@promptctl/rich-js 0.14.0 → 0.16.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.
@@ -84,6 +84,15 @@ export declare class ColorTable {
84
84
  * the ratio decides which one wins.
85
85
  */
86
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;
87
96
  }
88
97
  export declare enum ColorDepth {
89
98
  DEFAULT = 0,
@@ -131,6 +140,15 @@ export declare class ColorSpec {
131
140
  private downgradeCache;
132
141
  constructor(name: string, type: ColorDepth, number?: number, value?: ColorRgba);
133
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;
134
152
  get isSystemDefined(): boolean;
135
153
  /**
136
154
  * Generates SGR parameter strings for this color.
@@ -122,6 +122,14 @@ function remember(cache, key, index) {
122
122
  cache.set(key, index);
123
123
  return index;
124
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
+ }
125
133
  export class ColorTable {
126
134
  colors;
127
135
  firstIndex;
@@ -158,10 +166,7 @@ export class ColorTable {
158
166
  let bestDist = Infinity;
159
167
  for (let i = 0; i < this.colors.length; i++) {
160
168
  const c = this.colors[i];
161
- const dr = c.red - value.red;
162
- const dg = c.green - value.green;
163
- const db = c.blue - value.blue;
164
- const dist = dr * dr + dg * dg + db * db;
169
+ const dist = rgbDistance(c, value);
165
170
  if (dist < bestDist) {
166
171
  bestDist = dist;
167
172
  bestIndex = i;
@@ -192,12 +197,9 @@ export class ColorTable {
192
197
  const lc = this.luminances()[i];
193
198
  const ratio = luminanceRatio(lc, lOn);
194
199
  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
200
  // A passing entry scores by closeness; a failing one only by contrast,
199
201
  // and loses to every passing one.
200
- const score = passes ? -(dr * dr + dg * dg + db * db) : ratio;
202
+ const score = passes ? -rgbDistance(c, value) : ratio;
201
203
  if ((passes && !bestPasses) ||
202
204
  (passes === bestPasses && score > bestScore)) {
203
205
  best = i;
@@ -207,6 +209,20 @@ export class ColorTable {
207
209
  }
208
210
  return remember(this.readableCache, key, this.firstIndex + best);
209
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
+ }
210
226
  }
211
227
  // --- Enums ---
212
228
  export var ColorDepth;
@@ -345,6 +361,28 @@ export class ColorSpec {
345
361
  get isDefault() {
346
362
  return this.type === ColorDepth.DEFAULT;
347
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
+ }
348
386
  get isSystemDefined() {
349
387
  return (this.type === ColorDepth.STANDARD || this.type === ColorDepth.WINDOWS);
350
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
  }
@@ -67,7 +67,7 @@ export declare class Strip<T extends StyledRenderable = StyledRenderable> implem
67
67
  */
68
68
  export declare const SEAM_MIN_DELTA_E = 0.04;
69
69
  export interface PowerlineJoinerOptions {
70
- /** Glyph used for every join. */
70
+ /** Glyph for every join between two coloured items. */
71
71
  glyph: string;
72
72
  /**
73
73
  * Glyph drawn between neighbours whose backgrounds the eye cannot tell apart,
@@ -75,12 +75,30 @@ export interface PowerlineJoinerOptions {
75
75
  * shared background.
76
76
  */
77
77
  divider: string;
78
+ /**
79
+ * Glyph for every join a coloured item is entered from nothing — the strip's
80
+ * start, or a colourless left neighbour — painted in the right item's
81
+ * background. `""` begins a coloured run flat.
82
+ */
83
+ lead: string;
84
+ /**
85
+ * Glyph for every join a coloured item leaves into nothing — the strip's end,
86
+ * or a colourless right neighbour — painted in the left item's background.
87
+ * `""` ends a coloured run flat.
88
+ */
89
+ tail: string;
78
90
  }
79
- /** The powerline pair: U+E0B0 (right-arrow) divided by U+E0B1 (thin right-arrow). */
91
+ /**
92
+ * The powerline set: U+E0B0 (right-arrow) divided by U+E0B1 (thin right-arrow),
93
+ * led by U+E0B2 (left-arrow) and tailed by the arrow itself — so a strip's two
94
+ * ends are one shape.
95
+ */
80
96
  export declare const POWERLINE_JOINER_GLYPHS: Readonly<PowerlineJoinerOptions>;
81
97
  export declare class PowerlineJoiner<T extends StyledRenderable = StyledRenderable> implements Joiner<T> {
82
98
  private readonly _glyph;
83
99
  private readonly _divider;
100
+ private readonly _lead;
101
+ private readonly _tail;
84
102
  constructor(options?: PowerlineJoinerOptions);
85
103
  join(left: T | null, right: T | null): Renderable;
86
104
  }
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { Segment } from "./segment.js";
29
29
  import { Style } from "./style.js";
30
- import { ColorSpec, SURFACE_BLACK, blendRgb } from "./color.js";
30
+ import { ColorDepth, ColorSpec, blendRgb } from "./color.js";
31
31
  import { Oklch } from "./oklch.js";
32
32
  // --- Strip ---
33
33
  export class Strip {
@@ -101,6 +101,18 @@ function bgAsFg(edge) {
101
101
  function paintableBg(bg) {
102
102
  return bg !== undefined && !bg.isDefault ? bg : undefined;
103
103
  }
104
+ // A cap or arrow is its cell continuing: that cell's ground as the writer draws
105
+ // it, so a translucent ground is not composited a second time over whatever it
106
+ // enters.
107
+ function drawnGround(bg) {
108
+ return new Style({ bgcolor: bg }).drawnColors().bgcolor;
109
+ }
110
+ // A cap is its glyph in its cell's ground, and "" is a flat end: no glyph, so
111
+ // no segment — the same output as a join with nothing to paint.
112
+ function* cap(glyph, bg) {
113
+ if (glyph !== "")
114
+ yield new Segment(glyph, new Style({ color: drawnGround(bg) }));
115
+ }
104
116
  // --- PowerlineJoiner ---
105
117
  /**
106
118
  * The least ΔE_OK two neighbouring backgrounds must differ by for the powerline
@@ -110,63 +122,97 @@ function paintableBg(bg) {
110
122
  * one cell wide.
111
123
  */
112
124
  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)
125
+ // An arrow the eye cannot tell from the ground it is drawn on. What is
126
+ // measured is what is drawn — `Style.drawnColors`, the colours the writer
127
+ // encodes: the arrow's colour flattened onto its ground and the ground onto
128
+ // the terminal's black, both downgraded to the depth the render encodes at,
129
+ // so two grounds 256 colours round to one cube entry are the one entry they
130
+ // render as. A colour whose RGB is the terminal theme's own (ANSI 0–15) has no
131
+ // value here, so two of them are the same only when they are the same palette
132
+ // slot — its `number`, never the name or the depth that spells it ("red",
133
+ // "color(1)", an EIGHT_BIT spec on 0–15 are one slot).
134
+ function vanishes(arrow, colorSystem) {
135
+ // No colour emitted draws nothing to tell apart; measure what was handed.
136
+ const { color, bgcolor } = arrow.drawnColors(colorSystem ?? ColorDepth.TRUECOLOR);
137
+ if (color === undefined || bgcolor === undefined)
121
138
  return false;
122
- const av = a.flattenAlpha(SURFACE_BLACK).value;
123
- const bv = b.flattenAlpha(SURFACE_BLACK).value;
139
+ const av = color.fixedValue;
140
+ const bv = bgcolor.fixedValue;
124
141
  return av !== undefined && bv !== undefined
125
142
  ? Oklch.fromRgba(av).deltaE(Oklch.fromRgba(bv)) < SEAM_MIN_DELTA_E
126
- : a.type === b.type && a.number === b.number;
143
+ : color.number === bgcolor.number;
127
144
  }
128
- /** The powerline pair: U+E0B0 (right-arrow) divided by U+E0B1 (thin right-arrow). */
129
- export const POWERLINE_JOINER_GLYPHS = Object.freeze({ glyph: "\ue0b0", divider: "\ue0b1" });
145
+ /**
146
+ * The powerline set: U+E0B0 (right-arrow) divided by U+E0B1 (thin right-arrow),
147
+ * led by U+E0B2 (left-arrow) and tailed by the arrow itself — so a strip's two
148
+ * ends are one shape.
149
+ */
150
+ export const POWERLINE_JOINER_GLYPHS = Object.freeze({
151
+ glyph: "\ue0b0",
152
+ divider: "\ue0b1",
153
+ lead: "\ue0b2",
154
+ tail: "\ue0b0",
155
+ });
130
156
  export class PowerlineJoiner {
131
157
  _glyph;
132
158
  _divider;
159
+ _lead;
160
+ _tail;
133
161
  constructor(options = POWERLINE_JOINER_GLYPHS) {
134
162
  this._glyph = options.glyph;
135
163
  this._divider = options.divider;
164
+ this._lead = options.lead;
165
+ this._tail = options.tail;
136
166
  }
137
167
  join(left, right) {
138
- // [LAW:dataflow-not-control-flow] One expression for all three positions
139
- // (start cap, mid-join, end cap). The powerline separator is painted in the
140
- // LEFT edge's bg — the colour bleeding rightward — over the RIGHT edge's bg.
141
- // The endpoints are not control-flow special cases; they are the DATA cases
168
+ // [LAW:dataflow-not-control-flow] One table for all three positions (start
169
+ // cap, mid-join, end cap), read off which side has a colour to paint. The
170
+ // endpoints are not control-flow special cases; they are the DATA cases
142
171
  // where a neighbour (hence its bg) is absent:
143
- // • no left bg — the start cap, OR a left item with no background — has no
144
- // colour to bleed, so there is no separator to paint and the join
145
- // yields nothing. (This matches vim-airline / tmux-powerline: a
146
- // colourless arrow is not drawn.)
147
- // • no right bg — the end cap — bleeds the left colour out over the
148
- // terminal background (fg = left bg, no bg).
172
+ // • both bgs — the arrow in the LEFT bg (the colour bleeding rightward)
173
+ // over the RIGHT bg.
174
+ // • left bg only — the end cap, OR a coloured item before a colourless
175
+ // one — the tail bleeds the left colour out over the terminal
176
+ // background (fg = left bg, no bg).
177
+ // • right bg only — the start cap, OR a colourless item before a coloured
178
+ // one — the lead: the right colour reaching back over the terminal
179
+ // background (fg = right bg, no bg).
180
+ // • neither — nothing to paint, so the join yields nothing.
149
181
  // Equal REAL bgs still emit: a same-bg seam between two distinct items is a
150
182
  // structural boundary, never suppressed. The arrow would be drawn in its own
151
183
  // background colour there and vanish, so the seam is the DIVIDER instead, in
152
184
  // 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.
185
+ // that share a background. "Equal" is perceptual (SEAM_MIN_DELTA_E) and
186
+ // measured on the colours the terminal draws at `options.colorSystem`: an
187
+ // arrow a hair off its background is as invisible as one exactly on it,
188
+ // and two backgrounds 256 colours round to one entry are one background.
155
189
  // Background colour is paint, not structure; its ABSENCE (nothing to paint)
156
190
  // is the only thing that elides the separator, and that is paint logic.
157
191
  // "Absent" = no bg OR the terminal default (transparent) — paintableBg folds
158
192
  // both to undefined so an explicit `… on default` cannot smuggle a separator.
159
193
  const glyph = this._glyph;
160
194
  const divider = this._divider;
195
+ const lead = this._lead;
196
+ const tail = this._tail;
161
197
  return deferred(function* (options) {
162
198
  const leftEdge = left?.edgeStyle("right", options);
163
199
  const leftBg = paintableBg(leftEdge?.bgcolor);
164
- if (leftBg === undefined)
165
- return;
166
200
  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 }));
201
+ if (leftBg === undefined) {
202
+ if (rightBg !== undefined)
203
+ yield* cap(lead, rightBg);
204
+ return;
205
+ }
206
+ if (rightBg === undefined) {
207
+ yield* cap(tail, leftBg);
208
+ return;
209
+ }
210
+ const arrow = new Style({ color: drawnGround(leftBg), bgcolor: rightBg });
211
+ // The divider is the left item's text on the left item's own ground,
212
+ // so it reads exactly as well as that item's text does, at any depth.
213
+ yield vanishes(arrow, options.colorSystem)
214
+ ? new Segment(divider, new Style({ color: leftEdge?.color, bgcolor: leftBg }))
215
+ : new Segment(glyph, arrow);
170
216
  });
171
217
  }
172
218
  }
@@ -69,6 +69,17 @@ export declare class Style {
69
69
  */
70
70
  add(other: Style | undefined): Style;
71
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
+ };
72
83
  /**
73
84
  * Returns the SGR parameter list this style emits (e.g. `"1;31;48;2;0;0;255"`),
74
85
  * or `""` when the style has no SGR contribution. Excludes OSC 8 link bytes —
@@ -264,6 +264,28 @@ export class Style {
264
264
  this.overline === other.overline &&
265
265
  this.link === other.link);
266
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
+ }
267
289
  /**
268
290
  * Returns the SGR parameter list this style emits (e.g. `"1;31;48;2;0;0;255"`),
269
291
  * or `""` when the style has no SGR contribution. Excludes OSC 8 link bytes —
@@ -278,22 +300,11 @@ export class Style {
278
300
  if (this.isNull)
279
301
  return "";
280
302
  const attrs = [];
281
- // [LAW:dataflow-not-control-flow] Always resolve a substrate and flatten
282
- // alpha before downgrade. Opaque colors short-circuit inside compositeOver,
283
- // so the same code path runs every render — the alpha value is the data,
284
- // not a branch.
285
- const surface = SURFACE_BLACK;
286
- const bgFlat = this.bgcolor?.flattenAlpha(surface);
287
- const fgSubstrate = bgFlat?.getTruecolor(undefined, false) ?? surface;
288
- const fgFlat = this.color?.flattenAlpha(fgSubstrate);
289
- if (fgFlat) {
290
- const c = colorSystem !== undefined ? fgFlat.downgrade(colorSystem) : fgFlat;
291
- attrs.push(...c.getAnsiCodes(true));
292
- }
293
- if (bgFlat) {
294
- const c = colorSystem !== undefined ? bgFlat.downgrade(colorSystem) : bgFlat;
295
- attrs.push(...c.getAnsiCodes(false));
296
- }
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));
297
308
  // An attribute set false writes nothing, as in Python Rich 9d8f9a3's
298
309
  // `_make_ansi_codes`. It overrides an inherited attribute when styles
299
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
@@ -43,8 +43,11 @@ export { relativeLuminance, contrastRatio };
43
43
  * the terminal rounds text and background independently, and two roundings
44
44
  * can meet in the middle, so the ratio is measured on the drawn pair: a
45
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).
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.
48
51
  *
49
52
  * [LAW:single-enforcer] The one place "is this text readable, and if not fix
50
53
  * it" is decided. Callers route every fg/bg pair through here and the
@@ -54,3 +57,26 @@ export { relativeLuminance, contrastRatio };
54
57
  */
55
58
  export declare function ensureContrast(fg: ColorRgba, bg: ColorRgba, minRatio?: number, // WCAG AA for normal text
56
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, SURFACE_BLACK, } 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) {
@@ -120,8 +120,11 @@ const CONTRAST_ITERS = 20;
120
120
  * the terminal rounds text and background independently, and two roundings
121
121
  * can meet in the middle, so the ratio is measured on the drawn pair: a
122
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).
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.
125
128
  *
126
129
  * [LAW:single-enforcer] The one place "is this text readable, and if not fix
127
130
  * it" is decided. Callers route every fg/bg pair through here and the
@@ -134,16 +137,55 @@ drawnAt = ColorDepth.TRUECOLOR, substrate = SURFACE_BLACK) {
134
137
  const ground = drawnBackground(bg, substrate);
135
138
  const chosen = ensureTruecolorContrast(fg, ground, minRatio);
136
139
  // [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];
140
+ // draws from; every table is measured the same way.
141
+ const table = DRAWN_FROM[drawnAt];
139
142
  if (table === undefined)
140
143
  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
+ const drawnBg = drawnColour(ground, drawnAt, substrate);
145
+ if (contrastRatio(drawnColour(chosen, drawnAt, substrate), drawnBg) >= minRatio)
144
146
  return chosen;
145
147
  return table.get(table.matchReadable(chosen, drawnBg, minRatio));
146
148
  }
149
+ /**
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
+ }
147
189
  /**
148
190
  * A background as it is drawn: composited over the surface beneath it. That
149
191
  * surface is a fact about where the pair is drawn, so it arrives as a value:
@@ -165,14 +207,17 @@ function drawnBackground(bg, substrate) {
165
207
  return bg.compositeOver(substrate);
166
208
  }
167
209
  /**
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.
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.
173
217
  */
174
- const MEASURABLE_DOWNGRADE = {
218
+ const DRAWN_FROM = {
175
219
  [ColorDepth.EIGHT_BIT]: EIGHT_BIT_DOWNGRADE_TABLE,
220
+ [ColorDepth.STANDARD]: STANDARD_TABLE,
176
221
  };
177
222
  function ensureTruecolorContrast(fg, bg, minRatio) {
178
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.14.0",
3
+ "version": "0.16.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,