Chore(ui): device-side switch for page transitions

`?vt=off` turns the View Transitions off, `?vt=unnamed` keeps them but
leaves the Nav un-named (it slides with the page), `?vt=default`
restores the normal behaviour; the choice lives in localStorage. WebKit
cannot run in the sandbox and the Nav's animations stall on iOS after
a transition, so this is how the trigger gets bisected on the device.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
This commit is contained in:
Claude committed 2026-10-05 15:58:12 +00:00
1 parent 38a61fdf92
commit d6d80dac24
4 files changed
+71 -4

No files matched your search

+2
View File
@@ -192,6 +192,8 @@ Every "business state → color" mapping goes through `use-status-tone.js`
`view-transition-name` (only while one runs); nothing else gets one, and
the animations inside those named elements are restarted when a transition
ends — WebKit leaves them stalled after a capture (`restartAnimations`).
`?vt=off` / `?vt=unnamed` / `?vt=default` is a device-side diagnostic
switch (kept in localStorage) for problems only WebKit shows.
- **Responsive hide** — `.hidden` is `!important` (`style/style.css`), so
`hidden sm:flex` never shows: write `max-sm:hidden`.
- **Drawer vs Sheet** — bottom Drawer only for a full-bleed expansion of an
+2 -2
View File
@@ -284,10 +284,10 @@ footer {
animations inside a captured element are restarted when the transition
ends (utils/page-transition.js, NAMED_ELEMENTS_SELECTOR — keep the
selector and these classes in step). */
html[data-page-transition] .jn-site-nav {
html[data-page-transition]:not([data-page-transition-names="off"]) .jn-site-nav {
view-transition-name: site-nav;
}
html[data-page-transition] .jn-site-status-bar {
html[data-page-transition]:not([data-page-transition-names="off"]) .jn-site-status-bar {
view-transition-name: site-status-bar;
}
::view-transition-old(site-nav),
+27 -1
View File
@@ -52,6 +52,30 @@ export const NAMED_ELEMENTS_SELECTOR = '.jn-site-nav, .jn-site-status-bar';
// Cancel + play recreates each running animation on the compositor; a paused
// or finished one is left as it is. An infinite spin or ping restarting from
// its first frame is not visible.
// Device-side diagnostic switch for a rendering problem that only shows on
// iOS (WebKit cannot run in the sandbox): `?vt=off` turns page transitions
// off, `?vt=unnamed` keeps them but leaves the Nav un-named (it slides with
// the page), `?vt=default` restores the normal behaviour. The choice is kept
// in localStorage so it survives the reloads a test needs.
export const TRANSITION_MODE_KEY = 'jn-page-transitions';
const TRANSITION_MODES = new Set(['off', 'unnamed', 'default']);
export const rememberTransitionMode = (win) => {
let mode = null;
try {
const asked = new URLSearchParams(win?.location?.search ?? '').get('vt');
if (TRANSITION_MODES.has(asked)) {
win.localStorage?.setItem(TRANSITION_MODE_KEY, asked);
mode = asked;
} else {
mode = win?.localStorage?.getItem(TRANSITION_MODE_KEY) ?? null;
}
} catch {
/* storage disabled: the default behaviour */
}
return TRANSITION_MODES.has(mode) ? mode : 'default';
};
export const restartAnimations = (element) => {
const animations = element?.getAnimations?.({ subtree: true }) ?? [];
for (const animation of animations) {
@@ -73,6 +97,8 @@ export const installPageTransitions = (router, {
} = {}) => {
if (!doc?.documentElement) return;
const root = doc.documentElement;
const mode = rememberTransitionMode(win);
if (mode === 'unnamed') root.dataset.pageTransitionNames = 'off';
let active = null; // the ViewTransition until its `finished` settles
let endUpdate = null; // resolves the active transition's update callback
let uaTransition = false; // the pending history step was animated by the browser
@@ -99,7 +125,7 @@ export const installPageTransitions = (router, {
if (inFlight && direction) active.skipTransition?.();
const animate = shouldAnimate({
direction,
supported: typeof doc.startViewTransition === 'function',
supported: mode !== 'off' && typeof doc.startViewTransition === 'function',
reducedMotion: Boolean(win?.matchMedia?.(REDUCED_MOTION_QUERY)?.matches),
inFlight,
uaTransition: viaBrowser,
+40 -1
View File
@@ -7,6 +7,7 @@ import { describe, it } from 'node:test';
import {
transitionDirection,
shouldAnimate,
rememberTransitionMode,
restartAnimations,
installPageTransitions,
} from '../frontend/utils/page-transition.js';
@@ -22,6 +23,34 @@ const fakeAnimated = (states) => {
return { log, getAnimations: () => animations };
};
describe('rememberTransitionMode()', () => {
const fakeWin = (search, stored = null) => {
const store = new Map(stored ? [[ 'jn-page-transitions', stored ]] : []);
return {
location: { search },
localStorage: { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, v) },
store,
};
};
it('defaults without a query or a stored choice', () => {
assert.equal(rememberTransitionMode(fakeWin('')), 'default');
assert.equal(rememberTransitionMode(undefined), 'default');
});
it('stores a valid ?vt= choice and reads it back without the query', () => {
const win = fakeWin('?vt=off');
assert.equal(rememberTransitionMode(win), 'off');
assert.equal(win.store.get('jn-page-transitions'), 'off');
assert.equal(rememberTransitionMode(fakeWin('', 'unnamed')), 'unnamed');
});
it('ignores an unknown value and a stored garbage value', () => {
assert.equal(rememberTransitionMode(fakeWin('?vt=sideways', 'unnamed')), 'unnamed');
assert.equal(rememberTransitionMode(fakeWin('', 'garbage')), 'default');
});
});
describe('restartAnimations()', () => {
it('cancels and replays the running animations, leaves the others alone', () => {
const el = fakeAnimated(['running', 'paused', 'finished', 'running']);
@@ -96,7 +125,7 @@ describe('shouldAnimate()', () => {
// A router stand-in recording the hooks, and a document whose
// startViewTransition runs the update callback on the next task like a browser
// does after snapshotting the old page.
const setup = ({ supported = true, reducedMotion = false, timeoutMs = 1000 } = {}) => {
const setup = ({ supported = true, reducedMotion = false, timeoutMs = 1000, search = '' } = {}) => {
const hooks = { beforeResolve: null, afterEach: null, onError: null };
const router = {
beforeResolve: (fn) => { hooks.beforeResolve = fn; },
@@ -128,6 +157,7 @@ const setup = ({ supported = true, reducedMotion = false, timeoutMs = 1000 } = {
const win = {
matchMedia: () => ({ matches: reducedMotion }),
addEventListener: (type, fn) => { if (type === 'popstate') popstate = fn; },
location: { search },
};
installPageTransitions(router, { doc, win, nextTick: () => Promise.resolve(), timeoutMs });
return { hooks, calls, doc, nav, popstate: (event) => popstate(event) };
@@ -155,6 +185,15 @@ describe('installPageTransitions()', () => {
assert.equal(doc.documentElement.dataset.pageTransition, undefined);
});
it('honours the diagnostic switch: off never animates, unnamed un-names the Nav', () => {
const off = setup({ search: '?vt=off' });
assert.equal(off.hooks.beforeResolve(whois, home), undefined);
assert.equal(off.doc.documentElement.dataset.pageTransitionNames, undefined);
const unnamed = setup({ search: '?vt=unnamed' });
assert.equal(unnamed.doc.documentElement.dataset.pageTransitionNames, 'off');
assert.ok(unnamed.hooks.beforeResolve(whois, home) instanceof Promise);
});
it('restarts the named elements\' animations once the transition has finished', async () => {
const { hooks, calls, nav } = setup();
await hooks.beforeResolve(whois, home);