fix(design-system): unify layout details and glass tab behavior

Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-09-09 10:32:35 -07:00
parent 7c7139e3ae
commit 76525c9bdb
11 changed files with 240 additions and 31 deletions

View File

@@ -34,3 +34,7 @@ Spacing and motion regressions exercise command empty-state recovery, dialog hea
gaps, accordion density, toast entrance/exit, interrupted tab selection, and reduced
motion in the production catalog. Animation checks stretch duration for frame
inspection without adding transition properties that could mask missing motion.
Layout coverage verifies table alignment, native/custom scrollbar treatment, actual
scrolling, pointer and keyboard resizing, grip sizing under zoom, and shared Glass
tab motion, keyboard selection, and reduced motion.

View File

@@ -1,3 +1,4 @@
import { Tabs } from "@buzz/ui";
import { BLUR, RAMPS } from "@/shared/tokens/registry";
import { Note, PageHeader, Section, Swatch } from "./primitives";
@@ -5,27 +6,23 @@ import { Note, PageHeader, Section, Swatch } from "./primitives";
const GLASS = RAMPS.find((ramp) => ramp.id === "glass");
function ChromePill() {
const items: Array<[string, boolean]> = [
["Me", false],
["Messages", true],
["Projects", false],
];
const items = ["Me", "Messages", "Projects"];
return (
<div className="rim-glass blur-chrome flex items-center gap-1 rounded-full bg-chrome-glass p-1.5">
{items.map(([label, selected]) => (
<button
key={label}
type="button"
className={
selected
? "elevate-xs rounded-full bg-chrome-selected px-5 py-2 text-label text-primary"
: "rounded-full px-5 py-2 text-body text-secondary transition-colors hover:bg-chrome-glass-hover hover:text-primary"
}
>
{label}
</button>
<Tabs.Root defaultValue="Messages">
<Tabs.List variant="glass" aria-label="Glass navigation preview">
{items.map((label) => (
<Tabs.Tab key={label} value={label}>
{label}
</Tabs.Tab>
))}
<Tabs.Indicator />
</Tabs.List>
{items.map((label) => (
<Tabs.Panel key={label} value={label} className="bui-sr-only">
{label} surface selected in this material preview.
</Tabs.Panel>
))}
</div>
</Tabs.Root>
);
}
@@ -39,7 +36,7 @@ export function GlassPage() {
<Section
title="Live"
description="The chrome pill, rendered from the real tokens over the real backdrop. Hover an unselected item to see the glass hover move one step up the ramp."
description="The chrome pill, rendered from the real tokens over the real backdrop. Select a tab to see the shared indicator slide; hover an unselected item to see the glass hover move one step up the ramp."
>
<div className="texture-dots flex items-center justify-center rounded-xl bg-app px-8 py-12">
<ChromePill />

View File

@@ -0,0 +1,112 @@
import { expect, test } from "@playwright/test";
test("table cells align to the reading edge and scroll surfaces use thin bars", async ({
page,
}) => {
await page.goto("/design/components");
const cells = page.locator("#table th, #table td");
for (const cell of await cells.all())
await expect(cell).toHaveCSS("text-align", "start");
for (const selector of [
"html",
"textarea",
"#table .bui-table-scroll",
".catalog-source pre",
]) {
await expect(page.locator(selector).first()).toHaveCSS(
"scrollbar-width",
"thin",
);
}
const viewport = page.locator("#scroll-area .bui-scroll-viewport");
// Custom viewports must continue hiding the native bar, avoiding double scrollbars.
await expect(viewport).toHaveCSS("scrollbar-width", "none");
await expect(page.locator("#scroll-area .bui-scrollbar")).toHaveCSS(
"width",
"6px",
);
await viewport.press("End");
await expect
.poll(() => viewport.evaluate((node) => node.scrollTop))
.toBeGreaterThan(0);
});
test("resize grip is one rem with a usable pointer and keyboard target", async ({
page,
}) => {
await page.goto("/design/components");
const handle = page.getByRole("separator", { name: "Resize project list" });
await handle.scrollIntoViewIfNeeded();
const grip = () =>
handle.evaluate((node) => getComputedStyle(node, "::after").height);
expect(await grip()).toBe("16px");
const bounds = await handle.boundingBox();
if (!bounds) throw new Error("Missing resize target");
expect(bounds.width).toBeGreaterThanOrEqual(24);
expect(bounds.height).toBeGreaterThan(16);
const before = await handle.getAttribute("aria-valuenow");
await page.mouse.move(
bounds.x + bounds.width / 2,
bounds.y + bounds.height / 2,
);
await page.mouse.down();
await page.mouse.move(
bounds.x + bounds.width / 2 + 40,
bounds.y + bounds.height / 2,
{ steps: 5 },
);
await page.mouse.up();
await expect(handle).not.toHaveAttribute("aria-valuenow", before ?? "");
const after = await handle.getAttribute("aria-valuenow");
await handle.press("ArrowLeft");
await expect(handle).not.toHaveAttribute("aria-valuenow", after ?? "");
await page.addStyleTag({ content: "html { font-size: 20px; }" });
expect(await grip()).toBe("20px");
});
test("Glass navigation shares selection motion and keyboard semantics", async ({
page,
}) => {
await page.goto("/design/glass");
const tabs = page.getByRole("tablist", { name: "Glass navigation preview" });
const indicator = tabs.locator(".bui-tab-indicator");
await expect(
tabs.getByRole("tab", { name: "Messages", exact: true }),
).toHaveAttribute("aria-selected", "true");
await page.addStyleTag({
content: ".bui-tab-indicator { transition-duration: 2s; }",
});
await tabs.getByRole("tab", { name: "Projects", exact: true }).click();
await expect
.poll(() =>
indicator.evaluate((node) =>
node
.getAnimations()
.map((a) => (a as CSSTransition).transitionProperty),
),
)
.toContain("clip-path");
await tabs.getByRole("tab", { name: "Me", exact: true }).click();
await expect(
tabs.getByRole("tab", { name: "Me", exact: true }),
).toHaveAttribute("aria-selected", "true");
await tabs.getByRole("tab", { name: "Me", exact: true }).press("ArrowRight");
await expect(
tabs.getByRole("tab", { name: "Messages", exact: true }),
).toBeFocused();
await page.keyboard.press("Enter");
await expect(
tabs.getByRole("tab", { name: "Messages", exact: true }),
).toHaveAttribute("aria-selected", "true");
expect(await indicator.evaluate((node) => node.getAnimations().length)).toBe(
0,
);
await page.emulateMedia({ reducedMotion: "reduce" });
await tabs.getByRole("tab", { name: "Projects", exact: true }).click();
await expect(
tabs.getByRole("tab", { name: "Projects", exact: true }),
).toHaveAttribute("aria-selected", "true");
expect(await indicator.evaluate((node) => node.getAnimations().length)).toBe(
0,
);
});

View File

@@ -3,6 +3,9 @@
--control-sm: 2rem;
--control-md: 2.5rem;
--control-lg: 3.25rem;
--size-scrollbar: 0.375rem;
--size-grip-length: 1rem;
--size-grip-thickness: 0.25rem;
--space-action-inset: 1.25rem;
--space-tooltip-block: 0.125rem;
--layer-dialog: 80;

View File

@@ -7,6 +7,17 @@ export const FOUNDATION_GROUPS = [
["control-sm", "2rem", "Small actions."],
["control-md", "2.5rem", "Standard actions and inputs."],
["control-lg", "3.25rem", "Prominent actions."],
[
"size-scrollbar",
"0.375rem",
"Thin custom scrollbars and native scrollbar fallback.",
],
[
"size-grip-length",
"1rem",
"Visible resize grip length, independent of its hit area.",
],
["size-grip-thickness", "0.25rem", "Visible resize grip thickness."],
["space-action-inset", "1.25rem", "Standard button horizontal padding."],
["space-tooltip-block", "0.125rem", "Compact tooltip vertical padding."],
["layer-dialog", "80", "Modal surfaces."],

View File

@@ -53,7 +53,9 @@ refs, controlled state, and events. Styled parts merge both string and state-fun
class names. The underlying libraries own focus management, keyboard navigation,
ARIA semantics, and dismissal. Caller-supplied labels and composition still matter.
Place `Tabs.Indicator` inside `Tabs.List` for a sliding selection pill. Pointer
Place `Tabs.Indicator` inside `Tabs.List` for a sliding selection pill.
Use `Tabs.List variant="glass"` for translucent chrome; it shares the same
selection behavior and motion as the default solid variant. Pointer
selection retargets the shared 120ms transition; keyboard focus and reduced motion
keep selection immediate. Panels update immediately. Toasts use the shared
180ms entrance and 120ms exit transitions, with no movement under reduced motion.
@@ -72,6 +74,11 @@ or proprietary BlockUI package compatibility is implied.
See `../../desktop-next/OPEN_SOURCE.md` for provenance and `THIRD_PARTY_NOTICES.md`
for upstream notices. New source is covered by the repository's Apache-2.0 license.
Native scroll surfaces use thin scrollbars; custom ScrollArea bars share the
`size-scrollbar` token. Resize grips use `size-grip-length` (1rem) for their visible
length while retaining a larger pointer target. Table cells align to the reading
edge, including row and column headings.
## Generated responses
`GeneratedResponse` accepts unknown input and validates it before rendering. The

View File

@@ -10,11 +10,19 @@ function StyledRoot({
}
function StyledList({
variant = "solid",
className,
...props
}: ComponentProps<typeof Primitive.List>) {
}: ComponentProps<typeof Primitive.List> & {
/** Material of the tab track; behavior and indicator motion remain shared. */
variant?: "solid" | "glass";
}) {
return (
<Primitive.List {...props} className={skin("bui-tabs-list", className)} />
<Primitive.List
{...props}
data-variant={variant}
className={skin("bui-tabs-list", className)}
/>
);
}

View File

@@ -8,6 +8,7 @@
@import "./styles/overlays.css";
@import "./styles/surfaces.css";
@import "./styles/layout.css";
@import "./styles/scrollbars.css";
@keyframes bui-spin {
to {
transform: rotate(360deg);
@@ -120,15 +121,31 @@
background: var(--bg-inset);
}
.bui-resize-handle {
background: var(--border-tertiary);
border-radius: var(--radius-pill);
width: 0.375rem;
margin-inline: var(--space-control-gap);
display: flex;
align-items: center;
justify-content: center;
width: var(--space-section-gap);
background: transparent;
}
.bui-resize-handle:hover,
.bui-resize-handle:focus-visible {
.bui-resize-handle::after {
content: "";
width: var(--size-grip-thickness);
height: var(--size-grip-length);
border-radius: var(--radius-pill);
background: var(--border-primary);
}
.bui-resize-handle[aria-orientation="horizontal"] {
width: auto;
height: var(--space-section-gap);
}
.bui-resize-handle[aria-orientation="horizontal"]::after {
width: var(--size-grip-length);
height: var(--size-grip-thickness);
}
.bui-resize-handle:hover::after,
.bui-resize-handle:focus-visible::after {
background: var(--border-interactive);
}
.bui-sr-only {
position: absolute;
width: 1px;

View File

@@ -131,6 +131,9 @@
);
transition: clip-path var(--duration-state) var(--ease-standard);
}
.bui-tabs-list[data-variant="glass"] .bui-tab-indicator {
background: var(--bg-chrome-selected);
}
.bui-tabs-list:has(.bui-tab:focus-visible) .bui-tab-indicator {
transition: none;
}
@@ -166,6 +169,19 @@
.bui-tab-panel {
padding-block: var(--space-control-inset);
}
.bui-tabs-list[data-variant="glass"] {
background: var(--bg-chrome-glass);
backdrop-filter: blur(var(--blur-md));
box-shadow:
inset 0 1px 0 var(--rim-lit),
inset 0 -1px 0 var(--rim-shade);
}
.bui-tabs-list[data-variant="glass"] .bui-tab {
padding-inline: var(--space-action-inset);
}
.bui-tabs-list[data-variant="glass"] .bui-tab:not([data-active]):hover {
background: var(--bg-chrome-glass-hover);
}
.bui-accordion {
display: grid;
gap: 0;

View File

@@ -45,16 +45,23 @@
}
.bui-scrollbar {
display: flex;
width: 0.5rem;
width: var(--size-scrollbar);
margin: 0.25rem;
border-radius: var(--radius-pill);
background: var(--bg-inset);
background: transparent;
}
.bui-scrollbar[data-orientation="horizontal"] {
width: auto;
height: var(--size-scrollbar);
}
.bui-scroll-thumb {
width: 100%;
background: var(--border-primary);
border-radius: inherit;
}
.bui-scrollbar[data-orientation="horizontal"] .bui-scroll-thumb {
height: 100%;
}
.bui-toast-viewport {
position: fixed;
bottom: var(--space-panel-inset);
@@ -133,6 +140,7 @@
}
.bui-table th,
.bui-table td {
text-align: start;
padding: var(--space-control-inset);
border-bottom: 1px solid var(--border-tertiary);
white-space: nowrap;

View File

@@ -0,0 +1,26 @@
/* Apply one thin native treatment to pages, code, tables, inputs, and overlays.
* Base UI viewports retain their own native-scrollbar suppression. */
@layer base {
* {
scrollbar-width: thin;
scrollbar-color: var(--border-primary) transparent;
}
@supports not (scrollbar-width: thin) {
::-webkit-scrollbar {
width: var(--size-scrollbar);
height: var(--size-scrollbar);
}
::-webkit-scrollbar-thumb {
background: var(--border-primary);
border-radius: var(--radius-pill);
}
::-webkit-scrollbar-track {
background: transparent;
}
}
@media (forced-colors: active) {
* {
scrollbar-color: auto;
}
}
}