From a0127297279ae77f5e2383190713f47cbb6817d6 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:51:14 +0100 Subject: [PATCH 1/6] Give the split workspace's loading states the two-bar sheet, with no ground header The desktop document route now paints a workspace skeleton while its code loads: the sheet with its 40px title bar and views bar, the Chat column at its stored width and choice, and the document lines where the mounted editor's preface draws them. It takes the workspace's own layout classes, so the loaded workspace lands on the same geometry; compact keeps its header and tab row. The mounted workspace keeps the lines until the document first arrives on every device, not only phones, instead of an empty editor. The empty workspace and workspace notices no longer reserve the old ground header row in split: their sheet rises to the top margin and clears the floating sidebar trigger as an expanded document does. useWorkspaceLayout, the Chat pane bounds and the stored Chat choice move to small modules so the skeleton can be in the initial bundle without pulling the workspace chunk in; the initial JavaScript budget grows 2 KB raw. Co-Authored-By: Claude Opus 5.5 --- apps/web/bundle-budget.ts | 3 +- apps/web/src/empty-workspace.tsx | 51 +++++---- apps/web/src/hosted.tsx | 5 +- apps/web/src/pane-motion.ts | 10 -- apps/web/src/phone-skeleton.css | 64 ++++++++++++ apps/web/src/room-workspace.tsx | 5 +- apps/web/src/workspace-layout.ts | 45 ++++++++ apps/web/src/workspace-model.test.ts | 3 +- apps/web/src/workspace-model.ts | 7 -- apps/web/src/workspace-notice.tsx | 35 +++++-- apps/web/src/workspace-sizing.ts | 14 +++ apps/web/src/workspace-skeleton.tsx | 99 ++++++++++++++++++ apps/web/src/workspace.tsx | 49 ++------- e2e/workspace-skeleton.e2e.ts | 150 +++++++++++++++++++++++++++ 14 files changed, 446 insertions(+), 94 deletions(-) create mode 100644 apps/web/src/workspace-layout.ts create mode 100644 apps/web/src/workspace-skeleton.tsx create mode 100644 e2e/workspace-skeleton.e2e.ts diff --git a/apps/web/bundle-budget.ts b/apps/web/bundle-budget.ts index 4da27ab77..c0316c610 100644 --- a/apps/web/bundle-budget.ts +++ b/apps/web/bundle-budget.ts @@ -17,9 +17,10 @@ export type JavaScriptBudget = { gzip: number; raw: number }; // Includes the keyboard focus recovery and document-creation guards/progress. // The latter adds a 1 KB compressed allowance, including Linux gzip variation. // Shared menu dismissal and the account menu keyboard contract add 1 KB raw. +// The desktop workspace skeleton, which must paint before any lazy chunk, adds 2 KB raw. export const INITIAL_JAVASCRIPT_BUDGET: JavaScriptBudget = { gzip: 81_500, - raw: 256_000, + raw: 258_000, }; export function enforceInitialJavaScript( diff --git a/apps/web/src/empty-workspace.tsx b/apps/web/src/empty-workspace.tsx index 4f206d481..e1befe14f 100644 --- a/apps/web/src/empty-workspace.tsx +++ b/apps/web/src/empty-workspace.tsx @@ -1,4 +1,4 @@ -import { useWorkspaceLayout } from "./workspace"; +import { useWorkspaceLayout } from "./workspace-layout"; export function EmptyWorkspace( { disabled, hasProjects, onAddProject, onNewDocument }: { @@ -9,30 +9,39 @@ export function EmptyWorkspace( }, ) { let { frame, mode } = useWorkspaceLayout(); + let split = mode === "split"; + // Split has no ground header: the sheet rises to the top margin, as an expanded document does. return ( -
-
+
+ {!split &&
}
-

No document open

-

- {hasProjects - ? "Pick one from the sidebar or start a new one." - : "Add a project to start your first document."} -

- +

No document open

+

+ {hasProjects + ? "Pick one from the sidebar or start a new one." + : "Add a project to start your first document."} +

+ +
); diff --git a/apps/web/src/hosted.tsx b/apps/web/src/hosted.tsx index 69db777b8..eb5399879 100644 --- a/apps/web/src/hosted.tsx +++ b/apps/web/src/hosted.tsx @@ -8,6 +8,7 @@ import { rememberOriginRect } from "./child-origin"; import { WorkspaceNotice } from "./workspace-notice"; import { NavigationShell } from "./navigation-shell"; import { PhoneDocumentSkeleton } from "./phone-skeleton"; +import { WorkspaceSkeleton } from "./workspace-skeleton"; import type { ReactNode } from "react"; import type { ResearchOpener } from "@chopin/editor"; @@ -100,10 +101,10 @@ export function retryableChannelFailure(error: unknown): boolean { || error.status >= 500; } -/** On a phone, a document's loading state is the workspace's own shape rather than a label. */ +/** A document's loading state is the workspace's own shape rather than a label. */ function Loading({ document, label = "Loading" }: { document?: boolean; label?: string }) { let phone = usePhone(); - if (document && phone) return ; + if (document) return phone ? : ; return (
) { }; }, [target]); } - -/** The workspace waits for the sidebar track to settle before it re-measures its layout. */ -export function sidebarMoving(): boolean { - return document.getAnimations().some(animation => - animation instanceof CSSTransition && animation.transitionProperty === "width" - && animation.effect instanceof KeyframeEffect - && animation.effect.target instanceof Element - && animation.effect.target.matches(".motion-sidebar") - ); -} diff --git a/apps/web/src/phone-skeleton.css b/apps/web/src/phone-skeleton.css index 1245629b7..a17875957 100644 --- a/apps/web/src/phone-skeleton.css +++ b/apps/web/src/phone-skeleton.css @@ -207,6 +207,67 @@ } } +/* + * The desktop loading shape (workspace-skeleton.tsx). The workspace's own classes place its panes + * and bars; these only draw quiet marks in them, and the lines sit where the document's preface + * puts them. + */ +.workspace-skeleton-title, +.workspace-skeleton-views { + gap: calc(var(--spacing) * 3); + + & > span { + 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-tabs > span { + display: grid; + place-items: center; + + &::before { + width: calc(var(--spacing) * 16); + height: calc(var(--spacing) * 3); + border-radius: var(--radius-sm); + background: var(--color-selected); + content: ""; + animation: phone-skeleton-pulse calc(var(--duration-linger) * 2.5) var(--ease-out) infinite + alternate; + } +} + +.workspace-skeleton-page { + --workspace-skeleton-gutter: var(--document-gutter); + overflow: hidden; + background: var(--color-page); + + & > .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); + } +} + /* 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 +281,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..11354b6c3 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -95,7 +95,8 @@ import { Wire } from "./wire"; import { ExperimentStore } from "./experiments/store"; import { InvestigationReference, removableInvestigation } from "./experiments/card"; import { EvidenceDecisions, ViewDecision } from "./experiments/evidence"; -import { useWorkspaceIds, useWorkspaceLayout, useWorkspaceState, Workspace } from "./workspace"; +import { useWorkspaceIds, useWorkspaceState, Workspace } from "./workspace"; +import { useWorkspaceLayout } from "./workspace-layout"; import { initialDocumentView, presentWorkspace, workspaceProfile } from "./workspace-model"; import type { ConversationPlan, Plan, Question, Research, Session } from "@chopin/protocol"; @@ -1129,7 +1130,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-layout.ts b/apps/web/src/workspace-layout.ts new file mode 100644 index 000000000..86b0cf087 --- /dev/null +++ b/apps/web/src/workspace-layout.ts @@ -0,0 +1,45 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { usePhone } from "@chopin/editor/pointer"; + +import { workspaceSizing } from "./workspace-sizing"; + +/** The workspace waits for the sidebar track to settle before it re-measures its layout. */ +function sidebarMoving(): boolean { + return document.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === "width" + && animation.effect instanceof KeyframeEffect + && animation.effect.target instanceof Element + && animation.effect.target.matches(".motion-sidebar") + ); +} + +/** Measures the workspace frame to choose split or compact, for the workspace and its skeleton. */ +export function useWorkspaceLayout() { + let frame = useRef(null); + let [available, setAvailable] = useState(() => Math.max(0, window.innerWidth - 24)); + useLayoutEffect(() => { + let element = frame.current; + if (!element) return; + let measure = () => { + if (!sidebarMoving()) setAvailable(element.clientWidth); + }; + measure(); + let observer = new ResizeObserver(measure); + observer.observe(element); + document.addEventListener("transitionend", measure); + document.addEventListener("transitioncancel", measure); + return () => { + observer.disconnect(); + document.removeEventListener("transitionend", measure); + document.removeEventListener("transitioncancel", measure); + }; + }, []); + // A phone keeps one destination at a time even when landscape gives it a wide window. + let phone = usePhone(); + return { + available, + frame, + mode: phone ? "compact" as const : workspaceSizing(available, 500).mode, + phone, + }; +} diff --git a/apps/web/src/workspace-model.test.ts b/apps/web/src/workspace-model.test.ts index 74695cbb6..c2ef0617a 100644 --- a/apps/web/src/workspace-model.test.ts +++ b/apps/web/src/workspace-model.test.ts @@ -1,16 +1,15 @@ import { describe, expect, it } from "bun:test"; import { - CHAT_CHOICE_STORAGE_KEY, initialDocumentView, initialWorkspaceState, presentWorkspace, - storedDesktopChat, transitionWorkspace, workspaceDestinations, workspaceHeadingId, workspaceProfile, } from "./workspace-model"; +import { CHAT_CHOICE_STORAGE_KEY, storedDesktopChat } from "./workspace-sizing"; import type { WorkspaceState } from "./workspace-model"; diff --git a/apps/web/src/workspace-model.ts b/apps/web/src/workspace-model.ts index c64e32956..b25e1aa00 100644 --- a/apps/web/src/workspace-model.ts +++ b/apps/web/src/workspace-model.ts @@ -59,13 +59,6 @@ export type WorkspaceEvent = | { type: "set-chat"; open: boolean } | { type: "set-desktop-chat"; open: boolean }; -export const CHAT_CHOICE_STORAGE_KEY = "chopin:pane:chat:choice"; - -export function storedDesktopChat(value: string | null): boolean | undefined { - if (value === "true") return true; - if (value === "false") return false; -} - export function initialWorkspaceState( desktopChatOpen: boolean | undefined, ): WorkspaceState { diff --git a/apps/web/src/workspace-notice.tsx b/apps/web/src/workspace-notice.tsx index f0b4a4d26..af4f42b30 100644 --- a/apps/web/src/workspace-notice.tsx +++ b/apps/web/src/workspace-notice.tsx @@ -1,16 +1,37 @@ +import { useWorkspaceLayout } from "./workspace-layout"; + 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. Split has no + * ground header, so the message rises to the sheet's top margin, as an expanded document does. + */ export function WorkspaceNotice( { actions, body, title }: { actions?: ReactNode; body?: ReactNode; title: string }, ) { + let { frame, mode } = useWorkspaceLayout(); + let split = mode === "split"; return ( -
-
-
-

{title}

- {body &&

{body}

} - {actions &&
{actions}
} +
+ {!split &&
} +
+
+

{title}

+ {body &&

{body}

} + {actions &&
{actions}
} +
); diff --git a/apps/web/src/workspace-sizing.ts b/apps/web/src/workspace-sizing.ts index 5b4914160..b2cf3a21d 100644 --- a/apps/web/src/workspace-sizing.ts +++ b/apps/web/src/workspace-sizing.ts @@ -1,5 +1,19 @@ import type { WorkspaceMode } from "./workspace-model"; +export const CHAT_PANE = { + initial: 500, + max: Number.MAX_SAFE_INTEGER, + min: 250, + storageKey: "chopin:pane:chat", +}; + +export const CHAT_CHOICE_STORAGE_KEY = "chopin:pane:chat:choice"; + +export function storedDesktopChat(value: string | null): boolean | undefined { + if (value === "true") return true; + if (value === "false") return false; +} + export function workspaceSizing(available: number, preferred: number): { chat: number; maximum: number; diff --git a/apps/web/src/workspace-skeleton.tsx b/apps/web/src/workspace-skeleton.tsx new file mode 100644 index 000000000..be1ea07c6 --- /dev/null +++ b/apps/web/src/workspace-skeleton.tsx @@ -0,0 +1,99 @@ +/** + * The desktop loading shape: the workspace's panes and bars where the loaded workspace puts them, + * so nothing moves when it arrives. Its elements take the workspace's own layout classes, and + * `workspace-skeleton.e2e.ts` holds the two to the same geometry. Phones use `PhoneSkeleton`. + */ + +import { useWorkspaceLayout } from "./workspace-layout"; +import { + CHAT_CHOICE_STORAGE_KEY, + CHAT_PANE, + storedDesktopChat, + workspaceSizing, +} from "./workspace-sizing"; +import { PhoneSkeletonPage } from "./phone-skeleton"; +import "./workspace-sizing.css"; + +import type { CSSProperties } from "react"; + +// Document, Decisions and Build; compact puts Chat beside them. +const VIEWS = 3; + +/** The loaded workspace's saved Chat choice and width, read before its first paint. */ +function storedChat(): { open: boolean | undefined; width: number } { + try { + return { + open: storedDesktopChat(localStorage.getItem(CHAT_CHOICE_STORAGE_KEY)), + // workspaceSizing clamps it, as it does the restored width. + width: Number(localStorage.getItem(CHAT_PANE.storageKey)) || CHAT_PANE.initial, + }; + } catch { + return { open: undefined, width: CHAT_PANE.initial }; + } +} + +export function WorkspaceSkeleton({ label }: { label: string }) { + let { available, frame, mode } = useWorkspaceLayout(); + let split = mode === "split"; + let stored = storedChat(); + let chat = split && (stored.open ?? true); + let width = workspaceSizing(available, stored.width).chat; + return ( +
+

{label}

+ {!split && ( + <> +
+ + + )} + + ); +} diff --git a/apps/web/src/workspace.tsx b/apps/web/src/workspace.tsx index 716ded4c1..8be38f715 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -5,24 +5,26 @@ import { Count } from "@chopin/editor/count"; import { ContentSwapLayer } from "@chopin/editor/content-swap"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; import { ArrowLeftIcon, ChevronIcon, ExpandIcon, MessageIcon } from "@chopin/icons"; -import { usePhone } from "@chopin/editor/pointer"; import { - CHAT_CHOICE_STORAGE_KEY, initialWorkspaceState, presentWorkspace, - storedDesktopChat, transitionWorkspace, workspaceDestinations, workspaceHeadingId, workspaceProfile, } from "./workspace-model"; import { clampPane, ResizeHandle, usePaneWidth } from "./resizable-pane"; -import { workspaceSizing } from "./workspace-sizing"; +import { + CHAT_CHOICE_STORAGE_KEY, + CHAT_PANE, + storedDesktopChat, + workspaceSizing, +} from "./workspace-sizing"; import "./workspace-sizing.css"; import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; -import { sidebarMoving, usePaneMotion, usePaneSettledWidth } from "./pane-motion"; +import { usePaneMotion, usePaneSettledWidth } from "./pane-motion"; import { listenForShortcuts } from "./global-shortcuts"; import { currentShortcutPlatform, shortcutLabel } from "./shortcuts"; import { DocumentActivityDot, documentActivityLabel } from "./document-activity"; @@ -43,13 +45,6 @@ import type { export type Pane = "chat"; -const CHAT_PANE = { - initial: 500, - max: Number.MAX_SAFE_INTEGER, - min: 250, - storageKey: "chopin:pane:chat", -}; - export type WorkspaceIds = { heading: Record; pane: Record; @@ -68,36 +63,6 @@ export function useWorkspaceIds(): WorkspaceIds { }; } -export function useWorkspaceLayout() { - let frame = useRef(null); - let [available, setAvailable] = useState(() => Math.max(0, window.innerWidth - 24)); - useLayoutEffect(() => { - let element = frame.current; - if (!element) return; - let measure = () => { - if (!sidebarMoving()) setAvailable(element.clientWidth); - }; - measure(); - let observer = new ResizeObserver(measure); - observer.observe(element); - document.addEventListener("transitionend", measure); - document.addEventListener("transitioncancel", measure); - return () => { - observer.disconnect(); - document.removeEventListener("transitionend", measure); - document.removeEventListener("transitioncancel", measure); - }; - }, []); - // A phone keeps one destination at a time even when landscape gives it a wide window. - let phone = usePhone(); - return { - available, - frame, - mode: phone ? "compact" as const : workspaceSizing(available, 500).mode, - phone, - }; -} - /** Only the desktop Chat preference crosses a page load. */ export function useWorkspaceState( profile: WorkspaceProfile, diff --git a/e2e/workspace-skeleton.e2e.ts b/e2e/workspace-skeleton.e2e.ts new file mode 100644 index 000000000..80cd4e1f8 --- /dev/null +++ b/e2e/workspace-skeleton.e2e.ts @@ -0,0 +1,150 @@ +/** + * 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 .workspace-document-panel", + title: ".workspace-skeleton-title", + views: ".workspace-skeleton-views", + chat: ".workspace-skeleton .workspace-chat-body", + header: ".workspace-skeleton .room-header", + tabs: ".workspace-skeleton-tabs", + frame: ".workspace-skeleton .workspace-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:not(.workspace-skeleton) .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 CASES = [ + { + name: "split", + viewport: { width: 1440, height: 900 }, + parts: ["sheet", "title", "views", "chat"] as Part[], + }, + { + name: "split with Chat closed", + viewport: { width: 1440, height: 900 }, + chat: "false", + parts: ["sheet", "title", "views"] as Part[], + }, + { + name: "compact", + viewport: { width: 390, height: 844 }, + parts: ["header", "tabs", "frame"] as Part[], + }, +]; + +for (let { chat, name, parts, 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 (chat) { + await page.addInitScript(value => { + localStorage.setItem("chopin:pane:chat:choice", value); + }, chat); + } + // Hold the workspace's code, then its document, so each loading stage stays on screen. + 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)); + + let skeleton = page.locator(".workspace-skeleton"); + 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-chat-panel")).toHaveCount( + name === "split" ? 1 : 0, + ); + + // 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")).toHaveCount(0); + 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(); + } +}); From cebd0fcfec33a9ce3f6a598aeb38f155337a7be8 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:51:38 +0100 Subject: [PATCH 2/6] Renew design-contract review for the workspace skeleton The reviewed dynamic owners in workspace.tsx (SurfaceToggle feedback, Chat pane presence) keep the same data flow; the file only lost the layout hook and Chat pane bounds, which moved to workspace-layout.ts and workspace-sizing.ts. Co-Authored-By: Claude Opus 5.5 --- scripts/design-contract/exceptions/dynamic-web.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index bb2524139..226d80d29 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": "470ba7243f13957997b98f3d746ce1c10a3a7f2c5512629c077d9e82044194c8" }, { "file": "apps/web/src/navigation-dialog.tsx", From 8769e4def5307a75b2b74393f444f6d90bfa50b1 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:28:11 +0100 Subject: [PATCH 3/6] Return the workspace layout hook and Chat pane bounds, and the budget, to main The skeleton no longer needs them in the initial bundle, so the moves to workspace-layout.ts and workspace-sizing.ts, the 2 KB raw budget raise and the design-contract renewal for workspace.tsx all go back. Co-Authored-By: Claude Opus 5.5 --- apps/web/bundle-budget.ts | 3 +- apps/web/src/pane-motion.ts | 10 ++++ apps/web/src/room-workspace.tsx | 3 +- apps/web/src/workspace-layout.ts | 45 ----------------- apps/web/src/workspace-model.test.ts | 3 +- apps/web/src/workspace-model.ts | 7 +++ apps/web/src/workspace-sizing.ts | 14 ------ apps/web/src/workspace.tsx | 49 ++++++++++++++++--- .../exceptions/dynamic-web.json | 2 +- 9 files changed, 64 insertions(+), 72 deletions(-) delete mode 100644 apps/web/src/workspace-layout.ts diff --git a/apps/web/bundle-budget.ts b/apps/web/bundle-budget.ts index c0316c610..4da27ab77 100644 --- a/apps/web/bundle-budget.ts +++ b/apps/web/bundle-budget.ts @@ -17,10 +17,9 @@ export type JavaScriptBudget = { gzip: number; raw: number }; // Includes the keyboard focus recovery and document-creation guards/progress. // The latter adds a 1 KB compressed allowance, including Linux gzip variation. // Shared menu dismissal and the account menu keyboard contract add 1 KB raw. -// The desktop workspace skeleton, which must paint before any lazy chunk, adds 2 KB raw. export const INITIAL_JAVASCRIPT_BUDGET: JavaScriptBudget = { gzip: 81_500, - raw: 258_000, + raw: 256_000, }; export function enforceInitialJavaScript( diff --git a/apps/web/src/pane-motion.ts b/apps/web/src/pane-motion.ts index 74340e706..1cb6ea7f9 100644 --- a/apps/web/src/pane-motion.ts +++ b/apps/web/src/pane-motion.ts @@ -86,3 +86,13 @@ export function usePaneSettledWidth(target: RefObject) { }; }, [target]); } + +/** The workspace waits for the sidebar track to settle before it re-measures its layout. */ +export function sidebarMoving(): boolean { + return document.getAnimations().some(animation => + animation instanceof CSSTransition && animation.transitionProperty === "width" + && animation.effect instanceof KeyframeEffect + && animation.effect.target instanceof Element + && animation.effect.target.matches(".motion-sidebar") + ); +} diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 11354b6c3..10872a5d7 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -95,8 +95,7 @@ import { Wire } from "./wire"; import { ExperimentStore } from "./experiments/store"; import { InvestigationReference, removableInvestigation } from "./experiments/card"; import { EvidenceDecisions, ViewDecision } from "./experiments/evidence"; -import { useWorkspaceIds, useWorkspaceState, Workspace } from "./workspace"; -import { useWorkspaceLayout } from "./workspace-layout"; +import { useWorkspaceIds, useWorkspaceLayout, useWorkspaceState, Workspace } from "./workspace"; import { initialDocumentView, presentWorkspace, workspaceProfile } from "./workspace-model"; import type { ConversationPlan, Plan, Question, Research, Session } from "@chopin/protocol"; diff --git a/apps/web/src/workspace-layout.ts b/apps/web/src/workspace-layout.ts deleted file mode 100644 index 86b0cf087..000000000 --- a/apps/web/src/workspace-layout.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useLayoutEffect, useRef, useState } from "react"; -import { usePhone } from "@chopin/editor/pointer"; - -import { workspaceSizing } from "./workspace-sizing"; - -/** The workspace waits for the sidebar track to settle before it re-measures its layout. */ -function sidebarMoving(): boolean { - return document.getAnimations().some(animation => - animation instanceof CSSTransition && animation.transitionProperty === "width" - && animation.effect instanceof KeyframeEffect - && animation.effect.target instanceof Element - && animation.effect.target.matches(".motion-sidebar") - ); -} - -/** Measures the workspace frame to choose split or compact, for the workspace and its skeleton. */ -export function useWorkspaceLayout() { - let frame = useRef(null); - let [available, setAvailable] = useState(() => Math.max(0, window.innerWidth - 24)); - useLayoutEffect(() => { - let element = frame.current; - if (!element) return; - let measure = () => { - if (!sidebarMoving()) setAvailable(element.clientWidth); - }; - measure(); - let observer = new ResizeObserver(measure); - observer.observe(element); - document.addEventListener("transitionend", measure); - document.addEventListener("transitioncancel", measure); - return () => { - observer.disconnect(); - document.removeEventListener("transitionend", measure); - document.removeEventListener("transitioncancel", measure); - }; - }, []); - // A phone keeps one destination at a time even when landscape gives it a wide window. - let phone = usePhone(); - return { - available, - frame, - mode: phone ? "compact" as const : workspaceSizing(available, 500).mode, - phone, - }; -} diff --git a/apps/web/src/workspace-model.test.ts b/apps/web/src/workspace-model.test.ts index c2ef0617a..74695cbb6 100644 --- a/apps/web/src/workspace-model.test.ts +++ b/apps/web/src/workspace-model.test.ts @@ -1,15 +1,16 @@ import { describe, expect, it } from "bun:test"; import { + CHAT_CHOICE_STORAGE_KEY, initialDocumentView, initialWorkspaceState, presentWorkspace, + storedDesktopChat, transitionWorkspace, workspaceDestinations, workspaceHeadingId, workspaceProfile, } from "./workspace-model"; -import { CHAT_CHOICE_STORAGE_KEY, storedDesktopChat } from "./workspace-sizing"; import type { WorkspaceState } from "./workspace-model"; diff --git a/apps/web/src/workspace-model.ts b/apps/web/src/workspace-model.ts index b25e1aa00..c64e32956 100644 --- a/apps/web/src/workspace-model.ts +++ b/apps/web/src/workspace-model.ts @@ -59,6 +59,13 @@ export type WorkspaceEvent = | { type: "set-chat"; open: boolean } | { type: "set-desktop-chat"; open: boolean }; +export const CHAT_CHOICE_STORAGE_KEY = "chopin:pane:chat:choice"; + +export function storedDesktopChat(value: string | null): boolean | undefined { + if (value === "true") return true; + if (value === "false") return false; +} + export function initialWorkspaceState( desktopChatOpen: boolean | undefined, ): WorkspaceState { diff --git a/apps/web/src/workspace-sizing.ts b/apps/web/src/workspace-sizing.ts index b2cf3a21d..5b4914160 100644 --- a/apps/web/src/workspace-sizing.ts +++ b/apps/web/src/workspace-sizing.ts @@ -1,19 +1,5 @@ import type { WorkspaceMode } from "./workspace-model"; -export const CHAT_PANE = { - initial: 500, - max: Number.MAX_SAFE_INTEGER, - min: 250, - storageKey: "chopin:pane:chat", -}; - -export const CHAT_CHOICE_STORAGE_KEY = "chopin:pane:chat:choice"; - -export function storedDesktopChat(value: string | null): boolean | undefined { - if (value === "true") return true; - if (value === "false") return false; -} - export function workspaceSizing(available: number, preferred: number): { chat: number; maximum: number; diff --git a/apps/web/src/workspace.tsx b/apps/web/src/workspace.tsx index 8be38f715..716ded4c1 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -5,26 +5,24 @@ import { Count } from "@chopin/editor/count"; import { ContentSwapLayer } from "@chopin/editor/content-swap"; import { useTransitionPresence } from "@chopin/editor/transition-presence"; import { ArrowLeftIcon, ChevronIcon, ExpandIcon, MessageIcon } from "@chopin/icons"; +import { usePhone } from "@chopin/editor/pointer"; import { + CHAT_CHOICE_STORAGE_KEY, initialWorkspaceState, presentWorkspace, + storedDesktopChat, transitionWorkspace, workspaceDestinations, workspaceHeadingId, workspaceProfile, } from "./workspace-model"; import { clampPane, ResizeHandle, usePaneWidth } from "./resizable-pane"; -import { - CHAT_CHOICE_STORAGE_KEY, - CHAT_PANE, - storedDesktopChat, - workspaceSizing, -} from "./workspace-sizing"; +import { workspaceSizing } from "./workspace-sizing"; import "./workspace-sizing.css"; import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; -import { usePaneMotion, usePaneSettledWidth } from "./pane-motion"; +import { sidebarMoving, usePaneMotion, usePaneSettledWidth } from "./pane-motion"; import { listenForShortcuts } from "./global-shortcuts"; import { currentShortcutPlatform, shortcutLabel } from "./shortcuts"; import { DocumentActivityDot, documentActivityLabel } from "./document-activity"; @@ -45,6 +43,13 @@ import type { export type Pane = "chat"; +const CHAT_PANE = { + initial: 500, + max: Number.MAX_SAFE_INTEGER, + min: 250, + storageKey: "chopin:pane:chat", +}; + export type WorkspaceIds = { heading: Record; pane: Record; @@ -63,6 +68,36 @@ export function useWorkspaceIds(): WorkspaceIds { }; } +export function useWorkspaceLayout() { + let frame = useRef(null); + let [available, setAvailable] = useState(() => Math.max(0, window.innerWidth - 24)); + useLayoutEffect(() => { + let element = frame.current; + if (!element) return; + let measure = () => { + if (!sidebarMoving()) setAvailable(element.clientWidth); + }; + measure(); + let observer = new ResizeObserver(measure); + observer.observe(element); + document.addEventListener("transitionend", measure); + document.addEventListener("transitioncancel", measure); + return () => { + observer.disconnect(); + document.removeEventListener("transitionend", measure); + document.removeEventListener("transitioncancel", measure); + }; + }, []); + // A phone keeps one destination at a time even when landscape gives it a wide window. + let phone = usePhone(); + return { + available, + frame, + mode: phone ? "compact" as const : workspaceSizing(available, 500).mode, + phone, + }; +} + /** Only the desktop Chat preference crosses a page load. */ export function useWorkspaceState( profile: WorkspaceProfile, diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index 226d80d29..bb2524139 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -276,7 +276,7 @@ 1 ] ], - "sourceHash": "470ba7243f13957997b98f3d746ce1c10a3a7f2c5512629c077d9e82044194c8" + "sourceHash": "6dcdd2c5d6243bdc297c311742fc47e8e5da433d201b6e1b728c42c714d9a9c4" }, { "file": "apps/web/src/navigation-dialog.tsx", From 770959b43b6d67fcc05156dbd23bbcd411949870 Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:28:12 +0100 Subject: [PATCH 4/6] Draw the desktop skeleton and workspace notices with CSS alone A container query picks split or compact at the 700px frame workspaceSizing uses, and Chat's width is clamped in CSS from the saved width the skeleton reads, so the skeleton ships as static markup and semantic classes. The notice does the same for its header row. It has equal 8px gutters in compact, and in split it takes the panel radius and raised shadow of the document sheet. The empty workspace is now a notice. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/empty-workspace.tsx | 51 +++------ apps/web/src/navigation.css | 64 +++++++++++ apps/web/src/phone-skeleton.css | 163 +++++++++++++++++++++++----- apps/web/src/workspace-notice.tsx | 29 ++--- apps/web/src/workspace-skeleton.tsx | 105 ++++++------------ 5 files changed, 257 insertions(+), 155 deletions(-) diff --git a/apps/web/src/empty-workspace.tsx b/apps/web/src/empty-workspace.tsx index e1befe14f..90a8a1030 100644 --- a/apps/web/src/empty-workspace.tsx +++ b/apps/web/src/empty-workspace.tsx @@ -1,4 +1,4 @@ -import { useWorkspaceLayout } from "./workspace-layout"; +import { WorkspaceNotice } from "./workspace-notice"; export function EmptyWorkspace( { disabled, hasProjects, onAddProject, onNewDocument }: { @@ -8,41 +8,22 @@ export function EmptyWorkspace( onNewDocument: () => void; }, ) { - let { frame, mode } = useWorkspaceLayout(); - let split = mode === "split"; - // Split has no ground header: the sheet rises to the top margin, as an expanded document does. return ( -
- {!split &&
} -
-
-

No document open

-

- {hasProjects - ? "Pick one from the sidebar or start a new one." - : "Add a project to start your first document."} -

- -
-
-
+ {hasProjects ? "New document" : "Add project"} + + } + body={hasProjects + ? "Pick one from the sidebar or start a new one." + : "Add a project to start your first document."} + title="No document open" + /> ); } diff --git a/apps/web/src/navigation.css b/apps/web/src/navigation.css index 1e8b38f8b..9972c4ea6 100644 --- a/apps/web/src/navigation.css +++ b/apps/web/src/navigation.css @@ -56,6 +56,70 @@ padding-left: calc(44px - var(--spacing) * 3); } +/* + * A workspace notice (workspace-notice.tsx) keeps the document's frame: split puts its sheet at + * the top margin, clear of the floating Show sidebar trigger as an expanded document is; compact + * keeps the ground header above it. Split starts with a 700px frame, as `workspaceSizing` does. + */ +.workspace-notice { + display: flex; + flex-direction: column; + height: 100%; + background: var(--color-ground); + container: workspace-notice / inline-size; + + & > .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 a17875957..5c34d2b27 100644 --- a/apps/web/src/phone-skeleton.css +++ b/apps/web/src/phone-skeleton.css @@ -208,21 +208,95 @@ } /* - * The desktop loading shape (workspace-skeleton.tsx). The workspace's own classes place its panes - * and bars; these only draw quiet marks in them, and the lines sit where the document's preface - * puts them. + * 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); + outline: var(--edge-width) solid var(--color-edge); + outline-offset: calc(-1 * var(--edge-width)); +} + +.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) { + .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); +} - & > span { - 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 { + 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 { @@ -235,25 +309,11 @@ margin-inline-start: calc(var(--spacing) * 2); } -.workspace-skeleton-tabs > span { - display: grid; - place-items: center; - - &::before { - width: calc(var(--spacing) * 16); - height: calc(var(--spacing) * 3); - border-radius: var(--radius-sm); - background: var(--color-selected); - content: ""; - animation: phone-skeleton-pulse calc(var(--duration-linger) * 2.5) var(--ease-out) infinite - alternate; - } -} - .workspace-skeleton-page { --workspace-skeleton-gutter: var(--document-gutter); + flex: 1; + min-height: 0; overflow: hidden; - background: var(--color-page); & > .phone-skeleton-page { max-width: calc(var(--document-prose-measure) + var(--workspace-skeleton-gutter) * 2); @@ -268,6 +328,57 @@ } } +/* 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; + outline: 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; diff --git a/apps/web/src/workspace-notice.tsx b/apps/web/src/workspace-notice.tsx index af4f42b30..58425ae4f 100644 --- a/apps/web/src/workspace-notice.tsx +++ b/apps/web/src/workspace-notice.tsx @@ -1,33 +1,18 @@ -import { useWorkspaceLayout } from "./workspace-layout"; - import type { ReactNode } from "react"; /** - * A message inside the workspace frame, so the sidebar and frame stay in place. Split has no - * ground header, so the message rises to the sheet's top margin, as an expanded document does. + * 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 }, ) { - let { frame, mode } = useWorkspaceLayout(); - let split = mode === "split"; return ( -
- {!split &&
} -
-
+
+
+
+

{title}

{body &&

{body}

} {actions &&
{actions}
} diff --git a/apps/web/src/workspace-skeleton.tsx b/apps/web/src/workspace-skeleton.tsx index be1ea07c6..dd80254dc 100644 --- a/apps/web/src/workspace-skeleton.tsx +++ b/apps/web/src/workspace-skeleton.tsx @@ -1,98 +1,59 @@ /** * The desktop loading shape: the workspace's panes and bars where the loaded workspace puts them, - * so nothing moves when it arrives. Its elements take the workspace's own layout classes, and - * `workspace-skeleton.e2e.ts` holds the two to the same geometry. Phones use `PhoneSkeleton`. + * 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 { useWorkspaceLayout } from "./workspace-layout"; -import { - CHAT_CHOICE_STORAGE_KEY, - CHAT_PANE, - storedDesktopChat, - workspaceSizing, -} from "./workspace-sizing"; import { PhoneSkeletonPage } from "./phone-skeleton"; -import "./workspace-sizing.css"; import type { CSSProperties } from "react"; -// Document, Decisions and Build; compact puts Chat beside them. -const VIEWS = 3; - -/** The loaded workspace's saved Chat choice and width, read before its first paint. */ -function storedChat(): { open: boolean | undefined; width: number } { +/** 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 { - open: storedDesktopChat(localStorage.getItem(CHAT_CHOICE_STORAGE_KEY)), - // workspaceSizing clamps it, as it does the restored width. - width: Number(localStorage.getItem(CHAT_PANE.storageKey)) || CHAT_PANE.initial, + closed: localStorage.getItem("chopin:pane:chat:choice") === "false", + width: Number.isFinite(width) && width > 0 ? `${width}px` : undefined, }; } catch { - return { open: undefined, width: CHAT_PANE.initial }; + return { closed: false }; } } export function WorkspaceSkeleton({ label }: { label: string }) { - let { available, frame, mode } = useWorkspaceLayout(); - let split = mode === "split"; - let stored = storedChat(); - let chat = split && (stored.open ?? true); - let width = workspaceSizing(available, stored.width).chat; + let chat = savedChat(); return (

{label}

- {!split && ( - <> -
-