Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions docs/api-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
22 changes: 16 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -97,15 +98,17 @@ 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

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

Expand All @@ -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).
Expand Down
Binary file modified docs/assets/screenshots/create-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 13 additions & 4 deletions docs/persistence-and-privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions docs/screenshot-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 11 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions docs/writing-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
8 changes: 7 additions & 1 deletion frontend/e2e/screenshot-review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
121 changes: 120 additions & 1 deletion frontend/e2e/workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
})
Loading
Loading