UI Components
Where UI components live, how upstream source flows through the landing zone into owned tiers, and the rules for importing and promoting components
Read only the page your task touches
The UI rulebook is seven pages; this one holds the component-library rules.
| Your task touches | Read |
|---|---|
| Where a component lives, imports, installing or promoting shadcn/upstream source, house style, Base UI | this page |
| Writing or changing a Storybook story, story tags (Proto / Spec / Live), what a story failure means, assertion style | Storybook |
| The block/module/document card furniture — what it is and where its authority lives | The Stack |
| The rest/hover/selected colour schedule a card's spine, header band and body wash read from | stack-palette.ts (shared/ui/molecules/stack/v3), with the values in src/app/app.css |
| The drag grip, sortable mechanics, and the lift's visual contract | Shared/Molecules/Stack/v3/Drag and drop, with drag-grip.tsx and stack-sortable.tsx beside it |
| Tailwind class conventions and composition | Tailwind |
Each rule lives on exactly one page; none of them restates another.
Folder layout
src/components/ is gone (2026-08-11). The legacy atomic-design tree —
ui/, atoms/, molecules/, organisms/, templates/ — has been emptied
into the Feature-Sliced Design tree (src/shared, src/entities,
src/widgets, src/pages). Its last tenant, the module card, became
src/entities/module when the module-scoped
Control Panel earned that slice, and the folder was deleted with it.
src/
├── shared/ui/ ← domain-neutral bricks (atoms/, molecules/) + the upstream landing zone
├── entities/ ← block, module, document, folio, agent — each with ui/, lib/, @x/
├── widgets/
└── pages/All shared UI goes to src/shared/ui/; see
the architecture decision record.
The landing-zone rules below are unchanged by the migration — upstream source
still lands in one folder, untouched, and is still promoted into an owned tier
under src/shared/ui/.
Rule: the landing zone is not for application use
The landing zone is src/shared/ui/upstream/ (the shadcn alias ui in components.json). It holds unmodified source from third-party component libraries installed via the shadcn CLI (shadcn/ui, ai-elements, any future registries).
- Application code never imports from the landing zone. Import from
#/shared/ui/atoms/*,#/shared/ui/molecules/*, or the owning entity/widget slice instead. - Do not edit files in
upstream/in place. If you need to change a component, promote it to an atomic folder and edit it there. - Do not hand-write files in
upstream/. It exists solely as a staging area for output from the shadcn CLI and similar tools. - Delete the upstream copy once the promotion has settled (owner, 2026-10-04). Land, promote, and when the promoted component's story is
liveand the app has used it, remove the landing-zone file. The baseline for a later diff is the commit that landed it and the registry itself (npx shadcn@latest add <name>fetches it again); a copy in the tree earns nothing once the promoted version is the one being read.Keep the upstream copy as a read-only baseline— folded 2026-10-04: fourteen promoted files sat beside their promoted versions, and the pair invited a fix to the wrong one. - The landing zone sits outside the app's TypeScript program.
tsconfig.jsonexcludessrc/shared/ui/upstream. CLI output references packages we deliberately do not install —radix-ui, where our primitive layer is Base UI, andcn, where our class merger is theclsx+tailwind-mergewrapper insrc/shared/lib/utils.ts— so typechecking a baseline nothing imports would force either a second primitive library intopackage.jsonor an edit to the untouched copy, and both are forbidden above. The import is replaced when the component is promoted, and the promoted file is typechecked in its own tier like any other. A baseline nothing imports and nothing typechecks earns no dependency of its own, andknip.jsonignores the folder rather than reporting it as dead. Checkpackage.jsonafter a CLI install.shadcn addwrites the landing zone's own imports in as dependencies, and becauseknip.jsonignores the folder, the only thing importing them is invisible to it — so they surface later as an unexplained unused dependency rather than at the install that added them.cnarrived this way and was removed on 2026-09-18.
components.json has aliases.ui = "#/shared/ui/upstream", so npx shadcn@latest add … and npx ai-elements@latest add … land there by default. The folder is created by the first install; nothing is checked in until then.
Why keep the separation
- Clean diff against upstream. When shadcn updates a component, re-install into
upstream/and diff against the adapted version inatoms/to cherry-pick fixes. - Prevent silent drift. Because app code imports from atomic folders only, a stray import from
upstream/stands out in review. - Deliberate curation. Promoting from
upstream/into the atomic tier is an explicit decision. Stops the design system from silently bloating with every install.
Promotion workflow
When you need a component from upstream/ in application code:
- Install —
npx shadcn@latest add <name>(ornpx ai-elements@latest add <name>) drops source intoupstream/. - Review — read the generated file(s). Note any upstream dependencies that may also land in
upstream/. - Choose a tier — atom (single-purpose primitive), molecule (composition of atoms), etc. When in doubt, start at the lowest tier that fits.
- Create a folder and adapt — copy into e.g.
atoms/<name>/<name>.tsx. Apply house-style conventions (below). - Add a barrel —
atoms/<name>/index.tsre-exports the public API so imports remain#/shared/ui/atoms/<name>. - Add a Storybook story —
atoms/<name>/<name>.stories.tsxalongside the component. - Delete the
upstream/copy once the promotion has settled — when the promoted story isliveand the app imports the promoted component, remove the landing-zone file. Git history and the registry are the baseline.
Promotion checklist
Before a component graduates out of the landing zone, confirm:
- The app imports the promoted component from an owned tier such as
#/shared/ui/atoms/*, not from the landing zone. - The promoted file lives in its own component folder with a local
index.tsbarrel. - The public API has been reviewed and renamed or narrowed where needed to match app conventions.
- Styling tokens, variants, and defaults reflect the house style rather than raw upstream defaults.
- Any upstream-only runtime or type dependencies have been removed, localized, or consciously accepted.
- Accessibility behavior has been checked for keyboard interaction, labeling, focus treatment, and disabled states.
- A Storybook story exists for the promoted component.
- The
upstream/source has been deleted, or is still unmodified and waiting for the promotion to settle.
House-style conventions for promoted components
When adapting a component into an atomic folder, apply these conventions:
-
Folder per component.
atoms/button/button.tsx,atoms/button/button.stories.tsx,atoms/button/index.ts. -
Data attributes for targeting:
data-slot,data-variant,data-size. These support CSS sibling selectors, testing, and analytics without relying on class names. -
Named Tailwind groups (
group/button,group/message) where nesting matters — avoids ambiguity when groups are composed. -
Extended size scales where we use them:
xs,icon-xs,icon-sm,icon-lg(stock shadcn ships onlydefault,sm,lg,icon). -
Export both the component and its
cvavariants function (e.g.Button,buttonVariants) so other components can reuse the class set for link-styled-as-button patterns. -
Loading states draw the page frame, not a line of text. A list or canvas still opening renders
PageLoading(src/shared/ui/molecules/page-loading/v1) inside the loaded page'sWorkScreen: the real frame and bands, no contents, after 400ms, with a screen-reader status line (owner, 2026-10-01).
The canonical reference implementation is src/shared/ui/atoms/button/button.tsx — use it as the template for new atoms.
Menus and their flyouts
The shared DropdownMenu atom owns one width and one item type scale, and
callers inherit both rather than restating either. A setting with a current
value uses DropdownMenuPropertyTrigger, so the presentation — setting on the
first line, muted current value on the second, alternatives in the flyout — is
the component rather than a convention. The current value is informational: it
is announced in the trigger's accessible description, never a second focusable
row.
Two rules the components cannot state for themselves:
- Commands inside a flyout carry an icon; settings do not (owner, 2026-09-08). Save Version wears a bookmark at the top of the Version flyout because it depends on nothing beneath it; Download wears its arrow at the bottom of Export because it needs the format set above it. Rows of the main menu stay text-only — the icon marks the one thing a flyout does, as distinct from what it records or sets.
- A caret-anchored autocomplete is not a menu. The document mention composer is a listbox sized to the editing field, so it does not inherit the menu width.
Headless primitive layer: Base UI
We use Base UI (@base-ui/react) as the headless primitive layer for shadcn components, not Radix UI.
This matters when reading or adapting shadcn source, because the official shadcn docs and CLI output still target Radix. The two libraries have different data attributes and component APIs:
| Radix UI | Base UI | |
|---|---|---|
| Package | radix-ui (umbrella) | @base-ui/react/<component> |
| Active tab attribute | data-state="active" | data-active="" (empty string when active, absent when not) |
| Tailwind active selector | data-[state=active]:* | data-[active]:* (matches attribute present with any value, including "") |
| Tabs API | Tabs.Root / List / Trigger / Content | Tabs.Root / List / Tab / Panel / Indicator |
Rules when promoting shadcn components
- Replace the import. Change
from "radix-ui"(orfrom "@radix-ui/react-*") to the equivalentfrom "@base-ui/react/<component>"path. - Fix data attribute selectors. Audit every
data-[state=*]:Tailwind class and replace it with the Base UI equivalent. Check the Base UI API reference for the exact attributes each component exposes. - Use Base UI's Indicator components. Where Radix relies on
::afterpseudo-elements or manual state tracking for animated indicators (e.g. tabs underline, select checkmark), Base UI ships a dedicated<Component.Indicator>that uses CSS variables for position — prefer it. - Verify prop names. Some props differ (e.g. Radix
onValueChangevs Base UIonValueChangemay have different signatures;asChildbecomes therenderprop in Base UI).
Third-party registries (ai-elements and similar)
Some libraries (notably ai-elements) ship components with type-only imports from runtime packages like ai and @ai-sdk/react. When promoting such components:
- Replace the upstream types with local aliases in the adapted file. For Vercel's
UIMessage["role"],ChatStatus,FileUIPart, this is typically ~15 lines of type aliases across the affected files. - Do not install the runtime packages unless we actually use them — the types are structural and can be redefined locally.
- Install standalone helper deps (e.g.
use-stick-to-bottom,streamdown) only when the specific component that needs them is being promoted.
Message composition
The Folio content-surface prototype uses shadcn's promoted MessageRow in
shared/ui/molecules/message-row and Bubble in shared/ui/atoms/bubble.
MessageRow owns alignment and avatar slots; Bubble owns the visible surface.
The warm bubble variant supplies the prototype's pale-gold assistant treatment.
The original shared Message and MessageList remain the live conversation
panel's API; this prototype does not migrate those consumers. Untouched CLI
sources remain in shared/ui/upstream for comparison; promoted components use
our existing cn helper and Base UI-backed Slot adapter.
Chat Markdown display
Chat text is stored and sent as Markdown strings. Shared MarkdownText owns
read-only parsing and presentation for inline and sidebar messages: CommonMark
plus GitHub-flavoured lists, tables and strikethrough. Plain-text line breaks
remain visible. Raw HTML stays inert and the parser's safe URL policy remains
enabled. Message chrome, pending labels and tool parts stay outside the parser.
The current implementation uses react-markdown and remark-gfm, hidden
behind a string-only content prop and optional className. Consumers do not
receive parser-specific options. Tiptap is the intended later rendering/editing
system; replacing this implementation must not require changing stored chat
messages. The composer remains a textarea in this change.
GB-297 review: required and completed (new shared contract and dependencies). No blocking implementation findings. Two inherited deletion-story expectations were corrected to match menu confirmation; evidence is in the task receipt.