diff --git a/e2e/run.ts b/e2e/run.ts index a121dde..9639ff8 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -257,16 +257,22 @@ const appsButton = (page: Page) => page.locator("#visor-app"); /** The strip's right half: who this is, and this device's settings. */ const settingsButton = (page: Page) => page.locator("#visor-self"); -/** Wait for the drawer to hold exactly one pane, done sliding. +/** Wait for the drawer to hold exactly one pane, done sliding and not on its + * way out. * * A tenant switch renders two panes for the length of the slide — the one * arriving still wearing an `enter-` class — and a click into a moving - * target lands wherever the animation had got to. */ + * target lands wherever the animation had got to. A drawer in mid-close is + * one pane and no `enter` class, so "settled" has to exclude `closing` too + * (visor/src/ui.rs `drawer_class`) or this passes on a pane that is + * leaving. */ async function paneSettled(page: Page): Promise { await drawer(page).waitFor({ timeout: 10_000 }); await page.waitForFunction( () => { - const panes = document.querySelectorAll("#visor-drawer .pane"); + const d = document.querySelector("#visor-drawer"); + if (d === null || d.className.includes("closing")) return false; + const panes = d.querySelectorAll(".pane"); return panes.length === 1 && !(panes[0] as HTMLElement).className.includes("enter"); }, @@ -318,6 +324,19 @@ async function launchApp( await page.waitForSelector("#app-zone iframe[sandbox]", { timeout: 30_000, }); + // The frame is not the end of the launch: the visor closes the drawer + // over it (visor/src/ui.rs `apply`, the `(Some(t), None)` arm), and that + // close outlives the frame's arrival — first as an open drawer whose + // `closing` render has not landed yet, then as a mounted `closing` + // drawer, which is `inert` and so hands every press to the scrim + // underneath. Returning inside that window makes the caller press a + // drawer that is on its way out. `Drawer::Closed` unmounts the element, + // so its absence is the visor's own statement that the launch is over. + await page.waitForFunction( + () => document.querySelector("#visor-drawer") === null, + undefined, + { timeout: 10_000 }, + ); } } @@ -2982,9 +3001,88 @@ const scenarios: Scenario[] = [ await visorReady(page); await page.setViewportSize({ width: 390, height: 780 }); await openSettingsSheet(page); - // Both halves of the strip are on screen at once, one pressed and one - // not: the pressed half is lightened by a translucent overlay, so it - // is a different background under the same ink. + // A glyph, saved: with no glyph the circle has no text, and the + // contrast sweep below only measures leaves with some — the circle + // must carry one to be in scope for it at all. Which is the point of + // saving it here: the circle is a medium-dark plate under light ink + // (visor/src/style.rs `#visor-circle`, `--strip-edge`), so the sweep + // has to answer for that pairing at every hue like any other text. + await drawer(page).locator("label").filter({ hasText: /^your glyph$/ }) + .locator("input").fill("A"); + await saveDraft(page); + await page.waitForFunction( + () => document.querySelector("#visor-circle")?.textContent === "A", + ); + const circleStyle = await page.locator("#visor-circle").evaluate( + (el) => { + const s = getComputedStyle(el); + return { + w: s.width, + h: s.height, + fontSize: s.fontSize, + lineHeight: s.lineHeight, + fontWeight: s.fontWeight, + }; + }, + ); + eq( + circleStyle, + { + w: "32px", + h: "32px", + fontSize: "20px", + lineHeight: "20px", + fontWeight: "600", + }, + "the circle's fixed geometry drifted", + ); + // Self is selected right now (the settings sheet is open): a marker + // along its bottom edge and no fill — unlike the app half's own + // pressed dress, and out of the way of the circle beside it. + const selfPressed = await page.locator("#visor-self").evaluate((el) => { + const s = getComputedStyle(el); + return { bg: s.backgroundColor, shadow: s.boxShadow }; + }); + check( + selfPressed.bg === "rgba(0, 0, 0, 0)" || + selfPressed.bg === "transparent", + `self selected must have no fill, got ${selfPressed.bg}`, + ); + // The offsets, not merely "some shadow": an outline on all four sides + // is what this replaced, and it reads as a box around the identity. + check( + selfPressed.shadow.includes("inset") && + /(^|\s)0px -2px 0px(\s|$)/.test( + selfPressed.shadow.replace(" inset", ""), + ), + `self selected must be marked along its bottom edge only, got ${selfPressed.shadow}`, + ); + // The circle itself does not change with selection: same plate + // whether settings is open or not. + const circleSelected = await page.locator("#visor-circle").evaluate( + (el) => getComputedStyle(el).backgroundColor, + ); + await openApps(page); + const circleUnselected = await page.locator("#visor-circle").evaluate( + (el) => getComputedStyle(el).backgroundColor, + ); + eq( + circleSelected, + circleUnselected, + "the identity circle changed when settings opened", + ); + // App selected keeps the existing light fill. + const appPressed = await page.locator("#visor-app").evaluate((el) => + getComputedStyle(el).backgroundColor + ); + check( + appPressed !== "rgba(0, 0, 0, 0)" && appPressed !== "transparent", + `app selected must keep its light fill, got ${appPressed}`, + ); + await openSettingsSheet(page); + // Both halves of the strip are on screen at once, self selected: the + // ink above is unaffected by that, which is what the sweep below + // relies on to speak for both halves alike. const painted = await page.locator("#visor-root").getAttribute("style"); const worst = new Map(); diff --git a/visor/src/style.rs b/visor/src/style.rs index 82891c7..5a2f464 100644 --- a/visor/src/style.rs +++ b/visor/src/style.rs @@ -97,10 +97,10 @@ pub(crate) const CSS: &str = r#" padding: 4px 8px; text-align: left; } #visor-root #visor-self { flex-direction: row-reverse; text-align: right; } -/* `box-shadow: none`: the drawer's pressed dress is an inset outline, which - on a half the width of the strip reads as a stray box. Here the lightening - is the whole of it. */ -#visor-root #visor-app[aria-pressed="true"], #visor-root #visor-self[aria-pressed="true"] { background: oklch(1 0 0 / 0.18); box-shadow: none; } +/* The light fill is reserved for app selection; self is marked underneath + instead, so the identity beside it is never dressed by the selection. */ +#visor-root #visor-app[aria-pressed="true"] { background: oklch(1 0 0 / 0.18); box-shadow: none; } +#visor-root #visor-self[aria-pressed="true"] { background: none; box-shadow: inset 0 -2px 0 var(--strip-edge); } #visor-divider { width: 1px; align-self: stretch; margin: 5px; background: var(--strip-edge); } #visor-app-glyph, #visor-circle { flex: none; @@ -108,8 +108,15 @@ pub(crate) const CSS: &str = r#" } /* The app's mark is the glyph itself, unboxed and large. */ #visor-app-glyph { width: 40px; height: 40px; font-size: 28px; border-radius: 6px; } -/* The plate: a light mark on the saturated strip. */ -#visor-circle { width: 28px; height: 28px; border-radius: 50%; background: var(--plate); color: var(--plate-ink); } +/* Text glyphs take `color`; emoji retain their own artwork regardless. The + plate is a shade of the strip rather than its ink: enough to read the mark + against, not so dark that it becomes the loudest thing on the strip. Fixed + regardless of press state — the circle is who you are, not the selection. */ +#visor-circle { + width: 32px; height: 32px; border-radius: 50%; + font-size: 20px; line-height: 1; font-weight: 600; + background: var(--strip-edge); color: var(--accent-ink); +} #visor-root .stack { flex: 1; min-width: 0; } #visor-root .stack .top, #visor-root .stack .bottom { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #visor-root .stack .bottom { font-size: 12px; } @@ -181,10 +188,13 @@ pub(crate) const CSS: &str = r#" @keyframes visor-leave-left { to { transform: translateX(-100%); } } @keyframes visor-leave-right { to { transform: translateX(100%); } } -/* Movement is decoration; the states it moves between are not. Zeroing the - duration keeps every `animationend` the visor unmounts on firing. */ +/* Movement is decoration; the states it moves between are not. 1ms, not 0s: + a zero-duration animation can finish without ever dispatching the + `animationend` the visor unmounts on (observed 9 runs in 10 on a throttled + CPU, leaving the drawer stuck mid-switch), and 1ms is imperceptible while + still being an animation the browser reports the end of. */ @media (prefers-reduced-motion: reduce) { - #visor-root * { animation-duration: 0s !important; } + #visor-root * { animation-duration: 1ms !important; } } /* Unsaved changes, resting on the strip: the only thing in this tree that