From db12fdb4f77cc14ce9751f04bda494a0542e2227 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:00:28 -0700 Subject: [PATCH] desktop-new: SegmentedNavigation becomes Tabs, with a surface variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- desktop-new/src/shared/styles/components.css | 268 ++++++++++++++---- desktop-new/src/shared/ui/DockWorkspace.tsx | 10 +- .../ui/{WorkspaceSurface.tsx => Panel.tsx} | 12 +- desktop-new/src/shared/ui/Tabs.tsx | 52 +++- desktop-new/src/shared/ui/registry.test.ts | 102 +++++++ desktop-new/src/shared/ui/registry.ts | 134 ++++++++- 6 files changed, 490 insertions(+), 88 deletions(-) rename desktop-new/src/shared/ui/{WorkspaceSurface.tsx => Panel.tsx} (57%) create mode 100644 desktop-new/src/shared/ui/registry.test.ts diff --git a/desktop-new/src/shared/styles/components.css b/desktop-new/src/shared/styles/components.css index a9fb579958..f096a79a38 100644 --- a/desktop-new/src/shared/styles/components.css +++ b/desktop-new/src/shared/styles/components.css @@ -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; } } diff --git a/desktop-new/src/shared/ui/DockWorkspace.tsx b/desktop-new/src/shared/ui/DockWorkspace.tsx index 25ecacc23b..49df096162 100644 --- a/desktop-new/src/shared/ui/DockWorkspace.tsx +++ b/desktop-new/src/shared/ui/DockWorkspace.tsx @@ -12,6 +12,9 @@ type WorkspacePanels = { conversation: ReactNode; }; +const DEFAULT_NAVIGATOR_WIDTH = 216; +const MIN_NAVIGATOR_WIDTH = 176; + const WorkspacePanelContext = createContext(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 ( diff --git a/desktop-new/src/shared/ui/WorkspaceSurface.tsx b/desktop-new/src/shared/ui/Panel.tsx similarity index 57% rename from desktop-new/src/shared/ui/WorkspaceSurface.tsx rename to desktop-new/src/shared/ui/Panel.tsx index dd9f9e56c4..c47e2bad7d 100644 --- a/desktop-new/src/shared/ui/WorkspaceSurface.tsx +++ b/desktop-new/src/shared/ui/Panel.tsx @@ -1,26 +1,26 @@ import type { ComponentProps, ElementType, ReactNode } from "react"; -type SurfaceVariant = +type PanelVariant = | "panel" | "connected-left" | "connected-right" | "navigator-demo"; -type WorkspaceSurfaceProps = { +type PanelProps = { as?: T; children: ReactNode; - variant?: SurfaceVariant; + variant?: PanelVariant; } & Omit, "as" | "children" | "className">; -export function WorkspaceSurface({ +export function Panel({ as, children, variant = "panel", ...props -}: WorkspaceSurfaceProps) { +}: PanelProps) { const Component = as ?? "section"; return ( - + {children} ); diff --git a/desktop-new/src/shared/ui/Tabs.tsx b/desktop-new/src/shared/ui/Tabs.tsx index 748724cff8..c79c899786 100644 --- a/desktop-new/src/shared/ui/Tabs.tsx +++ b/desktop-new/src/shared/ui/Tabs.tsx @@ -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 = { +export type TabItem = { value: Value; label: string; icon?: ReactNode; }; -export function SegmentedNavigation({ +/** + * 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, items, label, onValueChange, trailingAction, + variant = "chrome", }: { value: Value; - items: readonly SegmentedNavigationItem[]; + items: readonly TabItem[]; label: string; onValueChange: (value: Value) => void; trailingAction?: ReactNode; + variant?: TabsVariant; }) { return ( - onValueChange(nextValue as Value)} > - - + + {items.map((item) => ( - {item.icon} {item.label} - + ))} - + {trailingAction} - + ); } diff --git a/desktop-new/src/shared/ui/registry.test.ts b/desktop-new/src/shared/ui/registry.test.ts new file mode 100644 index 0000000000..e8a11aeb22 --- /dev/null +++ b/desktop-new/src/shared/ui/registry.test.ts @@ -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; + +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}`); + } + }); +}); diff --git a/desktop-new/src/shared/ui/registry.ts b/desktop-new/src/shared/ui/registry.ts index b29a72dd89..64e03f3f40 100644 --- a/desktop-new/src/shared/ui/registry.ts +++ b/desktop-new/src/shared/ui/registry.ts @@ -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; + +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(); + 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([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()] }; +}