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 ( -
-
-
-

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/hosted.tsx b/apps/web/src/hosted.tsx index 69db777b8..9744c77d3 100644 --- a/apps/web/src/hosted.tsx +++ b/apps/web/src/hosted.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useContext, useEffect, useRef, useState } from "react"; import { usePhone } from "@chopin/editor/pointer"; import { parseChildDocumentPath, parseDocumentPath } from "@chopin/protocol/document-url"; @@ -8,6 +8,8 @@ import { rememberOriginRect } from "./child-origin"; import { WorkspaceNotice } from "./workspace-notice"; import { NavigationShell } from "./navigation-shell"; import { PhoneDocumentSkeleton } from "./phone-skeleton"; +import { SidebarTrigger } from "./project-sidebar-chrome"; +import { WorkspaceSkeleton } from "./workspace-skeleton"; import type { ReactNode } from "react"; import type { ResearchOpener } from "@chopin/editor"; @@ -100,10 +102,18 @@ 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(); + let shell = useContext(SidebarTrigger); if (document && phone) return ; + // Before the session the sidebar has yet to take its width, so the ground stays blank. A child + // opens as a card over its parent, not in this shape, so it keeps the label. + if (document && hostedRoute(location.pathname).page !== "child") { + return shell + ? + :

{label}

; + } 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}

+