desktop-new: SegmentedNavigation becomes Tabs, with a surface variant

The old name described one variant's appearance — a segmented control — which is
exactly why a second appearance felt like it needed a second component. `Tabs`
names the behaviour, so both variants sit under it naturally. Base UI's own
export is also `Tabs`, so it is imported as `BaseTabs`; that reads better anyway,
since `BaseTabs.Root` says plainly which layer each part comes from.

`variant="chrome" | "panel"` is the chrome/surface axis DESIGN.md had deferred
until a second component made the case. `IconButton` made it once; this made it
twice, on a real screen. The failure: the chrome container is `glass-2`, which
over a white panel composites to pure white, and its selected pill is `neutral-1`
— also pure white. Container and selection became one colour with only a shadow
between them, and no guard could see it because the component had no way to state
which background it expected. Retinting `--bg-chrome-selected` would only have
moved the collision.

One component with a variant rather than two components, because behaviour,
keyboard model, accessibility, props, and the Base UI parts underneath are
identical — a sibling would duplicate all of it to change how selection is drawn,
and the two would drift exactly as the four hand-assembled chrome surfaces did.
The panel variant still emits `role="tablist"`, correct `aria-selected`, roving
`tabIndex`, and arrow-key navigation, which is the concrete payoff.

Each variant states all three of its states in full rather than inheriting a
colour and overriding part of it. When a shared rule and a variant rule both set
a property, the more specific selector wins regardless of source order, and a
variant rule is always more specific — so the shared `[data-selected]` rule lost
and the selected tab silently rendered as unselected. Hover carries
`:not([data-selected])` because hover and selected genuinely overlap: a cursor
can sit on the selected tab, and DESIGN.md § State says a selected item in a
toggle group is not interactive. Those two selectors score identical specificity,
so without the exclusion only source order would keep it right. This follows
Chrome's modern CSS guidance: use `:not()` to say a state is irrelevant, never to
defend one rule from another.

`WorkspaceSurface` is renamed to `Panel` in the same change, since both touch the
same rules in components.css.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-09-07 16:00:28 -07:00
parent 698b73d943
commit db12fdb4f7
6 changed files with 490 additions and 88 deletions

View File

@@ -27,12 +27,15 @@
}
.buzz-button[data-variant="primary"] {
background: var(--bg-accent);
background: var(--purple-9);
color: var(--text-on-accent);
}
/* The accent's own hover step, not a fade. `opacity: 0.9` lightened the label
along with the fill and composited an unnamed colour nothing could measure —
the move check-color.mjs rejects everywhere else. */
.buzz-button[data-variant="primary"]:hover:not([data-disabled]) {
opacity: 0.9;
background: var(--purple-10);
}
.buzz-button[data-variant="quiet"] {
@@ -40,9 +43,17 @@
color: var(--text-primary);
}
/* Scoped with `:not([data-icon-variant])` because IconButton passes
`variant="ghost"` down to Button and carries its real appearance in
`data-icon-variant`. Both selectors match, and this one is more specific
(two pseudo-classes), so an unscoped neutral hover repainted a solid accent
IconButton light grey while its text stayed white — Lc 13, invisible. An
icon button's hover belongs to its icon variant; see below. */
.buzz-button[data-variant="quiet"]:hover:not([data-disabled]),
.buzz-button[data-variant="ghost"]:hover:not([data-disabled]) {
background: var(--bg-hover);
.buzz-button[data-variant="ghost"]:not([data-icon-variant]):hover:not(
[data-disabled]
) {
background: var(--neutral-4);
}
.buzz-button[data-disabled] {
@@ -68,25 +79,31 @@
border-radius: var(--radius-pill);
}
/* Every icon variant states its own hover. The one that did not —
`solid` — fell through to the neutral ghost hover and lost its accent. */
.buzz-button[data-icon-variant="quiet"] {
background: var(--bg-inset);
}
.buzz-button[data-icon-variant="quiet"]:hover:not([data-disabled]),
.buzz-button[data-icon-variant="ghost"]:hover:not([data-disabled]) {
background: var(--neutral-4);
}
.buzz-button[data-icon-variant="solid"] {
background: var(--bg-accent);
background: var(--purple-9);
color: var(--text-on-accent);
}
.buzz-button[data-icon-variant="chrome"] {
background: var(--bg-chrome-glass);
box-shadow:
inset 0 1px 0 var(--rim-lit),
inset 0 -1px 0 var(--rim-shade);
backdrop-filter: blur(var(--blur-md));
.buzz-button[data-icon-variant="solid"]:hover:not([data-disabled]) {
background: var(--purple-10);
}
.buzz-button[data-icon-variant="chrome"]:hover {
background: var(--bg-chrome-glass-hover);
/* Chrome sits on the backdrop and is clickable, so it takes the interactive
material whole — fill, blur, rim, and hover in one line. It used to spell
out three of those four and get the hover from a separate rule. */
.buzz-button[data-icon-variant="chrome"] {
@apply glass-primary-interactive;
}
.buzz-avatar {
@@ -118,91 +135,209 @@
object-fit: cover;
}
.workspace-surface {
/* A panel is the default opaque reading surface over the atmospheric
backdrop. Its border and threshold shadow arrive together: taking only
bg-panel would lose the boundary that lets it sit above the gradient. */
.panel {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid var(--border-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-panel);
background: var(--bg-panel-glass);
box-shadow: var(--shadow-sm);
backdrop-filter: blur(var(--blur-lg));
background: var(--bg-panel);
box-shadow: var(--shadow-xs);
}
.workspace-surface[data-variant="connected-left"] {
.panel[data-variant="connected-left"] {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.workspace-surface[data-variant="connected-right"] {
.panel[data-variant="connected-right"] {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.segmented-navigation {
display: inline-flex;
height: 2.5rem;
align-items: center;
gap: var(--space-1);
padding: var(--space-1);
border-radius: var(--radius-pill);
background: var(--bg-chrome-glass);
box-shadow:
var(--shadow-xs),
inset 0 1px 0 var(--rim-lit),
inset 0 -1px 0 var(--rim-shade);
backdrop-filter: blur(var(--blur-md));
}
/* ============================================================
Tabs — two surfaces, one control.
.segmented-navigation-list {
`data-variant="chrome"` is the glass pill for the app gradient.
`data-variant="panel"` is an underline for a plain surface.
The split exists because the chrome version failed on a white panel in a
way nothing could catch: its container is `glass-2`, which over a white
panel composites to pure white, and its selected pill is `neutral-1`,
also pure white. Container and selection became the same colour and only
a shadow separated them. That was not a wrong token value — retinting
`bg-chrome-selected` would only move the collision — it was a component
with no way to say which background it expects.
What is shared sits in the unprefixed rules: geometry, the tab box, the
Base UI wiring. What differs is only how a surface and a selection are
drawn.
============================================================ */
/* --- Shared ------------------------------------------------ */
.buzz-tabs-list {
position: relative;
display: inline-flex;
height: 2rem;
align-items: center;
gap: var(--space-1);
}
.segmented-navigation-indicator {
/* Geometry only. **Colour is not here, deliberately.**
Each variant states all three of its states — unselected, hover, selected —
in full, rather than inheriting a colour from here and overriding some of it.
That is more lines and it is the right trade: when a shared rule and a
variant rule both set the same property, which one wins depends on how
specific each selector is, not on which is written later. A variant rule is
always more specific, so it silently beat the shared `[data-selected]` rule
and repainted the *selected* tab as unselected — a state that just stopped
working, with no error.
Each variant owning its own states means no two rules ever set the colour of
the same tab, so there is nothing to resolve and no `:not()` exceptions to
reason about. Read one variant block and you know every colour it uses. */
.buzz-tabs-tab {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 0;
background: transparent;
font: inherit;
cursor: pointer;
}
/* Base UI drives both indicators from the same four custom properties
(`--active-tab-left`, `--active-tab-width`, and their siblings), so the
travelling animation is one implementation and the two surfaces only
differ in what travels. */
.buzz-tabs-indicator {
position: absolute;
top: var(--space-1);
bottom: var(--space-1);
left: 0;
width: var(--active-tab-width);
border-radius: var(--radius-pill);
background: var(--bg-chrome-selected);
box-shadow: var(--shadow-xs);
transform: translateX(var(--active-tab-left));
transition:
width var(--duration-settle) var(--easing-settle),
transform var(--duration-settle) var(--easing-settle);
}
.segmented-navigation-item {
position: relative;
z-index: 1;
/* --- chrome: a glass pill on the gradient ------------------ */
/* This was the one chrome surface carrying an outer `shadow-xs` on top of the
rim where the other three carried none. Nothing justified the difference —
same height, same backdrop, same depth — so it takes the same material and
the odd shadow is gone. */
.buzz-tabs[data-variant="chrome"] {
@apply glass-primary;
display: inline-flex;
height: 2rem;
height: 2.5rem;
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 0;
gap: var(--space-1);
padding: var(--space-1);
border-radius: var(--radius-pill);
}
.buzz-tabs[data-variant="chrome"] .buzz-tabs-list {
height: 2rem;
gap: var(--space-1);
}
.buzz-tabs[data-variant="chrome"] .buzz-tabs-tab {
height: 2rem;
border-radius: var(--radius-pill);
padding: 0 var(--space-4);
background: transparent;
color: var(--text-secondary);
font: inherit;
cursor: pointer;
}
.segmented-navigation-item:hover:not([data-selected]) {
background: var(--bg-chrome-glass-hover);
/* A hover *inside* a glass surface, which is not itself a material: no blur,
because the parent already blurs what is behind both of them, and a second
backdrop-filter here would blur an already-blurred layer.
So this is a fill only, taking the hover step of the material it sits on.
Note it composites: glass-3 over the parent's glass-2 lands near 0.82
rather than at 0.62. That is the intended direction — an item under the
cursor should read as more solid, per DESIGN.md § Surface and depth — but it
is why a hover inside glass cannot simply reuse a whole material. */
/* `:not([data-selected])` because hover and selected can both be true at once —
a cursor can sit on the selected tab — and DESIGN.md § State says a selected
item in a toggle group is not interactive, so it must not react. These two
selectors score identical specificity, so without the exclusion only source
order would keep it right, and reordering them would silently give the
selected tab a hover fill. This is the case `:not()` is *for*: stating that a
state is irrelevant, not defending a rule from another rule. */
.buzz-tabs[data-variant="chrome"] .buzz-tabs-tab:hover:not([data-selected]) {
background: var(--bg-glass-primary-hover);
color: var(--text-primary);
}
.segmented-navigation-item[data-selected] {
.buzz-tabs[data-variant="chrome"] .buzz-tabs-tab[data-selected] {
color: var(--text-primary);
}
.buzz-tabs[data-variant="chrome"] .buzz-tabs-indicator {
top: var(--space-1);
bottom: var(--space-1);
border-radius: var(--radius-pill);
background: var(--bg-chrome-selected);
box-shadow: var(--shadow-xs);
}
/* --- panel: an underline on a plain surface ---------------- */
/* No container fill and no border: on a panel the tabs are text, and the only
drawn element is the bar. That is what keeps this legible where the glass
pill was not — there is no second surface to collide with the first. */
.buzz-tabs[data-variant="panel"] {
display: flex;
align-items: center;
gap: var(--space-4);
}
.buzz-tabs[data-variant="panel"] .buzz-tabs-list {
gap: var(--space-5);
}
/* The three states, in full. Same size in every one — selection is carried by
colour and the bar, never by weight, because a weight change reflows the row
and makes the tabs shift as the selection moves.
Tertiary at rest, where chrome uses secondary: on glass the container groups
the tabs so a small step reads, but here the bar is the only mark and the
text carries more of the work. Tertiary is a meta role at the Lc 45 target
and measures 60.6 light / 52.5 dark on these surfaces. */
.buzz-tabs[data-variant="panel"] .buzz-tabs-tab {
height: 2.25rem;
padding: 0;
color: var(--text-tertiary);
}
/* Excluded for the same reason as the chrome variant above. */
.buzz-tabs[data-variant="panel"] .buzz-tabs-tab:hover:not([data-selected]) {
color: var(--text-primary);
}
.buzz-tabs[data-variant="panel"] .buzz-tabs-tab[data-selected] {
color: var(--text-primary);
}
/* Sits on the container's bottom edge and spans the tab exactly. `height` is
the hairline; the indicator inherits its horizontal travel from the shared
rule above. */
.buzz-tabs[data-variant="panel"] .buzz-tabs-indicator {
bottom: 0;
height: 2px;
border-radius: var(--radius-pill);
background: var(--text-primary);
}
.panel-header {
position: relative;
display: flex;
@@ -219,7 +354,7 @@
bottom: 0;
left: var(--space-panel-inset);
height: 1px;
background: var(--border-tertiary);
background: var(--border-primary);
content: "";
pointer-events: none;
}
@@ -255,7 +390,7 @@
}
.search-field:focus-within {
box-shadow: 0 0 0 2px var(--ring-focus);
box-shadow: 0 0 0 2px var(--purple-8);
}
.search-field input {
@@ -281,6 +416,15 @@
flex-direction: column;
}
/* The section owns its own separation rather than asking every consumer to
remember a gap. Rows inside a section sit 1px apart; sections sit
`--space-section-gap` apart, and that difference is the entire signal for
where one group ends and the next begins. Expressed as an adjacent-sibling
margin so a section stacked anywhere gets it, with no wrapper required. */
.navigator-section + .navigator-section {
margin-top: var(--space-section-gap);
}
.navigator-section-label {
min-height: var(--size-row);
margin: 0;
@@ -303,7 +447,7 @@
.buzz-button[data-navigator-row]:hover,
.buzz-button[data-navigator-row][data-selected] {
background: var(--bg-hover);
background: var(--neutral-4);
color: var(--text-primary);
}
@@ -325,14 +469,14 @@
}
.buzz-button:focus-visible,
.segmented-navigation-item:focus-visible {
outline: 2px solid var(--ring-focus);
.buzz-tabs-tab:focus-visible {
outline: 2px solid var(--purple-8);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.buzz-button,
.segmented-navigation-indicator {
.buzz-tabs-indicator {
transition-duration: 0ms;
}
}

View File

@@ -12,6 +12,9 @@ type WorkspacePanels = {
conversation: ReactNode;
};
const DEFAULT_NAVIGATOR_WIDTH = 216;
const MIN_NAVIGATOR_WIDTH = 176;
const WorkspacePanelContext = createContext<WorkspacePanels | null>(null);
function WorkspacePanel({ id }: { id: keyof WorkspacePanels }) {
@@ -27,6 +30,7 @@ const components = {
),
};
/** Layout-only workspace shell. Product panels own their own surface treatment. */
export function DockWorkspace({ panels }: { panels: WorkspacePanels }) {
const onReady = useCallback((event: DockviewReadyEvent) => {
if (event.api.panels.length > 0) return;
@@ -34,7 +38,8 @@ export function DockWorkspace({ panels }: { panels: WorkspacePanels }) {
id: "navigator",
component: "navigator",
title: "Browse",
initialWidth: 255,
initialWidth: DEFAULT_NAVIGATOR_WIDTH,
minimumWidth: MIN_NAVIGATOR_WIDTH,
});
navigator.group.header.hidden = true;
const conversation = event.api.addPanel({
@@ -44,6 +49,9 @@ export function DockWorkspace({ panels }: { panels: WorkspacePanels }) {
position: { referencePanel: navigator, direction: "right" },
});
conversation.group.header.hidden = true;
requestAnimationFrame(() => {
navigator.group.api.setSize({ width: DEFAULT_NAVIGATOR_WIDTH });
});
}, []);
return (

View File

@@ -1,26 +1,26 @@
import type { ComponentProps, ElementType, ReactNode } from "react";
type SurfaceVariant =
type PanelVariant =
| "panel"
| "connected-left"
| "connected-right"
| "navigator-demo";
type WorkspaceSurfaceProps<T extends ElementType> = {
type PanelProps<T extends ElementType> = {
as?: T;
children: ReactNode;
variant?: SurfaceVariant;
variant?: PanelVariant;
} & Omit<ComponentProps<T>, "as" | "children" | "className">;
export function WorkspaceSurface<T extends ElementType = "section">({
export function Panel<T extends ElementType = "section">({
as,
children,
variant = "panel",
...props
}: WorkspaceSurfaceProps<T>) {
}: PanelProps<T>) {
const Component = as ?? "section";
return (
<Component {...props} className="workspace-surface" data-variant={variant}>
<Component {...props} className="panel" data-variant={variant}>
{children}
</Component>
);

View File

@@ -1,45 +1,71 @@
import { Tabs } from "@base-ui/react/tabs";
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
import type { ReactNode } from "react";
export type SegmentedNavigationItem<Value extends string> = {
export type TabItem<Value extends string> = {
value: Value;
label: string;
icon?: ReactNode;
};
export function SegmentedNavigation<Value extends string>({
/**
* Which background this control is drawn for.
*
* `chrome` is the glass pill that belongs on the app gradient. `panel` is an
* underline for a plain light or dark surface.
*
* **A variant rather than a second component**, because behaviour, keyboard
* model, accessibility, props, and the Base UI tabs underneath are identical —
* only the appearance differs, which is what a variant is for. `IconButton`
* spells the same idea the same way.
*
* `chrome` is the default because every existing call site is on the gradient.
*/
export type TabsVariant = "chrome" | "panel";
export function Tabs<Value extends string>({
value,
items,
label,
onValueChange,
trailingAction,
variant = "chrome",
}: {
value: Value;
items: readonly SegmentedNavigationItem<Value>[];
items: readonly TabItem<Value>[];
label: string;
onValueChange: (value: Value) => void;
trailingAction?: ReactNode;
variant?: TabsVariant;
}) {
return (
<Tabs.Root
className="segmented-navigation"
<BaseTabs.Root
className="buzz-tabs"
data-variant={variant}
value={value}
onValueChange={(nextValue) => onValueChange(nextValue as Value)}
>
<Tabs.List className="segmented-navigation-list" aria-label={label}>
<Tabs.Indicator className="segmented-navigation-indicator" />
<BaseTabs.List className="buzz-tabs-list" aria-label={label}>
<BaseTabs.Indicator className="buzz-tabs-indicator" />
{items.map((item) => (
<Tabs.Tab
<BaseTabs.Tab
key={item.value}
value={item.value}
className="segmented-navigation-item"
className="buzz-tabs-tab"
/* Base UI spells the selected tab `data-active`; every other
selectable thing in this codebase is styled on `data-selected`.
Restating it here keeps one spelling in the stylesheet rather than
asking a reader to know which components happen to be Base
UI-backed. Without it the selected label silently kept
`--text-secondary` — no error, just a tab that never looked
selected. */
data-selected={item.value === value || undefined}
>
{item.icon}
<span>{item.label}</span>
</Tabs.Tab>
</BaseTabs.Tab>
))}
</Tabs.List>
</BaseTabs.List>
{trailingAction}
</Tabs.Root>
</BaseTabs.Root>
);
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
BASE_UI_PARTS,
COMPONENTS,
resolveBaseUiBacking,
baseUiDocsUrl,
} from "./registry";
/**
* The component sources as text, read through Vite rather than `node:fs` so the
* paths resolve the same way the app resolves them and no Node types are needed.
*/
const SOURCES = import.meta.glob("/src/shared/ui/*.tsx", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
function read(source: string): string {
const contents = SOURCES[`/src/${source}`];
if (contents === undefined) {
throw new Error(`No source file at src/${source}`);
}
return contents;
}
/**
* The Base UI column on /design/components/base-ui is only worth reading if it
* reports what the code imports. These tests bind it to the files themselves —
* a component that gains or loses a Base UI import fails here rather than
* quietly showing a stale answer on the page.
*/
describe("component registry — Base UI backing", () => {
it("names a source file that exists for every component", () => {
for (const component of COMPONENTS) {
expect(() => read(component.source), component.slug).not.toThrow();
}
});
it("claims exactly the Base UI modules its source imports", () => {
for (const component of COMPONENTS) {
const source = read(component.source);
const imported = [
...source.matchAll(/from "(@base-ui\/react\/[a-z-]+)"/g),
].map((match) => match[1]);
const claimed = component.baseUi.map((part) => part.module);
expect(new Set(claimed), `${component.slug} claims`).toEqual(
new Set(imported),
);
}
});
it("composes only siblings it actually imports", () => {
for (const component of COMPONENTS) {
const source = read(component.source);
for (const slug of component.composes) {
const sibling = COMPONENTS.find((candidate) => candidate.slug === slug);
expect(sibling, `${component.slug} composes ${slug}`).toBeDefined();
expect(
source.includes(`from "./${sibling?.name}"`),
`${component.slug} imports ${sibling?.name}`,
).toBe(true);
}
}
});
it("resolves inherited backing through composition without double-counting", () => {
// IconButton imports no Base UI part but renders a Buzz Button, so Base UI
// Button is underneath it — reported as inherited, not own.
const iconButton = resolveBaseUiBacking("icon-button");
expect(iconButton.own).toEqual([]);
expect(iconButton.inherited).toEqual([
{ part: BASE_UI_PARTS.button, through: "Button" },
]);
// SearchField imports Field and Input directly and composes IconButton,
// which reaches Button — three distinct parts, none listed twice.
const searchField = resolveBaseUiBacking("search-field");
expect(searchField.own).toEqual([BASE_UI_PARTS.field, BASE_UI_PARTS.input]);
expect(searchField.inherited.map((entry) => entry.part.name)).toEqual([
"Button",
]);
// A component whose own import already covers a part does not also report
// it as inherited.
const button = resolveBaseUiBacking("button");
expect(button.own).toEqual([BASE_UI_PARTS.button]);
expect(button.inherited).toEqual([]);
});
it("builds a docs URL per documented Base UI component slug", () => {
for (const part of Object.values(BASE_UI_PARTS)) {
expect(baseUiDocsUrl(part)).toBe(
`https://base-ui.com/react/components/${part.docs}`,
);
expect(part.module).toBe(`@base-ui/react/${part.docs}`);
}
});
});

View File

@@ -1,5 +1,36 @@
export type ComponentStatus = "core" | "proposed";
/**
* A Base UI part a component is built on. `name` is the export as documented;
* `docs` is the path segment on base-ui.com/react/components, and `module` is
* the import specifier, so the two cannot be claimed independently of the code.
*/
export type BaseUiPart = {
name: string;
docs: string;
module: string;
};
export const BASE_UI_PARTS = {
avatar: { name: "Avatar", docs: "avatar", module: "@base-ui/react/avatar" },
button: { name: "Button", docs: "button", module: "@base-ui/react/button" },
field: { name: "Field", docs: "field", module: "@base-ui/react/field" },
input: { name: "Input", docs: "input", module: "@base-ui/react/input" },
tabs: { name: "Tabs", docs: "tabs", module: "@base-ui/react/tabs" },
accordion: {
name: "Accordion",
docs: "accordion",
module: "@base-ui/react/accordion",
},
dialog: { name: "Dialog", docs: "dialog", module: "@base-ui/react/dialog" },
} as const satisfies Record<string, BaseUiPart>;
export const BASE_UI_DOCS_ROOT = "https://base-ui.com/react/components";
export function baseUiDocsUrl(part: BaseUiPart): string {
return `${BASE_UI_DOCS_ROOT}/${part.docs}`;
}
export type ComponentDefinition = {
slug: string;
name: string;
@@ -8,6 +39,19 @@ export type ComponentDefinition = {
variants: readonly string[];
status: ComponentStatus;
owner?: string;
/** The file this component lives in, relative to `src/`. */
source: string;
/**
* The Base UI parts this component imports itself. Empty means it is built
* from native elements — which is a legitimate answer, not a gap: a header,
* a section, and a surface have no behaviour to inherit.
*/
baseUi: readonly BaseUiPart[];
/**
* Sibling components it composes, by slug. A component with no Base UI part
* of its own can still inherit behaviour through one of these.
*/
composes: readonly string[];
};
export const COMPONENTS: readonly ComponentDefinition[] = [
@@ -19,6 +63,9 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
variants: ["primary", "quiet", "ghost"],
status: "proposed",
owner: "desktop-new Messages",
source: "shared/ui/Button.tsx",
baseUi: [BASE_UI_PARTS.button],
composes: [],
},
{
slug: "icon-button",
@@ -28,6 +75,9 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
variants: ["quiet", "ghost", "solid", "chrome"],
status: "proposed",
owner: "desktop-new Messages",
source: "shared/ui/IconButton.tsx",
baseUi: [],
composes: ["button"],
},
{
slug: "avatar",
@@ -37,6 +87,9 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
variants: ["small", "default", "large"],
status: "proposed",
owner: "desktop-new Messages",
source: "shared/ui/Avatar.tsx",
baseUi: [BASE_UI_PARTS.avatar],
composes: [],
},
{
slug: "inline-chip",
@@ -47,24 +100,34 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
variants: ["person", "agent", "channel", "message", "link"],
status: "proposed",
owner: "desktop-new Messages",
source: "shared/ui/InlineChip.tsx",
baseUi: [],
composes: [],
},
{
slug: "workspace-surface",
name: "WorkspaceSurface",
slug: "panel",
name: "Panel",
purpose: "A major panel sitting on the atmospheric workspace backdrop.",
behavior: "Semantic native region",
variants: ["panel", "connected-left", "connected-right"],
status: "proposed",
owner: "desktop-new Messages",
source: "shared/ui/Panel.tsx",
baseUi: [],
composes: [],
},
{
slug: "segmented-navigation",
name: "SegmentedNavigation",
purpose: "A single-select switch between top-level workspace destinations.",
slug: "tabs",
name: "Tabs",
purpose:
"A single-select switch between sibling views. `chrome` is the glass pill for the app gradient; `panel` is an underline for a plain surface. One component because only the surface differs — the behaviour, keyboard model, and props are identical.",
behavior: "Base UI Tabs",
variants: ["default"],
variants: ["chrome", "panel"],
status: "proposed",
owner: "desktop-new Messages",
source: "shared/ui/Tabs.tsx",
baseUi: [BASE_UI_PARTS.tabs],
composes: [],
},
{
slug: "panel-header",
@@ -75,6 +138,9 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
variants: ["default", "compact"],
status: "proposed",
owner: "desktop-new Messages",
source: "shared/ui/PanelHeader.tsx",
baseUi: [],
composes: [],
},
{
slug: "search-field",
@@ -84,6 +150,9 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
variants: ["default"],
status: "proposed",
owner: "desktop-new Messages",
source: "shared/ui/SearchField.tsx",
baseUi: [BASE_UI_PARTS.field, BASE_UI_PARTS.input],
composes: ["icon-button"],
},
{
slug: "navigator-section",
@@ -93,6 +162,9 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
variants: ["default"],
status: "proposed",
owner: "desktop-new Messages",
source: "shared/ui/NavigatorSection.tsx",
baseUi: [],
composes: [],
},
{
slug: "navigator-row",
@@ -102,5 +174,55 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
variants: ["default", "inset", "selected"],
status: "proposed",
owner: "desktop-new Messages",
source: "shared/ui/NavigatorRow.tsx",
baseUi: [],
composes: ["button"],
},
];
/**
* What a component is actually built on, once composition is followed.
*
* `own` is what the file itself imports from Base UI. `inherited` is what it
* gets through a sibling — IconButton has no Base UI import of its own, but it
* renders a Buzz Button, so Base UI Button's behaviour is still underneath it.
* Reporting only `own` would call that component unbacked, which is wrong; not
* distinguishing them would hide where the dependency actually enters.
*/
export type BaseUiBacking = {
own: readonly BaseUiPart[];
inherited: readonly { part: BaseUiPart; through: string }[];
};
export function resolveBaseUiBacking(slug: string): BaseUiBacking {
const byPart = new Map<string, { part: BaseUiPart; through: string }>();
const root = COMPONENTS.find((candidate) => candidate.slug === slug);
if (!root) return { own: [], inherited: [] };
// Bounded by the registry itself, and `seen` makes a cycle terminate rather
// than recurse — a component composing a component that composes it back is a
// mistake we would rather see as a missing row than a hung page.
const seen = new Set<string>([slug]);
const queue = [...root.composes];
while (queue.length > 0) {
const nextSlug = queue.shift();
if (!nextSlug || seen.has(nextSlug)) continue;
seen.add(nextSlug);
const next = COMPONENTS.find((candidate) => candidate.slug === nextSlug);
if (!next) continue;
for (const part of next.baseUi) {
if (!byPart.has(part.name))
byPart.set(part.name, { part, through: next.name });
}
queue.push(...next.composes);
}
// A part imported directly is reported once, as its own — not twice because a
// composed sibling happens to use it too.
for (const part of root.baseUi) byPart.delete(part.name);
return { own: root.baseUi, inherited: [...byPart.values()] };
}