anentrypoint-design 0.0.384 → 0.0.385

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.
@@ -1,809 +1,19 @@
1
1
  // Chrome: Topbar, Crumb, Side, Status, AppShell, plus primitives
2
2
  // (Brand, Chip, Btn, Glyph, Heading, Lede). Pure factories — props in,
3
3
  // webjsx vnode out. CSS in app-shell.css uses these class names.
4
-
5
- import * as webjsx from '../../vendor/webjsx/index.js';
6
- import { trapTab } from './overlay-primitives.js';
7
- const h = webjsx.createElement;
8
-
9
- /**
10
- * The wordmark used in Topbar/AppShell headers.
11
- *
12
- * @param {Object} [props]
13
- * @param {string} [props.name='247420'] - the brand text.
14
- * @param {*} [props.leaf] - optional trailing breadcrumb-style leaf, rendered after a " / " separator.
15
- * @returns {*} webjsx vnode
16
- */
17
- export function Brand({ name = '247420', leaf } = {}) {
18
- return h('span', { class: 'brand' }, name,
19
- leaf ? h('span', { class: 'slash' }, ' / ') : null,
20
- leaf || null);
21
- }
22
-
23
- /**
24
- * A small pill/tag label.
25
- *
26
- * @param {Object} props
27
- * @param {string} [props.tone=''] - semantic color tone (empty = neutral).
28
- * @param {'sm'|'md'|'lg'} [props.size='md']
29
- * @param {boolean} [props.tag=false] - true renders a rectangular sentence-case variant for dense data (drops the all-caps pill styling). Orthogonal to tone.
30
- * @param {Function} [props.onRemove] - if given, renders a trailing dismiss (x) button that calls onRemove() on click. Omitted entirely (no button) when not supplied.
31
- * @param {*} props.children
32
- * @returns {*} webjsx vnode
33
- */
34
- export function Chip({ tone = '', size = 'md', tag = false, onRemove, children }) {
35
- const sizeCls = size === 'sm' ? ' chip--sm' : (size === 'lg' ? ' chip--lg' : '');
36
- return h('span', { class: 'chip' + sizeCls + (tag ? ' chip--tag' : '') + (tone ? ' tone-' + tone : '') + (onRemove ? ' ds-chip-removable' : '') },
37
- children,
38
- onRemove ? h('button', { type: 'button', class: 'ds-chip-remove-btn', 'aria-label': 'Remove', onclick: (e) => { e.stopPropagation(); onRemove(); } }, Icon('x')) : null);
39
- }
40
-
41
- /**
42
- * The standard button/link factory. Renders an `<a>` when `href` is given,
43
- * otherwise a `<button>`.
44
- *
45
- * @param {Object} props
46
- * @param {string} [props.href] - if present, renders as a link instead of a button.
47
- * @param {'default'|'primary'|'ghost'|'danger'} [props.variant='default']
48
- * @param {'sm'|'md'|'lg'} [props.size='md']
49
- * @param {*} props.children
50
- * @param {Function} [props.onClick]
51
- * @param {string} [props['aria-label']]
52
- * @param {boolean} [props.primary] - legacy alias for variant:'primary', kept for backward compatibility.
53
- * @param {boolean} [props.ghost] - legacy alias for variant:'ghost'.
54
- * @param {boolean} [props.danger] - legacy alias for variant:'danger'.
55
- * @param {boolean} [props.disabled]
56
- * @param {string} [props.class] - extra class name(s) appended to the generated class list.
57
- * @param {*} [props.key]
58
- * @returns {*} webjsx vnode
59
- */
60
- export function Btn({ href, variant = 'default', size = 'md', children, onClick, 'aria-label': ariaLabel, primary, ghost, danger, disabled, class: className, key }) {
61
- // Support legacy primary/ghost props for backward compatibility, but prefer variant
62
- const resolvedVariant = variant !== 'default' ? variant : (primary ? 'primary' : (ghost ? 'ghost' : (danger ? 'danger' : 'default')));
63
- // size: 'sm' | 'md' | 'lg' — md is the base .btn rule (no class); sm/lg add a
64
- // modifier that snaps height/padding/font to the --ctl-* ladder. Unknown
65
- // sizes fall back to md so a typo never drops the button's base styling.
66
- const sizeCls = size === 'sm' ? ' btn-sm' : (size === 'lg' ? ' btn-lg' : '');
67
- const cls = (resolvedVariant === 'primary' ? 'btn-primary' : (resolvedVariant === 'ghost' ? 'btn-ghost' : (resolvedVariant === 'danger' ? 'btn-primary danger' : 'btn')))
68
- + sizeCls
69
- + (disabled ? ' is-disabled' : '')
70
- + (className ? ' ' + className : '');
71
- const onclick = (e) => {
72
- if (disabled) { e.preventDefault(); return; }
73
- if (onClick) onClick(e);
74
- };
75
- const ariaName = ariaLabel || (typeof children === 'string' ? children : undefined);
76
-
77
- // A real navigational href renders an anchor; everything else is an action
78
- // button and renders a native <button> (correct semantics + keyboard
79
- // activation for free, no role=button / href="#" scroll-jump hack).
80
- // children may be a string OR an array of vnodes (e.g. icon + label); spread
81
- // arrays so each vnode is a real child - passing the array as a single child
82
- // produces a nested array webjsx applyDiff cannot key-diff (reading 'key').
83
- const kids = Array.isArray(children) ? children : [children];
84
- const isLink = href != null && href !== '' && href !== '#';
85
- if (isLink) {
86
- return h('a', {
87
- key,
88
- class: cls, href,
89
- 'aria-label': ariaName,
90
- 'aria-disabled': disabled ? 'true' : null,
91
- tabindex: disabled ? '-1' : null,
92
- onclick
93
- }, ...kids);
94
- }
95
- return h('button', {
96
- key,
97
- type: 'button', class: cls,
98
- disabled: disabled ? true : null,
99
- 'aria-label': ariaName,
100
- onclick
101
- }, ...kids);
102
- }
103
-
104
- export function IconButton({ icon, onClick, title, size = 'base', variant = 'ghost', disabled = false }) {
105
- const cls = 'ds-icon-btn ds-icon-btn-' + variant + ' ds-icon-btn-' + size + (disabled ? ' is-disabled' : '');
106
- return h('button', {
107
- type: 'button',
108
- class: cls,
109
- title,
110
- 'aria-label': title,
111
- disabled: disabled ? true : null,
112
- onclick: (e) => { if (disabled) { e.preventDefault(); return; } if (onClick) onClick(e); }
113
- }, Glyph({ children: icon, size }));
114
- }
115
-
116
- export function Badge({ children, variant = 'default', tone = 'neutral', size = 'md' }) {
117
- // size: 'sm' | 'md' | 'lg' — md is the base 18px badge.
118
- const sizeCls = size === 'sm' ? ' ds-badge--sm' : (size === 'lg' ? ' ds-badge--lg' : '');
119
- return h('span', { class: 'ds-badge ds-badge-' + variant + sizeCls + ' tone-' + tone }, children);
120
- }
121
-
122
- // Pill — plain non-interactive label chip for tag-like annotations (a phase
123
- // name, an id, a subsystem tag). Distinct from Chip (status-tone indicator),
124
- // Badge (count/variant marker), and FilterPills (interactive toggle-group):
125
- // Pill renders no button, carries no pressed/active state, just a small
126
- // rounded label. tone is a semantic keyword ('' | 'accent' | 'muted'),
127
- // never a raw color — every visual rides colors_and_type.css tokens.
128
- export function Pill({ tone = '', children, key } = {}) {
129
- return h('span', { key, class: 'ds-pill' + (tone ? ' tone-' + tone : '') }, children);
130
- }
131
-
132
- export function Glyph({ children, color, size = 'base', label } = {}) {
133
- // Font-size is var-driven per size class (--glyph-size-{size}) so themes can
134
- // retune glyph scale; inline fallback keeps sizing if the SDK CSS hasn't
135
- // loaded yet. Size class is the stable hook (glyph-sm / glyph-base / glyph-lg).
136
- const fallback = size === 'sm' ? '11px' : (size === 'lg' ? '16px' : '13px');
137
- const cls = 'glyph glyph-' + size;
138
- const style = `font-size:var(--glyph-size-${size}, ${fallback})` + (color ? `;color:${color}` : '');
139
- // Decorative by default (screen readers skip the glyph char). Pass `label`
140
- // to expose an accessible name instead.
141
- return h('span', label
142
- ? { class: cls, style, role: 'img', 'aria-label': label }
143
- : { class: cls, style, 'aria-hidden': 'true' }, children);
144
- }
145
-
146
- // Monochrome inline-SVG icons (stroke=currentColor) so chrome reads as one
147
- // coherent line-icon set instead of multicolor OS emoji. 16px box, 1.6 stroke.
148
- export const ICON_PATHS = {
149
- lock: '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
150
- mic: '<path d="M12 3a3 3 0 0 0-3 3v5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>',
151
- 'mic-off': '<path d="M9 9v2a3 3 0 0 0 4.5 2.6M15 11V6a3 3 0 0 0-5.9-.8"/><path d="M5 11a7 7 0 0 0 11.5 5.4M12 18v3"/><path d="m4 4 16 16"/>',
152
- speaker: '<path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/>',
153
- 'speaker-off': '<path d="M11 5 6 9H3v6h3l5 4z"/><path d="m17 9 4 6M21 9l-4 6"/>',
154
- camera: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3z"/>',
155
- screen: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
156
- phone: '<path d="M5 4h3l2 5-2 1a11 11 0 0 0 5 5l1-2 5 2v3a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z"/>',
157
- members: '<circle cx="9" cy="8" r="3"/><path d="M3 20a6 6 0 0 1 12 0M16 6a3 3 0 0 1 0 6M21 20a6 6 0 0 0-4-5.7"/>',
158
- menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
159
- settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1 7 17M17 7l2.1-2.1"/>',
160
- paperclip: '<path d="M21 11.5 12.5 20a5 5 0 0 1-7-7l8-8a3.5 3.5 0 0 1 5 5l-8 8a2 2 0 0 1-3-3l7.5-7.5"/>',
161
- smile: '<circle cx="12" cy="12" r="9"/><path d="M8 14a4 4 0 0 0 8 0"/><path d="M9 9h.01M15 9h.01"/>',
162
- 'more-horizontal': '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
163
- 'arrow-up': '<path d="M12 19V5M5 12l7-7 7 7"/>',
164
- send: '<path d="M22 2 11 13M22 2l-7 20-4-9-9-4z"/>',
165
- hash: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>',
166
- megaphone: '<path d="M3 11v2a1 1 0 0 0 1 1h2l5 4V6L6 10H4a1 1 0 0 0-1 1z"/><path d="M15 8a4 4 0 0 1 0 8M18 5a8 8 0 0 1 0 14"/>',
167
- forum: '<path d="M4 5h13a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H9l-4 3v-3H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z"/>',
168
- page: '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M8 13h8M8 17h6"/>',
169
- thread: '<path d="M5 6h14M5 11h14M5 16h8"/><circle cx="17" cy="17" r="3"/>',
170
- // status / control icons (replace decorative text glyphs at the source)
171
- check: '<path d="M20 6 9 17l-5-5"/>',
172
- 'check-check': '<path d="M18 6 7 17l-3-3"/><path d="m22 10-7.5 7.5L13 16"/>',
173
- 'chevron-right': '<path d="m9 6 6 6-6 6"/>',
174
- 'chevron-down': '<path d="m6 9 6 6 6-6"/>',
175
- 'chevron-up': '<path d="m6 15 6-6 6 6"/>',
176
- 'arrow-down': '<path d="M12 5v14M5 12l7 7 7-7"/>',
177
- 'arrow-right': '<path d="M5 12h14M12 5l7 7-7 7"/>',
178
- x: '<path d="M18 6 6 18M6 6l12 12"/>',
179
- plus: '<path d="M12 5v14M5 12h14"/>',
180
- play: '<path d="M6 4v16l14-8z"/>',
181
- pause: '<path d="M8 5v14M16 5v14"/>',
182
- refresh: '<path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/>',
183
- circle: '<circle cx="12" cy="12" r="9"/>',
184
- 'circle-dot': '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3" fill="currentColor"/>',
185
- dot: '<circle cx="12" cy="12" r="4" fill="currentColor"/>',
186
- square: '<rect x="4" y="4" width="16" height="16" rx="2"/>',
187
- activity: '<path d="M3 12h4l3 8 4-16 3 8h4"/>',
188
- info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
189
- help: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 4.9.8c0 1.7-2.4 2-2.4 3.7M12 17h.01"/>',
190
- warn: '<path d="M10.3 4 2.7 17a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/>',
191
- // file-type icons (replace the FILE_GLYPHS unicode set)
192
- 'file-pdf': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h6"/>',
193
- 'file-zip': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="M11 4v3M11 9v3M11 14v3"/>',
194
- 'file-video': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="m10 12 4 2.5L10 17z"/>',
195
- 'file-audio': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="M9 17v-3l4-1v3"/><circle cx="8" cy="17" r="1"/><circle cx="12" cy="16" r="1"/>',
196
- 'file-sheet': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="M8 13h8M8 17h8M12 11v8"/>',
197
- 'file-code': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="m10 12-2 2 2 2M14 12l2 2-2 2"/>',
198
- 'file-text': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><path d="M8 13h8M8 17h6"/>',
199
- file: '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/>',
200
- pencil: '<path d="M4 20h4L19 9a2 2 0 0 0-3-3L5 17z"/><path d="M14 6l3 3"/>',
201
- 'skip-forward': '<path d="M5 5v14l9-7z"/><path d="M19 5v14"/>',
202
- 'chevron-left': '<path d="m15 6-6 6 6 6"/>',
203
- trash: '<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/>',
204
- 'external-link': '<path d="M14 4h6v6M20 4l-9 9M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6"/>',
205
- // theme-toggle icons (replace decorative sun/moon/contrast text glyphs)
206
- sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
207
- moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>',
208
- contrast: '<circle cx="12" cy="12" r="9"/><path d="M12 3v18a9 9 0 0 0 0-18z" fill="currentColor"/>',
209
- // file-browser icons (replace folder/file emoji + arrow glyphs in fs apps)
210
- folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
211
- 'folder-open': '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2H5l-2 9z"/><path d="M3 18l2-9h17l-2 9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
212
- // density-picker icons (list / compact / thumbnail view modes)
213
- rows: '<path d="M4 6h16M4 12h16M4 18h16"/>',
214
- 'rows-tight': '<path d="M4 5h16M4 9h16M4 13h16M4 17h16"/>',
215
- grid: '<rect x="4" y="4" width="7" height="7" rx="1"/><rect x="13" y="4" width="7" height="7" rx="1"/><rect x="4" y="13" width="7" height="7" rx="1"/><rect x="13" y="13" width="7" height="7" rx="1"/>',
216
- 'file-image': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><circle cx="9.5" cy="12.5" r="1.5"/><path d="M18 19l-4-4-3 3-2-2-3 3"/>',
217
- link: '<path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/>',
218
- upload: '<path d="M12 16V4M7 9l5-5 5 5"/><path d="M5 20h14"/>',
219
- download: '<path d="M12 4v12M7 11l5 5 5-5"/><path d="M5 20h14"/>',
220
- 'corner-up-left': '<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 5 5v6"/>',
221
- // clipboard/copy — for the per-block code copy + message copy action, so the
222
- // copy affordance reads as copy, not the lined-document `page` glyph.
223
- copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/>',
224
- clipboard: '<rect x="8" y="4" width="8" height="4" rx="1"/><path d="M8 6H6a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-2"/>'
225
- };
226
- // Raw-DOM consumers (no webjsx render in scope) need the SVG as a markup string
227
- // rather than an h() vnode. Same path table, same viewBox/stroke contract as
228
- // Icon(); use innerHTML = iconMarkup(name). Keeps the icon paths upstream so
229
- // raw-DOM call sites never reintroduce decorative glyph literals.
230
- // The single SVG attribute contract (viewBox/stroke/linecap…) shared by both
231
- // the markup-string and the vnode renderers below, so the icon shape is defined
232
- // once. Insertion order is the serialized attribute order iconMarkup emits.
233
- function iconAttrs(name, size) {
234
- return {
235
- class: 'ds-icon ds-icon-' + name,
236
- width: String(size), height: String(size), viewBox: '0 0 24 24',
237
- fill: 'none', stroke: 'currentColor', 'stroke-width': 'var(--ds-icon-stroke, 1.6)',
238
- 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true',
239
- };
240
- }
241
- // Normalize the (name) | ({name,size}) call shapes both renderers accept.
242
- function iconArgs(name, size) {
243
- if (name && typeof name === 'object') ({ name, size = 16 } = name);
244
- return { name, size };
245
- }
246
- // Raw-DOM consumers (no webjsx render in scope) need the SVG as a markup string
247
- // rather than an h() vnode. Same path table + attr contract as Icon(); use
248
- // innerHTML = iconMarkup(name). Keeps the icon paths upstream so raw-DOM call
249
- // sites never reintroduce decorative glyph literals.
250
- export function iconMarkup(name, { size = 16 } = {}) {
251
- ({ name, size } = iconArgs(name, size));
252
- const inner = ICON_PATHS[name];
253
- if (!inner) return '';
254
- const attrs = Object.entries(iconAttrs(name, size)).map(([k, v]) => `${k}="${v}"`).join(' ');
255
- return `<svg ${attrs}>${inner}</svg>`;
256
- }
257
- export function Icon(name, { size = 16 } = {}) {
258
- ({ name, size } = iconArgs(name, size));
259
- const inner = ICON_PATHS[name];
260
- if (!inner) return h('span', { class: 'glyph', 'aria-hidden': 'true' }, '');
261
- return h('svg', { ...iconAttrs(name, size), dangerouslySetInnerHTML: { __html: inner } });
262
- }
263
-
264
- export function Topbar({ brand = '247420', leaf = '', items = [], active = '', onNav, search } = {}) {
265
- return h('header', { class: 'app-topbar', role: 'banner' },
266
- Brand({ name: brand, leaf }),
267
- search ? h('label', { class: 'app-search' },
268
- h('span', { class: 'icon', 'aria-hidden': 'true' }, 'search'),
269
- // `search` is either a plain placeholder string (renders the
270
- // default uncontrolled input) or a caller-built VElement (has
271
- // .type/.props — e.g. a controlled <input> wired to app state)
272
- // rendered as-is. Stringifying a VElement into placeholder/
273
- // aria-label previously produced literal "[object Object]" text.
274
- (search && typeof search === 'object' && 'type' in search)
275
- ? search
276
- : h('input', { type: 'search', name: 'q', placeholder: search, 'aria-label': `search ${search}` })
277
- ) : null,
278
- h('nav', { 'aria-label': 'main navigation' }, ...items.map(([label, href]) => {
279
- const cleanLabel = String(label).replace(' ->', '');
280
- return h('a', {
281
- key: label,
282
- href,
283
- class: active === cleanLabel ? 'active' : '',
284
- 'aria-current': active === cleanLabel ? 'page' : null,
285
- onclick: (e) => {
286
- if (!String(href).startsWith('http') && onNav) {
287
- e.preventDefault();
288
- onNav(cleanLabel);
289
- }
290
- }
291
- }, label);
292
- }))
293
- );
294
- }
295
-
296
- export function Crumb({ trail = [], leaf = '', right } = {}) {
297
- const parts = [];
298
- trail.forEach((t, i) => {
299
- parts.push(h('span', { key: 't' + i }, t));
300
- parts.push(h('span', { key: 's' + i, class: 'sep' }, '/'));
301
- });
302
- parts.push(h('span', { key: 'leaf', class: 'leaf' }, leaf));
303
- if (right) parts.push(h('span', { key: 'r', class: 'crumb-right' }, ...(Array.isArray(right) ? right : [right])));
304
- return h('div', { class: 'app-crumb' }, ...parts);
305
- }
306
-
307
- // ArrowUp/ArrowDown/Home/End move focus between sidebar links without
308
- // altering tabindex -- every link stays naturally Tab-reachable (a plain
309
- // link list, not a role=tablist), arrows are a same-list quick-nav shortcut
310
- // layered on top, mirroring the roving-nav affordance Tabs already has
311
- // (editor-primitives.js) but without roving-tabindex's activate-on-move
312
- // semantics, since a nav link's "activation" is a real navigation the user
313
- // should still choose deliberately with Enter/click.
314
- function onSideLinkKeyDown(e) {
315
- let dir = 0;
316
- if (e.key === 'ArrowDown') dir = 1;
317
- else if (e.key === 'ArrowUp') dir = -1;
318
- else if (e.key === 'Home' || e.key === 'End') dir = e.key === 'Home' ? 'first' : 'last';
319
- else return;
320
- const side = e.currentTarget.closest('.app-side');
321
- if (!side) return;
322
- const links = Array.from(side.querySelectorAll('a'));
323
- const curIdx = links.indexOf(e.currentTarget);
324
- if (curIdx === -1) return;
325
- e.preventDefault();
326
- let nextIdx;
327
- if (dir === 'first') nextIdx = 0;
328
- else if (dir === 'last') nextIdx = links.length - 1;
329
- else nextIdx = (curIdx + dir + links.length) % links.length;
330
- const next = links[nextIdx];
331
- if (next) next.focus();
332
- }
333
-
334
- export function Side({ sections = [] } = {}) {
335
- return h('aside', { class: 'app-side', role: 'navigation', 'aria-label': 'sidebar navigation' }, ...sections.map(sec => {
336
- const groupId = 'side-group-' + String(sec.group).replace(/\W+/g, '-').toLowerCase();
337
- // Each section is a group labelled by its heading, so AT users hear the
338
- // heading as the group name instead of an orphan heading.
339
- return h('div', { class: 'app-side-group', key: sec.group, role: 'group', 'aria-labelledby': groupId },
340
- h('h2', { class: 'group', id: groupId }, sec.group),
341
- ...sec.items.map((item, i) => {
342
- const { glyph, label, href = '#', active, count, color, onClick } = item;
343
- const countLabel = (count != null && count !== 0 && count !== '0') ? ` (${count})` : '';
344
- return h('a', {
345
- key: sec.group + i,
346
- href,
347
- class: active ? 'active' : '',
348
- 'aria-current': active ? 'page' : null,
349
- 'aria-label': label + countLabel,
350
- onclick: onClick,
351
- onkeydown: onSideLinkKeyDown
352
- },
353
- glyph != null ? Glyph({ children: glyph, color }) : h('span', { class: 'glyph', 'aria-hidden': 'true' }),
354
- h('span', {}, label),
355
- (count != null && count !== 0 && count !== '0') ? h('span', { class: 'count', 'aria-hidden': 'true' }, String(count)) : null
356
- );
357
- })
358
- );
359
- }));
360
- }
361
-
362
- export function Status({ left = [], right = [] } = {}) {
363
- return h('footer', { class: 'app-status', role: 'contentinfo' },
364
- ...left.map((t, i) => h('span', { key: 'l' + i, class: 'item' }, t)),
365
- h('span', { key: 'spread', class: 'spread', 'aria-hidden': 'true' }),
366
- ...right.map((t, i) => h('span', { key: 'r' + i, class: 'item' }, t))
367
- );
368
- }
369
-
370
- // Toggle the sidebar drawer. Pure-DOM because AppShell is stateless chrome; the
371
- // class lives on .app-body and is read by the @container(max-width:900px) query.
372
- // `fromEl` scopes the toggle to the shell that owns the clicked control — without
373
- // it, document.querySelector grabs the FIRST .app-body on the page, so a second
374
- // dashboard instance (multiple thebird WM windows) would toggle the wrong drawer.
375
- function toggleSide(open, fromEl) {
376
- const shell = (fromEl && fromEl.closest && fromEl.closest('.app')) || document;
377
- const body = shell.querySelector('.app-body');
378
- if (!body) return;
379
- const next = open != null ? open : !body.classList.contains('side-open');
380
- body.classList.toggle('side-open', next);
381
- const btn = shell.querySelector('.app-side-toggle');
382
- if (btn) btn.setAttribute('aria-expanded', next ? 'true' : 'false');
383
- // Keyboard parity with toggleWsDrawer: Esc dismisses the drawer and Tab is
384
- // trapped inside it while it overlays the content behind the scrim.
385
- if (body._dsSideKey) { document.removeEventListener('keydown', body._dsSideKey); body._dsSideKey = null; }
386
- if (next) {
387
- const drawer = shell.querySelector('.app-side-shell');
388
- const focusable = drawer && drawer.querySelector('button, a, input, [tabindex]');
389
- if (focusable) try { focusable.focus(); } catch (_) { /* swallow: focus() can throw on a detached/hidden element, drawer still opens */ }
390
- const onKey = (e) => {
391
- if (e.key === 'Escape') { toggleSide(false, btn || body); if (btn) try { btn.focus(); } catch (_) { /* swallow: focus() can throw on a detached/hidden element */ } return; }
392
- if (drawer) trapTab(drawer, e);
393
- };
394
- body._dsSideKey = onKey;
395
- document.addEventListener('keydown', onKey);
396
- }
397
- }
398
-
399
- // Ref on the .app root: re-sync the toggle's aria-expanded from the live
400
- // .side-open class (applyDiff re-renders reset the attribute to 'false'), and
401
- // arm a ResizeObserver that closes a stuck-open drawer when the shell grows
402
- // past the 900px container breakpoint (the drawer CSS stops applying there,
403
- // but the class would otherwise persist and reappear on the next shrink).
404
- function syncAppSide(el) {
405
- if (!el) return;
406
- const body = el.querySelector('.app-body');
407
- const btn = el.querySelector('.app-side-toggle');
408
- if (btn && body) btn.setAttribute('aria-expanded', body.classList.contains('side-open') ? 'true' : 'false');
409
- if (!el._dsSideRO && typeof ResizeObserver !== 'undefined') {
410
- el._dsSideRO = new ResizeObserver((entries) => {
411
- const w = entries[0] && entries[0].contentRect.width;
412
- const b = el.querySelector('.app-body');
413
- if (w > 900 && b && b.classList.contains('side-open')) toggleSide(false, el);
414
- });
415
- el._dsSideRO.observe(el);
416
- }
417
- }
418
-
419
- export function AppShell({ topbar, crumb, side, main, status, narrow } = {}) {
420
- const hasSide = Boolean(side);
421
- const sideNode = hasSide ? side : h('aside', { class: 'app-side', 'aria-hidden': 'true' });
422
- // Topbar and crumb used to stack as two separate chrome bars — a "double
423
- // title bar". When both are present, fold them into one sticky row:
424
- // brand + nav (topbar) and breadcrumb + right slot (crumb) share a single
425
- // band so the chrome reads as one bar, not two. Either prop alone still
426
- // renders on its own (consumers that pass only a topbar are unaffected).
427
- const chrome = (topbar && crumb)
428
- ? h('div', { class: 'app-chrome' }, topbar, crumb)
429
- : (topbar || crumb || null);
430
- return h('div', { class: 'app', ref: syncAppSide },
431
- h('a', { href: '#app-main', class: 'skip-link' }, 'skip to main content'),
432
- hasSide ? h('button', {
433
- class: 'app-side-toggle', type: 'button',
434
- 'aria-label': 'toggle navigation', 'aria-expanded': 'false', 'aria-controls': 'app-side-shell',
435
- onclick: (e) => toggleSide(null, e.currentTarget),
436
- }, Icon('menu')) : null,
437
- chrome,
438
- h('div', { class: 'app-body' + (hasSide ? '' : ' no-side') },
439
- h('div', { class: 'app-side-scrim', 'aria-hidden': 'true', onclick: (e) => toggleSide(false, e.currentTarget) }),
440
- h('div', { class: 'app-side-shell', id: 'app-side-shell', onclick: (e) => { if (e.target.closest('a')) toggleSide(false, e.currentTarget); } }, sideNode),
441
- // tabindex=-1 so the skip-link (href="#app-main") actually moves
442
- // keyboard focus into the main region, not just scroll to it.
443
- h('main', { class: 'app-main' + (narrow ? ' narrow' : ''), id: 'app-main', tabindex: '-1' }, ...(Array.isArray(main) ? main : [main]))
444
- ),
445
- status || null
446
- );
447
- }
448
-
449
- // Toggle a named WorkspaceShell column (left rail or right pane). Pure-DOM like
450
- // toggleSide: WorkspaceShell is stateless chrome, the collapsed class lives on
451
- // .ws-shell and is read by both CSS and the toggle buttons' aria-expanded.
452
- function toggleWs(which, fromEl) {
453
- // Scope to the shell owning the clicked control, like toggleSide — the
454
- // first-on-page querySelector toggles the WRONG shell with two instances.
455
- const shell = (fromEl && fromEl.closest && fromEl.closest('.ws-shell')) || document.querySelector('.ws-shell');
456
- if (!shell) return;
457
- const cls = which === 'pane' ? 'ws-pane-collapsed'
458
- : which === 'sessions' ? 'ws-sessions-collapsed'
459
- : 'ws-rail-collapsed';
460
- const nowCollapsed = shell.classList.toggle(cls);
461
- // Inline --ws-*-w beats the collapsed-class rule in the cascade, so a
462
- // persisted width would render a "collapsed" column 200-640px wide.
463
- if (nowCollapsed) shell.style.removeProperty('--ws-' + which + '-w');
464
- shell.querySelectorAll('.ws-' + which + '-toggle').forEach((btn) => {
465
- btn.setAttribute('aria-expanded', nowCollapsed ? 'false' : 'true');
466
- const nextLabel = nowCollapsed ? 'expand ' + which : 'collapse ' + which;
467
- btn.setAttribute('aria-label', nextLabel);
468
- btn.setAttribute('title', nextLabel);
469
- });
470
- try {
471
- localStorage.setItem('ds.ws.' + which, nowCollapsed ? 'collapsed' : 'open');
472
- } catch (_) { /* swallow: persistence is best-effort, collapse state still applies in-memory */ }
473
- // Expanding restores the persisted width (seed skips collapsed columns, so
474
- // it must run after the open flag is written).
475
- if (!nowCollapsed) seedWsWidths(shell);
476
- }
477
-
478
- // Column resize: read the current rendered track width and write a clamped inline
479
- // --ws-<col>-w on .ws-shell (inline overrides the fluid clamp base), persisted.
480
- // Floors match the CSS fluid clamp() floors in app-shell.css (--ws-rail-w
481
- // clamp(200,16vw,260); sessions clamp(248,22vw,360); pane clamp(288,24vw,420))
482
- // so a drag/arrow can never shrink a column below its designed minimum (the
483
- // collapsed rail is a SEPARATE class, not a resize target). The ceilings are
484
- // INTENTIONALLY raised above the fluid clamp() mid-term ceilings: on wide
485
- // viewports the 16/22/24vw mid term already pins each column to its clamp
486
- // ceiling, so a ceiling-equals-clamp bound made the outward drag inert there.
487
- // The higher resize ceilings let a deliberate drag/arrow grow a column past its
488
- // auto-fluid width (the inline --ws-<col>-w override pins the chosen width past
489
- // the clamp base).
490
- const WS_RESIZE_CLAMP = { rail: [200, 320], sessions: [248, 520], pane: [288, 640] };
491
- function wsResize(col, dx, persist = true, fromEl) {
492
- const shell = (fromEl && fromEl.closest && fromEl.closest('.ws-shell')) || document.querySelector('.ws-shell');
493
- if (!shell) return;
494
- const track = shell.querySelector('.ws-' + col);
495
- const cur = track ? track.getBoundingClientRect().width : 0;
496
- const [lo, hi] = WS_RESIZE_CLAMP[col] || [120, 600];
497
- const next = Math.max(lo, Math.min(hi, Math.round(cur + dx)));
498
- shell.style.setProperty('--ws-' + col + '-w', next + 'px');
499
- const handle = shell.querySelector('.ws-resizer-' + col);
500
- if (handle) { handle.setAttribute('aria-valuenow', String(next)); handle.setAttribute('aria-valuetext', next + ' pixels'); }
501
- // Commit to storage only on a settled move (pointerup / keyboard), not on
502
- // every pointermove frame (that fired dozens of synchronous writes per drag).
503
- if (persist) { try { localStorage.setItem('ds.ws.w.' + col, String(next)); } catch (_) { /* swallow: persistence is best-effort, resize still applies in-memory */ } }
504
- }
505
- // Per-column viewport caps for persisted widths: a width dragged on a wide
506
- // monitor must not crush the content column when the page reloads on a
507
- // narrower screen (rail 320 + sessions 520 would leave ~180px of content).
508
- const WS_VW_CAP = { rail: '20vw', sessions: '30vw', pane: '32vw' };
509
- function seedWsWidths(el) {
510
- if (!el) return;
511
- ['rail', 'sessions', 'pane'].forEach((col) => {
512
- try {
513
- // A persisted-collapsed column must stay collapsed: the inline var
514
- // would beat the .ws-*-collapsed class rule in the cascade.
515
- if (wsCollapsed(col, false)) return;
516
- const v = localStorage.getItem('ds.ws.w.' + col);
517
- if (v && /^\d+$/.test(v)) el.style.setProperty('--ws-' + col + '-w', `min(${v}px, ${WS_VW_CAP[col]})`);
518
- } catch (_) { /* swallow: localStorage unavailable, seeding is best-effort */ }
519
- });
520
- }
521
- function WsResizer(col) {
522
- const onKey = (e) => {
523
- if (e.key === 'ArrowLeft') { e.preventDefault(); wsResize(col, -16, true, e.currentTarget); }
524
- else if (e.key === 'ArrowRight') { e.preventDefault(); wsResize(col, 16, true, e.currentTarget); }
525
- };
526
- const onDown = (e) => {
527
- e.preventDefault();
528
- const handleEl = e.currentTarget;
529
- let lastX = e.clientX;
530
- const move = (ev) => { const dx = ev.clientX - lastX; lastX = ev.clientX; wsResize(col, dx, false, handleEl); };
531
- const up = () => {
532
- document.removeEventListener('pointermove', move);
533
- document.removeEventListener('pointerup', up);
534
- document.body.style.cursor = '';
535
- wsResize(col, 0, true, handleEl); // commit the settled width once
536
- };
537
- document.addEventListener('pointermove', move);
538
- document.addEventListener('pointerup', up);
539
- document.body.style.cursor = 'col-resize';
540
- };
541
- const [lo, hi] = WS_RESIZE_CLAMP[col] || [120, 600];
542
- // Seed aria-valuenow from the rendered track width so AT announces real widths.
543
- const seedNow = (el) => {
544
- if (!el) return;
545
- const track = el.closest('.ws-shell') && el.closest('.ws-shell').querySelector('.ws-' + col);
546
- if (track) { const w = Math.round(track.getBoundingClientRect().width); el.setAttribute('aria-valuenow', String(w)); el.setAttribute('aria-valuetext', w + ' pixels'); }
547
- };
548
- return h('div', {
549
- class: 'ws-resizer ws-resizer-' + col, role: 'separator', tabindex: '0',
550
- 'aria-orientation': 'vertical', 'aria-label': 'resize ' + col + ' column (arrow keys)',
551
- 'aria-valuemin': String(lo), 'aria-valuemax': String(hi), 'aria-valuetext': String(hi) + ' pixels',
552
- onpointerdown: onDown, onkeydown: onKey, ref: seedNow,
553
- });
554
- }
555
-
556
- // Toggle a mobile WorkspaceShell DRAWER (sessions or pane). Distinct from the
557
- // desktop width-collapse (toggleWs): on mobile the columns are fixed overlays
558
- // revealed by .ws-sessions-open / .ws-pane-open. Opening one closes the other
559
- // (only one drawer at a time over the content). Esc + scrim dismiss call this
560
- // with open=false. Pure-DOM, matching the AppShell toggleSide pattern.
561
- function toggleWsDrawer(which, open, fromEl) {
562
- const shell = (fromEl && fromEl.closest && fromEl.closest('.ws-shell')) || document.querySelector('.ws-shell');
563
- if (!shell) return;
564
- const cls = which === 'pane' ? 'ws-pane-open' : 'ws-sessions-open';
565
- const other = which === 'pane' ? 'ws-sessions-open' : 'ws-pane-open';
566
- const next = open != null ? open : !shell.classList.contains(cls);
567
- shell.classList.toggle(cls, next);
568
- if (next) shell.classList.remove(other);
569
- const btn = shell.querySelector('.ws-' + which + '-drawer-toggle');
570
- if (btn) btn.setAttribute('aria-expanded', next ? 'true' : 'false');
571
- if (!next) { removeWsDrawerHandlers(shell); return; }
572
- // When opening, move focus into the drawer, arm an Esc-to-close, and trap
573
- // Tab/Shift+Tab inside the drawer (a real focus trap, matching the kit's
574
- // own dialogs - Tab from inside an open drawer previously walked focus out
575
- // into the scrim/background content behind it).
576
- const drawer = shell.querySelector(which === 'pane' ? '.ws-pane' : '.ws-sessions');
577
- const focusable = drawer && drawer.querySelector('button, a, input, [tabindex]');
578
- if (focusable) try { focusable.focus(); } catch (_) { /* swallow: focus() can throw on a detached/hidden element, drawer still opens */ }
579
- removeWsDrawerHandlers(shell); // replace, never stack (opening one drawer over the other)
580
- const onKey = (e) => {
581
- if (e.key === 'Escape') { toggleWsDrawer(which, false, shell); if (btn) try { btn.focus(); } catch (_) { /* swallow: focus() can throw on a detached/hidden element */ } return; }
582
- if (drawer) trapTab(drawer, e);
583
- };
584
- shell._wsEscHandler = onKey;
585
- document.addEventListener('keydown', onKey);
586
- // The drawer CSS stops applying above its breakpoint; auto-close when the
587
- // viewport grows past it so the open class and armed Esc/focus-trap
588
- // handlers do not linger invisibly in desktop layout.
589
- const mq = window.matchMedia('(max-width: 1480px)');
590
- const onMq = () => { if (!mq.matches) closeWsDrawers(shell); };
591
- shell._wsDrawerMq = { mq, onMq };
592
- mq.addEventListener('change', onMq);
593
- }
594
- function removeWsDrawerHandlers(shell) {
595
- // Remove Esc/focus-trap handler armed by toggleWsDrawer (prevents ghost
596
- // close on next Esc) and the viewport-growth auto-close listener.
597
- if (shell._wsEscHandler) { document.removeEventListener('keydown', shell._wsEscHandler); shell._wsEscHandler = null; }
598
- if (shell._wsDrawerMq) { shell._wsDrawerMq.mq.removeEventListener('change', shell._wsDrawerMq.onMq); shell._wsDrawerMq = null; }
599
- }
600
- function closeWsDrawers(fromEl) {
601
- const shell = (fromEl && fromEl.closest && fromEl.closest('.ws-shell')) || document.querySelector('.ws-shell');
602
- if (!shell) return;
603
- shell.classList.remove('ws-sessions-open', 'ws-pane-open');
604
- shell.querySelectorAll('.ws-sessions-drawer-toggle, .ws-pane-drawer-toggle').forEach((b) => b.setAttribute('aria-expanded', 'false'));
605
- removeWsDrawerHandlers(shell);
606
- }
607
-
608
- // Read persisted collapse state for a WorkspaceShell column so the layout is
609
- // predictable across reloads (Claude-Desktop keeps the rail where you left it).
610
- function wsCollapsed(which, fallback) {
611
- try {
612
- const v = localStorage.getItem('ds.ws.' + which);
613
- if (v === 'collapsed') return true;
614
- if (v === 'open') return false;
615
- } catch (_) { /* swallow: localStorage unavailable, fall back to caller-supplied default */ }
616
- return !!fallback;
617
- }
618
-
619
- /**
620
- * A Claude-Desktop / cowork three-(or four-)column app shell.
621
- *
622
- * Pure stateless chrome (props in, vnode out). Collapse is DOM-class + a
623
- * persisted flag, so the host does not have to thread collapse state through
624
- * its own store. Visual styling lives in app-shell.css (.ws-*).
625
- *
626
- * @param {Object} props
627
- * @param {*} props.rail - the persistent left workspace nav (icon+label items, collapsible to icon-only). Pass the result of WorkspaceRail() or any vnode.
628
- * @param {*} props.sessions - an OPTIONAL second column (a conversation/session list) shown between the rail and the main content. Null hides it.
629
- * @param {*} props.main - the primary content column (chat thread, files view, dashboard...).
630
- * @param {*} props.pane - an OPTIONAL right context pane (per-conversation context, file preview...). Null hides it; collapsible when present.
631
- * @param {*} props.crumb - an optional thin top chrome bar (breadcrumb + status), spanning the content area only (the rail has its own header).
632
- * @param {*} props.status - an optional footer.
633
- * @param {boolean} props.narrow - caller's isNarrow() — drives the mobile single-column collapse.
634
- * @param {boolean} props.railCollapsed - initial rail collapse (persisted state wins).
635
- * @param {boolean} props.paneCollapsed - initial pane collapse (persisted state wins).
636
- * @returns {*} webjsx vnode
637
- */
638
- export function WorkspaceShell({ rail, sessions, main, pane, crumb, status, narrow,
639
- railCollapsed = false, paneCollapsed = false,
640
- railLabel = 'workspace navigation',
641
- paneLabel = 'context', stableFrame = false, mainFlush = false } = {}) {
642
- const hasSessions = Boolean(sessions);
643
- const hasPane = Boolean(pane);
644
- // Stable frame: keep the pane grid TRACK present even when this tab has no
645
- // pane, so the shell does not re-flow its column count (4/3/2) on every tab
646
- // switch - the loudest "separate pages" tell. The track collapses to width 0
647
- // (ws-pane-collapsed) instead of being removed (ws-no-pane), so chat/history/
648
- // files/live/settings all keep the same column geometry. The sessions column
649
- // gets the identical treatment (ws-sessions-collapsed instead of ws-no-sessions)
650
- // so files/live/settings do not shift the main column when sessions is null.
651
- const keepPaneTrack = stableFrame && !hasPane;
652
- const keepSessionsTrack = stableFrame && !hasSessions;
653
- const railIsCollapsed = wsCollapsed('rail', railCollapsed);
654
- const paneIsCollapsed = hasPane ? wsCollapsed('pane', paneCollapsed) : true;
655
- const sessionsIsCollapsed = hasSessions ? wsCollapsed('sessions', false) : true;
656
- const shellCls = 'ws-shell'
657
- + (railIsCollapsed ? ' ws-rail-collapsed' : '')
658
- + ((hasPane || keepPaneTrack) ? '' : ' ws-no-pane')
659
- + (((hasPane && paneIsCollapsed) || keepPaneTrack) ? ' ws-pane-collapsed' : '')
660
- + ((hasSessions || keepSessionsTrack) ? '' : ' ws-no-sessions')
661
- + (((hasSessions && sessionsIsCollapsed) || keepSessionsTrack) ? ' ws-sessions-collapsed' : '')
662
- + (narrow ? ' narrow' : '');
663
- return h('div', { class: shellCls, ref: seedWsWidths },
664
- h('a', { href: '#ws-main', class: 'skip-link' }, 'skip to main content'),
665
- // Left rail column. Its own toggle collapses it to icon-only.
666
- h('nav', { class: 'ws-rail', role: 'navigation', 'aria-label': railLabel },
667
- h('button', {
668
- class: 'ws-rail-toggle', type: 'button',
669
- // Label reflects the ACTION the click performs (expand when
670
- // collapsed, collapse when expanded), not a static word - a
671
- // stale "collapse navigation" on an already-collapsed rail
672
- // mis-announces the control to AT.
673
- 'aria-label': railIsCollapsed ? 'expand navigation' : 'collapse navigation',
674
- title: railIsCollapsed ? 'expand navigation' : 'collapse navigation',
675
- 'aria-expanded': railIsCollapsed ? 'false' : 'true',
676
- onclick: (e) => toggleWs('rail', e.currentTarget),
677
- }, Icon('menu')),
678
- rail || null),
679
- // Tap-scrim behind an open mobile drawer; click anywhere dismisses.
680
- h('div', { class: 'ws-scrim', 'aria-hidden': 'true', onclick: (e) => closeWsDrawers(e.currentTarget) }),
681
- // Optional sessions column. On mobile it is a drawer; selecting a row
682
- // (any button click inside) auto-closes it, mirroring AppShell.
683
- hasSessions
684
- ? h('div', { id: 'ws-sessions-col', class: 'ws-sessions', role: 'complementary', 'aria-label': 'conversations',
685
- // Drawer mode is detected by geometry (position:fixed only holds
686
- // in drawer mode), not window.innerWidth - the shell may live in
687
- // an embedded window narrower than the viewport.
688
- onclick: (e) => {
689
- const col = e.currentTarget;
690
- if (getComputedStyle(col).position === 'fixed' && e.target.closest('button, a, [role="button"]')) closeWsDrawers(col);
691
- } }, sessions)
692
- : null,
693
- // Primary content column, with an optional thin crumb bar on top. On
694
- // mobile the crumb hosts the drawer toggles (sessions on the left, pane
695
- // on the right) so both overlay columns are reachable - without them the
696
- // conversation list and context pane are dead on <=900px.
697
- h('div', { class: 'ws-content' },
698
- crumb
699
- ? h('div', { class: 'ws-crumb' },
700
- hasSessions ? h('button', {
701
- class: 'ws-drawer-toggle ws-sessions-drawer-toggle', type: 'button',
702
- 'aria-label': 'toggle conversations', 'aria-expanded': 'false',
703
- 'aria-controls': 'ws-sessions-col',
704
- onclick: (e) => toggleWsDrawer('sessions', null, e.currentTarget),
705
- }, Icon('thread')) : null,
706
- // Desktop-only sessions collapse (reclaims its width for a
707
- // full-width thread/grid). Hidden on mobile via CSS.
708
- hasSessions ? h('button', {
709
- class: 'ws-desktop-toggle ws-sessions-toggle', type: 'button',
710
- 'aria-label': sessionsIsCollapsed ? 'expand conversations' : 'collapse conversations',
711
- title: sessionsIsCollapsed ? 'expand conversations' : 'collapse conversations',
712
- 'aria-expanded': sessionsIsCollapsed ? 'false' : 'true', onclick: (e) => toggleWs('sessions', e.currentTarget),
713
- }, Icon(sessionsIsCollapsed ? 'chevron-right' : 'chevron-left')) : null,
714
- h('div', { class: 'ws-crumb-main' }, crumb),
715
- // Desktop-only context-pane collapse, on the same crumb-level
716
- // chrome idiom as the sessions toggle. Hidden on mobile via CSS.
717
- hasPane ? h('button', {
718
- class: 'ws-desktop-toggle ws-pane-toggle', type: 'button',
719
- 'aria-label': paneIsCollapsed ? 'show context pane' : 'hide context pane',
720
- title: paneIsCollapsed ? 'show context pane' : 'hide context pane',
721
- 'aria-expanded': paneIsCollapsed ? 'false' : 'true',
722
- onclick: (e) => toggleWs('pane', e.currentTarget),
723
- }, Icon(paneIsCollapsed ? 'chevron-left' : 'chevron-right')) : null,
724
- hasPane ? h('button', {
725
- class: 'ws-drawer-toggle ws-pane-drawer-toggle', type: 'button',
726
- 'aria-label': 'toggle context pane', 'aria-expanded': 'false',
727
- 'aria-controls': 'ws-pane-col',
728
- onclick: (e) => toggleWsDrawer('pane', null, e.currentTarget),
729
- }, Icon('page')) : null)
730
- : null,
731
- h('main', { class: 'ws-main' + (narrow ? ' narrow' : '') + (mainFlush ? ' ws-main--flush' : ''), id: 'ws-main', tabindex: '-1' },
732
- ...(Array.isArray(main) ? main : [main])),
733
- status || null),
734
- // Optional right context pane. Its desktop collapse toggle now lives in
735
- // the crumb cluster, alongside the sessions toggle.
736
- hasPane
737
- ? h('aside', { id: 'ws-pane-col', class: 'ws-pane', role: 'complementary', 'aria-label': paneLabel },
738
- pane)
739
- : null,
740
- // Keyboard/pointer column resize handles (desktop only).
741
- (!narrow && !railIsCollapsed) ? WsResizer('rail') : null,
742
- (!narrow && (hasSessions || keepSessionsTrack) && !sessionsIsCollapsed) ? WsResizer('sessions') : null,
743
- (!narrow && (hasPane || keepPaneTrack) && !paneIsCollapsed) ? WsResizer('pane') : null,
744
- );
745
- }
746
-
747
- // WorkspaceRail — the contents of the WorkspaceShell left rail: a brand/header,
748
- // a primary action (New chat), and a list of nav items. Each item collapses to
749
- // an icon when the rail is collapsed (the label is kept in the DOM for AT and
750
- // shown via CSS when expanded).
751
4
  //
752
- // brand : short product name shown in the rail header.
753
- // action : { label, icon, onClick } a prominent primary button (New chat).
754
- // items : [{ key, label, icon, active, count, rail, onClick }] nav entries.
755
- // `rail` (optional tone e.g. 'flame') paints an attention dot on the
756
- // item used when something in that surface needs the user's eyes
757
- // even though they are looking at a different tab (e.g. a live
758
- // session in error while the user is in Chat).
759
- // footer : optional vnode pinned to the rail bottom (e.g. settings/theme).
760
- export function WorkspaceRail({ brand = '247420', action, items = [], footer } = {}) {
761
- return h('div', { class: 'ws-rail-inner' },
762
- h('div', { class: 'ws-rail-head' },
763
- h('span', { class: 'ws-rail-brand' }, brand)),
764
- action
765
- ? h('button', {
766
- class: 'ws-rail-action', type: 'button',
767
- 'aria-label': action.label,
768
- onclick: action.onClick || null,
769
- }, action.icon ? Icon(action.icon) : null, h('span', { class: 'ws-rail-action-label' }, action.label))
770
- : null,
771
- h('ul', { class: 'ws-rail-nav', role: 'list' },
772
- ...items.map((it) => h('li', { key: it.key || it.label, role: 'listitem' },
773
- h('button', {
774
- type: 'button',
775
- class: 'ws-rail-item' + (it.active ? ' active' : '') + (it.rail ? ' has-rail-flag' : ''),
776
- 'aria-current': it.active ? 'page' : null,
777
- 'aria-label': it.label + (it.count ? ' (' + it.count + ')' : '') + (it.rail === 'flame' ? ', needs attention' : ''),
778
- title: it.label,
779
- onclick: it.onClick || null,
780
- },
781
- it.icon ? Icon(it.icon) : h('span', { class: 'ws-rail-item-glyph', 'aria-hidden': 'true' }),
782
- h('span', { class: 'ws-rail-item-label' }, it.label),
783
- (it.count != null && it.count !== 0 && it.count !== '0')
784
- ? h('span', { class: 'ws-rail-item-count', 'aria-hidden': 'true' }, String(it.count))
785
- : null,
786
- it.rail ? h('span', { class: 'ws-rail-item-flag tone-' + it.rail, 'aria-hidden': 'true' }) : null)))),
787
- footer ? h('div', { class: 'ws-rail-foot' }, footer) : null,
788
- );
789
- }
790
-
791
- export function Heading({ level = 1, children, style = '', class: className = '', 'aria-level': ariaLevel }) {
792
- return h('h' + level, { class: className || null, style, 'aria-level': ariaLevel != null ? String(ariaLevel) : null }, children);
793
- }
794
-
795
- export function Lede({ children }) {
796
- return h('p', { class: 'lede' }, children);
797
- }
798
-
799
- export function Dot({ tone = 'on' }) {
800
- const isOn = tone === 'on' || tone === 'live';
801
- const cls = 'ds-dot ' + (isOn ? 'ds-dot-on' : 'ds-dot-off');
802
- const statusLabel = isOn ? 'on status indicator' : 'off status indicator';
803
- // Drawn as a CSS circle (.ds-dot) — no decorative text glyph.
804
- return h('span', { class: cls, role: 'img', 'aria-label': statusLabel });
805
- }
806
-
807
- export function Rail({ tone = 'green' }) {
808
- return h('span', { class: 'ds-rail tone-' + tone, 'aria-hidden': 'true' });
809
- }
5
+ // This module is a barrel: every component lives in a single-responsibility
6
+ // submodule under ./shell/, and the public export surface here is unchanged
7
+ // no consumer import needs to move.
8
+
9
+ import { Brand, Chip, Btn, IconButton, Badge, Pill, Glyph, Heading, Lede, Dot, Rail } from './shell/atoms.js';
10
+ import { ICON_PATHS, iconMarkup, Icon } from './shell/icons.js';
11
+ import { Topbar, Crumb, Side, Status, AppShell } from './shell/app-shell.js';
12
+ import { WorkspaceShell, WorkspaceRail } from './shell/workspace-shell.js';
13
+
14
+ export {
15
+ Brand, Chip, Btn, IconButton, Badge, Pill, Glyph, Heading, Lede, Dot, Rail,
16
+ ICON_PATHS, iconMarkup, Icon,
17
+ Topbar, Crumb, Side, Status, AppShell,
18
+ WorkspaceShell, WorkspaceRail,
19
+ };