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.
+
+
+
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 })}
/>
) : (
}
- onClick={() => setPickerTarget(selectedIndex ?? 'add')}
+ onClick={() => setPickerTarget(selectedColorId ?? 'add')}
>
Pick from image
@@ -706,13 +772,15 @@ function App() {
addColor()}
onDuplicateColor={duplicateColor}
onRemoveColor={removeColor}
+ onMoveColor={moveColor}
onPickFromImage={setPickerTarget}
/>
@@ -778,6 +846,13 @@ function App() {
const headerActions = paletteDraft ? (