From ed030cada68c4343f93d5da2a355b9cef9188d0c Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 2 Sep 2026 07:11:11 +0200 Subject: [PATCH] Release Colour Tools 0.2.0 --- .gitea/workflows/verify.yml | 39 ++ CHANGELOG.md | 6 +- README.md | 16 +- SOURCE.md | 6 +- THIRD_PARTY_NOTICES.md | 4 +- docs/ARCHITECTURE.md | 19 +- docs/PRIVACY-SECURITY.md | 6 + package-lock.json | 32 +- package.json | 8 +- playwright.config.ts | 22 +- public/CHANGELOG.md | 6 +- public/LICENSES/npm-runtime-licenses.txt | 4 +- public/README.md | 16 +- public/SOURCE.md | 6 +- public/THIRD_PARTY_NOTICES.md | 4 +- public/docs/ARCHITECTURE.md | 19 +- public/docs/PRIVACY-SECURITY.md | 6 + public/sw.js | 2 +- public/toolbox-app.json | 23 +- src/colour/accessibility.ts | 28 ++ src/colour/dtcg.ts | 461 +++++++++++++++++++++++ src/colour/gradient.ts | 108 ++++++ src/colour/index.ts | 9 + src/colour/palette.ts | 48 ++- src/colour/types.ts | 15 + src/components/ImagePickerLab.tsx | 1 + src/components/PaletteLab.tsx | 173 ++++++++- src/components/StepsLab.tsx | 116 +++++- src/styles.css | 48 +++ src/toolbox/manifest.source.json | 44 ++- src/version.ts | 2 +- tests/browser/colour-tools.spec.ts | 38 +- tests/browser/responsive.spec.ts | 18 + tests/colour/compare-palette.test.ts | 167 +++++++- 34 files changed, 1446 insertions(+), 74 deletions(-) create mode 100644 .gitea/workflows/verify.yml create mode 100644 src/colour/dtcg.ts create mode 100644 src/colour/gradient.ts create mode 100644 tests/browser/responsive.spec.ts diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..b84a7cb --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -0,0 +1,39 @@ +name: Verify + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: verify-${{ gitea.repository }}-${{ gitea.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CI: "true" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - name: Select declared npm version + run: npm install --global npm@11.17.0 + - name: Install dependencies + run: npm ci + - name: Audit runtime dependencies + run: npm audit --omit=dev --audit-level=moderate + - name: Check, test, and build + run: npm run check + - name: Install browser engines + run: npx playwright install --with-deps chromium firefox webkit + - name: Browser tests + run: npm run test:browser diff --git a/CHANGELOG.md b/CHANGELOG.md index c5782f7..254cff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,11 @@ All notable changes are documented here. -## Unreleased +## 0.2.0 - 2026-09-02 + +- Added DTCG 2025.10 structured colour import/export with bounded local reference resolution and diagnostics. +- Added a bounded directional palette contrast matrix. +- Added linear, radial, conic and repeating gradient preview plus CSS and versioned recipe export. ## 0.1.0 - 2026-08-31 diff --git a/README.md b/README.md index fbf24c5..78ce37c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ has no analytics, accounts, advertisements or remote lookup feature. - **Steps** builds multi-stop colour ramps in perceptual, RGB or cylindrical colour spaces. It supports positioned stops, five hue routes, easing, premultiplied-alpha interpolation by default with a straight-alpha option, - and copy-ready CSS and JSON output. + and a linear/radial/conic/repeating gradient editor with copy/download CSS + and a versioned JSON recipe. - **Pick** provides a large keyboard- and pointer-operable HSV picker, alpha control, exact CSS entry, browser-native colour input, recent picks and the system EyeDropper API where the browser exposes it. @@ -38,7 +39,9 @@ has no analytics, accounts, advertisements or remote lookup feature. colour-vision deficiencies. - **Palette** creates OKLCH harmonies, tints, shades and tones; keeps a named palette in local browser storage; imports common text/JSON token forms; and - exports CSS, SCSS, JSON, Design Tokens JSON, JavaScript or CSV. + imports/exports DTCG 2025.10 structured colour tokens and local aliases; + displays a bounded directional WCAG contrast matrix; and exports CSS, SCSS, + JSON, Design Tokens JSON, JavaScript or CSV. Adding a generated or sampled colour to the palette is always explicit. The saved palette is device-local and can be cleared from the Palette workspace or @@ -58,6 +61,13 @@ large text, 4.5:1 for normal AA text and 7:1 for normal AAA text. They are an engineering aid, not a complete accessibility audit; typography, state, context and non-colour cues still need human review. +DTCG import requires an explicit, inherited or reference-resolved `$type` and +does not guess token types. Supported colour-space components are validated +against the 2025.10 ranges; `none` components are reported as unsupported +because the current internal colour model is numeric. Imports are bounded to +2 MiB, 10,000 tokens and 32 group levels. Exact duplicate saved values are +reported and not added twice. + ## Local image handling Selecting an image decodes a bounded raster locally with `createImageBitmap` @@ -117,7 +127,7 @@ npm run release:artifact This command checks the manifest, types, lint, formatting, unit tests, production build, Toolbox contract and browser workflows, then creates a -deterministic `release/colour-tools-0.1.0.zip` and matching SHA-256 sidecar. +deterministic `release/colour-tools-0.2.0.zip` and matching SHA-256 sidecar. The archive is a ready-to-host static application and contains project and third-party licence notices. diff --git a/SOURCE.md b/SOURCE.md index 7b05887..086229f 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source and provenance -The corresponding source for Colour Tools 0.1.0 is: +The corresponding source for Colour Tools 0.2.0 is: -https://git.add-ideas.de/lotobo/colour-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/colour-tools/src/tag/v0.2.0 Build that tag with Node.js 22 and the exact dependency graph recorded in `package-lock.json`: @@ -20,7 +20,7 @@ and the applicable third-party notices. ## Implementation provenance - The application is original project code and uses the shared Toolbox SDK - packages at version 0.2.3 for its manifest contract, shell, and build checks. + packages at version 0.3.0 for its manifest contract, shell, and build checks. - Colour conversion and adaptation use Color.js 0.7.1. Its MIT licence is reproduced in `LICENSES/colorjs.io-MIT.txt`. - The compositing, interpolation, palette, image-sampling, contrast, comparison, diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2cfadf9..e687a1c 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,8 +5,8 @@ packages into `LICENSES/npm-runtime-licenses.txt` inside the static artifact. | Package | Version | Licence | Role | Source | | -------------------------------- | ------- | ---------- | ------------------------------------ | ------------------------------------------- | -| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | manifest and context contract | https://git.add-ideas.de/lotobo/toolbox-sdk | -| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | shared shell, theme and actions | https://git.add-ideas.de/lotobo/toolbox-sdk | +| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 | manifest and context contract | https://git.add-ideas.de/lotobo/toolbox-sdk | +| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | shared shell, theme and actions | https://git.add-ideas.de/lotobo/toolbox-sdk | | `colorjs.io` | 0.7.1 | MIT | parsing, conversion and colour maths | https://github.com/color-js/color.js | | `react` | 19.2.8 | MIT | application UI | https://github.com/facebook/react | | `react-dom` | 19.2.8 | MIT | browser rendering | https://github.com/facebook/react | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dc86eed..702905c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -9,8 +9,9 @@ last valid result while an input is incomplete. 1. `src/colour/` is the serialisable colour domain. It parses and formats colours, converts gamuts, composites layers, interpolates stops, evaluates - contrast and Delta E, simulates colour-vision deficiencies, and builds or - exports palettes. Its public API is re-exported from `src/colour/index.ts`. + contrast and Delta E, simulates colour-vision deficiencies, builds CSS + gradients, imports DTCG tokens, and builds or exports palettes. Its public + API is re-exported from `src/colour/index.ts`. 2. `src/palette/` contains browser-independent raster bounds, sampling and deterministic OKLab clustering. `src/workers/palette.worker.ts` exposes the expensive extraction path to a dedicated worker. @@ -35,6 +36,20 @@ Browser preview is a separate operation. It maps a value to sRGB and reports whether mapping occurred. This keeps display limitations from mutating the source colour used by later conversions or comparisons. +`dtcg.ts` walks at most 32 group levels/10,000 tokens, validates DTCG 2025.10 +structured colour components, resolves local curly aliases and local JSON +Pointer `$ref` token/property references with cycle detection, and resolves token type from +the token, its nearest group or its reference. It does not infer a missing +type. Numeric `none` semantics cannot be represented by `ColourValue`, so such +tokens produce an explicit diagnostic. Imported wide-gamut coordinates are +stored as CSS rather than being destructively mapped to the preview gamut. + +`accessibility.ts` parses a maximum of 64 palette colours once before its +bounded 64×64 directional contrast loop. Each foreground/background cell +reports WCAG ratio thresholds after flattening transparency against a stated +canvas. `gradient.ts` validates 2–64 ascending stops and exports inert CSS plus +a schema-versioned recipe for linear, radial, conic and repeating syntax. + ## Raster pipeline The image picker validates the selected file before decode, rejects excessive diff --git a/docs/PRIVACY-SECURITY.md b/docs/PRIVACY-SECURITY.md index 0dffc56..d33eeab 100644 --- a/docs/PRIVACY-SECURITY.md +++ b/docs/PRIVACY-SECURITY.md @@ -14,6 +14,12 @@ icons and worker files on initial load and revalidation. Host logs, reverse proxy logs and browser extension behaviour are outside the application's local processing boundary. +DTCG documents and gradient recipes are parsed/generated in memory only. DTCG +references are restricted to the same pasted JSON document; no URI or remote +token resolver is used. Token/group depth, count and source size are bounded, +and cycles stop with diagnostics. Imported names/values and gradient CSS are +rendered as text or style values, never injected as HTML. + ## Recommended headers Serve the static artifact over HTTPS and apply a policy equivalent to: diff --git a/package-lock.json b/package-lock.json index 709de6b..ceda47e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,22 @@ { "name": "colour-tools", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "colour-tools", - "version": "0.1.0", + "version": "0.2.0", "license": "GPL-3.0-or-later", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "colorjs.io": "0.7.1", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", @@ -43,18 +43,18 @@ } }, "node_modules/@add-ideas/toolbox-contract": { - "version": "0.2.3", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz", - "integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz", + "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==", "license": "Apache-2.0" }, "node_modules/@add-ideas/toolbox-shell-react": { - "version": "0.2.3", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz", - "integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz", + "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "peerDependencies": { "react": ">=18 <20", @@ -62,13 +62,13 @@ } }, "node_modules/@add-ideas/toolbox-testkit": { - "version": "0.2.3", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz", - "integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz", + "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "bin": { "toolbox-check": "dist/cli.js" diff --git a/package.json b/package.json index 6aea290..8670e18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "colour-tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Convert, composite, compare and build colours locally in the browser.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", @@ -39,14 +39,14 @@ "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" }, "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "colorjs.io": "0.7.1", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts index 2c0c008..c782f09 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,7 +18,25 @@ export default defineConfig({ timeout: 180_000, }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { + name: "chromium", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Safari"] }, + }, + { + name: "mobile-chromium", + testMatch: /responsive\.spec\.ts/, + use: { ...devices["Pixel 5"] }, + }, ], }); diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index c5782f7..254cff4 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -2,7 +2,11 @@ All notable changes are documented here. -## Unreleased +## 0.2.0 - 2026-09-02 + +- Added DTCG 2025.10 structured colour import/export with bounded local reference resolution and diagnostics. +- Added a bounded directional palette contrast matrix. +- Added linear, radial, conic and repeating gradient preview plus CSS and versioned recipe export. ## 0.1.0 - 2026-08-31 diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt index 0914ee8..7e41a06 100644 --- a/public/LICENSES/npm-runtime-licenses.txt +++ b/public/LICENSES/npm-runtime-licenses.txt @@ -1,5 +1,5 @@ ============================================================================== -@add-ideas/toolbox-contract@0.2.3 +@add-ideas/toolbox-contract@0.3.0 Declared licence: Apache-2.0 Installed from: node_modules/@add-ideas/toolbox-contract ============================================================================== @@ -199,7 +199,7 @@ Installed from: node_modules/@add-ideas/toolbox-contract ============================================================================== -@add-ideas/toolbox-shell-react@0.2.3 +@add-ideas/toolbox-shell-react@0.3.0 Declared licence: Apache-2.0 Installed from: node_modules/@add-ideas/toolbox-shell-react ============================================================================== diff --git a/public/README.md b/public/README.md index fbf24c5..78ce37c 100644 --- a/public/README.md +++ b/public/README.md @@ -24,7 +24,8 @@ has no analytics, accounts, advertisements or remote lookup feature. - **Steps** builds multi-stop colour ramps in perceptual, RGB or cylindrical colour spaces. It supports positioned stops, five hue routes, easing, premultiplied-alpha interpolation by default with a straight-alpha option, - and copy-ready CSS and JSON output. + and a linear/radial/conic/repeating gradient editor with copy/download CSS + and a versioned JSON recipe. - **Pick** provides a large keyboard- and pointer-operable HSV picker, alpha control, exact CSS entry, browser-native colour input, recent picks and the system EyeDropper API where the browser exposes it. @@ -38,7 +39,9 @@ has no analytics, accounts, advertisements or remote lookup feature. colour-vision deficiencies. - **Palette** creates OKLCH harmonies, tints, shades and tones; keeps a named palette in local browser storage; imports common text/JSON token forms; and - exports CSS, SCSS, JSON, Design Tokens JSON, JavaScript or CSV. + imports/exports DTCG 2025.10 structured colour tokens and local aliases; + displays a bounded directional WCAG contrast matrix; and exports CSS, SCSS, + JSON, Design Tokens JSON, JavaScript or CSV. Adding a generated or sampled colour to the palette is always explicit. The saved palette is device-local and can be cleared from the Palette workspace or @@ -58,6 +61,13 @@ large text, 4.5:1 for normal AA text and 7:1 for normal AAA text. They are an engineering aid, not a complete accessibility audit; typography, state, context and non-colour cues still need human review. +DTCG import requires an explicit, inherited or reference-resolved `$type` and +does not guess token types. Supported colour-space components are validated +against the 2025.10 ranges; `none` components are reported as unsupported +because the current internal colour model is numeric. Imports are bounded to +2 MiB, 10,000 tokens and 32 group levels. Exact duplicate saved values are +reported and not added twice. + ## Local image handling Selecting an image decodes a bounded raster locally with `createImageBitmap` @@ -117,7 +127,7 @@ npm run release:artifact This command checks the manifest, types, lint, formatting, unit tests, production build, Toolbox contract and browser workflows, then creates a -deterministic `release/colour-tools-0.1.0.zip` and matching SHA-256 sidecar. +deterministic `release/colour-tools-0.2.0.zip` and matching SHA-256 sidecar. The archive is a ready-to-host static application and contains project and third-party licence notices. diff --git a/public/SOURCE.md b/public/SOURCE.md index 7b05887..086229f 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source and provenance -The corresponding source for Colour Tools 0.1.0 is: +The corresponding source for Colour Tools 0.2.0 is: -https://git.add-ideas.de/lotobo/colour-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/colour-tools/src/tag/v0.2.0 Build that tag with Node.js 22 and the exact dependency graph recorded in `package-lock.json`: @@ -20,7 +20,7 @@ and the applicable third-party notices. ## Implementation provenance - The application is original project code and uses the shared Toolbox SDK - packages at version 0.2.3 for its manifest contract, shell, and build checks. + packages at version 0.3.0 for its manifest contract, shell, and build checks. - Colour conversion and adaptation use Color.js 0.7.1. Its MIT licence is reproduced in `LICENSES/colorjs.io-MIT.txt`. - The compositing, interpolation, palette, image-sampling, contrast, comparison, diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md index 2cfadf9..e687a1c 100644 --- a/public/THIRD_PARTY_NOTICES.md +++ b/public/THIRD_PARTY_NOTICES.md @@ -5,8 +5,8 @@ packages into `LICENSES/npm-runtime-licenses.txt` inside the static artifact. | Package | Version | Licence | Role | Source | | -------------------------------- | ------- | ---------- | ------------------------------------ | ------------------------------------------- | -| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | manifest and context contract | https://git.add-ideas.de/lotobo/toolbox-sdk | -| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | shared shell, theme and actions | https://git.add-ideas.de/lotobo/toolbox-sdk | +| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 | manifest and context contract | https://git.add-ideas.de/lotobo/toolbox-sdk | +| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | shared shell, theme and actions | https://git.add-ideas.de/lotobo/toolbox-sdk | | `colorjs.io` | 0.7.1 | MIT | parsing, conversion and colour maths | https://github.com/color-js/color.js | | `react` | 19.2.8 | MIT | application UI | https://github.com/facebook/react | | `react-dom` | 19.2.8 | MIT | browser rendering | https://github.com/facebook/react | diff --git a/public/docs/ARCHITECTURE.md b/public/docs/ARCHITECTURE.md index dc86eed..702905c 100644 --- a/public/docs/ARCHITECTURE.md +++ b/public/docs/ARCHITECTURE.md @@ -9,8 +9,9 @@ last valid result while an input is incomplete. 1. `src/colour/` is the serialisable colour domain. It parses and formats colours, converts gamuts, composites layers, interpolates stops, evaluates - contrast and Delta E, simulates colour-vision deficiencies, and builds or - exports palettes. Its public API is re-exported from `src/colour/index.ts`. + contrast and Delta E, simulates colour-vision deficiencies, builds CSS + gradients, imports DTCG tokens, and builds or exports palettes. Its public + API is re-exported from `src/colour/index.ts`. 2. `src/palette/` contains browser-independent raster bounds, sampling and deterministic OKLab clustering. `src/workers/palette.worker.ts` exposes the expensive extraction path to a dedicated worker. @@ -35,6 +36,20 @@ Browser preview is a separate operation. It maps a value to sRGB and reports whether mapping occurred. This keeps display limitations from mutating the source colour used by later conversions or comparisons. +`dtcg.ts` walks at most 32 group levels/10,000 tokens, validates DTCG 2025.10 +structured colour components, resolves local curly aliases and local JSON +Pointer `$ref` token/property references with cycle detection, and resolves token type from +the token, its nearest group or its reference. It does not infer a missing +type. Numeric `none` semantics cannot be represented by `ColourValue`, so such +tokens produce an explicit diagnostic. Imported wide-gamut coordinates are +stored as CSS rather than being destructively mapped to the preview gamut. + +`accessibility.ts` parses a maximum of 64 palette colours once before its +bounded 64×64 directional contrast loop. Each foreground/background cell +reports WCAG ratio thresholds after flattening transparency against a stated +canvas. `gradient.ts` validates 2–64 ascending stops and exports inert CSS plus +a schema-versioned recipe for linear, radial, conic and repeating syntax. + ## Raster pipeline The image picker validates the selected file before decode, rejects excessive diff --git a/public/docs/PRIVACY-SECURITY.md b/public/docs/PRIVACY-SECURITY.md index 0dffc56..d33eeab 100644 --- a/public/docs/PRIVACY-SECURITY.md +++ b/public/docs/PRIVACY-SECURITY.md @@ -14,6 +14,12 @@ icons and worker files on initial load and revalidation. Host logs, reverse proxy logs and browser extension behaviour are outside the application's local processing boundary. +DTCG documents and gradient recipes are parsed/generated in memory only. DTCG +references are restricted to the same pasted JSON document; no URI or remote +token resolver is used. Token/group depth, count and source size are bounded, +and cycles stop with diagnostics. Imported names/values and gradient CSS are +rendered as text or style values, never injected as HTML. + ## Recommended headers Serve the static artifact over HTTPS and apply a policy equivalent to: diff --git a/public/sw.js b/public/sw.js index 9cbf55d..30bdf4d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ const CACHE_PREFIX = "colour-tools-shell-"; -const CACHE_NAME = `${CACHE_PREFIX}0.1.0`; +const CACHE_NAME = `${CACHE_PREFIX}0.2.0`; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"]; self.addEventListener("install", (event) => { diff --git a/public/toolbox-app.json b/public/toolbox-app.json index da7363c..e1f6543 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.colour-tools", "name": "Colour Tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Convert, composite, compare and build colours locally in the browser.", "entry": "./", "icon": "./favicon.svg", @@ -26,14 +26,31 @@ }, "requirements": { "secureContext": false, - "workers": true, + "workers": false, "indexedDb": false, "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { + "mediaType": "image/*", + "extensions": [".png", ".jpg", ".jpeg", ".webp"] + }, + { "mediaType": "application/json", "extensions": [".json"] }, + { "mediaType": "text/plain", "extensions": [".txt", ".css", ".gpl"] } + ], + "produces": [ + { "mediaType": "application/json", "extensions": [".json"] }, + { "mediaType": "text/csv", "extensions": [".csv"] }, + { "mediaType": "text/css", "extensions": [".css"] }, + { "mediaType": "text/plain", "extensions": [".txt", ".gpl"] } + ] + }, + "capabilities": { "required": [], "optional": ["eye-dropper", "workers"] }, "privacy": { "processing": "local", - "fileUploads": false, + "fileUploads": true, "telemetry": false, "label": "Colours and images stay in this browser; nothing is uploaded." }, diff --git a/src/colour/accessibility.ts b/src/colour/accessibility.ts index 86b8a58..bc5952c 100644 --- a/src/colour/accessibility.ts +++ b/src/colour/accessibility.ts @@ -16,6 +16,7 @@ import type { ContrastOptions, ContrastReport, ContrastSuggestion, + PaletteContrastMatrix, } from "./types"; const WHITE: ColourValue = { space: "srgb", coords: [1, 1, 1], alpha: 1 }; @@ -92,6 +93,33 @@ export function contrastReport( }; } +export function paletteContrastMatrix( + colours: readonly ColourInput[], + options: ContrastOptions = {}, +): PaletteContrastMatrix { + if (colours.length < 1 || colours.length > 64) + throw new RangeError("Contrast matrices require 1–64 colours."); + // Parse once before the quadratic loop so invalid input fails atomically. + const parsed = colours.map(parseColour); + return { + size: parsed.length, + cells: parsed.map((foreground, foregroundIndex) => + parsed.map((background, backgroundIndex) => { + const ratio = contrastRatio(foreground, background, options); + return { + foregroundIndex, + backgroundIndex, + ratio, + aaNormal: ratio >= 4.5, + aaLarge: ratio >= 3, + aaaNormal: ratio >= 7, + nonText: ratio >= 3, + }; + }), + ), + }; +} + interface Candidate { colour: ColourValue; ratio: number; diff --git a/src/colour/dtcg.ts b/src/colour/dtcg.ts new file mode 100644 index 0000000..e05a0f3 --- /dev/null +++ b/src/colour/dtcg.ts @@ -0,0 +1,461 @@ +import { parseColour } from "./parse"; +import type { PaletteEntry } from "./types"; + +const MAX_SOURCE = 2 * 1024 * 1024; +const MAX_TOKENS = 10_000; +const MAX_DEPTH = 32; + +interface TokenRecord { + path: string[]; + object: Record; + inheritedType?: string; +} + +interface TokenIndex { + byPath: ReadonlyMap; + byObject: WeakMap; + byValue: WeakMap; +} + +export interface DtcgDiagnostic { + path: string; + severity: "warning" | "error"; + message: string; +} + +export interface DtcgImport { + entries: PaletteEntry[]; + diagnostics: DtcgDiagnostic[]; + skippedNonColourTokens: number; + schema?: string; +} + +function pathText(path: readonly string[]): string { + return path.join(".") || "(root)"; +} + +function collectTokens(root: Record): { + records: TokenRecord[]; + diagnostics: DtcgDiagnostic[]; +} { + const records: TokenRecord[] = []; + const diagnostics: DtcgDiagnostic[] = []; + const visit = ( + object: Record, + path: string[], + inheritedType: string | undefined, + depth: number, + ) => { + if (depth > MAX_DEPTH) + throw new Error(`DTCG group nesting exceeds ${MAX_DEPTH} levels.`); + const localType = + typeof object.$type === "string" ? object.$type : inheritedType; + if ("$value" in object || "$ref" in object) { + if (records.length >= MAX_TOKENS) + throw new Error( + `DTCG input exceeds ${MAX_TOKENS.toLocaleString()} tokens.`, + ); + records.push({ + path, + object, + ...(localType ? { inheritedType: localType } : {}), + }); + return; + } + for (const [name, value] of Object.entries(object)) { + if (name === "$root") { + if (value && typeof value === "object" && !Array.isArray(value)) + visit(value as Record, path, localType, depth + 1); + else + diagnostics.push({ + path: pathText(path), + severity: "error", + message: "$root must contain a token object.", + }); + continue; + } + if (name.startsWith("$")) continue; + const nextPath = [...path, name]; + if (/[$.{}]/u.test(name) || name.length > 200) + diagnostics.push({ + path: pathText(nextPath), + severity: "error", + message: + "Token/group names must be at most 200 characters and cannot contain $, period, or braces.", + }); + if (/[$.{}]/u.test(name) || name.length > 200) continue; + if (!value || typeof value !== "object" || Array.isArray(value)) { + diagnostics.push({ + path: pathText(nextPath), + severity: "warning", + message: "Non-object group member was skipped.", + }); + continue; + } + visit(value as Record, nextPath, localType, depth + 1); + } + }; + visit(root, [], undefined, 0); + return { records, diagnostics }; +} + +function jsonPointer(root: unknown, pointer: string): unknown { + // DTCG 2025.10 specifies #/ as its document-root spelling. Accept the RFC + // 6901 empty fragment as well for interoperable local documents. + if (pointer === "#" || pointer === "#/") return root; + if (!pointer.startsWith("#/")) + throw new Error( + "Only local JSON Pointer references beginning #/ are supported.", + ); + let value = root; + for (const raw of pointer.slice(2).split("/")) { + let decoded: string; + try { + decoded = decodeURIComponent(raw); + } catch { + throw new Error(`JSON Pointer has invalid percent encoding: ${pointer}`); + } + if (/~(?:[^01]|$)/u.test(decoded)) + throw new Error(`JSON Pointer has invalid ~ escaping: ${pointer}`); + const key = decoded.replaceAll("~1", "/").replaceAll("~0", "~"); + if (!value || typeof value !== "object" || !Object.hasOwn(value, key)) + throw new Error(`JSON Pointer does not resolve: ${pointer}`); + value = (value as Record)[key]; + } + return value; +} + +function resolveEmbeddedReferences( + value: unknown, + root: Record, + index: TokenIndex, + visiting: Set, + depth: number, +): unknown { + if (depth > 64) + throw new Error("DTCG value/reference nesting exceeds 64 levels."); + if (Array.isArray(value)) + return value.map((item) => + resolveEmbeddedReferences(item, root, index, visiting, depth + 1), + ); + if (!value || typeof value !== "object") return value; + const object = value as Record; + if ("$ref" in object) { + if (typeof object.$ref !== "string") + throw new Error("$ref must be a JSON Pointer string."); + if (Object.keys(object).length !== 1) + throw new Error( + "A property-level $ref object cannot have sibling fields.", + ); + const key = `pointer:${object.$ref}`; + if (visiting.has(key)) throw new Error("JSON Pointer cycle detected."); + visiting.add(key); + try { + const resolved = jsonPointer(root, object.$ref); + if ( + resolved && + typeof resolved === "object" && + !Array.isArray(resolved) && + ("$value" in resolved || "$ref" in resolved) + ) { + const target = index.byObject.get(resolved); + if (target) return resolveValue(target, root, index, visiting); + } + return resolveEmbeddedReferences( + resolved, + root, + index, + visiting, + depth + 1, + ); + } finally { + visiting.delete(key); + } + } + return Object.fromEntries( + Object.entries(object).map(([name, item]) => [ + name, + resolveEmbeddedReferences(item, root, index, visiting, depth + 1), + ]), + ); +} + +function tokenPathReference(value: string, index: TokenIndex): TokenRecord { + const match = /^\{([^{}]+)\}$/u.exec(value); + if (!match) + throw new Error("Token reference must use a complete {group.token} path."); + const found = index.byPath.get(match[1]!); + if (!found) throw new Error(`Token reference does not resolve: ${value}`); + return found; +} + +function resolveValue( + record: TokenRecord, + root: Record, + index: TokenIndex, + visiting: Set, +): unknown { + const key = pathText(record.path); + if (visiting.has(key)) throw new Error("Token reference cycle detected."); + visiting.add(key); + try { + if ("$ref" in record.object) { + if (typeof record.object.$ref !== "string") + throw new Error("$ref must be a JSON Pointer string."); + const resolved = jsonPointer(root, record.object.$ref); + if (resolved && typeof resolved === "object") { + const target = index.byObject.get(resolved); + if (target) return resolveValue(target, root, index, visiting); + } + return resolveEmbeddedReferences(resolved, root, index, visiting, 0); + } + const value = record.object.$value; + if (typeof value === "string" && /^\{[^{}]+\}$/u.test(value)) + return resolveValue( + tokenPathReference(value, index), + root, + index, + visiting, + ); + return resolveEmbeddedReferences(value, root, index, visiting, 0); + } finally { + visiting.delete(key); + } +} + +function referencedRecord( + record: TokenRecord, + root: Record, + index: TokenIndex, +): TokenRecord | undefined { + if ("$ref" in record.object) { + if (typeof record.object.$ref !== "string") + throw new Error("$ref must be a JSON Pointer string."); + const resolved = jsonPointer(root, record.object.$ref); + if (resolved && typeof resolved === "object") + return index.byObject.get(resolved) ?? index.byValue.get(resolved); + return undefined; + } + const value = record.object.$value; + return typeof value === "string" && /^\{[^{}]+\}$/u.test(value) + ? tokenPathReference(value, index) + : undefined; +} + +function resolveType( + record: TokenRecord, + root: Record, + index: TokenIndex, + visiting: Set, +): string | undefined { + const direct = + typeof record.object.$type === "string" + ? record.object.$type + : record.inheritedType; + if (direct) return direct; + const key = pathText(record.path); + if (visiting.has(key)) throw new Error("Token reference cycle detected."); + visiting.add(key); + try { + const target = referencedRecord(record, root, index); + return target ? resolveType(target, root, index, visiting) : undefined; + } finally { + visiting.delete(key); + } +} + +function componentArray(value: unknown): number[] { + if ( + !Array.isArray(value) || + value.length !== 3 || + value.some( + (component) => + typeof component !== "number" || !Number.isFinite(component), + ) + ) + throw new Error( + "Color components must be an array of three finite numbers; 'none' components are not supported.", + ); + return value as number[]; +} + +function inRange(value: number, minimum: number, maximum: number): boolean { + return value >= minimum && value <= maximum; +} + +function validateComponents(space: string, components: readonly number[]) { + const [first, second, third] = components as [number, number, number]; + const rgbLike = new Set([ + "srgb", + "srgb-linear", + "display-p3", + "a98-rgb", + "prophoto-rgb", + "rec2020", + "xyz-d50", + "xyz-d65", + ]); + if (rgbLike.has(space)) { + if (!components.every((component) => inRange(component, 0, 1))) + throw new Error(`${space} components must each be from 0 to 1.`); + return; + } + if (space === "hsl" || space === "hwb") { + if ( + !inRange(first, 0, 360) || + first === 360 || + !inRange(second, 0, 100) || + !inRange(third, 0, 100) + ) + throw new Error( + `${space.toUpperCase()} requires hue 0–<360 and percentage components 0–100.`, + ); + return; + } + if (space === "lab") { + if (!inRange(first, 0, 100)) + throw new Error("Lab lightness must be from 0 to 100."); + return; + } + if (space === "lch") { + if ( + !inRange(first, 0, 100) || + second < 0 || + !inRange(third, 0, 360) || + third === 360 + ) + throw new Error( + "LCH requires lightness 0–100, non-negative chroma and hue 0–<360.", + ); + return; + } + if (space === "oklab") { + if (!inRange(first, 0, 1)) + throw new Error("OKLab lightness must be from 0 to 1."); + return; + } + if (space === "oklch") { + if ( + !inRange(first, 0, 1) || + second < 0 || + !inRange(third, 0, 360) || + third === 360 + ) + throw new Error( + "OKLCH requires lightness 0–1, non-negative chroma and hue 0–<360.", + ); + return; + } + throw new Error(`Unsupported DTCG colorSpace: ${space}.`); +} + +function dtcgColour(value: unknown): PaletteEntry["colour"] { + if (typeof value === "string") return parseColour(value); + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("Color $value must be a DTCG color object."); + const object = value as Record; + if (typeof object.colorSpace !== "string") + throw new Error("Color $value requires a colorSpace string."); + if ( + object.hex !== undefined && + (typeof object.hex !== "string" || !/^#[0-9a-f]{6}$/iu.test(object.hex)) + ) + throw new Error("Optional color hex fallback must use exactly #RRGGBB."); + const components = componentArray(object.components); + validateComponents(object.colorSpace, components); + const alpha = object.alpha === undefined ? 1 : object.alpha; + if ( + typeof alpha !== "number" || + !Number.isFinite(alpha) || + alpha < 0 || + alpha > 1 + ) + throw new Error("Color alpha must be a finite number from 0 to 1."); + const slash = alpha < 1 ? ` / ${alpha}` : ""; + const [first, second, third] = components; + const css = + object.colorSpace === "hsl" + ? `hsl(${first} ${second}% ${third}%${slash})` + : object.colorSpace === "hwb" + ? `hwb(${first} ${second}% ${third}%${slash})` + : ["lab", "lch"].includes(object.colorSpace) + ? `${object.colorSpace}(${first}% ${second} ${third}${slash})` + : ["oklab", "oklch"].includes(object.colorSpace) + ? `${object.colorSpace}(${first} ${second} ${third}${slash})` + : `color(${object.colorSpace} ${components.join(" ")}${slash})`; + return parseColour(css); +} + +export function parseDtcgTokens(source: string): DtcgImport { + if (source.length > MAX_SOURCE) + throw new Error("DTCG input exceeds the 2 MiB limit."); + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + throw new Error("DTCG input is not valid JSON."); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + throw new Error("DTCG document must be a JSON object."); + const root = parsed as Record; + const inventory = collectTokens(root); + const byPath = new Map( + inventory.records.map((record) => [pathText(record.path), record]), + ); + const byObject = new WeakMap(); + const byValue = new WeakMap(); + for (const record of inventory.records) { + byObject.set(record.object, record); + const value = record.object.$value; + if (value && typeof value === "object") byValue.set(value, record); + } + const index: TokenIndex = { byPath, byObject, byValue }; + const diagnostics = [...inventory.diagnostics]; + const entries: PaletteEntry[] = []; + let skippedNonColourTokens = 0; + for (const record of inventory.records) { + const path = pathText(record.path); + try { + const type = resolveType(record, root, index, new Set()); + if (type !== "color") { + skippedNonColourTokens += 1; + if (!type) + diagnostics.push({ + path, + severity: "error", + message: + "Token has no explicit, inherited, or reference-resolved $type; type guessing is not allowed.", + }); + continue; + } + const raw = resolveValue(record, root, index, new Set()); + if (typeof raw === "string" && !/^\{[^{}]+\}$/u.test(raw)) + diagnostics.push({ + path, + severity: "warning", + message: + "Legacy string color value was accepted; DTCG 2025.10 uses a structured color object.", + }); + entries.push({ + name: record.path.length ? record.path.join("-") : "root", + colour: dtcgColour(raw), + source: JSON.stringify(raw), + }); + } catch (error) { + diagnostics.push({ + path, + severity: "error", + message: + error instanceof Error + ? error.message + : "Color token could not be resolved.", + }); + } + } + return { + entries, + diagnostics, + skippedNonColourTokens, + ...(typeof root.$schema === "string" ? { schema: root.$schema } : {}), + }; +} diff --git a/src/colour/gradient.ts b/src/colour/gradient.ts new file mode 100644 index 0000000..07f1bdb --- /dev/null +++ b/src/colour/gradient.ts @@ -0,0 +1,108 @@ +import { toSrgbCss } from "./format"; +import type { ColourInput } from "./types"; + +export type GradientKind = "linear" | "radial" | "conic"; + +export interface GradientStop { + colour: ColourInput; + position: number; +} + +export interface GradientOptions { + kind?: GradientKind; + angle?: number; + repeating?: boolean; + radialShape?: "circle" | "ellipse"; +} + +function normalizedStops(stops: readonly GradientStop[]): GradientStop[] { + if (stops.length < 2 || stops.length > 64) + throw new Error("A gradient requires 2–64 stops."); + let previous = -Infinity; + return stops.map((stop, index) => { + if ( + !Number.isFinite(stop.position) || + stop.position < 0 || + stop.position > 1 + ) + throw new Error( + `Gradient stop ${index + 1} position must be from 0 to 1.`, + ); + if (stop.position < previous) + throw new Error("Gradient stop positions must be in ascending order."); + previous = stop.position; + return { colour: stop.colour, position: stop.position }; + }); +} + +export function buildCssGradient( + input: readonly GradientStop[], + options: GradientOptions = {}, +): string { + const stops = normalizedStops(input); + const kind = options.kind ?? "linear"; + if (!["linear", "radial", "conic"].includes(kind)) + throw new Error("Gradient kind must be linear, radial, or conic."); + if ( + options.radialShape !== undefined && + !["circle", "ellipse"].includes(options.radialShape) + ) + throw new Error("Radial shape must be circle or ellipse."); + if (options.repeating !== undefined && typeof options.repeating !== "boolean") + throw new Error("Gradient repeating must be true or false."); + const angle = options.angle ?? 90; + if (!Number.isFinite(angle) || Math.abs(angle) > 100_000) + throw new Error( + "Gradient angle must be a finite value within ±100,000 degrees.", + ); + const prefix = options.repeating ? "repeating-" : ""; + const lead = + kind === "linear" + ? `${angle}deg` + : kind === "conic" + ? `from ${angle}deg at center` + : `${options.radialShape ?? "ellipse"} at center`; + return `${prefix}${kind}-gradient(${lead}, ${stops + .map( + (stop) => + `${toSrgbCss(stop.colour)} ${Number((stop.position * 100).toFixed(4))}%`, + ) + .join(", ")})`; +} + +export function exportGradientRecipe( + input: readonly GradientStop[], + options: GradientOptions = {}, +): string { + const stops = normalizedStops(input); + const normalizedOptions = { + kind: options.kind ?? "linear", + angle: options.angle ?? 90, + repeating: options.repeating ?? false, + radialShape: options.radialShape ?? "ellipse", + }; + return JSON.stringify( + { + schemaVersion: 1, + generatedBy: "add-ideas Colour Tools", + type: "css-gradient", + options: normalizedOptions, + stops: stops.map((stop) => ({ + position: stop.position, + color: toSrgbCss(stop.colour), + })), + css: buildCssGradient(stops, normalizedOptions), + }, + null, + 2, + ); +} + +export function gradientCssDocument(gradient: string): string { + if ( + gradient.length > 64 * 1024 || + !/^(?:repeating-)?(?:linear|radial|conic)-gradient\(/u.test(gradient) + ) + throw new Error("Gradient CSS is invalid or exceeds 64 KiB."); + return `.gradient {\n background-image: ${gradient};\n}\n`; +} diff --git a/src/colour/index.ts b/src/colour/index.ts index c121c8b..11a0d02 100644 --- a/src/colour/index.ts +++ b/src/colour/index.ts @@ -14,12 +14,19 @@ export { } from "./format"; export { DEFAULT_GAMUT_SPACES, gamutReport, mapToGamut } from "./gamut"; export { compositeLayers, compositeSourceOver } from "./composite"; +export { + buildCssGradient, + exportGradientRecipe, + gradientCssDocument, +} from "./gradient"; +export type { GradientKind, GradientOptions, GradientStop } from "./gradient"; export { interpolateColourStops, interpolateStops } from "./interpolate"; export { contrastRatio, contrastReport, flattenColour, nearestPassingColour, + paletteContrastMatrix, relativeLuminance, } from "./accessibility"; export { @@ -44,5 +51,7 @@ export { tints, tones, } from "./palette"; +export { parseDtcgTokens } from "./dtcg"; +export type { DtcgDiagnostic, DtcgImport } from "./dtcg"; export type * from "./types"; diff --git a/src/colour/palette.ts b/src/colour/palette.ts index 0f8957c..c14987e 100644 --- a/src/colour/palette.ts +++ b/src/colour/palette.ts @@ -1,4 +1,4 @@ -import { asColor, clamp, colorFrom, toColourValue } from "./internal"; +import { asColor, clamp, colorFrom, round, toColourValue } from "./internal"; import { formatColour } from "./format"; import { mapToGamut } from "./gamut"; import { interpolateColourStops } from "./interpolate"; @@ -271,13 +271,53 @@ export function exportDesignTokens( entries: readonly PaletteEntry[], options: PaletteExportOptions = {}, ): string { - const value = Object.fromEntries( + const supported: Record = { + srgb: "srgb", + "srgb-linear": "srgb-linear", + p3: "display-p3", + a98rgb: "a98-rgb", + prophoto: "prophoto-rgb", + rec2020: "rec2020", + "xyz-d50": "xyz-d50", + "xyz-d65": "xyz-d65", + lab: "lab", + lch: "lch", + oklab: "oklab", + oklch: "oklch", + hsl: "hsl", + hwb: "hwb", + }; + const tokens = Object.fromEntries( uniqueNames(entries).map(({ name, entry }) => [ name, - { $type: "color", $value: outputValue(entry, options.format ?? "hex") }, + { + $type: "color", + $value: (() => { + const sourceSpace = supported[entry.colour.space]; + const targetSpace = sourceSpace ? entry.colour.space : "srgb"; + const converted = asColor(entry.colour).to(targetSpace); + return { + colorSpace: sourceSpace ?? "srgb", + components: converted.coords.map((coordinate) => + round(Number(coordinate ?? 0), 6), + ), + ...(entry.colour.alpha < 1 + ? { alpha: round(entry.colour.alpha, 6) } + : {}), + hex: formatColour(entry.colour, "hex"), + }; + })(), + }, ]), ); - return JSON.stringify(value, null, options.pretty === false ? undefined : 2); + return JSON.stringify( + { + $schema: "https://www.designtokens.org/schemas/2025.10/format.json", + ...tokens, + }, + null, + options.pretty === false ? undefined : 2, + ); } export function exportTailwindPalette( diff --git a/src/colour/types.ts b/src/colour/types.ts index d1bc0da..50d94a9 100644 --- a/src/colour/types.ts +++ b/src/colour/types.ts @@ -209,6 +209,21 @@ export interface ContrastSuggestion { direction: "lighter" | "darker"; } +export interface PaletteContrastCell { + foregroundIndex: number; + backgroundIndex: number; + ratio: number; + aaNormal: boolean; + aaLarge: boolean; + aaaNormal: boolean; + nonText: boolean; +} + +export interface PaletteContrastMatrix { + size: number; + cells: PaletteContrastCell[][]; +} + export type DeltaEMethod = "76" | "cmc" | "2000" | "ok" | "itp" | "jz"; export type ColourVisionDeficiency = diff --git a/src/components/ImagePickerLab.tsx b/src/components/ImagePickerLab.tsx index eec10da..2da2ded 100644 --- a/src/components/ImagePickerLab.tsx +++ b/src/components/ImagePickerLab.tsx @@ -432,6 +432,7 @@ export function ImagePickerLab({ ref={inputRef} className="visually-hidden" type="file" + aria-label="Choose image to sample" accept={imageLimits.acceptedMimeTypes.join(",")} onChange={(event: ChangeEvent) => { acceptFiles(event.currentTarget.files); diff --git a/src/components/PaletteLab.tsx b/src/components/PaletteLab.tsx index d5ff9d3..7c1e2f0 100644 --- a/src/components/PaletteLab.tsx +++ b/src/components/PaletteLab.tsx @@ -2,6 +2,9 @@ import { useMemo, useState } from "react"; import { colourHarmony, exportPalette, + formatColour, + paletteContrastMatrix, + parseDtcgTokens, parsePaletteList, shades, tints, @@ -9,6 +12,7 @@ import { toSrgbPreview, tryParseColour, type HarmonyType, + type DtcgDiagnostic, type PaletteEntry, type PaletteExportFormat, } from "../colour"; @@ -39,6 +43,10 @@ export function PaletteLab({ palette }: { palette: PaletteStore }) { const [prefix, setPrefix] = useState("brand"); const [importText, setImportText] = useState(""); const [importMessage, setImportMessage] = useState(""); + const [importDiagnostics, setImportDiagnostics] = useState( + [], + ); + const [matrixCanvas, setMatrixCanvas] = useState("#ffffff"); const generated = useMemo(() => { const parsed = tryParseColour(base); @@ -75,18 +83,65 @@ export function PaletteLab({ palette }: { palette: PaletteStore }) { entries.length > 0 ? exportPalette(entries, format, { prefix, pretty: true }) : ""; + const contrastMatrix = useMemo(() => { + if (entries.length === 0) return { value: undefined, error: "" }; + try { + return { + value: paletteContrastMatrix( + entries.map((entry) => entry.colour), + { canvas: matrixCanvas }, + ), + error: "", + }; + } catch (error) { + return { + value: undefined, + error: + error instanceof Error + ? error.message + : "Could not calculate the contrast matrix.", + }; + } + }, [entries, matrixCanvas]); function importPalette() { try { - const imported = parsePaletteList(importText); - imported.forEach((entry) => { - const preview = toSrgbPreview(entry.colour); - palette.addColour(preview.css, entry.name); + const trimmed = importText.trim(); + const isDtcg = + trimmed.startsWith("{") && + /"\$(?:value|type|ref|schema)"\s*:/u.test(trimmed); + const dtcg = isDtcg ? parseDtcgTokens(importText) : undefined; + const imported = dtcg?.entries ?? parsePaletteList(importText); + setImportDiagnostics(dtcg?.diagnostics ?? []); + if (imported.length === 0) + throw new Error( + dtcg + ? "No valid color tokens were found. Review the DTCG diagnostics." + : "No colours were found.", + ); + const seen = new Set( + palette.colours.map((entry) => entry.value.toLowerCase()), + ); + const additions = imported.flatMap((entry) => { + const value = formatColour(entry.colour, "css", { + precision: 6, + mapToSrgb: false, + }); + const identity = value.toLowerCase(); + if (seen.has(identity)) return []; + seen.add(identity); + return [{ ...entry, value }]; }); + additions.forEach((entry) => palette.addColour(entry.value, entry.name)); + const duplicates = imported.length - additions.length; + const diagnosticSummary = dtcg + ? ` DTCG: ${dtcg.diagnostics.filter((item) => item.severity === "error").length} error(s), ${dtcg.diagnostics.filter((item) => item.severity === "warning").length} warning(s), ${dtcg.skippedNonColourTokens} non-colour token(s) skipped.` + : ""; setImportMessage( - `Added ${imported.length} colour${imported.length === 1 ? "" : "s"}.`, + `Added ${additions.length} colour${additions.length === 1 ? "" : "s"}${duplicates ? `; ignored ${duplicates} exact duplicate${duplicates === 1 ? "" : "s"}` : ""}.${diagnosticSummary}`, ); } catch (error) { + setImportDiagnostics([]); setImportMessage( error instanceof Error ? error.message @@ -331,13 +386,95 @@ export function PaletteLab({ palette }: { palette: PaletteStore }) { )} +
+
+
+

Accessibility preflight

+

Palette contrast matrix

+

+ Each row is foreground text/icon colour; each column is the + background. Transparent colours are flattened against the stated + canvas before WCAG 2 contrast is calculated. +

+
+ +
+ {contrastMatrix.error && ( +

+ {contrastMatrix.error} +

+ )} + {entries.length === 0 ? ( +
+ Add valid saved colours to compare them. +
+ ) : ( + contrastMatrix.value && ( +
+ + + + + + {entries.map((entry, index) => ( + + ))} + + + + {contrastMatrix.value.cells.map((row, rowIndex) => ( + + + {row.map((cell) => ( + + ))} + + ))} + +
+ WCAG 2 contrast ratios, foreground colours by row and + backgrounds by column +
Foreground ↓ / background → + {entry.name} +
{entries[rowIndex]?.name} + {cell.ratio.toFixed(2)}:1 +
+
+ ) + )} +

+ Green: AAA normal text; blue: AA normal; amber: large text/non-text + only; red: below 3:1. This matrix does not assess font size, state, + typography, or non-colour cues. +

+
+
-

Import palette text

+

Import palette or DTCG tokens

- JSON, one colour per line, or name: colour. + DTCG 2025.10 color tokens (including local aliases), JSON, one + colour per line, or name: colour.

@@ -349,6 +486,7 @@ export function PaletteLab({ palette }: { palette: PaletteStore }) { onChange={(event) => { setImportText(event.target.value); setImportMessage(""); + setImportDiagnostics([]); }} placeholder={"primary: #6252d3\naccent: oklch(76% 0.17 87)"} /> @@ -367,6 +505,25 @@ export function PaletteLab({ palette }: { palette: PaletteStore }) { )}
+ {importDiagnostics.length > 0 && ( +
+ + Review {importDiagnostics.length} DTCG diagnostic + {importDiagnostics.length === 1 ? "" : "s"} + +
    + {importDiagnostics.slice(0, 100).map((item, index) => ( +
  • + {item.severity} · {item.path}:{" "} + {item.message} +
  • + ))} +
+ {importDiagnostics.length > 100 && ( +

Only the first 100 diagnostics are rendered.

+ )} +
+ )}
@@ -393,7 +550,7 @@ export function PaletteLab({ palette }: { palette: PaletteStore }) { - + diff --git a/src/components/StepsLab.tsx b/src/components/StepsLab.tsx index fc99085..1efcbce 100644 --- a/src/components/StepsLab.tsx +++ b/src/components/StepsLab.tsx @@ -1,5 +1,8 @@ import { useMemo, useState } from "react"; import { + buildCssGradient, + exportGradientRecipe, + gradientCssDocument, interpolateStops, tryParseColour, type ColourStop, @@ -7,6 +10,7 @@ import { type HueInterpolation, type InterpolationSpace, type InterpolationStep, + type GradientKind, } from "../colour"; import { ColourField } from "./ColourField"; import { CopyButton } from "./CopyButton"; @@ -109,6 +113,12 @@ export function StepsLab({ const [lastGood, setLastGood] = useState( () => calculateSteps(initialConfig).steps ?? [], ); + const [gradientKind, setGradientKind] = useState("linear"); + const [gradientAngle, setGradientAngle] = useState(90); + const [gradientRepeating, setGradientRepeating] = useState(false); + const [radialShape, setRadialShape] = useState<"circle" | "ellipse">( + "ellipse", + ); const { stops, count, space, hue, easing, premultiplied } = config; const calculation = useMemo(() => { @@ -132,9 +142,28 @@ export function StepsLab({ null, 2, ); - const gradient = `linear-gradient(90deg, ${shownSteps - .map((step) => `${step.css} ${(step.position * 100).toFixed(2)}%`) - .join(", ")})`; + const gradientOptions = { + kind: gradientKind, + angle: gradientAngle, + repeating: gradientRepeating, + radialShape, + }; + const gradientStops = shownSteps.map((step) => ({ + colour: step.colour, + position: step.position, + })); + const gradient = buildCssGradient(gradientStops, gradientOptions); + const gradientRecipe = exportGradientRecipe(gradientStops, gradientOptions); + const gradientCss = gradientCssDocument(gradient); + + function downloadText(value: string, name: string, type: string) { + const url = URL.createObjectURL(new Blob([value], { type })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = name; + anchor.click(); + URL.revokeObjectURL(url); + } function updateStop(id: string, update: Partial) { commit({ @@ -331,9 +360,90 @@ export function StepsLab({
+
+ + {gradientKind !== "radial" && ( + + )} + {gradientKind === "radial" && ( + + )} + +
+
+            {gradient}
+          
+
+ + + + +
{calculation.error && (

{calculation.error} Keeping the last valid ramp. diff --git a/src/styles.css b/src/styles.css index 7d32c39..bbe0690 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1347,6 +1347,54 @@ body { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } +.palette-matrix-panel { + display: grid; + gap: 1rem; +} +.contrast-matrix-scroll { + max-width: 100%; + overflow: auto; + border: 1px solid var(--toolbox-border); + border-radius: 0.7rem; +} +.contrast-matrix { + min-width: max-content; + border-collapse: collapse; + font-size: 0.78rem; +} +.contrast-matrix th, +.contrast-matrix td { + max-width: 10rem; + padding: 0.5rem 0.6rem; + border-right: 1px solid var(--toolbox-border); + border-bottom: 1px solid var(--toolbox-border); + text-align: center; + overflow-wrap: anywhere; +} +.contrast-matrix th { + position: sticky; + z-index: 1; + background: var(--toolbox-surface-soft); +} +.contrast-matrix thead th { + top: 0; +} +.contrast-matrix tbody th { + left: 0; + text-align: left; +} +.contrast-matrix .matrix-aaa { + background: color-mix(in srgb, #2f9e61 18%, var(--toolbox-surface)); +} +.contrast-matrix .matrix-aa { + background: color-mix(in srgb, #3584d8 17%, var(--toolbox-surface)); +} +.contrast-matrix .matrix-large { + background: color-mix(in srgb, #d99a20 22%, var(--toolbox-surface)); +} +.contrast-matrix .matrix-fail { + background: color-mix(in srgb, #c93f57 16%, var(--toolbox-surface)); +} .palette-io-layout > section { min-width: 0; } diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json index da7363c..e6f0b96 100644 --- a/src/toolbox/manifest.source.json +++ b/src/toolbox/manifest.source.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.colour-tools", "name": "Colour Tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Convert, composite, compare and build colours locally in the browser.", "entry": "./", "icon": "./favicon.svg", @@ -26,14 +26,52 @@ }, "requirements": { "secureContext": false, - "workers": true, + "workers": false, "indexedDb": false, "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { + "mediaType": "image/*", + "extensions": [".png", ".jpg", ".jpeg", ".webp"] + }, + { + "mediaType": "application/json", + "extensions": [".json"] + }, + { + "mediaType": "text/plain", + "extensions": [".txt", ".css", ".gpl"] + } + ], + "produces": [ + { + "mediaType": "application/json", + "extensions": [".json"] + }, + { + "mediaType": "text/csv", + "extensions": [".csv"] + }, + { + "mediaType": "text/css", + "extensions": [".css"] + }, + { + "mediaType": "text/plain", + "extensions": [".txt", ".gpl"] + } + ] + }, + "capabilities": { + "required": [], + "optional": ["eye-dropper", "workers"] + }, "privacy": { "processing": "local", - "fileUploads": false, + "fileUploads": true, "telemetry": false, "label": "Colours and images stay in this browser; nothing is uploaded." }, diff --git a/src/version.ts b/src/version.ts index 76162f8..0ba6e37 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = "0.1.0"; +export const APP_VERSION = "0.2.0"; diff --git a/tests/browser/colour-tools.spec.ts b/tests/browser/colour-tools.spec.ts index 0db95ff..83a511c 100644 --- a/tests/browser/colour-tools.spec.ts +++ b/tests/browser/colour-tools.spec.ts @@ -71,6 +71,14 @@ test("converts, composites and interpolates without transient empty results", as await expect(page.locator(".step-swatch")).toHaveCount(9); await page.getByLabel(/Swatches/u).fill("5"); await expect(page.locator(".step-swatch")).toHaveCount(5); + await page.getByLabel("Gradient type").selectOption("radial"); + await expect(page.locator(".gradient-preview + pre")).toContainText( + "radial-gradient(ellipse at center", + ); + await page.getByText("Repeating", { exact: true }).click(); + await expect(page.locator(".gradient-preview + pre")).toContainText( + "repeating-radial-gradient", + ); expect(externalRequests).toEqual([]); expect(runtimeErrors).toEqual([]); @@ -123,9 +131,35 @@ test("keeps picking, accessibility analysis and palette data browser-local", asy "--brand-primary", ); + await page.getByLabel("Palette data").fill( + JSON.stringify({ + vivid: { + $type: "color", + $value: { + colorSpace: "display-p3", + components: [0.9, 0.2, 0.1], + }, + }, + vividAlias: { $value: "{vivid}" }, + }), + ); + await page.getByRole("button", { name: "Import colours" }).click(); + await expect(page.getByRole("status")).toContainText( + "Added 1 colour; ignored 1 exact duplicate", + ); + await expect(page.locator(".saved-colour-list > li")).toHaveCount(4); + await expect(page.locator(".contrast-matrix tbody tr")).toHaveCount(4); + await expect( + page.locator(".contrast-matrix tbody tr").first().locator("td"), + ).toHaveCount(4); + await page.getByLabel("Format").selectOption("tokens"); + await expect(page.locator(".export-preview")).toContainText( + "https://www.designtokens.org/schemas/2025.10/format.json", + ); + await page.reload(); await page.getByRole("tab", { name: /Palette/u }).click(); - await expect(page.locator(".saved-colour-list > li")).toHaveCount(3); + await expect(page.locator(".saved-colour-list > li")).toHaveCount(4); expect(externalRequests).toEqual([]); expect(runtimeErrors).toEqual([]); @@ -194,7 +228,7 @@ test("serves a relocatable production artifact with hardened headers", async ({ expect(manifest.headers()["content-type"]).toContain("application/json"); await expect(manifest.json()).resolves.toMatchObject({ id: "de.add-ideas.colour-tools", - version: "0.1.0", + version: "0.2.0", entry: "./", icon: "./favicon.svg", }); diff --git a/tests/browser/responsive.spec.ts b/tests/browser/responsive.spec.ts new file mode 100644 index 0000000..2293995 --- /dev/null +++ b/tests/browser/responsive.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; + +test("keeps the primary workspace inside a narrow viewport", async ({ + page, +}) => { + await page.goto("/deep/nested/colour/"); + await expect(page.locator("main").first()).toBeVisible(); + await expect( + page.locator("main .loading, main .workbench-loading"), + ).toHaveCount(0); + + const widths = await page.evaluate(() => ({ + content: document.documentElement.scrollWidth, + viewport: document.documentElement.clientWidth, + })); + expect(widths.viewport).toBeLessThanOrEqual(430); + expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1); +}); diff --git a/tests/colour/compare-palette.test.ts b/tests/colour/compare-palette.test.ts index 729528c..1715444 100644 --- a/tests/colour/compare-palette.test.ts +++ b/tests/colour/compare-palette.test.ts @@ -2,11 +2,15 @@ import { describe, expect, it } from "vitest"; import { colourHarmony, + buildCssGradient, deltaE, + exportGradientRecipe, exportPalette, formatColour, normaliseTokenName, parsePaletteList, + parseDtcgTokens, + paletteContrastMatrix, shades, simulateColourVision, simulateColourVisionSet, @@ -99,13 +103,170 @@ describe("palette construction and export", () => { paper: "#f8f5ee", ink: "#111111", }); - expect(JSON.parse(exportPalette(entries, "tokens")).paper).toEqual({ - $type: "color", - $value: "#f8f5ee", + expect(JSON.parse(exportPalette(entries, "tokens"))).toMatchObject({ + $schema: "https://www.designtokens.org/schemas/2025.10/format.json", + paper: { + $type: "color", + $value: { + colorSpace: "srgb", + components: expect.any(Array), + hex: "#f8f5ee", + }, + }, }); expect(exportPalette(entries, "tailwind")).toMatch(/^export default /); expect(exportPalette(entries, "csv")).toBe( "name,value\npaper,#f8f5ee\nink,#111111", ); }); + + it("imports DTCG 2025.10 structured colours and local aliases", () => { + const result = parseDtcgTokens( + JSON.stringify({ + $schema: "https://www.designtokens.org/schemas/2025.10/format.json", + base: { + $type: "color", + blue: { + $value: { + colorSpace: "display-p3", + components: [0.2, 0.4, 0.8], + alpha: 0.75, + hex: "#3366cc", + }, + }, + }, + semantic: { + primary: { $type: "color", $value: "{base.blue}" }, + }, + }), + ); + expect(result.entries.map((entry) => entry.name)).toEqual([ + "base-blue", + "semantic-primary", + ]); + expect(result.diagnostics).toEqual([]); + expect(formatColour(result.entries[0]!.colour, "hex8")).toMatch( + /^#[0-9a-f]{8}$/u, + ); + }); + + it("reports missing DTCG types instead of guessing them", () => { + const result = parseDtcgTokens( + '{"untyped":{"$value":{"colorSpace":"srgb","components":[1,0,0]}}}', + ); + expect(result.entries).toEqual([]); + expect(result.diagnostics[0]).toMatchObject({ severity: "error" }); + }); + + it("inherits a DTCG alias type and rejects out-of-range components", () => { + const alias = parseDtcgTokens( + JSON.stringify({ + base: { + $type: "color", + $value: { colorSpace: "srgb", components: [1, 0, 0] }, + }, + alias: { $value: "{base}" }, + }), + ); + expect(alias.entries.map((entry) => entry.name)).toEqual(["base", "alias"]); + expect(alias.diagnostics).toEqual([]); + + const invalid = parseDtcgTokens( + JSON.stringify({ + bad: { + $type: "color", + $value: { colorSpace: "display-p3", components: [1.2, 0, 0] }, + }, + }), + ); + expect(invalid.entries).toEqual([]); + expect(invalid.diagnostics[0]?.message).toMatch(/from 0 to 1/u); + }); + + it("resolves DTCG JSON Pointer aliases and property-level references", () => { + const result = parseDtcgTokens( + JSON.stringify({ + base: { + $type: "color", + $value: { + colorSpace: "srgb", + components: [0.2, 0.4, 0.8], + hex: "#3366cc", + }, + }, + alias: { $ref: "#/base/$value" }, + mixed: { + $type: "color", + $value: { + colorSpace: "srgb", + components: [ + { $ref: "#/base/$value/components/0" }, + { $ref: "#/base/$value/components/1" }, + 0.7, + ], + }, + }, + }), + ); + expect(result.entries.map((entry) => entry.name)).toEqual([ + "base", + "alias", + "mixed", + ]); + expect(result.diagnostics).toEqual([]); + }); + + it("reports cyclic and malformed DTCG property references", () => { + const result = parseDtcgTokens( + JSON.stringify({ + cycle: { + $type: "color", + $value: { $ref: "#/cycle/$value" }, + }, + siblings: { + $type: "color", + $value: { $ref: "#/cycle/$value", extra: true }, + }, + }), + ); + expect(result.entries).toEqual([]); + expect(result.diagnostics.map((item) => item.message).join(" ")).toMatch( + /cycle|sibling/iu, + ); + }); +}); + +describe("palette accessibility and gradients", () => { + it("builds a directional contrast matrix with WCAG thresholds", () => { + const matrix = paletteContrastMatrix(["black", "white", "#777"]); + expect(matrix.size).toBe(3); + expect(matrix.cells[0]?.[1]).toMatchObject({ + ratio: 21, + aaNormal: true, + aaaNormal: true, + }); + expect(matrix.cells[2]?.[1]?.aaNormal).toBe(false); + expect(() => paletteContrastMatrix([])).toThrow(/1–64/u); + }); + + it("exports linear, radial, conic and repeating CSS gradients", () => { + const stops = [ + { colour: "red", position: 0 }, + { colour: "blue", position: 1 }, + ]; + expect(buildCssGradient(stops, { angle: 45 })).toMatch( + /^linear-gradient\(45deg,/u, + ); + expect( + buildCssGradient(stops, { kind: "radial", radialShape: "circle" }), + ).toMatch(/^radial-gradient\(circle at center,/u); + expect(buildCssGradient(stops, { kind: "conic", repeating: true })).toMatch( + /^repeating-conic-gradient\(/u, + ); + expect(JSON.parse(exportGradientRecipe(stops))).toMatchObject({ + schemaVersion: 1, + type: "css-gradient", + stops: [{ position: 0 }, { position: 1 }], + }); + }); });