diff --git a/README.md b/README.md index 82bfeb6..8e67acd 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ contrast, proposes optional colors, and generates browser-side exports. ## Current workflow -1. Create a palette from a JPG, PNG, or WebP source image, or select **Start manually**. -2. Edit, add, duplicate, remove, or sample palette colors. +1. Create a palette from a JPG, PNG, or WebP source image, select **Start manually**, or import a ColorCraft JSON file. +2. Edit, name, reorder, add, duplicate, remove, or sample palette colors. 3. Select **Analyze palette**. 4. Use **Overview**, **Harmony**, **Contrast**, and **Suggestions** in Review. 5. Assign color roles and evaluate the applicable contrast pairs. @@ -37,6 +37,13 @@ The API accepts a source image up to 10 MB and 40 million decoded pixels. Extrac ColorCraft has no accounts or cloud synchronization. Source-image bytes and unsaved changes remain session-only. Browser history stores the active view and Review tab, not palette data. Export generation runs in the browser. +ColorCraft JSON import is also browser-only. It accepts ColorCraft portable +schema versions 1 and 2, validates the complete file before changing the +workspace, and leaves an imported palette unsaved until **Save palette** is +selected. + +![Named and reordered ColorCraft palette](./docs/assets/screenshots/palette-organization-light.png) + The shell reports **Loopback only** or **LAN enabled** from runtime metadata. SVG exports select black or white swatch labels by measured contrast. diff --git a/docs/api-contracts.md b/docs/api-contracts.md index c309275..ee8c8d3 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -6,6 +6,15 @@ FastAPI Pydantic models define the service contracts. Matching frontend Zod schemas validate API responses. Public JSON fields use camelCase. Unknown request fields are rejected. +### Portable palette boundary + +ColorCraft JSON import and export are browser-side portable-format contracts, +not backend API endpoints. Portable schema version 2 uses +`format: "colorcraft-palette"`, supports optional Unicode color names and exact +order, and excludes internal workspace IDs. The importer also accepts portable +schema version 1. Backend endpoint request and response schemas are unchanged; +analysis and suggestion requests still contain only HEX, RGB, and HSL. + The default base URL is `http://127.0.0.1:4100`. FastAPI provides interactive OpenAPI documentation at `/docs` and the OpenAPI document at `/openapi.json`. diff --git a/docs/architecture.md b/docs/architecture.md index 716a509..5244c11 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,6 +37,7 @@ The React frontend owns: - Stale-analysis invalidation - Suggestion invalidation - Browser-side export generation +- Browser-side ColorCraft JSON validation and import - Saved palette persistence - Theme preference @@ -97,7 +98,9 @@ geometric suggestion approaches for each base color. `commonAssociations` and `useCases` contain qualified conventional guidance. Suggestions do not change the palette automatically. The user must select **Add**. -The frontend fingerprints the current palette. A palette color change +The frontend gives each active palette color a stable internal ID and optional +name. Selection and reordering use the ID; backend requests are canonicalized +to HEX, RGB, and HSL only. The frontend fingerprints the current palette. A palette color change invalidates the displayed suggestion results. ## Export flow @@ -105,7 +108,7 @@ invalidates the displayed suggestion results. The frontend generates every export without an API request: - CSS custom properties -- Schema-versioned JSON +- Portable ColorCraft JSON schema version 2 - Tailwind theme colors - SVG swatch sheet @@ -121,10 +124,17 @@ Export does not create or update a saved palette record. The current source-image preview, analysis, suggestions, and unsaved changes remain in memory. The URL records the active application view and Review tab. -Saved palette records use schema version 1 in the browser's `colorcraft` -IndexedDB database. The frontend validates each record before use. It migrates -unversioned and version-0 records. It ignores malformed records and unknown -future schema versions. +Saved palette records use schema version 2 in the browser's `colorcraft` +IndexedDB database. Saved colors contain internal IDs and optional names. The +frontend validates each record before use. It migrates schema-version-1 and +legacy version-0 or unversioned records with deterministic color IDs. It rejects +malformed records and unknown future schema versions. + +The internal saved schema and portable JSON schema are separate contracts. +Portable JSON version 2 includes `format: "colorcraft-palette"`, optional names, +ordered colors, extraction metadata, and HEX-based roles, but excludes internal +IDs. Import also accepts the prior portable JSON version 1. Parsing and +validation run in the browser before workspace state changes. Source-image bytes are not part of a saved palette record. See [Persistence and privacy](./persistence-and-privacy.md). diff --git a/docs/assets/screenshots/create-light.png b/docs/assets/screenshots/create-light.png index 32c0321..8073415 100644 Binary files a/docs/assets/screenshots/create-light.png and b/docs/assets/screenshots/create-light.png differ diff --git a/docs/assets/screenshots/palette-organization-light.png b/docs/assets/screenshots/palette-organization-light.png new file mode 100644 index 0000000..9942b96 Binary files /dev/null and b/docs/assets/screenshots/palette-organization-light.png differ diff --git a/docs/persistence-and-privacy.md b/docs/persistence-and-privacy.md index 9a856ff..2f53677 100644 --- a/docs/persistence-and-privacy.md +++ b/docs/persistence-and-privacy.md @@ -15,20 +15,29 @@ The frontend uses: ## Saved palette records The `colorcraft` IndexedDB database contains the `palettes` object store. A -schema-version-1 record contains: +schema-version-2 record contains: - Record ID and palette name - Created and updated timestamps - Manual or image source type - Optional source filename -- 1–10 normalized palette colors +- 1–10 ordered normalized palette colors with stable internal IDs and optional names - Optional population and `pixelCount` - Color-role assignments -The frontend validates each record with Zod. It can migrate an unversioned or -version-0 record to the current shape. The next save writes schema version 1. +The frontend validates each record with Zod. It can migrate a schema-version-1, +version-0, or unversioned record to the current shape. +Legacy colors receive deterministic IDs, so repeatedly opening a migrated +record does not create a false modified state. Listing the Library does not +rewrite records. The next save writes schema version 2. The frontend ignores malformed records and unknown future schema versions. +Portable ColorCraft JSON is not an IndexedDB record. Portable schema version 2 +uses `format: "colorcraft-palette"` and excludes internal IDs. Import supports +portable versions 1 and 2, reads at most 1 MB, validates the complete file +locally, and does not send its contents to the API. An imported palette remains +session-only until the user selects **Save palette**. + ## Source images The browser creates a session-only source-image preview. It sends the source diff --git a/docs/screenshot-review.md b/docs/screenshot-review.md index b0be00c..930efe2 100644 --- a/docs/screenshot-review.md +++ b/docs/screenshot-review.md @@ -7,10 +7,15 @@ cd frontend corepack pnpm@9.15.9 review:screenshots ``` -It creates 16 fixture-driven PNGs under `.tmp/ui-review`: empty Create in both themes, extraction progress, image and manual palettes, selected and modified states, four Review surfaces, Export, Library, two mobile surfaces, and an API error. Network-dependent extraction is mocked; analysis uses fixed palette data. +It creates 16 fixture-driven PNGs under `.tmp/ui-review`: empty Create in both themes with the JSON import affordance, extraction progress, image and named/reordered manual palettes, selected and modified states, four Review surfaces, Export, Library, two mobile surfaces, and an API error. Network-dependent extraction is mocked; analysis uses fixed palette data. `.tmp` is intentionally ignored. Review those files locally for clipping, hierarchy, contrast, focus, error placement, and responsive behavior. Temporary test artifacts must not be linked from documentation. When a screenshot is representative and stable, copy only that file to `docs/assets/screenshots`, give it a semantic name, and reference it from documentation. Curated images are reviewable product documentation; the complete temporary set is disposable diagnostic output. -The current curated set shows light Create, dark Review Overview, dark role-based Contrast, dark Suggestions, dark Library, and mobile Create. The Review images document the metadata-derived network status, three-way temperature evidence, typed contrast checks, and qualified suggestion language. Regenerate the set only when a deliberate interface change makes an existing image inaccurate. +The current curated set shows light Create with JSON import, a named and +reordered palette, dark Review Overview, dark role-based Contrast, dark +Suggestions, dark Library, and mobile Create. The Review images document the +metadata-derived network status, three-way temperature evidence, typed contrast +checks, and qualified suggestion language. Regenerate the set only when a +deliberate interface change makes an existing image inaccurate. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b511108..3caeb49 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -139,6 +139,17 @@ Palette Library. **Recovery:** Return to the original browser profile and origin. ColorCraft does not have cloud recovery. +## A ColorCraft JSON file will not import + +**Cause:** The file is larger than 1 MB, is not valid UTF-8 JSON, uses an +unsupported schema or format, contains unknown fields, or has inconsistent +color or role data. + +**Recovery:** Export the palette again from ColorCraft when possible. Confirm +that the file is a single ColorCraft palette using portable schema version 1 or +2. ColorCraft validates the complete file and does not partially import invalid +colors or roles. + ## Browser history does not restore a palette **Cause:** The URL stores only the application view and Review tab. It does not diff --git a/docs/user-guide.md b/docs/user-guide.md index 557dd22..48b2b29 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -38,6 +38,20 @@ that is within the byte and decoded-pixel limits. The palette can contain at most 10 colors. +## Import a ColorCraft JSON palette + +1. In empty Create, or from the active Create header, select **Import ColorCraft JSON**. +2. Choose a ColorCraft JSON file that is 1 MB or smaller. +3. If the current palette has unsaved or modified work, confirm whether to + discard it. + +Import runs entirely in the browser. ColorCraft accepts portable schema version +1 and version 2 files, validates the whole file before changing the workspace, +and never imports arbitrary JSON. The imported palette retains its name, color +order, optional color names, extraction metadata when present, and valid role +assignments. It has no source image and remains **Unsaved** until you select +**Save palette**. + ## Edit a palette color Use one of these controls: @@ -48,7 +62,14 @@ Use one of these controls: - Select **Remove color**. - For an active source image, select **Pick color _n_ from image**. -ColorCraft does not currently provide a palette-reorder control. +Enter an optional name in **Name for color N**. A name can contain at most 80 +trimmed characters. Blur the field or press Enter to save it. Press Escape to +restore the previous name. Empty input clears the name. + +Open the color actions menu and select **Move up** or **Move down** to reorder a +color. The first and last unavailable directions are disabled. Reordering +preserves the logical color, its name, and extraction metadata, and invalidates +the current analysis. ## Sample a color from the source image @@ -173,12 +194,21 @@ stated in percentage points. - **JSON** - **Tailwind theme colors** - **SVG swatch sheet** + +Named colors produce safe ASCII keys in CSS and Tailwind output and visible +labels in SVG. Unnamed colors keep the `palette-1`, `palette-2` numeric pattern. +ColorCraft JSON schema version 2 preserves Unicode names, exact order, +extraction metadata, and roles. It does not contain internal workspace IDs. 4. Select **Copy** or **Download**. **Copy** places the generated text on the clipboard. **Download** creates an exported file through the browser. Export does not create or update a saved palette record. +Role assignments currently reference HEX values. Duplicate colors with the same +HEX value therefore share a role association even when their internal IDs or +names differ. + For SVG output, ColorCraft measures each swatch against black and white. It uses the text color with the higher contrast ratio. diff --git a/docs/writing-style.md b/docs/writing-style.md index 86ada08..7ce991c 100644 --- a/docs/writing-style.md +++ b/docs/writing-style.md @@ -204,6 +204,8 @@ contents, clipboard contents, or unrelated palette data in user-facing errors. | Export | The application view that formats the current palette for external use. | Preserve capitalization. | | export format | One supported representation of the current palette. | Current labels are CSS custom properties, JSON, Tailwind theme colors, and SVG swatch sheet. | | exported file | A file that the browser creates from the current palette. | Do not imply that ColorCraft stores the file. | +| ColorCraft JSON | The portable, single-palette JSON format that ColorCraft exports and imports in the browser. | Distinguish its schema version from the IndexedDB saved-palette schema and backend API schemas. | +| imported palette | A validated ColorCraft JSON palette activated in the current session. | State that it remains unsaved until **Save palette** is selected. | | copy | Place generated export text on the system clipboard. | Do not use as a synonym for export or download. | | download | Save generated export data as a local file through the browser. | Do not use as a synonym for copy. | | current session | The active in-memory application state in the current browser tab. | State separately whether data is saved in IndexedDB. | diff --git a/frontend/e2e/screenshot-review.spec.ts b/frontend/e2e/screenshot-review.spec.ts index 5461ce2..acc050f 100644 --- a/frontend/e2e/screenshot-review.spec.ts +++ b/frontend/e2e/screenshot-review.spec.ts @@ -93,12 +93,18 @@ test('@screenshots fixture-driven UI review', async ({ page }) => { await capture(page, '03-extraction-progress') await expect(page.getByText(/3 distinct colors were/)).toBeVisible() await capture(page, '04-image-palette-workspace') - await page.locator('[aria-label="Palette color 2"]').click() + await page.locator('[aria-label="Color 2 · #F2763F"]').click() await capture(page, '05-selected-palette-color') await page.getByRole('button', { name: 'New palette' }).click() await page.getByRole('button', { name: 'Discard and start new' }).click() await setPalette(page) + await page.getByLabel('Name for color 1').fill('Primary action') + await page.getByLabel('Name for color 1').blur() + await page.getByLabel('Name for color 3').fill('Primary text') + await page.getByLabel('Name for color 3').blur() + await page.getByRole('button', { name: /Actions for Primary text/ }).click() + await page.getByRole('menuitem', { name: 'Move up' }).click() await capture(page, '06-manual-palette-workspace') await page.getByRole('button', { name: 'Save palette' }).click() await expect(page.getByText('Saved', { exact: true })).toBeVisible() diff --git a/frontend/e2e/workflow.spec.ts b/frontend/e2e/workflow.spec.ts index e03f2f4..19c8def 100644 --- a/frontend/e2e/workflow.spec.ts +++ b/frontend/e2e/workflow.spec.ts @@ -87,7 +87,7 @@ test('trust-sensitive Create, Review, Suggestions, and Export states are explici await page.getByRole('tab', { name: 'Contrast' }).click() await page.getByLabel('Page background').selectOption('#F5F0E8') await page.getByLabel('Surface').selectOption('#F5F0E8') - await page.getByLabel('Primary text').selectOption('#141D29') + await page.getByLabel('Primary text', { exact: true }).selectOption('#141D29') await page.getByLabel('Border').selectOption('#141D29') await page.getByLabel('Focus indicator').selectOption('#80FF00') await expect(page.getByText(/AA normal text/).first()).toBeVisible() @@ -144,3 +144,122 @@ test('representative Create and Library states have no serious axe violations', expect(serious).toEqual([]) } }) + +test('portable JSON round trip preserves names, order, roles, and local save lifecycle', async ({ + page, +}) => { + await createManualPalette(page) + await page.getByRole('button', { name: 'Add color' }).click() + await page + .getByRole('textbox', { name: 'Color 3', exact: true }) + .fill('#141D29') + await page.getByRole('textbox', { name: 'Color 3', exact: true }).blur() + for (const [index, name] of [ + [1, 'Primary action'], + [2, 'Surface'], + [3, 'Primary text'], + ] as const) { + await page.getByLabel(`Name for color ${index}`).fill(name) + await page.getByLabel(`Name for color ${index}`).blur() + } + await page.getByRole('button', { name: /Actions for Primary text/ }).click() + await page.getByRole('menuitem', { name: 'Move up' }).click() + await expect(page.getByLabel('Name for color 2')).toHaveValue('Primary text') + + await page.getByRole('button', { name: 'Analyze palette' }).click() + await page.getByRole('tab', { name: 'Contrast' }).click() + await page.getByLabel('Primary text', { exact: true }).selectOption('#141D29') + await page.getByRole('button', { name: 'Save palette' }).click() + await expect(page.getByText('Saved', { exact: true })).toBeVisible() + + await page + .getByRole('navigation', { name: 'Primary' }) + .getByRole('button', { name: 'Export' }) + .click() + await page.getByRole('tab', { name: 'JSON' }).click() + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: 'Download' }).click() + const download = await downloadPromise + const downloadPath = await download.path() + expect(downloadPath).not.toBeNull() + + await page.getByRole('button', { name: 'New palette' }).click() + await expect(page.getByText('Create a palette')).toBeVisible() + await page + .locator('input[accept=".json,application/json"]') + .setInputFiles(downloadPath!) + await expect( + page.getByText(/was imported locally and remains unsaved/), + ).toBeVisible() + await expect(page.getByText('Unsaved', { exact: true })).toBeVisible() + await expect(page.getByLabel('Name for color 1')).toHaveValue( + 'Primary action', + ) + await expect(page.getByLabel('Name for color 2')).toHaveValue('Primary text') + await expect(page.getByLabel('Name for color 3')).toHaveValue('Surface') + + await page.getByRole('button', { name: 'Save palette' }).click() + await page + .getByRole('navigation', { name: 'Primary' }) + .getByRole('button', { name: 'Library' }) + .click() + await page + .getByRole('button', { name: 'Open Untitled palette' }) + .first() + .click() + await expect(page.getByLabel('Name for color 2')).toHaveValue('Primary text') +}) + +test('empty, import error, and imported organization states pass axe', async ({ + page, +}) => { + await page.goto('/?view=create') + const assertAxe = async () => { + const results = await new AxeBuilder({ page }).analyze() + expect( + results.violations.filter((violation) => + ['serious', 'critical'].includes(violation.impact ?? ''), + ), + ).toEqual([]) + } + await expect( + page.locator('button', { hasText: 'Import ColorCraft JSON' }), + ).toBeVisible() + await assertAxe() + + const input = page.locator('input[accept=".json,application/json"]') + await input.setInputFiles({ + name: 'invalid.json', + mimeType: 'application/json', + buffer: Buffer.from('{'), + }) + await expect(page.getByRole('alert')).toContainText('valid JSON') + await assertAxe() + + await input.setInputFiles({ + name: 'palette.json', + mimeType: 'application/json', + buffer: Buffer.from( + JSON.stringify({ + schemaVersion: 2, + format: 'colorcraft-palette', + paletteName: 'Imported organization', + colors: [ + { + order: 1, + name: 'Primary action', + hex: '#FF0000', + rgb: { r: 255, g: 0, b: 0 }, + hsl: { h: 0, s: 100, l: 50 }, + roles: [], + }, + ], + roleAssignments: {}, + }), + ), + }) + await expect(page.getByLabel('Name for color 1')).toHaveValue( + 'Primary action', + ) + await assertAxe() +}) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index ad62a12..24e383f 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -15,8 +15,34 @@ import { } from './api/client' import { analysis, red } from './test/fixtures' import { savePalette } from './persistence' +import { serializePortablePalette } from './portablePalette' import App from './App' +function portableFile(name = 'Imported palette') { + const text = serializePortablePalette( + name, + [ + { ...red, name: 'Primary action' }, + { + ...red, + id: 'portable-blue', + name: 'Surface', + hex: '#0000FF', + rgb: { r: 0, g: 0, b: 255 }, + hsl: { h: 240, s: 100, l: 50 }, + }, + ], + { primaryText: red.hex }, + ) + const file = new File([text], 'palette.json', { + type: 'application/json', + }) + Object.defineProperty(file, 'arrayBuffer', { + value: vi.fn(async () => new TextEncoder().encode(text).buffer), + }) + return file +} + vi.mock('./api/client', () => ({ extractColors: vi.fn(), analyzeColors: vi.fn(), @@ -320,4 +346,44 @@ describe('ColorCraft workspace', () => { within(desktopNavigation()).getByRole('button', { name: 'Review' }), ).toBeDisabled() }) + + it('imports a validated portable palette locally into an unsaved workspace', async () => { + const { container } = render() + const input = container.querySelector( + 'input[accept=".json,application/json"]', + )! + fireEvent.change(input, { target: { files: [portableFile()] } }) + + expect(await screen.findByText('Imported palette')).toBeInTheDocument() + expect(screen.getByDisplayValue('Primary action')).toBeInTheDocument() + expect(screen.getByDisplayValue('Surface')).toBeInTheDocument() + expect(screen.getByText('Unsaved')).toBeInTheDocument() + expect( + screen.getByText(/was imported locally and remains unsaved/), + ).toBeInTheDocument() + }) + + it('cancels or confirms import replacement without reparsing the file', async () => { + const { container } = render() + fireEvent.click(screen.getByRole('button', { name: 'Start manually' })) + const input = container.querySelector( + 'input[accept=".json,application/json"]', + )! + const file = portableFile('Replacement') + fireEvent.change(input, { target: { files: [file] } }) + expect( + await screen.findByRole('dialog', { name: 'Import another palette?' }), + ).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Keep working' })) + expect(screen.getByText('Untitled palette')).toBeInTheDocument() + + fireEvent.change(input, { target: { files: [file] } }) + fireEvent.click( + await screen.findByRole('button', { + name: 'Discard changes and import', + }), + ) + expect(await screen.findByText('Replacement')).toBeInTheDocument() + expect(file.arrayBuffer).toHaveBeenCalledTimes(2) + }) }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b7546d8..a60bc11 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import ColorPalette from './components/ColorPalette' import ExportWorkspace from './components/ExportWorkspace' import ImageColorPicker from './components/ImageColorPicker' import ImageUpload, { validateImageFile } from './components/ImageUpload' +import JsonImportButton from './components/JsonImportButton' import InlineNotice, { type NoticeState } from './components/InlineNotice' import PaletteLibrary from './components/PaletteLibrary' import ReviewWorkspace from './components/ReviewWorkspace' @@ -38,7 +39,10 @@ import { type SavedPalette, } from './persistence' import { + clonePaletteColor, colorFromHex, + paletteColorFromApi, + replacePaletteColorValue, type PaletteColor, type ReviewView, type WorkspaceView, @@ -47,6 +51,7 @@ import { urlForView, viewFromLocation, } from './workspace' +import type { ImportedPalette } from './portablePalette' interface SourceImage { file: File @@ -70,7 +75,7 @@ function App() { >(null) const [sourceFilename, setSourceFilename] = useState() const [colors, setColors] = useState([]) - const [selectedIndex, setSelectedIndex] = useState(null) + const [selectedColorId, setSelectedColorId] = useState(null) const [requestedColors, setRequestedColors] = useState(6) const [analysis, setAnalysis] = useState(null) const [analysisStale, setAnalysisStale] = useState(false) @@ -81,7 +86,7 @@ function App() { state: 'loading', }) const [confirmNewPalette, setConfirmNewPalette] = useState(false) - const [pickerTarget, setPickerTarget] = useState(null) + const [pickerTarget, setPickerTarget] = useState(null) const [reviewTab, setReviewTab] = useState(() => reviewFromLocation(), ) @@ -93,6 +98,9 @@ function App() { const [savedFingerprint, setSavedFingerprint] = useState(null) const [pendingOpenPalette, setPendingOpenPalette] = useState(null) + const [pendingImport, setPendingImport] = useState( + null, + ) const [deleteTarget, setDeleteTarget] = useState(null) const changeImageInputRef = useRef(null) const sourceUrlRef = useRef(null) @@ -257,8 +265,9 @@ function App() { try { const data = await extractColors(file, count, controller.signal) if (requestId !== extractionRequestRef.current) return - setColors(data.colors) - setSelectedIndex(data.colors.length ? 0 : null) + const extracted = data.colors.map((color) => paletteColorFromApi(color)) + setColors(extracted) + setSelectedColorId(extracted[0]?.id ?? null) invalidateAnalysis() setNotice({ variant: data.count < count ? 'information' : 'success', @@ -292,7 +301,7 @@ function App() { setActivePaletteId(null) setSavedFingerprint(null) setColors([]) - setSelectedIndex(null) + setSelectedColorId(null) setAnalysis(null) setAnalysisStale(false) navigate('create') @@ -307,8 +316,9 @@ function App() { setSourceFilename(undefined) setActivePaletteId(null) setSavedFingerprint(null) - setColors([manualStarter]) - setSelectedIndex(0) + const starter = paletteColorFromApi(manualStarter) + setColors([starter]) + setSelectedColorId(starter.id) setAnalysis(null) setAnalysisStale(false) setNotice(null) @@ -328,7 +338,7 @@ function App() { setPaletteSourceType(null) setSourceFilename(undefined) setColors([]) - setSelectedIndex(null) + setSelectedColorId(null) setAnalysis(null) setAnalysisStale(false) setRoles({}) @@ -348,9 +358,11 @@ function App() { else resetPalette() } - const updateColor = (index: number, color: Color) => { + const updateColor = (id: string, color: Color) => { setColors((current) => - current.map((item, itemIndex) => (itemIndex === index ? color : item)), + current.map((item) => + item.id === id ? replacePaletteColorValue(item, color) : item, + ), ) invalidateAnalysis() } @@ -363,12 +375,14 @@ function App() { }) return } - setColors((current) => [...current, color]) - setSelectedIndex(colors.length) + const added = paletteColorFromApi(color) + setColors((current) => [...current, added]) + setSelectedColorId(added.id) invalidateAnalysis() } - const duplicateColor = (index: number) => { + const duplicateColor = (id: string) => { + const index = colors.findIndex((color) => color.id === id) const sourceColor = colors[index] if (!sourceColor || colors.length >= 10) { if (colors.length >= 10) { @@ -379,31 +393,81 @@ function App() { } return } - const duplicate: Color = { - hex: sourceColor.hex, - rgb: { ...sourceColor.rgb }, - hsl: { ...sourceColor.hsl }, - } + const duplicate = clonePaletteColor(sourceColor, { + newId: true, + copyName: true, + }) setColors((current) => [ ...current.slice(0, index + 1), duplicate, ...current.slice(index + 1), ]) - setSelectedIndex(index + 1) + setSelectedColorId(duplicate.id) + invalidateAnalysis() + } + + const removeColor = (id: string) => { + const index = colors.findIndex((color) => color.id === id) + const next = colors.filter((color) => color.id !== id) + setColors(next) + if (selectedColorId === id) { + setSelectedColorId(next[Math.min(index, next.length - 1)]?.id ?? null) + } invalidateAnalysis() } - const removeColor = (index: number) => { + const moveColor = (id: string, direction: -1 | 1) => { + setColors((current) => { + const index = current.findIndex((color) => color.id === id) + const destination = index + direction + if (index < 0 || destination < 0 || destination >= current.length) + return current + const next = [...current] + ;[next[index], next[destination]] = [next[destination], next[index]] + return next + }) + invalidateAnalysis() + } + + const updateColorName = (id: string, name?: string) => { setColors((current) => - current.filter((_, itemIndex) => itemIndex !== index), + current.map((color) => + color.id === id + ? { ...color, ...(name ? { name } : { name: undefined }) } + : color, + ), ) - setSelectedIndex((current) => { - if (current === null) return null - if (colors.length <= 1) return null - if (current > index) return current - 1 - return Math.min(current, colors.length - 2) + } + + const activateImport = (palette: ImportedPalette) => { + analysisRequestRef.current += 1 + extractionRequestRef.current += 1 + analysisControllerRef.current?.abort() + extractionControllerRef.current?.abort() + revokeSource() + setSource(null) + setManualPalette(true) + setPaletteSourceType('manual') + setSourceFilename(undefined) + setPaletteName(palette.name) + setColors(palette.colors) + setRoles(palette.roles) + setSelectedColorId(palette.colors[0]?.id ?? null) + setAnalysis(null) + setAnalysisStale(false) + setActivePaletteId(null) + setSavedFingerprint(null) + setPendingImport(null) + setNotice({ + variant: 'success', + message: `${palette.name} was imported locally and remains unsaved.`, }) - invalidateAnalysis() + navigate('create') + } + + const requestImport = (palette: ImportedPalette) => { + if (hasPendingChanges) setPendingImport(palette) + else activateImport(palette) } const handleAnalyze = async () => { @@ -464,7 +528,7 @@ function App() { setPaletteName(palette.name) setColors(palette.colors) setRoles(palette.roles) - setSelectedIndex(palette.colors.length ? 0 : null) + setSelectedColorId(palette.colors[0]?.id ?? null) setAnalysis(null) setAnalysisStale(false) setActivePaletteId(palette.id) @@ -584,6 +648,8 @@ function App() { setNotice({ variant: 'error', message })} /> ) : (