anentrypoint-design 0.0.236 → 0.0.238

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.236",
3
+ "version": "0.0.238",
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",
@@ -480,7 +480,10 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
480
480
  autoGrowScheduled = true;
481
481
  requestAnimationFrame(() => {
482
482
  ta.style.height = 'auto';
483
- ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';
483
+ // Respect the CSS max-height cap (120px in short-landscape via
484
+ // app-shell.css) instead of a hardcoded 200px.
485
+ const cap = parseFloat(getComputedStyle(ta).maxHeight) || 200;
486
+ ta.style.height = Math.min(ta.scrollHeight, cap) + 'px';
484
487
  autoGrowScheduled = false;
485
488
  });
486
489
  }
@@ -493,7 +496,8 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
493
496
  const next = value || '';
494
497
  if (el.value !== next) el.value = next;
495
498
  el.style.height = 'auto';
496
- el.style.height = Math.min(el.scrollHeight, 200) + 'px';
499
+ const cap = parseFloat(getComputedStyle(el).maxHeight) || 200;
500
+ el.style.height = Math.min(el.scrollHeight, cap) + 'px';
497
501
  };
498
502
  // Optional context line shown above the textarea: agent / model / cwd at the
499
503
  // point of typing (the way Claude-Desktop surfaces the active target inline).
@@ -541,10 +545,17 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
541
545
  }, joined);
542
546
  }
543
547
  const hasDraft = !!(value && value.trim());
548
+ // Clamp the picker anchor to the visual viewport: with the on-screen
549
+ // keyboard open the composer sits near the visual-viewport bottom, and on
550
+ // narrow screens the picker width can overflow the right edge.
551
+ const anchorRect = (anchorEl && anchorEl.getBoundingClientRect) ? anchorEl.getBoundingClientRect() : null;
552
+ const vvWidth = (typeof window !== 'undefined')
553
+ ? ((window.visualViewport && window.visualViewport.width) || window.innerWidth)
554
+ : 0;
544
555
  const triggerPicker = triggerMatch ? EmojiPicker({
545
556
  open: true,
546
- anchorX: (anchorEl && anchorEl.getBoundingClientRect) ? anchorEl.getBoundingClientRect().left : 0,
547
- anchorY: (anchorEl && anchorEl.getBoundingClientRect) ? anchorEl.getBoundingClientRect().top : 0,
557
+ anchorX: anchorRect ? Math.max(0, Math.min(anchorRect.left, vvWidth - 280)) : 0,
558
+ anchorY: anchorRect ? Math.max(8, anchorRect.top - 8) : 0,
548
559
  query: triggerMatch[2] || '',
549
560
  onSelect: (ch) => insertEmoji(ch),
550
561
  onClose: () => { if (taEl) { const v = taEl.value.replace(EMOJI_TRIGGER_RE, (full, tail) => full.slice(0, full.length - tail.length)); if (onInput) onInput(v); taEl.value = v; taEl.focus(); } },
@@ -146,6 +146,9 @@ export function Hero({ eyebrow, title, body, accent, badge, badgeCount, actions
146
146
  }
147
147
 
148
148
  export function Marquee({ items = [], sep = '/' }) {
149
+ // No items -> no ticker: an empty marquee still paints its border-block
150
+ // rules as an unexplained full-width stripe.
151
+ if (!items.length) return null;
149
152
  // Two identical runs make the -50% translate loop seamless. Each text and
150
153
  // separator is a keyed span so webjsx applyDiff never sees a primitive
151
154
  // sibling beside a keyed VElement.
@@ -258,7 +261,9 @@ export function Table({ headers = [], rows = [], onRowClick, emptyText = 'nothin
258
261
  // Native <table>/<tr>/<th>/<td> already carry the correct implicit ARIA
259
262
  // roles — explicit role="table"/row/columnheader/cell is redundant and only
260
263
  // risks overriding native semantics, so it is omitted.
261
- return h('table', {},
264
+ // Scroll containment lives on the component itself: a wide table used
265
+ // outside a Panel must never force page-level horizontal scroll.
266
+ return h('div', { class: 'ds-table-wrap' }, h('table', {},
262
267
  h('thead', {}, h('tr', {}, ...headers.map((hd, i) => h('th', { key: i, scope: 'col' }, hd)))),
263
268
  h('tbody', {}, ...rows.map((row, i) => h('tr', {
264
269
  key: i,
@@ -267,7 +272,7 @@ export function Table({ headers = [], rows = [], onRowClick, emptyText = 'nothin
267
272
  // Space scrolls by default — preventDefault on Space (and Enter) so
268
273
  // keyboard activation matches click without page jump.
269
274
  ...(onRowClick ? { tabindex: '0', role: 'button', 'aria-label': 'open ' + labelFor(row, i), onkeydown: (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onRowClick(i); } } } : {})
270
- }, ...row.map((c, j) => h('td', { key: j }, c == null ? '' : (typeof c === 'object' ? c : String(c))))))));
275
+ }, ...row.map((c, j) => h('td', { key: j }, c == null ? '' : (typeof c === 'object' ? c : String(c)))))))));
271
276
  }
272
277
 
273
278
  export function HomeView({ state = {}, onNav, onToggleWork, works = [], posts = [], manifesto = [], currentlyShipping } = {}) {
@@ -108,7 +108,13 @@ export function SessionRow({ sessId, phaseWalkProps, events, verbs, prd, muts, r
108
108
  muts != null ? muts + ' mut' : null,
109
109
  resid != null ? resid + ' resid' : null,
110
110
  ].filter(Boolean).join(' · ');
111
- return h('div', { class: 'ds-session-row', onclick: onClick || null, role: onClick ? 'button' : null, tabindex: onClick ? '0' : null },
111
+ // Keyboard activation parity: role=button + tabindex without onkeydown is
112
+ // announced as a button but inert to Enter/Space (mirrors Table()).
113
+ return h('div', {
114
+ class: 'ds-session-row', onclick: onClick || null,
115
+ role: onClick ? 'button' : null, tabindex: onClick ? '0' : null,
116
+ onkeydown: onClick ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onClick(e); } } : null,
117
+ },
112
118
  h('span', { class: 'ds-session-row-id' }, sessId),
113
119
  h('span', { class: 'ds-session-row-counts' }, counts),
114
120
  (deviations != null && deviations !== 0) ? h('span', { class: 'ds-session-row-devcnt' }, String(deviations) + ' dev') : null,
@@ -333,7 +333,12 @@ export function ContextMenu({ items = [], anchor = { x: 0, y: 0 }, onClose } = {
333
333
  tabindex: '-1',
334
334
  onkeydown: onKey,
335
335
  ref: (el) => {
336
- if (!el) return;
336
+ if (!el) {
337
+ // Unmount: unhook the resize re-clamp bound on mount.
338
+ if (rootEl && rootEl._dsCtxClampOff) { rootEl._dsCtxClampOff(); }
339
+ rootEl = null;
340
+ return;
341
+ }
337
342
  rootEl = el;
338
343
  // Position at the anchor immediately, then clamp once layout has
339
344
  // settled — measuring synchronously in ref reads a zero-size box
@@ -341,16 +346,28 @@ export function ContextMenu({ items = [], anchor = { x: 0, y: 0 }, onClose } = {
341
346
  const ax = anchor.x || 0, ay = anchor.y || 0;
342
347
  el.style.left = ax + 'px';
343
348
  el.style.top = ay + 'px';
349
+ const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
344
350
  const clamp = () => {
345
351
  const vw = window.innerWidth, vh = window.innerHeight;
346
352
  const r = el.getBoundingClientRect();
347
353
  let x = ax, y = ay;
354
+ // Touch: keep the menu clear of the lifting finger — nudge
355
+ // below the touch point, or open above when it fits and the
356
+ // anchor sits in the lower half (lift-off would otherwise
357
+ // activate the first item).
358
+ if (coarse) {
359
+ y = ay + 10;
360
+ if (ay > vh / 2 && ay - r.height >= 4) y = ay - r.height;
361
+ }
348
362
  if (x + r.width > vw) x = Math.max(4, vw - r.width - 4);
349
363
  if (y + r.height > vh) y = Math.max(4, vh - r.height - 4);
350
364
  el.style.left = x + 'px';
351
365
  el.style.top = y + 'px';
352
366
  };
353
367
  requestAnimationFrame(clamp);
368
+ // Re-clamp on resize/orientation change for the menu's lifetime.
369
+ window.addEventListener('resize', clamp);
370
+ el._dsCtxClampOff = () => { window.removeEventListener('resize', clamp); el._dsCtxClampOff = null; };
354
371
  queueMicrotask(() => { el.querySelector('button[data-ix]')?.focus(); });
355
372
  }
356
373
  },
@@ -373,12 +390,18 @@ export function ContextMenu({ items = [], anchor = { x: 0, y: 0 }, onClose } = {
373
390
  // Helper: wires right-click + long-press to a target ref. Caller manages state.
374
391
  export function useContextMenu(targetEl, items, openCb) {
375
392
  if (!targetEl) return () => {};
376
- let touchTimer = null;
377
- const open = (x, y) => { if (openCb) openCb({ x, y, items }); };
393
+ let touchTimer = null, lastOpen = 0;
394
+ // Android fires the native contextmenu event on long-press AND our 500ms
395
+ // touch timer — dedupe so the menu opens once, not twice (open/flicker).
396
+ const open = (x, y) => {
397
+ if (Date.now() - lastOpen < 700) return;
398
+ lastOpen = Date.now();
399
+ if (openCb) openCb({ x, y, items });
400
+ };
378
401
  const onCtx = (e) => { e.preventDefault(); open(e.clientX, e.clientY); };
379
402
  const onTouchStart = (e) => {
380
403
  const t = e.touches && e.touches[0]; if (!t) return;
381
- touchTimer = setTimeout(() => { open(t.clientX, t.clientY); }, 500);
404
+ touchTimer = setTimeout(() => { touchTimer = null; open(t.clientX, t.clientY); }, 500);
382
405
  };
383
406
  const cancel = () => { if (touchTimer) { clearTimeout(touchTimer); touchTimer = null; } };
384
407
  targetEl.addEventListener('contextmenu', onCtx);
@@ -6,6 +6,10 @@ import { fileGlyph, fmtFileSize } from './files.js';
6
6
  import { highlightAllUnder } from '../highlight.js';
7
7
  const h = webjsx.createElement;
8
8
 
9
+ // Full focusable set for the modal Tab trap — omitting textarea/select/a[href]
10
+ // lets Tab escape behind the fixed backdrop (fully obscured at mobile sizes).
11
+ const FOCUSABLE_SEL = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
12
+
9
13
  // Stable per-call-site id source for aria-labelledby: we want one fixed id per
10
14
  // logical dialog instance (rename, confirm, prompt, preview), NOT a monotonic
11
15
  // counter that advances on every render and leaves the old aria-labelledby
@@ -40,9 +44,7 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
40
44
  // disabled mid-flight (busy state) are excluded from the cycle and
41
45
  // do not break tab navigation.
42
46
  if (e.key === 'Tab') {
43
- const focusables = modal.querySelectorAll(
44
- 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
45
- );
47
+ const focusables = modal.querySelectorAll(FOCUSABLE_SEL);
46
48
  if (focusables.length === 0) {
47
49
  e.preventDefault();
48
50
  return;
@@ -86,7 +88,7 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
86
88
  // Auto-focus on open - only when focus is not already inside the modal
87
89
  // (re-renders must not yank the caret around).
88
90
  if (!el.contains(document.activeElement)) {
89
- const preferred = modal.querySelector('[autofocus]') || firstFocusable;
91
+ const preferred = modal.querySelector('[autofocus]') || modal.querySelector(FOCUSABLE_SEL);
90
92
  if (preferred) preferred.focus();
91
93
  }
92
94
  };
@@ -323,16 +325,41 @@ function previewKeyNav(onPrev, onNext) {
323
325
  };
324
326
  }
325
327
 
328
+ // Touch stepping: horizontal swipe on the preview body steps prev/next. Skips
329
+ // when the gesture starts inside a horizontally-scrollable child (code <pre>)
330
+ // so panning wide code never flips files.
331
+ function previewSwipe(onPrev, onNext) {
332
+ if (!onPrev && !onNext) return {};
333
+ let sx = null, sy = null;
334
+ return {
335
+ onpointerdown: (e) => {
336
+ const scroller = e.target.closest && e.target.closest('pre');
337
+ if (scroller && scroller.scrollWidth > scroller.clientWidth) { sx = null; return; }
338
+ sx = e.clientX; sy = e.clientY;
339
+ },
340
+ onpointerup: (e) => {
341
+ if (sx == null) return;
342
+ const dx = e.clientX - sx, dy = e.clientY - sy;
343
+ sx = null;
344
+ if (Math.abs(dx) < 48 || Math.abs(dy) > Math.abs(dx)) return;
345
+ if (dx < 0 && onNext) onNext();
346
+ else if (dx > 0 && onPrev) onPrev();
347
+ },
348
+ onpointercancel: () => { sx = null; },
349
+ };
350
+ }
351
+
326
352
  export function FileViewer({ file, body, onClose, onAction, onPrev, onNext } = {}) {
327
353
  if (!file) return null;
354
+ const keyNav = previewKeyNav(onPrev, onNext);
328
355
  return Modal({
329
356
  onClose,
330
357
  kind: 'preview',
331
358
  headClass: 'ds-preview-head',
332
- headAttrs: { 'data-file-type': file.type || 'other', onkeydown: previewKeyNav(onPrev, onNext) },
359
+ headAttrs: { 'data-file-type': file.type || 'other', onkeydown: keyNav },
333
360
  head: previewHead({ file, onClose, onAction, onPrev, onNext }),
334
361
  bodyClass: 'ds-preview-body',
335
- bodyAttrs: { 'data-file-type': file.type || 'other' },
362
+ bodyAttrs: { 'data-file-type': file.type || 'other', onkeydown: keyNav, ...previewSwipe(onPrev, onNext) },
336
363
  body: Array.isArray(body) ? body : [body],
337
364
  });
338
365
  }
@@ -50,9 +50,13 @@ export function useFloating(anchorEl, contentEl, { placement = 'bottom-start', o
50
50
  const cb = () => compute();
51
51
  window.addEventListener('resize', cb);
52
52
  window.addEventListener('scroll', cb, true);
53
+ // Reposition when the content box itself resizes (async-loaded content
54
+ // grows the popover after initial positioning, pushing it off-viewport).
55
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(cb) : null;
56
+ if (ro) ro.observe(contentEl);
53
57
  return {
54
58
  update: compute,
55
- dispose() { window.removeEventListener('resize', cb); window.removeEventListener('scroll', cb, true); },
59
+ dispose() { window.removeEventListener('resize', cb); window.removeEventListener('scroll', cb, true); if (ro) ro.disconnect(); },
56
60
  get finalPlacement() { return finalPlacement; }
57
61
  };
58
62
  }
@@ -283,6 +283,40 @@ function toggleSide(open, fromEl) {
283
283
  body.classList.toggle('side-open', next);
284
284
  const btn = shell.querySelector('.app-side-toggle');
285
285
  if (btn) btn.setAttribute('aria-expanded', next ? 'true' : 'false');
286
+ // Keyboard parity with toggleWsDrawer: Esc dismisses the drawer and Tab is
287
+ // trapped inside it while it overlays the content behind the scrim.
288
+ if (body._dsSideKey) { document.removeEventListener('keydown', body._dsSideKey); body._dsSideKey = null; }
289
+ if (next) {
290
+ const drawer = shell.querySelector('.app-side-shell');
291
+ const focusable = drawer && drawer.querySelector('button, a, input, [tabindex]');
292
+ if (focusable) try { focusable.focus(); } catch (_) {}
293
+ const onKey = (e) => {
294
+ if (e.key === 'Escape') { toggleSide(false, btn || body); if (btn) try { btn.focus(); } catch (_) {} return; }
295
+ if (drawer) trapTab(drawer, e);
296
+ };
297
+ body._dsSideKey = onKey;
298
+ document.addEventListener('keydown', onKey);
299
+ }
300
+ }
301
+
302
+ // Ref on the .app root: re-sync the toggle's aria-expanded from the live
303
+ // .side-open class (applyDiff re-renders reset the attribute to 'false'), and
304
+ // arm a ResizeObserver that closes a stuck-open drawer when the shell grows
305
+ // past the 900px container breakpoint (the drawer CSS stops applying there,
306
+ // but the class would otherwise persist and reappear on the next shrink).
307
+ function syncAppSide(el) {
308
+ if (!el) return;
309
+ const body = el.querySelector('.app-body');
310
+ const btn = el.querySelector('.app-side-toggle');
311
+ if (btn && body) btn.setAttribute('aria-expanded', body.classList.contains('side-open') ? 'true' : 'false');
312
+ if (!el._dsSideRO && typeof ResizeObserver !== 'undefined') {
313
+ el._dsSideRO = new ResizeObserver((entries) => {
314
+ const w = entries[0] && entries[0].contentRect.width;
315
+ const b = el.querySelector('.app-body');
316
+ if (w > 900 && b && b.classList.contains('side-open')) toggleSide(false, el);
317
+ });
318
+ el._dsSideRO.observe(el);
319
+ }
286
320
  }
287
321
 
288
322
  export function AppShell({ topbar, crumb, side, main, status, narrow } = {}) {
@@ -296,17 +330,17 @@ export function AppShell({ topbar, crumb, side, main, status, narrow } = {}) {
296
330
  const chrome = (topbar && crumb)
297
331
  ? h('div', { class: 'app-chrome' }, topbar, crumb)
298
332
  : (topbar || crumb || null);
299
- return h('div', { class: 'app' },
333
+ return h('div', { class: 'app', ref: syncAppSide },
300
334
  h('a', { href: '#app-main', class: 'skip-link' }, 'skip to main content'),
301
335
  hasSide ? h('button', {
302
336
  class: 'app-side-toggle', type: 'button',
303
- 'aria-label': 'toggle navigation', 'aria-expanded': 'false', 'aria-controls': 'app-main',
337
+ 'aria-label': 'toggle navigation', 'aria-expanded': 'false', 'aria-controls': 'app-side-shell',
304
338
  onclick: (e) => toggleSide(null, e.currentTarget),
305
339
  }, Icon('menu')) : null,
306
340
  chrome,
307
341
  h('div', { class: 'app-body' + (hasSide ? '' : ' no-side') },
308
342
  h('div', { class: 'app-side-scrim', 'aria-hidden': 'true', onclick: (e) => toggleSide(false, e.currentTarget) }),
309
- h('div', { class: 'app-side-shell', onclick: (e) => { if (e.target.closest('a')) toggleSide(false, e.currentTarget); } }, sideNode),
343
+ h('div', { class: 'app-side-shell', id: 'app-side-shell', onclick: (e) => { if (e.target.closest('a')) toggleSide(false, e.currentTarget); } }, sideNode),
310
344
  // tabindex=-1 so the skip-link (href="#app-main") actually moves
311
345
  // keyboard focus into the main region, not just scroll to it.
312
346
  h('main', { class: 'app-main' + (narrow ? ' narrow' : ''), id: 'app-main', tabindex: '-1' }, ...(Array.isArray(main) ? main : [main]))
@@ -318,14 +352,19 @@ export function AppShell({ topbar, crumb, side, main, status, narrow } = {}) {
318
352
  // Toggle a named WorkspaceShell column (left rail or right pane). Pure-DOM like
319
353
  // toggleSide: WorkspaceShell is stateless chrome, the collapsed class lives on
320
354
  // .ws-shell and is read by both CSS and the toggle buttons' aria-expanded.
321
- function toggleWs(which) {
322
- const shell = document.querySelector('.ws-shell');
355
+ function toggleWs(which, fromEl) {
356
+ // Scope to the shell owning the clicked control, like toggleSide — the
357
+ // first-on-page querySelector toggles the WRONG shell with two instances.
358
+ const shell = (fromEl && fromEl.closest && fromEl.closest('.ws-shell')) || document.querySelector('.ws-shell');
323
359
  if (!shell) return;
324
360
  const cls = which === 'pane' ? 'ws-pane-collapsed'
325
361
  : which === 'sessions' ? 'ws-sessions-collapsed'
326
362
  : 'ws-rail-collapsed';
327
363
  const nowCollapsed = shell.classList.toggle(cls);
328
- document.querySelectorAll('.ws-' + which + '-toggle').forEach((btn) => {
364
+ // Inline --ws-*-w beats the collapsed-class rule in the cascade, so a
365
+ // persisted width would render a "collapsed" column 200-640px wide.
366
+ if (nowCollapsed) shell.style.removeProperty('--ws-' + which + '-w');
367
+ shell.querySelectorAll('.ws-' + which + '-toggle').forEach((btn) => {
329
368
  btn.setAttribute('aria-expanded', nowCollapsed ? 'false' : 'true');
330
369
  const nextLabel = nowCollapsed ? 'expand ' + which : 'collapse ' + which;
331
370
  btn.setAttribute('aria-label', nextLabel);
@@ -334,6 +373,9 @@ function toggleWs(which) {
334
373
  try {
335
374
  localStorage.setItem('ds.ws.' + which, nowCollapsed ? 'collapsed' : 'open');
336
375
  } catch (_) {}
376
+ // Expanding restores the persisted width (seed skips collapsed columns, so
377
+ // it must run after the open flag is written).
378
+ if (!nowCollapsed) seedWsWidths(shell);
337
379
  }
338
380
 
339
381
  // Column resize: read the current rendered track width and write a clamped inline
@@ -349,8 +391,8 @@ function toggleWs(which) {
349
391
  // auto-fluid width (the inline --ws-<col>-w override pins the chosen width past
350
392
  // the clamp base).
351
393
  const WS_RESIZE_CLAMP = { rail: [200, 320], sessions: [248, 520], pane: [288, 640] };
352
- function wsResize(col, dx, persist = true) {
353
- const shell = document.querySelector('.ws-shell');
394
+ function wsResize(col, dx, persist = true, fromEl) {
395
+ const shell = (fromEl && fromEl.closest && fromEl.closest('.ws-shell')) || document.querySelector('.ws-shell');
354
396
  if (!shell) return;
355
397
  const track = shell.querySelector('.ws-' + col);
356
398
  const cur = track ? track.getBoundingClientRect().width : 0;
@@ -363,29 +405,37 @@ function wsResize(col, dx, persist = true) {
363
405
  // every pointermove frame (that fired dozens of synchronous writes per drag).
364
406
  if (persist) { try { localStorage.setItem('ds.ws.w.' + col, String(next)); } catch (_) {} }
365
407
  }
408
+ // Per-column viewport caps for persisted widths: a width dragged on a wide
409
+ // monitor must not crush the content column when the page reloads on a
410
+ // narrower screen (rail 320 + sessions 520 would leave ~180px of content).
411
+ const WS_VW_CAP = { rail: '20vw', sessions: '30vw', pane: '32vw' };
366
412
  function seedWsWidths(el) {
367
413
  if (!el) return;
368
414
  ['rail', 'sessions', 'pane'].forEach((col) => {
369
415
  try {
416
+ // A persisted-collapsed column must stay collapsed: the inline var
417
+ // would beat the .ws-*-collapsed class rule in the cascade.
418
+ if (wsCollapsed(col, false)) return;
370
419
  const v = localStorage.getItem('ds.ws.w.' + col);
371
- if (v && /^\d+$/.test(v)) el.style.setProperty('--ws-' + col + '-w', v + 'px');
420
+ if (v && /^\d+$/.test(v)) el.style.setProperty('--ws-' + col + '-w', `min(${v}px, ${WS_VW_CAP[col]})`);
372
421
  } catch (_) {}
373
422
  });
374
423
  }
375
424
  function WsResizer(col) {
376
425
  const onKey = (e) => {
377
- if (e.key === 'ArrowLeft') { e.preventDefault(); wsResize(col, -16, true); }
378
- else if (e.key === 'ArrowRight') { e.preventDefault(); wsResize(col, 16, true); }
426
+ if (e.key === 'ArrowLeft') { e.preventDefault(); wsResize(col, -16, true, e.currentTarget); }
427
+ else if (e.key === 'ArrowRight') { e.preventDefault(); wsResize(col, 16, true, e.currentTarget); }
379
428
  };
380
429
  const onDown = (e) => {
381
430
  e.preventDefault();
431
+ const handleEl = e.currentTarget;
382
432
  let lastX = e.clientX;
383
- const move = (ev) => { const dx = ev.clientX - lastX; lastX = ev.clientX; wsResize(col, dx, false); };
433
+ const move = (ev) => { const dx = ev.clientX - lastX; lastX = ev.clientX; wsResize(col, dx, false, handleEl); };
384
434
  const up = () => {
385
435
  document.removeEventListener('pointermove', move);
386
436
  document.removeEventListener('pointerup', up);
387
437
  document.body.style.cursor = '';
388
- wsResize(col, 0, true); // commit the settled width once
438
+ wsResize(col, 0, true, handleEl); // commit the settled width once
389
439
  };
390
440
  document.addEventListener('pointermove', move);
391
441
  document.addEventListener('pointerup', up);
@@ -411,39 +461,51 @@ function WsResizer(col) {
411
461
  // revealed by .ws-sessions-open / .ws-pane-open. Opening one closes the other
412
462
  // (only one drawer at a time over the content). Esc + scrim dismiss call this
413
463
  // with open=false. Pure-DOM, matching the AppShell toggleSide pattern.
414
- function toggleWsDrawer(which, open) {
415
- const shell = document.querySelector('.ws-shell');
464
+ function toggleWsDrawer(which, open, fromEl) {
465
+ const shell = (fromEl && fromEl.closest && fromEl.closest('.ws-shell')) || document.querySelector('.ws-shell');
416
466
  if (!shell) return;
417
467
  const cls = which === 'pane' ? 'ws-pane-open' : 'ws-sessions-open';
418
468
  const other = which === 'pane' ? 'ws-sessions-open' : 'ws-pane-open';
419
469
  const next = open != null ? open : !shell.classList.contains(cls);
420
470
  shell.classList.toggle(cls, next);
421
471
  if (next) shell.classList.remove(other);
422
- const btn = document.querySelector('.ws-' + which + '-drawer-toggle');
472
+ const btn = shell.querySelector('.ws-' + which + '-drawer-toggle');
423
473
  if (btn) btn.setAttribute('aria-expanded', next ? 'true' : 'false');
474
+ if (!next) { removeWsDrawerHandlers(shell); return; }
424
475
  // When opening, move focus into the drawer, arm an Esc-to-close, and trap
425
476
  // Tab/Shift+Tab inside the drawer (a real focus trap, matching the kit's
426
477
  // own dialogs - Tab from inside an open drawer previously walked focus out
427
478
  // into the scrim/background content behind it).
428
- if (next) {
429
- const drawer = shell.querySelector(which === 'pane' ? '.ws-pane' : '.ws-sessions');
430
- const focusable = drawer && drawer.querySelector('button, a, input, [tabindex]');
431
- if (focusable) try { focusable.focus(); } catch (_) {}
432
- const onKey = (e) => {
433
- if (e.key === 'Escape') { toggleWsDrawer(which, false); document.removeEventListener('keydown', onKey); if (btn) try { btn.focus(); } catch (_) {} return; }
434
- if (drawer) trapTab(drawer, e);
435
- };
436
- shell._wsEscHandler = onKey;
437
- document.addEventListener('keydown', onKey);
438
- }
479
+ const drawer = shell.querySelector(which === 'pane' ? '.ws-pane' : '.ws-sessions');
480
+ const focusable = drawer && drawer.querySelector('button, a, input, [tabindex]');
481
+ if (focusable) try { focusable.focus(); } catch (_) {}
482
+ removeWsDrawerHandlers(shell); // replace, never stack (opening one drawer over the other)
483
+ const onKey = (e) => {
484
+ if (e.key === 'Escape') { toggleWsDrawer(which, false, shell); if (btn) try { btn.focus(); } catch (_) {} return; }
485
+ if (drawer) trapTab(drawer, e);
486
+ };
487
+ shell._wsEscHandler = onKey;
488
+ document.addEventListener('keydown', onKey);
489
+ // The drawer CSS stops applying above its breakpoint; auto-close when the
490
+ // viewport grows past it so the open class and armed Esc/focus-trap
491
+ // handlers do not linger invisibly in desktop layout.
492
+ const mq = window.matchMedia('(max-width: 1480px)');
493
+ const onMq = () => { if (!mq.matches) closeWsDrawers(shell); };
494
+ shell._wsDrawerMq = { mq, onMq };
495
+ mq.addEventListener('change', onMq);
496
+ }
497
+ function removeWsDrawerHandlers(shell) {
498
+ // Remove Esc/focus-trap handler armed by toggleWsDrawer (prevents ghost
499
+ // close on next Esc) and the viewport-growth auto-close listener.
500
+ if (shell._wsEscHandler) { document.removeEventListener('keydown', shell._wsEscHandler); shell._wsEscHandler = null; }
501
+ if (shell._wsDrawerMq) { shell._wsDrawerMq.mq.removeEventListener('change', shell._wsDrawerMq.onMq); shell._wsDrawerMq = null; }
439
502
  }
440
- function closeWsDrawers() {
441
- const shell = document.querySelector('.ws-shell');
503
+ function closeWsDrawers(fromEl) {
504
+ const shell = (fromEl && fromEl.closest && fromEl.closest('.ws-shell')) || document.querySelector('.ws-shell');
442
505
  if (!shell) return;
443
506
  shell.classList.remove('ws-sessions-open', 'ws-pane-open');
444
- document.querySelectorAll('.ws-sessions-drawer-toggle, .ws-pane-drawer-toggle').forEach((b) => b.setAttribute('aria-expanded', 'false'));
445
- // Remove Esc handler armed by toggleWsDrawer to prevent ghost close on next Esc.
446
- if (shell._wsEscHandler) { document.removeEventListener('keydown', shell._wsEscHandler); shell._wsEscHandler = null; }
507
+ shell.querySelectorAll('.ws-sessions-drawer-toggle, .ws-pane-drawer-toggle').forEach((b) => b.setAttribute('aria-expanded', 'false'));
508
+ removeWsDrawerHandlers(shell);
447
509
  }
448
510
 
449
511
  // Read persisted collapse state for a WorkspaceShell column so the layout is
@@ -513,16 +575,22 @@ export function WorkspaceShell({ rail, sessions, main, pane, crumb, status, narr
513
575
  'aria-label': railIsCollapsed ? 'expand navigation' : 'collapse navigation',
514
576
  title: railIsCollapsed ? 'expand navigation' : 'collapse navigation',
515
577
  'aria-expanded': railIsCollapsed ? 'false' : 'true',
516
- onclick: () => toggleWs('rail'),
578
+ onclick: (e) => toggleWs('rail', e.currentTarget),
517
579
  }, Icon('menu')),
518
580
  rail || null),
519
581
  // Tap-scrim behind an open mobile drawer; click anywhere dismisses.
520
- h('div', { class: 'ws-scrim', 'aria-hidden': 'true', onclick: () => closeWsDrawers() }),
582
+ h('div', { class: 'ws-scrim', 'aria-hidden': 'true', onclick: (e) => closeWsDrawers(e.currentTarget) }),
521
583
  // Optional sessions column. On mobile it is a drawer; selecting a row
522
584
  // (any button click inside) auto-closes it, mirroring AppShell.
523
585
  hasSessions
524
586
  ? h('div', { id: 'ws-sessions-col', class: 'ws-sessions', role: 'complementary', 'aria-label': 'conversations',
525
- onclick: (e) => { if (window.innerWidth <= 1100 && e.target.closest('button, a, [role="button"]')) closeWsDrawers(); } }, sessions)
587
+ // Drawer mode is detected by geometry (position:fixed only holds
588
+ // in drawer mode), not window.innerWidth - the shell may live in
589
+ // an embedded window narrower than the viewport.
590
+ onclick: (e) => {
591
+ const col = e.currentTarget;
592
+ if (getComputedStyle(col).position === 'fixed' && e.target.closest('button, a, [role="button"]')) closeWsDrawers(col);
593
+ } }, sessions)
526
594
  : null,
527
595
  // Primary content column, with an optional thin crumb bar on top. On
528
596
  // mobile the crumb hosts the drawer toggles (sessions on the left, pane
@@ -535,7 +603,7 @@ export function WorkspaceShell({ rail, sessions, main, pane, crumb, status, narr
535
603
  class: 'ws-drawer-toggle ws-sessions-drawer-toggle', type: 'button',
536
604
  'aria-label': 'toggle conversations', 'aria-expanded': 'false',
537
605
  'aria-controls': 'ws-sessions-col',
538
- onclick: () => toggleWsDrawer('sessions'),
606
+ onclick: (e) => toggleWsDrawer('sessions', null, e.currentTarget),
539
607
  }, Icon('thread')) : null,
540
608
  // Desktop-only sessions collapse (reclaims its width for a
541
609
  // full-width thread/grid). Hidden on mobile via CSS.
@@ -543,7 +611,7 @@ export function WorkspaceShell({ rail, sessions, main, pane, crumb, status, narr
543
611
  class: 'ws-desktop-toggle ws-sessions-toggle', type: 'button',
544
612
  'aria-label': sessionsIsCollapsed ? 'expand conversations' : 'collapse conversations',
545
613
  title: sessionsIsCollapsed ? 'expand conversations' : 'collapse conversations',
546
- 'aria-expanded': sessionsIsCollapsed ? 'false' : 'true', onclick: () => toggleWs('sessions'),
614
+ 'aria-expanded': sessionsIsCollapsed ? 'false' : 'true', onclick: (e) => toggleWs('sessions', e.currentTarget),
547
615
  }, Icon(sessionsIsCollapsed ? 'chevron-right' : 'chevron-left')) : null,
548
616
  h('div', { class: 'ws-crumb-main' }, crumb),
549
617
  // Desktop-only context-pane collapse, on the same crumb-level
@@ -553,13 +621,13 @@ export function WorkspaceShell({ rail, sessions, main, pane, crumb, status, narr
553
621
  'aria-label': paneIsCollapsed ? 'show context pane' : 'hide context pane',
554
622
  title: paneIsCollapsed ? 'show context pane' : 'hide context pane',
555
623
  'aria-expanded': paneIsCollapsed ? 'false' : 'true',
556
- onclick: () => toggleWs('pane'),
624
+ onclick: (e) => toggleWs('pane', e.currentTarget),
557
625
  }, Icon(paneIsCollapsed ? 'chevron-left' : 'chevron-right')) : null,
558
626
  hasPane ? h('button', {
559
627
  class: 'ws-drawer-toggle ws-pane-drawer-toggle', type: 'button',
560
628
  'aria-label': 'toggle context pane', 'aria-expanded': 'false',
561
629
  'aria-controls': 'ws-pane-col',
562
- onclick: () => toggleWsDrawer('pane'),
630
+ onclick: (e) => toggleWsDrawer('pane', null, e.currentTarget),
563
631
  }, Icon('page')) : null)
564
632
  : null,
565
633
  h('main', { class: 'ws-main' + (narrow ? ' narrow' : '') + (mainFlush ? ' ws-main--flush' : ''), id: 'ws-main', tabindex: '-1' },
@@ -6,13 +6,14 @@
6
6
  .launcher-dock {
7
7
  position: fixed;
8
8
  left: 0; top: 0; bottom: 0;
9
- width: var(--os-rail-w, 56px);
9
+ width: calc(var(--os-rail-w, 56px) + env(safe-area-inset-left, 0px));
10
10
  background: var(--os-bg-2);
11
11
  border: none;
12
12
  display: flex;
13
13
  flex-direction: column;
14
14
  align-items: center;
15
15
  padding: var(--space-2, 8px) 0;
16
+ padding-left: env(safe-area-inset-left, 0px);
16
17
  gap: var(--space-2, 8px);
17
18
  z-index: 10000;
18
19
  pointer-events: auto;
@@ -67,4 +68,10 @@
67
68
  .launcher-close { font-size: 11px; opacity: 0.7; width: 20px; height: 20px; color: var(--os-fg-3); }
68
69
  .launcher-close:hover { opacity: 1; color: var(--os-red); }
69
70
 
70
- .launcher-dock + .wm-root { left: var(--os-rail-w, 56px) !important; }
71
+ .launcher-dock + .wm-root { left: calc(var(--os-rail-w, 56px) + env(safe-area-inset-left, 0px)) !important; }
72
+
73
+ /* Touch tap-target floor for dock controls. */
74
+ @media (pointer: coarse) {
75
+ .launcher-btn { width: 44px; height: 44px; }
76
+ .launcher-close { width: 44px; height: 44px; font-size: 13px; }
77
+ }
Binary file