From 76525c9bdb840f4fcde7f5325b12592c4dcc841b Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Wed, 9 Sep 2026 10:32:35 -0700 Subject: [PATCH] fix(design-system): unify layout details and glass tab behavior Signed-off-by: Arjun Mahanti --- desktop-next/TESTING.md | 4 + .../features/design-system/ui/GlassPage.tsx | 37 +++--- desktop-next/tests/layout-refinements.spec.ts | 112 ++++++++++++++++++ packages/design-tokens/src/foundations.css | 3 + packages/design-tokens/src/foundations.ts | 11 ++ packages/ui/README.md | 9 +- packages/ui/src/primitives/tabs.tsx | 12 +- packages/ui/src/styles.css | 29 ++++- packages/ui/src/styles/controls.css | 16 +++ packages/ui/src/styles/layout.css | 12 +- packages/ui/src/styles/scrollbars.css | 26 ++++ 11 files changed, 240 insertions(+), 31 deletions(-) create mode 100644 desktop-next/tests/layout-refinements.spec.ts create mode 100644 packages/ui/src/styles/scrollbars.css diff --git a/desktop-next/TESTING.md b/desktop-next/TESTING.md index bb8e50449c..7c0ed40511 100644 --- a/desktop-next/TESTING.md +++ b/desktop-next/TESTING.md @@ -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. diff --git a/desktop-next/src/features/design-system/ui/GlassPage.tsx b/desktop-next/src/features/design-system/ui/GlassPage.tsx index 3789c7e37f..4eaf8b2d81 100644 --- a/desktop-next/src/features/design-system/ui/GlassPage.tsx +++ b/desktop-next/src/features/design-system/ui/GlassPage.tsx @@ -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 ( -
- {items.map(([label, selected]) => ( - + + + {items.map((label) => ( + + {label} + + ))} + + + {items.map((label) => ( + + {label} surface selected in this material preview. + ))} -
+ ); } @@ -39,7 +36,7 @@ export function GlassPage() {
diff --git a/desktop-next/tests/layout-refinements.spec.ts b/desktop-next/tests/layout-refinements.spec.ts new file mode 100644 index 0000000000..7c01ff93f4 --- /dev/null +++ b/desktop-next/tests/layout-refinements.spec.ts @@ -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, + ); +}); diff --git a/packages/design-tokens/src/foundations.css b/packages/design-tokens/src/foundations.css index a238a58687..e696553c30 100644 --- a/packages/design-tokens/src/foundations.css +++ b/packages/design-tokens/src/foundations.css @@ -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; diff --git a/packages/design-tokens/src/foundations.ts b/packages/design-tokens/src/foundations.ts index e7700e93e3..5d9e105456 100644 --- a/packages/design-tokens/src/foundations.ts +++ b/packages/design-tokens/src/foundations.ts @@ -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."], diff --git a/packages/ui/README.md b/packages/ui/README.md index 0889035f6e..ca6ad03898 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -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 diff --git a/packages/ui/src/primitives/tabs.tsx b/packages/ui/src/primitives/tabs.tsx index 0b773e77e6..3055c3a05a 100644 --- a/packages/ui/src/primitives/tabs.tsx +++ b/packages/ui/src/primitives/tabs.tsx @@ -10,11 +10,19 @@ function StyledRoot({ } function StyledList({ + variant = "solid", className, ...props -}: ComponentProps) { +}: ComponentProps & { + /** Material of the tab track; behavior and indicator motion remain shared. */ + variant?: "solid" | "glass"; +}) { return ( - + ); } diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 0ce4ace87e..01aef3d84a 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -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; diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index 540b836c1d..01cefa70c9 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -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; diff --git a/packages/ui/src/styles/layout.css b/packages/ui/src/styles/layout.css index 142441838a..a6e48a8459 100644 --- a/packages/ui/src/styles/layout.css +++ b/packages/ui/src/styles/layout.css @@ -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; diff --git a/packages/ui/src/styles/scrollbars.css b/packages/ui/src/styles/scrollbars.css new file mode 100644 index 0000000000..03edc58f94 --- /dev/null +++ b/packages/ui/src/styles/scrollbars.css @@ -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; + } + } +}