Blob: src/client/components/ui/README.md
UI primitives
This directory owns the app's interactive primitives. Three invariants govern what lives here and how new UI gets built.
I1 — Stacking
Every floating menu, popover, and suggestion surface portals to document.body through <DropdownPortal>. Inline absolute z-N for floating UI is a bug — the surrounding element's transform, filter, or backdrop-filter will eventually trap it.
The only legitimate bracketed z-layers are:
| Layer | Value | File |
|---|---|---|
| Mobile drawer | z-[80] |
ui/mobile-drawer.tsx |
| Dialog backdrop / search dialog | z-[90] |
ui/dialog.tsx, sidebar/search-dialog.tsx |
| Toast | z-[100] |
toast.tsx |
| Skip-link on focus | z-[200] |
workspace/layout.tsx, share/layout.tsx, layouts/standalone-layout.tsx |
Sub-stacking inside dialog/drawer/canvas surfaces uses small z-10/z-30/z-40 and is OK — those are page-region surfaces, not menus.
If you find yourself writing className="… absolute z-N" for a menu, stop — use <DropdownPortal>.
I2 — Action surface
Every interactive action renders through <Button> (app) or <ToolbarButton> (editor). Hover, focus, and disabled vocabularies are owned by the primitives.
The one exception is actions that perform top-level navigation (e.g. an OIDC sign-in that hands off to an identity-provider URL). Those render as <a href> to preserve native link semantics — cmd/ctrl-click new-tab, hover URL preview, right-click → "Open in new tab", link role for AT — and consume the same visual tokens from button-classes.ts. The rationale, contract, and current callers are documented in the // ADR: block at the top of that file.
Raw <button> is reserved for non-action elements:
- Data badges that toggle a menu —
workspace/settings.tsx:323member role chip,workspace/settings.tsx:152workspace icon chip. The badge IS the data display; its dropdown affordance is incidental. - Visual selectors — cover-picker gradient swatches, color-picker color swatches. They're previews, not action labels.
- Drop zones —
cover-picker.tsx:137"Choose image" dashed-border drop target. - List-row wrappers — share-tab user/link rows where the whole row is clickable.
- Drag handles —
editor/controllers/drag-handle.tsx(drag + onClick semantics, not toolbar). - Menu items — items inside
<DropdownPortal>content,tiptap-menu-item,tiptap-block-menu-item,tiptap-table-menu-item. They useonClickactivation withonMouseDown.preventDefault()for drag-cancel forgiveness, which is different from<ToolbarButton>'sonMouseDown-activate.
I3 — Motion
Three tokens own the motion vocabulary. No hand-coded keyframe strings in component CSS.
| Token | Duration | Where |
|---|---|---|
var(--animate-menu) |
150ms fade-in |
every compact menu/popover (<DropdownPortal> inner, editor toolbar bubbles, dropdowns) |
var(--animate-scale-fade) |
300ms scale-fade |
shell-level reveals (search dialog, command palette) |
var(--animate-slide-up) |
350ms slide-up |
modal dialogs, toasts, mobile drawer |
var(--animate-fade-in) |
400ms fade-in |
page-level reveals only (landing-page.tsx, document-layout.tsx) — not for menus |
Default transition duration for hover/state changes is --default-transition-duration: 75ms (from theme.css). For Tailwind utility classes use transition (covers opacity, colors, transform, shadow) or scoped equivalents — never the bracket-list syntax transition-[a,b,c].
--animate-menu is opacity-only by design. Menus position via transform: translate(...) (Floating UI) or top/left, and a transform-based animation would conflict with positioning. If you ever bring back a scale flourish, do it on an inner div (the way <DropdownPortal> nests already), not on the floating element itself.
Intentional motion exceptions
summarize-sheetuses a bespoke 200ms cubic-bezier translateX+opacity. It's the only horizontal side-sheet in the codebase — tokenizing for one occurrence would be speculative.search-dialogbypasses<Dialog>and usesanimate-scale-fadedirectly. It's a command-palette pattern (transient overlay), not a modal — keep the bypass.
Primitives
| Primitive | File | Use for |
|---|---|---|
<Button> |
ui/button.tsx |
Every app action button. Variants: primary/secondary/danger/ghost/subtle (subtle = metadata affordance with faded label, e.g. "Add icon"/"Add cover" above the page title). Sizes: xs/sm/md. Set iconOnly for icon-only buttons (pass aria-label). |
<ToolbarButton> |
editor/controllers/toolbar-button.tsx |
Buttons inside Tiptap floating toolbars (formatting, link). Forwards ref for popover anchoring. Uses onMouseDown + preventDefault to preserve editor selection. Visual styles live in editor/styles/*.css via CSS class targeting like .tiptap-toolbar button.is-active. |
<DropdownPortal> |
ui/dropdown-portal.tsx |
Every floating menu/popover/suggestion list. Portals to body, handles outside-click and Escape. Set widthMode="match-trigger" to size to the trigger element. |
<Dialog> |
ui/dialog.tsx |
Modal dialogs. Portaled, focus-trapped, Escape-dismissable. |
<MobileDrawer> |
ui/mobile-drawer.tsx |
Mobile-only side drawer. Slides up on enter (uses --animate-slide-up). |
Adding to the system
- New action button? Use
<Button>with the closest variant. Don't override hover colors viaclassName— that's drift the primitive should own. If the right variant doesn't exist yet, add it to the primitive. - New menu? Use
<DropdownPortal>. Anchor to the trigger viatriggerRef. Don't writeabsolute z-Ninline. - New animation? Pick from the three motion tokens. Don't hand-code keyframes in component CSS.
- New shared (SSR-safe) variant of a primitive? Mirror the
Skeletonpattern — implementation lives insrc/shared/components/ui/, the client re-exports it fromsrc/client/components/ui/. Only do this when an SSR consumer actually exists.
Deferred
- Tooltip primitive. Native
title=is used on data badges today (accessibility-correct, visually weak). Building a tooltip is its own brief (hover-intent timing, mobile press-and-hold, keyboard focus showing). <MenuItem>primitive. Menu items inside<DropdownPortal>content are currently raw<button>with custom CSS classes. A primitive could capture theonClick+onMouseDown.preventDefault()+ role pattern. ~5 consumer call sites; worth extracting if a sixth appears.- ESLint enforcement. A
no-restricted-syntaxrule flaggingclassName=strings matching/\babsolute\b.*\bz-\d/insrc/client/components/would mechanically enforce I1. Add when the next inline-menu drift incident proves the README isn't enough.