@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 +21 -0
- package/README.md +367 -0
- package/dist/index.d.ts +2429 -0
- package/dist/index.js +1901 -0
- package/package.json +71 -0
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
|