@promptctl/rich-js 0.11.0 → 0.12.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.
@@ -41,6 +41,13 @@ export interface ThemeKey {
41
41
  /** Additive on lightness; applied *after* the scale. */
42
42
  readonly lightnessShift: number;
43
43
  }
44
+ /** How far `Oklch.mixAxes` moves each axis toward its target, each in [0, 1]. */
45
+ export interface OklchWeights {
46
+ readonly l: number;
47
+ readonly c: number;
48
+ readonly h: number;
49
+ readonly alpha: number;
50
+ }
44
51
  export declare const IDENTITY: ThemeKey;
45
52
  /**
46
53
  * Flip lightness around the midpoint (`L' = 1 - L`) with hue and chroma
@@ -70,6 +77,7 @@ export declare function isIdentityKey(k: ThemeKey): boolean;
70
77
  * sRGB-quantization step normalizes them.
71
78
  */
72
79
  export declare class Oklch {
80
+ #private;
73
81
  readonly l: number;
74
82
  readonly c: number;
75
83
  readonly h: number;
@@ -115,6 +123,32 @@ export declare class Oklch {
115
123
  * there too.
116
124
  */
117
125
  mix(toward: Oklch, t: number): Oklch;
126
+ /**
127
+ * `mix` with each axis moved its own share of the way: `weights.l` of the
128
+ * lightness gap, `weights.c` of the chroma gap, and so on, each in [0, 1].
129
+ * `mix(toward, t)` is `mixAxes` with every weight `t` — one interpolation,
130
+ * so the shorter-arc hue and the powerless-endpoint rule are the same
131
+ * whichever is called.
132
+ *
133
+ * It exists because perceptual axes are independent: a tint can take most of
134
+ * a hue's colourfulness while keeping close to the lightness it started at,
135
+ * which a single `t` cannot say — raising `t` for chroma drags lightness with
136
+ * it. Every weight is required: an axis left out would need a default, and
137
+ * "unchanged" (0) and "same as the others" are both plausible readings.
138
+ *
139
+ * A weak `h` beside a strong `c` shows the starting colour's hue at high
140
+ * chroma. The powerless rule covers only a truly achromatic start, so a
141
+ * near-grey whose hue is noise (a theme surface at c ≈ 0.004) keeps that
142
+ * noise in proportion. To land on the target's hue, pass `h: 1`.
143
+ */
144
+ mixAxes(toward: Oklch, weights: OklchWeights): Oklch;
145
+ /**
146
+ * ΔE_OK — the Euclidean distance between this colour and `other` in OKLab
147
+ * (CSS Color 4's `deltaEOK`), where ~0.02 is the smallest difference the eye
148
+ * resolves. Alpha is not a coordinate of the space and does not count.
149
+ * Symmetric and pure.
150
+ */
151
+ deltaE(other: Oklch): number;
118
152
  /** Linear-sRGB coordinates for an explicit (l, C, h). Pure; `toRgba` passes
119
153
  * already-normalized values so this never sees out-of-range inputs. */
120
154
  private toLinearRgb;
@@ -20,7 +20,11 @@
20
20
  * nothing else from inside the project. Nothing in core/ depends back on
21
21
  * this file.
22
22
  */
23
+ var _a;
23
24
  import { ColorRgba } from "./color.js";
25
+ // The axes `mixAxes` checks, named once so a weights object's own keys — a
26
+ // missing one, or an extra field riding along — never decide what is checked.
27
+ const OKLCH_AXES = ["l", "c", "h", "alpha"];
24
28
  export const IDENTITY = Object.freeze({
25
29
  hueShift: 0,
26
30
  chromaScale: 1,
@@ -144,7 +148,7 @@ export class Oklch {
144
148
  const bLab = 0.0259040371 * lCube + 0.7827717662 * mCube - 0.8086757660 * sCube;
145
149
  const C = Math.sqrt(aLab * aLab + bLab * bLab);
146
150
  const H = wrapHue(Math.atan2(bLab, aLab) * (180 / Math.PI));
147
- return new Oklch(L, C, hueOf(C, H), a);
151
+ return new _a(L, C, hueOf(C, H), a);
148
152
  }
149
153
  /**
150
154
  * Polar → OKLab → linear → sRGB (with chroma-bisection gamut clamping).
@@ -181,7 +185,7 @@ export class Oklch {
181
185
  const newL = clamp01(this.l * k.lightnessScale + k.lightnessShift);
182
186
  const newC = Math.max(0, this.c * k.chromaScale);
183
187
  const newH = wrapHue(this.h + k.hueShift);
184
- return new Oklch(newL, newC, hueOf(newC, newH), this.alpha);
188
+ return new _a(newL, newC, hueOf(newC, newH), this.alpha);
185
189
  }
186
190
  /**
187
191
  * The color `t` of the way from this one toward `toward`, in OKLCH.
@@ -208,6 +212,38 @@ export class Oklch {
208
212
  if (!(t >= 0 && t <= 1)) {
209
213
  throw new RangeError(`Oklch.mix: t must be in [0, 1]; got ${t}`);
210
214
  }
215
+ return this.#interpolate(toward, t, t, t, t);
216
+ }
217
+ /**
218
+ * `mix` with each axis moved its own share of the way: `weights.l` of the
219
+ * lightness gap, `weights.c` of the chroma gap, and so on, each in [0, 1].
220
+ * `mix(toward, t)` is `mixAxes` with every weight `t` — one interpolation,
221
+ * so the shorter-arc hue and the powerless-endpoint rule are the same
222
+ * whichever is called.
223
+ *
224
+ * It exists because perceptual axes are independent: a tint can take most of
225
+ * a hue's colourfulness while keeping close to the lightness it started at,
226
+ * which a single `t` cannot say — raising `t` for chroma drags lightness with
227
+ * it. Every weight is required: an axis left out would need a default, and
228
+ * "unchanged" (0) and "same as the others" are both plausible readings.
229
+ *
230
+ * A weak `h` beside a strong `c` shows the starting colour's hue at high
231
+ * chroma. The powerless rule covers only a truly achromatic start, so a
232
+ * near-grey whose hue is noise (a theme surface at c ≈ 0.004) keeps that
233
+ * noise in proportion. To land on the target's hue, pass `h: 1`.
234
+ */
235
+ mixAxes(toward, weights) {
236
+ for (const axis of OKLCH_AXES) {
237
+ const w = weights[axis];
238
+ if (!(w >= 0 && w <= 1)) {
239
+ throw new RangeError(`Oklch.mixAxes: ${axis} weight must be in [0, 1]; got ${w}`);
240
+ }
241
+ }
242
+ return this.#interpolate(toward, weights.l, weights.c, weights.h, weights.alpha);
243
+ }
244
+ // The one interpolation both entry points share; each has already checked
245
+ // its own weights, so this is the arithmetic alone.
246
+ #interpolate(toward, wl, wc, wh, walpha) {
211
247
  const thisHasHue = this.c >= ACHROMATIC_EPS;
212
248
  const towardHasHue = toward.c >= ACHROMATIC_EPS;
213
249
  const fromH = wrapHue(thisHasHue ? this.h : towardHasHue ? toward.h : 0);
@@ -219,8 +255,19 @@ export class Oklch {
219
255
  const arc = toH - fromH;
220
256
  const fromU = arc < -180 ? fromH - 360 : fromH;
221
257
  const toU = arc > 180 ? toH - 360 : toH;
222
- const c = lerp(this.c, toward.c, t);
223
- return new Oklch(lerp(this.l, toward.l, t), c, hueOf(c, wrapHue(lerp(fromU, toU, t))), lerp(this.alpha, toward.alpha, t));
258
+ const c = lerp(this.c, toward.c, wc);
259
+ return new _a(lerp(this.l, toward.l, wl), c, hueOf(c, wrapHue(lerp(fromU, toU, wh))), lerp(this.alpha, toward.alpha, walpha));
260
+ }
261
+ /**
262
+ * ΔE_OK — the Euclidean distance between this colour and `other` in OKLab
263
+ * (CSS Color 4's `deltaEOK`), where ~0.02 is the smallest difference the eye
264
+ * resolves. Alpha is not a coordinate of the space and does not count.
265
+ * Symmetric and pure.
266
+ */
267
+ deltaE(other) {
268
+ const a = (this.h * Math.PI) / 180;
269
+ const b = (other.h * Math.PI) / 180;
270
+ return Math.hypot(this.l - other.l, this.c * Math.cos(a) - other.c * Math.cos(b), this.c * Math.sin(a) - other.c * Math.sin(b));
224
271
  }
225
272
  /** Linear-sRGB coordinates for an explicit (l, C, h). Pure; `toRgba` passes
226
273
  * already-normalized values so this never sees out-of-range inputs. */
@@ -262,3 +309,4 @@ export class Oklch {
262
309
  return lo;
263
310
  }
264
311
  }
312
+ _a = Oklch;
@@ -58,12 +58,29 @@ export declare class Strip<T extends StyledRenderable = StyledRenderable> implem
58
58
  constructor(items: readonly T[], joiner: Joiner<T>);
59
59
  render(options: RenderOptions): Iterable<Segment>;
60
60
  }
61
+ /**
62
+ * The least ΔE_OK two neighbouring backgrounds must differ by for the powerline
63
+ * arrow between them to be seen. Below it the arrow is drawn in a colour the
64
+ * eye cannot tell from its own background, so the joiner draws the divider
65
+ * instead. Twice the ~.02 threshold of a visible difference, because a seam is
66
+ * one cell wide.
67
+ */
68
+ export declare const SEAM_MIN_DELTA_E = 0.04;
61
69
  export interface PowerlineJoinerOptions {
62
- /** Glyph used for every join (default: U+E0B0, the powerline right-arrow). */
63
- glyph?: string;
70
+ /** Glyph used for every join. */
71
+ glyph: string;
72
+ /**
73
+ * Glyph drawn between neighbours whose backgrounds the eye cannot tell apart,
74
+ * in the left item's text colour — the arrow itself would vanish into the
75
+ * shared background.
76
+ */
77
+ divider: string;
64
78
  }
79
+ /** The powerline pair: U+E0B0 (right-arrow) divided by U+E0B1 (thin right-arrow). */
80
+ export declare const POWERLINE_JOINER_GLYPHS: Readonly<PowerlineJoinerOptions>;
65
81
  export declare class PowerlineJoiner<T extends StyledRenderable = StyledRenderable> implements Joiner<T> {
66
82
  private readonly _glyph;
83
+ private readonly _divider;
67
84
  constructor(options?: PowerlineJoinerOptions);
68
85
  join(left: T | null, right: T | null): Renderable;
69
86
  }
@@ -26,8 +26,9 @@
26
26
  * styling, so the cell type does too.
27
27
  */
28
28
  import { Segment } from "./segment.js";
29
- import { Style } from "./style.js";
29
+ import { Style, SURFACE_BLACK } from "./style.js";
30
30
  import { ColorSpec, blendRgb } from "./color.js";
31
+ import { Oklch } from "./oklch.js";
31
32
  // --- Strip ---
32
33
  export class Strip {
33
34
  items;
@@ -100,10 +101,38 @@ function bgAsFg(edge) {
100
101
  function paintableBg(bg) {
101
102
  return bg !== undefined && !bg.isDefault ? bg : undefined;
102
103
  }
104
+ // --- PowerlineJoiner ---
105
+ /**
106
+ * The least ΔE_OK two neighbouring backgrounds must differ by for the powerline
107
+ * arrow between them to be seen. Below it the arrow is drawn in a colour the
108
+ * eye cannot tell from its own background, so the joiner draws the divider
109
+ * instead. Twice the ~.02 threshold of a visible difference, because a seam is
110
+ * one cell wide.
111
+ */
112
+ export const SEAM_MIN_DELTA_E = 0.04;
113
+ // Two backgrounds the eye cannot tell apart. What is measured is what is
114
+ // drawn: each colour flattened onto the substrate Style.toSgrCodes flattens it
115
+ // onto, so two alphas over one RGB are the two greys they render as. A palette
116
+ // colour has no value here, so two of them are the same only when they are the
117
+ // same palette slot — `type` + `number`, never the name, which spells one slot
118
+ // many ways ("red", "color(1)").
119
+ function indistinct(a, b) {
120
+ if (b === undefined)
121
+ return false;
122
+ const av = a.flattenAlpha(SURFACE_BLACK).value;
123
+ const bv = b.flattenAlpha(SURFACE_BLACK).value;
124
+ return av !== undefined && bv !== undefined
125
+ ? Oklch.fromRgba(av).deltaE(Oklch.fromRgba(bv)) < SEAM_MIN_DELTA_E
126
+ : a.type === b.type && a.number === b.number;
127
+ }
128
+ /** The powerline pair: U+E0B0 (right-arrow) divided by U+E0B1 (thin right-arrow). */
129
+ export const POWERLINE_JOINER_GLYPHS = Object.freeze({ glyph: "\ue0b0", divider: "\ue0b1" });
103
130
  export class PowerlineJoiner {
104
131
  _glyph;
105
- constructor(options) {
106
- this._glyph = options?.glyph ?? "";
132
+ _divider;
133
+ constructor(options = POWERLINE_JOINER_GLYPHS) {
134
+ this._glyph = options.glyph;
135
+ this._divider = options.divider;
107
136
  }
108
137
  join(left, right) {
109
138
  // [LAW:dataflow-not-control-flow] One expression for all three positions
@@ -117,20 +146,27 @@ export class PowerlineJoiner {
117
146
  // colourless arrow is not drawn.)
118
147
  // • no right bg — the end cap — bleeds the left colour out over the
119
148
  // terminal background (fg = left bg, no bg).
120
- // Equal REAL bgs still emit: the glyph is drawn in its own background colour
121
- // and is invisible, but the cell is present — a same-bg seam between two
122
- // distinct items is a structural boundary, never suppressed. Background
123
- // colour is paint, not structure; its ABSENCE (nothing to paint) is the only
124
- // thing that elides the separator, and that is paint logic, not structure.
149
+ // Equal REAL bgs still emit: a same-bg seam between two distinct items is a
150
+ // structural boundary, never suppressed. The arrow would be drawn in its own
151
+ // background colour there and vanish, so the seam is the DIVIDER instead, in
152
+ // the left item's text colour — the vim-airline convention for neighbours
153
+ // that share a background. "Equal" is perceptual (SEAM_MIN_DELTA_E): an
154
+ // arrow a hair off its background is as invisible as one exactly on it.
155
+ // Background colour is paint, not structure; its ABSENCE (nothing to paint)
156
+ // is the only thing that elides the separator, and that is paint logic.
125
157
  // "Absent" = no bg OR the terminal default (transparent) — paintableBg folds
126
158
  // both to undefined so an explicit `… on default` cannot smuggle a separator.
127
159
  const glyph = this._glyph;
160
+ const divider = this._divider;
128
161
  return deferred(function* (options) {
129
- const leftBg = paintableBg(left?.edgeStyle("right", options).bgcolor);
162
+ const leftEdge = left?.edgeStyle("right", options);
163
+ const leftBg = paintableBg(leftEdge?.bgcolor);
130
164
  if (leftBg === undefined)
131
165
  return;
132
166
  const rightBg = paintableBg(right?.edgeStyle("left", options).bgcolor);
133
- yield new Segment(glyph, new Style({ color: leftBg, bgcolor: rightBg }));
167
+ yield indistinct(leftBg, rightBg)
168
+ ? new Segment(divider, new Style({ color: leftEdge?.color, bgcolor: rightBg }))
169
+ : new Segment(glyph, new Style({ color: leftBg, bgcolor: rightBg }));
134
170
  });
135
171
  }
136
172
  }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Immutable style descriptors — colors, text attributes, links, metadata.
3
3
  */
4
- import { ColorSpec, ColorDepth } from "./color.js";
4
+ import { ColorRgba, ColorSpec, ColorDepth } from "./color.js";
5
+ export declare const SURFACE_BLACK: ColorRgba;
5
6
  /**
6
7
  * Canonical text-attribute inventory. Single source of truth consumed by
7
8
  * `Style.parse` / `Style.toString` and the template bindings — adding an
@@ -8,7 +8,7 @@ import { OSC8_CLOSE, osc8Open } from "./osc8.js";
8
8
  // canonical canvas color (black), inlined to avoid pulling in any preset
9
9
  // theme constants. Preset themes live in `src/themes/` and depend on core,
10
10
  // never the reverse.
11
- const SURFACE_BLACK = new ColorRgba(0, 0, 0);
11
+ export const SURFACE_BLACK = new ColorRgba(0, 0, 0);
12
12
  // --- Attribute definitions ---
13
13
  /**
14
14
  * Canonical text-attribute inventory. Single source of truth consumed by
package/dist/core/text.js CHANGED
@@ -48,8 +48,9 @@ function hangingWhitespace(line) {
48
48
  // [LAW:single-enforcer] RichText is the data-model trust boundary for link
49
49
  // URLs: a `Style` is sanitized as it enters (`admitStyle`), and the `Style` a
50
50
  // stored string resolves to is sanitized as it leaves for a render
51
- // (`resolveStyle`). Wire-byte safety is enforced separately in render.ts and
52
- // style.ts through the same `stripOscTerminators`.
51
+ // (`resolveStyle`). Wire-byte safety is enforced separately by `osc8Open`
52
+ // (core/osc8.ts), which every link producer calls and which applies the same
53
+ // `stripOscTerminators`.
53
54
  function sanitizeStyleLink(style) {
54
55
  const link = style.link;
55
56
  if (!link)
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export type { CellCol, CodeUnit, CodePoint } from "./core/cells.js";
3
3
  export { ColorRgba, ColorTable, ColorDepth, ColorSpec, ColorParseError, TerminalTheme, parseRgbHex, parseRgbaHex, blendRgb, resolveColorSystem, detectColorSystem, STANDARD_TABLE, EIGHT_BIT_TABLE, WINDOWS_TABLE, ANSI_COLOR_NAMES, } from "./core/color.js";
4
4
  export type { DetectColorOptions } from "./core/color.js";
5
5
  export { Oklch, IDENTITY, INVERT_LIGHTNESS, isIdentityKey, } from "./core/oklch.js";
6
- export type { ThemeKey } from "./core/oklch.js";
6
+ export type { ThemeKey, OklchWeights } from "./core/oklch.js";
7
7
  export { Palette } from "./themes/palette.js";
8
8
  export { resolveColorRef, parseHexColor, ColorRefError, HEX_COLOR_RE, } from "./themes/colorRef.js";
9
9
  export { buildPalette } from "./themes/buildPalette.js";
@@ -28,7 +28,7 @@ export type { Unsubscribe } from "./core/subscription.js";
28
28
  export { Measurement, measureRenderables } from "./core/measure.js";
29
29
  export { Span, RichText } from "./core/text.js";
30
30
  export type { RichTextOptions } from "./core/text.js";
31
- export { Strip, PowerlineJoiner, CapsuleJoiner, PlainJoiner, GradientJoiner, } from "./core/strip.js";
31
+ export { Strip, PowerlineJoiner, SEAM_MIN_DELTA_E, POWERLINE_JOINER_GLYPHS, CapsuleJoiner, PlainJoiner, GradientJoiner, } from "./core/strip.js";
32
32
  export type { StyledRenderable, Joiner, PowerlineJoinerOptions, CapsuleJoinerOptions, PlainJoinerOptions, GradientJoinerOptions, } from "./core/strip.js";
33
33
  export { renderToString, segmentToString, segmentsToString, } from "./core/render.js";
34
34
  export type { RenderToStringOptions } from "./core/render.js";
package/dist/index.js CHANGED
@@ -38,7 +38,7 @@ export { Measurement, measureRenderables } from "./core/measure.js";
38
38
  // Text
39
39
  export { Span, RichText } from "./core/text.js";
40
40
  // Strip + Joiner
41
- export { Strip, PowerlineJoiner, CapsuleJoiner, PlainJoiner, GradientJoiner, } from "./core/strip.js";
41
+ export { Strip, PowerlineJoiner, SEAM_MIN_DELTA_E, POWERLINE_JOINER_GLYPHS, CapsuleJoiner, PlainJoiner, GradientJoiner, } from "./core/strip.js";
42
42
  // renderToString — stateless one-shot emission
43
43
  export { renderToString, segmentToString, segmentsToString, } from "./core/render.js";
44
44
  // OSC 8 hyperlink wire grammar — for consumers that read rendered bytes back
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/rich-js",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Rich text and beautiful formatting in the terminal — a TypeScript port of Python's Rich",
5
5
  "type": "module",
6
6
  "sideEffects": false,