Release Colour Tools 0.2.0
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 2–64 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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Generated
+16
-16
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 2–64 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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 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 {
|
interface Candidate {
|
||||||
colour: ColourValue;
|
colour: ColourValue;
|
||||||
ratio: number;
|
ratio: number;
|
||||||
|
|||||||
@@ -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 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<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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.2.0";
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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({
|
||||||
|
$schema: "https://www.designtokens.org/schemas/2025.10/format.json",
|
||||||
|
paper: {
|
||||||
$type: "color",
|
$type: "color",
|
||||||
$value: "#f8f5ee",
|
$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(/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 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user