@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 +183 -2
- package/package.json +29 -3
- package/src/assistant.tsx +135 -0
- package/src/button.tsx +31 -0
- package/src/card.tsx +85 -0
- package/src/command-palette.tsx +59 -0
- package/src/commands.ts +27 -0
- package/src/dialog.tsx +96 -0
- package/src/feedback.tsx +37 -0
- package/src/form.tsx +40 -0
- package/src/format.ts +53 -0
- package/src/frame.css +214 -0
- package/src/frame.tsx +293 -0
- package/src/gate.tsx +27 -0
- package/src/index.ts +61 -0
- package/src/menu.tsx +62 -0
- package/src/nav.ts +47 -0
- package/src/primitives.css +239 -0
- package/src/slide-over.tsx +66 -0
- package/src/styles.css +7 -0
- package/src/table.tsx +53 -0
- package/src/theme-toggle.tsx +33 -0
- package/src/theme.ts +98 -0
- package/src/tokens.css +143 -0
- package/src/use-resource.ts +70 -0
package/src/feedback.tsx
ADDED
|
@@ -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
|
+
}
|