From 8bb08291f12ab075a32982377de5f60867bdfb56 Mon Sep 17 00:00:00 2001 From: etodanik Date: Sat, 7 Nov 2026 01:26:11 +0900 Subject: [PATCH 1/2] refactor(web): move styles into per-component CSS modules Every component's styles now live in a CSS module beside it, with exact types generated by typed-css-modules, so a misspelt class fails the typecheck. A look used in more than one place is a shared component that owns its whole look and takes variants as data-* attributes, rather than a class several components share. styles.css keeps only what is global. Settings moves into pages/settings/, and Storybook is set up with stories for the components and screens. --- .gitignore | 4 + apps/web/.storybook/main.ts | 8 + apps/web/.storybook/preview.tsx | 55 + apps/web/AGENTS.md | 25 + apps/web/package.json | 13 +- apps/web/src/App.module.css | 486 +++ apps/web/src/App.tsx | 56 +- apps/web/src/components.module.css | 138 + apps/web/src/components.stories.tsx | 159 + apps/web/src/components.tsx | 175 +- apps/web/src/composer.module.css | 164 + apps/web/src/composer.tsx | 37 + apps/web/src/css-modules-preload.ts | 17 + apps/web/src/css-modules.test.ts | 65 + apps/web/src/dictation.module.css | 95 + apps/web/src/dictation.tsx | 11 +- .../src/{flourish.css => flourish.module.css} | 70 +- apps/web/src/flourish.stories.tsx | 19 + apps/web/src/flourish.tsx | 44 +- apps/web/src/main.tsx | 11 +- apps/web/src/markdown.module.css | 204 ++ apps/web/src/markdown.stories.tsx | 43 + apps/web/src/markdown.tsx | 21 +- apps/web/src/pages/approval-fields.module.css | 99 + apps/web/src/pages/approval-fields.tsx | 130 + apps/web/src/pages/invite-accept.tsx | 40 +- apps/web/src/pages/model-setup.module.css | 57 + apps/web/src/pages/model-setup.tsx | 25 +- apps/web/src/pages/profile-select.test.tsx | 2 +- apps/web/src/pages/profile-select.tsx | 4 +- apps/web/src/pages/project-cover.module.css | 58 + apps/web/src/pages/project-cover.tsx | 6 +- apps/web/src/pages/project-library.css | 218 -- apps/web/src/pages/project-library.module.css | 141 + apps/web/src/pages/project-library.tsx | 31 +- apps/web/src/pages/project-menu.module.css | 37 + apps/web/src/pages/project-menu.tsx | 36 +- .../{home-layout.css => projects.module.css} | 46 +- apps/web/src/pages/projects.tsx | 38 +- apps/web/src/pages/settings-layout.css | 312 -- .../pages/settings/collaborators.module.css | 12 + .../pages/{ => settings}/collaborators.tsx | 112 +- .../settings/custom-providers.module.css | 123 + .../pages/{ => settings}/custom-providers.tsx | 171 +- .../src/pages/settings/profiles.module.css | 22 + .../web/src/pages/{ => settings}/profiles.tsx | 91 +- .../pages/settings/provider-form.module.css | 17 + apps/web/src/pages/settings/provider-form.tsx | 18 + .../pages/settings/provider-logos.stories.tsx | 41 + .../pages/{ => settings}/provider-logos.tsx | 9 +- .../pages/{ => settings}/providers.test.ts | 8 +- .../src/pages/{ => settings}/providers.tsx | 58 +- .../pages/settings/settings-row.module.css | 97 + .../pages/settings/settings-row.stories.tsx | 50 + .../src/pages/settings/settings-row.test.tsx | 32 + apps/web/src/pages/settings/settings-row.tsx | 96 + .../settings/settings-section.module.css | 22 + .../src/pages/settings/settings-section.tsx | 20 + .../src/pages/settings/settings.module.css | 46 + .../web/src/pages/{ => settings}/settings.tsx | 349 +- .../settings/workspace-approvals.module.css | 5 + .../pages/settings/workspace-approvals.tsx | 189 ++ apps/web/src/pages/workspace-layout.css | 625 ---- apps/web/src/pages/workspace.tsx | 3 - .../approaches-comparison.module.css | 63 + .../pages/workspace/approaches-comparison.tsx | 26 +- .../approval-package-preview.module.css | 165 + .../approval-package-preview.test.tsx | 2 +- .../workspace/approval-package-preview.tsx | 83 +- .../approval-package-slides.module.css | 212 ++ .../workspace/approval-package-slides.tsx | 57 +- .../workspace/approval-rounds.module.css | 452 +++ .../pages/workspace/approval-rounds.test.tsx | 53 +- .../src/pages/workspace/approval-rounds.tsx | 758 ++--- .../pages/workspace/artifact-pane.module.css | 196 ++ .../pages/workspace/artifact-pane.stories.tsx | 137 + .../pages/workspace/artifact-pane.test.tsx | 29 + .../web/src/pages/workspace/artifact-pane.tsx | 25 +- .../workspace/comparison-view.module.css | 47 + .../workspace/comparison-view.stories.tsx | 28 + .../src/pages/workspace/comparison-view.tsx | 9 +- .../workspace/deliverable-block.module.css | 84 + .../src/pages/workspace/deliverable-block.tsx | 69 +- .../workspace/design-concepts.module.css | 33 + .../src/pages/workspace/design-concepts.tsx | 77 +- .../pages/workspace/design-preview.module.css | 21 + .../src/pages/workspace/design-preview.tsx | 26 +- .../pages/workspace/design-themes.module.css | 73 + .../web/src/pages/workspace/design-themes.tsx | 29 +- .../pages/workspace/device-preview.module.css | 57 + .../workspace/device-preview.stories.tsx | 46 + .../src/pages/workspace/device-preview.tsx | 28 +- .../src/pages/workspace/diff-pane.module.css | 10 + .../src/pages/workspace/diff-pane.stories.tsx | 26 + apps/web/src/pages/workspace/diff-pane.tsx | 12 +- .../workspace/document-decision.module.css | 153 + .../workspace/document-decision.stories.tsx | 68 + .../src/pages/workspace/document-decision.tsx | 36 +- .../web/src/pages/workspace/document-tips.tsx | 10 +- .../src/pages/workspace/gui-frame.module.css | 27 + apps/web/src/pages/workspace/gui-frame.tsx | 50 +- apps/web/src/pages/workspace/index.module.css | 178 ++ apps/web/src/pages/workspace/index.tsx | 177 +- .../src/pages/workspace/live-app.module.css | 214 ++ apps/web/src/pages/workspace/live-app.tsx | 86 +- .../pages/workspace/mockup-tweaks.module.css | 81 + .../web/src/pages/workspace/mockup-tweaks.tsx | 67 +- .../src/pages/workspace/next-step.module.css | 24 + apps/web/src/pages/workspace/next-step.tsx | 7 +- .../src/pages/workspace/pane-bar.module.css | 128 + .../src/pages/workspace/pane-bar.stories.tsx | 61 + apps/web/src/pages/workspace/pane-bar.tsx | 35 +- apps/web/src/pages/workspace/pane-classes.ts | 10 - .../pages/workspace/passage-note.module.css | 20 + .../pages/workspace/passage-note.stories.tsx | 36 + apps/web/src/pages/workspace/passage-note.tsx | 11 +- .../pages/workspace/persistent-workspace.css | 1349 -------- .../workspace/preview-interactions.test.tsx | 37 + .../pages/workspace/print-document.module.css | 46 + .../web/src/pages/workspace/print-document.ts | 14 +- ...ons-card.css => questions-card.module.css} | 60 +- .../workspace/questions-card.stories.tsx | 92 + .../pages/workspace/questions-card.test.tsx | 2 +- .../src/pages/workspace/questions-card.tsx | 71 +- .../pages/workspace/room-skeleton.module.css | 39 + .../pages/workspace/room-skeleton.stories.tsx | 19 + .../web/src/pages/workspace/room-skeleton.tsx | 33 +- .../src/pages/workspace/sketching.module.css | 107 + apps/web/src/pages/workspace/sketching.tsx | 73 + .../pages/workspace/source-link.module.css | 32 + apps/web/src/pages/workspace/source-link.tsx | 26 + .../workspace/specialist-updates.module.css | 31 + .../pages/workspace/specialist-updates.tsx | 13 +- apps/web/src/pages/workspace/stage-chrome.css | 20 - .../src/pages/workspace/stage-view.module.css | 14 + apps/web/src/pages/workspace/stage-view.tsx | 7 + .../src/pages/workspace/transcript.module.css | 72 + .../pages/workspace/transcript.stories.tsx | 142 + .../src/pages/workspace/transcript.test.tsx | 12 +- apps/web/src/pages/workspace/transcript.tsx | 10 +- .../src/pages/workspace/typed-yes.module.css | 38 + .../src/pages/workspace/typed-yes.stories.tsx | 33 + apps/web/src/pages/workspace/typed-yes.tsx | 8 +- apps/web/src/pages/workspace/user-manual.tsx | 1 + apps/web/src/pages/workspace/version-menu.tsx | 5 +- .../pages/workspace/work-unit-view.module.css | 32 + .../src/pages/workspace/work-unit-view.tsx | 11 +- apps/web/src/styles.css | 2839 +---------------- apps/web/src/workspace-menu.module.css | 78 + apps/web/src/workspace-menu.tsx | 19 +- apps/web/tsconfig.json | 2 +- bun.lock | 380 ++- bunfig.toml | 3 + package.json | 2 +- 154 files changed, 8434 insertions(+), 7166 deletions(-) create mode 100644 apps/web/.storybook/main.ts create mode 100644 apps/web/.storybook/preview.tsx create mode 100644 apps/web/src/App.module.css create mode 100644 apps/web/src/components.module.css create mode 100644 apps/web/src/components.stories.tsx create mode 100644 apps/web/src/composer.module.css create mode 100644 apps/web/src/composer.tsx create mode 100644 apps/web/src/css-modules-preload.ts create mode 100644 apps/web/src/css-modules.test.ts create mode 100644 apps/web/src/dictation.module.css rename apps/web/src/{flourish.css => flourish.module.css} (71%) create mode 100644 apps/web/src/flourish.stories.tsx create mode 100644 apps/web/src/markdown.module.css create mode 100644 apps/web/src/markdown.stories.tsx create mode 100644 apps/web/src/pages/approval-fields.module.css create mode 100644 apps/web/src/pages/approval-fields.tsx create mode 100644 apps/web/src/pages/model-setup.module.css create mode 100644 apps/web/src/pages/project-cover.module.css delete mode 100644 apps/web/src/pages/project-library.css create mode 100644 apps/web/src/pages/project-library.module.css create mode 100644 apps/web/src/pages/project-menu.module.css rename apps/web/src/pages/{home-layout.css => projects.module.css} (57%) delete mode 100644 apps/web/src/pages/settings-layout.css create mode 100644 apps/web/src/pages/settings/collaborators.module.css rename apps/web/src/pages/{ => settings}/collaborators.tsx (78%) create mode 100644 apps/web/src/pages/settings/custom-providers.module.css rename apps/web/src/pages/{ => settings}/custom-providers.tsx (91%) create mode 100644 apps/web/src/pages/settings/profiles.module.css rename apps/web/src/pages/{ => settings}/profiles.tsx (91%) create mode 100644 apps/web/src/pages/settings/provider-form.module.css create mode 100644 apps/web/src/pages/settings/provider-form.tsx create mode 100644 apps/web/src/pages/settings/provider-logos.stories.tsx rename apps/web/src/pages/{ => settings}/provider-logos.tsx (93%) rename apps/web/src/pages/{ => settings}/providers.test.ts (87%) rename apps/web/src/pages/{ => settings}/providers.tsx (91%) create mode 100644 apps/web/src/pages/settings/settings-row.module.css create mode 100644 apps/web/src/pages/settings/settings-row.stories.tsx create mode 100644 apps/web/src/pages/settings/settings-row.test.tsx create mode 100644 apps/web/src/pages/settings/settings-row.tsx create mode 100644 apps/web/src/pages/settings/settings-section.module.css create mode 100644 apps/web/src/pages/settings/settings-section.tsx create mode 100644 apps/web/src/pages/settings/settings.module.css rename apps/web/src/pages/{ => settings}/settings.tsx (63%) create mode 100644 apps/web/src/pages/settings/workspace-approvals.module.css create mode 100644 apps/web/src/pages/settings/workspace-approvals.tsx delete mode 100644 apps/web/src/pages/workspace-layout.css delete mode 100644 apps/web/src/pages/workspace.tsx create mode 100644 apps/web/src/pages/workspace/approaches-comparison.module.css create mode 100644 apps/web/src/pages/workspace/approval-package-preview.module.css create mode 100644 apps/web/src/pages/workspace/approval-package-slides.module.css create mode 100644 apps/web/src/pages/workspace/approval-rounds.module.css create mode 100644 apps/web/src/pages/workspace/artifact-pane.module.css create mode 100644 apps/web/src/pages/workspace/artifact-pane.stories.tsx create mode 100644 apps/web/src/pages/workspace/comparison-view.module.css create mode 100644 apps/web/src/pages/workspace/comparison-view.stories.tsx create mode 100644 apps/web/src/pages/workspace/deliverable-block.module.css create mode 100644 apps/web/src/pages/workspace/design-concepts.module.css create mode 100644 apps/web/src/pages/workspace/design-preview.module.css create mode 100644 apps/web/src/pages/workspace/design-themes.module.css create mode 100644 apps/web/src/pages/workspace/device-preview.module.css create mode 100644 apps/web/src/pages/workspace/device-preview.stories.tsx create mode 100644 apps/web/src/pages/workspace/diff-pane.module.css create mode 100644 apps/web/src/pages/workspace/diff-pane.stories.tsx create mode 100644 apps/web/src/pages/workspace/document-decision.module.css create mode 100644 apps/web/src/pages/workspace/document-decision.stories.tsx create mode 100644 apps/web/src/pages/workspace/gui-frame.module.css create mode 100644 apps/web/src/pages/workspace/index.module.css create mode 100644 apps/web/src/pages/workspace/live-app.module.css create mode 100644 apps/web/src/pages/workspace/mockup-tweaks.module.css create mode 100644 apps/web/src/pages/workspace/next-step.module.css create mode 100644 apps/web/src/pages/workspace/pane-bar.module.css create mode 100644 apps/web/src/pages/workspace/pane-bar.stories.tsx delete mode 100644 apps/web/src/pages/workspace/pane-classes.ts create mode 100644 apps/web/src/pages/workspace/passage-note.module.css create mode 100644 apps/web/src/pages/workspace/passage-note.stories.tsx delete mode 100644 apps/web/src/pages/workspace/persistent-workspace.css create mode 100644 apps/web/src/pages/workspace/print-document.module.css rename apps/web/src/pages/workspace/{questions-card.css => questions-card.module.css} (72%) create mode 100644 apps/web/src/pages/workspace/questions-card.stories.tsx create mode 100644 apps/web/src/pages/workspace/room-skeleton.module.css create mode 100644 apps/web/src/pages/workspace/room-skeleton.stories.tsx create mode 100644 apps/web/src/pages/workspace/sketching.module.css create mode 100644 apps/web/src/pages/workspace/sketching.tsx create mode 100644 apps/web/src/pages/workspace/source-link.module.css create mode 100644 apps/web/src/pages/workspace/source-link.tsx create mode 100644 apps/web/src/pages/workspace/specialist-updates.module.css delete mode 100644 apps/web/src/pages/workspace/stage-chrome.css create mode 100644 apps/web/src/pages/workspace/stage-view.module.css create mode 100644 apps/web/src/pages/workspace/stage-view.tsx create mode 100644 apps/web/src/pages/workspace/transcript.module.css create mode 100644 apps/web/src/pages/workspace/transcript.stories.tsx create mode 100644 apps/web/src/pages/workspace/typed-yes.module.css create mode 100644 apps/web/src/pages/workspace/typed-yes.stories.tsx create mode 100644 apps/web/src/pages/workspace/work-unit-view.module.css create mode 100644 apps/web/src/workspace-menu.module.css create mode 100644 bunfig.toml 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..ad782686 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}

-
+
@@ -215,7 +217,7 @@ function ProjectLoadFailure({ {wording.action.tryAgain}
-
+
{wording.projectLoadFailure.details}
{detail}
+ ))} + + +
+ ), +}; + +export const BlockButton: StoryObj = { + render: () => ( +
+ +
+ ), +}; + +export const Banners: StoryObj = { + render: () => ( +
+ + Replies may take longer than usual. + + + Check the key in settings, then try again. + + + + + +
+ ), +}; + +export const Marks: StoryObj = { + args: { size: 26 }, + render: (args) => , +}; + +export const QuietSelects: StoryObj = { + render: () => ( + + + + + ), +}; + +export const Notices: StoryObj = { + render: () => ( +
+ + + +
+ ), +}; 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 ? ( -
- -
- ) : 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 ( +
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 ( + - -
+ +
{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.

-
+
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} - + ); return provider.tip ? ( @@ -297,20 +311,21 @@ function FieldRow({ children?: ReactNode; }) { return ( -
-
- + {htmlFor ? ( ) : ( )} - - {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 ? ( -
+ + ); } @@ -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} -
- - + - -
+ + {open ? ( -
+
) : null} @@ -832,7 +842,7 @@ function CustomProviderModels({ ))} -
+ -
+ ); } @@ -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 ? ( <>
+ + ); })} {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} - +
+ ); })} @@ -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 + + + + + + + + + + + +
+ ), +}; 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..02a59d19 --- /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( + + + ); +} 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} - )} - {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} - - -
- {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 ? ( -
+ -
+ ) : 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) => ( + + ))} + + + + {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 : ( - +
    ); } 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} ) : ( -
- + - + - + {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}

- +
@@ -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 ( -
-
+
+
-
-
- ) : null} -
- - {roundText.fieldLabels.focus} - -
- - setReviewerField(reviewer.key, "goal", event.target.value) - } - /> -
-
-
- - {roundText.fieldLabels.email} - -
- + - setReviewerField( - reviewer.key, - "email", - event.target.value, - ) + setCustomPersonaDraft(reviewer.key, event.target.value) } + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitCustomPersona(reviewer.key); + } + }} /> -
-
- {showSaveToggle ? ( -
- - {roundText.fieldLabels.workspace} - -
commitCustomPersona(reviewer.key)} > + {roundText.addCustomPersona} + + + ) : null} + + + setReviewerField(reviewer.key, "goal", event.target.value) + } + /> + + + + setReviewerField(reviewer.key, "email", event.target.value) + } + /> + + {showSaveToggle ? ( + +
- + {roundText.saveToWorkspaceNote} - +
-
+ ) : null} -
- - {roundText.fieldLabels.expires} - -
- -
-
-
+ + + setExpiry(event.target.value as ExpiryPreset) + } + > + {EXPIRY_OPTIONS.map((preset) => ( + + ))} + + + ); })}
-
+
@@ -773,25 +727,24 @@ function TriageItemRow({ const alreadyIncorporated = item.status === "incorporated"; return ( -
  • -
    -
    - {item.text} - {scopeLabel} +
  • +
    +
    + {item.text} + {scopeLabel} {item.status === "pending" ? null : ( - + {roundText.triageStatus[item.status]} - + )}
    -
    +
    - {fault === null ? null : ( -

    - {fault} -

    - )} + {fault === null ? null : {fault}}
  • ); } @@ -836,8 +786,8 @@ function renderReviewerDecisionSection( return null; } return ( -
    -
      +
      +
        {reviewerItems.map((item) => ( -
        - - {title} - +
        {bundle === null ? ( -

        {wording.approvalPackage.unreadable}

        + {wording.approvalPackage.unreadable} ) : ( )} - + {title} -
        +
        {bundle === null ? ( -

        - {wording.approvalPackage.unreadable} -

        + {wording.approvalPackage.unreadable} ) : ( )} @@ -1193,225 +1135,176 @@ function ReviewerTableRow({ return (
      • -
    {text.manifest.sourceKind}
    +
    @@ -271,9 +265,7 @@ export function DocumentDecision({
    {text.conceptColumns.design}
    )} - {body === undefined ? null : ( -

    {body}

    - )} + {body === undefined ? null :

    {body}

    } {asked === undefined || shown || name === undefined || @@ -282,14 +274,14 @@ export function DocumentDecision({ type="button" variant="link" size="sm" - className="document-decision-show" + className={styles.show} onClick={() => onShowDocument(asked.kind)} > {text.show(name)} )} {instruction !== undefined && FOLDED_INSTRUCTION_ASKS.has(tool) ? ( -
    +
    {text.details}

    {instruction}

    @@ -329,7 +321,7 @@ export function PackageDecision({ }; return ( -

    {text.packagesDetail}

    +

    {text.packagesDetail}

    {countdown}
    ); diff --git a/apps/web/src/pages/workspace/document-tips.tsx b/apps/web/src/pages/workspace/document-tips.tsx index 0cc29584..412390eb 100644 --- a/apps/web/src/pages/workspace/document-tips.tsx +++ b/apps/web/src/pages/workspace/document-tips.tsx @@ -1,4 +1,4 @@ -import { MenuItem, MenuLabel } from "@corbits/react-ui/ui/menu"; +import { MenuItem } from "@corbits/react-ui/ui/menu"; import { Lightbulb } from "lucide-react"; import { wording } from "../../config.ts"; import { @@ -7,7 +7,7 @@ import { } from "../../document-tips-setting.ts"; import { useReducedMotion } from "../../reduced-motion.ts"; import { useElapsedMs } from "./elapsed.tsx"; -import type { PaneBarGroup } from "./pane-bar.tsx"; +import { PaneBarTip, type PaneBarGroup } from "./pane-bar.tsx"; const TIP_MS = 8_000; @@ -17,11 +17,7 @@ function RotatingTip() { const elapsed = useElapsedMs(); const tips = wording.tips.items; const index = still ? 0 : Math.floor(elapsed / TIP_MS) % tips.length; - return ( - - {tips[index]} - - ); + return {tips[index]}; } /** The tips as the pane bar's last group: an icon that opens one short tip at a time. */ diff --git a/apps/web/src/pages/workspace/gui-frame.module.css b/apps/web/src/pages/workspace/gui-frame.module.css new file mode 100644 index 00000000..341f18ba --- /dev/null +++ b/apps/web/src/pages/workspace/gui-frame.module.css @@ -0,0 +1,27 @@ +/* A page shown among a document's other parts takes most of the viewport's height. */ +.frame { + width: 100%; + height: 65dvh; + min-height: 480px; + border: 0; + background: #fff; +} + +.frame[data-fill] { + flex: 1; + height: auto; + min-height: 0; +} + +/* Four times the container's width, as a desktop lays it out, scaled down to fit; the container + is positioned and clips it. A still picture, so it takes no pointer. */ +.frame[data-thumbnail] { + position: absolute; + inset: 0 auto auto 0; + width: 400%; + height: 400%; + min-height: 0; + transform: scale(0.25); + transform-origin: 0 0; + pointer-events: none; +} diff --git a/apps/web/src/pages/workspace/gui-frame.tsx b/apps/web/src/pages/workspace/gui-frame.tsx index 0093c79f..d22b6f85 100644 --- a/apps/web/src/pages/workspace/gui-frame.tsx +++ b/apps/web/src/pages/workspace/gui-frame.tsx @@ -3,6 +3,21 @@ * run only for a designer's mockup. A running generated app is AppFrame. */ import { useEffect, useRef, type Ref } from "react"; +import { InlineNote } from "../../components.tsx"; +import styles from "./gui-frame.module.css"; + +/** How the frame is sized: a page-high frame, the whole of a flex container, or a scaled-down still. */ +type FrameSize = { + /** Grows to fill a flex container instead of taking a page's height. */ + fill?: boolean | undefined; + /** Lays the page out at desktop width and scales it to a quarter, over a positioned container. */ + thumbnail?: boolean | undefined; +}; + +const sizeAttributes = ({ fill = false, thumbnail = false }: FrameSize) => ({ + "data-fill": fill ? "" : undefined, + "data-thumbnail": thumbnail ? "" : undefined, +}); const MOCKUP_HOST = "/mockup-frame.html"; @@ -16,9 +31,11 @@ function assignRef(ref: Ref | undefined, value: T | null) { function ScriptedFrame({ html, frameRef, + size, }: { html: string; frameRef?: Ref | undefined; + size: FrameSize; }) { const own = useRef(null); useEffect(() => { @@ -40,7 +57,9 @@ function ScriptedFrame({ own.current = node; assignRef(frameRef, node); }} - className="gui-frame" + className={styles.frame} + data-slot="gui-frame" + {...sizeAttributes(size)} title="Design" sandbox="allow-scripts" src={MOCKUP_HOST} @@ -52,21 +71,28 @@ export function GuiFrame({ html, scripts = false, frameRef, + fill, + thumbnail, }: { html: string | null; scripts?: boolean; frameRef?: Ref | undefined; -}) { +} & FrameSize) { + const size = { fill, thumbnail }; if (html === null || html.trim().length === 0) { - return

    No design to show.

    ; + return No design to show.; } // The host takes one page, so a changed page is a fresh frame. if (scripts) - return ; + return ( + + ); return (