anentrypoint-design 0.0.211 → 0.0.213

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.211",
3
+ "version": "0.0.213",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -215,6 +215,19 @@ function ToolCallNode(p) {
215
215
  // collapse on success unless the caller explicitly overrides with open:true.
216
216
  const defaultOpen = p.open != null ? !!p.open : (status === 'running' || status === 'error');
217
217
  const iconName = status === 'running' ? 'refresh' : (status === 'error' ? 'warn' : 'check');
218
+ const copyText = (txt) => (e) => {
219
+ const b = e.currentTarget;
220
+ const done = () => {
221
+ b.textContent = 'copied';
222
+ b.classList.add('is-copied');
223
+ setTimeout(() => { b.textContent = 'copy'; b.classList.remove('is-copied'); }, 1600);
224
+ };
225
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done).catch(() => {});
226
+ else { try { const t = document.createElement('textarea'); t.value = txt; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
227
+ };
228
+ const sectionLabel = (text, txt) => h('div', { class: 'chat-tool-section-label' },
229
+ h('span', {}, text),
230
+ h('button', { type: 'button', class: 'chat-code-copy chat-tool-copy', 'aria-label': 'copy ' + text, onclick: copyText(txt) }, 'copy'));
218
231
  return h('details', { class: 'chat-bubble chat-tool tool-' + status, open: defaultOpen },
219
232
  h('summary', { class: 'chat-tool-head' },
220
233
  h('span', { class: 'chat-tool-icon', 'aria-hidden': 'true' }, Icon(iconName, { size: 14 })),
@@ -224,10 +237,10 @@ function ToolCallNode(p) {
224
237
  ),
225
238
  h('div', { class: 'chat-tool-body' },
226
239
  h('div', { class: 'chat-tool-section' },
227
- h('div', { class: 'chat-tool-section-label' }, 'args'),
240
+ sectionLabel('args', argsText),
228
241
  h('pre', { class: 'chat-tool-pre' }, h('code', {}, argsText))),
229
242
  resultText ? h('div', { class: 'chat-tool-section' },
230
- h('div', { class: 'chat-tool-section-label' }, p.error ? 'error' : 'result'),
243
+ sectionLabel(p.error ? 'error' : 'result', resultText),
231
244
  h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText)))
232
245
  // A finished tool with no output would otherwise render no result
233
246
  // section, reading identically to a still-running tool. Show an
@@ -6,8 +6,8 @@ import * as webjsx from '../../vendor/webjsx/index.js';
6
6
  import { Btn, Heading, Lede, Dot, Icon } from './shell.js';
7
7
  const h = webjsx.createElement;
8
8
 
9
- export function Panel({ title, count, right, style = '', children, kind, id }) {
10
- const cls = 'panel' + (kind ? ' panel-' + kind : '');
9
+ export function Panel({ title, count, right, style = '', class: className = '', children, kind, id }) {
10
+ const cls = 'panel' + (kind ? ' panel-' + kind : '') + (className ? ' ' + className : '');
11
11
  return h('div', { class: cls, style, id: id || null },
12
12
  title != null ? h('div', { class: 'panel-head' },
13
13
  h('span', {}, title),
@@ -43,7 +43,7 @@ function highlightTitle(title, highlight) {
43
43
  return segs;
44
44
  }
45
45
 
46
- export function Row({ code, rank, title, sub, meta, active, state = 'default', onClick, key, style, href, kind, cols, leading, trailing, target, selected, rail, expanded, highlight, actions }) {
46
+ export function Row({ code, rank, title, sub, meta, active, state = 'default', onClick, key, style, href, kind, cols, leading, trailing, target, selected, rail, expanded, highlight, actions, detail }) {
47
47
  // `rank` is an alias for `code` (the leading monospace index); callers use
48
48
  // either name. `rail` renders a thin colour bar at the row's leading edge as
49
49
  // a status indicator (tone: green | purple | flame | <any token>).
@@ -105,12 +105,15 @@ export function Row({ code, rank, title, sub, meta, active, state = 'default', o
105
105
  // state. green is the unremarkable default - announcing "ok" everywhere would
106
106
  // be AT noise - so it emits nothing.
107
107
  const railWord = rail === 'flame' ? 'error' : rail === 'purple' ? 'subagent' : null;
108
+ // `detail` renders as a sibling block AFTER the title/meta children (its own
109
+ // line via flex-basis:100% in .ds-row-detail), not inside the title span.
108
110
  return h(isLink ? 'a' : 'div', props,
109
111
  railWord ? h('span', { class: 'sr-only' }, railWord) : null,
110
112
  leading != null ? leading : (codeVal != null ? h('span', { class: 'code' }, codeVal) : null),
111
113
  h('span', { class: 'title' }, titleNode, sub ? h('span', { class: 'sub' }, sub) : null),
112
114
  trailing != null ? trailing : (meta != null ? h('span', { class: 'meta' }, meta) : null),
113
- actionRow);
115
+ actionRow,
116
+ detail != null ? h('pre', { class: 'ds-row-detail' }, detail) : null);
114
117
  }
115
118
 
116
119
  export function RowLink({ code, title, sub, meta, href = '#', key, target }) {
@@ -4,10 +4,6 @@ import * as webjsx from '../../vendor/webjsx/index.js';
4
4
  import { Btn, Icon } from './shell.js';
5
5
  const h = webjsx.createElement;
6
6
 
7
- // Minimum column width for the responsive file grid (minmax floor). Named so
8
- // the magic 240px isn't buried in the gridTemplateColumns string.
9
- const FILE_GRID_MIN_COL = '240px';
10
-
11
7
  const FILE_TYPES = ['dir', 'image', 'video', 'audio', 'code', 'text', 'archive', 'document', 'symlink', 'other'];
12
8
  const TYPE_ICON = {
13
9
  dir: 'folder', image: 'file-image', video: 'file-video', audio: 'file-audio', code: 'file-code',
@@ -59,9 +55,12 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
59
55
  const noAccess = locked || permissions === 'EACCES' || (Array.isArray(permissions) && permissions.length === 0);
60
56
  const readOnly = !noAccess && Array.isArray(permissions) && permissions.indexOf('write') === -1 && permissions.indexOf('read') !== -1;
61
57
  const permTag = noAccess ? 'no access' : (readOnly ? 'read-only' : null);
62
- const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null, permTag].filter(Boolean).join(' · ');
58
+ // permTag is rendered as its own chip (a SHAPE channel, not folded into the
59
+ // muted meta text) - so drop it from the meta join, but keep it in the
60
+ // accessible label so AT still announces the restriction.
61
+ const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null].filter(Boolean).join(' · ');
63
62
  const typeLabel = TYPE_LABELS[type] || 'file';
64
- const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}`;
63
+ const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}${permTag ? ', ' + permTag : ''}`;
65
64
  const canOpen = onOpen && !noAccess && !busy;
66
65
  // Mutation actions on a read-only/no-access row render disabled (with a
67
66
  // 'read-only' title) instead of vanishing, so the affordance reads honestly.
@@ -113,10 +112,13 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
113
112
  'aria-pressed': active ? 'true' : 'false',
114
113
  disabled: canOpen ? null : true,
115
114
  },
116
- code != null ? h('span', { class: 'code', 'aria-label': `code: ${code}` }, code) : null,
117
- FileIcon({ type }),
118
- h('span', { class: 'title' }, name),
119
- h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—')
115
+ ...[
116
+ code != null ? h('span', { class: 'code', 'aria-label': `code: ${code}` }, code) : null,
117
+ FileIcon({ type }),
118
+ h('span', { class: 'title' }, name),
119
+ h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—'),
120
+ permTag ? h('span', { class: 'ds-file-perm-tag' + (noAccess ? ' is-noaccess' : ''), 'aria-hidden': 'true' }, permTag) : null,
121
+ ].filter(Boolean)
120
122
  ),
121
123
  actionBtns.length ? h('span', { key: 'acts', class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
122
124
  ...actionBtns
@@ -125,6 +127,7 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
125
127
  return h('div', {
126
128
  key,
127
129
  class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : '')
130
+ + (readOnly ? ' is-restricted' : '')
128
131
  + (marked ? ' is-marked' : '') + (selectable ? ' is-selectable' : ''),
129
132
  'data-file-type': type,
130
133
  'aria-busy': busy ? 'true' : null,
@@ -134,7 +137,7 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
134
137
  // FileSkeleton — placeholder shimmer rows shown while a directory loads, so the
135
138
  // grid does not flash from a bare spinner to a full list (predictable perceived
136
139
  // perf, the file-manager feel). `rows` controls how many ghost rows render.
137
- export function FileSkeleton({ rows = 8 } = {}) {
140
+ export function FileSkeleton({ rows = 12 } = {}) {
138
141
  return h('div', { class: 'ds-file-grid ds-file-skeleton', 'aria-hidden': 'true' },
139
142
  ...Array.from({ length: Math.max(1, rows) }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-file-row ds-file-row-skeleton' },
140
143
  h('span', { class: 'ds-skel ds-skel-icon' }),
@@ -177,8 +180,8 @@ export function sortFiles(files = [], sort = 'name', dir = 'asc') {
177
180
  // CAP and a "show N more" row, mirroring the History tab's "load N older".
178
181
  const FILE_GRID_CAP = 200;
179
182
 
180
- export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet',
181
- columns = 'auto', sort, filter, loading = false,
183
+ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet', emptyAction,
184
+ sort, filter, loading = false,
182
185
  shown, onShowMore, actions, busy,
183
186
  // Canonical multi-select contract (shared with
184
187
  // SessionDashboard): selected/onToggleSelect.
@@ -191,8 +194,14 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
191
194
  // delete / upload round-trip) keeps the rows on screen and dims them -
192
195
  // flashing the whole directory to shimmer rows on every mutation reads as
193
196
  // data loss.
194
- if (loading && !files.length) return FileSkeleton({});
195
- if (!files.length) return EmptyState({ text: emptyText });
197
+ if (loading && !files.length) return FileSkeleton({ rows: 12 });
198
+ // A filtered miss is NOT an empty directory: when the in-grid filter narrows
199
+ // to zero matches, the host still passes an empty `files` array - but we must
200
+ // keep the controls toolbar (the filter input that caused the miss) mounted so
201
+ // the user can clear/edit it to recover. Only a genuinely-empty directory (no
202
+ // active filter) gets the bare cold EmptyState early-return.
203
+ const hasFilter = !!(filter && (filter.value || '').length > 0);
204
+ if (!files.length && !hasFilter) return EmptyState({ text: emptyText, glyph: Icon('folder-open', { size: 28 }), action: emptyAction });
196
205
  const refreshing = loading && files.length > 0;
197
206
  // Cap the rendered rows. `shown` (host-controlled) overrides the default cap
198
207
  // so "show more" can grow it; otherwise default to FILE_GRID_CAP.
@@ -200,16 +209,11 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
200
209
  const capped = files.length > limit;
201
210
  const visible = capped ? files.slice(0, limit) : files;
202
211
  const isThumb = density === 'thumb';
212
+ // NOTE: the old `columns`-driven data-columns card-mode was removed - it placed
213
+ // flex list-rows into a 2-4 col grid (squashed rows, mis-sized actions) and was
214
+ // a half-wired third layout never exposed by the density radiogroup (list/
215
+ // compact/thumb). Thumb density is the canonical multi-column grid.
203
216
  const gridAttrs = {};
204
- if (!isThumb && columns !== 'auto' && columns > 0) {
205
- const col = Math.max(1, Math.min(4, Math.floor(columns)));
206
- gridAttrs['data-columns'] = String(col);
207
- gridAttrs.style = {
208
- display: 'grid',
209
- gridTemplateColumns: `repeat(${col}, minmax(${FILE_GRID_MIN_COL}, 1fr))`,
210
- gap: 'var(--space-3)'
211
- };
212
- }
213
217
  // Multi-select bookkeeping. Entries are keyed by path (fallback name); a
214
218
  // locked/EACCES entry is never selectable — bulk mutations would fail on it.
215
219
  const entryKeyOf = (f) => f.path || f.name;
@@ -250,30 +254,49 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
250
254
  // it switches presentation of the same content, not panels.
251
255
  const densityCtl = onDensity
252
256
  ? h('div', { key: 'density', class: 'ds-density', role: 'radiogroup', 'aria-label': 'view density' },
253
- ...DENSITIES.map(([k, label]) => h('button', {
257
+ ...DENSITIES.map(([k, label], idx) => h('button', {
254
258
  key: 'd-' + k, type: 'button', role: 'radio',
255
259
  class: 'ds-density-btn' + (density === k ? ' active' : ''),
256
260
  'aria-checked': density === k ? 'true' : 'false',
261
+ // Icon-led, but the density name stays the accessible name
262
+ // (aria-label) + the native tooltip (title) so the control reads
263
+ // dense without losing its label.
264
+ 'aria-label': label, title: label,
265
+ // Single tab stop: the checked radio is tabbable, the rest are
266
+ // roved. Arrow/Home/End move + select (selection follows focus).
267
+ tabindex: density === k ? '0' : '-1',
268
+ onkeydown: (e) => rovingRadio(e, idx, DENSITIES, (tk) => { if (density !== tk) onDensity(tk); }),
257
269
  onclick: () => { if (density !== k) onDensity(k); },
258
- }, label)))
270
+ }, Icon(DENSITY_ICONS[k], { size: 15 }))))
259
271
  : null;
260
- const controlsKids = [selectAllCtl, head,
261
- (selectAllCtl || head) && densityCtl ? h('span', { key: 'spread', class: 'spread' }) : null,
272
+ // One toolbar baseline: filter + select-all + sort sit left, density is
273
+ // pushed right by the spread. The filter used to be a separate right-aligned
274
+ // strip ABOVE controls, giving two strips with conflicting alignment.
275
+ const filterCtl = filter ? h('input', {
276
+ key: 'filter',
277
+ class: 'ds-file-filter-input', type: 'search',
278
+ value: filter.value || '', placeholder: filter.placeholder || 'Filter files',
279
+ 'aria-label': filter.placeholder || 'Filter files in this directory',
280
+ oninput: (e) => filter.onInput && filter.onInput(e.target.value),
281
+ }) : null;
282
+ const leftKids = [filterCtl, selectAllCtl, head].filter(Boolean);
283
+ const controlsKids = [
284
+ ...leftKids,
285
+ (leftKids.length && densityCtl) ? h('span', { key: 'spread', class: 'spread' }) : null,
262
286
  densityCtl].filter(Boolean);
263
287
  const controls = controlsKids.length
264
288
  ? h('div', { class: 'ds-file-controls' }, ...controlsKids)
265
289
  : null;
266
- const filterBar = filter ? h('div', { class: 'ds-file-filter' },
267
- h('input', {
268
- class: 'ds-file-filter-input', type: 'search',
269
- value: filter.value || '', placeholder: filter.placeholder || 'Filter files',
270
- 'aria-label': filter.placeholder || 'Filter files in this directory',
271
- oninput: (e) => filter.onInput && filter.onInput(e.target.value),
272
- })) : null;
290
+ // A filtered miss (zero rows but an active filter) renders the EmptyState
291
+ // INSIDE the listing, below the controls toolbar, so the filter input stays
292
+ // mounted and editable - the user can clear/edit it to recover instead of
293
+ // being stranded with no toolbar (the early-return only fires for a genuinely
294
+ // empty directory). The host passes filter-aware copy via emptyText.
295
+ const filteredEmpty = !files.length && hasFilter;
273
296
  // role=group not listbox: the rows contain real <button> action controls, so
274
297
  // listbox/option semantics are invalid (an option can't host interactive
275
298
  // children). Keyboard nav still works via roving focus over the open buttons.
276
- const grid = h('div', {
299
+ const grid = filteredEmpty ? EmptyState({ text: emptyText, glyph: Icon('folder-open', { size: 28 }) }) : h('div', {
277
300
  class: 'ds-file-grid' + (isThumb ? ' ds-file-grid-thumb' : '') + (refreshing ? ' is-refreshing' : ''),
278
301
  role: 'group', 'aria-label': 'files', tabindex: '0',
279
302
  'aria-busy': refreshing ? 'true' : 'false',
@@ -311,12 +334,31 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
311
334
  onclick: () => onShowMore(Math.min(files.length, limit + FILE_GRID_CAP)) },
312
335
  'show ' + Math.min(FILE_GRID_CAP, files.length - limit) + ' more') : null)
313
336
  : null;
314
- return (controls || filterBar || more)
315
- ? h('div', { class: 'ds-file-listing' }, filterBar, controls, grid, more)
337
+ return (controls || more)
338
+ ? h('div', { class: 'ds-file-listing' }, controls, grid, more)
316
339
  : grid;
317
340
  }
318
341
 
319
342
  const DENSITIES = [['list', 'list'], ['compact', 'compact'], ['thumb', 'thumbnails']];
343
+ const DENSITY_ICONS = { list: 'rows', compact: 'rows-tight', thumb: 'grid' };
344
+
345
+ // Roving-radiogroup keyboard helper (the WAI-ARIA radio pattern): a radiogroup
346
+ // is a SINGLE tab stop where Arrow/Home/End move AND select among options, with
347
+ // selection following focus. `items` is the ordered [[key, ...], ...] list;
348
+ // `onSelect(targetKey)` is the same handler the onclick fires. Mouse path is
349
+ // unchanged - this only adds keyboard navigation.
350
+ function rovingRadio(e, idx, items, onSelect) {
351
+ let target = -1;
352
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') target = (idx - 1 + items.length) % items.length;
353
+ else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') target = (idx + 1) % items.length;
354
+ else if (e.key === 'Home') target = 0;
355
+ else if (e.key === 'End') target = items.length - 1;
356
+ else return;
357
+ e.preventDefault();
358
+ onSelect(items[target][0]);
359
+ const sib = e.currentTarget.parentNode && e.currentTarget.parentNode.children[target];
360
+ sib && sib.focus();
361
+ }
320
362
 
321
363
  // FileCell — the thumbnail-density tile. Image entries show a real (lazy)
322
364
  // thumbnail through the host's confined thumbUrl; everything else keeps its
@@ -474,10 +516,20 @@ export function UploadProgress({ items = [], onDismiss } = {}) {
474
516
  );
475
517
  }
476
518
 
477
- export function EmptyState({ text = 'nothing here', glyph = Icon('circle') } = {}) {
519
+ export function EmptyState({ text = 'nothing here', glyph = Icon('circle'), action } = {}) {
520
+ // action: { onClick, label } - an optional CTA (e.g. 'go up' / 'upload a
521
+ // file'), mirroring the SessionDashboard emptyAction contract so an empty
522
+ // directory is not a dead end. Children are built as an array + filtered so
523
+ // the keyed Btn never sits beside an unkeyed span (webjsx applyDiff 'key'
524
+ // crash on mixed keyed/unkeyed siblings).
478
525
  return h('div', { class: 'ds-file-empty', role: 'status' },
479
- h('span', { class: 'ds-file-empty-glyph', 'aria-hidden': 'true' }, glyph),
480
- h('span', { class: 'ds-file-empty-text' }, text)
526
+ ...[
527
+ h('span', { key: 'glyph', class: 'ds-file-empty-glyph', 'aria-hidden': 'true' }, glyph),
528
+ h('span', { key: 'text', class: 'ds-file-empty-text' }, text),
529
+ (action && action.onClick)
530
+ ? Btn({ key: 'ea', onClick: action.onClick, children: action.label || 'go up' })
531
+ : null,
532
+ ].filter(Boolean)
481
533
  );
482
534
  }
483
535
 
@@ -296,6 +296,13 @@ export function useKeyboardShortcut(map = {}, { scope = 'global', enabled = true
296
296
 
297
297
  export function ShortcutHint({ combo, kind = 'kbd' } = {}) { return h('kbd', { class: 'ds-kbd ds-kbd-' + kind }, formatShortcut(combo || '')); }
298
298
 
299
+ export function ShortcutList({ shortcuts = [] } = {}) {
300
+ return h('div', { class: 'ds-shortcuts-hint' },
301
+ ...shortcuts.map(s => h('div', { class: 'ds-shortcut-row' },
302
+ h('kbd', { class: 'ds-kbd' }, s.keys || s.combo || ''),
303
+ h('span', { class: 'ds-kbd-label' }, s.desc || s.description || s.label || ''))));
304
+ }
305
+
299
306
  export function useKeyboardShortcutHelp() { return { registry: Array.from(SHORTCUT_REGISTRY) }; }
300
307
  export function ShortcutHelpDialog({ open = false, onClose, registry } = {}) {
301
308
  if (!open) return null;
@@ -171,7 +171,9 @@ export function SessionCard({ session = {}, onStop, onOpen, onView, active = fal
171
171
  // sessions with no cost source (external tally rows) simply omit the segment.
172
172
  const tokText = s.tokens != null ? (typeof s.tokens === 'number' ? s.tokens.toLocaleString() : s.tokens) + ' tok' : null;
173
173
  const costText = s.cost != null ? (typeof s.cost === 'number' ? '$' + s.cost.toFixed(4) : String(s.cost)) : null;
174
- const statBits = [elapsedText, s.counter != null ? s.counter : null, tokText, costText].filter((x) => x != null && x !== '');
174
+ // Cost is rendered as its own emphasized segment (not buried in the mono run)
175
+ // so the command-center cost-at-a-glance signal is scannable.
176
+ const statBits = [elapsedText, s.counter != null ? s.counter : null, tokText].filter((x) => x != null && x !== '');
175
177
  const activityBits = [
176
178
  s.currentTool ? 'running: ' + s.currentTool : null,
177
179
  s.lastActivity ? 'last ' + s.lastActivity : null,
@@ -195,7 +197,13 @@ export function SessionCard({ session = {}, onStop, onOpen, onView, active = fal
195
197
  ].filter(Boolean));
196
198
  const meta = h('div', { class: 'ds-dash-meta' }, ...[
197
199
  s.cwd ? h('span', { class: 'ds-dash-cwd', title: s.cwd }, s.cwd) : null,
198
- statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
200
+ (statBits.length || costText) ? h('span', { class: 'ds-dash-stat' },
201
+ ...[
202
+ statBits.length ? statBits.join(' · ') : null,
203
+ (statBits.length && costText) ? ' · ' : null,
204
+ costText ? h('span', { class: 'ds-dash-stat-cost' }, costText) : null,
205
+ ].filter(Boolean)
206
+ ) : null,
199
207
  activityBits.length ? h('span', { class: 'ds-dash-activity' }, activityBits.join(' · ')) : null,
200
208
  ].filter(Boolean));
201
209
  const actions = h('div', { class: 'ds-dash-actions', role: 'group', 'aria-label': 'session actions' }, ...[
@@ -146,6 +146,10 @@ const ICON_PATHS = {
146
146
  // file-browser icons (replace folder/file emoji + arrow glyphs in fs apps)
147
147
  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"/>',
148
148
  '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"/>',
149
+ // density-picker icons (list / compact / thumbnail view modes)
150
+ rows: '<path d="M4 6h16M4 12h16M4 18h16"/>',
151
+ 'rows-tight': '<path d="M4 5h16M4 9h16M4 13h16M4 17h16"/>',
152
+ 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"/>',
149
153
  '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"/>',
150
154
  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"/>',
151
155
  upload: '<path d="M12 16V4M7 9l5-5 5 5"/><path d="M5 20h14"/>',
@@ -318,8 +322,18 @@ function toggleWs(which) {
318
322
 
319
323
  // Column resize: read the current rendered track width and write a clamped inline
320
324
  // --ws-<col>-w on .ws-shell (inline overrides the fluid clamp base), persisted.
321
- const WS_RESIZE_CLAMP = { rail: [60, 360], sessions: [200, 520], pane: [240, 560] };
322
- function wsResize(col, dx) {
325
+ // Floors match the CSS fluid clamp() floors in app-shell.css (--ws-rail-w
326
+ // clamp(200,16vw,260); sessions clamp(248,22vw,360); pane clamp(288,24vw,420))
327
+ // so a drag/arrow can never shrink a column below its designed minimum (the
328
+ // collapsed rail is a SEPARATE class, not a resize target). The ceilings are
329
+ // INTENTIONALLY raised above the fluid clamp() mid-term ceilings: on wide
330
+ // viewports the 16/22/24vw mid term already pins each column to its clamp
331
+ // ceiling, so a ceiling-equals-clamp bound made the outward drag inert there.
332
+ // The higher resize ceilings let a deliberate drag/arrow grow a column past its
333
+ // auto-fluid width (the inline --ws-<col>-w override pins the chosen width past
334
+ // the clamp base).
335
+ const WS_RESIZE_CLAMP = { rail: [200, 320], sessions: [248, 520], pane: [288, 640] };
336
+ function wsResize(col, dx, persist = true) {
323
337
  const shell = document.querySelector('.ws-shell');
324
338
  if (!shell) return;
325
339
  const track = shell.querySelector('.ws-' + col);
@@ -327,7 +341,11 @@ function wsResize(col, dx) {
327
341
  const [lo, hi] = WS_RESIZE_CLAMP[col] || [120, 600];
328
342
  const next = Math.max(lo, Math.min(hi, Math.round(cur + dx)));
329
343
  shell.style.setProperty('--ws-' + col + '-w', next + 'px');
330
- try { localStorage.setItem('ds.ws.w.' + col, String(next)); } catch (_) {}
344
+ const handle = shell.querySelector('.ws-resizer-' + col);
345
+ if (handle) handle.setAttribute('aria-valuenow', String(next));
346
+ // Commit to storage only on a settled move (pointerup / keyboard), not on
347
+ // every pointermove frame (that fired dozens of synchronous writes per drag).
348
+ if (persist) { try { localStorage.setItem('ds.ws.w.' + col, String(next)); } catch (_) {} }
331
349
  }
332
350
  function seedWsWidths(el) {
333
351
  if (!el) return;
@@ -340,22 +358,35 @@ function seedWsWidths(el) {
340
358
  }
341
359
  function WsResizer(col) {
342
360
  const onKey = (e) => {
343
- if (e.key === 'ArrowLeft') { e.preventDefault(); wsResize(col, -16); }
344
- else if (e.key === 'ArrowRight') { e.preventDefault(); wsResize(col, 16); }
361
+ if (e.key === 'ArrowLeft') { e.preventDefault(); wsResize(col, -16, true); }
362
+ else if (e.key === 'ArrowRight') { e.preventDefault(); wsResize(col, 16, true); }
345
363
  };
346
364
  const onDown = (e) => {
347
365
  e.preventDefault();
348
366
  let lastX = e.clientX;
349
- const move = (ev) => { const dx = ev.clientX - lastX; lastX = ev.clientX; wsResize(col, dx); };
350
- const up = () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); document.body.style.cursor = ''; };
367
+ const move = (ev) => { const dx = ev.clientX - lastX; lastX = ev.clientX; wsResize(col, dx, false); };
368
+ const up = () => {
369
+ document.removeEventListener('pointermove', move);
370
+ document.removeEventListener('pointerup', up);
371
+ document.body.style.cursor = '';
372
+ wsResize(col, 0, true); // commit the settled width once
373
+ };
351
374
  document.addEventListener('pointermove', move);
352
375
  document.addEventListener('pointerup', up);
353
376
  document.body.style.cursor = 'col-resize';
354
377
  };
378
+ const [lo, hi] = WS_RESIZE_CLAMP[col] || [120, 600];
379
+ // Seed aria-valuenow from the rendered track width so AT announces real widths.
380
+ const seedNow = (el) => {
381
+ if (!el) return;
382
+ const track = el.closest('.ws-shell') && el.closest('.ws-shell').querySelector('.ws-' + col);
383
+ if (track) el.setAttribute('aria-valuenow', String(Math.round(track.getBoundingClientRect().width)));
384
+ };
355
385
  return h('div', {
356
386
  class: 'ws-resizer ws-resizer-' + col, role: 'separator', tabindex: '0',
357
387
  'aria-orientation': 'vertical', 'aria-label': 'resize ' + col + ' column (arrow keys)',
358
- onpointerdown: onDown, onkeydown: onKey,
388
+ 'aria-valuemin': String(lo), 'aria-valuemax': String(hi),
389
+ onpointerdown: onDown, onkeydown: onKey, ref: seedNow,
359
390
  });
360
391
  }
361
392
 
@@ -483,6 +514,15 @@ export function WorkspaceShell({ rail, sessions, main, pane, crumb, status, narr
483
514
  'aria-expanded': 'true', onclick: () => toggleWs('sessions'),
484
515
  }, Icon('chevron-left')) : null,
485
516
  h('div', { class: 'ws-crumb-main' }, crumb),
517
+ // Desktop-only context-pane collapse, on the same crumb-level
518
+ // chrome idiom as the sessions toggle. Hidden on mobile via CSS.
519
+ hasPane ? h('button', {
520
+ class: 'ws-desktop-toggle ws-pane-toggle', type: 'button',
521
+ 'aria-label': paneIsCollapsed ? 'show context pane' : 'hide context pane',
522
+ title: paneIsCollapsed ? 'show context pane' : 'hide context pane',
523
+ 'aria-expanded': paneIsCollapsed ? 'false' : 'true',
524
+ onclick: () => toggleWs('pane'),
525
+ }, Icon(paneIsCollapsed ? 'chevron-left' : 'chevron-right')) : null,
486
526
  hasPane ? h('button', {
487
527
  class: 'ws-drawer-toggle ws-pane-drawer-toggle', type: 'button',
488
528
  'aria-label': 'toggle context pane', 'aria-expanded': 'false',
@@ -492,16 +532,10 @@ export function WorkspaceShell({ rail, sessions, main, pane, crumb, status, narr
492
532
  h('main', { class: 'ws-main' + (narrow ? ' narrow' : '') + (mainFlush ? ' ws-main--flush' : ''), id: 'ws-main', tabindex: '-1' },
493
533
  ...(Array.isArray(main) ? main : [main])),
494
534
  status || null),
495
- // Optional right context pane with its own collapse toggle.
535
+ // Optional right context pane. Its desktop collapse toggle now lives in
536
+ // the crumb cluster, alongside the sessions toggle.
496
537
  hasPane
497
538
  ? h('aside', { class: 'ws-pane', role: 'complementary', 'aria-label': paneLabel },
498
- h('button', {
499
- class: 'ws-pane-toggle', type: 'button',
500
- 'aria-label': paneIsCollapsed ? 'show context pane' : 'hide context pane',
501
- title: paneIsCollapsed ? 'show context pane' : 'hide context pane',
502
- 'aria-expanded': paneIsCollapsed ? 'false' : 'true',
503
- onclick: () => toggleWs('pane'),
504
- }, Icon(paneIsCollapsed ? 'chevron-left' : 'chevron-right')),
505
539
  pane)
506
540
  : null,
507
541
  // Keyboard/pointer column resize handles (desktop only).
@@ -550,8 +584,8 @@ export function WorkspaceRail({ brand = '247420', action, items = [], footer } =
550
584
  );
551
585
  }
552
586
 
553
- export function Heading({ level = 1, children, style = '', 'aria-level': ariaLevel }) {
554
- return h('h' + level, { style, 'aria-level': ariaLevel != null ? String(ariaLevel) : null }, children);
587
+ export function Heading({ level = 1, children, style = '', class: className = '', 'aria-level': ariaLevel }) {
588
+ return h('h' + level, { class: className || null, style, 'aria-level': ariaLevel != null ? String(ariaLevel) : null }, children);
555
589
  }
556
590
 
557
591
  export function Lede({ children }) {
package/src/components.js CHANGED
@@ -66,7 +66,7 @@ export {
66
66
 
67
67
  export {
68
68
  useDraggable, useDropTarget, useNumberScrub, usePointerDrag, Reorderable,
69
- useKeyboardShortcut, formatShortcut, ShortcutHint,
69
+ useKeyboardShortcut, formatShortcut, ShortcutHint, ShortcutList,
70
70
  useKeyboardShortcutHelp, ShortcutHelpDialog
71
71
  } from './components/interaction-primitives.js';
72
72