Appearance
Building Components
Components are presentational. They render UI and manage their own interaction state (hover, focus, open/closed), but they don't fetch data or contain business logic. That responsibility lives in state stores (data/state/) and the coordinator.
Component structure
Each component lives in a kebab-case folder under ui/components/:
timer/
├── index.tsx # Component (named export)
├── component.ftl # Localization resource (en-US baseline)
├── component.test.tsx # Vitest + testing-library tests
├── component.story.tsx # Storybook story
├── style.module.css # Scoped styles
└── hooks/ # Optional: colocated hooks for derived display valuesA minimal component looks like this:
typescript
import style from "./style.module.css"
export function Header() {
return (
<div className={style.base} data-testid="header">
{/* ... */}
</div>
)
}Key patterns:
- Named exports,
export function Timer(), notexport default data-testidon every component, used by tests- CSS module import first, style import is always the first line
- No business logic, if you're reaching for
fetchor complex conditional logic, it probably belongs indata/state/
The _base/ directory holds shared decorators that multiple components use.
State access
Components consume state through Zustand hooks with selectors:
typescript
import { useTimer } from "@data/state/timer"
export function TimerDisplay() {
const status = useTimer((s) => s.data.status)
const start = useTimer((s) => s.actions.start)
return <button onClick={start}>{status}</button>
}What makes this work well:
- Selectors keep re-renders tight.
useTimer((s) => s.data.status)re-renders only whenstatuschanges.useTimer()re-renders on every store update. That's the difference. - Narrow selectors for data and actions. Pick what you need, leave the rest.
- Package aliases for imports.
@data/state/timerreads better and won't break when things move around, unlike../../../data/state/timer.
For more on how stores work: State management.
CSS Modules
Every component gets a style.module.css. CSS Modules scope class names to the component at build time, so two components can both define .base without colliding. Each gets a unique hash in the output. You import the module and reference classes as properties:
typescript
import style from "./style.module.css"
export function Timer() {
return <div className={style.base}>...</div>
}The module boundary is the isolation mechanism. Styles in one component's module cannot leak into another's. No global namespace, no BEM prefixing. The tooling handles it.
Nesting
Native CSS nesting keeps related styles together under your class name instead of scattering them across the file. Use & for pseudo-states and pseudo-elements, and nest child classes directly:
css
.base {
display: grid;
container-type: inline-size;
gap: var(--space-medium);
&:hover {
box-shadow: var(--box-shadow-card-hover);
}
.inner {
padding: var(--space-medium);
}
}Nesting reflects the DOM structure. This keeps the relationship explicit and avoids flat lists of disconnected selectors.
Not everything needs to live under .base. Define additional top-level classes when a part of the component has its own layout concerns or doesn't share a container relationship with .base. .base gets you started with a reasonable root container, not a cage.
Key patterns
- Container queries for responsive behavior, components respond to their own size, not the viewport
- CSS variables for design tokens,
var(--space-medium), not16px .baseas the root class name, consistent entry point for every component's styles
Global styles and design tokens live in ui/styles/.
Localization
Each component that renders user-visible text gets a component.ftl file in the same directory. This is the en-US baseline, the only locale authored in this repo. Components reference message IDs via data-l10n-id attributes — the ESLint plugin (no-missing-message) validates every reference against the colocated FTL at edit time.
Static strings
The simplest case: a message with no variables.
ftl
renderer-info-title = Intentionally Boring Renderertsx
<h1 data-l10n-id="renderer-info-title" />Variable interpolation
When a message includes dynamic content, document each variable with a # Variables: block above the message. Pass values at runtime via data-l10n-args as a JSON object.
ftl
# Variables:
# $hash (String) - Content hash of the current renderer entry artifact
renderer-info-hash = Hash: { $hash }tsx
<li
data-l10n-id="renderer-info-hash"
data-l10n-args={JSON.stringify({ hash })}
/>Selectors
Use selectors when a message has variants driven by state. This keeps all display strings in FTL and out of component logic. Boolean props are passed as strings since Fluent variant keys are string-matched — String(prop) is the convention.
ftl
# Variables:
# $updating (String) - "true" when a renderer update is pending, "false" otherwise
renderer-info-renderer-section =
{ $updating ->
[true] Renderer — will update
*[false] Renderer — cached
}tsx
<header
data-l10n-id="renderer-info-renderer-section"
data-l10n-args={JSON.stringify({ updating: String(renderUpdate) })}
/>The * prefix marks the default variant — use it on the most common or least surprising state.
At build time, all component.ftl files are aggregated into a single baseline artifact for the snapshot. Non-baseline translations are produced externally. For the full pipeline: Localization.
Storybook
Each component has a component.story.tsx for visual development and documentation:
typescript
import { Meta, StoryObj } from "@storybook/react-vite"
import { Timer } from "."
const meta: Meta<typeof Timer> = {
title: "Timer / Overview",
component: Timer,
}
export default meta
export const Overview: StoryObj<typeof Timer> = {
render: () => <Timer />,
}Sidebar structure
Story titles use a flat two-level format: "Group / Name". No deeper nesting. The named export matches the sidebar leaf.
typescript
// correct — two levels, export matches leaf
const meta = { title: "Dev Panel / Sources" }
export const Sources: StoryObj<...> = { ... }
// avoid — three levels create nested folders in the sidebar
const meta = { title: "Dev / Panel / Sources" }State variants via controls
Prefer one story with controls over multiple named exports for different states. Add custom boolean args via a ComponentPropsAndCustomArgs type and derive props in render:
typescript
type ComponentPropsAndCustomArgs = {
withData: boolean
} & React.ComponentProps<typeof MyComponent>
const meta: Meta<ComponentPropsAndCustomArgs> = {
title: "Section / MyComponent",
component: MyComponent,
}
export default meta
export const MyComponent: StoryObj<ComponentPropsAndCustomArgs> = {
render: (args) => {
const withData = args.withData ? { data: mockData } : {}
return <MyComponent {...args} {...withData} />
},
args: { withData: false },
argTypes: {
data: { table: { disable: true } },
},
}Run Storybook with:
bash
pnpm storybookStories serve double duty: they're development sandboxes (see your component in isolation) and living documentation (other developers see what it looks like and how it behaves).
Quick orientation
- Business logic lives in
data/, notui/ - Narrow selectors keep components fast
- Every component has a test, a story, and a
.ftlfile alongside it
Related documentation
- Code conventions — naming, imports, TypeScript patterns
- Localization — FTL pipeline, identity participation, two-channel delivery
- State management — commit vs set, selector patterns
- Testing — test file structure and trust-in-layers model
- Tooling — scaffolding new features with
pnpm gen