Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 10 additions & 20 deletions apps/web/src/empty-workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useWorkspaceLayout } from "./workspace";
import { WorkspaceNotice } from "./workspace-notice";

export function EmptyWorkspace(
{ disabled, hasProjects, onAddProject, onNewDocument }: {
Expand All @@ -8,32 +8,22 @@ export function EmptyWorkspace(
onNewDocument: () => void;
},
) {
let { frame, mode } = useWorkspaceLayout();
return (
<div className="flex h-full flex-col bg-ground" ref={frame}>
<div className="room-header shrink-0" />
<div
className={`flex min-h-0 flex-1 flex-col items-center justify-center gap-1 text-center ${
mode === "split"
? "mx-3 mb-3 overflow-hidden rounded-panel bg-ground shadow-raised ring-hairline"
: "m-2 overflow-hidden rounded-panel bg-ground shadow-resting ring-hairline"
}`}
>
<h2 className="text-sm font-semibold text-text-primary">No document open</h2>
<p className="text-sm text-text-tertiary">
{hasProjects
? "Pick one from the sidebar or start a new one."
: "Add a project to start your first document."}
</p>
<WorkspaceNotice
actions={
<button
className="btn btn-sm btn-secondary mt-3"
className="btn btn-sm btn-secondary"
disabled={hasProjects && disabled}
onClick={hasProjects ? onNewDocument : onAddProject}
type="button"
>
{hasProjects ? "New document" : "Add project"}
</button>
</div>
</div>
}
body={hasProjects
? "Pick one from the sidebar or start a new one."
: "Add a project to start your first document."}
title="No document open"
/>
);
}
14 changes: 12 additions & 2 deletions apps/web/src/hosted.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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";
Expand Down Expand Up @@ -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 <PhoneDocumentSkeleton />;
// 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
? <WorkspaceSkeleton label={label} />
: <p className="sr-only" role="status">{label}</p>;
}
return (
<div
className="hosted-loading flex h-full items-center justify-center bg-ground px-4 text-sm text-text-tertiary"
Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/navigation.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
177 changes: 177 additions & 0 deletions apps/web/src/phone-skeleton.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/room-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 13 additions & 7 deletions apps/web/src/workspace-notice.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-full flex-col bg-ground" data-hosted="">
<div className="room-header shrink-0" />
<div className="m-2 flex min-h-0 flex-1 flex-col items-center justify-center gap-1 overflow-hidden rounded-xl bg-ground px-4 text-center shadow-resting ring-hairline md:mx-3 md:mb-3">
<h1 className="text-sm font-semibold text-text-primary">{title}</h1>
{body && <p className="max-w-sm break-words text-sm text-text-tertiary">{body}</p>}
{actions && <div className="mt-3 flex flex-wrap justify-center gap-2">{actions}</div>}
<div className="workspace-notice" data-hosted="">
<div className="room-header" />
<div className="workspace-notice-frame">
<div className="workspace-notice-sheet">
<h1 className="text-sm font-semibold text-text-primary">{title}</h1>
{body && <p className="max-w-sm break-words text-sm text-text-tertiary">{body}</p>}
{actions && <div className="mt-3 flex flex-wrap justify-center gap-2">{actions}</div>}
</div>
</div>
</div>
);
Expand Down
Loading
Loading