@henryavila/blink-tui 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Henry Avila
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.
package/README.md ADDED
@@ -0,0 +1,367 @@
1
+ # blink
2
+
3
+ **a framework for building modern, elegant TUI apps.**
4
+
5
+ ```
6
+ bl▎nk
7
+ ```
8
+
9
+ blink is a thin layer over [Ink](https://github.com/vadimdemedes/ink) (React for
10
+ the terminal) that gives every app the same considered house style: swappable
11
+ themes (Tokyo Night, Catppuccin, Nord, Gruvbox, Latte …), dual-mode Nerd Font
12
+ glyphs, box-drawing panes, keyboard-only interaction — all on a strict
13
+ character-cell grid.
14
+
15
+ > _if you can't draw it with characters, it doesn't belong in a blink app._
16
+
17
+ The name is always lowercase **blink**. The mark is the blinking cursor block —
18
+ the one motion the contract permits — accented in lavender.
19
+
20
+ ---
21
+
22
+ ## what it looks like
23
+
24
+ `svcd`, the bundled reference app, exercises every primitive at the 100×30
25
+ design target — two panes, a focused-pane border recolour (lavender, never a
26
+ heavier line), a list with state + domain glyphs, a live status bar, modal
27
+ dialogs, search, and the braille spinner:
28
+
29
+ ```
30
+ ▎ svcd · built with blink ready
31
+ ╭─ services (7) ───────────────────────────────────────╮╭─ detail ─────────────────────────────────╮
32
+ │ ✓ postgres@14.10 data/pg/main ││ docker │
33
+ │ ✓ redis@7.2 /var/redis ││ │
34
+ │ ► ◯ docker 28 containers · 4 stopped ││ state ◯ starting │
35
+ │ ◐ nginx config out-of-date ││ path 28 containers · 4 stopped │
36
+ │ ✓ node · api pid 4821 · :3000 ││ port — │
37
+ │ ✗ grafana missing on host ││ │
38
+ │ ✓ ssh-agent 3 keys loaded ││ ↳ actions │
39
+ │ ││ ↯ a apply now │
40
+ ╰──────────────────────────────────────────────────────╯╰──────────────────────────────────────────╯
41
+ tab pane / search a apply d delete ? help q quit ✓ 4 ◯ 1 ◐ 1 ✗ 1
42
+ ```
43
+
44
+ Run it in your terminal (full colour + Nerd Font glyphs) and drive it with the
45
+ keyboard — `↑↓`/`j k`, `tab`, `/`, `a`, `d`, `?`, `q`:
46
+
47
+ ```bash
48
+ npm run example
49
+ ```
50
+
51
+ `npm run example:snapshot > shot.ansi` writes a single colour frame you can
52
+ `cat` or drop in docs (the block above is that frame, de-coloured for GitHub).
53
+
54
+ ---
55
+
56
+ ## install
57
+
58
+ ```bash
59
+ npm i @henryavila/blink-tui ink react
60
+ ```
61
+
62
+ `ink` and `react@18` are peer dependencies. blink ships ESM only.
63
+
64
+ ## quick start
65
+
66
+ ```tsx
67
+ import React from 'react';
68
+ import { render, Box, Text } from 'ink';
69
+ import {
70
+ ThemeProvider, detectIconSet,
71
+ Pane, List, Footer,
72
+ useTokens,
73
+ } from '@henryavila/blink-tui';
74
+
75
+ function App() {
76
+ const t = useTokens();
77
+ return (
78
+ <Box flexDirection="column" height={30}>
79
+ <Box flexGrow={1} flexDirection="row">
80
+ <Pane title="services (3)" tone="focus" flexBasis="56%">
81
+ {/* rows declare INTENT — state names, not glyphs/colours; blink paints them */}
82
+ <List
83
+ focusedId="pg"
84
+ rows={[
85
+ { id: 'pg', state: 'installed', label: 'postgres@14.10', meta: 'data/pg' },
86
+ { id: 'redis', state: 'installed', label: 'redis@7.2' },
87
+ { id: 'nginx', state: 'drift', label: 'nginx', meta: 'drift' },
88
+ ]}
89
+ />
90
+ </Pane>
91
+ <Pane title="detail" flexBasis="44%">
92
+ <Text color={t.fg}>state running</Text>
93
+ </Pane>
94
+ </Box>
95
+ <Footer
96
+ keys={[
97
+ { k: 'tab', desc: 'pane' },
98
+ { k: 'enter', desc: 'open' },
99
+ { k: 'q', desc: 'quit' },
100
+ ]}
101
+ right="3 of 3"
102
+ />
103
+ </Box>
104
+ );
105
+ }
106
+
107
+ const iconSet = await detectIconSet();
108
+ render(
109
+ <ThemeProvider iconSet={iconSet}>
110
+ <App />
111
+ </ThemeProvider>,
112
+ );
113
+ ```
114
+
115
+ Run any blink app with [`tsx`](https://github.com/privatenumber/tsx):
116
+ `npx tsx app.tsx`. The terminal is the preview — there is no browser step.
117
+
118
+ ---
119
+
120
+ ## the contract
121
+
122
+ Everything a blink app renders flows from these rules. They are not style
123
+ suggestions; they are what makes blink apps look like blink apps.
124
+
125
+ - **One family, one size, one weight.** CaskaydiaMono Nerd Font, 14px, 400.
126
+ "Bold" is **inverse video**, not 700.
127
+ - **Colour is the surface's, never the component's.** Like a real terminal, the
128
+ scheme belongs to the surface (the `ThemeProvider`); a component emits a
129
+ semantic role and the surface decides the pixels. Background is always a solid
130
+ fill — never a gradient, image, or blur. Three text tiers (`fg` / `fgMuted` /
131
+ `fgDim`); past that, reach for an accent, never a fourth grey. Semantic colour
132
+ lives on **glyphs**, not body text (green is `✓`, not the word "ok"). blink
133
+ ships seven themes (default Tokyo Night); switching one repaints the whole tree
134
+ from the intent tokens, and no component can diverge because it owns no colour.
135
+ - **Every border is a glyph.** Box-drawing characters via `Pane` — never a CSS
136
+ border, radius, or outline (Ink has none anyway; the discipline is in the
137
+ composition). The house style is **single-line, rounded** corners; there is
138
+ **no double-line border** (it reads dated). Focus and modals are signalled by
139
+ border **colour** (lavender), never a heavier line — so the layout never
140
+ shifts when a pane gains focus.
141
+ - **Intent, not style.** A component takes a *semantic* prop describing what a
142
+ thing MEANS (`tone="focus"`, `state="installed"`, `selected`,
143
+ `domain="postgresql"`), never a raw glyph, colour, or shape — blink resolves
144
+ the looks from the house tokens. This is the rule that keeps every blink app
145
+ on-style for free (see below).
146
+ - **Flexbox only.** `<Box flexDirection="row|column">`, nested for multi-pane.
147
+ No absolute positioning, no z-index. Target window 100×30; mobile-mosh
148
+ fallback 60×20 (read `useStdoutDimensions()` to switch layouts).
149
+ - **Keyboard only.** No *mouse* scroll, no scrollbar, no wheel, no hover. Focus
150
+ is character-based: the `►` caret, a surface fill, or a recoloured (lavender)
151
+ border. A list longer than its container is **paged by the keyboard** — focus
152
+ moves, the window follows (`List height` / `useListWindow`), with `▴ N more` /
153
+ `▾ N more` as the only affordance. That is not mouse-scroll; it is how the
154
+ 60×20 target is honoured for any non-trivial list.
155
+ - **One animation.** The cursor blinks at 1 Hz, step-end (`useBlink`). A spinner
156
+ may cycle (`useSpinnerFrame` + `Spinner`). Nothing else *moves* — no fades, no
157
+ transitions, no transforms. Content re-rendering on new data (a windowed list
158
+ following its focus, a `LogView` following its tail) is **not motion** — it is
159
+ the same frame redrawn with new content, which the rule does not govern.
160
+ - **No emoji, no SVG, no raster.** Status is carried by glyphs from the palette
161
+ (`✓ ✗ ◯ ◐ ⚠ ↻`). The logo is characters.
162
+
163
+ ### copy voice
164
+
165
+ Terse, lowercase, command-shaped — like output from a well-engineered CLI.
166
+ Second person imperative ("press `?` for help"). No exclamation marks. State,
167
+ then action (`3 changes ↳ press a to apply`). `UPPER CASE` is reserved for KEY
168
+ indicators.
169
+
170
+ ### intent, not style
171
+
172
+ The single API rule, and what keeps every blink app on-style for free: **the
173
+ consumer chooses the intent; blink owns the style.** A component never accepts a
174
+ raw glyph, a raw colour, or a shape name — it accepts a *semantic* prop, and the
175
+ framework resolves the glyph, colour, border, and spacing from the house tokens.
176
+
177
+ ```tsx
178
+ // ✗ style leaking into the API — the consumer paints pixels
179
+ <Pane variant="double" />
180
+ <List rows={[{ id, glyph: '✓', glyphColor: t.stateOk, domainColor: '#89b4fa' }]} />
181
+ <Banner glyph="✓" color="green" />
182
+
183
+ // ✓ intent only — the framework decides how it looks
184
+ <Pane tone="focus" />
185
+ <List rows={[{ id, state: 'installed', domain: 'postgresql', selected: true }]} />
186
+ <Banner tone="success" />
187
+ ```
188
+
189
+ | concern | intent prop (consumer) | blink owns (framework) |
190
+ |--------------------|----------------------------------------------|----------------------------------------------|
191
+ | pane emphasis | `tone` = `resting \| focus \| error` | border + title colour; rounded shape |
192
+ | row / detail status| `state` = `installed \| missing \| drift \| pending \| …` | the glyph (`✓ ✗ ◐ ◯ ⚠ ↻`) + its colour |
193
+ | selection | `selected` / `locked` (bool) | `☑ / ☐ / ▣` + colour |
194
+ | domain icon | `domain` = a registered NAME | the glyph + its colour (owned at registration) |
195
+ | notice severity | `tone` = `info \| success \| warn` | leading glyph + colour |
196
+ | de-emphasis | `muted` (bool) | which grey tier |
197
+
198
+ If you find yourself wanting to pass a hex value or a glyph character into a
199
+ component, it's missing an *intent* — add the intent, don't open a style hole.
200
+ The maps live centrally in `glyphs.ts` (`stateGlyph()`, `selectionIntents`, and
201
+ the registry's per-entry `color`).
202
+
203
+ ---
204
+
205
+ ## api
206
+
207
+ ### theme
208
+
209
+ | export | what |
210
+ |---|---|
211
+ | `ThemeProvider` | wrap your app once; takes `theme` (id or `Theme`, default `tokyonight`) + `iconSet` |
212
+ | `useTheme()` / `useTokens()` / `useIconSet()` | read active theme / semantic tokens / resolved icon set |
213
+ | `useThemeControls()` | `{ theme, themeId, setTheme, themes }` — what a theme picker drives |
214
+ | `getTheme(id)` / `listThemes()` / `registerTheme({…})` | look up, list, or create a theme at runtime |
215
+ | `palettes`, `buildTokens(palette)` | the raw palettes; build the intent tokens for one |
216
+ | `mocha`, `catppuccinMocha`, `mochaTokens` | the neutral (Catppuccin Mocha) theme, palette, tokens |
217
+ | `SemanticTokens`, `Theme`, `ThemeMeta`, `Palette` | types |
218
+
219
+ Seven themes ship — `neutral` (Catppuccin Mocha) · `contrast` · `vivid` ·
220
+ `nord` · `gruvbox` · `tokyonight` (default) · `latte` (light). Components consume
221
+ **semantic tokens** (`tokens.fg`, `tokens.accent`, `tokens.stateOk`,
222
+ `tokens.domainBlue`, …) — never raw hex — so a picker calling
223
+ `useThemeControls().setTheme(id)` repaints the whole app, and `registerTheme()`
224
+ lets an app add its own palette at runtime (inheriting any slots it omits).
225
+
226
+ ### glyphs (dual-mode)
227
+
228
+ Every glyph has three variants — `{ nerd, unicode, ascii }` — so an app never
229
+ shows tofu (□). `detectIconSet()` picks the mode once at startup; the worst case
230
+ is text-shaped fallbacks (`[x]`, `pg`), never broken boxes.
231
+
232
+ | export | what |
233
+ |---|---|
234
+ | `detectIconSet(opts?)` | resolve `'nerd' \| 'unicode' \| 'ascii'` (env → user pref → font marker → terminal hints → CI → default) |
235
+ | `useGlyph()` | `(name) => string`, bound to the icon set in context |
236
+ | `glyph(name, set)` | low-level resolver |
237
+ | `registerGlyphs(...maps)` | register app-domain glyphs — verbose `{nerd,unicode,ascii,color}`, easy `{nf:'dev-laravel'}`, or `{cp:'e73f'}`; takes one or more packs (last wins) |
238
+ | `glyph(name, set)` / `glyphColor(name)` | resolve a registered glyph / its owned colour **token** (resolve via `tokens[…]`) |
239
+ | `stateGlyph(name)` | the intent map: a state name → `{ glyph, token }` (what `List`/`DescriptionList` use) |
240
+ | `COMMON_DOMAINS` (t1) · `LANGUAGES` `DATABASES` `CLOUD` `EDITORS` `OS` `COMPANIES` `FRAMEWORKS` `FILES` `SOCIAL` `ACTIONS` `PACKAGES` `DEVINFRA` (t2) · `GLYPH_PACKS` | curated packs — opt in with `registerGlyphs(PACK)` |
241
+ | `nf(name)` · `nfHas` · `registerNerdIndex` · `NERD_INDEX` | tier 3 — the raw Nerd Font index (escape hatch); `nf('fa-rocket')` → the char or `''` |
242
+ | `boxChars`, `spinnerFor`, `blocks`, `blocksH` | border sets, spinner frames, block-shade ramp, eighth-block ramp (for `ProgressBar`) |
243
+ | `cellWidth(str)` | terminal cell width — the same measure `List`/`Footer` use, so custom rows align exactly |
244
+
245
+ **Four tiers — contract is owned, content is opt-in.** blink core ships only the
246
+ *contract* glyphs (tier 0) and seeds the registry with them — states (`check
247
+ cross circle half checkboxOn checkboxOff checkboxLock warn rerun`) and nav
248
+ (`focus collapsed expanded depends flow back moreAbove moreBelow`), which never
249
+ change. Everything else is **content the app opts into**: tier 1 `COMMON_DOMAINS`
250
+ (the usual dev-tool domains), tier 2 category packs (`LANGUAGES`, `DATABASES`, …
251
+ — take only what you use), and tier 3 `nf()`, the raw Nerd Font index as a
252
+ deliberate escape hatch. A domain glyph's `color` is a **semantic token** (e.g.
253
+ `domainBlue`), so it recolours with the active theme. There is **no double-line
254
+ border** in `boxChars` — the house style is single-line rounded only.
255
+
256
+ Override env vars: `BLINK_ICON_SET=nerd|unicode|ascii`, `BLINK_NERD_FONT=1|0`,
257
+ `BLINK_ASCII=1`.
258
+
259
+ ### components
260
+
261
+ | component | what |
262
+ |---|---|
263
+ | `Pane` | box-drawn rectangle with a title inside the top border; `tone` (`resting`/`focus`/`error`) drives the colour — one rounded shape |
264
+ | `List` / `ListRow` | rows declared by **intent** (`state`, `selected`/`locked`, `domain` name, `muted`) with `►` focus caret, right-aligned meta, full-row selection fill; set `height` to window a long list |
265
+ | `Header` | the one-row top status bar — accent mark + title (`· subtitle`) + a right status slot |
266
+ | `DescriptionList` | key/value block for a detail pane; rows take intent (`state`, `muted`), aligned to a gutter |
267
+ | `LogView` | bottom-anchored, height-bounded tail of a growing line stream (subprocess output, build logs); `follow`/`wrap` |
268
+ | `Footer` | the always-visible bottom hotkey bar; inverse-video key chips + a right status slot |
269
+ | `Input` / `Cursor` | single-line field with the blinking `▎` cursor (presentational — wire keys with Ink's `useInput`); derives its `tone` from `focused`/`error` |
270
+ | `Dialog` | centred rounded (lavender) modal; `tone` (`default`/`error`); plain-text `lines` or a rich `children` body; the primary action renders in inverse-accent |
271
+ | `Banner` | one-line, non-blocking in-flow notice; `tone` (`info`/`success`/`warn`) — the framework owns the glyph |
272
+ | `Spinner` | braille spinner (ASCII fallback), driven by `useSpinnerFrame` |
273
+ | `ProgressBar` | determinate bar from the eighth-block ramp; `value` (0..1) + `width` |
274
+ | `ProgressList` | job runner: per-line `state` (`pending`/`running`/`ok`/`failed`/`waiting`/`skipped`) → glyph or live spinner + colour; windows + follows the active line like `List` |
275
+ | `Form` / `useFormNavigation` | labelled fields by `kind` (`text`/`secret`/`toggle`/`select`/`multiselect`); blink owns the control glyph, focus fill, required `*`, and error line; the headless hook drives keys (`next`/`prev`/`toggle`/`setText`/`commit`) |
276
+
277
+ ### hooks
278
+
279
+ | hook | what |
280
+ |---|---|
281
+ | `useStdoutDimensions()` | live `{ columns, rows }`, updates on resize — switch 100×30 ↔ 60×20 layouts |
282
+ | `useBlink(active?, hz?)` | the 1 Hz step-end cursor blink |
283
+ | `useSpinnerFrame({active?, intervalMs?})` | a frame counter for spinners (icon-set agnostic) |
284
+ | `useListWindow({rowCount, focusedIndex, height, ...})` | the windowing engine behind `List height` — reusable for any keyboard-paged viewport |
285
+ | `useListNavigation({ids, ...})` | headless focus movement (next/prev/first/last/seek) — you own `useInput`, it owns the cursor |
286
+ | `useListSelection({ids, mode, min?, max?})` | headless single/multi selection with min/max guards; feeds `List selectedIds` |
287
+
288
+ The three `useList*` hooks are **headless** (the [downshift](https://www.downshift-js.com/)/react-aria pattern): blink owns the *logic*, the app owns the keys (it calls the hooks' intent methods from its own `useInput`). No blink component reads keystrokes — the presentational contract stays intact.
289
+
290
+ ---
291
+
292
+ ## why blink
293
+
294
+ Ink gives you flexbox and `<Text>`. blink gives you the *decisions* — the
295
+ palette, the glyph fallback strategy, the border discipline, the focus model —
296
+ so every TUI you build is consistent and considered from the first render
297
+ instead of re-litigated per app. Build the app; inherit the house style.
298
+
299
+ The design system this codifies (palette, glyphs, the full visual contract, and
300
+ a reference app) lives in [`design-reference/`](./design-reference/), exported
301
+ from [Claude Design](https://claude.ai/design). `SKILL.md` makes it loadable as
302
+ an Agent Skill.
303
+
304
+ ## design-system fidelity
305
+
306
+ blink is a faithful port of the design system in [`design-reference/`](./design-reference/)
307
+ (the in-repo mirror is byte-identical to the upstream handoff tarball). The DS is
308
+ authored as HTML/JSX mocks; blink renders the same intent through Ink. Verified
309
+ 1:1: all 5 base palettes (every hex), the 7 themes and their token maps, every
310
+ Nerd-Font glyph codepoint across the 13 packs and the Tier-3 index, and the
311
+ glyph + colour + layout of every component.
312
+
313
+ A few places diverge **on purpose** — each is a deliberate adaptation, not drift:
314
+
315
+ - **Locked single-cell glyph swaps.** The focus caret is `►` (U+25BA) not the
316
+ DS's `▶`, and `bolt`/`ngrok` use `↯` not `⚡` — the DS glyphs are double-width
317
+ and would jitter a monospace column by a cell. These two are the *only*
318
+ Nerd-tier divergences; the unicode fallbacks are width-1 siblings.
319
+ - **`apple` unicode fallback.** The DS leaves `apple.unicode` empty (a fallback
320
+ hole — non-Nerd unicode terminals would drop to the text label), so blink uses
321
+ `◇` instead, keeping a glyph on screen. The Nerd-Font glyph (U+F179) is
322
+ unchanged.
323
+ - **Backgrounds live on `<Text>`, not `<Box>`.** Ink only takes `backgroundColor`
324
+ on `<Text>`, and a terminal cell holds one glyph with one fg + one bg (no
325
+ layering). So any filled region — the sunken `Footer` bar, a focused
326
+ `List`/`ProgressList` row band, the inverse hotkey chip — is painted by making
327
+ the *gaps and padding themselves* background-carrying spaces, often inside a
328
+ single `<Text>` with nested `<Text>` overriding the bg. There is no "fill a box,
329
+ then write text over it"; the fill *is* the text.
330
+ - **`ProgressBar` percent.** Matches the DS default `showPercent` (a ` NN%`
331
+ readout in `fgDim`); pass `showPercent={false}` when the caller prints its own
332
+ count, as the showcase does.
333
+ - **Borders.** Single-line rounded corners are house style; the `Pane` bottom
334
+ edge is drawn by Ink's `borderStyle` rather than hand-composed, but the output
335
+ is identical to the DS's hand-drawn frame.
336
+
337
+ Two small items are tracked but intentionally **not** matched, because they cost
338
+ fidelity nothing: the `vivid` selection fills interpolate in OKLab rather than the
339
+ CSS spec's `color-mix(in oklch)` (≤1/255 in one channel, invisible), and the DS's
340
+ unused box tee/cross joints and vertical-block ramp are omitted (no component
341
+ draws them; the `ProgressBar` carries the horizontal eighth-block ramp instead).
342
+
343
+ ## development
344
+
345
+ ```bash
346
+ npm install
347
+ npm run build # tsup → dist/ (ESM + .d.ts)
348
+ npm test # vitest + ink-testing-library
349
+ npm run typecheck # tsc --noEmit
350
+ npm run example # the demo launcher — pick a screen from the menu
351
+ ```
352
+
353
+ `npm run example` (`examples/index.tsx`) opens a **launcher**: a menu of demo
354
+ screens you open with ↑↓ + enter, built from blink primitives itself. Two screens
355
+ ship — press `q` inside one to return to the menu:
356
+
357
+ - **svcd** (`examples/svcd.tsx`) — the narrative reference app, a services
358
+ manager that reads like a real tool (Pane · List · Footer · Input · Dialog ·
359
+ Spinner).
360
+ - **showcase** (`examples/showcase.tsx`) — the kitchen sink: every primitive on
361
+ one screen, each region labelled with the `‹component›` it demonstrates, all
362
+ driven by the intent-not-style API (Header · Banner · DescriptionList · Pane
363
+ tones · List · Input · Form · Dialog · Spinner · ProgressBar · ProgressList).
364
+
365
+ ## license
366
+
367
+ MIT © Henry Avila