From 9f95d068506e5b69e2343e56fb60300918b5c02c Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 28 Jul 2026 17:07:54 +0200 Subject: [PATCH 01/16] feat(guided): add dialog-local palette flow --- .../dialogs/pf-paint-by-number-dialog.ts | 631 ++++++++++++++---- .../dialogs/pf-paint-by-number-dialog.test.ts | 219 ++++++ 2 files changed, 728 insertions(+), 122 deletions(-) diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index 07d55a2b..dcbf5260 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -1,6 +1,7 @@ -import { css, html } from 'lit'; +import { css, html, type PropertyValues } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { BaseComponent } from '../../core/base-component'; +import { PRESET_PALETTES } from '../../data/preset-palettes'; import { checkboxStyles } from '../../styles/editor-control-styles'; import { generateNumberedGuide, @@ -9,7 +10,11 @@ import { import { createGuidedProject } from '../../services/paint-by-number/guided-project'; import { decodeImageFile } from '../../services/paint-by-number/image-file'; import { sampleImageToGrid } from '../../services/paint-by-number/image-sampling'; -import { getActiveProjectContext } from '../../stores/project-context'; +import { + loadCustomPalettes, + saveAsNewPalette, +} from '../../stores/palette/persistence'; +import type { CustomPalette } from '../../types/palette'; import type { GuidedColorMapping, GuidedDrawingSettings, @@ -18,6 +23,9 @@ import type { import '../ui/pf-dialog'; const PREVIEW_DEBOUNCE_MS = 180; +const GENERATED_PALETTE_VALUE = 'generated'; +const CREATE_PALETTE_VALUE = 'create'; +const DEFAULT_NEW_COLOR = '#000000'; const GUIDE_PRESETS = [ { label: 'Compact', longSide: 16, maxColors: 6 }, @@ -61,6 +69,41 @@ export class PFPaintByNumberDialog extends BaseComponent { gap: 6px; } + .palette-creation { + display: grid; + gap: 16px; + } + + .palette-colors { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + } + + .palette-color { + display: grid; + grid-template-columns: minmax(0, 1fr) repeat(3, auto); + gap: 8px; + align-items: center; + } + + .palette-color input[type='color'] { + min-height: 38px; + padding: 3px; + } + + .palette-color button { + min-width: 34px; + padding-inline: 8px; + } + + .palette-create-actions { + display: flex; + justify-content: flex-start; + } + input, select { box-sizing: border-box; @@ -217,6 +260,10 @@ export class PFPaintByNumberDialog extends BaseComponent { color: var(--pf-color-danger, #f0aaa2); } + .palette-status { + min-height: 1.45em; + } + button { padding: 7px 12px; border-radius: var(--pf-radius-sm); @@ -257,6 +304,16 @@ export class PFPaintByNumberDialog extends BaseComponent { @state() private longSide = 24; @state() private maxColors = 8; @state() private paletteSource: GuidedPaletteSource = 'generated'; + @state() private selectedPalette = GENERATED_PALETTE_VALUE; + @state() private restrictedPalette: string[] | undefined; + @state() private customPalettes: CustomPalette[] = []; + @state() private isLoadingPalettes = false; + @state() private isCreatingPalette = false; + @state() private isSavingPalette = false; + @state() private paletteName = ''; + @state() private paletteColors: string[] = []; + @state() private paletteMessage = ''; + @state() private paletteError = ''; @state() private mapping: GuidedColorMapping = 'color'; @state() private simplifyIsolatedPixels = true; @state() private isGenerating = false; @@ -265,155 +322,311 @@ export class PFPaintByNumberDialog extends BaseComponent { @state() private errorMessage = ''; @query('#source-preview') private sourceCanvas?: HTMLCanvasElement; @query('#guide-preview') private guideCanvas?: HTMLCanvasElement; + @query('#guided-palette-source') private paletteSelect?: HTMLSelectElement; + @query('#guided-palette-name') private paletteNameInput?: HTMLInputElement; private previewTimer: number | null = null; private requestVersion = 0; + private paletteLoadVersion = 0; disconnectedCallback() { super.disconnectedCallback(); this.cancelPendingPreview(); } - protected updated(): void { + protected updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); this.drawPreviews(); + if (changedProperties.has('open') && this.open) { + void this.loadPaletteChoices(); + } } render() { return html` Guided drawing -
- - -
- Guide preset -
- ${GUIDE_PRESETS.map((preset) => html` - - `)} -
-
- -
- Advanced settings -
- - - - - - - + ${this.isCreatingPalette + ? this.renderPaletteCreation() + : this.renderGuidedSetup()} + ${this.renderActions()} + + `; + } - -
-
- -
- ${this.renderPreviewFigure('Source', 'source-preview', Boolean(this.sourceImage))} - ${this.renderPreviewFigure('Guide', 'guide-preview', Boolean(this.guide))} + private renderGuidedSetup() { + return html` + + + +
+ Guide preset +
+ ${GUIDE_PRESETS.map((preset) => html` + + `)}
+
+ +
+ Advanced settings +
+ + + + + + + + + +
+
+ +
+ ${this.renderPreviewFigure('Source', 'source-preview', Boolean(this.sourceImage))} + ${this.renderPreviewFigure('Guide', 'guide-preview', Boolean(this.guide))} +
-

+ ${this.errorMessage || this.statusMessage} +

+ + `; + } + + private renderPaletteCreation() { + return html` +
+

+ Create a reusable palette without leaving Guided Drawing. Your image and guide settings + stay unchanged. +

+ -
+
+ Colors +
    + ${this.paletteColors.map((color, index) => html` +
  1. + this.updatePaletteColor(index, event)} + > + + + +
  2. + `)} +
+
+ +
+
+ +

+ ${this.paletteError || this.paletteMessage} +

+ + `; + } + private renderActions() { + if (this.isCreatingPalette) { + return html`
- +
- + `; + } + + return html` +
+ + +
`; } @@ -477,9 +690,14 @@ export class PFPaintByNumberDialog extends BaseComponent { this.schedulePreview(); }; - private handlePaletteSourceChange = (event: Event) => { - this.paletteSource = (event.currentTarget as HTMLSelectElement).value as GuidedPaletteSource; - this.schedulePreview(); + private handlePaletteSelection = (event: Event) => { + const value = (event.currentTarget as HTMLSelectElement).value; + if (value === CREATE_PALETTE_VALUE) { + this.beginPaletteCreation(); + return; + } + + this.selectPalette(value); }; private handleMappingChange = (event: Event) => { @@ -513,7 +731,7 @@ export class PFPaintByNumberDialog extends BaseComponent { const sampled = sampleImageToGrid(this.sourceImage, { longSide: this.longSide }); const restrictedPalette = this.getRestrictedPalette(); const guide = generateNumberedGuide(sampled, { - maxColors: this.maxColors, + maxColors: restrictedPalette ? undefined : this.maxColors, palette: restrictedPalette, mapping: this.mapping, simplifyIsolatedPixels: this.simplifyIsolatedPixels, @@ -538,9 +756,175 @@ export class PFPaintByNumberDialog extends BaseComponent { private getRestrictedPalette(): string[] | undefined { if (this.paletteSource !== 'restricted') return undefined; - return [...getActiveProjectContext().palette.mainColors.value]; + return this.restrictedPalette ? [...this.restrictedPalette] : undefined; } + private async loadPaletteChoices() { + const version = ++this.paletteLoadVersion; + this.isLoadingPalettes = true; + this.paletteError = ''; + this.paletteMessage = 'Loading saved palettes…'; + + try { + const palettes = await loadCustomPalettes(); + if (version !== this.paletteLoadVersion) return; + + this.customPalettes = palettes; + this.paletteMessage = palettes.length > 0 + ? `${palettes.length} saved ${palettes.length === 1 ? 'palette' : 'palettes'} available.` + : 'No saved palettes yet.'; + + if ( + this.selectedPalette.startsWith('custom:') + && !palettes.some((palette) => `custom:${palette.id}` === this.selectedPalette) + ) { + this.selectPalette(GENERATED_PALETTE_VALUE); + } + } catch { + if (version !== this.paletteLoadVersion) return; + this.customPalettes = []; + this.paletteMessage = ''; + this.paletteError = 'Saved palettes could not be loaded. Preset palettes are still available.'; + } finally { + if (version === this.paletteLoadVersion) this.isLoadingPalettes = false; + } + } + + private selectPalette(value: string) { + if (value === GENERATED_PALETTE_VALUE) { + this.selectedPalette = value; + this.paletteSource = 'generated'; + this.restrictedPalette = undefined; + this.paletteError = ''; + this.schedulePreview(); + return; + } + + const palette = this.findPalette(value); + if (!palette) { + this.paletteError = 'That palette is no longer available.'; + return; + } + + this.selectedPalette = value; + this.paletteSource = 'restricted'; + this.restrictedPalette = [...palette.colors]; + this.paletteError = ''; + this.schedulePreview(); + } + + private findPalette(value: string): { colors: string[] } | undefined { + if (value.startsWith('preset:')) { + const id = value.slice('preset:'.length); + return PRESET_PALETTES.find((palette) => palette.id === id); + } + if (value.startsWith('custom:')) { + const id = value.slice('custom:'.length); + return this.customPalettes.find((palette) => palette.id === id); + } + return undefined; + } + + private beginPaletteCreation() { + this.paletteName = ''; + this.paletteColors = [DEFAULT_NEW_COLOR]; + this.paletteMessage = ''; + this.paletteError = ''; + this.isCreatingPalette = true; + void this.updateComplete.then(() => { + this.paletteNameInput?.focus(); + }); + } + + private cancelPaletteCreation = () => { + this.isCreatingPalette = false; + this.paletteName = ''; + this.paletteColors = []; + this.paletteError = ''; + this.paletteMessage = 'Palette creation cancelled.'; + void this.updateComplete.then(() => { + this.paletteSelect?.focus(); + }); + }; + + private handlePaletteCreationKeydown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + this.cancelPaletteCreation(); + }; + + private handlePaletteNameInput = (event: Event) => { + this.paletteName = (event.currentTarget as HTMLInputElement).value; + this.paletteError = ''; + }; + + private updatePaletteColor(index: number, event: Event) { + const colors = [...this.paletteColors]; + colors[index] = (event.currentTarget as HTMLInputElement).value; + this.paletteColors = colors; + this.paletteError = ''; + } + + private addPaletteColor = () => { + this.paletteColors = [...this.paletteColors, DEFAULT_NEW_COLOR]; + this.paletteError = ''; + }; + + private removePaletteColor(index: number) { + this.paletteColors = this.paletteColors.filter((_, colorIndex) => colorIndex !== index); + this.paletteError = ''; + } + + private movePaletteColor(fromIndex: number, toIndex: number) { + if (toIndex < 0 || toIndex >= this.paletteColors.length) return; + const colors = [...this.paletteColors]; + const [color] = colors.splice(fromIndex, 1); + colors.splice(toIndex, 0, color); + this.paletteColors = colors; + } + + private saveCreatedPalette = async (event: Event) => { + event.preventDefault(); + if (this.isSavingPalette) return; + const name = this.paletteName.trim(); + if (!name) { + this.paletteError = 'Enter a palette name.'; + this.paletteNameInput?.focus(); + return; + } + if (this.paletteColors.length === 0) { + this.paletteError = 'Add at least one color.'; + return; + } + + this.isSavingPalette = true; + this.paletteError = ''; + this.paletteMessage = 'Saving palette…'; + try { + const id = await saveAsNewPalette(name, this.paletteColors); + const palettes = await loadCustomPalettes(); + const savedPalette = palettes.find((palette) => palette.id === id); + if (!savedPalette) { + throw new Error('Saved palette could not be verified.'); + } + + this.customPalettes = palettes; + this.isCreatingPalette = false; + this.paletteName = ''; + this.paletteColors = []; + this.paletteMessage = 'Palette saved and selected.'; + this.selectPalette(`custom:${id}`); + await this.updateComplete; + this.paletteSelect?.focus(); + } catch { + this.paletteMessage = ''; + this.paletteError = 'Palette could not be saved. Try again.'; + } finally { + this.isSavingPalette = false; + } + }; + private describeGuide(guide: NumberedGuide): string { const { complexity } = guide; const simplified = complexity.simplifiedCells > 0 @@ -616,6 +1000,9 @@ export class PFPaintByNumberDialog extends BaseComponent { close = () => { this.cancelPendingPreview(); + this.paletteLoadVersion += 1; + this.isCreatingPalette = false; + this.isSavingPalette = false; this.open = false; this.dispatchEvent(new CustomEvent('close')); }; diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts index e98b5034..9a752256 100644 --- a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -4,6 +4,8 @@ const decodeImageFileMock = vi.hoisted(() => vi.fn()); const sampleImageToGridMock = vi.hoisted(() => vi.fn()); const generateNumberedGuideMock = vi.hoisted(() => vi.fn()); const createGuidedProjectMock = vi.hoisted(() => vi.fn()); +const loadCustomPalettesMock = vi.hoisted(() => vi.fn()); +const saveAsNewPaletteMock = vi.hoisted(() => vi.fn()); vi.mock('../../../src/services/paint-by-number/image-file', () => ({ decodeImageFile: decodeImageFileMock, @@ -21,8 +23,16 @@ vi.mock('../../../src/services/paint-by-number/guided-project', () => ({ createGuidedProject: createGuidedProjectMock, })); +vi.mock('../../../src/stores/palette/persistence', async (importOriginal) => ({ + ...(await importOriginal()), + loadCustomPalettes: loadCustomPalettesMock, + saveAsNewPalette: saveAsNewPaletteMock, +})); + import '../../../src/components/dialogs/pf-paint-by-number-dialog'; import type { PFPaintByNumberDialog } from '../../../src/components/dialogs/pf-paint-by-number-dialog'; +import { PRESET_PALETTES } from '../../../src/data/preset-palettes'; +import type { CustomPalette } from '../../../src/types/palette'; const sourceImage = { width: 4, @@ -77,6 +87,26 @@ function chooseFile(element: PFPaintByNumberDialog, file: File) { input?.dispatchEvent(new Event('change')); } +async function selectPalette(element: PFPaintByNumberDialog, value: string) { + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + if (!select) throw new Error('Palette select is unavailable'); + select.value = value; + select.dispatchEvent(new Event('change')); + await settle(element); +} + +function customPalette(id: string, name: string, colors: string[]): CustomPalette { + return { + id, + name, + colors, + createdAt: 1, + updatedAt: 1, + }; +} + describe('pf-paint-by-number-dialog', () => { beforeEach(() => { vi.useFakeTimers(); @@ -91,6 +121,8 @@ describe('pf-paint-by-number-dialog', () => { item: {}, projectId: 'guided-project', }); + loadCustomPalettesMock.mockResolvedValue([]); + saveAsNewPaletteMock.mockResolvedValue('new-palette'); }); afterEach(() => { @@ -159,6 +191,193 @@ describe('pf-paint-by-number-dialog', () => { expect(input?.checked).toBe(false); }); + it('selects preset and saved palettes without reading an active project palette', async () => { + const saved = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); + loadCustomPalettesMock.mockResolvedValue([saved]); + const element = await createDialog(); + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + + expect(select?.options[0].textContent).toBe('Generate from image'); + expect(Array.from(select?.options ?? []).map((option) => option.textContent)).toEqual( + expect.arrayContaining(['DB32', 'PICO-8', 'Night colors', 'Create palette…']), + ); + + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + const preset = PRESET_PALETTES.find((palette) => palette.id === 'gameboy')!; + await selectPalette(element, 'preset:gameboy'); + await vi.runAllTimersAsync(); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: preset.colors, + mapping: 'color', + simplifyIsolatedPixels: true, + }); + expect(generateNumberedGuideMock.mock.lastCall?.[1].palette).not.toBe(preset.colors); + + await selectPalette(element, 'custom:night'); + await vi.runAllTimersAsync(); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: ['#101020', '#f0f0ff'], + mapping: 'color', + simplifyIsolatedPixels: true, + }); + expect(generateNumberedGuideMock.mock.lastCall?.[1].palette).not.toBe(saved.colors); + }); + + it('creates, edits, reorders, removes, saves, and selects a custom palette inline', async () => { + const created = customPalette('new-palette', 'Forest', ['#0000ff', '#00ff00']); + loadCustomPalettesMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([created]); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'forest.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + const longSide = element.shadowRoot?.querySelector('#guided-long-side'); + if (longSide) { + longSide.value = '32'; + longSide.dispatchEvent(new Event('change')); + } + await selectPalette(element, 'create'); + + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + expect(element.shadowRoot?.activeElement).toBe(name); + if (name) { + name.value = 'Forest'; + name.dispatchEvent(new Event('input')); + } + + const firstColor = element.shadowRoot?.querySelector( + 'input[aria-label="Color 1"]', + ); + if (firstColor) { + firstColor.value = '#ff0000'; + firstColor.dispatchEvent(new Event('input')); + } + element.shadowRoot?.querySelector('.palette-create-actions button')?.click(); + await settle(element); + const secondColor = element.shadowRoot?.querySelector( + 'input[aria-label="Color 2"]', + ); + if (secondColor) { + secondColor.value = '#00ff00'; + secondColor.dispatchEvent(new Event('input')); + } + element.shadowRoot?.querySelector('.palette-create-actions button')?.click(); + await settle(element); + const thirdColor = element.shadowRoot?.querySelector( + 'input[aria-label="Color 3"]', + ); + if (thirdColor) { + thirdColor.value = '#0000ff'; + thirdColor.dispatchEvent(new Event('input')); + } + element.shadowRoot + ?.querySelector('button[aria-label="Move color 3 up"]') + ?.click(); + await settle(element); + element.shadowRoot + ?.querySelector('button[aria-label="Remove color 1"]') + ?.click(); + await settle(element); + + element.shadowRoot + ?.querySelector('button[form="palette-creation-form"]') + ?.click(); + await settle(element); + await vi.runAllTimersAsync(); + + expect(saveAsNewPaletteMock).toHaveBeenCalledWith('Forest', ['#0000ff', '#00ff00']); + expect(element.shadowRoot?.querySelector( + '#guided-palette-source', + )?.value).toBe('custom:new-palette'); + expect(element.shadowRoot?.querySelector( + '#guided-long-side', + )?.value).toBe('32'); + expect(element.shadowRoot?.querySelector('#source-preview')).toBeTruthy(); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: ['#0000ff', '#00ff00'], + mapping: 'color', + simplifyIsolatedPixels: true, + }); + }); + + it('cancels inline creation with Escape and restores the previous selection and focus', async () => { + const element = await createDialog(); + await selectPalette(element, 'preset:pico8'); + const callsBeforeCreation = generateNumberedGuideMock.mock.calls.length; + await selectPalette(element, 'create'); + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + + name?.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + })); + await settle(element); + + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + expect(element.open).toBe(true); + expect(select?.value).toBe('preset:pico8'); + expect(element.shadowRoot?.activeElement).toBe(select); + expect(generateNumberedGuideMock).toHaveBeenCalledTimes(callsBeforeCreation); + expect(element.shadowRoot?.textContent).toContain('Palette creation cancelled.'); + }); + + it('shows a recoverable persistence error without losing source or setup state', async () => { + loadCustomPalettesMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + customPalette('new-palette', 'Retry palette', ['#000000']), + ]); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'source.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'create'); + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + if (name) { + name.value = 'Retry palette'; + name.dispatchEvent(new Event('input')); + } + + element.shadowRoot + ?.querySelector('button[form="palette-creation-form"]') + ?.click(); + await settle(element); + + expect(element.shadowRoot?.querySelector('[role="alert"]')?.textContent).toContain( + 'Palette could not be saved', + ); + expect(element.shadowRoot?.querySelector( + '#guided-palette-name', + )?.value).toBe('Retry palette'); + expect(element.shadowRoot?.querySelector('#palette-creation-form')).toBeTruthy(); + + element.shadowRoot + ?.querySelector('button[form="palette-creation-form"]') + ?.click(); + await settle(element); + await vi.runAllTimersAsync(); + + expect(saveAsNewPaletteMock).toHaveBeenCalledTimes(2); + expect(element.shadowRoot?.querySelector('#source-preview')).toBeTruthy(); + expect(element.shadowRoot?.querySelector( + '#guided-palette-source', + )?.value).toBe('custom:new-palette'); + }); + it('ignores a stale image decode after a newer file is selected', async () => { const element = await createDialog(); const firstImage = { ...sourceImage, width: 10 } as ImageData; From b8ab7a636e34f4cae70ad3cb94b3d8c7913fb7bb Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 28 Jul 2026 17:14:32 +0200 Subject: [PATCH 02/16] test(guided): harden palette flow states --- .../dialogs/pf-paint-by-number-dialog.ts | 12 +-- .../dialogs/pf-paint-by-number-dialog.test.ts | 84 ++++++++++++++++++- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index dcbf5260..eb837507 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -69,11 +69,6 @@ export class PFPaintByNumberDialog extends BaseComponent { gap: 6px; } - .palette-creation { - display: grid; - gap: 16px; - } - .palette-colors { display: grid; gap: 8px; @@ -346,8 +341,11 @@ export class PFPaintByNumberDialog extends BaseComponent { return html` Guided drawing @@ -513,10 +511,8 @@ export class PFPaintByNumberDialog extends BaseComponent { return html`

Create a reusable palette without leaving Guided Drawing. Your image and guide settings @@ -848,7 +844,7 @@ export class PFPaintByNumberDialog extends BaseComponent { }; private handlePaletteCreationKeydown = (event: KeyboardEvent) => { - if (event.key !== 'Escape') return; + if (!this.isCreatingPalette || event.key !== 'Escape') return; event.preventDefault(); event.stopPropagation(); this.cancelPaletteCreation(); diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts index 9a752256..7fde8501 100644 --- a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -32,6 +32,7 @@ vi.mock('../../../src/stores/palette/persistence', async (importOriginal) => ({ import '../../../src/components/dialogs/pf-paint-by-number-dialog'; import type { PFPaintByNumberDialog } from '../../../src/components/dialogs/pf-paint-by-number-dialog'; import { PRESET_PALETTES } from '../../../src/data/preset-palettes'; +import * as projectContext from '../../../src/stores/project-context'; import type { CustomPalette } from '../../../src/types/palette'; const sourceImage = { @@ -192,6 +193,7 @@ describe('pf-paint-by-number-dialog', () => { }); it('selects preset and saved palettes without reading an active project palette', async () => { + const activeProjectPaletteSpy = vi.spyOn(projectContext, 'getActiveProjectContext'); const saved = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); loadCustomPalettesMock.mockResolvedValue([saved]); const element = await createDialog(); @@ -228,6 +230,22 @@ describe('pf-paint-by-number-dialog', () => { simplifyIsolatedPixels: true, }); expect(generateNumberedGuideMock.mock.lastCall?.[1].palette).not.toBe(saved.colors); + + element.shadowRoot?.querySelector('button.primary')?.click(); + await settle(element); + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'restricted', + maxColors: undefined, + restrictedPalette: ['#101020', '#f0f0ff'], + mapping: 'color', + simplifyIsolatedPixels: true, + }, + sourceName: 'portrait.png', + }); + expect(activeProjectPaletteSpy).not.toHaveBeenCalled(); }); it('creates, edits, reorders, removes, saves, and selects a custom palette inline', async () => { @@ -315,9 +333,10 @@ describe('pf-paint-by-number-dialog', () => { await selectPalette(element, 'preset:pico8'); const callsBeforeCreation = generateNumberedGuideMock.mock.calls.length; await selectPalette(element, 'create'); - const name = element.shadowRoot?.querySelector('#guided-palette-name'); + const cancel = Array.from(element.shadowRoot?.querySelectorAll('button') ?? []) + .find((button) => button.textContent?.trim() === 'Cancel palette'); - name?.dispatchEvent(new KeyboardEvent('keydown', { + cancel?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true, @@ -378,6 +397,67 @@ describe('pf-paint-by-number-dialog', () => { )?.value).toBe('custom:new-palette'); }); + it('announces palette loading failures while keeping preset choices available', async () => { + let rejectLoad: ((reason: Error) => void) | undefined; + loadCustomPalettesMock.mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectLoad = reject; + })); + const element = await createDialog(); + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + + expect(select?.disabled).toBe(true); + expect(element.shadowRoot?.textContent).toContain('Loading saved palettes…'); + + rejectLoad?.(new Error('IndexedDB unavailable')); + await settle(element); + + expect(select?.disabled).toBe(false); + expect(element.shadowRoot?.querySelector('[role="alert"]')?.textContent).toContain( + 'Saved palettes could not be loaded', + ); + expect(Array.from(select?.options ?? []).map((option) => option.textContent)).toContain( + 'PICO-8', + ); + }); + + it('blocks duplicate palette saves while persistence is pending', async () => { + let resolveSave: ((id: string) => void) | undefined; + saveAsNewPaletteMock.mockImplementationOnce(() => new Promise((resolve) => { + resolveSave = resolve; + })); + loadCustomPalettesMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + customPalette('new-palette', 'Pending palette', ['#000000']), + ]); + const element = await createDialog(); + await selectPalette(element, 'create'); + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + if (name) { + name.value = 'Pending palette'; + name.dispatchEvent(new Event('input')); + } + const save = element.shadowRoot?.querySelector( + 'button[form="palette-creation-form"]', + ); + + save?.click(); + save?.click(); + await settle(element); + + expect(saveAsNewPaletteMock).toHaveBeenCalledTimes(1); + expect(save?.disabled).toBe(true); + expect(save?.textContent).toContain('Saving…'); + + resolveSave?.('new-palette'); + await settle(element); + expect(element.shadowRoot?.querySelector( + '#guided-palette-source', + )?.value).toBe('custom:new-palette'); + }); + it('ignores a stale image decode after a newer file is selected', async () => { const element = await createDialog(); const firstImage = { ...sourceImage, width: 10 } as ImageData; From 85103e6b18a0e97d00d98bc5cefbef0c6a9f23a6 Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 28 Jul 2026 17:17:41 +0200 Subject: [PATCH 03/16] fix(guided): lock palette flow while saving --- src/components/dialogs/pf-paint-by-number-dialog.ts | 1 + .../dialogs/pf-paint-by-number-dialog.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index eb837507..fbee61fc 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -847,6 +847,7 @@ export class PFPaintByNumberDialog extends BaseComponent { if (!this.isCreatingPalette || event.key !== 'Escape') return; event.preventDefault(); event.stopPropagation(); + if (this.isSavingPalette) return; this.cancelPaletteCreation(); }; diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts index 7fde8501..eb2499a2 100644 --- a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -451,6 +451,16 @@ describe('pf-paint-by-number-dialog', () => { expect(save?.disabled).toBe(true); expect(save?.textContent).toContain('Saving…'); + save?.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + })); + await settle(element); + + expect(element.shadowRoot?.querySelector('#palette-creation-form')).toBeTruthy(); + expect(saveAsNewPaletteMock).toHaveBeenCalledTimes(1); + resolveSave?.('new-palette'); await settle(element); expect(element.shadowRoot?.querySelector( From 91cde2d282a62d2d96a7a476ecffa66eb904db5b Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 28 Jul 2026 17:19:39 +0200 Subject: [PATCH 04/16] refactor(guided): reuse current preview settings --- src/components/dialogs/pf-paint-by-number-dialog.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index fbee61fc..aeda1424 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -725,12 +725,12 @@ export class PFPaintByNumberDialog extends BaseComponent { try { const sampled = sampleImageToGrid(this.sourceImage, { longSide: this.longSide }); - const restrictedPalette = this.getRestrictedPalette(); + const settings = this.currentSettings(); const guide = generateNumberedGuide(sampled, { - maxColors: restrictedPalette ? undefined : this.maxColors, - palette: restrictedPalette, - mapping: this.mapping, - simplifyIsolatedPixels: this.simplifyIsolatedPixels, + maxColors: settings.maxColors, + palette: settings.restrictedPalette, + mapping: settings.mapping, + simplifyIsolatedPixels: settings.simplifyIsolatedPixels, }); if (guide.palette.length === 0) { throw new Error('The image has no visible paintable pixels.'); From 1fd3795a1e2fd0a3e1452def46f996c321c58900 Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 28 Jul 2026 17:37:43 +0200 Subject: [PATCH 05/16] fix(guided): make palette saves unambiguous --- .../dialogs/pf-paint-by-number-dialog.ts | 34 ++++++------ .../persistence/palette-persistence.ts | 4 +- src/stores/palette/persistence.ts | 9 ++-- src/stores/palette/store.ts | 6 +-- .../dialogs/pf-paint-by-number-dialog.test.ts | 45 +++++++++------- .../persistence/palette-persistence.test.ts | 52 +++++++++++++++++++ tests/stores/palette-persistence.test.ts | 42 +++++++++++++++ 7 files changed, 151 insertions(+), 41 deletions(-) create mode 100644 tests/services/persistence/palette-persistence.test.ts create mode 100644 tests/stores/palette-persistence.test.ts diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index aeda1424..5e36024c 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -526,6 +526,7 @@ export class PFPaintByNumberDialog extends BaseComponent { required autocomplete="off" .value=${this.paletteName} + ?disabled=${this.isSavingPalette} @input=${this.handlePaletteNameInput} > @@ -539,6 +540,7 @@ export class PFPaintByNumberDialog extends BaseComponent { type="color" .value=${color} aria-label="Color ${index + 1}" + ?disabled=${this.isSavingPalette} @input=${(event: Event) => this.updatePaletteColor(index, event)} >

${this.paletteError || this.paletteMessage} @@ -881,13 +888,18 @@ export class PFPaintByNumberDialog extends BaseComponent { } private addPaletteColor = () => { + if (this.paletteColors.length >= MAX_PALETTE_SIZE) return; this.paletteColors = [...this.paletteColors, DEFAULT_NEW_COLOR]; this.paletteError = ''; + this.paletteMessage = this.paletteColors.length === MAX_PALETTE_SIZE + ? `Palette limit reached: ${MAX_PALETTE_SIZE} colors maximum.` + : ''; }; private removePaletteColor(index: number) { this.paletteColors = this.paletteColors.filter((_, colorIndex) => colorIndex !== index); this.paletteError = ''; + this.paletteMessage = ''; } private movePaletteColor(fromIndex: number, toIndex: number) { @@ -911,6 +923,13 @@ export class PFPaintByNumberDialog extends BaseComponent { this.paletteError = 'Add at least one color.'; return; } + if (this.paletteColors.length > MAX_PALETTE_SIZE) { + const extraColors = this.paletteColors.length - MAX_PALETTE_SIZE; + this.paletteError = `Palettes can contain at most ${MAX_PALETTE_SIZE} colors. Remove ${extraColors} ${extraColors === 1 ? 'color' : 'colors'} before saving.`; + await this.updateComplete; + this.paletteCreationStatus?.focus(); + return; + } this.isSavingPalette = true; this.paletteError = ''; diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts index 046a84e6..7f2ef07f 100644 --- a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -35,6 +35,7 @@ import type { PFPaintByNumberDialog } from '../../../src/components/dialogs/pf-p import { PRESET_PALETTES } from '../../../src/data/preset-palettes'; import { MAX_GUIDE_COLORS } from '../../../src/services/paint-by-number/guide-generator'; import * as projectContext from '../../../src/stores/project-context'; +import { MAX_PALETTE_SIZE } from '../../../src/stores/palette/types'; import type { CustomPalette } from '../../../src/types/palette'; const sourceImage = { @@ -379,6 +380,62 @@ describe('pf-paint-by-number-dialog', () => { }); }); + it('disables adding colors when a new palette reaches the persisted limit', async () => { + const element = await createDialog(); + await selectPalette(element, 'create'); + const paletteState = element as unknown as { paletteColors: string[] }; + paletteState.paletteColors = Array.from( + { length: MAX_PALETTE_SIZE - 1 }, + () => '#000000', + ); + await settle(element); + + const addColor = element.shadowRoot?.querySelector( + '.palette-create-actions button', + ); + addColor?.click(); + await settle(element); + + expect(paletteState.paletteColors).toHaveLength(MAX_PALETTE_SIZE); + expect(addColor?.disabled).toBe(true); + expect(element.shadowRoot?.querySelector('[role="status"]')?.textContent).toContain( + '256 colors maximum', + ); + + addColor?.click(); + expect(paletteState.paletteColors).toHaveLength(MAX_PALETTE_SIZE); + }); + + it('rejects an oversized palette before persistence and focuses the error', async () => { + const element = await createDialog(); + await selectPalette(element, 'create'); + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + if (name) { + name.value = 'Too many colors'; + name.dispatchEvent(new Event('input')); + } + const paletteState = element as unknown as { paletteColors: string[] }; + paletteState.paletteColors = Array.from( + { length: MAX_PALETTE_SIZE + 1 }, + () => '#000000', + ); + await settle(element); + + element.shadowRoot + ?.querySelector('button[form="palette-creation-form"]') + ?.click(); + await settle(element); + + const error = element.shadowRoot?.querySelector( + '#guided-palette-creation-status', + ); + expect(saveAsNewPaletteMock).not.toHaveBeenCalled(); + expect(error?.getAttribute('role')).toBe('alert'); + expect(error?.textContent).toContain('at most 256 colors'); + expect(error?.textContent).toContain('Remove 1 color'); + expect(element.shadowRoot?.activeElement).toBe(error); + }); + it('cancels inline creation with Escape and restores the previous selection and focus', async () => { const element = await createDialog(); await selectPalette(element, 'preset:pico8'); From 4e17deb6e0c14be3502225251c97c69fcf90e4b5 Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 28 Jul 2026 18:23:13 +0200 Subject: [PATCH 08/16] fix(palette): keep failed saves recoverable --- src/components/color/pf-palette-panel.ts | 41 +++++-- .../color/pf-save-palette-dialog.ts | 53 +++++++-- .../dialogs/pf-paint-by-number-dialog.ts | 4 +- .../pf-palette-panel-save-dialog.test.ts | 109 ++++++++++++++++++ .../color/pf-save-palette-dialog.test.ts | 73 ++++++++++++ .../dialogs/pf-paint-by-number-dialog.test.ts | 22 ++++ 6 files changed, 281 insertions(+), 21 deletions(-) create mode 100644 tests/components/color/pf-palette-panel-save-dialog.test.ts create mode 100644 tests/components/color/pf-save-palette-dialog.test.ts diff --git a/src/components/color/pf-palette-panel.ts b/src/components/color/pf-palette-panel.ts index f93bfd07..0960fc75 100644 --- a/src/components/color/pf-palette-panel.ts +++ b/src/components/color/pf-palette-panel.ts @@ -66,6 +66,8 @@ export class PFPalettePanel extends BaseComponent { @state() private saveDialogTitle = 'Save Palette'; @state() private saveDialogDefaultName = ''; @state() private isRenaming = false; + @state() private isSaveDialogPending = false; + @state() private saveDialogError = ''; // Unsaved changes dialog state @state() private showUnsavedDialog = false; @@ -110,6 +112,8 @@ export class PFPalettePanel extends BaseComponent { this.saveDialogDefaultName = ''; } this.isRenaming = false; + this.isSaveDialogPending = false; + this.saveDialogError = ''; this.showSaveDialog = true; } @@ -118,6 +122,8 @@ export class PFPalettePanel extends BaseComponent { this.saveDialogTitle = 'Rename Palette'; this.saveDialogDefaultName = context.palette.getCurrentPaletteName(); this.isRenaming = true; + this.isSaveDialogPending = false; + this.saveDialogError = ''; this.showSaveDialog = true; } @@ -132,23 +138,39 @@ export class PFPalettePanel extends BaseComponent { // ========================================== private async handleSaveDialogSave(e: CustomEvent) { + if (this.isSaveDialogPending) return; const { name } = e.detail; const palette = this.saveDialogContext.palette; - if (this.isRenaming) { - const customId = palette.currentCustomPaletteId.value; - if (customId) { - await palette.renameCustomPalette(customId, name); + this.isSaveDialogPending = true; + this.saveDialogError = ''; + try { + if (this.isRenaming) { + const customId = palette.currentCustomPaletteId.value; + if (customId) { + await palette.renameCustomPalette(customId, name); + } + } else { + await palette.saveAsNewPalette(name); } - } else { - await palette.saveAsNewPalette(name); + this.showSaveDialog = false; + } catch { + this.saveDialogError = this.isRenaming + ? 'Palette could not be renamed. Try again.' + : 'Palette could not be saved. Try again.'; + } finally { + this.isSaveDialogPending = false; } - - this.showSaveDialog = false; } private handleSaveDialogCancel() { + if (this.isSaveDialogPending) return; this.showSaveDialog = false; + this.saveDialogError = ''; + } + + private handleSaveDialogNameInput() { + this.saveDialogError = ''; } // ========================================== @@ -294,8 +316,11 @@ export class PFPalettePanel extends BaseComponent { ?open=${this.showSaveDialog} .title=${this.saveDialogTitle} .defaultName=${this.saveDialogDefaultName} + .pending=${this.isSaveDialogPending} + .errorMessage=${this.saveDialogError} @save=${this.handleSaveDialogSave} @cancel=${this.handleSaveDialogCancel} + @name-input=${this.handleSaveDialogNameInput} > -

e.stopPropagation()}> +
e.stopPropagation()} + >

${this.title}

- + - ${this.error ? html` -
${this.error}
+ ${visibleError ? html` + ` : nothing}
- + + ?disabled=${this.pending || !this.name.trim()} + >${this.pending ? 'Saving…' : 'Save'}
diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index 726ce8c7..27e0df5f 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -346,9 +346,9 @@ export class PFPaintByNumberDialog extends BaseComponent { return html` ({})); + +import '../../../src/components/color/pf-palette-panel'; +import type { PFPalettePanel } from '../../../src/components/color/pf-palette-panel'; +import type { PfSavePaletteDialog } from '../../../src/components/color/pf-save-palette-dialog'; +import { + createProjectContext, + restoreDefaultProjectContext, + setActiveProjectContext, + type ProjectContext, +} from '../../../src/stores/project-context'; + +let context: ProjectContext; + +async function settle(panel: PFPalettePanel) { + await Promise.resolve(); + await panel.updateComplete; + await Promise.resolve(); + await panel.updateComplete; +} + +async function settleWithDialog(panel: PFPalettePanel, dialog: PfSavePaletteDialog) { + await settle(panel); + await dialog.updateComplete; + await panel.updateComplete; +} + +function shadowElement( + host: { shadowRoot: ShadowRoot | null }, + selector: string, +): T { + const element = host.shadowRoot?.querySelector(selector); + if (!element) throw new Error(`Missing test element: ${selector}`); + return element; +} + +describe('pf-palette-panel save dialog', () => { + beforeEach(() => { + document.body.replaceChildren(); + localStorage.clear(); + context = createProjectContext(); + context.palette.setPalette(['#112233']); + setActiveProjectContext(context); + }); + + afterEach(() => { + document.body.replaceChildren(); + localStorage.clear(); + restoreDefaultProjectContext(); + context.dispose(); + vi.restoreAllMocks(); + }); + + it('keeps a failed Save As open and announces the recoverable error', async () => { + let rejectSave = (_reason: Error) => { + throw new Error('Save rejection was not initialized'); + }; + const saveAsNewPalette = vi.spyOn(context.palette, 'saveAsNewPalette') + .mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectSave = reject; + })); + const panel = document.createElement('pf-palette-panel') as PFPalettePanel; + document.body.append(panel); + await settle(panel); + + shadowElement(panel, 'pf-palette-toolbar').dispatchEvent( + new CustomEvent('menu-save-as'), + ); + await settle(panel); + + const dialog = shadowElement( + panel, + 'pf-save-palette-dialog', + ); + const input = shadowElement(dialog, '.form-input'); + input.value = 'Forest'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await settleWithDialog(panel, dialog); + const save = shadowElement(dialog, '.btn-save'); + save.click(); + save.click(); + await settleWithDialog(panel, dialog); + + expect(saveAsNewPalette).toHaveBeenCalledTimes(1); + expect(dialog.open).toBe(true); + expect(dialog.pending).toBe(true); + expect(shadowElement(dialog, '.form-input').disabled).toBe(true); + expect(shadowElement(dialog, '.btn-save').textContent) + .toContain('Saving…'); + + rejectSave(new Error('IndexedDB write failed')); + await settleWithDialog(panel, dialog); + + const error = shadowElement(dialog, '#palette-save-error'); + expect(dialog.open).toBe(true); + expect(dialog.pending).toBe(false); + expect(error.getAttribute('role')).toBe('alert'); + expect(error.textContent).toContain('Palette could not be saved'); + + const retryInput = shadowElement(dialog, '.form-input'); + retryInput.value = 'Forest retry'; + retryInput.dispatchEvent(new Event('input', { bubbles: true })); + await settleWithDialog(panel, dialog); + + expect(dialog.shadowRoot?.querySelector('#palette-save-error')).toBeNull(); + }); +}); diff --git a/tests/components/color/pf-save-palette-dialog.test.ts b/tests/components/color/pf-save-palette-dialog.test.ts new file mode 100644 index 00000000..5a609782 --- /dev/null +++ b/tests/components/color/pf-save-palette-dialog.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import '../../../src/components/color/pf-save-palette-dialog'; +import type { PfSavePaletteDialog } from '../../../src/components/color/pf-save-palette-dialog'; + +async function settle(dialog: PfSavePaletteDialog) { + await Promise.resolve(); + await dialog.updateComplete; +} + +async function createDialog() { + const dialog = document.createElement('pf-save-palette-dialog') as PfSavePaletteDialog; + dialog.defaultName = 'Forest'; + dialog.open = true; + document.body.append(dialog); + await settle(dialog); + return dialog; +} + +describe('pf-save-palette-dialog', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it('locks its fields and close paths while a save is pending', async () => { + const dialog = await createDialog(); + const saveSpy = vi.fn(); + const cancelSpy = vi.fn(); + dialog.addEventListener('save', saveSpy); + dialog.addEventListener('cancel', cancelSpy); + dialog.pending = true; + await settle(dialog); + + const input = dialog.shadowRoot?.querySelector('.form-input'); + const save = dialog.shadowRoot?.querySelector('.btn-save'); + const cancel = dialog.shadowRoot?.querySelector('.btn-cancel'); + expect(input?.disabled).toBe(true); + expect(save?.disabled).toBe(true); + expect(save?.textContent).toContain('Saving…'); + expect(cancel?.disabled).toBe(true); + + input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + dialog.shadowRoot + ?.querySelector('.overlay') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(saveSpy).not.toHaveBeenCalled(); + expect(cancelSpy).not.toHaveBeenCalled(); + }); + + it('exposes parent failures as an accessible error and requests clearing on input', async () => { + const dialog = await createDialog(); + const inputSpy = vi.fn(); + dialog.addEventListener('name-input', inputSpy); + dialog.errorMessage = 'Palette could not be saved. Try again.'; + await settle(dialog); + + const input = dialog.shadowRoot?.querySelector('.form-input'); + const error = dialog.shadowRoot?.querySelector('#palette-save-error'); + expect(error?.getAttribute('role')).toBe('alert'); + expect(error?.textContent).toContain('Palette could not be saved'); + expect(input?.getAttribute('aria-invalid')).toBe('true'); + expect(input?.getAttribute('aria-describedby')).toBe('palette-save-error'); + + if (input) { + input.value = 'Forest 2'; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + + expect(inputSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts index 7f2ef07f..ae12d055 100644 --- a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -37,6 +37,7 @@ import { MAX_GUIDE_COLORS } from '../../../src/services/paint-by-number/guide-ge import * as projectContext from '../../../src/stores/project-context'; import { MAX_PALETTE_SIZE } from '../../../src/stores/palette/types'; import type { CustomPalette } from '../../../src/types/palette'; +import type { PFDialog } from '../../../src/components/ui/pf-dialog'; const sourceImage = { width: 4, @@ -461,6 +462,27 @@ describe('pf-paint-by-number-dialog', () => { expect(element.shadowRoot?.textContent).toContain('Palette creation cancelled.'); }); + it('keeps Guided creation open until its explicit cancel action is used', async () => { + const element = await createDialog(); + await selectPalette(element, 'create'); + const shell = element.shadowRoot?.querySelector('pf-dialog'); + await shell?.updateComplete; + + expect(shell?.closeOnBackdrop).toBe(false); + expect(shell?.closeOnEscape).toBe(false); + expect(shell?.showCloseButton).toBe(false); + expect(shell?.shadowRoot?.querySelector('.close-btn')).toBeNull(); + + shell?.shadowRoot + ?.querySelector('.overlay') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await settle(element); + + expect(element.open).toBe(true); + expect(element.shadowRoot?.querySelector('#palette-creation-form')).toBeTruthy(); + expect(element.shadowRoot?.textContent).toContain('Cancel palette'); + }); + it('shows a recoverable save error without losing source or setup state', async () => { const saved = customPalette('new-palette', 'Retry palette', ['#000000']); loadCustomPalettesOrThrowMock.mockResolvedValueOnce([]); From fbb9e5ae97a413624f729961dd831a6df6f4cc72 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 29 Jul 2026 10:03:55 +0200 Subject: [PATCH 09/16] fix(guided-drawing): refresh reloaded palette snapshot --- .../dialogs/pf-paint-by-number-dialog.ts | 4 ++ .../dialogs/pf-paint-by-number-dialog.test.ts | 62 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index 27e0df5f..7865c2df 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -795,6 +795,10 @@ export class PFPaintByNumberDialog extends BaseComponent { const selectedPalette = this.findPalette(this.selectedPalette); if (selectedPalette) { + if (this.selectedPalette.startsWith('custom:')) { + this.selectPalette(this.selectedPalette); + return; + } this.paletteMessage = this.describePaletteAdaptation(selectedPalette.colors.length); return; } diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts index ae12d055..7d6318df 100644 --- a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -558,7 +558,11 @@ describe('pf-paint-by-number-dialog', () => { .mockResolvedValueOnce([saved]) .mockRejectedValueOnce(new Error('IndexedDB unavailable')); const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); await selectPalette(element, 'custom:night'); + await vi.runAllTimersAsync(); element.open = false; await settle(element); @@ -575,6 +579,64 @@ describe('pf-paint-by-number-dialog', () => { expect(element.shadowRoot?.querySelector('[role="alert"]')?.textContent).toContain( 'Saved palettes could not be loaded', ); + + element.shadowRoot?.querySelector('button.primary')?.click(); + await settle(element); + + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'restricted', + maxColors: undefined, + restrictedPalette: ['#101020', '#f0f0ff'], + mapping: 'color', + simplifyIsolatedPixels: true, + }, + sourceName: 'portrait.png', + }); + }); + + it('refreshes a selected custom palette snapshot when the dialog reopens', async () => { + const saved = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); + const updatedColors = Array.from( + { length: MAX_GUIDE_COLORS + 1 }, + (_, index) => `#${index.toString(16).padStart(6, '0')}`, + ); + const updated = customPalette('night', 'Updated night colors', updatedColors); + loadCustomPalettesOrThrowMock + .mockResolvedValueOnce([saved]) + .mockResolvedValueOnce([updated]); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'custom:night'); + await vi.runAllTimersAsync(); + + element.open = false; + await settle(element); + element.open = true; + await settle(element); + await vi.runAllTimersAsync(); + + const adaptedColors = updatedColors.slice(0, MAX_GUIDE_COLORS); + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + expect(select?.value).toBe('custom:night'); + expect(Array.from(select?.options ?? []).map((option) => option.textContent)).toContain( + 'Updated night colors', + ); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: adaptedColors, + mapping: 'color', + simplifyIsolatedPixels: true, + }); + expect(element.shadowRoot?.querySelector('#guided-palette-status')?.textContent).toContain( + 'uses the first 255 colors', + ); }); it('blocks duplicate palette saves while persistence is pending', async () => { From eab341ff4ea52b3225d95088de23f907fd7b0b9f Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 29 Jul 2026 10:05:11 +0200 Subject: [PATCH 10/16] fix(palette): propagate rename persistence failures --- .../persistence/palette-persistence.ts | 5 +- src/stores/palette/persistence.ts | 9 ++- .../pf-palette-panel-save-dialog.test.ts | 59 +++++++++++++++++++ .../persistence/palette-persistence.test.ts | 35 +++++++++++ tests/stores/palette-persistence.test.ts | 18 ++++++ 5 files changed, 122 insertions(+), 4 deletions(-) diff --git a/src/services/persistence/palette-persistence.ts b/src/services/persistence/palette-persistence.ts index dc65f240..02ece1b3 100644 --- a/src/services/persistence/palette-persistence.ts +++ b/src/services/persistence/palette-persistence.ts @@ -74,7 +74,7 @@ class PalettePersistenceService { /** * Update palette properties */ - async updatePalette(id: string, updates: Partial): Promise { + async updatePalette(id: string, updates: Partial): Promise { try { const db = await this.dbPromise; const existing = await db.get("palettes", id); @@ -84,9 +84,12 @@ class PalettePersistenceService { ...updates, updatedAt: Date.now(), }); + return true; } + return false; } catch (error) { log.error("Failed to update palette in IndexedDB:", error); + return false; } } diff --git a/src/stores/palette/persistence.ts b/src/stores/palette/persistence.ts index cfc5fe88..28d52267 100644 --- a/src/stores/palette/persistence.ts +++ b/src/stores/palette/persistence.ts @@ -121,8 +121,8 @@ export async function saveAsNewPalette( export async function updatePalette( id: string, updates: Partial> -): Promise { - await palettePersistence.updatePalette(id, updates); +): Promise { + return palettePersistence.updatePalette(id, updates); } /** @@ -136,7 +136,10 @@ export async function deletePalette(id: string): Promise { * Rename a custom palette. */ export async function renamePalette(id: string, newName: string): Promise { - await palettePersistence.updatePalette(id, { name: newName.trim() }); + const updated = await palettePersistence.updatePalette(id, { name: newName.trim() }); + if (!updated) { + throw new Error('Palette could not be renamed.'); + } } // ========================================== diff --git a/tests/components/color/pf-palette-panel-save-dialog.test.ts b/tests/components/color/pf-palette-panel-save-dialog.test.ts index 43ff4ee3..1c2f1223 100644 --- a/tests/components/color/pf-palette-panel-save-dialog.test.ts +++ b/tests/components/color/pf-palette-panel-save-dialog.test.ts @@ -1,10 +1,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const paletteDatabase = vi.hoisted(() => ({ + getAllFromIndex: vi.fn(() => Promise.resolve([])), + get: vi.fn(), + put: vi.fn(), +})); +const openPaletteDatabase = vi.hoisted(() => vi.fn(() => Promise.resolve(paletteDatabase))); + +vi.mock('idb', () => ({ + openDB: openPaletteDatabase, +})); + vi.mock('../../../src/components/color/pf-color-picker-popup', () => ({})); import '../../../src/components/color/pf-palette-panel'; import type { PFPalettePanel } from '../../../src/components/color/pf-palette-panel'; import type { PfSavePaletteDialog } from '../../../src/components/color/pf-save-palette-dialog'; +import type { CustomPalette } from '../../../src/types/palette'; import { createProjectContext, restoreDefaultProjectContext, @@ -38,6 +50,7 @@ function shadowElement( describe('pf-palette-panel save dialog', () => { beforeEach(() => { + vi.clearAllMocks(); document.body.replaceChildren(); localStorage.clear(); context = createProjectContext(); @@ -106,4 +119,50 @@ describe('pf-palette-panel save dialog', () => { expect(dialog.shadowRoot?.querySelector('#palette-save-error')).toBeNull(); }); + + it('keeps rename open when the persistence adapter rejects the update', async () => { + const savedPalette: CustomPalette = { + id: 'saved-palette', + name: 'Saved palette', + colors: ['#112233'], + createdAt: 1, + updatedAt: 1, + }; + context.palette.currentPresetId.value = null; + context.palette.currentCustomPaletteId.value = savedPalette.id; + context.palette.customPalettes.value = [savedPalette]; + paletteDatabase.get.mockResolvedValueOnce(savedPalette); + paletteDatabase.put.mockRejectedValueOnce(new Error('IndexedDB write failed')); + const panel = document.createElement('pf-palette-panel') as PFPalettePanel; + document.body.append(panel); + await settle(panel); + + shadowElement(panel, 'pf-palette-toolbar').dispatchEvent( + new CustomEvent('menu-rename'), + ); + await settle(panel); + + const dialog = shadowElement( + panel, + 'pf-save-palette-dialog', + ); + const input = shadowElement(dialog, '.form-input'); + input.value = 'Renamed palette'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await settleWithDialog(panel, dialog); + shadowElement(dialog, '.btn-save').click(); + await settleWithDialog(panel, dialog); + + expect(paletteDatabase.get).toHaveBeenCalledWith('palettes', 'saved-palette'); + expect(paletteDatabase.put).toHaveBeenCalledWith('palettes', { + ...savedPalette, + name: 'Renamed palette', + updatedAt: expect.any(Number), + }); + const error = shadowElement(dialog, '#palette-save-error'); + expect(dialog.open).toBe(true); + expect(dialog.pending).toBe(false); + expect(error.getAttribute('role')).toBe('alert'); + expect(error.textContent).toContain('Palette could not be renamed'); + }); }); diff --git a/tests/services/persistence/palette-persistence.test.ts b/tests/services/persistence/palette-persistence.test.ts index 91cb9756..cc6500e5 100644 --- a/tests/services/persistence/palette-persistence.test.ts +++ b/tests/services/persistence/palette-persistence.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const database = vi.hoisted(() => ({ getAllFromIndex: vi.fn(), + get: vi.fn(), put: vi.fn(), })); const openDatabase = vi.hoisted(() => vi.fn(() => Promise.resolve(database))); @@ -52,6 +53,40 @@ describe('palettePersistence.savePalette', () => { }); }); +describe('palettePersistence.updatePalette', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns true after IndexedDB updates an existing palette', async () => { + database.get.mockResolvedValueOnce(palette); + database.put.mockResolvedValueOnce('saved-palette'); + + await expect( + palettePersistence.updatePalette('saved-palette', { name: 'Renamed palette' }), + ).resolves.toBe(true); + expect(database.put).toHaveBeenCalledWith('palettes', { + ...palette, + name: 'Renamed palette', + updatedAt: expect.any(Number), + }); + }); + + it('returns false when IndexedDB rejects the update', async () => { + const error = new Error('IndexedDB unavailable'); + database.get.mockResolvedValueOnce(palette); + database.put.mockRejectedValueOnce(error); + + await expect( + palettePersistence.updatePalette('saved-palette', { name: 'Renamed palette' }), + ).resolves.toBe(false); + expect(logError).toHaveBeenCalledWith( + 'Failed to update palette in IndexedDB:', + error, + ); + }); +}); + describe('palettePersistence palette loading', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/tests/stores/palette-persistence.test.ts b/tests/stores/palette-persistence.test.ts index 9afc8d2b..dfb9ec25 100644 --- a/tests/stores/palette-persistence.test.ts +++ b/tests/stores/palette-persistence.test.ts @@ -2,16 +2,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const savePalette = vi.hoisted(() => vi.fn()); const getAllPalettesOrThrow = vi.hoisted(() => vi.fn()); +const updatePalette = vi.hoisted(() => vi.fn()); vi.mock('../../src/services/persistence/palette-persistence', () => ({ palettePersistence: { getAllPalettesOrThrow, savePalette, + updatePalette, }, })); import { loadCustomPalettesOrThrow, + renamePalette, saveAsNewPalette, } from '../../src/stores/palette/persistence'; @@ -51,4 +54,19 @@ describe('palette persistence helpers', () => { await expect(loadCustomPalettesOrThrow()).rejects.toBe(error); }); + + it('resolves a rename after the update is persisted', async () => { + updatePalette.mockResolvedValueOnce(true); + + await expect(renamePalette('saved-palette', ' Forest ')).resolves.toBeUndefined(); + expect(updatePalette).toHaveBeenCalledWith('saved-palette', { name: 'Forest' }); + }); + + it('rejects a rename when the update is not persisted', async () => { + updatePalette.mockResolvedValueOnce(false); + + await expect(renamePalette('saved-palette', 'Forest')).rejects.toThrow( + 'Palette could not be renamed.', + ); + }); }); From 2c87bff3d258d4112ce13b06d4d9b3d2f852cf61 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 29 Jul 2026 10:33:44 +0200 Subject: [PATCH 11/16] fix(guided-drawing): wait for selected palette reload --- .../dialogs/pf-paint-by-number-dialog.ts | 16 ++++++- .../dialogs/pf-paint-by-number-dialog.test.ts | 43 +++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index 7865c2df..338a079b 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -627,7 +627,10 @@ export class PFPaintByNumberDialog extends BaseComponent { class="primary" type="submit" form="guided-project-form" - ?disabled=${!this.guide || this.isGenerating || this.isCreating} + ?disabled=${!this.guide + || this.isGenerating + || this.isCreating + || this.isSelectedCustomPaletteLoading()} @click=${this.createProject} > ${this.isCreating ? 'Creating…' : 'Create guided project'} @@ -849,6 +852,10 @@ export class PFPaintByNumberDialog extends BaseComponent { return undefined; } + private isSelectedCustomPaletteLoading(): boolean { + return this.isLoadingPalettes && this.selectedPalette.startsWith('custom:'); + } + private beginPaletteCreation() { this.paletteName = ''; this.paletteColors = [DEFAULT_NEW_COLOR]; @@ -972,7 +979,12 @@ export class PFPaintByNumberDialog extends BaseComponent { private createProject = async (event: Event) => { event.preventDefault(); - if (!this.guide || !this.sourceFile || this.isCreating) return; + if ( + !this.guide + || !this.sourceFile + || this.isCreating + || this.isSelectedCustomPaletteLoading() + ) return; this.isCreating = true; this.errorMessage = ''; diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts index 7d6318df..e1214dc8 100644 --- a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -597,18 +597,22 @@ describe('pf-paint-by-number-dialog', () => { }); }); - it('refreshes a selected custom palette snapshot when the dialog reopens', async () => { + it('waits for a selected custom palette to reload before creating a project', async () => { const saved = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); const updatedColors = Array.from( { length: MAX_GUIDE_COLORS + 1 }, (_, index) => `#${index.toString(16).padStart(6, '0')}`, ); const updated = customPalette('night', 'Updated night colors', updatedColors); + let resolveReload: ((palettes: CustomPalette[]) => void) | undefined; loadCustomPalettesOrThrowMock .mockResolvedValueOnce([saved]) - .mockResolvedValueOnce([updated]); + .mockImplementationOnce(() => new Promise((resolve) => { + resolveReload = resolve; + })); const element = await createDialog(); - chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + const file = new File(['pixels'], 'portrait.png', { type: 'image/png' }); + chooseFile(element, file); await Promise.resolve(); await vi.runAllTimersAsync(); await selectPalette(element, 'custom:night'); @@ -618,7 +622,23 @@ describe('pf-paint-by-number-dialog', () => { await settle(element); element.open = true; await settle(element); + + const create = element.shadowRoot?.querySelector( + 'button[form="guided-project-form"]', + ); + expect(create?.disabled).toBe(true); + + create?.click(); + element.shadowRoot?.querySelector('#guided-project-form') + ?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await settle(element); + + expect(createGuidedProjectMock).not.toHaveBeenCalled(); + + resolveReload?.([updated]); + await settle(element); await vi.runAllTimersAsync(); + await settle(element); const adaptedColors = updatedColors.slice(0, MAX_GUIDE_COLORS); const select = element.shadowRoot?.querySelector( @@ -637,6 +657,23 @@ describe('pf-paint-by-number-dialog', () => { expect(element.shadowRoot?.querySelector('#guided-palette-status')?.textContent).toContain( 'uses the first 255 colors', ); + expect(create?.disabled).toBe(false); + + create?.click(); + await settle(element); + + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'restricted', + maxColors: undefined, + restrictedPalette: adaptedColors, + mapping: 'color', + simplifyIsolatedPixels: true, + }, + sourceName: file.name, + }); }); it('blocks duplicate palette saves while persistence is pending', async () => { From f981cbdb8c3e2402ff4afed18cc879b6c377e811 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 29 Jul 2026 13:26:32 +0200 Subject: [PATCH 12/16] fix(guided-drawing): reject empty saved palettes --- .../dialogs/pf-paint-by-number-dialog.ts | 6 ++ .../dialogs/pf-paint-by-number-dialog.test.ts | 61 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index 338a079b..a850fd9f 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -826,6 +826,12 @@ export class PFPaintByNumberDialog extends BaseComponent { return; } + if (value.startsWith('custom:') && palette.colors.length === 0) { + if (this.paletteSelect) this.paletteSelect.value = this.selectedPalette; + this.paletteError = 'This saved palette needs at least one color before Guided Drawing can use it.'; + return; + } + this.selectedPalette = value; this.paletteSource = 'restricted'; this.restrictedPalette = palette.colors.slice(0, MAX_GUIDE_COLORS); diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts index e1214dc8..7fd268eb 100644 --- a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -257,6 +257,67 @@ describe('pf-paint-by-number-dialog', () => { expect(activeProjectPaletteSpy).not.toHaveBeenCalled(); }); + it('rejects an empty saved palette without changing the current guide setup', async () => { + const valid = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); + const empty = customPalette('empty', 'Empty palette', []); + loadCustomPalettesOrThrowMock.mockResolvedValueOnce([valid, empty]); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + + await selectPalette(element, 'custom:night'); + await vi.runAllTimersAsync(); + + const mapping = element.shadowRoot?.querySelector('#guided-mapping'); + if (mapping) { + mapping.value = 'luminance'; + mapping.dispatchEvent(new Event('change')); + } + const simplify = element.shadowRoot?.querySelector('#guided-simplify'); + simplify?.click(); + await vi.runAllTimersAsync(); + await settle(element); + + const generationCalls = generateNumberedGuideMock.mock.calls.length; + const guidePreview = element.shadowRoot?.querySelector('#guide-preview'); + const create = element.shadowRoot?.querySelector( + 'button[form="guided-project-form"]', + ); + expect(guidePreview).toBeTruthy(); + expect(create?.disabled).toBe(false); + + await selectPalette(element, 'custom:empty'); + await vi.runAllTimersAsync(); + + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + const error = element.shadowRoot?.querySelector('#guided-palette-status'); + expect(select?.value).toBe('custom:night'); + expect(error?.getAttribute('role')).toBe('alert'); + expect(error?.textContent).toContain('needs at least one color'); + expect(generateNumberedGuideMock).toHaveBeenCalledTimes(generationCalls); + expect(element.shadowRoot?.querySelector('#guide-preview')).toBe(guidePreview); + expect(create?.disabled).toBe(false); + + create?.click(); + await settle(element); + + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'restricted', + maxColors: undefined, + restrictedPalette: ['#101020', '#f0f0ff'], + mapping: 'luminance', + simplifyIsolatedPixels: false, + }, + sourceName: 'portrait.png', + }); + }); + it('uses the first 255 colors from an oversized selected palette', async () => { const colors = Array.from( { length: MAX_GUIDE_COLORS + 1 }, From c29fb91221505e5bf0d2ca1fe36b46b9a69c8112 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 30 Jul 2026 14:25:47 +0200 Subject: [PATCH 13/16] fix(guided-drawing): invalidate unusable reloaded palette --- .../dialogs/pf-paint-by-number-dialog.ts | 5 + .../dialogs/pf-paint-by-number-dialog.test.ts | 101 ++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index a850fd9f..9ef0e5d2 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -799,6 +799,11 @@ export class PFPaintByNumberDialog extends BaseComponent { const selectedPalette = this.findPalette(this.selectedPalette); if (selectedPalette) { if (this.selectedPalette.startsWith('custom:')) { + if (selectedPalette.colors.length === 0) { + this.selectPalette(GENERATED_PALETTE_VALUE); + this.paletteError = 'This saved palette can no longer be used because it needs at least one color. Generated colors are selected instead.'; + return; + } this.selectPalette(this.selectedPalette); return; } diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts index 7fd268eb..38b0fc10 100644 --- a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -658,6 +658,107 @@ describe('pf-paint-by-number-dialog', () => { }); }); + it('falls back to generated colors when the selected palette reloads empty', async () => { + const saved = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); + const empty = customPalette('night', 'Night colors', []); + loadCustomPalettesOrThrowMock + .mockResolvedValueOnce([saved]) + .mockResolvedValueOnce([empty]); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'custom:night'); + await vi.runAllTimersAsync(); + + element.open = false; + await settle(element); + element.open = true; + await settle(element); + await vi.runAllTimersAsync(); + await settle(element); + + const paletteState = element as unknown as { + selectedPalette: string; + paletteSource: string; + restrictedPalette: string[] | undefined; + }; + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + const status = element.shadowRoot?.querySelector('#guided-palette-status'); + const create = element.shadowRoot?.querySelector( + 'button[form="guided-project-form"]', + ); + expect(select?.value).toBe('generated'); + expect(paletteState.selectedPalette).toBe('generated'); + expect(paletteState.paletteSource).toBe('generated'); + expect(paletteState.restrictedPalette).toBeUndefined(); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: 8, + palette: undefined, + mapping: 'color', + simplifyIsolatedPixels: true, + }); + expect(status?.getAttribute('role')).toBe('alert'); + expect(status?.textContent).toContain('needs at least one color'); + expect(create?.disabled).toBe(false); + + create?.click(); + await settle(element); + + expect(createGuidedProjectMock).toHaveBeenCalledWith({ + guide, + settings: { + longSide: 24, + paletteSource: 'generated', + maxColors: 8, + restrictedPalette: undefined, + mapping: 'color', + simplifyIsolatedPixels: true, + }, + sourceName: 'portrait.png', + }); + }); + + it('falls back to generated colors when the selected palette is deleted before reload', async () => { + const saved = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); + loadCustomPalettesOrThrowMock + .mockResolvedValueOnce([saved]) + .mockResolvedValueOnce([]); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'portrait.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'custom:night'); + await vi.runAllTimersAsync(); + + element.open = false; + await settle(element); + element.open = true; + await settle(element); + await vi.runAllTimersAsync(); + await settle(element); + + const paletteState = element as unknown as { + selectedPalette: string; + paletteSource: string; + restrictedPalette: string[] | undefined; + }; + expect(element.shadowRoot?.querySelector( + '#guided-palette-source', + )?.value).toBe('generated'); + expect(paletteState.selectedPalette).toBe('generated'); + expect(paletteState.paletteSource).toBe('generated'); + expect(paletteState.restrictedPalette).toBeUndefined(); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: 8, + palette: undefined, + mapping: 'color', + simplifyIsolatedPixels: true, + }); + }); + it('waits for a selected custom palette to reload before creating a project', async () => { const saved = customPalette('night', 'Night colors', ['#101020', '#f0f0ff']); const updatedColors = Array.from( From c05c8887a033a708a87400cb3cf1a0dc8b1a24a2 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 30 Jul 2026 16:56:10 +0200 Subject: [PATCH 14/16] fix(guided-drawing): preserve palette save confirmation --- .../dialogs/pf-paint-by-number-dialog.ts | 5 +- .../dialogs/pf-paint-by-number-dialog.test.ts | 51 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index 9ef0e5d2..90003f24 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -973,8 +973,11 @@ export class PFPaintByNumberDialog extends BaseComponent { this.isCreatingPalette = false; this.paletteName = ''; this.paletteColors = []; - this.paletteMessage = 'Palette saved and selected.'; this.selectPalette(`custom:${savedPalette.id}`); + const adaptationMessage = this.describePaletteAdaptation(savedPalette.colors.length); + this.paletteMessage = adaptationMessage + ? `Palette saved and selected. ${adaptationMessage}` + : 'Palette saved and selected.'; this.isSavingPalette = false; await this.updateComplete; this.paletteSelect?.focus(); diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts index 38b0fc10..fd2b21e1 100644 --- a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -434,6 +434,9 @@ describe('pf-paint-by-number-dialog', () => { '#guided-long-side', )?.value).toBe('32'); expect(element.shadowRoot?.querySelector('#source-preview')).toBeTruthy(); + const status = element.shadowRoot?.querySelector('#guided-palette-status'); + expect(status?.getAttribute('role')).toBe('status'); + expect(status?.textContent).toContain('Palette saved and selected.'); expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { maxColors: undefined, palette: ['#0000ff', '#00ff00'], @@ -442,6 +445,54 @@ describe('pf-paint-by-number-dialog', () => { }); }); + it('announces save confirmation and adaptation for a 256-color palette', async () => { + const colors = Array.from( + { length: MAX_PALETTE_SIZE }, + (_, index) => `#${index.toString(16).padStart(6, '0')}`, + ); + const created = customPalette('max-palette', 'Maximum palette', colors); + loadCustomPalettesOrThrowMock.mockResolvedValueOnce([]); + saveAsNewPaletteMock.mockResolvedValueOnce(created); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'source.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'create'); + + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + if (name) { + name.value = 'Maximum palette'; + name.dispatchEvent(new Event('input')); + } + const paletteState = element as unknown as { paletteColors: string[] }; + paletteState.paletteColors = colors; + await settle(element); + + element.shadowRoot + ?.querySelector('button[form="palette-creation-form"]') + ?.click(); + await settle(element); + await vi.runAllTimersAsync(); + await settle(element); + + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); + const status = element.shadowRoot?.querySelector('#guided-palette-status'); + expect(saveAsNewPaletteMock).toHaveBeenCalledWith('Maximum palette', colors); + expect(select?.value).toBe('custom:max-palette'); + expect(status?.getAttribute('role')).toBe('status'); + expect(status?.textContent).toContain('Palette saved and selected.'); + expect(status?.textContent).toContain('uses the first 255 colors'); + expect(status?.textContent).toContain('omits 1 additional color'); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: colors.slice(0, MAX_GUIDE_COLORS), + mapping: 'color', + simplifyIsolatedPixels: true, + }); + }); + it('disables adding colors when a new palette reaches the persisted limit', async () => { const element = await createDialog(); await selectPalette(element, 'create'); From fafdd11c26614f02b2dae910af57843842298923 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 30 Jul 2026 17:02:56 +0200 Subject: [PATCH 15/16] test(guided-drawing): isolate save confirmation coverage --- .../dialogs/pf-paint-by-number-dialog.test.ts | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts index fd2b21e1..4bf6dafb 100644 --- a/tests/components/dialogs/pf-paint-by-number-dialog.test.ts +++ b/tests/components/dialogs/pf-paint-by-number-dialog.test.ts @@ -434,15 +434,51 @@ describe('pf-paint-by-number-dialog', () => { '#guided-long-side', )?.value).toBe('32'); expect(element.shadowRoot?.querySelector('#source-preview')).toBeTruthy(); + expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { + maxColors: undefined, + palette: ['#0000ff', '#00ff00'], + mapping: 'color', + simplifyIsolatedPixels: true, + }); + }); + + it('announces confirmation after saving and selecting a palette', async () => { + const created = customPalette('new-palette', 'Saved palette', ['#000000']); + loadCustomPalettesOrThrowMock.mockResolvedValueOnce([]); + saveAsNewPaletteMock.mockResolvedValueOnce(created); + const element = await createDialog(); + chooseFile(element, new File(['pixels'], 'source.png', { type: 'image/png' })); + await Promise.resolve(); + await vi.runAllTimersAsync(); + await selectPalette(element, 'create'); + + const name = element.shadowRoot?.querySelector('#guided-palette-name'); + if (name) { + name.value = 'Saved palette'; + name.dispatchEvent(new Event('input')); + } + element.shadowRoot + ?.querySelector('button[form="palette-creation-form"]') + ?.click(); + await settle(element); + await vi.runAllTimersAsync(); + await settle(element); + + const select = element.shadowRoot?.querySelector( + '#guided-palette-source', + ); const status = element.shadowRoot?.querySelector('#guided-palette-status'); + expect(saveAsNewPaletteMock).toHaveBeenCalledWith('Saved palette', ['#000000']); + expect(select?.value).toBe('custom:new-palette'); expect(status?.getAttribute('role')).toBe('status'); expect(status?.textContent).toContain('Palette saved and selected.'); expect(generateNumberedGuideMock).toHaveBeenLastCalledWith(sampledImage, { maxColors: undefined, - palette: ['#0000ff', '#00ff00'], + palette: ['#000000'], mapping: 'color', simplifyIsolatedPixels: true, }); + expect(element.shadowRoot?.activeElement).toBe(select); }); it('announces save confirmation and adaptation for a 256-color palette', async () => { From 9de64e463253ed81c4317a78f723bcab9d4718da Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 30 Jul 2026 17:15:54 +0200 Subject: [PATCH 16/16] fix(guided-drawing): separate palette status from label --- .../dialogs/pf-paint-by-number-dialog.ts | 6 ++-- .../dialogs/pf-paint-by-number-dialog.test.ts | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/components/dialogs/pf-paint-by-number-dialog.ts b/src/components/dialogs/pf-paint-by-number-dialog.ts index 90003f24..747534d8 100644 --- a/src/components/dialogs/pf-paint-by-number-dialog.ts +++ b/src/components/dialogs/pf-paint-by-number-dialog.ts @@ -427,8 +427,8 @@ export class PFPaintByNumberDialog extends BaseComponent { > -