@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
@@ -26,7 +26,7 @@
26
26
  *
27
27
  * ### The carrier is a hex string, deliberately
28
28
  *
29
- * A color crosses the template seam as `#RRGGBB`, not as an opaque object,
29
+ * A color crosses the template seam as `#RRGGBB` (or `#RRGGBBAA`), not as an opaque object,
30
30
  * for three reasons:
31
31
  *
32
32
  * 1. **The type separates.** `"string"` is the strictest arg slot the template
@@ -55,9 +55,8 @@
55
55
  * from that point on the math is pure. A consumer with no theme system at all still gets
56
56
  * the full color vocabulary by feeding it hex literals. [LAW:one-way-deps]
57
57
  */
58
- import { blendRgb, ColorDepth } from "../core/color.js";
58
+ import { blendRgb, ColorDepth, ColorParseError, parseHexColor } from "../core/color.js";
59
59
  import { Oklch, IDENTITY } from "../core/oklch.js";
60
- import { HEX_COLOR_RE, parseHexColor } from "../themes/colorRef.js";
61
60
  import { darken, contrastFor, ensureContrast } from "../themes/colorMath.js";
62
61
  /**
63
62
  * The gate every color argument crosses. [LAW:parse-dont-validate] — the
@@ -67,15 +66,37 @@ import { darken, contrastFor, ensureContrast } from "../themes/colorMath.js";
67
66
  * its error *wording*, because the mistake it catches is almost always the
68
67
  * same one — a palette name passed where a color belongs (`darken "primary" 2`)
69
68
  * — and an author who reads "wrap it: darken (color "primary")" is corrected in
70
- * one step, where "expected #RRGGBB" leaves them guessing.
69
+ * one step, where "expected #RRGGBB" leaves them guessing. What it parses
70
+ * must then be a colour these functions can compute with (`computable`).
71
71
  * [LAW:no-silent-failure]
72
72
  */
73
73
  function asColor(value, func) {
74
- if (!HEX_COLOR_RE.test(value.trim())) {
74
+ let colour;
75
+ try {
76
+ colour = parseHexColor(value.trim());
77
+ }
78
+ catch (cause) {
79
+ if (!(cause instanceof ColorParseError))
80
+ throw cause;
75
81
  throw new TypeError(`${func} expected a color (#RRGGBB or #RRGGBBAA), got ${JSON.stringify(value)}` +
76
- ` — to use a palette name here, wrap it: ${func} (color ${JSON.stringify(value)}) …`);
82
+ ` — to use a palette name here, wrap it: ${func} (color ${JSON.stringify(value)}) …`, { cause });
83
+ }
84
+ return computable(colour, func, JSON.stringify(value));
85
+ }
86
+ /**
87
+ * A colour the colour functions can compute with. A fully transparent one — a
88
+ * theme's "no background", `#00000000` — has no colour of its own: what shows
89
+ * through it is whatever surface it lands on, which a template cannot know.
90
+ * Darkening, mixing or contrasting it would answer for a surface nobody named,
91
+ * so it is refused here, for every colour function and every `ramp` stop.
92
+ * [LAW:single-enforcer]
93
+ */
94
+ export function computable(colour, func, written) {
95
+ if (colour.alpha === 0) {
96
+ throw new RangeError(`${func} got ${written}, which is fully transparent: it draws no colour of its own, ` +
97
+ `so there is nothing to compute with — use the colour of the surface it sits on, e.g. (color "background")`);
77
98
  }
78
- return parseHexColor(value);
99
+ return colour;
79
100
  }
80
101
  // [LAW:single-enforcer] Every colour function is fixed-arity, so the engine's
81
102
  // arity gate refuses a missing or surplus argument before the body runs.
@@ -145,11 +166,16 @@ const RATIO_RANGE = {
145
166
  note: "4.5 = AA body text, 3 = AA large text",
146
167
  };
147
168
  const contrastOnFunc = colorFunc(["string"], ((bgHex) => contrastFor(asColor(bgHex, "contrastOn")).hex));
169
+ const TRUECOLOR = { depth: ColorDepth.TRUECOLOR };
148
170
  /**
149
- * The `readableOn` binding, measuring at the depth `drawnAt` names when it is
150
- * evaluated — a consumer that renders at a per-call depth registers its own.
171
+ * The `readableOn` binding, measuring where `drawnAt` says the text lands when
172
+ * it is evaluated — a consumer that renders to a per-call destination
173
+ * registers its own.
151
174
  */
152
- export const readableOnFunc = (drawnAt) => colorFunc(["string", "string", "float"], ((fgHex, bgHex, ratio) => ensureContrast(asColor(fgHex, "readableOn"), asColor(bgHex, "readableOn"), asAmount(ratio, "readableOn", "ratio", RATIO_RANGE), drawnAt()).hex));
175
+ export const readableOnFunc = (drawnAt) => colorFunc(["string", "string", "float"], ((fgHex, bgHex, ratio) => {
176
+ const { depth, terminal } = drawnAt();
177
+ return ensureContrast(asColor(fgHex, "readableOn"), asColor(bgHex, "readableOn"), asAmount(ratio, "readableOn", "ratio", RATIO_RANGE), depth, undefined, terminal).hex;
178
+ }));
153
179
  // --- OKLCH axes ---
154
180
  //
155
181
  // [LAW:one-source-of-truth] One function per `ThemeKey` axis, and the table is
@@ -201,11 +227,11 @@ function oklchAxisFuncs() {
201
227
  * Pair with `paletteFuncs()` to name colors from a theme, and with
202
228
  * `richTextStyleFuncs()`'s `fg`/`bg` to paint them onto text.
203
229
  *
204
- * @param drawnAt the depth the terminal will draw at, read on every
205
- * `readableOn` evaluation so a host that learns its depth per render passes
206
- * one getter; defaults to truecolor.
230
+ * @param drawnAt where the text will be drawn — the depth, and the terminal
231
+ * when known — read on every `readableOn` evaluation so a host that learns
232
+ * its destination per render passes one getter; defaults to truecolor.
207
233
  */
208
- export function colorFuncs(drawnAt = () => ColorDepth.TRUECOLOR) {
234
+ export function colorFuncs(drawnAt = () => TRUECOLOR) {
209
235
  return {
210
236
  darken: darkenFunc,
211
237
  lighten: lightenFunc,
@@ -6,7 +6,7 @@
6
6
  * registrations and the palette-function registrations use this same operation;
7
7
  * keeping one copy prevents the two from drifting.
8
8
  */
9
- import { Style } from "../core/style.js";
9
+ import { type Style } from "../core/style.js";
10
10
  import { RichText } from "../core/text.js";
11
11
  /**
12
12
  * Apply a style on top of an already-styled RichText fragment.
@@ -19,6 +19,8 @@ import { RichText } from "../core/text.js";
19
19
  * is the binding's own `T`. Misuse (`{{ red someMap }}`) fails loudly here
20
20
  * rather than producing a malformed fragment.
21
21
  *
22
- * Conflict resolution: `Style.add` — the outer (newly applied) style wins.
22
+ * Conflict resolution: `Style.add` — the outer (newly applied) style wins. A
23
+ * base style that is a name is layered under, not resolved: a template runs
24
+ * before the render whose theme says what the name stands for.
23
25
  */
24
26
  export declare function applyStyleToFragment(child: unknown, style: Style): RichText;
@@ -6,7 +6,7 @@
6
6
  * registrations and the palette-function registrations use this same operation;
7
7
  * keeping one copy prevents the two from drifting.
8
8
  */
9
- import { Style, StyleSyntaxError } from "../core/style.js";
9
+ import { layerStyle } from "../core/style.js";
10
10
  import { RichText } from "../core/text.js";
11
11
  /**
12
12
  * Apply a style on top of an already-styled RichText fragment.
@@ -19,30 +19,15 @@ import { RichText } from "../core/text.js";
19
19
  * is the binding's own `T`. Misuse (`{{ red someMap }}`) fails loudly here
20
20
  * rather than producing a malformed fragment.
21
21
  *
22
- * Conflict resolution: `Style.add` — the outer (newly applied) style wins.
22
+ * Conflict resolution: `Style.add` — the outer (newly applied) style wins. A
23
+ * base style that is a name is layered under, not resolved: a template runs
24
+ * before the render whose theme says what the name stands for.
23
25
  */
24
26
  export function applyStyleToFragment(child, style) {
25
27
  if (!(child instanceof RichText)) {
26
28
  throw new TypeError(`template function expected a RichText fragment, got ${typeof child === "object" ? Object.prototype.toString.call(child) : typeof child}`);
27
29
  }
28
30
  const result = child.copy();
29
- result.style = baseStyleOf(child.style).add(style);
31
+ result.style = layerStyle(child.style, style);
30
32
  return result;
31
33
  }
32
- /**
33
- * A fragment's base style as a `Style`. A string must parse as a definition: a
34
- * name resolves only against the theme of the render that draws it, and a
35
- * template runs before any render exists.
36
- */
37
- function baseStyleOf(style) {
38
- if (style instanceof Style)
39
- return style;
40
- try {
41
- return Style.parse(style);
42
- }
43
- catch (err) {
44
- if (!(err instanceof StyleSyntaxError))
45
- throw err;
46
- throw new TypeError(`template function cannot style a fragment whose base style "${style}" is not a style definition: a style name resolves only against the theme of a render`, { cause: err });
47
- }
48
- }
@@ -36,9 +36,10 @@
36
36
  import { type Engine, type FuncMap } from "@promptctl/go-template-js";
37
37
  import { RichText } from "../core/text.js";
38
38
  import { Segment } from "../core/segment.js";
39
- import { ColorDepth } from "../core/color.js";
39
+ import type { RenderOptions } from "../core/protocol.js";
40
+ import { type DrawnAt } from "./color-funcs.js";
40
41
  export { paletteFuncs } from "./palette-funcs.js";
41
- export { colorFuncs, readableOnFunc } from "./color-funcs.js";
42
+ export { colorFuncs, readableOnFunc, type DrawnAt } from "./color-funcs.js";
42
43
  /**
43
44
  * Funcs registered by the rich-js binding — the colour sinks, the palette-free
44
45
  * colour math, text attributes, and the `link` cell-splitter. Nothing here
@@ -57,7 +58,7 @@ export { colorFuncs, readableOnFunc } from "./color-funcs.js";
57
58
  * into a wider engine must keep `T = RichText`; merging into an engine
58
59
  * whose `T` is something else will compile but fail at evaluation time.
59
60
  */
60
- export declare function richTextFuncs(drawnAt?: () => ColorDepth): FuncMap;
61
+ export declare function richTextFuncs(drawnAt?: () => DrawnAt): FuncMap;
61
62
  /**
62
63
  * Construct an `Engine<RichText>` with the rich-js style-function set
63
64
  * registered. Consumers that already manage their own engine should call
@@ -71,6 +72,18 @@ export declare function richTextFuncs(drawnAt?: () => ColorDepth): FuncMap;
71
72
  * not desired anyway.
72
73
  */
73
74
  export declare function createRichTextEngine(): Engine<RichText>;
75
+ export interface RenderTemplateOptions extends Partial<RenderOptions> {
76
+ /**
77
+ * Defaults to 400 — large enough that downstream `splitLines` /
78
+ * `adjustLineLength` clipping decides actual width, matching the typical
79
+ * "render wide, fit on output" pipeline. The error line is fitted to it too,
80
+ * so only a caller that passes the width it draws at sees a cut error end in
81
+ * `…` rather than be cropped by whatever draws it.
82
+ */
83
+ maxWidth?: number;
84
+ /** The error line's style, a `Style.parse` spec (default `"red dim"`). */
85
+ errorStyle?: string;
86
+ }
74
87
  /**
75
88
  * Compile a template source against `engine` and render the result to a
76
89
  * flat `Segment[]`. The 90%-case convenience over chaining `engine.compile`,
@@ -79,14 +92,20 @@ export declare function createRichTextEngine(): Engine<RichText>;
79
92
  * - Runs `engine.compile(source)(scope)` to get the engine's `RichText[]`.
80
93
  * - Flattens that fragment list into a single styled `RichText` via
81
94
  * `RichText.fromFragments` so every fragment's wrapping style survives.
82
- * - Renders to a `Segment[]` at the requested `maxWidth`.
83
- * - Wraps the whole flow in a try/catch — on parse/evaluate failure,
84
- * emits a single dim styled `[error: <message>]` segment the caller can
85
- * drop into their layout. No bespoke fallback wiring required at every
95
+ * - Renders to a `Segment[]` with the given render options — `maxWidth`,
96
+ * `theme`, `onStyleError` and the rest — as a terminal unless they say not.
97
+ * - On a parse/evaluate failure, emits a single dim styled
98
+ * `[error: <message>]` segment, one line fitted to `maxWidth`, the caller
99
+ * can drop into their layout. No bespoke fallback wiring required at every
86
100
  * call site.
87
101
  *
102
+ * Only compile, evaluate and flatten are inside the `try`: the error line reports a
103
+ * template that failed, nothing else. Whatever the render throws — a style
104
+ * error an `onStyleError` handler rethrew, or a fault in the render itself —
105
+ * leaves this function rather than being folded into an `[error: …]` line.
106
+ *
88
107
  * [LAW:single-enforcer] One place owns "render a template to segments,
89
- * degrade gracefully on errors" — every consumer that wants this exact
108
+ * degrade gracefully when the template fails" — every consumer that wants this exact
90
109
  * shape reads from here rather than re-implementing the same try/catch +
91
110
  * error-formatting glue.
92
111
  *
@@ -95,13 +114,6 @@ export declare function createRichTextEngine(): Engine<RichText>;
95
114
  * and use `RichText.fromFragments` to flatten. This helper is sugar for
96
115
  * the live-render case (e.g. a preview pane), not a replacement for the
97
116
  * compile-once-evaluate-many pattern.
98
- *
99
- * @param maxWidth defaults to 400 — large enough that downstream `splitLines`
100
- * / `adjustLineLength` clipping decides actual width, matching the typical
101
- * "render wide, fit on output" pipeline.
102
- * @param errorStyle is a `Style.parse` spec (default `"red dim"`).
117
+
103
118
  */
104
- export declare function renderTemplate(engine: Engine<RichText>, source: string, scope?: unknown, options?: {
105
- maxWidth?: number;
106
- errorStyle?: string;
107
- }): Segment[];
119
+ export declare function renderTemplate(engine: Engine<RichText>, source: string, scope?: unknown, options?: RenderTemplateOptions): Segment[];
@@ -91,14 +91,20 @@ export function createRichTextEngine() {
91
91
  * - Runs `engine.compile(source)(scope)` to get the engine's `RichText[]`.
92
92
  * - Flattens that fragment list into a single styled `RichText` via
93
93
  * `RichText.fromFragments` so every fragment's wrapping style survives.
94
- * - Renders to a `Segment[]` at the requested `maxWidth`.
95
- * - Wraps the whole flow in a try/catch — on parse/evaluate failure,
96
- * emits a single dim styled `[error: <message>]` segment the caller can
97
- * drop into their layout. No bespoke fallback wiring required at every
94
+ * - Renders to a `Segment[]` with the given render options — `maxWidth`,
95
+ * `theme`, `onStyleError` and the rest — as a terminal unless they say not.
96
+ * - On a parse/evaluate failure, emits a single dim styled
97
+ * `[error: <message>]` segment, one line fitted to `maxWidth`, the caller
98
+ * can drop into their layout. No bespoke fallback wiring required at every
98
99
  * call site.
99
100
  *
101
+ * Only compile, evaluate and flatten are inside the `try`: the error line reports a
102
+ * template that failed, nothing else. Whatever the render throws — a style
103
+ * error an `onStyleError` handler rethrew, or a fault in the render itself —
104
+ * leaves this function rather than being folded into an `[error: …]` line.
105
+ *
100
106
  * [LAW:single-enforcer] One place owns "render a template to segments,
101
- * degrade gracefully on errors" — every consumer that wants this exact
107
+ * degrade gracefully when the template fails" — every consumer that wants this exact
102
108
  * shape reads from here rather than re-implementing the same try/catch +
103
109
  * error-formatting glue.
104
110
  *
@@ -107,27 +113,37 @@ export function createRichTextEngine() {
107
113
  * and use `RichText.fromFragments` to flatten. This helper is sugar for
108
114
  * the live-render case (e.g. a preview pane), not a replacement for the
109
115
  * compile-once-evaluate-many pattern.
110
- *
111
- * @param maxWidth defaults to 400 — large enough that downstream `splitLines`
112
- * / `adjustLineLength` clipping decides actual width, matching the typical
113
- * "render wide, fit on output" pipeline.
114
- * @param errorStyle is a `Style.parse` spec (default `"red dim"`).
116
+
115
117
  */
116
118
  export function renderTemplate(engine, source, scope = {}, options) {
119
+ const { errorStyle, ...renderOptions } = options ?? {};
120
+ const maxWidth = renderOptions.maxWidth ?? 400;
121
+ // Flattening belongs to the template: a scope value the engine passes
122
+ // through unlifted fails here, and that is a template that failed.
123
+ let text;
117
124
  try {
118
- const frags = engine.compile(source)(scope);
119
- const rt = RichText.fromFragments(frags);
120
- return Array.from(rt.render({
121
- maxWidth: options?.maxWidth ?? 400,
122
- isTerminal: true,
123
- }));
125
+ text = RichText.fromFragments(engine.compile(source)(scope));
124
126
  }
125
127
  catch (e) {
126
- // An engine error's `message` is its one-line summary; `String(e)` adds a
127
- // multi-line code frame, which would break this single segment across lines.
128
- const summary = e instanceof Error ? e.message : String(e);
129
- return [new Segment(`[error: ${summary.slice(0, 80)}]`, safeErrorStyle(options?.errorStyle))];
128
+ return [new Segment(errorLine(e, maxWidth), safeErrorStyle(errorStyle))];
130
129
  }
130
+ return Array.from(text.render({ isTerminal: true, ...renderOptions, maxWidth }));
131
+ }
132
+ /**
133
+ * A caught error as the one line `renderTemplate` promises. The summary is the
134
+ * error's `message` — `String(e)` on an engine error appends a multi-line code
135
+ * frame — and even a `message` can carry the character it is complaining about
136
+ * (`bad escape sequence \⏎`). Any whitespace that is not a plain space — a line
137
+ * break, or a tab, whose stop depends on the column the line is set at and so
138
+ * cannot be measured here — folds, with the spaces around it, to one space.
139
+ *
140
+ * [LAW:one-source-of-truth] The line fits the same `maxWidth` the success path
141
+ * draws at, not a width of its own, and a cut ends in an ellipsis so it never
142
+ * reads as the whole message.
143
+ */
144
+ function errorLine(e, maxWidth) {
145
+ const summary = (e instanceof Error ? e.message : String(e)).replace(/\s*[^\S ]\s*/g, " ");
146
+ return new RichText(`[error: ${summary}]`).truncate(maxWidth).plain;
131
147
  }
132
148
  // [LAW:single-enforcer] The error-path Style must not itself throw, or
133
149
  // the whole "degrade gracefully" promise of renderTemplate is broken. A
@@ -67,7 +67,9 @@ import type { Palette } from "../themes/palette.js";
67
67
  /**
68
68
  * Register `color "name-or-hex"` against a live palette.
69
69
  *
70
- * `color` resolves a palette variable name to a `#RRGGBB` string, and passes
70
+ * `color` resolves a palette variable name to its hex string — `#RRGGBB`, or
71
+ * `#00000000` for a theme's "no background", which `bg` paints as no
72
+ * background and the colour functions refuse — and passes
71
73
  * an already-literal color through unchanged. That second half is not a
72
74
  * convenience — it makes `color` **idempotent**, which is what lets consumers
73
75
  * apply it unconditionally to any author-written color string without first
@@ -63,7 +63,9 @@
63
63
  * they are gone.
64
64
  */
65
65
  import { resolveColorRef } from "../themes/colorRef.js";
66
- import { ColorRamp, parseRampEasing } from "../themes/ramp.js";
66
+ import { parseEase } from "../core/easing.js";
67
+ import { ColorRamp } from "../themes/ramp.js";
68
+ import { computable } from "./color-funcs.js";
67
69
  /**
68
70
  * The `(position, color-ref)` pairs a `ramp` call's tail spells, as stops.
69
71
  *
@@ -85,7 +87,7 @@ function colorStops(tail, palette) {
85
87
  for (let i = 0; i < tail.length; i += 2) {
86
88
  stops.push({
87
89
  at: tail[i],
88
- color: resolveColorRef(palette, tail[i + 1]),
90
+ color: computable(resolveColorRef(palette, tail[i + 1]), "ramp", JSON.stringify(tail[i + 1])),
89
91
  });
90
92
  }
91
93
  return stops;
@@ -93,7 +95,9 @@ function colorStops(tail, palette) {
93
95
  /**
94
96
  * Register `color "name-or-hex"` against a live palette.
95
97
  *
96
- * `color` resolves a palette variable name to a `#RRGGBB` string, and passes
98
+ * `color` resolves a palette variable name to its hex string — `#RRGGBB`, or
99
+ * `#00000000` for a theme's "no background", which `bg` paints as no
100
+ * background and the colour functions refuse — and passes
97
101
  * an already-literal color through unchanged. That second half is not a
98
102
  * convenience — it makes `color` **idempotent**, which is what lets consumers
99
103
  * apply it unconditionally to any author-written color string without first
@@ -124,14 +128,14 @@ export function paletteFuncs(getPalette) {
124
128
  arity: { kind: "exact" },
125
129
  returnType: "string",
126
130
  };
127
- // `ramp <value> <easing> <position> <color> …` — the argument list is one
128
- // float/string cycle end to end (value, easing, then each stop's position
131
+ // `ramp <value> <ease> <position> <color> …` — the argument list is one
132
+ // float/string cycle end to end (value, ease, then each stop's position
129
133
  // and color), so the engine's `alternating` gate types every slot; the
130
134
  // pairing and the references are parsed in `colorStops`. Its minimum is the
131
- // value, the easing, and one stop, so the gate refuses a stopless call.
135
+ // value, the ease, and one stop, so the gate refuses a stopless call.
132
136
  // [LAW:types-are-the-program]
133
137
  const rampFunc = {
134
- fn: ((value, easing, ...tail) => new ColorRamp(parseRampEasing(easing), colorStops(tail, getPalette())).at(value)
138
+ fn: ((value, ease, ...tail) => new ColorRamp(parseEase(ease), colorStops(tail, getPalette())).at(value)
135
139
  .hex),
136
140
  argTypes: ["float", "string"],
137
141
  arity: { kind: "alternating", minimum: 4 },
@@ -113,11 +113,13 @@ const styleSpecFunc = {
113
113
  // `link` is the cell-splitter for the multi-cell consumer contract.
114
114
  // Implementation-wise it is the same shape as any other style function:
115
115
  // it sets the `link` slot of `Style` exactly as the existing string-form
116
- // `link URL` does, so a template-built fragment is byte-equivalent to
116
+ // `link URL` does, so `{{ link "u" "x" }}` is byte-equivalent to
117
117
  // `RichText("x", { style: Style.parse("link u") })`.
118
118
  //
119
119
  // The cell-boundary signal that consumers (cc-candybar et al.) walk is
120
- // `fragment.style.link` being truthy. `Style.add` propagates `link`
120
+ // `theme.resolve(fragment.style).link` being truthy: a fragment whose base
121
+ // style is a string keeps the link in a `LayeredStyle` until a theme says
122
+ // what the string stands for. `Style.add` propagates `link`
121
123
  // through any outer wrapping call, so `{{ fg "red" (link "u" "x") }}` and
122
124
  // `{{ link "u" "x" }}` produce shapes that both qualify as cells from
123
125
  // the consumer's perspective. Outer-wins on nested links comes for free
@@ -14,6 +14,8 @@ export interface BaseColors {
14
14
  background: ColorRgba;
15
15
  foreground: ColorRgba;
16
16
  }
17
+ type AccentKey = "primary" | "secondary" | "accent" | "success" | "warning" | "error";
18
+ export declare const ACCENT_KEYS: readonly AccentKey[];
17
19
  /**
18
20
  * Build a full semantic palette from base colors.
19
21
  *
@@ -25,12 +27,31 @@ export interface BaseColors {
25
27
  * de-emphasizing body/structural text reaches for
26
28
  * `foreground-muted` the same way it reaches for
27
29
  * `primary-muted` to de-emphasize an accent.
28
- * - `text-*` = contrast text tinted 66% with the accent color (use as
29
- * foreground in muted/background-tinted contexts)
30
+ * - `text-*` = contrast text tinted 66% with the accent color, then held
31
+ * to WCAG AA (4.5:1) on `*-muted` — the foreground for a
32
+ * `*-muted` ground
30
33
  * - `on-*` = WCAG-correct contrast colour (black or white) for use as
31
34
  * foreground when the FULL accent is the background. Picked
32
35
  * by relative luminance — single source of truth so widgets
33
36
  * never need to invert / reverse fg/bg to get readable text.
34
37
  * - `surface` = background blended 5% toward foreground
38
+ *
39
+ * A translucent base colour is drawn onto `background` before anything is
40
+ * derived from it, so `on-*` is chosen against the colour the accent is drawn
41
+ * as and every derived entry is opaque.
42
+ */
43
+ export declare function buildPalette(name: string, dark: boolean, given: BaseColors): Palette;
44
+ /**
45
+ * A palette's `text-*`: its tint held to WCAG AA on `*-muted`, moved toward
46
+ * the palette's own text side so it also reads on `background`, where widgets
47
+ * draw it too. The muted floor is the promise: where no lightness on the text
48
+ * side clears it, the colour that does is on the other side, and the
49
+ * background pair is the one given up. Every bundled palette clears both
50
+ * (test/themes/text-on-muted.test.ts).
51
+ *
52
+ * [LAW:single-enforcer] The one rule for the pair, for the palettes derived
53
+ * here and the authored ones the registry hydrates: the tint is the theme's,
54
+ * whether it reads is decided here.
35
55
  */
36
- export declare function buildPalette(name: string, dark: boolean, base: BaseColors): Palette;
56
+ export declare function accentText(tint: ColorRgba, muted: ColorRgba, background: ColorRgba): ColorRgba;
57
+ export {};
@@ -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;