From 9cbbd6f2ed70977ad7a6db96e5d9aff33c1ea07f Mon Sep 17 00:00:00 2001
From: John Crafts <5889731+Artsen@users.noreply.github.com>
Date: Sat, 25 Jul 2026 19:25:40 -0400
Subject: [PATCH] feat: bind roles to stable palette colors
---
README.md | 9 +-
docs/api-contracts.md | 11 +-
docs/architecture.md | 33 ++-
docs/color-analysis.md | 10 +-
docs/persistence-and-privacy.md | 23 +-
docs/screenshot-review.md | 7 +-
docs/testing.md | 6 +-
docs/troubleshooting.md | 8 +-
docs/user-guide.md | 23 +-
docs/writing-style.md | 4 +-
frontend/e2e/screenshot-review.spec.ts | 30 ++-
frontend/e2e/workflow.spec.ts | 144 ++++++++++-
frontend/src/App.test.tsx | 2 +-
frontend/src/App.tsx | 4 +-
.../src/components/ExportWorkspace.test.tsx | 17 ++
frontend/src/components/ExportWorkspace.tsx | 6 +
.../src/components/PaletteLibrary.test.tsx | 4 +-
.../src/components/ReviewWorkspace.test.tsx | 50 +++-
frontend/src/components/ReviewWorkspace.tsx | 18 +-
frontend/src/contrast.test.ts | 53 +++-
frontend/src/contrast.ts | 63 ++++-
frontend/src/exporters.test.ts | 43 +++-
frontend/src/exporters.ts | 70 +++++-
frontend/src/persistence.test.ts | 108 ++++++---
frontend/src/persistence.ts | 226 +++++++++++-------
frontend/src/portablePalette.test.ts | 128 +++++++---
frontend/src/portablePalette.ts | 148 +++++++++---
frontend/src/workspace.ts | 10 +
28 files changed, 976 insertions(+), 282 deletions(-)
diff --git a/README.md b/README.md
index 8e67acd..35e6055 100644
--- a/README.md
+++ b/README.md
@@ -37,10 +37,11 @@ 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.
+ColorCraft JSON import is also browser-only. New exports use portable schema
+version 3 with document-local color keys. Imports accept versions 1, 2, and 3,
+validate the complete file before changing the workspace, and leave an imported
+palette unsaved until **Save palette** is selected. CSS and Tailwind exports
+include semantic role tokens for assigned colors.

diff --git a/docs/api-contracts.md b/docs/api-contracts.md
index ee8c8d3..80ae6cf 100644
--- a/docs/api-contracts.md
+++ b/docs/api-contracts.md
@@ -9,11 +9,14 @@ 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
+not backend API endpoints. Portable schema version 3 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.
+order, assigns deterministic document-local keys, and excludes internal
+workspace IDs. Role assignments reference the portable keys. The importer also
+accepts portable schema versions 1 and 2 and maps their HEX roles to the first
+matching color. Backend endpoint request and response schemas are unchanged;
+analysis and suggestion requests still contain only HEX, RGB, and HSL, never
+IDs, names, portable keys, or roles.
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 5244c11..16e78a5 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -99,20 +99,23 @@ geometric suggestion approaches for each base color. `commonAssociations` and
the palette automatically. The user must select **Add**.
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.
+name. Selection, reordering, and role assignments 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
The frontend generates every export without an API request:
- CSS custom properties
-- Portable ColorCraft JSON schema version 2
+- Portable ColorCraft JSON schema version 3
- Tailwind theme colors
- SVG swatch sheet
-CSS and Tailwind comments replace line breaks and the `*/` sequence in the
+CSS emits base `--color-*` values and assigned `--role-*` aliases. Tailwind
+emits base keys and assigned `role-*` semantic keys. SVG annotates each row with
+its assigned roles. Comments replace line breaks and the `*/` sequence in the
palette name. SVG output escapes `&`, `<`, `>`, `"`, and `'`. Each swatch label
uses black or white according to the higher measured contrast ratio. Download
uses an object URL and revokes the URL after the browser starts the download.
@@ -124,17 +127,21 @@ 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 2 in the browser's `colorcraft`
+Saved palette records use schema version 3 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.
+frontend validates each record before use. Version-3 roles reference those IDs.
+It migrates schema-version-2 HEX roles and schema-version-1, version-0, or
+unversioned records. A legacy role maps to the first matching color in palette
+order; missing matches are pruned. 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.
+Portable JSON version 3 includes `format: "colorcraft-palette"`, optional names,
+ordered colors, extraction metadata, and deterministic document-local color
+keys. Role assignments reference those keys and internal IDs remain excluded.
+Import also accepts portable versions 1 and 2, whose HEX roles map to the first
+matching color. 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/color-analysis.md b/docs/color-analysis.md
index f4f0884..a878004 100644
--- a/docs/color-analysis.md
+++ b/docs/color-analysis.md
@@ -207,8 +207,14 @@ color changes.
## Export accuracy and escaping
-JSON export uses `schemaVersion: 1`. JSON includes ordered colors, normalized
-color values, optional population, color-role metadata, and role assignments.
+JSON export uses portable `schemaVersion: 3`. JSON includes deterministic
+document-local color keys, ordered normalized colors, optional extraction
+metadata and names, per-color role metadata, and key-based role assignments.
+Internal workspace IDs are excluded.
+
+CSS emits base palette properties plus assigned `--role-*` aliases. Tailwind
+emits base palette keys plus assigned `role-*` semantic keys. SVG rows annotate
+assigned roles.
CSS and Tailwind output sanitize palette names used in comments. SVG output
escapes XML-sensitive characters. Each SVG swatch compares measured contrast
diff --git a/docs/persistence-and-privacy.md b/docs/persistence-and-privacy.md
index 2f53677..fbe48c2 100644
--- a/docs/persistence-and-privacy.md
+++ b/docs/persistence-and-privacy.md
@@ -15,7 +15,7 @@ The frontend uses:
## Saved palette records
The `colorcraft` IndexedDB database contains the `palettes` object store. A
-schema-version-2 record contains:
+schema-version-3 record contains:
- Record ID and palette name
- Created and updated timestamps
@@ -23,20 +23,23 @@ schema-version-2 record contains:
- Optional source filename
- 1–10 ordered normalized palette colors with stable internal IDs and optional names
- Optional population and `pixelCount`
-- Color-role assignments
+- Color-role assignments that reference stable internal color IDs
-The frontend validates each record with Zod. It can migrate a schema-version-1,
-version-0, or unversioned record to the current shape.
+The frontend validates each record with Zod. It can migrate a schema-version-2,
+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.
+rewrite records. Version-2 and older HEX roles map to the first matching color
+in palette order; missing matches are discarded. The next save writes schema
+version 3.
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**.
+Portable ColorCraft JSON is not an IndexedDB record. Portable schema version 3
+uses `format: "colorcraft-palette"` and deterministic document-local keys while
+excluding internal IDs. Import supports portable versions 1, 2, and 3, 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
diff --git a/docs/screenshot-review.md b/docs/screenshot-review.md
index 930efe2..bb154b1 100644
--- a/docs/screenshot-review.md
+++ b/docs/screenshot-review.md
@@ -7,15 +7,16 @@ cd frontend
corepack pnpm@9.15.9 review:screenshots
```
-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.
+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, semantic-token Export, Library, two mobile surfaces, and an API error. The Contrast fixture assigns distinct roles to named duplicate-HEX colors. 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 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
+reordered palette, dark Review Overview, dark role-based Contrast, semantic
+role export, 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/testing.md b/docs/testing.md
index 2e1c811..6023e76 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -76,13 +76,15 @@ gate.
## Test scope
-- Persistence tests cover validation, migration, update identity, sorting,
+- Persistence tests cover version-3 validation, legacy HEX-role migration,
+ duplicate-role remapping, update identity, sorting,
rename, duplicate, and delete.
- Frontend tests cover navigation, palette state, stale analysis, Save states,
Review, Export, and API contract validation.
- Backend tests cover upload defenses, extraction, relationship analysis,
suggestions, contrast, metadata, readiness, configuration, and launcher
supervision.
-- Browser tests cover create, save, analyze, role assignment, export, reopen,
+- Browser tests cover create, save, analyze, stable duplicate-color role
+ assignment, semantic export, portable round trips, reopen,
delete, and representative accessibility states.
- Documentation tests verify local Markdown links and curated screenshot names.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 3caeb49..d234209 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -146,9 +146,11 @@ 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.
+that the file is a single ColorCraft palette using portable schema version 1,
+2, or 3. Version-3 role references must point to unique valid portable color
+keys. ColorCraft validates the complete file and does not partially import
+invalid colors or roles. In version 1 or 2, a role associated with a duplicate
+HEX value maps to the first matching color in palette order.
## Browser history does not restore a palette
diff --git a/docs/user-guide.md b/docs/user-guide.md
index 48b2b29..26bfd07 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -45,8 +45,8 @@ The palette can contain at most 10 colors.
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,
+Import runs entirely in the browser. ColorCraft accepts portable schema
+versions 1, 2, and 3, 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
@@ -155,6 +155,11 @@ The current color-role labels are:
- **Border**
- **Focus indicator**
+Roles are bound to the selected palette color, not its HEX value. Renaming,
+reordering, or editing that color preserves the assignment. Duplicate HEX
+colors remain separate choices. Removing a color clears only roles assigned to
+that color.
+
Text checks show AA and AAA text thresholds. **Border against surface** is a
non-text component check with a 3:1 threshold. Focus-indicator checks use 3:1
for the selected adjacent-color pair.
@@ -197,17 +202,21 @@ stated in percentage points.
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.
+ColorCraft JSON schema version 3 preserves Unicode names, exact order,
+extraction metadata, and unambiguous role ownership. Each color receives a
+document-local key such as `color-1`; the file 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.
+CSS output adds assigned `--role-*` aliases that reference base `--color-*`
+tokens. Tailwind output adds assigned `role-*` keys, and SVG rows identify their
+assigned roles. Imported version-1 and version-2 files still use HEX role
+references. When duplicate HEX values make those older files ambiguous,
+ColorCraft maps the role to the first matching color in palette order.
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 7ce991c..798e189 100644
--- a/docs/writing-style.md
+++ b/docs/writing-style.md
@@ -189,7 +189,7 @@ contents, clipboard contents, or unrelated palette data in user-facing errors.
| non-text component contrast check | A contrast-role check that applies the 3:1 color threshold to a component boundary or state. | Do not claim complete component accessibility. |
| focus-indicator color contrast check | A 3:1 check for one focus-indicator and adjacent-color pair. | Size, area, thickness, visibility, and focused-versus-unfocused appearance remain outside the calculation. |
| color role | A semantic interface role assigned to a palette color. | Current labels are Page background, Surface, Primary text, Secondary text, Primary action, Action text, Border, and Focus indicator. |
-| role assignment | The association between a palette color and a color role. | Do not use for an unassigned palette color. |
+| role assignment | The association between a stable palette color identity and a color role. | Do not describe an active role as a HEX association or expose the internal ID. |
| contrast-role check | A typed contrast test between two meaningful assigned color roles. | Identify whether the check is text, non-text component, or focus-indicator color contrast. |
| all-pairs text contrast matrix | The advanced matrix that applies text thresholds to every palette color pair. | Keep separate from typed contrast-role checks. |
| suggestion approach | One method that proposes colors from a base color. | Current API approaches include complementary, triadic, analogous, split-complementary, tetradic, rectangular, monochromatic, double-complementary, and shades and tints. |
@@ -205,6 +205,8 @@ contents, clipboard contents, or unrelated palette data in user-facing errors.
| 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. |
+| portable color key | A deterministic document-local color reference such as `color-1` in ColorCraft JSON version 3. | Do not call it an internal workspace ID. |
+| semantic role token | An assigned `--role-*` CSS alias or `role-*` Tailwind key generated from a color role. | Keep it separate from the base palette token. |
| 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. |
diff --git a/frontend/e2e/screenshot-review.spec.ts b/frontend/e2e/screenshot-review.spec.ts
index acc050f..fae8d15 100644
--- a/frontend/e2e/screenshot-review.spec.ts
+++ b/frontend/e2e/screenshot-review.spec.ts
@@ -23,12 +23,22 @@ async function capture(page: Page, name: string) {
})
}
+async function selectRoleColor(page: Page, role: string, visibleColor: string) {
+ const selector = page.getByLabel(role, { exact: true })
+ const value = await selector
+ .locator('option')
+ .filter({ hasText: visibleColor })
+ .getAttribute('value')
+ expect(value).not.toBeNull()
+ await selector.selectOption(value!)
+}
+
async function setPalette(page: Page) {
await page.getByRole('button', { name: 'Start manually' }).click()
for (let index = 0; index < 4; index += 1) {
await page.getByRole('button', { name: 'Add color' }).click()
}
- const colors = ['#6A5BCF', '#F2763F', '#141D29', '#F5F0E8', '#80FF00']
+ const colors = ['#6A5BCF', '#F2763F', '#141D29', '#F5F0E8', '#725FD6']
for (let index = 0; index < colors.length; index += 1) {
const input = page.getByRole('textbox', {
name: `Color ${index + 1}`,
@@ -103,6 +113,8 @@ test('@screenshots fixture-driven UI review', async ({ page }) => {
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.getByLabel('Name for color 5').fill('Primary action hover')
+ await page.getByLabel('Name for color 5').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')
@@ -124,14 +136,14 @@ test('@screenshots fixture-driven UI review', async ({ page }) => {
await page.getByRole('tab', { name: 'Harmony' }).click()
await capture(page, '09-review-harmony')
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('Secondary text').selectOption('#725FD6')
- await page.getByLabel('Primary action').selectOption('#725FD6')
- await page.getByLabel('Action text').selectOption('#F5F0E8')
- await page.getByLabel('Border').selectOption('#725FD6')
- await page.getByLabel('Focus indicator').selectOption('#F2763F')
+ await selectRoleColor(page, 'Page background', '#F5F0E8')
+ await selectRoleColor(page, 'Surface', '#F5F0E8')
+ await selectRoleColor(page, 'Primary text', '#141D29')
+ await selectRoleColor(page, 'Secondary text', 'Primary action hover')
+ await selectRoleColor(page, 'Primary action', 'Primary action ·')
+ await selectRoleColor(page, 'Action text', '#F5F0E8')
+ await selectRoleColor(page, 'Border', 'Primary action ·')
+ await selectRoleColor(page, 'Focus indicator', '#F2763F')
await capture(page, '10-contrast-roles')
await page.getByRole('tab', { name: 'Suggestions' }).click()
await page.getByRole('button', { name: 'Generate suggestions' }).click()
diff --git a/frontend/e2e/workflow.spec.ts b/frontend/e2e/workflow.spec.ts
index 19c8def..9345ab1 100644
--- a/frontend/e2e/workflow.spec.ts
+++ b/frontend/e2e/workflow.spec.ts
@@ -15,6 +15,22 @@ async function createManualPalette(page: Page) {
await page.getByRole('textbox', { name: 'Color 2', exact: true }).blur()
}
+async function selectRoleColor(
+ page: Page,
+ role: string,
+ visibleColor: string,
+ occurrence = 0,
+) {
+ const selector = page.getByLabel(role, { exact: true })
+ const value = await selector
+ .locator('option')
+ .filter({ hasText: visibleColor })
+ .nth(occurrence)
+ .getAttribute('value')
+ expect(value).not.toBeNull()
+ await selector.selectOption(value!)
+}
+
test('creation through save, review, export, reopen, and delete', async ({
page,
context,
@@ -28,8 +44,8 @@ test('creation through save, review, export, reopen, and delete', async ({
await page.getByRole('button', { name: 'Analyze palette' }).click()
await expect(page.getByText('Palette summary')).toBeVisible()
await page.getByRole('tab', { name: 'Contrast' }).click()
- await page.getByLabel('Page background').selectOption('#F5F0E8')
- await page.getByLabel('Primary text').selectOption('#6A5BCF')
+ await selectRoleColor(page, 'Page background', '#F5F0E8')
+ await selectRoleColor(page, 'Primary text', '#6A5BCF')
await expect(page.getByText('Primary text on page background')).toBeVisible()
await expect(page.getByText(/AA normal text/).first()).toBeVisible()
@@ -85,11 +101,11 @@ test('trust-sensitive Create, Review, Suggestions, and Export states are explici
await expect(page.getByText(/transitional/)).toBeVisible()
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', { exact: true }).selectOption('#141D29')
- await page.getByLabel('Border').selectOption('#141D29')
- await page.getByLabel('Focus indicator').selectOption('#80FF00')
+ await selectRoleColor(page, 'Page background', '#F5F0E8')
+ await selectRoleColor(page, 'Surface', '#F5F0E8')
+ await selectRoleColor(page, 'Primary text', '#141D29')
+ await selectRoleColor(page, 'Border', '#141D29')
+ await selectRoleColor(page, 'Focus indicator', '#80FF00')
await expect(page.getByText(/AA normal text/).first()).toBeVisible()
await expect(page.getByText(/Non-text component contrast/)).toBeVisible()
await expect(
@@ -168,7 +184,7 @@ test('portable JSON round trip preserves names, order, roles, and local save lif
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 selectRoleColor(page, 'Primary text', 'Primary text')
await page.getByRole('button', { name: 'Save palette' }).click()
await expect(page.getByText('Saved', { exact: true })).toBeVisible()
@@ -210,6 +226,118 @@ test('portable JSON round trip preserves names, order, roles, and local save lif
await expect(page.getByLabel('Name for color 2')).toHaveValue('Primary text')
})
+test('stable role identity survives duplicate colors, edits, save, and portable export', async ({
+ page,
+}) => {
+ await page.goto('/?view=create')
+ await page.getByRole('button', { name: 'Start manually' }).click()
+ await page.getByRole('button', { name: 'Add color' }).click()
+ await page.getByRole('button', { name: 'Add color' }).click()
+ for (const index of [1, 2]) {
+ const input = page.getByRole('textbox', {
+ name: `Color ${index}`,
+ exact: true,
+ })
+ await input.fill('#141D29')
+ await input.blur()
+ }
+ await page
+ .getByRole('textbox', { name: 'Color 3', exact: true })
+ .fill('#F5F0E8')
+ await page.getByRole('textbox', { name: 'Color 3', exact: true }).blur()
+ await page.getByLabel('Name for color 1').fill('Primary action')
+ await page.getByLabel('Name for color 1').blur()
+ await page.getByLabel('Name for color 2').fill('Primary action hover')
+ await page.getByLabel('Name for color 2').blur()
+ await page.getByLabel('Name for color 3').fill('Canvas')
+ await page.getByLabel('Name for color 3').blur()
+
+ await page.getByRole('button', { name: 'Analyze palette' }).click()
+ await page.getByRole('tab', { name: 'Contrast' }).click()
+ await selectRoleColor(page, 'Page background', 'Canvas')
+ await selectRoleColor(page, 'Primary text', 'Primary action hover')
+ await selectRoleColor(page, 'Border', 'Primary action ·')
+ await expect(
+ page.getByLabel('Primary text').locator('option:checked'),
+ ).toContainText('Primary action hover')
+ await expect(page.getByText(/AA normal text \(4.5:1\): Pass/)).toBeVisible()
+
+ await page
+ .getByRole('navigation', { name: 'Primary' })
+ .getByRole('button', { name: 'Create' })
+ .click()
+ await page
+ .getByRole('button', { name: /Actions for Primary action hover/ })
+ .click()
+ await page.getByRole('menuitem', { name: 'Move up' }).click()
+ await page.getByLabel('Name for color 1').fill('Hover updated')
+ await page.getByLabel('Name for color 1').blur()
+ const edited = page.getByRole('textbox', { name: 'Color 1', exact: true })
+ await edited.fill('#000000')
+ await edited.blur()
+ await page.getByRole('button', { name: 'Analyze palette' }).click()
+ await page.getByRole('tab', { name: 'Contrast' }).click()
+ await expect(
+ page.getByLabel('Primary text').locator('option:checked'),
+ ).toContainText('Hover updated · #000000 · Color 1')
+ await expect(
+ page.getByLabel('Page background').locator('option:checked'),
+ ).toContainText('Canvas · #F5F0E8 · Color 3')
+ await expect(
+ page.getByLabel('Border').locator('option:checked'),
+ ).toContainText('Primary action · #141D29 · Color 2')
+
+ 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' }).click()
+ await expect(page.getByText('Saved', { exact: true })).toBeVisible()
+
+ await page
+ .getByRole('navigation', { name: 'Primary' })
+ .getByRole('button', { name: 'Export' })
+ .click()
+ const preview = page.getByLabel(/Generated CSS custom properties preview/)
+ await expect(preview).toContainText('--role-page-background:')
+ await expect(preview).toContainText('--role-primary-text:')
+ await page.getByRole('tab', { name: 'Tailwind theme colors' }).click()
+ await expect(
+ page.getByLabel(/Generated Tailwind theme colors preview/),
+ ).toContainText("'role-primary-text': '#000000'")
+ await page.getByRole('tab', { name: 'SVG swatch sheet' }).click()
+ await expect(
+ page.getByLabel(/Generated SVG swatch sheet preview/),
+ ).toContainText('Primary text')
+
+ 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 page
+ .locator('input[accept=".json,application/json"]')
+ .setInputFiles(downloadPath!)
+ await page.getByRole('button', { name: 'Analyze palette' }).click()
+ await page.getByRole('tab', { name: 'Contrast' }).click()
+ await expect(
+ page.getByLabel('Primary text').locator('option:checked'),
+ ).toContainText('Hover updated')
+ await expect(
+ page.getByLabel('Page background').locator('option:checked'),
+ ).toContainText('Canvas')
+
+ const results = await new AxeBuilder({ page }).analyze()
+ expect(
+ results.violations.filter((violation) =>
+ ['serious', 'critical'].includes(violation.impact ?? ''),
+ ),
+ ).toEqual([])
+})
+
test('empty, import error, and imported organization states pass axe', async ({
page,
}) => {
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 24e383f..5c2f19d 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -32,7 +32,7 @@ function portableFile(name = 'Imported palette') {
hsl: { h: 240, s: 100, l: 50 },
},
],
- { primaryText: red.hex },
+ { primaryText: red.id },
)
const file = new File([text], 'palette.json', {
type: 'application/json',
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a60bc11..0ac6c04 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -810,10 +810,10 @@ function App() {
roles={roles}
onSelectTab={navigateReview}
onAnalyze={() => void handleAnalyze()}
- onAssignRole={(role: PaletteRole, hex) => {
+ onAssignRole={(role: PaletteRole, colorId) => {
setRoles((current) => {
const next = { ...current }
- if (hex) next[role] = hex
+ if (colorId) next[role] = colorId
else delete next[role]
return next
})
diff --git a/frontend/src/components/ExportWorkspace.test.tsx b/frontend/src/components/ExportWorkspace.test.tsx
index d2fe457..909ef93 100644
--- a/frontend/src/components/ExportWorkspace.test.tsx
+++ b/frontend/src/components/ExportWorkspace.test.tsx
@@ -79,4 +79,21 @@ describe('ExportWorkspace actions', () => {
).toBeInTheDocument()
click.mockRestore()
})
+
+ it('describes and previews portable JSON schema version 3', () => {
+ render(
+
+ Portable ColorCraft JSON schema version 3 uses document-local color + keys for role assignments and excludes internal workspace IDs. +
+ )} {status && (