| name | components |
| description | Build React components in the Pond house style: compound component objects, CSS Modules, variants via data-attributes, props that extend native HTML elements. Use when creating, refactoring, or reviewing any component under apps/desktop/src/renderer/src/components/, ui/, or pages/. Triggers on /components or when the user asks to "make a component", "refactor this to match Settings", "follow the component convention", or asks how components should be structured in this codebase. |
/components: component patterns in the Pond house style
Quick start
The reference implementation lives in apps/desktop/src/renderer/src/components/settings/. Read it first. Every new component in this codebase should follow the same shape:
- One folder per component:
<name>/index.tsx + <name>/styles.module.css.
- Export a single PascalCase object whose keys are the sub-components (
Settings.Page, Settings.Header, Settings.Item, ...).
- Every sub-component is a thin function whose props extend a native HTML element via
React.ComponentPropsWithoutRef<"tag">.
- Variants are
data-* attributes, never boolean props. CSS targets them with &[data-x="y"].
- Co-located CSS Modules. No inline styles. No styling props (
className may still be passed in via ...props).
- Use semantic HTML.
<header> for headers, <h1>–<h3> by hierarchy, <p> for body, <div> only for layout primitives.
If a new component matches these six rules, it's correct. The rest of this file is the reasoning, the exact patterns to follow, and the things to avoid.
The reference: Settings
export const Settings = {
Page: Page,
Header: Header,
Title: Title,
Description: Description,
Section: Section,
SectionTitle: SectionTitle,
List: List,
Item: Item,
ItemDetails: ItemDetails,
ItemTitle: ItemTitle,
ItemDescription: ItemDescription,
ItemControl: ItemControl,
};
Each sub-component is one declarative function, defined in the same file, paired with a typed props interface.
interface PageProps extends React.ComponentPropsWithoutRef<"div"> {
width?: "narrow" | "medium" | "wide";
}
function Page({ width = "medium", ...props }: PageProps) {
return <div data-width={width} className={styles.page} {...props} />;
}
A consumer composes these like LEGO. No options, no variant props, no as props, no nested config objects.
<Settings.Page>
<Settings.Header>
<Settings.Title>Notifications</Settings.Title>
<Settings.Description>
Choose which background events surface as a toast.
</Settings.Description>
</Settings.Header>
Rules
1. One folder, two files
components/<name>/
index.tsx
styles.module.css
No types.ts, no <name>.tsx, no Component.tsx. The folder name is the import name.
import { Settings } from "../../../components/settings";
If a component grows past ~250 lines, split sub-components into sibling files (item.tsx, header.tsx) and re-export them from index.tsx. Keep the public surface a single object.
2. Compound component object
Always export a single PascalCase object. Never export the sub-components individually.
Good:
export const Settings = {
Page,
Header,
Title,
};
Bad:
export { Page, Header, Title };
This keeps the namespace obvious at the call site (<Settings.Page> reads like a sentence) and lets you rename internals without churning every consumer.
3. Props extend the underlying HTML element
Every sub-component declares a props interface that extends the exact element it renders.
interface SectionProps extends React.ComponentPropsWithoutRef<"div"> {}
function Section({ ...props }: SectionProps) {
return <div className={styles.section} {...props} />;
}
Even when the interface is empty, declare it. It documents which element this component is, and lets you add props later without changing the call signature.
Use React.ComponentPropsWithoutRef<"tag">, not HTMLAttributes<HTMLDivElement>. The former handles ref, key, and event types correctly for the specific tag.
4. Variants are data-* attributes, not boolean props
When a component has visual variants, expose them as a single string-union prop and forward it as a data-* attribute. Style with attribute selectors.
interface PageProps extends React.ComponentPropsWithoutRef<"div"> {
width?: "narrow" | "medium" | "wide";
}
function Page({ width = "medium", ...props }: PageProps) {
return <div data-width={width} className={styles.page} {...props} />;
}
.page {
&[data-width="narrow"] { --settings-max-width: 384px; }
&[data-width="medium"] { --settings-max-width: 640px; }
&[data-width="wide"] { --settings-max-width: 1024px; }
}
This works because it:
- Stays declarative in the DOM (you can inspect the variant in DevTools).
- Avoids prop explosion (
compact, large, dense, inverted...).
- Lets a parent override styling via CSS without touching the component.
If you find yourself adding boolean props to switch behavior, stop and turn them into a data-* variant or split the component.
5. Spread ...props last
Always destructure your custom props and spread the rest onto the element.
function Page({ width = "medium", ...props }: PageProps) {
return <div data-width={width} className={styles.page} {...props} />;
}
This lets a consumer pass id, aria-*, data-testid, event handlers, and even className (which will replace yours; accept that and merge with clsx only when necessary).
6. Semantic HTML by role
Pick the tag that matches the role, not the layout.
| Role | Tag |
|---|
| Page heading | <h1> |
| Section heading | <h2> |
| Item heading | <h3> |
| Body / description | <p> |
| Header band | <header> |
| Generic layout box | <div> |
| Interactive control | <button> / <input> (from ui/) |
The Settings file uses h1/h2/h3/p/header deliberately. Mirror this in any component that has the same hierarchy. Don't reach for <div> because "it's just text".
7. CSS Modules, co-located, kebab-case classes
- One
styles.module.css per component, in the same folder.
- Import as
import styles from "./styles.module.css";.
- Class names are kebab-case (
section-title, item-description).
- Access via
styles.foo for single-word names, styles["section-title"] for multi-word.
<h2 className={styles["section-title"]} {...props} />
Don't use Tailwind, styled-components, cva, inline style={{ ... }}, or clsx on internal classes. The few style={{ ... }} blocks you'll find in pages (e.g. preferences.tsx) are page-level glue, not component primitives. Don't propagate them into new components.
8. CSS custom properties for tokens, locals on the root
Use design-system tokens for every color, never raw hex. Define component-local variables on the root class so callers can override them from the outside without writing component-specific CSS.
.page {
--settings-padding-inline-inset: 16px;
--settings-margin-top: 64px;
display: flex;
flex-direction: column;
gap: 40px;
margin-top: var(--settings-margin-top);
}
.header {
padding-inline: var(--settings-padding-inline-inset);
}
Locals live at the top of the root class, in declaration order. Token reads (var(--ds-gray-12)) live wherever they're needed.
Tokens: always use --ds-*
All theme tokens live in packages/ui/src/theme.css (re-exported as @pond/ui/theme.css). There is one system: --ds-*. Don't reach for ad-hoc colours, hex literals, or page-local hand-rolled scales.
- Color scales. Backed by Radix Themes:
--ds-gray-1…--ds-gray-12 plus alpha (--ds-gray-a1…--ds-gray-a12), and --ds-accent-1…--ds-accent-12 (sky-based) plus alpha. Same scale, same semantics in light and dark.
- Semantic.
--ds-tomato-{1..12,a3,a6} for danger / required / error. --ds-grass-{3,6,9,11,a6} for success.
- Surface.
--ds-background-primary for the app body. Use --ds-gray-1 for default surface, --ds-gray-2 for subtle panels.
- Radius.
--ds-radius-xs (8px), --ds-radius-sm (10px), --ds-radius-md (12px), --ds-radius-lg (14px), --ds-radius-xl (16px), --ds-radius-full (9999px).
- Shadows (structural ring).
--ds-shadow-1, --ds-shadow-2, --ds-shadow-2-focused, --ds-shadow-3. Resting elements.
- Shadows (floating popup).
--ds-shadow-popover, --ds-shadow-dialog, --ds-shadow-toast, --ds-shadow-tooltip.
- Shadows (atomic).
--ds-shadow-thumb, --ds-shadow-badge, --ds-shadow-focus-halo.
- Motion.
--ds-duration-{fast 80, snap 120, medium 180, emphasized 250}, --ds-easing-{standard ease, snap cubic-bezier(0.22, 1, 0.36, 1)}. Press feedback is transform: scale(0.98) everywhere.
- Mono font.
var(--ds-font-mono) for <code>, <kbd>, and any identifier-leaning string.
- Brand.
--ds-brand-{twitter, cosmos, arena, facebook, pinterest, dribbble} for source-badge backgrounds. Stay inside the badge; never escape into surrounding chrome.
Radix scale cheat-sheet for picking the right step:
| Step | Use for |
|---|
| 1 | App background |
| 2 | Subtle background (cards, panels) |
| 3 | UI element background (resting) |
| 4 | Hovered UI element background |
| 5 | Active / selected UI element background |
| 6 | Subtle separators |
| 7 | UI element borders |
| 8 | Hovered borders, focus rings |
| 9 | Solid backgrounds (buttons, badges) |
| 10 | Hovered solid backgrounds |
| 11 | Low-contrast accessible text |
| 12 | High-contrast text |
Common picks for everyday situations:
| Need | Token |
|---|
| High-contrast text and icons | --ds-gray-12 |
| Soft secondary text | --ds-gray-11 |
| Hover background (composes on any surface) | --ds-gray-a3 |
| Selected / pressed background | --ds-gray-a4 |
| Focus rings, hovered borders | --ds-accent-8 |
| Solid accent (button, link) | --ds-accent-9 |
| Subtle separators | --ds-gray-a4 |
| UI element borders | --ds-gray-a6 |
| Subtle accent tint (pill, avatar swatch) | --ds-accent-3 |
9. Defaults at destructure, not in JSX
function Page({ width = "medium", ...props }: PageProps) {
Not:
function Page(props: PageProps) {
const width = props.width ?? "medium";
...
}
The destructure form is the same line count, types correctly, and shows the default in the signature.
10. No JSDoc on primitives, prose where intent matters
Sub-components like Settings.Item don't need JSDoc; the name and the props say it all. Reserve doc comments for non-obvious behavior, like the notifications section explaining how toast categories map to the useToast wrapper.
/**
* Notifications section. Each switch maps to a `category` tag the
* shared `useToast()` wrapper checks before rendering. See
* `apps/desktop/src/renderer/src/ui/toast.tsx`. Untagged toasts
* (system errors, IPC failures) always show.
*/
Don't comment what the JSX already says. Do comment why a piece of state or an effect exists.
Component skeleton
Use this as the starting point for a new compound component. Replace the name and the sub-components.