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
110 changes: 104 additions & 6 deletions e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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");
},
Expand Down Expand Up @@ -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 },
);
}
}

Expand Down Expand Up @@ -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<string, number>();
Expand Down
28 changes: 19 additions & 9 deletions visor/src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,19 +97,26 @@ 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;
display: flex; align-items: center; justify-content: center;
}
/* 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; }
Expand Down Expand Up @@ -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
Expand Down
Loading