@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.
@@ -0,0 +1,37 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export type AttentionTone = 'accent' | 'warn' | 'crit'
4
+
5
+ /**
6
+ * Something the reader has to know before acting. `action` is the one thing
7
+ * they can do about it, inline — a banner with no way out is just noise.
8
+ */
9
+ export function Attention({ tone = 'accent', action, onAction, children, style }:
10
+ { tone?: AttentionTone; action?: ReactNode; onAction?: () => void; children: ReactNode; style?: React.CSSProperties }) {
11
+ return (
12
+ <div className={'attn' + (tone === 'accent' ? '' : ` ${tone}`)} style={style}>
13
+ <span className="sev" />
14
+ <div>{children}</div>
15
+ {action && <button className="act" onClick={onAction}>{action}</button>}
16
+ </div>
17
+ )
18
+ }
19
+
20
+ /** Literal text: a token, a command, a config fragment. Never re-wrapped. */
21
+ export function CodeBox({ children, style }: { children: ReactNode; style?: React.CSSProperties }) {
22
+ return <div className="codebox" style={style}>{children}</div>
23
+ }
24
+
25
+ /** The receipt for something that already happened. Null hides it. */
26
+ export function Toast({ message }: { message: string | null }) {
27
+ return <div className={'toast' + (message ? ' on' : '')}>{message}</div>
28
+ }
29
+
30
+ /** Short, clickable suggestions — what to try when the page is empty. */
31
+ export function Chips({ items }: { items: { label: string; onPick: () => void }[] }) {
32
+ return (
33
+ <div className="chips">
34
+ {items.map((c) => <button key={c.label} className="chip" onClick={c.onPick}>{c.label}</button>)}
35
+ </div>
36
+ )
37
+ }
package/src/form.tsx ADDED
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /** A labelled control with an optional line of help under it. */
4
+ export function Field({ label, htmlFor, hint, children }:
5
+ { label: ReactNode; htmlFor?: string; hint?: ReactNode; children: ReactNode }) {
6
+ return (
7
+ <div className="field">
8
+ <label htmlFor={htmlFor}>{label}</label>
9
+ {children}
10
+ {hint && <small className="cell-sub">{hint}</small>}
11
+ </div>
12
+ )
13
+ }
14
+
15
+ /** One line: the input grows, the selector and the button stay their own size. */
16
+ export function FormRow({ children }: { children: ReactNode }) {
17
+ return <div className="form-row">{children}</div>
18
+ }
19
+
20
+ /**
21
+ * Why the write failed, next to the form that failed — not as a toast. A toast
22
+ * is a receipt for something that happened; this is a correction to make.
23
+ */
24
+ export function FormError({ children }: { children: ReactNode }) {
25
+ return children ? <div className="form-error">{children}</div> : null
26
+ }
27
+
28
+ /** An on/off control that is its own label. */
29
+ export function Switch({ checked, onChange, label, disabled }:
30
+ { checked: boolean; onChange: (next: boolean) => void; label: ReactNode; disabled?: boolean }) {
31
+ return (
32
+ <label className="optline" style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: disabled ? 'default' : 'pointer' }}>
33
+ <span className={'sw' + (checked ? ' on' : '')} role="switch" aria-checked={checked}
34
+ aria-disabled={disabled} tabIndex={0}
35
+ onClick={() => !disabled && onChange(!checked)}
36
+ onKeyDown={(e) => { if (!disabled && (e.key === ' ' || e.key === 'Enter')) { e.preventDefault(); onChange(!checked) } }} />
37
+ <span>{label}</span>
38
+ </label>
39
+ )
40
+ }
package/src/format.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Display formatting. Timestamps arrive as ISO-8601 or epoch and are shown
3
+ * relative; money arrives as integer minor units and is shown as a decimal.
4
+ */
5
+
6
+ /**
7
+ * How long ago, as a finished phrase.
8
+ *
9
+ * It includes "ago" on purpose. Returning a bare duration made every call site
10
+ * append it, and the shortest case then read "just now ago".
11
+ */
12
+ export function age(iso: string | number): string {
13
+ const t = typeof iso === 'number' ? iso : new Date(iso).getTime()
14
+ const minutes = Math.floor((Date.now() - t) / 60_000)
15
+ if (minutes < 1) return 'just now'
16
+ if (minutes < 60) return `${minutes} min ago`
17
+ const hours = Math.floor(minutes / 60)
18
+ if (hours < 24) return `${hours} h ago`
19
+ return `${Math.floor(hours / 24)} d ago`
20
+ }
21
+
22
+ /** How long until an ISO timestamp, or that it has already passed. */
23
+ export function until(iso: string): string {
24
+ const ms = new Date(iso).getTime() - Date.now()
25
+ if (ms <= 0) return 'expired'
26
+ const hours = Math.floor(ms / 3_600_000)
27
+ if (hours < 1) return `in ${Math.floor(ms / 60_000)} min`
28
+ if (hours < 48) return `in ${hours} h`
29
+ return `in ${Math.floor(hours / 24)} d`
30
+ }
31
+
32
+ export function untilEpoch(sec: number): string {
33
+ const ms = sec * 1000 - Date.now()
34
+ if (ms <= 0) return 'expired'
35
+ const hours = Math.floor(ms / 3_600_000)
36
+ if (hours < 1) return `${Math.floor(ms / 60_000)} min`
37
+ return `${hours} h`
38
+ }
39
+
40
+ /**
41
+ * Integer minor units → a decimal string. Money is never a float on the wire
42
+ * (a float that is nearly the right amount is the wrong amount), so it is
43
+ * divided only here, on the way to a screen.
44
+ */
45
+ export function money(minor: number, currency?: string): string {
46
+ const s = (minor / 100).toFixed(2)
47
+ return currency ? `${s} ${currency}` : s
48
+ }
49
+
50
+ /** The same, grouped for a column of figures: 1234567 → "12,345.67". */
51
+ export function amount(minor: number): string {
52
+ return (minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
53
+ }
package/src/frame.css ADDED
@@ -0,0 +1,214 @@
1
+ /* @baseworks/ui — the application frame.
2
+
3
+ Rail, sub panel, top bar, assistant, and the centered gate a person sees
4
+ before they are inside. A product supplies its own modules and icons; the
5
+ shape is the same everywhere. */
6
+
7
+ /* The icon rail: modules, the account, the panel toggle. */
8
+ .app{display:flex;min-height:100vh}
9
+ .rail{display:flex;flex:none;position:sticky;top:0;height:100vh;z-index:40}
10
+ .rail-icons{
11
+ width:64px;flex:none;display:flex;flex-direction:column;align-items:center;
12
+ border-right:1px solid var(--line);background:var(--surface);padding:14px 0 12px;gap:4px;
13
+ }
14
+ .brand-mark{
15
+ width:32px;height:32px;border-radius:9px;background:var(--accent);color:var(--accent-ink);
16
+ display:grid;place-items:center;font-family:var(--sans);font-weight:700;font-size:15px;
17
+ margin-bottom:14px;flex:none;
18
+ }
19
+ .rail-icons nav{display:flex;flex-direction:column;gap:4px;align-items:center}
20
+ .rail-sep{width:28px;height:1px;background:var(--line);margin:8px 0}
21
+ .nav-item{
22
+ position:relative;width:40px;height:40px;border-radius:10px;display:grid;place-items:center;
23
+ color:var(--muted);
24
+ }
25
+ .nav-item:hover{background:var(--surface2);color:var(--ink)}
26
+ .nav-item.active{background:var(--accent-soft);color:var(--accent)}
27
+ .nav-item.active::before{
28
+ content:"";position:absolute;left:-12px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--accent);
29
+ }
30
+ .nav-dot{position:absolute;top:7px;right:7px;width:7px;height:7px;border-radius:50%;background:var(--warn);border:1.5px solid var(--surface)}
31
+ .rail-toggle{margin-top:auto;color:var(--faint)}
32
+ .rail-toggle:hover{color:var(--ink);background:var(--surface2)}
33
+ .rail-toggle svg{transition:transform .18s}
34
+ body.nav-open .rail-toggle svg{transform:rotate(180deg)}
35
+ /* The hover card beside a rail icon (RailTip): a name and one line on what is
36
+ there. It waits a beat so sweeping down the rail does not flash every card. */
37
+ .rail-tip{
38
+ position:absolute;left:calc(100% + 12px);top:50%;transform:translate(-4px,-50%);
39
+ display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding:7px 10px;border-radius:8px;
40
+ background:var(--ink);color:var(--surface);font-size:12px;line-height:1.3;font-weight:400;text-align:left;
41
+ white-space:nowrap;pointer-events:none;opacity:0;z-index:60;box-shadow:var(--shadow);
42
+ transition:opacity .12s,transform .12s;
43
+ }
44
+ .rail-tip b{font-size:13px;font-weight:600}
45
+ .rail-tip span{opacity:.72}
46
+ :hover > .rail-tip,:focus-visible > .rail-tip{opacity:1;transform:translate(0,-50%);transition-delay:.15s}
47
+ /* An open menu covers the same spot; the card would sit on top of it. */
48
+ .brand-pop:has(> .brand-menu) .rail-tip{display:none}
49
+ .avatar{width:32px;height:32px;border-radius:50%;background:var(--surface2);border:1px solid var(--line-strong);display:grid;place-items:center;font-size:11px;font-weight:600;color:var(--muted);margin-top:8px;flex:none}
50
+ /* The sub panel: what is inside the active module. */
51
+ .subrail{
52
+ width:0;overflow:hidden;transition:width .18s ease;
53
+ background:var(--surface);border-right:1px solid var(--line);
54
+ }
55
+ body.nav-open .subrail{width:192px}
56
+ .subrail-inner{width:192px;height:100%;display:flex;flex-direction:column}
57
+ .subrail-head{padding:20px 16px 10px}
58
+ .subrail-head .mn{font-family:var(--sans);font-weight:600;font-size:15px}
59
+ .subrail-head .md{font-size:11.5px;color:var(--faint);margin-top:1px}
60
+ .subrail-items{display:flex;flex-direction:column;gap:2px;padding:4px 10px}
61
+ .sub-item{
62
+ display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:8px;
63
+ color:var(--muted);font-size:13px;text-align:left;width:100%;
64
+ }
65
+ .sub-item:hover{background:var(--surface2);color:var(--ink)}
66
+ .sub-item.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
67
+ .sub-item .cnt{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--faint)}
68
+ .sub-item .soon{margin-left:auto;font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:var(--faint);border:1px solid var(--line-strong);border-radius:99px;padding:1px 6px}
69
+ .subrail-foot{margin-top:auto;padding:12px 16px;border-top:1px solid var(--line);font-size:12px;line-height:1.3}
70
+ .subrail-foot small{color:var(--faint);display:block}
71
+ /* The main column and its top bar. */
72
+ .main{flex:1;min-width:0;display:flex;flex-direction:column}
73
+ .topbar{
74
+ display:flex;align-items:center;gap:12px;padding:12px 24px;
75
+ border-bottom:1px solid var(--line);background:var(--surface);
76
+ position:sticky;top:0;z-index:30;
77
+ }
78
+ .search-hint{
79
+ flex:1;max-width:420px;display:flex;align-items:center;gap:8px;color:var(--faint);
80
+ border:1px solid var(--line);border-radius:8px;padding:6px 12px;background:var(--bg);
81
+ }
82
+ .search-hint kbd{
83
+ margin-left:auto;font-family:var(--mono);font-size:11px;border:1px solid var(--line-strong);
84
+ border-radius:5px;padding:1px 6px;color:var(--muted);background:var(--surface);
85
+ }
86
+ .top-actions{margin-left:auto;display:flex;align-items:center;gap:8px}
87
+ .icon-btn{
88
+ width:34px;height:34px;border-radius:8px;display:grid;place-items:center;color:var(--muted);
89
+ border:1px solid transparent;
90
+ }
91
+ .icon-btn:hover{background:var(--surface2);color:var(--ink)}
92
+ .chat-toggle{
93
+ display:flex;align-items:center;gap:8px;padding:7px 14px;border-radius:8px;
94
+ background:var(--accent);color:var(--accent-ink);font-weight:600;
95
+ }
96
+ .chat-toggle:hover{filter:brightness(1.06)}
97
+ .content{padding:24px;max-width:1160px;width:100%;margin:0 auto}
98
+ /* Popover menu, anchored to the button that opened it. */
99
+ .brand-pop{position:relative}
100
+ .brand-menu{
101
+ position:absolute;right:0;top:42px;background:var(--surface);border:1px solid var(--line);
102
+ border-radius:12px;box-shadow:var(--shadow);padding:8px;display:flex;flex-direction:column;gap:2px;
103
+ min-width:180px;z-index:50;
104
+ }
105
+ .brand-opt{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;font-size:13px;width:100%;text-align:left;color:var(--muted)}
106
+ .brand-opt:hover{background:var(--surface2);color:var(--ink)}
107
+ .brand-opt.sel{color:var(--ink);font-weight:600}
108
+ .brand-opt .check{margin-left:auto;color:var(--accent)}
109
+ /* Organization switcher, at the top of the rail. */
110
+ .org-rail-btn{
111
+ position:relative;width:32px;height:32px;border-radius:9px;background:var(--accent);color:var(--accent-ink);
112
+ display:grid;place-items:center;font-family:var(--sans);font-weight:700;font-size:15px;
113
+ margin-bottom:14px;flex:none;
114
+ }
115
+ .org-rail-btn .org-rail-caret{
116
+ position:absolute;right:-4px;bottom:-4px;width:13px;height:13px;border-radius:50%;
117
+ background:var(--surface);color:var(--muted);border:1px solid var(--line-strong);
118
+ font-size:7px;display:grid;place-items:center;
119
+ }
120
+ .org-rail-btn:hover .org-rail-caret{color:var(--accent);border-color:var(--accent)}
121
+ .org-menu.rail{left:48px;right:auto;top:0}
122
+ .org-menu-brand{
123
+ display:flex;align-items:center;gap:8px;padding:6px 10px 8px;margin-bottom:4px;
124
+ border-bottom:1px solid var(--line);font-family:var(--sans);font-weight:700;color:var(--accent);
125
+ }
126
+ .org-menu-brand span{font-family:var(--sans);font-weight:400;font-size:11.5px;color:var(--faint)}
127
+ .org-mark{
128
+ width:22px;height:22px;border-radius:6px;background:var(--accent-soft);color:var(--accent);
129
+ display:grid;place-items:center;font-size:12px;font-weight:700;flex:none;
130
+ }
131
+ .org-mark.sm{width:18px;height:18px;font-size:10.5px;border-radius:5px}
132
+ .org-mark.plus{background:var(--surface2);color:var(--muted);font-weight:500}
133
+ .org-menu{left:0;right:auto;min-width:240px}
134
+ .org-menu-head{font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--faint);padding:4px 10px 6px}
135
+ .org-menu-sep{height:1px;background:var(--line);margin:6px 4px}
136
+ .role-chip{
137
+ font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--faint);
138
+ border:1px solid var(--line-strong);border-radius:99px;padding:1px 7px;flex:none;
139
+ }
140
+ .org-create{display:flex;gap:6px;padding:4px}
141
+ .org-create input{
142
+ flex:1;min-width:0;border:1px solid var(--line-strong);border-radius:7px;padding:6px 10px;
143
+ background:var(--surface);color:var(--ink);font-size:13px;
144
+ }
145
+ /* The centered gate: sign-in, and the create-your-organization step. */
146
+ .onboard{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--bg)}
147
+ .onboard-card{
148
+ width:min(440px,100%);background:var(--surface);border:1px solid var(--line);border-radius:16px;
149
+ box-shadow:var(--shadow);padding:32px;display:flex;flex-direction:column;gap:16px;
150
+ }
151
+ .onboard-brand{
152
+ width:36px;height:36px;border-radius:10px;background:var(--accent);color:var(--accent-ink);
153
+ display:grid;place-items:center;font-family:var(--sans);font-weight:700;font-size:16px;
154
+ }
155
+ .onboard-card h1{font-family:var(--display);font-size:27px;font-weight:400;margin:0;letter-spacing:-.01em}
156
+ .onboard-card p{margin:0;color:var(--muted);font-size:13.5px;line-height:1.6}
157
+ .onboard-card .btn{justify-content:center}
158
+ .onboard-card .invite-stack{margin:14px 0 4px;text-align:left}
159
+ .login-card .btn{width:100%;justify-content:center}
160
+ .login-foot{margin-top:14px;text-align:center}
161
+ /* An invitation is an offer, not a warning: accent border, never red. */
162
+ .invite-stack{display:flex;flex-direction:column;gap:10px;margin-bottom:16px}
163
+ .invite-card{
164
+ display:flex;align-items:center;gap:14px;
165
+ padding:13px 15px;border:1px solid var(--line);border-left:3px solid var(--accent);
166
+ border-radius:10px;background:var(--surface)
167
+ }
168
+ .invite-body{min-width:0;flex:1}
169
+ .invite-title{font-size:13.5px;margin-bottom:2px}
170
+ .invite-actions{display:flex;gap:8px;flex:none}
171
+ /* A surface's own side panel: the assistant's column, before the assistant.
172
+ The surface sizes what it puts inside; this only pins it full height. */
173
+ .frame-aside{flex:none;position:sticky;top:0;height:100vh;display:flex}
174
+ /* The assistant, sliding in from the right. */
175
+ .chat{
176
+ width:0;flex:none;overflow:hidden;transition:width .22s ease;
177
+ border-left:1px solid var(--line);background:var(--chat-bg);
178
+ position:sticky;top:0;height:100vh;display:flex;flex-direction:column;
179
+ }
180
+ .chat.open{width:400px}
181
+ /* Expanded: the panel takes the middle column too. An answer that is a table —
182
+ a trial balance, an account's movements — needs the room a table needs, and
183
+ 400px turns one into a column of wrapped numbers. The content stays mounted
184
+ beside it rather than being replaced, so closing the panel returns to exactly
185
+ the screen that was there. */
186
+ .chat.open.expanded{width:min(1000px,64vw)}
187
+ .chat-inner{width:400px;display:flex;flex-direction:column;height:100%}
188
+ .chat.expanded .chat-inner{width:100%}
189
+ .chat.expanded .msg{max-width:74%}
190
+ .chat.expanded .chat-scroll{padding:20px 24px}
191
+ .chat-head{display:flex;align-items:center;gap:10px;padding:14px 18px;border-bottom:1px solid var(--line);background:var(--surface)}
192
+ .chat-head>div:nth-child(2){flex:1;min-width:0}
193
+ .chat-head .ttl{font-weight:600}
194
+ .chat-head .sub{font-size:11px;color:var(--faint)}
195
+ .chat-scroll{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px}
196
+ .msg{max-width:86%;padding:10px 14px;border-radius:14px;font-size:13.5px;line-height:1.45}
197
+ .msg.user{align-self:flex-end;background:var(--accent);color:var(--accent-ink);border-bottom-right-radius:4px}
198
+ .msg.bot{align-self:flex-start;background:var(--surface);border:1px solid var(--line);border-bottom-left-radius:4px}
199
+ .typing{align-self:flex-start;display:flex;gap:4px;padding:12px 16px}
200
+ .typing i{width:6px;height:6px;border-radius:50%;background:var(--faint);animation:blink 1s infinite}
201
+ .typing i:nth-child(2){animation-delay:.2s}
202
+ .typing i:nth-child(3){animation-delay:.4s}
203
+ @keyframes blink{0%,80%,100%{opacity:.3}40%{opacity:1}}
204
+ .chat-input{display:flex;gap:8px;padding:12px 14px;border-top:1px solid var(--line);background:var(--surface)}
205
+ .chat-input input{
206
+ flex:1;border:1px solid var(--line-strong);border-radius:10px;padding:9px 13px;background:var(--bg);color:var(--ink);
207
+ }
208
+ .chat-input input::placeholder{color:var(--faint)}
209
+ .send{width:38px;height:38px;border-radius:10px;background:var(--accent);color:var(--accent-ink);display:grid;place-items:center;flex:none}
210
+ /* Narrow screens: the sub panel and the assistant float over the content. */
211
+ @media(max-width:900px){
212
+ body.nav-open .subrail{position:fixed;left:64px;top:0;height:100vh;box-shadow:var(--shadow);z-index:45}
213
+ .chat.open,.chat.open.expanded{position:fixed;right:0;z-index:50;width:min(400px,100vw)}
214
+ }
package/src/frame.tsx ADDED
@@ -0,0 +1,293 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * The application frame: icon rail, sub panel, top bar, assistant, ⌘K, toast
5
+ * and the confirm dialog.
6
+ *
7
+ * It is presentational. It knows how a surface is laid out and nothing about
8
+ * what the surface is for: the modules, the icons, the counts, who is signed
9
+ * in and which organization is active all arrive as props or slots. The
10
+ * console fills them with a realm; a product fills them with its own screens.
11
+ *
12
+ * What it does own is the state that belongs to the layout — which panel is
13
+ * open, whether the assistant has taken the middle column, what the palette
14
+ * is showing, the toast that is up — because every surface would otherwise
15
+ * write the same thing again.
16
+ */
17
+
18
+ import {
19
+ createContext, useCallback, useContext, useEffect, useMemo, useRef, useState,
20
+ type ReactNode,
21
+ } from 'react'
22
+ import { CommandPalette, type PaletteItem } from './command-palette'
23
+ import { ConfirmDialog, type ConfirmRequest } from './dialog'
24
+ import { Toast } from './feedback'
25
+ import { activeModule, type Module, type SubItem } from './nav'
26
+
27
+ export type FrameCtx = {
28
+ toast: (message: string) => void
29
+ confirm: (request: ConfirmRequest) => void
30
+ /** Whether the assistant is open, for a screen that wants to offer it. */
31
+ assistantOpen: boolean
32
+ openAssistant: () => void
33
+ }
34
+
35
+ const Ctx = createContext<FrameCtx | null>(null)
36
+
37
+ export function useFrame(): FrameCtx {
38
+ const c = useContext(Ctx)
39
+ if (!c) throw new Error('useFrame must be used inside Frame')
40
+ return c
41
+ }
42
+
43
+ export interface FrameProps {
44
+ /** The surface's own modules, already filtered to what it offers. */
45
+ modules: Module[]
46
+ /** Usually the router's. Decides which module is active. */
47
+ pathname: string
48
+ onNavigate: (href: string) => void
49
+ /** Looked up by `countKey`; undefined means "not known yet", and nothing is shown. */
50
+ counts?: Record<string, number | undefined>
51
+ /** Rail icon per module key. */
52
+ icons?: Record<string, ReactNode>
53
+ fallbackIcon?: ReactNode
54
+ /** A dot on a module's rail icon — something is waiting there. */
55
+ badges?: Record<string, boolean>
56
+ /** Above the module list: the organization switcher, usually. */
57
+ railTop?: ReactNode
58
+ /** Below it: the account menu. */
59
+ railBottom?: ReactNode
60
+ /** After the separator under the modules: settings, help. */
61
+ railExtra?: ReactNode
62
+ topbarLeft?: ReactNode
63
+ topbarRight?: ReactNode
64
+ /** The foot of the sub panel: which organization, and the caller's role. */
65
+ footer?: ReactNode
66
+ commands?: PaletteItem[]
67
+ /**
68
+ * The assistant. Given a renderer, the frame owns whether it is open and
69
+ * whether it is expanded, and puts the toggle in the top bar.
70
+ */
71
+ assistant?: (state: {
72
+ open: boolean
73
+ onClose: () => void
74
+ expanded: boolean
75
+ onToggleExpand: () => void
76
+ }) => ReactNode
77
+ assistantLabel?: string
78
+ /**
79
+ * A surface's own side panel, in the column the assistant uses — full
80
+ * height, right of the content, left of the assistant when both are open.
81
+ * A screen that opens a panel about something (a node on a map, a row in a
82
+ * list) puts it here rather than inside the content column, so it reads as
83
+ * the same kind of thing as the assistant: beside the screen, not in it.
84
+ * The surface owns its width and whether it is open.
85
+ */
86
+ aside?: ReactNode
87
+ children: ReactNode
88
+ }
89
+
90
+ /**
91
+ * The hover card beside a rail icon: its name, and a line on what is there.
92
+ * Shown on hover and on keyboard focus of its parent, so put it inside the
93
+ * button it describes. A surface uses it for its own rail slots too — the
94
+ * organization button, the account — so every icon on the rail answers alike.
95
+ */
96
+ export function RailTip({ title, desc }: { title: ReactNode; desc?: ReactNode }) {
97
+ return (
98
+ <span className="rail-tip" role="tooltip">
99
+ <b>{title}</b>
100
+ {desc && <span>{desc}</span>}
101
+ </span>
102
+ )
103
+ }
104
+
105
+ export function Frame({
106
+ modules,
107
+ pathname,
108
+ onNavigate,
109
+ counts = {},
110
+ icons = {},
111
+ fallbackIcon,
112
+ badges = {},
113
+ railTop,
114
+ railBottom,
115
+ railExtra,
116
+ topbarLeft,
117
+ topbarRight,
118
+ footer,
119
+ commands = [],
120
+ assistant,
121
+ assistantLabel = 'Assistant',
122
+ aside,
123
+ children,
124
+ }: FrameProps) {
125
+ const mod = activeModule(pathname, modules)
126
+
127
+ const [navOpen, setNavOpen] = useState(false)
128
+ const [chatOpen, setChatOpen] = useState(false)
129
+ const [chatWide, setChatWide] = useState(false)
130
+ const [cmdkOpen, setCmdkOpen] = useState(false)
131
+ const [confirmReq, setConfirmReq] = useState<ConfirmRequest | null>(null)
132
+ const [toastMsg, setToastMsg] = useState<string | null>(null)
133
+ const toastTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
134
+
135
+ const toast = useCallback((m: string) => {
136
+ setToastMsg(m)
137
+ clearTimeout(toastTimer.current)
138
+ toastTimer.current = setTimeout(() => setToastMsg(null), 2600)
139
+ }, [])
140
+ const confirm = useCallback((request: ConfirmRequest) => setConfirmReq(request), [])
141
+
142
+ useEffect(() => () => clearTimeout(toastTimer.current), [])
143
+
144
+ // The sub panel overlays the content on a narrow screen, which is a body-level
145
+ // concern: the rule that positions it is in frame.css, keyed off this class.
146
+ useEffect(() => {
147
+ document.body.classList.toggle('nav-open', navOpen)
148
+ return () => document.body.classList.remove('nav-open')
149
+ }, [navOpen])
150
+
151
+ useEffect(() => {
152
+ const onKey = (e: KeyboardEvent) => {
153
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
154
+ e.preventDefault()
155
+ setCmdkOpen(true)
156
+ }
157
+ }
158
+ document.addEventListener('keydown', onKey)
159
+ return () => document.removeEventListener('keydown', onKey)
160
+ }, [])
161
+
162
+ const onModuleClick = (m: Module) => {
163
+ if (m.href === mod.href) setNavOpen((v) => !v)
164
+ else {
165
+ onNavigate(m.href)
166
+ setNavOpen(true)
167
+ }
168
+ }
169
+
170
+ const onSubItem = (it: SubItem) => {
171
+ if (it.soon) {
172
+ toast('On the roadmap — not designed yet.')
173
+ return
174
+ }
175
+ if (it.href) {
176
+ onNavigate(it.href)
177
+ return
178
+ }
179
+ if (it.hash) {
180
+ const go = () => document.getElementById(it.hash!)?.scrollIntoView({ behavior: 'smooth' })
181
+ if (pathname !== mod.href) {
182
+ onNavigate(mod.href)
183
+ setTimeout(go, 250)
184
+ } else go()
185
+ }
186
+ }
187
+
188
+ const ctx = useMemo<FrameCtx>(
189
+ () => ({ toast, confirm, assistantOpen: chatOpen, openAssistant: () => setChatOpen(true) }),
190
+ [toast, confirm, chatOpen],
191
+ )
192
+
193
+ const desc =
194
+ mod.descCountKey && counts[mod.descCountKey] !== undefined
195
+ ? `${counts[mod.descCountKey]} ${mod.descCountLabel ?? ''}`.trim()
196
+ : mod.desc
197
+
198
+ return (
199
+ <Ctx.Provider value={ctx}>
200
+ <div className="app">
201
+ <aside className="rail">
202
+ <div className="rail-icons">
203
+ {railTop}
204
+ <nav aria-label="Main menu">
205
+ {modules.map((m) => (
206
+ <button
207
+ key={m.key}
208
+ className={'nav-item' + (m.key === mod.key ? ' active' : '')}
209
+ aria-label={m.title}
210
+ aria-current={m.key === mod.key ? 'page' : undefined}
211
+ onClick={() => onModuleClick(m)}
212
+ >
213
+ {icons[m.key] ?? fallbackIcon}
214
+ {badges[m.key] && <span className="nav-dot" />}
215
+ <RailTip title={m.title} desc={m.desc} />
216
+ </button>
217
+ ))}
218
+ {railExtra && <div className="rail-sep" role="separator" />}
219
+ {railExtra}
220
+ </nav>
221
+ <button
222
+ className="nav-item rail-toggle"
223
+ aria-label="Toggle menu"
224
+ aria-expanded={navOpen}
225
+ onClick={() => setNavOpen((v) => !v)}
226
+ >
227
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="m6 3.5 4.5 4.5L6 12.5" /></svg>
228
+ <RailTip title={navOpen ? 'Hide menu' : 'Show menu'} />
229
+ </button>
230
+ {railBottom}
231
+ </div>
232
+ <div className="subrail">
233
+ <div className="subrail-inner">
234
+ <div className="subrail-head">
235
+ <div className="mn">{mod.title}</div>
236
+ <div className="md">{desc}</div>
237
+ </div>
238
+ <div className="subrail-items">
239
+ {mod.items.map((it) => (
240
+ <button
241
+ key={it.label}
242
+ className={'sub-item' + (it.href && pathname === it.href ? ' active' : '')}
243
+ onClick={() => onSubItem(it)}
244
+ >
245
+ {it.label}
246
+ {it.countKey && counts[it.countKey] !== undefined && (
247
+ <span className="cnt num">{counts[it.countKey]}</span>
248
+ )}
249
+ {it.soon && <span className="soon">soon</span>}
250
+ </button>
251
+ ))}
252
+ </div>
253
+ {footer && <div className="subrail-foot">{footer}</div>}
254
+ </div>
255
+ </div>
256
+ </aside>
257
+
258
+ <div className="main">
259
+ <header className="topbar">
260
+ {topbarLeft}
261
+ <button className="search-hint" onClick={() => setCmdkOpen(true)}>
262
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6"><circle cx="7" cy="7" r="5" /><path d="m11 11 3 3" /></svg>
263
+ Search or run a command… <kbd>⌘K</kbd>
264
+ </button>
265
+ <div className="top-actions">
266
+ {topbarRight}
267
+ {assistant && (
268
+ <button className="chat-toggle" onClick={() => setChatOpen((v) => !v)} aria-expanded={chatOpen}>
269
+ <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6"><path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h9A1.5 1.5 0 0 1 14 3.5v6a1.5 1.5 0 0 1-1.5 1.5H6l-3.2 2.6a.4.4 0 0 1-.65-.31L2 11V3.5Z" /></svg>
270
+ {assistantLabel}
271
+ </button>
272
+ )}
273
+ </div>
274
+ </header>
275
+ <div className="content">{children}</div>
276
+ </div>
277
+
278
+ {aside && <div className="frame-aside">{aside}</div>}
279
+
280
+ {assistant?.({
281
+ open: chatOpen,
282
+ onClose: () => setChatOpen(false),
283
+ expanded: chatWide,
284
+ onToggleExpand: () => setChatWide((v) => !v),
285
+ })}
286
+ </div>
287
+
288
+ <CommandPalette open={cmdkOpen} onClose={() => setCmdkOpen(false)} items={commands} />
289
+ <ConfirmDialog request={confirmReq} onClose={() => setConfirmReq(null)} />
290
+ <Toast message={toastMsg} />
291
+ </Ctx.Provider>
292
+ )
293
+ }
package/src/gate.tsx ADDED
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /**
4
+ * The centered card a person sees before they are inside: signing in, or
5
+ * creating the organization they have to have. No rail, no top bar — there is
6
+ * nothing to navigate to yet, and offering it would be a lie.
7
+ */
8
+ export function Gate({ mark, title, children, above, className }: {
9
+ /** The two-letter brand mark at the top of the card. */
10
+ mark: ReactNode
11
+ title: ReactNode
12
+ children: ReactNode
13
+ /** Rendered above the title — an invitation the person could accept instead. */
14
+ above?: ReactNode
15
+ className?: string
16
+ }) {
17
+ return (
18
+ <div className="onboard">
19
+ <div className={['onboard-card', className].filter(Boolean).join(' ')}>
20
+ <div className="onboard-brand">{mark}</div>
21
+ {above}
22
+ <h1>{title}</h1>
23
+ {children}
24
+ </div>
25
+ </div>
26
+ )
27
+ }