@promptctl/rich-js 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,11 +31,42 @@ export declare class ColorRgba {
31
31
  */
32
32
  compositeOver(bg: ColorRgba): ColorRgba;
33
33
  }
34
+ /**
35
+ * The surface a terminal draws a translucent colour over. A terminal cannot
36
+ * know what lies under its cells, so the SGR writer (`Style.toSgrCodes`), the
37
+ * strip's seam test and the contrast choosers (`themes/colorMath`) all
38
+ * composite over this one colour. [LAW:one-source-of-truth] One constant, so
39
+ * the colour text is chosen against is the colour the writer draws.
40
+ */
41
+ export declare const SURFACE_BLACK: ColorRgba;
42
+ /**
43
+ * WCAG 2.x relative luminance (0..1) of an opaque color. The single
44
+ * luminance function in the codebase — `contrastFor`, `contrastRatio`, and
45
+ * any caller that needs to reason about readability all funnel through it.
46
+ * [LAW:one-source-of-truth]
47
+ */
48
+ export declare function relativeLuminance(c: ColorRgba): number;
49
+ /**
50
+ * WCAG 2.x contrast ratio between two colors, in [1, 21]. Symmetric — the
51
+ * order of arguments does not matter. 4.5 is the AA threshold for normal
52
+ * text, 3.0 for large text.
53
+ *
54
+ * Assumes opaque inputs: alpha is ignored, since the displayed contrast of a
55
+ * translucent color depends on what it composites over. For a translucent
56
+ * foreground, flatten it first (or use `ensureContrast`, which does).
57
+ */
58
+ export declare function contrastRatio(a: ColorRgba, b: ColorRgba): number;
34
59
  export declare class ColorTable {
35
60
  private readonly colors;
61
+ private readonly firstIndex;
36
62
  private readonly matchCache;
37
- constructor(colors: ColorRgba[]);
63
+ private readonly readableCache;
64
+ constructor(colors: ColorRgba[], firstIndex?: number);
38
65
  get(index: number): ColorRgba;
66
+ private luminanceCache;
67
+ /** Each entry's relative luminance, computed once per table. */
68
+ private luminances;
69
+ /** How many entries the table holds (terminal indices `firstIndex`…). */
39
70
  get size(): number;
40
71
  /**
41
72
  * Finds the nearest table index to the given color (Euclidean RGB distance, cached).
@@ -43,6 +74,16 @@ export declare class ColorTable {
43
74
  * don't collide, but is not used in the distance metric.
44
75
  */
45
76
  match(value: ColorRgba): number;
77
+ /**
78
+ * The nearest entry to `value` (the distance `match` uses) among those that
79
+ * clear `minRatio` against `on`; when none does, the entry with the most
80
+ * contrast against `on`. Text downgraded on its own background: `match`
81
+ * moves text and background independently, and two independent roundings
82
+ * can meet in the middle, so a pair that read at 4.5:1 in truecolor can
83
+ * draw at 2:1. [LAW:dataflow-not-control-flow] One scan scores every entry;
84
+ * the ratio decides which one wins.
85
+ */
86
+ matchReadable(value: ColorRgba, on: ColorRgba, minRatio: number): number;
46
87
  }
47
88
  export declare enum ColorDepth {
48
89
  DEFAULT = 0,
@@ -153,6 +194,13 @@ export declare class TerminalTheme {
153
194
  }
154
195
  export declare const STANDARD_TABLE: ColorTable;
155
196
  export declare const EIGHT_BIT_TABLE: ColorTable;
197
+ /**
198
+ * What a downgrade to 256 colours may choose: the cube and the grey ramp,
199
+ * indices 16–255. Indices 0–15 are the terminal's own ANSI colours, which
200
+ * every theme redefines, so their RGB is unknown and a match against them is
201
+ * a guess; Python Rich never picks them either.
202
+ */
203
+ export declare const EIGHT_BIT_DOWNGRADE_TABLE: ColorTable;
156
204
  export declare const WINDOWS_TABLE: ColorTable;
157
205
  export declare const ANSI_COLOR_NAMES: Record<string, number>;
158
206
  /**
@@ -65,16 +65,82 @@ export class ColorRgba {
65
65
  return new ColorRgba(Math.round(bg.red + (this.red - bg.red) * t), Math.round(bg.green + (this.green - bg.green) * t), Math.round(bg.blue + (this.blue - bg.blue) * t), 1);
66
66
  }
67
67
  }
68
+ /**
69
+ * The surface a terminal draws a translucent colour over. A terminal cannot
70
+ * know what lies under its cells, so the SGR writer (`Style.toSgrCodes`), the
71
+ * strip's seam test and the contrast choosers (`themes/colorMath`) all
72
+ * composite over this one colour. [LAW:one-source-of-truth] One constant, so
73
+ * the colour text is chosen against is the colour the writer draws.
74
+ */
75
+ export const SURFACE_BLACK = new ColorRgba(0, 0, 0);
76
+ /**
77
+ * WCAG 2.x relative luminance (0..1) of an opaque color. The single
78
+ * luminance function in the codebase — `contrastFor`, `contrastRatio`, and
79
+ * any caller that needs to reason about readability all funnel through it.
80
+ * [LAW:one-source-of-truth]
81
+ */
82
+ export function relativeLuminance(c) {
83
+ const ch = (v) => {
84
+ const x = v / 255;
85
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
86
+ };
87
+ return 0.2126 * ch(c.red) + 0.7152 * ch(c.green) + 0.0722 * ch(c.blue);
88
+ }
89
+ /**
90
+ * WCAG 2.x contrast ratio between two colors, in [1, 21]. Symmetric — the
91
+ * order of arguments does not matter. 4.5 is the AA threshold for normal
92
+ * text, 3.0 for large text.
93
+ *
94
+ * Assumes opaque inputs: alpha is ignored, since the displayed contrast of a
95
+ * translucent color depends on what it composites over. For a translucent
96
+ * foreground, flatten it first (or use `ensureContrast`, which does).
97
+ */
98
+ export function contrastRatio(a, b) {
99
+ return luminanceRatio(relativeLuminance(a), relativeLuminance(b));
100
+ }
101
+ // [LAW:single-enforcer] The WCAG ratio over two relative luminances — the one
102
+ // formula `contrastRatio` and `ColorTable.matchReadable` (which caches its
103
+ // entries' luminances) both measure with.
104
+ function luminanceRatio(la, lb) {
105
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
106
+ }
68
107
  // --- ColorTable ---
108
+ /**
109
+ * An indexed palette: entry `i` of `colors` is terminal index `firstIndex + i`.
110
+ * `firstIndex` lets a table hold only the part of a palette a downgrade may
111
+ * choose (the 256-colour cube and grey ramp start at 16) while every index it
112
+ * reports is the terminal's own.
113
+ */
114
+ // [LAW:single-enforcer] The one size policy for ColorTable's memos: a key is
115
+ // derived from colours a long-running host computes without end (ramp stops,
116
+ // mixes), so an unbounded map grows with every render. Clearing at the cap is
117
+ // the policy `cellLen` already uses; a refill costs one table scan per key.
118
+ const TABLE_CACHE_MAX = 4096;
119
+ function remember(cache, key, index) {
120
+ if (cache.size >= TABLE_CACHE_MAX)
121
+ cache.clear();
122
+ cache.set(key, index);
123
+ return index;
124
+ }
69
125
  export class ColorTable {
70
126
  colors;
127
+ firstIndex;
71
128
  matchCache = new Map();
72
- constructor(colors) {
129
+ readableCache = new Map();
130
+ constructor(colors, firstIndex = 0) {
73
131
  this.colors = colors;
132
+ this.firstIndex = firstIndex;
74
133
  }
75
134
  get(index) {
76
- return this.colors[index];
135
+ return this.colors[index - this.firstIndex];
136
+ }
137
+ luminanceCache;
138
+ /** Each entry's relative luminance, computed once per table. */
139
+ luminances() {
140
+ this.luminanceCache ??= this.colors.map(relativeLuminance);
141
+ return this.luminanceCache;
77
142
  }
143
+ /** How many entries the table holds (terminal indices `firstIndex`…). */
78
144
  get size() {
79
145
  return this.colors.length;
80
146
  }
@@ -101,8 +167,45 @@ export class ColorTable {
101
167
  bestIndex = i;
102
168
  }
103
169
  }
104
- this.matchCache.set(key, bestIndex);
105
- return bestIndex;
170
+ return remember(this.matchCache, key, this.firstIndex + bestIndex);
171
+ }
172
+ /**
173
+ * The nearest entry to `value` (the distance `match` uses) among those that
174
+ * clear `minRatio` against `on`; when none does, the entry with the most
175
+ * contrast against `on`. Text downgraded on its own background: `match`
176
+ * moves text and background independently, and two independent roundings
177
+ * can meet in the middle, so a pair that read at 4.5:1 in truecolor can
178
+ * draw at 2:1. [LAW:dataflow-not-control-flow] One scan scores every entry;
179
+ * the ratio decides which one wins.
180
+ */
181
+ matchReadable(value, on, minRatio) {
182
+ const key = `${value.red},${value.green},${value.blue}|${on.red},${on.green},${on.blue}|${minRatio}`;
183
+ const cached = this.readableCache.get(key);
184
+ if (cached !== undefined)
185
+ return cached;
186
+ const lOn = relativeLuminance(on);
187
+ let best = 0;
188
+ let bestPasses = false;
189
+ let bestScore = -Infinity;
190
+ for (let i = 0; i < this.colors.length; i++) {
191
+ const c = this.colors[i];
192
+ const lc = this.luminances()[i];
193
+ const ratio = luminanceRatio(lc, lOn);
194
+ const passes = ratio >= minRatio;
195
+ const dr = c.red - value.red;
196
+ const dg = c.green - value.green;
197
+ const db = c.blue - value.blue;
198
+ // A passing entry scores by closeness; a failing one only by contrast,
199
+ // and loses to every passing one.
200
+ const score = passes ? -(dr * dr + dg * dg + db * db) : ratio;
201
+ if ((passes && !bestPasses) ||
202
+ (passes === bestPasses && score > bestScore)) {
203
+ best = i;
204
+ bestPasses = passes;
205
+ bestScore = score;
206
+ }
207
+ }
208
+ return remember(this.readableCache, key, this.firstIndex + best);
106
209
  }
107
210
  }
108
211
  // --- Enums ---
@@ -370,7 +473,7 @@ export class ColorSpec {
370
473
  const triplet = this.getTruecolor();
371
474
  switch (targetSystem) {
372
475
  case ColorDepth.EIGHT_BIT: {
373
- const index = EIGHT_BIT_TABLE.match(triplet);
476
+ const index = EIGHT_BIT_DOWNGRADE_TABLE.match(triplet);
374
477
  return ColorSpec.fromAnsi(index);
375
478
  }
376
479
  case ColorDepth.STANDARD: {
@@ -537,6 +640,13 @@ function buildWindowsTable() {
537
640
  }
538
641
  export const STANDARD_TABLE = new ColorTable(buildStandard16());
539
642
  export const EIGHT_BIT_TABLE = new ColorTable(build256Table());
643
+ /**
644
+ * What a downgrade to 256 colours may choose: the cube and the grey ramp,
645
+ * indices 16–255. Indices 0–15 are the terminal's own ANSI colours, which
646
+ * every theme redefines, so their RGB is unknown and a match against them is
647
+ * a guess; Python Rich never picks them either.
648
+ */
649
+ export const EIGHT_BIT_DOWNGRADE_TABLE = new ColorTable(build256Table().slice(16), 16);
540
650
  export const WINDOWS_TABLE = new ColorTable(buildWindowsTable());
541
651
  // --- Internal fallback theme ---
542
652
  //
@@ -26,8 +26,8 @@
26
26
  * styling, so the cell type does too.
27
27
  */
28
28
  import { Segment } from "./segment.js";
29
- import { Style, SURFACE_BLACK } from "./style.js";
30
- import { ColorSpec, blendRgb } from "./color.js";
29
+ import { Style } from "./style.js";
30
+ import { ColorSpec, SURFACE_BLACK, blendRgb } from "./color.js";
31
31
  import { Oklch } from "./oklch.js";
32
32
  // --- Strip ---
33
33
  export class Strip {
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Immutable style descriptors — colors, text attributes, links, metadata.
3
3
  */
4
- import { ColorRgba, ColorSpec, ColorDepth } from "./color.js";
5
- export declare const SURFACE_BLACK: ColorRgba;
4
+ import { ColorSpec, ColorDepth } from "./color.js";
6
5
  /**
7
6
  * Canonical text-attribute inventory. Single source of truth consumed by
8
7
  * `Style.parse` / `Style.toString` and the template bindings — adding an
@@ -1,14 +1,11 @@
1
1
  /**
2
2
  * Immutable style descriptors — colors, text attributes, links, metadata.
3
3
  */
4
- import { COLOR_NAMES, ColorRgba, ColorSpec, } from "./color.js";
4
+ import { COLOR_NAMES, ColorSpec, SURFACE_BLACK, } from "./color.js";
5
5
  import { OSC8_CLOSE, osc8Open } from "./osc8.js";
6
6
  // [LAW:one-way-deps] `core/style` depends only on `core/color` and the leaf
7
- // `core/osc8` (the link wire grammar). The substrate fallback is the
8
- // canonical canvas color (black), inlined to avoid pulling in any preset
9
- // theme constants. Preset themes live in `src/themes/` and depend on core,
10
- // never the reverse.
11
- export const SURFACE_BLACK = new ColorRgba(0, 0, 0);
7
+ // `core/osc8` (the link wire grammar). The substrate a translucent colour is
8
+ // flattened over is `SURFACE_BLACK`, defined beside `compositeOver`.
12
9
  // --- Attribute definitions ---
13
10
  /**
14
11
  * Canonical text-attribute inventory. Single source of truth consumed by
@@ -55,7 +55,13 @@
55
55
  * from that point on the math is pure. A consumer with no theme system at all still gets
56
56
  * the full color vocabulary by feeding it hex literals. [LAW:one-way-deps]
57
57
  */
58
- import type { FuncMap } from "@promptctl/go-template-js";
58
+ import type { FuncMap, TemplateFunc } from "@promptctl/go-template-js";
59
+ import { ColorDepth } from "../core/color.js";
60
+ /**
61
+ * The `readableOn` binding, measuring at the depth `drawnAt` names when it is
62
+ * evaluated — a consumer that renders at a per-call depth registers its own.
63
+ */
64
+ export declare const readableOnFunc: (drawnAt: () => ColorDepth) => TemplateFunc;
59
65
  /**
60
66
  * The palette-free color vocabulary: every function takes colors and returns a
61
67
  * color, so they compose by nesting.
@@ -73,5 +79,9 @@ import type { FuncMap } from "@promptctl/go-template-js";
73
79
  *
74
80
  * Pair with `paletteFuncs()` to name colors from a theme, and with
75
81
  * `richTextStyleFuncs()`'s `fg`/`bg` to paint them onto text.
82
+ *
83
+ * @param drawnAt the depth the terminal will draw at, read on every
84
+ * `readableOn` evaluation so a host that learns its depth per render passes
85
+ * one getter; defaults to truecolor.
76
86
  */
77
- export declare function colorFuncs(): FuncMap;
87
+ export declare function colorFuncs(drawnAt?: () => ColorDepth): FuncMap;
@@ -55,7 +55,7 @@
55
55
  * from that point on the math is pure. A consumer with no theme system at all still gets
56
56
  * the full color vocabulary by feeding it hex literals. [LAW:one-way-deps]
57
57
  */
58
- import { blendRgb } from "../core/color.js";
58
+ import { blendRgb, ColorDepth } from "../core/color.js";
59
59
  import { Oklch, IDENTITY } from "../core/oklch.js";
60
60
  import { HEX_COLOR_RE, parseHexColor } from "../themes/colorRef.js";
61
61
  import { darken, contrastFor, ensureContrast } from "../themes/colorMath.js";
@@ -147,7 +147,11 @@ const RATIO_RANGE = {
147
147
  note: "4.5 = AA body text, 3 = AA large text",
148
148
  };
149
149
  const contrastOnFunc = colorFunc(["string"], ((bgHex) => contrastFor(asColor(bgHex, "contrastOn")).hex));
150
- const readableOnFunc = colorFunc(["string", "string", "float"], ((fgHex, bgHex, ratio) => ensureContrast(asColor(fgHex, "readableOn"), asColor(bgHex, "readableOn"), asAmount(ratio, "readableOn", "ratio", RATIO_RANGE)).hex));
150
+ /**
151
+ * The `readableOn` binding, measuring at the depth `drawnAt` names when it is
152
+ * evaluated — a consumer that renders at a per-call depth registers its own.
153
+ */
154
+ export const readableOnFunc = (drawnAt) => colorFunc(["string", "string", "float"], ((fgHex, bgHex, ratio) => ensureContrast(asColor(fgHex, "readableOn"), asColor(bgHex, "readableOn"), asAmount(ratio, "readableOn", "ratio", RATIO_RANGE), drawnAt()).hex));
151
155
  // --- OKLCH axes ---
152
156
  //
153
157
  // [LAW:one-source-of-truth] One function per `ThemeKey` axis, and the table is
@@ -198,14 +202,18 @@ function oklchAxisFuncs() {
198
202
  *
199
203
  * Pair with `paletteFuncs()` to name colors from a theme, and with
200
204
  * `richTextStyleFuncs()`'s `fg`/`bg` to paint them onto text.
205
+ *
206
+ * @param drawnAt the depth the terminal will draw at, read on every
207
+ * `readableOn` evaluation so a host that learns its depth per render passes
208
+ * one getter; defaults to truecolor.
201
209
  */
202
- export function colorFuncs() {
210
+ export function colorFuncs(drawnAt = () => ColorDepth.TRUECOLOR) {
203
211
  return {
204
212
  darken: darkenFunc,
205
213
  lighten: lightenFunc,
206
214
  mix: mixFunc,
207
215
  contrastOn: contrastOnFunc,
208
- readableOn: readableOnFunc,
216
+ readableOn: readableOnFunc(drawnAt),
209
217
  ...oklchAxisFuncs(),
210
218
  };
211
219
  }
@@ -36,13 +36,15 @@
36
36
  import { type Engine, type FuncMap } from "@promptctl/go-template-js";
37
37
  import { RichText } from "../core/text.js";
38
38
  import { Segment } from "../core/segment.js";
39
+ import { ColorDepth } from "../core/color.js";
39
40
  export { paletteFuncs } from "./palette-funcs.js";
40
- export { colorFuncs } from "./color-funcs.js";
41
+ export { colorFuncs, readableOnFunc } from "./color-funcs.js";
41
42
  /**
42
43
  * Funcs registered by the rich-js binding — the colour sinks, the palette-free
43
- * colour math, text attributes, and the `link` cell-splitter. Everything here
44
- * is configuration-free by construction; the two that need a theme (`color`,
45
- * `ramp`) ship separately via `paletteFuncs(getPalette)`, merged consumer-side.
44
+ * colour math, text attributes, and the `link` cell-splitter. Nothing here
45
+ * needs a theme; the two that do (`color`, `ramp`) ship separately via
46
+ * `paletteFuncs(getPalette)`, merged consumer-side. `drawnAt` is forwarded to
47
+ * `colorFuncs` — the depth `readableOn` measures at, truecolor when omitted.
46
48
  *
47
49
  * `FuncMap` is not parameterised over `T` in `@promptctl/go-template-js` — the engine's
48
50
  * `T` lives on the `Engine`/`EngineConfig`, and per-function input/output
@@ -55,7 +57,7 @@ export { colorFuncs } from "./color-funcs.js";
55
57
  * into a wider engine must keep `T = RichText`; merging into an engine
56
58
  * whose `T` is something else will compile but fail at evaluation time.
57
59
  */
58
- export declare function richTextFuncs(): FuncMap;
60
+ export declare function richTextFuncs(drawnAt?: () => ColorDepth): FuncMap;
59
61
  /**
60
62
  * Construct an `Engine<RichText>` with the rich-js style-function set
61
63
  * registered. Consumers that already manage their own engine should call
@@ -40,12 +40,13 @@ import { Segment } from "../core/segment.js";
40
40
  import { richTextStyleFuncs } from "./style-funcs.js";
41
41
  import { colorFuncs } from "./color-funcs.js";
42
42
  export { paletteFuncs } from "./palette-funcs.js";
43
- export { colorFuncs } from "./color-funcs.js";
43
+ export { colorFuncs, readableOnFunc } from "./color-funcs.js";
44
44
  /**
45
45
  * Funcs registered by the rich-js binding — the colour sinks, the palette-free
46
- * colour math, text attributes, and the `link` cell-splitter. Everything here
47
- * is configuration-free by construction; the two that need a theme (`color`,
48
- * `ramp`) ship separately via `paletteFuncs(getPalette)`, merged consumer-side.
46
+ * colour math, text attributes, and the `link` cell-splitter. Nothing here
47
+ * needs a theme; the two that do (`color`, `ramp`) ship separately via
48
+ * `paletteFuncs(getPalette)`, merged consumer-side. `drawnAt` is forwarded to
49
+ * `colorFuncs` — the depth `readableOn` measures at, truecolor when omitted.
49
50
  *
50
51
  * `FuncMap` is not parameterised over `T` in `@promptctl/go-template-js` — the engine's
51
52
  * `T` lives on the `Engine`/`EngineConfig`, and per-function input/output
@@ -58,8 +59,10 @@ export { colorFuncs } from "./color-funcs.js";
58
59
  * into a wider engine must keep `T = RichText`; merging into an engine
59
60
  * whose `T` is something else will compile but fail at evaluation time.
60
61
  */
61
- export function richTextFuncs() {
62
- return { ...richTextStyleFuncs(), ...colorFuncs() };
62
+ // [LAW:one-source-of-truth] The truecolor default lives on `colorFuncs` alone;
63
+ // an omitted `drawnAt` is forwarded as omitted.
64
+ export function richTextFuncs(drawnAt) {
65
+ return { ...richTextStyleFuncs(), ...colorFuncs(drawnAt) };
63
66
  }
64
67
  /**
65
68
  * Construct an `Engine<RichText>` with the rich-js style-function set
@@ -1,4 +1,4 @@
1
- import { ColorRgba } from "../core/color.js";
1
+ import { ColorDepth, ColorRgba, contrastRatio, relativeLuminance } from "../core/color.js";
2
2
  /**
3
3
  * Darken a color by N levels, where each level reduces HSL lightness by 10%.
4
4
  * Negative levels lighten. Level 0 returns an equivalent triplet (after the
@@ -17,26 +17,11 @@ export declare function alphaBlend(fg: ColorRgba, bg: ColorRgba, alpha: number):
17
17
  /**
18
18
  * Pick a contrasting foreground (black or white) for a background, using the
19
19
  * WCAG relative-luminance threshold of 0.179 (the perceptually correct cutoff
20
- * where black and white are equally readable).
20
+ * where black and white are equally readable). A translucent `bg` is judged
21
+ * as drawn: composited over `substrate` (see `drawnBackground`).
21
22
  */
22
- export declare function contrastFor(bg: ColorRgba): ColorRgba;
23
- /**
24
- * WCAG 2.x relative luminance (0..1) of an opaque color. The single
25
- * luminance function in the codebase — `contrastFor`, `contrastRatio`, and
26
- * any caller that needs to reason about readability all funnel through it.
27
- * [LAW:one-source-of-truth]
28
- */
29
- export declare function relativeLuminance(c: ColorRgba): number;
30
- /**
31
- * WCAG 2.x contrast ratio between two colors, in [1, 21]. Symmetric — the
32
- * order of arguments does not matter. 4.5 is the AA threshold for normal
33
- * text, 3.0 for large text.
34
- *
35
- * Assumes opaque inputs: alpha is ignored, since the displayed contrast of a
36
- * translucent color depends on what it composites over. For a translucent
37
- * foreground, flatten it first (or use `ensureContrast`, which does).
38
- */
39
- export declare function contrastRatio(a: ColorRgba, b: ColorRgba): number;
23
+ export declare function contrastFor(bg: ColorRgba, substrate?: ColorRgba): ColorRgba;
24
+ export { relativeLuminance, contrastRatio };
40
25
  /**
41
26
  * Return a foreground guaranteed to clear `minRatio` against `bg`, keeping the
42
27
  * color *recognizably itself*. If the themed `fg` already passes it is returned
@@ -48,10 +33,18 @@ export declare function contrastRatio(a: ColorRgba, b: ColorRgba): number;
48
33
  * background where even pure black-or-white tops out below the target) does it
49
34
  * fall back to `contrastFor`'s black/white — the true maximum-contrast pick.
50
35
  *
51
- * A translucent `fg` is flattened over `bg` first (the displayed color is
52
- * `fg` composited over `bg`), so the ratio is measured on what the eye
53
- * actually sees and the returned color is opaque. `bg` is treated as the
54
- * opaque substrate.
36
+ * A translucent `bg` is measured as it is drawn — composited over
37
+ * `substrate`, the SGR writer's black by default — and a translucent `fg` is
38
+ * then flattened over that drawn background, the order the writer composites
39
+ * in, so the ratio is measured on what the eye actually sees and the returned
40
+ * color is opaque.
41
+ *
42
+ * `drawnAt` is the depth the terminal will draw the pair at. At 256 colours
43
+ * the terminal rounds text and background independently, and two roundings
44
+ * can meet in the middle, so the ratio is measured on the drawn pair: a
45
+ * colour that loses the floor there is replaced by the nearest cube/grey entry
46
+ * that clears it (whose own rounding is itself). Every other depth draws a
47
+ * colour the chosen one IS (truecolor) or one only the terminal knows (ANSI).
55
48
  *
56
49
  * [LAW:single-enforcer] The one place "is this text readable, and if not fix
57
50
  * it" is decided. Callers route every fg/bg pair through here and the
@@ -59,4 +52,5 @@ export declare function contrastRatio(a: ColorRgba, b: ColorRgba): number;
59
52
  * function always runs; the measured ratio (data) decides how far the
60
53
  * lightness moves — there is no caller-side "should I check contrast" branch.
61
54
  */
62
- export declare function ensureContrast(fg: ColorRgba, bg: ColorRgba, minRatio?: number): ColorRgba;
55
+ export declare function ensureContrast(fg: ColorRgba, bg: ColorRgba, minRatio?: number, // WCAG AA for normal text
56
+ drawnAt?: ColorDepth, substrate?: ColorRgba): ColorRgba;
@@ -1,4 +1,4 @@
1
- import { ColorRgba, blendRgb } from "../core/color.js";
1
+ import { ColorDepth, ColorRgba, EIGHT_BIT_DOWNGRADE_TABLE, blendRgb, contrastRatio, relativeLuminance, SURFACE_BLACK, } from "../core/color.js";
2
2
  import { Oklch } from "../core/oklch.js";
3
3
  const LEVEL_STEP = 0.1;
4
4
  function rgbToHsl(c) {
@@ -82,43 +82,20 @@ export function alphaBlend(fg, bg, alpha) {
82
82
  /**
83
83
  * Pick a contrasting foreground (black or white) for a background, using the
84
84
  * WCAG relative-luminance threshold of 0.179 (the perceptually correct cutoff
85
- * where black and white are equally readable).
85
+ * where black and white are equally readable). A translucent `bg` is judged
86
+ * as drawn: composited over `substrate` (see `drawnBackground`).
86
87
  */
87
- export function contrastFor(bg) {
88
- const lum = relativeLuminance(bg);
88
+ export function contrastFor(bg, substrate = SURFACE_BLACK) {
89
+ const lum = relativeLuminance(drawnBackground(bg, substrate));
89
90
  return lum > 0.179
90
91
  ? new ColorRgba(0, 0, 0)
91
92
  : new ColorRgba(255, 255, 255);
92
93
  }
93
- /**
94
- * WCAG 2.x relative luminance (0..1) of an opaque color. The single
95
- * luminance function in the codebase — `contrastFor`, `contrastRatio`, and
96
- * any caller that needs to reason about readability all funnel through it.
97
- * [LAW:one-source-of-truth]
98
- */
99
- export function relativeLuminance(c) {
100
- const ch = (v) => {
101
- const x = v / 255;
102
- return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
103
- };
104
- return 0.2126 * ch(c.red) + 0.7152 * ch(c.green) + 0.0722 * ch(c.blue);
105
- }
106
- /**
107
- * WCAG 2.x contrast ratio between two colors, in [1, 21]. Symmetric — the
108
- * order of arguments does not matter. 4.5 is the AA threshold for normal
109
- * text, 3.0 for large text.
110
- *
111
- * Assumes opaque inputs: alpha is ignored, since the displayed contrast of a
112
- * translucent color depends on what it composites over. For a translucent
113
- * foreground, flatten it first (or use `ensureContrast`, which does).
114
- */
115
- export function contrastRatio(a, b) {
116
- const la = relativeLuminance(a);
117
- const lb = relativeLuminance(b);
118
- const hi = la > lb ? la : lb;
119
- const lo = la > lb ? lb : la;
120
- return (hi + 0.05) / (lo + 0.05);
121
- }
94
+ // [LAW:one-way-deps] The WCAG measures live in core/color.ts, beside the
95
+ // ColorTable whose `matchReadable` needs them to pick a drawn text colour, and
96
+ // below the theme math here. Re-exported so this module stays the colour-math
97
+ // surface.
98
+ export { relativeLuminance, contrastRatio };
122
99
  // Iterations for the lightness bisection below. 20 resolves L to ~1e-6 — far
123
100
  // finer than 8-bit quantization or the eye.
124
101
  const CONTRAST_ITERS = 20;
@@ -133,10 +110,18 @@ const CONTRAST_ITERS = 20;
133
110
  * background where even pure black-or-white tops out below the target) does it
134
111
  * fall back to `contrastFor`'s black/white — the true maximum-contrast pick.
135
112
  *
136
- * A translucent `fg` is flattened over `bg` first (the displayed color is
137
- * `fg` composited over `bg`), so the ratio is measured on what the eye
138
- * actually sees and the returned color is opaque. `bg` is treated as the
139
- * opaque substrate.
113
+ * A translucent `bg` is measured as it is drawn — composited over
114
+ * `substrate`, the SGR writer's black by default — and a translucent `fg` is
115
+ * then flattened over that drawn background, the order the writer composites
116
+ * in, so the ratio is measured on what the eye actually sees and the returned
117
+ * color is opaque.
118
+ *
119
+ * `drawnAt` is the depth the terminal will draw the pair at. At 256 colours
120
+ * the terminal rounds text and background independently, and two roundings
121
+ * can meet in the middle, so the ratio is measured on the drawn pair: a
122
+ * colour that loses the floor there is replaced by the nearest cube/grey entry
123
+ * that clears it (whose own rounding is itself). Every other depth draws a
124
+ * colour the chosen one IS (truecolor) or one only the terminal knows (ANSI).
140
125
  *
141
126
  * [LAW:single-enforcer] The one place "is this text readable, and if not fix
142
127
  * it" is decided. Callers route every fg/bg pair through here and the
@@ -144,7 +129,52 @@ const CONTRAST_ITERS = 20;
144
129
  * function always runs; the measured ratio (data) decides how far the
145
130
  * lightness moves — there is no caller-side "should I check contrast" branch.
146
131
  */
147
- export function ensureContrast(fg, bg, minRatio = 4.5) {
132
+ export function ensureContrast(fg, bg, minRatio = 4.5, // WCAG AA for normal text
133
+ drawnAt = ColorDepth.TRUECOLOR, substrate = SURFACE_BLACK) {
134
+ const ground = drawnBackground(bg, substrate);
135
+ const chosen = ensureTruecolorContrast(fg, ground, minRatio);
136
+ // [LAW:dataflow-not-control-flow] The depth names the table the terminal
137
+ // draws from; only one whose entries have a known RGB can be measured.
138
+ const table = MEASURABLE_DOWNGRADE[drawnAt];
139
+ if (table === undefined)
140
+ return chosen;
141
+ const drawnBg = table.get(table.match(ground));
142
+ const drawn = table.get(table.match(chosen));
143
+ if (contrastRatio(drawn, drawnBg) >= minRatio)
144
+ return chosen;
145
+ return table.get(table.matchReadable(chosen, drawnBg, minRatio));
146
+ }
147
+ /**
148
+ * A background as it is drawn: composited over the surface beneath it. That
149
+ * surface is a fact about where the pair is drawn, so it arrives as a value:
150
+ * the SGR writer (`Style.toSgrCodes`) composites over `SURFACE_BLACK`, the
151
+ * default here; a caller choosing text for a different surface — an export's
152
+ * canvas, `exportCanvas(theme).background` — names that one.
153
+ * [LAW:no-silent-failure] A surface has nothing under it, so a translucent one
154
+ * has no drawn colour to offer; `compositeOver` would read its raw RGB as if
155
+ * it were opaque, so it is refused here rather than measured wrong.
156
+ * [LAW:one-source-of-truth] Text is chosen against the colour the surface will
157
+ * show — measuring the raw RGBA reads a colour that is drawn nowhere, and text
158
+ * that "clears" it can land below the floor. Opaque colours composite to
159
+ * themselves.
160
+ */
161
+ function drawnBackground(bg, substrate) {
162
+ if (substrate.alpha !== 1) {
163
+ throw new RangeError(`a contrast substrate is the opaque surface under a translucent background; got ${substrate.hex}`);
164
+ }
165
+ return bg.compositeOver(substrate);
166
+ }
167
+ /**
168
+ * The downgrade tables whose entries the terminal draws at a known RGB, by the
169
+ * depth that draws from them. 256 colours is the one: its cube and grey ramp
170
+ * are fixed by xterm. ANSI 0–15 and the default colour are the terminal
171
+ * theme's own, so text drawn there has no ratio to keep; truecolor draws the
172
+ * chosen colour itself.
173
+ */
174
+ const MEASURABLE_DOWNGRADE = {
175
+ [ColorDepth.EIGHT_BIT]: EIGHT_BIT_DOWNGRADE_TABLE,
176
+ };
177
+ function ensureTruecolorContrast(fg, bg, minRatio) {
148
178
  // Flatten translucency so the guarantee holds for the displayed color, not
149
179
  // the raw bytes (e.g. a "#FFFFFF60" text-disabled over a light surface).
150
180
  const opaqueFg = fg.compositeOver(bg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/rich-js",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Rich text and beautiful formatting in the terminal — a TypeScript port of Python's Rich",
5
5
  "type": "module",
6
6
  "sideEffects": false,