| name | designtools-frontend |
| description | Frontend conventions for MxM projects. Use whenever building, editing, or reviewing screens, components, layouts, or styles in a project generated by designtools-start: creating a page, adding a component, changing visual design, or any React or CSS work. Encodes the stack rules: Base UI, CSS Modules, design tokens, Lucide, motion, accessibility, and the three states. |
Frontend conventions
The stack is fixed: React + TypeScript on Vite, Base UI primitives, CSS Modules with design tokens, react-router, Lucide icons, motion. No Tailwind, no ShadCN, no Radix, no CSS-in-JS, no other component or icon library.
The foundation is generated, not copied
There is no starter template. You write the project's shell — config, base styles, the HTML document, the app entry — from the conventions below. Only two things are pinned rather than generated, because the agent can't safely infer them: package.json is built from designtools-core/versions.json (dependency pins), and .claude/settings.json is copied verbatim from designtools-core/settings.json (permission policy, wanted exact). Colour comes from designtools-tokens. Everything else here you generate, so it fits the project.
Base styles (src/styles/base.css)
One small, stable file, imported after tokens.css (which the token engine generates). It sets:
-
Cascade layers, declared once in order: @layer reset, base, components, utilities; — so component and utility CSS never fight on specificity.
-
A minimal reset in @layer reset: box-sizing: border-box, zeroed margins, min-height: 100dvh, block + max-width: 100% media, font: inherit on form controls, text-wrap: balance on headings and pretty on paragraphs.
-
The scale, as CSS variables on :root in @layer base — the vocabulary every component draws on, so "no magic numbers" has something to reach for: space --space-1…8 (0.25rem → 4rem), type --text-xs…4xl, --weight-normal…bold, --radius-sm/md/lg/full, and a --shadow-sm/md/lg elevation trio. Keep the names; retune the values when the brief wants a different density or rhythm. The names are the contract every component relies on; the values are art direction. The prefixes are also how @designtools/surface sorts tokens into its Tokens tab and populates its per-element scale pickers (Size, Weight, spacing, …) — --text-*/--weight-*/--leading-*/--tracking-* → Typography, --space-* → Sizing, --radius-* → Radii, --shadow-* → Shadows — so keep the vocabulary; renaming a scale hides it from the visual editor. (Surface also recognises the common variants, e.g. Tailwind v4's --font-weight-*, so an established convention isn't required — but ours is the paved path.)
-
Elevation is layered light, not one blur. Each --shadow-* is two layers over the --shadow colour token: a tight, near-zero-blur contact shadow with slight negative spread (anchors the element to the surface) plus a softer, lower-opacity ambient shadow (diffuse depth). A single 0 4px 12px rgba(0,0,0,.1) on everything is the generic-app tell; two layers read as considered. Treat the trio as a depth scale — sm/md/lg map to how much an element should lift (a resting card vs a menu vs a dialog), not "put a shadow on it". Example resting/ambient pair:
--shadow-md:
0 1px 1px -0.5px color-mix(in oklch, var(--shadow) 14%, transparent),
0 6px 16px -4px color-mix(in oklch, var(--shadow) 14%, transparent);
-
Radius with restraint. Reserve --radius-lg for genuine card surfaces; reach for --radius-sm/--radius-md for controls and inputs, and don't pill everything. Uniform big radii everywhere is the other half of the generic look.
-
color-scheme: light dark and the font stacks (--font-sans, --font-mono).
-
One focus treatment for the whole app: :focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }.
-
Reduced motion: a prefers-reduced-motion: reduce block that neutralises animation and smooth scroll.
The colour tokens (--canvas, --primary, --ring, --shadow, the -foreground pairs) live in tokens.css, so base.css consumes them and must be imported second. Never hardcode a colour here.
The document (<head>)
Finish the document — a bare title and a missing favicon read as unfinished. Fill it per project, from the brief and brand:
<title> and <meta name="description"> — real and specific, never "Project".
- A favicon — at least a simple mark from the brand colour (an inline SVG
data: favicon is fine); never leave the 404. Add apple-touch-icon when it may be saved to a home screen.
theme-color for light and dark (<meta name="theme-color" … media="(prefers-color-scheme: dark)">).
lang on <html>, plus charset and viewport (width=device-width, initial-scale=1).
- Open Graph / Twitter card tags when the project will be shared — title, description, one image.
- One Google Font at most,
<link>ed here and wired to --font-sans, and only if the project picks a display face.
Project config
- Vite — the React plugin,
base: "./" (the build then opens from any subpath or a file path without config), server.open off.
- TypeScript —
strict, bundler resolution, react-jsx, verbatimModuleSyntax, noUnusedLocals/noUnusedParameters, noEmit (Vite builds); include: ["src"].
- Entry —
src/main.tsx mounts <App /> in <StrictMode> inside <BrowserRouter>, importing tokens.css then base.css, in that order.
- Ambient types — always generate
src/vite-env.d.ts containing /// <reference types="vite/client" />. Under strict + verbatimModuleSyntax, TypeScript has no declaration for *.module.css or asset (.svg, images) imports without it, so tsc — and therefore tsc && vite build — fails the moment any component imports its .module.css, i.e. essentially every real project. The bare placeholder home screen has no such import, so a project without this file runs in dev but won't build; generate it up front so it can't be missed.
Components
There is no pre-made component set. The reusable layer is Base UI (@base-ui/react) — accessible, behaviour-correct primitives with well-designed APIs. Styling is the app: it varies per brief, so components are composed and styled on demand, not shipped as a frozen set. A suite for making vastly different apps must not hand every project the same buttons — that is exactly why generated apps all look alike.
Build a few basics to start, then more as screens need them. Don't scaffold a library up front. Compose the couple of primitives the first screens actually need — usually something like a button and a card, but that call is yours, driven by the brief, not a fixed list — and add the rest when a screen calls for one. Build only what gets used.
To build one: compose a Base UI primitive, style it with a CSS module driven by tokens, and hold it to the floor below. Read the Base UI API at base-ui.com before assuming it (per-component imports).
- One folder per component:
src/components/button/button.tsx plus button.module.css. Files kebab-case, components PascalCase. Screens live in src/screens/<name>/.
- Variants use data attributes, not class permutations:
<button className={styles.button} data-variant="outline" data-intent="destructive">
.button { background: var(--primary); color: var(--primary-foreground); }
.button[data-variant="outline"] { background: transparent; border: 1px solid var(--border); }
.button[data-intent="destructive"] { background: var(--destructive); color: var(--destructive-foreground); }
variant is the style of: default, outline, ghost, text. intent is the meaning: default, neutral, success, destructive, warning. Where a component has variants, follow this split.
- Every styled element gets a
data-slot. Put data-slot="<name>" on each element that carries a CSS-module class — the component's root and every stylable inner part — kebab-case, named for purpose (card / card-header / card-title; select-trigger / select-item). Base UI forwards it to the DOM, so <Select.Trigger data-slot="select-trigger"> works. This is how @designtools/surface (see designtools-surface) identifies a component and lets a designer restyle each part by eye; without it, clicking an inner part selects the whole component.
- A component used twice gets extracted. A one-off stays in its screen.
The floor a component clears (deliberately minimal — a starting point to extend as real projects teach us, not a spec to satisfy):
- Accessible. Base UI carries behaviour and ARIA; you carry the visible floor — a focus ring on anything focusable, adequate target size, contrast (the tokens are already contrast-checked), labels associated.
- Composable and pure. Renders from its props. No hidden global state, no data fetching inside it; layout is the caller's job.
- Token-driven. No hex, no magic numbers where a token exists. This is also what keeps independently-built components reading as one app.
- Typed at the edges. Forward the native element's props (
className, onClick, disabled, …); don't re-invent them.
Lean on established primitive and accessibility guidance rather than re-deriving it. These conventions are meant to evolve.
Art direction: this is the job
Styling is where the project becomes itself. Don't reach for a default look — start each component from tokens and the brief, not from a generic baseline you then decorate. Style the component's .module.css, or shift the whole system via tokens. Choose type, spacing rhythm, density, and imagery with intent. A fintech dashboard and a children's reading app should not come out looking the same — and with no shared component set, nothing pushes them to.
Avoid the AI defaults, which are a tell rather than a choice: Inter, indigo-to-purple gradients, three cards in a row, heavy shadows on everything, huge border radii. Pick something because the brief earns it.
Colour: tokens only
Never a hex value, named colour, or primitive shade where a semantic token exists. The palette lives in src/styles/tokens.css and handles light and dark automatically, provided you stay semantic.
Use first, each with a matching -foreground and -subdued / -highlight pairs:
--primary, --neutral, --success, --destructive, --warning
Surfaces: --canvas (page), --surface (cards, panels), --overlay (popovers, dialogs), each with -foreground. Utility: --border, --input, --ring (focus), --muted and --muted-foreground.
Backgrounds and foregrounds travel in pairs:
background: var(--success-subdued); color: var(--success-subdued-foreground);
Never a semantic background with a guessed text colour; the pairing is what guarantees contrast in both modes.
Primitive scales (--color-primary-500 and friends) are for gradients, charts, and illustration only. A second brand colour arrives as a primitive scale (--color-<name>-500); it has no semantic slots, so use it decoratively.
Layout, type, spacing
- Spacing, radius, shadow, and type sizes come from tokens; no magic pixel values in modules.
- Modern CSS is the toolkit: nesting,
:has(), container queries, grid, light-dark() where useful. No resets beyond base.css.
- Responsive by default, smallest screen first. Every screen must be presentable at 375px and 1280px.
- Type: the
--font-sans system stack unless the project picks a display font. One Google Font per project maximum, loaded in index.html.
The style-guide page (opt-in)
A /styleguide route that renders the live design system from the project's own tokens and components: the type scale, the spacing scale, the colour tokens (semantic pairs and primitive ramps, in both light and dark), radii and the elevation scale, and every component you've built shown in its variants and its three states (loading / empty / error). Not a shipped page — route it off the main nav, never in the app's real navigation.
It earns its place three ways: a living reference the designer can point at ("show me all my colours / buttons / states"); a visual smoke test that surfaces a broken token or state at a glance; and the ideal surface to open @designtools/surface against — every token and component on one page, each with its data-slot, beats hunting for an instance inside a feature screen.
It's opt-in: it takes a while to build and isn't wanted for a throwaway sketch, so offer it, don't force it (designtools-start asks). A cheap first version — a page that imports the token CSS and renders swatches plus one of each component — delivers most of the value; grow it as components are added.
Non-negotiables
- Icons are Lucide (
lucide-react). Emojis never appear in UI, code, or copy.
- A domain icon set (weather glyphs, brand marks, a map/transit set) is a fine exception when the brief needs it — Lucide stays the default for general UI. Add it the clean way, not by hand: install the maintained npm package (
pnpm add <pkg>), import each glyph you use as a local URL asset (import clearDay from "@bybas/weather-icons/…/clear-day.svg" → <img src={clearDay} …>), and keep it scoped to the one domain that needs it. Never curl raw SVGs out of a repo or paste them inline. Say what and why before installing, then proceed — the gate is a heads-up, not a wall.
- Animation is motion (
import { motion } from "motion/react"); keep it purposeful and respect prefers-reduced-motion.
- Every data view ships three states: loading (skeleton), empty (friendly, with a next action), and error (plain words, retry). No view is done with only the happy path.
- Accessibility floor: semantic HTML, every input labelled, alt text on images, keyboard reachable (Base UI handles most of it),
:focus-visible outlined with var(--ring) on interactive elements. Contrast is the tokens' job, so do not override foregrounds arbitrarily.
- Dependencies are fixed: react, react-dom, react-router, @base-ui/react, lucide-react, motion, plus dev tooling. Anything beyond that list, say what and why before installing.