@baseworks/ui 0.0.0-stage → 0.2.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/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @baseworks/ui — the design system every baseworks surface is built from.
3
+ *
4
+ * One palette, warm paper and ultramarine, in a light and a dark form. The
5
+ * operating system chooses until the viewer does, with `ThemeToggle`. There is
6
+ * no brand switcher: a second palette is a second design to keep correct.
7
+ *
8
+ * Three layers, and they stay in that order:
9
+ *
10
+ * tokens.css colour, type, spacing. The only place a value is written.
11
+ * primitives.css buttons, cards, tables, forms, dialogs.
12
+ * frame.css the rail, the sub panel, the top bar, the gate.
13
+ *
14
+ * The components here are thin: they put the right class on the right element
15
+ * and hold the behaviour that would otherwise be retyped (outside-click on a
16
+ * menu, focus placement in a confirm dialog, the highlight in the palette).
17
+ * Anything that knows what the product *is* belongs in the product.
18
+ *
19
+ * Import the stylesheet once, in the root layout:
20
+ *
21
+ * import '@baseworks/ui/styles.css'
22
+ */
23
+
24
+ export { Button, IconButton } from './button'
25
+ export type { ButtonProps, ButtonTone } from './button'
26
+
27
+ export { PageHead, Card, CardBody, CardGrid, Stats, Stat, KeyValue } from './card'
28
+
29
+ export { Table, Pill, CellName, CellSub } from './table'
30
+ export type { PillTone } from './table'
31
+
32
+ export { Field, FormRow, FormError, Switch } from './form'
33
+
34
+ export { Attention, CodeBox, Toast, Chips } from './feedback'
35
+ export type { AttentionTone } from './feedback'
36
+
37
+ export { ConfirmDialog } from './dialog'
38
+ export type { ConfirmRequest } from './dialog'
39
+
40
+ export { Popover, MenuItem, MenuHead, MenuSeparator } from './menu'
41
+
42
+ export { CommandPalette } from './command-palette'
43
+ export type { PaletteItem } from './command-palette'
44
+
45
+ export { SlideOver } from './slide-over'
46
+ export { Gate } from './gate'
47
+
48
+ export { Frame, RailTip, useFrame } from './frame'
49
+ export type { FrameProps, FrameCtx } from './frame'
50
+ export { activeModule } from './nav'
51
+ export type { Module, SubItem } from './nav'
52
+ export { AssistantPanel } from './assistant'
53
+ export type { AssistantPanelProps } from './assistant'
54
+ export { navigationCommands } from './commands'
55
+ export { useResource } from './use-resource'
56
+ export type { Resource, ResourceOptions } from './use-resource'
57
+ export { age, until, untilEpoch, money, amount } from './format'
58
+
59
+ export { applyTheme, currentTheme, resolvedTheme, themeInitScript } from './theme'
60
+ export type { ThemeMode } from './theme'
61
+ export { ThemeToggle } from './theme-toggle'
package/src/menu.tsx ADDED
@@ -0,0 +1,62 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
4
+
5
+ /**
6
+ * A menu anchored to the button that opens it.
7
+ *
8
+ * It closes on a click anywhere outside itself, which every dropdown in the
9
+ * frame needs and each one used to implement again. `align="rail"` is the
10
+ * variant that opens to the right of the icon rail instead of below the button.
11
+ */
12
+ export function Popover({ trigger, children, align = 'below', menuClassName, menuStyle, label }: {
13
+ /** Rendered with the open state so the trigger can show it (a caret, a pressed look). */
14
+ trigger: (props: { open: boolean; toggle: () => void }) => ReactNode
15
+ children: ReactNode | ((close: () => void) => ReactNode)
16
+ align?: 'below' | 'rail'
17
+ menuClassName?: string
18
+ menuStyle?: React.CSSProperties
19
+ label?: string
20
+ }) {
21
+ const [open, setOpen] = useState(false)
22
+ const rootRef = useRef<HTMLDivElement>(null)
23
+
24
+ useEffect(() => {
25
+ if (!open) return
26
+ const close = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false) }
27
+ const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
28
+ document.addEventListener('mousedown', close)
29
+ document.addEventListener('keydown', esc)
30
+ return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', esc) }
31
+ }, [open])
32
+
33
+ const cls = ['brand-menu', align === 'rail' && 'org-menu rail', menuClassName].filter(Boolean).join(' ')
34
+ return (
35
+ <div className="brand-pop" ref={rootRef}>
36
+ {trigger({ open, toggle: () => setOpen((v) => !v) })}
37
+ {open && (
38
+ <div className={cls} style={menuStyle} role="menu" aria-label={label}>
39
+ {typeof children === 'function' ? children(() => setOpen(false)) : children}
40
+ </div>
41
+ )}
42
+ </div>
43
+ )
44
+ }
45
+
46
+ /** One row in a Popover. `selected` marks the current choice with a tick. */
47
+ export function MenuItem({ selected, onClick, children }:
48
+ { selected?: boolean; onClick?: () => void; children: ReactNode }) {
49
+ return (
50
+ <button className={'brand-opt' + (selected ? ' sel' : '')} onClick={onClick} role="menuitem">
51
+ {children}{selected && <span className="check">✓</span>}
52
+ </button>
53
+ )
54
+ }
55
+
56
+ export function MenuHead({ children }: { children: ReactNode }) {
57
+ return <div className="org-menu-head">{children}</div>
58
+ }
59
+
60
+ export function MenuSeparator() {
61
+ return <div className="org-menu-sep" role="separator" />
62
+ }
package/src/nav.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The shape of a module list — not any particular list.
3
+ *
4
+ * A surface supplies its own modules: the console's are the realm's services,
5
+ * a product's are its own screens. What is shared is the shape and the rule
6
+ * for which one is active, because the frame renders both the same way.
7
+ */
8
+
9
+ export type SubItem = {
10
+ label: string
11
+ /** A route to go to. */
12
+ href?: string
13
+ /** A card on the module's own page to scroll to. */
14
+ hash?: string
15
+ /** Which count to show beside the label, looked up in `counts`. */
16
+ countKey?: string
17
+ /** Designed, not built. The frame says so rather than navigating. */
18
+ soon?: boolean
19
+ }
20
+
21
+ export type Module = {
22
+ key: string
23
+ href: string
24
+ title: string
25
+ /** Shown under the title when no count is configured. */
26
+ desc: string
27
+ descCountKey?: string
28
+ descCountLabel?: string
29
+ /**
30
+ * The service this module is a face for, if it needs one. The frame does not
31
+ * use this — a surface filters its own list before passing it in — but it
32
+ * travels with the module so that filter has something to read.
33
+ */
34
+ service?: string
35
+ items: SubItem[]
36
+ }
37
+
38
+ /**
39
+ * The module a path belongs to: the longest `href` that prefixes it, so
40
+ * `/reports/vat` picks `reports` over `/`. Falls back to the first module.
41
+ */
42
+ export function activeModule(pathname: string, modules: Module[]): Module {
43
+ const found = modules
44
+ .filter((m) => (m.href === '/' ? pathname === '/' : pathname.startsWith(m.href)))
45
+ .sort((a, b) => b.href.length - a.href.length)[0]
46
+ return found ?? modules[0]!
47
+ }
@@ -0,0 +1,239 @@
1
+ /* @baseworks/ui — primitives.
2
+
3
+ The pieces a screen is built from: buttons, cards, tables, forms, dialogs.
4
+ They know nothing about what the product is; every colour comes from
5
+ tokens.css. Classes, not utilities: one name per component, so a screen reads
6
+ as markup rather than as a list of measurements. */
7
+
8
+ /* Page header — the title of a screen and one line saying what it is. */
9
+ .page-head{display:flex;align-items:flex-end;gap:16px;margin:4px 0 20px;flex-wrap:wrap}
10
+ .page-head h1{font-family:var(--display);font-size:27px;font-weight:400;margin:0;letter-spacing:-.01em;text-wrap:balance}
11
+ .page-head p{margin:2px 0 0;color:var(--muted);font-size:13px}
12
+ .page-head .spacer{flex:1}
13
+ /* The light/dark toggle. Which icon shows is decided by the same conditions
14
+ tokens.css uses to pick the palette, so it is correct in the first paint and
15
+ needs no script until someone clicks it. */
16
+ .theme-toggle .i-to-dark,.theme-toggle .i-to-light{display:none;line-height:0}
17
+ .theme-toggle .i-to-dark{display:inline-block}
18
+ @media (prefers-color-scheme: dark){
19
+ :root:not([data-theme="light"]) .theme-toggle .i-to-dark{display:none}
20
+ :root:not([data-theme="light"]) .theme-toggle .i-to-light{display:inline-block}
21
+ }
22
+ :root[data-theme="dark"] .theme-toggle .i-to-dark{display:none}
23
+ :root[data-theme="dark"] .theme-toggle .i-to-light{display:inline-block}
24
+ :root[data-theme="light"] .theme-toggle .i-to-dark{display:inline-block}
25
+ :root[data-theme="light"] .theme-toggle .i-to-light{display:none}
26
+
27
+ /* Buttons. One shape; ghost is the quiet one, danger the irreversible one. */
28
+ .btn{
29
+ display:inline-flex;align-items:center;gap:8px;padding:8px 14px;border-radius:8px;font-weight:600;
30
+ background:var(--accent);color:var(--accent-ink);
31
+ }
32
+ .btn:hover{filter:brightness(1.06)}
33
+ .btn.ghost{background:var(--surface);color:var(--ink);border:1px solid var(--line-strong);font-weight:500}
34
+ .btn.ghost:hover{background:var(--surface2);filter:none}
35
+ .btn.sm{padding:5px 10px;font-size:12.5px;border-radius:7px}
36
+ .btn.danger{background:var(--crit);color:#fff}
37
+ .btn.danger[disabled]{opacity:.45;cursor:not-allowed}
38
+ .btn[disabled]{opacity:.6;cursor:progress}
39
+ /* Cards, the stat strip, and key/value rows. */
40
+ /* Cards stacked down a page need air between them, and a screen is usually a
41
+ stack of them. The gap lives on the card rather than on a wrapper so a screen
42
+ can put one anywhere without remembering to space it; inside `.grid2` the
43
+ grid's own gap takes over and this is cancelled. */
44
+ .card{background:var(--surface);border:1px solid var(--line);border-radius:12px;margin-bottom:16px}
45
+ .card:last-child{margin-bottom:0}
46
+ .grid2>.card{margin-bottom:0}
47
+ .card-pad{padding:18px}
48
+ .card-head{
49
+ display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;
50
+ padding:14px 18px;border-bottom:1px solid var(--line);
51
+ }
52
+ .card-head h2{padding:0;border:0}
53
+ .card-head .cell-sub{margin-left:auto;text-align:right}
54
+ /* What you can do to the card's contents, in its header. `margin-left:auto`
55
+ pushes it right; when a hint is there too, the hint has already claimed the
56
+ auto margin and this simply follows it. */
57
+ .card-actions{margin-left:auto;display:flex;gap:8px;flex:none}
58
+ .card-head .cell-sub + .card-actions{margin-left:14px}
59
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:20px}
60
+ .stat{padding:16px 18px}
61
+ .stat .k{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:6px}
62
+ .stat .v{font-family:var(--sans);font-size:30px;font-weight:600;line-height:1.15;margin-top:4px}
63
+ .stat .s{font-size:12px;color:var(--faint);margin-top:2px}
64
+ .stat .s b{color:var(--ok);font-weight:600}
65
+ .grid2{display:grid;grid-template-columns:1.4fr 1fr;gap:16px}
66
+ @media(max-width:980px){.grid2{grid-template-columns:1fr}}
67
+ .card h2{font-size:13px;font-weight:600;margin:0;padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px}
68
+ .card h2 .mini{margin-left:auto;font-size:12px;color:var(--accent);font-weight:600;cursor:pointer}
69
+ .kv{display:flex;justify-content:space-between;gap:16px;padding:8px 0;border-bottom:1px solid var(--line);font-size:13px}
70
+ .kv:last-child{border-bottom:0}
71
+ .kv>span:first-child{color:var(--muted)}
72
+ /* Attention strip — something the reader has to know before acting. */
73
+ .attn{
74
+ display:flex;align-items:center;gap:12px;padding:10px 14px;border-radius:10px;
75
+ border:1px solid var(--line);background:var(--surface);font-size:13px;
76
+ }
77
+ .attn .sev{width:8px;height:8px;border-radius:50%;flex:none}
78
+ .attn.warn .sev{background:var(--warn)}
79
+ .attn.warn{border-color:var(--warn);border-left-width:3px}
80
+ .attn.crit .sev{background:var(--crit)}
81
+ .attn.crit{border-color:var(--crit);border-left-width:3px}
82
+ .attn .act{margin-left:auto;color:var(--accent);font-weight:600;flex:none}
83
+ /* Lists: an activity feed and a progress bar. */
84
+ .feed{list-style:none;margin:0;padding:6px 0}
85
+ .feed li{display:flex;gap:12px;padding:9px 18px;font-size:13px;align-items:baseline}
86
+ .feed li+li{border-top:1px solid var(--line)}
87
+ .feed time{font-family:var(--mono);font-size:11px;color:var(--faint);flex:none;width:44px}
88
+ .feed .via{margin-left:auto;flex:none;font-size:11px;color:var(--faint);border:1px solid var(--line);border-radius:99px;padding:1px 8px}
89
+ .feed .via.chat{color:var(--accent);border-color:var(--accent)}
90
+ .prog{height:4px;border-radius:99px;background:var(--surface2);margin-top:8px;overflow:hidden}
91
+ .prog i{display:block;height:100%;background:var(--accent);border-radius:99px}
92
+ /* Tables, their toolbar, status pills, and the per-row menu.
93
+
94
+ The row menu is position:fixed and takes its coordinates from JS, because
95
+ the scroll container's overflow would otherwise clip it. */
96
+ .toolbar{display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap}
97
+ .toolbar input[type=search]{
98
+ border:1px solid var(--line-strong);border-radius:8px;padding:7px 12px;background:var(--surface);
99
+ min-width:220px;color:var(--ink);
100
+ }
101
+ .toolbar input::placeholder{color:var(--faint)}
102
+ .table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:12px;background:var(--surface)}
103
+ table{border-collapse:collapse;width:100%;font-size:13px;min-width:640px}
104
+ th{
105
+ text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--faint);
106
+ font-weight:600;padding:10px 16px;border-bottom:1px solid var(--line);white-space:nowrap;
107
+ }
108
+ td{padding:12px 16px;border-bottom:1px solid var(--line);vertical-align:middle}
109
+ tr:last-child td{border-bottom:none}
110
+ tbody tr:hover{background:var(--surface2)}
111
+ .cell-name{font-weight:600}
112
+ .cell-name .cell-tag{
113
+ margin-left:7px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;
114
+ color:var(--muted);border:1px solid var(--line-strong);border-radius:99px;padding:1px 6px;
115
+ vertical-align:1px;white-space:nowrap;
116
+ }
117
+ .cell-sub{font-size:11.5px;color:var(--faint)}
118
+ .pill{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;border-radius:99px;padding:2px 10px}
119
+ .pill .d{width:6px;height:6px;border-radius:50%}
120
+ .pill.ok{background:var(--ok-soft);color:var(--ok)}
121
+ .pill.ok .d{background:var(--ok)}
122
+ .pill.off{background:var(--surface2);color:var(--muted)}
123
+ .pill.off .d{background:var(--faint)}
124
+ .pill.warn{background:var(--warn-soft);color:var(--warn)}
125
+ .pill.warn .d{background:var(--warn)}
126
+ .pill.crit{background:var(--crit-soft);color:var(--crit)}
127
+ .pill.crit .d{background:var(--crit)}
128
+ .pill.acc{background:var(--accent-soft);color:var(--accent)}
129
+ .pill.acc .d{background:var(--accent)}
130
+ .row-actions{color:var(--faint);font-weight:700;letter-spacing:.1em}
131
+ .row-menu{
132
+ position:fixed;z-index:80;min-width:170px;
133
+ max-height:calc(100vh - 16px);overflow-y:auto;
134
+ background:var(--surface);border:1px solid var(--line);border-radius:10px;
135
+ box-shadow:var(--shadow);padding:6px;display:flex;flex-direction:column;gap:2px;
136
+ }
137
+ .row-menu button{
138
+ text-align:left;padding:7px 10px;border-radius:7px;font-size:13px;color:var(--ink);width:100%;
139
+ }
140
+ .row-menu button:hover{background:var(--surface2)}
141
+ .row-menu button.danger{color:var(--crit)}
142
+ .row-menu button.danger:hover{background:var(--crit-soft)}
143
+ /* Tabs. */
144
+ .tabs{display:flex;gap:2px;border-bottom:1px solid var(--line);margin-bottom:16px;overflow-x:auto}
145
+ .tab{
146
+ position:relative;padding:9px 14px;font-size:13px;font-weight:600;color:var(--muted);
147
+ border-bottom:2px solid transparent;white-space:nowrap;background:none;
148
+ }
149
+ .tab:hover{color:var(--ink)}
150
+ .tab.active{color:var(--ink);border-bottom-color:var(--accent)}
151
+ .tab-badge{
152
+ display:inline-block;margin-left:7px;min-width:17px;padding:1px 5px;border-radius:9px;
153
+ background:var(--accent-soft);color:var(--accent);font-size:11px;font-weight:700;text-align:center;
154
+ }
155
+ .tab-badge.warn{background:color-mix(in srgb,var(--warn) 18%,transparent);color:var(--warn)}
156
+ /* Forms: fields, single-line rows, switches, errors.
157
+
158
+ The input selector excludes what is not a text box rather than listing what
159
+ is: a new input type should inherit the field styling by default, and a
160
+ checkbox stretched to 100% width is the bug this avoids. */
161
+ .form-error{
162
+ font-size:12.5px;color:var(--crit);background:var(--crit-soft);
163
+ border-radius:8px;padding:8px 12px;
164
+ }
165
+ .sw{width:34px;height:20px;border-radius:99px;background:var(--line-strong);position:relative;transition:background .15s;flex:none}
166
+ .sw::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--surface);transition:left .15s;box-shadow:0 1px 3px rgba(0,0,0,.25)}
167
+ .sw.on{background:var(--ok)}
168
+ .sw.on::after{left:16px}
169
+ .field label{display:block;font-size:12px;font-weight:600;color:var(--muted);margin-bottom:6px}
170
+ .field input:not([type=checkbox]):not([type=radio]):not([type=range]),
171
+ .field select,
172
+ .field textarea,
173
+ .form-row input:not([type=checkbox]):not([type=radio]),
174
+ .form-row select{
175
+ width:100%;border:1px solid var(--line-strong);border-radius:8px;padding:9px 12px;
176
+ background:var(--surface);color:var(--ink);
177
+ }
178
+ .optline{display:flex;align-items:center;gap:10px;padding:10px 0;border-top:1px solid var(--line);font-size:13px}
179
+ .optline small{color:var(--faint);display:block}
180
+ .optline .sw{margin-left:auto}
181
+ .form-row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
182
+ .form-row input{flex:1;min-width:220px}
183
+ .form-row select{flex:none;width:auto;min-width:150px}
184
+ .form-row .btn{flex:none}
185
+ /* Chips — a short, clickable suggestion. */
186
+ .chips{display:flex;flex-wrap:wrap;gap:6px}
187
+ .chip{font-size:12px;border:1px solid var(--line-strong);background:var(--surface);border-radius:99px;padding:5px 12px;color:var(--muted)}
188
+ .chip:hover{border-color:var(--accent);color:var(--accent)}
189
+ /* A block of literal text: a token, a command, a config fragment. */
190
+ .codebox{font-family:var(--mono);font-size:12.5px;background:var(--surface2);border:1px solid var(--line);border-radius:8px;padding:10px 12px;white-space:pre-wrap;word-break:break-all}
191
+ /* Dialog — a decision that blocks the page until it is answered. */
192
+ .dialog-wrap{
193
+ position:fixed;inset:0;z-index:90;display:flex;align-items:flex-start;justify-content:center;
194
+ padding:16vh 16px 24px;overflow-y:auto;background:rgba(10,16,15,.5);
195
+ }
196
+ .dialog{
197
+ width:min(440px,92vw);background:var(--surface);border:1px solid var(--line);
198
+ border-radius:14px;box-shadow:var(--shadow);overflow:hidden;
199
+ }
200
+ .dialog-head{display:flex;align-items:center;gap:10px;padding:16px 20px;border-bottom:1px solid var(--line)}
201
+ .dialog-head .sev{width:8px;height:8px;border-radius:50%;background:var(--crit);flex:none;box-shadow:0 0 0 3px var(--crit-soft)}
202
+ .dialog-head h3{margin:0;font-size:15.5px;font-family:var(--sans);font-weight:600}
203
+ .dialog-body{padding:18px 20px;max-height:62vh;overflow-y:auto;display:flex;flex-direction:column;gap:12px;font-size:13.5px;line-height:1.5}
204
+ .dialog-body p{margin:0}
205
+ .dialog-losses{margin:0;padding-left:18px;color:var(--muted);font-size:13px;display:flex;flex-direction:column;gap:3px}
206
+ .dialog-foot{padding:14px 20px;border-top:1px solid var(--line);display:flex;gap:10px;justify-content:flex-end}
207
+ /* Slide-over panel: a form that keeps the page behind it visible. */
208
+ .scrim{position:fixed;inset:0;background:rgba(10,16,15,.45);z-index:60;opacity:0;pointer-events:none;transition:opacity .18s}
209
+ .scrim.on{opacity:1;pointer-events:auto}
210
+ .panel{
211
+ position:fixed;top:0;right:0;height:100vh;width:min(480px,100vw);z-index:70;background:var(--surface);
212
+ border-left:1px solid var(--line);box-shadow:var(--shadow);transform:translateX(102%);transition:transform .22s ease;
213
+ display:flex;flex-direction:column;
214
+ }
215
+ .panel.on{transform:none}
216
+ .panel-head{display:flex;align-items:center;gap:10px;padding:16px 20px;border-bottom:1px solid var(--line)}
217
+ .panel-head .icon-btn{margin-left:auto}
218
+ .panel-head h3{margin:0;font-size:16px;font-family:var(--sans);font-weight:600}
219
+ .panel-body{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:18px}
220
+ .panel-foot{padding:14px 20px;border-top:1px solid var(--line);display:flex;gap:10px;justify-content:flex-end}
221
+ /* Command palette. */
222
+ .cmdk{position:fixed;inset:0;z-index:80;display:none;align-items:flex-start;justify-content:center;padding-top:14vh;background:rgba(10,16,15,.45)}
223
+ .cmdk.on{display:flex}
224
+ .cmdk-box{width:min(560px,92vw);background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
225
+ .cmdk-box input{
226
+ width:100%;border:none;background:transparent;padding:16px 20px;font-size:15px;color:var(--ink);outline:none;
227
+ border-bottom:1px solid var(--line);
228
+ }
229
+ .cmdk-list{max-height:320px;overflow-y:auto;padding:8px}
230
+ .cmdk-item{width:100%;display:flex;align-items:center;gap:12px;text-align:left;padding:10px 12px;border-radius:8px;font-size:13.5px}
231
+ .cmdk-item:hover,.cmdk-item.hl{background:var(--accent-soft)}
232
+ .cmdk-item .k{font-family:var(--mono);font-size:11px;color:var(--faint);margin-left:auto}
233
+ /* Toast — the receipt for something that already happened. */
234
+ .toast{
235
+ position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(80px);z-index:90;
236
+ background:var(--ink);color:var(--bg);padding:10px 18px;border-radius:10px;font-size:13px;font-weight:500;
237
+ transition:transform .22s ease;box-shadow:var(--shadow);max-width:90vw;
238
+ }
239
+ .toast.on{transform:translateX(-50%) translateY(0)}
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, type ReactNode } from 'react'
4
+
5
+ /**
6
+ * A form that slides in from the right and keeps the page behind it visible —
7
+ * for adding to a list while still reading the list you are adding to.
8
+ *
9
+ * The panel stays in the DOM and is moved off-screen, which is what makes the
10
+ * animation possible and creates two problems it has to solve itself:
11
+ *
12
+ * - `autoFocus` on a field inside fires when the panel first mounts, closed, so
13
+ * opening it leaves focus on the button behind. The first field is focused
14
+ * here instead, and only when `open` changes — an effect that also depended on
15
+ * `onClose` re-ran on every render, because a caller writing `onClose={() =>
16
+ * setOpen(false)}` passes a new function each time, and the focus then jumped
17
+ * back to the first field on every keystroke in any other one.
18
+ * - A closed panel's fields are still tabbable. `inert` takes the whole thing
19
+ * out of the tab order and away from assistive technology until it is open,
20
+ * so a keyboard does not wander into a panel nobody can see.
21
+ */
22
+ export function SlideOver({ open, onClose, title, footer, children }: {
23
+ open: boolean
24
+ onClose: () => void
25
+ title: ReactNode
26
+ footer?: ReactNode
27
+ children: ReactNode
28
+ }) {
29
+ const bodyRef = useRef<HTMLDivElement>(null)
30
+ // Read through a ref so the effect below depends on `open` alone. See the
31
+ // note above: depending on the callback itself made it re-run per render.
32
+ const closeRef = useRef(onClose)
33
+ closeRef.current = onClose
34
+
35
+ useEffect(() => {
36
+ if (!open) return
37
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeRef.current() }
38
+ document.addEventListener('keydown', onKey)
39
+ // After the frame that opens it: focusing an element still being
40
+ // transformed into place scrolls the panel oddly in some browsers.
41
+ const t = setTimeout(() => {
42
+ bodyRef.current?.querySelector<HTMLElement>(
43
+ 'input:not([disabled]), select:not([disabled]), textarea:not([disabled])',
44
+ )?.focus()
45
+ }, 60)
46
+ return () => { document.removeEventListener('keydown', onKey); clearTimeout(t) }
47
+ }, [open])
48
+
49
+ return (
50
+ <>
51
+ <div className={'scrim' + (open ? ' on' : '')} onClick={onClose} />
52
+ <aside
53
+ className={'panel' + (open ? ' on' : '')}
54
+ inert={!open}
55
+ aria-label={typeof title === 'string' ? title : undefined}
56
+ >
57
+ <div className="panel-head">
58
+ <h3>{title}</h3>
59
+ <button className="icon-btn" aria-label="Close" onClick={onClose}>✕</button>
60
+ </div>
61
+ <div className="panel-body" ref={bodyRef}>{children}</div>
62
+ {footer && <div className="panel-foot">{footer}</div>}
63
+ </aside>
64
+ </>
65
+ )
66
+ }
package/src/styles.css ADDED
@@ -0,0 +1,7 @@
1
+ /* @baseworks/ui — the whole stylesheet.
2
+ Import this once, in the app's root layout: import '@baseworks/ui/styles.css'
3
+ The three files can also be imported separately when a product wants the
4
+ tokens and primitives without the frame. */
5
+ @import './tokens.css';
6
+ @import './primitives.css';
7
+ @import './frame.css';
package/src/table.tsx ADDED
@@ -0,0 +1,53 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export type PillTone = 'neutral' | 'ok' | 'off' | 'warn' | 'crit' | 'acc'
4
+
5
+ /** A status, as a word and a colour. Never a colour alone. */
6
+ export function Pill({ tone = 'neutral', children }: { tone?: PillTone; children: ReactNode }) {
7
+ return (
8
+ <span className={'pill' + (tone === 'neutral' ? '' : ` ${tone}`)}>
9
+ {tone !== 'neutral' && <span className="d" />}{children}
10
+ </span>
11
+ )
12
+ }
13
+
14
+ /**
15
+ * The first line of a cell: the thing's name.
16
+ *
17
+ * `tag` is the small note that belongs to the name rather than to a column of
18
+ * its own — "you", "active", "default". It has its own class instead of
19
+ * borrowing the rail's, which only has a rule inside the rail and rendered as
20
+ * bare text pushed against the name everywhere else.
21
+ */
22
+ export function CellName({ children, tag }: { children: ReactNode; tag?: ReactNode }) {
23
+ return <div className="cell-name">{children}{tag && <span className="cell-tag">{tag}</span>}</div>
24
+ }
25
+
26
+ /** The quiet second line: an id, a timestamp, a reason. */
27
+ export function CellSub({ children }: { children: ReactNode }) {
28
+ return <div className="cell-sub">{children}</div>
29
+ }
30
+
31
+ /**
32
+ * A table with its scroll container.
33
+ *
34
+ * `note` renders as a single full-width row and is the only thing shown when
35
+ * there are no rows: loading, an error, or "nothing here yet". Screens used to
36
+ * hand-roll that row with a colSpan they had to keep in sync with the header;
37
+ * `columns` gives the count for free.
38
+ */
39
+ export function Table({ columns, note, children }:
40
+ { columns: ReactNode[]; note?: ReactNode; children?: ReactNode }) {
41
+ const empty = !children || (Array.isArray(children) && children.flat().filter(Boolean).length === 0)
42
+ return (
43
+ <div className="table-wrap">
44
+ <table>
45
+ <thead><tr>{columns.map((c, i) => <th key={i}>{c}</th>)}</tr></thead>
46
+ <tbody>
47
+ {children}
48
+ {note && empty && <tr><td colSpan={columns.length}><div className="cell-sub">{note}</div></td></tr>}
49
+ </tbody>
50
+ </table>
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+
3
+ import { applyTheme, resolvedTheme } from './theme'
4
+
5
+ /**
6
+ * Light ↔ dark, in the top bar.
7
+ *
8
+ * Both icons are rendered and CSS hides one, using the same conditions
9
+ * tokens.css uses to pick the palette. The alternative — asking JavaScript
10
+ * which theme is on and rendering that icon — cannot know the answer until
11
+ * after hydration, so a viewer on a dark system would see the wrong icon for a
12
+ * frame on every load. Here the icon is right in the first paint, and script
13
+ * runs only when someone clicks.
14
+ */
15
+ export function ThemeToggle({ prefix }: { prefix?: string }) {
16
+ return (
17
+ <button
18
+ className="icon-btn theme-toggle"
19
+ title="Switch between light and dark"
20
+ aria-label="Switch between light and dark"
21
+ onClick={() => applyTheme(resolvedTheme() === 'dark' ? 'light' : 'dark', prefix)}
22
+ >
23
+ {/* Shown in light: a moon, because it offers dark. */}
24
+ <span className="i-to-dark" aria-hidden="true">
25
+ <svg width="17" height="17" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M13.5 9.5A5.8 5.8 0 0 1 6.5 2.5a5.8 5.8 0 1 0 7 7Z"/></svg>
26
+ </span>
27
+ {/* Shown in dark: a sun. */}
28
+ <span className="i-to-light" aria-hidden="true">
29
+ <svg width="17" height="17" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="8" cy="8" r="3"/><path d="M8 1.5v1.6M8 12.9v1.6M1.5 8h1.6M12.9 8h1.6M3.4 3.4l1.1 1.1M11.5 11.5l1.1 1.1M12.6 3.4l-1.1 1.1M4.5 11.5l-1.1 1.1"/></svg>
30
+ </span>
31
+ </button>
32
+ )
33
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Theme.
3
+ *
4
+ * There is one palette — see tokens.css — in a light and a dark form. The
5
+ * operating system decides which, until someone says otherwise: `data-theme` on
6
+ * <html> forces a form in both directions, and `ThemeToggle` is the control
7
+ * that sets it.
8
+ *
9
+ * The choice is remembered per browser, so it has to be restored before the
10
+ * first paint — `themeInitScript()` in <head>, or the page renders the system's
11
+ * answer and then flips to the stored one.
12
+ */
13
+
14
+ export type ThemeMode = 'system' | 'light' | 'dark'
15
+
16
+ const themeKey = (prefix: string) => `${prefix}.theme`
17
+
18
+ /** What the document is showing now — not what is stored. */
19
+ export function currentTheme(): ThemeMode {
20
+ if (typeof document === 'undefined') return 'system'
21
+ const t = document.documentElement.dataset['theme']
22
+ return t === 'dark' || t === 'light' ? t : 'system'
23
+ }
24
+
25
+ /**
26
+ * What the viewer is actually seeing: the forced form if there is one, else the
27
+ * system's. This is the honest answer to "which theme is on" — `currentTheme()`
28
+ * says `system`, which is a setting, not a colour.
29
+ */
30
+ export function resolvedTheme(): 'light' | 'dark' {
31
+ const forced = currentTheme()
32
+ if (forced !== 'system') return forced
33
+ if (typeof window === 'undefined') return 'light'
34
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
35
+ }
36
+
37
+ /** Force a form, or hand the decision back to the system. Remembered per browser. */
38
+ export function applyTheme(mode: ThemeMode, prefix = 'bw'): void {
39
+ if (mode === 'system') delete document.documentElement.dataset['theme']
40
+ else document.documentElement.dataset['theme'] = mode
41
+ try { localStorage.setItem(themeKey(prefix), mode) } catch { /* private mode: the choice just does not persist */ }
42
+ }
43
+
44
+ /**
45
+ * The snippet for <head>, for a product that uses `applyTheme`. It is
46
+ * deliberately tiny and wrapped in try/catch: a browser that refuses
47
+ * localStorage must still render the page.
48
+ *
49
+ * A product with no toggle does not need this at all — with nothing stored,
50
+ * `prefers-color-scheme` already decides.
51
+ */
52
+ export function themeInitScript(prefix = 'bw'): string {
53
+ return `try{var t=localStorage.getItem("${themeKey(prefix)}");`
54
+ + `if(t==="dark"||t==="light")document.documentElement.dataset.theme=t}catch(e){}`
55
+ }