okchroma 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,134 +1,95 @@
1
1
  # OKChroma
2
2
 
3
- **A color-system engine.** Give it one (or two) brand color(s) and it generates a complete,
4
- accessible, theme-ready color system around it.
5
-
6
- Each color family is a light and dark ramp built from pre-reserved roles — 3 papers,
7
- 4 highlighters, 1 crayon, 1 pencil, and 2 pens — plus the stamp fill with its hover and
8
- pressed states and its on-text color, for each brand hex inputted. Alongside the brand color(s), it generates a neutral and the critical / warning / positive / info signal scales. The inputted hex color(s) is/are preserved in identity swatches for logos or other brand identity moments.
9
-
10
- The point is **white-label predictability**: every ramps's stops land at the same
11
- perceived lightness and play the same role, so you map your design tokens to step
12
- **numbers** once and they hold for any color scale. Contrast is built into the math, not
13
- bolted on after: the system solves against **WCAG** ratios (every text color guaranteed
14
- to pass), with **APCA** as an added legibility floor on CTA text (Lc 65 clearance on top
15
- of the 4.5:1 requirement).
16
-
17
- Output comes in two interchangeable forms carrying the same values:
18
-
19
- - **CSS custom properties** — per-brand CSS generated live (`brandCss`) + the static
20
- `dist/signals.css`, via the semantic layer `tokens/semantic.css`.
21
- - **Figma variables** — written into a Figma file by the bundled plugin.
22
-
23
- The live demo and the Figma plugin are previews/front-ends; the engine and its output are
24
- the product.
25
-
26
- > The "reserved role per step" model is a conceptual nod to
3
+ **A color-system engine.** Give it one brand hex, or two, and it resolves a complete
4
+ light and dark token system whose contrast requirements are solved during generation,
5
+ then emits it as CSS custom properties, Figma variables, or DTCG requirement tokens.
6
+
7
+ Every family (neutral, brand, brand-alt, critical, warning, positive, info) carries the
8
+ same scale: 3 papers, 4 highlighters, 1 crayon, 1 pencil, 2 pens, plus the stamp (the
9
+ solid fill with its hover and pressed states, its edge, and its text). The neutral adds
10
+ the two poles. Light and dark resolve together and ship on the same names, so a token is
11
+ mapped once and holds for any brand.
12
+
13
+ Contrast is built into the math, not checked afterwards: `crayon-26` reads on every paper
14
+ at 3:1 (the non-text bar), `pencil-47` at 4.5:1, `pen-58` and `pen-70` at 4.5:1 on every
15
+ paper and highlighter in both directions; the stamp's text passes 4.5:1 on the fill and
16
+ brand and signal fills clear APCA Lc 65 on top. Each claim, its scope, and the audit that
17
+ proves it: [Guarantees](https://egerrity.github.io/okchroma/#/docs/guarantees).
18
+
19
+ > The reserved-role-per-stop model is a conceptual nod to
27
20
  > [Radix Colors](https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale).
28
- > It is **not** a dependency and does not affect the math — all color computation is original.
29
-
30
- ## Quick start
31
-
32
- ```bash
33
- npm install
34
- npm run demo:build # generate token CSS + bundle the demo
35
- npx serve . # then open http://localhost:3000/demo/index.html
36
- ```
37
-
38
- Live editing: `npm run dev` (watch mode). Build the Figma plugin: `npm run plugin:build`,
39
- then import `plugin/manifest.json` in Figma.
21
+ > It is not a dependency and does not touch the math; the color computation is original.
40
22
 
41
- ## Use as an npm package
23
+ ## Install
42
24
 
43
25
  ```bash
44
26
  npm install okchroma
45
27
  ```
46
28
 
47
- ```ts
48
- import { resolveTheme, brandCss, signalsCss } from 'okchroma'
29
+ ESM and CommonJS builds with TypeScript declarations; no runtime dependencies (the one
30
+ perceptual-distance library the engine uses is bundled in).
49
31
 
50
- const t = resolveTheme({ primaryHex: '#E93D82', name: 'Acme' })
51
- const css = brandCss('acme', 'Acme', t.themed) + '\n' + signalsCss()
32
+ ```ts
33
+ import { resolveTheme, brandCss, signalsCss, neutralTintHue } from 'okchroma'
34
+
35
+ const theme = resolveTheme({ primaryHex: '#E93D82', name: 'acme', deriveSecondary: true })
36
+ const neutralH = neutralTintHue(theme.themed.scale.brandH)
37
+ const css = brandCss('acme', 'Acme', theme.themed, theme.secondary?.scale ?? null,
38
+ '', 'default', undefined, theme.secondary?.style, false, null, true, neutralH)
39
+ + '\n' + signalsCss()
40
+ // put css in a stylesheet; set data-brand="acme" on the themed root,
41
+ // and data-theme="dark" on it for dark mode
52
42
  ```
53
43
 
54
- `resolveTheme` solves the full system for a seed (primary, optional secondary, neutral
55
- source, signal policy). `brandCss` emits the solved light and dark values as CSS custom
56
- properties scoped to `[data-brand="acme"]`, with dark values under
57
- `[data-brand="acme"][data-theme="dark"]`; the per-brand neutral is included in the same
58
- block. Variables are named `--brand-<token>`, `--neutral-<token>`, and
59
- `--<signal>-<token>`. Inject the string into a `<style>` tag at runtime or write it to a
60
- file at build time; `signalsCss()` is brand-independent and emitted once.
61
-
62
- The same resolved theme feeds the other emitters: `themeToFigma` returns the Figma
63
- variable payload, and `emitDtcgRamp` returns DTCG JSON carrying the live requirement in
64
- `$extensions`. ESM and CJS builds ship with TypeScript declarations. The package bundles
65
- its one runtime dependency (helmlab), so installing it adds nothing transitive.
66
-
67
- ## How it works (30 seconds)
68
-
69
- Every token is a **requirement the engine solves, not a frozen value**. A pure-data
70
- declaration (`src/engine/requirements/spec.ts`) states each stop's producer (perceptual
71
- placement, warm drift, chroma ladder) and its requirements (contrast floors, seam
72
- separations, on-text rules); a resolver executes it per seed — **produce → require →
73
- refine**. `resolveBrand(hex)` runs that engine, applies **policy** (status-color
74
- collisions, signal shifts), and an **emitter** (`cssRender` / `figmaRender`) maps the
75
- resolved stops onto named tokens and picks light vs dark. The declaration also
76
- round-trips to DTCG tokens (`$value` fallback + the live requirement in `$extensions`).
77
-
78
- - **Engine:** `src/engine/*` — pure TypeScript with one runtime dependency (helmlab,
79
- the P2 adjacency metric), bundled into the npm package at build time.
80
- - **Entry points:** `resolveBrand` (`src/engine/resolve.ts`) and `generateScale`
81
- (`src/engine/colorEngine.ts`, an adapter over the resolver — same signature as always).
82
- - **Build:** `src/build.ts` writes the one static output, `dist/signals.css`. There is no
83
- brand roster: per-brand CSS is generated live by whichever caller resolves a hex (the
84
- demo's hex input, a plugin's form field).
85
-
86
- ## The Figma plugin
87
-
88
- The bundled plugin runs the same engine inside Figma and writes the resolved system into
89
- your file as variables: **theme** + **mode** collections whose semantic tokens alias onto
90
- shared primitives — per-brand ramps under `brand/<name>`, with neutrals and signals
91
- deduplicated across brands, light + dark values on every token.
92
-
93
- **Install** — from source:
44
+ The Figma tree comes from `themeToFigma`, the DTCG requirement tokens from
45
+ `emitDtcgRamp`. Signatures, the full input, and an end-to-end example:
46
+ [Install and API](https://egerrity.github.io/okchroma/#/docs/install).
47
+
48
+ ## Run from source
94
49
 
95
50
  ```bash
96
- npm install && npm run plugin:build
51
+ npm install
52
+ npm run demo:build # writes dist/signals.css and bundles the demo
53
+ npx serve . # open http://localhost:3000/demo/index.html
54
+ npm run dev # watch mode
97
55
  ```
98
56
 
99
- then Figma → Plugins → Development → **Import plugin from manifest** →
100
- `plugin/manifest.json`.
101
-
102
- > The hosted download for **this** plugin is **withdrawn** as of 2026-07-29. The scale
103
- > changed shape in that round — `highlight-9` and `on-highlight` were removed and the pen
104
- > stops renumbered — and this plugin does not yet carry the rename table that migrates an
105
- > existing Figma file across it, so updating an installed copy would orphan its variable
106
- > bindings. Building from source is unaffected: a fresh file gets the new shape correctly.
107
- >
108
- > The **extended collections** plugin (Figma Enterprise) does carry the migration and is
109
- > published as normal — **[install it here](https://egerrity.github.io/okchroma/install.html)**.
110
-
111
- **Use** — name the brand, pick a primary, and Apply. Optional before applying:
112
-
113
- - **Secondary color** — "+ Add secondary" starts on *From primary* (a pastel derived from
114
- your primary); type any hex for a custom secondary in **Tint / Pastel / Outline / Exact**
115
- style.
116
- - **Neutral color** — *Default* (a touch of primary hue), *Medium* (slightly more tint), *Intense*, or *True grey*.
117
- - **Contrast standard** — **APCA** (default) or **WCAG**. One profile per collection pair:
118
- applying the other profile to an existing file forks a clearly-labeled second pair
119
- (`theme-wcag`/`mode-wcag`) instead of ever mixing values.
120
-
121
- Re-applying the same brand name updates it in place (after a confirm); a new name adds a
122
- brand to the same collections — two applies with two brand colors is a multi-brand system
123
- on shared foundations.
57
+ `npm run typecheck` runs the compiler; the audit gates (`npm run req:audit`,
58
+ `npm run audit:guarantee`, and the rest of `package.json`) each sweep agnostic seeds and
59
+ fail on the worst case. What each proves:
60
+ [How it is verified](https://egerrity.github.io/okchroma/#/docs/guarantees/how-it-is-verified).
124
61
 
125
62
  ## Documentation
126
63
 
127
- Full system documentation — overview, architecture (data flow + design decisions),
128
- dependencies, setup — is in **[docs/architecture.md](docs/architecture.md)**.
129
- The scale model and its per-stop targets are in **[docs/scale.md](docs/scale.md)**.
130
- The requirement-token schema — the DTCG token format, field by field, with real
131
- emitted examples — is in **[docs/schema.md](docs/schema.md)**.
64
+ The docs site is the source: [egerrity.github.io/okchroma/#/docs](https://egerrity.github.io/okchroma/#/docs).
65
+
66
+ - [Overview](https://egerrity.github.io/okchroma/#/docs/overview): what goes in, what comes out, the token roster.
67
+ - [Output contract](https://egerrity.github.io/okchroma/#/docs/output): the naming grammar, families and prefixes, modes and selectors, a live CSS block and Figma tree.
68
+ - [Guarantees](https://egerrity.github.io/okchroma/#/docs/guarantees): every claim stated exactly, and how it is verified.
69
+ - [How the theme is generated](https://egerrity.github.io/okchroma/#/docs/generation): the pipeline in execution order, with the constants and the code that runs each step.
70
+ - [Signals and companions](https://egerrity.github.io/okchroma/#/docs/signals), [Requirement tokens](https://egerrity.github.io/okchroma/#/docs/token-schema), [Reference](https://egerrity.github.io/okchroma/#/docs/reference).
71
+
72
+ In the repo: [docs/architecture.md](docs/architecture.md) (the maintainer's map: modules,
73
+ pipeline stages, data structures, the extended plugin's zones),
74
+ [docs/scale.md](docs/scale.md) (the scale and its declared targets),
75
+ [docs/schema.md](docs/schema.md) (the requirement-token format in the repo),
76
+ [docs/agents.md](docs/agents.md) (how a coding agent consuming the tokens should read
77
+ them), and [CHANGELOG.md](CHANGELOG.md).
78
+
79
+ ## The Figma plugins
80
+
81
+ - **OKChroma Extended** (`plugin-ext/`) is the shipped Figma front-end. It requires the
82
+ Figma desktop app and a Figma Enterprise plan: it writes extended variable collections,
83
+ one base collection with light and dark modes plus one extension per brand that
84
+ overrides only what differs. Download and install steps:
85
+ [install page](https://egerrity.github.io/okchroma/install.html). Build from source with
86
+ `npm run plugin-ext:build`; see [plugin-ext/README.md](plugin-ext/README.md).
87
+ - **OKChroma** (`plugin/`, the community plugin) is withdrawn from download until it
88
+ carries the rename table that migrates an existing file across the scale change of July
89
+ 2026. It still builds from source with `npm run plugin:build` and imports from
90
+ `plugin/manifest.json`; a fresh file gets the current shape.
91
+
92
+ The demo and the plugins are front-ends. The product is the engine and what it emits.
132
93
 
133
94
  ## License
134
95
 
@@ -947,9 +947,9 @@ var LIGHT = {
947
947
  { stop: 11, rootL: ROOT_L_LIGHT[11], group: groupOf(11), produce: PL_TEXT, chromaMult: SCALE_C_LIGHT[11].textMult, textMaxC: SCALE_C_LIGHT[11].textMaxC, chromaFloor: SCALE_C_LIGHT[11].chromaFloor, require: T11 }
948
948
  ],
949
949
  roles: [
950
- { role: "cta", produce: { hue: "constant", L: "anchor", chroma: "brand" }, floorL: 0, chromaMult: 1 },
951
- { role: "cta-hover", produce: { hue: "constant", L: "hover", chroma: "brand" }, floorL: 0, chromaMult: 1 },
952
- { role: "cta-pressed", produce: { hue: "constant", L: "pressed", chroma: "brand" }, floorL: 0, chromaMult: 1 }
950
+ { role: STAMP_FILL, produce: { hue: "constant", L: "anchor", chroma: "brand" }, floorL: 0, chromaMult: 1 },
951
+ { role: STAMP_FILL_HOVER, produce: { hue: "constant", L: "hover", chroma: "brand" }, floorL: 0, chromaMult: 1 },
952
+ { role: STAMP_FILL_PRESSED, produce: { hue: "constant", L: "pressed", chroma: "brand" }, floorL: 0, chromaMult: 1 }
953
953
  ],
954
954
  ons: ONS
955
955
  };
@@ -979,9 +979,9 @@ var DARK = {
979
979
  { stop: 11, rootL: ROOT_L_DARK[11], group: groupOf(11), produce: P_TEXT, chromaMult: SCALE_C_DARK[11].textMult, textMaxC: SCALE_C_DARK[11].textMaxC, chromaFloor: SCALE_C_DARK[11].chromaFloor, require: T11 }
980
980
  ],
981
981
  roles: [
982
- { role: "cta", produce: { hue: "constant", L: "anchor", chroma: "brand" }, floorL: DARK_CTA_MIN_L, chromaMult: 1 },
983
- { role: "cta-hover", produce: { hue: "constant", L: "hover", chroma: "brand" }, floorL: DARK_CTA_MIN_L, chromaMult: 1 },
984
- { role: "cta-pressed", produce: { hue: "constant", L: "pressed", chroma: "brand" }, floorL: DARK_CTA_MIN_L, chromaMult: 1 }
982
+ { role: STAMP_FILL, produce: { hue: "constant", L: "anchor", chroma: "brand" }, floorL: DARK_CTA_MIN_L, chromaMult: 1 },
983
+ { role: STAMP_FILL_HOVER, produce: { hue: "constant", L: "hover", chroma: "brand" }, floorL: DARK_CTA_MIN_L, chromaMult: 1 },
984
+ { role: STAMP_FILL_PRESSED, produce: { hue: "constant", L: "pressed", chroma: "brand" }, floorL: DARK_CTA_MIN_L, chromaMult: 1 }
985
985
  ],
986
986
  ons: ONS
987
987
  };
@@ -2358,6 +2358,10 @@ function solveDarkCtaExit(cur, cFor, darkH, redDark, enforceLc, coLc) {
2358
2358
  var oklchToSrgb = (L, C2, H2) => Object.values(srgbEmitChannels({ L, C: C2, H: H2 })).map((c) => Math.max(0, Math.min(1, c)));
2359
2359
  var toHex = (rgb) => "#" + rgb.map((c) => Math.round(c * 255).toString(16).padStart(2, "0")).join("");
2360
2360
  var CTA_CLEARANCE_CAPS = [0.05, 0.92];
2361
+ var CHROMATIC_W80_WORST_SHIP_Y = { light: 0.459357, dark: 0.074936 };
2362
+ var CHROMATIC_P3_WORST_SHIP_Y = { light: 0.836007, dark: 0.016041 };
2363
+ var NEUTRAL_P3_WORST_SHIP_Y = { light: 0.845015, dark: 0.014247 };
2364
+ var NEUTRAL_W80_WORST_SHIP_Y = { light: 0.506433, dark: 0.074262 };
2361
2365
  function resolveRamp(hex, mode, spec, opts) {
2362
2366
  const o = hexToOklch(hex);
2363
2367
  const seed = { hex, ...o };
@@ -2369,10 +2373,10 @@ function resolveRamp(hex, mode, spec, opts) {
2369
2373
  stops: spec.stops.map((s) => s.stop === 10 && s.require?.metric === "wcag" ? { ...s, require: { ...s.require, target: 6.5 } } : s)
2370
2374
  };
2371
2375
  }
2372
- const ctaReq = spec.roles.find((r) => r.role === "cta");
2373
- const hoverReq = spec.roles.find((r) => r.role === "cta-hover");
2374
- const pressedReq = spec.roles.find((r) => r.role === "cta-pressed");
2375
- if (!ctaReq) throw new Error("spec has no cta role");
2376
+ const ctaReq = spec.roles.find((r) => r.role === STAMP_FILL);
2377
+ const hoverReq = spec.roles.find((r) => r.role === STAMP_FILL_HOVER);
2378
+ const pressedReq = spec.roles.find((r) => r.role === STAMP_FILL_PRESSED);
2379
+ if (!ctaReq) throw new Error(`spec has no ${STAMP_FILL} role`);
2376
2380
  const onFillEnforce = ctx.opts?.enforceOnFillContrast ?? spec.ons.onFill.enforce;
2377
2381
  const dctx = mode === "dark" ? buildDarkContext(ctx, ctaReq.floorL) : null;
2378
2382
  const stops = [];
@@ -2391,8 +2395,6 @@ function resolveRamp(hex, mode, spec, opts) {
2391
2395
  const textGround = ctx.opts?.textGround?.[mode];
2392
2396
  const groundOf = (req, stop) => textGround && stop >= 9 ? textGround : refOf(wcagAnchorStop(req, stop), stop);
2393
2397
  const apcaGroundOf = (req, stop) => textGround && stop >= 9 ? textGround : refOf(declaredAnchor(req), stop);
2394
- const NEUTRAL_P3_WORST_SHIP_Y = { light: 0.845015, dark: 0.014247 };
2395
- const NEUTRAL_W80_WORST_SHIP_Y = { light: 0.506433, dark: 0.074262 };
2396
2398
  const maxLForOf = (req, forWhom, withMargin) => {
2397
2399
  if (req.metric === "wcag") {
2398
2400
  const g2 = groundOf(req, forWhom);
@@ -2501,11 +2503,18 @@ function resolveRamp(hex, mode, spec, opts) {
2501
2503
  const own = contrastRatio(y2, anchorShipY);
2502
2504
  return paperBound ? Math.min(own, contrastRatio(y2, crossBoundY)) : own;
2503
2505
  };
2504
- if (shipRatio(placed.L) < sp.require.target) {
2506
+ const highlighterBoundY = ctx.opts?.crossHighlighterBoundY && sp.stop >= 10 && !textGround ? ctx.opts.crossHighlighterBoundY[mode] : void 0;
2507
+ const highlighterRatio = (L) => highlighterBoundY === void 0 ? Infinity : contrastRatio(shippedY(L, placed.C, placed.H), highlighterBoundY);
2508
+ const paperBoundY = ctx.opts?.crossPaperBoundY && sp.stop >= 8 && !textGround ? ctx.opts.crossPaperBoundY[mode] : void 0;
2509
+ const paperBar = sp.stop === 8 ? STOP_8_NONTEXT_CONTRAST : sp.stop === 9 ? PENCIL_9_CONTRAST : PEN_10_CONTRAST;
2510
+ const paperRatio = (L) => paperBoundY === void 0 ? Infinity : contrastRatio(shippedY(L, placed.C, placed.H), paperBoundY);
2511
+ const target = sp.require.target;
2512
+ const clears = (L) => shipRatio(L) >= target && highlighterRatio(L) >= PEN_10_CONTRAST && paperRatio(L) >= paperBar;
2513
+ if (!clears(placed.L)) {
2505
2514
  const away = shippedY(placed.L, placed.C, placed.H) < anchorShipY ? -1 : 1;
2506
2515
  let L2 = placed.L;
2507
- for (let i = 0; i < 100 && shipRatio(L2) < sp.require.target; i++) L2 += away * 1e-3;
2508
- if (shipRatio(L2) >= sp.require.target) {
2516
+ for (let i = 0; i < 100 && !clears(L2); i++) L2 += away * 1e-3;
2517
+ if (clears(L2)) {
2509
2518
  placed = { ...placed, L: L2 };
2510
2519
  clamped = true;
2511
2520
  }
@@ -2516,6 +2525,8 @@ function resolveRamp(hex, mode, spec, opts) {
2516
2525
  shipRatio(placed.L)
2517
2526
  );
2518
2527
  if (got < sp.require.target - 1e-3) unresolvable = `stop ${sp.stop}: contrast ${got.toFixed(2)} < required ${sp.require.target}`;
2528
+ else if (highlighterRatio(placed.L) < PEN_10_CONTRAST - 1e-3) unresolvable = `stop ${sp.stop}: contrast ${highlighterRatio(placed.L).toFixed(2)} < required ${PEN_10_CONTRAST} on the worst chromatic highlighter-20`;
2529
+ else if (paperRatio(placed.L) < paperBar - 1e-3) unresolvable = `stop ${sp.stop}: contrast ${paperRatio(placed.L).toFixed(2)} < required ${paperBar} on the worst chromatic paper-5`;
2519
2530
  } else if (sp.require?.metric === "apca") {
2520
2531
  const ga = apcaGroundOf(sp.require, sp.stop);
2521
2532
  const refA = apcaYAt(ga.L, ga.C, ga.H);
@@ -2568,13 +2579,13 @@ function resolveRamp(hex, mode, spec, opts) {
2568
2579
  repelled = true;
2569
2580
  }
2570
2581
  }
2571
- cta = emitRole("cta", light9L, ctaCFor(light9L) * ctaCMul, ctaH);
2582
+ cta = emitRole(STAMP_FILL, light9L, ctaCFor(light9L) * ctaCMul, ctaH);
2572
2583
  const hCFor = (L) => ctx.cAt("light", L, (hoverReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul;
2573
2584
  const pCFor = (L) => ctx.cAt("light", L, (pressedReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul;
2574
2585
  const hL = stateFillL(light9L, "light", 1);
2575
2586
  const pL = stateFillL(light9L, "light", 2);
2576
- ctaHover = emitRole("cta-hover", hL, hCFor(hL), ctaH);
2577
- ctaPressed = emitRole("cta-pressed", pL, pCFor(pL), ctaH);
2587
+ ctaHover = emitRole(STAMP_FILL_HOVER, hL, hCFor(hL), ctaH);
2588
+ ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, pCFor(pL), ctaH);
2578
2589
  if (light9L !== ctx.scaleL) {
2579
2590
  cta.enforced = true;
2580
2591
  ctaHover.enforced = true;
@@ -2597,19 +2608,19 @@ function resolveRamp(hex, mode, spec, opts) {
2597
2608
  const cta9L = ctx.opts?.darkCtaFlatApp !== void 0 ? flatDarkCtaL(d, (L) => ctx.cAt("dark", L, d.darkC9), ctx.darkCtaH, ctx.opts.darkCtaFlatApp) : d.dark9L;
2598
2609
  const dCFor = (L) => ctx.cAt("dark", L, d.darkC9);
2599
2610
  const dTrio = (baseL) => [stateFillL(baseL, "dark", 1), stateFillL(baseL, "dark", 2)];
2600
- cta = emitRole("cta", cta9L, dCFor(cta9L), ctx.darkCtaH);
2611
+ cta = emitRole(STAMP_FILL, cta9L, dCFor(cta9L), ctx.darkCtaH);
2601
2612
  {
2602
2613
  const [hL, pL] = dTrio(cta9L);
2603
- ctaHover = emitRole("cta-hover", hL, dCFor(hL), ctx.darkCtaH);
2604
- ctaPressed = emitRole("cta-pressed", pL, dCFor(pL), ctx.darkCtaH);
2614
+ ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2615
+ ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2605
2616
  }
2606
2617
  onFillIsWhite = onFillIsWhiteDarkAt(cta.L, cta.C, cta.H, enforceLc !== void 0 ? false : onFillEnforce);
2607
2618
  const enforcedL = enforceLc !== void 0 ? ctaDarkEnforcedLApca(ctx, cta, onFillIsWhite, onFillEnforce, enforceLc) : ctaDarkEnforcedL(ctx, cta, onFillIsWhite, onFillEnforce);
2608
2619
  if (enforcedL !== null) {
2609
- cta = emitRole("cta", enforcedL, dCFor(enforcedL), ctx.darkCtaH);
2620
+ cta = emitRole(STAMP_FILL, enforcedL, dCFor(enforcedL), ctx.darkCtaH);
2610
2621
  const [hL, pL] = dTrio(enforcedL);
2611
- ctaHover = emitRole("cta-hover", hL, dCFor(hL), ctx.darkCtaH);
2612
- ctaPressed = emitRole("cta-pressed", pL, dCFor(pL), ctx.darkCtaH);
2622
+ ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2623
+ ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2613
2624
  cta.enforced = true;
2614
2625
  ctaHover.enforced = true;
2615
2626
  ctaPressed.enforced = true;
@@ -2618,10 +2629,10 @@ function resolveRamp(hex, mode, spec, opts) {
2618
2629
  const [capLoL, capHiL] = CTA_CLEARANCE_CAPS;
2619
2630
  const clearedL = ctaDarkDualGateL(cta, ctx.darkCtaH, onFillIsWhite, onFillEnforce, coLc + APCA_ENFORCE_MARGIN_LC, capLoL, capHiL);
2620
2631
  if (Math.abs(clearedL - cta.L) > 1e-9) {
2621
- cta = emitRole("cta", clearedL, dCFor(clearedL), ctx.darkCtaH);
2632
+ cta = emitRole(STAMP_FILL, clearedL, dCFor(clearedL), ctx.darkCtaH);
2622
2633
  const [hL, pL] = dTrio(clearedL);
2623
- ctaHover = emitRole("cta-hover", hL, dCFor(hL), ctx.darkCtaH);
2624
- ctaPressed = emitRole("cta-pressed", pL, dCFor(pL), ctx.darkCtaH);
2634
+ ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2635
+ ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2625
2636
  cta.enforced = true;
2626
2637
  ctaHover.enforced = true;
2627
2638
  ctaPressed.enforced = true;
@@ -2630,10 +2641,10 @@ function resolveRamp(hex, mode, spec, opts) {
2630
2641
  if (ctx.opts?.ctaSolve) {
2631
2642
  const exitL = solveDarkCtaExit(cta, dCFor, ctx.darkCtaH, ctx.opts.ctaSolve.redDark, enforceLc, coLc);
2632
2643
  if (exitL !== null) {
2633
- cta = emitRole("cta", exitL, dCFor(exitL), ctx.darkCtaH);
2644
+ cta = emitRole(STAMP_FILL, exitL, dCFor(exitL), ctx.darkCtaH);
2634
2645
  const [hL, pL] = dTrio(exitL);
2635
- ctaHover = emitRole("cta-hover", hL, dCFor(hL), ctx.darkCtaH);
2636
- ctaPressed = emitRole("cta-pressed", pL, dCFor(pL), ctx.darkCtaH);
2646
+ ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2647
+ ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2637
2648
  cta.enforced = true;
2638
2649
  ctaHover.enforced = true;
2639
2650
  ctaPressed.enforced = true;
@@ -2715,7 +2726,11 @@ function generateNeutralScale(brandH, level = "default", contrastProfile) {
2715
2726
  const scale = generateScale(grayHex, "neutral", "light", {
2716
2727
  chromaCurve: curve,
2717
2728
  enforceOnFillContrast: true,
2718
- contrastProfile
2729
+ contrastProfile,
2730
+ // T13: the neutral's pens clear every chromatic family's highlighter-20 (the symmetric
2731
+ // pen guarantee); the chromatic ramps clear the neutral's through NEUTRAL_W80_WORST_SHIP_Y
2732
+ crossHighlighterBoundY: CHROMATIC_W80_WORST_SHIP_Y,
2733
+ crossPaperBoundY: CHROMATIC_P3_WORST_SHIP_Y
2719
2734
  });
2720
2735
  const asCta = (stop, src) => makeStop(stop, src.L, src.C, src.H);
2721
2736
  scale.cta = asCta(9, scale.light[3]);
@@ -3962,9 +3977,14 @@ function themeToFigma(r, input) {
3962
3977
  // src/engine/requirements/dtcg.ts
3963
3978
  var EXT_KEY = "org.okchroma.requirement";
3964
3979
  var RESOLVER_ID = "okchroma-reqtoken@2";
3980
+ var LEGACY_ROLE_NAMES = {
3981
+ "cta": STAMP_FILL,
3982
+ "cta-hover": STAMP_FILL_HOVER,
3983
+ "cta-pressed": STAMP_FILL_PRESSED
3984
+ };
3965
3985
  var hexToValue = (hex) => {
3966
3986
  const h = hex.replace("#", "");
3967
- const c = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
3987
+ const c = (i) => Math.round(parseInt(h.slice(i, i + 2), 16) / 255 * 1e4) / 1e4;
3968
3988
  return { colorSpace: "srgb", components: [c(0), c(2), c(4)], alpha: 1, hex: hex.toLowerCase() };
3969
3989
  };
3970
3990
  function emitDtcgRamp(hex, mode, groupName, spec) {
@@ -3980,9 +4000,9 @@ function emitDtcgRamp(hex, mode, groupName, spec) {
3980
4000
  group[String(sp.stop)] = { $type: "color", $value: hexToValue(st.hex), $extensions: { [EXT_KEY]: { resolver: RESOLVER_ID, seed: seedRef, mode, ...sp } } };
3981
4001
  }
3982
4002
  const roleVal = {
3983
- "cta": ramp.roles.cta,
3984
- "cta-hover": ramp.roles.ctaHover,
3985
- "cta-pressed": ramp.roles.ctaPressed
4003
+ [STAMP_FILL]: ramp.roles.cta,
4004
+ [STAMP_FILL_HOVER]: ramp.roles.ctaHover,
4005
+ [STAMP_FILL_PRESSED]: ramp.roles.ctaPressed
3986
4006
  };
3987
4007
  for (const rr of spec.roles) {
3988
4008
  const rv = roleVal[rr.role];
@@ -4002,6 +4022,7 @@ function parseToken(token) {
4002
4022
  if (isRole) {
4003
4023
  for (const k of ["role", "produce", "floorL", "chromaMult"]) if (req[k] === void 0) throw new Error(`role requirement missing "${k}"`);
4004
4024
  }
4025
+ if (isRole && LEGACY_ROLE_NAMES[req.role]) req.role = LEGACY_ROLE_NAMES[req.role];
4005
4026
  return { req, seedRef: seed, mode };
4006
4027
  }
4007
4028
  function resolveDtcgRamp(group) {
@@ -868,9 +868,9 @@ var LIGHT = {
868
868
  { stop: 11, rootL: ROOT_L_LIGHT[11], group: groupOf(11), produce: PL_TEXT, chromaMult: SCALE_C_LIGHT[11].textMult, textMaxC: SCALE_C_LIGHT[11].textMaxC, chromaFloor: SCALE_C_LIGHT[11].chromaFloor, require: T11 }
869
869
  ],
870
870
  roles: [
871
- { role: "cta", produce: { hue: "constant", L: "anchor", chroma: "brand" }, floorL: 0, chromaMult: 1 },
872
- { role: "cta-hover", produce: { hue: "constant", L: "hover", chroma: "brand" }, floorL: 0, chromaMult: 1 },
873
- { role: "cta-pressed", produce: { hue: "constant", L: "pressed", chroma: "brand" }, floorL: 0, chromaMult: 1 }
871
+ { role: STAMP_FILL, produce: { hue: "constant", L: "anchor", chroma: "brand" }, floorL: 0, chromaMult: 1 },
872
+ { role: STAMP_FILL_HOVER, produce: { hue: "constant", L: "hover", chroma: "brand" }, floorL: 0, chromaMult: 1 },
873
+ { role: STAMP_FILL_PRESSED, produce: { hue: "constant", L: "pressed", chroma: "brand" }, floorL: 0, chromaMult: 1 }
874
874
  ],
875
875
  ons: ONS
876
876
  };
@@ -900,9 +900,9 @@ var DARK = {
900
900
  { stop: 11, rootL: ROOT_L_DARK[11], group: groupOf(11), produce: P_TEXT, chromaMult: SCALE_C_DARK[11].textMult, textMaxC: SCALE_C_DARK[11].textMaxC, chromaFloor: SCALE_C_DARK[11].chromaFloor, require: T11 }
901
901
  ],
902
902
  roles: [
903
- { role: "cta", produce: { hue: "constant", L: "anchor", chroma: "brand" }, floorL: DARK_CTA_MIN_L, chromaMult: 1 },
904
- { role: "cta-hover", produce: { hue: "constant", L: "hover", chroma: "brand" }, floorL: DARK_CTA_MIN_L, chromaMult: 1 },
905
- { role: "cta-pressed", produce: { hue: "constant", L: "pressed", chroma: "brand" }, floorL: DARK_CTA_MIN_L, chromaMult: 1 }
903
+ { role: STAMP_FILL, produce: { hue: "constant", L: "anchor", chroma: "brand" }, floorL: DARK_CTA_MIN_L, chromaMult: 1 },
904
+ { role: STAMP_FILL_HOVER, produce: { hue: "constant", L: "hover", chroma: "brand" }, floorL: DARK_CTA_MIN_L, chromaMult: 1 },
905
+ { role: STAMP_FILL_PRESSED, produce: { hue: "constant", L: "pressed", chroma: "brand" }, floorL: DARK_CTA_MIN_L, chromaMult: 1 }
906
906
  ],
907
907
  ons: ONS
908
908
  };
@@ -2279,6 +2279,10 @@ function solveDarkCtaExit(cur, cFor, darkH, redDark, enforceLc, coLc) {
2279
2279
  var oklchToSrgb = (L, C2, H2) => Object.values(srgbEmitChannels({ L, C: C2, H: H2 })).map((c) => Math.max(0, Math.min(1, c)));
2280
2280
  var toHex = (rgb) => "#" + rgb.map((c) => Math.round(c * 255).toString(16).padStart(2, "0")).join("");
2281
2281
  var CTA_CLEARANCE_CAPS = [0.05, 0.92];
2282
+ var CHROMATIC_W80_WORST_SHIP_Y = { light: 0.459357, dark: 0.074936 };
2283
+ var CHROMATIC_P3_WORST_SHIP_Y = { light: 0.836007, dark: 0.016041 };
2284
+ var NEUTRAL_P3_WORST_SHIP_Y = { light: 0.845015, dark: 0.014247 };
2285
+ var NEUTRAL_W80_WORST_SHIP_Y = { light: 0.506433, dark: 0.074262 };
2282
2286
  function resolveRamp(hex, mode, spec, opts) {
2283
2287
  const o = hexToOklch(hex);
2284
2288
  const seed = { hex, ...o };
@@ -2290,10 +2294,10 @@ function resolveRamp(hex, mode, spec, opts) {
2290
2294
  stops: spec.stops.map((s) => s.stop === 10 && s.require?.metric === "wcag" ? { ...s, require: { ...s.require, target: 6.5 } } : s)
2291
2295
  };
2292
2296
  }
2293
- const ctaReq = spec.roles.find((r) => r.role === "cta");
2294
- const hoverReq = spec.roles.find((r) => r.role === "cta-hover");
2295
- const pressedReq = spec.roles.find((r) => r.role === "cta-pressed");
2296
- if (!ctaReq) throw new Error("spec has no cta role");
2297
+ const ctaReq = spec.roles.find((r) => r.role === STAMP_FILL);
2298
+ const hoverReq = spec.roles.find((r) => r.role === STAMP_FILL_HOVER);
2299
+ const pressedReq = spec.roles.find((r) => r.role === STAMP_FILL_PRESSED);
2300
+ if (!ctaReq) throw new Error(`spec has no ${STAMP_FILL} role`);
2297
2301
  const onFillEnforce = ctx.opts?.enforceOnFillContrast ?? spec.ons.onFill.enforce;
2298
2302
  const dctx = mode === "dark" ? buildDarkContext(ctx, ctaReq.floorL) : null;
2299
2303
  const stops = [];
@@ -2312,8 +2316,6 @@ function resolveRamp(hex, mode, spec, opts) {
2312
2316
  const textGround = ctx.opts?.textGround?.[mode];
2313
2317
  const groundOf = (req, stop) => textGround && stop >= 9 ? textGround : refOf(wcagAnchorStop(req, stop), stop);
2314
2318
  const apcaGroundOf = (req, stop) => textGround && stop >= 9 ? textGround : refOf(declaredAnchor(req), stop);
2315
- const NEUTRAL_P3_WORST_SHIP_Y = { light: 0.845015, dark: 0.014247 };
2316
- const NEUTRAL_W80_WORST_SHIP_Y = { light: 0.506433, dark: 0.074262 };
2317
2319
  const maxLForOf = (req, forWhom, withMargin) => {
2318
2320
  if (req.metric === "wcag") {
2319
2321
  const g2 = groundOf(req, forWhom);
@@ -2422,11 +2424,18 @@ function resolveRamp(hex, mode, spec, opts) {
2422
2424
  const own = contrastRatio(y2, anchorShipY);
2423
2425
  return paperBound ? Math.min(own, contrastRatio(y2, crossBoundY)) : own;
2424
2426
  };
2425
- if (shipRatio(placed.L) < sp.require.target) {
2427
+ const highlighterBoundY = ctx.opts?.crossHighlighterBoundY && sp.stop >= 10 && !textGround ? ctx.opts.crossHighlighterBoundY[mode] : void 0;
2428
+ const highlighterRatio = (L) => highlighterBoundY === void 0 ? Infinity : contrastRatio(shippedY(L, placed.C, placed.H), highlighterBoundY);
2429
+ const paperBoundY = ctx.opts?.crossPaperBoundY && sp.stop >= 8 && !textGround ? ctx.opts.crossPaperBoundY[mode] : void 0;
2430
+ const paperBar = sp.stop === 8 ? STOP_8_NONTEXT_CONTRAST : sp.stop === 9 ? PENCIL_9_CONTRAST : PEN_10_CONTRAST;
2431
+ const paperRatio = (L) => paperBoundY === void 0 ? Infinity : contrastRatio(shippedY(L, placed.C, placed.H), paperBoundY);
2432
+ const target = sp.require.target;
2433
+ const clears = (L) => shipRatio(L) >= target && highlighterRatio(L) >= PEN_10_CONTRAST && paperRatio(L) >= paperBar;
2434
+ if (!clears(placed.L)) {
2426
2435
  const away = shippedY(placed.L, placed.C, placed.H) < anchorShipY ? -1 : 1;
2427
2436
  let L2 = placed.L;
2428
- for (let i = 0; i < 100 && shipRatio(L2) < sp.require.target; i++) L2 += away * 1e-3;
2429
- if (shipRatio(L2) >= sp.require.target) {
2437
+ for (let i = 0; i < 100 && !clears(L2); i++) L2 += away * 1e-3;
2438
+ if (clears(L2)) {
2430
2439
  placed = { ...placed, L: L2 };
2431
2440
  clamped = true;
2432
2441
  }
@@ -2437,6 +2446,8 @@ function resolveRamp(hex, mode, spec, opts) {
2437
2446
  shipRatio(placed.L)
2438
2447
  );
2439
2448
  if (got < sp.require.target - 1e-3) unresolvable = `stop ${sp.stop}: contrast ${got.toFixed(2)} < required ${sp.require.target}`;
2449
+ else if (highlighterRatio(placed.L) < PEN_10_CONTRAST - 1e-3) unresolvable = `stop ${sp.stop}: contrast ${highlighterRatio(placed.L).toFixed(2)} < required ${PEN_10_CONTRAST} on the worst chromatic highlighter-20`;
2450
+ else if (paperRatio(placed.L) < paperBar - 1e-3) unresolvable = `stop ${sp.stop}: contrast ${paperRatio(placed.L).toFixed(2)} < required ${paperBar} on the worst chromatic paper-5`;
2440
2451
  } else if (sp.require?.metric === "apca") {
2441
2452
  const ga = apcaGroundOf(sp.require, sp.stop);
2442
2453
  const refA = apcaYAt(ga.L, ga.C, ga.H);
@@ -2489,13 +2500,13 @@ function resolveRamp(hex, mode, spec, opts) {
2489
2500
  repelled = true;
2490
2501
  }
2491
2502
  }
2492
- cta = emitRole("cta", light9L, ctaCFor(light9L) * ctaCMul, ctaH);
2503
+ cta = emitRole(STAMP_FILL, light9L, ctaCFor(light9L) * ctaCMul, ctaH);
2493
2504
  const hCFor = (L) => ctx.cAt("light", L, (hoverReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul;
2494
2505
  const pCFor = (L) => ctx.cAt("light", L, (pressedReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul;
2495
2506
  const hL = stateFillL(light9L, "light", 1);
2496
2507
  const pL = stateFillL(light9L, "light", 2);
2497
- ctaHover = emitRole("cta-hover", hL, hCFor(hL), ctaH);
2498
- ctaPressed = emitRole("cta-pressed", pL, pCFor(pL), ctaH);
2508
+ ctaHover = emitRole(STAMP_FILL_HOVER, hL, hCFor(hL), ctaH);
2509
+ ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, pCFor(pL), ctaH);
2499
2510
  if (light9L !== ctx.scaleL) {
2500
2511
  cta.enforced = true;
2501
2512
  ctaHover.enforced = true;
@@ -2518,19 +2529,19 @@ function resolveRamp(hex, mode, spec, opts) {
2518
2529
  const cta9L = ctx.opts?.darkCtaFlatApp !== void 0 ? flatDarkCtaL(d, (L) => ctx.cAt("dark", L, d.darkC9), ctx.darkCtaH, ctx.opts.darkCtaFlatApp) : d.dark9L;
2519
2530
  const dCFor = (L) => ctx.cAt("dark", L, d.darkC9);
2520
2531
  const dTrio = (baseL) => [stateFillL(baseL, "dark", 1), stateFillL(baseL, "dark", 2)];
2521
- cta = emitRole("cta", cta9L, dCFor(cta9L), ctx.darkCtaH);
2532
+ cta = emitRole(STAMP_FILL, cta9L, dCFor(cta9L), ctx.darkCtaH);
2522
2533
  {
2523
2534
  const [hL, pL] = dTrio(cta9L);
2524
- ctaHover = emitRole("cta-hover", hL, dCFor(hL), ctx.darkCtaH);
2525
- ctaPressed = emitRole("cta-pressed", pL, dCFor(pL), ctx.darkCtaH);
2535
+ ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2536
+ ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2526
2537
  }
2527
2538
  onFillIsWhite = onFillIsWhiteDarkAt(cta.L, cta.C, cta.H, enforceLc !== void 0 ? false : onFillEnforce);
2528
2539
  const enforcedL = enforceLc !== void 0 ? ctaDarkEnforcedLApca(ctx, cta, onFillIsWhite, onFillEnforce, enforceLc) : ctaDarkEnforcedL(ctx, cta, onFillIsWhite, onFillEnforce);
2529
2540
  if (enforcedL !== null) {
2530
- cta = emitRole("cta", enforcedL, dCFor(enforcedL), ctx.darkCtaH);
2541
+ cta = emitRole(STAMP_FILL, enforcedL, dCFor(enforcedL), ctx.darkCtaH);
2531
2542
  const [hL, pL] = dTrio(enforcedL);
2532
- ctaHover = emitRole("cta-hover", hL, dCFor(hL), ctx.darkCtaH);
2533
- ctaPressed = emitRole("cta-pressed", pL, dCFor(pL), ctx.darkCtaH);
2543
+ ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2544
+ ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2534
2545
  cta.enforced = true;
2535
2546
  ctaHover.enforced = true;
2536
2547
  ctaPressed.enforced = true;
@@ -2539,10 +2550,10 @@ function resolveRamp(hex, mode, spec, opts) {
2539
2550
  const [capLoL, capHiL] = CTA_CLEARANCE_CAPS;
2540
2551
  const clearedL = ctaDarkDualGateL(cta, ctx.darkCtaH, onFillIsWhite, onFillEnforce, coLc + APCA_ENFORCE_MARGIN_LC, capLoL, capHiL);
2541
2552
  if (Math.abs(clearedL - cta.L) > 1e-9) {
2542
- cta = emitRole("cta", clearedL, dCFor(clearedL), ctx.darkCtaH);
2553
+ cta = emitRole(STAMP_FILL, clearedL, dCFor(clearedL), ctx.darkCtaH);
2543
2554
  const [hL, pL] = dTrio(clearedL);
2544
- ctaHover = emitRole("cta-hover", hL, dCFor(hL), ctx.darkCtaH);
2545
- ctaPressed = emitRole("cta-pressed", pL, dCFor(pL), ctx.darkCtaH);
2555
+ ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2556
+ ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2546
2557
  cta.enforced = true;
2547
2558
  ctaHover.enforced = true;
2548
2559
  ctaPressed.enforced = true;
@@ -2551,10 +2562,10 @@ function resolveRamp(hex, mode, spec, opts) {
2551
2562
  if (ctx.opts?.ctaSolve) {
2552
2563
  const exitL = solveDarkCtaExit(cta, dCFor, ctx.darkCtaH, ctx.opts.ctaSolve.redDark, enforceLc, coLc);
2553
2564
  if (exitL !== null) {
2554
- cta = emitRole("cta", exitL, dCFor(exitL), ctx.darkCtaH);
2565
+ cta = emitRole(STAMP_FILL, exitL, dCFor(exitL), ctx.darkCtaH);
2555
2566
  const [hL, pL] = dTrio(exitL);
2556
- ctaHover = emitRole("cta-hover", hL, dCFor(hL), ctx.darkCtaH);
2557
- ctaPressed = emitRole("cta-pressed", pL, dCFor(pL), ctx.darkCtaH);
2567
+ ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2568
+ ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2558
2569
  cta.enforced = true;
2559
2570
  ctaHover.enforced = true;
2560
2571
  ctaPressed.enforced = true;
@@ -2636,7 +2647,11 @@ function generateNeutralScale(brandH, level = "default", contrastProfile) {
2636
2647
  const scale = generateScale(grayHex, "neutral", "light", {
2637
2648
  chromaCurve: curve,
2638
2649
  enforceOnFillContrast: true,
2639
- contrastProfile
2650
+ contrastProfile,
2651
+ // T13: the neutral's pens clear every chromatic family's highlighter-20 (the symmetric
2652
+ // pen guarantee); the chromatic ramps clear the neutral's through NEUTRAL_W80_WORST_SHIP_Y
2653
+ crossHighlighterBoundY: CHROMATIC_W80_WORST_SHIP_Y,
2654
+ crossPaperBoundY: CHROMATIC_P3_WORST_SHIP_Y
2640
2655
  });
2641
2656
  const asCta = (stop, src) => makeStop(stop, src.L, src.C, src.H);
2642
2657
  scale.cta = asCta(9, scale.light[3]);
@@ -3883,9 +3898,14 @@ function themeToFigma(r, input) {
3883
3898
  // src/engine/requirements/dtcg.ts
3884
3899
  var EXT_KEY = "org.okchroma.requirement";
3885
3900
  var RESOLVER_ID = "okchroma-reqtoken@2";
3901
+ var LEGACY_ROLE_NAMES = {
3902
+ "cta": STAMP_FILL,
3903
+ "cta-hover": STAMP_FILL_HOVER,
3904
+ "cta-pressed": STAMP_FILL_PRESSED
3905
+ };
3886
3906
  var hexToValue = (hex) => {
3887
3907
  const h = hex.replace("#", "");
3888
- const c = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
3908
+ const c = (i) => Math.round(parseInt(h.slice(i, i + 2), 16) / 255 * 1e4) / 1e4;
3889
3909
  return { colorSpace: "srgb", components: [c(0), c(2), c(4)], alpha: 1, hex: hex.toLowerCase() };
3890
3910
  };
3891
3911
  function emitDtcgRamp(hex, mode, groupName, spec) {
@@ -3901,9 +3921,9 @@ function emitDtcgRamp(hex, mode, groupName, spec) {
3901
3921
  group[String(sp.stop)] = { $type: "color", $value: hexToValue(st.hex), $extensions: { [EXT_KEY]: { resolver: RESOLVER_ID, seed: seedRef, mode, ...sp } } };
3902
3922
  }
3903
3923
  const roleVal = {
3904
- "cta": ramp.roles.cta,
3905
- "cta-hover": ramp.roles.ctaHover,
3906
- "cta-pressed": ramp.roles.ctaPressed
3924
+ [STAMP_FILL]: ramp.roles.cta,
3925
+ [STAMP_FILL_HOVER]: ramp.roles.ctaHover,
3926
+ [STAMP_FILL_PRESSED]: ramp.roles.ctaPressed
3907
3927
  };
3908
3928
  for (const rr of spec.roles) {
3909
3929
  const rv = roleVal[rr.role];
@@ -3923,6 +3943,7 @@ function parseToken(token) {
3923
3943
  if (isRole) {
3924
3944
  for (const k of ["role", "produce", "floorL", "chromaMult"]) if (req[k] === void 0) throw new Error(`role requirement missing "${k}"`);
3925
3945
  }
3946
+ if (isRole && LEGACY_ROLE_NAMES[req.role]) req.role = LEGACY_ROLE_NAMES[req.role];
3926
3947
  return { req, seedRef: seed, mode };
3927
3948
  }
3928
3949
  function resolveDtcgRamp(group) {
@@ -89,6 +89,14 @@ export interface GenerateOptions {
89
89
  H: number;
90
90
  };
91
91
  };
92
+ crossHighlighterBoundY?: {
93
+ light: number;
94
+ dark: number;
95
+ };
96
+ crossPaperBoundY?: {
97
+ light: number;
98
+ dark: number;
99
+ };
92
100
  deltaHKPlace?: boolean;
93
101
  deltaChromaEq?: boolean;
94
102
  deltaLiftFloor?: boolean;
@@ -43,4 +43,21 @@ export type ResolvedRamp = {
43
43
  };
44
44
  };
45
45
  export type { ResolveOpts };
46
+ export declare const CTA_CLEARANCE_CAPS: [number, number];
47
+ export declare const CHROMATIC_W80_WORST_SHIP_Y: {
48
+ readonly light: 0.459357;
49
+ readonly dark: 0.074936;
50
+ };
51
+ export declare const CHROMATIC_P3_WORST_SHIP_Y: {
52
+ readonly light: 0.836007;
53
+ readonly dark: 0.016041;
54
+ };
55
+ export declare const NEUTRAL_P3_WORST_SHIP_Y: {
56
+ readonly light: 0.845015;
57
+ readonly dark: 0.014247;
58
+ };
59
+ export declare const NEUTRAL_W80_WORST_SHIP_Y: {
60
+ readonly light: 0.506433;
61
+ readonly dark: 0.074262;
62
+ };
46
63
  export declare function resolveRamp(hex: string, mode: 'light' | 'dark', spec?: ModeSpec, opts?: ResolveOpts): ResolvedRamp;
@@ -1,3 +1,4 @@
1
+ import { STAMP_FILL, STAMP_FILL_HOVER, STAMP_FILL_PRESSED } from '../tokenNames';
1
2
  export type Group = 'paper' | 'highlighter' | 'crayon' | 'pencil' | 'pen';
2
3
  export declare const textLane: (g: string) => boolean;
3
4
  export type Producer = {
@@ -31,7 +32,7 @@ export type StopReq = {
31
32
  chromaFloor?: number;
32
33
  require?: Require;
33
34
  };
34
- export type RoleName = 'cta' | 'cta-hover' | 'cta-pressed';
35
+ export type RoleName = typeof STAMP_FILL | typeof STAMP_FILL_HOVER | typeof STAMP_FILL_PRESSED;
35
36
  export type RoleReq = {
36
37
  role: RoleName;
37
38
  produce: {
@@ -1,8 +1,25 @@
1
1
  import { type GeneratedScale, type ContrastProfile } from './colorEngine';
2
2
  import type { SignalDef } from './signals';
3
+ type Side = {
4
+ kind: 'swap';
5
+ note: string;
6
+ baseHex: string;
7
+ } | {
8
+ kind: 'shift';
9
+ note: string;
10
+ } | {
11
+ kind: 'none';
12
+ };
13
+ interface SignalShiftRule {
14
+ splitH: number;
15
+ below: Side;
16
+ atOrAbove: Side;
17
+ }
18
+ export declare const SHIFT_RULES: Partial<Record<SignalDef['name'], SignalShiftRule>>;
3
19
  export interface ShiftResult {
4
20
  scale: GeneratedScale;
5
21
  note: string;
6
22
  }
7
23
  export declare function signalSwapVariants(def: SignalDef, contrastProfile?: ContrastProfile): ShiftResult[];
8
24
  export declare function pickSignalShift(brand: GeneratedScale, canonicalSignalScale: GeneratedScale, def: SignalDef, contrastProfile?: ContrastProfile): ShiftResult | null;
25
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okchroma",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "description": "Color token engine: one brand hex in, a full WCAG-enforced light+dark token system out (CSS variables, DTCG JSON, Figma variables)",
6
6
  "repository": {
@@ -53,7 +53,8 @@
53
53
  "audit:desc": "esbuild scripts/desc-audit.ts --bundle --platform=node --outfile=dist/desc-audit.js && node dist/desc-audit.js",
54
54
  "name-lint": "esbuild scripts/name-lint.ts --bundle --platform=node --outfile=dist/name-lint.js && node dist/name-lint.js",
55
55
  "typecheck": "tsc --noEmit",
56
- "typecheck:ext": "tsc --noEmit -p plugin-ext"
56
+ "typecheck:ext": "tsc --noEmit -p plugin-ext",
57
+ "docs:lint": "esbuild scripts/docs-lint.ts --bundle --platform=node --outfile=dist/docs-lint.js && node dist/docs-lint.js"
57
58
  },
58
59
  "devDependencies": {
59
60
  "@types/node": "^25.9.2",