refactor(desktop-next): take colour and type specimens out of cards

A swatch on a grey fill is being judged against a surface the product will never
reproduce, and the dividers between swatches were redundant — each colour already
separates itself. Same for type specimens: a box around a size sample changes the
contrast you are evaluating it in.

Roles and specimens now sit directly on the page, separated by space alone. The
distinction is between two list shapes rather than a preference: `Rows` keeps
dividers for uniform rows where the eye needs a line to track along (the size and
tracking ramps, the audit table); `Specimens` has neither container nor divider
for entries that carry their own visible difference.

The swatch keeps its hairline. `bg-panel` is white on a white page, so without it
the most-used role in the system renders as nothing — that is a genuine boundary,
not decoration, and it is what makes the no-container treatment safe in both modes.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-09-03 13:07:51 -07:00
parent 30847f42ff
commit 61e3792118
4 changed files with 73 additions and 48 deletions

View File

@@ -58,6 +58,8 @@ Buzz is a place where people build together and bring their agents into the room
## Density and rhythm
- **Dense data renders as rows with dividers, edge to edge.** Wrapping every list item in its own card is the most common way a functional surface becomes a marketing page.
- **Content that separates itself needs no divider, and no container.** A divider is for uniform rows where the eye needs a line to track along. When each entry already carries a visible difference — a colour swatch, a type specimen, an avatar — the content is the separator, and adding a rule or a card on top is redundant structure. Space alone is enough.
- **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.

View File

@@ -7,9 +7,18 @@ import {
import { Note, PageHeader, Section, Swatch } from "./primitives";
/**
* A role, sitting directly on the page.
*
* No card and no divider: the swatch is its own separator, and a colour judged
* on a grey card is not being judged on the surface it will actually be used on.
* The hairline stays on the swatch itself — `bg-panel` is white on a white page,
* so without it the most-used role in the system renders as nothing. That is a
* genuine boundary rather than decoration.
*/
function RoleRow({ role }: { role: Role }) {
return (
<div className="flex items-start gap-4 border-b border-tertiary py-3 last:border-b-0">
<div className="flex items-start gap-4 py-2.5">
<div
className="mt-0.5 h-9 w-16 shrink-0 rounded-md border border-tertiary"
style={{ background: `var(${role.variable})` }}
@@ -92,7 +101,7 @@ export function ColourPage() {
{group.description}
</p>
</div>
<div className="rounded-xl bg-inset px-5">
<div className="mt-1 flex flex-col">
{group.roles.map((role) => (
<RoleRow key={role.token} role={role} />
))}
@@ -106,11 +115,14 @@ export function ColourPage() {
title="Deliberate exceptions"
description="Literal values exist only in the ramps, and nothing above a ramp holds one — except these. The list is short and complete on purpose: a vague exception policy is how a layered system quietly erodes."
>
<div className="flex flex-col gap-3">
{/* No swatch here to do the separating, so these entries keep a little
structure — the token name leads and the spacing groups it with its
reason. Still no card: this is prose, not data. */}
<div className="flex flex-col gap-5">
{EXCEPTIONS.map((exception) => (
<div key={exception.name} className="rounded-xl bg-inset px-5 py-4">
<code className="text-meta text-primary">{exception.name}</code>
<p className="mt-1 text-caption text-secondary">
<div key={exception.name} className="flex flex-col gap-1">
<code className="text-code text-accent">{exception.name}</code>
<p className="max-w-2xl text-caption text-secondary">
{exception.why}
</p>
</div>

View File

@@ -5,7 +5,7 @@ import {
type TypeRole,
} from "@/shared/tokens/registry";
import { Note, PageHeader, Row, Rows, Section } from "./primitives";
import { Note, PageHeader, Row, Rows, Section, Specimens } from "./primitives";
/**
* Every specimen below is set in the role it documents, so the page is the
@@ -14,23 +14,21 @@ import { Note, PageHeader, Row, Rows, Section } from "./primitives";
*/
function RoleSpecimen({ role }: { role: TypeRole }) {
return (
<Row>
<div className="flex flex-col gap-2">
<p
className={`${role.token} ${role.mono ? "font-mono" : ""} text-primary`}
>
{role.mono ? "createChannel(name, members)" : "Bring your agents in"}
</p>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<code className="text-code text-accent">{role.token}</code>
<span className="text-meta text-tertiary">{role.pointsAt}</span>
<span className="text-meta text-tertiary">
{role.size} / {role.lineHeight} / {role.tracking} / {role.weight}
</span>
</div>
<p className="max-w-xl text-caption text-secondary">{role.use}</p>
<div className="flex flex-col gap-2">
<p
className={`${role.token} ${role.mono ? "font-mono" : ""} text-primary`}
>
{role.mono ? "createChannel(name, members)" : "Bring your agents in"}
</p>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<code className="text-code text-accent">{role.token}</code>
<span className="text-meta text-tertiary">{role.pointsAt}</span>
<span className="text-meta text-tertiary">
{role.size} / {role.lineHeight} / {role.tracking} / {role.weight}
</span>
</div>
</Row>
<p className="max-w-xl text-caption text-secondary">{role.use}</p>
</div>
);
}
@@ -46,36 +44,34 @@ export function TypographyPage() {
title="The faces"
description="Both already ship in every current Buzz client, so this is a decision to keep rather than to make. Inter is drawn for interface text at small sizes, which is most of this product."
>
<Rows>
<Specimens>
{TYPE_FAMILIES.map((family) => (
<Row key={family.token}>
<div className="flex flex-col gap-1.5">
<p
className={`text-subheading text-primary ${
family.token === "font-mono" ? "font-mono" : "font-sans"
}`}
>
{family.name}
</p>
<code className="text-code text-accent">{family.token}</code>
<p className="max-w-xl text-caption text-secondary">
{family.use}
</p>
</div>
</Row>
<div key={family.token} className="flex flex-col gap-1.5">
<p
className={`text-subheading text-primary ${
family.token === "font-mono" ? "font-mono" : "font-sans"
}`}
>
{family.name}
</p>
<code className="text-code text-accent">{family.token}</code>
<p className="max-w-xl text-caption text-secondary">
{family.use}
</p>
</div>
))}
</Rows>
</Specimens>
</Section>
<Section
title="The roles"
description="Named for the job the text does, never for its size. text-title, not text-28 — a size name is a value in disguise and goes stale the moment the ramp moves. Each specimen is set in the role it documents."
>
<Rows>
<Specimens>
{TYPE_ROLES.map((role) => (
<RoleSpecimen key={role.token} role={role} />
))}
</Rows>
</Specimens>
</Section>
{TYPE_RAMPS.map((ramp) => (

View File

@@ -88,24 +88,39 @@ export function Stub({ what, decide }: { what: string; decide: string[] }) {
}
/**
* A group of rows that reads as one region.
* A list of uniform rows — the tabular case, where every row looks alike and the
* eye needs a line to track along. Dividers, no container: the section heading
* already says these belong together, so a fill behind them adds a box without
* adding meaning. See DESIGN.md § Density and rhythm.
*
* The fill does the separating, so the container needs no outline. Rows inside
* are divided by the lightest hairline in the system, because dense data wants
* an edge-to-edge divider rather than a card each — see DESIGN.md § Density.
* If the rows carry their own visual difference — a swatch, a type specimen — use
* `Specimens` instead. Content that separates itself needs no divider.
*/
export function Rows({ children }: { children: ReactNode }) {
return <div className="rounded-xl bg-inset px-5">{children}</div>;
return <div className="flex flex-col">{children}</div>;
}
export function Row({ children }: { children: ReactNode }) {
return (
<div className="border-tertiary border-b py-4 last:border-b-0">
<div className="border-tertiary border-b py-3 last:border-b-0">
{children}
</div>
);
}
/**
* A list whose entries are visibly different from each other — colour swatches,
* type specimens, elevation samples.
*
* No container and no dividers. The specimen is its own separator, and two
* things a colour system must never do are judge a swatch against a fill it
* will never sit on, or set a type specimen in a box that changes its contrast.
* Separation comes from space alone.
*/
export function Specimens({ children }: { children: ReactNode }) {
return <div className="flex flex-col gap-7">{children}</div>;
}
/** Renders a live swatch of whatever a CSS custom property currently holds. */
export function Swatch({
variable,