Component conventions
Elevation & shadows
A surface gets a drop shadow only to signal it sits on a layer above what's
directly behind it — the bigger the elevation jump, the bigger the shadow. Flat
(border only) is the default; add a shadow to communicate elevation, never for
decoration. Use the design-system tokens, not ad-hoc shadow-* values. See
ADR 0005 for the rationale.
| Role | Shadow | Examples |
|---|---|---|
| In-flow grouped rows / settings / list card | none (border only) | list-rows Card, detail rows, sections |
| Raised primary content container (on the page background) | shadow-prominent | data-table card, stat / summary / ranking cards, the active card, horizontal scrollers |
| Drawer / sidebar / rail surface | shadow-sidebar | the sliding aside, station rails |
| Floating chrome over content | shadow-floating | FABs, floating action toolbars/pills, banners/toasts, overlay CTAs |
| Modal / dialog / full-screen overlay (over a backdrop) | shadow-2xl | confirm dialogs, full-screen overlays |
Peer consistency is the rule reviewers enforce: all surfaces in the same role
on the same screen get the same treatment. Most inconsistency is a peer mismatch
(a raised card next to a flat sibling), not a missing token. Keep the two in-flow
card tiers distinct: the iOS-settings list-rows Card stays flat; a
primary content container that is a feature's content uses shadow-prominent.
Where components live
- Reusable, presentational components:
packages/ui/src/(@enode/ui) —Button,TagPill,Sheet,SortableList,BottomSheetCard,LottieCircle,TagsCard,ConfirmDialog,AvatarUploadField, … These are the components worth a Storybook story. (TagsCardandAvatarUploadFieldare exceptions with no story:TagsCardreads the prefetched tag store anduseTextContent;AvatarUploadField's real behaviour is the file-pick → canvas crop pipeline, which a static story can't exercise. See the exclusions below.) - App-specific feature components: under each app's
src/app/— composed from co-locateduse-*hooks. Mostly tied to live session state, so generally not good Storybook candidates without a mock.
Storybook
Stories document the reusable @enode/ui layer, co-located with the component as
ComponentName.stories.tsx. Storybook renders them with the real design tokens
(Tailwind v4 + @enode/ui/styles/design-system.css, wired in
docs-site/.storybook).
Currently covered:
packages/ui/src/button.stories.tsx— all variants, sizes, disabled, rounded pill, leading/trailing icon.packages/ui/src/tag-pill.stories.tsx— contrast swap, no-colour fallback, interactive, long-content truncation.packages/ui/src/confirm-dialog.stories.tsx— destructive vs neutral confirm, child item list, busy (buttons locked during an async action).
Adding a story
-
Pick a reusable
@enode/uicomponent with meaningful visual states. Skip provider-only wrappers, route-only pages, and components that need live backend state without a safe mock. -
Create
ComponentName.stories.tsxnext to the component. -
Use Component Story Format with TypeScript:
import type { Meta, StoryObj } from "@storybook/react";import { fn } from "storybook/test";import { MyComponent } from "./my-component";const meta = {title: "UI/MyComponent",component: MyComponent,tags: ["autodocs"],args: { onChange: fn() },} satisfies Meta<typeof MyComponent>;export default meta;type Story = StoryObj<typeof meta>;export const Default: Story = { args: { /* realistic props */ } }; -
For
ReactNode/callback props, setargTypes: { prop: { control: false } }and add dedicated stories — editable controls produce broken values. -
Only document states the component actually supports. Use realistic args, mock callbacks with
fn(), never call real APIs, never invent props. -
Run
npm run storybookto preview,npm run docs:storybookto build.
TODO:
Sheet/BottomSheetCard/SortableListare reusable but need a small amount of mock wiring (portals, drag context). Add stories when those mocks are in place — do not force a story that fakes behavior.