diff --git a/.gitignore b/.gitignore index 3d6679c5..67239a72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Dependencies and build output node_modules/ dist/ +storybook-static/ coverage/ *.tsbuildinfo .cache/ @@ -31,3 +32,6 @@ test-results/ .DS_Store .claude/ .amp/ + +# Generated from each CSS module by `bun run --cwd apps/web css-types` +*.module.css.d.ts diff --git a/apps/web/.storybook/main.ts b/apps/web/.storybook/main.ts new file mode 100644 index 00000000..1da4bbe9 --- /dev/null +++ b/apps/web/.storybook/main.ts @@ -0,0 +1,8 @@ +import { defineMain } from "@storybook/react-vite/node"; + +// Storybook reads apps/web/vite.config.ts, so stories build with the same +// plugins the interface does. +export default defineMain({ + framework: "@storybook/react-vite", + stories: ["../src/**/*.stories.tsx"], +}); diff --git a/apps/web/.storybook/preview.tsx b/apps/web/.storybook/preview.tsx new file mode 100644 index 00000000..618ff58d --- /dev/null +++ b/apps/web/.storybook/preview.tsx @@ -0,0 +1,55 @@ +import { useEffect, type ReactNode } from "react"; +import { ThemeProvider, useTheme } from "@corbits/react-ui"; +import type { Preview } from "@storybook/react-vite"; +import { Toaster } from "sonner"; +// The same order main.tsx imports them in: the library's sheet, then ours. +import "@corbits/react-ui/styles.css"; +import "../src/styles.css"; + +type Mode = "light" | "dark"; + +// Follows the toolbar instead of the stored preference, so a story renders +// in the mode the reviewer picked. +function Mode({ mode, children }: { mode: Mode; children: ReactNode }) { + const { setMode, resolvedMode } = useTheme(); + useEffect(() => setMode(mode), [mode, setMode]); + return ( + <> + {children} + + > + ); +} + +const preview: Preview = { + globalTypes: { + mode: { + description: "Light or dark theme", + toolbar: { + title: "Theme", + icon: "mirror", + items: [ + { value: "light", title: "Light" }, + { value: "dark", title: "Dark" }, + ], + dynamicTitle: true, + }, + }, + }, + initialGlobals: { mode: "light" }, + decorators: [ + (Story, context) => ( + + + + + + ), + ], + parameters: { + layout: "padded", + controls: { expanded: true }, + }, +}; + +export default preview; diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 0b13ab85..9f088d45 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -18,6 +18,31 @@ like what is already there, and extends it rather than standing beside it. maintainers decide whether it moves. - Wording lives in the interface's config. +## Styles + +- A component's styles live in a CSS module beside it (`pane-bar.tsx`, + `pane-bar.module.css`), imported as `styles`, with camelCase class names. + `styles.css` holds only what is global: fonts, tokens, element defaults, + overrides of the library that apply everywhere, and shared keyframes. +- Each module has exact types, generated by `bun run css-types` (the `dev` + script keeps them current and `typecheck` regenerates them), so + `styles.pane` is a `string` and a misspelt class is a type error. Join + classes with `clsx` only when one is conditional. +- A look used in more than one place is a component, such as `InlineNote`, + `Field`, `Fault` or `Banner`, not a class two components share. Shared + components live in `components.tsx`, or in a file of their own when they + belong to one area. +- A shared component owns its whole look and takes no `className`. Variants + are props, rendered as `data-*` attributes (`data-tone="error"`, or an + empty `data-pill=""` for a flag); the element it renders is an `as` prop. +- Every shared component's root carries a `data-slot` naming it. A parent + positions it from its own module through that attribute, such as + `.notes > [data-slot="inline-note"]` for margin, size or placement, never + through another module's class, and never by restyling its inside. +- The library's own classes, such as `.dark`, go in `:global(...)`, and a + global keyframe in `global(...)`: `animation: global(fade-in) 200ms`. +- Code and tests find elements by `data-*` attributes, never by a class name. + ## Every artifact kind is a first-class document A new kind (a sheet, a deck, a diagram) is integrated everywhere a document diff --git a/apps/web/package.json b/apps/web/package.json index 6f545fe0..003025b3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,8 +7,13 @@ "author": "Sawyer Cutler ", "type": "module", "scripts": { - "dev": "vite", - "build": "vite build" + "dev": "bun run --parallel css-types:watch serve", + "serve": "vite", + "build": "vite build", + "css-types": "tcm -s -p \"src/**/*.module.css\"", + "css-types:watch": "tcm -s -w -p \"src/**/*.module.css\"", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build -o storybook-static" }, "dependencies": { "@corbits-builder/client": "workspace:*", @@ -23,6 +28,7 @@ "@tanstack/react-query": "5.104.1", "@tanstack/react-query-persist-client": "5.104.1", "arktype": "catalog:", + "clsx": "2.1.1", "diff": "9.0.0", "idb-keyval": "6.2.2", "jszip": "3.10.2", @@ -32,9 +38,12 @@ "sonner": "2.0.8" }, "devDependencies": { + "@storybook/react-vite": "11.0.0-beta.0", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "5.2.0", + "storybook": "11.0.0-beta.0", + "typed-css-modules": "0.9.1", "typescript": "5.9.3", "vite": "6.4.5" } diff --git a/apps/web/src/App.module.css b/apps/web/src/App.module.css new file mode 100644 index 00000000..4fa59f96 --- /dev/null +++ b/apps/web/src/App.module.css @@ -0,0 +1,486 @@ +/* ---- App shell: a rail on the left, a filling canvas beside it ---- */ + +.app { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + /* The window is the app. Nothing scrolls but the one region meant to. */ + height: 100vh; + overflow: hidden; + background: var(--wb-frame); +} + +/* A grid item defaults to min-content width, so anything wide inside — a + table, the stage timeline — pushes the whole page sideways instead of + scrolling within its own panel. */ +.app > * { + min-width: 0; +} + +/* ---- Rail: quiet type on the frame, no rules or boxes of its own ---- + 32px rows with 16px icons on one left edge; the current row is lifted + onto the page surface rather than tinted. */ + +.sidebar { + --rail-hover: color-mix(in srgb, var(--wb-foreground) 5%, transparent); + --rail-press: color-mix(in srgb, var(--wb-foreground) 8%, transparent); + grid-row: 1 / -1; + width: 15rem; + padding: 10px 8px 8px; + border-right: 0; + background: transparent; + transition: width 200ms cubic-bezier(0.2, 0, 0, 1); +} + +.sidebar[data-collapsed="true"] { + width: 3.5rem; +} + +.sidebar [data-slot="sidebar-header"] { + min-height: 40px; + gap: 8px; + margin-bottom: 8px; + padding: 0 2px 0 8px; + border: 0; +} + +.sidebar [data-slot="sidebar-collapse-toggle"] { + width: 32px; + height: 32px; + border-radius: var(--radius-md); + color: var(--wb-muted-foreground); + transition: + background-color 120ms ease, + color 120ms ease; +} + +.sidebar [data-slot="sidebar-collapse-toggle"]:hover { + background: var(--rail-hover); + color: var(--wb-foreground); +} + +.sidebar [data-slot="sidebar-content"] { + padding: 0; +} + +.list { + display: flex; + flex-direction: column; + gap: 1px; +} + +/* The projects in progress sit under a thin rule; their label is for assistive tech only. */ +.sidebar [data-slot="sidebar-section"].projects { + margin-top: 12px; + padding: 12px 0 0; + border-top: 1px solid var(--wb-border); +} + +.sidebar [data-slot="sidebar-section"].projects > p { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.sidebar [data-slot="sidebar-section"] > p { + margin: 0 0 3px; + padding: 0 8px 4px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0; + text-transform: none; +} + +.sidebar [data-slot="sidebar-section"] > ul { + gap: 1px; +} + +/* SidebarItem puts all children in one inline label, so the badge needs its own row to centre and space against the text. */ +.whatsNewItem { + display: inline-flex; + align-items: center; + gap: 8px; + vertical-align: middle; +} + +.sidebar .initial { + width: 18px; + height: 18px; + border-radius: 5px; + background: color-mix(in srgb, var(--wb-foreground) 7%, transparent); + color: inherit; + font-size: 0.625rem; +} + +.sidebar [aria-current] .initial { + background: var(--wb-foreground); + color: var(--wb-background); +} + +/* Every row's highlight spans the rail, whatever its label's length, with + the main pane's corner radius. A project row's highlight is its inner + frame, which holds the name and the options menu together. */ +.sidebar li { + display: block; + width: 100%; +} + +.sidebar [data-slot="sidebar-item"], +.sidebar [data-slot="sidebar-item-row"] > div { + box-sizing: border-box; + width: 100%; + min-height: 32px; + border-radius: var(--radius-panel); + background: transparent; + color: var(--wb-muted-foreground); + font-size: 0.84375rem; + font-weight: 500; + line-height: 1.25; + transition: + background-color 120ms ease, + color 120ms ease, + box-shadow 120ms ease; +} + +.sidebar [data-slot="sidebar-item"] { + display: flex; + gap: 10px; + padding: 5px 8px; + border: 0; + font: inherit; + font-size: 0.84375rem; + font-weight: 500; + text-align: left; + cursor: pointer; +} + +.sidebar [data-slot="sidebar-item-row"] > div { + gap: 0; + padding-right: 4px; +} + +.sidebar [data-slot="sidebar-item-row"] > div > button:first-child { + gap: 10px; + padding: 5px 8px; +} + +.sidebar [data-slot="sidebar-item"] > span:first-child { + color: var(--wb-muted-foreground); + transition: color 120ms ease; +} + +.sidebar [data-slot="sidebar-item"] svg { + width: 16px; + height: 16px; + stroke-width: 1.75; +} + +.sidebar [data-slot="sidebar-item"]:hover, +.sidebar [data-slot="sidebar-item-row"] > div:hover { + background: var(--rail-hover); + color: var(--wb-foreground); +} + +.sidebar [data-slot="sidebar-item"]:hover > span:first-child { + color: var(--wb-foreground); +} + +.sidebar [data-slot="sidebar-item"]:active, +.sidebar [data-slot="sidebar-item-row"] > div:active { + background: var(--rail-press); +} + +.sidebar [data-slot="sidebar-item"][aria-current="page"], +.sidebar [data-slot="sidebar-item-row"] > div:has(> [aria-current]) { + background: var(--wb-background); + box-shadow: var(--wb-shadow); + color: var(--wb-foreground); + font-weight: 600; +} + +.sidebar [data-slot="sidebar-item"][aria-current="page"] > span:first-child { + color: var(--wb-foreground); +} + +.sidebar [data-slot="sidebar-item"]:focus-visible, +.sidebar [data-slot="sidebar-item-row"] button:focus-visible, +.sidebar [data-slot="sidebar-collapse-toggle"]:focus-visible { + outline: 2px solid var(--wb-ring); + outline-offset: -2px; +} + +.sidebar [data-slot="sidebar-footer"] { + margin-top: 6px; + padding: 8px 0 0; + border-top: 1px solid color-mix(in srgb, var(--wb-border) 60%, transparent); +} + +/* Collapsed: the brand mark over its toggle, and every row a centred + 36px square, so the icon column reads as deliberate rather than clipped. */ +.sidebar[data-collapsed="true"] [data-slot="sidebar-header"] { + flex-direction: column; + justify-content: center; + gap: 6px; + padding: 4px 0; +} + +.sidebar[data-collapsed="true"] [data-slot="sidebar-item"], +.sidebar[data-collapsed="true"] [data-slot="sidebar-item-row"] > div { + width: 36px; + min-height: 36px; + margin: 0 auto; + padding: 0; +} + +.sidebar[data-collapsed="true"] + [data-slot="sidebar-item-row"] + > div + > button:first-child { + justify-content: center; + gap: 0; + padding: 0; +} + +/* The one call to action in the rail, in the primary colour. */ +.sidebar [data-slot="sidebar-item"].newProject, +.sidebar [data-slot="sidebar-item"].newProject > span:first-child { + color: var(--primary-emphasis, var(--wb-primary)); +} + +.sidebar [data-slot="sidebar-item"].newProject { + margin-bottom: 4px; + background: color-mix(in srgb, var(--wb-primary) 10%, transparent); + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--wb-primary) 45%, transparent); + font-weight: 600; +} + +.sidebar [data-slot="sidebar-item"].newProject:hover { + background: color-mix(in srgb, var(--wb-primary) 16%, transparent); +} + +.sidebar [data-slot="sidebar-item"].newProject[aria-current="page"] { + background: color-mix(in srgb, var(--wb-primary) 18%, transparent); + box-shadow: inset 0 0 0 1px var(--wb-primary); +} + +/* A project row's options: the library reveals the ellipsis while the row + is pointed at or focused; an open menu keeps it up. */ +.sidebar .rowMenu { + width: 24px; + height: 24px; + color: var(--wb-muted-foreground); +} + +.sidebar + [data-slot="sidebar-item-row"] + > div + > span:has(> .rowMenu[data-state="open"]) { + opacity: 1; +} + +.sidebar .rowMenu:hover, +.sidebar .rowMenu[data-state="open"] { + background: var(--rail-press); + color: var(--wb-foreground); +} + +.sidebar[data-collapsed="true"] [data-slot="sidebar-item-row"] > div > span { + display: none; +} + +.sidebar[data-collapsed="true"] + [data-slot="sidebar-item-row"] + button + > span:last-child { + flex: none; + width: 0; +} + +/* ---- Canvas ---- */ + +.canvas { + display: flex; + margin: var(--space-2) var(--space-2) var(--space-2) 0; + border-radius: var(--radius-panel); + box-shadow: var(--wb-pane-shadow); + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--wb-pane); +} + +/* Everything beside the rail. Scrolls, except where a surface fills instead. */ +.canvasBody { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: var(--space-4) var(--space-5) var(--space-6); + display: grid; + gap: var(--space-2); + align-content: start; + max-width: var(--section-max); + width: 100%; +} + +.canvasBody[data-fill] { + background: var(--wb-background); + overflow: hidden; + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + max-width: none; +} + +/* The conversation log is the room's scrollport. A content-sized room lets + the settle spacer's height feed back into that measurement, and the page + grows on every resize. */ +.canvasBody[data-fill]:has(> [data-slot="stage-view"]) { + overflow: hidden; +} + +.canvasBody[data-fill] > * { + flex: 1; + min-height: 0; + gap: 0; +} + +.canvasBody[data-fill] > [data-slot="stage-view"] { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.canvasBody[data-fill] > [data-slot="banner"] { + flex: none; + margin: var(--space-3) var(--space-4) 0; +} + +.canvasBody:not([data-fill]) > [data-slot="card"] { + background: var(--wb-background); + box-shadow: var(--wb-shadow); +} + +.canvasBody[data-fill] > [data-slot="card"] { + background: var(--wb-background); + border: 0; + border-radius: 0; +} + +/* The home page runs edge to edge; its banner keeps the page's inset. */ +.canvasBody:has(> [data-slot="home-page"]) { + padding: 0; + max-width: none; +} + +.canvasBody:has(> [data-slot="home-page"]) > [data-slot="banner"] { + margin: var(--space-4) var(--space-6) 0; +} + +/* Phone widths: the rail stays an icon column, and opening it lays the full + rail over the workspace instead of squeezing it. */ +@media (max-width: 640px) { + .app { + grid-template-columns: 3.5rem minmax(0, 1fr); + } + + .sidebar[data-collapsed="false"] { + position: fixed; + inset: 0 auto 0 0; + z-index: 40; + background: var(--wb-frame); + box-shadow: var(--wb-pane-shadow); + } + + .canvas { + padding: var(--space-4) var(--space-4) var(--space-8); + } + + .canvas:has(> .canvasBody[data-fill]) { + padding: 0; + } +} + +/* A failed project load: calm and centered, not a raw red box. */ +.loadFailure { + max-width: 32rem; + margin: var(--space-8) auto; + padding: var(--space-6); + display: grid; + justify-items: center; + gap: var(--space-2); + text-align: center; +} + +.canvasBody[data-fill] > .loadFailure { + place-content: center; +} + +.loadFailure p { + margin: 0; + color: var(--wb-muted-foreground); +} + +.loadFailureTitle { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.loadFailureActions { + margin-top: var(--space-2); + display: flex; + gap: var(--space-2); +} + +.loadFailureDetails { + margin-top: var(--space-4); + width: 100%; + text-align: left; +} + +.loadFailureDetails summary { + cursor: pointer; + color: var(--wb-muted-foreground); + font-size: 0.8125rem; +} + +.loadFailureDetails pre { + margin: var(--space-2) 0; + padding: var(--space-3); + border: 1px solid var(--wb-border-strong); + border-radius: var(--radius-panel); + background: var(--wb-card); + white-space: pre-wrap; + word-break: break-word; + font-family: var(--wb-font-mono); + font-size: 0.8125rem; +} + +.whatsNewPanel { + padding-top: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.875rem; + font-family: var(--font-sans); +} + +.whatsNewCopy { + margin: 0; + font-size: 0.875rem; + line-height: 1.55; + color: var(--fg); +} + +.whatsNewActions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding-top: 0.25rem; +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8939b459..ef1824de 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,6 +9,7 @@ * shows in the workspace. */ import { wording } from "./config.ts"; +import styles from "./App.module.css"; import { humanFault, responseCode } from "./pages/workspace/human-fault.ts"; import { ProductApiError } from "@corbits-builder/client"; import { queryClient } from "./queries/client.ts"; @@ -37,6 +38,7 @@ import { import { Banner, Button, + InlineNote, Mark, notify, type NoticeAction, @@ -53,7 +55,7 @@ import { type HubUser, } from "./api/auth.ts"; import { WorkspaceMenu } from "./workspace-menu.tsx"; -import { Settings, type SettingsTab } from "./pages/settings.jsx"; +import { Settings, type SettingsTab } from "./pages/settings/settings.jsx"; import { Avatar, Badge, @@ -78,7 +80,7 @@ import { DialogTitle, } from "@corbits/react-ui/ui/dialog"; import { Auth } from "./pages/auth.jsx"; -import { StageWorkspace } from "./pages/workspace.jsx"; +import { StageWorkspace } from "./pages/workspace/index.tsx"; import { firstRunScreen, type HubAuthState } from "./first-run.ts"; import { ModelSetup, @@ -202,12 +204,12 @@ function ProjectLoadFailure({ }; return ( - - + + {wording.projectLoadFailure.title} {wording.projectLoadFailure.lead} - + {wording.projectLoadFailure.back} @@ -215,7 +217,7 @@ function ProjectLoadFailure({ {wording.action.tryAgain} - + {wording.projectLoadFailure.details} {detail} void copyDetail()}> @@ -259,10 +261,10 @@ export function WhatsNewDialog({ if (tabId === "approvals") { const feature = copy.features.approvals; return ( - - {feature.description} - - {feature.statusNote} + + {feature.description} + + {feature.statusNote} - {feature.description} + + {feature.description} ); }; @@ -294,7 +296,6 @@ export function WhatsNewDialog({ active={activeTab} onChange={setActiveTab} label={copy.sections} - className="whats-new-tabs" > {renderFeatureTab} @@ -360,7 +361,7 @@ export function AppSidebar({ ) .sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt)); return ( - + - + }> onNavigate("projects")} > {wording.shell.newProject} @@ -399,7 +400,7 @@ export function AppSidebar({ {listed.length > 0 ? ( {listed.map((item) => { const open = inProject && item.id === project.id; @@ -410,7 +411,7 @@ export function AppSidebar({ onSelect={() => onOpenProject(item.id)} leading={ @@ -445,10 +446,10 @@ export function AppSidebar({ ) : null} - + - + {wording.shell.whatsNew} {wording.shell.whatsNewBadge} @@ -803,9 +804,44 @@ export function App() { ); } + const projectView = () => { + if (selected === null) + return ( + + + {wording.shell.noProjectTitle} + + {wording.shell.noProjectLead} + + ); + if (settledMissId === selected && openProjectSummary === null) + return ( + void refresh()} + onBackToProjects={() => navigate("projects")} + /> + ); + return ( + + ); + }; + return ( <> - + - + {offline ? ( @@ -871,40 +906,7 @@ export function App() { /> ) : null} - {view === "project" ? ( - selected !== null && - settledMissId === selected && - openProjectSummary === null ? ( - void refresh()} - onBackToProjects={() => navigate("projects")} - /> - ) : selected !== null ? ( - - ) : ( - - - {wording.shell.noProjectTitle} - - {wording.shell.noProjectLead} - - ) - ) : null} + {view === "project" ? projectView() : null} {view === "settings" ? ( p { + margin: 0; +} + +.field { + display: grid; + gap: var(--space-2); +} + +.danger { + color: var(--wb-destructive); +} + +.menuItemLabel { + display: flex; + flex: 1; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +/* The one look for every select and menu trigger: borderless quiet text + that underlines on hover. */ +.quietControl { + width: auto; + height: auto; + padding: 2px 4px; + border: 0; + border-radius: 4px; + background: transparent; + box-shadow: none; + color: var(--wb-muted-foreground); + font: inherit; + font-size: 0.8125rem; + text-underline-offset: 3px; + cursor: pointer; +} + +select.quietControl { + padding-right: 22px; +} + +/* A button may carry an icon beside its words, such as a disclosure chevron. */ +button.quietControl { + display: inline-flex; + align-items: center; + gap: var(--space-1); +} + +.quietControl:hover:not(:disabled) { + color: var(--wb-foreground); + text-decoration: underline; +} + +.quietControl:focus-visible { + outline: 2px solid var(--wb-ring); + outline-offset: 2px; +} diff --git a/apps/web/src/components.stories.tsx b/apps/web/src/components.stories.tsx new file mode 100644 index 00000000..66e861d3 --- /dev/null +++ b/apps/web/src/components.stories.tsx @@ -0,0 +1,159 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { + Banner, + BannerAction, + Button, + Mark, + notify, + QuietSelect, + QuietState, + StateLabel, +} from "./components.tsx"; + +const TONES = [ + "success", + "okay", + "warning", + "error", + "selected", + "info", + "loading", + "disabled", +] as const; + +const meta = { + title: "Components/Primitives", +} satisfies Meta; + +export default meta; + +export const StateLabels: StoryObj = { + render: () => ( + + {TONES.map((tone) => ( + + {tone} + + ))} + + ), +}; + +export const QuietStates: StoryObj = { + render: () => ( + + {TONES.map((tone) => ( + + {tone} + + ))} + + ), +}; + +export const Buttons: StoryObj = { + args: { children: "Continue", onClick: fn() }, + argTypes: { + variant: { + control: "select", + options: [ + "primary", + "secondary", + "outline", + "ghost", + "link", + "destructive", + ], + }, + }, + render: (args) => , +}; + +export const ButtonVariants: StoryObj = { + render: () => ( + + {( + [ + "primary", + "secondary", + "outline", + "ghost", + "link", + "destructive", + ] as const + ).map((variant) => ( + + {variant} + + ))} + loading + + disabled + + + ), +}; + +export const BlockButton: StoryObj = { + render: () => ( + + + Sign in + + + ), +}; + +export const Banners: StoryObj = { + render: () => ( + + + Replies may take longer than usual. + + + Check the key in settings, then try again. + + Open settings + + + + + ), +}; + +export const Marks: StoryObj = { + args: { size: 26 }, + render: (args) => , +}; + +export const QuietSelects: StoryObj = { + render: () => ( + + List + Grid + + ), +}; + +export const Notices: StoryObj = { + render: () => ( + + notify("Saved.")}>Complete + notify("Saved two of three files.", false)}> + Partial + + + notify("Project archived.", true, { label: "Undo", onClick: fn() }) + } + > + With action + + + ), +}; diff --git a/apps/web/src/components.tsx b/apps/web/src/components.tsx index ab40ff61..691b05a9 100644 --- a/apps/web/src/components.tsx +++ b/apps/web/src/components.tsx @@ -8,9 +8,12 @@ import { StatusDot, type StatusDotTone, } from "@corbits/react-ui"; +import { MenuItem } from "@corbits/react-ui/ui/menu"; import corbitsMark from "./assets/corbits-mark.svg"; +import { wording } from "./config.ts"; +import styles from "./components.module.css"; -type Tone = +export type Tone = | "success" | "okay" | "warning" @@ -60,23 +63,35 @@ export function StateLabel({ ); } -/** A state beside other text, without a badge: a dot and the word that names it. */ +/** + * A state beside other text, without a badge: a dot and the word that names it. + * Where a row of states would be a row of dots, the words alone carry the tone. + */ export function QuietState({ tone = "info", + dot = true, children, }: { tone?: Tone; + dot?: boolean; children: ReactNode; }) { const mapped = STATE_TONES[tone]; return ( - - + + {dot ? ( + + ) : null} {children} ); @@ -135,39 +150,124 @@ export function Button({ ); } +/** + * Something about the whole surface that the person should know before going on. A failure with + * an obvious next move offers it in a `BannerAction` rather than describing it. + */ export function Banner({ tone = "warning", + role = "status", title, - action, children, }: { tone?: "warning" | "error" | "okay"; + /** An alert interrupts a screen reader; a status waits its turn. */ + role?: "status" | "alert"; title: string; - /** - * The way out. A failure with an obvious next move should offer it here - * rather than describe it and leave the person to find the screen. - */ - action?: { label: string; onClick: () => void }; children?: ReactNode; }) { return ( - {title} + {title} {/* A div, not a p: banners can carry block children, such as a form, and a form inside a p is invalid HTML. */} - {children ? {children} : null} - {action ? ( - - {action.label} - - ) : null} + {children ? {children} : null} + + ); +} + +/** The way out of a banner, under its words. */ +export function BannerAction({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** A short status or explanation in supporting text, beside or under what it is about. */ +export function InlineNote({ + as: Tag = "p", + pill = false, + role, + children, +}: { + /** A span sits within a line of other text rather than a paragraph of its own. */ + as?: "p" | "span"; + /** A note that labels a view, such as which version it shows, in a rounded chip. */ + pill?: boolean; + role?: "status"; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** + * What went wrong, where it went wrong, with the way to try again when there is one. Read aloud + * as soon as it appears. + */ +export function Fault({ + onRetry, + children, +}: { + onRetry?: (() => void) | undefined; + children: ReactNode; +}) { + if (onRetry === undefined) + return ( + + {children} + + ); + return ( + + {children} + + {wording.action.retry} + + + ); +} + +/** A label stacked above the control it names. */ +export function Field({ children }: { children: ReactNode }) { + return ( + + {children} ); } +/** A menu item that destroys or removes something, in the destructive colour. */ +export function DangerMenuItem( + props: Omit, "className">, +) { + return ; +} + +/** A menu item's words with something that belongs beside them, such as a version's date. */ +export function MenuItemLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + export function Mark({ size = 26 }: { size?: number }) { return ( ) { +/** The one look for a select or a small action among words: borderless text that underlines on hover. */ +export function QuietSelect( + props: Omit, "className">, +) { return ( + ); +} + +export function QuietButton( + props: Omit, "className" | "type">, +) { + return ( + ); } diff --git a/apps/web/src/composer.module.css b/apps/web/src/composer.module.css new file mode 100644 index 00000000..bfb78c2c --- /dev/null +++ b/apps/web/src/composer.module.css @@ -0,0 +1,164 @@ +.composer { + padding: var(--space-2) var(--space-4) var(--space-4); + display: grid; + gap: var(--space-2); + flex: none; + position: relative; + /* Capped so the send, stop, attach and mic controls never leave the pane. + The composer itself must not scroll: the attachment tray and the model + list float above the box, and a scrolling composer clips them. The + textarea scrolls its own text, and the notes above the box give way. */ + max-height: 70vh; + min-height: 0; +} + +.composer[data-variant="page"] { + padding: 0; +} + +.composer > form { + position: relative; +} + +/* While the specialist writes, a thin ring sweeps the box's border. */ +.composer[data-working] > form::after { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 2px; + background: conic-gradient( + from var(--ring-angle), + var(--wb-primary), + color-mix(in srgb, var(--wb-primary) 15%, transparent), + var(--wb-primary) + ); + /* The mask only needs an opaque colour; which one is irrelevant. */ + -webkit-mask: + linear-gradient(currentColor 0 0) content-box, + linear-gradient(currentColor 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + animation: + global(ring-spin) 1.1s linear infinite, + global(fade-in) 220ms ease-out both; + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + .composer[data-working] > form::after { + animation: none; + } +} + +.notes { + display: grid; + gap: var(--space-2); + min-height: 0; + overflow-y: auto; +} + +.notes:empty { + display: none; +} + +.composer > [data-slot="prompt-input"] { + flex-shrink: 0; +} + +/* The library's prompt input: the rounded box around the words and controls. */ +.composer [data-slot="prompt-input"] { + display: flex; + flex-flow: row nowrap; + align-items: flex-end; + gap: var(--space-1); + min-width: 0; + min-height: 0; + max-height: min(50vh, 480px); + border: 1px solid var(--wb-border); + border-radius: var(--radius-lg); + background: var(--wb-card); + padding: 6px 6px 6px var(--space-2); + transition: border-color 150ms; +} + +.composer [data-slot="prompt-input"]:focus-within { + outline: none; + border-color: var(--wb-border-strong); +} + +.composer [data-slot="prompt-input"] [data-slot="prompt-input-field"] { + align-self: center; + font-size: 15px; + line-height: 1.5; +} + +.composer + [data-slot="prompt-input"] + :is(textarea, [data-slot="prompt-input-mirror"]) { + padding-block: 4px; +} + +.composer [data-slot="prompt-input"] > div:has(> textarea) { + align-self: stretch; +} + +/* The library's input row is `flex: 1 1 0%` beside a full-basis toolbar that wraps below it, and + with a content-sized textarea the row resolved to 0 px wide: one character per line, and the + box grew to fill the page. A full basis gives it the whole first line. */ +.composer[data-variant="page"] + [data-slot="prompt-input"] + > div:has(> textarea) { + flex: 1 1 100%; +} + +.composer [data-slot="prompt-input"] textarea { + width: 100%; + min-height: 0; + max-height: min(200px, 30vh); + overflow-y: auto; + display: block; +} + +.composer [data-slot="prompt-input"] textarea:placeholder-shown { + white-space: nowrap; + text-overflow: ellipsis; +} + +.composer [data-slot="prompt-input"] textarea::placeholder { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.composer [data-slot="prompt-input"] [data-slot="prompt-input-toolbar"] { + flex: none; + gap: var(--space-1); + padding: 0; +} + +.composer [data-slot="prompt-input"] [data-slot="prompt-input-submit"] { + width: 32px; + height: 32px; + min-width: 32px; + min-height: 32px; + padding: 0; + border: 0; + background: var(--wb-foreground); + color: var(--wb-background); + border-radius: 50%; + box-shadow: none; +} + +.composer + [data-slot="prompt-input"] + [data-slot="prompt-input-submit"]:hover:not(:disabled) { + background: var(--wb-foreground); + opacity: 0.85; +} + +.composer + [data-slot="prompt-input"] + [data-slot="prompt-input-submit"]:disabled { + opacity: 0.2; +} diff --git a/apps/web/src/composer.tsx b/apps/web/src/composer.tsx new file mode 100644 index 00000000..c4d0d19d --- /dev/null +++ b/apps/web/src/composer.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; +import styles from "./composer.module.css"; + +/** + * The frame every composer sits in, on the home page and in a project: notes + * above the box, the box beneath. While the specialist works, a ring sweeps + * the box's border. + */ +export function Composer({ + variant = "pane", + working = false, + notes, + children, +}: { + /** In a pane it keeps its own inset; on a page the page gives it a gutter and its words the whole first line. */ + variant?: "pane" | "page"; + working?: boolean; + /** Faults and notices about the message, above the box; they give way before the box does. */ + notes?: ReactNode; + children: ReactNode; +}) { + return ( + + {notes === undefined ? null : ( + + {notes} + + )} + {children} + + ); +} diff --git a/apps/web/src/css-modules.test.ts b/apps/web/src/css-modules.test.ts new file mode 100644 index 00000000..01f26be7 --- /dev/null +++ b/apps/web/src/css-modules.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { Glob } from "bun"; + +// A class a component reads but its module lacks is a type error (see the `css-types` script); +// these are the two rules types cannot check. + +const root = import.meta.dir; + +function files(pattern: string): string[] { + return [...new Glob(pattern).scanSync(root)].sort(); +} + +/** The classes a module defines; `:global(...)` names belong to someone else. */ +function moduleClasses(path: string): Set { + const css = readFileSync(path, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/url\([^)]*\)/g, "") + .replace(/:global\([^)]*\)/g, "") + .replace(/"[^"]*"|'[^']*'/g, ""); + return new Set([...css.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)].map((m) => m[1]!)); +} + +/** Every class each module's importers read, keyed by the module's path. */ +function classesRead(): Map> { + const read = new Map>(); + for (const file of files("**/*.{ts,tsx}")) { + if (file.endsWith(".d.ts") || /\.test\.tsx?$/.test(file)) continue; + const path = join(root, file); + const source = readFileSync(path, "utf8"); + for (const [, local, specifier] of source.matchAll( + /import\s+(\w+)\s+from\s+"(\.{1,2}\/[^"]+\.module\.css)"/g, + )) { + const module = resolve(dirname(path), specifier!); + const names = read.get(module) ?? new Set(); + for (const [, name] of source.matchAll( + new RegExp(`\\b${local}\\.([A-Za-z_]\\w*)`, "g"), + )) + names.add(name!); + read.set(module, names); + } + } + return read; +} + +describe("component styles", () => { + test("live in a CSS module beside the component; styles.css holds only what is global", () => { + const plain = files("**/*.css").filter( + (file) => file !== "styles.css" && !file.endsWith(".module.css"), + ); + expect(plain).toEqual([]); + }); + + test("every class a module defines is read by a component", () => { + const read = classesRead(); + const unused = files("**/*.module.css").flatMap((file) => { + const names = read.get(join(root, file)); + return [...moduleClasses(join(root, file))] + .filter((name) => !names?.has(name)) + .map((name) => `${relative(root, join(root, file))}: .${name}`); + }); + expect(unused).toEqual([]); + }); +}); diff --git a/apps/web/src/dictation.module.css b/apps/web/src/dictation.module.css new file mode 100644 index 00000000..b4078d9b --- /dev/null +++ b/apps/web/src/dictation.module.css @@ -0,0 +1,95 @@ +.dictated { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.row { + display: flex; + align-items: flex-end; + gap: var(--space-2); + min-width: 0; +} + +.row > :last-child { + flex: 1; + min-width: 0; +} + +.row[data-align="start"] { + align-items: flex-start; +} + +.row[data-align="center"] { + align-items: center; +} + +.dictate { + width: 36px; + height: 36px; + margin-bottom: var(--space-2); + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-control); + border: 1px solid var(--wb-border); + background: var(--wb-card); + color: var(--wb-foreground); + cursor: pointer; + flex: none; +} + +.row:is([data-align="start"], [data-align="center"]) > .dictate { + margin-bottom: 0; +} + +.dictate svg { + width: 16px; + height: 16px; +} + +.dictate:hover:not(:disabled) { + border-color: var(--wb-primary); +} + +/* In a toolbar: a round, borderless icon like the toolbar's own. */ +.dictate.inToolbar { + width: 32px; + height: 32px; + margin: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--wb-muted-foreground); +} + +.dictate.inToolbar:hover:not(:disabled) { + background: var(--wb-muted); + color: var(--wb-foreground); +} + +.dictate:disabled { + cursor: default; + opacity: 0.5; +} + +/* Listening: the pressed look, green with the stop glyph. */ +.dictate[aria-pressed="true"] { + border-color: var(--wb-okay); + color: var(--wb-okay); + background: color-mix(in srgb, var(--wb-okay) 12%, var(--wb-card)); +} + +.state { + margin: 0; + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 1.25rem; + font-size: 0.8125rem; + color: var(--wb-muted-foreground); +} + +.state[role="alert"] { + color: var(--wb-destructive); +} diff --git a/apps/web/src/dictation.tsx b/apps/web/src/dictation.tsx index f0b8bfbc..0ee8eb1e 100644 --- a/apps/web/src/dictation.tsx +++ b/apps/web/src/dictation.tsx @@ -10,6 +10,8 @@ * tap on the microphone ends it, and so does a short silence once something * has been said. */ +import { clsx } from "clsx"; +import styles from "./dictation.module.css"; import { useCallback, useEffect, @@ -296,7 +298,8 @@ export function Dictated({ : "idle"; const control = supported ? ( void start()} @@ -305,17 +308,17 @@ export function Dictated({ const field = slotted ? children(control) : children; if (!supported) return <>{field}>; return ( - + {slotted ? ( field ) : ( - + {control} {field} )} = { + args: { size: 26 }, + render: (args) => , +}; + +/** Clicking the credit throws a swarm of flyers across the page. */ +export const OwnerCredit: StoryObj = { + render: () => , +}; diff --git a/apps/web/src/flourish.tsx b/apps/web/src/flourish.tsx index 526cf7b7..7b109373 100644 --- a/apps/web/src/flourish.tsx +++ b/apps/web/src/flourish.tsx @@ -9,7 +9,7 @@ import { } from "react"; import { wording } from "./config.ts"; import { Mark } from "./components.jsx"; -import "./flourish.css"; +import styles from "./flourish.module.css"; const FINE_MS = 3000; const CARD_MS = 6000; @@ -47,7 +47,7 @@ function HockeyStick(props: FlyerProps) { return ( @@ -60,20 +60,15 @@ function PalmTree(props: FlyerProps) { - - + + ); } @@ -117,7 +112,7 @@ export function FineMode() { }; }, [on]); return on ? ( - + {wording.flourish.fine} ) : null; @@ -158,12 +153,12 @@ export function LogoMark({ size }: { size: number }) { }, [shown]); return ( <> - + {shown ? ( - - + + @@ -190,7 +185,7 @@ export function Credit() { <> setSwarm(Date.now())} > {wording.flourish.forgedBy} @@ -236,9 +231,9 @@ function FlyerSwarm() { }); }, []); return ( - + {flyers.map(({ Kind, style }, index) => ( - + ))} ); @@ -280,15 +275,12 @@ export function useShellReply(area: RefObject) { return true; }; const reply = box ? ( - - + + {`$ ${wording.flourish.shellRequest}`} - {`\n${wording.flourish.shellAnswer}${SANDWICH}`} + {`\n${wording.flourish.shellAnswer}${SANDWICH}`} ) : null; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index f2707877..1ca0e9a1 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,14 +1,15 @@ +// The global sheets load before any module that imports a component, so each +// component's own CSS module comes after them and wins a tie. The library's +// sheet carries the Tailwind reset and the shared theme; ours adds the tokens +// and element defaults on top of it. +import "@corbits/react-ui/styles.css"; +import "./styles.css"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { ThemeProvider } from "@corbits/react-ui"; import { QueryClientProvider } from "@tanstack/react-query"; import { App } from "./App.jsx"; import { queryCacheReady, queryClient } from "./queries/client.ts"; -// The Corbits component library first — it carries the Tailwind reset and the -// shared theme — then our own sheet, which overrides it for surfaces this app -// owns. Order is the whole contract. -import "@corbits/react-ui/styles.css"; -import "./styles.css"; import { openSharedEventSource } from "./shared-event-source.ts"; import { redirectHashLink } from "./routes.ts"; import { wording } from "./config.ts"; diff --git a/apps/web/src/markdown.module.css b/apps/web/src/markdown.module.css new file mode 100644 index 00000000..a8ff5cc3 --- /dev/null +++ b/apps/web/src/markdown.module.css @@ -0,0 +1,204 @@ +/* The document itself. Measure and rhythm are the whole job here: this is the + one surface in the app a person reads rather than scans. */ +.prose { + font-size: 0.9375rem; + line-height: 1.7; +} + +/* Tracked changes: added in green, removed in red and struck through. */ +.prose ins { + text-decoration: none; + background: color-mix(in srgb, var(--wb-okay) 18%, transparent); + color: color-mix(in srgb, var(--wb-okay) 60%, var(--wb-foreground)); + border-radius: 2px; +} + +.prose del { + background: color-mix(in srgb, var(--wb-destructive) 12%, transparent); + color: color-mix(in srgb, var(--wb-destructive) 70%, var(--wb-foreground)); + text-decoration: line-through; + border-radius: 2px; +} + +/* The measure applies to running text, not the block: a table, code fence + or embedded frame inside the same document reads better at the pane's + full width than squeezed into the same column as the prose around it. + Left-aligned rather than centred: the measure lines up with the document + header above it, and a centred column in a wide pane reads as a mistake. + Wide enough that the card's own width — not this cap — is usually what + limits a line: 80ch left a wide empty gutter once the document pane grew + to take the room the proportional chat column gave up. */ +.prose > :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote) { + max-width: min(120ch, 1100px); +} + +/* In the document pane a line of running text stops at a comfortable reading + length, and its last line is not left with one word. */ +.prose[data-document] > :where(p, ul, ol, blockquote) { + max-width: 75ch; + text-wrap: pretty; +} + +.prose > * + * { + margin-top: 1em; +} + +.prose h2, +.prose h3, +.prose h4 { + font-weight: 500; + line-height: 1.3; +} + +.prose > h2 { + font-size: 1.25rem; + margin-top: 2em; +} + +.prose > h3 { + font-size: 1.0625rem; + margin-top: 1.75em; +} + +.prose > h4 { + font-size: 0.9375rem; + margin-top: 1.5em; + color: var(--wb-muted-foreground); +} + +.prose > :first-child { + margin-top: 0; +} + +.prose ul, +.prose ol { + padding-left: 1.25em; +} + +.prose ul { + list-style: disc; +} + +.prose ol { + list-style: decimal; +} + +.prose li.task { + list-style: none; + margin-left: -1.25em; +} + +.prose li + li { + margin-top: 0.35em; +} + +.prose blockquote { + margin-left: 0; + padding-left: var(--space-4); + border-left: 2px solid var(--wb-border-strong); + color: var(--wb-muted-foreground); +} + +.prose code { + font-family: var(--wb-font-mono); + font-size: 0.875em; + background: var(--wb-muted); + padding: 1px var(--space-1); +} + +.prose pre { + background: var(--wb-muted); + padding: var(--space-3) var(--space-4); + overflow-x: auto; +} + +.prose pre code { + background: none; + padding: 0; +} + +/* A wide table scrolls inside its own box rather than widening the page. */ +.table { + overflow-x: auto; + margin: 1em 0; +} + +.prose table { + border-collapse: collapse; + font-size: 0.9375em; +} + +.prose th, +.prose td { + border: 1px solid var(--wb-border); + padding: var(--space-2) var(--space-3); + text-align: left; + vertical-align: top; +} + +.prose th { + background: var(--wb-muted); + font-weight: 600; +} + +.prose hr { + border: 0; + border-top: 1px solid var(--wb-border); + margin: 2em 0; +} + +.prose ::selection { + background: color-mix(in srgb, var(--wb-primary) 26%, transparent); +} + +/* A picture in a document (#737): the PRD for people's screens, captioned. */ +.prose .figure { + margin: 1rem 0 1.25rem; +} + +.prose .figure img { + display: block; + max-width: 100%; + height: auto; + border: 1px solid var(--wb-border, #e5e7eb); + border-radius: 8px; +} + +.prose .figure figcaption { + margin-top: 0.375rem; + font-size: 0.8125rem; + color: var(--wb-muted-foreground); +} + +.prose .missingPicture { + color: var(--wb-muted-foreground); +} + +/* A document named in the conversation, opened in the reading pane. */ +.mention { + color: var(--wb-foreground); + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.mention:focus-visible { + outline: 2px solid var(--wb-foreground); + outline-offset: 2px; +} + +/* On paper the page sets the size and the measure, and nothing scrolls. */ +@media print { + .prose { + font-size: inherit; + line-height: inherit; + } + + .prose > *, + .prose[data-document] > :where(p, ul, ol, blockquote) { + max-width: none; + } + + .table { + overflow: visible; + } +} diff --git a/apps/web/src/markdown.stories.tsx b/apps/web/src/markdown.stories.tsx new file mode 100644 index 00000000..3e91ee9d --- /dev/null +++ b/apps/web/src/markdown.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { Markdown } from "./markdown.tsx"; + +const SAMPLE = `## Problem + +Small bakeries lose orders because they take them by phone and write them on paper. + +### Who has it + +- Owners who bake and serve at once +- Staff who **answer the phone** mid-shift +- [x] Interviewed three bakeries +- [ ] Priced an order tablet + +### Sources + +See the [interview notes](document:interview_notes) and \`orders.csv\`. + +> Half of Saturday's cake orders came in after noon. + +| Day | Orders | Missed | +| --- | --- | --- | +| Friday | 41 | 3 | +| Saturday | 66 | 9 | +`; + +const meta = { + title: "Components/Markdown", + component: Markdown, + args: { source: SAMPLE }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** A `document:` link shows as its words. */ +export const Document: Story = {}; + +/** A `document:` link opens the document. */ +export const WithDocumentLinks: Story = { + args: { openDocument: fn() }, +}; diff --git a/apps/web/src/markdown.tsx b/apps/web/src/markdown.tsx index 2ade90b1..92d4d763 100644 --- a/apps/web/src/markdown.tsx +++ b/apps/web/src/markdown.tsx @@ -13,6 +13,7 @@ */ import type { JSX, ReactNode } from "react"; import { DEL, END, INS } from "./revisions.ts"; +import styles from "./markdown.module.css"; /** * Inline spans: code, bold, italic, links, with tracked changes threaded through them. @@ -57,12 +58,12 @@ function inline( images?.get(path) ?? (path.startsWith("data:image/") ? path : null); out.push( src ? ( - + {caption ? {caption} : null} ) : ( - + {caption || "picture"} (picture in the documents download) ), @@ -81,7 +82,8 @@ function inline( key={key} role="button" tabIndex={0} - className="artifact-mention" + className={styles.mention} + data-slot="document-mention" onClick={() => openDocument(id)} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; @@ -291,17 +293,24 @@ const TASK = /^\[([ xX])\]\s+/; export function Markdown({ source, + document = false, images, openDocument, }: { source: string; + /** A document read in the document pane: running text keeps a reading measure. */ + document?: boolean; images?: ReadonlyMap; /** Opens a `document:` link; without it such a link shows as its words. */ openDocument?: (id: string) => void; }): JSX.Element { const blocks = parse(source); return ( - + {blocks.map((block, index) => { const key = `b${index}`; switch (block.kind) { @@ -333,7 +342,7 @@ export function Markdown({ {block.items.map((item, at) => { const task = TASK.exec(item); return task ? ( - + + diff --git a/apps/web/src/pages/approval-fields.module.css b/apps/web/src/pages/approval-fields.module.css new file mode 100644 index 00000000..5464ef88 --- /dev/null +++ b/apps/web/src/pages/approval-fields.module.css @@ -0,0 +1,99 @@ +.fields { + display: flex; + flex-direction: column; + gap: 0.375rem; + width: 100%; + min-width: 0; +} + +.fields[data-separated] { + margin-top: var(--space-2); + padding-top: var(--space-2); + border-top: 1px solid var(--wb-border, var(--border)); +} + +.row { + display: grid; + grid-template-columns: 4.75rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; + width: 100%; + min-width: 0; + min-height: 1.875rem; +} + +.row[data-variant="form"] { + grid-template-columns: 4.75rem minmax(0, 1fr); +} + +.label { + font-size: 0.75rem; + font-weight: 500; + color: var(--muted-fg, var(--wb-muted-foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.control { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-width: 0; +} + +.leanControl { + width: 100%; + min-width: 0; + height: 1.75rem; + padding: 0.125rem 0.5rem; + font-size: 0.8125rem; + border-radius: var(--radius-sm, 0.25rem); + border: 1px solid transparent; + background: transparent; + transition: + border-color 120ms ease, + background-color 120ms ease; +} + +.leanControl:hover { + border-color: var(--border-subtle, var(--border, var(--wb-border))); +} + +.leanControl:focus { + border-color: var(--border, var(--wb-border)); + background: var(--surface, transparent); + outline: none; +} + +.leanControl[data-variant="form"] { + border: 1px solid var(--wb-border, var(--border)); + background: var(--wb-background, transparent); +} + +.leanControl[data-variant="form"]:focus { + border-color: var(--wb-primary, var(--primary)); +} + +.saveButton { + height: 1.625rem; + padding: 0 0.5rem; + font-size: 0.75rem; + white-space: nowrap; +} + +.leanButton { + height: 1.625rem; + padding: 0 0.5rem; + font-size: 0.75rem; + border: none; + background: transparent; + color: var(--muted-fg, var(--wb-muted-foreground)); + box-shadow: none; +} + +.leanButton:hover:not(:disabled) { + color: var(--fg, var(--wb-foreground)); + background: var(--surface-hover, rgba(0, 0, 0, 0.04)); +} diff --git a/apps/web/src/pages/approval-fields.tsx b/apps/web/src/pages/approval-fields.tsx new file mode 100644 index 00000000..ccbda10e --- /dev/null +++ b/apps/web/src/pages/approval-fields.tsx @@ -0,0 +1,130 @@ +/** + * The compact reviewer fields shared by the approval rounds and the workspace approvals setting: + * a stack of label-and-control rows, their borderless controls and their quiet buttons. + */ +import { Button, Input } from "@corbits/react-ui"; +import { + createContext, + useContext, + type ComponentProps, + type ReactNode, +} from "react"; +import styles from "./approval-fields.module.css"; + +type FieldsVariant = "inline" | "form"; + +/** Which variant of `ApprovalFields` a row or control sits in; outside one it is inline. */ +const FieldsVariantContext = createContext("inline"); + +/** + * A stack of reviewer fields. The `form` variant boxes its controls and drops the action column, + * for entering a new reviewer; `separated` rules it off from what sits above. + */ +export function ApprovalFields({ + variant = "inline", + separated = false, + children, + ...props +}: { + variant?: FieldsVariant; + separated?: boolean; + children: ReactNode; +} & Pick, "role" | "aria-label">) { + return ( + + {children} + + ); +} + +/** One field: its label, its control, and in the inline variant an action beside it. */ +export function ApprovalField({ + label, + action, + title, + children, +}: { + label: ReactNode; + /** Present (even when null) in the inline variant, so every row keeps its action column. */ + action?: ReactNode; + title?: string; + children: ReactNode; +}) { + const variant = useContext(FieldsVariantContext); + return ( + + {label} + + {children} + + {action === undefined ? null : {action}} + + ); +} + +/** A borderless text input that shows its edge on hover and focus, boxed in a form. */ +export function LeanInput( + props: Omit, "className">, +) { + const variant = useContext(FieldsVariantContext); + return ( + + ); +} + +/** The select that matches `LeanInput`. */ +export function LeanSelect(props: Omit, "className">) { + const variant = useContext(FieldsVariantContext); + return ( + + ); +} + +/** The outlined button that saves one field or adds an entry. */ +export function FieldSaveButton( + props: Omit, "className" | "size" | "variant">, +) { + return ( + + ); +} + +/** A quiet text button for the secondary actions around reviewers. */ +export function LeanButton( + props: Omit, "className" | "size" | "variant">, +) { + return ( + + ); +} diff --git a/apps/web/src/pages/invite-accept.tsx b/apps/web/src/pages/invite-accept.tsx index a63ab06f..9f3934c7 100644 --- a/apps/web/src/pages/invite-accept.tsx +++ b/apps/web/src/pages/invite-accept.tsx @@ -82,7 +82,7 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) { if (accepted !== null) { return ( - + Welcome in @@ -98,7 +98,7 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) { if (failed !== null) { return ( - + This invitation could not be read @@ -109,7 +109,7 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) { if (invitation === null) { return ( - + Opening the invitation… @@ -119,37 +119,37 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) { const pending = invitation.state === "pending"; return ( - + You're invited - - - - + + + + Invited by - {invitation.invitedBy} + {invitation.invitedBy} - - + + Workspace - {invitation.tenantId} + {invitation.tenantId} - - + + Role - {roleName ?? invitation.roleId} + {roleName ?? invitation.roleId} - - + + Email - {invitation.email} + {invitation.email} - - + + State diff --git a/apps/web/src/pages/model-setup.module.css b/apps/web/src/pages/model-setup.module.css new file mode 100644 index 00000000..b531557a --- /dev/null +++ b/apps/web/src/pages/model-setup.module.css @@ -0,0 +1,57 @@ +/* Connect a model: the sign-in column's rhythm and LoginForm's heading, for + the one screen before the project list. */ +.setup { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.setup hgroup { + display: grid; + gap: var(--space-2); +} + +.setup h1 { + margin: 0; + font-size: 1.875rem; + font-weight: 600; + line-height: 1; + letter-spacing: -0.015em; + text-wrap: balance; +} + +.setup hgroup p, +.later p { + margin: 0; + color: var(--wb-muted-foreground); + font-size: 0.875rem; + line-height: 1.5; +} + +/* No cursor here: the global button rules give an enabled tile the hand and a + disabled one not-allowed. */ +.tiles > button { + gap: var(--space-2); + min-height: 7.5rem; + padding: var(--space-4); + border-radius: var(--radius-md); +} + +.tiles > button svg { + width: 24px; + height: 24px; + color: var(--wb-foreground); +} + +.divider { + margin: 0; + border: 0; + border-top: 1px solid var(--wb-border); +} + +.later { + display: grid; + justify-items: center; + gap: var(--space-1); + text-align: center; +} diff --git a/apps/web/src/pages/model-setup.tsx b/apps/web/src/pages/model-setup.tsx index 13ddfe08..523b090a 100644 --- a/apps/web/src/pages/model-setup.tsx +++ b/apps/web/src/pages/model-setup.tsx @@ -14,13 +14,14 @@ import { DialogHeader, DialogTitle, } from "@corbits/react-ui/ui/dialog"; -import { Banner, Mark, QUIET_CONTROL } from "../components.jsx"; +import { Banner, Mark, QuietButton } from "../components.jsx"; import { AddProviderDialog, useModelCatalog, useSubscriptionLogins, -} from "./providers.jsx"; -import { ProviderLogo } from "./provider-logos.tsx"; +} from "./settings/providers.jsx"; +import { ProviderLogo } from "./settings/provider-logos.tsx"; +import styles from "./model-setup.module.css"; const copy = wording.providers; @@ -90,7 +91,7 @@ function ProviderTiles({ {fault} ({ id: provider.id, title: provider.label, @@ -103,10 +104,10 @@ function ProviderTiles({ if (provider) void signIn(provider); }} /> - + ({ id: provider.id, title: provider.label, @@ -152,16 +153,14 @@ export function ModelSetup({ } panel={} > - + {copy.connect} {copy.noneHint} - - + { try { sessionStorage.setItem(MODEL_SETUP_LATER_KEY, "1"); @@ -176,7 +175,7 @@ export function ModelSetup({ }} > {wording.action.skipForNow} - + {copy.addLater} @@ -207,7 +206,7 @@ export function EnableAiDialog({ {copy.connect} {copy.noneHint} - + - {html === null ? null : } + {html === null ? null : } ); } diff --git a/apps/web/src/pages/project-library.css b/apps/web/src/pages/project-library.css deleted file mode 100644 index 0f0a40a7..00000000 --- a/apps/web/src/pages/project-library.css +++ /dev/null @@ -1,218 +0,0 @@ -/** - * The Projects page: filter chips over a grid of cards. All rules are under - * `.library-page`. - */ - -.library-page { - display: grid; - gap: var(--space-4); - align-content: start; -} - -.library-page .library-filters { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - -.library-page .library-note { - margin: 0; - color: var(--wb-muted-foreground); -} - -.library-page .library-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); - gap: var(--space-4); - margin: 0; - padding: 0; - list-style: none; -} - -.library-page .library-card { - position: relative; - overflow: hidden; - border: 1px solid var(--wb-border); - border-radius: var(--radius-panel); - background: var(--wb-card); - transition: border-color 120ms ease; -} - -.library-page .library-card:hover, -.library-page .library-card:focus-within { - border-color: var(--wb-border-strong); -} - -.library-page .library-card-open { - display: flex; - flex-direction: column; - width: 100%; - padding: 0; - border: 0; - background: transparent; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; -} - -.library-page .library-card-open:focus-visible { - outline: 2px solid var(--wb-ring); - outline-offset: -2px; -} - -.library-page .library-card-body { - display: grid; - gap: var(--space-2); - padding: var(--space-3) var(--space-4) var(--space-4); -} - -.library-page .library-card-title { - overflow: hidden; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -.library-page .library-card-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-2); - padding-right: var(--space-6); - color: var(--wb-muted-foreground); - font-size: 0.8125rem; -} - -.library-page .library-card-menu { - position: absolute; - right: var(--space-2); - bottom: var(--space-3); -} - -/* A cover that shows the mockup lays its first page out at four times the tile's width, the way a - desktop would, and scales it down. The frame is white like every other mockup frame, so a page - that sets no background of its own reads the same here as in the themes pane. */ -.library-page .library-cover[data-mockup] { - position: relative; - overflow: hidden; -} - -.library-page .library-cover .gui-frame { - position: absolute; - inset: 0 auto auto 0; - width: 400%; - height: 400%; - min-height: 0; - border: 0; - background: #fff; - transform: scale(0.25); - transform-origin: 0 0; - pointer-events: none; -} - -/* Each cover mixes two theme colours into the card's own surface, so it stays - soft in light mode and does not glare in dark mode. */ -.library-page .library-cover { - --cover-1: color-mix(in oklab, var(--cover-a) 65%, var(--wb-card)); - --cover-2: color-mix(in oklab, var(--cover-b) 45%, var(--wb-card)); - display: block; - aspect-ratio: 16 / 7; - background: linear-gradient( - var(--cover-angle), - var(--cover-1), - var(--cover-2) - ); -} - -.library-page .library-cover[data-pattern="radial"] { - background: radial-gradient( - circle at var(--cover-x) var(--cover-y), - var(--cover-1), - var(--cover-2) 75% - ); -} - -.library-page .library-cover[data-pattern="stripes"] { - background: repeating-linear-gradient( - var(--cover-angle), - var(--cover-1) 0 14px, - var(--cover-2) 14px 28px - ); -} - -.library-page .library-cover[data-pattern="rings"] { - background: repeating-radial-gradient( - circle at var(--cover-x) var(--cover-y), - var(--cover-1) 0 12px, - var(--cover-2) 12px 24px - ); -} - -.library-page .library-cover[data-pattern="mesh"] { - background: - radial-gradient( - at var(--cover-x) var(--cover-y), - var(--cover-1), - transparent 60% - ), - radial-gradient( - at calc(100% - var(--cover-x)) calc(100% - var(--cover-y)), - var(--cover-2), - transparent 60% - ), - color-mix(in oklab, var(--cover-b) 15%, var(--wb-card)); -} - -.library-page .library-view { - display: inline-flex; - gap: var(--space-1); -} - -/* As a list: one line per project, a small swatch of its cover at the start, - the stage and last activity after the name, and the menu at the end. */ -.library-page .library-grid[data-view="rows"] { - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.library-page .library-grid[data-view="rows"] .library-card-open { - flex-direction: row; - align-items: center; - gap: var(--space-3); - padding: var(--space-2) var(--space-12) var(--space-2) var(--space-3); -} - -.library-page .library-grid[data-view="rows"] .library-cover { - flex: none; - width: 2rem; - aspect-ratio: 1; - border-radius: var(--radius-lg); -} - -.library-page .library-grid[data-view="rows"] .library-card-body { - display: flex; - flex: 1; - min-width: 0; - align-items: center; - gap: var(--space-3); - padding: 0; -} - -.library-page .library-grid[data-view="rows"] .library-card-title { - flex: 1; - min-width: 0; -} - -.library-page .library-grid[data-view="rows"] .library-card-meta { - flex-wrap: nowrap; - padding-right: 0; - white-space: nowrap; -} - -.library-page .library-grid[data-view="rows"] .library-card-menu { - top: 50%; - bottom: auto; - transform: translateY(-50%); -} diff --git a/apps/web/src/pages/project-library.module.css b/apps/web/src/pages/project-library.module.css new file mode 100644 index 00000000..2ecd8314 --- /dev/null +++ b/apps/web/src/pages/project-library.module.css @@ -0,0 +1,141 @@ +/* The Projects page: filter chips over a grid of cards. */ + +.page { + display: grid; + gap: var(--space-4); + align-content: start; +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.note { + margin: 0; + color: var(--wb-muted-foreground); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: var(--space-4); + margin: 0; + padding: 0; + list-style: none; +} + +.card { + position: relative; + overflow: hidden; + border: 1px solid var(--wb-border); + border-radius: var(--radius-panel); + background: var(--wb-card); + transition: border-color 120ms ease; +} + +.card:hover, +.card:focus-within { + border-color: var(--wb-border-strong); +} + +.cardOpen { + display: flex; + flex-direction: column; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.cardOpen:focus-visible { + outline: 2px solid var(--wb-ring); + outline-offset: -2px; +} + +.cardBody { + display: grid; + gap: var(--space-2); + padding: var(--space-3) var(--space-4) var(--space-4); +} + +.cardTitle { + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cardMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + padding-right: var(--space-6); + color: var(--wb-muted-foreground); + font-size: 0.8125rem; +} + +.cardMenu { + position: absolute; + right: var(--space-2); + bottom: var(--space-3); +} + +.view { + display: inline-flex; + gap: var(--space-1); +} + +/* As a list: one line per project, a small swatch of its cover at the start, + the stage and last activity after the name, and the menu at the end. */ +.grid[data-view="rows"] { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.grid[data-view="rows"] .cardOpen { + flex-direction: row; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-12) var(--space-2) var(--space-3); +} + +.grid[data-view="rows"] [data-slot="project-cover"] { + flex: none; + width: 2rem; + aspect-ratio: 1; + border-radius: var(--radius-lg); +} + +.grid[data-view="rows"] .cardBody { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: var(--space-3); + padding: 0; +} + +.grid[data-view="rows"] .cardTitle { + flex: 1; + min-width: 0; +} + +.grid[data-view="rows"] .cardMeta { + flex-wrap: nowrap; + padding-right: 0; + white-space: nowrap; +} + +.grid[data-view="rows"] .cardMenu { + top: 50%; + bottom: auto; + transform: translateY(-50%); +} diff --git a/apps/web/src/pages/project-library.tsx b/apps/web/src/pages/project-library.tsx index 737875cc..022cfae4 100644 --- a/apps/web/src/pages/project-library.tsx +++ b/apps/web/src/pages/project-library.tsx @@ -31,7 +31,7 @@ import type { ProjectSummary } from "../api/types.ts"; import type { NoticeAction } from "../components.jsx"; import { ProjectCover } from "./project-cover.tsx"; import { ProjectMenu } from "./project-menu.jsx"; -import "./project-library.css"; +import styles from "./project-library.module.css"; type Filter = ProjectStage | "archived"; const FILTERS: readonly Filter[] = ["idea", "building", "shipped", "archived"]; @@ -100,7 +100,7 @@ function ViewToggle({ }) { const text = wording.library.view; return ( - + project.id === pending.id)) return; - const cards = - section.current?.querySelectorAll(".library-card-open") ?? - []; + const root = section.current; + if (root === null) return; + const cards = root.querySelectorAll("[data-card-open]"); const next = cards[Math.min(pending.index, cards.length - 1)] ?? - section.current?.querySelector( - ".library-filters [aria-pressed='true']", - ); + root.querySelector("[data-filters] [aria-pressed='true']"); next?.focus(); }, [projects, shown]); let body: ReactNode; if (!loaded) body = ( - + {failed ? wording.library.failed : wording.library.loading} ); @@ -220,14 +218,15 @@ export function ProjectLibrary({ ); else body = ( - + {shown.map((project, index) => { const cover = coverOf(project.id); return ( - + onOpen(project.id)} > - - {project.title} - + + {project.title} + @@ -289,13 +288,14 @@ export function ProjectLibrary({ ); return ( - + diff --git a/apps/web/src/pages/project-menu.module.css b/apps/web/src/pages/project-menu.module.css new file mode 100644 index 00000000..7b376fce --- /dev/null +++ b/apps/web/src/pages/project-menu.module.css @@ -0,0 +1,37 @@ +.info { + width: min(52rem, calc(100vw - 2rem)); + max-width: none; +} + +.facts { + margin-bottom: var(--space-4); +} + +.facts > div { + display: grid; + grid-template-columns: 144px minmax(0, 1fr); + gap: var(--space-4); + padding: var(--space-1) 0; + border-bottom: 1px solid var(--wb-border); +} + +.facts > div:last-child { + border-bottom: 0; +} + +.facts > div > dt { + margin: 0; +} + +.facts > div > dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + .facts > div { + grid-template-columns: 1fr; + gap: var(--space-1); + } +} diff --git a/apps/web/src/pages/project-menu.tsx b/apps/web/src/pages/project-menu.tsx index a73e80e7..2ccae1e3 100644 --- a/apps/web/src/pages/project-menu.tsx +++ b/apps/web/src/pages/project-menu.tsx @@ -37,7 +37,14 @@ import { shipProject, updateProject, } from "../api/projects.ts"; -import { Button, type NoticeAction } from "../components.jsx"; +import { + Button, + DangerMenuItem, + Fault, + Field, + type NoticeAction, +} from "../components.jsx"; +import styles from "./project-menu.module.css"; import { Dictated } from "../dictation.jsx"; /** What the menu needs of a project: the summaries (rail, top bar) both have it. */ @@ -186,8 +193,7 @@ export function ProjectMenu({ {confirming ? ( - void act(async () => { await archiveProject(project.id); @@ -196,17 +202,16 @@ export function ProjectMenu({ } > Yes, delete it - + ) : ( - { event.preventDefault(); setConfirming(true); }} > Delete… - + )} @@ -214,7 +219,6 @@ export function ProjectMenu({ // Portaled, but React-nested in the host: its events bubble to a // card face's handlers unless stopped here, the same as the menu's. event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()} @@ -231,7 +235,6 @@ export function ProjectMenu({ ) : null} {updating ? ( event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()} @@ -245,7 +248,6 @@ export function ProjectMenu({ ) : null} {exporting ? ( event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()} @@ -352,11 +354,7 @@ function ExportDialog({ {available === null ? ( {wording.exportProject.loading} ) : null} - {failure === null ? null : ( - - {failure} - - )} + {failure === null ? null : {failure}} {EXPORT_SECTIONS.map((section) => ( (open ? undefined : onClose())}> - + {project.title} - + Name - - + + {project.createdAt !== undefined ? ( Created diff --git a/apps/web/src/pages/home-layout.css b/apps/web/src/pages/projects.module.css similarity index 57% rename from apps/web/src/pages/home-layout.css rename to apps/web/src/pages/projects.module.css index cfdf64e6..8da60700 100644 --- a/apps/web/src/pages/home-layout.css +++ b/apps/web/src/pages/projects.module.css @@ -1,20 +1,6 @@ -/** - * The New project page: a centred prompt. - * - * All rules are under `.home-page` (or `:has(> .home-page)`) so settings - * rows and the workspace sheet do not share selectors. - */ +/* The New project page: a centred prompt. */ -.canvas-body:has(> .home-page) { - padding: 0; - max-width: none; -} - -.canvas-body:has(> .home-page) > .banner { - margin: var(--space-4) var(--space-6) 0; -} - -.home-page { +.homePage { display: grid; align-content: start; max-width: 1080px; @@ -26,7 +12,7 @@ /* The prompt is the page: centred in the pane's height, a reading width wide, with the question above it. */ -.home-page .new-project { +.newProject { display: flex; flex-direction: column; gap: var(--space-4); @@ -37,14 +23,14 @@ margin: 0 auto; } -.home-page .new-project-head { +.head { display: grid; gap: var(--space-2); margin-bottom: var(--space-4); text-align: center; } -.home-page .new-project-head h1 { +.head h1 { margin: 0; font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem); font-weight: 700; @@ -53,56 +39,50 @@ text-wrap: balance; } -.home-page .new-project-head p { +.head p { margin: 0; font-size: 1.0625rem; color: var(--wb-muted-foreground); text-wrap: balance; } -.home-page .new-project-actions { +.actions { display: flex; align-items: center; justify-content: center; gap: var(--space-4); } -.home-page .new-project-actions [data-slot="button"] { +.actions [data-slot="button"] { font-size: 0.75rem; color: var(--wb-muted-foreground); } -/* The composer is the workspace's own (workspace-layout.css), only wider: - the page already gives it its gutter. */ -.home-page .new-project > .composer { - padding: 0; -} - -.home-page .new-project-actions [role="status"] { +.actions [role="status"] { font-size: 0.8125rem; color: var(--wb-muted-foreground); } -.home-page .import-confirm-actions { +.importActions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } -.home-page .import-folder-notes { +.folderNotes { margin: 8px 0 0; padding-inline-start: 1.25em; font-size: 0.8125rem; color: var(--wb-muted-foreground); } -.home-page .import-folder-files { +.folderFiles { display: grid; gap: 4px; margin-top: 12px; } -.home-page .import-folder-files > p { +.folderFiles > p { margin: 0; } diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx index 5eca16a2..3454e4ac 100644 --- a/apps/web/src/pages/projects.tsx +++ b/apps/web/src/pages/projects.tsx @@ -30,14 +30,14 @@ import { readDirectory, type PickedDirectory, } from "../api/repository.ts"; -import { Banner, Button } from "../components.jsx"; -import { ConnectProvider } from "./providers.jsx"; +import { Banner, BannerAction, Button } from "../components.jsx"; +import { ConnectProvider } from "./settings/providers.jsx"; import { NewProjectProfile } from "./profile-select.jsx"; import { Dictated } from "../dictation.jsx"; import { useShellReply } from "../flourish.tsx"; -import { COMPOSER_BOX_CLASS } from "./workspace/pane-classes.ts"; +import { Composer } from "../composer.tsx"; import { HOME_COMPOSER_PLACEHOLDER, canStartProject } from "./home-view.ts"; -import "./home-layout.css"; +import styles from "./projects.module.css"; import type { ProjectImport } from "@corbits-builder/schemas/project-import"; import { DOCUMENT_LABELS, @@ -219,22 +219,25 @@ export function Projects({ }; return ( - - - + + + {wording.newProject.title} {wording.newProject.lead} {needsProvider ? ( {wording.providers.noneHint} - + - + ) : null} - {/* The project page's composer, the same markup under the same class. */} - + {/* The project page's composer, the same frame as a project's. */} + {(mic) => ( void start()} @@ -261,9 +263,9 @@ export function Projects({ )} - + {shell.reply} - + {starting ? ( {wording.newProject.starting} @@ -326,7 +328,7 @@ export function Projects({ Importing with approvals records your own approval of exactly these versions. - + @@ -406,14 +408,14 @@ function RepositoryBanner({ {text.folderToWrite(labels(toWrite))} ) : null} {report.warnings.length > 0 ? ( - + {report.warnings.map((warning) => ( {warning} ))} ) : null} {choices.size > 0 ? ( - + {text.folderLeaveOut} {[...choices].map(([path, reason]) => ( ) : null} - + {text.folderImport} diff --git a/apps/web/src/pages/settings-layout.css b/apps/web/src/pages/settings-layout.css deleted file mode 100644 index 571d6f10..00000000 --- a/apps/web/src/pages/settings-layout.css +++ /dev/null @@ -1,312 +0,0 @@ -/* - * Settings page body — the mockup's section/row language, scoped so it cannot - * collide with `.field` / `.settings-section` in the shared sheet. Rows sit - * on the page rather than in bordered cards; space separates them, not rules. - */ -.settings-page { - max-width: 720px; - width: 100%; - min-width: 0; - margin: 0 auto; - padding: var(--space-8) var(--space-6) var(--space-12); - display: flex; - flex-direction: column; - gap: var(--space-6); -} - -.settings-page .settings-tabs { - gap: var(--space-8); -} - -.settings-page .settings-tabs > [role="tablist"] { - flex-wrap: nowrap; - overflow-x: auto; - scrollbar-width: none; - border-bottom-color: color-mix(in srgb, var(--border) 60%, transparent); -} - -.settings-page .settings-tabs [role="tab"] { - flex: none; - white-space: nowrap; -} - -.settings-page .settings-tabs > [role="tabpanel"] { - display: flex; - flex-direction: column; - gap: var(--space-12); - outline: none; -} - -.settings-page > h1 { - margin: 0; - font-size: 22px; - font-weight: 700; -} - -.settings-page .section { - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.settings-page .section-head { - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.settings-page .section-head h2 { - margin: 0; - font-size: 15px; - font-weight: 700; -} - -.settings-page .section-head p { - margin: 0; - font-size: 13px; - color: var(--muted-foreground); -} - -.settings-page .section-body { - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.settings-page .row, -.settings-rows .row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); - padding: var(--space-3) 0; - font-size: 14px; -} - -.settings-page .row .k, -.settings-rows .row .k { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; -} - -.settings-page .row .k b, -.settings-rows .row .k b { - display: inline-flex; - align-items: center; - gap: var(--space-2); - font-weight: 600; - font-size: 13.5px; -} - -.settings-page .row .k span, -.settings-rows .row .k span { - font-size: 12px; - color: var(--muted-foreground); - font-family: var(--font-mono); -} - -.settings-page .row .v, -.settings-rows .row .v { - font-family: var(--font-mono); - font-size: 12px; - color: var(--muted-foreground); - display: flex; - align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - gap: var(--space-3); - flex-shrink: 0; - max-width: min(420px, 100%); -} - -.settings-page input.field, -.settings-rows input.field { - display: block; - font-family: var(--font-mono); - font-size: 12.5px; - border: 1px solid var(--border-strong); - border-radius: var(--radius-md); - background: var(--background); - color: var(--foreground); - padding: 6px 10px; - width: 220px; - max-width: 100%; - outline: none; -} - -.settings-page input.field:focus, -.settings-rows input.field:focus { - border-color: var(--primary); -} - -.settings-page .section-body > .banner, -.settings-page .section-body > .inline-note, -.settings-page .section-body > p { - margin: var(--space-2) 0; -} - -.settings-page .section-body > .inline-note, -.settings-page .section-body > p { - font-size: 13px; - color: var(--muted-foreground); -} - -.settings-page .invite-form { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: var(--space-2); -} - -/* At phone width a row's controls drop under its label instead of squeezing - it to one word per line, and the canvas's own padding is the gutter. */ -@media (max-width: 560px) { - .settings-page { - padding: var(--space-4) 0 var(--space-12); - } - - .settings-page .row, - .settings-rows .row { - flex-wrap: wrap; - } - - .settings-page .row .v, - .settings-rows .row .v { - flex-shrink: 1; - justify-content: flex-start; - } - - .settings-page .invite-form { - justify-content: flex-start; - } -} - -.settings-page .quiet-state, -.settings-rows .quiet-state { - display: inline-flex; - align-items: center; - gap: var(--space-2); - flex: none; - font-size: 13px; - color: var(--muted-foreground); -} - -.profile-role { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--space-2); - margin: 0; - padding: 0; - border: 0; - min-width: 0; -} - -.profile-role > legend { - grid-column: 1 / -1; - padding: 0 0 6px; - font-size: 13px; - font-weight: 600; -} - -@media (max-width: 520px) { - .profile-role { - grid-template-columns: minmax(0, 1fr); - } -} - -/* Settings rows in a dialog: the body's own gap would double the rows' spacing, and rows sit a - little closer so a whole form fits without scrolling. */ -.provider-form.settings-rows { - gap: 0; -} - -.provider-form.settings-rows .row { - padding: var(--space-2) 0; -} - -/* A custom endpoint's API types: three compact choices on one line, the path each one calls. */ -.api-options { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--space-2); - padding-bottom: var(--space-1); -} - -.api-options .provider-hint { - font-family: var(--font-mono); - font-size: 12px; -} - -/* A custom provider's models, listed under its row. */ -.custom-models { - padding-left: var(--space-4); -} - -.settings-page input.field.token-field, -.settings-rows input.field.token-field, -.token-field { - width: 104px; -} - -.token-head { - display: inline-flex; - align-items: center; - gap: var(--space-1); - font-family: var(--font-sans); -} - -.token-remove { - width: 2.25rem; - flex: none; -} - -.models-toggle { - display: inline-flex; - align-items: center; - gap: var(--space-1); -} - -@media (max-width: 560px) { - .settings-page input.field.token-field, - .settings-rows input.field.token-field, - .token-field { - width: 88px; - } - - .custom-models { - padding-left: 0; - } -} - -@media (max-width: 520px) { - .api-options { - grid-template-columns: minmax(0, 1fr); - } -} - -.settings-page .about-subhead { - margin: var(--space-2) 0 0; - font-size: 13.5px; - font-weight: 600; -} - -.settings-page .about-lead { - font-size: 12.5px; - color: var(--muted-foreground); -} - -.settings-page .row .v a { - color: inherit; - text-decoration: underline; - text-underline-offset: 2px; -} - -.settings-page .row.is-highlighted { - margin: 0 calc(var(--space-2) * -1); - padding-left: var(--space-2); - padding-right: var(--space-2); - border-radius: var(--radius-md); - background: var(--wb-muted); -} diff --git a/apps/web/src/pages/settings/collaborators.module.css b/apps/web/src/pages/settings/collaborators.module.css new file mode 100644 index 00000000..85c7f038 --- /dev/null +++ b/apps/web/src/pages/settings/collaborators.module.css @@ -0,0 +1,12 @@ +.inviteForm { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); +} + +@media (max-width: 560px) { + .inviteForm { + justify-content: flex-start; + } +} diff --git a/apps/web/src/pages/collaborators.tsx b/apps/web/src/pages/settings/collaborators.tsx similarity index 78% rename from apps/web/src/pages/collaborators.tsx rename to apps/web/src/pages/settings/collaborators.tsx index 097499c2..929bd3e5 100644 --- a/apps/web/src/pages/collaborators.tsx +++ b/apps/web/src/pages/settings/collaborators.tsx @@ -7,18 +7,32 @@ * a member directory, so this page lists you and every open invitation and * says that other members are not listed. */ -import { wording } from "../config.ts"; +import { wording } from "../../config.ts"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import { Banner, Button, QuietSelect, StateLabel } from "../components.jsx"; -import { productClient } from "../api/transport.ts"; -import { workspaceTenantId, type HubUser } from "../api/auth.ts"; +import { + Banner, + Button, + InlineNote, + QuietSelect, + StateLabel, +} from "../../components.jsx"; +import { productClient } from "../../api/transport.ts"; +import { workspaceTenantId, type HubUser } from "../../api/auth.ts"; import type { Invitation } from "@corbits-builder/schemas/invitation"; import { ProductApiError, type ProductApiClient, } from "@corbits-builder/client"; -import { responseCode } from "./workspace/human-fault.ts"; +import { responseCode } from "../workspace/human-fault.ts"; +import { Section } from "@corbits/react-ui"; +import { + SettingsInput, + SettingsRow, + SettingsRowValue, +} from "./settings-row.tsx"; +import { SettingsSectionBody } from "./settings-section.tsx"; +import styles from "./collaborators.module.css"; type RoleRow = Awaited< ReturnType @@ -131,52 +145,45 @@ export function Collaborators({ user }: { user: HubUser | null }) { }; return ( - - - Collaborators - Who works in this workspace, and what they may do. - - + + {error ? : null} {notice ? : null} - - - {user ? user.name : wording.speaker.you} - {user ? user.email : ""} - - + + {myRoles === null ? "…" : myRoles.length > 0 ? myRoles.join(", ") : "Member"} - - - - - Workspace roles - - What an invitation can grant. Names and grants come from the hub. - - - + + + + {roles === null ? "…" : roles.length === 0 ? "None defined yet." : roles.map((role) => role.name).join(", ")} - - - - - Invite a collaborator - They appear here once they accept their link. - - + + + + {roles !== null && roles.length > 0 ? ( - - + - + + {invitations !== null && invitations.length > 0 ? ( - - - Open invitations - Each is a link the invited person accepts from. - - + + {invitations.map((invitation) => ( - + {invitation.email} · {expiresInLabel(invitation.expiresAt)} @@ -235,15 +241,15 @@ export function Collaborators({ user }: { user: HubUser | null }) { ))} - - + + ) : null} - + A full member directory is not part of this workspace yet: the hub lists your own memberships, not every principal, so only you and open invitations are shown. - - - + + + ); } diff --git a/apps/web/src/pages/settings/custom-providers.module.css b/apps/web/src/pages/settings/custom-providers.module.css new file mode 100644 index 00000000..9f9920f5 --- /dev/null +++ b/apps/web/src/pages/settings/custom-providers.module.css @@ -0,0 +1,123 @@ +.dialogForm { + display: flex; + flex-direction: column; + min-height: 0; +} + +.picker { + display: grid; + gap: var(--space-2); +} + +.picker[data-layout="cards"] { + grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr)); +} + +/* A custom endpoint's API types: three compact choices on one line, the path each one calls. */ +.picker[data-layout="apis"] { + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding-bottom: var(--space-1); +} + +.card { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: 10px 12px; + border: 1px solid var(--wb-border); + border-radius: var(--radius-md); + background: var(--wb-card); + color: var(--wb-foreground); + font: inherit; + text-align: left; + cursor: pointer; + transition: + border-color 120ms ease-out, + background-color 120ms ease-out; +} + +.card:hover:not(:disabled) { + border-color: var(--wb-border-strong); +} + +.card[aria-checked="true"] { + border-color: var(--wb-primary); + background: color-mix(in srgb, var(--wb-primary) 8%, var(--wb-card)); + box-shadow: inset 0 0 0 1px var(--wb-primary); +} + +.card:focus-visible { + outline: 2px solid var(--wb-ring); + outline-offset: 2px; +} + +.card:disabled { + cursor: default; + opacity: 0.6; +} + +.cardHead { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.875rem; +} + +.cardLogo { + display: inline-grid; + place-items: center; + flex: none; + width: 24px; + height: 24px; +} + +.cardLogo > [data-slot="provider-logo"] { + width: 20px; + height: 20px; +} + +.hint { + margin: 0; + font-size: 0.8125rem; + color: var(--wb-muted-foreground); +} + +.picker[data-layout="apis"] .hint { + font-family: var(--font-mono); + font-size: 12px; +} + +/* A custom provider's models, listed under its row. */ +.customModels { + padding-left: var(--space-4); +} + +/* As wide as the token inputs under it, so each head sits over its column. */ +.tokenHead { + width: 104px; + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-family: var(--font-sans); +} + +.tokenRemove { + width: 2.25rem; + flex: none; +} + +@media (max-width: 560px) { + .tokenHead { + width: 88px; + } + + .customModels { + padding-left: 0; + } +} + +@media (max-width: 520px) { + .picker[data-layout="apis"] { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/web/src/pages/custom-providers.tsx b/apps/web/src/pages/settings/custom-providers.tsx similarity index 91% rename from apps/web/src/pages/custom-providers.tsx rename to apps/web/src/pages/settings/custom-providers.tsx index 575097b3..2869fdad 100644 --- a/apps/web/src/pages/custom-providers.tsx +++ b/apps/web/src/pages/settings/custom-providers.tsx @@ -8,11 +8,10 @@ * all. The hub needs one model to add a provider, so the add dialog asks for the first; the rest * are managed under the row. The dialog shell and card picker here are the add dialog's too. */ -import { wording } from "../config.ts"; +import { wording } from "../../config.ts"; import { Button as UiButton, Switch } from "@corbits/react-ui"; import { Dialog, - DialogBody, DialogContent, DialogDescription, DialogFooter, @@ -38,11 +37,23 @@ import { type ModelCatalog, type ModelProvider, } from "@corbits-builder/schemas/models"; -import { Button, QuietState } from "../components.jsx"; -import { keys } from "../queries/keys.ts"; -import { productClient } from "../api/transport.ts"; -import { workspaceTenantId } from "../api/auth.ts"; +import { + Button, + DangerMenuItem, + QuietButton, + QuietState, +} from "../../components.jsx"; +import { keys } from "../../queries/keys.ts"; +import { productClient } from "../../api/transport.ts"; +import { workspaceTenantId } from "../../api/auth.ts"; import { ProviderLogo } from "./provider-logos.tsx"; +import { ProviderFormBody } from "./provider-form.tsx"; +import { + SettingsInput, + SettingsRow, + SettingsRowValue, +} from "./settings-row.tsx"; +import styles from "./custom-providers.module.css"; const copy = wording.providers; const tips = copy.custom.tips; @@ -164,19 +175,15 @@ export function ProviderDialog(props: { {props.description} { event.preventDefault(); props.onSubmit(); }} > - + {props.children} - + & { @@ -222,7 +229,8 @@ export function ProviderPicker({ chosenId: string | undefined; disabled: boolean; labelledBy: string; - className?: string; + /** Cards that fill the row, or the three compact API types of a custom endpoint. */ + layout?: "cards" | "apis"; onChoose: (id: string) => void; }) { const cards = useRef<(HTMLButtonElement | null)[]>([]); @@ -240,7 +248,13 @@ export function ProviderPicker({ cards.current[target]?.focus(); }; return ( - + {providers.map((provider, index) => { const checked = provider.id === chosenId; const card = ( @@ -254,17 +268,17 @@ export function ProviderPicker({ aria-checked={checked} tabIndex={checked ? 0 : -1} disabled={disabled} - className="provider-card" + className={styles.card} onClick={() => onChoose(provider.id)} onKeyDown={(event) => move(event, index)} > - - + + {provider.label} - {provider.hint} + {provider.hint} ); return provider.tip ? ( @@ -297,20 +311,21 @@ function FieldRow({ children?: ReactNode; }) { return ( - - - + {htmlFor ? ( {label} ) : ( {label} )} - - {hint ? {hint} : null} - + > + } + hint={hint} + > {children} - + ); } @@ -488,9 +503,8 @@ function ConnectionRows({ return ( <> - onChange({ ...draft, api: api as CustomApi })} /> - - - - + {copy.custom.modelId} - - {duplicate ? ( + > + } + hint={ + duplicate ? ( {copy.custom.duplicateIds} ) : ( children - )} - - + ) + } + > + {MODEL_FIELDS.map((field) => ( - + {field.label} ))} {removable ? ( - + ) : null} - - + + ); } @@ -636,11 +651,11 @@ function ModelRow({ }) { const blur = onCommit ? { onBlur: onCommit } : {}; return ( - - - {editableId ? ( - ) : ( - {model.id} - )} - - + model.id + ) + } + > + {MODEL_FIELDS.map((field) => ( - ))} {children} - - + + ); } @@ -719,31 +735,25 @@ export function CustomProviderRow({ const listId = `custom-models-${provider.id}`; return ( <> - - - {provider.label} - {provider.hint} - - - + + setOpen(!open)} > {copy.custom.modelCount(provider.models.length)} - + - - + + {open ? ( - + ) : null} @@ -832,7 +842,7 @@ function CustomProviderModels({ ))} - + @@ -841,7 +851,7 @@ function CustomProviderModels({ > {copy.custom.addModel} - + > ); } @@ -905,16 +915,11 @@ function CustomProviderActions({ {confirming ? ( - void remove()} - > + void remove()}> {copy.custom.confirmRemove} - + ) : ( - { event.preventDefault(); @@ -922,7 +927,7 @@ function CustomProviderActions({ }} > {copy.custom.remove} - + )} diff --git a/apps/web/src/pages/settings/profiles.module.css b/apps/web/src/pages/settings/profiles.module.css new file mode 100644 index 00000000..6372a2c2 --- /dev/null +++ b/apps/web/src/pages/settings/profiles.module.css @@ -0,0 +1,22 @@ +.role { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); + margin: 0; + padding: 0; + border: 0; + min-width: 0; +} + +.role > legend { + grid-column: 1 / -1; + padding: 0 0 6px; + font-size: 13px; + font-weight: 600; +} + +@media (max-width: 520px) { + .role { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/web/src/pages/profiles.tsx b/apps/web/src/pages/settings/profiles.tsx similarity index 91% rename from apps/web/src/pages/profiles.tsx rename to apps/web/src/pages/settings/profiles.tsx index 4374c3f6..493656ed 100644 --- a/apps/web/src/pages/profiles.tsx +++ b/apps/web/src/pages/settings/profiles.tsx @@ -7,11 +7,10 @@ * provider's (`automaticProfile`); with several connected, the person picks one here. A new * project keeps the bindings it started with, so a change here never alters a running project. */ -import { wording } from "../config.ts"; +import { wording } from "../../config.ts"; import { Input, Section, Switch } from "@corbits/react-ui"; import { Dialog, - DialogBody, DialogContent, DialogDescription, DialogFieldLabel, @@ -28,15 +27,25 @@ import type { RoleBinding, Tier, } from "@corbits-builder/schemas/models"; -import { Banner, Button, QuietSelect, QuietState } from "../components.jsx"; -import { keys } from "../queries/keys.ts"; -import { productClient } from "../api/transport.ts"; -import { workspaceTenantId } from "../api/auth.ts"; +import { + Banner, + Button, + Field, + QuietSelect, + QuietState, +} from "../../components.jsx"; +import { keys } from "../../queries/keys.ts"; +import { productClient } from "../../api/transport.ts"; +import { workspaceTenantId } from "../../api/auth.ts"; import { useModelCatalog } from "./providers.jsx"; +import { ProviderFormBody } from "./provider-form.tsx"; +import { SettingsRow, SettingsRowValue } from "./settings-row.tsx"; +import { SettingsSectionBody } from "./settings-section.tsx"; +import styles from "./profiles.module.css"; const copy = wording.profiles; -type Field = { value: ModelChoice | null; version: number }; +type ChoiceField = { value: ModelChoice | null; version: number }; type Tiers = CustomProfile["tiers"]; type Profile = { id: string; @@ -89,10 +98,12 @@ function complete(draft: Draft, tiers: readonly Tier[]): Tiers | null { return done as Tiers; } -export function Profiles({ initialField }: { initialField?: Field } = {}) { +export function Profiles({ + initialField, +}: { initialField?: ChoiceField } = {}) { const catalog = useModelCatalog(); const queryClient = useQueryClient(); - const [field, setField] = useState(initialField ?? null); + const [field, setField] = useState(initialField ?? null); const [error, setError] = useState(null); const [editing, setEditing] = useState(null); const [viewing, setViewing] = useState(null); @@ -177,7 +188,7 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { } > - + {!active && data.unresolved ? ( ) : null} @@ -185,12 +196,12 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { const runnable = available(data, profile.tiers); const activeRow = profile.id === active?.id; return ( - - - {profile.label} - {profileSubtitle(profile, runnable)} - - + + {profile.custom ? ( <> ) : null} - - + + ); })} {error ? : null} - + {viewed ? ( - + {data.tiers.map((tier) => { const binding = viewed.tiers[tier.id]; const provider = data.providers.find( @@ -262,12 +273,12 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { ); const roles = data.roles.filter((role) => role.tier === tier.id); return ( - - - {copy.tier(tier.label)} - {copy.usedBy(roles.map((role) => role.label))} - - + role.label))} + > + {copy.binding( model?.label ?? binding.model, data.providers.length > 1 @@ -275,11 +286,11 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { : null, effort?.label ?? binding.effort, )} - - + + ); })} - + ) : null} {editing ? ( @@ -393,9 +404,9 @@ function ProfileEditor({ {copy.editorLead} - + {editing.mode === "new" ? ( - + {copy.startFrom} @@ -420,9 +431,9 @@ function ProfileEditor({ ))} - + ) : null} - + {copy.name} @@ -435,7 +446,7 @@ function ProfileEditor({ setDraft({ ...draft, label: event.target.value }) } /> - + {catalog.tiers.map((tier) => { const binding = draft.tiers[tier.id] ?? {}; const provider = catalog.providers.find( @@ -443,11 +454,7 @@ function ProfileEditor({ ); const providers = editorProviders(catalog, binding.provider); return ( - + {copy.tier(tier.label)} + {copy.replaceDefault(replacing)} @@ -535,9 +542,9 @@ function ProfileEditor({ disabled={busy} onCheckedChange={setMakeDefault} /> - + ) : null} - + {wording.action.cancel} diff --git a/apps/web/src/pages/settings/provider-form.module.css b/apps/web/src/pages/settings/provider-form.module.css new file mode 100644 index 00000000..8078cbb0 --- /dev/null +++ b/apps/web/src/pages/settings/provider-form.module.css @@ -0,0 +1,17 @@ +.form { + display: flex; + flex-direction: column; + gap: var(--space-4); + margin: -4px; + padding: 4px; +} + +/* Settings rows in a dialog: the body's own gap would double the rows' spacing, and rows sit a + little closer so a whole form fits without scrolling. */ +.form[data-rows] { + gap: 0; +} + +.form[data-rows] > [data-slot="settings-row"] { + padding: var(--space-2) 0; +} diff --git a/apps/web/src/pages/settings/provider-form.tsx b/apps/web/src/pages/settings/provider-form.tsx new file mode 100644 index 00000000..44c2b650 --- /dev/null +++ b/apps/web/src/pages/settings/provider-form.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; +import { DialogBody } from "@corbits/react-ui/ui/dialog"; +import styles from "./provider-form.module.css"; + +/** The body of a provider or profile dialog: its fields stacked, or laid out as Settings rows. */ +export function ProviderFormBody({ + rows = false, + children, +}: { + rows?: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/pages/settings/provider-logos.stories.tsx b/apps/web/src/pages/settings/provider-logos.stories.tsx new file mode 100644 index 00000000..3dc2caf0 --- /dev/null +++ b/apps/web/src/pages/settings/provider-logos.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProviderLogo } from "./provider-logos.tsx"; + +const PROVIDERS = ["openai", "anthropic", "google", "xai", "custom"] as const; + +const meta = { + title: "Components/ProviderLogo", + component: ProviderLogo, + args: { providerId: "anthropic" }, + argTypes: { providerId: { control: "select", options: PROVIDERS } }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Logo: Story = {}; + +/** A provider without a mark gets a neutral plug. */ +export const All: Story = { + decorators: [], + render: () => ( + + {PROVIDERS.map((id) => ( + + + + ))} + + ), +}; diff --git a/apps/web/src/pages/provider-logos.tsx b/apps/web/src/pages/settings/provider-logos.tsx similarity index 93% rename from apps/web/src/pages/provider-logos.tsx rename to apps/web/src/pages/settings/provider-logos.tsx index 7ef827af..921b658e 100644 --- a/apps/web/src/pages/provider-logos.tsx +++ b/apps/web/src/pages/settings/provider-logos.tsx @@ -26,9 +26,14 @@ const MARKS: Readonly> = { export function ProviderLogo({ providerId }: { providerId: string }) { const mark = MARKS[providerId]; - if (!mark) return ; + if (!mark) return ; return ( - + ); diff --git a/apps/web/src/pages/providers.test.ts b/apps/web/src/pages/settings/providers.test.ts similarity index 87% rename from apps/web/src/pages/providers.test.ts rename to apps/web/src/pages/settings/providers.test.ts index d4352f6e..3c09037d 100644 --- a/apps/web/src/pages/providers.test.ts +++ b/apps/web/src/pages/settings/providers.test.ts @@ -1,13 +1,13 @@ import { afterAll, expect, mock, test } from "bun:test"; import { environmentManager } from "@tanstack/react-query"; import type { ModelLoginState } from "@corbits-builder/client"; -import * as actualTransport from "../api/transport.ts"; -import { wording } from "../config.ts"; +import * as actualTransport from "../../api/transport.ts"; +import { wording } from "../../config.ts"; const realTransport = { ...actualTransport }; let answers: ModelLoginState[] = []; -mock.module("../api/transport.ts", () => ({ +mock.module("../../api/transport.ts", () => ({ ...realTransport, productClient: () => ({ modelLogin: async () => answers.shift() ?? { status: "pending" }, @@ -18,7 +18,7 @@ const wasServer = environmentManager.isServer(); environmentManager.setIsServer(() => false); afterAll(() => { environmentManager.setIsServer(() => wasServer); - mock.module("../api/transport.ts", () => realTransport); + mock.module("../../api/transport.ts", () => realTransport); }); const { loginCredential } = await import("./providers.tsx"); diff --git a/apps/web/src/pages/providers.tsx b/apps/web/src/pages/settings/providers.tsx similarity index 91% rename from apps/web/src/pages/providers.tsx rename to apps/web/src/pages/settings/providers.tsx index 1efef7ad..3b246592 100644 --- a/apps/web/src/pages/providers.tsx +++ b/apps/web/src/pages/settings/providers.tsx @@ -9,7 +9,7 @@ * sits in the same list (custom-providers.tsx). Keys live in form state until they are handed * over, and nothing here renders a key or token back. */ -import { wording } from "../config.ts"; +import { wording } from "../../config.ts"; import { EmptyState, Input, Section } from "@corbits/react-ui"; import { DialogFieldLabel } from "@corbits/react-ui/ui/dialog"; import { QueryObserver, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -21,12 +21,12 @@ import { type ProviderStatus, } from "@corbits-builder/schemas/models"; import { type } from "arktype"; -import { Banner, Button, QuietState } from "../components.jsx"; -import { queryClient } from "../queries/client.ts"; -import { keys } from "../queries/keys.ts"; -import { productClient } from "../api/transport.ts"; -import { workspaceTenantId } from "../api/auth.ts"; -import { setCredential } from "../api/credentials.ts"; +import { Banner, Button, Field, QuietState } from "../../components.jsx"; +import { queryClient } from "../../queries/client.ts"; +import { keys } from "../../queries/keys.ts"; +import { productClient } from "../../api/transport.ts"; +import { workspaceTenantId } from "../../api/auth.ts"; +import { setCredential } from "../../api/credentials.ts"; import { CustomProviderRow, ProviderDialog, @@ -34,6 +34,8 @@ import { message, useCustomProviderForm, } from "./custom-providers.tsx"; +import { SettingsRow } from "./settings-row.tsx"; +import { SettingsSectionBody } from "./settings-section.tsx"; const copy = wording.providers; @@ -152,11 +154,11 @@ export function SubscriptionLogins({ (connection) => connection.status === "connected", ); return ( - - - {copy.login(provider.label)} - {live ? copy.status.connected : copy.notConnected} - + - + ); })} > @@ -214,20 +216,20 @@ export function ProvidersSection({ onChanged }: { onChanged: () => void }) { ); } return ( - - - {connection.name} - {connection.name === provider.label ? null : ( - {provider.label} - )} - {connection.note ? {connection.note} : null} - + {connection.status ? ( {copy.status[connection.status]} ) : null} - + ); })} > @@ -243,7 +245,7 @@ export function ProvidersSection({ onChanged }: { onChanged: () => void }) { description={copy.lead} action={hasLogins ? undefined : addAction} > - + {catalog.error ? ( ) : null} @@ -255,7 +257,7 @@ export function ProvidersSection({ onChanged }: { onChanged: () => void }) { ) : ( apiKeys )} - + ) : null} {providerId || custom ? null : ( - + {copy.provider} @@ -427,12 +429,12 @@ export function AddProviderDialog({ setChosenId(id); }} /> - + )} {custom ? ( customForm.rows(busy) ) : ( - + {copy.key} setSecret(event.target.value)} /> - + )} ); diff --git a/apps/web/src/pages/settings/settings-row.module.css b/apps/web/src/pages/settings/settings-row.module.css new file mode 100644 index 00000000..72e79ef3 --- /dev/null +++ b/apps/web/src/pages/settings/settings-row.module.css @@ -0,0 +1,97 @@ +/* Rows sit on the page rather than in bordered cards; space separates them, not rules. */ +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-3) 0; + font-size: 14px; + transition: background-color 300ms ease; +} + +.row[data-highlighted] { + margin: 0 calc(var(--space-2) * -1); + padding-left: var(--space-2); + padding-right: var(--space-2); + border-radius: var(--radius-sm, 0.25rem); + background: var(--surface-hover, rgba(233, 132, 40, 0.08)); +} + +.key { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.label { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-weight: 600; + font-size: 13.5px; +} + +.hint { + font-size: 12px; + color: var(--muted-foreground); + font-family: var(--font-mono); +} + +.value { + font-family: var(--font-mono); + font-size: 12px; + color: var(--muted-foreground); + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: var(--space-3); + flex-shrink: 0; + max-width: min(420px, 100%); +} + +.value a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} + +.input { + display: block; + font-family: var(--font-mono); + font-size: 12.5px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--background); + color: var(--foreground); + padding: 6px 10px; + width: 220px; + max-width: 100%; + outline: none; +} + +.input:focus { + border-color: var(--primary); +} + +.input[data-size="token"] { + width: 104px; +} + +/* At phone width a row's controls drop under its label instead of squeezing + it to one word per line. */ +@media (max-width: 560px) { + .row { + flex-wrap: wrap; + } + + .value { + flex-shrink: 1; + justify-content: flex-start; + } + + .input[data-size="token"] { + width: 88px; + } +} diff --git a/apps/web/src/pages/settings/settings-row.stories.tsx b/apps/web/src/pages/settings/settings-row.stories.tsx new file mode 100644 index 00000000..9cb0a419 --- /dev/null +++ b/apps/web/src/pages/settings/settings-row.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "../../components.tsx"; +import { + SettingsInput, + SettingsRow, + SettingsRowValue, +} from "./settings-row.tsx"; +import { SettingsSectionBody, SettingsSubhead } from "./settings-section.tsx"; + +const meta = { + title: "Components/Settings/SettingsRow", + component: SettingsRow, + args: { + label: "Theme", + hint: "How Builder looks on this device.", + children: , + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Row: Story = {}; + +/** A row the person was sent to, marked until the highlight fades. */ +export const Highlighted: Story = { args: { highlighted: true } }; + +/** A section's rows with a subhead, a lead, a value column and a bare action row. */ +export const Section: Story = { + render: () => ( + + Open source + Builder is made with these projects. + + + MIT License + + + + + + + + + + Sign out + + + ), +}; diff --git a/apps/web/src/pages/settings/settings-row.test.tsx b/apps/web/src/pages/settings/settings-row.test.tsx new file mode 100644 index 00000000..4eef9eeb --- /dev/null +++ b/apps/web/src/pages/settings/settings-row.test.tsx @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { SettingsRow } from "./settings-row.tsx"; + +test("a row names its control in its key, with one hint line per entry", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toMatch( + /data-slot="settings-row-key">]*>Theme<\/span><\/label>/, + ); + expect(html.match(/>Light or dark<|>On this device { + const html = renderToStaticMarkup( + + Sign out + , + ); + + expect(html).toContain('data-slot="settings-row" data-highlighted=""'); + expect(html).not.toContain('data-slot="settings-row-key"'); +}); diff --git a/apps/web/src/pages/settings/settings-row.tsx b/apps/web/src/pages/settings/settings-row.tsx new file mode 100644 index 00000000..590101cd --- /dev/null +++ b/apps/web/src/pages/settings/settings-row.tsx @@ -0,0 +1,94 @@ +import type { ComponentProps, ReactNode } from "react"; +import styles from "./settings-row.module.css"; + +/** A row's left column: its name in bold, then its hint, or one line per hint in a list. */ +function SettingsRowKey({ + label, + hint, + htmlFor, +}: { + label?: ReactNode; + hint?: ReactNode; + htmlFor?: string | undefined; +}) { + const hints: readonly ReactNode[] = Array.isArray(hint) ? hint : [hint]; + let name: ReactNode = null; + if (label !== undefined) name = {label}; + if (label !== undefined && htmlFor !== undefined) + name = {name}; + return ( + + {name} + {hints.map((line, index) => + line === undefined || line === null || line === "" ? null : ( + + {line} + + ), + )} + + ); +} + +/** A row's right column, when it holds more than a single control. */ +export function SettingsRowValue({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * One setting: its name and hint on the left, its control on the right. A row without a label + * or hint is a bare action row. Code finds a row by its `data-slot`, and a highlighted one by + * `data-highlighted`. + */ +export function SettingsRow({ + id, + highlighted = false, + label, + hint, + htmlFor, + children, +}: { + id?: string; + highlighted?: boolean; + label?: ReactNode; + /** Muted lines under the label; a list puts each on its own line. */ + hint?: ReactNode; + /** The control the label names, when the row holds one. */ + htmlFor?: string; + children?: ReactNode; +}) { + return ( + + {label === undefined && hint === undefined ? null : ( + + )} + {children} + + ); +} + +/** The text input of a Settings row; `token` is the narrow one a model's columns use. */ +export function SettingsInput({ + size, + ...props +}: Omit, "className" | "size"> & { + size?: "token"; +}) { + return ( + + ); +} diff --git a/apps/web/src/pages/settings/settings-section.module.css b/apps/web/src/pages/settings/settings-section.module.css new file mode 100644 index 00000000..8a6bbc4e --- /dev/null +++ b/apps/web/src/pages/settings/settings-section.module.css @@ -0,0 +1,22 @@ +.body { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.body > [data-slot="banner"], +.body > [data-slot="inline-note"], +.body > p { + margin: var(--space-2) 0; +} + +.body > p { + font-size: 13px; + color: var(--muted-foreground); +} + +.subhead { + margin: var(--space-2) 0 0; + font-size: 13.5px; + font-weight: 600; +} diff --git a/apps/web/src/pages/settings/settings-section.tsx b/apps/web/src/pages/settings/settings-section.tsx new file mode 100644 index 00000000..88820e13 --- /dev/null +++ b/apps/web/src/pages/settings/settings-section.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; +import styles from "./settings-section.module.css"; + +/** A section's rows, with the banners, notes and paragraphs between them. */ +export function SettingsSectionBody({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** A heading for a group of rows inside a section. */ +export function SettingsSubhead({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/pages/settings/settings.module.css b/apps/web/src/pages/settings/settings.module.css new file mode 100644 index 00000000..3732598e --- /dev/null +++ b/apps/web/src/pages/settings/settings.module.css @@ -0,0 +1,46 @@ +.page { + max-width: 720px; + width: 100%; + min-width: 0; + margin: 0 auto; + padding: var(--space-8) var(--space-6) var(--space-12); + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.page > h1 { + margin: 0; + font-size: 22px; + font-weight: 700; +} + +.tabs { + gap: var(--space-8); +} + +.tabs > [role="tablist"] { + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: none; + border-bottom-color: color-mix(in srgb, var(--border) 60%, transparent); +} + +.tabs [role="tab"] { + flex: none; + white-space: nowrap; +} + +.tabs > [role="tabpanel"] { + display: flex; + flex-direction: column; + gap: var(--space-12); + outline: none; +} + +/* At phone width the canvas's own padding is the gutter. */ +@media (max-width: 560px) { + .page { + padding: var(--space-4) 0 var(--space-12); + } +} diff --git a/apps/web/src/pages/settings.tsx b/apps/web/src/pages/settings/settings.tsx similarity index 63% rename from apps/web/src/pages/settings.tsx rename to apps/web/src/pages/settings/settings.tsx index ed093ac8..ddfe3bec 100644 --- a/apps/web/src/pages/settings.tsx +++ b/apps/web/src/pages/settings/settings.tsx @@ -7,53 +7,49 @@ * * They are grouped into tabs; the open tab is `?tab=` beside `?view=settings`, * so each one can be linked. Each section is the library's `Section`; its - * rows keep the mockup's row / k / v language. + * rows are `SettingsRow`s. */ -import { wording } from "../config.ts"; +import { wording } from "../../config.ts"; import { - Button as UiButton, Section, Switch, Tabs, useTheme, type ThemeMode, } from "@corbits/react-ui"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; -import { keys } from "../queries/keys.ts"; +import { keys } from "../../queries/keys.ts"; import { ProvidersSection } from "./providers.jsx"; import { Profiles } from "./profiles.jsx"; import { useDocumentTips, writeDocumentTips, -} from "../document-tips-setting.ts"; +} from "../../document-tips-setting.ts"; import { useQuestionsLayout, writeQuestionsLayout, -} from "../questions-layout-setting.ts"; +} from "../../questions-layout-setting.ts"; import { - addCustomReviewerPersona, - addWorkspaceReviewer, - allReviewerPersonas, - removeWorkspaceReviewer, - useCustomReviewerPersonas, useWorkspaceApprovalsEnabled, - useWorkspaceReviewers, writeWorkspaceApprovalsEnabled, type OnOff, -} from "../workspace-approvals-setting.ts"; +} from "../../workspace-approvals-setting.ts"; +import { SettingsRow, SettingsRowValue } from "./settings-row.tsx"; +import { SettingsSectionBody, SettingsSubhead } from "./settings-section.tsx"; +import { WorkspaceApprovals } from "./workspace-approvals.tsx"; import { Collaborators } from "./collaborators.jsx"; -import { Banner, Button, QuietSelect, QuietState } from "../components.jsx"; -import { productClient } from "../api/transport.ts"; -import { workspaceTenantId, type HubUser } from "../api/auth.ts"; -import { openSourceNotices } from "../open-source.ts"; -import { Credit } from "../flourish.tsx"; +import { Banner, Button, QuietSelect, QuietState } from "../../components.jsx"; +import { productClient } from "../../api/transport.ts"; +import { workspaceTenantId, type HubUser } from "../../api/auth.ts"; +import { openSourceNotices } from "../../open-source.ts"; +import { Credit } from "../../flourish.tsx"; import type { PreferenceRegistry, Preferences, } from "@corbits-builder/schemas/preferences"; -import "./settings-layout.css"; +import styles from "./settings.module.css"; export type SettingsTab = keyof typeof wording.settings.tab; @@ -195,14 +191,14 @@ export function Settings({ ); }; return ( - + {wording.settings.title} {renderTabContent} @@ -210,238 +206,6 @@ export function Settings({ ); } -function Row({ - id, - highlighted = false, - label, - hint, - htmlFor, - children, -}: { - id?: string; - highlighted?: boolean; - label: string; - hint?: string; - /** The control the label names, when the row holds one. */ - htmlFor?: string; - children: ReactNode; -}) { - return ( - - - {htmlFor ? ( - - {label} - - ) : ( - {label} - )} - {hint ? {hint} : null} - - {children} - - ); -} - -const OTHER_PERSONA_OPTION = "__other__"; - -/** Workspace-level approval toggle and saved workspace reviewers. */ -export function WorkspaceApprovals() { - const approvalsEnabled = useWorkspaceApprovalsEnabled(); - const reviewers = useWorkspaceReviewers(); - const customPersonas = useCustomReviewerPersonas(); - const personaOptions = allReviewerPersonas(customPersonas, reviewers); - const [name, setName] = useState(""); - const [persona, setPersona] = useState( - wording.approvalRound.reviewerPersonas[0], - ); - const [customPersonaOpen, setCustomPersonaOpen] = useState(false); - const [customPersonaDraft, setCustomPersonaDraft] = useState(""); - const [email, setEmail] = useState(""); - const [goal, setGoal] = useState(""); - const copy = wording.settings.approvals; - const roundCopy = wording.approvalRound; - - const effectivePersona = customPersonaOpen - ? customPersonaDraft.trim() - : persona.trim(); - const canAdd = name.trim().length > 0 && effectivePersona.length > 0; - - const handleSelectPersona = (value: string) => { - if (value === OTHER_PERSONA_OPTION) { - setCustomPersonaOpen(true); - setCustomPersonaDraft(""); - return; - } - setCustomPersonaOpen(false); - setPersona(value); - }; - - const handleAddReviewer = () => { - if (!canAdd) return; - if (customPersonaOpen) { - addCustomReviewerPersona(effectivePersona); - } - const trimmedEmail = email.trim(); - const trimmedGoal = goal.trim(); - addWorkspaceReviewer({ - name: name.trim(), - persona: effectivePersona, - ...(trimmedEmail.length > 0 ? { email: trimmedEmail } : {}), - ...(trimmedGoal.length > 0 ? { goal: trimmedGoal } : {}), - }); - setName(""); - setEmail(""); - setGoal(""); - if (customPersonaOpen) { - setPersona(effectivePersona); - setCustomPersonaOpen(false); - setCustomPersonaDraft(""); - } - }; - - return ( - - - - - writeWorkspaceApprovalsEnabled(on ? "on" : "off") - } - /> - - {copy.reviewersSubhead} - {copy.reviewersLead} - {reviewers.length === 0 ? ( - {copy.emptyReviewers} - ) : ( - reviewers.map((reviewer) => { - const hintParts = [reviewer.goal, reviewer.email].filter( - (part): part is string => part !== undefined && part !== "", - ); - return ( - - - - {reviewer.persona} - - removeWorkspaceReviewer(reviewer.id)} - > - {copy.removeReviewer} - - - - ); - }) - )} - - - - {roundCopy.fieldLabels.name} - - setName(event.target.value)} - /> - - - - {roundCopy.fieldLabels.role} - - handleSelectPersona(event.target.value)} - > - {personaOptions.map((option) => ( - - {option} - - ))} - - {roundCopy.otherPersona} - - - - {customPersonaOpen ? ( - - - {roundCopy.customPersonaLabel} - - setCustomPersonaDraft(event.target.value)} - /> - - ) : null} - - - {roundCopy.fieldLabels.focus} - - setGoal(event.target.value)} - /> - - - - {wording.settings.account.email} - - setEmail(event.target.value)} - /> - - - - {copy.addReviewer} - - - - - - ); -} - /** The theme, and the document tips and questions layout choices kept in this browser. */ function Appearance() { const { mode, setMode } = useTheme(); @@ -450,8 +214,8 @@ function Appearance() { const copy = wording.settings.appearance; return ( - - + + ))} - - + + writeDocumentTips(on ? "on" : "off")} /> - - + @@ -480,8 +247,8 @@ function Appearance() { checked={questionsLayout === "steps"} onCheckedChange={(on) => writeQuestionsLayout(on ? "steps" : "all")} /> - - + + ); } @@ -573,9 +340,11 @@ export function WorkingPreferences({ ); const rowEl = document.getElementById(`preference-row-${focusPreference}`) ?? - (controlEl?.closest(".row") as HTMLElement | null); + (controlEl?.closest( + '[data-slot="settings-row"]', + ) as HTMLElement | null); if (rowEl) { - rowEl.setAttribute("data-highlighted", "true"); + rowEl.setAttribute("data-highlighted", ""); if (typeof rowEl.scrollIntoView === "function") { rowEl.scrollIntoView({ behavior: "smooth", block: "center" }); } @@ -601,7 +370,9 @@ export function WorkingPreferences({ ); const rowEl = document.getElementById(`preference-row-${focusPreference}`) ?? - (controlEl?.closest(".row") as HTMLElement | null); + (controlEl?.closest( + '[data-slot="settings-row"]', + ) as HTMLElement | null); rowEl?.removeAttribute("data-highlighted"); }, 2_000); @@ -687,7 +458,7 @@ export function WorkingPreferences({ const preferences = mergeExperimentalPreferences(registry); return ( - + {preferences.map((preference) => { const id = `preference-${preference.id}`; const isExperimental = preference.id === "experimental_approvals"; @@ -699,7 +470,7 @@ export function WorkingPreferences({ const state = save?.id === preference.id ? save.state : null; const disabled = (!field && !isExperimental) || state === "saving"; return ( - - + {state ? ( {copy[state]} @@ -738,12 +509,12 @@ export function WorkingPreferences({ ))} )} - - + + ); })} {error ? : null} - + ); } @@ -769,15 +540,15 @@ function Account({ const copy = wording.settings.account; return ( - - - {user.name} - - - {user.email} - + + + {user.name} + + + {user.email} + {onSignOut ? ( - + {wording.action.signOut} - + ) : null} - + ); } @@ -796,20 +567,20 @@ function About() { const copy = wording.settings.about; return ( - - {copy.openSource} - {copy.openSourceLead} + + {copy.openSource} + {copy.openSourceLead} {openSourceNotices.map((notice) => ( - - + + {copy.kind[notice.kind]} {notice.license ?? copy.unconfirmed} - - + + ))} - + ); diff --git a/apps/web/src/pages/settings/workspace-approvals.module.css b/apps/web/src/pages/settings/workspace-approvals.module.css new file mode 100644 index 00000000..bccd7358 --- /dev/null +++ b/apps/web/src/pages/settings/workspace-approvals.module.css @@ -0,0 +1,5 @@ +.formActions { + display: flex; + justify-content: flex-end; + padding-top: 0.25rem; +} diff --git a/apps/web/src/pages/settings/workspace-approvals.tsx b/apps/web/src/pages/settings/workspace-approvals.tsx new file mode 100644 index 00000000..d75010ba --- /dev/null +++ b/apps/web/src/pages/settings/workspace-approvals.tsx @@ -0,0 +1,189 @@ +import { Section, Switch } from "@corbits/react-ui"; +import { useState } from "react"; +import { InlineNote } from "../../components.tsx"; +import { wording } from "../../config.ts"; +import { + addCustomReviewerPersona, + addWorkspaceReviewer, + allReviewerPersonas, + removeWorkspaceReviewer, + useCustomReviewerPersonas, + useWorkspaceApprovalsEnabled, + useWorkspaceReviewers, + writeWorkspaceApprovalsEnabled, +} from "../../workspace-approvals-setting.ts"; +import { SettingsRow, SettingsRowValue } from "./settings-row.tsx"; +import { SettingsSectionBody, SettingsSubhead } from "./settings-section.tsx"; +import { + ApprovalField, + ApprovalFields, + FieldSaveButton, + LeanButton, + LeanInput, + LeanSelect, +} from "../approval-fields.tsx"; +import styles from "./workspace-approvals.module.css"; + +const OTHER_PERSONA_OPTION = "__other__"; + +/** Workspace-level approval toggle and saved workspace reviewers. */ +export function WorkspaceApprovals() { + const approvalsEnabled = useWorkspaceApprovalsEnabled(); + const reviewers = useWorkspaceReviewers(); + const customPersonas = useCustomReviewerPersonas(); + const personaOptions = allReviewerPersonas(customPersonas, reviewers); + const [name, setName] = useState(""); + const [persona, setPersona] = useState( + wording.approvalRound.reviewerPersonas[0], + ); + const [customPersonaOpen, setCustomPersonaOpen] = useState(false); + const [customPersonaDraft, setCustomPersonaDraft] = useState(""); + const [email, setEmail] = useState(""); + const [goal, setGoal] = useState(""); + const copy = wording.settings.approvals; + const roundCopy = wording.approvalRound; + + const effectivePersona = customPersonaOpen + ? customPersonaDraft.trim() + : persona.trim(); + const canAdd = name.trim().length > 0 && effectivePersona.length > 0; + + const handleSelectPersona = (value: string) => { + if (value === OTHER_PERSONA_OPTION) { + setCustomPersonaOpen(true); + setCustomPersonaDraft(""); + return; + } + setCustomPersonaOpen(false); + setPersona(value); + }; + + const handleAddReviewer = () => { + if (!canAdd) return; + if (customPersonaOpen) { + addCustomReviewerPersona(effectivePersona); + } + const trimmedEmail = email.trim(); + const trimmedGoal = goal.trim(); + addWorkspaceReviewer({ + name: name.trim(), + persona: effectivePersona, + ...(trimmedEmail.length > 0 ? { email: trimmedEmail } : {}), + ...(trimmedGoal.length > 0 ? { goal: trimmedGoal } : {}), + }); + setName(""); + setEmail(""); + setGoal(""); + if (customPersonaOpen) { + setPersona(effectivePersona); + setCustomPersonaOpen(false); + setCustomPersonaDraft(""); + } + }; + + return ( + + + + + writeWorkspaceApprovalsEnabled(on ? "on" : "off") + } + /> + + {copy.reviewersSubhead} + {copy.reviewersLead} + {reviewers.length === 0 ? ( + {copy.emptyReviewers} + ) : ( + reviewers.map((reviewer) => { + const hintParts = [reviewer.goal, reviewer.email].filter( + (part): part is string => part !== undefined && part !== "", + ); + return ( + + + {reviewer.persona} + removeWorkspaceReviewer(reviewer.id)} + > + {copy.removeReviewer} + + + + ); + }) + )} + + + setName(event.target.value)} + /> + + + handleSelectPersona(event.target.value)} + > + {personaOptions.map((option) => ( + + {option} + + ))} + + {roundCopy.otherPersona} + + + + {customPersonaOpen ? ( + + setCustomPersonaDraft(event.target.value)} + /> + + ) : null} + + setGoal(event.target.value)} + /> + + + setEmail(event.target.value)} + /> + + + + {copy.addReviewer} + + + + + + ); +} diff --git a/apps/web/src/pages/workspace-layout.css b/apps/web/src/pages/workspace-layout.css deleted file mode 100644 index 793c0be6..00000000 --- a/apps/web/src/pages/workspace-layout.css +++ /dev/null @@ -1,625 +0,0 @@ -/** - * Project chrome matching mockups/project.html. - * - * Scoped to the two-pane workspace so home, onboarding and settings keep - * their own sheets. - */ - -/* ---- two panes: proportional conversation + draft ---- */ - -.panes { - flex: 1; - min-height: 0; -} - -.canvas-body.is-fill .panes { - flex: 1; - min-height: 0; -} - -/* Chat-first: before a stage has a drafted artifact, the conversation is - the whole page — a single centered column, generous margins either side, - the composer at its bottom. The split earns its keep only once there is - something to split against. */ -.panes.chat-first { - display: flex; - flex-direction: column; - min-height: 0; - width: 100%; - max-width: 720px; - margin: 0 auto; -} - -/* The column runs the full height, so the composer sits at the window's - foot with the thread above it, not just under the last message. */ -.panes.chat-first .conv { - flex: 1; - width: 100%; - border-right: 0; -} - -/* The composer arrives once there is something it could send: it rises - into place rather than appearing. */ -.panes.chat-first .composer { - animation: composer-in 280ms ease-out backwards; -} - -@keyframes composer-in { - from { - opacity: 0; - transform: translateY(16px); - } -} - -@media (prefers-reduced-motion: reduce) { - .panes.chat-first .composer { - animation: none; - } -} - -/* Chat-first, before the first message: center the composer (and the empty - note) vertically in the column, like a fresh page, rather than pinning it - to the bottom below mostly empty space. Once a message lands, `.conv-scroll` - goes back to filling the column and the composer settles to the bottom as - the thread grows -- the normal, already-existing layout. */ -.panes.chat-first - .stage-conversation:has(> .conv-scroll > .inline-note:only-child) { - flex: 1; - justify-content: center; -} - -.panes.chat-first - .stage-conversation:has(> .conv-scroll > .inline-note:only-child) - .conv-scroll { - flex: 0 1 auto; - justify-content: center; - text-align: center; -} - -.conv { - position: relative; - border-right: 1px solid var(--wb-border); - display: flex; - flex-direction: column; - min-height: 0; - background: var(--wb-background); -} - -.conv .stage-conversation { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; -} - -.room { - display: flex; - flex: 1 1 auto; - min-height: 0; - min-width: 0; -} - -.room > .panes { - flex: 1; - min-width: 0; -} - -.room .room-thread { - display: flex; - flex-direction: column; - flex: 1 1 auto; - min-height: 0; - min-width: 0; -} - -.room-mail-names { - display: block; - font-weight: 500; - color: var(--wb-foreground); -} - -/* The row's summary uses the column's width and wraps to a second line before it cuts off. */ -.room-mail-summary { - display: -webkit-box; - overflow: hidden; - white-space: normal; - color: var(--wb-muted-foreground); - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-clamp: 2; -} - -.room-mail-row { - align-items: flex-start; -} - -.room-mail-fold > summary { - cursor: pointer; - padding: var(--space-1); - font-size: 0.875rem; - color: var(--wb-muted-foreground); -} - -.room-mail-turn + .room-mail-turn { - margin-top: var(--space-3); -} - -.artifact-mention { - color: var(--wb-foreground); - text-decoration: underline; - text-underline-offset: 0.15em; -} - -.artifact-mention:focus-visible { - outline: 2px solid var(--wb-foreground); - outline-offset: 2px; -} - -/* The library's ask wears an accent wash; here it is a plain card like the conversation's others. */ -.document-decision [data-slot="tool-approval-card"] { - max-width: 36rem; - min-width: 0; - overflow-wrap: anywhere; - border-color: var(--wb-border); - background: var(--wb-background); -} - -.document-decision [data-slot="tool-approval-card"] > header > svg { - color: var(--wb-muted-foreground); -} - -.document-decision-what { - margin: 0; -} - -/* The library card always renders Approve always as its second action. - Document cards and the design card do not offer that standing grant. */ -.document-decision-once - [data-slot="tool-approval-card"] - > div:last-child - > button:nth-child(2) { - display: none; -} - -/* Not yet is sm:ms-auto, and every action is shrink-0 nowrap. A long middle - label fills the row, Not yet wraps, and the auto margin pins it to the - right edge, so Not yet drops that margin. A label wider than the card - wraps between words inside its own button; the card's overflow-wrap - anywhere would otherwise break it a letter at a time. The design card - sizes its actions on their own. */ -.document-decision:not(.document-decision-design) - [data-slot="tool-approval-card"] - > div:last-child - > button { - flex-shrink: 1; - min-width: 0; - height: auto; - min-height: 2rem; - white-space: normal; - overflow-wrap: normal; - word-break: normal; -} - -.document-decision:not(.document-decision-design) - [data-slot="tool-approval-card"] - > div:last-child - > button:last-child { - flex-shrink: 0; - margin-inline-start: 0; -} - -/* Design approval: the two actions share one line and one height, and Not - yet is not pinned to the right edge. Each starts at its label's width and - shares the spare room, so a label wraps between words only once the line - is too narrow for it; the button centres its text, so clipping it would - cut both ends. */ -.document-decision-design [data-slot="tool-approval-card"] > div:last-child { - flex-wrap: nowrap; - align-items: stretch; -} - -.document-decision-design - [data-slot="tool-approval-card"] - > div:last-child - > button { - flex: 1 1 auto; - margin-inline-start: 0; - height: auto; - min-height: 2rem; - white-space: normal; - overflow-wrap: normal; - word-break: normal; - text-align: center; -} - -/* The library keeps a settled line's words from shrinking, so a long document title ran past the - column; it ellipsizes here, and the full line is the card's tooltip. */ -.document-decision > p > span { - flex-shrink: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -/* A long document title wraps inside the card instead of running past its edge. */ -.document-decision-show { - align-self: flex-start; - max-width: 100%; - height: auto; - padding: 0; - white-space: normal; - text-align: start; - overflow-wrap: anywhere; -} - -.document-decision-sources { - display: flex; - flex-direction: column; - gap: var(--space-1); - margin: 0; - padding: 0; - list-style: none; -} - -.document-decision-concepts { - width: 100%; - margin: 0; - border-collapse: collapse; -} - -.document-decision-concepts th, -.document-decision-concepts td { - padding: var(--space-1) var(--space-3) var(--space-1) 0; - text-align: start; - vertical-align: baseline; - font-weight: 400; -} - -.document-decision-concepts thead th { - font-weight: 600; - color: var(--wb-muted-foreground); -} - -.document-decision-concepts tbody td:first-child { - font-weight: 600; -} - -.design-choice { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--space-2); - margin: 0; -} - -.design-choice > p { - margin: 0; -} - -.design-choice-options { - display: flex; - flex-direction: column; - gap: var(--space-1); - margin: 0; - padding: 0; - list-style: none; -} - -.design-choice-options li { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.document-decision-details summary { - color: var(--wb-muted-foreground); - font-size: 0.75rem; - cursor: pointer; -} - -/* An opened list of many versions scrolls inside the card, so the decision stays in view. */ -.document-decision-details .document-decision-sources { - max-height: 16rem; - margin-top: var(--space-1); - overflow-y: auto; -} - -.document-decision-details p { - margin: var(--space-1) 0 0; - font-size: 0.75rem; -} - -.document-decision-next { - margin: 0; - color: var(--wb-muted-foreground); - font-size: 0.75rem; -} - -/* The log scrolls inside the room. flex-shrink 0 made its height follow the - settle spacer, and the spacer then grew from that new height. */ -.room .conv-scroll { - flex: 1 1 auto; - min-height: 0; -} - -.deliverable-body { - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.conv-scroll { - flex: 1; - overflow-y: auto; - /* Never sideways (#321): `overflow-y: auto` alone makes the x axis auto - too, so one child wider than the column let a trackpad gesture carry - the whole log to the right and hide the start of every message. */ - overflow-x: hidden; - padding: var(--space-5); - display: flex; - flex-direction: column; - gap: var(--space-4); -} - -/* Each turn is held to the column; whatever is wide inside it scrolls or - clips within its own box. */ -.conv-scroll > * { - min-width: 0; - max-width: 100%; -} - -.pane-switch { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - -.pane-switch { - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--wb-border); -} - -.pane-switch button[aria-pressed="true"] { - border-color: var(--wb-primary); - color: var(--wb-primary); -} - -.document-read { - padding: var(--space-5); -} - -.document-raw, -.diff-lines { - font-family: var(--wb-font-mono); - font-size: 12px; - white-space: pre-wrap; - margin: 0; -} - -.gui-frame { - flex: 1 1 auto; - width: 100%; - min-height: 480px; - border: 0; - background: #fff; -} - -/* ---- composer: one floating line that grows as the person types ---- */ - -.composer { - border-top: 0; - padding: var(--space-2) var(--space-4) var(--space-4); - position: relative; - display: flex; - flex-direction: column; - flex-shrink: 0; - min-height: 0; -} - -/* The composer keeps a reading measure in a wide pane. */ -.stage-conversation > .composer { - box-sizing: border-box; - width: 100%; - max-width: 46rem; - margin-inline: auto; -} - -/* The budget row stays above the box, and it scrolls inside its own card. - Uncapped, the disclosure is taller than the window and the message box - and the log both leave the screen. */ -.composer > .banner { - max-height: 8.5rem; - overflow-y: auto; -} - -.composer > .composer-box { - flex-shrink: 0; -} - -/* Text, microphone and send share one row; the text grows upward from one line. */ -.composer .composer-box { - display: flex; - flex-flow: row nowrap; - align-items: flex-end; - gap: var(--space-1); - min-width: 0; - border: 1px solid var(--wb-border); - border-radius: var(--radius-lg); - background: var(--wb-card); - padding: 6px 6px 6px var(--space-2); - transition: border-color 150ms; -} - -.composer .composer-box:focus-within { - outline: none; - border-color: var(--wb-border-strong); -} - -/* Metrics on the field, padding on textarea and mirror alike: see home-layout.css. */ -.composer .composer-box [data-slot="prompt-input-field"] { - align-self: center; - font-size: 15px; - line-height: 1.5; -} - -.composer .composer-box :is(textarea, [data-slot="prompt-input-mirror"]) { - padding-block: 4px; -} - -.composer .composer-box textarea { - width: 100%; - min-height: 0; - max-height: min(180px, 30vh); - display: block; -} - -/* An empty box is one line tall, so a placeholder wider than the column would - wrap under the border; it stays on that line and ends in an ellipsis. */ -.composer .composer-box textarea:placeholder-shown { - white-space: nowrap; - text-overflow: ellipsis; -} - -.composer .composer-box textarea::placeholder { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.composer .composer-box [data-slot="prompt-input-toolbar"] { - flex: none; - gap: var(--space-1); - padding: 0; -} - -/* Ink round send — never the library's primary fill. */ -.composer .composer-box [data-slot="prompt-input-submit"] { - width: 32px; - height: 32px; - min-width: 32px; - min-height: 32px; - padding: 0; - border: 0; - background: var(--wb-foreground); - color: var(--wb-background); - border-radius: 50%; - box-shadow: none; -} - -.composer .composer-box [data-slot="prompt-input-submit"]:hover:not(:disabled) { - background: var(--wb-foreground); - opacity: 0.85; -} - -.composer .composer-box [data-slot="prompt-input-submit"]:disabled { - opacity: 0.2; -} - -.composer .dictated { - display: grid; - gap: var(--space-1); -} - -.composer .dictate { - width: 32px; - height: 32px; - margin: 0; - border: 0; - border-radius: 50%; - background: transparent; - color: var(--wb-muted-foreground); -} - -.composer .dictate:hover:not(:disabled) { - background: var(--wb-muted); - color: var(--wb-foreground); -} - -.composer .approve button { - background: transparent; - border-color: transparent; - color: var(--wb-muted-foreground); - height: 32px; - padding: 0 var(--space-3); - gap: 6px; - font-size: 0.8125rem; - font-weight: 550; - border-radius: var(--radius-md); - transition: - color 0.15s, - border-color 0.15s, - background 0.15s; -} - -.composer .approve button:hover:not(:disabled) { - color: var(--ok, var(--wb-okay)); - border-color: color-mix(in srgb, var(--ok, var(--wb-okay)) 40%, transparent); - background: color-mix(in srgb, var(--ok, var(--wb-okay)) 8%, transparent); -} - -@media (max-width: 900px) { - .panes, - .panes:has(> .panes-splitter) { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - } - - .conv { - border-right: 0; - border-bottom: 1px solid var(--wb-border); - } - - /* A lone conversation runs to the window's right edge, where the guide's - button is fixed: the composer keeps clear of it (44px plus its inset). */ - .panes.chat-first .composer { - padding-right: calc(var(--space-4) + 44px + var(--space-6)); - } -} - -/* Phone widths, from 402px: every pane takes the 16px gutter. */ -@media (max-width: 640px) { - .conv-scroll { - padding: var(--space-4); - } - - .composer { - padding: var(--space-2) var(--space-3) var(--space-3); - } -} - -/* ---- "How it will actually run": same paper as the doc pane, - whether nested inside .doc (stage 6) or standalone (the estimate view). */ - -.checklist { - list-style: none; - margin: 0 0 var(--space-3); - padding: 0; -} - -.checklist li { - display: flex; - gap: 8px; - align-items: baseline; - margin-bottom: 6px; -} - -.checklist li::before { - font-family: var(--wb-font-mono); - font-size: 11px; -} - -.checklist li.ok::before { - content: "✓"; - color: var(--ok, var(--wb-okay)); -} - -.checklist li.open::before { - content: "○"; - color: var(--wb-primary); -} - -.checklist li.fail::before { - content: "✗"; - color: var(--wb-destructive); -} diff --git a/apps/web/src/pages/workspace.tsx b/apps/web/src/pages/workspace.tsx deleted file mode 100644 index 4e9899d6..00000000 --- a/apps/web/src/pages/workspace.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import "./workspace-layout.css"; -import "./workspace/stage-chrome.css"; -export * from "./workspace/index.tsx"; diff --git a/apps/web/src/pages/workspace/approaches-comparison.module.css b/apps/web/src/pages/workspace/approaches-comparison.module.css new file mode 100644 index 00000000..81fc0012 --- /dev/null +++ b/apps/web/src/pages/workspace/approaches-comparison.module.css @@ -0,0 +1,63 @@ +/* The approaches a version filed, as columns. The grid fits as many as the + pane's width allows and stacks the rest, so a narrow pane never scrolls + sideways. */ +.approaches { + align-self: stretch; + margin: var(--space-4) 0 var(--space-6); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + gap: var(--space-4); +} + +.approaches .card { + min-width: 0; + gap: var(--space-3); + padding: var(--space-4); + border-radius: var(--radius-panel); + border-color: var(--wb-border); + background: var(--wb-card); +} + +.approaches .card[data-chosen], +.grid:not(:has([data-chosen])) .card[data-recommended] { + border-color: var(--wb-border-strong); + background: color-mix(in srgb, var(--wb-muted) 45%, var(--wb-card)); +} + +.badges { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-bottom: var(--space-1); +} + +.approaches .card p, +.points ul { + margin: 0; +} + +.points h4 { + margin: 0 0 var(--space-1); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--wb-muted-foreground); +} + +.points ul { + padding-inline-start: 1.1em; + list-style: disc; +} + +.points li + li { + margin-top: var(--space-1); +} + +.approaches .footer { + margin-top: auto; + padding-top: var(--space-2); +} diff --git a/apps/web/src/pages/workspace/approaches-comparison.tsx b/apps/web/src/pages/workspace/approaches-comparison.tsx index c946838a..ac63f794 100644 --- a/apps/web/src/pages/workspace/approaches-comparison.tsx +++ b/apps/web/src/pages/workspace/approaches-comparison.tsx @@ -18,14 +18,16 @@ import type { } from "@corbits-builder/schemas/approaches"; import { useState } from "react"; import { wording } from "../../config.ts"; +import { Fault } from "../../components.jsx"; import { humanFault } from "./human-fault.ts"; +import styles from "./approaches-comparison.module.css"; const text = wording.approaches; function Points({ label, points }: { label: string; points: string[] }) { if (points.length === 0) return null; return ( - + {label} {points.map((point, index) => ( @@ -62,22 +64,26 @@ export function ApproachesComparison({ } }; return ( - - + + {meta.approaches.map((approach) => { const recommended = approach.id === meta.recommended; const picked = approach.id === chosen; return ( - + {picked ? {text.chosen} : null} {recommended ? ( {text.recommended} @@ -87,14 +93,14 @@ export function ApproachesComparison({ {approach.name} {approach.summary} - + {text.bestFor}: {approach.bestFor} {onChoose === undefined || chosen !== undefined ? null : ( - + - {fault === null ? null : ( - - {fault} - - )} + {fault === null ? null : {fault}} ); } diff --git a/apps/web/src/pages/workspace/approval-package-preview.module.css b/apps/web/src/pages/workspace/approval-package-preview.module.css new file mode 100644 index 00000000..23bc5d70 --- /dev/null +++ b/apps/web/src/pages/workspace/approval-package-preview.module.css @@ -0,0 +1,165 @@ +.package { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: 0 var(--space-6) var(--space-6); +} + +.tabs { + display: inline-flex; + align-self: flex-start; + flex-wrap: wrap; + gap: 0.25rem; + padding: 0.25rem; + border: 1px solid var(--wb-border); + border-radius: var(--radius-control); + background: color-mix(in srgb, var(--wb-muted) 50%, var(--wb-background)); +} + +.subtab { + color: var(--wb-muted-foreground); + border: 1px solid transparent; +} + +.subtab[aria-selected="true"] { + background: var(--wb-card); + color: var(--wb-foreground); + border-color: var(--wb-border); + font-weight: 600; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); +} + +.pdf { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.pdfToolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--wb-border); + border-radius: var(--radius-panel); + background: var(--wb-card); +} + +.pdfToolbarMeta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-2); +} + +.pdfTitle { + font-size: 0.875rem; + font-weight: 600; + color: var(--wb-foreground); +} + +.pdfFilename { + font-family: var(--wb-font-mono); + font-size: 0.75rem; + color: var(--wb-muted-foreground); +} + +.pdfToolbarActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.pdfAction { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.375rem 0.75rem; + border-radius: var(--radius-control); + border: 1px solid var(--wb-border); + background: var(--wb-background); + color: var(--wb-foreground); + font-size: 0.8125rem; + font-weight: 500; + text-decoration: none; + transition: background-color 120ms ease-out; +} + +.pdfAction:hover { + background: var(--wb-muted); +} + +.pdfAction[data-primary] { + background: var(--wb-muted); + border-color: var(--wb-border-strong); + color: var(--wb-foreground); + font-weight: 600; +} + +.pdfAction[data-primary]:hover { + background: color-mix(in srgb, var(--wb-muted) 85%, var(--wb-foreground)); +} + +.sources { + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +.manifest { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); + gap: var(--space-3); + margin: 0; + padding: var(--space-4); + border: 1px solid var(--wb-border); + border-radius: var(--radius-panel); + background: var(--wb-card); +} + +.manifest dt { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--wb-muted-foreground); +} + +.manifest dd { + margin: var(--space-1) 0 0; + font-size: 0.9375rem; +} + +.sourcesTable { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.sourcesTable th, +.sourcesTable td { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--wb-border); + text-align: left; +} + +.sourcesTable th { + font-weight: 600; + color: var(--wb-muted-foreground); +} + +.documentsList { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.document { + padding: var(--space-4); + border: 1px solid var(--wb-border); + border-radius: var(--radius-panel); + background: var(--wb-card); +} diff --git a/apps/web/src/pages/workspace/approval-package-preview.test.tsx b/apps/web/src/pages/workspace/approval-package-preview.test.tsx index 37e64cca..3fb88136 100644 --- a/apps/web/src/pages/workspace/approval-package-preview.test.tsx +++ b/apps/web/src/pages/workspace/approval-package-preview.test.tsx @@ -288,7 +288,7 @@ describe("approval package preview", () => { expect(html).toContain("5 slides"); expect(html).toContain("Customer Portal Brief"); expect(html).toContain("Portal Requirements"); - expect(html).toContain("approval-package-source-link"); + expect(html).toContain('data-slot="source-link"'); expect(html).toContain("v2"); expect(html).toContain("abcdef012345"); expect(html).toContain("executive-summary.md"); diff --git a/apps/web/src/pages/workspace/approval-package-preview.tsx b/apps/web/src/pages/workspace/approval-package-preview.tsx index d890c207..2a1d0b3c 100644 --- a/apps/web/src/pages/workspace/approval-package-preview.tsx +++ b/apps/web/src/pages/workspace/approval-package-preview.tsx @@ -5,8 +5,11 @@ import { } from "@corbits-builder/schemas/artifact"; import { type } from "arktype"; import { Suspense, lazy, useEffect, useMemo, useState } from "react"; +import { InlineNote } from "../../components.tsx"; import { wording } from "../../config.ts"; import { Markdown } from "../../markdown.jsx"; +import styles from "./approval-package-preview.module.css"; +import { SourceLink } from "./source-link.tsx"; export const APPROVAL_PACKAGE_MEDIA_TYPE = "application/vnd.corbits-builder.approval-package+json"; @@ -16,7 +19,7 @@ const text = wording.approvalPackage; export type ApprovalPackageViewTab = "slides" | "pdf" | "sources"; function SlidesUnavailable() { - return {text.slidesUnavailable}; + return {text.slidesUnavailable}; } // The slide deck schema is costly to build, so it loads only once a package's slides are shown. @@ -38,7 +41,7 @@ const DeckPages = lazy(async () => ({ default: (await slidesModule())?.DeckPages ?? SlidesUnavailable, })); -const loadingSlides = {text.loadingSlides}; +const loadingSlides = {text.loadingSlides}; export function parseApprovalPackage( content: string, @@ -125,21 +128,19 @@ function PdfView({ const pdfHref = blobUrl ?? fallbackHref; return ( - - - - - {text.pdfTitle} - - {stem}.pdf + + + + {text.pdfTitle} + {stem}.pdf {file === null ? ( - {text.pdfUnreadable} + {text.pdfUnreadable} ) : ( - + {blobUrl !== null && ( )} @@ -175,31 +177,22 @@ function SourceRow({ return ( - onOpenSource?.(source.kind, source.version)} - > + onOpenSource?.(source.kind, source.version)}> {kindLabel} - + - onOpenSource?.(source.kind, source.version)} - > + onOpenSource?.(source.kind, source.version)}> {source.title} - + - onOpenSource?.(source.kind, source.version)} > v{String(source.version)} - + {source.sha256.slice(0, 12)} @@ -219,8 +212,8 @@ function SourcesView({ const hasDocuments = documents !== undefined && Object.keys(documents).length > 0; return ( - - + + {text.manifest.audience} {manifest.audience} @@ -240,7 +233,7 @@ function SourcesView({ {text.manifest.sources} - + {text.manifest.sourceKind} @@ -263,11 +256,11 @@ function SourcesView({ {hasDocuments ? ( {text.manifest.documents} - + {Object.entries(documents).map(([name, docContent]) => ( - + {name} - + ))} @@ -292,20 +285,20 @@ export function ApprovalPackagePreview({ useState(initialTab); const bundle = useMemo(() => parseApprovalPackage(content), [content]); if (bundle === null) { - return {text.unreadable}; + return {text.unreadable}; } return ( - - + + setActiveTab("slides")} > @@ -315,7 +308,7 @@ export function ApprovalPackagePreview({ role="tab" size="sm" variant="ghost" - className="approval-package-subtab" + className={styles.subtab} aria-selected={activeTab === "pdf"} onClick={() => setActiveTab("pdf")} > @@ -325,7 +318,7 @@ export function ApprovalPackagePreview({ role="tab" size="sm" variant="ghost" - className="approval-package-subtab" + className={styles.subtab} aria-selected={activeTab === "sources"} onClick={() => setActiveTab("sources")} > diff --git a/apps/web/src/pages/workspace/approval-package-slides.module.css b/apps/web/src/pages/workspace/approval-package-slides.module.css new file mode 100644 index 00000000..3eca4390 --- /dev/null +++ b/apps/web/src/pages/workspace/approval-package-slides.module.css @@ -0,0 +1,212 @@ +.slides { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.slide { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.slideMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: 0 var(--space-1); +} + +.slideIndex { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--wb-muted-foreground); +} + +.stage { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + container-type: inline-size; + display: flex; + flex-direction: column; + justify-content: space-between; + overflow: hidden; + border: 1px solid var(--wb-border-strong); + border-radius: var(--radius-panel); + background: var(--slide-bg, #ffffff); + color: var(--slide-fg, #0f172a); + box-shadow: + 0 12px 28px -8px rgba(15, 23, 42, 0.2), + 0 4px 10px -4px rgba(15, 23, 42, 0.1); +} + +@media (max-width: 640px) { + .stage { + aspect-ratio: auto; + min-height: 18rem; + } +} + +.accentBar { + width: 100%; + height: 5px; + flex: none; + background: var(--slide-accent, var(--wb-primary)); +} + +.canvas { + flex: 1; + display: flex; + flex-direction: column; + gap: clamp(0.625rem, 1.5cqw, 1.15rem); + padding: clamp(1rem, 2.8cqw, 2.25rem) clamp(1.25rem, 3.4cqw, 2.75rem) + clamp(0.5rem, 1.2cqw, 1rem); + min-height: 0; + overflow-y: auto; +} + +.stage[data-slide-layout="title"] .canvas { + justify-content: center; +} + +.kicker { + display: inline-block; + width: 3rem; + height: 4px; + border-radius: 999px; + margin-bottom: var(--space-1); +} + +.heading { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.title { + margin: 0; + font-size: clamp(1.25rem, 2.2vw, 1.65rem); + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.015em; + color: var(--slide-heading, inherit); +} + +.stage[data-slide-layout="title"] .title { + font-size: clamp(1.75rem, 3.2vw, 2.5rem); + line-height: 1.12; + letter-spacing: -0.025em; +} + +.subtitle { + margin: 0; + font-size: clamp(0.925rem, 1.35vw, 1.075rem); + line-height: 1.5; + max-width: 68ch; + color: var(--slide-subtitle, inherit); +} + +.stage[data-slide-layout="title"] .subtitle { + font-size: clamp(1rem, 1.6vw, 1.25rem); + line-height: 1.55; + max-width: 62ch; +} + +.body { + display: flex; + flex-direction: column; + gap: var(--space-2); + font-size: clamp(0.925rem, 1.3vw, 1.05rem); + line-height: 1.6; +} + +.body p { + margin: 0; +} + +.body ul, +.body ol { + margin: 0; + padding-left: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.body ul { + list-style: disc; +} + +.body ol { + list-style: decimal; +} + +.body li::marker { + color: var(--slide-accent, currentColor); +} + +.columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + gap: clamp(0.875rem, 2vw, 1.5rem); + flex: 1; + align-content: start; +} + +.column { + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: clamp(0.875rem, 1.8vw, 1.25rem); + border-radius: var(--radius-md); + background: var(--slide-column-bg, rgba(15, 23, 42, 0.035)); + border: 1px solid var(--slide-column-border, rgba(15, 23, 42, 0.1)); +} + +.columnHeading { + margin: 0; + padding-bottom: var(--space-1); + border-bottom: 1px solid var(--slide-column-border, rgba(15, 23, 42, 0.1)); + font-size: clamp(0.95rem, 1.35vw, 1.1rem); + font-weight: 600; + letter-spacing: -0.01em; +} + +.caption { + margin: auto 0 0; + font-size: 0.875rem; + color: var(--slide-subtitle, var(--wb-muted-foreground)); +} + +.stageFooter { + display: flex; + justify-content: flex-end; + align-items: center; + padding: 0 clamp(1.75rem, 4.2vw, 3.25rem) clamp(0.75rem, 1.6vw, 1.25rem); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + opacity: 0.8; +} + +.pdfPages { + display: flex; + flex-direction: column; + gap: var(--space-5); + padding: var(--space-5); + border: 1px solid var(--wb-border); + border-radius: var(--radius-panel); + background: color-mix(in srgb, var(--wb-muted) 65%, var(--wb-background)); +} + +.pdfPage { + margin: 0 auto; + width: 100%; + max-width: 56rem; +} diff --git a/apps/web/src/pages/workspace/approval-package-slides.tsx b/apps/web/src/pages/workspace/approval-package-slides.tsx index 259d2c7a..b52741fe 100644 --- a/apps/web/src/pages/workspace/approval-package-slides.tsx +++ b/apps/web/src/pages/workspace/approval-package-slides.tsx @@ -12,7 +12,9 @@ import { type Theme, } from "@corbits/slidedeck/deck"; import { useMemo, type CSSProperties, type ReactNode } from "react"; +import { InlineNote } from "../../components.tsx"; import { wording } from "../../config.ts"; +import styles from "./approval-package-slides.module.css"; const text = wording.approvalPackage; @@ -203,7 +205,7 @@ function SlideBlock({ block }: { block: Block }) { function SlideBlocks({ blocks }: { blocks: readonly Block[] }) { if (blocks.length === 0) return null; return ( - + {blocks.map((block, index) => ( ))} @@ -221,12 +223,9 @@ function SlideColumn({ headingColor: string; }) { return ( - + {heading === undefined ? null : ( - + {heading} )} @@ -248,7 +247,7 @@ function FreeText({ elements }: { elements: readonly FreeElement[] }) { } } if (paragraphs.length === 0) return null; - return {paragraphs}; + return {paragraphs}; } function SlideContent({ @@ -264,14 +263,14 @@ function SlideContent({ return ; case "two-column": return ( - + ); case "comparison": return ( - + - + {title === undefined && subtitle === undefined ? null : ( - + {slide.layout === "title" ? ( ) : null} {title === undefined ? null : ( {subtitle} @@ -353,16 +352,13 @@ function SlideCanvas({ )} {caption === undefined ? null : ( - + {caption} )}
+
{wording.projectLoadFailure.title}
{wording.projectLoadFailure.lead}
{detail}
{feature.description}
+ {wording.shell.noProjectTitle} +
{wording.shell.noProjectLead}
- {wording.shell.noProjectTitle} -
{title}
+ {children} +
{children}
+ + {`$ ${wording.flourish.shellRequest}`} - {`\n${wording.flourish.shellAnswer}${SANDWICH}`} + {`\n${wording.flourish.shellAnswer}${SANDWICH}`} ) : null; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index f2707877..1ca0e9a1 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,14 +1,15 @@ +// The global sheets load before any module that imports a component, so each +// component's own CSS module comes after them and wins a tie. The library's +// sheet carries the Tailwind reset and the shared theme; ours adds the tokens +// and element defaults on top of it. +import "@corbits/react-ui/styles.css"; +import "./styles.css"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { ThemeProvider } from "@corbits/react-ui"; import { QueryClientProvider } from "@tanstack/react-query"; import { App } from "./App.jsx"; import { queryCacheReady, queryClient } from "./queries/client.ts"; -// The Corbits component library first — it carries the Tailwind reset and the -// shared theme — then our own sheet, which overrides it for surfaces this app -// owns. Order is the whole contract. -import "@corbits/react-ui/styles.css"; -import "./styles.css"; import { openSharedEventSource } from "./shared-event-source.ts"; import { redirectHashLink } from "./routes.ts"; import { wording } from "./config.ts"; diff --git a/apps/web/src/markdown.module.css b/apps/web/src/markdown.module.css new file mode 100644 index 00000000..a8ff5cc3 --- /dev/null +++ b/apps/web/src/markdown.module.css @@ -0,0 +1,204 @@ +/* The document itself. Measure and rhythm are the whole job here: this is the + one surface in the app a person reads rather than scans. */ +.prose { + font-size: 0.9375rem; + line-height: 1.7; +} + +/* Tracked changes: added in green, removed in red and struck through. */ +.prose ins { + text-decoration: none; + background: color-mix(in srgb, var(--wb-okay) 18%, transparent); + color: color-mix(in srgb, var(--wb-okay) 60%, var(--wb-foreground)); + border-radius: 2px; +} + +.prose del { + background: color-mix(in srgb, var(--wb-destructive) 12%, transparent); + color: color-mix(in srgb, var(--wb-destructive) 70%, var(--wb-foreground)); + text-decoration: line-through; + border-radius: 2px; +} + +/* The measure applies to running text, not the block: a table, code fence + or embedded frame inside the same document reads better at the pane's + full width than squeezed into the same column as the prose around it. + Left-aligned rather than centred: the measure lines up with the document + header above it, and a centred column in a wide pane reads as a mistake. + Wide enough that the card's own width — not this cap — is usually what + limits a line: 80ch left a wide empty gutter once the document pane grew + to take the room the proportional chat column gave up. */ +.prose > :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote) { + max-width: min(120ch, 1100px); +} + +/* In the document pane a line of running text stops at a comfortable reading + length, and its last line is not left with one word. */ +.prose[data-document] > :where(p, ul, ol, blockquote) { + max-width: 75ch; + text-wrap: pretty; +} + +.prose > * + * { + margin-top: 1em; +} + +.prose h2, +.prose h3, +.prose h4 { + font-weight: 500; + line-height: 1.3; +} + +.prose > h2 { + font-size: 1.25rem; + margin-top: 2em; +} + +.prose > h3 { + font-size: 1.0625rem; + margin-top: 1.75em; +} + +.prose > h4 { + font-size: 0.9375rem; + margin-top: 1.5em; + color: var(--wb-muted-foreground); +} + +.prose > :first-child { + margin-top: 0; +} + +.prose ul, +.prose ol { + padding-left: 1.25em; +} + +.prose ul { + list-style: disc; +} + +.prose ol { + list-style: decimal; +} + +.prose li.task { + list-style: none; + margin-left: -1.25em; +} + +.prose li + li { + margin-top: 0.35em; +} + +.prose blockquote { + margin-left: 0; + padding-left: var(--space-4); + border-left: 2px solid var(--wb-border-strong); + color: var(--wb-muted-foreground); +} + +.prose code { + font-family: var(--wb-font-mono); + font-size: 0.875em; + background: var(--wb-muted); + padding: 1px var(--space-1); +} + +.prose pre { + background: var(--wb-muted); + padding: var(--space-3) var(--space-4); + overflow-x: auto; +} + +.prose pre code { + background: none; + padding: 0; +} + +/* A wide table scrolls inside its own box rather than widening the page. */ +.table { + overflow-x: auto; + margin: 1em 0; +} + +.prose table { + border-collapse: collapse; + font-size: 0.9375em; +} + +.prose th, +.prose td { + border: 1px solid var(--wb-border); + padding: var(--space-2) var(--space-3); + text-align: left; + vertical-align: top; +} + +.prose th { + background: var(--wb-muted); + font-weight: 600; +} + +.prose hr { + border: 0; + border-top: 1px solid var(--wb-border); + margin: 2em 0; +} + +.prose ::selection { + background: color-mix(in srgb, var(--wb-primary) 26%, transparent); +} + +/* A picture in a document (#737): the PRD for people's screens, captioned. */ +.prose .figure { + margin: 1rem 0 1.25rem; +} + +.prose .figure img { + display: block; + max-width: 100%; + height: auto; + border: 1px solid var(--wb-border, #e5e7eb); + border-radius: 8px; +} + +.prose .figure figcaption { + margin-top: 0.375rem; + font-size: 0.8125rem; + color: var(--wb-muted-foreground); +} + +.prose .missingPicture { + color: var(--wb-muted-foreground); +} + +/* A document named in the conversation, opened in the reading pane. */ +.mention { + color: var(--wb-foreground); + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.mention:focus-visible { + outline: 2px solid var(--wb-foreground); + outline-offset: 2px; +} + +/* On paper the page sets the size and the measure, and nothing scrolls. */ +@media print { + .prose { + font-size: inherit; + line-height: inherit; + } + + .prose > *, + .prose[data-document] > :where(p, ul, ol, blockquote) { + max-width: none; + } + + .table { + overflow: visible; + } +} diff --git a/apps/web/src/markdown.stories.tsx b/apps/web/src/markdown.stories.tsx new file mode 100644 index 00000000..3e91ee9d --- /dev/null +++ b/apps/web/src/markdown.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { Markdown } from "./markdown.tsx"; + +const SAMPLE = `## Problem + +Small bakeries lose orders because they take them by phone and write them on paper. + +### Who has it + +- Owners who bake and serve at once +- Staff who **answer the phone** mid-shift +- [x] Interviewed three bakeries +- [ ] Priced an order tablet + +### Sources + +See the [interview notes](document:interview_notes) and \`orders.csv\`. + +> Half of Saturday's cake orders came in after noon. + +| Day | Orders | Missed | +| --- | --- | --- | +| Friday | 41 | 3 | +| Saturday | 66 | 9 | +`; + +const meta = { + title: "Components/Markdown", + component: Markdown, + args: { source: SAMPLE }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** A `document:` link shows as its words. */ +export const Document: Story = {}; + +/** A `document:` link opens the document. */ +export const WithDocumentLinks: Story = { + args: { openDocument: fn() }, +}; diff --git a/apps/web/src/markdown.tsx b/apps/web/src/markdown.tsx index 2ade90b1..92d4d763 100644 --- a/apps/web/src/markdown.tsx +++ b/apps/web/src/markdown.tsx @@ -13,6 +13,7 @@ */ import type { JSX, ReactNode } from "react"; import { DEL, END, INS } from "./revisions.ts"; +import styles from "./markdown.module.css"; /** * Inline spans: code, bold, italic, links, with tracked changes threaded through them. @@ -57,12 +58,12 @@ function inline( images?.get(path) ?? (path.startsWith("data:image/") ? path : null); out.push( src ? ( - + {caption ? {caption} : null} ) : ( - + {caption || "picture"} (picture in the documents download) ), @@ -81,7 +82,8 @@ function inline( key={key} role="button" tabIndex={0} - className="artifact-mention" + className={styles.mention} + data-slot="document-mention" onClick={() => openDocument(id)} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; @@ -291,17 +293,24 @@ const TASK = /^\[([ xX])\]\s+/; export function Markdown({ source, + document = false, images, openDocument, }: { source: string; + /** A document read in the document pane: running text keeps a reading measure. */ + document?: boolean; images?: ReadonlyMap; /** Opens a `document:` link; without it such a link shows as its words. */ openDocument?: (id: string) => void; }): JSX.Element { const blocks = parse(source); return ( - + {blocks.map((block, index) => { const key = `b${index}`; switch (block.kind) { @@ -333,7 +342,7 @@ export function Markdown({ {block.items.map((item, at) => { const task = TASK.exec(item); return task ? ( - + + diff --git a/apps/web/src/pages/approval-fields.module.css b/apps/web/src/pages/approval-fields.module.css new file mode 100644 index 00000000..5464ef88 --- /dev/null +++ b/apps/web/src/pages/approval-fields.module.css @@ -0,0 +1,99 @@ +.fields { + display: flex; + flex-direction: column; + gap: 0.375rem; + width: 100%; + min-width: 0; +} + +.fields[data-separated] { + margin-top: var(--space-2); + padding-top: var(--space-2); + border-top: 1px solid var(--wb-border, var(--border)); +} + +.row { + display: grid; + grid-template-columns: 4.75rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; + width: 100%; + min-width: 0; + min-height: 1.875rem; +} + +.row[data-variant="form"] { + grid-template-columns: 4.75rem minmax(0, 1fr); +} + +.label { + font-size: 0.75rem; + font-weight: 500; + color: var(--muted-fg, var(--wb-muted-foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.control { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-width: 0; +} + +.leanControl { + width: 100%; + min-width: 0; + height: 1.75rem; + padding: 0.125rem 0.5rem; + font-size: 0.8125rem; + border-radius: var(--radius-sm, 0.25rem); + border: 1px solid transparent; + background: transparent; + transition: + border-color 120ms ease, + background-color 120ms ease; +} + +.leanControl:hover { + border-color: var(--border-subtle, var(--border, var(--wb-border))); +} + +.leanControl:focus { + border-color: var(--border, var(--wb-border)); + background: var(--surface, transparent); + outline: none; +} + +.leanControl[data-variant="form"] { + border: 1px solid var(--wb-border, var(--border)); + background: var(--wb-background, transparent); +} + +.leanControl[data-variant="form"]:focus { + border-color: var(--wb-primary, var(--primary)); +} + +.saveButton { + height: 1.625rem; + padding: 0 0.5rem; + font-size: 0.75rem; + white-space: nowrap; +} + +.leanButton { + height: 1.625rem; + padding: 0 0.5rem; + font-size: 0.75rem; + border: none; + background: transparent; + color: var(--muted-fg, var(--wb-muted-foreground)); + box-shadow: none; +} + +.leanButton:hover:not(:disabled) { + color: var(--fg, var(--wb-foreground)); + background: var(--surface-hover, rgba(0, 0, 0, 0.04)); +} diff --git a/apps/web/src/pages/approval-fields.tsx b/apps/web/src/pages/approval-fields.tsx new file mode 100644 index 00000000..ccbda10e --- /dev/null +++ b/apps/web/src/pages/approval-fields.tsx @@ -0,0 +1,130 @@ +/** + * The compact reviewer fields shared by the approval rounds and the workspace approvals setting: + * a stack of label-and-control rows, their borderless controls and their quiet buttons. + */ +import { Button, Input } from "@corbits/react-ui"; +import { + createContext, + useContext, + type ComponentProps, + type ReactNode, +} from "react"; +import styles from "./approval-fields.module.css"; + +type FieldsVariant = "inline" | "form"; + +/** Which variant of `ApprovalFields` a row or control sits in; outside one it is inline. */ +const FieldsVariantContext = createContext("inline"); + +/** + * A stack of reviewer fields. The `form` variant boxes its controls and drops the action column, + * for entering a new reviewer; `separated` rules it off from what sits above. + */ +export function ApprovalFields({ + variant = "inline", + separated = false, + children, + ...props +}: { + variant?: FieldsVariant; + separated?: boolean; + children: ReactNode; +} & Pick, "role" | "aria-label">) { + return ( + + {children} + + ); +} + +/** One field: its label, its control, and in the inline variant an action beside it. */ +export function ApprovalField({ + label, + action, + title, + children, +}: { + label: ReactNode; + /** Present (even when null) in the inline variant, so every row keeps its action column. */ + action?: ReactNode; + title?: string; + children: ReactNode; +}) { + const variant = useContext(FieldsVariantContext); + return ( + + {label} + + {children} + + {action === undefined ? null : {action}} + + ); +} + +/** A borderless text input that shows its edge on hover and focus, boxed in a form. */ +export function LeanInput( + props: Omit, "className">, +) { + const variant = useContext(FieldsVariantContext); + return ( + + ); +} + +/** The select that matches `LeanInput`. */ +export function LeanSelect(props: Omit, "className">) { + const variant = useContext(FieldsVariantContext); + return ( + + ); +} + +/** The outlined button that saves one field or adds an entry. */ +export function FieldSaveButton( + props: Omit, "className" | "size" | "variant">, +) { + return ( + + ); +} + +/** A quiet text button for the secondary actions around reviewers. */ +export function LeanButton( + props: Omit, "className" | "size" | "variant">, +) { + return ( + + ); +} diff --git a/apps/web/src/pages/invite-accept.tsx b/apps/web/src/pages/invite-accept.tsx index a63ab06f..9f3934c7 100644 --- a/apps/web/src/pages/invite-accept.tsx +++ b/apps/web/src/pages/invite-accept.tsx @@ -82,7 +82,7 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) { if (accepted !== null) { return ( - + Welcome in @@ -98,7 +98,7 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) { if (failed !== null) { return ( - + This invitation could not be read @@ -109,7 +109,7 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) { if (invitation === null) { return ( - + Opening the invitation… @@ -119,37 +119,37 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) { const pending = invitation.state === "pending"; return ( - + You're invited - - - - + + + + Invited by - {invitation.invitedBy} + {invitation.invitedBy} - - + + Workspace - {invitation.tenantId} + {invitation.tenantId} - - + + Role - {roleName ?? invitation.roleId} + {roleName ?? invitation.roleId} - - + + Email - {invitation.email} + {invitation.email} - - + + State diff --git a/apps/web/src/pages/model-setup.module.css b/apps/web/src/pages/model-setup.module.css new file mode 100644 index 00000000..b531557a --- /dev/null +++ b/apps/web/src/pages/model-setup.module.css @@ -0,0 +1,57 @@ +/* Connect a model: the sign-in column's rhythm and LoginForm's heading, for + the one screen before the project list. */ +.setup { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.setup hgroup { + display: grid; + gap: var(--space-2); +} + +.setup h1 { + margin: 0; + font-size: 1.875rem; + font-weight: 600; + line-height: 1; + letter-spacing: -0.015em; + text-wrap: balance; +} + +.setup hgroup p, +.later p { + margin: 0; + color: var(--wb-muted-foreground); + font-size: 0.875rem; + line-height: 1.5; +} + +/* No cursor here: the global button rules give an enabled tile the hand and a + disabled one not-allowed. */ +.tiles > button { + gap: var(--space-2); + min-height: 7.5rem; + padding: var(--space-4); + border-radius: var(--radius-md); +} + +.tiles > button svg { + width: 24px; + height: 24px; + color: var(--wb-foreground); +} + +.divider { + margin: 0; + border: 0; + border-top: 1px solid var(--wb-border); +} + +.later { + display: grid; + justify-items: center; + gap: var(--space-1); + text-align: center; +} diff --git a/apps/web/src/pages/model-setup.tsx b/apps/web/src/pages/model-setup.tsx index 13ddfe08..523b090a 100644 --- a/apps/web/src/pages/model-setup.tsx +++ b/apps/web/src/pages/model-setup.tsx @@ -14,13 +14,14 @@ import { DialogHeader, DialogTitle, } from "@corbits/react-ui/ui/dialog"; -import { Banner, Mark, QUIET_CONTROL } from "../components.jsx"; +import { Banner, Mark, QuietButton } from "../components.jsx"; import { AddProviderDialog, useModelCatalog, useSubscriptionLogins, -} from "./providers.jsx"; -import { ProviderLogo } from "./provider-logos.tsx"; +} from "./settings/providers.jsx"; +import { ProviderLogo } from "./settings/provider-logos.tsx"; +import styles from "./model-setup.module.css"; const copy = wording.providers; @@ -90,7 +91,7 @@ function ProviderTiles({ {fault} ({ id: provider.id, title: provider.label, @@ -103,10 +104,10 @@ function ProviderTiles({ if (provider) void signIn(provider); }} /> - + ({ id: provider.id, title: provider.label, @@ -152,16 +153,14 @@ export function ModelSetup({ } panel={} > - + {copy.connect} {copy.noneHint} - - + { try { sessionStorage.setItem(MODEL_SETUP_LATER_KEY, "1"); @@ -176,7 +175,7 @@ export function ModelSetup({ }} > {wording.action.skipForNow} - + {copy.addLater} @@ -207,7 +206,7 @@ export function EnableAiDialog({ {copy.connect} {copy.noneHint} - + - {html === null ? null : } + {html === null ? null : } ); } diff --git a/apps/web/src/pages/project-library.css b/apps/web/src/pages/project-library.css deleted file mode 100644 index 0f0a40a7..00000000 --- a/apps/web/src/pages/project-library.css +++ /dev/null @@ -1,218 +0,0 @@ -/** - * The Projects page: filter chips over a grid of cards. All rules are under - * `.library-page`. - */ - -.library-page { - display: grid; - gap: var(--space-4); - align-content: start; -} - -.library-page .library-filters { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - -.library-page .library-note { - margin: 0; - color: var(--wb-muted-foreground); -} - -.library-page .library-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); - gap: var(--space-4); - margin: 0; - padding: 0; - list-style: none; -} - -.library-page .library-card { - position: relative; - overflow: hidden; - border: 1px solid var(--wb-border); - border-radius: var(--radius-panel); - background: var(--wb-card); - transition: border-color 120ms ease; -} - -.library-page .library-card:hover, -.library-page .library-card:focus-within { - border-color: var(--wb-border-strong); -} - -.library-page .library-card-open { - display: flex; - flex-direction: column; - width: 100%; - padding: 0; - border: 0; - background: transparent; - color: inherit; - font: inherit; - text-align: left; - cursor: pointer; -} - -.library-page .library-card-open:focus-visible { - outline: 2px solid var(--wb-ring); - outline-offset: -2px; -} - -.library-page .library-card-body { - display: grid; - gap: var(--space-2); - padding: var(--space-3) var(--space-4) var(--space-4); -} - -.library-page .library-card-title { - overflow: hidden; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -.library-page .library-card-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-2); - padding-right: var(--space-6); - color: var(--wb-muted-foreground); - font-size: 0.8125rem; -} - -.library-page .library-card-menu { - position: absolute; - right: var(--space-2); - bottom: var(--space-3); -} - -/* A cover that shows the mockup lays its first page out at four times the tile's width, the way a - desktop would, and scales it down. The frame is white like every other mockup frame, so a page - that sets no background of its own reads the same here as in the themes pane. */ -.library-page .library-cover[data-mockup] { - position: relative; - overflow: hidden; -} - -.library-page .library-cover .gui-frame { - position: absolute; - inset: 0 auto auto 0; - width: 400%; - height: 400%; - min-height: 0; - border: 0; - background: #fff; - transform: scale(0.25); - transform-origin: 0 0; - pointer-events: none; -} - -/* Each cover mixes two theme colours into the card's own surface, so it stays - soft in light mode and does not glare in dark mode. */ -.library-page .library-cover { - --cover-1: color-mix(in oklab, var(--cover-a) 65%, var(--wb-card)); - --cover-2: color-mix(in oklab, var(--cover-b) 45%, var(--wb-card)); - display: block; - aspect-ratio: 16 / 7; - background: linear-gradient( - var(--cover-angle), - var(--cover-1), - var(--cover-2) - ); -} - -.library-page .library-cover[data-pattern="radial"] { - background: radial-gradient( - circle at var(--cover-x) var(--cover-y), - var(--cover-1), - var(--cover-2) 75% - ); -} - -.library-page .library-cover[data-pattern="stripes"] { - background: repeating-linear-gradient( - var(--cover-angle), - var(--cover-1) 0 14px, - var(--cover-2) 14px 28px - ); -} - -.library-page .library-cover[data-pattern="rings"] { - background: repeating-radial-gradient( - circle at var(--cover-x) var(--cover-y), - var(--cover-1) 0 12px, - var(--cover-2) 12px 24px - ); -} - -.library-page .library-cover[data-pattern="mesh"] { - background: - radial-gradient( - at var(--cover-x) var(--cover-y), - var(--cover-1), - transparent 60% - ), - radial-gradient( - at calc(100% - var(--cover-x)) calc(100% - var(--cover-y)), - var(--cover-2), - transparent 60% - ), - color-mix(in oklab, var(--cover-b) 15%, var(--wb-card)); -} - -.library-page .library-view { - display: inline-flex; - gap: var(--space-1); -} - -/* As a list: one line per project, a small swatch of its cover at the start, - the stage and last activity after the name, and the menu at the end. */ -.library-page .library-grid[data-view="rows"] { - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.library-page .library-grid[data-view="rows"] .library-card-open { - flex-direction: row; - align-items: center; - gap: var(--space-3); - padding: var(--space-2) var(--space-12) var(--space-2) var(--space-3); -} - -.library-page .library-grid[data-view="rows"] .library-cover { - flex: none; - width: 2rem; - aspect-ratio: 1; - border-radius: var(--radius-lg); -} - -.library-page .library-grid[data-view="rows"] .library-card-body { - display: flex; - flex: 1; - min-width: 0; - align-items: center; - gap: var(--space-3); - padding: 0; -} - -.library-page .library-grid[data-view="rows"] .library-card-title { - flex: 1; - min-width: 0; -} - -.library-page .library-grid[data-view="rows"] .library-card-meta { - flex-wrap: nowrap; - padding-right: 0; - white-space: nowrap; -} - -.library-page .library-grid[data-view="rows"] .library-card-menu { - top: 50%; - bottom: auto; - transform: translateY(-50%); -} diff --git a/apps/web/src/pages/project-library.module.css b/apps/web/src/pages/project-library.module.css new file mode 100644 index 00000000..2ecd8314 --- /dev/null +++ b/apps/web/src/pages/project-library.module.css @@ -0,0 +1,141 @@ +/* The Projects page: filter chips over a grid of cards. */ + +.page { + display: grid; + gap: var(--space-4); + align-content: start; +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.note { + margin: 0; + color: var(--wb-muted-foreground); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); + gap: var(--space-4); + margin: 0; + padding: 0; + list-style: none; +} + +.card { + position: relative; + overflow: hidden; + border: 1px solid var(--wb-border); + border-radius: var(--radius-panel); + background: var(--wb-card); + transition: border-color 120ms ease; +} + +.card:hover, +.card:focus-within { + border-color: var(--wb-border-strong); +} + +.cardOpen { + display: flex; + flex-direction: column; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.cardOpen:focus-visible { + outline: 2px solid var(--wb-ring); + outline-offset: -2px; +} + +.cardBody { + display: grid; + gap: var(--space-2); + padding: var(--space-3) var(--space-4) var(--space-4); +} + +.cardTitle { + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cardMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + padding-right: var(--space-6); + color: var(--wb-muted-foreground); + font-size: 0.8125rem; +} + +.cardMenu { + position: absolute; + right: var(--space-2); + bottom: var(--space-3); +} + +.view { + display: inline-flex; + gap: var(--space-1); +} + +/* As a list: one line per project, a small swatch of its cover at the start, + the stage and last activity after the name, and the menu at the end. */ +.grid[data-view="rows"] { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.grid[data-view="rows"] .cardOpen { + flex-direction: row; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-12) var(--space-2) var(--space-3); +} + +.grid[data-view="rows"] [data-slot="project-cover"] { + flex: none; + width: 2rem; + aspect-ratio: 1; + border-radius: var(--radius-lg); +} + +.grid[data-view="rows"] .cardBody { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: var(--space-3); + padding: 0; +} + +.grid[data-view="rows"] .cardTitle { + flex: 1; + min-width: 0; +} + +.grid[data-view="rows"] .cardMeta { + flex-wrap: nowrap; + padding-right: 0; + white-space: nowrap; +} + +.grid[data-view="rows"] .cardMenu { + top: 50%; + bottom: auto; + transform: translateY(-50%); +} diff --git a/apps/web/src/pages/project-library.tsx b/apps/web/src/pages/project-library.tsx index 737875cc..022cfae4 100644 --- a/apps/web/src/pages/project-library.tsx +++ b/apps/web/src/pages/project-library.tsx @@ -31,7 +31,7 @@ import type { ProjectSummary } from "../api/types.ts"; import type { NoticeAction } from "../components.jsx"; import { ProjectCover } from "./project-cover.tsx"; import { ProjectMenu } from "./project-menu.jsx"; -import "./project-library.css"; +import styles from "./project-library.module.css"; type Filter = ProjectStage | "archived"; const FILTERS: readonly Filter[] = ["idea", "building", "shipped", "archived"]; @@ -100,7 +100,7 @@ function ViewToggle({ }) { const text = wording.library.view; return ( - + project.id === pending.id)) return; - const cards = - section.current?.querySelectorAll(".library-card-open") ?? - []; + const root = section.current; + if (root === null) return; + const cards = root.querySelectorAll("[data-card-open]"); const next = cards[Math.min(pending.index, cards.length - 1)] ?? - section.current?.querySelector( - ".library-filters [aria-pressed='true']", - ); + root.querySelector("[data-filters] [aria-pressed='true']"); next?.focus(); }, [projects, shown]); let body: ReactNode; if (!loaded) body = ( - + {failed ? wording.library.failed : wording.library.loading} ); @@ -220,14 +218,15 @@ export function ProjectLibrary({ ); else body = ( - + {shown.map((project, index) => { const cover = coverOf(project.id); return ( - + onOpen(project.id)} > - - {project.title} - + + {project.title} + @@ -289,13 +288,14 @@ export function ProjectLibrary({ ); return ( - + diff --git a/apps/web/src/pages/project-menu.module.css b/apps/web/src/pages/project-menu.module.css new file mode 100644 index 00000000..7b376fce --- /dev/null +++ b/apps/web/src/pages/project-menu.module.css @@ -0,0 +1,37 @@ +.info { + width: min(52rem, calc(100vw - 2rem)); + max-width: none; +} + +.facts { + margin-bottom: var(--space-4); +} + +.facts > div { + display: grid; + grid-template-columns: 144px minmax(0, 1fr); + gap: var(--space-4); + padding: var(--space-1) 0; + border-bottom: 1px solid var(--wb-border); +} + +.facts > div:last-child { + border-bottom: 0; +} + +.facts > div > dt { + margin: 0; +} + +.facts > div > dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + .facts > div { + grid-template-columns: 1fr; + gap: var(--space-1); + } +} diff --git a/apps/web/src/pages/project-menu.tsx b/apps/web/src/pages/project-menu.tsx index a73e80e7..2ccae1e3 100644 --- a/apps/web/src/pages/project-menu.tsx +++ b/apps/web/src/pages/project-menu.tsx @@ -37,7 +37,14 @@ import { shipProject, updateProject, } from "../api/projects.ts"; -import { Button, type NoticeAction } from "../components.jsx"; +import { + Button, + DangerMenuItem, + Fault, + Field, + type NoticeAction, +} from "../components.jsx"; +import styles from "./project-menu.module.css"; import { Dictated } from "../dictation.jsx"; /** What the menu needs of a project: the summaries (rail, top bar) both have it. */ @@ -186,8 +193,7 @@ export function ProjectMenu({ {confirming ? ( - void act(async () => { await archiveProject(project.id); @@ -196,17 +202,16 @@ export function ProjectMenu({ } > Yes, delete it - + ) : ( - { event.preventDefault(); setConfirming(true); }} > Delete… - + )} @@ -214,7 +219,6 @@ export function ProjectMenu({ // Portaled, but React-nested in the host: its events bubble to a // card face's handlers unless stopped here, the same as the menu's. event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()} @@ -231,7 +235,6 @@ export function ProjectMenu({ ) : null} {updating ? ( event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()} @@ -245,7 +248,6 @@ export function ProjectMenu({ ) : null} {exporting ? ( event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()} @@ -352,11 +354,7 @@ function ExportDialog({ {available === null ? ( {wording.exportProject.loading} ) : null} - {failure === null ? null : ( - - {failure} - - )} + {failure === null ? null : {failure}} {EXPORT_SECTIONS.map((section) => ( (open ? undefined : onClose())}> - + {project.title} - + Name - - + + {project.createdAt !== undefined ? ( Created diff --git a/apps/web/src/pages/home-layout.css b/apps/web/src/pages/projects.module.css similarity index 57% rename from apps/web/src/pages/home-layout.css rename to apps/web/src/pages/projects.module.css index cfdf64e6..8da60700 100644 --- a/apps/web/src/pages/home-layout.css +++ b/apps/web/src/pages/projects.module.css @@ -1,20 +1,6 @@ -/** - * The New project page: a centred prompt. - * - * All rules are under `.home-page` (or `:has(> .home-page)`) so settings - * rows and the workspace sheet do not share selectors. - */ +/* The New project page: a centred prompt. */ -.canvas-body:has(> .home-page) { - padding: 0; - max-width: none; -} - -.canvas-body:has(> .home-page) > .banner { - margin: var(--space-4) var(--space-6) 0; -} - -.home-page { +.homePage { display: grid; align-content: start; max-width: 1080px; @@ -26,7 +12,7 @@ /* The prompt is the page: centred in the pane's height, a reading width wide, with the question above it. */ -.home-page .new-project { +.newProject { display: flex; flex-direction: column; gap: var(--space-4); @@ -37,14 +23,14 @@ margin: 0 auto; } -.home-page .new-project-head { +.head { display: grid; gap: var(--space-2); margin-bottom: var(--space-4); text-align: center; } -.home-page .new-project-head h1 { +.head h1 { margin: 0; font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem); font-weight: 700; @@ -53,56 +39,50 @@ text-wrap: balance; } -.home-page .new-project-head p { +.head p { margin: 0; font-size: 1.0625rem; color: var(--wb-muted-foreground); text-wrap: balance; } -.home-page .new-project-actions { +.actions { display: flex; align-items: center; justify-content: center; gap: var(--space-4); } -.home-page .new-project-actions [data-slot="button"] { +.actions [data-slot="button"] { font-size: 0.75rem; color: var(--wb-muted-foreground); } -/* The composer is the workspace's own (workspace-layout.css), only wider: - the page already gives it its gutter. */ -.home-page .new-project > .composer { - padding: 0; -} - -.home-page .new-project-actions [role="status"] { +.actions [role="status"] { font-size: 0.8125rem; color: var(--wb-muted-foreground); } -.home-page .import-confirm-actions { +.importActions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } -.home-page .import-folder-notes { +.folderNotes { margin: 8px 0 0; padding-inline-start: 1.25em; font-size: 0.8125rem; color: var(--wb-muted-foreground); } -.home-page .import-folder-files { +.folderFiles { display: grid; gap: 4px; margin-top: 12px; } -.home-page .import-folder-files > p { +.folderFiles > p { margin: 0; } diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx index 5eca16a2..3454e4ac 100644 --- a/apps/web/src/pages/projects.tsx +++ b/apps/web/src/pages/projects.tsx @@ -30,14 +30,14 @@ import { readDirectory, type PickedDirectory, } from "../api/repository.ts"; -import { Banner, Button } from "../components.jsx"; -import { ConnectProvider } from "./providers.jsx"; +import { Banner, BannerAction, Button } from "../components.jsx"; +import { ConnectProvider } from "./settings/providers.jsx"; import { NewProjectProfile } from "./profile-select.jsx"; import { Dictated } from "../dictation.jsx"; import { useShellReply } from "../flourish.tsx"; -import { COMPOSER_BOX_CLASS } from "./workspace/pane-classes.ts"; +import { Composer } from "../composer.tsx"; import { HOME_COMPOSER_PLACEHOLDER, canStartProject } from "./home-view.ts"; -import "./home-layout.css"; +import styles from "./projects.module.css"; import type { ProjectImport } from "@corbits-builder/schemas/project-import"; import { DOCUMENT_LABELS, @@ -219,22 +219,25 @@ export function Projects({ }; return ( - - - + + + {wording.newProject.title} {wording.newProject.lead} {needsProvider ? ( {wording.providers.noneHint} - + - + ) : null} - {/* The project page's composer, the same markup under the same class. */} - + {/* The project page's composer, the same frame as a project's. */} + {(mic) => ( void start()} @@ -261,9 +263,9 @@ export function Projects({ )} - + {shell.reply} - + {starting ? ( {wording.newProject.starting} @@ -326,7 +328,7 @@ export function Projects({ Importing with approvals records your own approval of exactly these versions. - + @@ -406,14 +408,14 @@ function RepositoryBanner({ {text.folderToWrite(labels(toWrite))} ) : null} {report.warnings.length > 0 ? ( - + {report.warnings.map((warning) => ( {warning} ))} ) : null} {choices.size > 0 ? ( - + {text.folderLeaveOut} {[...choices].map(([path, reason]) => ( ) : null} - + {text.folderImport} diff --git a/apps/web/src/pages/settings-layout.css b/apps/web/src/pages/settings-layout.css deleted file mode 100644 index 571d6f10..00000000 --- a/apps/web/src/pages/settings-layout.css +++ /dev/null @@ -1,312 +0,0 @@ -/* - * Settings page body — the mockup's section/row language, scoped so it cannot - * collide with `.field` / `.settings-section` in the shared sheet. Rows sit - * on the page rather than in bordered cards; space separates them, not rules. - */ -.settings-page { - max-width: 720px; - width: 100%; - min-width: 0; - margin: 0 auto; - padding: var(--space-8) var(--space-6) var(--space-12); - display: flex; - flex-direction: column; - gap: var(--space-6); -} - -.settings-page .settings-tabs { - gap: var(--space-8); -} - -.settings-page .settings-tabs > [role="tablist"] { - flex-wrap: nowrap; - overflow-x: auto; - scrollbar-width: none; - border-bottom-color: color-mix(in srgb, var(--border) 60%, transparent); -} - -.settings-page .settings-tabs [role="tab"] { - flex: none; - white-space: nowrap; -} - -.settings-page .settings-tabs > [role="tabpanel"] { - display: flex; - flex-direction: column; - gap: var(--space-12); - outline: none; -} - -.settings-page > h1 { - margin: 0; - font-size: 22px; - font-weight: 700; -} - -.settings-page .section { - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.settings-page .section-head { - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.settings-page .section-head h2 { - margin: 0; - font-size: 15px; - font-weight: 700; -} - -.settings-page .section-head p { - margin: 0; - font-size: 13px; - color: var(--muted-foreground); -} - -.settings-page .section-body { - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.settings-page .row, -.settings-rows .row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); - padding: var(--space-3) 0; - font-size: 14px; -} - -.settings-page .row .k, -.settings-rows .row .k { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; -} - -.settings-page .row .k b, -.settings-rows .row .k b { - display: inline-flex; - align-items: center; - gap: var(--space-2); - font-weight: 600; - font-size: 13.5px; -} - -.settings-page .row .k span, -.settings-rows .row .k span { - font-size: 12px; - color: var(--muted-foreground); - font-family: var(--font-mono); -} - -.settings-page .row .v, -.settings-rows .row .v { - font-family: var(--font-mono); - font-size: 12px; - color: var(--muted-foreground); - display: flex; - align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - gap: var(--space-3); - flex-shrink: 0; - max-width: min(420px, 100%); -} - -.settings-page input.field, -.settings-rows input.field { - display: block; - font-family: var(--font-mono); - font-size: 12.5px; - border: 1px solid var(--border-strong); - border-radius: var(--radius-md); - background: var(--background); - color: var(--foreground); - padding: 6px 10px; - width: 220px; - max-width: 100%; - outline: none; -} - -.settings-page input.field:focus, -.settings-rows input.field:focus { - border-color: var(--primary); -} - -.settings-page .section-body > .banner, -.settings-page .section-body > .inline-note, -.settings-page .section-body > p { - margin: var(--space-2) 0; -} - -.settings-page .section-body > .inline-note, -.settings-page .section-body > p { - font-size: 13px; - color: var(--muted-foreground); -} - -.settings-page .invite-form { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: var(--space-2); -} - -/* At phone width a row's controls drop under its label instead of squeezing - it to one word per line, and the canvas's own padding is the gutter. */ -@media (max-width: 560px) { - .settings-page { - padding: var(--space-4) 0 var(--space-12); - } - - .settings-page .row, - .settings-rows .row { - flex-wrap: wrap; - } - - .settings-page .row .v, - .settings-rows .row .v { - flex-shrink: 1; - justify-content: flex-start; - } - - .settings-page .invite-form { - justify-content: flex-start; - } -} - -.settings-page .quiet-state, -.settings-rows .quiet-state { - display: inline-flex; - align-items: center; - gap: var(--space-2); - flex: none; - font-size: 13px; - color: var(--muted-foreground); -} - -.profile-role { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--space-2); - margin: 0; - padding: 0; - border: 0; - min-width: 0; -} - -.profile-role > legend { - grid-column: 1 / -1; - padding: 0 0 6px; - font-size: 13px; - font-weight: 600; -} - -@media (max-width: 520px) { - .profile-role { - grid-template-columns: minmax(0, 1fr); - } -} - -/* Settings rows in a dialog: the body's own gap would double the rows' spacing, and rows sit a - little closer so a whole form fits without scrolling. */ -.provider-form.settings-rows { - gap: 0; -} - -.provider-form.settings-rows .row { - padding: var(--space-2) 0; -} - -/* A custom endpoint's API types: three compact choices on one line, the path each one calls. */ -.api-options { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--space-2); - padding-bottom: var(--space-1); -} - -.api-options .provider-hint { - font-family: var(--font-mono); - font-size: 12px; -} - -/* A custom provider's models, listed under its row. */ -.custom-models { - padding-left: var(--space-4); -} - -.settings-page input.field.token-field, -.settings-rows input.field.token-field, -.token-field { - width: 104px; -} - -.token-head { - display: inline-flex; - align-items: center; - gap: var(--space-1); - font-family: var(--font-sans); -} - -.token-remove { - width: 2.25rem; - flex: none; -} - -.models-toggle { - display: inline-flex; - align-items: center; - gap: var(--space-1); -} - -@media (max-width: 560px) { - .settings-page input.field.token-field, - .settings-rows input.field.token-field, - .token-field { - width: 88px; - } - - .custom-models { - padding-left: 0; - } -} - -@media (max-width: 520px) { - .api-options { - grid-template-columns: minmax(0, 1fr); - } -} - -.settings-page .about-subhead { - margin: var(--space-2) 0 0; - font-size: 13.5px; - font-weight: 600; -} - -.settings-page .about-lead { - font-size: 12.5px; - color: var(--muted-foreground); -} - -.settings-page .row .v a { - color: inherit; - text-decoration: underline; - text-underline-offset: 2px; -} - -.settings-page .row.is-highlighted { - margin: 0 calc(var(--space-2) * -1); - padding-left: var(--space-2); - padding-right: var(--space-2); - border-radius: var(--radius-md); - background: var(--wb-muted); -} diff --git a/apps/web/src/pages/settings/collaborators.module.css b/apps/web/src/pages/settings/collaborators.module.css new file mode 100644 index 00000000..85c7f038 --- /dev/null +++ b/apps/web/src/pages/settings/collaborators.module.css @@ -0,0 +1,12 @@ +.inviteForm { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); +} + +@media (max-width: 560px) { + .inviteForm { + justify-content: flex-start; + } +} diff --git a/apps/web/src/pages/collaborators.tsx b/apps/web/src/pages/settings/collaborators.tsx similarity index 78% rename from apps/web/src/pages/collaborators.tsx rename to apps/web/src/pages/settings/collaborators.tsx index 097499c2..929bd3e5 100644 --- a/apps/web/src/pages/collaborators.tsx +++ b/apps/web/src/pages/settings/collaborators.tsx @@ -7,18 +7,32 @@ * a member directory, so this page lists you and every open invitation and * says that other members are not listed. */ -import { wording } from "../config.ts"; +import { wording } from "../../config.ts"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import { Banner, Button, QuietSelect, StateLabel } from "../components.jsx"; -import { productClient } from "../api/transport.ts"; -import { workspaceTenantId, type HubUser } from "../api/auth.ts"; +import { + Banner, + Button, + InlineNote, + QuietSelect, + StateLabel, +} from "../../components.jsx"; +import { productClient } from "../../api/transport.ts"; +import { workspaceTenantId, type HubUser } from "../../api/auth.ts"; import type { Invitation } from "@corbits-builder/schemas/invitation"; import { ProductApiError, type ProductApiClient, } from "@corbits-builder/client"; -import { responseCode } from "./workspace/human-fault.ts"; +import { responseCode } from "../workspace/human-fault.ts"; +import { Section } from "@corbits/react-ui"; +import { + SettingsInput, + SettingsRow, + SettingsRowValue, +} from "./settings-row.tsx"; +import { SettingsSectionBody } from "./settings-section.tsx"; +import styles from "./collaborators.module.css"; type RoleRow = Awaited< ReturnType @@ -131,52 +145,45 @@ export function Collaborators({ user }: { user: HubUser | null }) { }; return ( - - - Collaborators - Who works in this workspace, and what they may do. - - + + {error ? : null} {notice ? : null} - - - {user ? user.name : wording.speaker.you} - {user ? user.email : ""} - - + + {myRoles === null ? "…" : myRoles.length > 0 ? myRoles.join(", ") : "Member"} - - - - - Workspace roles - - What an invitation can grant. Names and grants come from the hub. - - - + + + + {roles === null ? "…" : roles.length === 0 ? "None defined yet." : roles.map((role) => role.name).join(", ")} - - - - - Invite a collaborator - They appear here once they accept their link. - - + + + + {roles !== null && roles.length > 0 ? ( - - + - + + {invitations !== null && invitations.length > 0 ? ( - - - Open invitations - Each is a link the invited person accepts from. - - + + {invitations.map((invitation) => ( - + {invitation.email} · {expiresInLabel(invitation.expiresAt)} @@ -235,15 +241,15 @@ export function Collaborators({ user }: { user: HubUser | null }) { ))} - - + + ) : null} - + A full member directory is not part of this workspace yet: the hub lists your own memberships, not every principal, so only you and open invitations are shown. - - - + + + ); } diff --git a/apps/web/src/pages/settings/custom-providers.module.css b/apps/web/src/pages/settings/custom-providers.module.css new file mode 100644 index 00000000..9f9920f5 --- /dev/null +++ b/apps/web/src/pages/settings/custom-providers.module.css @@ -0,0 +1,123 @@ +.dialogForm { + display: flex; + flex-direction: column; + min-height: 0; +} + +.picker { + display: grid; + gap: var(--space-2); +} + +.picker[data-layout="cards"] { + grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr)); +} + +/* A custom endpoint's API types: three compact choices on one line, the path each one calls. */ +.picker[data-layout="apis"] { + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding-bottom: var(--space-1); +} + +.card { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: 10px 12px; + border: 1px solid var(--wb-border); + border-radius: var(--radius-md); + background: var(--wb-card); + color: var(--wb-foreground); + font: inherit; + text-align: left; + cursor: pointer; + transition: + border-color 120ms ease-out, + background-color 120ms ease-out; +} + +.card:hover:not(:disabled) { + border-color: var(--wb-border-strong); +} + +.card[aria-checked="true"] { + border-color: var(--wb-primary); + background: color-mix(in srgb, var(--wb-primary) 8%, var(--wb-card)); + box-shadow: inset 0 0 0 1px var(--wb-primary); +} + +.card:focus-visible { + outline: 2px solid var(--wb-ring); + outline-offset: 2px; +} + +.card:disabled { + cursor: default; + opacity: 0.6; +} + +.cardHead { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.875rem; +} + +.cardLogo { + display: inline-grid; + place-items: center; + flex: none; + width: 24px; + height: 24px; +} + +.cardLogo > [data-slot="provider-logo"] { + width: 20px; + height: 20px; +} + +.hint { + margin: 0; + font-size: 0.8125rem; + color: var(--wb-muted-foreground); +} + +.picker[data-layout="apis"] .hint { + font-family: var(--font-mono); + font-size: 12px; +} + +/* A custom provider's models, listed under its row. */ +.customModels { + padding-left: var(--space-4); +} + +/* As wide as the token inputs under it, so each head sits over its column. */ +.tokenHead { + width: 104px; + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-family: var(--font-sans); +} + +.tokenRemove { + width: 2.25rem; + flex: none; +} + +@media (max-width: 560px) { + .tokenHead { + width: 88px; + } + + .customModels { + padding-left: 0; + } +} + +@media (max-width: 520px) { + .picker[data-layout="apis"] { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/web/src/pages/custom-providers.tsx b/apps/web/src/pages/settings/custom-providers.tsx similarity index 91% rename from apps/web/src/pages/custom-providers.tsx rename to apps/web/src/pages/settings/custom-providers.tsx index 575097b3..2869fdad 100644 --- a/apps/web/src/pages/custom-providers.tsx +++ b/apps/web/src/pages/settings/custom-providers.tsx @@ -8,11 +8,10 @@ * all. The hub needs one model to add a provider, so the add dialog asks for the first; the rest * are managed under the row. The dialog shell and card picker here are the add dialog's too. */ -import { wording } from "../config.ts"; +import { wording } from "../../config.ts"; import { Button as UiButton, Switch } from "@corbits/react-ui"; import { Dialog, - DialogBody, DialogContent, DialogDescription, DialogFooter, @@ -38,11 +37,23 @@ import { type ModelCatalog, type ModelProvider, } from "@corbits-builder/schemas/models"; -import { Button, QuietState } from "../components.jsx"; -import { keys } from "../queries/keys.ts"; -import { productClient } from "../api/transport.ts"; -import { workspaceTenantId } from "../api/auth.ts"; +import { + Button, + DangerMenuItem, + QuietButton, + QuietState, +} from "../../components.jsx"; +import { keys } from "../../queries/keys.ts"; +import { productClient } from "../../api/transport.ts"; +import { workspaceTenantId } from "../../api/auth.ts"; import { ProviderLogo } from "./provider-logos.tsx"; +import { ProviderFormBody } from "./provider-form.tsx"; +import { + SettingsInput, + SettingsRow, + SettingsRowValue, +} from "./settings-row.tsx"; +import styles from "./custom-providers.module.css"; const copy = wording.providers; const tips = copy.custom.tips; @@ -164,19 +175,15 @@ export function ProviderDialog(props: { {props.description} { event.preventDefault(); props.onSubmit(); }} > - + {props.children} - + & { @@ -222,7 +229,8 @@ export function ProviderPicker({ chosenId: string | undefined; disabled: boolean; labelledBy: string; - className?: string; + /** Cards that fill the row, or the three compact API types of a custom endpoint. */ + layout?: "cards" | "apis"; onChoose: (id: string) => void; }) { const cards = useRef<(HTMLButtonElement | null)[]>([]); @@ -240,7 +248,13 @@ export function ProviderPicker({ cards.current[target]?.focus(); }; return ( - + {providers.map((provider, index) => { const checked = provider.id === chosenId; const card = ( @@ -254,17 +268,17 @@ export function ProviderPicker({ aria-checked={checked} tabIndex={checked ? 0 : -1} disabled={disabled} - className="provider-card" + className={styles.card} onClick={() => onChoose(provider.id)} onKeyDown={(event) => move(event, index)} > - - + + {provider.label} - {provider.hint} + {provider.hint} ); return provider.tip ? ( @@ -297,20 +311,21 @@ function FieldRow({ children?: ReactNode; }) { return ( - - - + {htmlFor ? ( {label} ) : ( {label} )} - - {hint ? {hint} : null} - + > + } + hint={hint} + > {children} - + ); } @@ -488,9 +503,8 @@ function ConnectionRows({ return ( <> - onChange({ ...draft, api: api as CustomApi })} /> - - - - + {copy.custom.modelId} - - {duplicate ? ( + > + } + hint={ + duplicate ? ( {copy.custom.duplicateIds} ) : ( children - )} - - + ) + } + > + {MODEL_FIELDS.map((field) => ( - + {field.label} ))} {removable ? ( - + ) : null} - - + + ); } @@ -636,11 +651,11 @@ function ModelRow({ }) { const blur = onCommit ? { onBlur: onCommit } : {}; return ( - - - {editableId ? ( - ) : ( - {model.id} - )} - - + model.id + ) + } + > + {MODEL_FIELDS.map((field) => ( - ))} {children} - - + + ); } @@ -719,31 +735,25 @@ export function CustomProviderRow({ const listId = `custom-models-${provider.id}`; return ( <> - - - {provider.label} - {provider.hint} - - - + + setOpen(!open)} > {copy.custom.modelCount(provider.models.length)} - + - - + + {open ? ( - + ) : null} @@ -832,7 +842,7 @@ function CustomProviderModels({ ))} - + @@ -841,7 +851,7 @@ function CustomProviderModels({ > {copy.custom.addModel} - + > ); } @@ -905,16 +915,11 @@ function CustomProviderActions({ {confirming ? ( - void remove()} - > + void remove()}> {copy.custom.confirmRemove} - + ) : ( - { event.preventDefault(); @@ -922,7 +927,7 @@ function CustomProviderActions({ }} > {copy.custom.remove} - + )} diff --git a/apps/web/src/pages/settings/profiles.module.css b/apps/web/src/pages/settings/profiles.module.css new file mode 100644 index 00000000..6372a2c2 --- /dev/null +++ b/apps/web/src/pages/settings/profiles.module.css @@ -0,0 +1,22 @@ +.role { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); + margin: 0; + padding: 0; + border: 0; + min-width: 0; +} + +.role > legend { + grid-column: 1 / -1; + padding: 0 0 6px; + font-size: 13px; + font-weight: 600; +} + +@media (max-width: 520px) { + .role { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/web/src/pages/profiles.tsx b/apps/web/src/pages/settings/profiles.tsx similarity index 91% rename from apps/web/src/pages/profiles.tsx rename to apps/web/src/pages/settings/profiles.tsx index 4374c3f6..493656ed 100644 --- a/apps/web/src/pages/profiles.tsx +++ b/apps/web/src/pages/settings/profiles.tsx @@ -7,11 +7,10 @@ * provider's (`automaticProfile`); with several connected, the person picks one here. A new * project keeps the bindings it started with, so a change here never alters a running project. */ -import { wording } from "../config.ts"; +import { wording } from "../../config.ts"; import { Input, Section, Switch } from "@corbits/react-ui"; import { Dialog, - DialogBody, DialogContent, DialogDescription, DialogFieldLabel, @@ -28,15 +27,25 @@ import type { RoleBinding, Tier, } from "@corbits-builder/schemas/models"; -import { Banner, Button, QuietSelect, QuietState } from "../components.jsx"; -import { keys } from "../queries/keys.ts"; -import { productClient } from "../api/transport.ts"; -import { workspaceTenantId } from "../api/auth.ts"; +import { + Banner, + Button, + Field, + QuietSelect, + QuietState, +} from "../../components.jsx"; +import { keys } from "../../queries/keys.ts"; +import { productClient } from "../../api/transport.ts"; +import { workspaceTenantId } from "../../api/auth.ts"; import { useModelCatalog } from "./providers.jsx"; +import { ProviderFormBody } from "./provider-form.tsx"; +import { SettingsRow, SettingsRowValue } from "./settings-row.tsx"; +import { SettingsSectionBody } from "./settings-section.tsx"; +import styles from "./profiles.module.css"; const copy = wording.profiles; -type Field = { value: ModelChoice | null; version: number }; +type ChoiceField = { value: ModelChoice | null; version: number }; type Tiers = CustomProfile["tiers"]; type Profile = { id: string; @@ -89,10 +98,12 @@ function complete(draft: Draft, tiers: readonly Tier[]): Tiers | null { return done as Tiers; } -export function Profiles({ initialField }: { initialField?: Field } = {}) { +export function Profiles({ + initialField, +}: { initialField?: ChoiceField } = {}) { const catalog = useModelCatalog(); const queryClient = useQueryClient(); - const [field, setField] = useState(initialField ?? null); + const [field, setField] = useState(initialField ?? null); const [error, setError] = useState(null); const [editing, setEditing] = useState(null); const [viewing, setViewing] = useState(null); @@ -177,7 +188,7 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { } > - + {!active && data.unresolved ? ( ) : null} @@ -185,12 +196,12 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { const runnable = available(data, profile.tiers); const activeRow = profile.id === active?.id; return ( - - - {profile.label} - {profileSubtitle(profile, runnable)} - - + + {profile.custom ? ( <> ) : null} - - + + ); })} {error ? : null} - + {viewed ? ( - + {data.tiers.map((tier) => { const binding = viewed.tiers[tier.id]; const provider = data.providers.find( @@ -262,12 +273,12 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { ); const roles = data.roles.filter((role) => role.tier === tier.id); return ( - - - {copy.tier(tier.label)} - {copy.usedBy(roles.map((role) => role.label))} - - + role.label))} + > + {copy.binding( model?.label ?? binding.model, data.providers.length > 1 @@ -275,11 +286,11 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) { : null, effort?.label ?? binding.effort, )} - - + + ); })} - + ) : null} {editing ? ( @@ -393,9 +404,9 @@ function ProfileEditor({ {copy.editorLead} - + {editing.mode === "new" ? ( - + {copy.startFrom} @@ -420,9 +431,9 @@ function ProfileEditor({ ))} - + ) : null} - + {copy.name} @@ -435,7 +446,7 @@ function ProfileEditor({ setDraft({ ...draft, label: event.target.value }) } /> - + {catalog.tiers.map((tier) => { const binding = draft.tiers[tier.id] ?? {}; const provider = catalog.providers.find( @@ -443,11 +454,7 @@ function ProfileEditor({ ); const providers = editorProviders(catalog, binding.provider); return ( - + {copy.tier(tier.label)} + {copy.replaceDefault(replacing)} @@ -535,9 +542,9 @@ function ProfileEditor({ disabled={busy} onCheckedChange={setMakeDefault} /> - + ) : null} - + {wording.action.cancel} diff --git a/apps/web/src/pages/settings/provider-form.module.css b/apps/web/src/pages/settings/provider-form.module.css new file mode 100644 index 00000000..8078cbb0 --- /dev/null +++ b/apps/web/src/pages/settings/provider-form.module.css @@ -0,0 +1,17 @@ +.form { + display: flex; + flex-direction: column; + gap: var(--space-4); + margin: -4px; + padding: 4px; +} + +/* Settings rows in a dialog: the body's own gap would double the rows' spacing, and rows sit a + little closer so a whole form fits without scrolling. */ +.form[data-rows] { + gap: 0; +} + +.form[data-rows] > [data-slot="settings-row"] { + padding: var(--space-2) 0; +} diff --git a/apps/web/src/pages/settings/provider-form.tsx b/apps/web/src/pages/settings/provider-form.tsx new file mode 100644 index 00000000..44c2b650 --- /dev/null +++ b/apps/web/src/pages/settings/provider-form.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; +import { DialogBody } from "@corbits/react-ui/ui/dialog"; +import styles from "./provider-form.module.css"; + +/** The body of a provider or profile dialog: its fields stacked, or laid out as Settings rows. */ +export function ProviderFormBody({ + rows = false, + children, +}: { + rows?: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/pages/settings/provider-logos.stories.tsx b/apps/web/src/pages/settings/provider-logos.stories.tsx new file mode 100644 index 00000000..3dc2caf0 --- /dev/null +++ b/apps/web/src/pages/settings/provider-logos.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ProviderLogo } from "./provider-logos.tsx"; + +const PROVIDERS = ["openai", "anthropic", "google", "xai", "custom"] as const; + +const meta = { + title: "Components/ProviderLogo", + component: ProviderLogo, + args: { providerId: "anthropic" }, + argTypes: { providerId: { control: "select", options: PROVIDERS } }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Logo: Story = {}; + +/** A provider without a mark gets a neutral plug. */ +export const All: Story = { + decorators: [], + render: () => ( + + {PROVIDERS.map((id) => ( + + + + ))} + + ), +}; diff --git a/apps/web/src/pages/provider-logos.tsx b/apps/web/src/pages/settings/provider-logos.tsx similarity index 93% rename from apps/web/src/pages/provider-logos.tsx rename to apps/web/src/pages/settings/provider-logos.tsx index 7ef827af..921b658e 100644 --- a/apps/web/src/pages/provider-logos.tsx +++ b/apps/web/src/pages/settings/provider-logos.tsx @@ -26,9 +26,14 @@ const MARKS: Readonly> = { export function ProviderLogo({ providerId }: { providerId: string }) { const mark = MARKS[providerId]; - if (!mark) return ; + if (!mark) return ; return ( - + ); diff --git a/apps/web/src/pages/providers.test.ts b/apps/web/src/pages/settings/providers.test.ts similarity index 87% rename from apps/web/src/pages/providers.test.ts rename to apps/web/src/pages/settings/providers.test.ts index d4352f6e..3c09037d 100644 --- a/apps/web/src/pages/providers.test.ts +++ b/apps/web/src/pages/settings/providers.test.ts @@ -1,13 +1,13 @@ import { afterAll, expect, mock, test } from "bun:test"; import { environmentManager } from "@tanstack/react-query"; import type { ModelLoginState } from "@corbits-builder/client"; -import * as actualTransport from "../api/transport.ts"; -import { wording } from "../config.ts"; +import * as actualTransport from "../../api/transport.ts"; +import { wording } from "../../config.ts"; const realTransport = { ...actualTransport }; let answers: ModelLoginState[] = []; -mock.module("../api/transport.ts", () => ({ +mock.module("../../api/transport.ts", () => ({ ...realTransport, productClient: () => ({ modelLogin: async () => answers.shift() ?? { status: "pending" }, @@ -18,7 +18,7 @@ const wasServer = environmentManager.isServer(); environmentManager.setIsServer(() => false); afterAll(() => { environmentManager.setIsServer(() => wasServer); - mock.module("../api/transport.ts", () => realTransport); + mock.module("../../api/transport.ts", () => realTransport); }); const { loginCredential } = await import("./providers.tsx"); diff --git a/apps/web/src/pages/providers.tsx b/apps/web/src/pages/settings/providers.tsx similarity index 91% rename from apps/web/src/pages/providers.tsx rename to apps/web/src/pages/settings/providers.tsx index 1efef7ad..3b246592 100644 --- a/apps/web/src/pages/providers.tsx +++ b/apps/web/src/pages/settings/providers.tsx @@ -9,7 +9,7 @@ * sits in the same list (custom-providers.tsx). Keys live in form state until they are handed * over, and nothing here renders a key or token back. */ -import { wording } from "../config.ts"; +import { wording } from "../../config.ts"; import { EmptyState, Input, Section } from "@corbits/react-ui"; import { DialogFieldLabel } from "@corbits/react-ui/ui/dialog"; import { QueryObserver, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -21,12 +21,12 @@ import { type ProviderStatus, } from "@corbits-builder/schemas/models"; import { type } from "arktype"; -import { Banner, Button, QuietState } from "../components.jsx"; -import { queryClient } from "../queries/client.ts"; -import { keys } from "../queries/keys.ts"; -import { productClient } from "../api/transport.ts"; -import { workspaceTenantId } from "../api/auth.ts"; -import { setCredential } from "../api/credentials.ts"; +import { Banner, Button, Field, QuietState } from "../../components.jsx"; +import { queryClient } from "../../queries/client.ts"; +import { keys } from "../../queries/keys.ts"; +import { productClient } from "../../api/transport.ts"; +import { workspaceTenantId } from "../../api/auth.ts"; +import { setCredential } from "../../api/credentials.ts"; import { CustomProviderRow, ProviderDialog, @@ -34,6 +34,8 @@ import { message, useCustomProviderForm, } from "./custom-providers.tsx"; +import { SettingsRow } from "./settings-row.tsx"; +import { SettingsSectionBody } from "./settings-section.tsx"; const copy = wording.providers; @@ -152,11 +154,11 @@ export function SubscriptionLogins({ (connection) => connection.status === "connected", ); return ( - - - {copy.login(provider.label)} - {live ? copy.status.connected : copy.notConnected} - + - + ); })} > @@ -214,20 +216,20 @@ export function ProvidersSection({ onChanged }: { onChanged: () => void }) { ); } return ( - - - {connection.name} - {connection.name === provider.label ? null : ( - {provider.label} - )} - {connection.note ? {connection.note} : null} - + {connection.status ? ( {copy.status[connection.status]} ) : null} - + ); })} > @@ -243,7 +245,7 @@ export function ProvidersSection({ onChanged }: { onChanged: () => void }) { description={copy.lead} action={hasLogins ? undefined : addAction} > - + {catalog.error ? ( ) : null} @@ -255,7 +257,7 @@ export function ProvidersSection({ onChanged }: { onChanged: () => void }) { ) : ( apiKeys )} - + ) : null} {providerId || custom ? null : ( - + {copy.provider} @@ -427,12 +429,12 @@ export function AddProviderDialog({ setChosenId(id); }} /> - + )} {custom ? ( customForm.rows(busy) ) : ( - + {copy.key} setSecret(event.target.value)} /> - + )} ); diff --git a/apps/web/src/pages/settings/settings-row.module.css b/apps/web/src/pages/settings/settings-row.module.css new file mode 100644 index 00000000..72e79ef3 --- /dev/null +++ b/apps/web/src/pages/settings/settings-row.module.css @@ -0,0 +1,97 @@ +/* Rows sit on the page rather than in bordered cards; space separates them, not rules. */ +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-3) 0; + font-size: 14px; + transition: background-color 300ms ease; +} + +.row[data-highlighted] { + margin: 0 calc(var(--space-2) * -1); + padding-left: var(--space-2); + padding-right: var(--space-2); + border-radius: var(--radius-sm, 0.25rem); + background: var(--surface-hover, rgba(233, 132, 40, 0.08)); +} + +.key { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.label { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-weight: 600; + font-size: 13.5px; +} + +.hint { + font-size: 12px; + color: var(--muted-foreground); + font-family: var(--font-mono); +} + +.value { + font-family: var(--font-mono); + font-size: 12px; + color: var(--muted-foreground); + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: var(--space-3); + flex-shrink: 0; + max-width: min(420px, 100%); +} + +.value a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} + +.input { + display: block; + font-family: var(--font-mono); + font-size: 12.5px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--background); + color: var(--foreground); + padding: 6px 10px; + width: 220px; + max-width: 100%; + outline: none; +} + +.input:focus { + border-color: var(--primary); +} + +.input[data-size="token"] { + width: 104px; +} + +/* At phone width a row's controls drop under its label instead of squeezing + it to one word per line. */ +@media (max-width: 560px) { + .row { + flex-wrap: wrap; + } + + .value { + flex-shrink: 1; + justify-content: flex-start; + } + + .input[data-size="token"] { + width: 88px; + } +} diff --git a/apps/web/src/pages/settings/settings-row.stories.tsx b/apps/web/src/pages/settings/settings-row.stories.tsx new file mode 100644 index 00000000..9cb0a419 --- /dev/null +++ b/apps/web/src/pages/settings/settings-row.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "../../components.tsx"; +import { + SettingsInput, + SettingsRow, + SettingsRowValue, +} from "./settings-row.tsx"; +import { SettingsSectionBody, SettingsSubhead } from "./settings-section.tsx"; + +const meta = { + title: "Components/Settings/SettingsRow", + component: SettingsRow, + args: { + label: "Theme", + hint: "How Builder looks on this device.", + children: , + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Row: Story = {}; + +/** A row the person was sent to, marked until the highlight fades. */ +export const Highlighted: Story = { args: { highlighted: true } }; + +/** A section's rows with a subhead, a lead, a value column and a bare action row. */ +export const Section: Story = { + render: () => ( + + Open source + Builder is made with these projects. + + + MIT License + + + + + + + + + + Sign out + + + ), +}; diff --git a/apps/web/src/pages/settings/settings-row.test.tsx b/apps/web/src/pages/settings/settings-row.test.tsx new file mode 100644 index 00000000..4eef9eeb --- /dev/null +++ b/apps/web/src/pages/settings/settings-row.test.tsx @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { SettingsRow } from "./settings-row.tsx"; + +test("a row names its control in its key, with one hint line per entry", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toMatch( + /data-slot="settings-row-key">]*>Theme<\/span><\/label>/, + ); + expect(html.match(/>Light or dark<|>On this device { + const html = renderToStaticMarkup( + + Sign out + , + ); + + expect(html).toContain('data-slot="settings-row" data-highlighted=""'); + expect(html).not.toContain('data-slot="settings-row-key"'); +}); diff --git a/apps/web/src/pages/settings/settings-row.tsx b/apps/web/src/pages/settings/settings-row.tsx new file mode 100644 index 00000000..590101cd --- /dev/null +++ b/apps/web/src/pages/settings/settings-row.tsx @@ -0,0 +1,94 @@ +import type { ComponentProps, ReactNode } from "react"; +import styles from "./settings-row.module.css"; + +/** A row's left column: its name in bold, then its hint, or one line per hint in a list. */ +function SettingsRowKey({ + label, + hint, + htmlFor, +}: { + label?: ReactNode; + hint?: ReactNode; + htmlFor?: string | undefined; +}) { + const hints: readonly ReactNode[] = Array.isArray(hint) ? hint : [hint]; + let name: ReactNode = null; + if (label !== undefined) name = {label}; + if (label !== undefined && htmlFor !== undefined) + name = {name}; + return ( + + {name} + {hints.map((line, index) => + line === undefined || line === null || line === "" ? null : ( + + {line} + + ), + )} + + ); +} + +/** A row's right column, when it holds more than a single control. */ +export function SettingsRowValue({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * One setting: its name and hint on the left, its control on the right. A row without a label + * or hint is a bare action row. Code finds a row by its `data-slot`, and a highlighted one by + * `data-highlighted`. + */ +export function SettingsRow({ + id, + highlighted = false, + label, + hint, + htmlFor, + children, +}: { + id?: string; + highlighted?: boolean; + label?: ReactNode; + /** Muted lines under the label; a list puts each on its own line. */ + hint?: ReactNode; + /** The control the label names, when the row holds one. */ + htmlFor?: string; + children?: ReactNode; +}) { + return ( + + {label === undefined && hint === undefined ? null : ( + + )} + {children} + + ); +} + +/** The text input of a Settings row; `token` is the narrow one a model's columns use. */ +export function SettingsInput({ + size, + ...props +}: Omit, "className" | "size"> & { + size?: "token"; +}) { + return ( + + ); +} diff --git a/apps/web/src/pages/settings/settings-section.module.css b/apps/web/src/pages/settings/settings-section.module.css new file mode 100644 index 00000000..8a6bbc4e --- /dev/null +++ b/apps/web/src/pages/settings/settings-section.module.css @@ -0,0 +1,22 @@ +.body { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.body > [data-slot="banner"], +.body > [data-slot="inline-note"], +.body > p { + margin: var(--space-2) 0; +} + +.body > p { + font-size: 13px; + color: var(--muted-foreground); +} + +.subhead { + margin: var(--space-2) 0 0; + font-size: 13.5px; + font-weight: 600; +} diff --git a/apps/web/src/pages/settings/settings-section.tsx b/apps/web/src/pages/settings/settings-section.tsx new file mode 100644 index 00000000..88820e13 --- /dev/null +++ b/apps/web/src/pages/settings/settings-section.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; +import styles from "./settings-section.module.css"; + +/** A section's rows, with the banners, notes and paragraphs between them. */ +export function SettingsSectionBody({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** A heading for a group of rows inside a section. */ +export function SettingsSubhead({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/pages/settings/settings.module.css b/apps/web/src/pages/settings/settings.module.css new file mode 100644 index 00000000..3732598e --- /dev/null +++ b/apps/web/src/pages/settings/settings.module.css @@ -0,0 +1,46 @@ +.page { + max-width: 720px; + width: 100%; + min-width: 0; + margin: 0 auto; + padding: var(--space-8) var(--space-6) var(--space-12); + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.page > h1 { + margin: 0; + font-size: 22px; + font-weight: 700; +} + +.tabs { + gap: var(--space-8); +} + +.tabs > [role="tablist"] { + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: none; + border-bottom-color: color-mix(in srgb, var(--border) 60%, transparent); +} + +.tabs [role="tab"] { + flex: none; + white-space: nowrap; +} + +.tabs > [role="tabpanel"] { + display: flex; + flex-direction: column; + gap: var(--space-12); + outline: none; +} + +/* At phone width the canvas's own padding is the gutter. */ +@media (max-width: 560px) { + .page { + padding: var(--space-4) 0 var(--space-12); + } +} diff --git a/apps/web/src/pages/settings.tsx b/apps/web/src/pages/settings/settings.tsx similarity index 63% rename from apps/web/src/pages/settings.tsx rename to apps/web/src/pages/settings/settings.tsx index ed093ac8..ddfe3bec 100644 --- a/apps/web/src/pages/settings.tsx +++ b/apps/web/src/pages/settings/settings.tsx @@ -7,53 +7,49 @@ * * They are grouped into tabs; the open tab is `?tab=` beside `?view=settings`, * so each one can be linked. Each section is the library's `Section`; its - * rows keep the mockup's row / k / v language. + * rows are `SettingsRow`s. */ -import { wording } from "../config.ts"; +import { wording } from "../../config.ts"; import { - Button as UiButton, Section, Switch, Tabs, useTheme, type ThemeMode, } from "@corbits/react-ui"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; -import { keys } from "../queries/keys.ts"; +import { keys } from "../../queries/keys.ts"; import { ProvidersSection } from "./providers.jsx"; import { Profiles } from "./profiles.jsx"; import { useDocumentTips, writeDocumentTips, -} from "../document-tips-setting.ts"; +} from "../../document-tips-setting.ts"; import { useQuestionsLayout, writeQuestionsLayout, -} from "../questions-layout-setting.ts"; +} from "../../questions-layout-setting.ts"; import { - addCustomReviewerPersona, - addWorkspaceReviewer, - allReviewerPersonas, - removeWorkspaceReviewer, - useCustomReviewerPersonas, useWorkspaceApprovalsEnabled, - useWorkspaceReviewers, writeWorkspaceApprovalsEnabled, type OnOff, -} from "../workspace-approvals-setting.ts"; +} from "../../workspace-approvals-setting.ts"; +import { SettingsRow, SettingsRowValue } from "./settings-row.tsx"; +import { SettingsSectionBody, SettingsSubhead } from "./settings-section.tsx"; +import { WorkspaceApprovals } from "./workspace-approvals.tsx"; import { Collaborators } from "./collaborators.jsx"; -import { Banner, Button, QuietSelect, QuietState } from "../components.jsx"; -import { productClient } from "../api/transport.ts"; -import { workspaceTenantId, type HubUser } from "../api/auth.ts"; -import { openSourceNotices } from "../open-source.ts"; -import { Credit } from "../flourish.tsx"; +import { Banner, Button, QuietSelect, QuietState } from "../../components.jsx"; +import { productClient } from "../../api/transport.ts"; +import { workspaceTenantId, type HubUser } from "../../api/auth.ts"; +import { openSourceNotices } from "../../open-source.ts"; +import { Credit } from "../../flourish.tsx"; import type { PreferenceRegistry, Preferences, } from "@corbits-builder/schemas/preferences"; -import "./settings-layout.css"; +import styles from "./settings.module.css"; export type SettingsTab = keyof typeof wording.settings.tab; @@ -195,14 +191,14 @@ export function Settings({ ); }; return ( - + {wording.settings.title} {renderTabContent} @@ -210,238 +206,6 @@ export function Settings({ ); } -function Row({ - id, - highlighted = false, - label, - hint, - htmlFor, - children, -}: { - id?: string; - highlighted?: boolean; - label: string; - hint?: string; - /** The control the label names, when the row holds one. */ - htmlFor?: string; - children: ReactNode; -}) { - return ( - - - {htmlFor ? ( - - {label} - - ) : ( - {label} - )} - {hint ? {hint} : null} - - {children} - - ); -} - -const OTHER_PERSONA_OPTION = "__other__"; - -/** Workspace-level approval toggle and saved workspace reviewers. */ -export function WorkspaceApprovals() { - const approvalsEnabled = useWorkspaceApprovalsEnabled(); - const reviewers = useWorkspaceReviewers(); - const customPersonas = useCustomReviewerPersonas(); - const personaOptions = allReviewerPersonas(customPersonas, reviewers); - const [name, setName] = useState(""); - const [persona, setPersona] = useState( - wording.approvalRound.reviewerPersonas[0], - ); - const [customPersonaOpen, setCustomPersonaOpen] = useState(false); - const [customPersonaDraft, setCustomPersonaDraft] = useState(""); - const [email, setEmail] = useState(""); - const [goal, setGoal] = useState(""); - const copy = wording.settings.approvals; - const roundCopy = wording.approvalRound; - - const effectivePersona = customPersonaOpen - ? customPersonaDraft.trim() - : persona.trim(); - const canAdd = name.trim().length > 0 && effectivePersona.length > 0; - - const handleSelectPersona = (value: string) => { - if (value === OTHER_PERSONA_OPTION) { - setCustomPersonaOpen(true); - setCustomPersonaDraft(""); - return; - } - setCustomPersonaOpen(false); - setPersona(value); - }; - - const handleAddReviewer = () => { - if (!canAdd) return; - if (customPersonaOpen) { - addCustomReviewerPersona(effectivePersona); - } - const trimmedEmail = email.trim(); - const trimmedGoal = goal.trim(); - addWorkspaceReviewer({ - name: name.trim(), - persona: effectivePersona, - ...(trimmedEmail.length > 0 ? { email: trimmedEmail } : {}), - ...(trimmedGoal.length > 0 ? { goal: trimmedGoal } : {}), - }); - setName(""); - setEmail(""); - setGoal(""); - if (customPersonaOpen) { - setPersona(effectivePersona); - setCustomPersonaOpen(false); - setCustomPersonaDraft(""); - } - }; - - return ( - - - - - writeWorkspaceApprovalsEnabled(on ? "on" : "off") - } - /> - - {copy.reviewersSubhead} - {copy.reviewersLead} - {reviewers.length === 0 ? ( - {copy.emptyReviewers} - ) : ( - reviewers.map((reviewer) => { - const hintParts = [reviewer.goal, reviewer.email].filter( - (part): part is string => part !== undefined && part !== "", - ); - return ( - - - - {reviewer.persona} - - removeWorkspaceReviewer(reviewer.id)} - > - {copy.removeReviewer} - - - - ); - }) - )} - - - - {roundCopy.fieldLabels.name} - - setName(event.target.value)} - /> - - - - {roundCopy.fieldLabels.role} - - handleSelectPersona(event.target.value)} - > - {personaOptions.map((option) => ( - - {option} - - ))} - - {roundCopy.otherPersona} - - - - {customPersonaOpen ? ( - - - {roundCopy.customPersonaLabel} - - setCustomPersonaDraft(event.target.value)} - /> - - ) : null} - - - {roundCopy.fieldLabels.focus} - - setGoal(event.target.value)} - /> - - - - {wording.settings.account.email} - - setEmail(event.target.value)} - /> - - - - {copy.addReviewer} - - - - - - ); -} - /** The theme, and the document tips and questions layout choices kept in this browser. */ function Appearance() { const { mode, setMode } = useTheme(); @@ -450,8 +214,8 @@ function Appearance() { const copy = wording.settings.appearance; return ( - - + + ))} - - + + writeDocumentTips(on ? "on" : "off")} /> - - + @@ -480,8 +247,8 @@ function Appearance() { checked={questionsLayout === "steps"} onCheckedChange={(on) => writeQuestionsLayout(on ? "steps" : "all")} /> - - + + ); } @@ -573,9 +340,11 @@ export function WorkingPreferences({ ); const rowEl = document.getElementById(`preference-row-${focusPreference}`) ?? - (controlEl?.closest(".row") as HTMLElement | null); + (controlEl?.closest( + '[data-slot="settings-row"]', + ) as HTMLElement | null); if (rowEl) { - rowEl.setAttribute("data-highlighted", "true"); + rowEl.setAttribute("data-highlighted", ""); if (typeof rowEl.scrollIntoView === "function") { rowEl.scrollIntoView({ behavior: "smooth", block: "center" }); } @@ -601,7 +370,9 @@ export function WorkingPreferences({ ); const rowEl = document.getElementById(`preference-row-${focusPreference}`) ?? - (controlEl?.closest(".row") as HTMLElement | null); + (controlEl?.closest( + '[data-slot="settings-row"]', + ) as HTMLElement | null); rowEl?.removeAttribute("data-highlighted"); }, 2_000); @@ -687,7 +458,7 @@ export function WorkingPreferences({ const preferences = mergeExperimentalPreferences(registry); return ( - + {preferences.map((preference) => { const id = `preference-${preference.id}`; const isExperimental = preference.id === "experimental_approvals"; @@ -699,7 +470,7 @@ export function WorkingPreferences({ const state = save?.id === preference.id ? save.state : null; const disabled = (!field && !isExperimental) || state === "saving"; return ( - - + {state ? ( {copy[state]} @@ -738,12 +509,12 @@ export function WorkingPreferences({ ))} )} - - + + ); })} {error ? : null} - + ); } @@ -769,15 +540,15 @@ function Account({ const copy = wording.settings.account; return ( - - - {user.name} - - - {user.email} - + + + {user.name} + + + {user.email} + {onSignOut ? ( - + {wording.action.signOut} - + ) : null} - + ); } @@ -796,20 +567,20 @@ function About() { const copy = wording.settings.about; return ( - - {copy.openSource} - {copy.openSourceLead} + + {copy.openSource} + {copy.openSourceLead} {openSourceNotices.map((notice) => ( - - + + {copy.kind[notice.kind]} {notice.license ?? copy.unconfirmed} - - + + ))} - + ); diff --git a/apps/web/src/pages/settings/workspace-approvals.module.css b/apps/web/src/pages/settings/workspace-approvals.module.css new file mode 100644 index 00000000..bccd7358 --- /dev/null +++ b/apps/web/src/pages/settings/workspace-approvals.module.css @@ -0,0 +1,5 @@ +.formActions { + display: flex; + justify-content: flex-end; + padding-top: 0.25rem; +} diff --git a/apps/web/src/pages/settings/workspace-approvals.tsx b/apps/web/src/pages/settings/workspace-approvals.tsx new file mode 100644 index 00000000..d75010ba --- /dev/null +++ b/apps/web/src/pages/settings/workspace-approvals.tsx @@ -0,0 +1,189 @@ +import { Section, Switch } from "@corbits/react-ui"; +import { useState } from "react"; +import { InlineNote } from "../../components.tsx"; +import { wording } from "../../config.ts"; +import { + addCustomReviewerPersona, + addWorkspaceReviewer, + allReviewerPersonas, + removeWorkspaceReviewer, + useCustomReviewerPersonas, + useWorkspaceApprovalsEnabled, + useWorkspaceReviewers, + writeWorkspaceApprovalsEnabled, +} from "../../workspace-approvals-setting.ts"; +import { SettingsRow, SettingsRowValue } from "./settings-row.tsx"; +import { SettingsSectionBody, SettingsSubhead } from "./settings-section.tsx"; +import { + ApprovalField, + ApprovalFields, + FieldSaveButton, + LeanButton, + LeanInput, + LeanSelect, +} from "../approval-fields.tsx"; +import styles from "./workspace-approvals.module.css"; + +const OTHER_PERSONA_OPTION = "__other__"; + +/** Workspace-level approval toggle and saved workspace reviewers. */ +export function WorkspaceApprovals() { + const approvalsEnabled = useWorkspaceApprovalsEnabled(); + const reviewers = useWorkspaceReviewers(); + const customPersonas = useCustomReviewerPersonas(); + const personaOptions = allReviewerPersonas(customPersonas, reviewers); + const [name, setName] = useState(""); + const [persona, setPersona] = useState( + wording.approvalRound.reviewerPersonas[0], + ); + const [customPersonaOpen, setCustomPersonaOpen] = useState(false); + const [customPersonaDraft, setCustomPersonaDraft] = useState(""); + const [email, setEmail] = useState(""); + const [goal, setGoal] = useState(""); + const copy = wording.settings.approvals; + const roundCopy = wording.approvalRound; + + const effectivePersona = customPersonaOpen + ? customPersonaDraft.trim() + : persona.trim(); + const canAdd = name.trim().length > 0 && effectivePersona.length > 0; + + const handleSelectPersona = (value: string) => { + if (value === OTHER_PERSONA_OPTION) { + setCustomPersonaOpen(true); + setCustomPersonaDraft(""); + return; + } + setCustomPersonaOpen(false); + setPersona(value); + }; + + const handleAddReviewer = () => { + if (!canAdd) return; + if (customPersonaOpen) { + addCustomReviewerPersona(effectivePersona); + } + const trimmedEmail = email.trim(); + const trimmedGoal = goal.trim(); + addWorkspaceReviewer({ + name: name.trim(), + persona: effectivePersona, + ...(trimmedEmail.length > 0 ? { email: trimmedEmail } : {}), + ...(trimmedGoal.length > 0 ? { goal: trimmedGoal } : {}), + }); + setName(""); + setEmail(""); + setGoal(""); + if (customPersonaOpen) { + setPersona(effectivePersona); + setCustomPersonaOpen(false); + setCustomPersonaDraft(""); + } + }; + + return ( + + + + + writeWorkspaceApprovalsEnabled(on ? "on" : "off") + } + /> + + {copy.reviewersSubhead} + {copy.reviewersLead} + {reviewers.length === 0 ? ( + {copy.emptyReviewers} + ) : ( + reviewers.map((reviewer) => { + const hintParts = [reviewer.goal, reviewer.email].filter( + (part): part is string => part !== undefined && part !== "", + ); + return ( + + + {reviewer.persona} + removeWorkspaceReviewer(reviewer.id)} + > + {copy.removeReviewer} + + + + ); + }) + )} + + + setName(event.target.value)} + /> + + + handleSelectPersona(event.target.value)} + > + {personaOptions.map((option) => ( + + {option} + + ))} + + {roundCopy.otherPersona} + + + + {customPersonaOpen ? ( + + setCustomPersonaDraft(event.target.value)} + /> + + ) : null} + + setGoal(event.target.value)} + /> + + + setEmail(event.target.value)} + /> + + + + {copy.addReviewer} + + + + + + ); +} diff --git a/apps/web/src/pages/workspace-layout.css b/apps/web/src/pages/workspace-layout.css deleted file mode 100644 index 793c0be6..00000000 --- a/apps/web/src/pages/workspace-layout.css +++ /dev/null @@ -1,625 +0,0 @@ -/** - * Project chrome matching mockups/project.html. - * - * Scoped to the two-pane workspace so home, onboarding and settings keep - * their own sheets. - */ - -/* ---- two panes: proportional conversation + draft ---- */ - -.panes { - flex: 1; - min-height: 0; -} - -.canvas-body.is-fill .panes { - flex: 1; - min-height: 0; -} - -/* Chat-first: before a stage has a drafted artifact, the conversation is - the whole page — a single centered column, generous margins either side, - the composer at its bottom. The split earns its keep only once there is - something to split against. */ -.panes.chat-first { - display: flex; - flex-direction: column; - min-height: 0; - width: 100%; - max-width: 720px; - margin: 0 auto; -} - -/* The column runs the full height, so the composer sits at the window's - foot with the thread above it, not just under the last message. */ -.panes.chat-first .conv { - flex: 1; - width: 100%; - border-right: 0; -} - -/* The composer arrives once there is something it could send: it rises - into place rather than appearing. */ -.panes.chat-first .composer { - animation: composer-in 280ms ease-out backwards; -} - -@keyframes composer-in { - from { - opacity: 0; - transform: translateY(16px); - } -} - -@media (prefers-reduced-motion: reduce) { - .panes.chat-first .composer { - animation: none; - } -} - -/* Chat-first, before the first message: center the composer (and the empty - note) vertically in the column, like a fresh page, rather than pinning it - to the bottom below mostly empty space. Once a message lands, `.conv-scroll` - goes back to filling the column and the composer settles to the bottom as - the thread grows -- the normal, already-existing layout. */ -.panes.chat-first - .stage-conversation:has(> .conv-scroll > .inline-note:only-child) { - flex: 1; - justify-content: center; -} - -.panes.chat-first - .stage-conversation:has(> .conv-scroll > .inline-note:only-child) - .conv-scroll { - flex: 0 1 auto; - justify-content: center; - text-align: center; -} - -.conv { - position: relative; - border-right: 1px solid var(--wb-border); - display: flex; - flex-direction: column; - min-height: 0; - background: var(--wb-background); -} - -.conv .stage-conversation { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; -} - -.room { - display: flex; - flex: 1 1 auto; - min-height: 0; - min-width: 0; -} - -.room > .panes { - flex: 1; - min-width: 0; -} - -.room .room-thread { - display: flex; - flex-direction: column; - flex: 1 1 auto; - min-height: 0; - min-width: 0; -} - -.room-mail-names { - display: block; - font-weight: 500; - color: var(--wb-foreground); -} - -/* The row's summary uses the column's width and wraps to a second line before it cuts off. */ -.room-mail-summary { - display: -webkit-box; - overflow: hidden; - white-space: normal; - color: var(--wb-muted-foreground); - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-clamp: 2; -} - -.room-mail-row { - align-items: flex-start; -} - -.room-mail-fold > summary { - cursor: pointer; - padding: var(--space-1); - font-size: 0.875rem; - color: var(--wb-muted-foreground); -} - -.room-mail-turn + .room-mail-turn { - margin-top: var(--space-3); -} - -.artifact-mention { - color: var(--wb-foreground); - text-decoration: underline; - text-underline-offset: 0.15em; -} - -.artifact-mention:focus-visible { - outline: 2px solid var(--wb-foreground); - outline-offset: 2px; -} - -/* The library's ask wears an accent wash; here it is a plain card like the conversation's others. */ -.document-decision [data-slot="tool-approval-card"] { - max-width: 36rem; - min-width: 0; - overflow-wrap: anywhere; - border-color: var(--wb-border); - background: var(--wb-background); -} - -.document-decision [data-slot="tool-approval-card"] > header > svg { - color: var(--wb-muted-foreground); -} - -.document-decision-what { - margin: 0; -} - -/* The library card always renders Approve always as its second action. - Document cards and the design card do not offer that standing grant. */ -.document-decision-once - [data-slot="tool-approval-card"] - > div:last-child - > button:nth-child(2) { - display: none; -} - -/* Not yet is sm:ms-auto, and every action is shrink-0 nowrap. A long middle - label fills the row, Not yet wraps, and the auto margin pins it to the - right edge, so Not yet drops that margin. A label wider than the card - wraps between words inside its own button; the card's overflow-wrap - anywhere would otherwise break it a letter at a time. The design card - sizes its actions on their own. */ -.document-decision:not(.document-decision-design) - [data-slot="tool-approval-card"] - > div:last-child - > button { - flex-shrink: 1; - min-width: 0; - height: auto; - min-height: 2rem; - white-space: normal; - overflow-wrap: normal; - word-break: normal; -} - -.document-decision:not(.document-decision-design) - [data-slot="tool-approval-card"] - > div:last-child - > button:last-child { - flex-shrink: 0; - margin-inline-start: 0; -} - -/* Design approval: the two actions share one line and one height, and Not - yet is not pinned to the right edge. Each starts at its label's width and - shares the spare room, so a label wraps between words only once the line - is too narrow for it; the button centres its text, so clipping it would - cut both ends. */ -.document-decision-design [data-slot="tool-approval-card"] > div:last-child { - flex-wrap: nowrap; - align-items: stretch; -} - -.document-decision-design - [data-slot="tool-approval-card"] - > div:last-child - > button { - flex: 1 1 auto; - margin-inline-start: 0; - height: auto; - min-height: 2rem; - white-space: normal; - overflow-wrap: normal; - word-break: normal; - text-align: center; -} - -/* The library keeps a settled line's words from shrinking, so a long document title ran past the - column; it ellipsizes here, and the full line is the card's tooltip. */ -.document-decision > p > span { - flex-shrink: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -/* A long document title wraps inside the card instead of running past its edge. */ -.document-decision-show { - align-self: flex-start; - max-width: 100%; - height: auto; - padding: 0; - white-space: normal; - text-align: start; - overflow-wrap: anywhere; -} - -.document-decision-sources { - display: flex; - flex-direction: column; - gap: var(--space-1); - margin: 0; - padding: 0; - list-style: none; -} - -.document-decision-concepts { - width: 100%; - margin: 0; - border-collapse: collapse; -} - -.document-decision-concepts th, -.document-decision-concepts td { - padding: var(--space-1) var(--space-3) var(--space-1) 0; - text-align: start; - vertical-align: baseline; - font-weight: 400; -} - -.document-decision-concepts thead th { - font-weight: 600; - color: var(--wb-muted-foreground); -} - -.document-decision-concepts tbody td:first-child { - font-weight: 600; -} - -.design-choice { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--space-2); - margin: 0; -} - -.design-choice > p { - margin: 0; -} - -.design-choice-options { - display: flex; - flex-direction: column; - gap: var(--space-1); - margin: 0; - padding: 0; - list-style: none; -} - -.design-choice-options li { - display: flex; - align-items: center; - gap: var(--space-2); -} - -.document-decision-details summary { - color: var(--wb-muted-foreground); - font-size: 0.75rem; - cursor: pointer; -} - -/* An opened list of many versions scrolls inside the card, so the decision stays in view. */ -.document-decision-details .document-decision-sources { - max-height: 16rem; - margin-top: var(--space-1); - overflow-y: auto; -} - -.document-decision-details p { - margin: var(--space-1) 0 0; - font-size: 0.75rem; -} - -.document-decision-next { - margin: 0; - color: var(--wb-muted-foreground); - font-size: 0.75rem; -} - -/* The log scrolls inside the room. flex-shrink 0 made its height follow the - settle spacer, and the spacer then grew from that new height. */ -.room .conv-scroll { - flex: 1 1 auto; - min-height: 0; -} - -.deliverable-body { - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.conv-scroll { - flex: 1; - overflow-y: auto; - /* Never sideways (#321): `overflow-y: auto` alone makes the x axis auto - too, so one child wider than the column let a trackpad gesture carry - the whole log to the right and hide the start of every message. */ - overflow-x: hidden; - padding: var(--space-5); - display: flex; - flex-direction: column; - gap: var(--space-4); -} - -/* Each turn is held to the column; whatever is wide inside it scrolls or - clips within its own box. */ -.conv-scroll > * { - min-width: 0; - max-width: 100%; -} - -.pane-switch { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - -.pane-switch { - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--wb-border); -} - -.pane-switch button[aria-pressed="true"] { - border-color: var(--wb-primary); - color: var(--wb-primary); -} - -.document-read { - padding: var(--space-5); -} - -.document-raw, -.diff-lines { - font-family: var(--wb-font-mono); - font-size: 12px; - white-space: pre-wrap; - margin: 0; -} - -.gui-frame { - flex: 1 1 auto; - width: 100%; - min-height: 480px; - border: 0; - background: #fff; -} - -/* ---- composer: one floating line that grows as the person types ---- */ - -.composer { - border-top: 0; - padding: var(--space-2) var(--space-4) var(--space-4); - position: relative; - display: flex; - flex-direction: column; - flex-shrink: 0; - min-height: 0; -} - -/* The composer keeps a reading measure in a wide pane. */ -.stage-conversation > .composer { - box-sizing: border-box; - width: 100%; - max-width: 46rem; - margin-inline: auto; -} - -/* The budget row stays above the box, and it scrolls inside its own card. - Uncapped, the disclosure is taller than the window and the message box - and the log both leave the screen. */ -.composer > .banner { - max-height: 8.5rem; - overflow-y: auto; -} - -.composer > .composer-box { - flex-shrink: 0; -} - -/* Text, microphone and send share one row; the text grows upward from one line. */ -.composer .composer-box { - display: flex; - flex-flow: row nowrap; - align-items: flex-end; - gap: var(--space-1); - min-width: 0; - border: 1px solid var(--wb-border); - border-radius: var(--radius-lg); - background: var(--wb-card); - padding: 6px 6px 6px var(--space-2); - transition: border-color 150ms; -} - -.composer .composer-box:focus-within { - outline: none; - border-color: var(--wb-border-strong); -} - -/* Metrics on the field, padding on textarea and mirror alike: see home-layout.css. */ -.composer .composer-box [data-slot="prompt-input-field"] { - align-self: center; - font-size: 15px; - line-height: 1.5; -} - -.composer .composer-box :is(textarea, [data-slot="prompt-input-mirror"]) { - padding-block: 4px; -} - -.composer .composer-box textarea { - width: 100%; - min-height: 0; - max-height: min(180px, 30vh); - display: block; -} - -/* An empty box is one line tall, so a placeholder wider than the column would - wrap under the border; it stays on that line and ends in an ellipsis. */ -.composer .composer-box textarea:placeholder-shown { - white-space: nowrap; - text-overflow: ellipsis; -} - -.composer .composer-box textarea::placeholder { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.composer .composer-box [data-slot="prompt-input-toolbar"] { - flex: none; - gap: var(--space-1); - padding: 0; -} - -/* Ink round send — never the library's primary fill. */ -.composer .composer-box [data-slot="prompt-input-submit"] { - width: 32px; - height: 32px; - min-width: 32px; - min-height: 32px; - padding: 0; - border: 0; - background: var(--wb-foreground); - color: var(--wb-background); - border-radius: 50%; - box-shadow: none; -} - -.composer .composer-box [data-slot="prompt-input-submit"]:hover:not(:disabled) { - background: var(--wb-foreground); - opacity: 0.85; -} - -.composer .composer-box [data-slot="prompt-input-submit"]:disabled { - opacity: 0.2; -} - -.composer .dictated { - display: grid; - gap: var(--space-1); -} - -.composer .dictate { - width: 32px; - height: 32px; - margin: 0; - border: 0; - border-radius: 50%; - background: transparent; - color: var(--wb-muted-foreground); -} - -.composer .dictate:hover:not(:disabled) { - background: var(--wb-muted); - color: var(--wb-foreground); -} - -.composer .approve button { - background: transparent; - border-color: transparent; - color: var(--wb-muted-foreground); - height: 32px; - padding: 0 var(--space-3); - gap: 6px; - font-size: 0.8125rem; - font-weight: 550; - border-radius: var(--radius-md); - transition: - color 0.15s, - border-color 0.15s, - background 0.15s; -} - -.composer .approve button:hover:not(:disabled) { - color: var(--ok, var(--wb-okay)); - border-color: color-mix(in srgb, var(--ok, var(--wb-okay)) 40%, transparent); - background: color-mix(in srgb, var(--ok, var(--wb-okay)) 8%, transparent); -} - -@media (max-width: 900px) { - .panes, - .panes:has(> .panes-splitter) { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - } - - .conv { - border-right: 0; - border-bottom: 1px solid var(--wb-border); - } - - /* A lone conversation runs to the window's right edge, where the guide's - button is fixed: the composer keeps clear of it (44px plus its inset). */ - .panes.chat-first .composer { - padding-right: calc(var(--space-4) + 44px + var(--space-6)); - } -} - -/* Phone widths, from 402px: every pane takes the 16px gutter. */ -@media (max-width: 640px) { - .conv-scroll { - padding: var(--space-4); - } - - .composer { - padding: var(--space-2) var(--space-3) var(--space-3); - } -} - -/* ---- "How it will actually run": same paper as the doc pane, - whether nested inside .doc (stage 6) or standalone (the estimate view). */ - -.checklist { - list-style: none; - margin: 0 0 var(--space-3); - padding: 0; -} - -.checklist li { - display: flex; - gap: 8px; - align-items: baseline; - margin-bottom: 6px; -} - -.checklist li::before { - font-family: var(--wb-font-mono); - font-size: 11px; -} - -.checklist li.ok::before { - content: "✓"; - color: var(--ok, var(--wb-okay)); -} - -.checklist li.open::before { - content: "○"; - color: var(--wb-primary); -} - -.checklist li.fail::before { - content: "✗"; - color: var(--wb-destructive); -} diff --git a/apps/web/src/pages/workspace.tsx b/apps/web/src/pages/workspace.tsx deleted file mode 100644 index 4e9899d6..00000000 --- a/apps/web/src/pages/workspace.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import "./workspace-layout.css"; -import "./workspace/stage-chrome.css"; -export * from "./workspace/index.tsx"; diff --git a/apps/web/src/pages/workspace/approaches-comparison.module.css b/apps/web/src/pages/workspace/approaches-comparison.module.css new file mode 100644 index 00000000..81fc0012 --- /dev/null +++ b/apps/web/src/pages/workspace/approaches-comparison.module.css @@ -0,0 +1,63 @@ +/* The approaches a version filed, as columns. The grid fits as many as the + pane's width allows and stacks the rest, so a narrow pane never scrolls + sideways. */ +.approaches { + align-self: stretch; + margin: var(--space-4) 0 var(--space-6); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + gap: var(--space-4); +} + +.approaches .card { + min-width: 0; + gap: var(--space-3); + padding: var(--space-4); + border-radius: var(--radius-panel); + border-color: var(--wb-border); + background: var(--wb-card); +} + +.approaches .card[data-chosen], +.grid:not(:has([data-chosen])) .card[data-recommended] { + border-color: var(--wb-border-strong); + background: color-mix(in srgb, var(--wb-muted) 45%, var(--wb-card)); +} + +.badges { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-bottom: var(--space-1); +} + +.approaches .card p, +.points ul { + margin: 0; +} + +.points h4 { + margin: 0 0 var(--space-1); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--wb-muted-foreground); +} + +.points ul { + padding-inline-start: 1.1em; + list-style: disc; +} + +.points li + li { + margin-top: var(--space-1); +} + +.approaches .footer { + margin-top: auto; + padding-top: var(--space-2); +} diff --git a/apps/web/src/pages/workspace/approaches-comparison.tsx b/apps/web/src/pages/workspace/approaches-comparison.tsx index c946838a..ac63f794 100644 --- a/apps/web/src/pages/workspace/approaches-comparison.tsx +++ b/apps/web/src/pages/workspace/approaches-comparison.tsx @@ -18,14 +18,16 @@ import type { } from "@corbits-builder/schemas/approaches"; import { useState } from "react"; import { wording } from "../../config.ts"; +import { Fault } from "../../components.jsx"; import { humanFault } from "./human-fault.ts"; +import styles from "./approaches-comparison.module.css"; const text = wording.approaches; function Points({ label, points }: { label: string; points: string[] }) { if (points.length === 0) return null; return ( - + {label} {points.map((point, index) => ( @@ -62,22 +64,26 @@ export function ApproachesComparison({ } }; return ( - - + + {meta.approaches.map((approach) => { const recommended = approach.id === meta.recommended; const picked = approach.id === chosen; return ( - + {picked ? {text.chosen} : null} {recommended ? ( {text.recommended} @@ -87,14 +93,14 @@ export function ApproachesComparison({ {approach.name} {approach.summary} - + {text.bestFor}: {approach.bestFor} {onChoose === undefined || chosen !== undefined ? null : ( - + - {fault === null ? null : ( - - {fault} - - )} + {fault === null ? null : {fault}} ); } diff --git a/apps/web/src/pages/workspace/approval-package-preview.module.css b/apps/web/src/pages/workspace/approval-package-preview.module.css new file mode 100644 index 00000000..23bc5d70 --- /dev/null +++ b/apps/web/src/pages/workspace/approval-package-preview.module.css @@ -0,0 +1,165 @@ +.package { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: 0 var(--space-6) var(--space-6); +} + +.tabs { + display: inline-flex; + align-self: flex-start; + flex-wrap: wrap; + gap: 0.25rem; + padding: 0.25rem; + border: 1px solid var(--wb-border); + border-radius: var(--radius-control); + background: color-mix(in srgb, var(--wb-muted) 50%, var(--wb-background)); +} + +.subtab { + color: var(--wb-muted-foreground); + border: 1px solid transparent; +} + +.subtab[aria-selected="true"] { + background: var(--wb-card); + color: var(--wb-foreground); + border-color: var(--wb-border); + font-weight: 600; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); +} + +.pdf { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.pdfToolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--wb-border); + border-radius: var(--radius-panel); + background: var(--wb-card); +} + +.pdfToolbarMeta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-2); +} + +.pdfTitle { + font-size: 0.875rem; + font-weight: 600; + color: var(--wb-foreground); +} + +.pdfFilename { + font-family: var(--wb-font-mono); + font-size: 0.75rem; + color: var(--wb-muted-foreground); +} + +.pdfToolbarActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.pdfAction { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.375rem 0.75rem; + border-radius: var(--radius-control); + border: 1px solid var(--wb-border); + background: var(--wb-background); + color: var(--wb-foreground); + font-size: 0.8125rem; + font-weight: 500; + text-decoration: none; + transition: background-color 120ms ease-out; +} + +.pdfAction:hover { + background: var(--wb-muted); +} + +.pdfAction[data-primary] { + background: var(--wb-muted); + border-color: var(--wb-border-strong); + color: var(--wb-foreground); + font-weight: 600; +} + +.pdfAction[data-primary]:hover { + background: color-mix(in srgb, var(--wb-muted) 85%, var(--wb-foreground)); +} + +.sources { + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +.manifest { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); + gap: var(--space-3); + margin: 0; + padding: var(--space-4); + border: 1px solid var(--wb-border); + border-radius: var(--radius-panel); + background: var(--wb-card); +} + +.manifest dt { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--wb-muted-foreground); +} + +.manifest dd { + margin: var(--space-1) 0 0; + font-size: 0.9375rem; +} + +.sourcesTable { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.sourcesTable th, +.sourcesTable td { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--wb-border); + text-align: left; +} + +.sourcesTable th { + font-weight: 600; + color: var(--wb-muted-foreground); +} + +.documentsList { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.document { + padding: var(--space-4); + border: 1px solid var(--wb-border); + border-radius: var(--radius-panel); + background: var(--wb-card); +} diff --git a/apps/web/src/pages/workspace/approval-package-preview.test.tsx b/apps/web/src/pages/workspace/approval-package-preview.test.tsx index 37e64cca..3fb88136 100644 --- a/apps/web/src/pages/workspace/approval-package-preview.test.tsx +++ b/apps/web/src/pages/workspace/approval-package-preview.test.tsx @@ -288,7 +288,7 @@ describe("approval package preview", () => { expect(html).toContain("5 slides"); expect(html).toContain("Customer Portal Brief"); expect(html).toContain("Portal Requirements"); - expect(html).toContain("approval-package-source-link"); + expect(html).toContain('data-slot="source-link"'); expect(html).toContain("v2"); expect(html).toContain("abcdef012345"); expect(html).toContain("executive-summary.md"); diff --git a/apps/web/src/pages/workspace/approval-package-preview.tsx b/apps/web/src/pages/workspace/approval-package-preview.tsx index d890c207..2a1d0b3c 100644 --- a/apps/web/src/pages/workspace/approval-package-preview.tsx +++ b/apps/web/src/pages/workspace/approval-package-preview.tsx @@ -5,8 +5,11 @@ import { } from "@corbits-builder/schemas/artifact"; import { type } from "arktype"; import { Suspense, lazy, useEffect, useMemo, useState } from "react"; +import { InlineNote } from "../../components.tsx"; import { wording } from "../../config.ts"; import { Markdown } from "../../markdown.jsx"; +import styles from "./approval-package-preview.module.css"; +import { SourceLink } from "./source-link.tsx"; export const APPROVAL_PACKAGE_MEDIA_TYPE = "application/vnd.corbits-builder.approval-package+json"; @@ -16,7 +19,7 @@ const text = wording.approvalPackage; export type ApprovalPackageViewTab = "slides" | "pdf" | "sources"; function SlidesUnavailable() { - return {text.slidesUnavailable}; + return {text.slidesUnavailable}; } // The slide deck schema is costly to build, so it loads only once a package's slides are shown. @@ -38,7 +41,7 @@ const DeckPages = lazy(async () => ({ default: (await slidesModule())?.DeckPages ?? SlidesUnavailable, })); -const loadingSlides = {text.loadingSlides}; +const loadingSlides = {text.loadingSlides}; export function parseApprovalPackage( content: string, @@ -125,21 +128,19 @@ function PdfView({ const pdfHref = blobUrl ?? fallbackHref; return ( - - - - - {text.pdfTitle} - - {stem}.pdf + + + + {text.pdfTitle} + {stem}.pdf {file === null ? ( - {text.pdfUnreadable} + {text.pdfUnreadable} ) : ( - + {blobUrl !== null && ( )} @@ -175,31 +177,22 @@ function SourceRow({ return ( - onOpenSource?.(source.kind, source.version)} - > + onOpenSource?.(source.kind, source.version)}> {kindLabel} - + - onOpenSource?.(source.kind, source.version)} - > + onOpenSource?.(source.kind, source.version)}> {source.title} - + - onOpenSource?.(source.kind, source.version)} > v{String(source.version)} - + {source.sha256.slice(0, 12)} @@ -219,8 +212,8 @@ function SourcesView({ const hasDocuments = documents !== undefined && Object.keys(documents).length > 0; return ( - - + + {text.manifest.audience} {manifest.audience} @@ -240,7 +233,7 @@ function SourcesView({ {text.manifest.sources} - + {text.manifest.sourceKind} @@ -263,11 +256,11 @@ function SourcesView({ {hasDocuments ? ( {text.manifest.documents} - + {Object.entries(documents).map(([name, docContent]) => ( - + {name} - + ))} @@ -292,20 +285,20 @@ export function ApprovalPackagePreview({ useState(initialTab); const bundle = useMemo(() => parseApprovalPackage(content), [content]); if (bundle === null) { - return {text.unreadable}; + return {text.unreadable}; } return ( - - + + setActiveTab("slides")} > @@ -315,7 +308,7 @@ export function ApprovalPackagePreview({ role="tab" size="sm" variant="ghost" - className="approval-package-subtab" + className={styles.subtab} aria-selected={activeTab === "pdf"} onClick={() => setActiveTab("pdf")} > @@ -325,7 +318,7 @@ export function ApprovalPackagePreview({ role="tab" size="sm" variant="ghost" - className="approval-package-subtab" + className={styles.subtab} aria-selected={activeTab === "sources"} onClick={() => setActiveTab("sources")} > diff --git a/apps/web/src/pages/workspace/approval-package-slides.module.css b/apps/web/src/pages/workspace/approval-package-slides.module.css new file mode 100644 index 00000000..3eca4390 --- /dev/null +++ b/apps/web/src/pages/workspace/approval-package-slides.module.css @@ -0,0 +1,212 @@ +.slides { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.slide { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.slideMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: 0 var(--space-1); +} + +.slideIndex { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--wb-muted-foreground); +} + +.stage { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + container-type: inline-size; + display: flex; + flex-direction: column; + justify-content: space-between; + overflow: hidden; + border: 1px solid var(--wb-border-strong); + border-radius: var(--radius-panel); + background: var(--slide-bg, #ffffff); + color: var(--slide-fg, #0f172a); + box-shadow: + 0 12px 28px -8px rgba(15, 23, 42, 0.2), + 0 4px 10px -4px rgba(15, 23, 42, 0.1); +} + +@media (max-width: 640px) { + .stage { + aspect-ratio: auto; + min-height: 18rem; + } +} + +.accentBar { + width: 100%; + height: 5px; + flex: none; + background: var(--slide-accent, var(--wb-primary)); +} + +.canvas { + flex: 1; + display: flex; + flex-direction: column; + gap: clamp(0.625rem, 1.5cqw, 1.15rem); + padding: clamp(1rem, 2.8cqw, 2.25rem) clamp(1.25rem, 3.4cqw, 2.75rem) + clamp(0.5rem, 1.2cqw, 1rem); + min-height: 0; + overflow-y: auto; +} + +.stage[data-slide-layout="title"] .canvas { + justify-content: center; +} + +.kicker { + display: inline-block; + width: 3rem; + height: 4px; + border-radius: 999px; + margin-bottom: var(--space-1); +} + +.heading { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.title { + margin: 0; + font-size: clamp(1.25rem, 2.2vw, 1.65rem); + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.015em; + color: var(--slide-heading, inherit); +} + +.stage[data-slide-layout="title"] .title { + font-size: clamp(1.75rem, 3.2vw, 2.5rem); + line-height: 1.12; + letter-spacing: -0.025em; +} + +.subtitle { + margin: 0; + font-size: clamp(0.925rem, 1.35vw, 1.075rem); + line-height: 1.5; + max-width: 68ch; + color: var(--slide-subtitle, inherit); +} + +.stage[data-slide-layout="title"] .subtitle { + font-size: clamp(1rem, 1.6vw, 1.25rem); + line-height: 1.55; + max-width: 62ch; +} + +.body { + display: flex; + flex-direction: column; + gap: var(--space-2); + font-size: clamp(0.925rem, 1.3vw, 1.05rem); + line-height: 1.6; +} + +.body p { + margin: 0; +} + +.body ul, +.body ol { + margin: 0; + padding-left: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.body ul { + list-style: disc; +} + +.body ol { + list-style: decimal; +} + +.body li::marker { + color: var(--slide-accent, currentColor); +} + +.columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + gap: clamp(0.875rem, 2vw, 1.5rem); + flex: 1; + align-content: start; +} + +.column { + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: clamp(0.875rem, 1.8vw, 1.25rem); + border-radius: var(--radius-md); + background: var(--slide-column-bg, rgba(15, 23, 42, 0.035)); + border: 1px solid var(--slide-column-border, rgba(15, 23, 42, 0.1)); +} + +.columnHeading { + margin: 0; + padding-bottom: var(--space-1); + border-bottom: 1px solid var(--slide-column-border, rgba(15, 23, 42, 0.1)); + font-size: clamp(0.95rem, 1.35vw, 1.1rem); + font-weight: 600; + letter-spacing: -0.01em; +} + +.caption { + margin: auto 0 0; + font-size: 0.875rem; + color: var(--slide-subtitle, var(--wb-muted-foreground)); +} + +.stageFooter { + display: flex; + justify-content: flex-end; + align-items: center; + padding: 0 clamp(1.75rem, 4.2vw, 3.25rem) clamp(0.75rem, 1.6vw, 1.25rem); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + opacity: 0.8; +} + +.pdfPages { + display: flex; + flex-direction: column; + gap: var(--space-5); + padding: var(--space-5); + border: 1px solid var(--wb-border); + border-radius: var(--radius-panel); + background: color-mix(in srgb, var(--wb-muted) 65%, var(--wb-background)); +} + +.pdfPage { + margin: 0 auto; + width: 100%; + max-width: 56rem; +} diff --git a/apps/web/src/pages/workspace/approval-package-slides.tsx b/apps/web/src/pages/workspace/approval-package-slides.tsx index 259d2c7a..b52741fe 100644 --- a/apps/web/src/pages/workspace/approval-package-slides.tsx +++ b/apps/web/src/pages/workspace/approval-package-slides.tsx @@ -12,7 +12,9 @@ import { type Theme, } from "@corbits/slidedeck/deck"; import { useMemo, type CSSProperties, type ReactNode } from "react"; +import { InlineNote } from "../../components.tsx"; import { wording } from "../../config.ts"; +import styles from "./approval-package-slides.module.css"; const text = wording.approvalPackage; @@ -203,7 +205,7 @@ function SlideBlock({ block }: { block: Block }) { function SlideBlocks({ blocks }: { blocks: readonly Block[] }) { if (blocks.length === 0) return null; return ( - + {blocks.map((block, index) => ( ))} @@ -221,12 +223,9 @@ function SlideColumn({ headingColor: string; }) { return ( - + {heading === undefined ? null : ( - + {heading} )} @@ -248,7 +247,7 @@ function FreeText({ elements }: { elements: readonly FreeElement[] }) { } } if (paragraphs.length === 0) return null; - return {paragraphs}; + return {paragraphs}; } function SlideContent({ @@ -264,14 +263,14 @@ function SlideContent({ return ; case "two-column": return ( - + ); case "comparison": return ( - + - + {title === undefined && subtitle === undefined ? null : ( - + {slide.layout === "title" ? ( ) : null} {title === undefined ? null : ( {subtitle} @@ -353,16 +352,13 @@ function SlideCanvas({ )} {caption === undefined ? null : ( - + {caption} )}
{`$ ${wording.flourish.shellRequest}`} - {`\n${wording.flourish.shellAnswer}${SANDWICH}`} + {`\n${wording.flourish.shellAnswer}${SANDWICH}`}
@@ -98,7 +98,7 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) { if (failed !== null) { return ( -
{copy.noneHint}
{copy.addLater}
{failed ? wording.library.failed : wording.library.loading}
{wording.exportProject.loading}
- {failure} -
{wording.newProject.lead}
{wording.providers.noneHint}
{text.folderToWrite(labels(toWrite))}
{text.folderLeaveOut}
Who works in this workspace, and what they may do.
+ A full member directory is not part of this workspace yet: the hub lists your own memberships, not every principal, so only you and open invitations are shown. -
Builder is made with these projects.
{copy.reviewersLead}
{copy.emptyReviewers}
{copy.openSourceLead}
{text.bestFor}: {approach.bestFor}
- {fault} -
{text.slidesUnavailable}
{text.loadingSlides}
{text.pdfUnreadable}
{source.sha256.slice(0, 12)}
{text.unreadable}
{caption}