mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
feat(design-system): add persistent Normal and Compact density
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-3 flex flex-col items-start gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={toggle}
|
||||
aria-label={`Switch to ${scheme === "light" ? "dark" : "light"} mode`}
|
||||
>
|
||||
{scheme === "light" ? "Dark mode" : "Light mode"}
|
||||
</Button>
|
||||
<fieldset className="min-w-0">
|
||||
<legend className="mb-2 text-meta text-tertiary">Density</legend>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(["normal", "compact"] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
size="sm"
|
||||
variant={density === value ? "primary" : "ghost"}
|
||||
aria-pressed={density === value}
|
||||
onClick={() => setDensity(value)}
|
||||
>
|
||||
{value === "normal" ? "Normal" : "Compact"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
{saveError && (
|
||||
<p role="status" className="text-caption text-secondary">
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<div className="flex min-h-screen flex-col bg-panel lg:flex-row">
|
||||
<nav
|
||||
aria-label="Design system"
|
||||
className="flex shrink-0 flex-col gap-8 px-4 py-8 lg:sticky lg:top-0 lg:h-screen lg:w-64 lg:overflow-y-auto"
|
||||
className="flex shrink-0 flex-col gap-8 px-4 py-8 lg:sticky lg:top-0 lg:h-screen lg:w-[16rem] lg:overflow-y-auto"
|
||||
>
|
||||
<div className="px-3">
|
||||
<Link to="/design" className="text-label text-primary">
|
||||
@@ -122,14 +120,7 @@ export function DesignSystemLayout() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={`Switch to ${scheme === "light" ? "dark" : "light"} mode`}
|
||||
className="mx-3 self-start rounded-lg bg-inset px-3 py-2 text-label text-secondary transition-colors hover:bg-hover hover:text-primary"
|
||||
>
|
||||
{scheme === "light" ? "Dark mode" : "Light mode"}
|
||||
</button>
|
||||
<AppearanceControls />
|
||||
</nav>
|
||||
|
||||
<main className="min-w-0 flex-1 px-6 py-10 sm:px-10 lg:px-16">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
<span className="text-body text-secondary">{value}</span>
|
||||
<span className="text-body text-secondary">
|
||||
{density === "compact"
|
||||
? (COMPACT_FOUNDATIONS[token] ?? value)
|
||||
: value}
|
||||
</span>
|
||||
</dd>
|
||||
<dd className="text-caption text-secondary">{description}</dd>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<DensityProvider initialDensity={initialDensity}>
|
||||
<RouterProvider router={router} />
|
||||
</DensityProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
84
desktop-next/src/shared/theme/DensityProvider.tsx
Normal file
84
desktop-next/src/shared/theme/DensityProvider.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<DensityContext.Provider value={{ density, setDensity, saveError }}>
|
||||
{children}
|
||||
</DensityContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
170
desktop-next/tests/density.spec.ts
Normal file
170
desktop-next/tests/density.spec.ts
Normal file
@@ -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");
|
||||
});
|
||||
@@ -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 `<html>` 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.
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 `<html data-density="compact">`.
|
||||
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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.bui-textarea {
|
||||
min-height: 7rem;
|
||||
min-height: var(--control-textarea-min);
|
||||
resize: vertical;
|
||||
}
|
||||
.bui-input-group {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
height: 1rem;
|
||||
}
|
||||
.bui-otp-input {
|
||||
width: 2.5rem;
|
||||
width: var(--control-md);
|
||||
text-align: center;
|
||||
padding: var(--space-control-gap);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user