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
2 changes: 1 addition & 1 deletion apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ text only when people need it to act or recover.

## Layout

The workspace places Chat on the left and the document on the right when space permits. Chat and its top bar (breadcrumb and document title) sit directly on the ground; the document is a raised sheet that rises to the top margin, and its tab row holds the expand control, the Document/Decisions/Build views and presence. Expanding the document hides Chat and the top bar; a Chat button in the same place restores them. Document, Decisions, and Build switch the main pane; Build is available on parent documents. Local investigation proposals, approval controls, and results appear in the document flow. The Projects sidebar slides over the document without resizing it; on narrower screens, panels take their own view. The document uses a maximum prose measure of 40.625rem (650px), with 3.75rem (60px) of top padding and at least 3.5rem (56px) of padding on each side. At browser widths of 600px or less, the horizontal padding becomes 1.5rem (24px). Images and diagrams can extend beyond the prose measure within those gutters; tables can grow toward the trailing gutter and retain their own scroll lane. On a phone (a coarse pointer on a small screen), when the document is narrower than 40rem, a wide table fades whichever side still hides columns, code wraps with a hanging indent instead of scrolling, and a Mermaid diagram below its readability floor offers Expand diagram under its scroll lane, which opens it in a full-screen sheet at its authored size. A narrow document alone does not trigger these: narrow desktop windows keep the scroll lanes.
The workspace places Chat on the left and the document on the right when space permits. Chat and its top bar (breadcrumb and document title) sit directly on the ground; the document is a raised sheet that rises to the top margin, and its tab row holds the expand control, the Document/Decisions/Build views and presence. Expanding the document hides Chat and the top bar; a Chat button in the same place restores them. Document, Decisions, and Build switch the main pane; Build is available on parent documents. Local investigation proposals, approval controls, and results appear in the document flow. The Projects sidebar slides over the document without resizing it; on narrower screens, panels take their own view. The document uses a maximum prose measure of 40.625rem (650px), with 3.75rem (60px) of top padding and at least 3.5rem (56px) of padding on each side. At browser widths of 600px or less, the horizontal padding becomes 1.5rem (24px). On a phone (a primary touch pointer with a width of 600px or less, or a height of 500px or less, so landscape phones count), the workspace drops its card and the raised sheet: panes run full-bleed with the same 1.5rem (24px) gutters, so margin comment markers keep their lane, under a translucent top bar that tucks away while reading down, above a floating bottom tab bar in the thumb zone. Landscape phones use a shorter top bar and a one-line tab bar. Destinations slide sideways in tab order, and crossfade under reduced motion. Images and diagrams can extend beyond the prose measure within those gutters; tables can grow toward the trailing gutter and retain their own scroll lane. On a phone (a coarse pointer on a small screen), when the document is narrower than 40rem, a wide table fades whichever side still hides columns, code wraps with a hanging indent instead of scrolling, and a Mermaid diagram below its readability floor offers Expand diagram under its scroll lane, which opens it in a full-screen sheet at its authored size. A narrow document alone does not trigger these: narrow desktop windows keep the scroll lanes.

Spacing starts from a 4px unit and uses the measured steps shown in the frontmatter. App chrome is dense; prose has larger margins and trailing space so the caret remains comfortable near the end of a document. Layout responds to both viewport and document container width. Safe-area insets and larger coarse-pointer targets are accounted for in the web styles.

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/chat/transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,7 @@ export function Transcript(
return (
<div
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-auto px-5 py-3"
data-chat-scroll=""
data-focus-boundary=""
ref={scroller}
onScroll={event => {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/motion-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ export type MotionKind =
| "feedback"
| "popover"
| "route-swap"
| "sidebar";
| "sidebar"
| "tab-slide";

export const MOTION_STATES = ["", "is-open", "is-closing"] as const;

Expand Down Expand Up @@ -32,6 +33,8 @@ let contracts = {
// `closeDelay` adds 50ms, so the incoming route starts as `--route-swap-out-dur` ends.
"route-swap": { className: "motion-route-swap", closeDuration: 30, states: MOTION_STATES },
sidebar: { className: "motion-sidebar", closeDuration: 180, states: MOTION_STATES },
// Phone destinations: matches `--tab-slide-dur`.
"tab-slide": { className: "motion-tab-slide", closeDuration: 380, states: MOTION_STATES },
} as const satisfies Record<MotionKind, MotionContract>;

export function motionContract<Kind extends MotionKind>(kind: Kind): (typeof contracts)[Kind] {
Expand Down
275 changes: 147 additions & 128 deletions apps/web/src/navigation-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
SIDEBAR_MAX,
SIDEBAR_MIN,
SIDEBAR_STORAGE_KEY,
SidebarTrigger,
} from "./project-sidebar-chrome";
import { clearRepositoryCache } from "./repository-cache";
import { NavigationError } from "./navigation-error";
Expand Down Expand Up @@ -291,7 +292,12 @@ export function NavigationShell(
);
let dialogMotion = dialogPresence.phase === "closed" ? undefined : dialogPresence;
let presentedDialog = dialogMotion?.value;
let triggerVisible = !sidebarVisible && !drawerOpen;
let [triggerClaims, setTriggerClaims] = useState(0);
let claimTrigger = useCallback(() => {
setTriggerClaims(count => count + 1);
return () => setTriggerClaims(count => count - 1);
}, []);
let triggerVisible = !sidebarVisible && !drawerOpen && triggerClaims === 0;
let resyncDecisions = useRef<(repositoryId: string) => void>(() => {});
let {
beginTotalRequest,
Expand Down Expand Up @@ -733,6 +739,11 @@ export function NavigationShell(
workspaceDocumentAction,
]);

let expandSidebar = useCallback(
() => mode === "drawer" ? setDrawerOpen(true) : setCollapsed(false),
[mode],
);

let signOut = async () => {
try {
await Api.logout();
Expand All @@ -755,6 +766,12 @@ export function NavigationShell(
};
let dismissDialog = () => setDialog(undefined);
let [sidebarShortcut, setSidebarShortcut] = useState<string>();
let sidebarTrigger = useMemo(() => ({
buttonRef: drawerOpener,
claim: claimTrigger,
onExpand: expandSidebar,
shortcut: sidebarShortcut,
}), [claimTrigger, expandSidebar, sidebarShortcut]);
let shortcutActions = useRef<ShortcutActions>({});
shortcutActions.current = {
search: () => showDialog("search"),
Expand Down Expand Up @@ -928,145 +945,147 @@ export function NavigationShell(

return (
<NavigationDocument.Provider value={navigationDocument}>
{countsReady && (
<SidebarFeatureBoundary>
<SidebarDecisionCounts
archived={catalogueMode === "archived"}
onCounts={updateDecisionCounts}
onSnapshot={updateDecisionSnapshot}
priorityRepositoryId={active?.repositoryId}
projects={projects}
refreshProject={refreshProject}
resync={resyncDecisions}
/>
</SidebarFeatureBoundary>
)}
<div
className="navigation-shell"
data-navigation-mode={mode}
onClickCapture={navigateLink}
>
{sidebarPresence.phase !== "closed" && (
<div
aria-hidden={sidebarPresence.phase === "closing" ? "true" : undefined}
className={`project-sidebar-frame ${sidebarMotion.className} ${sidebarPresence.className}`}
inert={sidebarPresence.phase === "closing"}
style={{ "--project-sidebar-width": `${width}px` } as CSSProperties}
>
<div className="project-sidebar-motion-content">{sidebar}</div>
<SidebarFeatureBoundary>
<SidebarResizeHandle onResize={resize} width={width} />
</SidebarFeatureBoundary>
</div>
)}
{triggerVisible && (
<ProjectSidebarExpandButton
buttonRef={drawerOpener}
onExpand={() => mode === "drawer" ? setDrawerOpen(true) : setCollapsed(false)}
shortcut={sidebarShortcut}
/>
)}
{drawerPresence.phase !== "closed" && (
<LazyDialogBoundary
onFailure={() => {
dismissDrawer();
setDialogLoadFailed(true);
}}
>
<NavigationDrawer motion={drawerPresence} onDismiss={dismissDrawer}>
{sidebar}
</NavigationDrawer>
</LazyDialogBoundary>
<SidebarTrigger.Provider value={sidebarTrigger}>
{countsReady && (
<SidebarFeatureBoundary>
<SidebarDecisionCounts
archived={catalogueMode === "archived"}
onCounts={updateDecisionCounts}
onSnapshot={updateDecisionSnapshot}
priorityRepositoryId={active?.repositoryId}
projects={projects}
refreshProject={refreshProject}
resync={resyncDecisions}
/>
</SidebarFeatureBoundary>
)}
{children === undefined
? (
<main
className="navigation-content"
data-project-sidebar-trigger={triggerVisible || undefined}
>
{content}
</main>
)
: (
<div
className="navigation-shell"
data-navigation-mode={mode}
onClickCapture={navigateLink}
>
{sidebarPresence.phase !== "closed" && (
<div
className="navigation-content"
data-project-sidebar-trigger={triggerVisible || undefined}
aria-hidden={sidebarPresence.phase === "closing" ? "true" : undefined}
className={`project-sidebar-frame ${sidebarMotion.className} ${sidebarPresence.className}`}
inert={sidebarPresence.phase === "closing"}
style={{ "--project-sidebar-width": `${width}px` } as CSSProperties}
>
{content}
<div className="project-sidebar-motion-content">{sidebar}</div>
<SidebarFeatureBoundary>
<SidebarResizeHandle onResize={resize} width={width} />
</SidebarFeatureBoundary>
</div>
)}
{dialogMotion && presentedDialog === "add" && (
<LazyDialogBoundary onFailure={() => setDialogLoadFailed(true)}>
<AddProjectDialog
added={navigation?.projects ?? []}
motion={dialogMotion}
onAdded={project => {
catalogueRefreshes.current.set(project.repositoryId, Date.now());
setFocusProjectId(project.repositoryId);
setCollapsed(false);
if (mode === "drawer") {
setDrawerOpen(true);
}
void refresh();
}}
onDismiss={dismissDialog}
userId={user.id}
{triggerVisible && (
<ProjectSidebarExpandButton
buttonRef={drawerOpener}
onExpand={expandSidebar}
shortcut={sidebarShortcut}
/>
</LazyDialogBoundary>
)}
{dialogMotion && presentedDialog === "new" && (
<LazyDialogBoundary onFailure={() => setDialogLoadFailed(true)}>
<Suspense fallback={null}>
<NewDocumentDialog
error={creation.error?.message}
)}
{drawerPresence.phase !== "closed" && (
<LazyDialogBoundary
onFailure={() => {
dismissDrawer();
setDialogLoadFailed(true);
}}
>
<NavigationDrawer motion={drawerPresence} onDismiss={dismissDrawer}>
{sidebar}
</NavigationDrawer>
</LazyDialogBoundary>
)}
{children === undefined
? (
<main
className="navigation-content"
data-project-sidebar-trigger={triggerVisible || undefined}
>
{content}
</main>
)
: (
<div
className="navigation-content"
data-project-sidebar-trigger={triggerVisible || undefined}
>
{content}
</div>
)}
{dialogMotion && presentedDialog === "add" && (
<LazyDialogBoundary onFailure={() => setDialogLoadFailed(true)}>
<AddProjectDialog
added={navigation?.projects ?? []}
motion={dialogMotion}
onAddProject={() =>
showDialog("add")}
onCreate={createDocument}
onDismiss={() => {
setDialog(undefined);
onAdded={project => {
catalogueRefreshes.current.set(project.repositoryId, Date.now());
setFocusProjectId(project.repositoryId);
setCollapsed(false);
if (mode === "drawer") {
requestAnimationFrame(() => drawerOpener.current?.focus());
setDrawerOpen(true);
}
void refresh();
}}
onRetry={retryProject ? () => createDocument(retryProject) : undefined}
pending={creation.pending}
onDismiss={dismissDialog}
userId={user.id}
/>
</LazyDialogBoundary>
)}
{dialogMotion && presentedDialog === "new" && (
<LazyDialogBoundary onFailure={() => setDialogLoadFailed(true)}>
<Suspense fallback={null}>
<NewDocumentDialog
error={creation.error?.message}
motion={dialogMotion}
onAddProject={() =>
showDialog("add")}
onCreate={createDocument}
onDismiss={() => {
setDialog(undefined);
if (mode === "drawer") {
requestAnimationFrame(() => drawerOpener.current?.focus());
}
}}
onRetry={retryProject ? () => createDocument(retryProject) : undefined}
pending={creation.pending}
projects={navigation?.projects ?? []}
/>
</Suspense>
</LazyDialogBoundary>
)}
{dialogMotion && presentedDialog === "search" && (
<LazyDialogBoundary onFailure={() => setDialogLoadFailed(true)}>
<DocumentSearchDialog
includeArchived={catalogueMode === "archived"}
motion={dialogMotion}
onDismiss={dismissDialog}
onSelect={navigateToDocument}
projects={navigation?.projects ?? []}
source={projects}
userId={user.id}
/>
</Suspense>
</LazyDialogBoundary>
)}
{dialogMotion && presentedDialog === "search" && (
<LazyDialogBoundary onFailure={() => setDialogLoadFailed(true)}>
<DocumentSearchDialog
includeArchived={catalogueMode === "archived"}
motion={dialogMotion}
onDismiss={dismissDialog}
onSelect={navigateToDocument}
projects={navigation?.projects ?? []}
source={projects}
userId={user.id}
/>
</LazyDialogBoundary>
)}
{dialogMotion && presentedDialog === "shortcuts" && (
<LazyDialogBoundary onFailure={() => setDialogLoadFailed(true)}>
<KeyboardShortcutsDialog motion={dialogMotion} onDismiss={dismissDialog} />
</LazyDialogBoundary>
)}
{dialogMotion && typeof presentedDialog === "object"
&& presentedDialog.type === "delete" && (
<LazyDialogBoundary onFailure={() => setDialogLoadFailed(true)}>
<DeleteDocumentDialog
channel={presentedDialog.channel}
motion={dialogMotion}
onDeleted={() =>
documentDeleted(presentedDialog.channel.id)}
onDismiss={dismissDialog}
/>
</LazyDialogBoundary>
)}
</div>
</LazyDialogBoundary>
)}
{dialogMotion && presentedDialog === "shortcuts" && (
<LazyDialogBoundary onFailure={() => setDialogLoadFailed(true)}>
<KeyboardShortcutsDialog motion={dialogMotion} onDismiss={dismissDialog} />
</LazyDialogBoundary>
)}
{dialogMotion && typeof presentedDialog === "object"
&& presentedDialog.type === "delete" && (
<LazyDialogBoundary onFailure={() => setDialogLoadFailed(true)}>
<DeleteDocumentDialog
channel={presentedDialog.channel}
motion={dialogMotion}
onDeleted={() =>
documentDeleted(presentedDialog.channel.id)}
onDismiss={dismissDialog}
/>
</LazyDialogBoundary>
)}
</div>
</SidebarTrigger.Provider>
</NavigationDocument.Provider>
);
}
Loading
Loading