Research 019 — Design-system components
Status: complete All six [NEEDS VERIFICATION] markers in spec.md have a verdict below. V3's refutation of the R8 Tooltip a11y claim has been folded back into the spec (R8/P4 #2/SC2 now require an accessible name on the trigger; the popup is visual-only), so every marker is resolved.
Verified against. @base-ui/react 1.6.0 — the installed package (package.json:3 "version": "1.6.0"; resolved under the pnpm store, import specifier @base-ui/react), read directly, not from web docs. Compiler facts verified against apps/web/vite.config.ts (React Compiler via @rolldown/plugin-babel + reactCompilerPreset({ panicThreshold: 'all_errors' })) and a discarded build spike. Codebase facts against the worktree at branch 019-design-system-components. Date: 2026-08-17. Package paths below are relative to @base-ui/react/.
V1 — Does Base UI 1.6.0 give a Field/Input primitive with automatic label association and aria-describedby error wiring? (spec R6)
Question. R6 wants a closed Input({label, error}) where the label is associated with the control and the error is exposed to assistive tech — without hand-rolling id/aria-*. That needs Base UI's Field to own the association.
Verdict. Confirmed. R6 holds; the closed Input maps label→Field.Label, error→ Field.Root invalid + Field.Error, and gets association/aria-describedby/aria-invalid for free.
Evidence.
- Parts exist:
field/index.parts.d.tsexportsField.{Root, Label, Control, Error, Description}(+Validity,Item).Field.Controlrenders<input>(field/control/FieldControl.d.ts:10-12); a standaloneInputalso exists (input/index.d.ts→export { Input };input/Input.d.ts:6-7: "A native input element that automatically works with Field"). - Label association is automatic:
field/label/FieldLabel.d.ts:6"automatically associated with the field control"; the control getsaria-labelledby(field/control/FieldControl.js:107). - Error →
aria-describedby:internals/labelable-provider/LabelableProvider.js:53composes registered message ids intoaria-describedby;Field.Errorregisters there. - Invalid:
field/root/useFieldValidation.js:273setsaria-invalid: true. External error control viaFieldRootProps.invalid?: boolean(field/root/FieldRoot.d.ts) andField.Error match={true}(field/error/FieldError.d.ts) — the seam for a plainerror: stringprop, novalidatepipeline.
V2 — Does Base UI 1.6.0 Dialog provide controlled open/close, focus trap, Escape, backdrop, and title/description a11y? (spec R7)
Question. R7 wants a closed Dialog({open, onOpenChange, title, description, children}) that is modal and accessible by construction.
Verdict. Confirmed. All of it is built in and on by default.
Evidence.
- Parts:
dialog/index.parts.d.tsexportsDialog.{Root, Trigger, Portal, Backdrop, Popup, Title, Description, Close}. - Controlled:
DialogRootPropshasopen?: boolean,onOpenChange?: (open, eventDetails) => void(dialog/root/DialogRoot.d.ts). The callback's 2nd arg is a details object — the closed component forwards only the boolean. - Modal behaviour:
modal?: boolean | 'trap-focus'defaulttrue— "focus is trapped, page scroll is locked, pointer interactions outside are disabled" (DialogRoot.d.ts). Escape is a built-in close reason (REASONS.escapeKey); outside-press dismissal is on by default (disablePointerDismissal?defaultfalse). - Title/Description auto-label the popup:
aria-labelledby/aria-describedby/roleapplied atdialog/popup/DialogPopup.js:88-90.
Caveat. With modal on, a Dialog.Close must be rendered inside Dialog.Popup so touch screen-reader users can dismiss, and Dialog.Portal is needed to mount the popup/backdrop at the document root (F3). Both are plan-level construction details, not blockers.
V3 — Does Base UI 1.6.0 Tooltip open on focus as well as hover, and associate its popup with the trigger for assistive tech? (spec R8)
Question. R8 asserts two things: (a) the tooltip opens on keyboard focus as well as hover, and (b) "the popup is associated with the trigger for assistive tech."
Verdict. Partial — (a) Confirmed, (b) Refuted. Opens on focus: yes. Auto-association: no such wiring exists. This refutes the R8 clause and its downstream criteria (P4 #2, SC2) — see Refuted claims.
Evidence.
- Opens on focus and hover: the trigger wires Floating UI's
useFocusalongside hover (tooltip/trigger/TooltipTrigger.js:178,155); the Root's change reasons include bothtriggerFocusandtriggerHover(tooltip/root/TooltipRoot.d.ts). - No auto aria association: a full grep of
tooltip/finds norole="tooltip"and noaria-describedby; the bundled floating-ui has nouseRole. The pinned docs (docs/react/components/tooltip.md:390-391) state it plainly: "Tooltips alone are not accessible to touch or screen reader users… The tooltip's trigger must have anaria-labelattribute that closely matches the tooltip's content." Examples putaria-labelonTooltip.Triggerandaria-hiddenon the visual. - Parts/positioning (for completeness):
tooltip/index.parts.d.tsexportsTooltip.{Provider, Root, Trigger, Portal, Positioner, Popup, Arrow};side/sideOffsetlive onTooltip.Positioner(tooltip/positioner/TooltipPositioner.d.ts, defaultside: 'top').
V4 — Can a Panda slot-recipe class attach to a Base UI part via className without being stripped? (spec R3)
Question. The whole closed-component design needs Panda's generated class strings to land on Base UI parts' DOM elements.
Verdict. Confirmed. Every part accepts className (plain string or (state) => string), plus a render prop and style; a plain Panda class is merged onto the element, not swallowed.
Evidence.
internals/types.d.tsBaseUIComponentProps:className?: string | ((state) => string | undefined),render?: ReactElement | ComponentRenderFn,style?: …. Every part extends this.utils/resolveClassName.jsreturns a string className as-is;internals/useRenderElement.js:54,82-83resolves it andmergeClassNames(outProps.className, className)onto the output props. So Panda classes apply (merged with any internal class).
Caveat. State-conditional styling (open/invalid/side) uses the (state) => string form; the base case is a plain string. See F1 on Base UI shipping no stylesheet.
V5 — Does a /ui gallery route compose with the existing App layout route, and does the page name satisfy the *Page rule? (spec R10)
Question. R10 adds a /ui route with no special handling and a *Page component.
Verdict. Confirmed. A child route added to main.tsx renders inside the App shell with no extra work; the page just needs the Page suffix.
Evidence.
apps/web/src/App.tsx:43-57— App is the layout route:<main>wraps<Outlet/>inQueryBoundary+Suspense. Any child route renders inside those boundaries; a gallery page fetches no server data, so they are inert around it.apps/web/src/main.tsx:15-19—children: [...accountRoutes, ...legendariesRoutes, ...healthRoutes]; a/uiroute table appends here.apps/web/src/features/account/routes.tsx:7— route element shape{ path, element: <…Page/> }; the*Pagenaming rule (spec 010 P1 #4) applies, so the gallery page is e.g.UiGalleryPage. "No nav item" = simply not adding aNavLinkinApp.tsx.
V6 — Do the Base UI wrappers pass the React Compiler at panicThreshold: 'all_errors'? (spec R15)
Question. R15 needs pnpm build to stay green — a compiler bail-out on Base UI usage would fail the build.
Verdict. Confirmed. A component rendering Base UI Field/Input/Dialog/Tooltip JSX compiled with no bail-out.
Evidence.
- The compiler never touches Base UI's own code:
@rolldown/plugin-babel's defaultexcludeis/[\/\\]node_modules[\/\\]…/(node_modules/@rolldown/plugin-babel/README.md:46-49), so only oursrc/wrappers are compiled. - Spike (discarded per constitution): a throwaway
src/_probe019.tsxrendering realField/Input/Dialog/TooltipJSX, mounted via a temporary/_probe019route, built clean —pnpm build(panda codegen && vite build): "443 modules transformed… built in 1.00s", no panic. The probe and the route edit were reverted; the working tree is clean.
F1 — Base UI is truly headless: nothing to import, but it injects functional inline <style>/<script>
No .css file exists in the package and package.json exports none — our Panda slot recipes are the only visual styling, and there is nothing to import. A few components inject functional inline <style>/<script> tags (not a theme sheet); CSPProvider exists to supply a nonce or disableStyleElements (csp-provider/CspProvider.d.ts).
Why it matters. Confirms the styling model for R3/R12 (Panda-only). If a strict CSP is added later, wrap the app root in CSPProvider — not needed now. Graduation candidate for design-system.md.
F2 — Base UI ships a Button, deliberately unused
Base UI has a button module, but the design uses a plain Panda Button (R5). The rationale generalises to a rule: a native <button> already has a button's full behaviour (focusable, Enter/Space activation, role, disabled), so wrapping Base UI adds a layer for no gain. Base UI's Button exists to make non-button elements behave like buttons — not our case.
Why it matters. Backs R5 and gives the DS a written rule: wrap Base UI per-component only where a native element falls short of the required behaviour. Graduation candidate for design-system.md.
F3 — Dialog construction details (modal)
With modal (default true), render a Dialog.Close inside Dialog.Popup for touch screen-reader dismissal, and use Dialog.Portal for the popup/backdrop. initialFocus/finalFocus on the popup tune focus placement (default: first tabbable). Why it matters. Plan-level detail for R7.
F4 — Tooltip prop locations
Delay props are on Tooltip.Trigger (delay default 600, closeDelay default 0), not Tooltip.Root (tooltip/trigger/TooltipTrigger.d.ts; Root destructure has none, tooltip/root/TooltipRoot.js:27-40). Tooltip.Provider is optional — it only enables shared delay-grouping across many tooltips (tooltip/provider/TooltipProvider.d.ts); a single tooltip works without one. Positioning (side, sideOffset, align) is on Tooltip.Positioner. Why it matters. Plan-level detail for R8, and confirms no app-root Provider is required.
Refuted claims
R8 (and P4 #2, SC2) — "the tooltip content is associated with the trigger for assistive tech" — REFUTED.
- Believed: Base UI's Tooltip auto-wires the popup to the trigger (a
role="tooltip"+aria-describedbyrelationship), so rendering the closedTooltipwould make the content available to screen readers. - True (V3 evidence): Base UI's Tooltip does no aria wiring. Per the pinned package's own docs, the content is visual-only and not announced; accessibility is the caller's job — the trigger must carry an
aria-labelthat matches the content. For information that must be announced, the library directs you toPopover(withopenOnHover), notTooltip. - Change required in the spec (back to step 1 — human decision): R8, P4 #2, and SC2 must stop asserting auto-association. The accessible mechanism the closed
Tooltipenforces instead is a required accessible name on the trigger (alabelprop applied asaria-label, defaulted fromcontentwhen it is a string), with the content documented as visual-only. An Out of scope line notes that essential, must-be-announced information belongs in a futurePopover, notTooltip. Applied — the human approved this fix (option 1, 2026-08-17); R8, P4 #2, SC2 and the P4 independent test are amended and the Popover Out of scope line is added.
Graduation
Findings to move to docs/architecture/ at step 6 (spec 019 already updates design-system.md/ react.md, so most land there as part of the work):
- F2 — "wrap Base UI only where a native element falls short" →
design-system.md(closed-component section). - The closed-Tooltip a11y contract (trigger carries an accessible name; content is visual-only) →
design-system.md/react.md. - F1 — Base UI is headless, Panda is the only styling;
CSPProvideris the escape hatch if a strict CSP is added later →design-system.md(styling seam). - F3/F4 — Dialog
Dialog.Close-inside-popup and Tooltip prop locations are component-build notes; they live in the components' own code, not the architecture docs.