@promptctl/rich-js 0.18.1 → 0.19.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.
Files changed (150) hide show
  1. package/README.md +9 -7
  2. package/dist/core/box.d.ts +3 -1
  3. package/dist/core/box.js +6 -13
  4. package/dist/core/cells.d.ts +61 -28
  5. package/dist/core/cells.js +171 -124
  6. package/dist/core/clock.d.ts +48 -0
  7. package/dist/core/clock.js +42 -0
  8. package/dist/core/color.d.ts +98 -18
  9. package/dist/core/color.js +331 -144
  10. package/dist/core/console.d.ts +38 -7
  11. package/dist/core/console.js +159 -90
  12. package/dist/core/easing.d.ts +86 -0
  13. package/dist/core/easing.js +171 -0
  14. package/dist/core/env.d.ts +16 -0
  15. package/dist/core/env.js +16 -0
  16. package/dist/core/export-html.js +1 -4
  17. package/dist/core/export-lines.d.ts +22 -13
  18. package/dist/core/export-lines.js +37 -19
  19. package/dist/core/export-svg.d.ts +49 -0
  20. package/dist/core/export-svg.js +213 -0
  21. package/dist/core/fnv1a.d.ts +10 -0
  22. package/dist/core/fnv1a.js +17 -0
  23. package/dist/core/highlighter.d.ts +2 -2
  24. package/dist/core/highlighter.js +113 -47
  25. package/dist/core/json.js +1 -1
  26. package/dist/core/markup.d.ts +28 -7
  27. package/dist/core/markup.js +235 -312
  28. package/dist/core/measure.d.ts +3 -3
  29. package/dist/core/measure.js +12 -7
  30. package/dist/core/memo.d.ts +19 -0
  31. package/dist/core/memo.js +34 -0
  32. package/dist/core/osc8.d.ts +6 -6
  33. package/dist/core/osc8.js +9 -21
  34. package/dist/core/paint.d.ts +107 -0
  35. package/dist/core/paint.js +150 -0
  36. package/dist/core/place.d.ts +1 -1
  37. package/dist/core/place.js +4 -6
  38. package/dist/core/pretty.d.ts +105 -2
  39. package/dist/core/pretty.js +527 -97
  40. package/dist/core/protocol.d.ts +41 -3
  41. package/dist/core/protocol.js +20 -0
  42. package/dist/core/render.d.ts +33 -30
  43. package/dist/core/render.js +43 -83
  44. package/dist/core/segment.d.ts +6 -5
  45. package/dist/core/segment.js +11 -15
  46. package/dist/core/strip.d.ts +13 -2
  47. package/dist/core/strip.js +44 -23
  48. package/dist/core/style.d.ts +27 -7
  49. package/dist/core/style.js +97 -34
  50. package/dist/core/text.d.ts +43 -16
  51. package/dist/core/text.js +180 -59
  52. package/dist/core/wrap.d.ts +5 -0
  53. package/dist/core/wrap.js +16 -3
  54. package/dist/host/app.d.ts +31 -19
  55. package/dist/host/app.js +90 -60
  56. package/dist/host/index.d.ts +2 -1
  57. package/dist/host/terminal-host.d.ts +3 -2
  58. package/dist/index.d.ts +17 -10
  59. package/dist/index.js +11 -4
  60. package/dist/node/prompt.d.ts +13 -0
  61. package/dist/node/prompt.js +30 -9
  62. package/dist/node/save.d.ts +4 -3
  63. package/dist/node/save.js +6 -3
  64. package/dist/node/terminal-host.d.ts +3 -2
  65. package/dist/node/traceback.d.ts +2 -1
  66. package/dist/node/traceback.js +3 -39
  67. package/dist/renderables/align.js +2 -9
  68. package/dist/renderables/columns.d.ts +45 -29
  69. package/dist/renderables/columns.js +142 -103
  70. package/dist/renderables/constrain.js +6 -12
  71. package/dist/renderables/effect.d.ts +98 -0
  72. package/dist/renderables/effect.js +160 -0
  73. package/dist/renderables/embed.d.ts +87 -30
  74. package/dist/renderables/embed.js +134 -45
  75. package/dist/renderables/group.d.ts +6 -3
  76. package/dist/renderables/group.js +5 -0
  77. package/dist/renderables/layout.d.ts +6 -23
  78. package/dist/renderables/layout.js +24 -113
  79. package/dist/renderables/live.d.ts +40 -16
  80. package/dist/renderables/live.js +96 -83
  81. package/dist/renderables/markdown.d.ts +16 -2
  82. package/dist/renderables/markdown.js +521 -118
  83. package/dist/renderables/padding.d.ts +22 -1
  84. package/dist/renderables/padding.js +30 -24
  85. package/dist/renderables/panel.d.ts +31 -33
  86. package/dist/renderables/panel.js +137 -141
  87. package/dist/renderables/progress.d.ts +104 -16
  88. package/dist/renderables/progress.js +205 -81
  89. package/dist/renderables/progressBar.d.ts +2 -2
  90. package/dist/renderables/progressBar.js +30 -19
  91. package/dist/renderables/prompt.d.ts +37 -15
  92. package/dist/renderables/prompt.js +125 -85
  93. package/dist/renderables/ratio.d.ts +123 -0
  94. package/dist/renderables/ratio.js +213 -0
  95. package/dist/renderables/rule.d.ts +1 -0
  96. package/dist/renderables/rule.js +44 -48
  97. package/dist/renderables/spinner.d.ts +28 -2
  98. package/dist/renderables/spinner.js +52 -19
  99. package/dist/renderables/status.d.ts +13 -7
  100. package/dist/renderables/status.js +15 -29
  101. package/dist/renderables/syntax.d.ts +37 -7
  102. package/dist/renderables/syntax.js +134 -83
  103. package/dist/renderables/table.d.ts +58 -20
  104. package/dist/renderables/table.js +370 -192
  105. package/dist/renderables/traceback.d.ts +5 -4
  106. package/dist/renderables/traceback.js +239 -68
  107. package/dist/renderables/tree.d.ts +10 -2
  108. package/dist/renderables/tree.js +61 -34
  109. package/dist/renderables/viewport.js +2 -4
  110. package/dist/template-bindings/color-funcs.d.ts +31 -9
  111. package/dist/template-bindings/color-funcs.js +40 -14
  112. package/dist/template-bindings/helpers.d.ts +4 -2
  113. package/dist/template-bindings/helpers.js +5 -20
  114. package/dist/template-bindings/index.d.ts +29 -17
  115. package/dist/template-bindings/index.js +36 -20
  116. package/dist/template-bindings/palette-funcs.d.ts +3 -1
  117. package/dist/template-bindings/palette-funcs.js +11 -7
  118. package/dist/template-bindings/style-funcs.js +4 -2
  119. package/dist/themes/buildPalette.d.ts +24 -3
  120. package/dist/themes/buildPalette.js +52 -8
  121. package/dist/themes/colorMath.d.ts +29 -17
  122. package/dist/themes/colorMath.js +74 -48
  123. package/dist/themes/colorRef.d.ts +2 -17
  124. package/dist/themes/colorRef.js +13 -31
  125. package/dist/themes/palette.d.ts +24 -0
  126. package/dist/themes/palette.js +46 -3
  127. package/dist/themes/ramp.d.ts +21 -34
  128. package/dist/themes/ramp.js +23 -42
  129. package/dist/themes/registry.js +39 -28
  130. package/dist/widgets/button.d.ts +2 -6
  131. package/dist/widgets/button.js +9 -40
  132. package/dist/widgets/checkbox.d.ts +2 -5
  133. package/dist/widgets/checkbox.js +9 -31
  134. package/dist/widgets/dropdown.d.ts +2 -5
  135. package/dist/widgets/dropdown.js +14 -48
  136. package/dist/widgets/ink.d.ts +22 -0
  137. package/dist/widgets/ink.js +31 -0
  138. package/dist/widgets/slider.d.ts +2 -5
  139. package/dist/widgets/slider.js +11 -34
  140. package/dist/widgets/text-input.d.ts +19 -10
  141. package/dist/widgets/text-input.js +156 -147
  142. package/dist/widgets/themed-widget.d.ts +20 -0
  143. package/dist/widgets/themed-widget.js +89 -0
  144. package/dist/widgets/toggle.d.ts +2 -5
  145. package/dist/widgets/toggle.js +8 -31
  146. package/dist/widgets/widget-app.d.ts +2 -0
  147. package/dist/widgets/widget-app.js +13 -6
  148. package/dist/widgets/widget-base.d.ts +2 -0
  149. package/dist/widgets/widget-base.js +9 -2
  150. package/package.json +11 -1
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Terminal color representation, parsing, downgrading, and ANSI code generation.
3
3
  */
4
+ import { Memo } from "./memo.js";
4
5
  // --- ColorRgba ---
5
6
  function hex2(byte) {
6
7
  return byte.toString(16).padStart(2, "0");
@@ -73,8 +74,9 @@ export class ColorRgba {
73
74
  /**
74
75
  * The surface a terminal draws a translucent colour over. A terminal cannot
75
76
  * know what lies under its cells, so the SGR writer (`Style.toSgrCodes`), the
76
- * strip's seam test and the contrast choosers (`themes/colorMath`) all
77
- * composite over this one colour. [LAW:one-source-of-truth] One constant, so
77
+ * exports that picture its output (`resolveLook`), the strip's seam test and
78
+ * the contrast choosers (`themes/colorMath`) all composite over this one
79
+ * colour. [LAW:one-source-of-truth] One constant, so
78
80
  * the colour text is chosen against is the colour the writer draws.
79
81
  */
80
82
  export const SURFACE_BLACK = new ColorRgba(0, 0, 0);
@@ -110,23 +112,6 @@ function luminanceRatio(la, lb) {
110
112
  return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
111
113
  }
112
114
  // --- ColorTable ---
113
- /**
114
- * An indexed palette: entry `i` of `colors` is terminal index `firstIndex + i`.
115
- * `firstIndex` lets a table hold only the part of a palette a downgrade may
116
- * choose (the 256-colour cube and grey ramp start at 16) while every index it
117
- * reports is the terminal's own.
118
- */
119
- // [LAW:single-enforcer] The one size policy for ColorTable's memos: a key is
120
- // derived from colours a long-running host computes without end (ramp stops,
121
- // mixes), so an unbounded map grows with every render. Clearing at the cap is
122
- // the policy `cellLen` already uses; a refill costs one table scan per key.
123
- const TABLE_CACHE_MAX = 4096;
124
- function remember(cache, key, index) {
125
- if (cache.size >= TABLE_CACHE_MAX)
126
- cache.clear();
127
- cache.set(key, index);
128
- return index;
129
- }
130
115
  // [LAW:one-source-of-truth] The one distance every table scan ranks by, so
131
116
  // `matchWhere`'s "nearest by the distance `match` uses" cannot drift.
132
117
  function rgbDistance(a, b) {
@@ -135,11 +120,18 @@ function rgbDistance(a, b) {
135
120
  const db = a.blue - b.blue;
136
121
  return dr * dr + dg * dg + db * db;
137
122
  }
123
+ /**
124
+ * An indexed palette: entry `i` of `colors` is terminal index `firstIndex + i`.
125
+ * `firstIndex` lets a table hold only the part of a palette a downgrade may
126
+ * choose (the 256-colour cube and grey ramp start at 16) while every index it
127
+ * reports is the terminal's own.
128
+ */
138
129
  export class ColorTable {
139
130
  colors;
140
131
  firstIndex;
141
- matchCache = new Map();
142
- readableCache = new Map();
132
+ // Keyed by colours a host computes without end (ramp stops, mixes), so bounded.
133
+ matchMemo = new Memo();
134
+ readableMemo = new Memo();
143
135
  constructor(colors, firstIndex = 0) {
144
136
  this.colors = colors;
145
137
  this.firstIndex = firstIndex;
@@ -164,9 +156,9 @@ export class ColorTable {
164
156
  */
165
157
  match(value) {
166
158
  const key = `${value.red},${value.green},${value.blue},${value.alpha}`;
167
- const cached = this.matchCache.get(key);
168
- if (cached !== undefined)
169
- return cached;
159
+ return this.matchMemo.get(key, () => this.nearest(value));
160
+ }
161
+ nearest(value) {
170
162
  let bestIndex = 0;
171
163
  let bestDist = Infinity;
172
164
  for (let i = 0; i < this.colors.length; i++) {
@@ -177,7 +169,7 @@ export class ColorTable {
177
169
  bestIndex = i;
178
170
  }
179
171
  }
180
- return remember(this.matchCache, key, this.firstIndex + bestIndex);
172
+ return this.firstIndex + bestIndex;
181
173
  }
182
174
  /**
183
175
  * The nearest entry to `value` (the distance `match` uses) among those that
@@ -190,9 +182,9 @@ export class ColorTable {
190
182
  */
191
183
  matchReadable(value, on, minRatio) {
192
184
  const key = `${value.red},${value.green},${value.blue}|${on.red},${on.green},${on.blue}|${minRatio}`;
193
- const cached = this.readableCache.get(key);
194
- if (cached !== undefined)
195
- return cached;
185
+ return this.readableMemo.get(key, () => this.nearestReadable(value, on, minRatio));
186
+ }
187
+ nearestReadable(value, on, minRatio) {
196
188
  const lOn = relativeLuminance(on);
197
189
  let best = 0;
198
190
  let bestPasses = false;
@@ -212,7 +204,7 @@ export class ColorTable {
212
204
  bestScore = score;
213
205
  }
214
206
  }
215
- return remember(this.readableCache, key, this.firstIndex + best);
207
+ return this.firstIndex + best;
216
208
  }
217
209
  /**
218
210
  * The nearest entry to `value` (the distance `match` uses) among those
@@ -238,38 +230,62 @@ export var ColorDepth;
238
230
  ColorDepth[ColorDepth["TRUECOLOR"] = 3] = "TRUECOLOR";
239
231
  ColorDepth[ColorDepth["WINDOWS"] = 4] = "WINDOWS";
240
232
  })(ColorDepth || (ColorDepth = {}));
241
- // [LAW:one-source-of-truth] One table covers every recognized string form
242
- // that resolves to a ColorDepth: user-facing CLI/config specs, FORCE_COLOR
243
- // values (the chalk/supports-color convention), TERM_PROGRAM identifiers,
244
- // and exact TERM names whose color capability is known. Keys are disjoint
245
- // across these sources, so one table is honest. `null` means "no color".
246
- // "auto" is intentionally absent — it routes to env detection.
247
- const STRING_TO_DEPTH = {
248
- // CLI/config specs
233
+ /**
234
+ * How many colours a depth can name, as a rank. WINDOWS is a sixteen-colour
235
+ * depth whose enum value sits above TRUECOLOR, so the enum's order is not this.
236
+ * STANDARD and WINDOWS share a rank: the same sixteen slots, drawn by
237
+ * different terminals, so each converts to the other by keeping the slot.
238
+ */
239
+ function fidelity(depth) {
240
+ switch (depth) {
241
+ case ColorDepth.DEFAULT:
242
+ return 0;
243
+ case ColorDepth.STANDARD:
244
+ case ColorDepth.WINDOWS:
245
+ return 1;
246
+ case ColorDepth.EIGHT_BIT:
247
+ return 2;
248
+ case ColorDepth.TRUECOLOR:
249
+ return 3;
250
+ }
251
+ }
252
+ // The depths a colour system is named for. Its keys and "auto" are the whole
253
+ // vocabulary: `ColorSystemName` is derived from them, so the type and the
254
+ // table cannot disagree. [LAW:one-source-of-truth]
255
+ const COLOR_SYSTEM_DEPTHS = {
249
256
  truecolor: ColorDepth.TRUECOLOR,
250
257
  "256": ColorDepth.EIGHT_BIT,
251
258
  ansi: ColorDepth.STANDARD,
252
259
  none: null,
260
+ };
261
+ // [LAW:one-source-of-truth] What the environment says about colour, by the
262
+ // value it says it with: FORCE_COLOR values (the chalk/supports-color
263
+ // convention), TERM_PROGRAM identifiers, and exact TERM names whose colour
264
+ // capability is known. A different vocabulary from `ColorSystemName`, so a
265
+ // different table: `FORCE_COLOR=none` is not a request for no colour. A `Map`,
266
+ // so a value naming an `Object.prototype` member is unknown, not a function.
267
+ // `null` means "no color".
268
+ const ENV_DEPTHS = new Map([
253
269
  // FORCE_COLOR values
254
- "0": null,
255
- false: null,
256
- "1": ColorDepth.STANDARD,
257
- true: ColorDepth.STANDARD,
258
- "2": ColorDepth.EIGHT_BIT,
259
- "3": ColorDepth.TRUECOLOR,
270
+ ["0", null],
271
+ ["false", null],
272
+ ["1", ColorDepth.STANDARD],
273
+ ["true", ColorDepth.STANDARD],
274
+ ["2", ColorDepth.EIGHT_BIT],
275
+ ["3", ColorDepth.TRUECOLOR],
260
276
  // TERM_PROGRAM identifiers
261
- "iTerm.app": ColorDepth.TRUECOLOR,
262
- Apple_Terminal: ColorDepth.EIGHT_BIT,
263
- vscode: ColorDepth.TRUECOLOR,
264
- Tabby: ColorDepth.TRUECOLOR,
277
+ ["iTerm.app", ColorDepth.TRUECOLOR],
278
+ ["Apple_Terminal", ColorDepth.EIGHT_BIT],
279
+ ["vscode", ColorDepth.TRUECOLOR],
280
+ ["Tabby", ColorDepth.TRUECOLOR],
265
281
  // Known truecolor TERM names
266
- "xterm-kitty": ColorDepth.TRUECOLOR,
267
- "xterm-ghostty": ColorDepth.TRUECOLOR,
268
- wezterm: ColorDepth.TRUECOLOR,
269
- alacritty: ColorDepth.TRUECOLOR,
270
- foot: ColorDepth.TRUECOLOR,
271
- contour: ColorDepth.TRUECOLOR,
272
- };
282
+ ["xterm-kitty", ColorDepth.TRUECOLOR],
283
+ ["xterm-ghostty", ColorDepth.TRUECOLOR],
284
+ ["wezterm", ColorDepth.TRUECOLOR],
285
+ ["alacritty", ColorDepth.TRUECOLOR],
286
+ ["foot", ColorDepth.TRUECOLOR],
287
+ ["contour", ColorDepth.TRUECOLOR],
288
+ ]);
273
289
  /**
274
290
  * Detect the terminal's color capability from env + TTY state.
275
291
  *
@@ -296,12 +312,21 @@ function forcedColorSystem(env) {
296
312
  const force = env["FORCE_COLOR"];
297
313
  if (force === undefined || force === "")
298
314
  return undefined;
299
- const mapped = STRING_TO_DEPTH[force];
315
+ const mapped = ENV_DEPTHS.get(force);
300
316
  return mapped !== undefined ? mapped : ColorDepth.STANDARD;
301
317
  }
302
318
  function envOf(options) {
303
319
  return options.env ?? (typeof process !== "undefined" ? process.env : {});
304
320
  }
321
+ /**
322
+ * A terminal that says it cannot be drawn on: `TERM` is `dumb` or `unknown`,
323
+ * as Emacs' shell mode sets it. It takes no colour and nothing that moves the
324
+ * cursor.
325
+ */
326
+ export function isDumbTerminal(env) {
327
+ const term = env["TERM"];
328
+ return term === "dumb" || term === "unknown";
329
+ }
305
330
  // What the destination can draw, preferences aside: TTY presence,
306
331
  // TERM=dumb/unknown, COLORTERM, known terminal names, TERM patterns. `null`
307
332
  // here means the destination takes no escapes at all.
@@ -311,19 +336,19 @@ function terminalColorSystem(options) {
311
336
  (typeof process !== "undefined" ? (process.stdout?.isTTY ?? false) : false);
312
337
  if (!isTTY)
313
338
  return null;
314
- const term = env["TERM"] ?? "";
315
- if (term === "dumb" || term === "unknown")
339
+ if (isDumbTerminal(env))
316
340
  return null;
341
+ const term = env["TERM"] ?? "";
317
342
  const colorterm = env["COLORTERM"];
318
343
  if (colorterm === "truecolor" || colorterm === "24bit") {
319
344
  return ColorDepth.TRUECOLOR;
320
345
  }
321
- const termDepth = STRING_TO_DEPTH[term];
346
+ const termDepth = ENV_DEPTHS.get(term);
322
347
  if (termDepth !== undefined)
323
348
  return termDepth;
324
349
  const termProgram = env["TERM_PROGRAM"];
325
350
  if (termProgram !== undefined) {
326
- const mapped = STRING_TO_DEPTH[termProgram];
351
+ const mapped = ENV_DEPTHS.get(termProgram);
327
352
  if (mapped !== undefined)
328
353
  return mapped;
329
354
  }
@@ -364,20 +389,20 @@ export function resolveDestination(spec, options) {
364
389
  };
365
390
  }
366
391
  /**
367
- * Resolve a string spec into a `ColorDepth` (or `null` for no color).
392
+ * Resolve a colour system name into a `ColorDepth` (or `null` for no color).
368
393
  *
369
- * `"auto"` triggers env-based detection; all other recognized specs are
370
- * direct table lookups against `STRING_TO_DEPTH`. Throws on unknown specs
371
- * — silent fallback would mask user typos.
394
+ * `"auto"` triggers env-based detection; every other name is a fixed depth.
395
+ * A name outside the vocabulary — reachable only from untyped JavaScript —
396
+ * throws: silent fallback would mask a typo.
372
397
  *
373
- * [LAW:single-enforcer] All string→ColorDepth resolution flows through here.
398
+ * [LAW:single-enforcer] All name→ColorDepth resolution flows through here.
374
399
  */
375
400
  export function resolveColorSystem(spec, options) {
376
401
  if (spec === "auto")
377
402
  return detectColorSystem(options);
378
- if (Object.hasOwn(STRING_TO_DEPTH, spec))
379
- return STRING_TO_DEPTH[spec];
380
- throw new ColorParseError(`Unknown color depth spec: ${JSON.stringify(spec)} (expected "auto", "truecolor", "256", "ansi", or "none")`);
403
+ if (Object.hasOwn(COLOR_SYSTEM_DEPTHS, spec))
404
+ return COLOR_SYSTEM_DEPTHS[spec];
405
+ throw new ColorParseError(`Unknown color system name: ${JSON.stringify(spec)} (expected "auto", "truecolor", "256", "ansi", or "none")`);
381
406
  }
382
407
  // --- ColorSpec ---
383
408
  export class ColorParseError extends Error {
@@ -386,8 +411,8 @@ export class ColorParseError extends Error {
386
411
  this.name = "ColorParseError";
387
412
  }
388
413
  }
389
- // [LAW:one-source-of-truth] Parse cache is the single source for parsed ColorSpec instances
390
- const parseCache = new Map();
414
+ // [LAW:one-source-of-truth] The single source for parsed ColorSpec instances.
415
+ const colorSpecParseMemo = new Memo();
391
416
  export class ColorSpec {
392
417
  name;
393
418
  type;
@@ -408,8 +433,9 @@ export class ColorSpec {
408
433
  * any alpha is flattened, so a translucent value is drawn composited over
409
434
  * its ground (`flattenAlpha`) and should be measured that way: a
410
435
  * truecolor value, or a 256-colour cube or grey-ramp entry (xterm fixes
411
- * indices 16–255, and `fromAnsi` types only those as EIGHT_BIT). ANSI 0–15
412
- * and the default colour are the terminal theme's own, so they have none.
436
+ * indices 16–255, and `fromAnsi` types only those as EIGHT_BIT). ANSI 0–15,
437
+ * the default colour and a blend reaching either are the terminal theme's
438
+ * own, so they have none.
413
439
  */
414
440
  get fixedValue() {
415
441
  switch (this.type) {
@@ -489,7 +515,7 @@ export class ColorSpec {
489
515
  downgrade(targetSystem) {
490
516
  if (this.type === ColorDepth.DEFAULT)
491
517
  return this;
492
- if (this.type <= targetSystem)
518
+ if (this.type === targetSystem || fidelity(this.type) < fidelity(targetSystem))
493
519
  return this;
494
520
  const cached = this.downgradeCache.get(targetSystem);
495
521
  if (cached)
@@ -507,19 +533,14 @@ export class ColorSpec {
507
533
  return this.value;
508
534
  case ColorDepth.EIGHT_BIT:
509
535
  // Slots 0–15 are the theme's own whatever depth names them, as `fixedValue` says.
510
- return this.number < 16 ? (theme ?? INTERNAL_DEFAULT_THEME).ansiColors.get(this.number) : EIGHT_BIT_TABLE.get(this.number);
511
- case ColorDepth.STANDARD: {
512
- const t = theme ?? INTERNAL_DEFAULT_THEME;
513
- return t.ansiColors.get(this.number);
514
- }
536
+ return this.number < 16 ? resolveTerminal(theme, this.type).ansiColors.get(this.number) : EIGHT_BIT_TABLE.get(this.number);
537
+ case ColorDepth.STANDARD:
538
+ case ColorDepth.WINDOWS:
539
+ return resolveTerminal(theme, this.type).ansiColors.get(this.number);
515
540
  case ColorDepth.DEFAULT: {
516
- const t = theme ?? INTERNAL_DEFAULT_THEME;
541
+ const t = resolveTerminal(theme, this.type);
517
542
  return foreground ? t.foregroundColor : t.backgroundColor;
518
543
  }
519
- case ColorDepth.WINDOWS: {
520
- const t = theme ?? INTERNAL_DEFAULT_THEME;
521
- return t.ansiColors.get(this.number);
522
- }
523
544
  }
524
545
  }
525
546
  // --- Static factories ---
@@ -537,34 +558,83 @@ export class ColorSpec {
537
558
  return ColorSpec.fromRgba(new ColorRgba(r, g, b));
538
559
  }
539
560
  /**
540
- * Parse a color string. Cached — identical strings return the same instance.
561
+ * The colour `t` of the way from `from` to `to`: what `blend(from,to,t)`
562
+ * parses to. Two fixed colours mix to a fixed colour. A mix reaching a
563
+ * theme's own colour stays a mix until it is drawn, so whatever theme draws
564
+ * an end draws the mix from it: exported under a theme whose `blue` is
565
+ * #61afef, a mix with `blue` starts from #61afef. A terminal is told its RGB
566
+ * under the standard table, the only one a render has.
567
+ *
568
+ * Each end is one opaque colour: `default` is no colour to mix, and a
569
+ * translucent end has no one shade until it is laid on a ground.
570
+ */
571
+ static blend(from, to, t) {
572
+ for (const end of [from, to]) {
573
+ if (end.isDefault)
574
+ throw new ColorParseError(`A blend cannot mix "default", which is no colour`);
575
+ if (end.value !== undefined && end.value.alpha < 1) {
576
+ throw new ColorParseError(`A blend's ends are opaque; "${end.name}" is translucent`);
577
+ }
578
+ }
579
+ if (!(t >= 0 && t <= 1))
580
+ throw new ColorParseError(`A blend's fraction is in [0, 1]; got ${t}`);
581
+ const a = from.fixedValue;
582
+ const b = to.fixedValue;
583
+ return a !== undefined && b !== undefined ? ColorSpec.fromRgba(blendRgb(a, b, t)) : new ColorBlend(from, to, t);
584
+ }
585
+ /**
586
+ * The spec that draws `value` nearest to it at `depth` on a terminal known
587
+ * to show `theme`, as the colour of the glyph (`foreground`) or of the
588
+ * ground. Where `downgrade` knows no terminal, and so rounds only against
589
+ * colours every terminal fixes (or, at sixteen colours, the stock
590
+ * sixteen), this rounds against every colour the depth can write whose
591
+ * look `theme` fixes: the default colour, the theme's sixteen, and at 256
592
+ * colours the cube and grey ramp too. A colour read off any of those and
593
+ * moved a little is drawn as the colour it came from — or, where the theme
594
+ * gives two slots one colour, as the lower slot, which looks the same.
595
+ *
596
+ * `value` is opaque: a translucent colour has no one look until it is laid
597
+ * on a ground (`flattenAlpha`).
598
+ */
599
+ static matchOn(value, depth, theme, foreground) {
600
+ if (value.alpha < 1)
601
+ throw new RangeError(`matchOn rounds an opaque colour; got alpha ${value.alpha}`);
602
+ if (depth === ColorDepth.TRUECOLOR)
603
+ return ColorSpec.fromRgba(value);
604
+ if (depth === ColorDepth.DEFAULT)
605
+ return ColorSpec.default();
606
+ const slot = theme.ansiColors.match(value);
607
+ // [LAW:dataflow-not-control-flow] Every candidate is scored; the depth
608
+ // only decides whether the cube is among them. Ties go to the earlier.
609
+ const candidates = [
610
+ [ColorSpec.default(), foreground ? theme.foregroundColor : theme.backgroundColor],
611
+ [new ColorSpec(`color(${slot})`, depth === ColorDepth.WINDOWS ? depth : ColorDepth.STANDARD, slot), theme.ansiColors.get(slot)],
612
+ ...(depth === ColorDepth.EIGHT_BIT ? [cubeCandidate(value)] : []),
613
+ ];
614
+ return candidates.reduce((best, next) => (rgbDistance(next[1], value) < rgbDistance(best[1], value) ? next : best))[0];
615
+ }
616
+ /**
617
+ * Parse a color string. Memoized (`Memo`): a string parsed again while
618
+ * remembered returns the same instance.
541
619
  */
542
620
  static parse(colorString) {
543
- const key = colorString.toLowerCase().trim();
544
- const cached = parseCache.get(key);
545
- if (cached)
546
- return cached;
547
- const result = parseSingle(key);
548
- parseCache.set(key, result);
549
- return result;
621
+ return colorSpecParseMemo.get(colorString.toLowerCase().trim(), parseSingle);
550
622
  }
551
623
  // --- private ---
552
624
  performDowngrade(targetSystem) {
553
- // Get the true RGB of this color
554
- const triplet = this.getTruecolor();
625
+ // The colour as the terminal this depth assumes draws it: a blend's named
626
+ // ends are the Windows console's own at WINDOWS.
627
+ const triplet = this.getTruecolor(resolveTerminal(undefined, targetSystem));
555
628
  switch (targetSystem) {
556
- case ColorDepth.EIGHT_BIT: {
557
- const index = EIGHT_BIT_DOWNGRADE_TABLE.match(triplet);
558
- return ColorSpec.fromAnsi(index);
559
- }
560
- case ColorDepth.STANDARD: {
561
- const index = STANDARD_TABLE.match(triplet);
562
- return new ColorSpec(`color(${index})`, ColorDepth.STANDARD, index);
629
+ case ColorDepth.EIGHT_BIT:
630
+ return ColorSpec.fromAnsi(downgradeTable(targetSystem).match(triplet));
631
+ case ColorDepth.STANDARD:
632
+ case ColorDepth.WINDOWS: {
633
+ // An ANSI slot is already one of the sixteen either depth writes; only
634
+ // a fixed colour is matched against the depth's table, as Rich does.
635
+ const index = this.number !== undefined && this.number < 16 ? this.number : downgradeTable(targetSystem).match(triplet);
636
+ return new ColorSpec(`color(${index})`, targetSystem, index);
563
637
  }
564
- // WINDOWS is a detection result (max enum value), not a downgrade target.
565
- // The `this.type <= targetSystem` guard in downgrade() always short-circuits
566
- // before reaching this method with WINDOWS, so this case is unreachable.
567
- case ColorDepth.WINDOWS:
568
638
  case ColorDepth.TRUECOLOR:
569
639
  return this;
570
640
  case ColorDepth.DEFAULT:
@@ -572,11 +642,47 @@ export class ColorSpec {
572
642
  }
573
643
  }
574
644
  }
645
+ /** The cube or grey-ramp entry nearest `value`, and its colour. */
646
+ function cubeCandidate(value) {
647
+ const index = EIGHT_BIT_DOWNGRADE_TABLE.match(value);
648
+ return [ColorSpec.fromAnsi(index), EIGHT_BIT_DOWNGRADE_TABLE.get(index)];
649
+ }
650
+ function mix(from, to, t, theme) {
651
+ return blendRgb(from.getTruecolor(theme), to.getTruecolor(theme), t);
652
+ }
653
+ // How a blend spells an end: by what it draws as, so every spelling of one
654
+ // colour ("blue", "color(4)", an EIGHT_BIT 4) gives the blend one name.
655
+ function blendTerm(end) {
656
+ return end.fixedValue?.hex ?? (end.number !== undefined ? `color(${end.number})` : end.name);
657
+ }
658
+ /**
659
+ * `ColorSpec.blend` with an end the theme draws. A terminal is told it as a
660
+ * truecolor value, so it is one, and `value` is what it is told; a theme is
661
+ * asked of its two ends instead. Its name is what `ColorSpec.parse` reads back.
662
+ */
663
+ class ColorBlend extends ColorSpec {
664
+ from;
665
+ to;
666
+ t;
667
+ constructor(from, to, t) {
668
+ super(`blend(${blendTerm(from)},${blendTerm(to)},${t})`, ColorDepth.TRUECOLOR, undefined, mix(from, to, t));
669
+ this.from = from;
670
+ this.to = to;
671
+ this.t = t;
672
+ }
673
+ get fixedValue() {
674
+ return undefined;
675
+ }
676
+ getTruecolor(theme) {
677
+ return mix(this.from, this.to, this.t, theme);
678
+ }
679
+ }
575
680
  // --- Parsing internals ---
576
- const HEX_RE = /^#([0-9a-f]{6})$/;
577
- const HEX_RGBA_RE = /^#([0-9a-f]{8})$/;
578
681
  const RGB_RE = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/;
579
682
  const COLOR_NUMBER_RE = /^color\((\d+)\)$/;
683
+ const BLEND_RE = /^blend\((.*)\)$/;
684
+ // A decimal as `String(number)` spells one, exponent included; `blend` owns its range.
685
+ const FRACTION_RE = /^\s*(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?\s*$/;
580
686
  function parseSingle(key) {
581
687
  if (key === "default" || key === "") {
582
688
  return ColorSpec.default();
@@ -587,15 +693,8 @@ function parseSingle(key) {
587
693
  const type = namedIndex < 16 ? ColorDepth.STANDARD : ColorDepth.EIGHT_BIT;
588
694
  return new ColorSpec(key, type, namedIndex);
589
695
  }
590
- // Hex (8-char with alpha takes precedence over 6-char to avoid the 6-char
591
- // regex matching a prefix of an 8-char string).
592
- const hexRgbaMatch = HEX_RGBA_RE.exec(key);
593
- if (hexRgbaMatch) {
594
- return new ColorSpec(key, ColorDepth.TRUECOLOR, undefined, parseRgbaHex(hexRgbaMatch[1]));
595
- }
596
- const hexMatch = HEX_RE.exec(key);
597
- if (hexMatch) {
598
- return new ColorSpec(key, ColorDepth.TRUECOLOR, undefined, parseRgbHex(hexMatch[1]));
696
+ if (key.startsWith("#")) {
697
+ return new ColorSpec(key, ColorDepth.TRUECOLOR, undefined, parseHexColor(key));
599
698
  }
600
699
  // rgb()
601
700
  const rgbMatch = RGB_RE.exec(key);
@@ -612,8 +711,38 @@ function parseSingle(key) {
612
711
  const type = n < 16 ? ColorDepth.STANDARD : ColorDepth.EIGHT_BIT;
613
712
  return new ColorSpec(key, type, n);
614
713
  }
714
+ // blend(a,b,t)
715
+ const blendMatch = BLEND_RE.exec(key);
716
+ if (blendMatch) {
717
+ const terms = blendTerms(blendMatch[1]);
718
+ const fraction = terms[2] ?? "";
719
+ if (terms.length !== 3 || !FRACTION_RE.test(fraction)) {
720
+ throw new ColorParseError(`ColorSpec "${key}": a blend is blend(<color>,<color>,<fraction>)`);
721
+ }
722
+ return ColorSpec.blend(ColorSpec.parse(terms[0]), ColorSpec.parse(terms[1]), Number(fraction));
723
+ }
615
724
  throw new ColorParseError(`Failed to parse color: "${key}"`);
616
725
  }
726
+ // A blend's terms, split at the commas outside parentheses so an `rgb(…)` or a
727
+ // nested `blend(…)` stays one term.
728
+ function blendTerms(inner) {
729
+ const terms = [];
730
+ let depth = 0;
731
+ let start = 0;
732
+ for (let i = 0; i < inner.length; i++) {
733
+ const c = inner[i];
734
+ if (c === "(")
735
+ depth++;
736
+ else if (c === ")")
737
+ depth--;
738
+ else if (c === "," && depth === 0) {
739
+ terms.push(inner.slice(start, i));
740
+ start = i + 1;
741
+ }
742
+ }
743
+ terms.push(inner.slice(start));
744
+ return terms;
745
+ }
617
746
  // [LAW:single-enforcer] The regexes admit any run of digits and leave the
618
747
  // range to this check alone, so every out-of-range byte is a ColorParseError
619
748
  // naming the field, never the generic parse failure or the constructor's RangeError.
@@ -625,26 +754,45 @@ function parseByte(key, digits, field) {
625
754
  return n;
626
755
  }
627
756
  // --- Utility functions ---
757
+ // [LAW:single-enforcer] The hex colour grammar, checked here and nowhere else:
758
+ // every string spelling a colour in hex — a ColorSpec, a theme's data, a colour
759
+ // reference, a template argument — becomes one through the three parsers below,
760
+ // and each is this check at the lengths it admits. Each digit pair is a byte, so
761
+ // the grammar holding is what keeps `ColorRgba`'s channels in range.
762
+ const HEX_DIGITS_RE = /^[0-9a-fA-F]+$/;
763
+ // Each admitted digit count, by the grammar it spells: the message a failure
764
+ // carries is read off the lengths a parser admits, so the two cannot disagree.
765
+ const HEX_GRAMMARS = { 6: "RRGGBB", 8: "RRGGBBAA" };
766
+ function hexColor(input, lead, digits, lengths) {
767
+ if (!lengths.includes(digits.length) || !HEX_DIGITS_RE.test(digits)) {
768
+ const expected = lengths.map((n) => lead + HEX_GRAMMARS[n]).join(" or ");
769
+ throw new ColorParseError(`Invalid hex colour ${JSON.stringify(input)} (expected ${expected})`);
770
+ }
771
+ const byte = (at) => parseInt(digits.slice(at, at + 2), 16);
772
+ return new ColorRgba(byte(0), byte(2), byte(4), digits.length === 8 ? byte(6) / 255 : 1);
773
+ }
774
+ /** Six hex digits, no `#`, as an opaque colour. @throws {ColorParseError} on anything else. */
628
775
  export function parseRgbHex(hex) {
629
- const r = parseInt(hex.slice(0, 2), 16);
630
- const g = parseInt(hex.slice(2, 4), 16);
631
- const b = parseInt(hex.slice(4, 6), 16);
632
- return new ColorRgba(r, g, b);
776
+ return hexColor(hex, "", hex, [6]);
633
777
  }
778
+ /** Eight hex digits, no `#`, the last pair alpha. @throws {ColorParseError} on anything else. */
634
779
  export function parseRgbaHex(hex) {
635
- const r = parseInt(hex.slice(0, 2), 16);
636
- const g = parseInt(hex.slice(2, 4), 16);
637
- const b = parseInt(hex.slice(4, 6), 16);
638
- const a = parseInt(hex.slice(6, 8), 16) / 255;
639
- return new ColorRgba(r, g, b, a);
780
+ return hexColor(hex, "", hex, [8]);
781
+ }
782
+ /**
783
+ * A `#RRGGBB` or `#RRGGBBAA` literal, exactly — no surrounding whitespace.
784
+ * @throws {ColorParseError} on anything else.
785
+ */
786
+ export function parseHexColor(literal) {
787
+ return hexColor(literal, "#", literal.startsWith("#") ? literal.slice(1) : "", [6, 8]);
640
788
  }
641
789
  export function blendRgb(color1, color2, crossFade = 0.5) {
642
790
  return new ColorRgba(Math.round(color1.red + (color2.red - color1.red) * crossFade), Math.round(color1.green + (color2.green - color1.green) * crossFade), Math.round(color1.blue + (color2.blue - color1.blue) * crossFade), color1.alpha + (color2.alpha - color1.alpha) * crossFade);
643
791
  }
644
792
  // --- TerminalTheme ---
645
793
  /**
646
- * A terminal theme — surface/foreground baseline, the ANSI 16/256 LUT, and a
647
- * semantic palette.
794
+ * A terminal theme — surface/foreground baseline, the sixteen ANSI colours,
795
+ * and a semantic palette.
648
796
  *
649
797
  * **`ansiColors` is the theme's own sixteen colours**, the shades a terminal
650
798
  * showing this theme draws `red`, `blue` and the rest in. `ColorSpec.parse("red")`
@@ -661,6 +809,11 @@ export class TerminalTheme {
661
809
  this.foregroundColor = foregroundColor;
662
810
  this.ansiColors = ansiColors;
663
811
  this.palette = palette;
812
+ // [LAW:parse-dont-validate] Every reader indexes `ansiColors` 0–15 as the
813
+ // terminal's sixteen, so a theme is the proof it holds exactly those.
814
+ if (ansiColors.firstIndex !== 0 || ansiColors.size !== 16) {
815
+ throw new RangeError(`a TerminalTheme's ansiColors are the sixteen ANSI colours, indices 0–15; got ${ansiColors.size} from index ${ansiColors.firstIndex}`);
816
+ }
664
817
  }
665
818
  }
666
819
  // --- ColorTable data ---
@@ -702,24 +855,29 @@ function build256Table() {
702
855
  }
703
856
  return colors;
704
857
  }
858
+ /**
859
+ * The Windows console's own sixteen (its default Campbell scheme), in ANSI
860
+ * index order: a WINDOWS colour is written as the same `30+n`/`90+n` SGR a
861
+ * STANDARD one is, so slot `n` here is the colour the console draws for it.
862
+ */
705
863
  function buildWindowsTable() {
706
864
  return [
707
- new ColorRgba(0, 0, 0), // 0
708
- new ColorRgba(0, 0, 128), // 1
709
- new ColorRgba(0, 128, 0), // 2
710
- new ColorRgba(0, 128, 128), // 3
711
- new ColorRgba(128, 0, 0), // 4
712
- new ColorRgba(128, 0, 128), // 5
713
- new ColorRgba(128, 128, 0), // 6
714
- new ColorRgba(192, 192, 192), // 7
715
- new ColorRgba(128, 128, 128), // 8
716
- new ColorRgba(0, 0, 255), // 9
717
- new ColorRgba(0, 255, 0), // 10
718
- new ColorRgba(0, 255, 255), // 11
719
- new ColorRgba(255, 0, 0), // 12
720
- new ColorRgba(255, 0, 255), // 13
721
- new ColorRgba(255, 255, 0), // 14
722
- new ColorRgba(255, 255, 255), // 15
865
+ new ColorRgba(12, 12, 12), // 0 black
866
+ new ColorRgba(197, 15, 31), // 1 red
867
+ new ColorRgba(19, 161, 14), // 2 green
868
+ new ColorRgba(193, 156, 0), // 3 yellow
869
+ new ColorRgba(0, 55, 218), // 4 blue
870
+ new ColorRgba(136, 23, 152), // 5 magenta
871
+ new ColorRgba(58, 150, 221), // 6 cyan
872
+ new ColorRgba(204, 204, 204), // 7 white
873
+ new ColorRgba(118, 118, 118), // 8 bright_black
874
+ new ColorRgba(231, 72, 86), // 9 bright_red
875
+ new ColorRgba(22, 198, 12), // 10 bright_green
876
+ new ColorRgba(249, 241, 165), // 11 bright_yellow
877
+ new ColorRgba(59, 120, 255), // 12 bright_blue
878
+ new ColorRgba(180, 0, 158), // 13 bright_magenta
879
+ new ColorRgba(97, 214, 214), // 14 bright_cyan
880
+ new ColorRgba(242, 242, 242), // 15 bright_white
723
881
  ];
724
882
  }
725
883
  export const STANDARD_TABLE = new ColorTable(buildStandard16());
@@ -739,14 +897,43 @@ export const WINDOWS_TABLE = new ColorTable(buildWindowsTable());
739
897
  // `themes/`. Keeping them out of `core/color.ts` makes the dependency strictly
740
898
  // `themes/* -> core/color`, with no runtime back-edge.
741
899
  //
742
- // `getTruecolor()` retains a fallback for callers that omit `theme`. The
743
- // fallback uses STANDARD_TABLE + black/white + an empty Palette — sufficient
744
- // for STANDARD/DEFAULT/WINDOWS lookups, which never read `palette`.
900
+ // `getTruecolor()` retains a fallback for callers that omit `theme`: the
901
+ // terminal a colour's depth assumes, built from that depth's own sixteen with
902
+ // an empty Palette — sufficient for the lookups it serves, which never read
903
+ // `palette`.
745
904
  //
746
905
  // Runtime-importing `Palette` from `themes/palette.ts` is safe: that module
747
906
  // only `import type`s from `core/color.ts`, so there is no runtime cycle.
748
907
  import { Palette } from "../themes/palette.js";
749
908
  const INTERNAL_DEFAULT_THEME = new TerminalTheme(new ColorRgba(0, 0, 0), new ColorRgba(255, 255, 255), STANDARD_TABLE, new Palette("default", true, new Map()));
909
+ /** The Windows console in its default Campbell scheme: a WINDOWS colour's terminal. */
910
+ const WINDOWS_CONSOLE_THEME = new TerminalTheme(WINDOWS_TABLE.get(0), WINDOWS_TABLE.get(7), WINDOWS_TABLE, new Palette("windows", true, new Map()));
911
+ /**
912
+ * The terminal a colour at `depth` is drawn by: `theme`, or when a caller names
913
+ * none, the one the depth assumes — the Windows console at WINDOWS, the VGA
914
+ * sixteen otherwise — for `ColorSpec.getTruecolor` and the contrast measurement
915
+ * that reads it. (Widgets fall back to `DEFAULT_TERMINAL_THEME`, which carries a
916
+ * palette this does not.)
917
+ */
918
+ export function resolveTerminal(theme, depth) {
919
+ return theme ?? (depth === ColorDepth.WINDOWS ? WINDOWS_CONSOLE_THEME : INTERNAL_DEFAULT_THEME);
920
+ }
921
+ /**
922
+ * The table a downgrade to `depth` rounds a fixed colour against.
923
+ * [LAW:one-source-of-truth] `ColorSpec.downgrade` picks the index a colour is
924
+ * written as from it, and colorMath hands back its entries as colours the
925
+ * writer rounds to those same indices.
926
+ */
927
+ export function downgradeTable(depth) {
928
+ switch (depth) {
929
+ case ColorDepth.EIGHT_BIT:
930
+ return EIGHT_BIT_DOWNGRADE_TABLE;
931
+ case ColorDepth.STANDARD:
932
+ return STANDARD_TABLE;
933
+ case ColorDepth.WINDOWS:
934
+ return WINDOWS_TABLE;
935
+ }
936
+ }
750
937
  // --- ANSI ColorSpec Names ---
751
938
  // [LAW:one-source-of-truth] Single canonical mapping from name → palette index
752
939
  /**