@promptctl/rich-js 0.18.1 → 0.20.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 (169) hide show
  1. package/README.md +11 -8
  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 +99 -18
  9. package/dist/core/color.js +334 -146
  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/noise.d.ts +31 -0
  33. package/dist/core/noise.js +94 -0
  34. package/dist/core/osc8.d.ts +6 -6
  35. package/dist/core/osc8.js +9 -21
  36. package/dist/core/paint.d.ts +107 -0
  37. package/dist/core/paint.js +161 -0
  38. package/dist/core/place.d.ts +1 -1
  39. package/dist/core/place.js +4 -6
  40. package/dist/core/pretty.d.ts +126 -2
  41. package/dist/core/pretty.js +571 -105
  42. package/dist/core/protocol.d.ts +43 -3
  43. package/dist/core/protocol.js +23 -2
  44. package/dist/core/render.d.ts +33 -30
  45. package/dist/core/render.js +43 -83
  46. package/dist/core/segment.d.ts +6 -5
  47. package/dist/core/segment.js +11 -15
  48. package/dist/core/selection.d.ts +12 -0
  49. package/dist/core/selection.js +14 -0
  50. package/dist/core/strip.d.ts +116 -74
  51. package/dist/core/strip.js +266 -217
  52. package/dist/core/style.d.ts +27 -7
  53. package/dist/core/style.js +106 -41
  54. package/dist/core/text.d.ts +43 -16
  55. package/dist/core/text.js +180 -59
  56. package/dist/core/wrap.d.ts +5 -0
  57. package/dist/core/wrap.js +16 -3
  58. package/dist/host/app.d.ts +95 -17
  59. package/dist/host/app.js +201 -61
  60. package/dist/host/index.d.ts +4 -1
  61. package/dist/host/index.js +1 -0
  62. package/dist/host/input.d.ts +84 -0
  63. package/dist/host/input.js +498 -0
  64. package/dist/host/terminal-host.d.ts +15 -2
  65. package/dist/host/terminal-host.js +2 -0
  66. package/dist/index.d.ts +26 -14
  67. package/dist/index.js +17 -7
  68. package/dist/node/prompt.d.ts +13 -0
  69. package/dist/node/prompt.js +30 -9
  70. package/dist/node/save.d.ts +4 -3
  71. package/dist/node/save.js +6 -3
  72. package/dist/node/terminal-host.d.ts +10 -2
  73. package/dist/node/terminal-host.js +7 -0
  74. package/dist/node/traceback.d.ts +2 -1
  75. package/dist/node/traceback.js +3 -39
  76. package/dist/renderables/align.js +2 -9
  77. package/dist/renderables/columns.d.ts +45 -29
  78. package/dist/renderables/columns.js +142 -103
  79. package/dist/renderables/constrain.js +6 -12
  80. package/dist/renderables/effect.d.ts +113 -0
  81. package/dist/renderables/effect.js +182 -0
  82. package/dist/renderables/effects.d.ts +252 -0
  83. package/dist/renderables/effects.js +565 -0
  84. package/dist/renderables/embed.d.ts +94 -31
  85. package/dist/renderables/embed.js +141 -45
  86. package/dist/renderables/group.d.ts +6 -3
  87. package/dist/renderables/group.js +5 -0
  88. package/dist/renderables/layout.d.ts +6 -23
  89. package/dist/renderables/layout.js +24 -113
  90. package/dist/renderables/list-view.d.ts +42 -0
  91. package/dist/renderables/list-view.js +78 -0
  92. package/dist/renderables/live.d.ts +40 -16
  93. package/dist/renderables/live.js +96 -83
  94. package/dist/renderables/markdown.d.ts +16 -2
  95. package/dist/renderables/markdown.js +521 -118
  96. package/dist/renderables/padding.d.ts +22 -1
  97. package/dist/renderables/padding.js +30 -24
  98. package/dist/renderables/panel.d.ts +31 -33
  99. package/dist/renderables/panel.js +137 -141
  100. package/dist/renderables/progress.d.ts +110 -16
  101. package/dist/renderables/progress.js +212 -81
  102. package/dist/renderables/progressBar.d.ts +24 -4
  103. package/dist/renderables/progressBar.js +78 -23
  104. package/dist/renderables/prompt.d.ts +37 -15
  105. package/dist/renderables/prompt.js +125 -85
  106. package/dist/renderables/ratio.d.ts +123 -0
  107. package/dist/renderables/ratio.js +213 -0
  108. package/dist/renderables/rule.d.ts +3 -2
  109. package/dist/renderables/rule.js +50 -52
  110. package/dist/renderables/spinner.d.ts +28 -2
  111. package/dist/renderables/spinner.js +52 -19
  112. package/dist/renderables/status.d.ts +13 -7
  113. package/dist/renderables/status.js +15 -29
  114. package/dist/renderables/syntax.d.ts +37 -7
  115. package/dist/renderables/syntax.js +134 -83
  116. package/dist/renderables/table.d.ts +58 -20
  117. package/dist/renderables/table.js +373 -197
  118. package/dist/renderables/traceback.d.ts +5 -4
  119. package/dist/renderables/traceback.js +239 -68
  120. package/dist/renderables/tree.d.ts +10 -2
  121. package/dist/renderables/tree.js +61 -34
  122. package/dist/renderables/viewport.d.ts +30 -0
  123. package/dist/renderables/viewport.js +61 -7
  124. package/dist/template-bindings/color-funcs.d.ts +31 -9
  125. package/dist/template-bindings/color-funcs.js +40 -14
  126. package/dist/template-bindings/helpers.d.ts +4 -2
  127. package/dist/template-bindings/helpers.js +5 -20
  128. package/dist/template-bindings/index.d.ts +29 -17
  129. package/dist/template-bindings/index.js +36 -20
  130. package/dist/template-bindings/palette-funcs.d.ts +3 -1
  131. package/dist/template-bindings/palette-funcs.js +11 -7
  132. package/dist/template-bindings/style-funcs.js +4 -2
  133. package/dist/themes/buildPalette.d.ts +24 -3
  134. package/dist/themes/buildPalette.js +52 -8
  135. package/dist/themes/colorMath.d.ts +29 -17
  136. package/dist/themes/colorMath.js +74 -48
  137. package/dist/themes/colorRef.d.ts +2 -17
  138. package/dist/themes/colorRef.js +13 -31
  139. package/dist/themes/palette.d.ts +24 -0
  140. package/dist/themes/palette.js +46 -3
  141. package/dist/themes/ramp.d.ts +21 -34
  142. package/dist/themes/ramp.js +23 -42
  143. package/dist/themes/registry.js +39 -28
  144. package/dist/widgets/button.d.ts +2 -6
  145. package/dist/widgets/button.js +9 -40
  146. package/dist/widgets/checkbox.d.ts +2 -5
  147. package/dist/widgets/checkbox.js +9 -31
  148. package/dist/widgets/dropdown.d.ts +2 -5
  149. package/dist/widgets/dropdown.js +17 -51
  150. package/dist/widgets/event-router.d.ts +31 -40
  151. package/dist/widgets/event-router.js +52 -483
  152. package/dist/widgets/index.d.ts +2 -1
  153. package/dist/widgets/ink.d.ts +22 -0
  154. package/dist/widgets/ink.js +31 -0
  155. package/dist/widgets/slider.d.ts +2 -5
  156. package/dist/widgets/slider.js +11 -34
  157. package/dist/widgets/text-input.d.ts +19 -10
  158. package/dist/widgets/text-input.js +156 -147
  159. package/dist/widgets/themed-widget.d.ts +20 -0
  160. package/dist/widgets/themed-widget.js +89 -0
  161. package/dist/widgets/toggle.d.ts +2 -5
  162. package/dist/widgets/toggle.js +8 -31
  163. package/dist/widgets/types.d.ts +8 -25
  164. package/dist/widgets/types.js +8 -0
  165. package/dist/widgets/widget-app.d.ts +24 -7
  166. package/dist/widgets/widget-app.js +35 -18
  167. package/dist/widgets/widget-base.d.ts +2 -0
  168. package/dist/widgets/widget-base.js +9 -2
  169. package/package.json +31 -17
@@ -1,10 +1,12 @@
1
1
  import { blendRgb } from "../core/color.js";
2
- import { alphaBlend, contrastFor } from "./colorMath.js";
3
- import { Palette } from "./palette.js";
2
+ import { alphaBlend, contrastFor, ensureContrastToward } from "./colorMath.js";
3
+ import { Palette, drawnOn } from "./palette.js";
4
4
  const MUTED_BLEND = 0.7;
5
5
  const TEXT_ALPHA = 0.66;
6
6
  const SURFACE_LIFT = 0.05;
7
- const ACCENT_KEYS = ["primary", "secondary", "accent", "success", "warning", "error"];
7
+ // WCAG AA for body text: `text-*` is the label drawn on `*-muted`.
8
+ const TEXT_ON_MUTED = 4.5;
9
+ export const ACCENT_KEYS = ["primary", "secondary", "accent", "success", "warning", "error"];
8
10
  /**
9
11
  * Build a full semantic palette from base colors.
10
12
  *
@@ -16,15 +18,41 @@ const ACCENT_KEYS = ["primary", "secondary", "accent", "success", "warning", "er
16
18
  * de-emphasizing body/structural text reaches for
17
19
  * `foreground-muted` the same way it reaches for
18
20
  * `primary-muted` to de-emphasize an accent.
19
- * - `text-*` = contrast text tinted 66% with the accent color (use as
20
- * foreground in muted/background-tinted contexts)
21
+ * - `text-*` = contrast text tinted 66% with the accent color, then held
22
+ * to WCAG AA (4.5:1) on `*-muted` — the foreground for a
23
+ * `*-muted` ground
21
24
  * - `on-*` = WCAG-correct contrast colour (black or white) for use as
22
25
  * foreground when the FULL accent is the background. Picked
23
26
  * by relative luminance — single source of truth so widgets
24
27
  * never need to invert / reverse fg/bg to get readable text.
25
28
  * - `surface` = background blended 5% toward foreground
29
+ *
30
+ * A translucent base colour is drawn onto `background` before anything is
31
+ * derived from it, so `on-*` is chosen against the colour the accent is drawn
32
+ * as and every derived entry is opaque.
26
33
  */
27
- export function buildPalette(name, dark, base) {
34
+ export function buildPalette(name, dark, given) {
35
+ const drawn = drawnOn(name, given.background);
36
+ // Every base colour is derived from, and a fully transparent one draws no
37
+ // colour of its own to derive a muted shade or a contrast from.
38
+ // [LAW:no-silent-failure]
39
+ const draw = (key) => {
40
+ const colour = given[key];
41
+ if (colour.alpha === 0) {
42
+ throw new RangeError(`palette ${JSON.stringify(name)}: ${key} is fully transparent (${colour.hex}), so there is no colour to derive the palette from`);
43
+ }
44
+ return drawn(colour);
45
+ };
46
+ const base = {
47
+ primary: draw("primary"),
48
+ secondary: draw("secondary"),
49
+ accent: draw("accent"),
50
+ success: draw("success"),
51
+ warning: draw("warning"),
52
+ error: draw("error"),
53
+ background: given.background,
54
+ foreground: draw("foreground"),
55
+ };
28
56
  const vars = new Map();
29
57
  // Base entries
30
58
  vars.set("background", base.background);
@@ -48,9 +76,25 @@ export function buildPalette(name, dark, base) {
48
76
  const contrastText = contrastFor(base.background);
49
77
  for (const key of ACCENT_KEYS) {
50
78
  const color = base[key];
51
- vars.set(`${key}-muted`, blendRgb(color, base.background, MUTED_BLEND));
52
- vars.set(`text-${key}`, alphaBlend(color, contrastText, TEXT_ALPHA));
79
+ const muted = blendRgb(color, base.background, MUTED_BLEND);
80
+ vars.set(`${key}-muted`, muted);
81
+ vars.set(`text-${key}`, accentText(alphaBlend(color, contrastText, TEXT_ALPHA), muted, base.background));
53
82
  vars.set(`on-${key}`, contrastFor(color));
54
83
  }
55
84
  return new Palette(name, dark, vars);
56
85
  }
86
+ /**
87
+ * A palette's `text-*`: its tint held to WCAG AA on `*-muted`, moved toward
88
+ * the palette's own text side so it also reads on `background`, where widgets
89
+ * draw it too. The muted floor is the promise: where no lightness on the text
90
+ * side clears it, the colour that does is on the other side, and the
91
+ * background pair is the one given up. Every bundled palette clears both
92
+ * (test/themes/text-on-muted.test.ts).
93
+ *
94
+ * [LAW:single-enforcer] The one rule for the pair, for the palettes derived
95
+ * here and the authored ones the registry hydrates: the tint is the theme's,
96
+ * whether it reads is decided here.
97
+ */
98
+ export function accentText(tint, muted, background) {
99
+ return ensureContrastToward(tint, muted, TEXT_ON_MUTED, contrastFor(background));
100
+ }
@@ -1,4 +1,4 @@
1
- import { ColorDepth, ColorRgba, contrastRatio, relativeLuminance } from "../core/color.js";
1
+ import { ColorDepth, ColorRgba, contrastRatio, relativeLuminance, type TerminalTheme } from "../core/color.js";
2
2
  /**
3
3
  * Darken a color by N levels, where each level reduces HSL lightness by 10%.
4
4
  * Negative levels lighten. Level 0 returns an equivalent triplet (after the
@@ -44,10 +44,11 @@ export { relativeLuminance, contrastRatio };
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
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.
47
+ * each index in its own theme's colour, so the ratio is measured on
48
+ * `terminal`'s sixteen: a theme whose black is light and white dark (Rosé Pine
49
+ * Dawn) turns the nominal table's sides over. A replacement is the colour the
50
+ * writer emits as the index nearest `fg` that clears the floor as `terminal`
51
+ * draws it. Truecolor draws the colour chosen.
51
52
  *
52
53
  * [LAW:single-enforcer] The one place "is this text readable, and if not fix
53
54
  * it" is decided. Callers route every fg/bg pair through here and the
@@ -56,14 +57,15 @@ export { relativeLuminance, contrastRatio };
56
57
  * lightness moves — there is no caller-side "should I check contrast" branch.
57
58
  */
58
59
  export declare function ensureContrast(fg: ColorRgba, bg: ColorRgba, minRatio?: number, // WCAG AA for normal text
59
- drawnAt?: ColorDepth, substrate?: ColorRgba): ColorRgba;
60
+ drawnAt?: ColorDepth, substrate?: ColorRgba, terminal?: TerminalTheme): ColorRgba;
60
61
  /**
61
62
  * `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
63
+ * terminal draws at rounds it to a colour `accept` refuses — the colour the
64
+ * writer emits as the nearest entry `accept` takes. `accept` sees the
65
+ * candidate as drawn, and `drawn`, the same rounding for any other colour it
66
+ * measures against, so the caller states a floor once and it holds on the
67
+ * colours the terminal shows — at ANSI, `terminal`'s own sixteen, as
68
+ * `ensureContrast` measures there. Truecolor
67
69
  * draws from no table, so a colour refused there has no replacement;
68
70
  * `undefined` means nothing the depth draws is accepted.
69
71
  *
@@ -71,12 +73,22 @@ drawnAt?: ColorDepth, substrate?: ColorRgba): ColorRgba;
71
73
  * floor that is not text on its background (an open state standing off every
72
74
  * closed cell, two planes standing off each other).
73
75
  */
74
- export declare function ensureDrawn(chosen: ColorRgba, drawnAt: ColorDepth, accept: (candidate: ColorRgba, drawn: (c: ColorRgba) => ColorRgba) => boolean, substrate?: ColorRgba): ColorRgba | undefined;
76
+ export declare function ensureDrawn(chosen: ColorRgba, drawnAt: ColorDepth, accept: (candidate: ColorRgba, drawn: (c: ColorRgba) => ColorRgba) => boolean, substrate?: ColorRgba, terminal?: TerminalTheme): ColorRgba | undefined;
75
77
  /**
76
78
  * 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.
79
+ * (the SGR writer's black by default), rounded to the index the writer emits
80
+ * at that depth, and shown as the terminal shows that index — at ANSI, in
81
+ * `terminal`'s own colour. The one account `ensureDrawn` and `ensureContrast`
82
+ * measure by, for a caller that measures a floor of its own.
81
83
  */
82
- export declare function drawnColour(colour: ColorRgba, drawnAt: ColorDepth, substrate?: ColorRgba): ColorRgba;
84
+ export declare function drawnColour(colour: ColorRgba, drawnAt: ColorDepth, substrate?: ColorRgba, terminal?: TerminalTheme): ColorRgba;
85
+ /**
86
+ * `fg` held to `minRatio` on `bg` in truecolor, moving in lightness toward
87
+ * `toward` (black or white). `ensureContrast` moves toward the pole `bg`
88
+ * itself contrasts with; a colour that already has a side (a palette's
89
+ * `text-*` is its text, tinted) passes that side, since on a ground near the
90
+ * 0.179 cutoff both poles clear and only one also reads on everything else
91
+ * the colour is drawn on. Where `toward` cannot clear `minRatio` on `bg`,
92
+ * the floor wins over the side: the answer is `contrastFor(bg)`.
93
+ */
94
+ export declare function ensureContrastToward(fg: ColorRgba, bg: ColorRgba, minRatio: number, toward: ColorRgba): ColorRgba;
@@ -1,4 +1,4 @@
1
- import { ColorDepth, ColorRgba, EIGHT_BIT_DOWNGRADE_TABLE, STANDARD_TABLE, blendRgb, contrastRatio, relativeLuminance, SURFACE_BLACK, } from "../core/color.js";
1
+ import { ColorDepth, ColorRgba, ColorSpec, blendRgb, contrastRatio, downgradeTable, relativeLuminance, resolveTerminal, 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) {
@@ -121,10 +121,11 @@ const CONTRAST_ITERS = 20;
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
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.
124
+ * each index in its own theme's colour, so the ratio is measured on
125
+ * `terminal`'s sixteen: a theme whose black is light and white dark (Rosé Pine
126
+ * Dawn) turns the nominal table's sides over. A replacement is the colour the
127
+ * writer emits as the index nearest `fg` that clears the floor as `terminal`
128
+ * draws it. Truecolor draws the colour chosen.
128
129
  *
129
130
  * [LAW:single-enforcer] The one place "is this text readable, and if not fix
130
131
  * it" is decided. Callers route every fg/bg pair through here and the
@@ -133,26 +134,27 @@ const CONTRAST_ITERS = 20;
133
134
  * lightness moves — there is no caller-side "should I check contrast" branch.
134
135
  */
135
136
  export function ensureContrast(fg, bg, minRatio = 4.5, // WCAG AA for normal text
136
- drawnAt = ColorDepth.TRUECOLOR, substrate = SURFACE_BLACK) {
137
+ drawnAt = ColorDepth.TRUECOLOR, substrate = SURFACE_BLACK, terminal) {
137
138
  const ground = drawnBackground(bg, substrate);
138
- const chosen = ensureTruecolorContrast(fg, ground, minRatio);
139
+ const chosen = ensureContrastToward(fg, ground, minRatio, contrastFor(ground));
139
140
  // [LAW:dataflow-not-control-flow] The depth names the table the terminal
140
141
  // draws from; every table is measured the same way.
141
- const table = DRAWN_FROM[drawnAt];
142
+ const table = drawnTable(drawnAt, terminal);
142
143
  if (table === undefined)
143
144
  return chosen;
144
- const drawnBg = drawnColour(ground, drawnAt, substrate);
145
- if (contrastRatio(drawnColour(chosen, drawnAt, substrate), drawnBg) >= minRatio)
145
+ const shownBg = shownAs(table, ground);
146
+ if (contrastRatio(shownAs(table, chosen), shownBg) >= minRatio)
146
147
  return chosen;
147
- return table.get(table.matchReadable(chosen, drawnBg, minRatio));
148
+ return table.written.get(table.shown.matchReadable(fg.compositeOver(ground), shownBg, minRatio));
148
149
  }
149
150
  /**
150
151
  * `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
152
+ * terminal draws at rounds it to a colour `accept` refuses — the colour the
153
+ * writer emits as the nearest entry `accept` takes. `accept` sees the
154
+ * candidate as drawn, and `drawn`, the same rounding for any other colour it
155
+ * measures against, so the caller states a floor once and it holds on the
156
+ * colours the terminal shows — at ANSI, `terminal`'s own sixteen, as
157
+ * `ensureContrast` measures there. Truecolor
156
158
  * draws from no table, so a colour refused there has no replacement;
157
159
  * `undefined` means nothing the depth draws is accepted.
158
160
  *
@@ -160,38 +162,45 @@ drawnAt = ColorDepth.TRUECOLOR, substrate = SURFACE_BLACK) {
160
162
  * floor that is not text on its background (an open state standing off every
161
163
  * closed cell, two planes standing off each other).
162
164
  */
163
- export function ensureDrawn(chosen, drawnAt, accept, substrate = SURFACE_BLACK) {
165
+ export function ensureDrawn(chosen, drawnAt, accept, substrate = SURFACE_BLACK, terminal) {
164
166
  // The floor was measured on `chosen` composited over `substrate`, so that
165
167
  // opaque colour is what is returned — never a translucent one a different
166
168
  // ground would composite into a colour `accept` never saw.
167
169
  const opaque = drawnBackground(chosen, substrate);
168
- const drawn = (c) => drawnColour(c, drawnAt, substrate);
170
+ const table = drawnTable(drawnAt, terminal);
171
+ const drawn = (c) => shownAs(table, drawnBackground(c, substrate));
169
172
  if (accept(drawn(opaque), drawn))
170
173
  return opaque;
171
174
  // [LAW:dataflow-not-control-flow] Truecolor draws from no table, so it has
172
175
  // 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
+ const index = table?.shown.matchWhere(opaque, (entry) => accept(entry, drawn));
177
+ return index === undefined ? undefined : table.written.get(index);
176
178
  }
177
179
  /**
178
180
  * 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.
181
+ * (the SGR writer's black by default), rounded to the index the writer emits
182
+ * at that depth, and shown as the terminal shows that index — at ANSI, in
183
+ * `terminal`'s own colour. The one account `ensureDrawn` and `ensureContrast`
184
+ * measure by, for a caller that measures a floor of its own.
183
185
  */
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));
186
+ export function drawnColour(colour, drawnAt, substrate = SURFACE_BLACK, terminal) {
187
+ return shownAs(drawnTable(drawnAt, terminal), drawnBackground(colour, substrate));
188
+ }
189
+ /**
190
+ * An opaque colour as the terminal shows it: downgraded to the index the
191
+ * writer emits and drawn as the terminal draws that index.
192
+ * [LAW:one-source-of-truth] That rounding is `ColorSpec.downgrade`'s, the one
193
+ * `Style.drawnColors` writes; the tables here only list the candidates.
194
+ */
195
+ function shownAs(table, opaque) {
196
+ return table === undefined ? opaque : ColorSpec.fromRgba(opaque).downgrade(table.depth).getTruecolor(table.terminal);
188
197
  }
189
198
  /**
190
199
  * A background as it is drawn: composited over the surface beneath it. That
191
200
  * 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.
201
+ * the SGR writer (`Style.toSgrCodes`) and the exports that picture its output
202
+ * composite over `SURFACE_BLACK`, the default here; a caller that composites
203
+ * its colours onto a different surface itself names that one.
195
204
  * [LAW:no-silent-failure] A surface has nothing under it, so a translucent one
196
205
  * has no drawn colour to offer; `compositeOver` would read its raw RGB as if
197
206
  * it were opaque, so it is refused here rather than measured wrong.
@@ -207,28 +216,45 @@ function drawnBackground(bg, substrate) {
207
216
  return bg.compositeOver(substrate);
208
217
  }
209
218
  /**
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.
219
+ * The table the terminal draws from at `drawnAt`. The 256-colour cube and
220
+ * grey ramp are fixed by xterm, so the colour written is the colour shown.
221
+ * ANSI 0–15 are the terminal theme's own: the writer still picks the index by
222
+ * the nominal table — the Windows console's at WINDOWS — and the terminal
223
+ * shows `terminal`'s colour there.
224
+ * Truecolor draws the chosen colour itself, as do the depths no table stands
225
+ * behind.
226
+ */
227
+ function drawnTable(drawnAt, terminal) {
228
+ const drawnBy = resolveTerminal(terminal, drawnAt);
229
+ switch (drawnAt) {
230
+ case ColorDepth.EIGHT_BIT:
231
+ return { depth: drawnAt, terminal: drawnBy, written: downgradeTable(drawnAt), shown: downgradeTable(drawnAt) };
232
+ case ColorDepth.STANDARD:
233
+ case ColorDepth.WINDOWS:
234
+ return { depth: drawnAt, terminal: drawnBy, written: downgradeTable(drawnAt), shown: drawnBy.ansiColors };
235
+ case ColorDepth.TRUECOLOR:
236
+ case ColorDepth.DEFAULT:
237
+ return undefined;
238
+ }
239
+ }
240
+ /**
241
+ * `fg` held to `minRatio` on `bg` in truecolor, moving in lightness toward
242
+ * `toward` (black or white). `ensureContrast` moves toward the pole `bg`
243
+ * itself contrasts with; a colour that already has a side (a palette's
244
+ * `text-*` is its text, tinted) passes that side, since on a ground near the
245
+ * 0.179 cutoff both poles clear and only one also reads on everything else
246
+ * the colour is drawn on. Where `toward` cannot clear `minRatio` on `bg`,
247
+ * the floor wins over the side: the answer is `contrastFor(bg)`.
217
248
  */
218
- const DRAWN_FROM = {
219
- [ColorDepth.EIGHT_BIT]: EIGHT_BIT_DOWNGRADE_TABLE,
220
- [ColorDepth.STANDARD]: STANDARD_TABLE,
221
- };
222
- function ensureTruecolorContrast(fg, bg, minRatio) {
249
+ export function ensureContrastToward(fg, bg, minRatio, toward) {
223
250
  // Flatten translucency so the guarantee holds for the displayed color, not
224
- // the raw bytes (e.g. a "#FFFFFF60" text-disabled over a light surface).
251
+ // the raw bytes (e.g. a hand-written "#FFFFFF60" over a light surface).
225
252
  const opaqueFg = fg.compositeOver(bg);
226
253
  if (contrastRatio(opaqueFg, bg) >= minRatio)
227
254
  return opaqueFg;
228
255
  const lab = Oklch.fromRgba(opaqueFg);
229
- // The pole that increases contrast: lighten toward white on a dark bg, darken
230
- // toward black on a light one. `contrastFor`'s 0.179 cutoff names it.
231
- const poleL = relativeLuminance(bg) > 0.179 ? 0 : 1;
256
+ // `toward` is black or white, the pole the lightness moves to.
257
+ const poleL = relativeLuminance(toward) > 0.179 ? 1 : 0;
232
258
  // If even the pole of this hue can't reach the ratio, the hue physically
233
259
  // can't — return the true maximum-contrast pick (pure black/white from
234
260
  // contrastFor). The gamut-clamped OKLCH pole is only *near* b/w, so
@@ -1,4 +1,4 @@
1
- import { ColorRgba } from "../core/color.js";
1
+ import { type ColorRgba } from "../core/color.js";
2
2
  import type { Palette } from "./palette.js";
3
3
  /**
4
4
  * A **color reference** is the one string form an author uses to name a
@@ -18,23 +18,8 @@ import type { Palette } from "./palette.js";
18
18
  */
19
19
  export declare class ColorRefError extends Error {
20
20
  readonly ref: string;
21
- constructor(ref: string, detail: string);
21
+ constructor(ref: string, detail: string, options?: ErrorOptions);
22
22
  }
23
- /**
24
- * The literal-color shape, exported so the template bindings gate on the same
25
- * pattern this module parses. [LAW:one-source-of-truth] — one regex, one
26
- * parser body; the bindings add only their own error wording.
27
- */
28
- export declare const HEX_COLOR_RE: RegExp;
29
- /**
30
- * Parse a `#RRGGBB` / `#RRGGBBAA` literal. The hex arm of
31
- * {@link resolveColorRef}, separated so callers that accept *only* literal
32
- * colors (color math, which cannot meaningfully operate on a palette name)
33
- * share the one implementation instead of re-deriving it.
34
- *
35
- * @throws {ColorRefError} when `hex` is not a well-formed literal.
36
- */
37
- export declare function parseHexColor(hex: string): ColorRgba;
38
23
  /**
39
24
  * Resolve a color reference against `palette`.
40
25
  *
@@ -1,4 +1,4 @@
1
- import { parseRgbHex, parseRgbaHex } from "../core/color.js";
1
+ import { ColorParseError, parseHexColor } from "../core/color.js";
2
2
  /**
3
3
  * A **color reference** is the one string form an author uses to name a
4
4
  * concrete color: a palette variable name (`"primary"`, `"surface-active"`,
@@ -17,8 +17,8 @@ import { parseRgbHex, parseRgbaHex } from "../core/color.js";
17
17
  */
18
18
  export class ColorRefError extends Error {
19
19
  ref;
20
- constructor(ref, detail) {
21
- super(`color reference ${JSON.stringify(ref)} did not resolve — ${detail}`);
20
+ constructor(ref, detail, options) {
21
+ super(`color reference ${JSON.stringify(ref)} did not resolve — ${detail}`, options);
22
22
  this.ref = ref;
23
23
  this.name = "ColorRefError";
24
24
  }
@@ -30,32 +30,6 @@ export class ColorRefError extends Error {
30
30
  // palette lookup that would report a confusing "unknown name" instead of
31
31
  // "malformed hex".
32
32
  const HEX_LEAD = "#";
33
- /**
34
- * The literal-color shape, exported so the template bindings gate on the same
35
- * pattern this module parses. [LAW:one-source-of-truth] — one regex, one
36
- * parser body; the bindings add only their own error wording.
37
- */
38
- export const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
39
- const OPAQUE_HEX_LENGTH = 7; // "#RRGGBB"
40
- /**
41
- * Parse a `#RRGGBB` / `#RRGGBBAA` literal. The hex arm of
42
- * {@link resolveColorRef}, separated so callers that accept *only* literal
43
- * colors (color math, which cannot meaningfully operate on a palette name)
44
- * share the one implementation instead of re-deriving it.
45
- *
46
- * @throws {ColorRefError} when `hex` is not a well-formed literal.
47
- */
48
- export function parseHexColor(hex) {
49
- const trimmed = hex.trim();
50
- if (!HEX_COLOR_RE.test(trimmed)) {
51
- throw new ColorRefError(hex, "expected #RRGGBB or #RRGGBBAA");
52
- }
53
- // Both parsers take the digits without the leading `#`.
54
- const digits = trimmed.slice(1);
55
- return trimmed.length > OPAQUE_HEX_LENGTH
56
- ? parseRgbaHex(digits)
57
- : parseRgbHex(digits);
58
- }
59
33
  // Cap the "did you mean" list. Palettes carry ~150 variables; dumping all of
60
34
  // them buries the message. Nearby names are what a mistyped reference needs.
61
35
  const SUGGESTION_LIMIT = 8;
@@ -74,8 +48,16 @@ const SUGGESTION_LIMIT = 8;
74
48
  */
75
49
  export function resolveColorRef(palette, ref) {
76
50
  const trimmed = ref.trim();
77
- if (trimmed.startsWith(HEX_LEAD))
78
- return parseHexColor(trimmed);
51
+ if (trimmed.startsWith(HEX_LEAD)) {
52
+ try {
53
+ return parseHexColor(trimmed);
54
+ }
55
+ catch (cause) {
56
+ if (!(cause instanceof ColorParseError))
57
+ throw cause;
58
+ throw new ColorRefError(ref, cause.message, { cause });
59
+ }
60
+ }
79
61
  const hit = palette.get(trimmed);
80
62
  if (hit === undefined) {
81
63
  throw new ColorRefError(ref, `no such variable in palette ${JSON.stringify(palette.name)}` +
@@ -11,6 +11,22 @@ import type { ColorRgba } from "../core/color.js";
11
11
  * author-written *reference* (a name, or a `#RRGGBB` literal) into a colour
12
12
  * is `resolveColorRef`'s job in `colorRef.ts`, so a Palette never has to
13
13
  * know about the syntax callers write.
14
+ *
15
+ * Every colour a Palette hands out is opaque or fully transparent. A theme's translucent variable
16
+ * (Textual's `boost`, a hover overlay) is a tint meant for that theme's own
17
+ * background, and a colour leaving the palette no longer knows which
18
+ * background that was: the SGR writer would composite it over the terminal's
19
+ * black, which is not the theme's background, and draw a tint the theme never
20
+ * meant.
21
+ * So it is drawn onto `background` here, once, and the writer, the exporters
22
+ * and the contrast choosers all read the colour it has there. On any other
23
+ * surface it keeps that colour: the tint does not follow what is under it.
24
+ * The exception is a fully transparent colour, Textual's "no background": it
25
+ * draws nothing on any surface, so it needs none and is handed out as it is,
26
+ * and a `Style` reads it as no background at all.
27
+ *
28
+ * @throws RangeError when `background` is translucent, or when a colour is
29
+ * partly transparent and there is no `background` to draw it on.
14
30
  */
15
31
  export declare class Palette {
16
32
  readonly name: string;
@@ -19,3 +35,11 @@ export declare class Palette {
19
35
  constructor(name: string, dark: boolean, vars: ReadonlyMap<string, ColorRgba>);
20
36
  get(key: string): ColorRgba | undefined;
21
37
  }
38
+ /**
39
+ * A palette colour as it is drawn: composited onto the palette's own
40
+ * `background`, which has nothing under it and so must itself be opaque.
41
+ * [LAW:one-source-of-truth] The one statement of what a translucent palette
42
+ * colour means; `buildPalette` draws its base colours through it before
43
+ * deriving from them.
44
+ */
45
+ export declare function drawnOn(name: string, background: ColorRgba): (colour: ColorRgba) => ColorRgba;
@@ -10,6 +10,22 @@
10
10
  * author-written *reference* (a name, or a `#RRGGBB` literal) into a colour
11
11
  * is `resolveColorRef`'s job in `colorRef.ts`, so a Palette never has to
12
12
  * know about the syntax callers write.
13
+ *
14
+ * Every colour a Palette hands out is opaque or fully transparent. A theme's translucent variable
15
+ * (Textual's `boost`, a hover overlay) is a tint meant for that theme's own
16
+ * background, and a colour leaving the palette no longer knows which
17
+ * background that was: the SGR writer would composite it over the terminal's
18
+ * black, which is not the theme's background, and draw a tint the theme never
19
+ * meant.
20
+ * So it is drawn onto `background` here, once, and the writer, the exporters
21
+ * and the contrast choosers all read the colour it has there. On any other
22
+ * surface it keeps that colour: the tint does not follow what is under it.
23
+ * The exception is a fully transparent colour, Textual's "no background": it
24
+ * draws nothing on any surface, so it needs none and is handed out as it is,
25
+ * and a `Style` reads it as no background at all.
26
+ *
27
+ * @throws RangeError when `background` is translucent, or when a colour is
28
+ * partly transparent and there is no `background` to draw it on.
13
29
  */
14
30
  export class Palette {
15
31
  name;
@@ -18,11 +34,38 @@ export class Palette {
18
34
  constructor(name, dark, vars) {
19
35
  this.name = name;
20
36
  this.dark = dark;
21
- // Defensive copy: ReadonlyMap is a compile-time aliasing constraint, not a
22
- // runtime one. Copying at the seam makes Palette genuinely immutable.
23
- this.vars = new Map(vars);
37
+ // [LAW:single-enforcer] Every palette is built through here, so this is
38
+ // where "a palette colour is opaque or draws nothing" holds. The fresh map is also the
39
+ // defensive copy: ReadonlyMap is a compile-time aliasing constraint, not a
40
+ // runtime one.
41
+ const background = vars.get("background");
42
+ const draw = background === undefined ? opaqueOnly(name) : drawnOn(name, background);
43
+ this.vars = new Map([...vars].map(([key, colour]) => [key, draw(colour, key)]));
24
44
  }
25
45
  get(key) {
26
46
  return this.vars.get(key);
27
47
  }
28
48
  }
49
+ /**
50
+ * A palette colour as it is drawn: composited onto the palette's own
51
+ * `background`, which has nothing under it and so must itself be opaque.
52
+ * [LAW:one-source-of-truth] The one statement of what a translucent palette
53
+ * colour means; `buildPalette` draws its base colours through it before
54
+ * deriving from them.
55
+ */
56
+ export function drawnOn(name, background) {
57
+ if (background.alpha !== 1) {
58
+ throw new RangeError(`palette ${JSON.stringify(name)}: background is the surface its translucent colours are drawn on, so it must be opaque; got ${background.hex}`);
59
+ }
60
+ // A fully transparent colour is not a tint: it draws nothing on any surface.
61
+ return (colour) => (colour.alpha === 0 ? colour : colour.compositeOver(background));
62
+ }
63
+ // A palette with no background has nothing to draw a translucent colour on.
64
+ function opaqueOnly(name) {
65
+ return (colour, key) => {
66
+ if (colour.alpha !== 1 && colour.alpha !== 0) {
67
+ throw new RangeError(`palette ${JSON.stringify(name)}: ${key} is translucent (${colour.hex}) but the palette has no "background" to draw it on`);
68
+ }
69
+ return colour;
70
+ };
71
+ }
@@ -9,46 +9,28 @@
9
9
  * can no longer transpose with the palette it was meant to belong to. A ramp
10
10
  * is the one function whose input is a number, so the decision stays inside.
11
11
  *
12
- * A threshold cascade is a ramp too: the same stops with a `step` easing hold
12
+ * A threshold cascade is a ramp too: the same stops with the `step` ease hold
13
13
  * each color until the next position, which is exactly `≥ threshold → hotter`
14
14
  * written as data. [LAW:one-type-per-behavior] One primitive; the easing is a
15
- * value, so a gradient and a cascade differ by one word, not by which function
16
- * was called.
15
+ * value — any `Ease` from `core/easing`, the vocabulary every effect shares —
16
+ * so a gradient and a cascade differ by one word, not by which function was
17
+ * called. [LAW:dataflow-not-control-flow] `at` applies it unconditionally.
17
18
  *
18
19
  * Interpolation is in OKLCH (`Oklch.mix`), so a `linear` ramp between two
19
20
  * theme colors passes through perceptually even steps rather than the muddy
20
21
  * midpoints of an sRGB blend.
21
22
  *
22
- * [LAW:one-way-deps] Imports `core/color` and `core/oklch` only; nothing here
23
- * knows what a palette is. Resolving stop *names* is the template binding's
23
+ * [LAW:one-way-deps] Imports `core/color`, `core/easing` and `core/oklch`
24
+ * only; nothing here knows what a palette is. Resolving stop *names* is the template binding's
24
25
  * job (`paletteFuncs`), which hands this module resolved colors.
25
26
  */
26
27
  import type { ColorRgba } from "../core/color.js";
28
+ import type { Ease } from "../core/easing.js";
27
29
  /** One stop: the color the ramp is exactly `color` at position `at`. */
28
30
  export interface ColorStop {
29
31
  readonly at: number;
30
32
  readonly color: ColorRgba;
31
33
  }
32
- /**
33
- * How a value between two stops maps to progress along them. Each easing is
34
- * a function on segment progress `t ∈ [0, 1)` — `linear` keeps it, `step`
35
- * holds the left stop for the whole segment.
36
- *
37
- * [LAW:dataflow-not-control-flow] The easing is looked up by name and applied
38
- * unconditionally; `at` runs the same code for a gradient and a cascade.
39
- */
40
- export declare const RAMP_EASINGS: {
41
- readonly linear: (t: number) => number;
42
- readonly step: () => number;
43
- };
44
- export type RampEasing = keyof typeof RAMP_EASINGS;
45
- export declare const RAMP_EASING_NAMES: readonly RampEasing[];
46
- /**
47
- * The gate a spelled easing crosses. [LAW:parse-dont-validate] — returns the
48
- * narrowed name, so `ColorRamp` never re-checks. Unknown names throw naming
49
- * every legal one. [LAW:no-silent-failure]
50
- */
51
- export declare function parseRampEasing(name: string): RampEasing;
52
34
  /**
53
35
  * An immutable ramp over stops sorted by position.
54
36
  *
@@ -65,19 +47,24 @@ export declare function parseRampEasing(name: string): RampEasing;
65
47
  * same way).
66
48
  */
67
49
  export declare class ColorRamp {
68
- readonly easing: RampEasing;
50
+ readonly ease: Ease;
69
51
  readonly stops: readonly ColorStop[];
70
- constructor(easing: RampEasing, stops: readonly ColorStop[]);
52
+ constructor(ease: Ease, stops: readonly ColorStop[]);
71
53
  /**
72
54
  * The color at `value`. Below the first stop it is the first color; at or
73
- * above the last stop it is the last; between two stops it is `easing`
74
- * of the way from the lower to the upper, so a value exactly on a stop is
75
- * that stop's color, byte for byte.
55
+ * above the last stop it is the last; between two stops the raw progress
56
+ * from the lower to the upper goes through `ease`, and the eased progress
57
+ * says how far to mix.
76
58
  *
77
- * The two endpoint returns are the exactness contract, not a shortcut:
78
- * the sRGB → OKLCH → sRGB round-trip can land a channel one unit off, and a
79
- * ramp that does not hit its own stops exactly would make a `step` ramp
80
- * paint a color the author never wrote.
59
+ * Eased progress 0 is the lower stop's color and 1 the upper's, byte for
60
+ * byte — the exactness contract, not a shortcut: the sRGB → OKLCH → sRGB
61
+ * round-trip can land a channel one unit off, and a ramp that did not hit
62
+ * its own stops exactly would make a `step` ramp paint a color the author
63
+ * never wrote. So a value on a stop is that stop's color whenever the ease
64
+ * starts at 0, as every built-in does but `step-start` and the
65
+ * `jump-start`/`jump-both` steps, which jump at the start of the interval as
66
+ * CSS defines them. An ease that overshoots [0, 1] holds the end colors
67
+ * where it does: a ramp never paints outside its stops.
81
68
  */
82
69
  at(value: number): ColorRgba;
83
70
  }