Skip to content
Merged
Prev Previous commit
Next Next commit
feat(web): restyle settings after Codex
Flat sidebar with search, one content pane, and shared Page/Section/Group/Row blocks; workspaces page gains a shortcuts section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  • Loading branch information
sanchitrk and claude committed Oct 9, 2026
commit 1865019c34d216adde0822e15572209324e2f499
69 changes: 69 additions & 0 deletions apps/web/src/settings/blocks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { ReactNode } from "react";

/** Building blocks every settings section is made of: a page of titled sections of row groups. */

export function Page({
title,
description,
children,
}: {
title: string;
description?: ReactNode;
children: ReactNode;
}) {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-8 px-4 py-8 md:px-10 md:py-12">
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description !== undefined && <p className="text-ink-muted">{description}</p>}
</header>
{children}
</div>
);
}

export function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="flex flex-col gap-2">
<h2 className="font-medium">{title}</h2>
{children}
</section>
);
}

/** A card of rows with a divider between each. */
export function Group({ children }: { children: ReactNode }) {
return (
<div className="flex flex-col divide-y divide-edge rounded-xl border border-edge bg-ink/[0.02]">{children}</div>
);
}

/** A labelled setting: title and a one-line description on the left, its control on the right. */
export function Row({
title,
description,
children,
}: {
title: string;
description?: ReactNode;
children?: ReactNode;
}) {
return (
<div className="flex items-center gap-4 px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-medium">{title}</span>
{description !== undefined && <span className="text-ink-muted">{description}</span>}
</div>
{children}
</div>
);
}

/** A shortcut as the keys to press. */
export function Keys({ children }: { children: ReactNode }) {
return (
<kbd className="shrink-0 rounded-md border border-edge bg-ink/5 px-1.5 py-0.5 font-sans text-ink-muted">
{children}
</kbd>
);
}
92 changes: 63 additions & 29 deletions apps/web/src/settings/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,86 @@
import { Link, Outlet, useMatchRoute } from "@tanstack/react-router";
import { Layers } from "lucide-react";
import type { CSSProperties } from "react";
import { Link, Outlet, useMatchRoute, useNavigate } from "@tanstack/react-router";
import { Search } from "lucide-react";
import { type CSSProperties, useState } from "react";

import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from "@/components/ui/sidebar";
import { search } from "./sections";

const sections = [{ to: "/settings/workspaces", label: "Workspaces", icon: Layers }] as const;

/** Settings: a section list beside the open section, both on the desktop's panels. */
/** Settings: a flat section list on the window chrome beside one pane for the open section. */
export function SettingsLayout() {
const matchRoute = useMatchRoute();
const navigate = useNavigate();
const [query, setQuery] = useState("");
const matches = search(query);
const groups = [...new Set(matches.map((section) => section.group))];

return (
// Wider than shadcn's 16rem default, so section names never crowd the icons;
// below md the sidebar folds to an icon rail so the section keeps the room.
<SidebarProvider className="h-full min-h-0 gap-2 p-2" style={{ "--sidebar-width": "20rem" } as CSSProperties}>
<Sidebar collapsible="none" className="panel bg-transparent max-md:w-14">
<SidebarHeader className="px-4 pt-4 max-md:sr-only">
<h1 className="text-base font-semibold">Settings</h1>
<SidebarProvider
className="h-full min-h-0 gap-2 p-2 pt-0"
style={{ "--sidebar-width": "20rem" } as CSSProperties}
>
<Sidebar collapsible="none" className="bg-transparent max-md:w-14">
<SidebarHeader className="gap-3 px-2 pt-3 max-md:hidden">
<h1 className="px-2 text-lg font-semibold">Settings</h1>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-muted" />
<SidebarInput
type="search"
placeholder="Search"
aria-label="Search settings"
value={query}
className="h-9 rounded-full border-transparent bg-ink/6 pl-9"
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
const first = matches[0];
if (event.key === "Enter" && first !== undefined) void navigate({ to: first.to });
if (event.key === "Escape" && query !== "") {
// Clears the query instead of leaving settings.
event.preventDefault();
setQuery("");
}
}}
/>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel className="max-md:sr-only">App</SidebarGroupLabel>
<SidebarMenu>
{sections.map(({ to, label, icon: Icon }) => (
<SidebarMenuItem key={to}>
<SidebarMenuButton
asChild
isActive={matchRoute({ to }) !== false}
className="max-md:justify-center"
>
<Link to={to} title={label}>
<Icon strokeWidth={1.75} />
<span className="max-md:sr-only">{label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
{groups.map((group) => (
<SidebarGroup key={group}>
<SidebarGroupLabel className="text-ink-muted max-md:sr-only">{group}</SidebarGroupLabel>
<SidebarMenu>
{matches
.filter((section) => section.group === group)
.map(({ to, label, icon: Icon }) => (
<SidebarMenuItem key={to}>
<SidebarMenuButton
asChild
size="lg"
isActive={matchRoute({ to }) !== false}
className="h-9 text-[14px] max-md:justify-center"
>
<Link to={to} title={label}>
<Icon strokeWidth={1.75} />
<span className="max-md:sr-only">{label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
))}
{matches.length === 0 && <p className="px-4 py-2 text-ink-muted">No settings match “{query}”.</p>}
</SidebarContent>
</Sidebar>
<main className="panel min-w-0 flex-1 overflow-auto">
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/settings/sections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Layers, type LucideIcon } from "lucide-react";

export interface SettingsSection {
readonly to: "/settings/workspaces";
readonly label: string;
readonly icon: LucideIcon;
/** Sidebar heading the section sits under. */
readonly group: string;
/** Extra words the search matches, beyond the label. */
readonly keywords: readonly string[];
}

export const sections: readonly SettingsSection[] = [
{
to: "/settings/workspaces",
label: "Workspaces",
icon: Layers,
group: "App",
keywords: ["tabs", "shortcuts", "reorder", "rename", "icon", "delete"],
},
];

/** Sections whose label or keywords contain every word of the query, in sidebar order. */
export function search(query: string): readonly SettingsSection[] {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
return sections.filter((section) => {
const text = [section.label, ...section.keywords].join(" ").toLowerCase();
return words.every((word) => text.includes(word));
});
}
115 changes: 67 additions & 48 deletions apps/web/src/settings/workspaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { isMac } from "../kernel/platform";
import { iconOf, icons } from "../shell/icons";
import { shortcutLabel } from "../shell/switcher";
import { deskAtom, only, untag } from "../wm/instances";
Expand All @@ -29,6 +30,7 @@ import {
workspacesKey,
workspaceTag,
} from "../wm/workspaces";
import { Group, Keys, Page, Row, Section } from "./blocks";

// webwm tag bits available, and so the Cmd+1–9 keys; the server enforces it too.
const MAX = 9;
Expand Down Expand Up @@ -83,56 +85,73 @@ export function WorkspacesSettings() {
};

return (
<div className="mx-auto flex max-w-2xl flex-col gap-4 p-4 md:p-6">
<header>
<h2 className="text-base font-semibold">Workspaces</h2>
<p className="text-ink-muted">
Each workspace is a tab in the title bar, in this order, and a {shortcutLabel(1)}–{MAX} shortcut. Drag to
reorder.
</p>
</header>
<ul className="flex flex-col divide-y divide-edge rounded-xl border border-edge">
{listed.map((workspace, index) => (
<li
key={workspace.id}
data-workspace={workspace.id}
className={`flex items-center gap-2 px-2 py-1.5 ${dragging === workspace.id ? "opacity-50" : ""}`}
onDragOver={(event) => {
event.preventDefault();
dragOver(workspace.id);
}}
onDrop={(event) => event.preventDefault()}
>
<span
draggable
aria-label={`Reorder ${workspace.name}`}
className="grid size-7 cursor-grab place-items-center text-ink-muted/60 active:cursor-grabbing"
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
const row = event.currentTarget.closest("li");
if (row) event.dataTransfer.setDragImage(row, 16, row.offsetHeight / 2);
setDragging(workspace.id);
<Page
title="Workspaces"
description="Each workspace is a tab in the title bar. Its place in the list is its keyboard shortcut."
>
<Section title="Your workspaces">
<Group>
{listed.map((workspace, index) => (
<div
key={workspace.id}
data-workspace={workspace.id}
className={`flex items-center gap-2 px-2 py-2 ${dragging === workspace.id ? "opacity-50" : ""}`}
onDragOver={(event) => {
event.preventDefault();
dragOver(workspace.id);
}}
onDragEnd={dragEnd}
onDrop={(event) => event.preventDefault()}
>
<GripVertical className="size-4" />
</span>
<IconPicker workspace={workspace} onError={setError} />
<NameField workspace={workspace} autoFocus={workspace.id === created} onError={setError} />
<kbd className="w-9 shrink-0 text-center font-sans text-ink-muted">{shortcutLabel(index + 1)}</kbd>
<DeleteButton workspace={workspace} disabled={workspaces.length === 1} onError={setError} />
</li>
))}
</ul>
<div className="flex items-center gap-3">
<Button variant="secondary" size="sm" disabled={workspaces.length >= MAX} onClick={addWorkspace}>
<Plus />
New workspace
</Button>
{workspaces.length >= MAX && <span className="text-ink-muted">All {MAX} workspaces are in use.</span>}
</div>
{error !== null && <p className="text-destructive">{error}</p>}
</div>
<span
draggable
aria-label={`Reorder ${workspace.name}`}
title="Drag to reorder"
className="grid size-7 cursor-grab place-items-center text-ink-muted/60 active:cursor-grabbing"
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
const row = event.currentTarget.parentElement;
if (row) event.dataTransfer.setDragImage(row, 16, row.offsetHeight / 2);
setDragging(workspace.id);
}}
onDragEnd={dragEnd}
>
<GripVertical className="size-4" />
</span>
<IconPicker workspace={workspace} onError={setError} />
<NameField workspace={workspace} autoFocus={workspace.id === created} onError={setError} />
<Keys>{shortcutLabel(index + 1)}</Keys>
<DeleteButton workspace={workspace} disabled={workspaces.length === 1} onError={setError} />
</div>
))}
<button
type="button"
disabled={workspaces.length >= MAX}
title={workspaces.length >= MAX ? `All ${MAX} workspaces are in use` : undefined}
className="flex items-center gap-2 rounded-b-xl px-4 py-3 text-ink-muted transition-colors hover:bg-ink/4 hover:text-ink disabled:pointer-events-none disabled:opacity-50"
onClick={addWorkspace}
>
<Plus className="size-4" />
New workspace
</button>
</Group>
{error !== null && <p className="text-destructive">{error}</p>}
</Section>
<Section title="Shortcuts">
<Group>
<Row title="Switch workspace" description="Jump to a workspace by its place in the list">
<Keys>
{shortcutLabel(1)}–{MAX}
</Keys>
</Row>
<Row title="Open settings">
<Keys>{isMac ? "⌘," : "Ctrl+,"}</Keys>
</Row>
<Row title="Close settings" description="Back to the workspace you came from">
<Keys>Esc</Keys>
</Row>
</Group>
</Section>
</Page>
);
}

Expand Down