Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop-next/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions desktop-next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions desktop-next/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions desktop-next/src/features/design-system/catalog/catalog.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]>];
Expand Down Expand Up @@ -66,16 +66,14 @@ 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
it becomes the sticky side rail. */
<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">
Expand Down Expand Up @@ -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">
Expand Down
13 changes: 11 additions & 2 deletions desktop-next/src/features/design-system/ui/FoundationRoles.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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>
Expand Down
10 changes: 9 additions & 1 deletion desktop-next/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand All @@ -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 changes: 84 additions & 0 deletions desktop-next/src/shared/theme/DensityProvider.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading