+ <>
+
{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 && (
-
Editing
+ {(lead || status) && (
+
+ {lead && {lead}}
+ {lead && status && ·}
+ {status}
+
)}
- {build}
+ {people.length > 0 && (
+
+ {people.slice(0, shown).map(handle => (
+
+ ))}
+ {people.length > shown && (
+
+ +{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",