@vincemakes/kiso-tui-cells 0.46.1 → 0.47.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.
package/dist/diff.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * v2e — the diff renderer: edit/write changes as inline ± lines, zero
3
- * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
- * the approval moment ONLY — the frozen summary stays one line (v2d's
5
- * anti-leak principle), /last has the full data.
3
+ * dependencies, no syntax highlighting (the spec's scope line). v2e drew
4
+ * it at the approval moment only; Graphite §6 (R2a) draws it on every
5
+ * edit's card too, from the call's own hunks (`hunksDiff`) — DECLARED
6
+ * REVERSAL of "the frozen summary stays one line".
6
7
  *
7
8
  * edit_file diffs IN PLACE (the search→replace windows are known — no
8
9
  * general engine needed); write_file does a row-level LCS over the old
@@ -17,7 +18,17 @@
17
18
  export type DiffLine = {
18
19
  kind: "-" | "+" | " " | "note";
19
20
  text: string;
21
+ /** Graphite §6 (R2a): the changed WORDS of a line that replaced one
22
+ * other line — `[start, end)` offsets into `text`, sorted, disjoint.
23
+ * Absent everywhere else. */
24
+ marks?: readonly (readonly [number, number])[];
20
25
  };
26
+ /** One change an edit_file call asks for: the text it looks for, and what
27
+ * replaces it. */
28
+ export interface Hunk {
29
+ readonly search: string;
30
+ readonly replace: string;
31
+ }
21
32
  export interface DiffResult {
22
33
  /** The FULL diff (with context, not truncated) — the display truncates. */
23
34
  lines: DiffLine[];
@@ -44,6 +55,8 @@ export interface DiffResult {
44
55
  * changes meaning. */
45
56
  notFound?: true;
46
57
  }
58
+ /** A line-level LCS diff — the classic two-row DP, ~small inputs. */
59
+ export declare function lcsDiff(oldLines: string[], newLines: string[]): DiffLine[];
47
60
  /** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
48
61
  export declare function truncateDiff(diff: DiffLine[]): DiffLine[];
49
62
  /** edit_file: the preview of a CHARACTER splice.
@@ -72,3 +85,27 @@ export declare function editFileDiff(oldContent: string, search: string, replace
72
85
  /** write_file: a new file is all +; an existing file diffs row-level
73
86
  * against its old content. */
74
87
  export declare function writeFileDiff(oldContent: string | null, newContent: string): DiffResult;
88
+ /** The hunks an edit_file call carries: its `edits`, or its one
89
+ * `search`/`replace` pair. Null when the input has neither (the tool
90
+ * refuses such a call, so there is nothing to draw). */
91
+ export declare function hunksOf(input: Record<string, unknown>): Hunk[] | null;
92
+ /** Graphite §6 (R2a) — what an edit CHANGED, drawn from the call's own
93
+ * hunks: per hunk a line LCS of `search` against `replace`, the lines
94
+ * both keep as context (two around each change), a `···` note between
95
+ * hunks, and word marks where one line replaced one line. The file is
96
+ * never read, so the diff is a pure function of the durable log — the
97
+ * same live, after a reprint, after resume. */
98
+ export declare function hunksDiff(hunks: readonly Hunk[]): DiffResult;
99
+ /** The approval preview of a batch: the hunks applied IN ORDER, each to
100
+ * the text the ones before it left — the tool's own rule (ACI-3) — and
101
+ * the result diffed against the file. A hunk the tool would refuse (not
102
+ * there, or there more than once) is named, and nothing is drawn: the
103
+ * tool writes all of the batch or none of it. */
104
+ export declare function editFileHunksDiff(oldContent: string, hunks: readonly Hunk[], path?: string): DiffResult;
105
+ /** A line's tokens: runs of word characters, runs of spaces, and every
106
+ * other character on its own. Joined, they are the line. */
107
+ export declare function wordTokens(line: string): string[];
108
+ /** The token LCS of two lines: for each side, the `[start, end)` offsets
109
+ * of the tokens the other side does not keep, adjacent ones merged.
110
+ * Null when too little survives for marks to mean anything. */
111
+ export declare function tokenMarks(a: string, b: string): [Array<[number, number]> | null, Array<[number, number]> | null];
package/dist/diff.js CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * v2e — the diff renderer: edit/write changes as inline ± lines, zero
3
- * dependencies, no syntax highlighting (the spec's scope line). Shown at
4
- * the approval moment ONLY — the frozen summary stays one line (v2d's
5
- * anti-leak principle), /last has the full data.
3
+ * dependencies, no syntax highlighting (the spec's scope line). v2e drew
4
+ * it at the approval moment only; Graphite §6 (R2a) draws it on every
5
+ * edit's card too, from the call's own hunks (`hunksDiff`) — DECLARED
6
+ * REVERSAL of "the frozen summary stays one line".
6
7
  *
7
8
  * edit_file diffs IN PLACE (the search→replace windows are known — no
8
9
  * general engine needed); write_file does a row-level LCS over the old
@@ -11,7 +12,7 @@
11
12
  * from the full diff.
12
13
  */
13
14
  /** A line-level LCS diff — the classic two-row DP, ~small inputs. */
14
- function lcsDiff(oldLines, newLines) {
15
+ export function lcsDiff(oldLines, newLines) {
15
16
  const n = oldLines.length;
16
17
  const m = newLines.length;
17
18
  const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
@@ -138,7 +139,7 @@ export function editFileDiff(oldContent, search, replace, path) {
138
139
  };
139
140
  }
140
141
  const result = oldContent.slice(0, at) + replace + oldContent.slice(at + search.length);
141
- const lines = withContext(lcsDiff(oldContent.split("\n"), result.split("\n")));
142
+ const lines = withMarks(withContext(lcsDiff(oldContent.split("\n"), result.split("\n"))));
142
143
  return { lines, ...stats(lines), outcome: "diff" };
143
144
  }
144
145
  /** write_file: a new file is all +; an existing file diffs row-level
@@ -151,3 +152,139 @@ export function writeFileDiff(oldContent, newContent) {
151
152
  const lines = withContext(lcsDiff(oldContent.split("\n"), newContent.split("\n")));
152
153
  return { lines, ...stats(lines), outcome: "diff" };
153
154
  }
155
+ /** The hunks an edit_file call carries: its `edits`, or its one
156
+ * `search`/`replace` pair. Null when the input has neither (the tool
157
+ * refuses such a call, so there is nothing to draw). */
158
+ export function hunksOf(input) {
159
+ const pair = (x) => {
160
+ if (typeof x !== "object" || x === null)
161
+ return null;
162
+ const { search, replace } = x;
163
+ return typeof search === "string" && typeof replace === "string" ? { search, replace } : null;
164
+ };
165
+ if (Array.isArray(input.edits)) {
166
+ const hunks = input.edits.map(pair);
167
+ return hunks.length > 0 && hunks.every((h) => h !== null) ? hunks : null;
168
+ }
169
+ const one = pair(input);
170
+ return one === null ? null : [one];
171
+ }
172
+ /** Graphite §6 (R2a) — what an edit CHANGED, drawn from the call's own
173
+ * hunks: per hunk a line LCS of `search` against `replace`, the lines
174
+ * both keep as context (two around each change), a `···` note between
175
+ * hunks, and word marks where one line replaced one line. The file is
176
+ * never read, so the diff is a pure function of the durable log — the
177
+ * same live, after a reprint, after resume. */
178
+ export function hunksDiff(hunks) {
179
+ const lines = [];
180
+ for (const [i, h] of hunks.entries()) {
181
+ const one = withMarks(withContext(lcsDiff(h.search.split("\n"), h.replace.split("\n"))));
182
+ if (one.length === 0)
183
+ continue;
184
+ if (i > 0 && lines.length > 0)
185
+ lines.push({ kind: "note", text: "\u00b7\u00b7\u00b7" });
186
+ lines.push(...one);
187
+ }
188
+ return { lines, ...stats(lines), outcome: "diff" };
189
+ }
190
+ /** The approval preview of a batch: the hunks applied IN ORDER, each to
191
+ * the text the ones before it left — the tool's own rule (ACI-3) — and
192
+ * the result diffed against the file. A hunk the tool would refuse (not
193
+ * there, or there more than once) is named, and nothing is drawn: the
194
+ * tool writes all of the batch or none of it. */
195
+ export function editFileHunksDiff(oldContent, hunks, path) {
196
+ if (hunks.length === 1)
197
+ return editFileDiff(oldContent, hunks[0].search, hunks[0].replace, path);
198
+ const where = path ?? "the file";
199
+ let edited = oldContent;
200
+ for (const [i, h] of hunks.entries()) {
201
+ const at = edited.indexOf(h.search);
202
+ const which = i === 0 ? "hunk 1" : `hunk ${i + 1}, after ${i === 1 ? "hunk 1" : `hunks 1\u2013${i}`} applied`;
203
+ if (at < 0)
204
+ return { lines: [{ kind: "note", text: `pattern not found in ${where} (${which})` }], added: 0, removed: 0, outcome: "not-found", notFound: true };
205
+ if (h.search.length > 0 && edited.indexOf(h.search, at + 1) > at) {
206
+ return { lines: [{ kind: "note", text: `pattern matches more than one place in ${where} (${which})` }], added: 0, removed: 0, outcome: "ambiguous" };
207
+ }
208
+ edited = edited.slice(0, at) + h.replace + edited.slice(at + h.search.length);
209
+ }
210
+ const lines = withMarks(withContext(lcsDiff(oldContent.split("\n"), edited.split("\n"))));
211
+ return { lines, ...stats(lines), outcome: "diff" };
212
+ }
213
+ /** A line's tokens: runs of word characters, runs of spaces, and every
214
+ * other character on its own. Joined, they are the line. */
215
+ export function wordTokens(line) {
216
+ return line.match(/[\p{L}\p{N}_]+|\s+|[\s\S]/gu) ?? [];
217
+ }
218
+ /** The share of a replaced line's tokens (spaces aside) that must survive
219
+ * for word marks to help: below it the whole line changed, and marking
220
+ * nearly every word says less than the line's own colour does. */
221
+ const MARK_MIN_KEPT = 0.34;
222
+ /** Word marks on a diff: every `-` line directly followed by one `+` line,
223
+ * with no other change beside the pair, gets the offsets of the tokens
224
+ * the other side does not keep (a token LCS). */
225
+ function withMarks(diff) {
226
+ const changed = (k) => k >= 0 && k < diff.length && (diff[k].kind === "-" || diff[k].kind === "+");
227
+ return diff.map((d, k) => {
228
+ const pairAt = d.kind === "-" ? k : d.kind === "+" ? k - 1 : -1;
229
+ if (pairAt < 0 || diff[pairAt]?.kind !== "-" || diff[pairAt + 1]?.kind !== "+" || changed(pairAt - 1) || changed(pairAt + 2))
230
+ return d;
231
+ const [oldMarks, newMarks] = tokenMarks(diff[pairAt].text, diff[pairAt + 1].text);
232
+ if (oldMarks === null || newMarks === null)
233
+ return d;
234
+ const marks = d.kind === "-" ? oldMarks : newMarks;
235
+ return marks.length === 0 ? d : { ...d, marks };
236
+ });
237
+ }
238
+ /** The token LCS of two lines: for each side, the `[start, end)` offsets
239
+ * of the tokens the other side does not keep, adjacent ones merged.
240
+ * Null when too little survives for marks to mean anything. */
241
+ export function tokenMarks(a, b) {
242
+ const ta = wordTokens(a);
243
+ const tb = wordTokens(b);
244
+ const n = ta.length;
245
+ const m = tb.length;
246
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
247
+ for (let i = n - 1; i >= 0; i -= 1) {
248
+ for (let j = m - 1; j >= 0; j -= 1)
249
+ dp[i][j] = ta[i] === tb[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
250
+ }
251
+ const keptA = new Array(n).fill(false);
252
+ const keptB = new Array(m).fill(false);
253
+ for (let i = 0, j = 0; i < n && j < m;) {
254
+ if (ta[i] === tb[j]) {
255
+ keptA[i] = true;
256
+ keptB[j] = true;
257
+ i += 1;
258
+ j += 1;
259
+ }
260
+ else if (dp[i + 1][j] >= dp[i][j + 1])
261
+ i += 1;
262
+ else
263
+ j += 1;
264
+ }
265
+ const solid = (t) => /\S/.test(t);
266
+ const words = ta.filter(solid).length + tb.filter(solid).length;
267
+ const kept = ta.filter((t, i) => keptA[i] && solid(t)).length + tb.filter((t, j) => keptB[j] && solid(t)).length;
268
+ if (words === 0 || kept / words < MARK_MIN_KEPT)
269
+ return [null, null];
270
+ return [spans(ta, keptA), spans(tb, keptB)];
271
+ }
272
+ /** The offsets of the tokens not kept, adjacent spans merged; a run of
273
+ * spaces between two changed tokens joins them. */
274
+ function spans(tokens, kept) {
275
+ const line = tokens.join("");
276
+ const out = [];
277
+ let at = 0;
278
+ for (const [i, t] of tokens.entries()) {
279
+ const end = at + t.length;
280
+ if (!kept[i] && /\S/.test(t)) {
281
+ const last = out[out.length - 1];
282
+ if (last !== undefined && /^\s*$/.test(line.slice(last[1], at)))
283
+ last[1] = end;
284
+ else
285
+ out.push([at, end]);
286
+ }
287
+ at = end;
288
+ }
289
+ return out;
290
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Graphite (design.md §2, §3.4) — the colour tokens, the output tier, and
3
+ * the surfaces derived from the ground the terminal actually reported.
4
+ *
5
+ * PURE: no process state is read except where a caller passes it in, so
6
+ * every decision here is testable without a terminal. `render.ts` turns
7
+ * these colours into SGR bytes; this module only decides the colours.
8
+ */
9
+ import { type Rgb } from "./ground.js";
10
+ export type Kind = "light" | "dark";
11
+ /** How colour reaches the terminal: 24-bit (`38;2;r;g;b`) where the
12
+ * terminal says it renders it, the nearest xterm-256 index otherwise. */
13
+ export type Tier = "24bit" | "256";
14
+ /** The Graphite table (design.md §2), on the two reference grounds. */
15
+ export declare const GRAPHITE: {
16
+ readonly light: {
17
+ readonly ground: "#ffffff";
18
+ readonly ink: "#111111";
19
+ readonly ink2: "#444444";
20
+ readonly dim: "#646464";
21
+ readonly rail: "#8c8c8c";
22
+ readonly line: "#e6e6e6";
23
+ readonly washRun: "#edf2fb";
24
+ readonly washDone: "#f1f1f1";
25
+ readonly washFail: "#fbecea";
26
+ readonly washAsk: "#f7f1e3";
27
+ readonly human: "#f7efdc";
28
+ readonly humanInk: "#171923";
29
+ readonly gold: "#8a5a00";
30
+ readonly goldMark: "#c9921f";
31
+ readonly blue: "#2456b5";
32
+ readonly code: "#e4ebf8";
33
+ readonly ok: "#2f7a3a";
34
+ readonly fail: "#b3261e";
35
+ readonly add: "#dff0e2";
36
+ readonly del: "#fadfdc";
37
+ readonly track: "#e2e2e2";
38
+ };
39
+ readonly dark: {
40
+ readonly ground: "#0b0b0b";
41
+ readonly ink: "#ededed";
42
+ readonly ink2: "#b5b5b5";
43
+ readonly dim: "#8e8e8e";
44
+ readonly rail: "#6b6b6b";
45
+ readonly line: "#212121";
46
+ readonly washRun: "#141b28";
47
+ readonly washDone: "#1b1b1b";
48
+ readonly washFail: "#2a1716";
49
+ readonly washAsk: "#211d13";
50
+ readonly human: "#efe6cf";
51
+ readonly humanInk: "#141620";
52
+ readonly gold: "#e3b04b";
53
+ readonly goldMark: "#e3b04b";
54
+ readonly blue: "#82a8f5";
55
+ readonly code: "#1a2438";
56
+ readonly ok: "#8fd19e";
57
+ readonly fail: "#f2877a";
58
+ readonly add: "#16301f";
59
+ readonly del: "#3a1b1a";
60
+ readonly track: "#2a2a2a";
61
+ };
62
+ };
63
+ export type Token = Exclude<keyof (typeof GRAPHITE)["light"], "ground">;
64
+ export type Colours = {
65
+ readonly [K in Token | "ground"]: Rgb;
66
+ };
67
+ /** The tokens that are GROUNDS near the terminal's own (§3.4): each is
68
+ * derived from the reported ground. Every other token is fixed per kind. */
69
+ export declare const SURFACES: readonly ["line", "washRun", "washDone", "washFail", "washAsk", "human", "code", "add", "del", "track"];
70
+ /** The text tokens that read on the ground and on the card surfaces (§2.1). */
71
+ export declare const CARD_TEXT: readonly ["ink", "ink2", "dim", "gold", "blue", "ok", "fail"];
72
+ export declare const CARDS: readonly ["washRun", "washDone", "washFail", "washAsk"];
73
+ type Surface = Token | "ground";
74
+ /** Every text-on-surface pair a human reads (§2.1's floor applies to each). */
75
+ export declare const TEXT_PAIRS: readonly (readonly [Token, Surface])[];
76
+ export declare const FLOOR = 4.5;
77
+ /** The floor for a GRAPHIC — a mark, not text (WCAG 1.4.11). The breath
78
+ * never drops below it (§2.2). */
79
+ export declare const GRAPHIC_FLOOR = 3;
80
+ export declare function hexRgb(hex: string): Rgb;
81
+ export declare function rgbHex({ r, g, b }: Rgb): string;
82
+ /** The WCAG contrast ratio, the measure of §2's floor. */
83
+ export declare function contrast(a: Rgb, b: Rgb): number;
84
+ /** `t` of the way from `a` to `b`, per channel. */
85
+ export declare function mix(a: Rgb, b: Rgb, t: number): Rgb;
86
+ /**
87
+ * §3.4 — one surface for any ground. Per channel an affine map
88
+ * `s = a + k·g`, fitted so that the white reference ground gives the
89
+ * table's white value and the black one its black value: the two
90
+ * grounds reproduce the table exactly, and a terminal whose black is
91
+ * `#1e1e1e` gets a card that sits the same distance off ITS ground.
92
+ */
93
+ export declare function deriveSurface(token: Token, ground: Rgb): Rgb;
94
+ /** A colour as the terminal will show it: itself in the 24-bit tier, the
95
+ * nearest xterm-256 entry in the 256 tier. The floor is checked on what
96
+ * is SHOWN — rounding to the nearest index can cost a pair its contrast. */
97
+ export declare function shown(c: Rgb, tier: Tier): Rgb;
98
+ /** The weakest text-on-surface pair of a colour set, as shown in `tier`. */
99
+ export declare function weakestPair(c: Colours, tier?: Tier): {
100
+ pair: readonly [Token, Surface];
101
+ ratio: number;
102
+ };
103
+ export declare function graphiteColours(kind: Kind, ground: Rgb | null, tier?: Tier): Colours;
104
+ /**
105
+ * The command breath (§5.2): seven steps from `gold` toward the running
106
+ * card's ground, peak → floor → peak. The floor is the deepest step that
107
+ * still meets the graphic floor on that ground (§2.2): the mark dims and
108
+ * never disappears.
109
+ */
110
+ export declare function breathRamp(c: Colours): readonly Rgb[];
111
+ /** The xterm-256 palette's colour for an index 16–255 (the cube and the
112
+ * grey ramp; the first sixteen are the terminal's own and never chosen). */
113
+ export declare function xterm256(index: number): Rgb;
114
+ /** The nearest xterm-256 index (16–255) to a colour, by squared distance
115
+ * in RGB — the 256 tier's whole translation. */
116
+ export declare function nearest256(c: Rgb): number;
117
+ /** §2 — 24-bit where the terminal says it renders it. `COLORTERM` is the
118
+ * standard statement; anything else gets the 256 tier, which every
119
+ * terminal kiso docks in renders. */
120
+ export declare function colourTier(colorterm: string | undefined): Tier;
121
+ export declare function fg(c: Rgb, tier: Tier): string;
122
+ export declare function bg(c: Rgb, tier: Tier): string;
123
+ export {};
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Graphite (design.md §2, §3.4) — the colour tokens, the output tier, and
3
+ * the surfaces derived from the ground the terminal actually reported.
4
+ *
5
+ * PURE: no process state is read except where a caller passes it in, so
6
+ * every decision here is testable without a terminal. `render.ts` turns
7
+ * these colours into SGR bytes; this module only decides the colours.
8
+ */
9
+ import { groundFrom, relativeLuminance } from "./ground.js";
10
+ /** The Graphite table (design.md §2), on the two reference grounds. */
11
+ export const GRAPHITE = {
12
+ light: {
13
+ ground: "#ffffff",
14
+ ink: "#111111",
15
+ ink2: "#444444",
16
+ dim: "#646464",
17
+ rail: "#8c8c8c",
18
+ line: "#e6e6e6",
19
+ washRun: "#edf2fb",
20
+ washDone: "#f1f1f1",
21
+ washFail: "#fbecea",
22
+ washAsk: "#f7f1e3",
23
+ human: "#f7efdc",
24
+ humanInk: "#171923",
25
+ gold: "#8a5a00",
26
+ goldMark: "#c9921f",
27
+ blue: "#2456b5",
28
+ code: "#e4ebf8",
29
+ ok: "#2f7a3a",
30
+ fail: "#b3261e",
31
+ add: "#dff0e2",
32
+ del: "#fadfdc",
33
+ track: "#e2e2e2",
34
+ },
35
+ dark: {
36
+ ground: "#0b0b0b",
37
+ ink: "#ededed",
38
+ ink2: "#b5b5b5",
39
+ dim: "#8e8e8e",
40
+ rail: "#6b6b6b",
41
+ line: "#212121",
42
+ washRun: "#141b28",
43
+ washDone: "#1b1b1b",
44
+ washFail: "#2a1716",
45
+ washAsk: "#211d13",
46
+ human: "#efe6cf",
47
+ humanInk: "#141620",
48
+ gold: "#e3b04b",
49
+ goldMark: "#e3b04b",
50
+ blue: "#82a8f5",
51
+ code: "#1a2438",
52
+ ok: "#8fd19e",
53
+ fail: "#f2877a",
54
+ add: "#16301f",
55
+ del: "#3a1b1a",
56
+ track: "#2a2a2a",
57
+ },
58
+ };
59
+ /** The tokens that are GROUNDS near the terminal's own (§3.4): each is
60
+ * derived from the reported ground. Every other token is fixed per kind. */
61
+ export const SURFACES = ["line", "washRun", "washDone", "washFail", "washAsk", "human", "code", "add", "del", "track"];
62
+ /** The text tokens that read on the ground and on the card surfaces (§2.1). */
63
+ export const CARD_TEXT = ["ink", "ink2", "dim", "gold", "blue", "ok", "fail"];
64
+ export const CARDS = ["washRun", "washDone", "washFail", "washAsk"];
65
+ /** Every text-on-surface pair a human reads (§2.1's floor applies to each). */
66
+ export const TEXT_PAIRS = [
67
+ ...["ground", ...CARDS].flatMap((surface) => CARD_TEXT.map((text) => [text, surface])),
68
+ ["humanInk", "human"],
69
+ ["blue", "code"],
70
+ ["ink", "add"],
71
+ ["ink", "del"],
72
+ ];
73
+ export const FLOOR = 4.5;
74
+ /** The floor for a GRAPHIC — a mark, not text (WCAG 1.4.11). The breath
75
+ * never drops below it (§2.2). */
76
+ export const GRAPHIC_FLOOR = 3;
77
+ export function hexRgb(hex) {
78
+ const n = Number.parseInt(hex.slice(1), 16);
79
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
80
+ }
81
+ export function rgbHex({ r, g, b }) {
82
+ return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
83
+ }
84
+ /** The WCAG contrast ratio, the measure of §2's floor. */
85
+ export function contrast(a, b) {
86
+ const [x, y] = [relativeLuminance(a), relativeLuminance(b)].sort((p, q) => q - p);
87
+ return (x + 0.05) / (y + 0.05);
88
+ }
89
+ /** `t` of the way from `a` to `b`, per channel. */
90
+ export function mix(a, b, t) {
91
+ const c = (x, y) => Math.round(x + (y - x) * t);
92
+ return { r: c(a.r, b.r), g: c(a.g, b.g), b: c(a.b, b.b) };
93
+ }
94
+ const REF_LIGHT = hexRgb(GRAPHITE.light.ground);
95
+ const REF_DARK = hexRgb(GRAPHITE.dark.ground);
96
+ /**
97
+ * §3.4 — one surface for any ground. Per channel an affine map
98
+ * `s = a + k·g`, fitted so that the white reference ground gives the
99
+ * table's white value and the black one its black value: the two
100
+ * grounds reproduce the table exactly, and a terminal whose black is
101
+ * `#1e1e1e` gets a card that sits the same distance off ITS ground.
102
+ */
103
+ export function deriveSurface(token, ground) {
104
+ const w = hexRgb(GRAPHITE.light[token]);
105
+ const d = hexRgb(GRAPHITE.dark[token]);
106
+ const chan = (wc, dc, lw, ld, g) => {
107
+ const k = (wc - dc) / (lw - ld);
108
+ return Math.max(0, Math.min(255, Math.round(dc + k * (g - ld))));
109
+ };
110
+ return {
111
+ r: chan(w.r, d.r, REF_LIGHT.r, REF_DARK.r, ground.r),
112
+ g: chan(w.g, d.g, REF_LIGHT.g, REF_DARK.g, ground.g),
113
+ b: chan(w.b, d.b, REF_LIGHT.b, REF_DARK.b, ground.b),
114
+ };
115
+ }
116
+ function table(kind) {
117
+ const t = GRAPHITE[kind];
118
+ return Object.fromEntries(Object.entries(t).map(([k, v]) => [k, hexRgb(v)]));
119
+ }
120
+ /** A colour as the terminal will show it: itself in the 24-bit tier, the
121
+ * nearest xterm-256 entry in the 256 tier. The floor is checked on what
122
+ * is SHOWN — rounding to the nearest index can cost a pair its contrast. */
123
+ export function shown(c, tier) {
124
+ return tier === "24bit" ? c : xterm256(nearest256(c));
125
+ }
126
+ /** One pair's ratio as shown. The ground is the terminal's own colour and
127
+ * is never rounded; every token kiso writes is. */
128
+ function pairRatio(c, text, surface, tier) {
129
+ const s = surface === "ground" ? c.ground : shown(c[surface], tier);
130
+ return contrast(shown(c[text], tier), s);
131
+ }
132
+ /** The weakest text-on-surface pair of a colour set, as shown in `tier`. */
133
+ export function weakestPair(c, tier = "24bit") {
134
+ let worst = { pair: TEXT_PAIRS[0], ratio: Number.POSITIVE_INFINITY };
135
+ for (const pair of TEXT_PAIRS) {
136
+ const ratio = pairRatio(c, pair[0], pair[1], tier);
137
+ if (ratio < worst.ratio)
138
+ worst = { pair, ratio };
139
+ }
140
+ return worst;
141
+ }
142
+ /** A text token moved toward the kind's extreme (white on a dark ground,
143
+ * black on a light one) just far enough that it clears the floor, as
144
+ * shown, on every surface it reads on. Unmoved when it already does. */
145
+ function lifted(c, token, kind, tier) {
146
+ const on = TEXT_PAIRS.filter(([t]) => t === token).map(([, s]) => s);
147
+ const extreme = kind === "dark" ? { r: 255, g: 255, b: 255 } : { r: 0, g: 0, b: 0 };
148
+ for (let t = 0; t <= 1.0001; t += 0.02) {
149
+ const cand = { ...c, [token]: mix(c[token], extreme, t) };
150
+ if (on.every((s) => pairRatio(cand, token, s, tier) >= FLOOR))
151
+ return cand[token];
152
+ }
153
+ return extreme;
154
+ }
155
+ /**
156
+ * The colours for one screen. `ground` is the RGB the terminal reported
157
+ * (OSC 11), or null when the ground was resolved without a colour — an
158
+ * explicit theme, the terminal's scheme report, COLORFGBG — in which case
159
+ * the table is used as it stands.
160
+ *
161
+ * A reported ground of the OTHER kind (a human who set `theme: light` on
162
+ * a black terminal) derives nothing: the answer the human gave wins, and
163
+ * surfaces fitted to a ground the text tokens were not chosen for would
164
+ * be wrong in both directions.
165
+ *
166
+ * On a reported ground the surfaces are derived (§3.4), and a text token
167
+ * that would fall under the floor — as shown in `tier` — on the ground or
168
+ * on a surface is moved toward the kind's extreme just far enough to clear
169
+ * it: a lighter dark theme lifts `dim`, never the other way. The table's
170
+ * own values pass on their reference grounds in both tiers, so there
171
+ * nothing moves. If a pair is still under the floor, the table is used.
172
+ */
173
+ /** The last derivation, kept: the opening's rules and wordmark ask for
174
+ * the colours on every frame (the banner is a live cell), and the answer
175
+ * only moves with its three inputs (the render-perf pass, 2026-10-07).
176
+ * Callers read the table; none writes to it. */
177
+ let lastColours = null;
178
+ export function graphiteColours(kind, ground, tier = "24bit") {
179
+ const r = ground === null ? -1 : ground.r;
180
+ const g = ground === null ? -1 : ground.g;
181
+ const b = ground === null ? -1 : ground.b;
182
+ const k = lastColours;
183
+ if (k !== null && k.kind === kind && k.tier === tier && k.r === r && k.g === g && k.b === b)
184
+ return k.out;
185
+ const out = deriveColours(kind, ground, tier);
186
+ lastColours = { kind, r, g, b, tier, out };
187
+ return out;
188
+ }
189
+ function deriveColours(kind, ground, tier) {
190
+ const base = table(kind);
191
+ if (ground === null || groundFrom(ground) !== kind)
192
+ return base;
193
+ const derived = { ...base, ground };
194
+ for (const s of SURFACES)
195
+ derived[s] = deriveSurface(s, ground);
196
+ for (const t of new Set(TEXT_PAIRS.map(([text]) => text)))
197
+ derived[t] = lifted(derived, t, kind, tier);
198
+ const out = derived;
199
+ return weakestPair(out, tier).ratio >= FLOOR ? out : base;
200
+ }
201
+ /**
202
+ * The command breath (§5.2): seven steps from `gold` toward the running
203
+ * card's ground, peak → floor → peak. The floor is the deepest step that
204
+ * still meets the graphic floor on that ground (§2.2): the mark dims and
205
+ * never disappears.
206
+ */
207
+ export function breathRamp(c) {
208
+ const card = c.washRun;
209
+ let deepest = 0;
210
+ for (let t = 0; t <= 1.0001; t += 0.01) {
211
+ if (contrast(mix(c.gold, card, t), card) >= GRAPHIC_FLOOR)
212
+ deepest = t;
213
+ else
214
+ break;
215
+ }
216
+ const at = (f) => mix(c.gold, card, deepest * f);
217
+ return [at(0), at(1 / 3), at(2 / 3), at(1), at(2 / 3), at(1 / 3), at(0)];
218
+ }
219
+ /** The xterm-256 palette's colour for an index 16–255 (the cube and the
220
+ * grey ramp; the first sixteen are the terminal's own and never chosen). */
221
+ export function xterm256(index) {
222
+ if (index >= 232) {
223
+ const v = 8 + (index - 232) * 10;
224
+ return { r: v, g: v, b: v };
225
+ }
226
+ const i = index - 16;
227
+ const step = (n) => (n === 0 ? 0 : 55 + n * 40);
228
+ return { r: step(Math.floor(i / 36)), g: step(Math.floor(i / 6) % 6), b: step(i % 6) };
229
+ }
230
+ /** The nearest xterm-256 index (16–255) to a colour, by squared distance
231
+ * in RGB — the 256 tier's whole translation. */
232
+ export function nearest256(c) {
233
+ let best = 16;
234
+ let bestD = Number.POSITIVE_INFINITY;
235
+ for (let i = 16; i <= 255; i += 1) {
236
+ const x = xterm256(i);
237
+ const d = (x.r - c.r) ** 2 + (x.g - c.g) ** 2 + (x.b - c.b) ** 2;
238
+ if (d < bestD) {
239
+ bestD = d;
240
+ best = i;
241
+ }
242
+ }
243
+ return best;
244
+ }
245
+ /** §2 — 24-bit where the terminal says it renders it. `COLORTERM` is the
246
+ * standard statement; anything else gets the 256 tier, which every
247
+ * terminal kiso docks in renders. */
248
+ export function colourTier(colorterm) {
249
+ const v = colorterm?.trim().toLowerCase();
250
+ return v === "truecolor" || v === "24bit" ? "24bit" : "256";
251
+ }
252
+ export function fg(c, tier) {
253
+ return tier === "24bit" ? `\x1b[38;2;${c.r};${c.g};${c.b}m` : `\x1b[38;5;${nearest256(c)}m`;
254
+ }
255
+ export function bg(c, tier) {
256
+ return tier === "24bit" ? `\x1b[48;2;${c.r};${c.g};${c.b}m` : `\x1b[48;5;${nearest256(c)}m`;
257
+ }
package/dist/index.d.ts CHANGED
@@ -7,14 +7,17 @@
7
7
  * API-stability promise yet.
8
8
  */
9
9
  export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, foldTerms, elapsedLabel, settledLabel, statusLine, boxTop, boxBottom, terminalPipe, type FrameCtx, type RenderLine, type Component, type BodyCell, } from "./components.js";
10
- export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
10
+ export { editFileDiff, editFileHunksDiff, hunksDiff, hunksOf, truncateDiff, writeFileDiff, type DiffLine, type DiffResult, type Hunk } from "./diff.js";
11
11
  export { pendingQueueRows } from "./components.js";
12
12
  export { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
13
13
  export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWidth, panelStatus, type PanelArgs, type PanelFlavor, type PanelPhase, deletionRiskHint, SAFER_BACK, SAFER_DEGRADED, type SaferOption, type SaferRuntime, panelOptions, type PanelOption, type PanelOptionKind, type PanelState, type PanelVerdict, type PanelView, type AskAnswer, type AskOption, type AskQuestion, type AskResult, type AskRuntime, type AskSpec, } from "./approval-panel.js";
14
14
  export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, type TrustArtifact, } from "./strings.js";
15
- export { coldResumeLine, coldResumeView, extensionsBannerText, helpRows, unansweredAskView, type BannerExtension } from "./strings.js";
15
+ export { coldResumeLine, coldResumeView, extensionsBannerText, extensionsFact, helpRows, unansweredAskView, type BannerExtension } from "./strings.js";
16
16
  export { displayVerb } from "./strings.js";
17
- export { bannerLines, COLOR_OFF, COLOR_DARK, COLOR_LIGHT, COLOR_NEUTRAL, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type ResumeMeta, } from "./render.js";
17
+ export { bannerLines, COLOR_OFF, COLOR_DARK, COLOR_LIGHT, COLOR_NEUTRAL, COLOR_ON, currentGround, currentGroundRgb, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, paletteFor, relativeTime, renderResumeList, MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame, renderTerminalGap, renderToolSummary, TAGLINE, MOTTO, WORDMARK_W, type BannerFact, type BannerMeta, toolTarget, truncateRow, type Palette, type ResumeMeta, } from "./render.js";
18
18
  /** DC-3 — the ground: is the terminal light or dark. Pure; see the
19
19
  * module comment for why `unknown` is a result and not a failure. */
20
20
  export { groundFrom, parseOscColor, relativeLuminance, resolveGround, type Ground, type GroundInputs, type Rgb } from "./ground.js";
21
+ /** Graphite (design.md §2, §3.4) — the tokens, the tier and the derived
22
+ * surfaces, as colours. Pure; `render.ts` writes them as SGR. */
23
+ export { GRAPHITE, breathRamp, colourTier, contrast, deriveSurface, graphiteColours, hexRgb, nearest256, rgbHex, weakestPair, type Colours, type Kind, type Tier, type Token } from "./graphite.js";
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * API-stability promise yet.
8
8
  */
9
9
  export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, foldTerms, elapsedLabel, settledLabel, statusLine, boxTop, boxBottom, terminalPipe, } from "./components.js";
10
- export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
10
+ export { editFileDiff, editFileHunksDiff, hunksDiff, hunksOf, truncateDiff, writeFileDiff } from "./diff.js";
11
11
  // W22 (the v8 input round): the pending-queue chips — the SAME
12
12
  // UserMessage chip with the □ gutter, pre-rendered above the input
13
13
  // row while turns wait in the queue.
@@ -24,11 +24,14 @@ export { panelAffordance, panelBlockRows, panelLead, panelLeadPlain, panelLeadWi
24
24
  // the cli; what the human reads is presentation.
25
25
  export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, } from "./strings.js";
26
26
  // KC3.5: the interrupted-ask copy and the extracted /help table.
27
- export { coldResumeLine, coldResumeView, extensionsBannerText, helpRows, unansweredAskView } from "./strings.js";
27
+ export { coldResumeLine, coldResumeView, extensionsBannerText, extensionsFact, helpRows, unansweredAskView } from "./strings.js";
28
28
  // TUI2-R2pre ④: the ONE display-verb table — the screen names the act,
29
29
  // the tool table names the call.
30
30
  export { displayVerb } from "./strings.js";
31
- export { bannerLines, COLOR_OFF, COLOR_DARK, COLOR_LIGHT, COLOR_NEUTRAL, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, relativeTime, renderResumeList, MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, } from "./render.js";
31
+ export { bannerLines, COLOR_OFF, COLOR_DARK, COLOR_LIGHT, COLOR_NEUTRAL, COLOR_ON, currentGround, currentGroundRgb, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, paletteFor, relativeTime, renderResumeList, MOTION_FRAMES, TWINKLE, breathFrame, twinkleFrame, renderTerminalGap, renderToolSummary, TAGLINE, MOTTO, WORDMARK_W, toolTarget, truncateRow, } from "./render.js";
32
32
  /** DC-3 — the ground: is the terminal light or dark. Pure; see the
33
33
  * module comment for why `unknown` is a result and not a failure. */
34
34
  export { groundFrom, parseOscColor, relativeLuminance, resolveGround } from "./ground.js";
35
+ /** Graphite (design.md §2, §3.4) — the tokens, the tier and the derived
36
+ * surfaces, as colours. Pure; `render.ts` writes them as SGR. */
37
+ export { GRAPHITE, breathRamp, colourTier, contrast, deriveSurface, graphiteColours, hexRgb, nearest256, rgbHex, weakestPair } from "./graphite.js";