@promptctl/rich-js 0.13.0 → 0.15.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,6 +31,14 @@ 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;
34
42
  /**
35
43
  * WCAG 2.x relative luminance (0..1) of an opaque color. The single
36
44
  * luminance function in the codebase — `contrastFor`, `contrastRatio`, and
@@ -76,6 +84,15 @@ export declare class ColorTable {
76
84
  * the ratio decides which one wins.
77
85
  */
78
86
  matchReadable(value: ColorRgba, on: ColorRgba, minRatio: number): number;
87
+ /**
88
+ * The nearest entry to `value` (the distance `match` uses) among those
89
+ * `accept` takes, given each entry's colour and terminal index; `undefined`
90
+ * when it takes none. Entries are offered nearest-first (ties to the lower
91
+ * index) and the first taken wins, so an expensive predicate runs only as
92
+ * far out as the answer. Uncached: the predicate is the caller's, and a
93
+ * closure has no key.
94
+ */
95
+ matchWhere(value: ColorRgba, accept: (entry: ColorRgba, index: number) => boolean): number | undefined;
79
96
  }
80
97
  export declare enum ColorDepth {
81
98
  DEFAULT = 0,
@@ -123,6 +140,15 @@ export declare class ColorSpec {
123
140
  private downgradeCache;
124
141
  constructor(name: string, type: ColorDepth, number?: number, value?: ColorRgba);
125
142
  get isDefault(): boolean;
143
+ /**
144
+ * The colour every terminal draws this spec as, where that is fixed — before
145
+ * any alpha is flattened, so a translucent value is drawn composited over
146
+ * its ground (`flattenAlpha`) and should be measured that way: a
147
+ * truecolor value, or a 256-colour cube or grey-ramp entry (xterm fixes
148
+ * indices 16–255, and `fromAnsi` types only those as EIGHT_BIT). ANSI 0–15
149
+ * and the default colour are the terminal theme's own, so they have none.
150
+ */
151
+ get fixedValue(): ColorRgba | undefined;
126
152
  get isSystemDefined(): boolean;
127
153
  /**
128
154
  * Generates SGR parameter strings for this color.
@@ -65,6 +65,14 @@ 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);
68
76
  /**
69
77
  * WCAG 2.x relative luminance (0..1) of an opaque color. The single
70
78
  * luminance function in the codebase — `contrastFor`, `contrastRatio`, and
@@ -114,6 +122,14 @@ function remember(cache, key, index) {
114
122
  cache.set(key, index);
115
123
  return index;
116
124
  }
125
+ // [LAW:one-source-of-truth] The one distance every table scan ranks by, so
126
+ // `matchWhere`'s "nearest by the distance `match` uses" cannot drift.
127
+ function rgbDistance(a, b) {
128
+ const dr = a.red - b.red;
129
+ const dg = a.green - b.green;
130
+ const db = a.blue - b.blue;
131
+ return dr * dr + dg * dg + db * db;
132
+ }
117
133
  export class ColorTable {
118
134
  colors;
119
135
  firstIndex;
@@ -150,10 +166,7 @@ export class ColorTable {
150
166
  let bestDist = Infinity;
151
167
  for (let i = 0; i < this.colors.length; i++) {
152
168
  const c = this.colors[i];
153
- const dr = c.red - value.red;
154
- const dg = c.green - value.green;
155
- const db = c.blue - value.blue;
156
- const dist = dr * dr + dg * dg + db * db;
169
+ const dist = rgbDistance(c, value);
157
170
  if (dist < bestDist) {
158
171
  bestDist = dist;
159
172
  bestIndex = i;
@@ -184,12 +197,9 @@ export class ColorTable {
184
197
  const lc = this.luminances()[i];
185
198
  const ratio = luminanceRatio(lc, lOn);
186
199
  const passes = ratio >= minRatio;
187
- const dr = c.red - value.red;
188
- const dg = c.green - value.green;
189
- const db = c.blue - value.blue;
190
200
  // A passing entry scores by closeness; a failing one only by contrast,
191
201
  // and loses to every passing one.
192
- const score = passes ? -(dr * dr + dg * dg + db * db) : ratio;
202
+ const score = passes ? -rgbDistance(c, value) : ratio;
193
203
  if ((passes && !bestPasses) ||
194
204
  (passes === bestPasses && score > bestScore)) {
195
205
  best = i;
@@ -199,6 +209,20 @@ export class ColorTable {
199
209
  }
200
210
  return remember(this.readableCache, key, this.firstIndex + best);
201
211
  }
212
+ /**
213
+ * The nearest entry to `value` (the distance `match` uses) among those
214
+ * `accept` takes, given each entry's colour and terminal index; `undefined`
215
+ * when it takes none. Entries are offered nearest-first (ties to the lower
216
+ * index) and the first taken wins, so an expensive predicate runs only as
217
+ * far out as the answer. Uncached: the predicate is the caller's, and a
218
+ * closure has no key.
219
+ */
220
+ matchWhere(value, accept) {
221
+ const dist = this.colors.map((c) => rgbDistance(c, value));
222
+ const nearestFirst = [...dist.keys()].sort((a, b) => dist[a] - dist[b]);
223
+ const found = nearestFirst.find((i) => accept(this.colors[i], this.firstIndex + i));
224
+ return found === undefined ? undefined : this.firstIndex + found;
225
+ }
202
226
  }
203
227
  // --- Enums ---
204
228
  export var ColorDepth;
@@ -337,6 +361,28 @@ export class ColorSpec {
337
361
  get isDefault() {
338
362
  return this.type === ColorDepth.DEFAULT;
339
363
  }
364
+ /**
365
+ * The colour every terminal draws this spec as, where that is fixed — before
366
+ * any alpha is flattened, so a translucent value is drawn composited over
367
+ * its ground (`flattenAlpha`) and should be measured that way: a
368
+ * truecolor value, or a 256-colour cube or grey-ramp entry (xterm fixes
369
+ * indices 16–255, and `fromAnsi` types only those as EIGHT_BIT). ANSI 0–15
370
+ * and the default colour are the terminal theme's own, so they have none.
371
+ */
372
+ get fixedValue() {
373
+ switch (this.type) {
374
+ case ColorDepth.TRUECOLOR:
375
+ return this.value;
376
+ case ColorDepth.EIGHT_BIT:
377
+ // The constructor admits an EIGHT_BIT spec at 0–15; those are the
378
+ // theme's own slots whatever depth names them.
379
+ return this.number < 16 ? undefined : EIGHT_BIT_TABLE.get(this.number);
380
+ case ColorDepth.DEFAULT:
381
+ case ColorDepth.STANDARD:
382
+ case ColorDepth.WINDOWS:
383
+ return undefined;
384
+ }
385
+ }
340
386
  get isSystemDefined() {
341
387
  return (this.type === ColorDepth.STANDARD || this.type === ColorDepth.WINDOWS);
342
388
  }
@@ -243,6 +243,7 @@ export class Console {
243
243
  asciiOnly: false,
244
244
  theme: this._theme,
245
245
  onStyleError: this._onStyleError,
246
+ colorSystem: this._colorSystem,
246
247
  };
247
248
  }
248
249
  // --- Print ---
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import type { Segment } from "./segment.js";
6
6
  import { type Style, type StyleSyntaxError, type Theme } from "./style.js";
7
+ import type { ColorDepth } from "./color.js";
7
8
  export interface RenderOptions {
8
9
  /**
9
10
  * The cells this renderable may occupy. A count of cells, so a non-negative
@@ -25,6 +26,14 @@ export interface RenderOptions {
25
26
  noWrap?: boolean;
26
27
  highlight?: unknown;
27
28
  markup?: unknown;
29
+ /**
30
+ * The depth the output will be encoded at, `null` when it carries no colour.
31
+ * A renderable choosing between two ways of drawing something — the strip's
32
+ * arrow or its divider — decides on the colours the terminal will draw at
33
+ * this depth, which at 256 colours or fewer are not the colours it was
34
+ * handed. Absent means truecolor.
35
+ */
36
+ colorSystem?: ColorDepth | null;
28
37
  /**
29
38
  * The names a style string may use. A `Console` passes its own; absent, the
30
39
  * built-in defaults apply. Read it through `getStyle` rather than directly.
@@ -132,6 +132,9 @@ export function renderToString(renderable, options) {
132
132
  isTerminal: false,
133
133
  encoding: "utf-8",
134
134
  asciiOnly: false,
135
+ // [LAW:one-source-of-truth] The depth the segments below are encoded at,
136
+ // so a renderable measures what this very call will draw.
137
+ colorSystem,
135
138
  };
136
139
  return segmentsToString(renderable.render(renderOptions), colorSystem);
137
140
  }
@@ -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 { ColorDepth, ColorSpec, blendRgb } from "./color.js";
31
31
  import { Oklch } from "./oklch.js";
32
32
  // --- Strip ---
33
33
  export class Strip {
@@ -110,20 +110,25 @@ function paintableBg(bg) {
110
110
  * one cell wide.
111
111
  */
112
112
  export const SEAM_MIN_DELTA_E = 0.04;
113
- // Two backgrounds the eye cannot tell apart. What is measured is what is
114
- // drawn: each colour flattened onto the substrate Style.toSgrCodes flattens it
115
- // onto, so two alphas over one RGB are the two greys they render as. A palette
116
- // colour has no value here, so two of them are the same only when they are the
117
- // same palette slot — `type` + `number`, never the name, which spells one slot
118
- // many ways ("red", "color(1)").
119
- function indistinct(a, b) {
120
- if (b === undefined)
113
+ // An arrow the eye cannot tell from the ground it is drawn on. What is
114
+ // measured is what is drawn — `Style.drawnColors`, the colours the writer
115
+ // encodes: the arrow's colour flattened onto its ground and the ground onto
116
+ // the terminal's black, both downgraded to the depth the render encodes at,
117
+ // so two grounds 256 colours round to one cube entry are the one entry they
118
+ // render as. A colour whose RGB is the terminal theme's own (ANSI 0–15) has no
119
+ // value here, so two of them are the same only when they are the same palette
120
+ // slot — its `number`, never the name or the depth that spells it ("red",
121
+ // "color(1)", an EIGHT_BIT spec on 0–15 are one slot).
122
+ function vanishes(arrow, colorSystem) {
123
+ // No colour emitted draws nothing to tell apart; measure what was handed.
124
+ const { color, bgcolor } = arrow.drawnColors(colorSystem ?? ColorDepth.TRUECOLOR);
125
+ if (color === undefined || bgcolor === undefined)
121
126
  return false;
122
- const av = a.flattenAlpha(SURFACE_BLACK).value;
123
- const bv = b.flattenAlpha(SURFACE_BLACK).value;
127
+ const av = color.fixedValue;
128
+ const bv = bgcolor.fixedValue;
124
129
  return av !== undefined && bv !== undefined
125
130
  ? Oklch.fromRgba(av).deltaE(Oklch.fromRgba(bv)) < SEAM_MIN_DELTA_E
126
- : a.type === b.type && a.number === b.number;
131
+ : color.number === bgcolor.number;
127
132
  }
128
133
  /** The powerline pair: U+E0B0 (right-arrow) divided by U+E0B1 (thin right-arrow). */
129
134
  export const POWERLINE_JOINER_GLYPHS = Object.freeze({ glyph: "\ue0b0", divider: "\ue0b1" });
@@ -150,8 +155,10 @@ export class PowerlineJoiner {
150
155
  // structural boundary, never suppressed. The arrow would be drawn in its own
151
156
  // background colour there and vanish, so the seam is the DIVIDER instead, in
152
157
  // the left item's text colour — the vim-airline convention for neighbours
153
- // that share a background. "Equal" is perceptual (SEAM_MIN_DELTA_E): an
154
- // arrow a hair off its background is as invisible as one exactly on it.
158
+ // that share a background. "Equal" is perceptual (SEAM_MIN_DELTA_E) and
159
+ // measured on the colours the terminal draws at `options.colorSystem`: an
160
+ // arrow a hair off its background is as invisible as one exactly on it,
161
+ // and two backgrounds 256 colours round to one entry are one background.
155
162
  // Background colour is paint, not structure; its ABSENCE (nothing to paint)
156
163
  // is the only thing that elides the separator, and that is paint logic.
157
164
  // "Absent" = no bg OR the terminal default (transparent) — paintableBg folds
@@ -164,9 +171,16 @@ export class PowerlineJoiner {
164
171
  if (leftBg === undefined)
165
172
  return;
166
173
  const rightBg = paintableBg(right?.edgeStyle("left", options).bgcolor);
167
- yield indistinct(leftBg, rightBg)
168
- ? new Segment(divider, new Style({ color: leftEdge?.color, bgcolor: rightBg }))
169
- : new Segment(glyph, new Style({ color: leftBg, bgcolor: rightBg }));
174
+ // The arrow is the left cell continuing: its ground as the writer draws
175
+ // it, so a translucent ground is not composited a second time over the
176
+ // right one.
177
+ const leftDrawn = new Style({ bgcolor: leftBg }).drawnColors().bgcolor;
178
+ const arrow = new Style({ color: leftDrawn, bgcolor: rightBg });
179
+ // The divider is the left item's text on the left item's own ground,
180
+ // so it reads exactly as well as that item's text does, at any depth.
181
+ yield vanishes(arrow, options.colorSystem)
182
+ ? new Segment(divider, new Style({ color: leftEdge?.color, bgcolor: leftBg }))
183
+ : new Segment(glyph, arrow);
170
184
  });
171
185
  }
172
186
  }
@@ -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
@@ -70,6 +69,17 @@ export declare class Style {
70
69
  */
71
70
  add(other: Style | undefined): Style;
72
71
  equals(other: Style): boolean;
72
+ /**
73
+ * The colours this style puts on screen at `colorSystem`: each flattened
74
+ * onto what lies beneath it — the background onto the terminal's black, the
75
+ * foreground onto that background — then downgraded to the depth. The one
76
+ * account of what a style draws: `toSgrCodes` encodes exactly these, and a
77
+ * renderable choosing between two ways of drawing measures them.
78
+ */
79
+ drawnColors(colorSystem?: ColorDepth): {
80
+ readonly color: ColorSpec | undefined;
81
+ readonly bgcolor: ColorSpec | undefined;
82
+ };
73
83
  /**
74
84
  * Returns the SGR parameter list this style emits (e.g. `"1;31;48;2;0;0;255"`),
75
85
  * or `""` when the style has no SGR contribution. Excludes OSC 8 link bytes —
@@ -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
@@ -267,6 +264,28 @@ export class Style {
267
264
  this.overline === other.overline &&
268
265
  this.link === other.link);
269
266
  }
267
+ /**
268
+ * The colours this style puts on screen at `colorSystem`: each flattened
269
+ * onto what lies beneath it — the background onto the terminal's black, the
270
+ * foreground onto that background — then downgraded to the depth. The one
271
+ * account of what a style draws: `toSgrCodes` encodes exactly these, and a
272
+ * renderable choosing between two ways of drawing measures them.
273
+ */
274
+ drawnColors(colorSystem) {
275
+ // [LAW:dataflow-not-control-flow] Always resolve a substrate and flatten
276
+ // alpha before downgrade. Opaque colors short-circuit inside compositeOver,
277
+ // so the same code path runs every render — the alpha value is the data,
278
+ // not a branch.
279
+ const surface = SURFACE_BLACK;
280
+ const bgFlat = this.bgcolor?.flattenAlpha(surface);
281
+ const fgSubstrate = bgFlat?.getTruecolor(undefined, false) ?? surface;
282
+ const fgFlat = this.color?.flattenAlpha(fgSubstrate);
283
+ const at = (c) => colorSystem !== undefined ? c.downgrade(colorSystem) : c;
284
+ return {
285
+ color: fgFlat === undefined ? undefined : at(fgFlat),
286
+ bgcolor: bgFlat === undefined ? undefined : at(bgFlat),
287
+ };
288
+ }
270
289
  /**
271
290
  * Returns the SGR parameter list this style emits (e.g. `"1;31;48;2;0;0;255"`),
272
291
  * or `""` when the style has no SGR contribution. Excludes OSC 8 link bytes —
@@ -281,22 +300,11 @@ export class Style {
281
300
  if (this.isNull)
282
301
  return "";
283
302
  const attrs = [];
284
- // [LAW:dataflow-not-control-flow] Always resolve a substrate and flatten
285
- // alpha before downgrade. Opaque colors short-circuit inside compositeOver,
286
- // so the same code path runs every render — the alpha value is the data,
287
- // not a branch.
288
- const surface = SURFACE_BLACK;
289
- const bgFlat = this.bgcolor?.flattenAlpha(surface);
290
- const fgSubstrate = bgFlat?.getTruecolor(undefined, false) ?? surface;
291
- const fgFlat = this.color?.flattenAlpha(fgSubstrate);
292
- if (fgFlat) {
293
- const c = colorSystem !== undefined ? fgFlat.downgrade(colorSystem) : fgFlat;
294
- attrs.push(...c.getAnsiCodes(true));
295
- }
296
- if (bgFlat) {
297
- const c = colorSystem !== undefined ? bgFlat.downgrade(colorSystem) : bgFlat;
298
- attrs.push(...c.getAnsiCodes(false));
299
- }
303
+ const drawn = this.drawnColors(colorSystem);
304
+ if (drawn.color)
305
+ attrs.push(...drawn.color.getAnsiCodes(true));
306
+ if (drawn.bgcolor)
307
+ attrs.push(...drawn.bgcolor.getAnsiCodes(false));
300
308
  // An attribute set false writes nothing, as in Python Rich 9d8f9a3's
301
309
  // `_make_ansi_codes`. It overrides an inherited attribute when styles
302
310
  // combine, and every styled run is written after a reset, so no off
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export { DEFAULT_TERMINAL_THEME, MONOKAI, SVG_EXPORT_THEME, NORD, GRUVBOX, DRACU
12
12
  export { getThemePalette, listThemePalettes } from "./themes/registry.js";
13
13
  export type { ThemeName } from "./themes/registry.js";
14
14
  export { transposePalette, themeKeyForRoot, isAnchored, ANCHORED_ROOTS, } from "./themes/transpose.js";
15
- export { lighten, darken, relativeLuminance, contrastRatio, contrastFor, ensureContrast, } from "./themes/colorMath.js";
15
+ export { lighten, darken, relativeLuminance, contrastRatio, contrastFor, ensureContrast, ensureDrawn, drawnColour, } from "./themes/colorMath.js";
16
16
  export { ColorRamp, RAMP_EASING_NAMES, parseRampEasing } from "./themes/ramp.js";
17
17
  export type { ColorStop, RampEasing } from "./themes/ramp.js";
18
18
  export { Style, StyleSyntaxError, StyleStack, Theme, NULL_STYLE, DEFAULT_STYLES, } from "./core/style.js";
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ export { transposePalette, themeKeyForRoot, isAnchored, ANCHORED_ROOTS, } from "
21
21
  // reaches for to tint a resolved color against itself (e.g. a focused-while-open
22
22
  // menu cell lightening its inherited background) without re-deriving from a
23
23
  // palette name. Negative levels invert (lighten(c,-n) === darken(c,n)).
24
- export { lighten, darken, relativeLuminance, contrastRatio, contrastFor, ensureContrast, } from "./themes/colorMath.js";
24
+ export { lighten, darken, relativeLuminance, contrastRatio, contrastFor, ensureContrast, ensureDrawn, drawnColour, } from "./themes/colorMath.js";
25
25
  // A number → a color through ordered stops, interpolated in OKLCH — the one
26
26
  // color operation whose input is a measurement rather than a color, so a
27
27
  // gradient (or, with the `step` easing, a threshold cascade) stays inside the
@@ -17,9 +17,10 @@ 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
+ export declare function contrastFor(bg: ColorRgba, substrate?: ColorRgba): ColorRgba;
23
24
  export { relativeLuminance, contrastRatio };
24
25
  /**
25
26
  * Return a foreground guaranteed to clear `minRatio` against `bg`, keeping the
@@ -32,17 +33,21 @@ export { relativeLuminance, contrastRatio };
32
33
  * background where even pure black-or-white tops out below the target) does it
33
34
  * fall back to `contrastFor`'s black/white — the true maximum-contrast pick.
34
35
  *
35
- * A translucent `fg` is flattened over `bg` first (the displayed color is
36
- * `fg` composited over `bg`), so the ratio is measured on what the eye
37
- * actually sees and the returned color is opaque. `bg` is treated as the
38
- * 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.
39
41
  *
40
42
  * `drawnAt` is the depth the terminal will draw the pair at. At 256 colours
41
43
  * the terminal rounds text and background independently, and two roundings
42
44
  * can meet in the middle, so the ratio is measured on the drawn pair: a
43
45
  * colour that loses the floor there is replaced by the nearest cube/grey entry
44
- * that clears it (whose own rounding is itself). Every other depth draws a
45
- * colour the chosen one IS (truecolor) or one only the terminal knows (ANSI).
46
+ * that clears it (whose own rounding is itself). At ANSI the terminal draws
47
+ * its own theme's colours, so the ratio is measured on the table's nominal
48
+ * ones (`DRAWN_FROM`): they say which side of the ground text belongs on, and
49
+ * text on its background's index measures 1:1 and is always replaced.
50
+ * Truecolor draws the colour chosen.
46
51
  *
47
52
  * [LAW:single-enforcer] The one place "is this text readable, and if not fix
48
53
  * it" is decided. Callers route every fg/bg pair through here and the
@@ -51,4 +56,27 @@ export { relativeLuminance, contrastRatio };
51
56
  * lightness moves — there is no caller-side "should I check contrast" branch.
52
57
  */
53
58
  export declare function ensureContrast(fg: ColorRgba, bg: ColorRgba, minRatio?: number, // WCAG AA for normal text
54
- drawnAt?: ColorDepth): ColorRgba;
59
+ drawnAt?: ColorDepth, substrate?: ColorRgba): ColorRgba;
60
+ /**
61
+ * `chosen` (composited opaque over `substrate`), or — when the depth the
62
+ * terminal draws at rounds it to a colour `accept` refuses — the nearest colour that depth draws as itself which
63
+ * `accept` takes. `accept` sees the candidate as drawn, and `drawn`, the same
64
+ * rounding for any other colour it measures against, so the caller states a
65
+ * floor once and it holds on the colours the terminal shows — at ANSI, on
66
+ * the table's nominal colours, as `ensureContrast` measures there. Truecolor
67
+ * draws from no table, so a colour refused there has no replacement;
68
+ * `undefined` means nothing the depth draws is accepted.
69
+ *
70
+ * `ensureContrast` is this with a contrast ratio as the floor; this is for a
71
+ * floor that is not text on its background (an open state standing off every
72
+ * closed cell, two planes standing off each other).
73
+ */
74
+ export declare function ensureDrawn(chosen: ColorRgba, drawnAt: ColorDepth, accept: (candidate: ColorRgba, drawn: (c: ColorRgba) => ColorRgba) => boolean, substrate?: ColorRgba): ColorRgba | undefined;
75
+ /**
76
+ * The colour a ground is shown as at `drawnAt`: composited over `substrate`
77
+ * (the SGR writer's black by default), then rounded to the table that depth
78
+ * draws from — at ANSI, that table's nominal colour stands in for the
79
+ * theme's. The one account `ensureDrawn` and `ensureContrast` measure by, for
80
+ * a caller that measures a floor of its own.
81
+ */
82
+ export declare function drawnColour(colour: ColorRgba, drawnAt: ColorDepth, substrate?: ColorRgba): ColorRgba;
@@ -1,4 +1,4 @@
1
- import { ColorDepth, ColorRgba, EIGHT_BIT_DOWNGRADE_TABLE, blendRgb, contrastRatio, relativeLuminance, } from "../core/color.js";
1
+ import { ColorDepth, ColorRgba, EIGHT_BIT_DOWNGRADE_TABLE, STANDARD_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,10 +82,11 @@ 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);
@@ -109,17 +110,21 @@ const CONTRAST_ITERS = 20;
109
110
  * background where even pure black-or-white tops out below the target) does it
110
111
  * fall back to `contrastFor`'s black/white — the true maximum-contrast pick.
111
112
  *
112
- * A translucent `fg` is flattened over `bg` first (the displayed color is
113
- * `fg` composited over `bg`), so the ratio is measured on what the eye
114
- * actually sees and the returned color is opaque. `bg` is treated as the
115
- * 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.
116
118
  *
117
119
  * `drawnAt` is the depth the terminal will draw the pair at. At 256 colours
118
120
  * the terminal rounds text and background independently, and two roundings
119
121
  * can meet in the middle, so the ratio is measured on the drawn pair: a
120
122
  * colour that loses the floor there is replaced by the nearest cube/grey entry
121
- * that clears it (whose own rounding is itself). Every other depth draws a
122
- * colour the chosen one IS (truecolor) or one only the terminal knows (ANSI).
123
+ * that clears it (whose own rounding is itself). At ANSI the terminal draws
124
+ * its own theme's colours, so the ratio is measured on the table's nominal
125
+ * ones (`DRAWN_FROM`): they say which side of the ground text belongs on, and
126
+ * text on its background's index measures 1:1 and is always replaced.
127
+ * Truecolor draws the colour chosen.
123
128
  *
124
129
  * [LAW:single-enforcer] The one place "is this text readable, and if not fix
125
130
  * it" is decided. Callers route every fg/bg pair through here and the
@@ -128,28 +133,91 @@ const CONTRAST_ITERS = 20;
128
133
  * lightness moves — there is no caller-side "should I check contrast" branch.
129
134
  */
130
135
  export function ensureContrast(fg, bg, minRatio = 4.5, // WCAG AA for normal text
131
- drawnAt = ColorDepth.TRUECOLOR) {
132
- const chosen = ensureTruecolorContrast(fg, bg, minRatio);
136
+ drawnAt = ColorDepth.TRUECOLOR, substrate = SURFACE_BLACK) {
137
+ const ground = drawnBackground(bg, substrate);
138
+ const chosen = ensureTruecolorContrast(fg, ground, minRatio);
133
139
  // [LAW:dataflow-not-control-flow] The depth names the table the terminal
134
- // draws from; only one whose entries have a known RGB can be measured.
135
- const table = MEASURABLE_DOWNGRADE[drawnAt];
140
+ // draws from; every table is measured the same way.
141
+ const table = DRAWN_FROM[drawnAt];
136
142
  if (table === undefined)
137
143
  return chosen;
138
- const drawnBg = table.get(table.match(bg));
139
- const drawn = table.get(table.match(chosen));
140
- if (contrastRatio(drawn, drawnBg) >= minRatio)
144
+ const drawnBg = drawnColour(ground, drawnAt, substrate);
145
+ if (contrastRatio(drawnColour(chosen, drawnAt, substrate), drawnBg) >= minRatio)
141
146
  return chosen;
142
147
  return table.get(table.matchReadable(chosen, drawnBg, minRatio));
143
148
  }
144
149
  /**
145
- * The downgrade tables whose entries the terminal draws at a known RGB, by the
146
- * depth that draws from them. 256 colours is the one: its cube and grey ramp
147
- * are fixed by xterm. ANSI 0–15 and the default colour are the terminal
148
- * theme's own, so text drawn there has no ratio to keep; truecolor draws the
149
- * chosen colour itself.
150
+ * `chosen` (composited opaque over `substrate`), or — when the depth the
151
+ * terminal draws at rounds it to a colour `accept` refuses — the nearest colour that depth draws as itself which
152
+ * `accept` takes. `accept` sees the candidate as drawn, and `drawn`, the same
153
+ * rounding for any other colour it measures against, so the caller states a
154
+ * floor once and it holds on the colours the terminal shows — at ANSI, on
155
+ * the table's nominal colours, as `ensureContrast` measures there. Truecolor
156
+ * draws from no table, so a colour refused there has no replacement;
157
+ * `undefined` means nothing the depth draws is accepted.
158
+ *
159
+ * `ensureContrast` is this with a contrast ratio as the floor; this is for a
160
+ * floor that is not text on its background (an open state standing off every
161
+ * closed cell, two planes standing off each other).
162
+ */
163
+ export function ensureDrawn(chosen, drawnAt, accept, substrate = SURFACE_BLACK) {
164
+ // The floor was measured on `chosen` composited over `substrate`, so that
165
+ // opaque colour is what is returned — never a translucent one a different
166
+ // ground would composite into a colour `accept` never saw.
167
+ const opaque = drawnBackground(chosen, substrate);
168
+ const drawn = (c) => drawnColour(c, drawnAt, substrate);
169
+ if (accept(drawn(opaque), drawn))
170
+ return opaque;
171
+ // [LAW:dataflow-not-control-flow] Truecolor draws from no table, so it has
172
+ // no candidates: a refusal there is `undefined` like any exhausted search.
173
+ const table = DRAWN_FROM[drawnAt];
174
+ const index = table?.matchWhere(opaque, (entry) => accept(entry, drawn));
175
+ return index === undefined ? undefined : table.get(index);
176
+ }
177
+ /**
178
+ * The colour a ground is shown as at `drawnAt`: composited over `substrate`
179
+ * (the SGR writer's black by default), then rounded to the table that depth
180
+ * draws from — at ANSI, that table's nominal colour stands in for the
181
+ * theme's. The one account `ensureDrawn` and `ensureContrast` measure by, for
182
+ * a caller that measures a floor of its own.
183
+ */
184
+ export function drawnColour(colour, drawnAt, substrate = SURFACE_BLACK) {
185
+ const opaque = drawnBackground(colour, substrate);
186
+ const table = DRAWN_FROM[drawnAt];
187
+ return table === undefined ? opaque : table.get(table.match(opaque));
188
+ }
189
+ /**
190
+ * A background as it is drawn: composited over the surface beneath it. That
191
+ * surface is a fact about where the pair is drawn, so it arrives as a value:
192
+ * the SGR writer (`Style.toSgrCodes`) composites over `SURFACE_BLACK`, the
193
+ * default here; a caller choosing text for a different surface — an export's
194
+ * canvas, `exportCanvas(theme).background` — names that one.
195
+ * [LAW:no-silent-failure] A surface has nothing under it, so a translucent one
196
+ * has no drawn colour to offer; `compositeOver` would read its raw RGB as if
197
+ * it were opaque, so it is refused here rather than measured wrong.
198
+ * [LAW:one-source-of-truth] Text is chosen against the colour the surface will
199
+ * show — measuring the raw RGBA reads a colour that is drawn nowhere, and text
200
+ * that "clears" it can land below the floor. Opaque colours composite to
201
+ * themselves.
202
+ */
203
+ function drawnBackground(bg, substrate) {
204
+ if (substrate.alpha !== 1) {
205
+ throw new RangeError(`a contrast substrate is the opaque surface under a translucent background; got ${substrate.hex}`);
206
+ }
207
+ return bg.compositeOver(substrate);
208
+ }
209
+ /**
210
+ * The downgrade table the terminal draws from, by depth, measured at each
211
+ * entry's colour. The 256-colour cube and grey ramp are fixed by xterm, so
212
+ * those measurements are exact. ANSI 0–15 are the terminal theme's own, so
213
+ * the table's nominal colours stand in for them: they are wrong in hue from
214
+ * theme to theme, but not about which side of a ground an entry sits on —
215
+ * and two colours on one index are one colour in every theme, which the
216
+ * nominal ratio of 1 refuses. Truecolor draws the chosen colour itself.
150
217
  */
151
- const MEASURABLE_DOWNGRADE = {
218
+ const DRAWN_FROM = {
152
219
  [ColorDepth.EIGHT_BIT]: EIGHT_BIT_DOWNGRADE_TABLE,
220
+ [ColorDepth.STANDARD]: STANDARD_TABLE,
153
221
  };
154
222
  function ensureTruecolorContrast(fg, bg, minRatio) {
155
223
  // Flatten translucency so the guarantee holds for the displayed color, not
@@ -245,6 +245,10 @@ let DefaultScreen = (() => {
245
245
  maxWidth: width,
246
246
  isTerminal: true,
247
247
  encoding: "utf-8",
248
+ // [LAW:one-source-of-truth] The depth the frame is encoded at (below,
249
+ // `segmentsToString`), so a renderable that decides on drawn colours
250
+ // decides on the ones this screen draws.
251
+ colorSystem: this.colorSystem,
248
252
  };
249
253
  const lines = [];
250
254
  const boundsList = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/rich-js",
3
- "version": "0.13.0",
3
+ "version": "0.15.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,