Release Colour Tools 0.2.0

This commit is contained in:
2026-09-02 07:11:11 +02:00
parent 0c1fc94b58
commit 26d9b35c89
34 changed files with 1446 additions and 74 deletions
+39
View File
@@ -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
+5 -1
View File
@@ -2,7 +2,11 @@
All notable changes are documented here. 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 ## 0.1.0 - 2026-08-31
+13 -3
View File
@@ -24,7 +24,8 @@ has no analytics, accounts, advertisements or remote lookup feature.
- **Steps** builds multi-stop colour ramps in perceptual, RGB or cylindrical - **Steps** builds multi-stop colour ramps in perceptual, RGB or cylindrical
colour spaces. It supports positioned stops, five hue routes, easing, colour spaces. It supports positioned stops, five hue routes, easing,
premultiplied-alpha interpolation by default with a straight-alpha option, 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 - **Pick** provides a large keyboard- and pointer-operable HSV picker, alpha
control, exact CSS entry, browser-native colour input, recent picks and the control, exact CSS entry, browser-native colour input, recent picks and the
system EyeDropper API where the browser exposes it. system EyeDropper API where the browser exposes it.
@@ -38,7 +39,9 @@ has no analytics, accounts, advertisements or remote lookup feature.
colour-vision deficiencies. colour-vision deficiencies.
- **Palette** creates OKLCH harmonies, tints, shades and tones; keeps a named - **Palette** creates OKLCH harmonies, tints, shades and tones; keeps a named
palette in local browser storage; imports common text/JSON token forms; and 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 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 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, engineering aid, not a complete accessibility audit; typography, state,
context and non-colour cues still need human review. 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 ## Local image handling
Selecting an image decodes a bounded raster locally with `createImageBitmap` 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, This command checks the manifest, types, lint, formatting, unit tests,
production build, Toolbox contract and browser workflows, then creates a 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 The archive is a ready-to-host static application and contains project and
third-party licence notices. third-party licence notices.
+3 -3
View File
@@ -1,8 +1,8 @@
# Corresponding source and provenance # 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 Build that tag with Node.js 22 and the exact dependency graph recorded in
`package-lock.json`: `package-lock.json`:
@@ -20,7 +20,7 @@ and the applicable third-party notices.
## Implementation provenance ## Implementation provenance
- The application is original project code and uses the shared Toolbox SDK - 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 - Colour conversion and adaptation use Color.js 0.7.1. Its MIT licence is
reproduced in `LICENSES/colorjs.io-MIT.txt`. reproduced in `LICENSES/colorjs.io-MIT.txt`.
- The compositing, interpolation, palette, image-sampling, contrast, comparison, - The compositing, interpolation, palette, image-sampling, contrast, comparison,
+2 -2
View File
@@ -5,8 +5,8 @@ packages into `LICENSES/npm-runtime-licenses.txt` inside the static artifact.
| Package | Version | Licence | Role | Source | | 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-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.2.3 | Apache-2.0 | shared shell, theme and actions | 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 | | `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` | 19.2.8 | MIT | application UI | https://github.com/facebook/react |
| `react-dom` | 19.2.8 | MIT | browser rendering | https://github.com/facebook/react | | `react-dom` | 19.2.8 | MIT | browser rendering | https://github.com/facebook/react |
+17 -2
View File
@@ -9,8 +9,9 @@ last valid result while an input is incomplete.
1. `src/colour/` is the serialisable colour domain. It parses and formats 1. `src/colour/` is the serialisable colour domain. It parses and formats
colours, converts gamuts, composites layers, interpolates stops, evaluates colours, converts gamuts, composites layers, interpolates stops, evaluates
contrast and Delta E, simulates colour-vision deficiencies, and builds or contrast and Delta E, simulates colour-vision deficiencies, builds CSS
exports palettes. Its public API is re-exported from `src/colour/index.ts`. 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 2. `src/palette/` contains browser-independent raster bounds, sampling and
deterministic OKLab clustering. `src/workers/palette.worker.ts` exposes the deterministic OKLab clustering. `src/workers/palette.worker.ts` exposes the
expensive extraction path to a dedicated worker. 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 whether mapping occurred. This keeps display limitations from mutating the
source colour used by later conversions or comparisons. 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 264 ascending stops and exports inert CSS plus
a schema-versioned recipe for linear, radial, conic and repeating syntax.
## Raster pipeline ## Raster pipeline
The image picker validates the selected file before decode, rejects excessive The image picker validates the selected file before decode, rejects excessive
+6
View File
@@ -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 proxy logs and browser extension behaviour are outside the application's local
processing boundary. 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 ## Recommended headers
Serve the static artifact over HTTPS and apply a policy equivalent to: Serve the static artifact over HTTPS and apply a policy equivalent to:
+16 -16
View File
@@ -1,22 +1,22 @@
{ {
"name": "colour-tools", "name": "colour-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "colour-tools", "name": "colour-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"colorjs.io": "0.7.1", "colorjs.io": "0.7.1",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -43,18 +43,18 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz", "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-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==", "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3", "version": "0.3.0",
"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", "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-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==", "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -62,13 +62,13 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz", "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-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==", "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "colour-tools", "name": "colour-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Convert, composite, compare and build colours locally in the browser.", "description": "Convert, composite, compare and build colours locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,14 +39,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"colorjs.io": "0.7.1", "colorjs.io": "0.7.1",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -18,7 +18,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ 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"] },
},
], ],
}); });
+5 -1
View File
@@ -2,7 +2,11 @@
All notable changes are documented here. 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 ## 0.1.0 - 2026-08-31
+2 -2
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
Installed from: node_modules/@add-ideas/toolbox-contract 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 Declared licence: Apache-2.0
Installed from: node_modules/@add-ideas/toolbox-shell-react Installed from: node_modules/@add-ideas/toolbox-shell-react
============================================================================== ==============================================================================
+13 -3
View File
@@ -24,7 +24,8 @@ has no analytics, accounts, advertisements or remote lookup feature.
- **Steps** builds multi-stop colour ramps in perceptual, RGB or cylindrical - **Steps** builds multi-stop colour ramps in perceptual, RGB or cylindrical
colour spaces. It supports positioned stops, five hue routes, easing, colour spaces. It supports positioned stops, five hue routes, easing,
premultiplied-alpha interpolation by default with a straight-alpha option, 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 - **Pick** provides a large keyboard- and pointer-operable HSV picker, alpha
control, exact CSS entry, browser-native colour input, recent picks and the control, exact CSS entry, browser-native colour input, recent picks and the
system EyeDropper API where the browser exposes it. system EyeDropper API where the browser exposes it.
@@ -38,7 +39,9 @@ has no analytics, accounts, advertisements or remote lookup feature.
colour-vision deficiencies. colour-vision deficiencies.
- **Palette** creates OKLCH harmonies, tints, shades and tones; keeps a named - **Palette** creates OKLCH harmonies, tints, shades and tones; keeps a named
palette in local browser storage; imports common text/JSON token forms; and 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 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 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, engineering aid, not a complete accessibility audit; typography, state,
context and non-colour cues still need human review. 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 ## Local image handling
Selecting an image decodes a bounded raster locally with `createImageBitmap` 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, This command checks the manifest, types, lint, formatting, unit tests,
production build, Toolbox contract and browser workflows, then creates a 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 The archive is a ready-to-host static application and contains project and
third-party licence notices. third-party licence notices.
+3 -3
View File
@@ -1,8 +1,8 @@
# Corresponding source and provenance # 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 Build that tag with Node.js 22 and the exact dependency graph recorded in
`package-lock.json`: `package-lock.json`:
@@ -20,7 +20,7 @@ and the applicable third-party notices.
## Implementation provenance ## Implementation provenance
- The application is original project code and uses the shared Toolbox SDK - 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 - Colour conversion and adaptation use Color.js 0.7.1. Its MIT licence is
reproduced in `LICENSES/colorjs.io-MIT.txt`. reproduced in `LICENSES/colorjs.io-MIT.txt`.
- The compositing, interpolation, palette, image-sampling, contrast, comparison, - The compositing, interpolation, palette, image-sampling, contrast, comparison,
+2 -2
View File
@@ -5,8 +5,8 @@ packages into `LICENSES/npm-runtime-licenses.txt` inside the static artifact.
| Package | Version | Licence | Role | Source | | 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-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.2.3 | Apache-2.0 | shared shell, theme and actions | 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 | | `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` | 19.2.8 | MIT | application UI | https://github.com/facebook/react |
| `react-dom` | 19.2.8 | MIT | browser rendering | https://github.com/facebook/react | | `react-dom` | 19.2.8 | MIT | browser rendering | https://github.com/facebook/react |
+17 -2
View File
@@ -9,8 +9,9 @@ last valid result while an input is incomplete.
1. `src/colour/` is the serialisable colour domain. It parses and formats 1. `src/colour/` is the serialisable colour domain. It parses and formats
colours, converts gamuts, composites layers, interpolates stops, evaluates colours, converts gamuts, composites layers, interpolates stops, evaluates
contrast and Delta E, simulates colour-vision deficiencies, and builds or contrast and Delta E, simulates colour-vision deficiencies, builds CSS
exports palettes. Its public API is re-exported from `src/colour/index.ts`. 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 2. `src/palette/` contains browser-independent raster bounds, sampling and
deterministic OKLab clustering. `src/workers/palette.worker.ts` exposes the deterministic OKLab clustering. `src/workers/palette.worker.ts` exposes the
expensive extraction path to a dedicated worker. 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 whether mapping occurred. This keeps display limitations from mutating the
source colour used by later conversions or comparisons. 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 264 ascending stops and exports inert CSS plus
a schema-versioned recipe for linear, radial, conic and repeating syntax.
## Raster pipeline ## Raster pipeline
The image picker validates the selected file before decode, rejects excessive The image picker validates the selected file before decode, rejects excessive
+6
View File
@@ -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 proxy logs and browser extension behaviour are outside the application's local
processing boundary. 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 ## Recommended headers
Serve the static artifact over HTTPS and apply a policy equivalent to: Serve the static artifact over HTTPS and apply a policy equivalent to:
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "colour-tools-shell-"; 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"]; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
+20 -3
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.colour-tools", "id": "de.add-ideas.colour-tools",
"name": "Colour Tools", "name": "Colour Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Convert, composite, compare and build colours locally in the browser.", "description": "Convert, composite, compare and build colours locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -26,14 +26,31 @@
}, },
"requirements": { "requirements": {
"secureContext": false, "secureContext": false,
"workers": true, "workers": false,
"indexedDb": false, "indexedDb": false,
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": false, "fileUploads": true,
"telemetry": false, "telemetry": false,
"label": "Colours and images stay in this browser; nothing is uploaded." "label": "Colours and images stay in this browser; nothing is uploaded."
}, },
+28
View File
@@ -16,6 +16,7 @@ import type {
ContrastOptions, ContrastOptions,
ContrastReport, ContrastReport,
ContrastSuggestion, ContrastSuggestion,
PaletteContrastMatrix,
} from "./types"; } from "./types";
const WHITE: ColourValue = { space: "srgb", coords: [1, 1, 1], alpha: 1 }; 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 164 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 { interface Candidate {
colour: ColourValue; colour: ColourValue;
ratio: number; ratio: number;
+461
View File
@@ -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<string, unknown>;
inheritedType?: string;
}
interface TokenIndex {
byPath: ReadonlyMap<string, TokenRecord>;
byObject: WeakMap<object, TokenRecord>;
byValue: WeakMap<object, TokenRecord>;
}
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<string, unknown>): {
records: TokenRecord[];
diagnostics: DtcgDiagnostic[];
} {
const records: TokenRecord[] = [];
const diagnostics: DtcgDiagnostic[] = [];
const visit = (
object: Record<string, unknown>,
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<string, unknown>, 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<string, unknown>, 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<string, unknown>)[key];
}
return value;
}
function resolveEmbeddedReferences(
value: unknown,
root: Record<string, unknown>,
index: TokenIndex,
visiting: Set<string>,
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<string, unknown>;
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<string, unknown>,
index: TokenIndex,
visiting: Set<string>,
): 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<string, unknown>,
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<string, unknown>,
index: TokenIndex,
visiting: Set<string>,
): 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 0100.`,
);
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 0100, 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 01, 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<string, unknown>;
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<string, unknown>;
const inventory = collectTokens(root);
const byPath = new Map(
inventory.records.map((record) => [pathText(record.path), record]),
);
const byObject = new WeakMap<object, TokenRecord>();
const byValue = new WeakMap<object, TokenRecord>();
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 } : {}),
};
}
+108
View File
@@ -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 264 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`;
}
+9
View File
@@ -14,12 +14,19 @@ export {
} from "./format"; } from "./format";
export { DEFAULT_GAMUT_SPACES, gamutReport, mapToGamut } from "./gamut"; export { DEFAULT_GAMUT_SPACES, gamutReport, mapToGamut } from "./gamut";
export { compositeLayers, compositeSourceOver } from "./composite"; export { compositeLayers, compositeSourceOver } from "./composite";
export {
buildCssGradient,
exportGradientRecipe,
gradientCssDocument,
} from "./gradient";
export type { GradientKind, GradientOptions, GradientStop } from "./gradient";
export { interpolateColourStops, interpolateStops } from "./interpolate"; export { interpolateColourStops, interpolateStops } from "./interpolate";
export { export {
contrastRatio, contrastRatio,
contrastReport, contrastReport,
flattenColour, flattenColour,
nearestPassingColour, nearestPassingColour,
paletteContrastMatrix,
relativeLuminance, relativeLuminance,
} from "./accessibility"; } from "./accessibility";
export { export {
@@ -44,5 +51,7 @@ export {
tints, tints,
tones, tones,
} from "./palette"; } from "./palette";
export { parseDtcgTokens } from "./dtcg";
export type { DtcgDiagnostic, DtcgImport } from "./dtcg";
export type * from "./types"; export type * from "./types";
+44 -4
View File
@@ -1,4 +1,4 @@
import { asColor, clamp, colorFrom, toColourValue } from "./internal"; import { asColor, clamp, colorFrom, round, toColourValue } from "./internal";
import { formatColour } from "./format"; import { formatColour } from "./format";
import { mapToGamut } from "./gamut"; import { mapToGamut } from "./gamut";
import { interpolateColourStops } from "./interpolate"; import { interpolateColourStops } from "./interpolate";
@@ -271,13 +271,53 @@ export function exportDesignTokens(
entries: readonly PaletteEntry[], entries: readonly PaletteEntry[],
options: PaletteExportOptions = {}, options: PaletteExportOptions = {},
): string { ): string {
const value = Object.fromEntries( const supported: Record<string, string> = {
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 }) => [ uniqueNames(entries).map(({ name, entry }) => [
name, 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( export function exportTailwindPalette(
+15
View File
@@ -209,6 +209,21 @@ export interface ContrastSuggestion {
direction: "lighter" | "darker"; 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 DeltaEMethod = "76" | "cmc" | "2000" | "ok" | "itp" | "jz";
export type ColourVisionDeficiency = export type ColourVisionDeficiency =
+1
View File
@@ -432,6 +432,7 @@ export function ImagePickerLab({
ref={inputRef} ref={inputRef}
className="visually-hidden" className="visually-hidden"
type="file" type="file"
aria-label="Choose image to sample"
accept={imageLimits.acceptedMimeTypes.join(",")} accept={imageLimits.acceptedMimeTypes.join(",")}
onChange={(event: ChangeEvent<HTMLInputElement>) => { onChange={(event: ChangeEvent<HTMLInputElement>) => {
acceptFiles(event.currentTarget.files); acceptFiles(event.currentTarget.files);
+165 -8
View File
@@ -2,6 +2,9 @@ import { useMemo, useState } from "react";
import { import {
colourHarmony, colourHarmony,
exportPalette, exportPalette,
formatColour,
paletteContrastMatrix,
parseDtcgTokens,
parsePaletteList, parsePaletteList,
shades, shades,
tints, tints,
@@ -9,6 +12,7 @@ import {
toSrgbPreview, toSrgbPreview,
tryParseColour, tryParseColour,
type HarmonyType, type HarmonyType,
type DtcgDiagnostic,
type PaletteEntry, type PaletteEntry,
type PaletteExportFormat, type PaletteExportFormat,
} from "../colour"; } from "../colour";
@@ -39,6 +43,10 @@ export function PaletteLab({ palette }: { palette: PaletteStore }) {
const [prefix, setPrefix] = useState("brand"); const [prefix, setPrefix] = useState("brand");
const [importText, setImportText] = useState(""); const [importText, setImportText] = useState("");
const [importMessage, setImportMessage] = useState(""); const [importMessage, setImportMessage] = useState("");
const [importDiagnostics, setImportDiagnostics] = useState<DtcgDiagnostic[]>(
[],
);
const [matrixCanvas, setMatrixCanvas] = useState("#ffffff");
const generated = useMemo(() => { const generated = useMemo(() => {
const parsed = tryParseColour(base); const parsed = tryParseColour(base);
@@ -75,18 +83,65 @@ export function PaletteLab({ palette }: { palette: PaletteStore }) {
entries.length > 0 entries.length > 0
? exportPalette(entries, format, { prefix, pretty: true }) ? 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() { function importPalette() {
try { try {
const imported = parsePaletteList(importText); const trimmed = importText.trim();
imported.forEach((entry) => { const isDtcg =
const preview = toSrgbPreview(entry.colour); trimmed.startsWith("{") &&
palette.addColour(preview.css, entry.name); /"\$(?: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( 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) { } catch (error) {
setImportDiagnostics([]);
setImportMessage( setImportMessage(
error instanceof Error error instanceof Error
? error.message ? error.message
@@ -331,13 +386,95 @@ export function PaletteLab({ palette }: { palette: PaletteStore }) {
)} )}
</section> </section>
<section className="panel palette-matrix-panel">
<div className="section-heading">
<div>
<p className="eyebrow">Accessibility preflight</p>
<h3>Palette contrast matrix</h3>
<p>
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.
</p>
</div>
<ColourField
id="palette-matrix-canvas"
label="Canvas below transparency"
value={matrixCanvas}
onChange={setMatrixCanvas}
compact
/>
</div>
{contrastMatrix.error && (
<p className="stale-notice" role="status">
{contrastMatrix.error}
</p>
)}
{entries.length === 0 ? (
<div className="empty-state">
<strong>Add valid saved colours to compare them.</strong>
</div>
) : (
contrastMatrix.value && (
<div className="contrast-matrix-scroll" tabIndex={0}>
<table className="contrast-matrix">
<caption className="sr-only">
WCAG 2 contrast ratios, foreground colours by row and
backgrounds by column
</caption>
<thead>
<tr>
<th scope="col">Foreground / background </th>
{entries.map((entry, index) => (
<th scope="col" key={`background-${index}`}>
{entry.name}
</th>
))}
</tr>
</thead>
<tbody>
{contrastMatrix.value.cells.map((row, rowIndex) => (
<tr key={`foreground-${rowIndex}`}>
<th scope="row">{entries[rowIndex]?.name}</th>
{row.map((cell) => (
<td
key={`${cell.foregroundIndex}-${cell.backgroundIndex}`}
className={
cell.aaaNormal
? "matrix-aaa"
: cell.aaNormal
? "matrix-aa"
: cell.aaLarge
? "matrix-large"
: "matrix-fail"
}
title={`AA normal ${cell.aaNormal ? "pass" : "fail"}; AA large/non-text ${cell.aaLarge ? "pass" : "fail"}; AAA normal ${cell.aaaNormal ? "pass" : "fail"}`}
>
{cell.ratio.toFixed(2)}:1
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
)}
<p className="field-help">
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.
</p>
</section>
<div className="palette-io-layout"> <div className="palette-io-layout">
<section className="panel"> <section className="panel">
<div className="section-heading"> <div className="section-heading">
<div> <div>
<h3>Import palette text</h3> <h3>Import palette or DTCG tokens</h3>
<p> <p>
JSON, one colour per line, or <code>name: colour</code>. DTCG 2025.10 color tokens (including local aliases), JSON, one
colour per line, or <code>name: colour</code>.
</p> </p>
</div> </div>
</div> </div>
@@ -349,6 +486,7 @@ export function PaletteLab({ palette }: { palette: PaletteStore }) {
onChange={(event) => { onChange={(event) => {
setImportText(event.target.value); setImportText(event.target.value);
setImportMessage(""); setImportMessage("");
setImportDiagnostics([]);
}} }}
placeholder={"primary: #6252d3\naccent: oklch(76% 0.17 87)"} placeholder={"primary: #6252d3\naccent: oklch(76% 0.17 87)"}
/> />
@@ -367,6 +505,25 @@ export function PaletteLab({ palette }: { palette: PaletteStore }) {
</span> </span>
)} )}
</div> </div>
{importDiagnostics.length > 0 && (
<details className="import-diagnostics">
<summary>
Review {importDiagnostics.length} DTCG diagnostic
{importDiagnostics.length === 1 ? "" : "s"}
</summary>
<ul>
{importDiagnostics.slice(0, 100).map((item, index) => (
<li key={`${item.path}-${index}`}>
<strong>{item.severity}</strong> · {item.path}:{" "}
{item.message}
</li>
))}
</ul>
{importDiagnostics.length > 100 && (
<p>Only the first 100 diagnostics are rendered.</p>
)}
</details>
)}
</section> </section>
<section className="panel"> <section className="panel">
<div className="section-heading"> <div className="section-heading">
@@ -393,7 +550,7 @@ export function PaletteLab({ palette }: { palette: PaletteStore }) {
<option value="css">CSS custom properties</option> <option value="css">CSS custom properties</option>
<option value="scss">SCSS variables</option> <option value="scss">SCSS variables</option>
<option value="json">JSON</option> <option value="json">JSON</option>
<option value="tokens">Design Tokens JSON</option> <option value="tokens">DTCG 2025.10 JSON</option>
<option value="tailwind">JavaScript object</option> <option value="tailwind">JavaScript object</option>
<option value="csv">CSV</option> <option value="csv">CSV</option>
</select> </select>
+113 -3
View File
@@ -1,5 +1,8 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
buildCssGradient,
exportGradientRecipe,
gradientCssDocument,
interpolateStops, interpolateStops,
tryParseColour, tryParseColour,
type ColourStop, type ColourStop,
@@ -7,6 +10,7 @@ import {
type HueInterpolation, type HueInterpolation,
type InterpolationSpace, type InterpolationSpace,
type InterpolationStep, type InterpolationStep,
type GradientKind,
} from "../colour"; } from "../colour";
import { ColourField } from "./ColourField"; import { ColourField } from "./ColourField";
import { CopyButton } from "./CopyButton"; import { CopyButton } from "./CopyButton";
@@ -109,6 +113,12 @@ export function StepsLab({
const [lastGood, setLastGood] = useState<InterpolationStep[]>( const [lastGood, setLastGood] = useState<InterpolationStep[]>(
() => calculateSteps(initialConfig).steps ?? [], () => calculateSteps(initialConfig).steps ?? [],
); );
const [gradientKind, setGradientKind] = useState<GradientKind>("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 { stops, count, space, hue, easing, premultiplied } = config;
const calculation = useMemo(() => { const calculation = useMemo(() => {
@@ -132,9 +142,28 @@ export function StepsLab({
null, null,
2, 2,
); );
const gradient = `linear-gradient(90deg, ${shownSteps const gradientOptions = {
.map((step) => `${step.css} ${(step.position * 100).toFixed(2)}%`) kind: gradientKind,
.join(", ")})`; 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<StopState>) { function updateStop(id: string, update: Partial<StopState>) {
commit({ commit({
@@ -331,9 +360,90 @@ export function StepsLab({
<CopyButton value={jsonExport} label="Copy JSON" /> <CopyButton value={jsonExport} label="Copy JSON" />
</div> </div>
</div> </div>
<div className="control-grid gradient-controls">
<label className="field">
<span className="field-label">Gradient type</span>
<select
value={gradientKind}
onChange={(event) =>
setGradientKind(event.target.value as GradientKind)
}
>
<option value="linear">Linear</option>
<option value="radial">Radial</option>
<option value="conic">Conic</option>
</select>
</label>
{gradientKind !== "radial" && (
<label className="field">
<span className="field-label">Angle · {gradientAngle}°</span>
<input
type="range"
min="-360"
max="360"
value={gradientAngle}
onChange={(event) =>
setGradientAngle(Number(event.target.value))
}
/>
</label>
)}
{gradientKind === "radial" && (
<label className="field">
<span className="field-label">Radial shape</span>
<select
value={radialShape}
onChange={(event) =>
setRadialShape(event.target.value as "circle" | "ellipse")
}
>
<option value="ellipse">Ellipse</option>
<option value="circle">Circle</option>
</select>
</label>
)}
<label className="toggle-label">
<input
type="checkbox"
checked={gradientRepeating}
onChange={(event) => setGradientRepeating(event.target.checked)}
/>
<span>
<strong>Repeating</strong>
<small>Use repeating-* CSS syntax</small>
</span>
</label>
</div>
<div className="gradient-preview checkerboard"> <div className="gradient-preview checkerboard">
<div style={{ background: gradient }} /> <div style={{ background: gradient }} />
</div> </div>
<pre className="export-preview">
<code>{gradient}</code>
</pre>
<div className="button-row">
<CopyButton value={gradient} label="Copy gradient" />
<CopyButton value={gradientRecipe} label="Copy recipe JSON" />
<button
type="button"
onClick={() =>
downloadText(gradientCss, "colour-gradient.css", "text/css")
}
>
Download CSS
</button>
<button
type="button"
onClick={() =>
downloadText(
gradientRecipe,
"colour-gradient.json",
"application/json",
)
}
>
Download recipe
</button>
</div>
{calculation.error && ( {calculation.error && (
<p className="stale-notice" role="status"> <p className="stale-notice" role="status">
{calculation.error} Keeping the last valid ramp. {calculation.error} Keeping the last valid ramp.
+48
View File
@@ -1347,6 +1347,54 @@ body {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem; 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 { .palette-io-layout > section {
min-width: 0; min-width: 0;
} }
+41 -3
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.colour-tools", "id": "de.add-ideas.colour-tools",
"name": "Colour Tools", "name": "Colour Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Convert, composite, compare and build colours locally in the browser.", "description": "Convert, composite, compare and build colours locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -26,14 +26,52 @@
}, },
"requirements": { "requirements": {
"secureContext": false, "secureContext": false,
"workers": true, "workers": false,
"indexedDb": false, "indexedDb": false,
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": false, "fileUploads": true,
"telemetry": false, "telemetry": false,
"label": "Colours and images stay in this browser; nothing is uploaded." "label": "Colours and images stay in this browser; nothing is uploaded."
}, },
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+36 -2
View File
@@ -71,6 +71,14 @@ test("converts, composites and interpolates without transient empty results", as
await expect(page.locator(".step-swatch")).toHaveCount(9); await expect(page.locator(".step-swatch")).toHaveCount(9);
await page.getByLabel(/Swatches/u).fill("5"); await page.getByLabel(/Swatches/u).fill("5");
await expect(page.locator(".step-swatch")).toHaveCount(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(externalRequests).toEqual([]);
expect(runtimeErrors).toEqual([]); expect(runtimeErrors).toEqual([]);
@@ -123,9 +131,35 @@ test("keeps picking, accessibility analysis and palette data browser-local", asy
"--brand-primary", "--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.reload();
await page.getByRole("tab", { name: /Palette/u }).click(); 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(externalRequests).toEqual([]);
expect(runtimeErrors).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"); expect(manifest.headers()["content-type"]).toContain("application/json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.colour-tools", id: "de.add-ideas.colour-tools",
version: "0.1.0", version: "0.2.0",
entry: "./", entry: "./",
icon: "./favicon.svg", icon: "./favicon.svg",
}); });
+18
View File
@@ -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);
});
+164 -3
View File
@@ -2,11 +2,15 @@ import { describe, expect, it } from "vitest";
import { import {
colourHarmony, colourHarmony,
buildCssGradient,
deltaE, deltaE,
exportGradientRecipe,
exportPalette, exportPalette,
formatColour, formatColour,
normaliseTokenName, normaliseTokenName,
parsePaletteList, parsePaletteList,
parseDtcgTokens,
paletteContrastMatrix,
shades, shades,
simulateColourVision, simulateColourVision,
simulateColourVisionSet, simulateColourVisionSet,
@@ -99,13 +103,170 @@ describe("palette construction and export", () => {
paper: "#f8f5ee", paper: "#f8f5ee",
ink: "#111111", ink: "#111111",
}); });
expect(JSON.parse(exportPalette(entries, "tokens")).paper).toEqual({ expect(JSON.parse(exportPalette(entries, "tokens"))).toMatchObject({
$type: "color", $schema: "https://www.designtokens.org/schemas/2025.10/format.json",
$value: "#f8f5ee", paper: {
$type: "color",
$value: {
colorSpace: "srgb",
components: expect.any(Array),
hex: "#f8f5ee",
},
},
}); });
expect(exportPalette(entries, "tailwind")).toMatch(/^export default /); expect(exportPalette(entries, "tailwind")).toMatch(/^export default /);
expect(exportPalette(entries, "csv")).toBe( expect(exportPalette(entries, "csv")).toBe(
"name,value\npaper,#f8f5ee\nink,#111111", "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(/164/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 }],
});
});
}); });