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
10 changes: 10 additions & 0 deletions demo/e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import soloErase from "./scenarios/solo-erase.ts";
import soloEphemeral from "./scenarios/solo-ephemeral.ts";
import soloStorage from "./scenarios/solo-storage.ts";
import soloGdrive from "./scenarios/solo-gdrive.ts";
import drawerOverflow from "./scenarios/drawer-overflow.ts";
import soloAccountStorage from "./scenarios/solo-account-storage.ts";
import soloPasskey from "./scenarios/solo-passkey.ts";
import visorReset from "./scenarios/visor-reset.ts";
Expand Down Expand Up @@ -149,6 +150,15 @@ const SCENARIOS: Scenario[] = [
// it). The real popup path is the point: the worker mints PKCE, the
// page only ever opens a window and relays a one-shot code.
soloGdrive,
// THE OPEN DRAWER'S GEOMETRY ON A PHONE. It follows solo-gdrive
// because it drives the same storage sheet, and it needs that sheet's
// ASYNC FILL to be working before its own claims mean anything: what
// it asserts is that the drawer's BOX keeps up with content that
// arrives late, that the assembly leaves a band of app surface
// showing, and that a gesture inside the visor stays inside it. It
// needs no MinIO and no fake Drive — nothing here connects to
// anything, and the connect button is only checked for reachability.
drawerOverflow,
// THE ACCOUNT'S STORAGE RECORD (DRIVE.md, "The account syncs its
// storage config; devices keep their credentials"): the pairing
// scenario's two isolated contexts and the Drive scenario's fake, in
Expand Down
234 changes: 234 additions & 0 deletions demo/e2e/scenarios/drawer-overflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// THE OPEN DRAWER'S GEOMETRY ON A PHONE — the shape a sheet must have
// when it is bigger than the space it was measured into.
//
// Reported from a real phone: the storage sheet came up "half closed",
// with its top out of view, and a drag inside it scrolled the app
// underneath. Both halves are geometry the suite could not see, for two
// different reasons:
//
// * THE STALE MEASURE. The drawer reveals by animating
// `#visor-drawer-inner`'s HEIGHT to a pixel value measured once, at
// the moment the sheet is mounted — and the storage sheet mounts a
// SKELETON, then fills in when `status()` and the stored config come
// back through the worker's port. Measured at 70px (two buttons),
// grown to 525px of content, and never re-measured: the inner is
// `overflow: hidden` with `justify-content: flex-end`, so the
// overflow is clipped from the TOP. The heading, the radios and the
// fields are all simply above the drawer's box. No e2e scenario
// caught it because a clipped element is still clickable through
// `page.evaluate` — which is exactly why the clicks below are REAL
// Playwright clicks: actionability is the claim, not a nuisance to
// be worked around (solo-gdrive.ts used to carry that workaround,
// and it was this bug seen at desk width).
//
// * THE APP-REVEAL BAND. A drawer permitted to grow until it covers
// the last of the app surface makes the visor's whole claim
// uncheckable: a full-screen sheet is indistinguishable from a page
// that has drawn one. `fit`'s budget keeps a band of dimmed app
// visible under the assembly at every size (visor.ts's APP_REVEAL).
//
// * THE CONTAINED GESTURE. A wheel or a drag inside the visor's own
// pixels must not move the page beneath it — one gesture, one
// surface.

import type { Ctx, Scenario } from "../run.ts";
import { act, assert, assertEquals, SOLO_KEYS } from "../util.ts";
import { solo, until, WAITS } from "../solo-util.ts";
import type { Page } from "npm:playwright@1.57.0";

/** A phone, and a short one: the viewport where a sheet of this size
* genuinely does not fit, so the budget and the internal scroll are
* both load-bearing rather than incidentally satisfied. */
const VIEWPORT = { width: 390, height: 664 };
/** The band of app surface the open assembly must leave visible. The
* assertion allows a little less than visor.ts's own APP_REVEAL (48):
* this is a claim about a visible boundary, not about an exact
* constant, and a rounding difference must not read as a regression. */
const REVEAL_FLOOR = 40;

interface Geometry {
vh: number;
innerTop: number;
innerBottom: number;
sheetTop: number;
sheetHeight: number;
sheetScrollHeight: number;
sheetClientHeight: number;
zoneBottom: number;
gdTop: number;
gdBottom: number;
}

function geometry(page: Page): Promise<Geometry> {
return page.evaluate(() => {
const inner = document.getElementById("visor-drawer-inner")!;
const sheet = document.querySelector("#visor-drawer-inner .cred-sheet") as HTMLElement;
const zone = document.getElementById("visor-zone")!;
const gd = document.getElementById("storage-kind-gdrive") as HTMLElement;
const ir = inner.getBoundingClientRect();
const sr = sheet.getBoundingClientRect();
const gr = gd.getBoundingClientRect();
return {
vh: globalThis.innerHeight,
innerTop: ir.top,
innerBottom: ir.bottom,
sheetTop: sr.top,
sheetHeight: sr.height,
sheetScrollHeight: sheet.scrollHeight,
sheetClientHeight: sheet.clientHeight,
zoneBottom: zone.getBoundingClientRect().bottom,
gdTop: gr.top,
gdBottom: gr.bottom,
};
});
}

const scenario: Scenario = {
name: "drawer-overflow",
why:
"an open drawer on a phone shows its TOP, leaves a band of app surface visible, scrolls internally, contains its own gestures, and is really clickable",
page: {
path: "/solo.html",
bootGlobal: "__solo",
viewport: VIEWPORT,
storage: {
[SOLO_KEYS.hue]: "265",
[SOLO_KEYS.identity]: JSON.stringify({ name: "Ada" }),
},
},

async run(page: Page, _ctx: Ctx) {
await act("an account, through the app frame's own entry ceremony", async () => {
// Same order rule as solo-gdrive.ts: the entry ceremony is a
// drawer sheet mounted only at first run, so it is driven BEFORE
// any other sheet has ever opened.
assertEquals(await solo(page, "newAccount"), true, "the entry sheet's button was clicked");
await until([page], "the account", async () => await solo(page, "hasAccount"), WAITS.boot);
});

await act("the storage sheet, filled in from the worker", async () => {
await page.evaluate(() => {
(document.getElementById("visor-settings") as HTMLButtonElement | null)?.click();
});
await page.waitForFunction(
() =>
document.querySelector(
'#visor-drawer-inner .settings-extra-action[data-action="storage"]',
) !== null,
undefined,
{ timeout: 15_000 },
);
await solo(page, "openStorageSheet");
await page.waitForSelector("#storage-kind-gdrive", { timeout: 15_000 });
// THE ASYNC FILL IS THE SUBJECT, so the wait is for the drawer to
// have caught up with it rather than for the fields to exist: the
// regression is precisely a drawer whose box never learned that
// its content grew.
await until([page], "the drawer to finish growing", async () => {
const g = await geometry(page);
return g.innerBottom - g.innerTop >= g.sheetHeight - 1 ? g : false;
}, 20_000);
});

await act("the sheet's TOP is on screen, and so is the gdrive radio", async () => {
const g = await geometry(page);
assert(
g.sheetTop >= -0.5,
`the sheet's top is at ${g.sheetTop.toFixed(1)}px — clipped above the drawer's box, ` +
`which is the drawer sitting half out of view (inner ${
(g.innerBottom - g.innerTop).toFixed(1)
}px vs sheet ${g.sheetHeight.toFixed(1)}px: a stale measure)`,
);
assert(
g.gdTop >= -0.5 && g.gdBottom <= g.vh + 0.5,
`the gdrive radio is at ${g.gdTop.toFixed(1)}..${g.gdBottom.toFixed(1)} — ` +
`outside the ${g.vh}px viewport`,
);
assert(
g.gdTop >= g.innerTop - 0.5 && g.gdBottom <= g.innerBottom + 0.5,
`the gdrive radio is at ${g.gdTop.toFixed(1)}..${g.gdBottom.toFixed(1)} — ` +
`outside the drawer's own visible box ${g.innerTop.toFixed(1)}..${
g.innerBottom.toFixed(1)
}`,
);
});

await act("a band of app surface stays visible under the assembly", async () => {
const g = await geometry(page);
assert(
g.zoneBottom <= g.vh - REVEAL_FLOOR,
`the visor assembly ends at ${g.zoneBottom.toFixed(1)}px of ${g.vh}px — ` +
`less than ${REVEAL_FLOOR}px of app surface is left showing, so the boundary ` +
`between the visor's pixels and the page's is no longer visible`,
);
});

await act("a REAL click reaches the sheet's controls", async () => {
// No `evaluate(...).click()` anywhere in this beat, deliberately:
// Playwright's actionability check is a stand-in for a thumb, and
// an element it refuses is an element a user cannot reach either.
await page.click("#storage-kind-gdrive");
await page.waitForSelector("#storage-gd-root", { state: "visible", timeout: 15_000 });
await page.click("#storage-gd-space-drive");
assertEquals(
await page.evaluate(() =>
(document.getElementById("storage-gd-space-drive") as HTMLInputElement).checked
),
true,
"the space radio took the real click",
);
// TRIAL: the connect button's reachability is the claim, not the
// ceremony behind it (this scenario has no Drive to connect to).
await page.click("#storage-connect", { trial: true });
});

await act("the sheet scrolls ITSELF, and the page underneath does not move", async () => {
const g = await geometry(page);
assert(
g.sheetScrollHeight > g.sheetClientHeight + 1,
`the sheet does not overflow at ${VIEWPORT.width}×${VIEWPORT.height} ` +
`(${g.sheetScrollHeight} vs ${g.sheetClientHeight}) — the internal-scroll claim ` +
`below would be trivially true`,
);
const box = (await page.locator("#visor-drawer-inner .cred-sheet").boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, g.vh / 2));
await page.mouse.wheel(0, 300);
await page.waitForTimeout(400);
const after = await page.evaluate(() => ({
doc: document.scrollingElement?.scrollTop ?? 0,
sheet:
(document.querySelector("#visor-drawer-inner .cred-sheet") as HTMLElement).scrollTop,
}));
assert(after.sheet > 0, `the wheel did not scroll the sheet (scrollTop ${after.sheet})`);
assertEquals(
after.doc,
0,
"the wheel over the sheet scrolled the DOCUMENT — the gesture chained past the visor " +
"into the app surface it is covering",
);
});

await act("a wheel over the dim moves nothing at all", async () => {
const dimUp = await page.evaluate(() => {
const d = document.getElementById("visor-dim");
return d !== null && !d.hidden;
});
assert(dimUp, "the storage sheet is supposed to dim the page behind it");
const before = await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0);
// Low on the screen, which is the app-reveal band itself: the one
// part of the dim no sheet is over.
await page.mouse.move(VIEWPORT.width / 2, VIEWPORT.height - 12);
await page.mouse.wheel(0, 300);
await page.waitForTimeout(400);
const after = await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0);
assertEquals(
after,
before,
"a wheel over the dim scrolled the page — the surface the visor has just declared " +
"out of play moved under the user's gesture",
);
});
},
};

export default scenario;
16 changes: 6 additions & 10 deletions demo/e2e/scenarios/solo-gdrive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,16 +180,12 @@ const scenario: Scenario = {
);
await solo(page, "openStorageSheet");
await page.waitForSelector("#storage-kind-gdrive", { timeout: 15_000 });
// A real click, but through evaluate: the drawer sheet renders
// taller than the viewport at this point (the S3 fields are
// still in the DOM, merely hidden), which Playwright's own
// actionability check reads as "outside the viewport" even
// though the element is visible and clickable to a user who
// has scrolled. `.click()` on the element itself is the same
// DOM event a user's click dispatches.
await page.evaluate(() => {
(document.getElementById("storage-kind-gdrive") as HTMLInputElement).click();
});
// A REAL click. This used to go through `evaluate`, blamed on
// a "Playwright actionability quirk"; it was the drawer bug
// that drawer-overflow.ts now guards — the sheet's content
// grew after it was measured and the drawer clipped it out of
// view, so the radio genuinely was unreachable.
await page.click("#storage-kind-gdrive");
await page.waitForSelector("#storage-gd-root", { state: "visible", timeout: 15_000 });
// THE DEFAULT IS ASSERTED, NOT SET (DRIVE.md §5): the sheet
// must arrive with hidden app data already chosen, so a
Expand Down
14 changes: 14 additions & 0 deletions demo/e2e/scenarios/strip-geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,13 +243,27 @@ const scenario: Scenario = {
await waitForSheet(narrow, "settings", true);
const ok = await narrow.evaluate(() => {
const r = document.getElementById("visor-strip")!.getBoundingClientRect();
const zone = document.getElementById("visor-zone")!.getBoundingClientRect();
return {
onScreen: r.bottom <= globalThis.innerHeight + 1 && r.top >= -1,
// What is left of the app under the whole assembly.
appBand: globalThis.innerHeight - zone.bottom,
overflow: document.documentElement.scrollWidth -
document.documentElement.clientWidth,
};
});
assert(ok.onScreen, "the strip was pushed off-screen by an open sheet at 390");
// AND THE BAND BELOW IT (visor.ts's APP_REVEAL): the anchor being
// on screen is not enough on its own — a sheet allowed to grow
// until the assembly fills the viewport leaves nothing of the app
// showing, and a visor covering everything is indistinguishable
// from a page that has drawn one. 40 rather than the constant's
// 48, so layout rounding cannot read as a regression.
assert(
ok.appBand >= 40,
`an open sheet at 390 left only ${ok.appBand.toFixed(1)}px of app surface below the ` +
`assembly — the boundary between the visor's pixels and the page's stops being visible`,
);
assert(ok.overflow <= 0, `an open sheet overflowed horizontally by ${ok.overflow}px at 390`);
});
},
Expand Down
36 changes: 33 additions & 3 deletions visor/ui/visor.css
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,17 @@
#visor-drawer-inner { height: 0; overflow: hidden;
display: flex; flex-direction: column;
justify-content: flex-end;
/* THE BACKSTOP FOR THE SHEET'S OWN CONTAINMENT.
`overscroll-behavior` only ends a chain at an
element that SCROLLS, and a sheet short enough
to fit does not scroll at all — so a wheel or a
drag on a small sheet chained straight past it
into the document (measured: the page moved
under an open drawer). The inner is a scroll
container by virtue of `overflow: hidden`, and
containing here catches the gesture whether the
sheet scrolls or not. */
overscroll-behavior: contain;
transition: height 700ms cubic-bezier(.2,.8,.2,1); }
@media (prefers-reduced-motion: reduce) {
#visor-drawer-inner { transition: none; }
Expand Down Expand Up @@ -535,9 +546,20 @@
}

/* The dim layer sits BETWEEN the page and the visor assembly: the
zone (30) above, every app surface below. */
zone (30) above, every app surface below.

IT ABSORBS THE GESTURE. The dim is the visor saying "the page is not
what you are talking to right now", and a drag on it that scrolled
the page underneath would contradict that in the most literal way
available — the surface the user was told is out of play moving under
their thumb. `touch-action: none` refuses the pan outright, and
`overflow: hidden` is what makes the dim a scroll container in the
first place — without it `overscroll-behavior` has nothing to end,
and a wheel over the dim chains to the document (measured). */
#visor-dim { position: fixed; inset: 0; z-index: 20;
background: rgba(6,8,16,.62); }
background: rgba(6,8,16,.62);
overflow: hidden;
overscroll-behavior: none; touch-action: none; }
#visor-dim[hidden] { display: none; }

/* --- Mobile geometry fixes (<=480px), visor cluster half only. Each
Expand Down Expand Up @@ -619,9 +641,17 @@
`width:100%` is load-bearing now that the container is a flex
column: `margin:0 auto` is an AUTO cross-axis margin, which
suppresses the default stretch and would size the sheet to its
content instead of to the page's centered column. */
content instead of to the page's centered column.

THE SCROLL STOPS HERE. A drag inside the sheet that ran the sheet to
its end and then carried on into the PAGE would move the app surface
under a sheet the user is reading a secret out of — the two surfaces
answering one gesture, which is precisely the distinction the drawer
is built to keep. `overscroll-behavior: contain` ends the chain at
the sheet for wheel and touch alike. */
.cred-sheet { box-sizing: border-box; width: 100%; max-width: 34em; margin: 0 auto;
max-height: var(--visor-sheet-max, 80dvh); overflow-y: auto;
overscroll-behavior: contain;
flex-shrink: 0;
padding: .8em 1.1em 1.1em; font-size: 13px; line-height: 1.45; }
.cred-sheet h2 { margin: 0 0 .35em; font-size: 15px; font-weight: 600; }
Expand Down
Loading
Loading