Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,15 @@
&& navigator.serviceWorker.controller
&& !sessionStorage.getItem('sw-auto-reloaded')) {
sessionStorage.setItem('sw-auto-reloaded', '1')
rememberShellStateForAutoReload()
// Shell.performShellReload captures its live ref-backed route
// before requesting the worker handoff. Keep that authoritative
// snapshot: localStorage mirrors React state in effects and can
// still describe the previous view during a just-mounted cold
// deep-link. The early boot handler is only a fallback for a
// controller change that has no Shell-owned snapshot.
if (!sessionStorage.getItem('shell-reload')) {
rememberShellStateForAutoReload()
}
window.location.reload()
}
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/AppCanvas/AppCanvas.css
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 2px;
}
Expand All @@ -217,6 +218,7 @@
font-size: 14px;
font-weight: 600;
line-height: 1;
max-width: 100%;
cursor: pointer;
}

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/Drawer/Drawer.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions frontend/src/components/Drawer/Drawer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/components/Shell/Shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/__tests__/serviceSurfaceControls.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ test('service failure actions render as separated touch-sized buttons', () => {
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/)
})
19 changes: 19 additions & 0 deletions frontend/src/lib/__tests__/shellControllerReload.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import assert from 'node:assert/strict'

const indexHtml = readFileSync(new URL('../../../index.html', import.meta.url), 'utf8')

test('controller-change reload preserves Shell live-route snapshot', () => {
const reloadHandler = indexHtml.slice(
indexHtml.indexOf('const reloadAfterControllerChange'),
indexHtml.indexOf("navigator.serviceWorker.addEventListener(\n 'controllerchange'"),
)

assert.match(reloadHandler, /if \(!sessionStorage\.getItem\('shell-reload'\)\) \{\s*rememberShellStateForAutoReload\(\)\s*\}/)
assert.ok(
reloadHandler.indexOf("sessionStorage.getItem('shell-reload')")
< reloadHandler.indexOf('rememberShellStateForAutoReload()'),
'the live Shell snapshot must be checked before the localStorage fallback writes',
)
})
73 changes: 65 additions & 8 deletions tests/navigation.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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', {
Expand Down
45 changes: 42 additions & 3 deletions tests/service-surface.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,30 @@ test('shared gateway stays branded until heartbeat, preserves cookies, and rejec
})
await page.addInitScript(() => {
window.__mobiusServiceReadyEvents = []
window.__mobiusAppFrameSandboxes = []
const recordAppFrames = root => {
const frames = root instanceof HTMLIFrameElement
? [root]
: [...(root.querySelectorAll?.('iframe') || [])]
for (const frame of frames) {
if (!frame.getAttribute('src')?.includes('/api/apps/')) continue
window.__mobiusAppFrameSandboxes.push({
src: frame.getAttribute('src'),
sandbox: frame.getAttribute('sandbox') || '',
})
}
}
const observeAppFrames = () => {
if (!document.documentElement) return
const observer = new MutationObserver(records => {
for (const record of records) {
for (const node of record.addedNodes) recordAppFrames(node)
}
})
observer.observe(document.documentElement, { childList: true, subtree: true })
}
if (document.documentElement) observeAppFrames()
else document.addEventListener('DOMContentLoaded', observeAppFrames, { once: true })
window.addEventListener('message', event => {
if (event.data?.type !== 'moebius:service-ready') return
const frame = [...document.querySelectorAll('iframe[src*="/_mobius/surface"]')]
Expand Down Expand Up @@ -200,9 +224,24 @@ test('shared gateway stays branded until heartbeat, preserves cookies, and rejec
// mistaken for a route/interception failure again.
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 })
const appSandbox = (await appFrame.getAttribute('sandbox') || '').split(/\s+/)
// The launcher posts moebius:open-service from its first React effect, so
// its ordinary app iframe can be replaced by the service iframe before a
// locator's next polling interval. Observe insertion instead of requiring
// that deliberately transient launcher to remain mounted.
await expect.poll(
() => page.evaluate(appId => (
window.__mobiusAppFrameSandboxes.some(entry =>
entry.src.includes(`/api/apps/${appId}/frame`)
)
), app.id),
{ timeout: 15_000 },
).toBe(true)
const mountedAppFrame = await page.evaluate(appId => (
window.__mobiusAppFrameSandboxes.find(entry =>
entry.src.includes(`/api/apps/${appId}/frame`)
)
), app.id)
const appSandbox = (mountedAppFrame.sandbox || '').split(/\s+/)
expect(appSandbox).not.toContain('allow-same-origin')
// load fires even for the deliberately blocked document. While the shell
// waits for the absent heartbeat, the frame must remain fully covered.
Expand Down
Loading