@rigerc/ink-ui 0.0.0-stage → 0.41.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/AGENTS.md ADDED
@@ -0,0 +1,536 @@
1
+ | A list or detail body before its first load lands | `SkeletonRows` in the body, `SkeletonBar` for a lone value, `count: null` on tabs — then real content, kept through every refresh |
2
+ # Building a CLI with @rigerc/ink-ui
3
+
4
+ The design-system manual, for an AI agent or a person writing a terminal UI
5
+ against this package. It carries the judgement calls the type definitions
6
+ cannot express — what the tokens mean, who owns the keyboard, what a page looks
7
+ like, why a state is never a colour. Everything else is in the types, which are
8
+ always current.
9
+
10
+ ## Read the types, not a list
11
+
12
+ **The exhaustive component surface is `dist/index.d.ts`.** Read it before
13
+ reaching for anything. This file deliberately does not list what exists: a
14
+ hand-maintained inventory goes stale, and a stale inventory is worse than none
15
+ because it tells you a component is missing when it is not.
16
+
17
+ **Before writing any component, check it isn't already here.** Bordered panes,
18
+ the page frame, scrolling viewports, selectable rows, tables, tab bars,
19
+ spinners, progress bars, key/value pairs, badges, pills and footer key hints are
20
+ all provided. A hand-rolled version of one of these is the single most common
21
+ mistake in a consuming repo.
22
+
23
+ ## The one rule that isn't in the types: who owns the keyboard
24
+
25
+ Components split into two kinds, and mixing them up is what produces a screen
26
+ that swallows keystrokes or responds twice.
27
+
28
+ **Uncontrolled — these call Ink's `useInput` themselves.** Mount at most one
29
+ per focus region, and gate the rest with `isDisabled` / `isActive`:
30
+
31
+ `Select` · `MultiSelect` · `TextInput` · `EmailInput` · `PasswordInput` ·
32
+ `ConfirmInput` · `ScrollView` · `UpdateBanner` · `CommandPalette`
33
+
34
+ **Presentational — everything else.** They take `active` / `value` / `on` and
35
+ render. They never listen for keys, so they compose freely and you drive them
36
+ from your own state.
37
+
38
+ **The two hooks supply that state.** `useTabs(items)` and
39
+ `useListCursor(length)` own the keyboard so you don't hand-roll it — and both
40
+ take `{ isActive }` so a screen with several focus regions can gate them.
41
+ `useTabs` binds Tab / Shift+Tab and ←→ alike, wrapping (a tab bar is a ring),
42
+ and takes `{ arrows: false }` where ←→ mean something else on that screen;
43
+ `useListCursor` clamps (a list has ends) and takes `{ wrap }` when you
44
+ genuinely want circular.
45
+
46
+ **Three keys belong to the app, never to a screen: `q`, `esc`, `backspace`.**
47
+ `useAppKeys({ onBack, onQuit, atRoot, isActive })` binds them, and it is
48
+ mounted **once, at the root**. Ink runs every active `useInput` on every key
49
+ with no order and no propagation, so "who gets `esc`" is only ever solved by
50
+ there being one claimant. `q` calls `onQuit`, which defaults to Ink's own
51
+ `exit()` and never `process.exit` — the terminal has to be restored. `esc` and
52
+ `backspace` call `onBack`, the host's *peel*: close the topmost layer you own
53
+ and return `true`, return `false` when there was nothing to pop, and `atRoot`
54
+ says what happens then — `"ignore"` by default, because a key that sometimes
55
+ quits is a key you flinch from. It is not a back stack: a host's layers are a
56
+ priority order over its own booleans (cockpit's has eight arms), and that
57
+ order stays in the host as a plain function. Pass `isActive: false` while a
58
+ text field has focus; there `q` is a letter and `backspace` deletes.
59
+
60
+ **A view exported from a `*-ink` package takes `onBack` and never binds `esc`
61
+ or `q` itself.** The host's peel routes to it. It binds only the keys that
62
+ open and close layers it pushes itself — its own overlay, its own expanded
63
+ row — and nothing the app already owns. Otherwise the mounted view and the
64
+ root hook both fire on the same `esc`, and the user goes back two levels for
65
+ one keystroke.
66
+
67
+ **Never write `useInput` to move a cursor, switch a tab, or leave.** That is
68
+ what the hooks are for, and hand-rolling it is how arrow keys, `esc` and `q`
69
+ end up behaving differently on every screen.
70
+
71
+ ## The keys contract
72
+
73
+ This is the contract every `@kud` TUI honours, verbatim, so a user moving
74
+ between them never relearns "how do I leave":
75
+
76
+ > `q` quits from anywhere, at any depth, no confirmation; never "close the
77
+ > overlay", never "back". `ctrl+c` likewise. Inside a focused text input `q`
78
+ > types and `backspace` deletes — the input owns the keyboard, `esc` blurs it
79
+ > and keeps the value. `esc` and `backspace` are synonyms for **back exactly
80
+ > one level**: input focus → overlay → screen → filter → main. A list filter
81
+ > is the bottom of the peel: `/` types, `↵` or `esc` keeps the filter and
82
+ > hands the letter keys back as hotkeys (an empty term leaves no filter),
83
+ > `ctrl+u` clears the term and stays in the field, `/` again goes back into
84
+ > the term, and `esc` from the list clears it once every layer above it is
85
+ > gone, so `esc` twice clears it from anywhere. At the main page with no
86
+ > filter `esc` does nothing, never quit. `?` opens the legend as an overlay
87
+ > everywhere. Only one thing listens at a time.
88
+
89
+ `TextInput` already does its half for a value prompt: `onCancel` fires on
90
+ `esc` and the value is kept. A list filter is `useFilterMode` instead — see
91
+ below — because it also has a kept mode the host's peel clears.
92
+
93
+ **The filter, vim's way.** A list filter has two modes and every `@kud` TUI
94
+ uses the same two. `useFilterMode()` owns them and returns `{ term, typing,
95
+ caret, active, clear, hints }` — `caret` is the index into `term` (←→ move it
96
+ while typing, printable input inserts there). Wire it in four places and nowhere else:
97
+ `useListCursor(n, { vimKeys: !typing })` so the arrows still walk the matches
98
+ while `j`/`k` type; `useAppKeys({ isActive: !typing })`; an early return in
99
+ your hotkey handler while `typing`; and one arm at the bottom of your peel,
100
+ `if (filter.active) return (filter.clear(), true)`. Matching stays yours —
101
+ the hook only holds the term. Draw it with `FilterBar` (passing `caret`) in
102
+ `Page`'s `filter` slot, and while `typing` show `filter.hints` in place of
103
+ your own, since they are the only keys that work.
104
+
105
+ This was amended on 2026-09-30, when `esc` never cleared a filter and one could
106
+ only be removed a character at a time, and again on 2026-10-05: `esc` while
107
+ typing now keeps the filter like `↵`, so leaving the field never throws the
108
+ term away, `ctrl+u` clears it in place, and clearing stays the peel's job.
109
+
110
+ ## The page: six bands, one frame
111
+
112
+ Every screen of an app — list, detail, loading, error, prompt — sits in the
113
+ same frame, so the border and the title row never come and go between states.
114
+ `Page` draws it and binds no keys, which is what lets a host take the frame
115
+ before (or without) taking the hook. Six bands inside one round border:
116
+
117
+ 1. **Title row** — the cockpit's, made general. The app's glyph and name,
118
+ bold in the accent; the count dim, padded in front so the header never
119
+ shuffles as it changes (` 127 items · `); `@user` plain, never dim —
120
+ scope is the one fact this row exists to state; a further `scope` dim; an
121
+ `alert` bold in the accent (red when `critical`) for something to act on
122
+ today; a `status` that is news (`● 5 new · 38 moved · r apply`, bold
123
+ accent), busy (`↻ refreshing…`, info) or quiet (`updated 42m ago`, dim);
124
+ then a dotted rule `╌` in dim info to the edge, which absorbs whatever
125
+ width the status gains or loses. Constant across every page of the app.
126
+ That is how you know which app you are in and how stale it is, and
127
+ neither changes when you open an item.
128
+ 2. **Blank row** — where search lives when it is open: `Page`'s `filter`
129
+ slot, which takes this row rather than adding one.
130
+ 3. **Tabs** — only on pages that have them. A detail page passes none and the
131
+ band is dropped, never left empty.
132
+ 4. **Body** — whatever the page is for.
133
+ 5. **Counter** — right-aligned, dim: `3 of 12`, `↓ 40%`.
134
+ 6. **Hints** — the footer, in the order below.
135
+
136
+ A root page:
137
+
138
+ ```
139
+ ╭──────────────────────────────────────────────────────────────────╮
140
+ │ ◆ Jira 12 items · @you updated 2m ago ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ │
141
+ │ │
142
+ │ To do (4) In progress (6) Done (2) │
143
+ │ ─────────────── │
144
+ │ │
145
+ │ ── SHOP-300 Checkout ── │
146
+ │ ❯ ▲ SHOP-312 Retry a declined card bug 3d │
147
+ │ SHOP-318 Address form loses its state task 1w │
148
+ │ ── SHOP-410 Search ── │
149
+ │ ▼ SHOP-412 Typo in the empty state task 2w │
150
+ │ │
151
+ │ 3 of 12 │
152
+ │ ↑↓ move ⏎ open o browser ⇥ tab / search ? help q quit │
153
+ ╰──────────────────────────────────────────────────────────────────╯
154
+ ```
155
+
156
+ A nested page keeps the app's title row; the item's key becomes the body's
157
+ first header and the facts gain a breadcrumb, so freshness survives to the
158
+ moment the user acts:
159
+
160
+ ```
161
+ ╭──────────────────────────────────────────────────────────────────╮
162
+ │ ◆ Jira SHOP-312 · 3 of 12 updated 2m ago ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ │
163
+ │ │
164
+ │ ▲ SHOP-312 Retry a declined card bug │
165
+ │ │
166
+ │ Status In progress │
167
+ │ Assignee @you │
168
+ │ Updated 3d ago │
169
+ │ │
170
+ │ A card the issuer declines is shown as a network error, so the │
171
+ │ customer retries a card that will never go through… │
172
+ │ ↓ 40% │
173
+ │ ↑↓ scroll o browser ⌫ back ? help q quit │
174
+ ╰──────────────────────────────────────────────────────────────────╯
175
+ ```
176
+
177
+ In code that is `<Page icon title count user scope status tabs counter hints page="nested">`
178
+ around the body; `PAGE_CHROME` is the four lines the frame spends around the
179
+ body (two borders, the title row, the blank under it) — subtract it, plus the
180
+ tab band and the footer rows you use, when sizing a body to the terminal.
181
+
182
+ **`fill` sizes the frame to the terminal and follows it on resize.** Give the
183
+ root page of a full-screen app `fill`; leave it off for a page printed inline
184
+ in a scrolling shell, which should stay as tall as its content. An explicit
185
+ `width` or `height` still wins on its own axis, and the title row's rule is
186
+ priced off whichever width won, so it always reaches the border.
187
+
188
+ **Skeletons are for when nothing is known yet.** Before the first load lands,
189
+ the body is `SkeletonRows` — `widths` as fractions of the space, cycled in
190
+ order, `gapEvery` to hint at grouping, `indent` to sit under a gutter — and a
191
+ tab whose count is unknown takes `count: null`, drawn as a chip holding `–` at a two-digit
192
+ count's width. `SkeletonBar` is the single bar they are made of, for a
193
+ value in a detail row. A soft highlight band sweeps each bar left to right on
194
+ one shared clock, staggered per row so the wave travels diagonally, then loops
195
+ after a short pause — a first load with nothing on screen reads as frozen
196
+ without motion. `animate={false}` stills them; piped output and `NO_MOTION` /
197
+ `REDUCE_MOTION` are still automatically.
198
+ **Once anything is known, never go back to skeletons:**
199
+ a refresh keeps the stale rows and counts on screen and says busy in `Page`'s
200
+ status slot (`↻ refreshing…`).
201
+ `Panel` stays for inner regions inside `Columns`.
202
+
203
+ ## The footer, in order
204
+
205
+ Hints read left to right in one fixed order, so the eye learns the tail once
206
+ and finds it on every page of every app:
207
+
208
+ | Position | Cells | Where |
209
+ | --- | --- | --- |
210
+ | 1 | Navigation — `↑↓ move`, `⏎ open` | every page |
211
+ | 2 | Page actions — cap three; the rest live in `?` | every page |
212
+ | 3 | Mode switches — `⇥ tab`, `/ search` | main page only |
213
+ | 4 | `⌫ back` | nested pages and overlays only, never on main |
214
+ | 5 | `? help` | every page with a legend |
215
+ | 6 | `q quit` | every page, always last |
216
+
217
+ `Page` passes `page` and `help` through to `FooterHints`, which appends the
218
+ last three from `tailHints(page, help)`. A host passes only its own hints —
219
+ navigation, actions, modes — and never those three, so the tail cannot drift
220
+ between apps. `⌫` is shown, `esc` implied.
221
+
222
+ ## Reaching for the right composition
223
+
224
+ | You need | Compose |
225
+ | --- | --- |
226
+ | A scrolling list of selectable rows | `useListCursor` + `SelectableRow`, one row per item |
227
+ | A long scrollable text/log region | `ScrollView` with `StyledLine[]` — it owns its own scroll keys |
228
+ | A tab bar | `useTabs` + `Tabs` — the hook holds `active`, the component renders it; a tab takes an optional `icon` before its label, and the strip folds inactive icon tabs to `icon count` when it does not fit its `width` |
229
+ | Tabular data with aligned columns | `Table` with a `Column[]` spec — do not lay out columns by hand |
230
+ | Two or more side-by-side regions | `Columns`, and `Panel` for each region that needs a border |
231
+ | A focusable bordered region | `Panel` with `focused` — the border brightens and the title gains a ● marker |
232
+ | One-off prompt for a value | `TextInput` / `EmailInput` / `PasswordInput` / `ConfirmInput` |
233
+ | Pick one / pick many from a list | `Select` / `MultiSelect` — these own their keyboard, unlike `SelectableRow` |
234
+ | A list you can narrow by typing, then act on with hotkeys | `useFilterMode` + `FilterBar` in `Page`'s `filter` slot — see the filter under the keys contract |
235
+ | A launcher — type, pick a row, Enter runs it | `CommandPalette`, mounted as the topmost layer with `useAppKeys` stood down; hand `fuzzyFilter` to `onQueryChange` for a fixed command tree, or derive `items` from the query yourself |
236
+ | A persistent key-hints footer | `FooterHints` with `Hint` tuples: `[["↑↓", "move"], ["q", "quit"]]` |
237
+ | Label/value detail rows | `KeyValue` with a shared `labelWidth` so values align |
238
+ | A category label — `epic`, `draft` — that should read as one object | `Pill`, filled and rounded; `Badge` for the bracket form. Never for a reference the reader follows |
239
+ | Transient feedback | `StatusMessage` (inline) · `Alert` (boxed, with title) · `Toast` (self-dismissing) |
240
+ | App chrome | `Page` as the frame of every screen, with `useAppKeys` once at the root; `Banner` at the top of a non-paged CLI, `Header` per section, `LoadingScreen` while booting |
241
+
242
+ Composing a domain component on top of these is right and expected — wrapping
243
+ `Table` to render your own row shape is the system working. Reimplementing
244
+ `Table` is not.
245
+
246
+ ## Opening the command palette: the trigger is the host's
247
+
248
+ `CommandPalette` binds no open key. A terminal never passes a `⌘` chord
249
+ through to the program, so a literal `⌘K` cannot be a binding. The host picks
250
+ a chord the terminal does deliver and opens the palette from its own
251
+ `useInput`; `Ctrl+K` is the convention across `@kud` TUIs. A user who wants
252
+ `⌘K` maps it in their terminal to send the same byte (`0x0b`, which is
253
+ `Ctrl+K`), so the host binds one chord and both work.
254
+
255
+ While the palette is open it is the topmost layer: stand `useAppKeys` and the
256
+ host's own hotkeys down with `isActive: false`, and close it from `onClose`.
257
+
258
+ The palette's `message` is the one line drawn where the rows would be when
259
+ there are none. A string is muted: what the query did not find, what is not
260
+ configured. A failure is not a miss, so pass a `PaletteMessage` with
261
+ `tone: "error"` and it draws in `colors.error` behind `✗`; give it `onSubmit`
262
+ and Enter retries while it shows, so the input stays open and a failure never
263
+ has to pose as a row to be actionable.
264
+
265
+ ## Colour: what the tokens mean
266
+
267
+ **Colour comes from tokens, never from a string literal.** Import `colors` and
268
+ use it. A literal like `color="orange"` or `color="#FF8C00"` is wrong even when
269
+ it renders identically — it breaks the moment a token moves.
270
+
271
+ ```ts
272
+ import { colors, palette, priorityColors, softColors, tonalColors, tonalStrongColors, spacing } from "@rigerc/ink-ui"
273
+ // colors: accent · secondary · muted · dim · success · error · warning · info · pending · group
274
+ // ticket · pr · link · added · removed · selection · track · trackHighlight
275
+ // palette: the raw hexes those are drawn from; priorityColors: highest · high · medium · low · lowest
276
+ // softColors: seven of them (not secondary), as quiet measured fills
277
+ // tonalColors/tonalStrongColors: the same seven hues as tints over the ground, inked in the hue — rest, and the stronger thing-you-are-on
278
+ // spacing.xs 1 · sm 2 · md 3 · lg 4
279
+ ```
280
+
281
+ Each token answers a different question, and picking by "what looks right" is how
282
+ two of them end up doing one job.
283
+
284
+ - **The default foreground is the answer.** No token. The row's title, the
285
+ value in a key/value pair, the number the screen exists to show.
286
+ - **`accent`** (`#FF8C00`, the one brand hex) says *here* — the active tab's
287
+ label and its underline, the focused thing, the app's own mark. One `accent`
288
+ per region: two on a row fight, which is why `group` is deliberately not the
289
+ accent hue.
290
+ - **`secondary`** (`#999999`) is the third neutral, text that is *context
291
+ rather than the answer* — a row's labels beside its title — and must read as
292
+ its own tier. The default foreground is the answer, `muted` is furniture, and
293
+ a middle tier drawn in either of those is not "quiet", it is absent: on a
294
+ dark ground `dimColor` inks at L* 50 and the eye files it with the age
295
+ column. It is a hex because the tier only exists as a measured step, tuned to
296
+ the dark ground every consumer already commits to. It has no soft twin
297
+ because it is never a fill.
298
+ - **`muted`** (`#75767E`, what `dimColor` measures on this ground) is
299
+ furniture: the frame, hint labels, facts, the counter, an age. Everything the
300
+ reader may skip. `dim` is one step below, for rules and separators only.
301
+ - **`success` · `error` · `warning` · `info`** are verdicts and events, and
302
+ they never travel alone: `StatusMessage` and `Alert` pair each with its
303
+ glyph (`✓` `✗` `⚠` `ℹ`) because the shape is the channel and the hue only
304
+ reinforces it.
305
+ - **`added` · `removed`** (`#4ADE80` · `#FF6B6B`) count a diff: the `+N -N`
306
+ beside a PR. Not `success`/`error`: a run of digits needs more chroma than a
307
+ single glyph, and a line count is a size, not a verdict.
308
+ - **`group`** is the container of the rows beneath it — an epic heading, a
309
+ fence over children that live elsewhere.
310
+ - **`track`** (`#2b323d`) is ground, not ink: the unfilled part of a
311
+ `ProgressBar` and the base of a `SkeletonBar`. `trackHighlight` is the crest
312
+ of that ground — the lighter step a skeleton's shimmer band peaks at, the
313
+ falloff each side blended between the two. Still ground, never a state and
314
+ never text — the crest is kept well above the base so the two separate by
315
+ lightness for any reader.
316
+
317
+ `softColors` is the seven semantic ones desaturated and darkened so white ink
318
+ reads on all of them against a dark ground, measured for a deutan/protan
319
+ reader. Only `Pill tone="soft"` should reach for it.
320
+
321
+ `tonalColors` is the same seven hues as tints over the ground, inked in the
322
+ hue — rest — with `tonalStrongColors` the stronger step for the thing you are
323
+ on. Only `Pill tone="tonal"` should reach for either.
324
+
325
+ The one exception to the token rule is `<Pill color>`, for a state an external
326
+ system **invented** and whose colour is its vocabulary — GitHub's merged
327
+ purple, a CI provider's result colours. Never for that system's *skin*: Jira
328
+ paints its issue types, but a type is a classification and takes a `variant`
329
+ chosen by meaning. The test is whether the hue names a state the reader already
330
+ knows from the source, or merely decorates a category the tokens can already
331
+ say. The pill inks itself against whatever fill it is given, so a caller never
332
+ picks a foreground.
333
+
334
+ ## Colour system
335
+
336
+ Tuned to #1a1b27; dark-only; truecolour assumed, chalk downsamples. `palette`
337
+ holds the raw hexes (`Palette` is their union), `colors` maps roles onto them,
338
+ `priorityColors` (`PriorityColor`) is the priority tier. There is no light
339
+ theme and no provider.
340
+
341
+ 1. **Hex only, never ANSI names.** The terminal theme repaints `cyan`,
342
+ `magenta` and `gray`; every token is a hex.
343
+ 2. **Red is for errors and failures only.** Not priority, not brand.
344
+ 3. **Status always carries a glyph or a word** (`✓` `⚠` `✗` `ℹ` `◌`); the hue
345
+ only reinforces it. `pending` has no hue: waiting is not news.
346
+ 4. **Identifiers take identifier hues, never status hues.** `ticket` (yellow)
347
+ for a Jira-style key, `pr` (orange) for a PR or issue number; bold only on
348
+ the row's own identifier, regular when cited. `link` (blue) is never an
349
+ identifier.
350
+ 5. **The accent is spent once per row or column.** On a PR row the number *is*
351
+ the accent, so nothing else there spends orange; the `❯` keeps its column.
352
+ 6. **Four text tiers:** default foreground, `secondary`, `muted`, `dim`. `dim`
353
+ is furniture (rules, separators, borders), never text to read.
354
+ 7. **On the `selection` ground, `muted` lifts to `secondary`**, and the row
355
+ still carries `❯`: the fill alone is 1.4:1.
356
+ 8. **Solid fill is an event, soft fill is a classification, tonal fill is chrome.**
357
+ 9. **Priority is lightness, not hue:** `ink` → `grey` → `slate`, with the
358
+ chevron shape carrying the rank.
359
+ 10. **An external system's state colour may be used, never its theme.**
360
+
361
+ Never share a hue: `accent`/`ticket`; identifiers/status; priority/status;
362
+ `ticket`/`pr`; `success`/`warning` (hence mint, not green); `pending`/any hue.
363
+ `warning` sits between `pr` and `ticket`, which is why it always carries `⚠`
364
+ and a word. `tokens.test.ts` checks these pairs stay apart at 256 colours.
365
+
366
+ ## Pill: solid says something happened, soft says what it is, tonal says where you are
367
+
368
+ The law in one line: **the column says where it sits, soft says what it is,
369
+ solid says something happened, tonal says where you are.**
370
+
371
+ - **`tone="solid"`** is an *event* — `merged`, a fresh arrival, a removal. It
372
+ earns its loud fill by being news. The default, so nothing already rendered
373
+ moves.
374
+ - **`tone="soft"`** is a *classification* — `epic`, `bug`, `task`. A quiet,
375
+ permanent fill from `softColors`, built to sit on every row without
376
+ out-shouting the one row that has news.
377
+ - **`tone="outline"`** is the same classification as a hue-only ring, for a
378
+ ground where even a soft block is too much.
379
+ - **`tone="tonal"`** is *chrome* — a count, a slot label. A tint of the hue
380
+ over the ground from `tonalColors`, inked in the hue: the quietest filled
381
+ form, for what the frame says about itself rather than row data. `Tabs`
382
+ draws its counts in it; nothing else should reach for it without a reason
383
+ as good. `strong` takes the tint from `tonalStrongColors` and bolds the
384
+ label, for the thing you are on — the active tab's chip — and is ignored by
385
+ every other tone.
386
+
387
+ A screen where every pill is solid has no way to say which pill is the news.
388
+ And a pill is for a word that *is* the information; a reference the reader
389
+ follows — a ticket key, a repo — stays dim text, because a fill gives a
390
+ breadcrumb a weight it has not earned, and once everything is a pill none of
391
+ them is. `pillWidth(text)` prices a pill before you lay out a fixed-width row:
392
+ label plus two caps.
393
+
394
+ ## State is never signalled by colour alone
395
+
396
+ **Every state carries a glyph, a word or a weight as well as its colour.** A
397
+ colourblind reader cannot see the hue, a piped terminal has no colour at all,
398
+ and a test frame carries no escape codes — so a state that lives only in a
399
+ tint is a state nobody can test for. `SelectableRow` marks the active row with
400
+ `❯` *and* bold; `Tabs` marks the active tab by the presence of its underline,
401
+ not its hue; `Panel` gains a ● when focused; every `StatusMessage` leads with
402
+ its glyph; a pill's word carries the meaning. Hold that line in anything you
403
+ add.
404
+
405
+ Two worked examples from real boards:
406
+
407
+ - **Opposing marks need distinct shape *and* a lightness step.** A
408
+ Jira row's priority is `▲` above normal, `▼` below, nothing for normal. The
409
+ shapes are the channel, but two arrows painted in the same colour still read
410
+ as "a mark" at row-scan speed. Priority is ordinal, so `priorityColors`
411
+ steps it in lightness (`ink` → `grey` → `slate`), never in a status hue:
412
+ a red chevron would claim a failure.
413
+ - **`behind` is a word, and the bright one.** When an epic's status trails its
414
+ children — still To do with a child started — its trailing note reads
415
+ `behind · 1 In progress`, the word bright and the rest dim. A word rather
416
+ than a glyph, because the one-column glyphs that survive a monospace grid are
417
+ few and already mean something else. Bright rather than red, because it is
418
+ the one thing on the row the reader must not miss, which is the next rule.
419
+
420
+ ## Emphasis follows importance
421
+
422
+ **Dim means "you may skip this". The answer is the brightest thing on the
423
+ row.** A caveat, a unit, an age, a source: `muted`. A count, a verdict, a
424
+ version, the word `behind`: default foreground or bold. Getting this backwards
425
+ is worse than no styling at all — a flat wall of text is merely flat, whereas
426
+ an inverted hierarchy points the eye away from the answer. So a status note
427
+ that is mostly furniture keeps its one load-bearing word bright, a facts
428
+ segment stays dim beside a bold app name, and a counter never outshines the
429
+ row it counts.
430
+
431
+ ## House rules
432
+
433
+ **Set the icon mode once, before the first render.** `setIconMode("nerd")`
434
+ swaps in Nerd Font glyphs; the default `"text"` is safe everywhere. Components
435
+ read it at render time, so calling it after mounting does nothing.
436
+
437
+ **There is no theme provider and no context.** Components take only the props
438
+ they need. Do not build a provider to pass tokens around — import them.
439
+
440
+ **ESM only.** `import`, never `require`. Node ≥ 20, with `ink` ≥ 7 and
441
+ `react` ≥ 19 as peer dependencies the consuming project installs itself.
442
+
443
+ ## Traps
444
+
445
+ - **A row that overflows its container compresses every flexible child.** If a
446
+ gutter or marker column must hold its width, wrap it in `<Box flexShrink={0}>`.
447
+ This only bites on content long enough to overflow, so it survives short test
448
+ fixtures and breaks in real use.
449
+ - **`esc` with two claimants goes back two levels.** With a `TextInput`
450
+ focused, its `onCancel` and the root `useAppKeys` both hear the same `esc`
451
+ unless the hook is given `isActive: false` for the duration. Blur the input
452
+ in `onCancel`, let the hook stand down while it is focused, and one keystroke
453
+ does one thing.
454
+ - **Check `key.backspace` and `key.delete` together.** The Backspace key sends
455
+ DEL (127) on a Mac and BS (8) elsewhere; Ink 7.1 maps both to `backspace`,
456
+ but older terminals and older Ink report DEL as `delete`. `useAppKeys` and
457
+ `TextInput` check both flags, and a hand-rolled handler that checks one
458
+ works on one keyboard and not another.
459
+ - **`Table` needs `maxWidth`** when it sits inside a bordered `Panel` or
460
+ `Page`, or the columns size against the terminal rather than the pane.
461
+ - **`Toast` returns `null` once it has expired** — it unmounts itself, so don't
462
+ rely on it holding layout space.
463
+ - **`useTabs` returns `active` as possibly `undefined`** when the item list is
464
+ empty. Guard before indexing.
465
+ - **Give every tab a marker of the same width, or none.** A marker on one tab
466
+ alone shifts every tab after it, and a bar that moves when news arrives is a
467
+ bar you have to re-find.
468
+
469
+ ## Testing a component
470
+
471
+ Every component renders a real Ink tree under `ink-testing-library`, and the
472
+ shape of a test is the same each time:
473
+
474
+ ```tsx
475
+ const { lastFrame, stdin } = render(<Host />)
476
+ stdin.write("q") // or String.fromCharCode(27) for esc
477
+ await new Promise((r) => setTimeout(r, 60)) // Ink handles input async
478
+ expect(lastFrame()).toMatch(/⌫ back.*\? help.*q quit/)
479
+ ```
480
+
481
+ Drive keys through `stdin.write` and wait a beat before asserting — Ink
482
+ processes input on its own tick, and a synchronous `expect` reads the frame
483
+ from before the key landed. Assert on what the frame *shows*: the glyph, the
484
+ word, the order of the cells. The runner is not a TTY, so the frame carries no
485
+ escape codes and a colour choice is unobservable — which is the accessibility
486
+ rule enforcing itself, since a state you cannot test for is a state that lived
487
+ only in a tint. Where a colour decision genuinely has to be asserted, export the
488
+ pure function that makes it and test that, as `Pill` does with its ink picker.
489
+
490
+ Unmounting is global: `src/test-setup.ts` registers ink-testing-library's own
491
+ `cleanup` in `afterEach`, so a component holding a timer cannot leak into the
492
+ next test and nobody has to remember it per file. `src/test-setup.test.tsx`
493
+ fails if that line is removed. Workers are capped at four in
494
+ `vitest.config.ts` for a reason recorded there — a suite that silently fails
495
+ to start a third of its files looks exactly like one that passed.
496
+
497
+ ## Working on this repo
498
+
499
+ If you are editing ink-ui itself rather than building with it: filenames are
500
+ kebab-case (`command-palette.tsx` exporting `CommandPalette` — the file is a path,
501
+ the export is code, and only the second is PascalCase), components stay
502
+ presentational unless they are in the uncontrolled list above, every new
503
+ component needs a `.test.tsx` beside it, and the public surface is whatever
504
+ `src/index.ts` exports — a component not exported there does not exist.
505
+ `npm run demo` renders the gallery. Run `npm run typecheck`, `npm test`,
506
+ `npm run build` and `npm run check:agents` before committing; the last one
507
+ fails the moment this file cites a symbol the index does not export, so cite
508
+ nothing before it ships.
509
+
510
+
511
+ ## Stepper: host-owned escape
512
+
513
+ `Stepper` is a wizard, not a second keyboard owner. It leaves Enter unbound
514
+ unless `keyboardNav` is explicitly enabled (only safe without an input
515
+ claiming Enter). It never claims `esc`, `backspace`, or `q`. Route
516
+ the host's one `useAppKeys({ onBack })` peel to
517
+ `useStepper().goBack()` through the active step or host bridge; calling
518
+ `goBack()` from the first step cancels. Provide explicit keys for dynamic
519
+ `Step` children.
520
+
521
+ `StepperProgress` uses the **existing** `colors` and new `symbols`
522
+ exports from `src/tokens.ts`. `Stepper` errors use `StatusMessage`
523
+ and its navigation hints use `FooterHints`. The token file also exports
524
+ `asciiSymbols`, `borders`, and `motion`; there is no parallel theme
525
+ provider or second palette.
526
+
527
+ ## Shared visual tokens
528
+
529
+ Use `symbols`, `asciiSymbols`, `borders`, and `motion` from
530
+ `src/tokens.ts` in all shared UI components instead of duplicating
531
+ semantic glyphs, border styles, or animation constants. The status
532
+ glyphs come from `statusVariant`, which preserves its Nerd Font mode.
533
+ Use `symbols.active` / `symbols.pending` for toggle states,
534
+ `symbols.pointer` for selection, and `symbols.line` /
535
+ `symbols.rule` for separators. Preserve specialized brand symbols and
536
+ caller-provided key hints rather than force-fitting them to other roles.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Erwann Mest
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.