diff --git a/desktop-next/DESIGN.md b/desktop-next/DESIGN.md index e4316ad0d6d..4c2bebc7bdd 100644 --- a/desktop-next/DESIGN.md +++ b/desktop-next/DESIGN.md @@ -62,7 +62,7 @@ Buzz is a place where people build together and bring their agents into the room - **Never judge a value against a surface it will not be used on.** A swatch on a grey fill, or a type specimen in a tinted box, is being evaluated in a context the product will never reproduce. Samples sit on the page. The one exception is a value that needs a backdrop to exist at all — translucency needs something behind it, and a white surface swatch needs a hairline or it renders as nothing. - **Cards are for widgets, galleries, and settings groups.** A card is a bordered, padded region on the page, not a different depth. - **Pick the frame before the content.** Decide what the surface is — a list, a reading column, a workspace — before filling it. -- **Whitespace is generous by default.** Crowding reads as a different product. +- **Whitespace is generous in Normal density.** Compact is an explicit user preference for a denser workspace: reduce shared geometry and layout rhythm, keeping readable type and essential interaction targets independent. Components inherit density from the root; never branch their markup by density. ## Motion diff --git a/desktop-next/README.md b/desktop-next/README.md index 4f5170de1f5..c0f4d06b22a 100644 --- a/desktop-next/README.md +++ b/desktop-next/README.md @@ -13,6 +13,16 @@ pnpm --filter buzz-desktop-next dev Open `http://localhost:1430/design`. Use another port when needed: `pnpm --filter buzz-desktop-next dev --port 5188`. +## Appearance + +The navigation contains independent theme and Normal / Compact controls. Density +applies to every page, shared component, composition, and portaled overlay. It is +saved locally, restored before mounting, and synchronized across open tabs. An +unavailable storage write leaves the current visit usable and displays a retry +message. Normal preserves the original dimensions; Compact retains text sizes +while reducing spacing and control sizes. The Spacing and Radius pages show the +values for the selected density. + ## Packages and portability - `packages/design-tokens`: semantic registry, CSS roles, typography, geometry, diff --git a/desktop-next/TESTING.md b/desktop-next/TESTING.md index 44db9f32cc0..1e024545417 100644 --- a/desktop-next/TESTING.md +++ b/desktop-next/TESTING.md @@ -43,3 +43,8 @@ Composer coverage exercises empty drafts, multiline and modifier handling, IME composition, pointer/keyboard send and stop, retained failed drafts and retry, attachment removal and limits, model controls, transcript preview, and 390/884/1440 layouts. Card header spacing is checked against the production component. + +Density coverage checks pointer/keyboard switching, root and portal geometry, +unchanged text size, retained drafts, reload and cross-tab persistence, invalid +stored values, save failure and retry, live foundation values, compositions, +Glass tab selection, and all 55 examples at 390/884/1440 in both themes. diff --git a/desktop-next/src/features/design-system/catalog/catalog.css b/desktop-next/src/features/design-system/catalog/catalog.css index 159b1a6c28d..46a2fa2b556 100644 --- a/desktop-next/src/features/design-system/catalog/catalog.css +++ b/desktop-next/src/features/design-system/catalog/catalog.css @@ -2,7 +2,7 @@ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--space-page-inset); - row-gap: 3rem; + row-gap: var(--space-catalog-gap); } .catalog-entry { display: flex; @@ -13,7 +13,7 @@ } .catalog-preview { padding-block: var(--space-panel-inset); - min-height: 10rem; + min-height: var(--size-catalog-preview); display: flex; align-items: center; } diff --git a/desktop-next/src/features/design-system/ui/AppearanceControls.tsx b/desktop-next/src/features/design-system/ui/AppearanceControls.tsx new file mode 100644 index 00000000000..b5000a29a3b --- /dev/null +++ b/desktop-next/src/features/design-system/ui/AppearanceControls.tsx @@ -0,0 +1,43 @@ +import { Button } from "@buzz/ui"; +import { useColorScheme } from "@/shared/theme/useColorScheme"; +import { useDensity } from "@/shared/theme/DensityProvider"; + +/** Independent appearance preferences for every design-system page. */ +export function AppearanceControls() { + const { scheme, toggle } = useColorScheme(); + const { density, setDensity, saveError } = useDensity(); + + return ( +
+ +
+ Density +
+ {(["normal", "compact"] as const).map((value) => ( + + ))} +
+
+ {saveError && ( +

+ {saveError} +

+ )} +
+ ); +} diff --git a/desktop-next/src/features/design-system/ui/DesignSystemLayout.tsx b/desktop-next/src/features/design-system/ui/DesignSystemLayout.tsx index 7ab3b369afe..3c3dec25f32 100644 --- a/desktop-next/src/features/design-system/ui/DesignSystemLayout.tsx +++ b/desktop-next/src/features/design-system/ui/DesignSystemLayout.tsx @@ -2,7 +2,7 @@ import "../catalog/catalog.css"; import { Link, Outlet } from "@tanstack/react-router"; import { Fragment, type ReactNode } from "react"; -import { useColorScheme } from "@/shared/theme/useColorScheme"; +import { AppearanceControls } from "./AppearanceControls"; /** A nav entry, optionally with children shown indented beneath it. */ type NavItem = [label: string, to: string, children?: Array<[string, string]>]; @@ -66,8 +66,6 @@ function NavLink({ } export function DesignSystemLayout() { - const { scheme, toggle } = useColorScheme(); - return ( /* Narrow: the nav stacks above the content as a wrapped list, because a 256px column beside a reading column leaves neither enough room. From lg @@ -75,7 +73,7 @@ export function DesignSystemLayout() {
diff --git a/desktop-next/src/features/design-system/ui/FoundationRoles.tsx b/desktop-next/src/features/design-system/ui/FoundationRoles.tsx index c0c01876760..0a6d7a65951 100644 --- a/desktop-next/src/features/design-system/ui/FoundationRoles.tsx +++ b/desktop-next/src/features/design-system/ui/FoundationRoles.tsx @@ -1,11 +1,16 @@ import type { CSSProperties } from "react"; -import { FOUNDATION_GROUPS } from "@buzz/design-tokens/foundations"; +import { + COMPACT_FOUNDATIONS, + FOUNDATION_GROUPS, +} from "@buzz/design-tokens/foundations"; +import { useDensity } from "@/shared/theme/DensityProvider"; /** Render geometry from the actual public custom properties, alongside registry values. */ export function FoundationRoles({ name, }: { name: (typeof FOUNDATION_GROUPS)[number]["name"]; }) { + const { density } = useDensity(); const group = FOUNDATION_GROUPS.find((group) => group.name === name); if (!group) throw new Error("Missing foundation group"); return ( @@ -33,7 +38,11 @@ export function FoundationRoles({ style={style} /> )} - {value} + + {density === "compact" + ? (COMPACT_FOUNDATIONS[token] ?? value) + : value} +
{description}
diff --git a/desktop-next/src/main.tsx b/desktop-next/src/main.tsx index 4d0c11ee4bd..dc21ef7dc51 100644 --- a/desktop-next/src/main.tsx +++ b/desktop-next/src/main.tsx @@ -7,6 +7,12 @@ import { RouterProvider, createRouter } from "@tanstack/react-router"; import "./shared/styles/globals.css"; import { routeTree } from "./app/routeTree.gen"; +import { + DensityProvider, + initializeDensity, +} from "./shared/theme/DensityProvider"; + +const initialDensity = initializeDensity(); const router = createRouter({ routeTree }); declare module "@tanstack/react-router" { @@ -20,6 +26,8 @@ if (!root) throw new Error("Missing #root element"); createRoot(root).render( - + + + , ); diff --git a/desktop-next/src/shared/theme/DensityProvider.tsx b/desktop-next/src/shared/theme/DensityProvider.tsx new file mode 100644 index 00000000000..d2dcf629d6d --- /dev/null +++ b/desktop-next/src/shared/theme/DensityProvider.tsx @@ -0,0 +1,84 @@ +import { + createContext, + type ReactNode, + useContext, + useEffect, + useLayoutEffect, + useState, +} from "react"; + +/** Layout density is independent of color scheme and readable text size. */ +export type Density = "normal" | "compact"; +const STORAGE_KEY = "buzz-next-density"; +const normalize = (value: string | null): Density => + value === "compact" ? "compact" : "normal"; + +/** Apply the saved density before mounting, including portal geometry. */ +export function initializeDensity(): Density { + let density: Density = "normal"; + try { + density = normalize(localStorage.getItem(STORAGE_KEY)); + } catch { + // Storage is optional. A blocked read cannot supply a saved preference. + } + document.documentElement.dataset.density = density; + return density; +} + +const DensityContext = createContext<{ + density: Density; + setDensity: (density: Density) => void; + saveError: string | null; +} | null>(null); + +/** One root preference survives route changes and synchronizes browser tabs. */ +export function DensityProvider({ + initialDensity, + children, +}: { + initialDensity: Density; + children: ReactNode; +}) { + const [density, updateDensity] = useState(initialDensity); + const [saveError, setSaveError] = useState(null); + + useLayoutEffect(() => { + document.documentElement.dataset.density = density; + }, [density]); + + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.storageArea !== localStorage) return; + if (event.key !== STORAGE_KEY && event.key !== null) return; + updateDensity(normalize(event.newValue)); + setSaveError(null); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + + const setDensity = (next: Density) => { + updateDensity(next); + try { + localStorage.setItem(STORAGE_KEY, next); + setSaveError(null); + } catch { + setSaveError( + "Density changed for this visit. Your browser couldn’t save it. Select it again to retry.", + ); + } + }; + + return ( + + {children} + + ); +} + +/** Read or change the application's shared density preference. */ +export function useDensity() { + const value = useContext(DensityContext); + if (!value) throw new Error("useDensity requires DensityProvider"); + return value; +} diff --git a/desktop-next/tests/density.spec.ts b/desktop-next/tests/density.spec.ts new file mode 100644 index 00000000000..cedbc96df7d --- /dev/null +++ b/desktop-next/tests/density.spec.ts @@ -0,0 +1,170 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; +import { waitForAnimations } from "../../desktop/tests/helpers/animations"; + +test("density changes geometry throughout without changing type or losing a draft", async ({ + page, + context, +}) => { + await page.goto("/design/components"); + const normal = page.getByRole("button", { name: "Normal", exact: true }); + const compact = page.getByRole("button", { name: "Compact", exact: true }); + const button = page.getByRole("button", { name: "Continue", exact: true }); + const input = page.getByRole("textbox", { + name: "Project name", + exact: true, + }); + const draft = page.getByRole("textbox", { name: "Prompt the assistant" }); + await expect(normal).toHaveAttribute("aria-pressed", "true"); + await expect(button).toHaveCSS("height", "40px"); + await expect(input).toHaveCSS("font-size", "14px"); + await draft.fill("Keep my draft while changing density"); + const anotherTab = await context.newPage(); + await anotherTab.goto("/design/glass"); + + await compact.focus(); + await compact.press("Space"); + await expect(compact).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator("html")).toHaveAttribute("data-density", "compact"); + await expect(anotherTab.locator("html")).toHaveAttribute( + "data-density", + "compact", + ); + await expect(button).toHaveCSS("height", "32px"); + await expect(input).toHaveCSS("height", "32px"); + await expect(input).toHaveCSS("font-size", "14px"); + await expect(draft).toHaveValue("Keep my draft while changing density"); + await expect(page.locator("#card .bui-card")).toHaveCSS("padding", "16px"); + await expect(page.locator("#table td").first()).toHaveCSS("padding", "8px"); + await expect(page.locator(".catalog-grid")).toHaveCSS("row-gap", "32px"); + await expect(page.locator("main header").first()).toHaveCSS( + "margin-bottom", + "42px", + ); + await expect(draft).toHaveCSS("min-height", "64px"); + + // Portals are outside the catalog but inherit the same root preference. + await page.getByRole("button", { name: "Edit project", exact: true }).click(); + await expect(page.getByRole("dialog")).toHaveCSS("padding", "16px"); + await expect( + page.getByRole("button", { name: "Done", exact: true }), + ).toHaveCSS("height", "32px"); + await page.keyboard.press("Escape"); + await page.getByRole("combobox", { name: "Digest frequency" }).click(); + await expect(page.getByRole("option").first()).toHaveCSS( + "padding", + "4px 8px", + ); + await page.keyboard.press("Escape"); + + await page.getByRole("button", { name: "Switch to dark mode" }).click(); + await expect(page.locator("html")).toHaveClass(/dark/); + await expect(button).toHaveCSS("height", "32px"); + await page.reload(); + await expect(compact).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator("html")).toHaveClass(/dark/); + await page + .getByRole("navigation", { name: "Design system", exact: true }) + .getByRole("link", { name: "Spacing", exact: true }) + .click(); + const token = page + .locator("dl > div") + .filter({ has: page.getByText("control-md", { exact: true }) }); + await expect(token).toContainText("2rem"); + await page + .getByRole("navigation", { name: "Design system", exact: true }) + .getByRole("link", { name: "Compositions", exact: true }) + .click(); + await expect(page.locator(".bui-card").first()).toHaveCSS("padding", "16px"); + await normal.click(); + await expect(page.locator(".bui-card").first()).toHaveCSS("padding", "24px"); + await expect(anotherTab.locator("html")).toHaveAttribute( + "data-density", + "normal", + ); + await expect(page.locator("html")).toHaveClass(/dark/); + await anotherTab.close(); +}); + +test("compact works at narrow and wide sizes in both themes, including Glass tabs", async ({ + page, +}, testInfo) => { + await page.addInitScript(() => + localStorage.setItem("buzz-next-density", "compact"), + ); + await page.goto("/design/components"); + for (const width of [390, 884, 1440]) { + await page.setViewportSize({ width, height: 960 }); + for (const scheme of ["light", "dark"] as const) { + const changeTheme = page.getByRole("button", { + name: `Switch to ${scheme} mode`, + }); + if (await changeTheme.count()) await changeTheme.click(); + await expect(page.locator(".catalog-entry")).toHaveCount(55); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + const composer = page.locator("#ai-composer .bui-composer"); + expect( + await composer.evaluate((el) => el.scrollWidth <= el.clientWidth), + ).toBe(true); + await composer.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await composer.screenshot({ + path: testInfo.outputPath(`compact-composer-${width}-${scheme}.png`), + }); + } + } + const axe = await new AxeBuilder({ page }) + .disableRules(["color-contrast"]) + .analyze(); + expect(axe.violations).toEqual([]); + await page.goto("/design/glass"); + const tabs = page.getByRole("tablist"); + await tabs.getByRole("tab", { name: "Me", exact: true }).click(); + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("Enter"); + await expect( + tabs.getByRole("tab", { name: "Messages", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect(tabs.getByRole("tab").first()).toHaveCSS("padding-left", "12px"); + await page.getByRole("button", { name: "Normal", exact: true }).click(); + await expect(tabs.getByRole("tab").first()).toHaveCSS("padding-left", "20px"); + await expect( + tabs.getByRole("tab", { name: "Messages", exact: true }), + ).toHaveAttribute("aria-selected", "true"); +}); + +test("invalid saved density falls back and a failed save stays usable with retry", async ({ + page, +}) => { + await page.addInitScript(() => { + localStorage.setItem("buzz-next-density", "invalid"); + const original = Storage.prototype.setItem; + let shouldFail = true; + Storage.prototype.setItem = function (key, value) { + if (key === "buzz-next-density" && shouldFail) { + shouldFail = false; + throw new DOMException("Storage unavailable", "QuotaExceededError"); + } + return original.call(this, key, value); + }; + }); + await page.goto("/design/components"); + await expect( + page.getByRole("button", { name: "Normal", exact: true }), + ).toHaveAttribute("aria-pressed", "true"); + const compact = page.getByRole("button", { name: "Compact", exact: true }); + await compact.click(); + await expect(page.getByText(/Your browser couldn’t save it/)).toBeVisible(); + await expect( + page.getByRole("button", { name: "Continue", exact: true }), + ).toHaveCSS("height", "32px"); + await compact.click(); + await expect(page.getByText(/Your browser couldn’t save it/)).toHaveCount(0); + expect( + await page.evaluate(() => localStorage.getItem("buzz-next-density")), + ).toBe("compact"); +}); diff --git a/packages/design-tokens/README.md b/packages/design-tokens/README.md index 2bca927e4d3..a8b87ce54c5 100644 --- a/packages/design-tokens/README.md +++ b/packages/design-tokens/README.md @@ -5,3 +5,24 @@ Portable, locally owned design foundations. Color and typography CSS hold the au Import the CSS exports with Tailwind v4. The UI package will also provide compiled CSS for applications without Tailwind. Switch color scheme with a dark class on the root. Fonts are supplied by the consuming application; the reference uses Inter Variable and JetBrains Mono from public packages. Run the repository-pinned Node against build.mjs to regenerate foundation CSS; --check detects drift. + +## Density + +Normal is the default. Set `data-density="compact"` on the root `` element +for Compact; remove it or set `data-density="normal"` to restore Normal. The root +scope includes portaled menus, dialogs, and toasts. This works with both the +Tailwind source exports and the UI package's compiled CSS. + +`FOUNDATION_GROUPS` holds Normal values; `COMPACT_FOUNDATIONS` holds the proposed +Compact overrides in the same source file. The generator emits both. Controls, +padding, grouped spacing, multiline fields, chips, and surface radii use these +roles. Tailwind's `--spacing` layout rhythm changes from 0.25rem to 0.1875rem so +utility-based layouts participate as well. That includes numeric width/height +utilities: use an explicit semantic dimension when a region must keep its size +(the catalog's navigation rail does). Content-driven and reading widths remain +independent of density. + +Typography, icons, color, motion, scrollbars, checkable control marks, and resize +hit areas keep their values. Root font-size still scales both profiles with zoom; +`--type-scale` remains the separate text preference. Consumer applications own +persistence; no browser storage is accessed by the token or component packages. diff --git a/packages/design-tokens/build.mjs b/packages/design-tokens/build.mjs index c946387ae4f..478ee040e5a 100644 --- a/packages/design-tokens/build.mjs +++ b/packages/design-tokens/build.mjs @@ -1,11 +1,16 @@ import { readFileSync, writeFileSync } from "node:fs"; -import { FOUNDATION_GROUPS } from "./src/foundations.ts"; +import { COMPACT_FOUNDATIONS, FOUNDATION_GROUPS } from "./src/foundations.ts"; const declarations = FOUNDATION_GROUPS.flatMap((g) => g.roles.map(([name, value]) => " --" + name + ": " + value + ";"), ).join("\n"); +const compact = Object.entries(COMPACT_FOUNDATIONS) + .map(([name, value]) => ` --${name}: ${value};`) + .join("\n"); const css = "/* Generated by packages/design-tokens/build.mjs. */\n@theme {\n" + declarations + + '\n}\n:root[data-density="compact"] {\n' + + compact + "\n}\n@media (prefers-reduced-motion: reduce) {\n :root {\n --duration-state: 0ms;\n --duration-enter: 0ms;\n --duration-exit: 0ms;\n --press-scale: 1;\n }\n}\n"; const path = new URL("./src/foundations.css", import.meta.url); if (process.argv.includes("--check")) { diff --git a/packages/design-tokens/src/foundations.css b/packages/design-tokens/src/foundations.css index eb3a5668e86..7c691c38582 100644 --- a/packages/design-tokens/src/foundations.css +++ b/packages/design-tokens/src/foundations.css @@ -3,18 +3,26 @@ --control-sm: 2rem; --control-md: 2.5rem; --control-lg: 3.25rem; + --control-calendar: 2.25rem; + --control-textarea-min: 7rem; --control-multiline-min: 5rem; --control-multiline-max: 16rem; --size-scrollbar: 0.375rem; --size-grip-length: 1rem; --size-grip-thickness: 0.25rem; + --size-resize-target: 1.5rem; --space-action-inset: 1.25rem; --space-tooltip-block: 0.125rem; --layer-dialog: 80; --layer-floating: 100; --layer-toast: 120; --overlay-scrim: rgb(0 0 0 / 0.5); + --spacing: 0.25rem; --space-control-gap: 0.5rem; + --space-chip-block: 0.375rem; + --space-chip-inline: 0.625rem; + --space-catalog-gap: 3rem; + --size-catalog-preview: 10rem; --space-control-inset: 0.75rem; --space-heading-gap: 1rem; --space-section-gap: 1.5rem; @@ -33,6 +41,32 @@ --ease-standard: cubic-bezier(0.2, 0, 0, 1); --press-scale: 0.97; } +:root[data-density="compact"] { + --control-sm: 1.75rem; + --control-md: 2rem; + --control-lg: 2.5rem; + --control-calendar: 1.75rem; + --control-textarea-min: 5rem; + --control-multiline-min: 4rem; + --space-action-inset: 0.75rem; + --spacing: 0.1875rem; + --space-control-gap: 0.25rem; + --space-control-inset: 0.5rem; + --space-heading-gap: 0.5rem; + --space-section-gap: 1rem; + --space-panel-inset: 1rem; + --space-page-inset: 1.5rem; + --space-chip-block: 0.25rem; + --space-chip-inline: 0.5rem; + --space-catalog-gap: 2rem; + --size-catalog-preview: 7rem; + --radius-control: 0.375rem; + --radius-notice: 0.5rem; + --radius-menu: 0.75rem; + --radius-container: 1rem; + --radius-overlay: 1.5rem; + --radius-sheet: 2rem; +} @media (prefers-reduced-motion: reduce) { :root { --duration-state: 0ms; diff --git a/packages/design-tokens/src/foundations.ts b/packages/design-tokens/src/foundations.ts index 6ca22bd6ad1..4354d0e6154 100644 --- a/packages/design-tokens/src/foundations.ts +++ b/packages/design-tokens/src/foundations.ts @@ -7,6 +7,12 @@ export const FOUNDATION_GROUPS = [ ["control-sm", "2rem", "Small actions."], ["control-md", "2.5rem", "Standard actions and inputs."], ["control-lg", "3.25rem", "Prominent actions."], + ["control-calendar", "2.25rem", "Calendar day and navigation targets."], + [ + "control-textarea-min", + "7rem", + "Minimum height for longer text fields.", + ], [ "control-multiline-min", "5rem", @@ -28,6 +34,11 @@ export const FOUNDATION_GROUPS = [ "Visible resize grip length, independent of its hit area.", ], ["size-grip-thickness", "0.25rem", "Visible resize grip thickness."], + [ + "size-resize-target", + "1.5rem", + "Resize hit area preserved at either density.", + ], ["space-action-inset", "1.25rem", "Standard button horizontal padding."], ["space-tooltip-block", "0.125rem", "Compact tooltip vertical padding."], ["layer-dialog", "80", "Modal surfaces."], @@ -39,9 +50,18 @@ export const FOUNDATION_GROUPS = [ { name: "Spacing", description: - "A four-pixel rhythm, expressed in rem so the entire interface follows zoom.", + "A four-pixel layout rhythm in Normal and a three-pixel rhythm in Compact, with named component insets. Rem units keep both responsive to zoom.", roles: [ + [ + "spacing", + "0.25rem", + "Base layout rhythm for Tailwind spacing utilities.", + ], ["space-control-gap", "0.5rem", "Space between a label and its icon."], + ["space-chip-block", "0.375rem", "Chip and badge vertical inset."], + ["space-chip-inline", "0.625rem", "Chip and badge horizontal inset."], + ["space-catalog-gap", "3rem", "Space between catalog rows."], + ["size-catalog-preview", "10rem", "Minimum catalog preview height."], ["space-control-inset", "0.75rem", "Compact control padding."], [ "space-heading-gap", @@ -84,3 +104,34 @@ export const FOUNDATION_GROUPS = [ ], }, ] as const; + +/** Proposed compact density. Unlisted roles retain Normal values. Text, icons, + * motion, and fine interaction geometry are deliberately independent of density. */ +export const COMPACT_FOUNDATIONS: Partial< + Record<(typeof FOUNDATION_GROUPS)[number]["roles"][number][0], string> +> = { + "control-sm": "1.75rem", + "control-md": "2rem", + "control-lg": "2.5rem", + "control-calendar": "1.75rem", + "control-textarea-min": "5rem", + "control-multiline-min": "4rem", + "space-action-inset": "0.75rem", + spacing: "0.1875rem", + "space-control-gap": "0.25rem", + "space-control-inset": "0.5rem", + "space-heading-gap": "0.5rem", + "space-section-gap": "1rem", + "space-panel-inset": "1rem", + "space-page-inset": "1.5rem", + "space-chip-block": "0.25rem", + "space-chip-inline": "0.5rem", + "space-catalog-gap": "2rem", + "size-catalog-preview": "7rem", + "radius-control": "0.375rem", + "radius-notice": "0.5rem", + "radius-menu": "0.75rem", + "radius-container": "1rem", + "radius-overlay": "1.5rem", + "radius-sheet": "2rem", +}; diff --git a/packages/ui/README.md b/packages/ui/README.md index f7e863568a0..4f0998cd3f1 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -132,3 +132,12 @@ Invalid input renders an error and an optional host-provided retry callback. The host owns streaming assembly, action authorization, persistence, retries, and network errors. Pass a new immutable complete snapshot when it changes. The renderer is a presentation boundary, not an agent execution engine or an authorization system. + +## Normal and Compact + +All components inherit density from the root ``. +Use `data-density="normal"` (or omit it) for original sizing. Density changes +shared geometry without remounting controls or replacing their behavior. Both +profiles work in light and dark mode and follow root zoom. Text sizing is +independent. Keep the density attribute on the document root so portals inherit +it too. See the design-token package for the full profile and layout rhythm. diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 39219e5c240..2b947a61735 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -79,8 +79,8 @@ .bui-calendar button { cursor: pointer; border-radius: var(--radius-pill); - min-width: 2.25rem; - min-height: 2.25rem; + min-width: var(--control-calendar); + min-height: var(--control-calendar); display: inline-flex; justify-content: center; align-items: center; @@ -125,7 +125,7 @@ display: flex; align-items: center; justify-content: center; - width: var(--space-section-gap); + width: var(--size-resize-target); background: transparent; } .bui-resize-handle::after { @@ -137,7 +137,7 @@ } .bui-resize-handle[aria-orientation="horizontal"] { width: auto; - height: var(--space-section-gap); + height: var(--size-resize-target); } .bui-resize-handle[aria-orientation="horizontal"]::after { width: var(--size-grip-length); @@ -160,7 +160,7 @@ } .bui-chart { width: 100%; - border-spacing: 0 0.5rem; + border-spacing: 0 var(--space-control-gap); border-collapse: separate; } .bui-chart th { diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index 01cefa70c97..c6067fddf24 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -25,7 +25,7 @@ cursor: not-allowed; } .bui-textarea { - min-height: 7rem; + min-height: var(--control-textarea-min); resize: vertical; } .bui-input-group { diff --git a/packages/ui/src/styles/layout.css b/packages/ui/src/styles/layout.css index a6e48a84599..e6124618eee 100644 --- a/packages/ui/src/styles/layout.css +++ b/packages/ui/src/styles/layout.css @@ -30,7 +30,7 @@ height: 1rem; } .bui-otp-input { - width: 2.5rem; + width: var(--control-md); text-align: center; padding: var(--space-control-gap); } diff --git a/packages/ui/src/styles/surfaces.css b/packages/ui/src/styles/surfaces.css index 6818bcffaf8..2d6f8b46a1e 100644 --- a/packages/ui/src/styles/surfaces.css +++ b/packages/ui/src/styles/surfaces.css @@ -23,7 +23,7 @@ display: inline-flex; align-items: center; gap: var(--space-control-gap); - padding: 0.375rem 0.625rem; + padding: var(--space-chip-block) var(--space-chip-inline); border-radius: var(--radius-pill); background: var(--bg-inset); color: var(--text-secondary);