From ab02a1ccaecde193853652145a69d07151b30319 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 7 Sep 2026 19:31:25 -0400 Subject: [PATCH] Visor: hued oklch palette, two-half strip, overlay drawer with drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip becomes two halves — the running app on the left (glyph square, plated title, petname) and the user/device on the right (petname, device petname, glyph circle) — around a centre divider. Every colour derives from `--hue`, set once on `#visor-root` in the open arm only, at the specified oklch lightness/chroma for strip, drawer and accent; the unclaimed dress is the same palette at zero chroma. The drawer now overlays the app zone below the strip instead of pushing it, so the strip never moves in any drawer state. It has a fixed height, scroll shadows on overflow, animates open/closed, slides sheets horizontally when switching while open, and is pinned open on the app list while nothing runs. A scrim closes it when a session runs. Settings and the new AppInfo sheet edit a draft (hue previews live); Save writes to the kernel, Revert discards, and leaving a dirty sheet raises a save/revert/cancel confirm. New kernel `device.meta`/`set-meta` with `meta-scope = user | device | app(id)`: free-form string maps in the sealed checkpoint for user-chosen labels (petname, glyph) while the field set is still in flux. --- e2e/run.ts | 230 ++++- runtime/component/src/component.rs | 26 + runtime/crates/kernel/src/device.rs | 22 + runtime/crates/kernel/src/lib.rs | 42 +- runtime/crates/kernel/tests/kernel.rs | 58 +- runtime/wit/internal.wit | 10 + visor/src/kernel.rs | 35 + visor/src/state.rs | 121 ++- visor/src/style.rs | 215 ++++- visor/src/ui.rs | 1157 ++++++++++++++++++------- web/worker.ts | 3 + 11 files changed, 1498 insertions(+), 421 deletions(-) diff --git a/e2e/run.ts b/e2e/run.ts index 6f158dd8..58dfecaa 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -221,20 +221,56 @@ async function open(ctx: BrowserContext, origin: string): Promise { // The visor, as these scenarios drive it // // Every selector and every label below is the visor's own tree (visor/src/ -// ui.rs): `#visor-strip` with `.unclaimed` until the device is open, -// `#visor-circle` carrying the hue and nothing else ever painting it, -// `#visor-drawer` holding one tenant at a time, `.sheet` / `.sheet-error` / -// `.app-row` / `.device-row`. They live in one block so a visor rename is one -// edit here rather than six. +// ui.rs): `#visor-root` carrying `.unclaimed` until the device is open and +// the `--hue` inline style when it is (nothing else ever paints it), +// `#visor-strip` with its two halves `#visor-app` and `#visor-self`, +// `#visor-drawer` holding one `.pane` per tenant, `.sheet` / `.sheet-error` +// / `.app-row` / `.device-row`. They live in one block so a visor rename is +// one edit here rather than six. +// +// The drawer is no longer a toggle: with nothing running it is pinned open +// on the app list, so "close" means "back to the app list" and pressing the +// half whose sheet is already showing does nothing at all. // --------------------------------------------------------------------------- const strip = (page: Page) => page.locator("#visor-strip"); const drawer = (page: Page) => page.locator("#visor-drawer"); +/** The strip's left half: what is running (the app list, or the running + * app's own sheet). */ +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"); -/** Press one of the strip's tenant buttons and wait for the drawer. */ -async function openTenant(page: Page, label: string): Promise { - await strip(page).getByRole("button", { name: label, exact: true }).click(); +/** Wait for the drawer to hold exactly one pane, done sliding. + * + * 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. */ +async function paneSettled(page: Page): Promise { await drawer(page).waitFor({ timeout: 10_000 }); + await page.waitForFunction( + () => { + const panes = document.querySelectorAll("#visor-drawer .pane"); + return panes.length === 1 && + !(panes[0] as HTMLElement).className.includes("enter"); + }, + undefined, + { timeout: 10_000 }, + ); +} + +/** Press the strip's left half and wait for the pane it raises. With + * nothing running that is the app list; with a session it is that session's + * own sheet. */ +async function openApps(page: Page): Promise { + await appsButton(page).click(); + await paneSettled(page); +} + +/** Press the strip's right half: the settings sheet. */ +async function openSettingsSheet(page: Page): Promise { + await settingsButton(page).click(); + await paneSettled(page); } /** The `.sheet` whose head says `head` — the drawer stacks several. */ @@ -248,14 +284,16 @@ function sheet(page: Page, head: string | RegExp) { * `awaitFrame` is false for an app that is expected to be refused: the * hostile fixture's frame can be torn down before a `waitForSelector` on it * ever polls, and "the frame existed for a moment" is not part of any claim - * — the claim is what the strip says afterwards. + * — the claim is what the visor says afterwards. */ async function launchApp( page: Page, title: string, awaitFrame = true, ): Promise { - await openTenant(page, "Apps"); + // With nothing running the app list is already what the drawer is + // showing, so this press is usually a no-op — which is the point. + await openApps(page); const row = drawer(page).locator(".app-row").filter({ hasText: title }) .first(); await row.waitFor({ timeout: 10_000 }); @@ -271,14 +309,39 @@ async function launchTodoMvc(page: Page): Promise { await launchApp(page, "TodoMVC"); } -/** Settings → the device's user-voice name. */ +/** + * Press `Save` in the settings sheet and wait for the draft to be clean. + * + * The button disables itself exactly when `draft == seed`, and the seed + * only catches up once every kernel call the save made has come back + * (visor/src/ui.rs `save_draft`) — so this is the one observable that says + * the device, and not merely the screen, has the new value. Reloading + * without it races the checkpoint. + */ +async function saveDraft(page: Page): Promise { + await drawer(page).getByRole("button", { name: "Save", exact: true }) + .click(); + await page.waitForFunction( + () => { + const save = Array.from( + document.querySelectorAll("#visor-drawer button"), + ).find((b) => b.textContent === "Save") as HTMLButtonElement | undefined; + return save !== undefined && save.disabled; + }, + undefined, + { timeout: 15_000 }, + ); +} + +/** Settings → the device's user-voice petname. Typed into the draft, and + * `Save` is the only thing the kernel hears. */ async function setDeviceName(page: Page, name: string): Promise { - await openTenant(page, "Settings"); - const field = drawer(page).locator("label").filter({ hasText: /^name$/ }) - .locator("input"); + await openSettingsSheet(page); + const field = drawer(page).locator("label").filter({ + hasText: /^device petname$/, + }).locator("input"); await field.fill(name); - // The visor writes on `change`, not on every keystroke. - await field.blur(); + await saveDraft(page); } /** @@ -292,7 +355,7 @@ async function keepDevice( petname: string, passphrase?: string, ): Promise { - if (await drawer(page).count() === 0) await openTenant(page, "Settings"); + await openSettingsSheet(page); const keep = sheet(page, "Keep this device"); await keep.waitFor({ timeout: 10_000 }); await keep.locator("input[type=text]").fill(petname); @@ -309,15 +372,16 @@ async function keepDevice( await sheet(page, "kept as").waitFor({ timeout: 15_000 }); } -/** Is the strip painted with an identity? `.unclaimed` is the whole dress: - * one class, and `#visor-circle` gets a background only in the open arm. */ +/** Is the visor painted with an identity? One class and one inline + * variable, both on `#visor-root`: `--hue` is emitted by the open arm alone + * and every colour in the stylesheet is a function of it. */ async function claimed(page: Page): Promise { - const cls = await strip(page).getAttribute("class") ?? ""; - const style = await page.locator("#visor-circle").getAttribute("style") ?? ""; - const painted = style.includes("hsl("); - if (cls.includes("unclaimed") && painted) { + const root = page.locator("#visor-root"); + const cls = await root.getAttribute("class") ?? ""; + const style = await root.getAttribute("style") ?? ""; + if (cls.includes("unclaimed") && style.includes("--hue")) { throw new Failure( - "the strip is unclaimed and yet the anchor colour is painted — " + + "the visor is unclaimed and yet the anchor colour is painted — " + 'docs/design.md "Devices" forbids exactly that', ); } @@ -346,13 +410,10 @@ async function claimed(page: Page): Promise { const devicesSheet = (page: Page) => sheet(page, "Devices"); -const settingsButton = (page: Page) => - strip(page).getByRole("button", { name: "Settings", exact: true }); - /** Settings open, showing the Devices section. */ async function openSettings(page: Page): Promise { if (await devicesSheet(page).count() > 0) return; - await settingsButton(page).click(); + await openSettingsSheet(page); await devicesSheet(page).waitFor({ timeout: 10_000 }); } @@ -596,9 +657,11 @@ async function connectDrive(page: Page): Promise { } await new Promise((r) => setTimeout(r, 500)); // Re-read: the ceremony completes in the kernel, and this world has no - // timer. Closing and reopening Settings is the press that reads. - await settingsButton(page).click(); - await settingsButton(page).click(); + // timer. The press that opens Settings is the read — and the drawer is + // not a toggle any more, so leaving it and coming back is what makes + // that press happen again. + await openApps(page); + await openSettings(page); } } @@ -773,8 +836,11 @@ const scenarios: Scenario[] = [ check(box !== null, "#visor-strip has no box"); eq(box!.height, 56, "#visor-strip height"); // The strip says "waking" until `device.status` answers over the - // worker port; the placeholder is the first kernel-backed pixel. - await strip.getByText("this device").waitFor({ timeout: 10_000 }); + // worker port; the placeholder is the first kernel-backed pixel, and + // it is the right half — the one that speaks for this device. + await page.locator("#visor-self").getByText("this device").waitFor({ + timeout: 10_000, + }); }, }, @@ -900,7 +966,7 @@ const scenarios: Scenario[] = [ 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 context should plate the app title"); + eq(plated, "TodoMVC", "the strip's left half should plate the app title"); }, }, @@ -960,16 +1026,16 @@ const scenarios: Scenario[] = [ ); // Settle before measuring, and settle on facts rather than on a - // timeout: the strip's claim is about where it ends up, and launching - // went through the Apps drawer, which is part of the visor's own tree - // and legitimately moves the strip while it is open. Comparing a - // drawer-open frame against a drawer-closed baseline would fail for a - // reason that has nothing to do with the app. - const context = page.locator("#visor-context"); - await context.getByText("ended", { exact: false }).waitFor({ + // 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. + const notice = page.locator("#visor-notice"); + await notice.getByText("ended", { exact: false }).waitFor({ timeout: 10_000, }); - await drawer(page).waitFor({ state: "detached", timeout: 10_000 }); // The trusted pixels do not move because an app misbehaved. const after = await strip(page).boundingBox(); @@ -980,9 +1046,9 @@ const scenarios: Scenario[] = [ // Framework voice for the reason, the app's own title plated: the // publisher's text never enters the sentence unquoted. eq( - await context.locator("q").first().textContent(), + await notice.locator("q").first().textContent(), "Hostile fixture", - "the strip should plate the ended app's title", + "the notice should plate the ended app's title", ); }, }, @@ -1079,7 +1145,7 @@ const scenarios: Scenario[] = [ await visorReady(page); await strip(page).getByText("this device").waitFor({ timeout: 15_000 }); - await openTenant(page, "Settings"); + await openSettingsSheet(page); await drawer(page).getByRole("button", { name: "Other devices" }).click(); const rows = drawer(page).locator(".device-row"); await drawer(page).getByRole("button", { name: "Start fresh here" }) @@ -1451,6 +1517,80 @@ const scenarios: Scenario[] = [ }, }, + { + // Unsaved changes are the user's, and the visor is the only thing that + // holds them: a field typed into the settings sheet reaches the kernel + // on `Save` and nowhere else, and a transition away from a dirty sheet + // asks rather than dropping it (visor/src/ui.rs `Draft`). + name: "visor-drafts", + async run(ctx, origin) { + const page = await open(ctx, origin); + await visorReady(page); + await openSettingsSheet(page); + const field = drawer(page).locator("label").filter({ + hasText: /^device petname$/, + }).locator("input"); + const confirm = page.locator("#visor-confirm"); + + await field.fill("half typed"); + + // Leaving a dirty sheet asks. Cancel means "I was not done": the + // sheet stays, and so does every character of it. + await appsButton(page).click(); + await confirm.waitFor({ timeout: 10_000 }); + await confirm.getByRole("button", { name: "Cancel", exact: true }) + .click(); + await confirm.waitFor({ state: "detached", timeout: 10_000 }); + eq(await field.inputValue(), "half typed", "Cancel dropped the draft"); + + // Revert means "throw it away and go": the transition happens, and + // the sheet goes back to what the kernel last said — which for a + // device nobody has named is nothing. + await appsButton(page).click(); + await confirm.waitFor({ timeout: 10_000 }); + await confirm.getByRole("button", { name: "Revert", exact: true }) + .click(); + await paneSettled(page); + check( + await drawer(page).locator(".app-row").count() > 0, + "Revert did not go on to the transition it was asked about", + ); + await openSettingsSheet(page); + eq(await field.inputValue(), "", "Revert kept the abandoned draft"); + + // Saved, and it is the kernel that remembers it: a reload has no + // draft at all, and reads the name back off the device. + await field.fill("the workbench"); + await saveDraft(page); + await page.reload(); + await visorReady(page); + await strip(page).getByText("the workbench").waitFor({ timeout: 15_000 }); + await openSettingsSheet(page); + eq( + await field.inputValue(), + "the workbench", + "the saved device petname did not survive a reload", + ); + + // The user's own labels ride in the same draft and land on the strip: + // the petname in the right half's top line, and the glyph — of which + // only the first character is ever drawn — in the circle. + await drawer(page).locator("label").filter({ hasText: /^your petname$/ }) + .locator("input").fill("ada"); + await drawer(page).locator("label").filter({ hasText: /^your glyph$/ }) + .locator("input").fill("🜁x"); + await saveDraft(page); + await page.waitForFunction( + () => document.querySelector("#visor-circle")?.textContent === "🜁", + undefined, + { timeout: 10_000 }, + ); + await page.locator("#visor-self").getByText("ada").waitFor({ + timeout: 10_000, + }); + }, + }, + { // Both realms on this side, named: the visor on the main thread and the // runtime in the SharedWorker. The worker is worth spelling out — a diff --git a/runtime/component/src/component.rs b/runtime/component/src/component.rs index fc1f038f..1d759a92 100644 --- a/runtime/component/src/component.rs +++ b/runtime/component/src/component.rs @@ -510,6 +510,32 @@ impl guest::device::Guest for Component { async fn reroll_word() -> Result { kernel()?.reroll_word().await.map_err(map_error) } + async fn meta(scope: guest::device::MetaScope) -> Result, Error> { + let scope = match scope { + guest::device::MetaScope::User => polyvisor_kernel::MetaScope::User, + guest::device::MetaScope::Device => polyvisor_kernel::MetaScope::Device, + guest::device::MetaScope::App(id) => polyvisor_kernel::MetaScope::App(id), + }; + Ok(kernel()? + .meta(scope) + .map_err(map_error)? + .into_iter() + .collect()) + } + async fn set_meta( + scope: guest::device::MetaScope, + entries: Vec<(String, String)>, + ) -> Result<(), Error> { + let scope = match scope { + guest::device::MetaScope::User => polyvisor_kernel::MetaScope::User, + guest::device::MetaScope::Device => polyvisor_kernel::MetaScope::Device, + guest::device::MetaScope::App(id) => polyvisor_kernel::MetaScope::App(id), + }; + kernel()? + .set_meta(scope, entries.into_iter().collect()) + .await + .map_err(map_error) + } async fn keep(petname: String, passphrase: Option) -> Result<(), Error> { kernel()?.keep(petname, passphrase).await.map_err(map_error) } diff --git a/runtime/crates/kernel/src/device.rs b/runtime/crates/kernel/src/device.rs index 29ca0327..055e9738 100644 --- a/runtime/crates/kernel/src/device.rs +++ b/runtime/crates/kernel/src/device.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::sync::LazyLock; use crate::{Error, ErrorCode}; @@ -126,6 +127,26 @@ pub struct Device { pub name: String, pub hue: u16, pub word: String, + /// The user's own labels — petname, glyph, whatever the visor settles on + /// (internal.wit `meta-scope`). Absent from checkpoints written before + /// this field existed; `serde(default)` keeps those loading. + #[serde(default)] + pub meta: Meta, +} + +/// One map per `MetaScope`; see internal.wit `meta-scope`. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Meta { + pub user: BTreeMap, + pub device: BTreeMap, + pub app: BTreeMap>, +} + +/// Which map `meta`/`set_meta` reads or replaces. +pub enum MetaScope { + User, + Device, + App(String), } impl Device { @@ -143,6 +164,7 @@ impl Device { name: String::new(), hue: (draw(rng) % 360) as u16, word: word_at(draw(rng)), + meta: Meta::default(), } } diff --git a/runtime/crates/kernel/src/lib.rs b/runtime/crates/kernel/src/lib.rs index f2634ff9..c513d44b 100644 --- a/runtime/crates/kernel/src/lib.rs +++ b/runtime/crates/kernel/src/lib.rs @@ -24,7 +24,7 @@ mod store; mod sync; pub use apps::{AppInfo, AssetInfo, ComponentArtifacts}; -pub use device::{DeviceStatus, IndexRow, Rest, State, Tier}; +pub use device::{DeviceStatus, IndexRow, MetaScope, Rest, State, Tier}; pub use drive::{Binding, HttpResponse}; pub use events::Event; pub use pairing::Phase; @@ -555,6 +555,46 @@ impl Kernel { self.checkpoint().await } + /// internal.wit `device.meta`: an unknown app id answers an empty map + /// rather than an error — the visor asks before it knows whether the app + /// has ever set anything. + pub fn meta(&self, scope: MetaScope) -> Result, Error> { + self.open()?; + let state = self.state.borrow(); + let device = state.device.as_ref().expect("open implies a device"); + Ok(match scope { + MetaScope::User => device.meta.user.clone(), + MetaScope::Device => device.meta.device.clone(), + MetaScope::App(id) => device.meta.app.get(&id).cloned().unwrap_or_default(), + }) + } + + /// internal.wit `device.set-meta`: replaces the whole map for `scope`. + /// An empty map for an app scope removes that app's entry rather than + /// leaving an empty one behind. + pub async fn set_meta( + &self, + scope: MetaScope, + meta: BTreeMap, + ) -> Result<(), Error> { + self.open()?; + self.with_device(|d| { + match scope { + MetaScope::User => d.meta.user = meta, + MetaScope::Device => d.meta.device = meta, + MetaScope::App(id) => { + if meta.is_empty() { + d.meta.app.remove(&id); + } else { + d.meta.app.insert(id, meta); + } + } + } + Ok(()) + })?; + self.checkpoint().await + } + pub async fn reroll_word(&self) -> Result { self.open()?; let word = { diff --git a/runtime/crates/kernel/tests/kernel.rs b/runtime/crates/kernel/tests/kernel.rs index dd6c5a8c..c80e4386 100644 --- a/runtime/crates/kernel/tests/kernel.rs +++ b/runtime/crates/kernel/tests/kernel.rs @@ -13,8 +13,8 @@ use futures::stream::StreamExt as _; use futures::{executor::LocalPool, task::LocalSpawnExt as _}; use polyvisor_kernel::{ Accepted, BootConfig, Bound, Clock, Dialed, EngineTransport, Error, ErrorCode, Event, Fetch, - Files, HttpResponse, IndexRow, Kernel, LEASE_TTL_MS, LocalFuture, Locks, Net, NetHandle, Phase, - Platform, Rest, Rng, Seams, Spawn, State, Tier, + Files, HttpResponse, IndexRow, Kernel, LEASE_TTL_MS, LocalFuture, Locks, MetaScope, Net, + NetHandle, Phase, Platform, Rest, Rng, Seams, Spawn, State, Tier, }; // -- harness ----------------------------------------------------------------- @@ -1427,6 +1427,60 @@ fn name_and_hue_persist_across_a_reload_and_an_out_of_range_hue_is_refused() { assert_eq!(reread.hue, 200); } +#[test] +fn meta_persists_across_a_reload_and_is_unavailable_while_sealed() { + let world = World::default(); + let kernel = world.boot(); + block_on(kernel.set_meta( + MetaScope::User, + BTreeMap::from([("petname".into(), "Lann".into())]), + )) + .unwrap(); + block_on(kernel.set_meta( + MetaScope::App("app-1".into()), + BTreeMap::from([("glyph".into(), "L".into())]), + )) + .unwrap(); + assert_eq!( + kernel.meta(MetaScope::User).unwrap(), + BTreeMap::from([("petname".into(), "Lann".into())]) + ); + assert_eq!( + kernel.meta(MetaScope::App("app-1".into())).unwrap(), + BTreeMap::from([("glyph".into(), "L".into())]) + ); + // An app with no meta ever set answers empty, not an error. + assert_eq!( + kernel.meta(MetaScope::App("unknown".into())).unwrap(), + BTreeMap::new() + ); + block_on(kernel.keep("desk".into(), Some("open sesame".into()))).unwrap(); + + let reread = world.boot(); + assert_eq!( + reread.device_status().unwrap().state, + State::Sealed, + "a durable device under a passphrase boots sealed" + ); + assert_eq!( + reread.meta(MetaScope::User).unwrap_err().code, + ErrorCode::Unavailable, + "meta rides in the sealed checkpoint: unavailable while sealed" + ); + assert_eq!( + block_on(reread.set_meta(MetaScope::Device, BTreeMap::new())) + .unwrap_err() + .code, + ErrorCode::Unavailable + ); + block_on(reread.unseal("open sesame".into())).unwrap(); + assert_eq!( + reread.meta(MetaScope::User).unwrap(), + BTreeMap::from([("petname".into(), "Lann".into())]), + "meta persisted across the reload, readable once unsealed" + ); +} + #[test] fn tasks_survive_a_reload_but_sessions_do_not() { let world = World::default(); diff --git a/runtime/wit/internal.wit b/runtime/wit/internal.wit index 50886232..04c23128 100644 --- a/runtime/wit/internal.wit +++ b/runtime/wit/internal.wit @@ -193,6 +193,16 @@ interface device { set-hue: async func(hue: u16) -> result<_, error>; reroll-word: async func() -> result; + /// The user's own labels for themself, this device, and each installed + /// app — petname, glyph, whatever the visor settles on. Free-form while + /// the field set is in flux: keys are the visor's vocabulary, values are + /// user voice. Personal: rides in the sealed checkpoint with name/hue/ + /// word, so `unavailable` while sealed. + variant meta-scope { user, device, app(string) } + meta: async func(scope: meta-scope) -> result>, error>; + /// Replaces the whole map for `scope`. + set-meta: async func(scope: meta-scope, entries: list>) -> result<_, error>; + /// Promote to durable. `passphrase = none` is `rests-open`. Idempotent /// on the petname; changing the rest of a kept device is `refused` /// (reseal is a later milestone). diff --git a/visor/src/kernel.rs b/visor/src/kernel.rs index 31b2519e..fe2dcf76 100644 --- a/visor/src/kernel.rs +++ b/visor/src/kernel.rs @@ -256,6 +256,41 @@ pub(crate) async fn reroll_word() -> Result { api::device::reroll_word().await.map_err(message) } +/// Which map `meta`/`set_meta` reads or replaces (internal.wit `meta-scope`). +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum MetaScope { + User, + Device, + App(String), +} + +fn meta_scope(scope: MetaScope) -> api::device::MetaScope { + match scope { + MetaScope::User => api::device::MetaScope::User, + MetaScope::Device => api::device::MetaScope::Device, + MetaScope::App(id) => api::device::MetaScope::App(id), + } +} + +pub(crate) type Meta = std::collections::BTreeMap; + +/// The user's own labels for themself, this device, and one app +/// (internal.wit `device.meta`). +pub(crate) async fn meta(scope: MetaScope) -> Result { + Ok(api::device::meta(meta_scope(scope)) + .await + .map_err(message)? + .into_iter() + .collect()) +} + +/// Replaces the whole map for `scope` (internal.wit `device.set-meta`). +pub(crate) async fn set_meta(scope: MetaScope, meta: Meta) -> Result<(), String> { + api::device::set_meta(meta_scope(scope), meta.into_iter().collect()) + .await + .map_err(message) +} + pub(crate) async fn installed() -> Result, String> { Ok(api::apps::installed() .await diff --git a/visor/src/state.rs b/visor/src/state.rs index ec76faa1..e7c3370d 100644 --- a/visor/src/state.rs +++ b/visor/src/state.rs @@ -2,17 +2,35 @@ //! only stateful thing in the visor's chrome is testable natively. /// What the drawer is showing when it is open. A closed set, so this is an -/// enum and not an abstraction. `Apps`/`Settings` are the strip's own -/// buttons; `Unseal` and `Devices` are ceremonies the boot may raise on its -/// own, and `Devices` is additionally reachable from `Settings`. +/// enum and not an abstraction. `Apps`/`AppInfo` are what the strip's left +/// half raises and `Settings` what its right half does; `Unseal` and +/// `Devices` are ceremonies the boot may raise on its own, and `Devices` is +/// additionally reachable from `Settings`. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum Tenant { Apps, + AppInfo, Settings, Unseal, Devices, } +impl Tenant { + /// Where this tenant sits on the one axis the drawer slides along, so a + /// switch has a direction: a sheet reached from the strip's left half + /// enters from the left of one reached from its right half, and + /// "Other devices" — reached from Settings — enters from the right of + /// it. Ties (`Apps`/`AppInfo`, which are the same half) slide the same + /// way as any other rightward move; only the sign is read. + pub(crate) fn ordinal(self) -> u8 { + match self { + Tenant::Apps | Tenant::AppInfo => 0, + Tenant::Settings | Tenant::Unseal => 1, + Tenant::Devices => 2, + } + } +} + /// `device.state` from internal.wit, as a plain value: the reducer decides /// what the drawer does at boot, and that decision must be testable without /// the component bindings (which exist only on the wasm target). @@ -46,6 +64,10 @@ pub(crate) enum Rest { /// brand new on an origin that already holds a kept one is far more likely /// to be a reload that lost its anchor than a deliberate second device. /// +/// `Closed` here means "no ceremony to raise", not "show nothing": the +/// caller runs the result through [`Drawer::reduce`] with the pinned flag, +/// which at boot — nothing is running yet — rests it on the app list. +/// // CONTRACT: the dispatch spells the "brand new" test two ways ("state is // fresh" and "our status.tier == ephemeral && status.petname == \"\""). The // conservative reading is the conjunction — all three must hold — so a @@ -190,22 +212,31 @@ pub(crate) enum Drawer { #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum Action { - /// A strip button for a tenant was pressed. - Toggle(Tenant), - /// Something happened that the drawer must get out of the way of — - /// a session opening, or closing, or a seal opening. + /// A half of the strip was pressed, or a sheet sent the user on. Never + /// closes: showing what is already shown is identity, so the drawer is + /// not a toggle any more and a press can never leave the user looking + /// at nothing. + Show(Tenant), + /// Something happened that the drawer must get out of the way of — a + /// session opening, or closing, a seal opening, or the scrim pressed. Close, } impl Drawer { - pub(crate) fn reduce(self, action: Action) -> Drawer { + /// `pinned` is "nothing is running": with no app on screen there is + /// nothing for the drawer to be in the way of, so the app list is what + /// the visor rests at and `Closed` is not a state it can reach. With a + /// session running the app owns the screen and `Close` means it. + pub(crate) fn reduce(self, action: Action, pinned: bool) -> Drawer { + let rest = if pinned { + Drawer::Open(Tenant::Apps) + } else { + Drawer::Closed + }; match action { - // The same button again closes: a strip button is a toggle, - // never a one-way trip, so the strip is always one press from - // showing nothing but itself. - Action::Toggle(t) if self == Drawer::Open(t) => Drawer::Closed, - Action::Toggle(t) => Drawer::Open(t), - Action::Close => Drawer::Closed, + Action::Show(t) if self == Drawer::Open(t) => self, + Action::Show(t) => Drawer::Open(t), + Action::Close => rest, } } @@ -221,35 +252,70 @@ impl Drawer { mod tests { use super::*; + /// Showing what is already shown is identity: a press on the half of + /// the strip whose sheet is open must not shut it, or the drawer would + /// flicker every time a user pressed the thing they were reading. #[test] - fn same_tenant_twice_closes() { - let d = Drawer::default().reduce(Action::Toggle(Tenant::Apps)); - assert_eq!(d, Drawer::Open(Tenant::Apps)); - assert_eq!(d.reduce(Action::Toggle(Tenant::Apps)), Drawer::Closed); + fn showing_the_open_tenant_changes_nothing() { + for pinned in [true, false] { + let d = Drawer::Open(Tenant::Apps); + assert_eq!(d.reduce(Action::Show(Tenant::Apps), pinned), d); + } } #[test] fn other_tenant_replaces() { - let d = Drawer::default().reduce(Action::Toggle(Tenant::Apps)); + let d = Drawer::default().reduce(Action::Show(Tenant::Apps), true); assert_eq!( - d.reduce(Action::Toggle(Tenant::Settings)), + d.reduce(Action::Show(Tenant::Settings), true), Drawer::Open(Tenant::Settings) ); } + /// Nothing running: the app list is where the drawer rests, so a close + /// lands there and `Closed` is not reachable at all. + #[test] + fn pinned_close_opens_the_app_list() { + for from in [ + Drawer::Open(Tenant::Settings), + Drawer::Open(Tenant::Devices), + Drawer::Open(Tenant::Unseal), + Drawer::Closed, + ] { + assert_eq!( + from.reduce(Action::Close, true), + Drawer::Open(Tenant::Apps), + "pinned close from {from:?}" + ); + } + } + /// A session opening (and closing) sends `Close`: the app frame gets /// the screen, the drawer never covers it. #[test] - fn session_change_closes_the_drawer() { + fn unpinned_close_shuts_the_drawer() { for open in [ Drawer::Open(Tenant::Apps), + Drawer::Open(Tenant::AppInfo), Drawer::Open(Tenant::Settings), Drawer::Closed, ] { - assert_eq!(open.reduce(Action::Close), Drawer::Closed); + assert_eq!(open.reduce(Action::Close, false), Drawer::Closed); } } + /// The slide direction is a sign, and it has to be the one the strip + /// implies: the left half's sheets sit left of the right half's, and + /// "Other devices" sits right of Settings, which is where it is + /// reached from. + #[test] + fn ordinals_order_the_sheets_left_to_right() { + assert_eq!(Tenant::Apps.ordinal(), Tenant::AppInfo.ordinal()); + assert!(Tenant::Apps.ordinal() < Tenant::Settings.ordinal()); + assert_eq!(Tenant::Unseal.ordinal(), Tenant::Settings.ordinal()); + assert!(Tenant::Settings.ordinal() < Tenant::Devices.ordinal()); + } + #[test] fn tenant_reports_what_is_open() { assert_eq!(Drawer::Closed.tenant(), None); @@ -271,12 +337,13 @@ mod tests { } /// The seal opening is a `Close`: the ceremony is over and the strip is - /// now painted with a real identity, which is the thing to look at. + /// now painted with a real identity. Nothing is running at that moment, + /// so what the drawer rests at is the app list. #[test] - fn unseal_success_closes_the_drawer() { + fn unseal_success_rests_on_the_app_list() { assert_eq!( - Drawer::Open(Tenant::Unseal).reduce(Action::Close), - Drawer::Closed + Drawer::Open(Tenant::Unseal).reduce(Action::Close, true), + Drawer::Open(Tenant::Apps) ); } @@ -316,7 +383,7 @@ mod tests { #[test] fn devices_is_reachable_from_settings() { assert_eq!( - Drawer::Open(Tenant::Settings).reduce(Action::Toggle(Tenant::Devices)), + Drawer::Open(Tenant::Settings).reduce(Action::Show(Tenant::Devices), true), Drawer::Open(Tenant::Devices) ); } diff --git a/visor/src/style.rs b/visor/src/style.rs index 76ae771d..186f75ae 100644 --- a/visor/src/style.rs +++ b/visor/src/style.rs @@ -3,41 +3,160 @@ //! Shipped as a `