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.
- package/community.css +21 -18
- package/dist/247420.css +107 -49
- package/dist/247420.js +23 -23
- package/package.json +1 -1
- package/scripts/lint-css.mjs +24 -10
- package/src/components/chat/message.js +130 -0
- package/src/components/chat/stats.js +36 -0
- package/src/components/chat/thread-scroll.js +42 -0
- package/src/components/freddie/pages-chat.js +149 -0
- package/src/components/freddie/pages-config.js +203 -0
- package/src/components/freddie/pages-infra.js +120 -0
- package/src/components/freddie/pages-overview.js +107 -0
- package/src/components/freddie/pages-runners.js +102 -0
- package/src/components/freddie/pages-telemetry.js +126 -0
- package/src/components/freddie/pages-workspace.js +176 -0
- package/src/components/freddie/shared.js +32 -0
- package/src/components/freddie/sse.js +85 -0
- package/src/components/freddie.js +21 -1064
- package/src/components/shell/app-shell.js +195 -0
- package/src/components/shell/atoms.js +165 -0
- package/src/components/shell/icons.js +123 -0
- package/src/components/shell/workspace-columns.js +179 -0
- package/src/components/shell/workspace-shell.js +181 -0
- package/src/components/shell.js +15 -805
- package/src/css/app-shell/chat-polish.css +18 -6
- package/src/css/app-shell/files.css +15 -4
- package/src/css/app-shell/hero-content.css +16 -1
- package/src/css/app-shell/kits-appended.css +26 -15
package/src/components/shell.js
CHANGED
|
@@ -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
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
+
};
|