@baseworks/ui 0.0.0-stage → 0.2.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/README.md CHANGED
@@ -1,3 +1,184 @@
1
- # Temporary Holding Version
1
+ # @baseworks/ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The design system: **tokens**, **primitives**, and the **application frame**.
4
+ Plain CSS and React — no utility framework, no runtime styling.
5
+
6
+ ```tsx
7
+ import '@baseworks/ui/styles.css' // once, in the root layout
8
+ import { Button, Card, Table, Pill } from '@baseworks/ui'
9
+ ```
10
+
11
+ ## Three layers
12
+
13
+ | File | What is in it |
14
+ |---|---|
15
+ | `tokens.css` | Colour, type, shadow. One palette, light and dark. The only place a value is written. |
16
+ | `primitives.css` | Buttons, cards, tables, forms, dialogs, palette, toast. |
17
+ | `frame.css` | Rail, sub panel, top bar, assistant, and the centered gate. |
18
+
19
+ `styles.css` imports all three. A product that wants the look without the frame
20
+ imports the first two.
21
+
22
+ ## The palette
23
+
24
+ **Warm paper, ultramarine ink.** One palette, in a light and a dark form.
25
+ `prefers-color-scheme` chooses until the viewer does, with `ThemeToggle` in the
26
+ top bar. There is no brand switcher — a second palette is a second design to
27
+ keep correct in every state.
28
+
29
+ | | Light | Dark |
30
+ |---|---|---|
31
+ | Page | `#FBFAF8` | `#1A1815` |
32
+ | Card | `#FFFFFF` | `#221F1B` |
33
+ | Text | `#1A1916` | `#EDE9E1` |
34
+ | Secondary | `#67635B` | `#A5A096` |
35
+ | Accent | `#2B44CE` | `#A9B5FF` |
36
+ | ok · warn · crit | `#16794F` `#8A6112` `#B3362F` | `#4FC28C` `#DFB255` `#F0736B` |
37
+
38
+ Two decisions are worth knowing, because they are the reason it looks like this.
39
+
40
+ **The neutrals are warm, not grey.** A console that is mostly tables and text is
41
+ sat in front of for hours, and a paper ground is easier to read than the cool
42
+ grey every admin panel ships with. Closest shadcn base: `stone`.
43
+
44
+ **The accent is 231° and that is functional.** The interface is full of status,
45
+ and ok/warn/crit own green, amber and red. An accent near any of them turns
46
+ "this is a link" into "this is a problem". Ultramarine sits 76° from ok, 169°
47
+ from warn and 132° from crit, so an accented thing never reads as a state. The
48
+ palette this replaced used a vermilion accent that sat beside its own crit red.
49
+
50
+ Every pairing the interface actually makes was measured rather than eyeballed:
51
+ body text ≥ 14:1, secondary ≥ 5.7:1, the accent as a link ≥ 7.4:1, a button
52
+ label on the accent ≥ 7.4:1, each status pill against its own tinted background
53
+ ≥ 4.7:1.
54
+
55
+ `tokens.css` also carries shadcn/ui's names (`--background`, `--primary`,
56
+ `--border`, …) as aliases, so a component copied in from there inherits this
57
+ palette. They are raw colour values — the form shadcn uses with Tailwind v4, not
58
+ the older `hsl(var(--background))` triplets.
59
+
60
+ ### Light and dark
61
+
62
+ ```tsx
63
+ <script dangerouslySetInnerHTML={{ __html: themeInitScript() }} /> // in <head>
64
+ <ThemeToggle /> // in the top bar
65
+ ```
66
+
67
+ `data-theme="light|dark"` on `<html>` overrides the system in both directions —
68
+ a forced dark on a light machine, and a forced light on a dark one. The script
69
+ in `<head>` restores a stored choice before the first paint; without it the page
70
+ renders the system's answer and then flips.
71
+
72
+ `ThemeToggle` renders both icons and lets CSS hide one, under the same
73
+ conditions that pick the palette. Asking JavaScript which theme is on cannot be
74
+ answered until after hydration, so a viewer on a dark machine would see the
75
+ wrong icon for a frame on every load.
76
+
77
+ `resolvedTheme()` is the honest answer to "which theme is on": `currentTheme()`
78
+ can say `system`, which is a setting rather than a colour.
79
+
80
+ ## Type
81
+
82
+ Instrument Sans for the interface, IBM Plex Mono for anything literal (an id, a
83
+ token, a path), and **Instrument Serif for page titles only**. One serif, at the
84
+ top of a document, and nowhere else — it gives the console a voice without
85
+ making a table harder to read.
86
+
87
+ ## Components
88
+
89
+ The components are thin. They put the right class on the right element and hold
90
+ the behaviour that would otherwise be retyped in every screen: the outside-click
91
+ that closes a menu, where focus lands in a confirm dialog, the highlight in the
92
+ palette. They never know what the product is.
93
+
94
+ | | |
95
+ |---|---|
96
+ | Layout | `PageHead` `Card` `CardBody` `CardGrid` `Stats` `Stat` `KeyValue` |
97
+ | Data | `Table` `Pill` `CellName` `CellSub` |
98
+ | Forms | `Field` `FormRow` `FormError` `Switch` `Button` `IconButton` |
99
+ | Telling | `Attention` `Toast` `CodeBox` `Chips` |
100
+ | Overlays | `ConfirmDialog` `SlideOver` `Popover` `MenuItem` `CommandPalette` `Gate` |
101
+ | Chrome | `ThemeToggle` |
102
+
103
+ ### The shape of a list screen
104
+
105
+ A screen that shows a list *is* the list. What you can do to it lives in the
106
+ card's header — `<Card actions={<Button small>Add a customer</Button>}>` — and
107
+ the form it opens is a `SlideOver`, not another card above the table. The list
108
+ stays on screen while you type, which matters because you are usually adding to
109
+ something you are looking at, and a form that pushes the list away makes you
110
+ close it to check what is already there.
111
+
112
+ The module's own menu lists places, not actions. An "Add a customer" entry there
113
+ would be a menu item whose job is to scroll you to a button.
114
+
115
+ Three of them carry a rule rather than a look:
116
+
117
+ - **`ConfirmDialog`** puts focus on Cancel, never on the destructive button, so
118
+ a reflex Enter destroys nothing; `typeToConfirm` makes the person write the
119
+ resource's name, which is the only guard against acting on the wrong row.
120
+ - **`Table`** takes `columns` and a `note`, and renders the note as one
121
+ full-width row when there are no rows — loading, error, or empty. Screens used
122
+ to hand-roll that row with a `colSpan` they had to keep in step with the
123
+ header.
124
+ - **`SlideOver`** stays in the DOM and moves off-screen, which is what makes it
125
+ animate and what it then has to compensate for: it focuses its first field
126
+ when it opens, because an `autoFocus` inside fires at mount while the panel is
127
+ closed, and it is `inert` while closed, so a keyboard does not tab into a
128
+ panel nobody can see. That focus effect depends on `open` alone and reads
129
+ `onClose` through a ref — depending on the callback made it re-run on every
130
+ render, since callers write `onClose={() => setOpen(false)}`, and focus then
131
+ jumped back to the first field on every keystroke in any other one.
132
+
133
+ ## The frame
134
+
135
+ `Frame` is the whole shell — rail, sub panel, top bar, assistant, ⌘K, toast,
136
+ confirm — and it is **presentational**. It knows how a surface is laid out and
137
+ nothing about what the surface is for:
138
+
139
+ ```tsx
140
+ <Frame
141
+ modules={modules} pathname={pathname} onNavigate={(href) => router.push(href)}
142
+ counts={counts} icons={ICONS}
143
+ railTop={<OrgSwitcher …/>} railBottom={<AccountMenu …/>}
144
+ topbarLeft={<ModeTag/>} topbarRight={<ThemeToggle/>}
145
+ assistant={(s) => <AssistantPanel {...s} />}
146
+ >
147
+ {children}
148
+ </Frame>
149
+ ```
150
+
151
+ Everything that knows what a product *is* — which modules exist, who is signed
152
+ in, which organization is active, what the counts count — arrives as props or
153
+ slots. What the frame owns is the state that belongs to the layout: which panel
154
+ is open, whether the assistant has taken the middle column, what the palette is
155
+ showing, the toast that is up. Screens reach the last two through `useFrame()`.
156
+
157
+ A surface supplies its own `Module[]`; `activeModule(pathname, modules)` decides
158
+ which is current, longest-prefix wins. `navigationCommands` turns the same list
159
+ into ⌘K entries, so a new module is reachable the moment it is added.
160
+
161
+ `AssistantPanel` takes `onAsk` — that is where a surface binds a real agent.
162
+ Without it the panel says it is a preview, which keeps the frame demonstrable
163
+ before the tools exist. It **expands into the middle column**: an answer that is
164
+ a table needs the room a table needs, and the expand state lives in `Frame`
165
+ because widening the panel takes space from the content beside it.
166
+
167
+ Also here, because every surface was otherwise writing them again:
168
+ `useResource` (loading, error, refresh, `enabled`), and the formatters `age`,
169
+ `until`, `untilEpoch`, `money`, `amount`.
170
+
171
+ ## How it is consumed
172
+
173
+ The package ships **source**, not a build: consumers list it in
174
+ `transpilePackages`. It has no repository of its own yet, so it is tracked in
175
+ the baseworks-ts root alongside `packages/tsconfig`. When it is published it
176
+ gets `dotlabshq/baseworks-ui`, a `tsup` build, and a line in PACKAGES.md §2.
177
+
178
+ ## Where it came from
179
+
180
+ Ported from the zone9 console (`apps/web`), which is where this shell was
181
+ designed and proven. What came across is what no product owns: tokens, frame,
182
+ primitives. What stayed behind is everything that knows about servers, regions
183
+ or domains. `apps/console` is the reference consumer; zone9 becomes one when it
184
+ drops its copy.
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@baseworks/ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.1",
4
+ "description": "The baseworks design system: tokens, primitives and the application frame. Plain CSS and React, no utility framework.",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./styles.css": "./src/styles.css",
12
+ "./tokens.css": "./src/tokens.css",
13
+ "./primitives.css": "./src/primitives.css",
14
+ "./frame.css": "./src/frame.css"
15
+ },
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "peerDependencies": {
20
+ "react": ">=19",
21
+ "react-dom": ">=19"
22
+ },
23
+ "devDependencies": {
24
+ "@types/react": "^19.1.0",
25
+ "@types/react-dom": "^19.1.0",
26
+ "typescript": "^5.9.3",
27
+ "@baseworks/tsconfig": "0.1.0"
28
+ },
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit"
31
+ }
6
32
  }
@@ -0,0 +1,135 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * The assistant, sliding in from the right.
5
+ *
6
+ * The panel is generic; what it is connected to is not. `onAsk` is where a
7
+ * surface binds a real agent — given none, the panel answers with a note that
8
+ * says so, which is honest and keeps the frame demonstrable before the tools
9
+ * exist. Nothing here computes an answer.
10
+ *
11
+ * `expanded` is owned by the frame, not by this component: widening the panel
12
+ * takes room from the content beside it, so the decision belongs where both
13
+ * are laid out.
14
+ */
15
+
16
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
17
+
18
+ type Msg = { id: number; kind: 'user' | 'bot' | 'typing'; body?: ReactNode }
19
+ let nextId = 1
20
+
21
+ export interface AssistantPanelProps {
22
+ open: boolean
23
+ onClose: () => void
24
+ /** Whether the panel has taken the middle column as well. */
25
+ expanded?: boolean
26
+ onToggleExpand?: () => void
27
+ /** The two-letter mark in the panel's head. */
28
+ mark?: ReactNode
29
+ title?: string
30
+ subtitle?: string
31
+ /** The first thing it says. */
32
+ greeting?: ReactNode
33
+ /**
34
+ * Answer a question. Return the reply's body; throw to show an error as a
35
+ * reply. Absent means the panel is a design preview and says so.
36
+ */
37
+ onAsk?: (text: string) => Promise<ReactNode>
38
+ }
39
+
40
+ const PREVIEW = 'This is a design preview — the assistant is not connected yet. It will use the same tools the screens and the CLI use.'
41
+
42
+ export function AssistantPanel({
43
+ open,
44
+ onClose,
45
+ expanded = false,
46
+ onToggleExpand,
47
+ mark = 'bw',
48
+ title = 'Assistant',
49
+ subtitle = 'does what the screens do',
50
+ greeting = 'Hello. I can answer questions about what is here and, once the tools are connected, act on your behalf — always asking before a write.',
51
+ onAsk,
52
+ }: AssistantPanelProps) {
53
+ const [msgs, setMsgs] = useState<Msg[]>([{ id: 0, kind: 'bot', body: greeting }])
54
+ const [text, setText] = useState('')
55
+ const [busy, setBusy] = useState(false)
56
+ const scrollRef = useRef<HTMLDivElement>(null)
57
+ useEffect(() => {
58
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
59
+ }, [msgs])
60
+
61
+ const add = (m: Omit<Msg, 'id'>) => {
62
+ const id = nextId++
63
+ setMsgs((p) => [...p, { ...m, id }])
64
+ return id
65
+ }
66
+
67
+ const send = async () => {
68
+ const asked = text.trim()
69
+ if (!asked || busy) return
70
+ add({ kind: 'user', body: asked })
71
+ setText('')
72
+ const typing = add({ kind: 'typing' })
73
+ setBusy(true)
74
+ try {
75
+ const body = onAsk ? await onAsk(asked) : await new Promise<ReactNode>((r) => setTimeout(() => r(PREVIEW), 700))
76
+ setMsgs((p) => p.filter((m) => m.id !== typing))
77
+ add({ kind: 'bot', body })
78
+ } catch (err) {
79
+ setMsgs((p) => p.filter((m) => m.id !== typing))
80
+ add({ kind: 'bot', body: err instanceof Error && err.message ? err.message : 'That did not answer.' })
81
+ } finally {
82
+ setBusy(false)
83
+ }
84
+ }
85
+
86
+ return (
87
+ <aside className={'chat' + (open ? ' open' : '') + (expanded ? ' expanded' : '')} aria-label={title}>
88
+ <div className="chat-inner">
89
+ <div className="chat-head">
90
+ <div className="brand-mark" style={{ width: 26, height: 26, fontSize: 13, margin: 0 }}>{mark}</div>
91
+ <div>
92
+ <div className="ttl">{title}</div>
93
+ <div className="sub">{subtitle}</div>
94
+ </div>
95
+ {onToggleExpand && (
96
+ <button
97
+ className="icon-btn chat-expand"
98
+ onClick={onToggleExpand}
99
+ aria-label={expanded ? 'Narrow the assistant' : 'Widen the assistant'}
100
+ aria-pressed={expanded}
101
+ title={expanded ? 'Narrow' : 'Widen'}
102
+ >
103
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.7">
104
+ {expanded ? <path d="M9.5 2v4.5H14M6.5 14V9.5H2" /> : <path d="M14 2H9.5v4.5M2 14h4.5V9.5" />}
105
+ </svg>
106
+ </button>
107
+ )}
108
+ <button className="icon-btn close" onClick={onClose} aria-label="Close">✕</button>
109
+ </div>
110
+ <div className="chat-scroll" ref={scrollRef}>
111
+ {msgs.map((m) =>
112
+ m.kind === 'typing' ? (
113
+ <div key={m.id} className="typing"><i /><i /><i /></div>
114
+ ) : (
115
+ <div key={m.id} className={'msg ' + m.kind}>{m.body}</div>
116
+ ),
117
+ )}
118
+ </div>
119
+ <div className="chat-input">
120
+ <input
121
+ type="text"
122
+ placeholder="Ask for something…"
123
+ aria-label="Message"
124
+ value={text}
125
+ onChange={(e) => setText(e.target.value)}
126
+ onKeyDown={(e) => e.key === 'Enter' && void send()}
127
+ />
128
+ <button className="send" onClick={() => void send()} aria-label="Send" disabled={busy}>
129
+ <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M2 8h11M9 4l4 4-4 4" /></svg>
130
+ </button>
131
+ </div>
132
+ </div>
133
+ </aside>
134
+ )
135
+ }
package/src/button.tsx ADDED
@@ -0,0 +1,31 @@
1
+ import type { ButtonHTMLAttributes, ReactNode } from 'react'
2
+
3
+ export type ButtonTone = 'primary' | 'ghost' | 'danger'
4
+
5
+ export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
6
+ /** `danger` is for the irreversible one; it is never the default. */
7
+ tone?: ButtonTone
8
+ small?: boolean
9
+ children: ReactNode
10
+ }
11
+
12
+ /**
13
+ * One button shape for the whole system.
14
+ *
15
+ * `type` defaults to "button": inside a form the HTML default is "submit", so a
16
+ * secondary button that only opens a menu would submit the form instead.
17
+ */
18
+ export function Button({ tone = 'primary', small, className, type, children, ...rest }: ButtonProps) {
19
+ const cls = ['btn', tone === 'ghost' && 'ghost', tone === 'danger' && 'danger', small && 'sm', className]
20
+ .filter(Boolean).join(' ')
21
+ return <button type={type ?? 'button'} className={cls} {...rest}>{children}</button>
22
+ }
23
+
24
+ /** A square icon-only button, as used in the top bar. Always needs a label. */
25
+ export function IconButton({ label, className, type, children, ...rest }:
26
+ Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> & { label: string; children: ReactNode }) {
27
+ return (
28
+ <button type={type ?? 'button'} className={['icon-btn', className].filter(Boolean).join(' ')}
29
+ title={label} aria-label={label} {...rest}>{children}</button>
30
+ )
31
+ }
package/src/card.tsx ADDED
@@ -0,0 +1,85 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /** The title of a screen, and one line saying what it is. */
4
+ export function PageHead({ title, children, actions }: { title: ReactNode; children?: ReactNode; actions?: ReactNode }) {
5
+ return (
6
+ <div className="page-head">
7
+ <div><h1>{title}</h1>{children && <p>{children}</p>}</div>
8
+ {actions && <><div className="spacer" />{actions}</>}
9
+ </div>
10
+ )
11
+ }
12
+
13
+ /**
14
+ * A card. `title` gives it a header; `hint` is the one-sentence explanation
15
+ * that sits on the right of that header — the place to say what the card is
16
+ * for, so the screen does not need a paragraph above it.
17
+ *
18
+ * `actions` is what you can do to the thing in the card, in its own header:
19
+ * "Add a customer" above the directory it will appear in. That is where a
20
+ * reader looks for it, and it keeps the form that follows out of the page —
21
+ * see `SlideOver`.
22
+ */
23
+ export function Card({ id, title, hint, actions, children, style, className }: {
24
+ id?: string
25
+ title?: ReactNode
26
+ hint?: ReactNode
27
+ actions?: ReactNode
28
+ children: ReactNode
29
+ style?: React.CSSProperties
30
+ /**
31
+ * Added to `card`, for the few things a card can be that are not about its
32
+ * contents — `no-print`, most of all. Wrapping a card in a div to carry such
33
+ * a class looks harmless and is not: `.card:last-child` drops the bottom
34
+ * margin, so the wrapped card loses the gap under it and the next one appears
35
+ * welded to it.
36
+ */
37
+ className?: string
38
+ }) {
39
+ return (
40
+ <div className={className ? `card ${className}` : 'card'} id={id} style={style}>
41
+ {title && (
42
+ <div className="card-head">
43
+ <h2>{title}</h2>
44
+ {hint && <span className="cell-sub">{hint}</span>}
45
+ {actions && <div className="card-actions">{actions}</div>}
46
+ </div>
47
+ )}
48
+ {children}
49
+ </div>
50
+ )
51
+ }
52
+
53
+ /** Padded body for a card whose content is not a table. */
54
+ export function CardBody({ children, style }: { children: ReactNode; style?: React.CSSProperties }) {
55
+ return <div className="card-pad" style={style}>{children}</div>
56
+ }
57
+
58
+ /** Two columns on a wide screen, one on a narrow one. */
59
+ export function CardGrid({ children }: { children: ReactNode }) {
60
+ return <div className="grid2">{children}</div>
61
+ }
62
+
63
+ /** The strip of numbers at the top of an overview. */
64
+ export function Stats({ children }: { children: ReactNode }) {
65
+ return <div className="stats">{children}</div>
66
+ }
67
+
68
+ /**
69
+ * One number. `value` is undefined until its source answers — the dash says
70
+ * "not known yet" rather than showing a zero that would read as a fact.
71
+ */
72
+ export function Stat({ label, value, note }: { label: ReactNode; value: ReactNode; note?: ReactNode }) {
73
+ return (
74
+ <div className="stat">
75
+ <div className="k">{label}</div>
76
+ <div className="v num">{value ?? '–'}</div>
77
+ {note && <div className="s">{note}</div>}
78
+ </div>
79
+ )
80
+ }
81
+
82
+ /** A labelled row inside a card: what it is on the left, the value on the right. */
83
+ export function KeyValue({ label, children }: { label: ReactNode; children: ReactNode }) {
84
+ return <div className="kv"><span>{label}</span><b>{children}</b></div>
85
+ }
@@ -0,0 +1,59 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
+
5
+ export type PaletteItem = {
6
+ label: string
7
+ /** The intent name, shown on the right. The palette, the API and the assistant share it. */
8
+ intent: string
9
+ run: () => void
10
+ }
11
+
12
+ /**
13
+ * ⌘K. Presentational: it filters, moves the highlight and calls `run`. What is
14
+ * in the list is the application's business, never the palette's.
15
+ */
16
+ export function CommandPalette({ open, onClose, items, placeholder = 'What do you want to do?' }: {
17
+ open: boolean
18
+ onClose: () => void
19
+ items: PaletteItem[]
20
+ placeholder?: string
21
+ }) {
22
+ const inputRef = useRef<HTMLInputElement>(null)
23
+ const [q, setQ] = useState('')
24
+ const [hl, setHl] = useState(0)
25
+
26
+ useEffect(() => { if (open) { setQ(''); setHl(0); inputRef.current?.focus() } }, [open])
27
+
28
+ const shown = useMemo(
29
+ () => items.filter((it) => !q || `${it.label} ${it.intent}`.toLowerCase().includes(q.toLowerCase())),
30
+ [items, q],
31
+ )
32
+ if (!open) return null
33
+
34
+ const pick = (it: PaletteItem) => { onClose(); it.run() }
35
+
36
+ return (
37
+ <div className="cmdk on" onClick={(e) => e.target === e.currentTarget && onClose()}>
38
+ <div className="cmdk-box">
39
+ <input ref={inputRef} type="text" placeholder={placeholder} value={q}
40
+ onChange={(e) => { setQ(e.target.value); setHl(0) }}
41
+ onKeyDown={(e) => {
42
+ if (e.key === 'ArrowDown') { e.preventDefault(); setHl((h) => Math.min(h + 1, shown.length - 1)) }
43
+ if (e.key === 'ArrowUp') { e.preventDefault(); setHl((h) => Math.max(h - 1, 0)) }
44
+ if (e.key === 'Escape') onClose()
45
+ if (e.key === 'Enter' && shown[hl]) pick(shown[hl])
46
+ }} />
47
+ <div className="cmdk-list">
48
+ {shown.map((it, i) => (
49
+ <button key={it.intent + it.label} className={'cmdk-item' + (i === hl ? ' hl' : '')}
50
+ onMouseEnter={() => setHl(i)} onClick={() => pick(it)}>
51
+ <span>{it.label}</span><span className="k">{it.intent}</span>
52
+ </button>
53
+ ))}
54
+ {shown.length === 0 && <div className="cell-sub" style={{ padding: '10px 14px' }}>Nothing matches.</div>}
55
+ </div>
56
+ </div>
57
+ </div>
58
+ )
59
+ }
@@ -0,0 +1,27 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * What ⌘K can do, derived from the module list so a new module is reachable
5
+ * the moment it is added. A screen's own commands (create, revoke, switch) are
6
+ * appended by whoever owns the action.
7
+ */
8
+
9
+ import type { PaletteItem } from './command-palette'
10
+ import type { Module } from './nav'
11
+
12
+ /**
13
+ * The palette offers what the rail offers. A command for something the surface
14
+ * has filtered out would fail on the way in, and offering it is a worse answer
15
+ * than not knowing about it.
16
+ */
17
+ export function navigationCommands(modules: Module[], go: (href: string) => void): PaletteItem[] {
18
+ const out: PaletteItem[] = []
19
+ for (const m of modules) {
20
+ for (const it of m.items) {
21
+ if (!it.href) continue
22
+ const slug = it.label.toLowerCase().replace(/\s+/g, '-')
23
+ out.push({ label: `→ ${m.title} · ${it.label}`, intent: `${m.key}.${slug}`, run: () => go(it.href!) })
24
+ }
25
+ }
26
+ return out
27
+ }
package/src/dialog.tsx ADDED
@@ -0,0 +1,96 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+
5
+ export type ConfirmRequest = {
6
+ title: string
7
+ /** What is about to happen, in one sentence. */
8
+ intro: string
9
+ /** What disappears for good. */
10
+ losses?: string[]
11
+ /** A footnote: a grace period, where the data goes. */
12
+ note?: string
13
+ confirmLabel: string
14
+ /** When set, the confirm button stays closed until this is typed exactly. */
15
+ typeToConfirm?: string
16
+ onConfirm: () => Promise<void>
17
+ }
18
+
19
+ /**
20
+ * The confirmation for a destructive action.
21
+ *
22
+ * Two things here are deliberate. Focus lands on Cancel (or on the text box),
23
+ * never on the destructive button, so a reflex Enter destroys nothing. And
24
+ * `typeToConfirm` is the only guard against clicking the right button on the
25
+ * wrong row — deletion has no undo, so the name has to be written out.
26
+ *
27
+ * A failure keeps the dialog open and shows why: closing it would leave the
28
+ * person believing the thing happened.
29
+ */
30
+ export function ConfirmDialog({ request, onClose }: { request: ConfirmRequest | null; onClose: () => void }) {
31
+ const [typed, setTyped] = useState('')
32
+ const [busy, setBusy] = useState(false)
33
+ const [error, setError] = useState<string | null>(null)
34
+ const inputRef = useRef<HTMLInputElement>(null)
35
+ const cancelRef = useRef<HTMLButtonElement>(null)
36
+
37
+ useEffect(() => {
38
+ if (!request) return
39
+ setTyped(''); setError(null); setBusy(false)
40
+ const t = setTimeout(() => (request.typeToConfirm ? inputRef.current : cancelRef.current)?.focus(), 30)
41
+ return () => clearTimeout(t)
42
+ }, [request])
43
+
44
+ useEffect(() => {
45
+ if (!request) return
46
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !busy) onClose() }
47
+ document.addEventListener('keydown', onKey)
48
+ return () => document.removeEventListener('keydown', onKey)
49
+ }, [request, busy, onClose])
50
+
51
+ if (!request) return null
52
+ const ready = !request.typeToConfirm || typed.trim() === request.typeToConfirm
53
+
54
+ const run = async () => {
55
+ if (!ready || busy) return
56
+ setBusy(true); setError(null)
57
+ try {
58
+ await request.onConfirm()
59
+ onClose()
60
+ } catch (err) {
61
+ setError(err instanceof Error ? err.message : 'The action failed.')
62
+ setBusy(false)
63
+ }
64
+ }
65
+
66
+ return (
67
+ <div className="dialog-wrap" onMouseDown={() => !busy && onClose()}>
68
+ <div className="dialog" role="alertdialog" aria-modal="true" aria-label={request.title}
69
+ onMouseDown={(e) => e.stopPropagation()}>
70
+ <div className="dialog-head"><span className="sev" /><h3>{request.title}</h3></div>
71
+ <div className="dialog-body">
72
+ <p>{request.intro}</p>
73
+ {request.losses && request.losses.length > 0 && (
74
+ <ul className="dialog-losses">{request.losses.map((l) => <li key={l}>{l}</li>)}</ul>
75
+ )}
76
+ {request.note && <p className="cell-sub">{request.note}</p>}
77
+ {request.typeToConfirm && (
78
+ <div className="field">
79
+ <label htmlFor="confirm-name">Type <span className="mono">{request.typeToConfirm}</span> to confirm</label>
80
+ <input id="confirm-name" ref={inputRef} type="text" autoComplete="off" spellCheck={false}
81
+ value={typed} disabled={busy} onChange={(e) => setTyped(e.target.value)}
82
+ onKeyDown={(e) => e.key === 'Enter' && run()} />
83
+ </div>
84
+ )}
85
+ {error && <div className="form-error">{error}</div>}
86
+ </div>
87
+ <div className="dialog-foot">
88
+ <button className="btn ghost" ref={cancelRef} onClick={onClose} disabled={busy}>Cancel</button>
89
+ <button className="btn danger" onClick={run} disabled={!ready || busy}>
90
+ {busy ? 'Working…' : request.confirmLabel}
91
+ </button>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ )
96
+ }