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
4 changes: 2 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,8 +448,8 @@ native tests, so browser gates are mandatory for every visor change.
- **M0** archive, skeleton, this record, both WIT packages, CI.
- **M1** three realms, one TodoMVC: stub kernel with in-memory `tasks`;
visor strip + settings sheet; frame loader under policy; ports.
Gates: app renders in the opaque frame; strip geometry immobile with
the app mounted; zero network requests from the frame; `jspi: false`;
Gates: app renders in the opaque frame; zero network requests from
the frame; `jspi: false`;
the frame policy's unit tests. (The frame-teardown integration test
waits for a hostile fixture component — M2. The path was exercised
anyway: the policy caught the TodoMVC example's outbound `href`.)
Expand Down
24 changes: 6 additions & 18 deletions e2e/run.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// The M1 gates on real Chromium (docs/design.md "Delivery": "Playwright on
// real Chromium for every claim about pixels or realms", and "M1": "app
// renders in the opaque frame; strip geometry immobile with the app mounted;
// zero network requests from the frame; no JSPI").
// renders in the opaque frame; zero network requests from the frame; no
// JSPI").
//
// The server sets neither COOP nor COEP: a SharedWorker needs none, and
// setting them would make this harness diverge from what a home origin
Expand Down Expand Up @@ -938,7 +938,6 @@ const scenarios: Scenario[] = [
const page = await open(ctx, origin);
await visorReady(page);
const strip = page.locator("#visor-strip");
const before = await strip.boundingBox();

await launchTodoMvc(page);

Expand All @@ -957,14 +956,10 @@ const scenarios: Scenario[] = [
});
check(!reachable, "the app frame's document was reachable from the page");

// Wait for the app to have actually painted before measuring: the
// claim is that a MOUNTED app does not move the strip.
// Wait for the app to have actually painted before measuring.
await page.frameLocator("#app-zone iframe").locator("input").first()
.waitFor({ timeout: 30_000 });

const after = await strip.boundingBox();
eq(after, before, "#visor-strip geometry moved when the app mounted");

const plated = await strip.locator("q").first().textContent();
eq(plated, "TodoMVC", "the strip's left half should plate the app title");
},
Expand Down Expand Up @@ -1011,7 +1006,6 @@ const scenarios: Scenario[] = [
async run(ctx, origin) {
const page = await open(ctx, origin);
await visorReady(page);
const before = await strip(page).boundingBox();

// `apps/hostile` renders one `<script>`, which web/policy.ts's tag
// table refuses. The rejection closes the mutation stream, the frame
Expand All @@ -1025,23 +1019,17 @@ const scenarios: Scenario[] = [
{ timeout: 5_000 },
);

// Settle before measuring, and settle on facts rather than on a
// timeout: the strip's claim is about where it ends up. The drawer is
// no part of that any more — it overlays the app zone instead of
// pushing anything — so the baseline was taken with it open and the
// comparison is made with it open again, and neither is a special
// case. With nothing running the drawer is pinned on the app list, so
// the session ending puts it back there; the notice is read from it.
// Settle on facts rather than on a timeout: with nothing running the
// drawer is pinned on the app list, so the session ending puts it
// back there; the notice is read from it.
const notice = page.locator("#visor-notice");
await notice.getByText("ended", { exact: false }).waitFor({
timeout: 10_000,
});

// The trusted pixels do not move because an app misbehaved.
const after = await strip(page).boundingBox();
check(after !== null, "#visor-strip lost its box");
eq(after!.height, 56, "#visor-strip height");
eq(after, before, "#visor-strip geometry moved when the session ended");

// Framework voice for the reason, the app's own title plated: the
// publisher's text never enters the sentence unquoted.
Expand Down
20 changes: 9 additions & 11 deletions visor/src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,8 @@ pub(crate) const CSS: &str = r#"
--field: oklch(0.88 0 0);
}

/* Fixed on all three axes so no content can push the anchor around, and
the one thing in this tree that is never overlaid: the drawer is
absolutely positioned below it rather than in flow, so opening anything
moves no pixel of the strip (docs/design.md M1: "strip geometry immobile
with the app mounted"). */
/* Fixed height on all three axes so no content can push the anchor
around. */
#visor-strip {
box-sizing: border-box;
height: 56px; min-height: 56px; max-height: 56px;
Expand Down Expand Up @@ -81,12 +78,13 @@ pub(crate) const CSS: &str = r#"
.stack .top, .stack .bottom { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stack .bottom { font-size: 12px; }

/* Everything below the strip is an overlay on the app zone, never a push:
the scrim covers the app, the drawer covers the scrim, and both start
exactly where the strip ends. */
#visor-scrim { position: fixed; inset: 56px 0 0 0; z-index: 1; background: oklch(0 0 0 / 0.25); }
/* The drawer is in normal flow, above the strip: it pushes the strip (and
the app zone under it) down when it opens rather than covering the app.
The scrim covers the app zone behind the strip and drawer alike, fixed
to the viewport since neither the strip nor the drawer moves it. */
#visor-scrim { position: fixed; inset: 0; z-index: 1; background: oklch(0 0 0 / 0.25); }
#visor-drawer {
position: absolute; top: 56px; left: 0; right: 0; z-index: 2;
position: relative; z-index: 2;
box-sizing: border-box;
height: min(60vh, 480px);
overflow: hidden;
Expand Down Expand Up @@ -141,7 +139,7 @@ pub(crate) const CSS: &str = r#"
/* Unsaved changes, over the drawer: the only thing in this tree that takes
the press away from what raised it. */
#visor-confirm {
position: absolute; top: 56px; left: 0; right: 0; z-index: 4;
position: absolute; top: 0; left: 0; right: 0; z-index: 4;
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
padding: 12px;
box-sizing: border-box;
Expand Down
87 changes: 44 additions & 43 deletions visor/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
//! rendered by this one component so there is exactly one tree, and no
//! ordering question about which of them the receiver mounts first.
//!
//! The drawer *overlays* the app zone rather than pushing anything: the
//! strip is the line between trusted pixels and the app, so it never moves,
//! in any drawer state (docs/design.md M1: "strip geometry immobile with
//! the app mounted").
//! The strip is the line between trusted pixels and the app zone, so
//! whatever the visor opens goes on the visor's side of that line — above
//! the strip — and pushes the strip, and the app zone under it, down
//! rather than covering it.
//!
//! The visor holds no state of its own beyond what is on screen right now
//! (docs/design.md "Visor and apps render through stream-dom"): identity,
Expand Down Expand Up @@ -987,16 +987,51 @@ pub(crate) fn Visor() -> Element {
};

rsx! {
// One positioned root, so the drawer can overlay the app zone
// instead of pushing it: the strip is the line between trusted
// pixels and the app, and a line that moves when the visor opens
// something is not a line (docs/design.md M1, "strip geometry
// immobile with the app mounted").
// One positioned root. The drawer is rendered before the strip so
// it sits in normal flow above it: the strip is the line between
// trusted pixels and the app zone, so whatever the visor opens goes
// on the visor's side of that line — above the strip — and pushes
// the strip, and the app zone under it, down rather than covering
// it.
div { id: "visor-root", class: "{root_class}", style: painted,
// The trusted pixels depend on nothing the page provides, so
// the visor ships its own stylesheet as part of its own tree.
style { "{CSS}" }

if let Some(t) = tenant {
// Only when there is something behind the drawer to
// dismiss back to. With nothing running the drawer is
// where the visor rests, and a scrim over an empty app
// zone would be a dismissal to nowhere.
if running {
div { id: "visor-scrim", onclick: move |_| request.call(Action::Close) }
}
div {
id: "visor-drawer",
class: "{drawer_class}",
onanimationend: move |_| {
// `closing` is the whole discriminator: the bridge
// reports no animation name, and the close path
// drops any pane still sliding, so the drawer's own
// animation is the only one that can be ending
// under it while this is true.
if closing() {
closing.set(false);
drawer.set(Drawer::Closed);
}
},
if let Some((from, forward)) = leaving() {
div {
key: "{from:?}",
class: if forward { "pane leave-to-left" } else { "pane leave-to-right" },
onanimationend: move |_| leaving.set(None),
{sheet_for(from, false)}
}
}
div { key: "{t:?}", class: "{entering}", {sheet_for(t, true)} }
}
}

div { id: "visor-strip",
// Left half: what is running. Both halves need a kernel
// that answers, so neither is offered before the seal
Expand Down Expand Up @@ -1058,40 +1093,6 @@ pub(crate) fn Visor() -> Element {
// request, never left standing in the strip.
}

if let Some(t) = tenant {
// Only when there is something behind the drawer to
// dismiss back to. With nothing running the drawer is
// where the visor rests, and a scrim over an empty app
// zone would be a dismissal to nowhere.
if running {
div { id: "visor-scrim", onclick: move |_| request.call(Action::Close) }
}
div {
id: "visor-drawer",
class: "{drawer_class}",
onanimationend: move |_| {
// `closing` is the whole discriminator: the bridge
// reports no animation name, and the close path
// drops any pane still sliding, so the drawer's own
// animation is the only one that can be ending
// under it while this is true.
if closing() {
closing.set(false);
drawer.set(Drawer::Closed);
}
},
if let Some((from, forward)) = leaving() {
div {
key: "{from:?}",
class: if forward { "pane leave-to-left" } else { "pane leave-to-right" },
onanimationend: move |_| leaving.set(None),
{sheet_for(from, false)}
}
}
div { key: "{t:?}", class: "{entering}", {sheet_for(t, true)} }
}
}

// Unsaved changes, over the drawer that holds them. The three
// answers are the three things a user could mean, and none of
// them is "lose it quietly".
Expand Down
4 changes: 1 addition & 3 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@
}
/* The visor sits at the top, where browsers put their own trusted UI,
and out of thumb range on a handheld — app interactions are the
common case. Its geometry must not move when an app mounts
(docs/design.md "M1": "strip geometry immobile with the app
mounted"). */
common case. */
#visor {
flex: 0 0 auto;
}
Expand Down
Loading