okchroma 0.9.0 → 0.9.1

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);
@@ -2224,6 +2224,20 @@ function ctaDarkDualGateL(base, darkH, onFillTextIsWhite, enforce, coLc, capLoL,
2224
2224
  const lLc = findLForBlackTextLc(startL, C2, darkH, coLc + APCA_SOLVE_MARGIN_LC, capHiL);
2225
2225
  return Math.max(l45, Math.min(lLc, capHiL));
2226
2226
  }
2227
+ function stepKeepsPole(mode, white, chromaAt, H2, law) {
2228
+ if (mode === "light") return void 0;
2229
+ if (law.enforceLc !== void 0) {
2230
+ const bar = law.enforceLc;
2231
+ const lcAt = white ? whiteTextLcAt : blackTextLcAt;
2232
+ return (L) => lcAt(L, chromaAt(L), H2) >= bar;
2233
+ }
2234
+ if (law.ratioFloor === void 0) return void 0;
2235
+ const floor = law.ratioFloor, poleY = white ? 1 : 0;
2236
+ return (L) => {
2237
+ const C2 = clampChromaToGamut(L, chromaAt(L), H2);
2238
+ return Math.min(legalRatio(L, C2, H2, poleY), contrastRatio(shippedY(L, C2, H2), poleY)) >= floor;
2239
+ };
2240
+ }
2227
2241
  function onFillIsWhiteDarkAt(L, C2, H2, enforce, ratioFloor) {
2228
2242
  return onTextIsWhite(apcaYAt(L, C2, H2), L, C2, H2, enforce, ratioFloor);
2229
2243
  }
@@ -2506,7 +2520,8 @@ function resolveRamp(hex, mode, spec, opts) {
2506
2520
  const enforceLc = spec.ons.onFill.enforceLc;
2507
2521
  const coLc = ctx.opts?.apcaClearance && enforceLc === void 0 ? ctx.opts.apcaClearanceLc ?? spec.ons.onFill.coEnforceLc : void 0;
2508
2522
  const onFloor = enforceLc !== void 0 ? void 0 : spec.ons.onFill.ratioFloor;
2509
- let cta, ctaHover, ctaPressed, onFillIsWhite;
2523
+ let cta, onFillIsWhite;
2524
+ let stateC, stateH;
2510
2525
  if (mode === "light") {
2511
2526
  onFillIsWhite = onFillIsWhiteLight(ctx, enforceLc !== void 0 ? false : onFillEnforce);
2512
2527
  let light9L = enforceLc !== void 0 ? ctaLightLApca(ctx, onFillIsWhite, onFillEnforce, enforceLc) : ctaLightL(ctx, onFillIsWhite, onFillEnforce);
@@ -2538,21 +2553,14 @@ function resolveRamp(hex, mode, spec, opts) {
2538
2553
  }
2539
2554
  }
2540
2555
  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
- }
2556
+ stateC = {
2557
+ hover: (L) => ctx.cAt("light", L, (hoverReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul,
2558
+ pressed: (L) => ctx.cAt("light", L, (pressedReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul
2559
+ };
2560
+ stateH = ctaH;
2561
+ if (light9L !== ctx.scaleL) cta.enforced = true;
2552
2562
  if (repelled) {
2553
2563
  cta.repelled = true;
2554
- ctaHover.repelled = true;
2555
- ctaPressed.repelled = true;
2556
2564
  onFillIsWhite = onFillIsWhiteDarkAt(
2557
2565
  cta.L,
2558
2566
  cta.C,
@@ -2565,50 +2573,29 @@ function resolveRamp(hex, mode, spec, opts) {
2565
2573
  const d = dctx;
2566
2574
  const cta9L = ctx.opts?.darkCtaFlatApp !== void 0 ? flatDarkCtaL(d, (L) => ctx.cAt("dark", L, d.darkC9), ctx.darkCtaH, ctx.opts.darkCtaFlatApp) : d.dark9L;
2567
2575
  const dCFor = (L) => ctx.cAt("dark", L, d.darkC9);
2568
- const dTrio = (baseL) => [stateFillL(baseL, "dark", 1), stateFillL(baseL, "dark", 2)];
2576
+ stateC = { hover: dCFor, pressed: dCFor };
2577
+ stateH = ctx.darkCtaH;
2569
2578
  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
2579
  onFillIsWhite = onFillIsWhiteDarkAt(cta.L, cta.C, cta.H, enforceLc !== void 0 ? false : onFillEnforce);
2576
2580
  const enforcedL = enforceLc !== void 0 ? ctaDarkEnforcedLApca(ctx, cta, onFillIsWhite, onFillEnforce, enforceLc) : ctaDarkEnforcedL(ctx, cta, onFillIsWhite, onFillEnforce);
2577
2581
  if (enforcedL !== null) {
2578
2582
  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
2583
  cta.enforced = true;
2583
- ctaHover.enforced = true;
2584
- ctaPressed.enforced = true;
2585
2584
  }
2586
2585
  if (coLc !== void 0) {
2587
2586
  const [capLoL, capHiL] = CTA_CLEARANCE_CAPS;
2588
2587
  const clearedL = ctaDarkDualGateL(cta, ctx.darkCtaH, onFillIsWhite, onFillEnforce, coLc + APCA_ENFORCE_MARGIN_LC, capLoL, capHiL);
2589
2588
  if (Math.abs(clearedL - cta.L) > 1e-9) {
2590
2589
  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
2590
  cta.enforced = true;
2595
- ctaHover.enforced = true;
2596
- ctaPressed.enforced = true;
2597
2591
  }
2598
2592
  }
2599
2593
  if (ctx.opts?.ctaSolve) {
2600
2594
  const exitL = solveDarkCtaExit(cta, dCFor, ctx.darkCtaH, ctx.opts.ctaSolve.redDark, enforceLc, coLc);
2601
2595
  if (exitL !== null) {
2602
2596
  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
2597
  cta.enforced = true;
2607
- ctaHover.enforced = true;
2608
- ctaPressed.enforced = true;
2609
2598
  cta.repelled = true;
2610
- ctaHover.repelled = true;
2611
- ctaPressed.repelled = true;
2612
2599
  onFillIsWhite = onFillIsWhiteDarkAt(
2613
2600
  cta.L,
2614
2601
  cta.C,
@@ -2624,6 +2611,19 @@ function resolveRamp(hex, mode, spec, opts) {
2624
2611
  const other = legalRatio(cta.L, cta.C, cta.H, onFillIsWhite ? 0 : 1);
2625
2612
  if (chosen < onFloor && other >= onFloor) onFillIsWhite = !onFillIsWhite;
2626
2613
  }
2614
+ const keepsPole = stepKeepsPole(mode, onFillIsWhite, stateC.pressed, stateH, { ratioFloor: onFloor, enforceLc });
2615
+ const hL = stateFillL(cta.L, mode, 1, keepsPole);
2616
+ const pL = stateFillL(cta.L, mode, 2, keepsPole);
2617
+ const ctaHover = emitRole(STAMP_FILL_HOVER, hL, stateC.hover(hL), stateH);
2618
+ const ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, stateC.pressed(pL), stateH);
2619
+ if (cta.enforced) {
2620
+ ctaHover.enforced = true;
2621
+ ctaPressed.enforced = true;
2622
+ }
2623
+ if (cta.repelled) {
2624
+ ctaHover.repelled = true;
2625
+ ctaPressed.repelled = true;
2626
+ }
2627
2627
  return { mode, seed, stops, roles: { cta, ctaHover, ctaPressed }, ons: { onFillIsWhite } };
2628
2628
  }
2629
2629
 
@@ -2706,18 +2706,18 @@ function generateNeutralScale(brandH, level = "default", contrastProfile) {
2706
2706
  if (popLift > 1e-6) {
2707
2707
  scale.ctaDark = makeStop(9, scale.dark[3].L + popLift, scale.dark[3].C, scale.dark[3].H);
2708
2708
  }
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
2709
  const onEnforce = contrastProfile !== "apca";
2718
2710
  const onFloor = 4.5;
2719
2711
  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
2712
  scale.onFillTextIsWhiteDark = onTextIsWhite(apcaY(scale.ctaDark.r, scale.ctaDark.g, scale.ctaDark.b), scale.ctaDark.L, scale.ctaDark.C, scale.ctaDark.H, onEnforce, onFloor);
2713
+ const nState = (stop, mode, rest, k, white) => {
2714
+ const L = stateFillL(rest.L, mode, k, stepKeepsPole(mode, white, (l) => curve(l, mode), rest.H, { ratioFloor: onFloor }));
2715
+ return makeStop(stop, L, curve(L, mode), rest.H);
2716
+ };
2717
+ scale.ctaHover = nState(10, "light", scale.cta, 1, scale.onFillTextIsWhite);
2718
+ scale.ctaPressed = nState(11, "light", scale.cta, 2, scale.onFillTextIsWhite);
2719
+ scale.ctaHoverDark = nState(10, "dark", scale.ctaDark, 1, scale.onFillTextIsWhiteDark);
2720
+ scale.ctaPressedDark = nState(11, "dark", scale.ctaDark, 2, scale.onFillTextIsWhiteDark);
2721
2721
  return scale;
2722
2722
  }
2723
2723
  function generateSubtleSecondary(hex, opts) {
@@ -2730,10 +2730,6 @@ function generateSubtleSecondary(hex, opts) {
2730
2730
  });
2731
2731
  const asCta = (stop, src) => makeStop(stop, src.L, src.C, src.H);
2732
2732
  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
2733
  if (opts?.ctaL) {
2738
2734
  scale.cta = mk(9, opts.ctaL.light, "light");
2739
2735
  scale.ctaDark = mk(9, opts.ctaL.dark, "dark");
@@ -2741,14 +2737,18 @@ function generateSubtleSecondary(hex, opts) {
2741
2737
  scale.cta = asCta(9, scale.light[3]);
2742
2738
  scale.ctaDark = asCta(9, scale.dark[3]);
2743
2739
  }
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
2740
  const onEnforce = opts?.contrastProfile !== "apca";
2749
2741
  const onFloor = 4.5;
2750
2742
  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
2743
  scale.onFillTextIsWhiteDark = onTextIsWhite(apcaY(scale.ctaDark.r, scale.ctaDark.g, scale.ctaDark.b), scale.ctaDark.L, scale.ctaDark.C, scale.ctaDark.H, onEnforce, onFloor);
2744
+ const st = (stop, mode, rest, k, white) => {
2745
+ const L = stateFillL(rest.L, mode, k, stepKeepsPole(mode, white, (l) => curve(l, mode), rest.H, { ratioFloor: onFloor }));
2746
+ return makeStop(stop, L, curve(L, mode), rest.H);
2747
+ };
2748
+ scale.ctaHover = st(10, "light", scale.cta, 1, scale.onFillTextIsWhite);
2749
+ scale.ctaPressed = st(11, "light", scale.cta, 2, scale.onFillTextIsWhite);
2750
+ scale.ctaHoverDark = st(10, "dark", scale.ctaDark, 1, scale.onFillTextIsWhiteDark);
2751
+ scale.ctaPressedDark = st(11, "dark", scale.ctaDark, 2, scale.onFillTextIsWhiteDark);
2752
2752
  return scale;
2753
2753
  }
2754
2754
 
@@ -3324,11 +3324,12 @@ function escapeCtaFamily(nScale, mode, contrastProfile) {
3324
3324
  if (!strongText) throw new Error("escapeCtaFamily: the neutral scale has no ink-11 stop");
3325
3325
  const mk = (stop, L) => makeStop(stop, L, strongText.C, strongText.H);
3326
3326
  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
3327
  const onEnforce = contrastProfile !== "apca";
3330
3328
  const onFloor = contrastProfile === "apca" ? void 0 : 4.5;
3331
3329
  const onFillIsWhite = onTextIsWhite(apcaY(cta.r, cta.g, cta.b), cta.L, cta.C, cta.H, onEnforce, onFloor);
3330
+ const keepsPole = stepKeepsPole(mode, onFillIsWhite, () => strongText.C, strongText.H, { ratioFloor: onFloor });
3331
+ const ctaHover = mk(10, stateFillL(strongText.L, mode, 1, keepsPole));
3332
+ const ctaPressed = mk(11, stateFillL(strongText.L, mode, 2, keepsPole));
3332
3333
  return { cta, ctaHover, ctaPressed, onFillIsWhite };
3333
3334
  }
3334
3335
  var SECONDARY_DISTINCT_DELTA_E = 0.12;
@@ -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);
@@ -2131,6 +2131,20 @@ function ctaDarkDualGateL(base, darkH, onFillTextIsWhite, enforce, coLc, capLoL,
2131
2131
  const lLc = findLForBlackTextLc(startL, C2, darkH, coLc + APCA_SOLVE_MARGIN_LC, capHiL);
2132
2132
  return Math.max(l45, Math.min(lLc, capHiL));
2133
2133
  }
2134
+ function stepKeepsPole(mode, white, chromaAt, H2, law) {
2135
+ if (mode === "light") return void 0;
2136
+ if (law.enforceLc !== void 0) {
2137
+ const bar = law.enforceLc;
2138
+ const lcAt = white ? whiteTextLcAt : blackTextLcAt;
2139
+ return (L) => lcAt(L, chromaAt(L), H2) >= bar;
2140
+ }
2141
+ if (law.ratioFloor === void 0) return void 0;
2142
+ const floor = law.ratioFloor, poleY = white ? 1 : 0;
2143
+ return (L) => {
2144
+ const C2 = clampChromaToGamut(L, chromaAt(L), H2);
2145
+ return Math.min(legalRatio(L, C2, H2, poleY), contrastRatio(shippedY(L, C2, H2), poleY)) >= floor;
2146
+ };
2147
+ }
2134
2148
  function onFillIsWhiteDarkAt(L, C2, H2, enforce, ratioFloor) {
2135
2149
  return onTextIsWhite(apcaYAt(L, C2, H2), L, C2, H2, enforce, ratioFloor);
2136
2150
  }
@@ -2413,7 +2427,8 @@ function resolveRamp(hex, mode, spec, opts) {
2413
2427
  const enforceLc = spec.ons.onFill.enforceLc;
2414
2428
  const coLc = ctx.opts?.apcaClearance && enforceLc === void 0 ? ctx.opts.apcaClearanceLc ?? spec.ons.onFill.coEnforceLc : void 0;
2415
2429
  const onFloor = enforceLc !== void 0 ? void 0 : spec.ons.onFill.ratioFloor;
2416
- let cta, ctaHover, ctaPressed, onFillIsWhite;
2430
+ let cta, onFillIsWhite;
2431
+ let stateC, stateH;
2417
2432
  if (mode === "light") {
2418
2433
  onFillIsWhite = onFillIsWhiteLight(ctx, enforceLc !== void 0 ? false : onFillEnforce);
2419
2434
  let light9L = enforceLc !== void 0 ? ctaLightLApca(ctx, onFillIsWhite, onFillEnforce, enforceLc) : ctaLightL(ctx, onFillIsWhite, onFillEnforce);
@@ -2445,21 +2460,14 @@ function resolveRamp(hex, mode, spec, opts) {
2445
2460
  }
2446
2461
  }
2447
2462
  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
- }
2463
+ stateC = {
2464
+ hover: (L) => ctx.cAt("light", L, (hoverReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul,
2465
+ pressed: (L) => ctx.cAt("light", L, (pressedReq?.chromaMult ?? 1) * ctx.brandC) * ctaCMul
2466
+ };
2467
+ stateH = ctaH;
2468
+ if (light9L !== ctx.scaleL) cta.enforced = true;
2459
2469
  if (repelled) {
2460
2470
  cta.repelled = true;
2461
- ctaHover.repelled = true;
2462
- ctaPressed.repelled = true;
2463
2471
  onFillIsWhite = onFillIsWhiteDarkAt(
2464
2472
  cta.L,
2465
2473
  cta.C,
@@ -2472,50 +2480,29 @@ function resolveRamp(hex, mode, spec, opts) {
2472
2480
  const d = dctx;
2473
2481
  const cta9L = ctx.opts?.darkCtaFlatApp !== void 0 ? flatDarkCtaL(d, (L) => ctx.cAt("dark", L, d.darkC9), ctx.darkCtaH, ctx.opts.darkCtaFlatApp) : d.dark9L;
2474
2482
  const dCFor = (L) => ctx.cAt("dark", L, d.darkC9);
2475
- const dTrio = (baseL) => [stateFillL(baseL, "dark", 1), stateFillL(baseL, "dark", 2)];
2483
+ stateC = { hover: dCFor, pressed: dCFor };
2484
+ stateH = ctx.darkCtaH;
2476
2485
  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
2486
  onFillIsWhite = onFillIsWhiteDarkAt(cta.L, cta.C, cta.H, enforceLc !== void 0 ? false : onFillEnforce);
2483
2487
  const enforcedL = enforceLc !== void 0 ? ctaDarkEnforcedLApca(ctx, cta, onFillIsWhite, onFillEnforce, enforceLc) : ctaDarkEnforcedL(ctx, cta, onFillIsWhite, onFillEnforce);
2484
2488
  if (enforcedL !== null) {
2485
2489
  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
2490
  cta.enforced = true;
2490
- ctaHover.enforced = true;
2491
- ctaPressed.enforced = true;
2492
2491
  }
2493
2492
  if (coLc !== void 0) {
2494
2493
  const [capLoL, capHiL] = CTA_CLEARANCE_CAPS;
2495
2494
  const clearedL = ctaDarkDualGateL(cta, ctx.darkCtaH, onFillIsWhite, onFillEnforce, coLc + APCA_ENFORCE_MARGIN_LC, capLoL, capHiL);
2496
2495
  if (Math.abs(clearedL - cta.L) > 1e-9) {
2497
2496
  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
2497
  cta.enforced = true;
2502
- ctaHover.enforced = true;
2503
- ctaPressed.enforced = true;
2504
2498
  }
2505
2499
  }
2506
2500
  if (ctx.opts?.ctaSolve) {
2507
2501
  const exitL = solveDarkCtaExit(cta, dCFor, ctx.darkCtaH, ctx.opts.ctaSolve.redDark, enforceLc, coLc);
2508
2502
  if (exitL !== null) {
2509
2503
  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
2504
  cta.enforced = true;
2514
- ctaHover.enforced = true;
2515
- ctaPressed.enforced = true;
2516
2505
  cta.repelled = true;
2517
- ctaHover.repelled = true;
2518
- ctaPressed.repelled = true;
2519
2506
  onFillIsWhite = onFillIsWhiteDarkAt(
2520
2507
  cta.L,
2521
2508
  cta.C,
@@ -2531,6 +2518,19 @@ function resolveRamp(hex, mode, spec, opts) {
2531
2518
  const other = legalRatio(cta.L, cta.C, cta.H, onFillIsWhite ? 0 : 1);
2532
2519
  if (chosen < onFloor && other >= onFloor) onFillIsWhite = !onFillIsWhite;
2533
2520
  }
2521
+ const keepsPole = stepKeepsPole(mode, onFillIsWhite, stateC.pressed, stateH, { ratioFloor: onFloor, enforceLc });
2522
+ const hL = stateFillL(cta.L, mode, 1, keepsPole);
2523
+ const pL = stateFillL(cta.L, mode, 2, keepsPole);
2524
+ const ctaHover = emitRole(STAMP_FILL_HOVER, hL, stateC.hover(hL), stateH);
2525
+ const ctaPressed = emitRole(STAMP_FILL_PRESSED, pL, stateC.pressed(pL), stateH);
2526
+ if (cta.enforced) {
2527
+ ctaHover.enforced = true;
2528
+ ctaPressed.enforced = true;
2529
+ }
2530
+ if (cta.repelled) {
2531
+ ctaHover.repelled = true;
2532
+ ctaPressed.repelled = true;
2533
+ }
2534
2534
  return { mode, seed, stops, roles: { cta, ctaHover, ctaPressed }, ons: { onFillIsWhite } };
2535
2535
  }
2536
2536
 
@@ -2613,18 +2613,18 @@ function generateNeutralScale(brandH, level = "default", contrastProfile) {
2613
2613
  if (popLift > 1e-6) {
2614
2614
  scale.ctaDark = makeStop(9, scale.dark[3].L + popLift, scale.dark[3].C, scale.dark[3].H);
2615
2615
  }
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
2616
  const onEnforce = contrastProfile !== "apca";
2625
2617
  const onFloor = 4.5;
2626
2618
  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
2619
  scale.onFillTextIsWhiteDark = onTextIsWhite(apcaY(scale.ctaDark.r, scale.ctaDark.g, scale.ctaDark.b), scale.ctaDark.L, scale.ctaDark.C, scale.ctaDark.H, onEnforce, onFloor);
2620
+ const nState = (stop, mode, rest, k, white) => {
2621
+ const L = stateFillL(rest.L, mode, k, stepKeepsPole(mode, white, (l) => curve(l, mode), rest.H, { ratioFloor: onFloor }));
2622
+ return makeStop(stop, L, curve(L, mode), rest.H);
2623
+ };
2624
+ scale.ctaHover = nState(10, "light", scale.cta, 1, scale.onFillTextIsWhite);
2625
+ scale.ctaPressed = nState(11, "light", scale.cta, 2, scale.onFillTextIsWhite);
2626
+ scale.ctaHoverDark = nState(10, "dark", scale.ctaDark, 1, scale.onFillTextIsWhiteDark);
2627
+ scale.ctaPressedDark = nState(11, "dark", scale.ctaDark, 2, scale.onFillTextIsWhiteDark);
2628
2628
  return scale;
2629
2629
  }
2630
2630
  function generateSubtleSecondary(hex, opts) {
@@ -2637,10 +2637,6 @@ function generateSubtleSecondary(hex, opts) {
2637
2637
  });
2638
2638
  const asCta = (stop, src) => makeStop(stop, src.L, src.C, src.H);
2639
2639
  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
2640
  if (opts?.ctaL) {
2645
2641
  scale.cta = mk(9, opts.ctaL.light, "light");
2646
2642
  scale.ctaDark = mk(9, opts.ctaL.dark, "dark");
@@ -2648,14 +2644,18 @@ function generateSubtleSecondary(hex, opts) {
2648
2644
  scale.cta = asCta(9, scale.light[3]);
2649
2645
  scale.ctaDark = asCta(9, scale.dark[3]);
2650
2646
  }
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
2647
  const onEnforce = opts?.contrastProfile !== "apca";
2656
2648
  const onFloor = 4.5;
2657
2649
  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
2650
  scale.onFillTextIsWhiteDark = onTextIsWhite(apcaY(scale.ctaDark.r, scale.ctaDark.g, scale.ctaDark.b), scale.ctaDark.L, scale.ctaDark.C, scale.ctaDark.H, onEnforce, onFloor);
2651
+ const st = (stop, mode, rest, k, white) => {
2652
+ const L = stateFillL(rest.L, mode, k, stepKeepsPole(mode, white, (l) => curve(l, mode), rest.H, { ratioFloor: onFloor }));
2653
+ return makeStop(stop, L, curve(L, mode), rest.H);
2654
+ };
2655
+ scale.ctaHover = st(10, "light", scale.cta, 1, scale.onFillTextIsWhite);
2656
+ scale.ctaPressed = st(11, "light", scale.cta, 2, scale.onFillTextIsWhite);
2657
+ scale.ctaHoverDark = st(10, "dark", scale.ctaDark, 1, scale.onFillTextIsWhiteDark);
2658
+ scale.ctaPressedDark = st(11, "dark", scale.ctaDark, 2, scale.onFillTextIsWhiteDark);
2659
2659
  return scale;
2660
2660
  }
2661
2661
 
@@ -3231,11 +3231,12 @@ function escapeCtaFamily(nScale, mode, contrastProfile) {
3231
3231
  if (!strongText) throw new Error("escapeCtaFamily: the neutral scale has no ink-11 stop");
3232
3232
  const mk = (stop, L) => makeStop(stop, L, strongText.C, strongText.H);
3233
3233
  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
3234
  const onEnforce = contrastProfile !== "apca";
3237
3235
  const onFloor = contrastProfile === "apca" ? void 0 : 4.5;
3238
3236
  const onFillIsWhite = onTextIsWhite(apcaY(cta.r, cta.g, cta.b), cta.L, cta.C, cta.H, onEnforce, onFloor);
3237
+ const keepsPole = stepKeepsPole(mode, onFillIsWhite, () => strongText.C, strongText.H, { ratioFloor: onFloor });
3238
+ const ctaHover = mk(10, stateFillL(strongText.L, mode, 1, keepsPole));
3239
+ const ctaPressed = mk(11, stateFillL(strongText.L, mode, 2, keepsPole));
3239
3240
  return { cta, ctaHover, ctaPressed, onFillIsWhite };
3240
3241
  }
3241
3242
  var SECONDARY_DISTINCT_DELTA_E = 0.12;
@@ -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;
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okchroma",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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": {