diff --git a/frontend/src/components/AppCanvas/AppCanvas.css b/frontend/src/components/AppCanvas/AppCanvas.css index f5ed089f..35e88d41 100644 --- a/frontend/src/components/AppCanvas/AppCanvas.css +++ b/frontend/src/components/AppCanvas/AppCanvas.css @@ -197,3 +197,46 @@ color: var(--muted, #999); font-family: var(--font, inherit); } + +.canvas-loading__offline-actions { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 10px; + margin-top: 2px; +} + +.canvas-loading__offline-button { + min-height: 44px; + padding: 10px 16px; + border: 1px solid var(--border, #333); + border-radius: 10px; + background: var(--surface, #1a1a1a); + color: var(--text, #e5e5e5); + font-family: var(--font, inherit); + font-size: 14px; + font-weight: 600; + line-height: 1; + max-width: 100%; + cursor: pointer; +} + +.canvas-loading__offline-button--primary { + border-color: var(--accent, #8b6cf7); + background: var(--accent, #8b6cf7); + color: var(--accent-fg, #fff); +} + +.canvas-loading__offline-button--secondary { + background: transparent; +} + +.canvas-loading__offline-button:active { + transform: scale(0.97); +} + +.canvas-loading__offline-button:focus-visible { + outline: 2px solid var(--accent, #8b6cf7); + outline-offset: 2px; +} diff --git a/frontend/src/components/AppCanvas/AppCanvas.jsx b/frontend/src/components/AppCanvas/AppCanvas.jsx index 4aeccdde..b6497abb 100644 --- a/frontend/src/components/AppCanvas/AppCanvas.jsx +++ b/frontend/src/components/AppCanvas/AppCanvas.jsx @@ -899,16 +899,24 @@ export default function AppCanvas({ ? 'Open it when you’re ready, or switch to another app.' : serviceSurface.error} -
- {serviceSurface.phase === 'error' && ( - + )}
diff --git a/frontend/src/components/ChatView/ChatView.jsx b/frontend/src/components/ChatView/ChatView.jsx index 023a5b6b..601cd556 100644 --- a/frontend/src/components/ChatView/ChatView.jsx +++ b/frontend/src/components/ChatView/ChatView.jsx @@ -79,6 +79,7 @@ import { saveFailedSendAttempt, sendAttemptIsDurable, } from './sendAttemptRecovery.js' +import { persistComposerDraft } from './composerDraft.js' import { EMPTY_BUILD_PHASE_RAIL, accumulateBuildPhase, @@ -169,27 +170,6 @@ export function deleteChatDraft(chatId) { clearChatQuestionDrafts(chatId) } -// Evict the oldest draft: key from sessionStorage so a new draft can land. -// Oldest = smallest numeric suffix after the colon; that's the chat that -// was least recently opened (chats get integer IDs assigned in order). -function evictOldestDraft() { - try { - const draftKeys = [] - for (let i = 0; i < sessionStorage.length; i++) { - const key = sessionStorage.key(i) - if (key?.startsWith('draft:')) draftKeys.push(key) - } - if (draftKeys.length === 0) return - // Sort ascending by the numeric part; remove the oldest (lowest ID). - draftKeys.sort((a, b) => { - const na = parseInt(a.slice(6), 10) || 0 - const nb = parseInt(b.slice(6), 10) || 0 - return na - nb - }) - sessionStorage.removeItem(draftKeys[0]) - } catch { /* ignore */ } -} - function tailResumableBlock(messages) { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].hidden) continue @@ -358,7 +338,14 @@ export default function ChatView({ if (!initialComposerRef.current) { initialComposerRef.current = readInitialComposer(chatId) } - const [input, setInput] = useState(() => initialComposerRef.current.input) + const [input, setInputState] = useState(() => initialComposerRef.current.input) + function setComposerInput(nextInput) { + // Navigation can unmount this component before React flushes passive + // effects. Keep every composer transition durable at the state boundary, + // whether it came from typing, voice, restoration, or send cleanup. + persistComposerDraft(chatId, nextInput) + setInputState(nextInput) + } const [sendFailure, setSendFailure] = useState(() => ( initialComposerRef.current.failedAttempt ? 'Möbius is checking whether your previous message reached the chat…' @@ -1407,7 +1394,7 @@ export default function ChatView({ function handleComposerInputChange(nextInput) { clearFailedAttempt() setSendFailure(null) - setInput(nextInput) + setComposerInput(nextInput) } function handleComposerAddFiles(fileList) { @@ -1426,7 +1413,7 @@ export default function ChatView({ text, { focus = false, preserveFailedAttempt = false } = {}, ) { - if (preserveFailedAttempt) setInput(text) + if (preserveFailedAttempt) setComposerInput(text) else handleComposerInputChange(text) requestAnimationFrame(() => { const el = inputRef.current @@ -1540,21 +1527,11 @@ export default function ChatView({ } // Persist draft so it survives leaving and re-entering the chat. + // This remains as a safety net for programmatic input changes (restores, + // voice transcription, send cleanup). Direct owner edits are saved + // synchronously in handleComposerInputChange above. useEffect(() => { - try { - if (input) sessionStorage.setItem(`draft:${chatId}`, input) - else sessionStorage.removeItem(`draft:${chatId}`) - } catch (e) { - // QuotaExceededError: evict the oldest draft: key and retry once so the - // current chat's draft is always fresh. - if (e?.name === 'QuotaExceededError' || e?.code === 22) { - evictOldestDraft() - try { - if (input) sessionStorage.setItem(`draft:${chatId}`, input) - } catch { /* still no room — skip */ } - } - // Otherwise private browsing / storage disabled — silently skip. - } + persistComposerDraft(chatId, input) }, [input, chatId]) // Auto-size textarea when a draft is restored. Cap matches the @@ -1690,7 +1667,7 @@ export default function ChatView({ if (failedAttempt) { if (sendAttemptIsDurable(failedAttempt, msgs, data.pending_messages)) { clearFailedAttempt() - setInput('') + setComposerInput('') clearFiles() setSendFailure(null) } else { @@ -2021,7 +1998,7 @@ export default function ChatView({ const queuedPinIntent = sendPinIntent rememberQueuedPinIntent(cid, queuedPinIntent) inlineSteerPinIntentRef.current = queuedPinIntent - setInput('') + setComposerInput('') clearComposerFilesForSend() if (inputRef.current) { inputRef.current.style.height = 'auto' @@ -2221,7 +2198,7 @@ export default function ChatView({ const userMsg = { role: 'user', content: text, ts: Date.now(), cid, optimistic: true } if (attachments.length > 0) userMsg.attachments = attachments commitMessages(prev => [...prev, userMsg]) - setInput('') + setComposerInput('') clearComposerFilesForSend() if (inputRef.current) { inputRef.current.style.height = 'auto' diff --git a/frontend/src/components/ChatView/__tests__/composerDraft.test.js b/frontend/src/components/ChatView/__tests__/composerDraft.test.js new file mode 100644 index 00000000..f75c81af --- /dev/null +++ b/frontend/src/components/ChatView/__tests__/composerDraft.test.js @@ -0,0 +1,56 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +import { persistComposerDraft } from '../composerDraft.js' + +function storageStub(initial = {}) { + const values = new Map(Object.entries(initial)) + return { + get length() { return values.size }, + key(index) { return [...values.keys()][index] ?? null }, + getItem(key) { return values.get(key) ?? null }, + setItem(key, value) { values.set(key, String(value)) }, + removeItem(key) { values.delete(key) }, + } +} + +test('persists and clears a chat draft synchronously', () => { + const storage = storageStub() + assert.equal(persistComposerDraft('chat-a', 'unfinished thought', storage), true) + assert.equal(storage.getItem('draft:chat-a'), 'unfinished thought') + + assert.equal(persistComposerDraft('chat-a', '', storage), true) + assert.equal(storage.getItem('draft:chat-a'), null) +}) + +test('evicts one draft and retries when session storage is full', () => { + const storage = storageStub({ 'draft:chat-a': 'older' }) + const normalSet = storage.setItem.bind(storage) + let first = true + storage.setItem = (key, value) => { + if (first) { + first = false + const error = new Error('full') + error.name = 'QuotaExceededError' + throw error + } + normalSet(key, value) + } + + assert.equal(persistComposerDraft('chat-b', 'latest', storage), true) + assert.equal(storage.getItem('draft:chat-a'), null) + assert.equal(storage.getItem('draft:chat-b'), 'latest') +}) + +test('the composer state boundary saves before scheduling React state', () => { + const source = readFileSync(new URL('../ChatView.jsx', import.meta.url), 'utf8') + const start = source.indexOf('function setComposerInput(nextInput)') + const end = source.indexOf('\n }', start) + const body = source.slice(start, end) + + const save = body.indexOf('persistComposerDraft(chatId, nextInput)') + const render = body.indexOf('setInputState(nextInput)') + assert.ok(save >= 0, 'all composer changes must be persisted directly') + assert.ok(render > save, 'draft persistence must happen before navigation can unmount React') +}) diff --git a/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js b/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js new file mode 100644 index 00000000..d08ec311 --- /dev/null +++ b/frontend/src/components/ChatView/__tests__/preserveTogglePosition.test.js @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { preserveTogglePosition } from '../preserveTogglePosition.js' + +test('toggle position is corrected from the DOM mutation before rAF', () => { + const originalMutationObserver = globalThis.MutationObserver + const originalRaf = globalThis.requestAnimationFrame + let mutationCallback = null + let rafCallback = null + let observedNode = null + let disconnected = false + + globalThis.MutationObserver = class { + constructor(callback) { mutationCallback = callback } + observe(node) { observedNode = node } + disconnect() { disconnected = true } + } + globalThis.requestAnimationFrame = (callback) => { + rafCallback = callback + return 1 + } + + try { + const parentElement = {} + const scroller = { scrollTop: 40 } + let top = 120 + const anchor = { + parentElement, + closest: () => scroller, + getBoundingClientRect: () => ({ top }), + } + + preserveTogglePosition(anchor) + assert.equal(observedNode, parentElement) + + top = 155 + mutationCallback() + assert.equal(scroller.scrollTop, 75) + assert.equal(disconnected, true) + + // The scheduled fallback must not apply the correction a second time. + rafCallback() + assert.equal(scroller.scrollTop, 75) + } + finally { + globalThis.MutationObserver = originalMutationObserver + globalThis.requestAnimationFrame = originalRaf + } +}) + +test('rAF remains a fallback when MutationObserver is unavailable', () => { + const originalMutationObserver = globalThis.MutationObserver + const originalRaf = globalThis.requestAnimationFrame + let rafCallback = null + + globalThis.MutationObserver = undefined + globalThis.requestAnimationFrame = (callback) => { + rafCallback = callback + return 1 + } + + try { + const scroller = { scrollTop: 20 } + let top = 80 + const anchor = { + parentElement: {}, + closest: () => scroller, + getBoundingClientRect: () => ({ top }), + } + + preserveTogglePosition(anchor) + top = 68 + rafCallback() + assert.equal(scroller.scrollTop, 8) + } + finally { + globalThis.MutationObserver = originalMutationObserver + globalThis.requestAnimationFrame = originalRaf + } +}) + +test('closing a disclosure primes the bottom spacer before React removes its body', () => { + const originalMutationObserver = globalThis.MutationObserver + const originalRaf = globalThis.requestAnimationFrame + const originalGetComputedStyle = globalThis.getComputedStyle + globalThis.MutationObserver = undefined + globalThis.requestAnimationFrame = () => 1 + globalThis.getComputedStyle = () => ({ marginTop: '4px', marginBottom: '2px' }) + + try { + const spacer = { offsetHeight: 100, style: {} } + const scroller = { + scrollTop: 50, + querySelector: selector => selector === '.spacer-dynamic' ? spacer : null, + } + const body = { getBoundingClientRect: () => ({ height: 60 }) } + const anchor = { + parentElement: {}, + nextElementSibling: body, + closest: () => scroller, + getAttribute: name => name === 'aria-expanded' ? 'true' : null, + getBoundingClientRect: () => ({ top: 120 }), + } + + preserveTogglePosition(anchor) + assert.equal(spacer.style.height, '166px') + } + finally { + globalThis.MutationObserver = originalMutationObserver + globalThis.requestAnimationFrame = originalRaf + globalThis.getComputedStyle = originalGetComputedStyle + } +}) + +test('opening a disclosure leaves normal spacer sizing alone', () => { + const originalMutationObserver = globalThis.MutationObserver + const originalRaf = globalThis.requestAnimationFrame + globalThis.MutationObserver = undefined + globalThis.requestAnimationFrame = () => 1 + + try { + const spacer = { offsetHeight: 100, style: {} } + const scroller = { + scrollTop: 50, + querySelector: () => spacer, + } + const anchor = { + parentElement: {}, + nextElementSibling: null, + closest: () => scroller, + getAttribute: () => 'false', + getBoundingClientRect: () => ({ top: 120 }), + } + + preserveTogglePosition(anchor) + assert.equal(spacer.style.height, undefined) + } + finally { + globalThis.MutationObserver = originalMutationObserver + globalThis.requestAnimationFrame = originalRaf + } +}) diff --git a/frontend/src/components/ChatView/__tests__/useScrollMode.test.js b/frontend/src/components/ChatView/__tests__/useScrollMode.test.js index 8b68f631..bde8d123 100644 --- a/frontend/src/components/ChatView/__tests__/useScrollMode.test.js +++ b/frontend/src/components/ChatView/__tests__/useScrollMode.test.js @@ -177,6 +177,22 @@ test('only scrolling keys claim reader ownership', () => { assert.equal(readerInputMayScroll('touchmove'), true) }) +test('a disclosure tap does not mistake its collapse clamp for a reader swipe', () => { + const disclosureTarget = { + closest(selector) { + assert.match(selector, /chat__activity-header/) + return { className: 'chat__activity-header' } + }, + } + const ordinaryTarget = { closest: () => null } + + assert.equal(readerInputMayScroll('pointerdown', '', disclosureTarget), false) + assert.equal(readerInputMayScroll('touchstart', '', disclosureTarget), false) + assert.equal(readerInputMayScroll('touchmove', '', disclosureTarget), true, + 'a real drag starting on the disclosure still claims reader ownership') + assert.equal(readerInputMayScroll('pointerdown', '', ordinaryTarget), true) +}) + test('inputs without an end event get a next-frame no-scroll release', () => { assert.equal(readerInputNeedsFrameRelease('wheel'), true) assert.equal(readerInputNeedsFrameRelease('keydown'), true) diff --git a/frontend/src/components/ChatView/composerDraft.js b/frontend/src/components/ChatView/composerDraft.js new file mode 100644 index 00000000..8ff75788 --- /dev/null +++ b/frontend/src/components/ChatView/composerDraft.js @@ -0,0 +1,50 @@ +function availableStorage(storage) { + if (storage !== undefined) return storage + try { return globalThis.sessionStorage ?? null } catch { return null } +} + +function evictOneDraft(storage) { + try { + const draftKeys = [] + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i) + if (key?.startsWith('draft:')) draftKeys.push(key) + } + if (draftKeys.length === 0) return + // Chat ids may be integers or UUIDs. Stable lexical order is sufficient: + // quota recovery only needs to free one older best-effort draft. + draftKeys.sort() + storage.removeItem(draftKeys[0]) + } catch { /* best-effort storage */ } +} + +/** + * Persist a composer value immediately. + * + * This deliberately belongs on the input event path rather than only in a + * React effect. A browser back gesture can commit navigation and unmount the + * chat before passive effects run, especially while the mobile keyboard is + * settling. Synchronous storage here makes the text durable before React gets + * a chance to remove the composer. + */ +export function persistComposerDraft(chatId, input, storage) { + const target = availableStorage(storage) + if (!target || chatId == null) return false + const key = `draft:${chatId}` + + try { + if (input) target.setItem(key, input) + else target.removeItem(key) + return true + } catch (error) { + if (error?.name !== 'QuotaExceededError' && error?.code !== 22) return false + evictOneDraft(target) + try { + if (input) target.setItem(key, input) + else target.removeItem(key) + return true + } catch { + return false + } + } +} diff --git a/frontend/src/components/ChatView/preserveTogglePosition.js b/frontend/src/components/ChatView/preserveTogglePosition.js index cb979379..83d66a28 100644 --- a/frontend/src/components/ChatView/preserveTogglePosition.js +++ b/frontend/src/components/ChatView/preserveTogglePosition.js @@ -2,10 +2,55 @@ export function preserveTogglePosition(anchorEl) { if (!anchorEl || typeof requestAnimationFrame !== 'function') return const scroller = anchorEl.closest?.('.chat__scroll') if (!scroller) return + + // Closing a disclosure at the physical tail removes height before the chat's + // ResizeObserver can grow its dynamic bottom reservation. The browser clamps + // scrollTop in that gap, paints the header lower for one frame, then the + // observer restores it — the visible down/up twitch. Reserve the body's exact + // outer height synchronously so total scroll height stays constant across the + // React commit; the observer replaces this provisional value with its normal + // spacer calculation on the next layout pass. + if (anchorEl.getAttribute?.('aria-expanded') === 'true') { + const spacer = scroller.querySelector?.('.spacer-dynamic') + const body = anchorEl.nextElementSibling + if (spacer && body) { + const rectHeight = body.getBoundingClientRect?.().height || 0 + const styles = typeof getComputedStyle === 'function' + ? getComputedStyle(body) + : null + const marginTop = Number.parseFloat(styles?.marginTop) || 0 + const marginBottom = Number.parseFloat(styles?.marginBottom) || 0 + const removedHeight = rectHeight + marginTop + marginBottom + if (removedHeight > 0) { + spacer.style.height = `${(spacer.offsetHeight || 0) + removedHeight}px` + } + } + } + const before = anchorEl.getBoundingClientRect().top - requestAnimationFrame(() => { + + // A disclosure inserts/removes its body during React's click commit. Observe + // that DOM mutation so the scroll correction lands in the same frame, before + // the browser paints the collapsed layout. The old rAF-only correction let + // one intermediate frame escape first: the page visibly moved with the + // collapse, then moved back when rAF adjusted scrollTop. + let settled = false + let observer = null + const settle = () => { + if (settled) return + settled = true + observer?.disconnect() const after = anchorEl.getBoundingClientRect().top const delta = after - before if (Math.abs(delta) > 0.5) scroller.scrollTop += delta - }) + } + + if (typeof MutationObserver === 'function' && anchorEl.parentElement) { + observer = new MutationObserver(settle) + observer.observe(anchorEl.parentElement, { childList: true, subtree: true }) + } + + // Safety fallback for an unusual disclosure that changes layout without a + // child-list mutation. When the observer fired, this becomes a cheap no-op. + requestAnimationFrame(settle) } diff --git a/frontend/src/components/ChatView/useScrollMode.js b/frontend/src/components/ChatView/useScrollMode.js index a67892fe..9d3d7d48 100644 --- a/frontend/src/components/ChatView/useScrollMode.js +++ b/frontend/src/components/ChatView/useScrollMode.js @@ -534,10 +534,21 @@ export function gestureLayoutRetryDelay(gestureWindowUntil, now) { } -/** Only keys whose default action can move the chat begin reader ownership. +/** Only input whose default action can move the chat begins reader ownership. * Text entry and activating controls inside a message must not freeze layout - * until the no-scroll dead-man expires. */ -export function readerInputMayScroll(type, key = '') { + * until the no-scroll dead-man expires. A disclosure tap is especially + * important here: collapsing its body can clamp scrollTop and emit a synthetic + * scroll event. Treating that clamp as the start of a swipe holds the smaller + * spacer for the 250ms momentum window, so the viewport moves once on collapse + * and again when layout ownership returns. A real drag that starts on a + * disclosure still produces touchmove, which claims reader ownership below. */ +export function readerInputMayScroll(type, key = '', target = null) { + if ((type === 'pointerdown' || type === 'touchstart') + && target?.closest?.( + '.chat__activity-header, .chat__tool-header, .chat__marker-header', + )) { + return false + } if (type !== 'keydown') return true return [ 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', 'Tab', ' ', @@ -1430,7 +1441,7 @@ export default function useScrollMode({ }) } const onUserInput = (event) => { - if (!readerInputMayScroll(event?.type, event?.key)) return + if (!readerInputMayScroll(event?.type, event?.key, event?.target)) return // Input and its first scroll event are ordered, but not guaranteed to be // less than 250ms apart under a busy renderer. Keep layout ownership // suspended until that first event actually lands; after it, the normal diff --git a/frontend/src/components/Drawer/Drawer.css b/frontend/src/components/Drawer/Drawer.css index 4a84f20d..e8f84f6f 100644 --- a/frontend/src/components/Drawer/Drawer.css +++ b/frontend/src/components/Drawer/Drawer.css @@ -55,6 +55,12 @@ read as part of the same design system. --cubic-move is the ease curve the SDK uses for sliding panels. */ transition: transform var(--transition-duration-basic, 250ms) var(--cubic-move, cubic-bezier(0.4, 0, 0.2, 1)); + /* Vertical gestures belong to the drawer list; horizontal gestures are + reserved for Drawer.jsx's swipe-to-close interaction. Keeping this modal + boundary explicit also stops a pan begun on non-scrollable drawer chrome + (a section label or gutter) from chaining into the app iframe beneath it. */ + touch-action: pan-y pinch-zoom; + overscroll-behavior: contain; } /* Give longer chat and app names more room on larger screens without diff --git a/frontend/src/components/Drawer/Drawer.jsx b/frontend/src/components/Drawer/Drawer.jsx index 0fd3c865..7526bb51 100644 --- a/frontend/src/components/Drawer/Drawer.jsx +++ b/frontend/src/components/Drawer/Drawer.jsx @@ -273,7 +273,11 @@ export default function Drawer({ // without easing. const drawerRef = useRef(null) const dragStart = useRef(null) // { x, y } or null - // True once a touch gesture has moved past the tap/swipe threshold. + // True once a touch gesture has become a HORIZONTAL swipe. Vertical movement + // belongs to the drawer's scroll containers and must never arm click + // suppression: some mobile browsers do not emit a synthetic click after a + // scroll, so the old "any movement" rule left the suppressor waiting and ate + // the user's next real tap on a chat/app row. // React's onTouch* handlers are passive, so preventDefault() in // onTouchMove is a no-op and a horizontal drag still emits a synthetic // click that lands on whatever row the finger lifted over — selecting @@ -331,13 +335,15 @@ export default function Drawer({ if (!dragStart.current || e.touches.length !== 1) return const dx = e.touches[0].clientX - dragStart.current.x const dy = e.touches[0].clientY - dragStart.current.y - // Any movement past the threshold (in either axis) means this is a - // drag, not a tap — mark it so touchend can suppress the trailing - // click even on a scroll/vertical pan that lifted over a row. - if (Math.abs(dx) > SWIPE_THRESHOLD || Math.abs(dy) > SWIPE_THRESHOLD) { + const isHorizontalSwipe = Math.abs(dx) > SWIPE_THRESHOLD + && Math.abs(dx) > Math.abs(dy) * 1.15 + // Only custom horizontal gestures need the one-shot click suppressor. + // Native vertical scrolling already owns its tap/click cancellation; arming + // our suppressor for it made a quick post-scroll destination tap look dead. + if (isHorizontalSwipe) { swipingRef.current = true } - if (dx < 0 && Math.abs(dx) > Math.abs(dy) * 1.15) { + if (dx < 0 && isHorizontalSwipe) { const el = drawerRef.current if (!el) return el.classList.add('drawer--dragging') diff --git a/frontend/src/components/Shell/Shell.css b/frontend/src/components/Shell/Shell.css index f9c6b3f0..2a7b44da 100644 --- a/frontend/src/components/Shell/Shell.css +++ b/frontend/src/components/Shell/Shell.css @@ -152,6 +152,18 @@ z-index: 1; } +/* `inert` is the semantic/modal contract, but mobile engines have historically + differed on whether an inert ancestor fully removes a cross-origin iframe + from native touch hit-testing. Make the physical contract explicit too: an + open drawer cannot scroll or activate the app canvas behind it. The drawer + and scrim are siblings above this content, so their own gestures are + unaffected. */ +.shell__content[inert] { + pointer-events: none; + touch-action: none; + overscroll-behavior: none; +} + /* `.shell__view` wraps each persisted view (ChatView, AppCanvas). All views are laid out simultaneously (position: absolute, inset 0) and toggled via `visibility` + `pointer-events`. Why not diff --git a/frontend/src/lib/__tests__/serviceSurfaceControls.test.js b/frontend/src/lib/__tests__/serviceSurfaceControls.test.js index 3e1dddd7..f081dfc4 100644 --- a/frontend/src/lib/__tests__/serviceSurfaceControls.test.js +++ b/frontend/src/lib/__tests__/serviceSurfaceControls.test.js @@ -14,3 +14,13 @@ test('service failure controls remain clickable inside the click-through loading assert.match(loading, /pointer-events:\s*none/) assert.match(offline, /pointer-events:\s*auto/) }) + +test('service failure actions render as separated touch-sized buttons', () => { + const actions = css.match(/\.canvas-loading__offline-actions\s*\{([^}]*)\}/)?.[1] || '' + const button = css.match(/\.canvas-loading__offline-button\s*\{([^}]*)\}/)?.[1] || '' + + assert.match(actions, /display:\s*flex/) + assert.match(actions, /flex-wrap:\s*wrap/) + assert.match(actions, /gap:\s*10px/) + assert.match(button, /min-height:\s*44px/) +}) diff --git a/tests/navigation.spec.mjs b/tests/navigation.spec.mjs index 6b5b95a0..127879af 100644 --- a/tests/navigation.spec.mjs +++ b/tests/navigation.spec.mjs @@ -726,15 +726,72 @@ test.describe('Drawer close paths converge through handleBack', () => { test('22b. Drawer scrim owns touch pans instead of the background', async ({ page }) => { await setup(page) await openDrawer(page) - const contract = await page.locator('.drawer-overlay').evaluate((overlay) => ({ - touchAction: getComputedStyle(overlay).touchAction, - overscrollBehavior: getComputedStyle(overlay).overscrollBehavior, - })) - expect(contract.touchAction).toBe('none') - expect(contract.overscrollBehavior).toBe('none') + const contract = await page.evaluate(() => { + const overlay = getComputedStyle(document.querySelector('.drawer-overlay')) + const drawer = getComputedStyle(document.querySelector('.drawer')) + const content = getComputedStyle(document.querySelector('.shell__content')) + return { + overlayTouchAction: overlay.touchAction, + overlayOverscroll: overlay.overscrollBehavior, + drawerTouchAction: drawer.touchAction, + drawerOverscroll: drawer.overscrollBehavior, + contentPointerEvents: content.pointerEvents, + contentTouchAction: content.touchAction, + } + }) + expect(contract).toEqual({ + overlayTouchAction: 'none', + overlayOverscroll: 'none', + drawerTouchAction: 'pan-y pinch-zoom', + drawerOverscroll: 'contain', + contentPointerEvents: 'none', + contentTouchAction: 'none', + }) + }) + + test('22c. Vertical drawer scroll does not swallow the next destination tap', async ({ page }) => { + await setup(page, { width: 426, height: 860 }) + await openDrawer(page) + + // Reproduce the phone sequence without waiting out the old suppressor's + // 400ms fallback: scroll vertically, lift, then immediately tap Settings. + // A vertical pan is native scrolling, not the custom horizontal + // swipe-to-close gesture, so the destination click must pass through. + await page.locator('.drawer').evaluate((drawer) => { + const point = (y) => new Touch({ + identifier: 37, + target: drawer, + clientX: 180, + clientY: y, + }) + drawer.dispatchEvent(new TouchEvent('touchstart', { + bubbles: true, + cancelable: true, + touches: [point(520)], + })) + drawer.dispatchEvent(new TouchEvent('touchmove', { + bubbles: true, + cancelable: true, + touches: [point(390)], + })) + drawer.dispatchEvent(new TouchEvent('touchend', { + bubbles: true, + cancelable: true, + touches: [], + changedTouches: [point(390)], + })) + + const settings = [...drawer.querySelectorAll('button')] + .find(button => button.textContent.trim() === 'Settings') + settings?.click() + }) + + await expect(page.locator('.settings')).toBeVisible() + await expect(page.getByRole('button', { name: 'Toggle navigation' })) + .toHaveAttribute('aria-expanded', 'false') }) - test('22c. Interrupted drawer swipe cannot strand an inert panel onscreen', async ({ page }) => { + test('22d. Interrupted drawer swipe cannot strand an inert panel onscreen', async ({ page }) => { await setup(page, { width: 426, height: 860 }) await openDrawer(page) @@ -776,7 +833,7 @@ test.describe('Drawer close paths converge through handleBack', () => { )).toBeLessThanOrEqual(-359) }) - test('22d. Closing scrim blocks the app until the panel is offscreen', async ({ page }) => { + test('22e. Closing scrim blocks the app until the panel is offscreen', async ({ page }) => { await setup(page, { width: 426, height: 860 }) await openDrawer(page) await page.locator('.drawer-overlay').dispatchEvent('pointerdown', { diff --git a/tests/service-surface.spec.mjs b/tests/service-surface.spec.mjs index 1b6f51ae..26d35d27 100644 --- a/tests/service-surface.spec.mjs +++ b/tests/service-surface.spec.mjs @@ -201,7 +201,13 @@ test('shared gateway stays branded until heartbeat, preserves cookies, and rejec expect(cspSources(shellResponse.headers()['content-security-policy'], 'frame-src')) .toEqual(["'self'", SERVICE_ORIGIN]) const appFrame = page.locator(`iframe[src*="/api/apps/${app.id}/frame"]`) - await expect(appFrame).toHaveCount(1, { timeout: 5_000 }) + // This is a cold deep-link immediately after creating the app. On a clean + // browser the shell may also complete its first service-worker handoff + // before the catalog hydrates; five seconds proved too tight under a busy + // CI runner even though the frame appeared and the same contract passed on + // the PR head. Keep this bounded, but align it with the shell's normal + // cold-start budget rather than treating scheduler latency as a failure. + await expect(appFrame).toHaveCount(1, { timeout: 15_000 }) const appSandbox = (await appFrame.getAttribute('sandbox') || '').split(/\s+/) expect(appSandbox).not.toContain('allow-same-origin') // load fires even for the deliberately blocked document. While the shell