No theme touches playback, any theme; the header clears the iOS status-bar blur

Every theme's full-window overlays are off while a video is open, the picture
is never filtered or blended, the player's lettering is plain. --blur, as in To
Do and Notes: the header (the top row of the app), dialogs, the full-screen
player's note and an expanded KVM start below iOS 26's scroll-edge blur in a
Home Screen app; zero on desktop and in a Safari tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-24 00:05:04 -07:00
co-authored by Claude Opus 5.5
parent 01e0da9022
commit 1c5cd2b0de
2 changed files with 41 additions and 15 deletions
+22 -8
View File
@@ -67,17 +67,31 @@ if (await page.locator('canvas.vp-sphere').count()) {
} }
await page.screenshot({ path: `${SHOTS}/h1-auto.png` }) await page.screenshot({ path: `${SHOTS}/h1-auto.png` })
// No theme touches playback: under Retro the scanlines are off and the controls keep a plain font. // No theme touches playback: for every theme the stylesheet has rules for, no full-window
// overlay is drawn, the picture isn't filtered, and the player's lettering is plain.
{ {
const was = await page.evaluate(() => document.documentElement.getAttribute('data-theme')) const was = await page.evaluate(() => document.documentElement.getAttribute('data-theme'))
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'retro')) const themes = await page.evaluate(() => {
const r = await page.evaluate(() => { const ids = new Set()
const app = document.querySelector('.app') for (const sh of document.styleSheets) { let rules; try { rules = sh.cssRules } catch { continue }
const over = app ? getComputedStyle(app, '::after').display : 'none' const walk = (rs) => { for (const r of rs) { if (r.cssRules) walk(r.cssRules); for (const m of (r.selectorText ?? '').matchAll(/data-theme="([^"]+)"/g)) ids.add(m[1]) } }
const font = getComputedStyle(document.querySelector('.vp button')).fontFamily walk(rules) }
return { over, font } return [...ids]
}) })
check('Retro leaves the video alone', r.over === 'none' && !/mono|courier/i.test(r.font), JSON.stringify(r)) const bad = []
for (const t of themes) {
const r = await page.evaluate((t) => {
document.documentElement.setAttribute('data-theme', t)
const els = [document.documentElement, document.body, document.querySelector('.app'), document.querySelector('#root')].filter(Boolean)
const overlays = els.flatMap((e) => ['::before', '::after'].map((p) => getComputedStyle(e, p))).filter((c) => c.display !== 'none' && c.content !== 'none' && c.content !== 'normal' && (c.position === 'fixed' || c.position === 'absolute')).length
const pics = [...document.querySelectorAll('.vp video, .vp canvas')].map((e) => getComputedStyle(e).filter).filter((f) => f !== 'none').length
const c = getComputedStyle(document.querySelector('.vp'))
const b = getComputedStyle(document.querySelector('.vp button'))
return { overlays, pics, plain: !/mono|courier/i.test(b.fontFamily) && c.fontWeight === '400' && c.letterSpacing === 'normal' && c.textShadow === 'none' }
}, t)
if (r.overlays || r.pics || !r.plain) bad.push(`${t}: ${JSON.stringify(r)}`)
}
check(`no theme touches the video (${themes.length} themes with their own rules)`, themes.length > 0 && bad.length === 0, bad.join(' / ') || themes.join(', '))
await page.evaluate((t) => t ? document.documentElement.setAttribute('data-theme', t) : document.documentElement.removeAttribute('data-theme'), was) await page.evaluate((t) => t ? document.documentElement.setAttribute('data-theme', t) : document.documentElement.removeAttribute('data-theme'), was)
} }
+19 -7
View File
@@ -5,6 +5,10 @@
--s1: #3987e5; --s2: #c98500; --track: rgba(57, 135, 229, .18); --s1: #3987e5; --s2: #c98500; --track: rgba(57, 135, 229, .18);
--btn-fg: #111; --term-bg: var(--term-bg); --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; --btn-fg: #111; --term-bg: var(--term-bg); --font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--header-h: 44px; --footer-h: 28px; --rail-w: 220px; --tabs-h: 34px; --header-h: 44px; --footer-h: 28px; --rail-w: 220px; --tabs-h: 34px;
/* iOS 26+ draws a soft blur under the status bar of a Home Screen app that reaches ~16pt
below it (hamlet docs/gotchas.md). Whatever starts at the safe area starts below that band
too. Zero where there is no status bar to clear: desktop, a Safari tab. */
--blur: min(env(safe-area-inset-top), 16px);
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; } html, body, #root { height: 100%; margin: 0; }
@@ -30,7 +34,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.svc-list > .error, .cron-list > .error { margin: 8px 12px; } .svc-list > .error, .cron-list > .error { margin: 8px 12px; }
/* layout */ /* layout */
.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); } .app { height: 100%; display: flex; flex-direction: column; padding-top: calc(env(safe-area-inset-top) + var(--blur)); }
.header { height: var(--header-h); display: flex; align-items: center; gap: 12px; padding: 0 12px; background: var(--bg2); border-bottom: 1px solid var(--border); } .header { height: var(--header-h); display: flex; align-items: center; gap: 12px; padding: 0 12px; background: var(--bg2); border-bottom: 1px solid var(--border); }
.brand { font-weight: 700; color: var(--accent); letter-spacing: .5px; } .brand { font-weight: 700; color: var(--accent); letter-spacing: .5px; }
.host { color: var(--muted); font-family: ui-monospace, monospace; font-size: 13px; } .host { color: var(--muted); font-family: ui-monospace, monospace; font-size: 13px; }
@@ -264,7 +268,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
than what is left, and scrolls inside itself. Its parts keep their height: in a column that than what is left, and scrolls inside itself. Its parts keep their height: in a column that
may shrink, anything with its own scrollbar (a preview, a list) is squeezed to a sliver may shrink, anything with its own scrollbar (a preview, a list) is squeezed to a sliver
instead, and the dialog never scrolls at all. */ instead, and the dialog never scrolls at all. */
.modal-back { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); } .modal-back { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; padding: max(16px, calc(env(safe-area-inset-top) + var(--blur))) max(16px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); }
.modal { --modal-bg: var(--bg2); width: min(420px, 100%); max-height: 100%; overflow-y: auto; overscroll-behavior: contain; background: var(--modal-bg); border: 1px solid var(--border); border-radius: 10px; padding: 16px; display: flex; flex-direction: column; gap: 12px; } .modal { --modal-bg: var(--bg2); width: min(420px, 100%); max-height: 100%; overflow-y: auto; overscroll-behavior: contain; background: var(--modal-bg); border: 1px solid var(--border); border-radius: 10px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.modal > * { flex-shrink: 0; } .modal > * { flex-shrink: 0; }
/* a long dialog's buttons (and what went wrong) stay in reach while the rest scrolls */ /* a long dialog's buttons (and what went wrong) stay in reach while the rest scrolls */
@@ -372,6 +376,7 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
on an iPhone the whole window */ on an iPhone the whole window */
.vp { position: relative; flex: 1; min-height: 240px; background: #000; color: #fff; overflow: hidden; user-select: none; -webkit-user-select: none; outline: none; } .vp { position: relative; flex: 1; min-height: 240px; background: #000; color: #fff; overflow: hidden; user-select: none; -webkit-user-select: none; outline: none; }
.vp.max { position: fixed; inset: 0; z-index: 1000; } .vp.max { position: fixed; inset: 0; z-index: 1000; }
.vp.max .vp-note { top: calc(env(safe-area-inset-top) + var(--blur) + 14px); }
.vp-v { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; } .vp-v { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
.vp-hidden { opacity: 0; pointer-events: none; } .vp-hidden { opacity: 0; pointer-events: none; }
.vp-sphere { touch-action: none; cursor: grab; } .vp-sphere { touch-action: none; cursor: grab; }
@@ -957,7 +962,7 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
@media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } } @media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } }
.kvm-none { text-align: center; } .kvm-none p { margin: 4px 0; } .kvm-none { text-align: center; } .kvm-none p { margin: 4px 0; }
/* No true fullscreen for a page element (Safari on iOS): fill the viewport in-page instead. */ /* No true fullscreen for a page element (Safari on iOS): fill the viewport in-page instead. */
.kvm-frame.expanded { position: fixed; inset: 0; z-index: 60; } .kvm-frame.expanded { position: fixed; inset: 0; z-index: 60; padding-top: calc(env(safe-area-inset-top) + var(--blur)); }
.kvm-frame.expanded iframe { height: 100%; } .kvm-frame.expanded iframe { height: 100%; }
.kvm-shrink { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 61; background: var(--bg2); border: 1px solid var(--border); opacity: .75; } .kvm-shrink { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 61; background: var(--bg2); border: 1px solid var(--border); opacity: .75; }
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; } .kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
@@ -972,7 +977,14 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.vp-time.inrow { display: none; } .vp-time.inrow { display: none; }
} }
/* No theme touches playback: the Retro scanlines (a full-window overlay) are off while a video
is open, and the player keeps one plain font whatever the theme's. */ /* No theme touches playback, whatever it draws. A theme's full-window overlays (the Retro
[data-theme] .app:has(.vp)::after { display: none; } scanlines today, any other tomorrow) are off while a player is open; the picture is never
.vp, .vp button, .vp input { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } filtered or blended; the player's text is plain, not the theme's lettering. Only the accent
colour of its controls follows the theme. */
html:has(.vp)::before, html:has(.vp)::after, body:has(.vp)::before, body:has(.vp)::after,
.app:has(.vp)::before, .app:has(.vp)::after, #root:has(.vp)::before, #root:has(.vp)::after { display: none !important; }
.vp { isolation: isolate; filter: none !important; mix-blend-mode: normal !important; }
.vp video, .vp canvas { filter: none !important; mix-blend-mode: normal !important; }
.vp { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-weight: 400; letter-spacing: normal; text-shadow: none; }
.vp button, .vp input, .vp select { font-family: inherit !important; }