okchroma 0.9.0 → 0.9.2

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,30 +1,18 @@
1
1
  # OKChroma
2
2
 
3
- **A color-system engine.** Give it one brand hex, or two, and it resolves a complete set
4
- of light and dark color primitives whose contrast requirements are solved during
5
- generation, then emits it as CSS custom properties, as DTCG documents, and as Figma
6
- variables, every token spelled the same way in each.
7
-
8
- Every family (neutral, brand, brand-alt, critical, warning, positive, info) carries the
9
- same scale: 3 papers, 4 chalks, 1 highlighter, 1 pencil, 2 pens, plus the stamp (the
10
- solid fill at rest and at one and two steps, its edge, and its text). The neutral adds
11
- the two poles. Two link trios and the two brand seeds complete the set. Light and dark
12
- resolve together and ship on the same names, so a token is mapped once and holds for any
13
- brand.
14
-
15
- Contrast is built into the math, not checked afterwards: highlighter reads on every paper at
16
- 3:1 (the non-text bar), pencil at 4.5:1, pen at 4.5:1 on every paper and chalk in
17
- both directions; the stamp's text passes 4.5:1 on its fill. APCA is used once, as a booster
18
- that nudges the stamp fill until its text reads at Lc 65. Each claim, its scope, and the audit that
19
- proves it: [Guarantees](https://egerrity.github.io/okchroma/#/docs/guarantees).
20
-
21
- The engine emits primitives only: values it calculates from the seed. Elevation planes,
22
- shadows, opacity ladders and state layers are a design system's to author on top of these,
23
- and [docs/agents.md](docs/agents.md) says which primitive each of those decisions reads.
3
+ okchroma is a color engine. A fixed set of formulas generates a color system's base
4
+ tokens from a seed color and a set of options. Each token gets a light and a dark value,
5
+ and the contrast requirements are solved while the values are generated. The set is
6
+ emitted as CSS custom properties, as DTCG documents, and as Figma variables.
24
7
 
25
- > The reserved-role-per-stop model is a conceptual nod to
26
- > [Radix Colors](https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale).
27
- > It is not a dependency and does not touch the math; the color computation is original.
8
+ The seed is one brand hex. The options add a second seed or derive a companion from the
9
+ first, pin a family's fill to a lightness anchor, and pick the contrast profile. They are
10
+ listed with the API: [Install and API](https://egerrity.github.io/okchroma/#/docs/install).
11
+
12
+ okchroma writes base tokens, designed for use under any semantic layer, including one
13
+ that must cover many themes. It writes no semantic tokens. The semantic tokens in this
14
+ repo, in [demo/semantic.css](demo/semantic.css), style the demo site and are not engine
15
+ output.
28
16
 
29
17
  ## Install
30
18
 
@@ -32,8 +20,9 @@ and [docs/agents.md](docs/agents.md) says which primitive each of those decision
32
20
  npm install okchroma
33
21
  ```
34
22
 
35
- ESM and CommonJS builds with TypeScript declarations; no runtime dependencies (the one
36
- perceptual-distance library the engine uses is bundled in). Node 18 or later; no DOM.
23
+ The package ships ESM and CommonJS builds with TypeScript declarations. It has no
24
+ runtime dependencies: the one library the engine uses, for perceptual color distance, is
25
+ bundled at build time. Node 18 or later; no DOM.
37
26
 
38
27
  ```ts
39
28
  import { resolveTheme, brandCss, signalsCss, themeTokens, tokensToDtcg, neutralTintHue } from 'okchroma'
@@ -58,107 +47,149 @@ const { light, dark } = tokensToDtcg(tokens)
58
47
  The Figma tree comes from `themeToFigma`. Signatures, the full input, and an end-to-end
59
48
  example: [Install and API](https://egerrity.github.io/okchroma/#/docs/install).
60
49
 
61
- ## What it emits
62
-
63
- Every token has one path, and every output spells it: `brand/stamp-0`,
64
- `neutral/paper-0`, `link/default-0`, `absolute/brand`. A path is a group and one word, and
65
- the word is a position: a band at a lightness, the stamp at a step, a link posture at a
66
- step. No name carries a state word; a semantic layer names the states and aliases the
67
- steps. Joined with hyphens the path is the CSS custom property (`--brand-stamp-0`); joined
68
- with slashes it is the Figma variable and the DTCG token path. A consumer whose paths
69
- start with the category puts a `color` group in front: the extended plugin writes
70
- `color/brand/stamp-0`, and the DTCG documents nest under the same word when asked. The
71
- CSS names never carry it.
72
-
73
- | Group | Tokens |
74
- |---|---|
75
- | `neutral` | `paper-0`, `paper-1`, `paper-3`, `paper-5`, `chalk-8`, `chalk-11`, `chalk-15`, `chalk-20`, `highlighter-26`, `pencil-47`, `pen-58`, `pen-70`, `pen-100`, and the stamp: `stamp-0`, `stamp-1`, `stamp-2`, `stamp-edge`, `stamp-on` |
76
- | `brand`, `brand-alt`, `critical`, `warning`, `positive`, `info` | the same eleven stops and the same five stamp tokens |
77
- | `link` | `default-0`, `default-1`, `default-2` for text on the papers; `inverse-0`, `inverse-1`, `inverse-2` for text on the pen ground; the link at rest, one step from rest, two steps |
78
- | `absolute` | `brand`, `brand-alt`: the seeds as given, reference values |
79
-
80
- What each name means, which stop to read for which job, and the rules for an agent
81
- writing UI against these: [docs/agents.md](docs/agents.md). The scale and its declared
82
- targets: [docs/scale.md](docs/scale.md).
83
-
84
- **The stops.** The instrument word is the job, the number is 100 minus the light-mode
85
- lightness target, and bigger is stronger. Papers are backgrounds; chalks are decorative
86
- borders and illustration, never text; the highlighter is the non-text contrast stop (focus
87
- rings, icons, large text, and the one stop to build translucent state layers on); the
88
- pencil and the pens are text, and the pencil doubles as the emphasis fill.
89
-
90
- **The stamp.** `stamp-0` is the call-to-action fill at rest, re-solved per family and
91
- theme; `stamp-1` and `stamp-2` are the fill one and two steps from rest, one apparent step
92
- each; `stamp-on` is the text over it, the pole that passes (the pole at alpha on a quiet
93
- fill); `stamp-edge` is a stroke that resolves to
94
- a visible offset only where the fill sits close to the page, else transparent, so a
95
- component renders the border unconditionally and layout never shifts.
96
-
97
- **The outputs.**
98
-
99
- - CSS: `brandCss` writes `[data-brand="acme"]` and `[data-brand="acme"][data-theme="dark"]`
100
- blocks, `signalsCss` the brand-independent signal block at `:root`. Values are sRGB
101
- hexes (or `rgba()` where a token carries alpha); stops whose chroma exceeds sRGB get a
102
- `color(display-p3 …)` override behind a browser gate. The repo's `npm run generate`
103
- writes the signal block to `dist/signals.css`.
104
- - The structured emit: `themeTokens` reads the CSS emission back into one object per
105
- mode with every reference resolved, for consumers with no cascade.
106
- - DTCG: `tokensToDtcg` returns one Design Tokens Format Module 2025.10 document per mode,
107
- every token with `$type`, an sRGB `$value` whose components and hex are the same 8-bit
108
- color (or an alias where the CSS writes a reference), and a `$description`. The two join
109
- through a resolver document. The format: [docs/schema.md](docs/schema.md). The repo's
110
- `npm run tokens:emit -- '#E93D82' acme` writes both files. Pass `{ rootGroup: 'color' }`,
111
- or `--root color` to the script, to nest every path and alias under that group.
112
- - Figma: `themeToFigma` returns a light and a dark group tree on the same paths; the
113
- extended plugin writes it into a file.
50
+ ## The tokens
51
+
52
+ Every family emits the same set of names, so a name says the token's job whichever
53
+ family carries it. The families are `neutral`, `brand`, `brand-alt`, `critical`,
54
+ `warning`, `positive` and `info`; the neutral adds the two poles. The link rows and the
55
+ seeds sit outside the families.
56
+
57
+ | Token | Job | Contrast guarantee |
58
+ |---|---|---|
59
+ | `{family}/paper-1` | background | none; the contrast stops are measured against the papers |
60
+ | `{family}/paper-3` | background | none |
61
+ | `{family}/paper-5` | background; the paper the contrast stops are solved against | none |
62
+ | `{family}/chalk-8` | decorative border, illustration, ground for inverted text; never text | none; the pens are measured against the chalks |
63
+ | `{family}/chalk-11` | the same | none |
64
+ | `{family}/chalk-15` | the same | none |
65
+ | `{family}/chalk-20` | the same | none |
66
+ | `{family}/highlighter-26` | focus ring, icon, large text; at a translucency, the state layer | 3:1 on the papers, the non-text bar |
67
+ | `{family}/pencil-47` | regular text; the emphasis fill | 4.5:1 on the papers |
68
+ | `{family}/pen-58` | text; inverted background | 4.5:1 on the papers and the chalks, both directions |
69
+ | `{family}/pen-70` | heavy-emphasis text; inverted background | 4.5:1 on the papers and the chalks, both directions |
70
+ | `neutral/paper-0` | white in light; in dark, the plane one seam below `paper-1` | none stated |
71
+ | `neutral/pen-100` | black in light, white in dark; the strongest text | none stated |
72
+ | `{family}/stamp-0` | the call-to-action fill at rest, solved per family and mode | `stamp-on` reads on it at 4.5:1 |
73
+ | `{family}/stamp-1` | the fill one step from rest | `stamp-on` reads on it at 4.5:1 |
74
+ | `{family}/stamp-2` | the fill two steps from rest | `stamp-on` reads on it at 4.5:1 |
75
+ | `{family}/stamp-edge` | a stroke: an offset where the fill sits close to the page, transparent elsewhere, so a component always renders it | none stated |
76
+ | `{family}/stamp-on` | the text on the fill, whichever pole passes; at alpha on a quiet fill | 4.5:1 on the three fills |
77
+ | `link/default-0` | link text on the papers, at rest | none stated |
78
+ | `link/default-1` | one step from rest | none stated |
79
+ | `link/default-2` | two steps from rest | none stated |
80
+ | `link/inverse-0` | link text on the pen ground, at rest | none stated |
81
+ | `link/inverse-1` | one step from rest | none stated |
82
+ | `link/inverse-2` | two steps from rest | none stated |
83
+ | `absolute/brand` | the seed as given; a reference value, not a UI color | none stated |
84
+ | `absolute/brand-alt` | the second seed as given | none stated |
85
+
86
+ The papers and chalks a stop is measured against are its own family's and the neutral's.
87
+ APCA is used once: the stamp fill also moves until its text clears Lc 65. The full
88
+ claims and their audits: [Guarantees](https://egerrity.github.io/okchroma/#/docs/guarantees).
89
+ The lightness targets behind the numbers: [docs/scale.md](docs/scale.md).
114
90
 
115
- ## Run from source
91
+ > The reserved-role-per-stop model is a conceptual nod to
92
+ > [Radix Colors](https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale).
93
+ > It is not a dependency and does not touch the math; the color computation is original.
94
+
95
+ ## The grammar
96
+
97
+ A name is a group and a leaf:
116
98
 
117
- ```bash
118
- npm install
119
- npm run demo:build # writes dist/signals.css and bundles the demo
120
- npx serve . # open http://localhost:3000/demo/index.html
121
- npm run dev # watch mode
99
+ ```
100
+ group/leaf
122
101
  ```
123
102
 
124
- `npm run typecheck` runs the compiler; the audit gates (`npm run req:audit`,
125
- `npm run audit:guarantee`, `npm run audit:dtcg`, and the rest of `package.json`) each
126
- sweep agnostic seeds and fail on the worst case. What each proves:
127
- [How it is verified](https://egerrity.github.io/okchroma/#/docs/guarantees/how-it-is-verified).
103
+ The group is a family, `link`, or `absolute`. The leaf names a position, never a state;
104
+ a consumer's semantic layer names the states and aliases the positions.
105
+
106
+ | Leaf | Pattern | Example |
107
+ |---|---|---|
108
+ | a scale stop | `{instrument}-{number}` | `pen-58` |
109
+ | a stamp fill | `stamp-{step}` | `stamp-0` |
110
+ | the stamp's edge and text | `stamp-edge`, `stamp-on` | `stamp-on` |
111
+ | a link | `{posture}-{step}`, the postures `default` and `inverse` | `default-1` |
112
+ | a seed | the family word | `brand-alt` |
113
+
114
+ The instrument is the job. On the scale (paper, chalk, highlighter, pencil, pen) the
115
+ number is 100 minus the light-mode lightness target, so a bigger number is a stronger
116
+ color. In the brand identity (stamp, link) the number is the step from rest: 0 at rest,
117
+ 1 one step away, 2 two steps.
118
+
119
+ Each output spells the same name its own way:
120
+
121
+ | Output | Spelling | `brand/stamp-0` becomes |
122
+ |---|---|---|
123
+ | CSS custom property | `--group-leaf` | `--brand-stamp-0` |
124
+ | Figma variable | `group/leaf`; the extended plugin writes it under `color/` | `color/brand/stamp-0` |
125
+ | DTCG token | nested as `group.leaf`, referenced as `{group.leaf}`; `rootGroup: 'color'` nests it under `color` | `{brand.stamp-0}` |
126
+
127
+ Which token to use for which job: [docs/agents.md](docs/agents.md).
128
+
129
+ ## The outputs
130
+
131
+ - CSS: `brandCss` writes a `[data-brand="your-brand"]` block and a
132
+ `[data-brand="your-brand"][data-theme="dark"]` block; `signalsCss` writes the
133
+ brand-independent signal block at `:root`. Values are sRGB hexes, or `rgba()` where a
134
+ token carries alpha. A stop whose chroma exceeds sRGB gets a `color(display-p3 …)`
135
+ override behind a browser gate. In the repo, `npm run generate` writes the signal block
136
+ to `dist/signals.css`.
137
+ - Objects: `themeTokens` returns one object per mode with every reference resolved, for a
138
+ consumer with no cascade.
139
+ - DTCG: `tokensToDtcg` returns one document per mode. Each token has a `$type`, an sRGB
140
+ `$value` (or an alias where the CSS writes a reference) and a `$description`; a
141
+ resolver document joins the two modes. The format: [docs/schema.md](docs/schema.md).
142
+ In the repo, `npm run tokens:emit -- '#E93D82' your-brand` writes both files; `--root color`
143
+ (or `{ rootGroup: 'color' }` in code) nests the paths under that group.
144
+ - Figma: `themeToFigma` returns a light and a dark group tree on the same paths; the
145
+ extended plugin writes them into a file.
128
146
 
129
147
  ## Documentation
130
148
 
131
149
  The docs site renders the mechanisms with live values from the engine:
132
150
  [egerrity.github.io/okchroma/#/docs](https://egerrity.github.io/okchroma/#/docs).
133
151
 
134
- - [Overview](https://egerrity.github.io/okchroma/#/docs/overview): what goes in, what comes out, the token roster.
152
+ - [Overview](https://egerrity.github.io/okchroma/#/docs/overview): the input the engine needs, and the token roster it makes from it.
135
153
  - [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.
136
- - [Guarantees](https://egerrity.github.io/okchroma/#/docs/guarantees): every claim stated exactly, and how it is verified.
154
+ - [Guarantees](https://egerrity.github.io/okchroma/#/docs/guarantees): each claim as stated, and how it is verified.
137
155
  - [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.
138
156
  - [Signals and companions](https://egerrity.github.io/okchroma/#/docs/signals) and the [Reference](https://egerrity.github.io/okchroma/#/docs/reference): glossary, constants, option types.
139
157
 
140
- In the repo: [docs/agents.md](docs/agents.md) (the consumer contract: what every token
141
- means and the rules for using it), [docs/schema.md](docs/schema.md) (the DTCG documents),
142
- [docs/scale.md](docs/scale.md) (the scale and its declared targets),
143
- [docs/architecture.md](docs/architecture.md) (the maintainer's map: modules, pipeline
144
- stages, data structures, the extended plugin's write path), and
145
- [CHANGELOG.md](CHANGELOG.md).
158
+ In the repo:
159
+
160
+ - [docs/agents.md](docs/agents.md): the consumer contract, what each token means and the rules for using it.
161
+ - [docs/schema.md](docs/schema.md): the DTCG documents.
162
+ - [docs/scale.md](docs/scale.md): the scale and its declared targets.
163
+ - [docs/architecture.md](docs/architecture.md): the maintainer's map, modules, pipeline stages, data structures, the extended plugin's write path.
164
+ - [CHANGELOG.md](CHANGELOG.md).
165
+
166
+ ## Run from source
167
+
168
+ ```bash
169
+ npm install
170
+ npm run demo:build # writes dist/signals.css and bundles the demo
171
+ npx serve . # open http://localhost:3000/demo/index.html
172
+ npm run dev # watch mode
173
+ ```
174
+
175
+ `npm run typecheck` runs the compiler. The audit scripts in `package.json`
176
+ (`npm run req:audit`, `npm run audit:guarantee`, `npm run audit:dtcg`, and the rest)
177
+ check the guarantees and the outputs. What each proves:
178
+ [How it is verified](https://egerrity.github.io/okchroma/#/docs/guarantees/how-it-is-verified).
146
179
 
147
180
  ## The Figma plugins
148
181
 
149
- - **OKChroma Extended** (`plugin-ext/`) is the shipped Figma front-end. It requires the
150
- Figma desktop app and a Figma Enterprise plan: it writes extended variable collections,
151
- one base collection with light and dark modes plus one extension per brand that
182
+ - OKChroma Extended (`plugin-ext/`) is the shipped Figma plugin. It needs the Figma
183
+ desktop app and a Figma Enterprise plan, because it writes extended variable
184
+ collections: one collection with light and dark modes, and an extension per brand that
152
185
  overrides only what differs. Download and install steps:
153
186
  [install page](https://egerrity.github.io/okchroma/install.html). Build from source with
154
187
  `npm run plugin-ext:build`; see [plugin-ext/README.md](plugin-ext/README.md).
155
- - **OKChroma** (`plugin/`, the community plugin) is withdrawn from download until it
156
- carries the rename table that migrates an existing file across the scale change of July
157
- 2026. It still builds from source with `npm run plugin:build` and imports from
188
+ - OKChroma (`plugin/`, the community plugin) is not offered for download. It has no
189
+ rename table for the last scale change, so updating an installed copy would leave the
190
+ old names in the file. It builds with `npm run plugin:build` and imports from
158
191
  `plugin/manifest.json`; a fresh file gets the current shape.
159
192
 
160
- The demo and the plugins are front-ends. The product is the engine and what it emits.
161
-
162
193
  ## License
163
194
 
164
195
  MIT
@@ -114,13 +114,13 @@ function stateStepL(restL, _mode, k) {
114
114
  }
115
115
  var OVERRIDE_LIGHT_BELOW = ARCHETYPES.find((a) => a.name === "rich").min;
116
116
  var OVERRIDE_DARK_ABOVE = ARCHETYPES.find((a) => a.name === "light").min;
117
- function stateFillL(restL, mode, k) {
117
+ function stateFillL(restL, mode, k, keepsPole) {
118
118
  const natural = mode === "dark" ? 1 : -1;
119
119
  const overridden = mode === "light" ? restL < OVERRIDE_LIGHT_BELOW : restL > OVERRIDE_DARK_ABOVE;
120
120
  const preferred = overridden ? -natural : natural;
121
121
  const fits = (d) => {
122
122
  const p = restL + d * stateStepL(restL, mode, 2);
123
- return p >= STATE_L_MIN && p <= STATE_L_MAX;
123
+ return p >= STATE_L_MIN && p <= STATE_L_MAX && (keepsPole?.(p) ?? true);
124
124
  };
125
125
  const dir = fits(preferred) ? preferred : fits(-preferred) ? -preferred : preferred;
126
126
  const L = restL + dir * stateStepL(restL, mode, k);
@@ -597,14 +597,11 @@ var RED_SOLVE = {
597
597
  brickCoolGold: -3,
598
598
  brickDesat: 0.85,
599
599
  brickExtraDeep: 0.02,
600
- // red's usable zones (her density map, error-range-checks.json): deep core or the light
601
- // edge tier — the .50-.58 middle is ring territory, canonical itself lives there, so a
602
- // lightened brand ALWAYS gets a deep-core red. Cool-first beside warm brands.
603
- coreL: [0.49, 0.47, 0.45],
604
- edgeL: [0.65, 0.68, 0.71, 0.75],
605
- redHuesWarmBrand: [28, 24, 33.3, 38],
606
- redHuesMagentaBrand: [33.3, 38, 28],
607
- redHueMagentaDh: -15
600
+ // the red complement's hue sets (resolve.ts redComplementVariant): the canonical hue and
601
+ // the two ends of the error band the field ships, read through this engine (the cool end
602
+ // near Primer's danger, the gold side near USWDS's error). The set furthest from the brand
603
+ // stamp by the side-by-side measure ships.
604
+ setHues: { true: 33.3, cool: 24, "gold-side": 38 }
608
605
  };
609
606
  function redSolveDist(c, red) {
610
607
  const dh = (c.H - red.H + 540) % 360 - 180;
@@ -2224,6 +2221,20 @@ function ctaDarkDualGateL(base, darkH, onFillTextIsWhite, enforce, coLc, capLoL,
2224
2221
  const lLc = findLForBlackTextLc(startL, C2, darkH, coLc + APCA_SOLVE_MARGIN_LC, capHiL);
2225
2222
  return Math.max(l45, Math.min(lLc, capHiL));
2226
2223
  }
2224
+ function stepKeepsPole(mode, white, chromaAt, H2, law) {
2225
+ if (mode === "light") return void 0;
2226
+ if (law.enforceLc !== void 0) {
2227
+ const bar = law.enforceLc;
2228
+ const lcAt = white ? whiteTextLcAt : blackTextLcAt;
2229
+ return (L) => lcAt(L, chromaAt(L), H2) >= bar;
2230
+ }
2231
+ if (law.ratioFloor === void 0) return void 0;
2232
+ const floor = law.ratioFloor, poleY = white ? 1 : 0;
2233
+ return (L) => {
2234
+ const C2 = clampChromaToGamut(L, chromaAt(L), H2);
2235
+ return Math.min(legalRatio(L, C2, H2, poleY), contrastRatio(shippedY(L, C2, H2), poleY)) >= floor;
2236
+ };
2237
+ }
2227
2238
  function onFillIsWhiteDarkAt(L, C2, H2, enforce, ratioFloor) {
2228
2239
  return onTextIsWhite(apcaYAt(L, C2, H2), L, C2, H2, enforce, ratioFloor);
2229
2240
  }
@@ -2506,7 +2517,8 @@ function resolveRamp(hex, mode, spec, opts) {
2506
2517
  const enforceLc = spec.ons.onFill.enforceLc;
2507
2518
  const coLc = ctx.opts?.apcaClearance && enforceLc === void 0 ? ctx.opts.apcaClearanceLc ?? spec.ons.onFill.coEnforceLc : void 0;
2508
2519
  const onFloor = enforceLc !== void 0 ? void 0 : spec.ons.onFill.ratioFloor;
2509
- let cta, ctaHover, ctaPressed, onFillIsWhite;
2520
+ let cta, onFillIsWhite;
2521
+ let stateC, stateH;
2510
2522
  if (mode === "light") {
2511
2523
  onFillIsWhite = onFillIsWhiteLight(ctx, enforceLc !== void 0 ? false : onFillEnforce);
2512
2524
  let light9L = enforceLc !== void 0 ? ctaLightLApca(ctx, onFillIsWhite, onFillEnforce, enforceLc) : ctaLightL(ctx, onFillIsWhite, onFillEnforce);
@@ -2538,21 +2550,14 @@ function resolveRamp(hex, mode, spec, opts) {
2538
2550
  }
2539
2551
  }
2540
2552
  cta = emitRole(STAMP_FILL, light9L, ctaCFor(light9L) * ctaCMul, ctaH);
2541
- const hCFor = (L) => ctx.cAt("light", L, (hoverReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul;
2542
- const pCFor = (L) => ctx.cAt("light", L, (pressedReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul;
2543
- const hL = stateFillL(light9L, "light", 1);
2544
- const pL = stateFillL(light9L, "light", 2);
2545
- ctaHover = emitRole(STAMP_FILL_HOVER, hL, hCFor(hL), ctaH);
2546
- ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, pCFor(pL), ctaH);
2547
- if (light9L !== ctx.scaleL) {
2548
- cta.enforced = true;
2549
- ctaHover.enforced = true;
2550
- ctaPressed.enforced = true;
2551
- }
2553
+ stateC = {
2554
+ hover: (L) => ctx.cAt("light", L, (hoverReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul,
2555
+ pressed: (L) => ctx.cAt("light", L, (pressedReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul
2556
+ };
2557
+ stateH = ctaH;
2558
+ if (light9L !== ctx.scaleL) cta.enforced = true;
2552
2559
  if (repelled) {
2553
2560
  cta.repelled = true;
2554
- ctaHover.repelled = true;
2555
- ctaPressed.repelled = true;
2556
2561
  onFillIsWhite = onFillIsWhiteDarkAt(
2557
2562
  cta.L,
2558
2563
  cta.C,
@@ -2565,50 +2570,29 @@ function resolveRamp(hex, mode, spec, opts) {
2565
2570
  const d = dctx;
2566
2571
  const cta9L = ctx.opts?.darkCtaFlatApp !== void 0 ? flatDarkCtaL(d, (L) => ctx.cAt("dark", L, d.darkC9), ctx.darkCtaH, ctx.opts.darkCtaFlatApp) : d.dark9L;
2567
2572
  const dCFor = (L) => ctx.cAt("dark", L, d.darkC9);
2568
- const dTrio = (baseL) => [stateFillL(baseL, "dark", 1), stateFillL(baseL, "dark", 2)];
2573
+ stateC = { hover: dCFor, pressed: dCFor };
2574
+ stateH = ctx.darkCtaH;
2569
2575
  cta = emitRole(STAMP_FILL, cta9L, dCFor(cta9L), ctx.darkCtaH);
2570
- {
2571
- const [hL, pL] = dTrio(cta9L);
2572
- ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2573
- ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2574
- }
2575
2576
  onFillIsWhite = onFillIsWhiteDarkAt(cta.L, cta.C, cta.H, enforceLc !== void 0 ? false : onFillEnforce);
2576
2577
  const enforcedL = enforceLc !== void 0 ? ctaDarkEnforcedLApca(ctx, cta, onFillIsWhite, onFillEnforce, enforceLc) : ctaDarkEnforcedL(ctx, cta, onFillIsWhite, onFillEnforce);
2577
2578
  if (enforcedL !== null) {
2578
2579
  cta = emitRole(STAMP_FILL, enforcedL, dCFor(enforcedL), ctx.darkCtaH);
2579
- const [hL, pL] = dTrio(enforcedL);
2580
- ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2581
- ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2582
2580
  cta.enforced = true;
2583
- ctaHover.enforced = true;
2584
- ctaPressed.enforced = true;
2585
2581
  }
2586
2582
  if (coLc !== void 0) {
2587
2583
  const [capLoL, capHiL] = CTA_CLEARANCE_CAPS;
2588
2584
  const clearedL = ctaDarkDualGateL(cta, ctx.darkCtaH, onFillIsWhite, onFillEnforce, coLc + APCA_ENFORCE_MARGIN_LC, capLoL, capHiL);
2589
2585
  if (Math.abs(clearedL - cta.L) > 1e-9) {
2590
2586
  cta = emitRole(STAMP_FILL, clearedL, dCFor(clearedL), ctx.darkCtaH);
2591
- const [hL, pL] = dTrio(clearedL);
2592
- ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2593
- ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2594
2587
  cta.enforced = true;
2595
- ctaHover.enforced = true;
2596
- ctaPressed.enforced = true;
2597
2588
  }
2598
2589
  }
2599
2590
  if (ctx.opts?.ctaSolve) {
2600
2591
  const exitL = solveDarkCtaExit(cta, dCFor, ctx.darkCtaH, ctx.opts.ctaSolve.redDark, enforceLc, coLc);
2601
2592
  if (exitL !== null) {
2602
2593
  cta = emitRole(STAMP_FILL, exitL, dCFor(exitL), ctx.darkCtaH);
2603
- const [hL, pL] = dTrio(exitL);
2604
- ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2605
- ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2606
2594
  cta.enforced = true;
2607
- ctaHover.enforced = true;
2608
- ctaPressed.enforced = true;
2609
2595
  cta.repelled = true;
2610
- ctaHover.repelled = true;
2611
- ctaPressed.repelled = true;
2612
2596
  onFillIsWhite = onFillIsWhiteDarkAt(
2613
2597
  cta.L,
2614
2598
  cta.C,
@@ -2624,6 +2608,19 @@ function resolveRamp(hex, mode, spec, opts) {
2624
2608
  const other = legalRatio(cta.L, cta.C, cta.H, onFillIsWhite ? 0 : 1);
2625
2609
  if (chosen < onFloor && other >= onFloor) onFillIsWhite = !onFillIsWhite;
2626
2610
  }
2611
+ const keepsPole = stepKeepsPole(mode, onFillIsWhite, stateC.pressed, stateH, { ratioFloor: onFloor, enforceLc });
2612
+ const hL = stateFillL(cta.L, mode, 1, keepsPole);
2613
+ const pL = stateFillL(cta.L, mode, 2, keepsPole);
2614
+ const ctaHover = emitRole(STAMP_FILL_HOVER, hL, stateC.hover(hL), stateH);
2615
+ const ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, stateC.pressed(pL), stateH);
2616
+ if (cta.enforced) {
2617
+ ctaHover.enforced = true;
2618
+ ctaPressed.enforced = true;
2619
+ }
2620
+ if (cta.repelled) {
2621
+ ctaHover.repelled = true;
2622
+ ctaPressed.repelled = true;
2623
+ }
2627
2624
  return { mode, seed, stops, roles: { cta, ctaHover, ctaPressed }, ons: { onFillIsWhite } };
2628
2625
  }
2629
2626
 
@@ -2706,18 +2703,18 @@ function generateNeutralScale(brandH, level = "default", contrastProfile) {
2706
2703
  if (popLift > 1e-6) {
2707
2704
  scale.ctaDark = makeStop(9, scale.dark[3].L + popLift, scale.dark[3].C, scale.dark[3].H);
2708
2705
  }
2709
- const nState = (stop, mode, rest, k) => {
2710
- const L = stateFillL(rest.L, mode, k);
2711
- return makeStop(stop, L, curve(L, mode), rest.H);
2712
- };
2713
- scale.ctaHover = nState(10, "light", scale.cta, 1);
2714
- scale.ctaPressed = nState(11, "light", scale.cta, 2);
2715
- scale.ctaHoverDark = nState(10, "dark", scale.ctaDark, 1);
2716
- scale.ctaPressedDark = nState(11, "dark", scale.ctaDark, 2);
2717
2706
  const onEnforce = contrastProfile !== "apca";
2718
2707
  const onFloor = 4.5;
2719
2708
  scale.onFillTextIsWhite = onTextIsWhite(apcaY(scale.cta.r, scale.cta.g, scale.cta.b), scale.cta.L, scale.cta.C, scale.cta.H, onEnforce, onFloor);
2720
2709
  scale.onFillTextIsWhiteDark = onTextIsWhite(apcaY(scale.ctaDark.r, scale.ctaDark.g, scale.ctaDark.b), scale.ctaDark.L, scale.ctaDark.C, scale.ctaDark.H, onEnforce, onFloor);
2710
+ const nState = (stop, mode, rest, k, white) => {
2711
+ const L = stateFillL(rest.L, mode, k, stepKeepsPole(mode, white, (l) => curve(l, mode), rest.H, { ratioFloor: onFloor }));
2712
+ return makeStop(stop, L, curve(L, mode), rest.H);
2713
+ };
2714
+ scale.ctaHover = nState(10, "light", scale.cta, 1, scale.onFillTextIsWhite);
2715
+ scale.ctaPressed = nState(11, "light", scale.cta, 2, scale.onFillTextIsWhite);
2716
+ scale.ctaHoverDark = nState(10, "dark", scale.ctaDark, 1, scale.onFillTextIsWhiteDark);
2717
+ scale.ctaPressedDark = nState(11, "dark", scale.ctaDark, 2, scale.onFillTextIsWhiteDark);
2721
2718
  return scale;
2722
2719
  }
2723
2720
  function generateSubtleSecondary(hex, opts) {
@@ -2730,10 +2727,6 @@ function generateSubtleSecondary(hex, opts) {
2730
2727
  });
2731
2728
  const asCta = (stop, src) => makeStop(stop, src.L, src.C, src.H);
2732
2729
  const mk = (stop, L, mode) => makeStop(stop, L, curve(L, mode), scale.brandH);
2733
- const st = (stop, mode, rest, k) => {
2734
- const L = stateFillL(rest.L, mode, k);
2735
- return makeStop(stop, L, curve(L, mode), rest.H);
2736
- };
2737
2730
  if (opts?.ctaL) {
2738
2731
  scale.cta = mk(9, opts.ctaL.light, "light");
2739
2732
  scale.ctaDark = mk(9, opts.ctaL.dark, "dark");
@@ -2741,14 +2734,18 @@ function generateSubtleSecondary(hex, opts) {
2741
2734
  scale.cta = asCta(9, scale.light[3]);
2742
2735
  scale.ctaDark = asCta(9, scale.dark[3]);
2743
2736
  }
2744
- scale.ctaHover = st(10, "light", scale.cta, 1);
2745
- scale.ctaPressed = st(11, "light", scale.cta, 2);
2746
- scale.ctaHoverDark = st(10, "dark", scale.ctaDark, 1);
2747
- scale.ctaPressedDark = st(11, "dark", scale.ctaDark, 2);
2748
2737
  const onEnforce = opts?.contrastProfile !== "apca";
2749
2738
  const onFloor = 4.5;
2750
2739
  scale.onFillTextIsWhite = onTextIsWhite(apcaY(scale.cta.r, scale.cta.g, scale.cta.b), scale.cta.L, scale.cta.C, scale.cta.H, onEnforce, onFloor);
2751
2740
  scale.onFillTextIsWhiteDark = onTextIsWhite(apcaY(scale.ctaDark.r, scale.ctaDark.g, scale.ctaDark.b), scale.ctaDark.L, scale.ctaDark.C, scale.ctaDark.H, onEnforce, onFloor);
2741
+ const st = (stop, mode, rest, k, white) => {
2742
+ const L = stateFillL(rest.L, mode, k, stepKeepsPole(mode, white, (l) => curve(l, mode), rest.H, { ratioFloor: onFloor }));
2743
+ return makeStop(stop, L, curve(L, mode), rest.H);
2744
+ };
2745
+ scale.ctaHover = st(10, "light", scale.cta, 1, scale.onFillTextIsWhite);
2746
+ scale.ctaPressed = st(11, "light", scale.cta, 2, scale.onFillTextIsWhite);
2747
+ scale.ctaHoverDark = st(10, "dark", scale.ctaDark, 1, scale.onFillTextIsWhiteDark);
2748
+ scale.ctaPressedDark = st(11, "dark", scale.ctaDark, 2, scale.onFillTextIsWhiteDark);
2752
2749
  return scale;
2753
2750
  }
2754
2751
 
@@ -3112,63 +3109,61 @@ function hueCollisionPending(scale, sigScales) {
3112
3109
  }
3113
3110
  var blackLcAt = (L, C2, H2) => Math.abs(apcaLc(apcaYAt(0, 0, 0), apcaYAt(L, clampChromaToGamut(L, C2, H2), H2)));
3114
3111
  var redCleanBeside = (brandCta, c) => p2Diff(brandCta, c) >= (c.L < brandCta.L ? P2_D : P2_D_UP) && redSolveDist(brandCta, c) >= RED_GATE.G + RED_SOLVE.ring;
3115
- function redComplementVariant(brandCta, seed, brandWentUp, red, contrastProfile) {
3116
- const rctx = buildContext(red.def.hex, {
3117
- darkChromaCurve,
3118
- darkCtaC: "signal",
3119
- darkFillMinL: red.def.darkFillMinL,
3120
- enforceOnFillContrast: true,
3121
- suppressRedCool: true,
3122
- goldBoost: true,
3123
- signalWarmDrift: true,
3124
- contrastProfile
3125
- });
3112
+ var redSetsCache = /* @__PURE__ */ new Map();
3113
+ var redSetKey = (set, band) => `${set}:${band}`;
3114
+ var bandBelow = (band) => {
3115
+ const i = ARCHETYPES.findIndex((a) => a.name === band);
3116
+ return ARCHETYPES[Math.max(0, i - 1)].name;
3117
+ };
3118
+ function redSetsFor(red, contrastProfile) {
3119
+ const cacheKey = contrastProfile ?? "wcag";
3120
+ const hit = redSetsCache.get(cacheKey);
3121
+ if (hit) return hit;
3122
+ const own = classifyArchetype(red.scale.cta.L);
3123
+ const sets = /* @__PURE__ */ new Map();
3124
+ for (const [set, hue] of Object.entries(RED_SOLVE.setHues)) for (const band of [own, bandBelow(own)]) {
3125
+ sets.set(redSetKey(set, band), generateScale(red.def.hex, red.def.name, band === own ? void 0 : band, {
3126
+ hueShiftDeg: hue - red.def.H,
3127
+ darkChromaCurve,
3128
+ darkCtaC: "signal",
3129
+ darkFillMinL: red.def.darkFillMinL,
3130
+ enforceOnFillContrast: true,
3131
+ suppressRedCool: true,
3132
+ goldBoost: true,
3133
+ signalWarmDrift: true,
3134
+ contrastProfile,
3135
+ apcaClearance: true,
3136
+ apcaClearanceLc: CRITICAL_CLEARANCE_LC
3137
+ }));
3138
+ }
3139
+ redSetsCache.set(cacheKey, sets);
3140
+ return sets;
3141
+ }
3142
+ function redComplementVariant(brandCta, brandWentUp, red, contrastProfile) {
3126
3143
  const redCta = red.scale.cta;
3127
- const at = (L, H2) => ({ L, C: clampChromaToGamut(L, rctx.cAt("light", L, rctx.brandC), H2), H: H2 });
3128
3144
  const fireLc = CRITICAL_CLEARANCE_LC + APCA_ENFORCE_MARGIN_LC;
3129
- const shipsWhite = (c) => onFillIsWhiteDarkAt(c.L, c.C, c.H, true, contrastProfile === "apca" ? void 0 : 4.5);
3130
3145
  const poleOk = (c) => {
3131
- const white = shipsWhite(c);
3146
+ const white = onFillIsWhiteDarkAt(c.L, c.C, c.H, true, contrastProfile === "apca" ? void 0 : 4.5);
3132
3147
  const lc = white ? whiteTextLcAt(c.L, c.C, c.H) : blackLcAt(c.L, c.C, c.H);
3133
3148
  const legal = contrastProfile === "apca" || legalRatio(c.L, c.C, c.H, white ? 1 : 0) >= 4.5;
3134
3149
  return legal && lc >= fireLc;
3135
3150
  };
3136
- const slideToBar = (c) => {
3137
- if (poleOk(c)) return c;
3138
- const L2 = shipsWhite(c) ? findLForWhiteTextLc(c.L, c.C, c.H, fireLc + APCA_SOLVE_MARGIN_LC) : findLForBlackTextLc(c.L, c.C, c.H, fireLc + APCA_SOLVE_MARGIN_LC, 0.92);
3139
- const c2 = at(L2, c.H);
3140
- return poleOk(c2) ? c2 : null;
3141
- };
3142
- const clean = (c) => redCleanBeside(brandCta, c) && poleOk(c);
3143
- if (!brandWentUp && clean(redCta)) return null;
3144
- const hues = hueDelta(seed.H, redCta.H) <= RED_SOLVE.redHueMagentaDh ? RED_SOLVE.redHuesMagentaBrand : RED_SOLVE.redHuesWarmBrand;
3145
- const wantLighter = brandCta.L <= redCta.L;
3146
- const tiers = wantLighter ? [[...RED_SOLVE.edgeL], RED_SOLVE.coreL.filter((l) => l > brandCta.L)] : [[...RED_SOLVE.coreL], [...RED_SOLVE.edgeL]];
3151
+ if (!brandWentUp && redCleanBeside(brandCta, redCta) && poleOk(redCta)) return null;
3152
+ const own = classifyArchetype(redCta.L);
3153
+ const band = classifyArchetype(brandCta.L) === own ? bandBelow(own) : own;
3154
+ const sets = redSetsFor(red, contrastProfile);
3147
3155
  let pick = null;
3148
- outer: for (const tier of tiers) {
3149
- const ls = [...tier].sort((a, b) => wantLighter ? a - b : b - a);
3150
- for (const L of ls) for (const H2 of hues) {
3151
- const c = slideToBar(at(L, H2));
3152
- if (!c) continue;
3153
- const onSide = wantLighter ? c.L > brandCta.L : c.L < brandCta.L;
3154
- if (!onSide || !clean(c)) continue;
3155
- pick = c;
3156
- break outer;
3157
- }
3158
- }
3159
- if (!pick) return null;
3160
- const cta = makeStop(redCta.stop, pick.L, rctx.cAt("light", pick.L, rctx.brandC), pick.H);
3161
- const vCFor = (L) => rctx.cAt("light", L, rctx.brandC);
3162
- const hL = stateFillL(pick.L, "light", 1);
3163
- const ctaHover = makeStop(red.scale.ctaHover.stop, hL, vCFor(hL), pick.H);
3164
- const pL = stateFillL(pick.L, "light", 2);
3165
- const ctaPressed = makeStop(red.scale.ctaPressed.stop, pL, vCFor(pL), pick.H);
3166
- const onFillTextIsWhite = onFillIsWhiteDarkAt(cta.L, cta.C, cta.H, true, contrastProfile === "apca" ? void 0 : 4.5);
3156
+ for (const set of Object.keys(RED_SOLVE.setHues)) {
3157
+ const scale = sets.get(redSetKey(set, band));
3158
+ const d = p2Diff(brandCta, scale.cta);
3159
+ if (!pick || d > pick.d) pick = { set, scale, d };
3160
+ }
3161
+ const { cta, ctaHover, ctaPressed, onFillTextIsWhite } = pick.scale;
3167
3162
  return {
3168
3163
  scale: { ...red.scale, cta, ctaHover, ctaPressed, onFillTextIsWhite },
3169
- // naming candidates only — the identity name is the owner's call at bless. No hue suffix:
3170
- // the plugin's note parser mints Figma variable paths from this string.
3171
- note: `red \u2192 ${pick.L < redCta.L ? "rich" : "coral"} L${pick.L.toFixed(2)}`
3164
+ // the community plugin shows the part after the arrow as a display label; the hex is
3165
+ // the variant's identity there, so the note carries no value
3166
+ note: `red \u2192 ${pick.set} at ${band}`
3172
3167
  };
3173
3168
  }
3174
3169
  function resolveBrand(hex, name, opts) {
@@ -3214,7 +3209,7 @@ function resolveBrand(hex, name, opts) {
3214
3209
  const signalOverrides = [];
3215
3210
  if (solving || opts?.exact && signalMoves && !opts?.archetypeOverride) {
3216
3211
  const brandWentUp = !!scale.ctaRepelled?.light && scale.cta.L > seedO.L + 1e-6;
3217
- const v2 = redComplementVariant(scale.cta, seedO, brandWentUp, sigScales.get("red"), opts?.contrastProfile);
3212
+ const v2 = redComplementVariant(scale.cta, brandWentUp, sigScales.get("red"), opts?.contrastProfile);
3218
3213
  if (v2) signalOverrides.push({ name: "red", scale: v2.scale, note: v2.note });
3219
3214
  }
3220
3215
  let warnVariant = null;
@@ -3324,11 +3319,12 @@ function escapeCtaFamily(nScale, mode, contrastProfile) {
3324
3319
  if (!strongText) throw new Error("escapeCtaFamily: the neutral scale has no ink-11 stop");
3325
3320
  const mk = (stop, L) => makeStop(stop, L, strongText.C, strongText.H);
3326
3321
  const cta = mk(9, strongText.L);
3327
- const ctaHover = mk(10, stateFillL(strongText.L, mode, 1));
3328
- const ctaPressed = mk(11, stateFillL(strongText.L, mode, 2));
3329
3322
  const onEnforce = contrastProfile !== "apca";
3330
3323
  const onFloor = contrastProfile === "apca" ? void 0 : 4.5;
3331
3324
  const onFillIsWhite = onTextIsWhite(apcaY(cta.r, cta.g, cta.b), cta.L, cta.C, cta.H, onEnforce, onFloor);
3325
+ const keepsPole = stepKeepsPole(mode, onFillIsWhite, () => strongText.C, strongText.H, { ratioFloor: onFloor });
3326
+ const ctaHover = mk(10, stateFillL(strongText.L, mode, 1, keepsPole));
3327
+ const ctaPressed = mk(11, stateFillL(strongText.L, mode, 2, keepsPole));
3332
3328
  return { cta, ctaHover, ctaPressed, onFillIsWhite };
3333
3329
  }
3334
3330
  var SECONDARY_DISTINCT_DELTA_E = 0.12;
@@ -3375,7 +3371,7 @@ function resolveTheme(input) {
3375
3371
  const red = sigScales.get("red");
3376
3372
  const primaryRed = mergedOverrides.find((o) => o.name === "red");
3377
3373
  if (!primaryRed) {
3378
- const v2 = redComplementVariant(secScale.cta, hexToOklch(secSeedHex), false, red, cp2);
3374
+ const v2 = redComplementVariant(secScale.cta, false, red, cp2);
3379
3375
  if (v2 && redCleanBeside(primary.scale.cta, v2.scale.cta)) adopt("red", v2.scale, `${v2.note} (for the secondary)`);
3380
3376
  }
3381
3377
  for (const sigName of ["yellow", "green", "blue"]) {
@@ -21,13 +21,13 @@ function stateStepL(restL, _mode, k) {
21
21
  }
22
22
  var OVERRIDE_LIGHT_BELOW = ARCHETYPES.find((a) => a.name === "rich").min;
23
23
  var OVERRIDE_DARK_ABOVE = ARCHETYPES.find((a) => a.name === "light").min;
24
- function stateFillL(restL, mode, k) {
24
+ function stateFillL(restL, mode, k, keepsPole) {
25
25
  const natural = mode === "dark" ? 1 : -1;
26
26
  const overridden = mode === "light" ? restL < OVERRIDE_LIGHT_BELOW : restL > OVERRIDE_DARK_ABOVE;
27
27
  const preferred = overridden ? -natural : natural;
28
28
  const fits = (d) => {
29
29
  const p = restL + d * stateStepL(restL, mode, 2);
30
- return p >= STATE_L_MIN && p <= STATE_L_MAX;
30
+ return p >= STATE_L_MIN && p <= STATE_L_MAX && (keepsPole?.(p) ?? true);
31
31
  };
32
32
  const dir = fits(preferred) ? preferred : fits(-preferred) ? -preferred : preferred;
33
33
  const L = restL + dir * stateStepL(restL, mode, k);
@@ -504,14 +504,11 @@ var RED_SOLVE = {
504
504
  brickCoolGold: -3,
505
505
  brickDesat: 0.85,
506
506
  brickExtraDeep: 0.02,
507
- // red's usable zones (her density map, error-range-checks.json): deep core or the light
508
- // edge tier — the .50-.58 middle is ring territory, canonical itself lives there, so a
509
- // lightened brand ALWAYS gets a deep-core red. Cool-first beside warm brands.
510
- coreL: [0.49, 0.47, 0.45],
511
- edgeL: [0.65, 0.68, 0.71, 0.75],
512
- redHuesWarmBrand: [28, 24, 33.3, 38],
513
- redHuesMagentaBrand: [33.3, 38, 28],
514
- redHueMagentaDh: -15
507
+ // the red complement's hue sets (resolve.ts redComplementVariant): the canonical hue and
508
+ // the two ends of the error band the field ships, read through this engine (the cool end
509
+ // near Primer's danger, the gold side near USWDS's error). The set furthest from the brand
510
+ // stamp by the side-by-side measure ships.
511
+ setHues: { true: 33.3, cool: 24, "gold-side": 38 }
515
512
  };
516
513
  function redSolveDist(c, red) {
517
514
  const dh = (c.H - red.H + 540) % 360 - 180;
@@ -2131,6 +2128,20 @@ function ctaDarkDualGateL(base, darkH, onFillTextIsWhite, enforce, coLc, capLoL,
2131
2128
  const lLc = findLForBlackTextLc(startL, C2, darkH, coLc + APCA_SOLVE_MARGIN_LC, capHiL);
2132
2129
  return Math.max(l45, Math.min(lLc, capHiL));
2133
2130
  }
2131
+ function stepKeepsPole(mode, white, chromaAt, H2, law) {
2132
+ if (mode === "light") return void 0;
2133
+ if (law.enforceLc !== void 0) {
2134
+ const bar = law.enforceLc;
2135
+ const lcAt = white ? whiteTextLcAt : blackTextLcAt;
2136
+ return (L) => lcAt(L, chromaAt(L), H2) >= bar;
2137
+ }
2138
+ if (law.ratioFloor === void 0) return void 0;
2139
+ const floor = law.ratioFloor, poleY = white ? 1 : 0;
2140
+ return (L) => {
2141
+ const C2 = clampChromaToGamut(L, chromaAt(L), H2);
2142
+ return Math.min(legalRatio(L, C2, H2, poleY), contrastRatio(shippedY(L, C2, H2), poleY)) >= floor;
2143
+ };
2144
+ }
2134
2145
  function onFillIsWhiteDarkAt(L, C2, H2, enforce, ratioFloor) {
2135
2146
  return onTextIsWhite(apcaYAt(L, C2, H2), L, C2, H2, enforce, ratioFloor);
2136
2147
  }
@@ -2413,7 +2424,8 @@ function resolveRamp(hex, mode, spec, opts) {
2413
2424
  const enforceLc = spec.ons.onFill.enforceLc;
2414
2425
  const coLc = ctx.opts?.apcaClearance && enforceLc === void 0 ? ctx.opts.apcaClearanceLc ?? spec.ons.onFill.coEnforceLc : void 0;
2415
2426
  const onFloor = enforceLc !== void 0 ? void 0 : spec.ons.onFill.ratioFloor;
2416
- let cta, ctaHover, ctaPressed, onFillIsWhite;
2427
+ let cta, onFillIsWhite;
2428
+ let stateC, stateH;
2417
2429
  if (mode === "light") {
2418
2430
  onFillIsWhite = onFillIsWhiteLight(ctx, enforceLc !== void 0 ? false : onFillEnforce);
2419
2431
  let light9L = enforceLc !== void 0 ? ctaLightLApca(ctx, onFillIsWhite, onFillEnforce, enforceLc) : ctaLightL(ctx, onFillIsWhite, onFillEnforce);
@@ -2445,21 +2457,14 @@ function resolveRamp(hex, mode, spec, opts) {
2445
2457
  }
2446
2458
  }
2447
2459
  cta = emitRole(STAMP_FILL, light9L, ctaCFor(light9L) * ctaCMul, ctaH);
2448
- const hCFor = (L) => ctx.cAt("light", L, (hoverReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul;
2449
- const pCFor = (L) => ctx.cAt("light", L, (pressedReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul;
2450
- const hL = stateFillL(light9L, "light", 1);
2451
- const pL = stateFillL(light9L, "light", 2);
2452
- ctaHover = emitRole(STAMP_FILL_HOVER, hL, hCFor(hL), ctaH);
2453
- ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, pCFor(pL), ctaH);
2454
- if (light9L !== ctx.scaleL) {
2455
- cta.enforced = true;
2456
- ctaHover.enforced = true;
2457
- ctaPressed.enforced = true;
2458
- }
2460
+ stateC = {
2461
+ hover: (L) => ctx.cAt("light", L, (hoverReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul,
2462
+ pressed: (L) => ctx.cAt("light", L, (pressedReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul
2463
+ };
2464
+ stateH = ctaH;
2465
+ if (light9L !== ctx.scaleL) cta.enforced = true;
2459
2466
  if (repelled) {
2460
2467
  cta.repelled = true;
2461
- ctaHover.repelled = true;
2462
- ctaPressed.repelled = true;
2463
2468
  onFillIsWhite = onFillIsWhiteDarkAt(
2464
2469
  cta.L,
2465
2470
  cta.C,
@@ -2472,50 +2477,29 @@ function resolveRamp(hex, mode, spec, opts) {
2472
2477
  const d = dctx;
2473
2478
  const cta9L = ctx.opts?.darkCtaFlatApp !== void 0 ? flatDarkCtaL(d, (L) => ctx.cAt("dark", L, d.darkC9), ctx.darkCtaH, ctx.opts.darkCtaFlatApp) : d.dark9L;
2474
2479
  const dCFor = (L) => ctx.cAt("dark", L, d.darkC9);
2475
- const dTrio = (baseL) => [stateFillL(baseL, "dark", 1), stateFillL(baseL, "dark", 2)];
2480
+ stateC = { hover: dCFor, pressed: dCFor };
2481
+ stateH = ctx.darkCtaH;
2476
2482
  cta = emitRole(STAMP_FILL, cta9L, dCFor(cta9L), ctx.darkCtaH);
2477
- {
2478
- const [hL, pL] = dTrio(cta9L);
2479
- ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2480
- ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2481
- }
2482
2483
  onFillIsWhite = onFillIsWhiteDarkAt(cta.L, cta.C, cta.H, enforceLc !== void 0 ? false : onFillEnforce);
2483
2484
  const enforcedL = enforceLc !== void 0 ? ctaDarkEnforcedLApca(ctx, cta, onFillIsWhite, onFillEnforce, enforceLc) : ctaDarkEnforcedL(ctx, cta, onFillIsWhite, onFillEnforce);
2484
2485
  if (enforcedL !== null) {
2485
2486
  cta = emitRole(STAMP_FILL, enforcedL, dCFor(enforcedL), ctx.darkCtaH);
2486
- const [hL, pL] = dTrio(enforcedL);
2487
- ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2488
- ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2489
2487
  cta.enforced = true;
2490
- ctaHover.enforced = true;
2491
- ctaPressed.enforced = true;
2492
2488
  }
2493
2489
  if (coLc !== void 0) {
2494
2490
  const [capLoL, capHiL] = CTA_CLEARANCE_CAPS;
2495
2491
  const clearedL = ctaDarkDualGateL(cta, ctx.darkCtaH, onFillIsWhite, onFillEnforce, coLc + APCA_ENFORCE_MARGIN_LC, capLoL, capHiL);
2496
2492
  if (Math.abs(clearedL - cta.L) > 1e-9) {
2497
2493
  cta = emitRole(STAMP_FILL, clearedL, dCFor(clearedL), ctx.darkCtaH);
2498
- const [hL, pL] = dTrio(clearedL);
2499
- ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2500
- ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2501
2494
  cta.enforced = true;
2502
- ctaHover.enforced = true;
2503
- ctaPressed.enforced = true;
2504
2495
  }
2505
2496
  }
2506
2497
  if (ctx.opts?.ctaSolve) {
2507
2498
  const exitL = solveDarkCtaExit(cta, dCFor, ctx.darkCtaH, ctx.opts.ctaSolve.redDark, enforceLc, coLc);
2508
2499
  if (exitL !== null) {
2509
2500
  cta = emitRole(STAMP_FILL, exitL, dCFor(exitL), ctx.darkCtaH);
2510
- const [hL, pL] = dTrio(exitL);
2511
- ctaHover = emitRole(STAMP_FILL_HOVER, hL, dCFor(hL), ctx.darkCtaH);
2512
- ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, dCFor(pL), ctx.darkCtaH);
2513
2501
  cta.enforced = true;
2514
- ctaHover.enforced = true;
2515
- ctaPressed.enforced = true;
2516
2502
  cta.repelled = true;
2517
- ctaHover.repelled = true;
2518
- ctaPressed.repelled = true;
2519
2503
  onFillIsWhite = onFillIsWhiteDarkAt(
2520
2504
  cta.L,
2521
2505
  cta.C,
@@ -2531,6 +2515,19 @@ function resolveRamp(hex, mode, spec, opts) {
2531
2515
  const other = legalRatio(cta.L, cta.C, cta.H, onFillIsWhite ? 0 : 1);
2532
2516
  if (chosen < onFloor && other >= onFloor) onFillIsWhite = !onFillIsWhite;
2533
2517
  }
2518
+ const keepsPole = stepKeepsPole(mode, onFillIsWhite, stateC.pressed, stateH, { ratioFloor: onFloor, enforceLc });
2519
+ const hL = stateFillL(cta.L, mode, 1, keepsPole);
2520
+ const pL = stateFillL(cta.L, mode, 2, keepsPole);
2521
+ const ctaHover = emitRole(STAMP_FILL_HOVER, hL, stateC.hover(hL), stateH);
2522
+ const ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, stateC.pressed(pL), stateH);
2523
+ if (cta.enforced) {
2524
+ ctaHover.enforced = true;
2525
+ ctaPressed.enforced = true;
2526
+ }
2527
+ if (cta.repelled) {
2528
+ ctaHover.repelled = true;
2529
+ ctaPressed.repelled = true;
2530
+ }
2534
2531
  return { mode, seed, stops, roles: { cta, ctaHover, ctaPressed }, ons: { onFillIsWhite } };
2535
2532
  }
2536
2533
 
@@ -2613,18 +2610,18 @@ function generateNeutralScale(brandH, level = "default", contrastProfile) {
2613
2610
  if (popLift > 1e-6) {
2614
2611
  scale.ctaDark = makeStop(9, scale.dark[3].L + popLift, scale.dark[3].C, scale.dark[3].H);
2615
2612
  }
2616
- const nState = (stop, mode, rest, k) => {
2617
- const L = stateFillL(rest.L, mode, k);
2618
- return makeStop(stop, L, curve(L, mode), rest.H);
2619
- };
2620
- scale.ctaHover = nState(10, "light", scale.cta, 1);
2621
- scale.ctaPressed = nState(11, "light", scale.cta, 2);
2622
- scale.ctaHoverDark = nState(10, "dark", scale.ctaDark, 1);
2623
- scale.ctaPressedDark = nState(11, "dark", scale.ctaDark, 2);
2624
2613
  const onEnforce = contrastProfile !== "apca";
2625
2614
  const onFloor = 4.5;
2626
2615
  scale.onFillTextIsWhite = onTextIsWhite(apcaY(scale.cta.r, scale.cta.g, scale.cta.b), scale.cta.L, scale.cta.C, scale.cta.H, onEnforce, onFloor);
2627
2616
  scale.onFillTextIsWhiteDark = onTextIsWhite(apcaY(scale.ctaDark.r, scale.ctaDark.g, scale.ctaDark.b), scale.ctaDark.L, scale.ctaDark.C, scale.ctaDark.H, onEnforce, onFloor);
2617
+ const nState = (stop, mode, rest, k, white) => {
2618
+ const L = stateFillL(rest.L, mode, k, stepKeepsPole(mode, white, (l) => curve(l, mode), rest.H, { ratioFloor: onFloor }));
2619
+ return makeStop(stop, L, curve(L, mode), rest.H);
2620
+ };
2621
+ scale.ctaHover = nState(10, "light", scale.cta, 1, scale.onFillTextIsWhite);
2622
+ scale.ctaPressed = nState(11, "light", scale.cta, 2, scale.onFillTextIsWhite);
2623
+ scale.ctaHoverDark = nState(10, "dark", scale.ctaDark, 1, scale.onFillTextIsWhiteDark);
2624
+ scale.ctaPressedDark = nState(11, "dark", scale.ctaDark, 2, scale.onFillTextIsWhiteDark);
2628
2625
  return scale;
2629
2626
  }
2630
2627
  function generateSubtleSecondary(hex, opts) {
@@ -2637,10 +2634,6 @@ function generateSubtleSecondary(hex, opts) {
2637
2634
  });
2638
2635
  const asCta = (stop, src) => makeStop(stop, src.L, src.C, src.H);
2639
2636
  const mk = (stop, L, mode) => makeStop(stop, L, curve(L, mode), scale.brandH);
2640
- const st = (stop, mode, rest, k) => {
2641
- const L = stateFillL(rest.L, mode, k);
2642
- return makeStop(stop, L, curve(L, mode), rest.H);
2643
- };
2644
2637
  if (opts?.ctaL) {
2645
2638
  scale.cta = mk(9, opts.ctaL.light, "light");
2646
2639
  scale.ctaDark = mk(9, opts.ctaL.dark, "dark");
@@ -2648,14 +2641,18 @@ function generateSubtleSecondary(hex, opts) {
2648
2641
  scale.cta = asCta(9, scale.light[3]);
2649
2642
  scale.ctaDark = asCta(9, scale.dark[3]);
2650
2643
  }
2651
- scale.ctaHover = st(10, "light", scale.cta, 1);
2652
- scale.ctaPressed = st(11, "light", scale.cta, 2);
2653
- scale.ctaHoverDark = st(10, "dark", scale.ctaDark, 1);
2654
- scale.ctaPressedDark = st(11, "dark", scale.ctaDark, 2);
2655
2644
  const onEnforce = opts?.contrastProfile !== "apca";
2656
2645
  const onFloor = 4.5;
2657
2646
  scale.onFillTextIsWhite = onTextIsWhite(apcaY(scale.cta.r, scale.cta.g, scale.cta.b), scale.cta.L, scale.cta.C, scale.cta.H, onEnforce, onFloor);
2658
2647
  scale.onFillTextIsWhiteDark = onTextIsWhite(apcaY(scale.ctaDark.r, scale.ctaDark.g, scale.ctaDark.b), scale.ctaDark.L, scale.ctaDark.C, scale.ctaDark.H, onEnforce, onFloor);
2648
+ const st = (stop, mode, rest, k, white) => {
2649
+ const L = stateFillL(rest.L, mode, k, stepKeepsPole(mode, white, (l) => curve(l, mode), rest.H, { ratioFloor: onFloor }));
2650
+ return makeStop(stop, L, curve(L, mode), rest.H);
2651
+ };
2652
+ scale.ctaHover = st(10, "light", scale.cta, 1, scale.onFillTextIsWhite);
2653
+ scale.ctaPressed = st(11, "light", scale.cta, 2, scale.onFillTextIsWhite);
2654
+ scale.ctaHoverDark = st(10, "dark", scale.ctaDark, 1, scale.onFillTextIsWhiteDark);
2655
+ scale.ctaPressedDark = st(11, "dark", scale.ctaDark, 2, scale.onFillTextIsWhiteDark);
2659
2656
  return scale;
2660
2657
  }
2661
2658
 
@@ -3019,63 +3016,61 @@ function hueCollisionPending(scale, sigScales) {
3019
3016
  }
3020
3017
  var blackLcAt = (L, C2, H2) => Math.abs(apcaLc(apcaYAt(0, 0, 0), apcaYAt(L, clampChromaToGamut(L, C2, H2), H2)));
3021
3018
  var redCleanBeside = (brandCta, c) => p2Diff(brandCta, c) >= (c.L < brandCta.L ? P2_D : P2_D_UP) && redSolveDist(brandCta, c) >= RED_GATE.G + RED_SOLVE.ring;
3022
- function redComplementVariant(brandCta, seed, brandWentUp, red, contrastProfile) {
3023
- const rctx = buildContext(red.def.hex, {
3024
- darkChromaCurve,
3025
- darkCtaC: "signal",
3026
- darkFillMinL: red.def.darkFillMinL,
3027
- enforceOnFillContrast: true,
3028
- suppressRedCool: true,
3029
- goldBoost: true,
3030
- signalWarmDrift: true,
3031
- contrastProfile
3032
- });
3019
+ var redSetsCache = /* @__PURE__ */ new Map();
3020
+ var redSetKey = (set, band) => `${set}:${band}`;
3021
+ var bandBelow = (band) => {
3022
+ const i = ARCHETYPES.findIndex((a) => a.name === band);
3023
+ return ARCHETYPES[Math.max(0, i - 1)].name;
3024
+ };
3025
+ function redSetsFor(red, contrastProfile) {
3026
+ const cacheKey = contrastProfile ?? "wcag";
3027
+ const hit = redSetsCache.get(cacheKey);
3028
+ if (hit) return hit;
3029
+ const own = classifyArchetype(red.scale.cta.L);
3030
+ const sets = /* @__PURE__ */ new Map();
3031
+ for (const [set, hue] of Object.entries(RED_SOLVE.setHues)) for (const band of [own, bandBelow(own)]) {
3032
+ sets.set(redSetKey(set, band), generateScale(red.def.hex, red.def.name, band === own ? void 0 : band, {
3033
+ hueShiftDeg: hue - red.def.H,
3034
+ darkChromaCurve,
3035
+ darkCtaC: "signal",
3036
+ darkFillMinL: red.def.darkFillMinL,
3037
+ enforceOnFillContrast: true,
3038
+ suppressRedCool: true,
3039
+ goldBoost: true,
3040
+ signalWarmDrift: true,
3041
+ contrastProfile,
3042
+ apcaClearance: true,
3043
+ apcaClearanceLc: CRITICAL_CLEARANCE_LC
3044
+ }));
3045
+ }
3046
+ redSetsCache.set(cacheKey, sets);
3047
+ return sets;
3048
+ }
3049
+ function redComplementVariant(brandCta, brandWentUp, red, contrastProfile) {
3033
3050
  const redCta = red.scale.cta;
3034
- const at = (L, H2) => ({ L, C: clampChromaToGamut(L, rctx.cAt("light", L, rctx.brandC), H2), H: H2 });
3035
3051
  const fireLc = CRITICAL_CLEARANCE_LC + APCA_ENFORCE_MARGIN_LC;
3036
- const shipsWhite = (c) => onFillIsWhiteDarkAt(c.L, c.C, c.H, true, contrastProfile === "apca" ? void 0 : 4.5);
3037
3052
  const poleOk = (c) => {
3038
- const white = shipsWhite(c);
3053
+ const white = onFillIsWhiteDarkAt(c.L, c.C, c.H, true, contrastProfile === "apca" ? void 0 : 4.5);
3039
3054
  const lc = white ? whiteTextLcAt(c.L, c.C, c.H) : blackLcAt(c.L, c.C, c.H);
3040
3055
  const legal = contrastProfile === "apca" || legalRatio(c.L, c.C, c.H, white ? 1 : 0) >= 4.5;
3041
3056
  return legal && lc >= fireLc;
3042
3057
  };
3043
- const slideToBar = (c) => {
3044
- if (poleOk(c)) return c;
3045
- const L2 = shipsWhite(c) ? findLForWhiteTextLc(c.L, c.C, c.H, fireLc + APCA_SOLVE_MARGIN_LC) : findLForBlackTextLc(c.L, c.C, c.H, fireLc + APCA_SOLVE_MARGIN_LC, 0.92);
3046
- const c2 = at(L2, c.H);
3047
- return poleOk(c2) ? c2 : null;
3048
- };
3049
- const clean = (c) => redCleanBeside(brandCta, c) && poleOk(c);
3050
- if (!brandWentUp && clean(redCta)) return null;
3051
- const hues = hueDelta(seed.H, redCta.H) <= RED_SOLVE.redHueMagentaDh ? RED_SOLVE.redHuesMagentaBrand : RED_SOLVE.redHuesWarmBrand;
3052
- const wantLighter = brandCta.L <= redCta.L;
3053
- const tiers = wantLighter ? [[...RED_SOLVE.edgeL], RED_SOLVE.coreL.filter((l) => l > brandCta.L)] : [[...RED_SOLVE.coreL], [...RED_SOLVE.edgeL]];
3058
+ if (!brandWentUp && redCleanBeside(brandCta, redCta) && poleOk(redCta)) return null;
3059
+ const own = classifyArchetype(redCta.L);
3060
+ const band = classifyArchetype(brandCta.L) === own ? bandBelow(own) : own;
3061
+ const sets = redSetsFor(red, contrastProfile);
3054
3062
  let pick = null;
3055
- outer: for (const tier of tiers) {
3056
- const ls = [...tier].sort((a, b) => wantLighter ? a - b : b - a);
3057
- for (const L of ls) for (const H2 of hues) {
3058
- const c = slideToBar(at(L, H2));
3059
- if (!c) continue;
3060
- const onSide = wantLighter ? c.L > brandCta.L : c.L < brandCta.L;
3061
- if (!onSide || !clean(c)) continue;
3062
- pick = c;
3063
- break outer;
3064
- }
3065
- }
3066
- if (!pick) return null;
3067
- const cta = makeStop(redCta.stop, pick.L, rctx.cAt("light", pick.L, rctx.brandC), pick.H);
3068
- const vCFor = (L) => rctx.cAt("light", L, rctx.brandC);
3069
- const hL = stateFillL(pick.L, "light", 1);
3070
- const ctaHover = makeStop(red.scale.ctaHover.stop, hL, vCFor(hL), pick.H);
3071
- const pL = stateFillL(pick.L, "light", 2);
3072
- const ctaPressed = makeStop(red.scale.ctaPressed.stop, pL, vCFor(pL), pick.H);
3073
- const onFillTextIsWhite = onFillIsWhiteDarkAt(cta.L, cta.C, cta.H, true, contrastProfile === "apca" ? void 0 : 4.5);
3063
+ for (const set of Object.keys(RED_SOLVE.setHues)) {
3064
+ const scale = sets.get(redSetKey(set, band));
3065
+ const d = p2Diff(brandCta, scale.cta);
3066
+ if (!pick || d > pick.d) pick = { set, scale, d };
3067
+ }
3068
+ const { cta, ctaHover, ctaPressed, onFillTextIsWhite } = pick.scale;
3074
3069
  return {
3075
3070
  scale: { ...red.scale, cta, ctaHover, ctaPressed, onFillTextIsWhite },
3076
- // naming candidates only — the identity name is the owner's call at bless. No hue suffix:
3077
- // the plugin's note parser mints Figma variable paths from this string.
3078
- note: `red \u2192 ${pick.L < redCta.L ? "rich" : "coral"} L${pick.L.toFixed(2)}`
3071
+ // the community plugin shows the part after the arrow as a display label; the hex is
3072
+ // the variant's identity there, so the note carries no value
3073
+ note: `red \u2192 ${pick.set} at ${band}`
3079
3074
  };
3080
3075
  }
3081
3076
  function resolveBrand(hex, name, opts) {
@@ -3121,7 +3116,7 @@ function resolveBrand(hex, name, opts) {
3121
3116
  const signalOverrides = [];
3122
3117
  if (solving || opts?.exact && signalMoves && !opts?.archetypeOverride) {
3123
3118
  const brandWentUp = !!scale.ctaRepelled?.light && scale.cta.L > seedO.L + 1e-6;
3124
- const v2 = redComplementVariant(scale.cta, seedO, brandWentUp, sigScales.get("red"), opts?.contrastProfile);
3119
+ const v2 = redComplementVariant(scale.cta, brandWentUp, sigScales.get("red"), opts?.contrastProfile);
3125
3120
  if (v2) signalOverrides.push({ name: "red", scale: v2.scale, note: v2.note });
3126
3121
  }
3127
3122
  let warnVariant = null;
@@ -3231,11 +3226,12 @@ function escapeCtaFamily(nScale, mode, contrastProfile) {
3231
3226
  if (!strongText) throw new Error("escapeCtaFamily: the neutral scale has no ink-11 stop");
3232
3227
  const mk = (stop, L) => makeStop(stop, L, strongText.C, strongText.H);
3233
3228
  const cta = mk(9, strongText.L);
3234
- const ctaHover = mk(10, stateFillL(strongText.L, mode, 1));
3235
- const ctaPressed = mk(11, stateFillL(strongText.L, mode, 2));
3236
3229
  const onEnforce = contrastProfile !== "apca";
3237
3230
  const onFloor = contrastProfile === "apca" ? void 0 : 4.5;
3238
3231
  const onFillIsWhite = onTextIsWhite(apcaY(cta.r, cta.g, cta.b), cta.L, cta.C, cta.H, onEnforce, onFloor);
3232
+ const keepsPole = stepKeepsPole(mode, onFillIsWhite, () => strongText.C, strongText.H, { ratioFloor: onFloor });
3233
+ const ctaHover = mk(10, stateFillL(strongText.L, mode, 1, keepsPole));
3234
+ const ctaPressed = mk(11, stateFillL(strongText.L, mode, 2, keepsPole));
3239
3235
  return { cta, ctaHover, ctaPressed, onFillIsWhite };
3240
3236
  }
3241
3237
  var SECONDARY_DISTINCT_DELTA_E = 0.12;
@@ -3282,7 +3278,7 @@ function resolveTheme(input) {
3282
3278
  const red = sigScales.get("red");
3283
3279
  const primaryRed = mergedOverrides.find((o) => o.name === "red");
3284
3280
  if (!primaryRed) {
3285
- const v2 = redComplementVariant(secScale.cta, hexToOklch(secSeedHex), false, red, cp2);
3281
+ const v2 = redComplementVariant(secScale.cta, false, red, cp2);
3286
3282
  if (v2 && redCleanBeside(primary.scale.cta, v2.scale.cta)) adopt("red", v2.scale, `${v2.note} (for the secondary)`);
3287
3283
  }
3288
3284
  for (const sigName of ["yellow", "green", "blue"]) {
@@ -10,4 +10,4 @@ export declare function medianLForArchetype(archetype: Archetype): number;
10
10
  export declare function hoverL(L: number): number;
11
11
  export declare function pressedL(L: number): number;
12
12
  export declare function stateStepL(restL: number, _mode: 'light' | 'dark', k: 1 | 2): number;
13
- export declare function stateFillL(restL: number, mode: 'light' | 'dark', k: 1 | 2): number;
13
+ export declare function stateFillL(restL: number, mode: 'light' | 'dark', k: 1 | 2, keepsPole?: (L: number) => boolean): number;
@@ -66,11 +66,11 @@ export declare const RED_SOLVE: {
66
66
  readonly brickCoolGold: -3;
67
67
  readonly brickDesat: 0.85;
68
68
  readonly brickExtraDeep: 0.02;
69
- readonly coreL: readonly [0.49, 0.47, 0.45];
70
- readonly edgeL: readonly [0.65, 0.68, 0.71, 0.75];
71
- readonly redHuesWarmBrand: readonly [28, 24, 33.3, 38];
72
- readonly redHuesMagentaBrand: readonly [33.3, 38, 28];
73
- readonly redHueMagentaDh: -15;
69
+ readonly setHues: {
70
+ readonly true: 33.3;
71
+ readonly cool: 24;
72
+ readonly 'gold-side': 38;
73
+ };
74
74
  };
75
75
  export declare function redSolveDist(c: {
76
76
  L: number;
@@ -1,6 +1,5 @@
1
1
  export declare const P2_D = 0.12;
2
2
  export declare const P2_D_UP = 0.11;
3
- export declare const P2_D_FALLBACK = 0.115;
4
3
  export declare function p2Diff(a: {
5
4
  L: number;
6
5
  C: number;
@@ -133,6 +133,10 @@ export declare function ctaDarkDualGateL(base: {
133
133
  L: number;
134
134
  C: number;
135
135
  }, darkH: number, onFillTextIsWhite: boolean, enforce: boolean, coLc: number, capLoL: number, capHiL: number): number;
136
+ export declare function stepKeepsPole(mode: 'light' | 'dark', white: boolean, chromaAt: (L: number) => number, H: number, law: {
137
+ ratioFloor?: number;
138
+ enforceLc?: number;
139
+ }): ((L: number) => boolean) | undefined;
136
140
  export declare function onFillIsWhiteDarkAt(L: number, C: number, H: number, enforce: boolean, ratioFloor?: number): boolean;
137
141
  export interface BrandExitLanding {
138
142
  L: number;
@@ -1,5 +1,5 @@
1
1
  import { type GeneratedScale, type ContrastProfile, type ColorStop } from './colorEngine';
2
- import type { Archetype } from './archetypes';
2
+ import { type Archetype } from './archetypes';
3
3
  import { type SignalDef } from './signals';
4
4
  type SignalScales = Map<SignalDef['name'], {
5
5
  def: SignalDef;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okchroma",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "license": "MIT",
5
5
  "description": "Color-system engine: one brand hex in, a light and dark set of color primitives out, with the contrast requirements solved during generation; emitted as CSS custom properties, DTCG documents, and Figma variables",
6
6
  "repository": {