diff --git a/apps/web/src/build-plan-button.css b/apps/web/src/build-plan-button.css index 2a26c63ab..ee64870a9 100644 --- a/apps/web/src/build-plan-button.css +++ b/apps/web/src/build-plan-button.css @@ -53,7 +53,66 @@ transform: translate(-50%, -50%); } -/* Phone: the glyph alone, without the pill, so it reads as status rather than a control. */ +/* Phone: the build's state and its action read as links in the top bar's subtitle. */ +.build-status { + display: inline-flex; + min-width: 0; + align-items: center; + gap: var(--spacing); +} + +.build-status-link { + position: relative; + display: inline-flex; + min-width: 0; + align-items: center; + gap: var(--spacing); + color: inherit; + font: inherit; + font-variant-numeric: tabular-nums; + white-space: nowrap; + touch-action: manipulation; + user-select: none; + -webkit-user-select: none; +} + +/* A thumb-sized target around the small text, as the capsule has. */ +.build-status-link::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: max(100%, var(--control-target-coarse)); + height: var(--control-target-coarse); + transform: translate(-50%, -50%); +} + +.build-status-link:active { + opacity: 0.6; +} + +/* Build plan and the action are things to do, so they take the brand's ink. */ +.build-status-link:not(:has(.build-capsule-label)) { + color: var(--color-brand-ink); + font-weight: var(--font-weight-medium); +} + +.build-status-link[aria-disabled="true"] { + color: inherit; + font-weight: inherit; +} + +.build-status-link .build-capsule-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.build-status-link .build-glyph { + width: calc(var(--spacing) * 3); + height: calc(var(--spacing) * 3); +} + +/* Compact: the glyph alone, without the pill, so it reads as status rather than a control. */ .build-capsule[data-compact] { justify-content: center; width: var(--control-height-compact); diff --git a/apps/web/src/build-plan-button.tsx b/apps/web/src/build-plan-button.tsx index bc203acaf..c62735138 100644 --- a/apps/web/src/build-plan-button.tsx +++ b/apps/web/src/build-plan-button.tsx @@ -1,4 +1,4 @@ -import { ClockIcon, LoaderIcon, ProgressRingIcon, WarningIcon, WrenchIcon } from "@chopin/icons"; +import { ClockIcon, LoaderIcon, ProgressRingIcon, WarningIcon } from "@chopin/icons"; import { useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from "react"; import { @@ -342,7 +342,7 @@ export function BuildPlanButton( onOpen: () => void; /** Phone: touch has no tooltip, so a tap that cannot act shows this reason instead. */ onExplain?: (reason: string) => void; - /** A phone's top bar: Build plan shows as its glyph. */ + /** A phone's top bar: the status and its action read as links in the title's subtitle. */ phone?: boolean; /** Build can open; until then a phone tap on the glyph explains instead. */ tabReady?: boolean; @@ -394,9 +394,8 @@ export function BuildPlanButton( {blocked && {blocked}} {live} @@ -426,18 +424,18 @@ export function BuildPlanButton( : look.kind === "failed" && look.retry && canEdit ? { name: "Retry", run: build.rebuild } : look.kind === "stale" && look.build && canEdit - // On a phone the Build tab sits just below, so the action names what it builds. - ? { name: compact ? "Build changes" : "Build", run: build.rebuild } + // Compact names what it builds; a phone's subtitle follows the status, which already says. + ? { name: compact && !phone ? "Build changes" : "Build", run: build.rebuild } : undefined; let refused = action?.run === build.rebuild ? build.rebuildError : undefined; let busy = build.busy || build.rebuilding; return ( <> - + {look.tip && {look.tip}} {action && ( <> + {phone && } {/* Stays focusable while blocked or busy; aria-disabled and aria-busy say why. */} + ) + : hostsTrigger && trigger && ( + + )} + + {child ?
: children} + + ); +} + +/** + * A document's title, subtitle and trailing controls in the phone bar. An open child renders its + * own into the parent's bar, with its parent as the subtitle. + */ +export function PhoneHeaderContent( + { + archivedAt, + canManage, + comments = 0, + context, + editing, + editingDocument, + label, + members, + onAction, + onComments, + onEditingChange, + onRenamed, + lead, + room, + }: PhoneHeaderDocument, +) { + let center = useRef(null); // Also runs as a save starts, not after it: keys typed while the rename travels would // otherwise land in a field that is about to close. let handOff = () => { // Naming a new document leads into writing it, unless the user already tapped elsewhere. - if (editing === "new" && header.current?.contains(document.activeElement)) { - header.current.closest(".workspace-root") + if (editing === "new" && center.current?.contains(document.activeElement)) { + center.current.closest(".workspace-root") ?.querySelector(".plan-content[contenteditable='true']") ?.focus(); } @@ -85,25 +148,34 @@ export function PhoneHeader( handOff(); onEditingChange(); }; + let status = archivedAt + ? Archived + : editingDocument + ? Editing + : context; + let people = peopleHere(members); + // A title that needs room takes it from the faces first: one face, and the rest counted. + let [oneFace, setOneFace] = useState(false); + let crowd = people.length > 1; + useLayoutEffect(() => { + let title = center.current?.querySelector(".phone-top-bar-title > .truncate"); + if (!title || !crowd) { + setOneFace(false); + return; + } + // Back to two only once the title would still fit beside them, so it never flickers. + let measure = () => + setOneFace(one => title.scrollWidth > title.clientWidth - (one ? FACE_ROOM : 0)); + measure(); + let observer = new ResizeObserver(measure); + observer.observe(title); + return () => observer.disconnect(); + }, [crowd, editing, label]); + let shown = oneFace ? 1 : 2; return ( -
-
- {hostsTrigger && trigger && ( - - )} -
-
+ <> +
{editing && canManage && !archivedAt ? ( @@ -114,7 +186,7 @@ export function PhoneHeader( // The body took focus as the save started; a refusal needs the field back. onErrorChange={error => error !== undefined - && header.current?.querySelector(".document-title-input") + && center.current?.querySelector(".document-title-input") ?.focus()} onSavingChange={saving => saving && handOff()} replay={editing === "new"} @@ -125,17 +197,40 @@ export function PhoneHeader( /> ) : {label}} - {archivedAt - ? Archived - : editingDocument && Editing}
- {/* The title waits for the heading to scroll away; the mode must show regardless. */} - {editingDocument && !archivedAt && ( - + {(lead || status) && ( +
+ {lead && {lead}} + {lead && status && } + {status} +
)}
- {build} + {people.length > 0 && ( +
+ {people.slice(0, shown).map(handle => ( + + ))} + {people.length > shown && ( + + )} +
+ )} {comments > 0 && onComments && (
-
+ ); } diff --git a/apps/web/src/phone-workspace.css b/apps/web/src/phone-workspace.css index dd37122b3..9101a7e6b 100644 --- a/apps/web/src/phone-workspace.css +++ b/apps/web/src/phone-workspace.css @@ -42,34 +42,11 @@ padding-inline: env(safe-area-inset-left) env(safe-area-inset-right); } -/* - * A child's stack bar sits under the top bar and tucks away with it, leaving the screen entirely. - * The child card runs from the top edge like any phone pane, so nothing shows a second band - * through the glass, and its document starts below both bars. - */ -.workspace-root[data-workspace-phone] .workspace-stack-bar { - z-index: 21; - top: var(--phone-top-chrome); - transition: - opacity var(--child-close-dur) var(--child-ease), - transform var(--phone-bar-dur) var(--ease-out), - visibility 0s; -} - -.workspace-root[data-workspace-phone][data-workspace-bar="hidden"] .workspace-stack-bar { - visibility: hidden; - opacity: 0; - transform: translateY(calc(-1 * (var(--phone-top-chrome) + var(--child-bar-height)))); - transition: - opacity var(--phone-bar-dur) var(--ease-out), - transform var(--phone-bar-dur) var(--ease-out), - visibility 0s linear var(--phone-bar-dur); -} - +/* The child card runs from the top edge like any phone pane, under the bar it has taken. */ .workspace-root[data-workspace-phone][data-workspace-mode] .workspace-document-stack .anchored-child-card { - --document-top-inset: calc(var(--phone-pane-top) + var(--child-bar-height)); + --document-top-inset: var(--phone-pane-top); inset: 0; } @@ -156,40 +133,40 @@ pointer-events: none; } -/* The small title waits until the document's own title has scrolled away. */ +/* + * The title waits until the document's own title has scrolled away; meanwhile the subtitle, + * which names the project and the build, settles onto the bar's centre line on its own. + */ .phone-top-bar-center { - display: grid; + display: flex; min-width: 0; - place-items: center; + flex-direction: column; + align-items: center; + transition: transform var(--duration-base) var(--ease-out); } .phone-top-bar-center > * { - grid-area: 1 / 1; + max-width: 100%; } -.phone-top-bar-title, -.phone-top-bar-mode { +.phone-top-bar-title { transition: opacity var(--duration-base) var(--ease-out); } -.workspace-root[data-workspace-title="hidden"] .phone-top-bar-title { - opacity: 0; +.workspace-root[data-workspace-title="hidden"] .phone-top-bar:not([data-phone-child]) { + .phone-top-bar-title:not(:has(input)) { + opacity: 0; + } + + .phone-top-bar-center:not(:has(input)) { + transform: translateY(calc(var(--text-sm) * -0.6)); + } } -/* Stands in for the hidden title while editing, so the mode is always on screen. */ +/* Editing names itself in the subtitle, so the mode is always on screen. */ .phone-top-bar-mode { - padding: calc(var(--spacing) / 2) calc(var(--spacing) * 2); - border-radius: var(--radius-full); - background: var(--color-brand-wash); color: var(--color-brand-ink); - font-size: var(--text-2xs); font-weight: var(--font-weight-medium); - opacity: 0; - pointer-events: none; -} - -.workspace-root[data-workspace-title="hidden"] .phone-top-bar-mode { - opacity: 1; } .phone-top-bar-slot { @@ -230,11 +207,50 @@ } .phone-top-bar-subtitle { + display: flex; + max-width: 100%; + min-width: 0; + align-items: center; + gap: var(--spacing); + font-size: var(--text-2xs); + line-height: var(--text-2xs--line-height); + white-space: nowrap; + color: var(--color-text-tertiary); +} + +.phone-top-bar-subtitle > .truncate { + min-width: 0; +} + +/* Links in the subtitle reach a thumb through their own invisible target, not their line box. */ +.room-header.phone-top-bar .phone-top-bar-subtitle button { + min-width: 0; + min-height: 0; +} + +/* The lead gives way first; the build's state is what changes. */ +.phone-top-bar-lead { + flex: 0 1 auto; +} + +/* With the bar's gap, the build's state in the subtitle keeps more than 12px clear of the faces. */ +.phone-top-bar-people { + display: flex; + flex: none; + align-items: center; + padding-inline: calc(var(--spacing) * 2.5) var(--spacing); font-size: var(--text-2xs); - font-weight: var(--font-weight-normal); color: var(--color-text-tertiary); } +.phone-top-bar-people > * + * { + margin-left: calc(var(--spacing) * -1.5); +} + +.phone-top-bar-people > .phone-top-bar-more { + margin-left: var(--spacing); +} + /* Bottom tab bar ------------------------------------------------------- */ .phone-tab-row { @@ -651,7 +667,7 @@ } .workspace-root[data-workspace-phone] > .phone-top-bar, - .workspace-root[data-workspace-phone] .workspace-stack-bar, + .phone-top-bar-center, .phone-tab-indicator, .phone-tab-row, .phone-tab-bar, diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 23a4bceed..b1f02cbb1 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -81,7 +81,7 @@ import { newestDocumentMetadata } from "./document-actions"; import { DocumentActionsMenu } from "./document-actions-menu"; import { DocumentRename } from "./document-rename"; import { browserStorage, rememberOpened } from "./documents-sheet-model"; -import { PhoneHeader } from "./phone-header"; +import { PhoneHeader, PhoneHeaderContent } from "./phone-header"; import { DocumentSkeleton } from "./phone-skeleton"; import { useEditorFocus, useKeyboardOpen } from "./keyboard-open"; import { motionContract } from "./motion-contract"; @@ -1427,7 +1427,29 @@ export function RoomWorkspace(

{announcement.text}

- {childTitle?.slot && createPortal(childTitleControl, childTitle.slot)} + {childTitle?.slot && (childTitle.phone + ? createPortal( + onDocumentAction(room, action)} + onComments={() => { + childChrome?.showDocument(); + openCommentList(room); + }} + onEditingChange={setTitleEdit} + onRenamed={updateMetadata} + lead={`in ${presentation.parent?.label ?? childTitle.parent}`} + room={room} + />, + childTitle.slot, + ) + : createPortal(childTitleControl, childTitle.slot))} {childTitle?.motion === "lift" && (
setBuildHover(on ? "capsule" : undefined)} - onOpen={() => selectDestination("build")} - tabReady={build.tabReady} - /> - ); + // has no build of its own. A phone shows this room's bar only off Document, so it keeps it. + let buildSlot = profile.implementation && (presentation.type !== "parent-with-child" || phone) + && ( + setBuildHover(on ? "capsule" : undefined)} + onOpen={() => selectDestination("build")} + tabReady={build.tabReady} + /> + ); // The tab bar's pencil edits the document on screen: an open child, or else this one. let barEdit = childOpen @@ -1591,24 +1617,34 @@ export function RoomWorkspace( header={phone ? ( onDocumentAction(room, action)} - onComments={() => { - // The list stands over the document, so it opens from any tab onto it. - selectDestination("plan"); - openCommentList(room); - }} - onEditingChange={setTitleEdit} - onRenamed={updateMetadata} presentation={presentation} - room={room} - build={buildSlot} - /> + > + onDocumentAction(room, action)} + onComments={() => { + // The list stands over the document, so it opens from any tab onto it. + selectDestination("plan"); + openCommentList(room); + }} + onEditingChange={setTitleEdit} + onRenamed={updateMetadata} + lead={repository.name} + room={room} + /> + ) :
} title={ diff --git a/apps/web/src/workspace.tsx b/apps/web/src/workspace.tsx index 716ded4c1..6364048a0 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -303,9 +303,10 @@ export function Workspace( let liftCovered = lift && !childClosing; // Split Zoom puts the child at the end of the title bar's path and folds the views away, since // a child has none. Compact has no title bar, so it keeps the stack bar, on every view, so a - // child left open under Decisions or Build is still in sight and one click away. + // child left open under Decisions or Build is still in sight and one click away. A phone's top + // bar becomes the child's, with Back, so it needs no stack bar. let zoom = !!nested && !lift; - let stackBar = zoom && mode !== "split"; + let stackBar = zoom && mode !== "split" && !phone; let planCovered = zoom && view === "plan"; let viewsFolded = zoom && mode === "split" && view === "plan" && !childClosing; // Phones crossfade rather than cut under reduced motion, so only keyboard input is immediate. diff --git a/e2e/phone-workspace.e2e.ts b/e2e/phone-workspace.e2e.ts index ca14da5fd..160525033 100644 --- a/e2e/phone-workspace.e2e.ts +++ b/e2e/phone-workspace.e2e.ts @@ -1,3 +1,4 @@ +import { titleChannel } from "./database"; import { expect, openIsolatedRoom, test } from "./room"; import { expectNoHorizontalOverflow, RESPONSIVE_SOURCE } from "./responsive"; import { installVisualViewport, setVisualViewport } from "./visual-viewport"; @@ -44,10 +45,18 @@ test("a phone gets full-bleed panes under a top bar and a bottom tab bar", async await expect(page.getByRole("button", { name: "Show sidebar" })).toHaveCount(0); await expect(topBar(page).getByRole("button", { name: "Documents", exact: true })).toBeVisible(); await expect(topBar(page).getByRole("button", { name: /^Actions for / })).toBeVisible(); - for (let control of await topBar(page).getByRole("button").all()) { + // The subtitle's build links are small text with a thumb-sized invisible target of their own. + for ( + let control of await topBar(page).getByRole("button") + .and(page.locator(":not(.phone-top-bar-subtitle *)")).all() + ) { let box = await control.boundingBox(); - expect(box!.height).toBeGreaterThanOrEqual(44); - expect(box!.width).toBeGreaterThanOrEqual(44); + expect(box!.height).toBeGreaterThanOrEqual(43.5); + expect(box!.width).toBeGreaterThanOrEqual(43.5); + } + for (let link of await topBar(page).locator(".phone-top-bar-subtitle button").all()) { + expect(await link.evaluate(element => getComputedStyle(element, "::after").height)) + .toBe("44px"); } await expectNoHorizontalOverflow(page); }); @@ -147,6 +156,74 @@ test("the top bar hides while reading down and returns on scroll up", async ({ j await expect(page.locator(".workspace-root")).not.toHaveAttribute("data-workspace-bar", "hidden"); }); +/** Horizontal space from the subtitle's last build control to the first face beside it. */ +function buildToFaces(page: Page): Promise { + return topBar(page).evaluate(bar => { + let links = [...bar.querySelectorAll(".phone-top-bar-subtitle .build-status-link")]; + let face = bar.querySelector(".phone-top-bar-people > *")!.getBoundingClientRect(); + return face.left - Math.max(...links.map(link => link.getBoundingClientRect().right)); + }); +} + +for (let width of [390, 430]) { + test(`at ${width} the top bar names the project and build beside who is here`, async ({ baseURL, join, room, seed }) => { + await seed(RESPONSIVE_SOURCE); + let viewport = { width, height: width === 390 ? 844 : 932 }; + let page = await join("ana", { ...PHONE, viewport }); + await join("bo", { viewport: { width: 1440, height: 900 } }); + let bar = topBar(page); + let people = bar.getByRole("group", { name: /^People here: / }); + await expect(people).toHaveAccessibleName(/\bana\b/); + await expect(people).toHaveAccessibleName(/\bbo\b/); + let subtitle = bar.locator(".phone-top-bar-subtitle"); + await expect(subtitle).toContainText("score"); + let plan = subtitle.getByRole("button", { name: "Build plan", exact: true }); + await expect(plan).toBeVisible(); + // The text is small; its invisible target is a thumb's. + expect(await plan.evaluate(element => getComputedStyle(element, "::after").height)) + .toBe("44px"); + + // At the top the title waits, and the subtitle alone sits on the bar's centre line. + await expect(bar.locator(".phone-top-bar-title")).toHaveCSS("opacity", "0"); + await expect.poll(async () => { + let line = (await subtitle.boundingBox())!; + let box = (await bar.boundingBox())!; + return Math.abs(line.y + line.height / 2 - (box.y + box.height / 2)); + }).toBeLessThanOrEqual(2); + expect(await buildToFaces(page)).toBeGreaterThanOrEqual(12); + let faces = people.locator(":scope > :not(.phone-top-bar-more)"); + await expect(faces).toHaveCount(2); + + // A long title truncates short of the faces, which keep their 12px from the build. + let long = `A very long document title that cannot fit beside the people ${room.slice(0, 6)}`; + await titleChannel(Number(new URL(baseURL!).port), room, long); + await page.reload(); + await expect(bar).toContainText(long); + await page.locator("[data-plan-scroll]").evaluate(element => element.scrollTo({ top: 400 })); + await page.locator("[data-plan-scroll]").evaluate(element => element.scrollTo({ top: 300 })); + let title = bar.locator(".phone-top-bar-title > .truncate"); + await expect(bar.locator(".phone-top-bar-title")).toHaveCSS("opacity", "1"); + expect(await title.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true); + let titleBox = (await title.boundingBox())!; + let face = (await people.boundingBox())!; + expect(titleBox.x + titleBox.width).toBeLessThanOrEqual(face.x); + // Needing room, the title takes it from the faces first: one face, the other counted. + await expect(faces).toHaveCount(1); + await expect(people.locator(".phone-top-bar-more")).toHaveText("+1"); + await expect(people).toHaveAccessibleName(/\bbo\b/); + expect(await buildToFaces(page)).toBeGreaterThanOrEqual(12); + for ( + let control of [ + bar.getByRole("button", { name: "Documents", exact: true }), + bar.getByRole("button", { name: /^Actions for / }), + ] + ) { + let box = (await control.boundingBox())!; + expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(43.5); + } + }); +} + /** Space between the current tab's label and its pill's rounded edge, at the label's lower part. */ async function labelClearance(page: Page): Promise { return page.locator(".phone-tab-bar").evaluate(bar => { diff --git a/e2e/responsive-child-phone.e2e.ts b/e2e/responsive-child-phone.e2e.ts index 091ea31a4..ec1dca950 100644 --- a/e2e/responsive-child-phone.e2e.ts +++ b/e2e/responsive-child-phone.e2e.ts @@ -136,6 +136,10 @@ test("a child opens for reading on a phone and the tab bar's pencil edits it", a await expect(childText(surface)).toBeFocused(); await expect(done(page)).toBeVisible(); await expect(page.locator(".phone-top-bar-mode")).toHaveText("Editing"); + // Editing joins the child's place rather than replacing it. + await expect(page.locator(".phone-top-bar-subtitle")).toHaveText( + new RegExp(`^in Test ${room.slice(0, 8)}\\s*·\\s*Editing$`), + ); // The parent beneath keeps reading. await expect(parentText(page)).toHaveAttribute("contenteditable", "false"); await page.keyboard.type("Typed "); @@ -155,6 +159,63 @@ test("a child opens for reading on a phone and the tab bar's pencil edits it", a } }); +test("a child takes the phone's top bar: Back, its own title and actions, and its parent", async ({ baseURL, browser, room, seed }) => { + await seed("# Parent\n\nThe parent stays put beneath its child.\n"); + let { context, page, surface } = await openChild(browser, baseURL!, room, "ana", PHONE); + try { + let parent = `Test ${room.slice(0, 8)}`; + let child = (await surface.getAttribute("aria-label"))!.replace("Child document: ", ""); + let bar = page.locator(".phone-top-bar"); + await expect(bar).toHaveCount(1); + // The bar replaces the stack bar the compact layout keeps. + await expect(page.getByRole("navigation", { name: "Document stack" })).toHaveCount(0); + await expect(bar.getByRole("button", { name: "Documents", exact: true })).toHaveCount(0); + await expect(bar.locator(".phone-top-bar-title")).toHaveText(child); + await expect(bar.locator(".phone-top-bar-title")).toHaveCSS("opacity", "1"); + await expect(bar.locator(".phone-top-bar-subtitle")).toHaveText(`in ${parent}`); + await expect(bar.getByRole("group", { name: /^People here: .*\bana\b/ })).toBeVisible(); + // The document under the bar starts below it. + let barBox = (await bar.boundingBox())!; + let heading = (await surface.getByRole("heading", { level: 1 }).boundingBox())!; + expect(heading.y).toBeGreaterThanOrEqual(barBox.y + barBox.height); + + // Its actions are the child's. + await bar.getByRole("button", { name: `Actions for ${child}` }).tap(); + await page.getByRole("button", { name: "Rename", exact: true }).tap(); + let field = page.getByRole("textbox", { name: "Document title" }); + await expect(field).toBeFocused(); + await expect(field).toHaveValue(child); + await field.press("Escape"); + await expect(field).toHaveCount(0); + + // Off Document the bar is the root's again: its Documents, title and build. + let tabs = page.getByRole("navigation", { name: "Workspace view" }); + for (let tab of [/^Chat/, /^Decisions/]) { + await tabs.getByRole("button", { name: tab }).tap(); + await expect(bar.getByRole("button", { name: "Documents", exact: true })).toBeVisible(); + await expect(bar.getByRole("button", { name: /^Back to / })).toHaveCount(0); + await expect(bar.locator(".phone-top-bar-title")).toHaveText(parent); + await expect(bar.locator(".phone-top-bar-subtitle")).toContainText("score"); + await expect(bar.getByRole("button", { name: `Actions for ${parent}` })).toBeVisible(); + } + await tabs.getByRole("button", { name: "Document", exact: true }).tap(); + await expect(surface).toBeVisible(); + await expect(bar.locator(".phone-top-bar-title")).toHaveText(child); + await expect(bar.locator(".phone-top-bar-subtitle")).toHaveText(`in ${parent}`); + + let back = bar.getByRole("button", { name: `Back to ${parent}` }); + let backBox = (await back.boundingBox())!; + expect(Math.min(backBox.width, backBox.height)).toBeGreaterThanOrEqual(43.5); + await back.tap(); + await expect(surface).toHaveCount(0); + await expect(page).toHaveURL(url => url.pathname.endsWith(`/test-${room.slice(0, 8)}`)); + await expect(bar.getByRole("button", { name: "Documents", exact: true })).toBeVisible(); + await expect(bar.locator(".phone-top-bar-subtitle")).toContainText("score"); + } finally { + await context.close(); + } +}); + test("a child's formatting docks above the keyboard in the parent's bar", async ({ baseURL, browser, room, seed }) => { await seed("# Parent\n\nThe parent stays put beneath its child.\n"); let { context, page, surface } = await openChild( diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index bb2524139..e1fe8f449 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -276,7 +276,7 @@ 1 ] ], - "sourceHash": "6dcdd2c5d6243bdc297c311742fc47e8e5da433d201b6e1b728c42c714d9a9c4" + "sourceHash": "443502c52a0e791e696f3624e02ab96a4a85891cf3b3567ec42957dd1407de57" }, { "file": "apps/web/src/navigation-dialog.tsx",