diff --git a/e2e/run.ts b/e2e/run.ts index ee841c79..ef13c1e4 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -347,8 +347,8 @@ async function launchTodoMvc(page: Page): Promise { /** * 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 + * Save disappears and clean `Close` replaces it when `draft == seed`; 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. @@ -358,10 +358,8 @@ async function saveDraft(page: Page): Promise { .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; + return document.querySelector("#visor-actions")?.textContent?.trim() === + "Close"; }, undefined, { timeout: 15_000 }, @@ -372,7 +370,7 @@ async function saveDraft(page: Page): Promise { * Raise the running app's own sheet, from wherever the drawer is. * * Not by pressing the strip's app half from another pane. With a session - * running, every pane offers "Return to app" and that is the supported way + * running, every clean pane offers `Close` and that is the supported way * out (visor/src/ui.rs `dismissal`); pressing the half for the tenant * already on screen is worse than useless, since a dirty draft parks the * transition and the dialog that raises makes the strip `inert` — after @@ -391,7 +389,7 @@ async function toAppSheet(page: Page): Promise { { timeout: 15_000 }, ); const back = drawer(page).getByRole("button", { - name: "Return to app", + name: "Close", exact: true, }); if (await back.count() > 0) { @@ -415,12 +413,8 @@ async function toAppSheet(page: Page): Promise { * The running app's own glyph (`device.meta`, `meta-scope.app`), typed and * SAVED — which is the value an install is allowed to paint with. * - * The app sheet carries no `Save` of its own: the draft it writes into is - * the same one the settings sheet saves, so the save here is the "unsaved - * changes" dialog that guards the transition away from a dirty sheet - * (visor/src/ui.rs `#visor-confirm`). Returns once the app sheet, reopened, - * shows the value read back off the kernel (`read_app_meta`) — so a caller - * that installs next is installing against a saved map, not a draft. + * The app sheet uses the same action bar as device settings. Save there and + * wait for the clean Close action, which means the kernel accepted the map. */ async function setAppGlyph(page: Page, glyph: string): Promise { await toAppSheet(page); @@ -446,22 +440,7 @@ async function setAppGlyph(page: Page, glyph: string): Promise { "the app sheet's glyph field would not hold a value", ); } - await settingsButton(page).click(); - const confirm = page.locator("#visor-confirm"); - await confirm.waitFor({ timeout: 10_000 }); - await confirm.getByRole("button", { name: "Save", exact: true }).click(); - // The dialog clears the moment it is answered, but the save it asked for - // is async and the parked transition is taken only once that save lands - // (visor/src/ui.rs `save_now`). The settings pane the dialog was guarding - // is therefore the observable that says the kernel has the glyph: - // navigating on the dialog's disappearance alone races the save, finds - // the draft still dirty, and parks the next transition behind a second - // dialog that nothing can then dismiss. - await confirm.waitFor({ state: "detached", timeout: 15_000 }); - await drawer(page).locator("label").filter({ hasText: /^device petname$/ }) - .waitFor({ timeout: 15_000 }); - await paneSettled(page); - await toAppSheet(page); + await saveDraft(page); await page.waitForFunction( (want) => { const label = Array.from( @@ -654,18 +633,8 @@ async function tabTour(page: Page, times: number): Promise { return seen; } -/** - * The measured contrast of every piece of text on screen at one point on the - * hue wheel, as WCAG ratios keyed by a description of the element. - * - * Measured, not computed: the sheet states `oklch`, and what a ratio is - * depends on how Chromium maps that into sRGB. So every colour goes through - * a 1×1 canvas — the browser's own parser and gamut mapping — and the - * translucent layers are composited there before anything is measured. - * - * `hue` drives the stylesheet's two arms directly; which arm a real visor - * takes is the anchor rule, which `claimed()` tests. - */ +/** Measured WCAG contrast for every visible text leaf at one hue. Chromium + * does the oklch parsing, gamut mapping, and translucent compositing. */ function inkContrast( page: Page, hue: number | "unclaimed", @@ -677,7 +646,6 @@ function inkContrast( root.classList.remove("unclaimed"); root.style.setProperty("--hue", String(h)); } - const cv = document.createElement("canvas"); cv.width = cv.height = 1; const g = cv.getContext("2d", { willReadFrequently: true })!; @@ -698,8 +666,6 @@ function inkContrast( }); return 0.2126 * r + 0.7152 * gg + 0.0722 * b; }; - /** Everything painted under `el`, its own background included, flattened - * onto the page's white. */ const behind = (el: Element): number[] => { const chain: Element[] = []; for (let n: Element | null = el; n !== null; n = n.parentElement) { @@ -711,7 +677,6 @@ function inkContrast( } return acc; }; - const out: Record = {}; for ( const el of document.querySelectorAll( @@ -719,10 +684,8 @@ function inkContrast( ) ) { const text = (el.textContent ?? "").trim(); - // Leaves only: a container's own `color` is not what is drawn. if (text === "" || el.querySelector("*") !== null) continue; - if (el.closest("[inert]") !== null) continue; - if (el.getClientRects().length === 0) continue; + if (el.closest("[inert]") !== null || el.getClientRects().length === 0) continue; const style = getComputedStyle(el); if (style.visibility === "hidden") continue; const bg = behind(el); @@ -730,14 +693,13 @@ function inkContrast( const hi = Math.max(lum(fg), lum(bg)) + 0.05; const lo = Math.min(lum(fg), lum(bg)) + 0.05; const cls = String(el.className).trim() || "-"; - out[`${el.tagName.toLowerCase()}.${cls} "${text.slice(0, 24)}"`] = hi / - lo; + out[`${el.tagName.toLowerCase()}.${cls} "${text.slice(0, 24)}"`] = hi / lo; } return out; }, hue); } -/** Every control below the 44px touch floor, on either axis. */ +/** Every active control below the retained 44px accessibility floor. */ function undersizedControls(page: Page): Promise { return page.evaluate(() => { const bad: string[] = []; @@ -2045,6 +2007,11 @@ const scenarios: Scenario[] = [ const unseal = sheet(page, "is sealed"); await unseal.waitFor({ timeout: 15_000 }); check(!await claimed(page), "a sealed boot painted an identity"); + eq( + await drawer(page).locator("#visor-actions").count(), + 0, + "the pinned Unseal ceremony offered Close", + ); const field = unseal.locator("input[type=password]"); const press = unseal.getByRole("button", { name: "Unseal", exact: true }); @@ -2530,6 +2497,29 @@ const scenarios: Scenario[] = [ }).locator("input"); const confirm = page.locator("#visor-confirm"); + eq( + (await drawer(page).locator("#visor-actions").textContent())?.trim(), + "Close", + "a clean editable sheet did not offer Close", + ); + await field.fill("half typed"); + eq( + (await drawer(page).locator("#visor-actions").textContent())?.trim(), + "SaveRevert", + "a dirty sheet did not offer Save and Revert", + ); + await shot(page, "desktop-action-bar-dirty"); + + await drawer(page).getByRole("button", { name: "Revert", exact: true }) + .click(); + eq(await field.inputValue(), "", "bar Revert kept the abandoned draft"); + check( + await focusIn(page, "#visor-actions") && + await drawer(page).getByRole("button", { name: "Close", exact: true }) + .count() === 1, + `bar Revert did not focus its replacement Close; focus is ${await focused(page)}`, + ); + await shot(page, "desktop-action-bar-clean"); await field.fill("half typed"); // Leaving a dirty sheet asks. Cancel means "I was not done": the @@ -2560,13 +2550,54 @@ const scenarios: Scenario[] = [ // draft at all, and reads the name back off the device. await field.fill("the workbench"); await saveDraft(page); + check( + await focusIn(page, "#visor-actions") && + await drawer(page).getByRole("button", { name: "Close", exact: true }) + .count() === 1, + `bar Save did not focus its replacement Close; focus is ${await focused(page)}`, + ); + + // Same-task input makes this deterministic without a permanent mock: + // Save captures "first snapshot", then newer typing and two navigation + // attempts arrive while that async persistence call is in flight. + await field.fill("first snapshot"); + await field.evaluate((input) => { + const save = document.querySelector( + "#visor-actions button", + )!; + save.focus(); + save.click(); + (input as HTMLInputElement).value = "newer text"; + input.dispatchEvent(new InputEvent("input", { bubbles: true })); + document.querySelector("#visor-app")!.click(); + document.querySelector("#visor-root")!.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + await page.waitForFunction( + () => document.querySelector("#visor-actions")?.textContent?.includes("Save"), + ); + eq( + await drawer(page).locator(".pane").getAttribute("aria-label"), + "settings", + "navigation escaped while Save was in flight", + ); + eq(await confirm.count(), 0, "navigation opened confirmation during Save"); + eq(await field.inputValue(), "newer text", "Save lost newer field text"); + await drawer(page).getByRole("button", { name: "Revert", exact: true }) + .click(); + eq( + await field.inputValue(), + "first snapshot", + "Revert did not return to the snapshot the kernel accepted", + ); await page.reload(); await visorReady(page); - await strip(page).getByText("the workbench").waitFor({ timeout: 15_000 }); + await strip(page).getByText("first snapshot").waitFor({ timeout: 15_000 }); await openSettingsSheet(page); eq( await field.inputValue(), - "the workbench", + "first snapshot", "the saved device petname did not survive a reload", ); @@ -2677,8 +2708,8 @@ const scenarios: Scenario[] = [ ); eq( - await drawer(page).locator(".pane-dismiss").textContent(), - "Return to app", + await drawer(page).locator("#visor-actions").textContent(), + "Close", "the running app's sheet offered no way back to it", ); @@ -2722,7 +2753,7 @@ const scenarios: Scenario[] = [ // Nothing behind the drawer, so it offers no way out at all. await paneSettled(page); eq( - await drawer(page).locator(".pane-dismiss").count(), + await drawer(page).locator("#visor-actions").count(), 0, "the resting app list offered a dismissal to nowhere", ); @@ -2731,8 +2762,8 @@ const scenarios: Scenario[] = [ await page.keyboard.press("Enter"); await paneSettled(page); eq( - await drawer(page).locator(".pane-dismiss").textContent(), - "Back to apps", + await drawer(page).locator("#visor-actions").textContent(), + "Close", "a pinned settings sheet offered no way back", ); check( @@ -2949,6 +2980,17 @@ const scenarios: Scenario[] = [ // Where the long identifiers are, and so what the pictures show. await devicesSheet(page).scrollIntoViewIfNeeded(); + await drawer(page).locator(".pane").evaluate((el) => + el.scrollTo(0, el.scrollHeight) + ); + const actionBox = await drawer(page).locator("#visor-actions") + .boundingBox(); + const stripBox = await strip(page).boundingBox(); + check( + actionBox !== null && stripBox !== null && + Math.abs(actionBox.y + actionBox.height - stripBox.y) <= 1, + "the action bar did not stay attached to the strip after scrolling", + ); for (const width of [390, 320]) { await page.setViewportSize({ width, height: 780 }); // A resize is a layout, not a render; give it one. @@ -2966,6 +3008,25 @@ const scenarios: Scenario[] = [ const ids = await unreadableIdentifiers(page); check(ids.length === 0, `at ${width}px: ${ids.join("; ")}`); await shot(page, `mobile-${width}-settings`); + if (width === 390) { + const name = drawer(page).locator("label").filter({ + hasText: /^device petname$/, + }).locator("input"); + await name.fill("mobile draft"); + await shot(page, "mobile-action-bar-dirty"); + await drawer(page).getByRole("button", { + name: "Revert", + exact: true, + }).click(); + await shot(page, "mobile-action-bar-clean"); + } + const action = await drawer(page).locator("#visor-actions").boundingBox(); + const anchor = await strip(page).boundingBox(); + check( + action !== null && anchor !== null && + Math.abs(action.y + action.height - anchor.y) <= 1, + `at ${width}px the action bar detached from the strip`, + ); } const shown = devicesSheet(page).locator("#visor-endpoint-id"); @@ -2979,38 +3040,26 @@ const scenarios: Scenario[] = [ }, { - // The strip stays saturated by ruling, so its text carries the whole - // burden of being readable — at every hue a user can pick, and in the - // grey the visor wears before it is claimed. + // Retained accessibility contracts: readable text at every selectable + // hue and active controls at least 44px on both axes. name: "visor-contrast-and-touch", async run(ctx, origin) { const page = await open(ctx, origin); await visorReady(page); await page.setViewportSize({ width: 390, height: 780 }); await openSettingsSheet(page); - // 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", ); - // The device's own colour, to put back after the sweep drives the - // sheet directly. const painted = await page.locator("#visor-root").getAttribute("style"); - const worst = new Map(); const wheel: Array = ["unclaimed"]; for (let h = 0; h < 360; h++) wheel.push(h); for (const hue of wheel) { - for ( - const [what, ratio] of Object.entries(await inkContrast(page, hue)) - ) { + for (const [what, ratio] of Object.entries(await inkContrast(page, hue))) { const key = `${what} @${hue}`; if (ratio < (worst.get(key) ?? Infinity)) worst.set(key, ratio); } @@ -3021,28 +3070,19 @@ const scenarios: Scenario[] = [ check( failing.length === 0, `${failing.length} text(s) below 4.5:1, worst ${ - failing.slice(0, 4).map(([k, r]) => `${k} = ${r.toFixed(2)}`).join( - "; ", - ) + failing.slice(0, 4).map(([k, r]) => `${k} = ${r.toFixed(2)}`).join("; ") }`, ); - // Put the device's own colour back: the sweep drove the sheet - // directly, and the rest of this is about the real visor. await page.locator("#visor-root").evaluate( (el, style) => el.setAttribute("style", style ?? ""), painted, ); - - const small = await undersizedControls(page); - check(small.length === 0, `under the touch floor: ${small.join("; ")}`); - await shot(page, "mobile-390-settings-touch"); - - // Desktop: the same floor. + let small = await undersizedControls(page); + check(small.length === 0, `mobile under touch floor: ${small.join("; ")}`); await page.setViewportSize({ width: 1280, height: 800 }); await page.waitForTimeout(300); - const wide = await undersizedControls(page); - check(wide.length === 0, `under the touch floor: ${wide.join("; ")}`); - await shot(page, "desktop-settings"); + small = await undersizedControls(page); + check(small.length === 0, `desktop under touch floor: ${small.join("; ")}`); }, }, diff --git a/visor/src/style.rs b/visor/src/style.rs index 0ca42db2..106cc2a1 100644 --- a/visor/src/style.rs +++ b/visor/src/style.rs @@ -131,6 +131,7 @@ pub(crate) const CSS: &str = r#" screens too short to have that room at all). */ #visor-drawer { position: relative; z-index: 2; + display: flex; flex-direction: column; box-sizing: border-box; height: min(60svh, 480px); max-height: max(0px, calc(100svh - 56px - 96px)); @@ -158,6 +159,7 @@ pub(crate) const CSS: &str = r#" content above or below, the cover has scrolled away and the shadow shows — so "there is more" is visible without a scrollbar being trusted to say it. */ +#visor-root .pane-host { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; } #visor-root .pane { position: absolute; inset: 0; overflow-y: auto; overflow-x: hidden; @@ -209,6 +211,19 @@ pub(crate) const CSS: &str = r#" #visor-confirm:focus { outline: none; } #visor-confirm .framework { font-weight: 600; font-size: 16px; flex: 1 1 100%; } +/* The drawer's non-scrolling last row. Its own content determines its + height, so the pane gives up exactly that much room without a duplicated + offset. The confirmation dialog is positioned at this same strip edge and + the drawer is inert beneath it. */ +#visor-actions { + flex: none; + display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; + padding: 8px 12px; + box-sizing: border-box; + background: var(--drawer); + border-top: 1px solid var(--edge); +} + /* The line at the top of every pane. Only the pane that is staying carries the id — during a slide there are two of these on screen. */ #visor-root .notice { margin-bottom: 8px; min-height: 1.4em; overflow-wrap: break-word; } @@ -222,13 +237,6 @@ pub(crate) const CSS: &str = r#" cursor: pointer; max-width: 100%; } -/* After the base rule, not before: an exit is not the thing to do here, and - these declarations have to be the ones that win. */ -#visor-root .pane-dismiss { - margin-bottom: 12px; - background: var(--plate); color: var(--plate-ink); - border-color: var(--edge); -} #visor-root button[aria-pressed="true"] { border-color: var(--plate-ink); box-shadow: inset 0 0 0 1px var(--accent-ink); } /* Disabled is said with the fill and the cursor rather than with an opacity. */ #visor-root button[disabled] { background: var(--edge); color: var(--plate-ink); border-color: var(--edge); cursor: default; } diff --git a/visor/src/ui.rs b/visor/src/ui.rs index 1a55bc75..0f5d5f8a 100644 --- a/visor/src/ui.rs +++ b/visor/src/ui.rs @@ -111,7 +111,7 @@ async fn save_draft( mut notice: Signal>, app_id: Option, mut status_gate: CopyValue, -) { +) -> bool { let (was, now) = (seed(), draft()); status_gate.write().bump(); let mut failed = false; @@ -160,6 +160,7 @@ async fn save_draft( if !failed { seed.set(now); } + !failed } /// One app's labels (internal.wit `device.meta`, `meta-scope.app`). Read @@ -200,6 +201,8 @@ enum FocusWant { #[default] Nowhere, Pane, + /// The clean action replacing Save/Revert after a bar invocation. + Bar, /// The half of the strip the drawer was raised from, because it closed. Strip { self_half: bool, @@ -219,13 +222,13 @@ fn raised_by_self_half(t: Tenant) -> bool { /// open drawer. `None` for the two panes with nowhere to go: the app list /// with nothing running (where the visor rests) and the unseal ceremony /// (every other kernel call is `unavailable` until it succeeds). -fn dismissal(t: Tenant, running: bool) -> Option<(&'static str, Action)> { +fn dismissal(t: Tenant, running: bool) -> Option { if running { - Some(("Return to app", Action::Close)) + Some(Action::Close) } else if t == Tenant::Apps || t == Tenant::Unseal { None } else { - Some(("Back to apps", Action::Show(Tenant::Apps))) + Some(Action::Show(Tenant::Apps)) } } @@ -570,6 +573,11 @@ pub(crate) fn Visor() -> Element { // A transition the user asked for while the draft was dirty. It waits // on `#visor-confirm` rather than happening. let mut pending = use_signal(|| None::); + let mut saving = use_signal(|| false); + // Whether the async bar Save still owns focus. A user who moves back to + // a field while it is in flight keeps the caret there, even if their + // newer text happens to equal the captured snapshot when it lands. + let mut bar_save_focused = use_signal(|| false); // Shut, but still on screen playing its close animation. The drawer // stays `Open(t)` throughout — this is what says the tenant showing is // the one on its way out — and `onanimationend` is what finally closes. @@ -731,7 +739,7 @@ pub(crate) fn Visor() -> Element { // that arrives now is input queued before it appeared — a scrim // click, a strip press already in flight — and taking it would // silently replace the transition the dialog is asking about. - if pending().is_some() { + if pending().is_some() || saving() { return; } if draft() != seed() { @@ -744,13 +752,17 @@ pub(crate) fn Visor() -> Element { } }); - let save_now = use_callback(move |after: Option| { + let save_now = use_callback(move |(after, from_bar): (Option, bool)| { + if saving() { + return; + } + saving.set(true); let app_id = session .read() .as_ref() .map(|(_, a): &(SessionId, App)| a.id.clone()); spawn(async move { - save_draft( + let saved = save_draft( draft, seed, status, @@ -761,16 +773,30 @@ pub(crate) fn Visor() -> Element { status_gate, ) .await; - if let Some(action) = after { - apply.call(action); + saving.set(false); + if saved { + if let Some(action) = after { + // Text typed after the captured snapshot stays in this + // sheet; it has not been answered by the earlier Save. + if draft() == seed() { + apply.call(action); + } + } else if from_bar && bar_save_focused() && draft() == seed() { + ask_focus.call(FocusWant::Bar); + } } }); }); - let revert_now = use_callback(move |after: Option| { + let revert_now = use_callback(move |(after, from_bar): (Option, bool)| { + if saving() { + return; + } draft.set(seed()); if let Some(action) = after { apply.call(action); + } else if from_bar { + ask_focus.call(FocusWant::Bar); } }); @@ -996,9 +1022,6 @@ pub(crate) fn Visor() -> Element { }); }); - let on_save = use_callback(move |()| save_now.call(None)); - let on_revert = use_callback(move |()| revert_now.call(None)); - let running = session.read().is_some(); let tenant = drawer().tenant(); let ident = Ident::of(&status.read()); @@ -1075,6 +1098,7 @@ pub(crate) fn Visor() -> Element { let (focus_gen, focus_want) = focus(); let focus_tag = |want: FocusWant| (focus_want == want).then(|| focus_gen.to_string()); let focus_pane = focus_tag(FocusWant::Pane); + let focus_bar = focus_tag(FocusWant::Bar); let focus_confirm = focus_tag(FocusWant::Confirm); let focus_app_half = focus_tag(FocusWant::Strip { self_half: false }); let focus_self_half = focus_tag(FocusWant::Strip { self_half: true }); @@ -1088,7 +1112,7 @@ pub(crate) fn Visor() -> Element { // drawer's open and close animations, since it is on screen for both. let app_inert = flag(tenant.is_some() || confirming); // The exit the pane offers, which Escape is the keyboard spelling of. - let escape = tenant.and_then(|t| dismissal(t, running)).map(|(_, a)| a); + let escape = tenant.and_then(|t| dismissal(t, running)); // One pane's worth of drawer. Rendered twice while a switch is // animating — the tenant coming in and the one going out — so it is a @@ -1126,20 +1150,7 @@ pub(crate) fn Visor() -> Element { ) }; - // Only the pane that is staying offers the way out; the one sliding - // away is inert and about to be gone. - let exit = if current { dismissal(t, running) } else { None }; - rsx! { - // First in the pane, so it is the first thing Tab reaches. - if let Some((label, action)) = exit { - button { - class: "pane-dismiss", - onclick: move |_| request.call(action), - "{label}" - } - } - // Whatever happened that the user did not ask for. It lives at // the top of every pane because the strip has no room to say // it any more and no business moving to make some. @@ -1256,7 +1267,6 @@ pub(crate) fn Visor() -> Element { Tenant::Settings => rsx! { SettingsSheet { draft, - seed, word: word.clone(), tier, petname: petname.clone(), @@ -1271,8 +1281,6 @@ pub(crate) fn Visor() -> Element { on_refresh_devices: refresh_devices, on_kept, on_devices: show_devices, - on_save, - on_revert, } }, } @@ -1351,27 +1359,50 @@ pub(crate) fn Visor() -> Element { drawer.set(Drawer::Closed); } }, - if let Some((from, forward)) = leaving() { + div { class: "pane-host", + if let Some((from, forward)) = leaving() { + div { + key: "{from:?}", + class: if forward { "pane leave-to-left" } else { "pane leave-to-right" }, + inert: flag(true), + onanimationend: move |_| leaving.set(None), + {sheet_for(from, false)} + } + } div { - key: "{from:?}", - class: if forward { "pane leave-to-left" } else { "pane leave-to-right" }, - // Still painted, no longer part of the page. - inert: flag(true), - onanimationend: move |_| leaving.set(None), - {sheet_for(from, false)} + key: "{t:?}", + class: "{entering}", + tabindex: "-1", + role: "group", + aria_label: pane_label(t), + "data-visor-focus": focus_pane, + {sheet_for(t, true)} } } - // `tabindex="-1"`: the caret can be put here without the - // pane becoming a tab stop; the name is what a screen - // reader announces on arrival. - div { - key: "{t:?}", - class: "{entering}", - tabindex: "-1", - role: "group", - aria_label: pane_label(t), - "data-visor-focus": focus_pane, - {sheet_for(t, true)} + + if matches!(t, Tenant::Settings | Tenant::AppInfo) && draft() != seed() { + div { id: "visor-actions", + button { + aria_disabled: "{saving()}", + onfocus: move |_| bar_save_focused.set(true), + onblur: move |_| bar_save_focused.set(false), + onclick: move |_| save_now.call((None, true)), + "Save" + } + button { + disabled: "{saving()}", + onclick: move |_| revert_now.call((None, true)), + "Revert" + } + } + } else if let Some(action) = dismissal(t, running) { + div { id: "visor-actions", + button { + "data-visor-focus": focus_bar, + onclick: move |_| request.call(action), + "Close" + } + } } } } @@ -1460,14 +1491,14 @@ pub(crate) fn Visor() -> Element { button { onclick: move |_| { pending.set(None); - save_now.call(Some(action)); + save_now.call((Some(action), false)); }, "Save" } button { onclick: move |_| { pending.set(None); - revert_now.call(Some(action)); + revert_now.call((Some(action), false)); }, "Revert" } @@ -2166,7 +2197,6 @@ fn EraseControl() -> Element { #[allow(clippy::too_many_arguments)] fn SettingsSheet( draft: Signal, - seed: Signal, word: String, tier: Tier, petname: String, @@ -2181,8 +2211,6 @@ fn SettingsSheet( on_refresh_devices: EventHandler<()>, on_kept: EventHandler, on_devices: EventHandler<()>, - on_save: EventHandler<()>, - on_revert: EventHandler<()>, ) -> Element { let mut draft = draft; // Read out rather than held: the field values are wanted here, and a @@ -2197,8 +2225,6 @@ fn SettingsSheet( d.user.get(GLYPH).cloned().unwrap_or_default(), ) }; - let clean = draft() == seed(); - rsx! { label { span { class: "{Voice::Framework.class()}", "device petname" } @@ -2246,11 +2272,6 @@ fn SettingsSheet( }, } } - div { class: "choice", - button { disabled: "{clean}", onclick: move |_| on_save.call(()), "Save" } - button { onclick: move |_| on_revert.call(()), "Revert" } - } - label { span { class: "{Voice::Framework.class()}", "word" } span { class: "{Voice::Framework.class()}", "{word}" }