diff --git a/apps/web/src/empty-workspace.tsx b/apps/web/src/empty-workspace.tsx
index 4f206d481..90a8a1030 100644
--- a/apps/web/src/empty-workspace.tsx
+++ b/apps/web/src/empty-workspace.tsx
@@ -1,4 +1,4 @@
-import { useWorkspaceLayout } from "./workspace";
+import { WorkspaceNotice } from "./workspace-notice";
export function EmptyWorkspace(
{ disabled, hasProjects, onAddProject, onNewDocument }: {
@@ -8,32 +8,22 @@ export function EmptyWorkspace(
onNewDocument: () => void;
},
) {
- let { frame, mode } = useWorkspaceLayout();
return (
-
.room-header {
+ display: none;
+ }
+}
+
+.workspace-notice-frame {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ margin: calc(var(--spacing) * 3) calc(var(--spacing) * 3 + env(safe-area-inset-right))
+ calc(var(--spacing) * 3) calc(var(--spacing) * 3 + env(safe-area-inset-left));
+}
+
+.navigation-content[data-project-sidebar-trigger] .workspace-notice-frame {
+ padding-left: calc(44px - var(--spacing) * 3);
+}
+
+.workspace-notice-sheet {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing);
+ min-width: 0;
+ padding-inline: calc(var(--spacing) * 4);
+ overflow: hidden;
+ border-radius: var(--radius-panel);
+ background: var(--color-ground);
+ box-shadow: var(--shadow-raised);
+ outline: var(--edge-width) solid var(--color-edge);
+ outline-offset: calc(-1 * var(--edge-width));
+ text-align: center;
+}
+
+@container workspace-notice (width < 724px) {
+ .workspace-notice > .room-header {
+ display: block;
+ flex: none;
+ }
+
+ .navigation-content[data-project-sidebar-trigger] .workspace-notice-frame,
+ .workspace-notice-frame {
+ margin: calc(var(--spacing) * 2);
+ padding-left: 0;
+ }
+
+ .workspace-notice-sheet {
+ box-shadow: var(--shadow-resting);
+ }
+}
+
.anchored-child-host {
position: relative;
height: 100%;
diff --git a/apps/web/src/phone-skeleton.css b/apps/web/src/phone-skeleton.css
index 1245629b7..e788f9098 100644
--- a/apps/web/src/phone-skeleton.css
+++ b/apps/web/src/phone-skeleton.css
@@ -207,6 +207,180 @@
}
}
+/*
+ * The desktop loading shape (workspace-skeleton.tsx), drawn to the loaded workspace's geometry:
+ * split from a 700px frame (`workspaceSizing`), Chat clamped between 250px and leaving the
+ * document 450px, and compact with the ground header and tab row. `workspace-skeleton.e2e.ts`
+ * holds the two together.
+ */
+.workspace-skeleton {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ background: var(--color-ground);
+ container: workspace-skeleton / inline-size;
+}
+
+.workspace-skeleton > .room-header,
+.workspace-skeleton-tabs {
+ display: none;
+}
+
+.workspace-skeleton-frame {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ margin: 0 calc(var(--spacing) * 3 + env(safe-area-inset-right)) calc(var(--spacing) * 3)
+ calc(var(--spacing) * 3 + env(safe-area-inset-left));
+}
+
+.workspace-skeleton-chat {
+ flex: none;
+ width: min(
+ max(250px, 100% - 450px),
+ max(250px, var(--saved-chat-width, 500px))
+ );
+ margin-top: calc(var(--spacing) * 3);
+}
+
+.workspace-skeleton-sheet {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-width: 0;
+ margin-top: calc(var(--spacing) * 3);
+ overflow: hidden;
+ border-radius: var(--radius-panel);
+ background: var(--color-page);
+ box-shadow: var(--shadow-raised);
+}
+
+.workspace-skeleton[data-chat="closed"] .workspace-skeleton-chat {
+ display: none;
+}
+
+/* The floating Show sidebar trigger: Chat starts below it, or the sheet clears it. */
+.navigation-content[data-project-sidebar-trigger] .workspace-skeleton-chat {
+ margin-top: var(--document-shell-header-height);
+}
+
+@container workspace-skeleton (width >= 724px) {
+ .workspace-skeleton-sheet {
+ outline: var(--edge-width) solid var(--color-edge);
+ outline-offset: calc(-1 * var(--edge-width));
+ }
+
+ .navigation-content[data-project-sidebar-trigger]
+ .workspace-skeleton[data-chat="closed"]
+ .workspace-skeleton-frame {
+ padding-left: calc(44px - var(--spacing) * 3);
+ }
+}
+
+.workspace-skeleton-title,
+.workspace-skeleton-views {
+ display: flex;
+ flex: none;
+ align-items: center;
+ gap: calc(var(--spacing) * 3);
+ block-size: var(--panel-header-height);
+ padding-inline: var(--panel-toolbar-padding-inline);
+}
+
+.workspace-skeleton-title {
+ border-bottom: var(--edge-width) solid var(--color-edge);
+}
+
+.workspace-skeleton-title > span,
+.workspace-skeleton-views > span,
+.workspace-skeleton-tabs > span::before {
+ height: calc(var(--spacing) * 3);
+ border-radius: var(--radius-sm);
+ background: var(--color-selected);
+ animation: phone-skeleton-pulse calc(var(--duration-linger) * 2.5) var(--ease-out) infinite
+ alternate;
+}
+
+.workspace-skeleton-title > span {
+ width: calc(var(--spacing) * 36);
+ margin-inline-start: calc(var(--spacing) * 10);
+}
+
+.workspace-skeleton-views > span {
+ width: calc(var(--spacing) * 16);
+ margin-inline-start: calc(var(--spacing) * 2);
+}
+
+.workspace-skeleton-page {
+ --workspace-skeleton-gutter: var(--document-gutter);
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+
+ & > .phone-skeleton-page {
+ max-width: calc(var(--document-prose-measure) + var(--workspace-skeleton-gutter) * 2);
+ margin-inline: auto;
+ padding: var(--document-top-inset) var(--workspace-skeleton-gutter) 0;
+ }
+}
+
+@media (max-width: 600px) {
+ .workspace-skeleton-page {
+ --workspace-skeleton-gutter: var(--document-gutter-compact);
+ }
+}
+
+/* Compact: the 700px frame plus its two 12px margins. */
+@container workspace-skeleton (width < 724px) {
+ .workspace-skeleton > .room-header {
+ display: block;
+ flex: none;
+ }
+
+ .workspace-skeleton-tabs {
+ display: grid;
+ flex: none;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ padding: var(--spacing) calc(var(--spacing) + env(safe-area-inset-right)) var(--spacing)
+ calc(var(--spacing) + env(safe-area-inset-left));
+ border-bottom: var(--edge-width) solid var(--color-edge);
+
+ & > span {
+ display: grid;
+ min-height: calc(var(--spacing) * 11);
+ place-items: center;
+ }
+
+ & > span::before {
+ width: calc(var(--spacing) * 16);
+ content: "";
+ }
+ }
+
+ .workspace-skeleton-frame {
+ margin-block: calc(var(--spacing) * 2);
+ overflow: hidden;
+ border-radius: var(--radius-panel);
+ background: var(--color-page);
+ box-shadow: var(--shadow-resting);
+ outline: var(--edge-width) solid var(--color-edge);
+ outline-offset: calc(-1 * var(--edge-width));
+ }
+
+ .workspace-skeleton-chat,
+ .workspace-skeleton-title,
+ .workspace-skeleton-views {
+ display: none;
+ }
+
+ .workspace-skeleton-sheet {
+ margin-top: 0;
+ border-radius: 0;
+ box-shadow: none;
+ }
+}
+
/* Until a document first arrives, its column shows the same lines in place of the empty editor. */
[data-plan-scroll]:has(> .plan-preface > .phone-skeleton-page) > .mdxeditor {
visibility: hidden;
@@ -220,6 +394,9 @@
@media (prefers-reduced-motion: reduce) {
.phone-skeleton-page > span,
+ .workspace-skeleton-title > span,
+ .workspace-skeleton-views > span,
+ .workspace-skeleton-tabs > span::before,
.phone-skeleton-bar > span::before,
.phone-skeleton-bar > span::after {
animation: none;
diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx
index f8ad2eaea..10872a5d7 100644
--- a/apps/web/src/room-workspace.tsx
+++ b/apps/web/src/room-workspace.tsx
@@ -1129,7 +1129,7 @@ export function RoomWorkspace(
if (opensEditing({ empty: planState.empty })) setEditing(true);
}, [arrived, planState.synced, planState.empty]);
// A failed open says so instead; the lines would otherwise pulse beside it indefinitely.
- let loading = phone && !arrived && !planState.failed;
+ let loading = !arrived && !planState.failed;
// A child on a phone borrows its parent's tab bar (see child-phone-chrome.ts).
let childChrome = useContext(ChildPhoneChromeContext);
// Where this room's title goes when it is an open child, and where its own child's goes.
diff --git a/apps/web/src/workspace-notice.tsx b/apps/web/src/workspace-notice.tsx
index f0b4a4d26..58425ae4f 100644
--- a/apps/web/src/workspace-notice.tsx
+++ b/apps/web/src/workspace-notice.tsx
@@ -1,16 +1,22 @@
import type { ReactNode } from "react";
-/** A message inside the workspace frame, so the sidebar and header stay in place. */
+/**
+ * A message inside the workspace frame, so the sidebar and frame stay in place. Its sheet sits
+ * where the document's does: at the top margin in split, below the ground header in compact
+ * (navigation.css).
+ */
export function WorkspaceNotice(
{ actions, body, title }: { actions?: ReactNode; body?: ReactNode; title: string },
) {
return (
-
-
-
-
{title}
- {body &&
{body}
}
- {actions &&
{actions}
}
+
+
+
+
+
{title}
+ {body &&
{body}
}
+ {actions &&
{actions}
}
+
);
diff --git a/apps/web/src/workspace-skeleton.tsx b/apps/web/src/workspace-skeleton.tsx
new file mode 100644
index 000000000..dd80254dc
--- /dev/null
+++ b/apps/web/src/workspace-skeleton.tsx
@@ -0,0 +1,60 @@
+/**
+ * The desktop loading shape: the workspace's panes and bars where the loaded workspace puts them,
+ * so nothing moves when it arrives. It is in the initial bundle, so CSS does the layout:
+ * a container query picks split or compact and sizes Chat as `workspaceSizing` does (see
+ * phone-skeleton.css). `workspace-skeleton.e2e.ts` holds it to the loaded geometry. Phones use
+ * `PhoneSkeleton`.
+ */
+
+import { PhoneSkeletonPage } from "./phone-skeleton";
+
+import type { CSSProperties } from "react";
+
+/** The root workspace's saved Chat choice and width, under the keys `workspace.tsx` writes. */
+function savedChat(): { closed: boolean; width?: string } {
+ try {
+ let width = Number(localStorage.getItem("chopin:pane:chat"));
+ return {
+ closed: localStorage.getItem("chopin:pane:chat:choice") === "false",
+ width: Number.isFinite(width) && width > 0 ? `${width}px` : undefined,
+ };
+ } catch {
+ return { closed: false };
+ }
+}
+
+export function WorkspaceSkeleton({ label }: { label: string }) {
+ let chat = savedChat();
+ return (
+
+
{label}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/e2e/workspace-skeleton.e2e.ts b/e2e/workspace-skeleton.e2e.ts
new file mode 100644
index 000000000..5c0c50206
--- /dev/null
+++ b/e2e/workspace-skeleton.e2e.ts
@@ -0,0 +1,168 @@
+/**
+ * The desktop workspace paints its shape while its code and its document load. The skeleton's
+ * sheet, bars and Chat column sit where the loaded workspace puts them, so nothing jumps.
+ */
+
+import { authenticate, expect, ready, roomPath, test } from "./room";
+import { RESPONSIVE_SOURCE } from "./responsive";
+
+import type { Locator, Page } from "@playwright/test";
+
+type Box = { x: number; y: number; width: number; height: number };
+type Part = "sheet" | "title" | "views" | "chat" | "header" | "tabs" | "frame";
+
+async function box(locator: Locator): Promise
{
+ let value = await locator.boundingBox();
+ expect(value).not.toBeNull();
+ return value!;
+}
+
+function expectSameBox(actual: Box, expected: Box, label: string) {
+ for (let key of ["x", "y", "width", "height"] as const) {
+ expect(Math.abs(actual[key] - expected[key]), `${label} ${key}`).toBeLessThanOrEqual(1);
+ }
+}
+
+const SKELETON: Record = {
+ sheet: ".workspace-skeleton-sheet",
+ title: ".workspace-skeleton-title",
+ views: ".workspace-skeleton-views",
+ chat: ".workspace-skeleton-chat",
+ header: ".workspace-skeleton > .room-header",
+ tabs: ".workspace-skeleton-tabs",
+ frame: ".workspace-skeleton-frame",
+};
+
+const LOADED: Record = {
+ sheet: "main.workspace-document-panel",
+ title: "[data-document-title-bar]",
+ views: "[data-document-toolbar]",
+ chat: "aside.workspace-chat-panel .workspace-chat-body",
+ header: ".workspace-header-slot .room-header",
+ tabs: "nav.workspace-navigation",
+ frame: ".workspace-root .workspace-frame",
+};
+
+async function measure(page: Page, selectors: Record, parts: Part[]) {
+ let boxes = new Map();
+ for (let part of parts) boxes.set(part, await box(page.locator(selectors[part]).first()));
+ return boxes;
+}
+
+const CHAT_CLOSED = { "chopin:pane:chat:choice": "false" };
+const SIDEBAR_COLLAPSED = { "chopin:pane:projects:collapsed": "true" };
+const SPLIT = { width: 1440, height: 900 };
+
+const CASES: { name: string; viewport: typeof SPLIT; storage?: object; parts: Part[] }[] = [
+ { name: "split", viewport: SPLIT, parts: ["sheet", "title", "views", "chat"] },
+ {
+ name: "split with Chat closed",
+ viewport: SPLIT,
+ storage: CHAT_CLOSED,
+ parts: ["sheet", "title", "views"],
+ },
+ {
+ name: "split under the sidebar trigger",
+ viewport: SPLIT,
+ storage: { ...SIDEBAR_COLLAPSED, "chopin:pane:chat": "640" },
+ parts: ["sheet", "title", "views", "chat"],
+ },
+ {
+ name: "expanded split under the sidebar trigger",
+ viewport: SPLIT,
+ storage: { ...SIDEBAR_COLLAPSED, ...CHAT_CLOSED },
+ parts: ["sheet", "title", "views"],
+ },
+ { name: "compact", viewport: { width: 390, height: 844 }, parts: ["header", "tabs", "frame"] },
+];
+
+for (let { name, parts, storage, viewport } of CASES) {
+ test(`the ${name} skeleton holds the loaded workspace's geometry`, async ({ baseURL, browser, room, seed }) => {
+ await seed(RESPONSIVE_SOURCE);
+ let context = await browser.newContext({ baseURL, viewport });
+ try {
+ let page = await context.newPage();
+ await authenticate(page, "ana", baseURL!);
+ if (storage) {
+ await page.addInitScript(entries => {
+ for (let [key, value] of Object.entries(entries)) localStorage.setItem(key, value);
+ }, storage as Record);
+ }
+ // Hold the session, the workspace's code, then its document, so each stage stays on screen.
+ let releaseSession!: () => void;
+ let session = new Promise(resolve => (releaseSession = resolve));
+ await page.route("**/api/session", async route => {
+ await session;
+ await route.continue();
+ });
+ let releaseCode!: () => void;
+ let code = new Promise(resolve => (releaseCode = resolve));
+ await page.route(/\/assets\/(document-route-view|room-workspace)-[^/]*\.js$/, async route => {
+ await code;
+ await route.continue();
+ });
+ let releaseSocket!: () => void;
+ let socket = new Promise(resolve => (releaseSocket = resolve));
+ await page.routeWebSocket(/\/ws(\?.*)?$/, async ws => {
+ await socket;
+ ws.connectToServer();
+ });
+ await page.goto(roomPath(room));
+
+ // Before the session the sidebar has no width yet, so the ground stays blank.
+ await expect(page.getByRole("status")).toHaveText("Loading");
+ let skeleton = page.locator(".workspace-skeleton");
+ await expect(skeleton).toHaveCount(0);
+ releaseSession();
+ await expect(skeleton).toBeVisible();
+ await expect(skeleton.getByRole("status")).toHaveText("Opening document…");
+ let heading = skeleton.locator(".phone-skeleton-heading");
+ await expect(heading).toBeVisible();
+ let shell = await measure(page, SKELETON, parts);
+ let lines = await box(heading);
+ await expect(skeleton.locator(".workspace-skeleton-chat")).toBeVisible({
+ visible: !!parts.includes("chat"),
+ });
+
+ // The workspace mounts before its document arrives, with the same lines in the column.
+ releaseCode();
+ await expect(skeleton).toHaveCount(0);
+ let preface = page.locator("[data-plan-scroll] .plan-preface .phone-skeleton-heading");
+ await expect(preface).toBeVisible();
+ let mounted = await measure(page, LOADED, parts);
+ for (let part of parts) expectSameBox(mounted.get(part)!, shell.get(part)!, part);
+ expectSameBox(await box(preface), lines, "document lines");
+
+ releaseSocket();
+ await ready(page);
+ await expect(preface).toHaveCount(0);
+ await expect(page.locator(".plan-content h1").first()).toBeVisible();
+ let loaded = await measure(page, LOADED, parts);
+ for (let part of parts) expectSameBox(loaded.get(part)!, shell.get(part)!, part);
+ } finally {
+ await context.close();
+ }
+ });
+}
+
+test("a split workspace notice rises to the sheet's top margin, with no header row above it", async ({ baseURL, browser, room, seed }) => {
+ await seed(RESPONSIVE_SOURCE);
+ let context = await browser.newContext({ baseURL, viewport: { width: 1440, height: 900 } });
+ try {
+ let page = await context.newPage();
+ await authenticate(page, "ana", baseURL!);
+ await page.goto(roomPath(room));
+ await ready(page);
+ let sheet = await box(page.locator(LOADED.sheet));
+
+ await page.goto("/documents/nobody/nothing");
+ let title = page.getByRole("heading", { name: "nobody/nothing isn't one of your projects" });
+ await expect(title).toBeVisible();
+ await expect(page.locator(".room-header")).toBeHidden();
+ let notice = await box(title.locator(".."));
+ expect(Math.abs(notice.y - sheet.y)).toBeLessThanOrEqual(1);
+ expect(Math.abs(notice.y + notice.height - (sheet.y + sheet.height))).toBeLessThanOrEqual(1);
+ } finally {
+ await context.close();
+ }
+});