@rigerc/ink-ui 0.0.0-stage → 0.41.1
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 +536 -0
- package/LICENSE +21 -0
- package/README.md +170 -2
- package/dist/index.d.ts +1094 -0
- package/dist/index.js +1920 -0
- package/package.json +68 -4
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.
|