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
20 changes: 20 additions & 0 deletions backend/scripts/seed-skills/building-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,26 @@ cleans sessions up on navigation. Never probe or fall back to the blocked
browser API from the opaque frame. The complete contract lives in the platform
`CAPABILITIES.md`.

## Local zoom surfaces

The Möbius shell keeps its toolbar, drawer, chat, and app frame at one stable
page scale on touch devices. An app that genuinely needs zoom—a map, image,
canvas, diagram, or document—owns it **inside that specific surface**:

- put `touch-action: none` on the zoomable content surface, not on the app root;
- use Pointer Events to track a two-pointer distance and transform only the
content inside that surface;
- keep surrounding controls and ordinary reading regions on native pan/tap
behavior;
- provide visible zoom-in, zoom-out, and reset controls (plus keyboard
equivalents where relevant), so pinch is never the only way to enlarge
content.

Do not add `user-scalable=no` or a maximum scale to the app frame, and do not
try to zoom the parent document. The shared frame intentionally leaves that
policy out: each app decides whether it needs a local zoom interaction, while
the shell chrome remains fixed.

---

## Agent-powered mini-apps
Expand Down
5 changes: 4 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<!-- Möbius chrome is a fixed application surface. Browser page zoom would
scale the toolbar and drawer with the active app; zoomable content owns
its gesture locally so it can transform without moving the shell. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<!-- Default theme-color; applyTheme updates this to the active theme's --bg. -->
<meta name="theme-color" content="#0d0d0d" />
<!-- Android/Chrome consults color-scheme before JS/CSS fully applies.
Expand Down
5 changes: 3 additions & 2 deletions frontend/public/app-frame.html
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,9 @@
News report renders in its own iframe, so its body text is unaffected. */
body { -webkit-user-select: none; user-select: none; }
input, textarea, [contenteditable], [contenteditable] * { -webkit-user-select: text; user-select: text; }
/* Keep taps responsive while preserving the browser's native pinch zoom.
Custom gesture surfaces can still opt into touch-action:none locally. */
/* Keep ordinary taps and pans responsive. The parent shell owns the page
viewport; a map, image, canvas, or document that needs zoom opts its own
surface into touch-action:none and transforms only that content. */
html, body { touch-action: manipulation; }
html, body, #root { height: 100%; }
/* The shell keeps the selected app painted behind its drawer scrim, but the
Expand Down
11 changes: 6 additions & 5 deletions frontend/src/components/Shell/Shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,12 @@
/* The rAF-written hold progress [0..1]; the hold ring reads it via the cascade
(builder-mode control below). */
--hold-progress: 0;
/* Suppress the double-tap zoom on the logo while KEEPING vertical scroll and
pinch zoom — only the horizontal swipe is owned here; viewport zoom is never
disabled globally. The callout/selection is suppressed so a press-and-hold
activates builder mode instead of raising a menu (the JS also preventDefaults
contextmenu during a hold). */
/* Suppress double-tap zoom on the logo while keeping this component's gesture
declaration local: vertical pan and pinch remain allowed here, then the
shell root applies the page-level pinch policy once for the whole document.
The callout/selection is suppressed so a press-and-hold activates builder
mode instead of raising a menu (the JS also preventDefaults contextmenu
during a hold). */
touch-action: pan-y pinch-zoom;
-webkit-touch-callout: none;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@ test('the logo keeps the stable "Toggle navigation" name; gesture rides aria-des
assert.match(shellBrand, /builderModeActive \? 'Builder mode' : 'Single screen'/)
})

test('mobile tab bodies pan while preserving pinch zoom and the kind icon owns either-axis dragging', () => {
test('tab gesture rules stay local while the shell root owns page zoom', () => {
assert.match(shellCss, /\.shell__tabstrip\s*\{[\s\S]*?touch-action:\s*pan-x pinch-zoom/)
assert.match(shellCss, /\.shell__tab-open\[data-drag-key\]\s*\{[\s\S]*?touch-action:\s*pan-x pinch-zoom/)
assert.match(shellCss, /\.shell__tab-kind\[data-touch-drag-handle\]\s*\{[\s\S]*?touch-action:\s*none/)
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,13 @@ html {
background-color: var(--bg, #0d0d0d);
}

/* Keep taps responsive while preserving native pinch zoom. Elements that own
a custom gesture (for example the image lightbox) override this locally. */
/* The shell is fixed chrome: pan normally, but never let page-level pinch zoom
scale the toolbar, drawer, chat, and active app as one document. The viewport
meta is the primary lock; this closes the iOS gap where user-scalable=no may
be ignored. Local zoom surfaces still own their gesture with touch-action:none
and transform only their content. */
html, body {
touch-action: manipulation;
touch-action: pan-x pan-y;
}

/* Accessibility: respect prefers-reduced-motion. Collapses all
Expand Down
43 changes: 37 additions & 6 deletions frontend/src/lib/__tests__/shellViewportZoom.test.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,51 @@
/* The top-level Möbius viewport preserves native accessibility zoom. */
/* Browser zoom never scales the Möbius shell chrome; each app owns local zoom. */
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'

const indexHtml = readFileSync(new URL('../../../index.html', import.meta.url), 'utf8')
const indexCss = readFileSync(new URL('../../index.css', import.meta.url), 'utf8')
const appFrameHtml = readFileSync(new URL('../../../public/app-frame.html', import.meta.url), 'utf8')
const buildingApps = readFileSync(
new URL('../../../../backend/scripts/seed-skills/building-apps.md', import.meta.url),
'utf8',
)

test('the shell viewport preserves native pinch zoom', () => {
const viewport = indexHtml.match(/<meta name="viewport" content="([^"]+)"/)?.[1] || ''
function viewportContent(html) {
return html.match(/<meta name="viewport" content="([^"]+)"/)?.[1] || ''
}

test('browser pinch cannot scale the shell chrome and active app together', () => {
const viewport = viewportContent(indexHtml)
assert.match(viewport, /width=device-width/)
assert.match(viewport, /initial-scale=1(?:\.0)?/)
assert.doesNotMatch(viewport, /maximum-scale/)
assert.doesNotMatch(viewport, /user-scalable/)
assert.match(viewport, /maximum-scale=1/)
assert.match(viewport, /user-scalable=no/)
assert.match(viewport, /viewport-fit=cover/)
assert.match(viewport, /interactive-widget=resizes-content/)

const rootTouchRule = indexCss.match(/html,\s*body\s*\{[\s\S]*?\}/)?.[0] || ''
assert.match(rootTouchRule, /touch-action:\s*manipulation/)
assert.match(rootTouchRule, /touch-action:\s*pan-x pan-y/)
assert.doesNotMatch(rootTouchRule, /pinch-zoom|manipulation/)
})

test('the app frame adds no second viewport lock and leaves local zoom to the app', () => {
const viewport = viewportContent(appFrameHtml)
assert.match(viewport, /width=device-width/)
assert.match(viewport, /initial-scale=1(?:\.0)?/)
// The shared app frame must NOT globally forbid zoom — that choice belongs to
// each app's own surface (touch-action:none + transform on its own content).
assert.doesNotMatch(viewport, /user-scalable=no/)
assert.doesNotMatch(viewport, /maximum-scale=1/)

const frameRootTouchRule = appFrameHtml.match(/html,\s*body\s*\{\s*touch-action:[^}]+\}/)?.[0] || ''
assert.doesNotMatch(frameRootTouchRule, /touch-action:\s*pan-x pan-y/)
})

test('app authors are told to zoom content locally, with an accessible control path', () => {
assert.match(buildingApps, /## Local zoom surfaces/)
assert.match(buildingApps, /touch-action: none/)
assert.match(buildingApps, /transform only the\s+content/)
assert.match(buildingApps, /zoom-in, zoom-out, and reset controls/)
assert.match(buildingApps, /Do not add `user-scalable=no`/)
})