feat(design-system): add BlockUI logic and grammar reference

Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-09-10 05:47:38 -07:00
parent 80b61e05ba
commit 37ca6f63c7
11 changed files with 1079 additions and 0 deletions

View File

@@ -28,6 +28,15 @@ Direct links and refreshes work with the same SPA fallback as the rest of the
site; unknown component IDs have a recovery link. Theme and density preferences
are shared.
## Logic & grammar
`/design/grammar` is a compact BlockUI reference under System. Expand a topic to
read its selection rules, usage boundaries, and links to the verified upstream
snapshot. Topic links support direct loading and refresh. It covers foundations,
component contracts, applet composition, and generation constraints; the final
topic records differences from Buzz's active token contract and upstream gaps.
Source links require Block access, but the page has no private runtime dependency.
## Appearance
The navigation contains independent theme and Normal / Compact controls. Density

View File

@@ -55,3 +55,7 @@ nested navigation links, unknown-ID recovery, input and keyboard interactions,
portal density, refresh, and appearance preferences. Representative pages are
checked at 390/884/1440 in both densities and audited in both themes, with distinct
per-component screenshots.
Grammar-page coverage checks sidebar navigation, keyboard disclosure controls,
reopening the same topic link, hash links across reloads, all 19 typography rows,
source-link destinations, responsive tables, and accessibility in both themes.

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { GrammarPage } from "@/features/design-system/ui/GrammarPage";
export const Route = createFileRoute("/design/grammar")({
component: GrammarPage,
});

View File

@@ -47,6 +47,25 @@
min-width: 0;
max-width: 100%;
}
.grammar-section {
scroll-margin-top: var(--space-panel-inset);
}
.grammar-section:target {
outline: 2px solid var(--ring-focus);
outline-offset: 4px;
}
.grammar-table {
table-layout: fixed;
}
.grammar-table th,
.grammar-table td {
white-space: normal;
overflow-wrap: anywhere;
vertical-align: top;
}
.grammar-table th:first-child {
width: 24%;
}
.component-page > header h1:focus {
outline: none;
}

View File

@@ -0,0 +1,427 @@
import type { GrammarSection } from "./types";
export const compositionGrammar: GrammarSection[] = [
{
id: "intent",
title: "Intent, archetype, and mode",
sources: [
"docs/intent-resolution.spec.md",
"docs/archetypes-and-modes.spec.md",
],
rows: [
[
"Pipeline",
"Domain → archetype → mode → anchor → visualization → support → density.",
"Data shape chooses the visualization after the archetype is selected.",
],
[
"Track",
"Monitor a changing value.",
"A goal, event review, or derived recommendation has a different job.",
],
[
"Goal",
"Show progress toward a target or limit.",
"A timeline or member avatar does not turn a goal into Activity.",
],
[
"Activity",
"Review events, dates, people, or recent items.",
"Use the user’s job, not the appearance of the chart, to classify it.",
],
[
"Insight",
"Explain a derived finding or suggestion.",
"Avoid relabeling an ordinary raw metric as an insight.",
],
[
"Archetype tie-break",
"The user’s verb overrides the domain default; for vague intent, try Insight → Goal → Activity → Track.",
"Track is the final fallback, accompanied by a warning.",
],
[
"Status",
"The answer leads with current state.",
"A supporting button or toggle does not make it Action mode.",
],
[
"Plan",
"The answer leads with a future date, schedule, or forecast.",
"Use Action when a recommendation or decision leads the answer.",
],
[
"Action",
"The anchor leads with the recommendation; the decision control belongs in support.",
"This test checks the resolved output; intent-side signal extraction remains proposed.",
],
[
"Unknown domain",
"Use neutral tokens and resolve by the user’s job.",
"Do not invent product tokens or a new taxonomy member.",
],
],
},
{
id: "slots",
title: "Applet slots and compatibility",
sources: [
"docs/slot-rules.spec.md",
"docs/intent-resolution.spec.md",
"docs/anti-slop.spec.md",
],
rows: [
[
"Reading order",
"Heading → A: anchor → B: visualization → C: support.",
"One subject, one lead answer, at most one visualization, and one support area; no nested applets.",
],
[
"Heading",
"A short subject identifier.",
"Applet kickers use the source’s small mono treatment; desktop artifact headings have different rules.",
],
[
"amount",
"A single figure that answers the question.",
"Applet bare amounts use regular headline-large, overriding the general numeral rule.",
],
[
"amountWithTrend",
"A value whose change is part of the answer.",
"Requires a comparison value; otherwise fall back to amount.",
],
[
"multiAmount",
"Two figures that need each other for meaning.",
"Use amount when only one figure exists.",
],
[
"text",
"A named period, subject, or conclusion.",
"It can accompany a chart when both concern the same topic.",
],
[
"Visualization",
"Develop the anchor’s subject with one data-appropriate figure.",
"A composite can count as one figure when it has one clear primary element.",
],
[
"Support",
"A useful takeaway or one available next step.",
"Actions belong only here; missing actions become context text, not dead buttons.",
],
[
"Actions",
"One secondary CTA; two only for an established binary choice.",
"The applet itself is the primary action. Size permission does not prove renderer readiness.",
],
[
"Shell",
"The applet reference specifies radius 32 and padding 24 at each size.",
"These are applet-specific reference values, not overrides of Buzz density tokens.",
],
],
},
{
id: "visualization",
title: "Data shape → visualization",
sources: [
"docs/intent-resolution.spec.md",
"docs/archetypes-and-modes.spec.md",
],
rows: [
[
"Per-period ranges",
"rangeBar",
"Distinguish volatility from a simple trend.",
],
[
"Trend over time",
"line",
"Do not use a smooth trend to imply unavailable ranges.",
],
[
"Future projection",
"forecast",
"Keep projected and observed values distinguishable.",
],
[
"Progress to one target",
"progressRing or progressBar",
"A collection of goals may need an activityList.",
],
[
"Parts of a whole",
"barBreakdown",
"Keep the focal category separate from neutral context.",
],
["Dated events", "calendar", "Ordered steps instead call for timeline."],
[
"Steps or milestones",
"timeline",
"Must survive the available height, or simplify.",
],
[
"Recent items",
"activityList",
"Avoid cramming a multi-row list into a short band.",
],
[
"People",
"avatarGroup",
"Membership can support a primary progress visualization.",
],
[
"A specific card",
"cardAsset",
"Use an existing taxonomy member and verified asset.",
],
[
"Locations",
"map",
"The reference marks glossary availability as pending; verify renderer support.",
],
],
},
{
id: "palettes",
title: "Archetype palettes",
sources: ["docs/archetypes-and-modes.spec.md"],
rows: [
[
"Track",
"Range/line series, breakdown, item list, or metric.",
"Amounts lead; support can be context, one action, attribution, or a reversible toggle.",
],
[
"Goal",
"Progress, forecast, goals, payoff timeline, shared participants, or line.",
"Amounts lead; support is context or one action.",
],
[
"Activity",
"Calendar, item list, timeline, participants, or line.",
"An amount or text leads; support is context, one action, or a labeled push.",
],
[
"Insight",
"Breakdown, line, forecast, progress, or map.",
"A conclusion or amount leads; support is context, one action, or toggle.",
],
[
"Validation",
"Check the selected visualization against the archetype’s palette.",
"Do not change archetype merely to justify an out-of-palette visualization.",
],
],
},
{
id: "mode-matrix",
title: "The 12 default compositions",
sources: ["docs/archetypes-and-modes.spec.md"],
rows: [
[
"Track · Status",
"Amount + trend → range/line → context or toggle.",
"Defaults describe Large applets; apply size rules afterward.",
],
[
"Track · Plan",
"Amount → forecast → schedule context.",
"Keep the future framing explicit.",
],
[
"Track · Action",
"Recommendation → metric/line → action.",
"Recommendation leads; the metric becomes evidence.",
],
[
"Goal · Status",
"Amount → progress or multiple goals → action.",
"One target and many targets use different visualizations.",
],
[
"Goal · Plan",
"Amount → forecast → context.",
"The goal persists while framing becomes forward-looking.",
],
[
"Goal · Action",
"Recommendation → metric → action.",
"One clear next step.",
],
[
"Activity · Status",
"Amount → recent items → context.",
"Report current or recent state.",
],
[
"Activity · Plan",
"Period label → calendar → context.",
"The slot rules retain a support line even where older matrix shorthand omits it.",
],
[
"Activity · Action",
"Recommendation → participants/metric → action.",
"Two controls require a meaningful binary decision.",
],
[
"Insight · Status",
"Amount → line/breakdown → context.",
"Keep one synthesized subject.",
],
[
"Insight · Plan",
"Amount → line → attributed context.",
"Make the future scope clear.",
],
[
"Insight · Action",
"Recommendation → breakdown → action.",
"The evidence must support the recommendation.",
],
],
},
{
id: "degradation",
title: "Size and missing-data fallbacks",
sources: ["docs/slot-rules.spec.md"],
rows: [
[
"Large → Medium",
"Reduce height at full width, remove secondary anchor detail, and compact the visualization.",
"Keep the main answer and support; action controls remain valid at Medium.",
],
[
"Medium → Small",
"Reduce width, step the anchor down, collapse paired amounts, and remove secondary labels.",
"This is content adaptation, not uniform scaling or Buzz’s Normal/Compact switch.",
],
[
"Small support",
"Replace the action control with a status takeaway.",
"Only the source-verified compact +/− pattern is an interactive exception; do not generalize it.",
],
[
"Visualization fit",
"Compact a single band or object before dropping it.",
"A multi-row timeline or list may fail to fit; the Medium band can be shorter than Small.",
],
[
"Minimum",
"Keep the subject heading, primary answer, and a line of support.",
"An Action-mode text answer may merge into support at Medium without losing its meaning.",
],
[
"Missing comparison",
"Replace amountWithTrend with amount.",
"Never fabricate movement or comparison data.",
],
[
"Missing figure",
"Use a text takeaway.",
"An empty numeric placeholder is not an answer.",
],
[
"Sparse or absent series",
"Try a metric or short list; omit the visualization when no meaningful data remains.",
"Collapse its space instead of rendering an empty slot.",
],
[
"Missing action",
"Show useful context.",
"Never offer an unavailable operation as a working button.",
],
],
},
{
id: "generation",
title: "Generated responses and consequence",
sources: ["Design.md", "docs/role-join.md", "docs/anti-slop.spec.md"],
rows: [
[
"Response grammar",
"Answer → evidence → meaning → actions.",
"This response recipe is related to, but not identical to, an applet’s Heading/A/B/C slots.",
],
[
"Structure versus values",
"Generated content selects supported structure; the renderer supplies design values.",
"The response contract is not a channel for arbitrary CSS or new token values.",
],
[
"Specificity",
"Lead with one concrete answer, evidence on that subject, and a useful interpretation.",
"Avoid co-equal metric grids, filler text, and interpretations that only repeat the answer.",
],
[
"Traceability",
"Tie component and slot choices to a documented rule or reference example.",
"A visually plausible invention is not proof of supported composition.",
],
[
"Artifact",
"A persistent work object with identity, context, lifecycle, and ordered content blocks.",
"A hero is conditional; a block can be bare or card-backed. An applet is not synonymous with every card.",
],
[
"R0 / R1 / R2",
"Informational → personalized read → recommendation; add source evidence and reasoning as consequence grows.",
"The source’s applet corpus supports these levels; this is a reference policy.",
],
[
"R3 / R4 / R5",
"Prepared draft → reversible execution → sensitive execution; require preview, confirmation, receipt, and appropriate review/audit.",
"The source marks these as forward-looking and blocks R3+ generation pending a reviewed baseline.",
],
[
"Authorization",
"Show → explain → suggest → draft → preview → confirm → execute; escalate when needed.",
"Do not promote intent into authorization or show success before backend confirmation.",
],
[
"Readiness",
"Check target support, required states, provenance, and known gaps before generation.",
"A component being legal in the size grammar does not mean it is approved or implemented.",
],
],
},
{
id: "buzz-boundary",
title: "Reference status and Buzz differences",
sources: [
"docs/type.resolution.draft.json",
"docs/color.roles.md",
"docs/role-join.md",
"docs/archetypes-and-modes.spec.md",
"foundations/typography/typography-foundation.md",
],
rows: [
[
"Reference snapshot",
"Summarized from the linked BlockUI sources, verified September 10, 2026.",
"Drafts, proposed rules, and unresolved platform differences retain their source status.",
],
[
"Buzz fonts and type",
"Buzz uses Inter and JetBrains Mono with its existing ten-role, rem-based scale.",
"This page documents BlockUI’s nineteen roles; it does not silently replace the Buzz type contract.",
],
[
"Buzz conventions",
"Buzz retains sentence-case labels, semantic geometry, and shared Normal/Compact density.",
"BlockUI’s mono uppercase applet kicker, applet sizes, and specific numeric settings are reference-specific.",
],
[
"Usage coverage",
"The upstream role join distinguishes exact authored rules, family matches, and gaps.",
"Do not assume every available token has complete usage guidance.",
],
[
"Open questions",
"Source gaps include intent-side signal extraction, role pairing, responsive type breakpoints, and some platform bindings.",
"This outline does not resolve those gaps or implement the BlockUI planner in Buzz.",
],
],
},
];

View File

@@ -0,0 +1,258 @@
import type { GrammarSection } from "./types";
export const foundationGrammar: GrammarSection[] = [
{
id: "role-resolution",
title: "Names, values, and usage",
sources: [
"docs/role-join.md",
"docs/color.roles.md",
"foundations/roles/role-usage.json",
],
rows: [
[
"Resolution",
"Choose a semantic role, then resolve its primitive and platform binding.",
"A similarly named platform token may have a different value.",
],
[
"Role join",
"Connect the token’s dot-notation key to its usage rule; slash names can be input aliases.",
"Family-level guidance is less specific than an authored member rule.",
],
[
"Usage contract",
"Consult when, why, and do_nots together.",
"A legal token can still be wrong for the situation.",
],
[
"Missing guidance",
"Report that no rule has been authored when resolution has no exact usage match.",
"Do not manufacture a rule or claim complete coverage.",
],
[
"Color naming",
"Primary / secondary / tertiary is the chosen ordinal vocabulary.",
"The reference snapshot still contains older standard / subtle / muted keys; match their meaning.",
],
[
"New roles",
"Add a role only for a distinct intent that existing roles cannot express.",
"A new size or visual preference alone is not a new semantic role.",
],
],
},
{
id: "color",
title: "Color and emphasis",
sources: [
"docs/color.roles.md",
"docs/color.resolution.draft.json",
"foundations/roles/role-usage.json",
],
rows: [
[
"Channel",
"Choose text, icon, surface, or border according to what receives the color.",
"An unassigned channel is a deliberate limit, not permission to invent a fill.",
],
[
"Primary text",
"Titles, key values, names, body content, and primary labels.",
"Use quieter roles for supporting metadata.",
],
[
"Secondary text",
"Supporting explanations and lower-priority content.",
"Quiet content remains readable and available.",
],
[
"Tertiary text",
"Timestamps, placeholders, annotations, and other scaffolding.",
"Do not substitute disabled styling for lower emphasis.",
],
[
"Inverse",
"Pair inverse content with an inverted surface.",
"Elevation alone does not imply inversion; both colors must work in either theme.",
],
[
"Icons",
"Match the corresponding content role.",
"Do not introduce a competing emphasis level beside its label.",
],
[
"Surfaces",
"Distinguish page, inset/group, selected/emphasis, card, and floating-overlay jobs.",
"Platform-specific fills may resolve differently; follow the selected platform.",
],
[
"Borders",
"Subtle separates; standard outlines a control; prominent emphasizes an edge; focus marks keyboard focus.",
"Do not use a high-emphasis outline for an ordinary divider.",
],
[
"Status",
"Use semantic color for a real error, caution, or confirmation.",
"Product-specific channel limits apply; do not invent a success background.",
],
[
"Brand",
"Use only where an identity treatment is explicitly assigned.",
"Brand is not a general-purpose accent for actions, status, or decoration.",
],
[
"Trend",
"Use signs, arrows, and words for ordinary up/down/flat changes.",
"Direction alone does not warrant success or error color.",
],
[
"Chart focus",
"Keep context marks neutral and distinguish the focal item with an assigned product color.",
"The focal encoding is separate from trend or status semantics.",
],
],
},
{
id: "states",
title: "Interaction states",
sources: ["docs/color.resolution.draft.json", "Design.md"],
rows: [
[
"Rest",
"Use the role’s resolved default value.",
"State changes derive from that resting role.",
],
[
"Hover",
"Move the surface one step toward stronger contrast.",
"Text and icons stay stable; hover is a pointer affordance.",
],
[
"Pressed",
"The reference color model moves the surface two contrast steps.",
"Follow the target platform’s active-state behavior.",
],
[
"Selected",
"Use a persistent neutral contrast change.",
"A narrow product-specific colored selection exception is not a global accent rule.",
],
[
"Focus",
"Show the keyboard focus ring without changing the fill.",
"Keep focus distinct from hover and selection.",
],
[
"Disabled",
"Use disabled tokens and actual unavailable-control semantics.",
"Do not use disabled to mean merely secondary, or rely only on color.",
],
[
"Loading",
"Represent the pending action in the control’s state contract.",
"Preserve its accessible name and avoid implying completion.",
],
],
},
{
id: "geometry",
title: "Spacing, shape, and grouping",
sources: [
"Design.md",
"docs/shape.resolution.draft.json",
"foundations/roles/role-usage.json",
"foundations/elevation/README.md",
"foundations/motion/README.md",
],
rows: [
[
"Spacing",
"BlockUI’s structural reference starts from an eight-unit rhythm.",
"Observed spacing examples are not universal prescriptions; specific surface rules take precedence.",
],
[
"Grouping",
"Try whitespace first, a divider next, and a container when stronger separation is needed.",
"Do not frame every related pair of elements.",
],
[
"control",
"Input-scale fields and small interactive elements.",
"Avoid content-container corners on small controls.",
],
[
"menu",
"Lightweight menus and transient popovers.",
"A primary modal or owned content surface uses a container role.",
],
[
"container",
"Cards and primary content or modal containers.",
"The applet shell has its own more specific geometry.",
],
[
"overlay / sheet",
"Large edge-attached overlays and bottom sheets.",
"The source documents platform differences rather than one universal radius.",
],
[
"pill",
"Fully rounded shapes wider than they are tall.",
"Resolve full rounding as a shape, not an arbitrary corner value.",
],
[
"circular",
"Square avatars, round icon controls, and other equal-sided shapes.",
"Do not force variable-width text into a circle.",
],
[
"Elevation / motion",
"Consult the foundation handoffs for platform-specific behavior and open work.",
"Elevation is a proposed lane in this snapshot; the motion comparison is not one settled universal grammar.",
],
],
},
{
id: "components",
title: "Component contracts",
sources: ["Design.md"],
rows: [
[
"Base component",
"A product-independent job with reusable content, state, interaction, or accessibility behavior.",
"Complexity does not disqualify a Table or Calendar from being a base component.",
],
[
"Product composition",
"A domain-specific workflow assembled from base components.",
"Repeated use alone does not make its product policy a base contract.",
],
[
"Button axes",
"Choose emphasis/tone and size independently.",
"A screen-level CTA is a layout role, not a special size token.",
],
[
"Action hierarchy",
"Usually one primary action per task context; supporting actions use secondary treatment.",
"Reserve ghost treatment for genuinely contextual or lower-priority actions.",
],
[
"Button anatomy",
"A text label with optional leading and trailing content.",
"Icon-only controls need an accessible name.",
],
[
"Ownership",
"Button owns the trigger; menu owns popup items, focus, keyboard navigation, and dismissal.",
"Select and Combobox own form-value selection; links own navigation.",
],
[
"Fields",
"Associate a label, help or validation, and the input.",
"Do not remove functional help when simplifying surrounding documentation.",
],
],
},
];

View File

@@ -0,0 +1,11 @@
/** A compact, cited summary of a BlockUI decision and its limits. */
export interface GrammarSection {
id: string;
title: string;
sources: string[];
rows: Array<[choice: string, use: string, boundary: string]>;
}
/** Verified upstream snapshot; reference links never load assets into the catalog. */
export const BLOCKUI_REFERENCE =
"https://github.com/squareup/design-blockinterface/blob/c89319ea3e11a35d58f19ba83f4d976d143e7485/blockUI/";

View File

@@ -0,0 +1,148 @@
import type { GrammarSection } from "./types";
export const typographyGrammar: GrammarSection[] = [
{
id: "type-selection",
title: "Choosing typography",
sources: ["docs/type.roles.md", "docs/type.resolution.draft.json"],
rows: [
[
"Content first",
"Choose Body for prose, Label for names, Link for navigation, Numeral for amounts, and Display for identity.",
"Equal size does not make roles interchangeable.",
],
[
"Weight",
"Regular supports reading; Medium identifies controls, structure, and numeric values.",
"Weight belongs to the role; it is not an independent emphasis dial.",
],
[
"Complete setting",
"Resolve size, leading, tracking, weight, and family together.",
"Avoid assembling an unregistered combination.",
],
[
"Numbers",
"General numeral roles use Medium, tabular figures, and zero tracking.",
"Applet anchors have a more specific rule: regular headline roles, described below.",
],
[
"Links",
"Use the Link roles and their underline treatment.",
"A button is an action component; a link is navigation.",
],
[
"Monospace",
"BlockUI reserves Cash Sans Mono for its smallest Detail tier.",
"Buzz uses open-source fonts and has a separate code role; this reference does not change it.",
],
[
"Collisions",
"Use page-title for screen identity and numeral-small for an amount, even at the same size.",
"Use the role’s exclusion rule to resolve ambiguity.",
],
],
},
{
id: "type-roles",
title: "The 19 typography roles",
sources: ["docs/type.roles.md"],
rows: [
[
"display/hero",
"Expressive welcome or brand statement.",
"Use page-title for ordinary screen identity; numeral-large for a number.",
],
[
"display/numeral-large",
"The main amount the screen concerns.",
"Use hero for words; numeral-small for a supporting amount.",
],
[
"display/headline-large",
"Leading editorial or marketing headline.",
"Use page-title for application structure.",
],
[
"display/page-title",
"Screen identity or modal title.",
"Use section-title for a content group; headline-small for editorial tone.",
],
[
"display/headline-small",
"Secondary editorial headline.",
"Use page-title for a structural screen title.",
],
[
"display/numeral-small",
"A supporting metric, card amount, or row total.",
"Use a title role when the content names a screen.",
],
[
"display/section-title",
"Heading that groups related content.",
"Use page-title for the whole screen.",
],
[
"body/body-large",
"A lead paragraph or blockquote.",
"Use body-medium for ordinary prose; label-large for a name.",
],
[
"body/label-large",
"A prominent control or group label.",
"Use body-large for prose; label-medium for standard labels.",
],
[
"body/body-medium",
"Default paragraphs, primary content, and messages.",
"Use label-medium for controls; body-small for supporting text.",
],
[
"body/label-medium",
"Form labels, control names, and primary row labels.",
"Use body-medium for running prose.",
],
[
"body/link-medium",
"A link at the normal reading scale.",
"Use Button for a full action control.",
],
[
"body/body-small",
"Supporting copy and dense table cells.",
"Use body-medium for the main reading content.",
],
[
"body/label-small",
"Badge text, chip labels, and table headers.",
"Use body-small for reading text.",
],
[
"body/link-small",
"A link in a dense interface.",
"Use a larger Link role when compactness is unnecessary.",
],
[
"detail/caption",
"Metadata, timestamps, help, errors, and footnotes.",
"Use Body for primary content.",
],
[
"detail/body-xsmall",
"Fine-print identifiers and monospace data.",
"Use caption for prose.",
],
[
"detail/label-xsmall",
"Very small data labels and chart annotations.",
"Use label-small for ordinary compact labels.",
],
[
"detail/link-xsmall",
"A small link inside monospace data.",
"Use a Body Link role outside micro-data contexts.",
],
],
},
];

View File

@@ -27,6 +27,7 @@ const SECTIONS: Array<{ heading: string; items: NavItem[] }> = [
{
heading: "System",
items: [
["Logic & grammar", "/design/grammar"],
["Vocabulary", "/design/vocabulary"],
["Growing the system", "/design/growth"],
],

View File

@@ -0,0 +1,115 @@
import { Table } from "@buzz/ui";
import { useEffect } from "react";
import { foundationGrammar } from "../grammar/foundations";
import { typographyGrammar } from "../grammar/typography";
import { compositionGrammar } from "../grammar/composition";
import { BLOCKUI_REFERENCE } from "../grammar/types";
import { PageHeader } from "./primitives";
const sections = [
foundationGrammar[0],
...typographyGrammar,
...foundationGrammar.slice(1),
...compositionGrammar,
];
/** Cited BlockUI reference guidance, separate from Buzz's active token contract. */
export function GrammarPage() {
useEffect(() => {
const revealTopic = () => {
const topic = document.getElementById(window.location.hash.slice(1));
if (topic instanceof HTMLDetailsElement) {
topic.open = true;
topic.scrollIntoView({ block: "start" });
}
};
revealTopic();
window.addEventListener("hashchange", revealTopic);
return () => window.removeEventListener("hashchange", revealTopic);
}, []);
return (
<article className="grammar-page">
<PageHeader title="Logic & grammar" />
<p className="text-caption text-secondary mb-6">
BlockUI reference · Usage, boundaries, and composition · Source links
require Block access
</p>
<nav
aria-label="Grammar topics"
className="flex flex-wrap gap-x-5 gap-y-2 mb-8"
>
{sections.map((section) => (
<a
key={section.id}
href={`#${section.id}`}
onClick={() => {
const topic = document.getElementById(section.id);
if (topic instanceof HTMLDetailsElement) topic.open = true;
}}
className="text-caption text-accent underline"
>
{section.title}
</a>
))}
</nav>
<div className="flex flex-col gap-4">
{sections.map((section) => (
<details
key={section.id}
id={section.id}
className="grammar-section rounded-control bg-inset p-4"
open={section.id === "role-resolution"}
>
<summary className="cursor-pointer text-subheading text-primary">
{section.title}
</summary>
<div className="hidden sm:block">
<Table className="grammar-table mt-5">
<caption className="bui-sr-only">{section.title}</caption>
<thead>
<tr>
<th scope="col">Choice</th>
<th scope="col">Use</th>
<th scope="col">Boundary</th>
</tr>
</thead>
<tbody>
{section.rows.map(([choice, use, boundary]) => (
<tr key={choice}>
<th scope="row">{choice}</th>
<td>{use}</td>
<td>{boundary}</td>
</tr>
))}
</tbody>
</Table>
</div>
<dl className="sm:hidden mt-4 flex flex-col gap-5">
{section.rows.map(([choice, use, boundary]) => (
<div key={choice} className="flex flex-col gap-2">
<dt className="text-label text-primary break-words">
{choice}
</dt>
<dd className="text-body text-primary">{use}</dd>
<dd className="text-caption text-secondary">{boundary}</dd>
</div>
))}
</dl>
<div className="flex flex-wrap gap-x-4 gap-y-2 mt-4">
{section.sources.map((source) => (
<a
key={source}
href={`${BLOCKUI_REFERENCE}${source}`}
className="text-meta text-accent underline break-all"
>
{source.split("/").at(-1)}
</a>
))}
</div>
</details>
))}
</div>
</article>
);
}

View File

@@ -0,0 +1,81 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../../desktop/tests/helpers/animations";
test("grammar navigation opens focused topics and preserves deep links", async ({
page,
}) => {
await page.goto("/design/typography");
await page
.getByRole("navigation", { name: "Design system", exact: true })
.getByRole("link", { name: "Logic & grammar", exact: true })
.click();
await expect(
page.getByRole("heading", { name: "Logic & grammar", exact: true }),
).toBeVisible();
const topic = page.locator("#type-roles");
const link = page
.getByRole("navigation", { name: "Grammar topics" })
.getByRole("link", { name: "The 19 typography roles", exact: true });
await link.focus();
await link.press("Enter");
await expect(topic).toHaveAttribute("open", "");
await expect(topic.locator("tbody tr")).toHaveCount(19);
await expect(
topic.getByRole("rowheader", { name: "body/label-medium", exact: true }),
).toBeVisible();
await expect(
topic.getByRole("link", { name: "type.roles.md", exact: true }),
).toHaveAttribute(
"href",
/github\.com\/squareup\/design-blockinterface\/blob\/[a-f0-9]{40}\/blockUI\/docs\/type\.roles\.md$/,
);
await topic.locator("summary").press("Space");
await expect(topic).not.toHaveAttribute("open", "");
// A second click on the same hash must reopen a manually closed topic.
await link.click();
await expect(topic).toHaveAttribute("open", "");
await page.reload();
await expect(topic).toHaveAttribute("open", "");
await expect(topic).toBeInViewport();
await page.goto("/design/grammar#buzz-boundary");
await expect(page.locator("#buzz-boundary")).toHaveAttribute("open", "");
await expect(
page.getByRole("rowheader", { name: "Buzz fonts and type", exact: true }),
).toBeVisible();
});
test("grammar tables fit both densities and themes with accessible disclosures", async ({
page,
}, testInfo) => {
await page.goto("/design/grammar");
for (const width of [390, 1440]) {
await page.setViewportSize({ width, height: 960 });
for (const density of ["Normal", "Compact"]) {
await page.getByRole("button", { name: density, exact: true }).click();
for (const section of await page.locator(".grammar-section").all()) {
if (
!(await section.evaluate((el) => (el as HTMLDetailsElement).open))
) {
await section.locator("summary").click();
}
}
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
}
}
for (const mode of ["light", "dark"]) {
if (mode === "dark")
await page.getByRole("button", { name: "Switch to dark mode" }).click();
const audit = await new AxeBuilder({ page })
.disableRules(["color-contrast"])
.analyze();
expect(audit.violations).toEqual([]);
await page.locator("#type-roles").scrollIntoViewIfNeeded();
await waitForAnimations(page);
await page.screenshot({ path: testInfo.outputPath(`grammar-${mode}.png`) });
}
});