create-pathfinder 1.5.1 → 1.7.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/src/theme.mjs ADDED
@@ -0,0 +1,590 @@
1
+ /**
2
+ * Every capability decision and every decorated byte the CLI emits.
3
+ *
4
+ * One seam, so that a second consumer or a second kind of decoration has
5
+ * somewhere to attach. Before this module, `marks()` and `supportsUnicode()`
6
+ * sat beside the one report they served, and two non-ASCII characters had
7
+ * already escaped the ASCII fallback entirely — which is the argument for the
8
+ * seam, not an accident of that particular pair.
9
+ *
10
+ * Three properties hold, and each one exists to stop a class of call-site bug:
11
+ *
12
+ * - **Total functions.** `theme.ok("text")` returns the plain string when
13
+ * colour is off; the line primitives return the empty string when repainting
14
+ * is not allowed. A call site never asks about capability, so a call site can
15
+ * never forget to. A call site that seems to need a conditional means this
16
+ * module is missing a helper.
17
+ * - **Pure.** `createTheme` reads `{ env, platform, isTTY }` and nothing else —
18
+ * no `process`, no `node:tty`, no filesystem. `run()` is already handed all
19
+ * three, so there is no second source to disagree with. The tests build
20
+ * several themes per file, which a module-level singleton would prevent.
21
+ * - **Decoration, not drawing.** No timer, no loop, no frame, no buffer, no
22
+ * state that survives a call. The line primitives are two escape strings and
23
+ * a boolean. If this file grows a concept of drawing, it has overreached.
24
+ *
25
+ * Deliberately absent: cursor visibility control. A renderer that never hides
26
+ * the cursor has nothing to restore, so no signal handler and no interrupted
27
+ * run can leave a terminal broken. A cursor parked at the end of a progress bar
28
+ * is the accepted cost of that guarantee. Feature 23 adds a second repainting
29
+ * surface and does not weaken this: the selector parks the cursor visibly below
30
+ * its block for exactly the same reason.
31
+ *
32
+ * Newly present, and worth naming beside that absence: `width` and `clip`, from
33
+ * `cells.mjs`. They measure rather than decorate, which is why the algorithm
34
+ * lives in its own module and only the seam is published here — and they are
35
+ * the one part of this file that answers the same way in every tier, because a
36
+ * string's printed width is a fact about the string rather than a capability of
37
+ * the terminal.
38
+ */
39
+
40
+ import { clip, width } from "./cells.mjs";
41
+
42
+ /**
43
+ * SGR codes, written out rather than depended on.
44
+ *
45
+ * Eight ANSI colours, bold, and dim. Every *severity* in this module is one of
46
+ * these and will stay one of these: they render identically everywhere, and a
47
+ * level that means "this went wrong" must never depend on a colour that some
48
+ * terminal renders as something else.
49
+ */
50
+ const SGR = Object.freeze({
51
+ reset: "\u001B[0m",
52
+ bold: "\u001B[1m",
53
+ dim: "\u001B[2m",
54
+ red: "\u001B[31m",
55
+ green: "\u001B[32m",
56
+ yellow: "\u001B[33m",
57
+ cyan: "\u001B[36m",
58
+ });
59
+
60
+ /**
61
+ * Blaze orange, `#E0611F`, in as many alphabets as terminals actually speak.
62
+ *
63
+ * This is the one colour in the CLI that is an *identity* rather than a level,
64
+ * and it is the only reason this module knows what a colour depth is. The
65
+ * severity paints are untouched by all of it, which is the containment that
66
+ * makes the extra depth affordable: a terminal that lies about its capability
67
+ * costs the brand its exact hue, and costs meaning nothing.
68
+ *
69
+ * Three renderings, best first:
70
+ *
71
+ * - **24-bit** - the real value, exactly. Nothing is approximated.
72
+ * - **256** - index 166, `#D75F00`. Chosen by computing the nearest cell of the
73
+ * 6x6x6 cube rather than by eye: `#E0611F` is (224, 97, 31), the cube's
74
+ * levels are 0/95/135/175/215/255, and (215, 95, 0) is nearest by squared
75
+ * distance at 1046 - well clear of the obvious rival 208 `#FF8700` at 3366.
76
+ * - **16** - bold yellow, and only here. This is the floor, never the
77
+ * preference: it is the only warm accent the eight ANSI values offer, so at
78
+ * this depth alone do the brand and `warn` share a hue. The collision is
79
+ * contained rather than waved away - at this depth the identity is carried by
80
+ * the mark's form, its letterspacing, and its position, and a warning is
81
+ * always additionally a glyph and a word. Closing that overlap is precisely
82
+ * what the two depths above are for.
83
+ */
84
+ const BRAND = Object.freeze({
85
+ 24: "\u001B[38;2;224;97;31m",
86
+ 8: "\u001B[38;5;166m",
87
+ 4: `${SGR.bold}${SGR.yellow}`,
88
+ });
89
+
90
+ /**
91
+ * The glyph table, in whichever alphabet this terminal can be trusted with.
92
+ *
93
+ * One table, so a finding and the action it leads to are marked the same way,
94
+ * and so that every character above U+007F the CLI prints has exactly one
95
+ * source. `ellipsis` and `dash` are here for the same reason the severity marks
96
+ * are: they were being printed unconditionally by call sites that had already
97
+ * been told the terminal only gets ASCII.
98
+ *
99
+ * `warn` is the one genuinely new choice. In ASCII it is `*` rather than the
100
+ * obvious `!`, because `!` already means `bad` — and a collision between the
101
+ * two would land precisely in the plain tier, where the glyph is doing the most
102
+ * work because there is no colour beside it.
103
+ *
104
+ * The second group is structural rather than severity: the marks a run's
105
+ * identity and phases are built from. `scan` and `box` are emoji in the
106
+ * decorated alphabet, which is a deliberate product decision and not a drift,
107
+ * and each has an ASCII counterpart that survives the fallback with its sense
108
+ * intact - a lens, and a crate.
109
+ *
110
+ * `barFull` and `barEmpty` are the progress bar's two cells. They only ever
111
+ * render in the expressive tier, since `dynamic` is false everywhere else and
112
+ * the bar draws nothing there - but they carry ASCII counterparts anyway, so
113
+ * that the alphabets stay the same size and the fallback stays a property of
114
+ * this table rather than a fact about who happens to call it.
115
+ *
116
+ * `rule` is both the stroke the Pathfinder mark is drawn from and the character
117
+ * any other horizontal device would use. `gutter` hangs a block together down
118
+ * its left edge. Both are drawn left to right from a fixed count and neither
119
+ * closes on the right, ~~so no caller ever has to know the printed width of a
120
+ * decorated string~~ — **superseded in Feature 22; see `width` and `clip`
121
+ * below.** That is why there is no corner, no box, and no border character in
122
+ * this table — a closed box cannot be aligned without width maths that emoji
123
+ * defeat, which the prototype demonstrated by failing to close its own.
124
+ *
125
+ * The struck clause was true of every device in this module and is still the
126
+ * reason none of them closes on the right. What falsified it is a surface this
127
+ * module did not have when the sentence was written: a *repainting* one. A
128
+ * renderer that redraws a block in place has to know how many rows that block
129
+ * occupies, and a line wider than the terminal silently becomes two — so the
130
+ * cursor-up count goes wrong and the block walks down the screen. Feature 23's
131
+ * prototype reproduced exactly that at 24 columns.
132
+ *
133
+ * So the conclusion is narrowed rather than reversed. Nothing here draws a box,
134
+ * and nothing should. But "no caller needs printed width" was a claim about
135
+ * *what this module happened to contain*, not a rule about terminals, and the
136
+ * honest correction is to publish the measurement rather than let a caller
137
+ * reach for `.length` and be wrong by the length of an escape sequence.
138
+ *
139
+ * The third group is the selector's, added by Feature 23, and it is five rather
140
+ * than the four a checkbox list looks like it needs. `pointer` marks the
141
+ * highlighted row, `checked` and `unchecked` carry a multi-select row's state,
142
+ * and `arrowUp` and `arrowDown` are the hint line's — printed as text in a
143
+ * sentence, and therefore glyphs like any other rather than the escape
144
+ * sequences the same arrows arrive as.
145
+ *
146
+ * Their ASCII counterparts are the reason the minimum-width floor is what it is.
147
+ * `[x]` is three cells where `◉` is one, and `...` is three where `…` is one, so
148
+ * ASCII is the *binding* alphabet for width — 5 to 8 cells wider per row — and
149
+ * the floor below is derived against it rather than against the pretty one.
150
+ */
151
+ const GLYPHS = Object.freeze({
152
+ unicode: Object.freeze({
153
+ ok: "✓",
154
+ info: "·",
155
+ warn: "▲",
156
+ bad: "✗",
157
+ dash: "—",
158
+ ellipsis: "…",
159
+ scan: "🔍",
160
+ box: "📦",
161
+ clipboard: "📋",
162
+ party: "🎉",
163
+ rule: "━",
164
+ gutter: "│",
165
+ barFull: "█",
166
+ barEmpty: "░",
167
+ pointer: "❯",
168
+ checked: "◉",
169
+ unchecked: "○",
170
+ arrowUp: "↑",
171
+ arrowDown: "↓",
172
+ }),
173
+ ascii: Object.freeze({
174
+ ok: "+",
175
+ info: "-",
176
+ warn: "*",
177
+ bad: "!",
178
+ dash: "-",
179
+ ellipsis: "...",
180
+ scan: "(o)",
181
+ box: "(=)",
182
+ clipboard: "(:)",
183
+ party: "\\o/",
184
+ rule: "=",
185
+ gutter: "|",
186
+ barFull: "#",
187
+ barEmpty: ".",
188
+ pointer: ">",
189
+ checked: "[x]",
190
+ unchecked: "[ ]",
191
+ arrowUp: "^",
192
+ arrowDown: "v",
193
+ }),
194
+ });
195
+
196
+ /**
197
+ * Can this terminal be trusted with the decorated glyphs?
198
+ *
199
+ * Answered from the environment rather than attempted and hoped for, and biased
200
+ * hard toward "no": an unanswerable environment gets ASCII, which is readable
201
+ * everywhere, while a wrong "yes" leaves mojibake in the first output a new
202
+ * user ever sees from this tool.
203
+ *
204
+ * Moved from `cli.mjs` unchanged. The rules are not revisited here — a rewrite
205
+ * would be a behaviour change wearing a refactor's clothes.
206
+ */
207
+ function detectUnicode(env, platform) {
208
+ if (platform === "win32") {
209
+ return Boolean(env.WT_SESSION) || env.TERM_PROGRAM === "vscode";
210
+ }
211
+ const locale = env.LC_ALL || env.LC_CTYPE || env.LANG || "";
212
+ return /utf-?8/i.test(locale);
213
+ }
214
+
215
+ /**
216
+ * May this run emit colour?
217
+ *
218
+ * The order of these checks is the whole answer, so it is written as a sequence
219
+ * of refusals ending in the default:
220
+ *
221
+ * 1. `FORCE_COLOR=0` is an explicit "no" and outranks everything, including a
222
+ * terminal that would otherwise qualify.
223
+ * 2. `NO_COLOR` disables on presence, whatever its value — that is the
224
+ * convention, and honouring the value would make `NO_COLOR=` a surprise.
225
+ * It outranks `FORCE_COLOR`, which promises only to override TTY detection.
226
+ * 3. `TERM=dumb` is the terminal telling us what it is.
227
+ * 4. `FORCE_COLOR` set to anything else turns colour on with no TTY, which is
228
+ * how a CI job that renders ANSI in its log viewer asks for it.
229
+ * 5. Otherwise: colour if this is a terminal.
230
+ */
231
+ function detectColor(env, isTTY) {
232
+ if (env.FORCE_COLOR === "0") return false;
233
+ if (env.NO_COLOR !== undefined) return false;
234
+ if (env.TERM === "dumb") return false;
235
+ if (env.FORCE_COLOR !== undefined) return true;
236
+ return isTTY;
237
+ }
238
+
239
+ /**
240
+ * How many colours this terminal has *said* it can render: 0, 4, 8, or 24 bits.
241
+ *
242
+ * A third axis beside colour and Unicode, and deliberately not a fourth tier.
243
+ * The tier answers "what kind of presentation is this", and every tier already
244
+ * works at every depth — so a depth is a refinement of one colour, never a
245
+ * different rendering. Exactly one consumer exists, `brand`, and if a severity
246
+ * ever reads this value something has gone wrong upstream.
247
+ *
248
+ * Answered only from what the environment volunteers. Nothing is probed, no
249
+ * escape sequence is written and read back, and no reply is waited for: a
250
+ * capability query is a round trip with a terminal that may never answer, and
251
+ * this module is not allowed to block or to hold state.
252
+ *
253
+ * The order is a sequence of claims, strongest first, ending in the floor:
254
+ *
255
+ * 1. `FORCE_COLOR` at 2 or 3 is someone stating a depth outright. This is the
256
+ * convention the ecosystem settled on, and it is also the only way to
257
+ * exercise the upper depths in a test without pretending to be a terminal.
258
+ * 2. `COLORTERM` of `truecolor` or `24bit` is the de-facto announcement, set by
259
+ * every terminal that means it.
260
+ * 3. A `TERM` ending in `-direct` is the terminfo spelling of the same claim.
261
+ * 4. A `TERM` containing `256color` is the older, narrower claim.
262
+ * 5. Otherwise 4 — the floor, and the answer for every terminal that said
263
+ * nothing.
264
+ *
265
+ * Biased toward under-claiming, exactly as `detectUnicode` is, but the stakes
266
+ * are far lower here and worth stating plainly: a wrong "yes" about Unicode
267
+ * leaves mojibake in someone's first impression, whereas a wrong "yes" here
268
+ * renders one wordmark in an unintended colour or, on a terminal that ignores
269
+ * the sequence entirely, in the default one. `COLORTERM` does leak across ssh,
270
+ * tmux, and sudo, so being wrong is realistic — it is simply cheap.
271
+ */
272
+ function detectColorDepth(env, color) {
273
+ if (!color) return 0;
274
+ if (env.FORCE_COLOR === "3") return 24;
275
+ if (env.FORCE_COLOR === "2") return 8;
276
+
277
+ const colorterm = env.COLORTERM || "";
278
+ if (/^(truecolor|24bit)$/i.test(colorterm)) return 24;
279
+
280
+ const term = env.TERM || "";
281
+ if (/-direct$/i.test(term)) return 24;
282
+ if (/256color/i.test(term)) return 8;
283
+
284
+ return 4;
285
+ }
286
+
287
+ /**
288
+ * Which presentation tier this run gets.
289
+ *
290
+ * Decided once, here, as a documented function of capability — the alternative
291
+ * is every call site guessing, and them disagreeing.
292
+ *
293
+ * - `contract` — not a terminal. A pipe, a redirect, a CI log. These bytes are
294
+ * a promise kept to scripts written against 1.4.1, so the tier is decided by
295
+ * the TTY alone and nothing else can promote a run into it or out of it.
296
+ * - `expressive` — a terminal that answered yes to both colour and Unicode.
297
+ * - `plain` — a terminal that did not. Not a degraded mode: it is a supported
298
+ * way to use this tool, and anything that reads correctly only in
299
+ * `expressive` is a defect.
300
+ *
301
+ * `FORCE_COLOR` with no TTY is the one combination worth stating outright: the
302
+ * tier is `contract` and colour is on. That is not a contradiction — the tier
303
+ * answers "is anyone watching this live", the capability answers "did they ask
304
+ * for colour", and someone setting `FORCE_COLOR` in a pipeline has answered the
305
+ * second question themselves.
306
+ */
307
+ function selectTier({ isTTY, color, unicode }) {
308
+ if (!isTTY) return "contract";
309
+ return color && unicode ? "expressive" : "plain";
310
+ }
311
+
312
+ /**
313
+ * How many columns a terminal that told us nothing is assumed to have.
314
+ *
315
+ * 80, because that is the width a terminal has when it has no opinion, and
316
+ * because guessing narrow is the safe direction: an over-wide guess lets a row
317
+ * wrap, which is the one failure the clipping in `cells.mjs` exists to prevent.
318
+ */
319
+ const DEFAULT_COLUMNS = 80;
320
+
321
+ /**
322
+ * The narrowest terminal keyboard selection is offered on: **49 columns**.
323
+ *
324
+ * Measured rather than chosen. The derivation ran the real prompt corpus — every
325
+ * selection question the CLI can ask — through the width model in `cells.mjs`,
326
+ * in both alphabets, and asked what each candidate width still preserves:
327
+ *
328
+ * | Tier preserved | Columns |
329
+ * |---------------------------------|---------|
330
+ * | marker + full label | 24 |
331
+ * | + full hint line | 41 |
332
+ * | **+ `-> path` context** | **49** |
333
+ * | + `(detected)` suffix | 56 |
334
+ *
335
+ * 49 is the smallest width at which nothing load-bearing is lost: the marker,
336
+ * the whole label, the whole interaction hint, and the path each row would
337
+ * write to. An earlier estimate of 32 was falsified outright — at 32 the hint
338
+ * loses the word `confirm` and the path is cut mid-word, and even 40 loses one
339
+ * character.
340
+ *
341
+ * The floor is deliberately **not** 56. `(detected)` is the only thing 49 gives
342
+ * up, and it is already stated in the run's ENVIRONMENT block, so it duplicates
343
+ * information rather than carrying it. A suffix that says nothing new does not
344
+ * get to decide whether the whole interaction is available — it is omitted
345
+ * cleanly at narrow widths instead, never truncated to a fragment.
346
+ *
347
+ * Not configurable, and that is the decision rather than an omission. A floor
348
+ * anyone can lower is a floor that stops meaning what it was measured to mean.
349
+ */
350
+ const SELECTION_MIN_COLUMNS = 49;
351
+
352
+ /**
353
+ * May this run ask its questions with the arrow keys?
354
+ *
355
+ * A capability in its own right, and specifically **not** derived from
356
+ * `dynamic`. `dynamic` is colour ∧ unicode, and neither is a claim about
357
+ * repainting: someone who set `NO_COLOR` asked for no decoration, not for no
358
+ * cursor movement, and someone in a Latin-1 locale said nothing at all about
359
+ * either. Deriving one from the other would take the keyboard away from users
360
+ * who never asked to lose it.
361
+ *
362
+ * Five conditions, written as refusals, in the order that makes each one's
363
+ * reason legible:
364
+ *
365
+ * 1. **`PATHFINDER_PROMPT=classic` outranks everything.** It is a person saying
366
+ * so, and it is the first-class answer for a screen reader — where a
367
+ * repainting block re-announces itself on every arrow press and the highlight
368
+ * is carried by position and colour, neither of which is conveyed. It also
369
+ * outranks capability so that scripts and anyone who simply prefers typing a
370
+ * number get a supported path rather than a workaround.
371
+ * 2. **Both ends must be terminals.** A selector needs somewhere to repaint
372
+ * *and* someone able to press a key. Either half missing and the question
373
+ * cannot be answered this way at all.
374
+ * 3. **`TERM=dumb` is the terminal telling us what it is.** Taken at its word,
375
+ * exactly as `detectColor` takes it.
376
+ * 4. **`setRawMode` must exist on the input.** Not called here or anywhere in
377
+ * this package — readline owns raw mode and keeps it — but its absence means
378
+ * the stream cannot deliver keypresses, so there is nothing to borrow.
379
+ * 5. **At least `SELECTION_MIN_COLUMNS`.** Below the floor the classic path is
380
+ * used, which is a supported way to answer the question rather than a
381
+ * degradation of this one.
382
+ */
383
+ function detectSelection({ env, isTTY, inputIsTTY, setRawMode, columns }) {
384
+ if (env.PATHFINDER_PROMPT === "classic") return false;
385
+ if (!isTTY || !inputIsTTY) return false;
386
+ if (env.TERM === "dumb") return false;
387
+ if (!setRawMode) return false;
388
+ return columns >= SELECTION_MIN_COLUMNS;
389
+ }
390
+
391
+ /**
392
+ * Build a theme from what the process was able to observe about the outside
393
+ * world.
394
+ *
395
+ * @param {object} [options]
396
+ * @param {Record<string, string | undefined>} [options.env] - the environment,
397
+ * as `run()` received it.
398
+ * @param {string} [options.platform] - `process.platform`, as `run()` received
399
+ * it.
400
+ * @param {boolean} [options.isTTY] - whether stdout is a terminal.
401
+ * @param {boolean} [options.inputIsTTY] - whether stdin is a terminal. Defaults
402
+ * to false for the same reason every other capability here does: an
403
+ * unanswerable environment gets the conservative answer.
404
+ * @param {boolean} [options.setRawMode] - whether the input stream offers
405
+ * `setRawMode`. Passed as a boolean rather than the stream, so this module
406
+ * stays pure and no test has to synthesize a TTY to ask a question about one.
407
+ * @param {number} [options.columns] - the terminal's width. Anything that is
408
+ * not a positive integer — including the `undefined` a non-TTY stream reports
409
+ * — falls back to 80 rather than throwing.
410
+ * @returns {Readonly<object>} the theme
411
+ */
412
+ export function createTheme({
413
+ env = {},
414
+ platform = "linux",
415
+ isTTY = false,
416
+ inputIsTTY = false,
417
+ setRawMode = false,
418
+ columns,
419
+ } = {}) {
420
+ const unicode = detectUnicode(env, platform);
421
+ const color = detectColor(env, isTTY);
422
+ const colorDepth = detectColorDepth(env, color);
423
+ const tier = selectTier({ isTTY, color, unicode });
424
+
425
+ // A width, always, and never a throw. `process.stdout.columns` is `undefined`
426
+ // whenever stdout is not a terminal, and every arithmetic done with it
427
+ // afterwards would be `NaN` — which compares false against every threshold and
428
+ // would silently answer "no" to questions nobody asked.
429
+ const terminalColumns =
430
+ Number.isInteger(columns) && columns > 0 ? columns : DEFAULT_COLUMNS;
431
+
432
+ // May this run repaint a line it has already written? Only where someone is
433
+ // watching it happen. A pipe keeps every byte ever written to it, so a
434
+ // progress treatment that repaints into a log file produces a transcript of
435
+ // its own animation.
436
+ const dynamic = tier === "expressive";
437
+
438
+ const selection = detectSelection({
439
+ env,
440
+ isTTY,
441
+ inputIsTTY,
442
+ setRawMode,
443
+ columns: terminalColumns,
444
+ });
445
+
446
+ // Who is allowed the cursor escapes, and it is the union of the two surfaces
447
+ // that repaint rather than either one alone.
448
+ //
449
+ // This used to be `dynamic` by itself, and that was correct while the progress
450
+ // bar was the only repainting thing in the package. It stopped being correct
451
+ // the moment a second surface repainted under a *different* capability: a
452
+ // selector on a `NO_COLOR` terminal is offered — `NO_COLOR` says nothing about
453
+ // repainting — and would then have been handed `""` for the very sequences it
454
+ // needs, clearing no line and leaving the tail of every longer row behind.
455
+ //
456
+ // No existing output moves. `progress.mjs` tests `theme.dynamic` itself before
457
+ // it reaches for a primitive, so widening the gate cannot widen what the
458
+ // progress bar draws. What it does narrow is one promise worth stating
459
+ // outright rather than discovering: "a run without colour emits no escape
460
+ // byte" now means no *colour* escape byte. A plain-tier terminal driving a
461
+ // selector emits `ESC[2K` and `ESC[nA`, because that is what a selector is.
462
+ const repaints = dynamic || selection;
463
+
464
+ /**
465
+ * Wrap `text` in an SGR pair, or hand it back untouched.
466
+ *
467
+ * The reset is unconditional rather than a matching "off" code, because a
468
+ * caller may nest and the cheap correct thing is to end every span the same
469
+ * way. Several codes may be given, which is how emphasis combines with a
470
+ * colour without a call site nesting two paints and emitting two resets.
471
+ */
472
+ const paint =
473
+ (...codes) =>
474
+ (text) =>
475
+ color ? `${codes.join("")}${text}${SGR.reset}` : `${text}`;
476
+
477
+ const glyph = unicode ? GLYPHS.unicode : GLYPHS.ascii;
478
+
479
+ return Object.freeze({
480
+ // What was decided, exposed for tests and for the one place that may
481
+ // legitimately branch: a caller choosing between whole presentations.
482
+ tier,
483
+ color,
484
+ unicode,
485
+ dynamic,
486
+
487
+ // May this run's questions be answered with the arrow keys?
488
+ //
489
+ // Read by `prompt.mjs` to choose between two whole implementations of the
490
+ // same interface, which is the one legitimate reason to branch on a
491
+ // capability. False is not an error and not a degraded rendering: it selects
492
+ // the numbered/`y n` path, which is a supported way to use this tool and
493
+ // stays byte-identical to the one 1.6.0 shipped.
494
+ selection,
495
+
496
+ // The width the capability above was decided against, resolved and never
497
+ // undefined.
498
+ //
499
+ // A *snapshot*, and the distinction matters: a terminal can be resized while
500
+ // a question is on screen, so a renderer clipping its rows must read the
501
+ // live value from the stream it is writing to. This is here to answer "how
502
+ // wide did we think it was when we decided", which is what a test and a
503
+ // reviewer need, and nothing else.
504
+ columns: terminalColumns,
505
+
506
+ glyph,
507
+
508
+ // Severity, named for what it means and never for the colour it happens to
509
+ // use. Four levels: `warn` is new and nothing consumes it yet.
510
+ //
511
+ // Colour never carries meaning alone. Every one of these takes a string
512
+ // that already says something, and the call sites pair them with a glyph
513
+ // from the table above — so a `plain` terminal, a screen reader, and a
514
+ // colour-blind reader all lose the decoration and keep the message.
515
+ ok: paint(SGR.green),
516
+ info: paint(SGR.cyan),
517
+ warn: paint(SGR.yellow),
518
+ bad: paint(SGR.red),
519
+
520
+ // Emphasis, not severity. Kept separate so that "important" and "something
521
+ // went wrong" cannot be confused for one another at a call site.
522
+ bold: paint(SGR.bold),
523
+ dim: paint(SGR.dim),
524
+
525
+ // What depth the brand got, exposed for the same reason `tier` is: tests
526
+ // need to assert it, and a reviewer needs to be able to ask.
527
+ colorDepth,
528
+
529
+ // Identity, and the only place a colour is chosen to mean "Pathfinder"
530
+ // rather than to mean a level.
531
+ //
532
+ // Written against BRAND rather than through `paint`, because this is the
533
+ // one paint whose escape sequence is chosen at run time from what the
534
+ // terminal claimed. Everything else in this module has exactly one code for
535
+ // its whole life, and keeping those two facts in separate functions is what
536
+ // stops a future severity from quietly acquiring a depth.
537
+ //
538
+ // Note what is *not* here: any attempt to reproduce #E0611F on a terminal
539
+ // that did not say it could. At depth 4 the brand degrades to the warm
540
+ // accent the eight ANSI values offer and the mark's form carries the rest,
541
+ // which is the whole reason the mark is a drawing of the logo and not a
542
+ // coloured word.
543
+ brand: (text) => (colorDepth === 0 ? `${text}` : `${BRAND[colorDepth]}${text}${SGR.reset}`),
544
+
545
+ // The only escape sequences that are not colour, and the reason they live
546
+ // here: a progress renderer that hand-rolled its own would be a second
547
+ // place capable of writing bytes into a pipe. Exactly three — return to
548
+ // column zero, clear the current line, and move up — and every one of them
549
+ // is the empty string wherever repainting is not allowed, so a caller that
550
+ // never checks a capability still emits nothing.
551
+ //
552
+ // `up` is the *only* escape Feature 23 added, and the budget is the design
553
+ // rather than a coincidence. A selector needs to get back to the top of the
554
+ // block it drew and rewrite it; it does not need absolute positioning, a
555
+ // scroll region, an alternate screen, or — above all — cursor hiding. A
556
+ // fourth primitive appearing here means a renderer has started drawing
557
+ // something this package decided not to draw.
558
+ //
559
+ // `n` is a row count, so a non-positive or non-integer one is not an error
560
+ // to raise but a movement to decline: the first paint of a block has nothing
561
+ // above it to return to, and `ESC[0A` moves one row on some terminals rather
562
+ // than none.
563
+ line: Object.freeze({
564
+ start: () => (repaints ? "\r" : ""),
565
+ clear: () => (repaints ? "\u001B[2K" : ""),
566
+ up: (n) => (repaints && Number.isInteger(n) && n > 0 ? `\u001B[${n}A` : ""),
567
+ }),
568
+
569
+ // How wide a rendered string actually is, and how to make it narrower.
570
+ //
571
+ // Published here rather than imported directly by call sites, for the same
572
+ // reason every paint is: one seam, so a second opinion about what a
573
+ // decorated string measures cannot come into existence. A caller reaching
574
+ // past this into `cells.mjs` is doing the thing this module exists to
575
+ // prevent — and a caller reaching for `.length` is simply wrong, by the
576
+ // length of whatever escape sequence the theme just wrapped around it.
577
+ //
578
+ // Note what these are *not* conditioned on. Unlike `line`, they consult
579
+ // neither `dynamic` nor `color` nor the tier: how many cells a string
580
+ // occupies is a fact about the string, and a function that gave a different
581
+ // answer in a pipe would be lying about identical bytes. Capability decides
582
+ // what a caller *does* with the answer, not what the answer is.
583
+ //
584
+ // Deliberately thin. No padding, no alignment, and above all no wrapping —
585
+ // `cells.mjs` explains why wrapping is the one addition that would turn
586
+ // this into the layout engine neither module is allowed to become.
587
+ width,
588
+ clip,
589
+ });
590
+ }