Goldie Blocks
Coding Rules

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 touchesRead
Where a component lives, imports, installing or promoting shadcn/upstream source, house style, Base UIthis page
Writing or changing a Storybook story, story tags (Proto / Spec / Live), what a story failure means, assertion styleStorybook
The block/module/document card furniture — what it is and where its authority livesThe Stack
The rest/hover/selected colour schedule a card's spine, header band and body wash read fromstack-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 contractShared/Molecules/Stack/v3/Drag and drop, with drag-grip.tsx and stack-sortable.tsx beside it
Tailwind class conventions and compositionTailwind

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 live and 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.json excludes src/shared/ui/upstream. CLI output references packages we deliberately do not install — radix-ui, where our primitive layer is Base UI, and cn, where our class merger is the clsx + tailwind-merge wrapper in src/shared/lib/utils.ts — so typechecking a baseline nothing imports would force either a second primitive library into package.json or 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, and knip.json ignores the folder rather than reporting it as dead. Check package.json after a CLI install. shadcn add writes the landing zone's own imports in as dependencies, and because knip.json ignores 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. cn arrived 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 in atoms/ 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:

  1. Install — npx shadcn@latest add <name> (or npx ai-elements@latest add <name>) drops source into upstream/.
  2. Review — read the generated file(s). Note any upstream dependencies that may also land in upstream/.
  3. Choose a tier — atom (single-purpose primitive), molecule (composition of atoms), etc. When in doubt, start at the lowest tier that fits.
  4. Create a folder and adapt — copy into e.g. atoms/<name>/<name>.tsx. Apply house-style conventions (below).
  5. Add a barrel — atoms/<name>/index.ts re-exports the public API so imports remain #/shared/ui/atoms/<name>.
  6. Add a Storybook story — atoms/<name>/<name>.stories.tsx alongside the component.
  7. Delete the upstream/ copy once the promotion has settled — when the promoted story is live and 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.ts barrel.
  • 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 only default, sm, lg, icon).

  • Export both the component and its cva variants 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's WorkScreen: 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.

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 UIBase UI
Packageradix-ui (umbrella)@base-ui/react/<component>
Active tab attributedata-state="active"data-active="" (empty string when active, absent when not)
Tailwind active selectordata-[state=active]:*data-[active]:* (matches attribute present with any value, including "")
Tabs APITabs.Root / List / Trigger / ContentTabs.Root / List / Tab / Panel / Indicator

Rules when promoting shadcn components

  • Replace the import. Change from "radix-ui" (or from "@radix-ui/react-*") to the equivalent from "@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 ::after pseudo-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 onValueChange vs Base UI onValueChange may have different signatures; asChild becomes the render prop 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.

On this page