Skip to main content

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.

RoleShadowExamples
In-flow grouped rows / settings / list cardnone (border only)list-rows Card, detail rows, sections
Raised primary content container (on the page background)shadow-prominentdata-table card, stat / summary / ranking cards, the active card, horizontal scrollers
Drawer / sidebar / rail surfaceshadow-sidebarthe sliding aside, station rails
Floating chrome over contentshadow-floatingFABs, floating action toolbars/pills, banners/toasts, overlay CTAs
Modal / dialog / full-screen overlay (over a backdrop)shadow-2xlconfirm 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. (TagsCard and AvatarUploadField are exceptions with no story: TagsCard reads the prefetched tag store and useTextContent; 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-located use-* 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

  1. Pick a reusable @enode/ui component with meaningful visual states. Skip provider-only wrappers, route-only pages, and components that need live backend state without a safe mock.

  2. Create ComponentName.stories.tsx next to the component.

  3. 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 */ } };
  4. For ReactNode/callback props, set argTypes: { prop: { control: false } } and add dedicated stories — editable controls produce broken values.

  5. Only document states the component actually supports. Use realistic args, mock callbacks with fn(), never call real APIs, never invent props.

  6. Run npm run storybook to preview, npm run docs:storybook to build.

TODO: Sheet / BottomSheetCard / SortableList are 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.