anentrypoint-design 0.0.237 → 0.0.239
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/app-shell.css +587 -47
- package/chat.css +3 -0
- package/colors_and_type.css +17 -15
- package/community.css +81 -20
- package/dist/247420.css +752 -89
- package/dist/247420.js +15 -15
- package/package.json +1 -1
- package/src/community-app.js +6 -5
- package/src/components/chat.js +15 -4
- package/src/components/content.js +7 -2
- package/src/components/data-density.js +7 -1
- package/src/components/editor-primitives.js +27 -4
- package/src/components/files-modals.js +33 -6
- package/src/components/overlay-primitives.js +5 -1
- package/src/components/shell.js +107 -39
- package/src/kits/os/launcher.css +9 -2
- package/src/kits/os/shell.js +0 -0
- package/src/kits/os/theme.css +37 -11
- package/src/kits/os/wm.css +21 -1
- package/src/kits/os/wm.js +32 -8
- package/src/kits/spoint/game-hud.css +5 -4
- package/src/kits/spoint/host-join-lobby.css +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.239",
|
|
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",
|
package/src/community-app.js
CHANGED
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
// isConnected, voiceConnected, voiceChannelName, voiceConnectionState,
|
|
13
13
|
// voiceParticipants, micMuted, voiceDeafened,
|
|
14
14
|
// audioQueueItems, audioQueueCurrentId, audioQueuePaused,
|
|
15
|
-
// showAuthModal, settingsOpen, voiceSettingsOpen, replyTarget
|
|
15
|
+
// showAuthModal, settingsOpen, voiceSettingsOpen, replyTarget,
|
|
16
|
+
// mobileMenuOpen // drives the .ca-rail off-canvas drawer on narrow shells
|
|
16
17
|
// }
|
|
17
18
|
// adapter.subscribe(cb) -> unsubscribe // cb fires when any snapshot field changes
|
|
18
19
|
// adapter.actions = {
|
|
19
20
|
// switchChannel(ch), send(text, opts), toggleMic(), toggleDeafen(),
|
|
20
|
-
// leaveVoice(), toggleMembers(), openMobileMenu(), openSettings(),
|
|
21
|
+
// leaveVoice(), toggleMembers(), openMobileMenu(), closeMobileMenu(), openSettings(),
|
|
21
22
|
// channelContext(id, x, y), serverContext(id, x, y), switchServer(id),
|
|
22
23
|
// goHome(), openServers(), memberMenu(id, name, x, y),
|
|
23
24
|
// replaySegment(id), skipSegment(), pauseQueue(), resumeQueue(),
|
|
@@ -202,9 +203,9 @@ export function mountCommunityApp(root, adapter = {}) {
|
|
|
202
203
|
MobileHeader({ channelType: ch.type || 'text', channelName: ch.name || '', onMenu: () => A.openMobileMenu && A.openMobileMenu(), onMembers: () => A.toggleMembers && A.toggleMembers() }),
|
|
203
204
|
Banner({ tone: 'warning', message: 'No relay connected. Reconnecting…', visible: s.isConnected === false }),
|
|
204
205
|
Banner({ tone: 'success', visible: !!showVoiceBanner, message: showVoiceBanner ? ('In voice: ' + (s.voiceChannelName || '') + ' — click to return') : '', actionLabel: 'Leave', onAction: (e) => { if (e && e.stopPropagation) e.stopPropagation(); A.leaveVoice && A.leaveVoice(); }, onClick: () => A.returnToVoice && A.returnToVoice() }),
|
|
205
|
-
h('div', { class: 'app-body' },
|
|
206
|
-
h('aside', { class: 'app-side ca-rail' }, railView(s)),
|
|
207
|
-
h('main', { class: 'app-main' },
|
|
206
|
+
h('div', { class: 'app-body' + (s.mobileMenuOpen ? ' ca-rail-open' : '') },
|
|
207
|
+
h('aside', { class: 'app-side ca-rail' + (s.mobileMenuOpen ? ' open' : '') }, railView(s)),
|
|
208
|
+
h('main', { class: 'app-main', onclick: () => { if (s.mobileMenuOpen && A.closeMobileMenu) A.closeMobileMenu(); } },
|
|
208
209
|
!inVoiceChannel && s.voiceConnected ? VoiceStrip({ channelName: s.voiceChannelName, status: s.voiceConnectionState || 'connected', muted: !!s.micMuted, deafened: !!s.voiceDeafened, onMute: () => A.toggleMic && A.toggleMic(), onDeafen: () => A.toggleDeafen && A.toggleDeafen(), onLeave: () => A.leaveVoice && A.leaveVoice(), open: true }) : null,
|
|
209
210
|
UserPanel({ name: (s.currentUser && (s.currentUser.displayName || s.currentUser.username || s.currentUser.name)) || 'You', tag: s.currentUser && s.currentUser.tag, color: avatarColor(s.userId), muted: !!s.micMuted, deafened: !!s.voiceDeafened, onMute: () => A.toggleMic && A.toggleMic(), onDeafen: () => A.toggleDeafen && A.toggleDeafen(), onSettings: () => A.openSettings && A.openSettings() }),
|
|
210
211
|
bodyMain,
|
package/src/components/chat.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
547
|
-
anchorY:
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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]') ||
|
|
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:
|
|
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
|
}
|
package/src/components/shell.js
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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' },
|
package/src/kits/os/launcher.css
CHANGED
|
@@ -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
|
+
}
|
package/src/kits/os/shell.js
CHANGED
|
Binary file
|