Imported from santialemarino/renly (
.agents/skills/web-components-pages/SKILL.md). Install upstream withnpx skills add santialemarino/renly --skill web-components-pages. Copyright stays with the author.
Web components and pages (Renly frontend)
Also load the
ux-motionskill whenever creating or restyling UI — it owns interaction states (focus-visible/hover/active/disabled), motion (motion/react + the animation constants), layout-shift avoidance, reduced motion, and the App-vs-Public surface split.
Creating a page
- Choose the route group: Auth (login, signup) →
app/(auth)/<name>/. Protected (dashboard, settings) →app/(protected)/<name>/. - Add the path to config: In
config/routes.ts, add the path toROUTES(e.g.settings: '/settings'). UseROUTES.*everywhere; no hardcoded paths. - Add
page.tsx: Default export a server component. For protected routes, the layout already ensures a session; callgetSession()in the page if you need user data. For auth routes, redirect toROUTES.homewhen the user is already logged in. - Metadata: Use
generatePageMetadata('namespace')fromlib/utils/page-metadata.tsxwhen the page has translations. If a page ever hand-rolls its owngenerateMetadata, spreadOG_SITE_DEFAULTS/TWITTER_SITE_DEFAULTS(config/site.ts) into itsopenGraph/twitter: Next REPLACES those objects rather than merging them into the layout's, so a page that names one dropsog:type,og:site_nameand the share image unless it restates them.
Creating a component
- Only used on one page: Create it in that page's
_components/folder, e.g.app/(auth)/login/_components/login-card.tsx. - Used on multiple pages: Create it in the app's
components/folder. - Reusable across apps (design system): Create it in
packages/ui/src/componentsand add it to theindex.ts, so it can be shared. In the web app, import from@repo/ui/componentsor the path defined in the UI package. - shadcn/ui components (
packages/ui/src/components/*.tsxgenerated by shadcn): do not enforce Tailwind class order or typography token conventions on these files. They are third-party-sourced and should be left as-is unless there's a specific functional reason to edit them. - Client interactivity: Add
'use client'at the top. Use for forms, hooks, event handlers, or anything that uses browser APIs or React state. - Importing: Always import components (and everything else) using aliases defined in
package.json- not using.or..- (e.g.@/app/(auth)/login/_components/login-form).
Reuse and componentization
-
Reuse first. Reuse
@repo/ui(button, card, badge, input, textarea, checkbox, switch, select, popover, tooltip, dialog, sheet, toggle/toggle-group, separator, skeleton, pill, hint, table, command, calendar) and existing app components before building new — restyle through existing CVA variants, don't fork. A genuinely new shared component goes inpackages/ui/src/components+ itsindex.ts. -
List-page primitives — reuse, never re-copy. Sortable column headers use
SortableTableHead(+SortIcon) with sort state from theuseTableSorthook; URL-param navigation usesuseSearchParamsNavigation(itsresetPageoption encodes whether filters reset pagination — expenses/income/investments do, the rest don't); the standard list toolbar (debounced search + optional filters slot + archived pill + add button) isEntityListToolbar; single-select toolbar filters areFilterCombobox; plain destructive confirms areConfirmDialogand type-to-confirm onesTypeToConfirmDialog(both takeentity+ label functions and internally retain the last non-null entity through the close animation — never re-implement that ref-dance); table row icon actions areRowActionButton— which takes eitheronClickfor an action orhreffor a row action that only navigates, and a pure-navigation action MUST passhrefso it stays a real link (new-tab, copy-address, middle-click, prefetch, and "link" in a screen reader's list all die on arouter.pushinside a button); a row whose actions are deliberately withheld usesRowLockedIndicator(a muted icon + tooltip that explains the absence in the emptied cell); the row-count + page links under a paginated table areTablePagination(it renders the count always and the links only past one page, and takes the already-translated per-entity count string because the noun differs per list); and a signed money amount in a movement row isSignedAmountCell(explicit +/− sign, magnitude, muted treatment for money leaving, plus an optionalsubLineslot); a scope-grouped table's section header isTableSectionRow(label + count + per-currency totals,sticky left-0so it stays readable on a wide scrolling table, and it resolves the pot-label fallback itself) with the row/header walk coming fromsectionedRowsinlib/list-scope.ts(pure, so it is testable where a Radix-rendering component is not); a segmented set of mutually-exclusive toolbar pills isSegmentedPills, and the scope filter built on it isScopePill; and thescope/intervalsearch params are read throughresolveListScope/resolveGridIntervalrather than parsed per call site, since a page and its toolbar both read them and must agree. All inapps/web/components/+lib/hooks/. -
A scope-grouped list is grouped, never switched — and a header may only state what its rows show. The four list pages and the snapshots grid group rows by scope with a filter pill (X2). Three rules hold across all five and are load-bearing: a scope selection FILTERS and is never a mode (a persistent mode lets somebody misread every number on screen, because the page keeps showing a subset while looking like the whole); a section total is the sum of what its own rows display, per currency and never converted, or a count where the rows carry no money column, so no header states a figure the visible rows fail to add up to; and the rows arrive scope-major with the caller's sort applied within each section, which is the only thing that makes a header drawable at all — a section can only be labelled where its rows are contiguous. A list endpoint's
scopetherefore defaults to whatever changes nothing for its existing callers, which differs per endpoint:/investments,/accountsand/snapshots/griddefault toprivatebecause nine other pages read them as a picker of the caller's own things, while/expensesand/incomedefault toallbecause they have unioned each member's share since the flow half shipped. Grouped-by-default is a property of the LIST PAGE, which asks for it. -
Withholding a row action: hide it, and say why — never disable it. A Radix tooltip never fires on a
disabledtrigger, so a disabled button with an explanatory tooltip explains nothing. Hide the action instead and renderRowLockedIndicatorin its place so the cell keeps its metrics and the user learns where the supported action lives. The indicator is deliberately a focusable non-interactive element, not aButton: it performs no action, so button semantics andcursor-pointerwould lie — it carries anaria-labelplus the icon-only focus treatment (see ux-motion) so the explanation is reachable by keyboard, not hover-only. -
Gate each withheld action on its own predicate. When two different invariants both suppress actions, keep them as separate named predicates rather than one flag — they usually diverge later, and a single flag then either over- or under-reaches. Derive a predicate from the existing source of truth (e.g. "is this category system-generated?" = not in the user-pickable set the picker renders) so it can never drift from the thing it describes.
-
Empty states & dismissible hints. For a data table with no rows, reuse
TableEmptyRow(apps/web/components/table-empty-row.tsx) — it renders the first-run teachingEmptyState(apps/web/components/empty-state.tsx— icon + title + description) or the plaintable.emptyfallback in onecolSpancell, so the plain-cell styling can't drift across pages. Compute the first-run flag withisFirstRunEmptyState(isEmpty, hasActiveFilters, settings)(@/lib/onboarding): teach only when the section has no rows at all, no search/filter is hiding existing rows, and the user hasn't completed onboarding (getSettings().onboardingCompleted, fail-closed on a load error) — a returning/onboarded user or a filtered-empty view gets the plain line, so someone who cleared their data isn't treated as a newbie. For a non-table section renderEmptyStatedirectly (e.g. the snapshots grid). For a dismissible contextual nudge/coachmark, reuseDismissableHint(apps/web/components/dismissable-hint.tsx— wrapsStyledHint, persists dismissal to localStorage bystorageKey) instead of reimplementing the localStorage read/write per hint. When the nudge teaches a concept the help page explains, useConceptHint(apps/web/components/concept-hint.tsx) instead — it owns the hint plus the trailing "Learn more" deep link, and takes the target asanchor={HELP_ANCHORS.x}rather than a hand-built#fragment, so a renamed help section is a type error and the parity test can assert every anchor still resolves. Add the anchor toHELP_ANCHORSinconfig/routes.ts; leave the copy with the caller. -
Never start a navigation with a write still to come.
router.push/router.replace/router.refreshbegin a navigation, and a Server Action called after one is cancelled by it — the promise then never settles, so the result branch,catchandfinallyall fail to run and the button sits on its loading label for good. There is no error anywhere, andtsc, ESLint, the unit tests andbuild:weball pass it; only a stuck spinner in the browser reveals it. Order every handler so the navigation is the last thing, and put it infinallywhen it must happen on the failure path too. Wrap it inuseTransitionand add thatisPendingto whatever disables the primary control: it stops a later write racing an in-flight navigation, and it is the honest state anyway, because until arouter.refresh()lands the server props still describe the world BEFORE the write — a summary rendered from them states the previous answer as if it were the new one. -
An
Input's WIDTH goes oncontainerClassName, neverclassName.@repo/ui'sInputrenders aw-fullwrapper carrying the border, background and focus ring, with the bare<input>inside it — soclassName="w-28"sizes the text box inside a still-full-width field and reads as though it did nothing.LocaleAmountInputforwardscontainerClassNamethrough to it for the same reason. Symptom to recognise: a narrow-looking class applied and the field still spanning the row, with a sibling label squeezed to zero width. -
Reuse a base component's props; match how peers use them. Before styling a control ad-hoc, use the props the base already exposes (
Labelrequired/blue,Switch/SelectTriggerblue/surface,SelectTriggericon,Buttonblue/variant/asChild) and copy the prop pattern peers use (e.g. a formSelectTriggertakesclassName="w-full"so its dropdown aligns under the field; aSwitchuses the sameblue/surfaceas its peers) instead of accepting raw defaults or hardcoding styles. If a base component lacks a shared behavior every instance should have (e.g. a select chevron that rotates on open), add it to the base component — don't reimplement it per call site.@repo/uiships its CSS prebuilt (exports["./styles"]→dist/index.css; components are consumed fromsrc, so logic is live but styles are not): after changing apackages/uicomponent's classNames, rebuild it (pnpm --filter @repo/ui build, or rely on itsdevwatcher) and restart the web — otherwise the new classes never reach the app and the change silently does nothing. -
Componentize repeated structure. Structure that repeats — feature cards, list/step items, page sections, a legal "section" block, a page header (title + meta —
PageHeader, whosetrailingslot takes a status the title cannot carry, e.g. an archived badge), a header/footer shell — is ONE component rendered by mapping data, never copy-pasted markup. The same element in two places is one shared component (app-only → the app'scomponents/; cross-app →packages/ui); sibling sections that share a shape become section components that a thin page composes. Consistency beats local cleverness: the same control means the same thing, in the same place and order, across surfaces (e.g. don't flip primary-then-secondary CTA order between a hero and a header). -
When you extract a shared component, share the STYLING — leave the copy with each caller. Two surfaces rendering the same shape often word it differently on purpose, so unifying their strings while extracting is a silent product change, not a refactor. Give the component a slot (
SignedAmountCell'ssubLine) and let each call site pass its own words. Collapsing two strings into one is only safe when they are already byte-identical. -
Button as a link. Use
<Button asChild>wrapping an<a>/<Link>rather than a hand-rolled anchor, so it keeps the variant, ring, and states. Gotcha: thedefaultvariant carries an[a]:hover:rule, so when you also passblue/custom hover on an anchor the anchor-scoped rule can win — confirm a link-button's hover matches the same button rendered as a real<button>. -
Dialogs. Reuse the
@repo/uiDialogprimitives (Dialog+DialogContent+DialogHeader+DialogTitle+DialogDescription+DialogFooter) — never hand-roll a modal. Always render aDialogTitleand aDialogDescription(Radix a11y warns without a description). The overlay, open/close animation, and the close ✕ (already styled to the icon-button focus convention) live inDialogContent— control behavior with itsshowCloseButton/closeOnClickOutsideprops; don't re-implement or re-style the ✕. Keep the dialog's data mounted through the close — pass it as a stable prop and toggle onlyopen(see ux-motion's dialog exit rule), so the body doesn't blank out mid-animation. -
Charts: recharts through
lib/constants/charts.ts, never ad-hoc values. Every visual property (colours, heights, margins, axis/tooltip/legend settings, animation) is a named const there, so a new chart is composed rather than styled. Three rules the pot value series added, all of which the next chart needs:- A series that can legitimately have GAPS must show them as gaps. Keep
connectNulls={false}and give every point adot, or a lone valued point between two unknowns draws a zero-length line segment and is simply invisible. Never substitute 0 for "we do not know" — on a value series that reads as growth from nothing. - Two or more series always carry a
<Legend>, because identity must never be colour alone; give it an explicitheightso the plot area does not reflow once the legend is measured. Where the two series are the same measure at two scopes (a total and a part of it), use one hue at two steps (CHART_COLOR_PRIMARY/CHART_COLOR_SECONDARY) rather than two unrelated colours — it encodes the relationship, and separating by lightness is also the most colour-vision-robust axis there is. - A figure and the date it belongs to are one fact — and say which fact. "As of {date}" claims the figure IS that date's value. If the date is instead the oldest input behind a current figure, say "updated through {date}": a tile reading "55,000 · as of 28 Feb" above a chart showing 38,000 that February states something false.
- A series that can legitimately have GAPS must show them as gaps. Keep
-
A money figure names its currency wherever a surface can show more than one. A list that holds every currency an entity has used, side by side and unconverted, must append the code to each amount — a bare
120beside a bare90,000leaves the reader to guess which is dollars. Where the main figure has been CONVERTED to a display currency, any sub-line about the original must restate enough to stand alone (the share and the whole, in the original currency), or the two figures read as one scale and invite a ratio that does not exist. Same family as the chart rule above: a figure and the thing it is measured in are one fact. -
Payment method + card fields. Any form offering a payment method reuses
PaymentMethodFields(apps/web/components/payment-method-fields.tsx) — it owns the method select, the conditional card select (height-reveal), the clear-card-on-method-change effect, and the zero-cards inline "Add a card" empty state with the stackedCreditCardFormDialog+ auto-select. Never re-implement the pair per dialog. -
An entry is created in exactly ONE place per record type — a new surface reuses the dialog, it never re-implements it. There are four canonical entry forms (
ExpenseFormDialog,IncomeFormDialog,SharedExpenseFormDialog,SharedIncomeFormDialog, all inapp/(protected)/_components/) and the global quick-add opens those same four, unchanged. A leaner "quick" form would be a second place an expense is created, and every rule the real one carries — the novel-currency confirm, the auto-charge duplicate warning, the cycle-advance preview, a linked plan's funding-account prefill, the split rows, the payer refusal — would be either absent (so the fast path writes rows the slow path would have questioned) or duplicated. Speed comes from REACH and PRE-FILL, not from dropping fields. -
A form that swaps for another one does it through
useDeferredDialogSwap(apps/web/lib/hooks/use-deferred-dialog-swap.ts). Three surfaces swap entry forms — the expenses toolbar, the income toolbar and the quick-add, the last on two axes at once — and the hook owns the close-then-reopen timer they share: two dialogs mounted at once stack two Radix overlays and double the dim, so the outgoing one has to finish its exit (DIALOG_EXIT_MS) first. The swap TARGET carries the in-progress values, which is what lets each target keep its own prefill type and means nothing about the outgoing dialog changes while it animates out.startcancels any pending swap; without that a timer scheduled a moment ago replaces the form the user just asked for. -
What crosses a swap is a rule, and it lives in
lib/entry-handover.ts— not beside the control.toHandovernarrows a SCOPE swap (private ↔ a group's shared form) and keeps the category, because both sides are the same list;toTypeHandovernarrows a TYPE swap (expense ↔ income) and DROPS it — and the reason is sharper than "the enums differ": they overlap on exactly two values,giftsandother, so eighteen of twenty categories would arrive as a blank picker refused with a 422 (loud), while those two would be silently ACCEPTED, turning a gift you paid for into a gift you received. The quiet case is what makes it a rule.EntryHandover<never>is how the type states that. Both are pure and underlib/for the reason every rule in this app is: the two controls render Radix primitives, which the web unit suite cannot mount at all, so a rule inside one is a rule nothing tests. -
The two "which record am I writing" controls are
EntryTypeFieldandEntryScopeField, stacked in that order at the top of all four entry forms, each rendered only when itson*Changeprop is supplied — so only the quick-add shows the type toggle, and the scope control still renders only for a user who belongs to a group. Both are MODES rather than fields and neither is part of any form's zod schema: they change which form you are filling in. -
A client component in the protected LAYOUT puts its whole import graph on every protected route — so load heavy dialogs from there with
next/dynamic. The sidebar is the layout's client surface, so anything it statically imports ships with all twenty-odd protected pages including/dashboard. Measured on the production build, static → dynamic, per route:/dashboard1680 → 1560 KiB,/notifications1326 → 1206 KiB,/snapshots1346 → 1254 KiB — and two of those three render no entry form at all. The quick-add is the one place in the app that usesnext/dynamic, and deferring costs the user nothing there because its open handler already awaits six reads with the trigger in a loading state. Measure the same way before reaching for it anywhere else: the per-route client chunk list is in.next/server/app/**/page_client-reference-manifest.js, and a marker string unique to the module (a form'sid) says which routes actually carry it.
Forms and inputs
Build inputs with the shared form stack — never a bare <Input> wired to ad-hoc useState. Consistency, accessibility, and error UX all come from reuse:
- Use the Form primitives. A field is
Form+FormField+FormItem+FormLabel+FormControl(wrapping the@repo/uiinput) +FormMessage, driven byreact-hook-form+ a colocated zod schema (form-schema.ts). Reuse the shared validators and messages (e.g.EMAIL_REGEX, thecommon.form.errors.*keys) rather than re-deriving them per form. - Required marker is a prop, not markup. Mark a required field with the base
Label'srequiredprop —<FormLabel required>for RHF fields, or<Label required>directly for a label outside a form. The asterisk (its color and spacing) is owned by the baseLabel, so every required label matches andFormLabeljust forwardsrequired. Never hand-write a per-label asterisk<span>— it drifts from the convention and misaligns (an-ml-1tuned for theLabel'sgap-2overlaps a plain<label>that has no gap). noValidateon every<form>. The browser's native validation bubbles are ugly and untranslated — suppress them so the inlineFormMessageis the single source of validation feedback.- Errors reveal without layout shift.
FormMessage(field) andFormError(form-level) animate height open/closed and reserve no space when absent — so showing or clearing an error never snaps the layout. Don't hand-roll an error<p>, and don't pad fixed space for one. - Surfacing a backend conflict from a form dialog.
submitWithLifecycle(useEntityFormDialog) shows the localized conflict when the save returns{ ok: false, conflictDetail }, falling back to its staticerrorMessagefor a genuine failure. Return the conflict as data, never a thrown error: the Server Action boundary strips prototype chains, so a thrown class instance arrives client-side as a plainErrorand its message is lost (seedeleteCreditCard/deleteExpensefor the shape). A save that returns nothing is still treated as success. - Surface server-side field errors inline. Map a field-specific API failure (e.g. a 409 "already taken", a rejected password) onto the field with
form.setError(name, …)so it reads like a validation error — same place, same animation — instead of only a toast. - A CONDITIONALLY RENDERED field must be
.optional()in its zod schema, and the branch rule belongs in asuperRefine. React Hook Form unregisters a field when its control leaves the DOM, and unregistering deletes its value — so a field that is only rendered on one branch of a form is simply absent from the submitted values once the user switches away from it. A requiredz.string()then fails with "expected string, received undefined" on a field that is no longer on screen, where there is noFormMessageto render the error and noaria-invalidto see. The symptom is a submit that does nothing at all: no request, no toast, no error anywhere, the button not even entering its loading state, andtsc, ESLint, the unit tests andbuild:weball green. Only instrumentingform.handleSubmit(onValid, onInvalid)reveals it. Make every branch-specific field optional and enforce its presence in asuperRefinekeyed on the branch discriminator — then test it bydelete-ing the key, not by blanking it, because a blank string and an absent one are exactly what the schema has to stop confusing. - An effect that normalises a field against LAZILY LOADED options must wait for the load. "The list is empty" and "the list has not arrived" are the same value and opposite facts. An effect that clears or rewrites a selection the moment its option list looks empty will silently rewrite a saved record the instant its form opens — gate it on the loaded state being non-null, not on the list's length.
- Dropdown fields use
FormCombobox, not the baseSelect. Single-select form dropdowns useFormCombobox(apps/web/components/form-combobox.tsx) — a Popover +Commandcombobox — because RadixSelectanimates open but snaps shut on close (no Presence), whereas the Popover animates both ways. It behaves like a native<select>: no search box, blind type-ahead (typing jumps the highlight) + arrow-key nav, withshouldFilter={false}so the list never filters/resizes (no re-flip). Wrap it in<FormControl>(it forwards id /aria-invalid/aria-describedbyto the trigger); passvalue/onValueChange(string values — convert number-valued fields at the call site) andoptions({ value, label, icon?, disabled?, group?, render? }); groups render viagroup, rich rows viarender. The baseSelectprimitive is kept in@repo/uifor non-form uses but should not be reached for in forms. Commandlists inside a Popover need two things (FormCombobox,FilterCombobox, currency/timezone comboboxes all do this):onWheel={(e) => e.stopPropagation()}on theCommandList— otherwise, when the popover is portaled outside an openDialog, that Dialog'sreact-remove-scrolllock swallows wheel/touchpad scroll over the list — and the shared thin-scrollbar classespr-1 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border.
Icons
- Prefer Lucide: Use
lucide-reactwhen an icon exists there (e.g.import { Check, Eye, XIcon } from 'lucide-react'). Same usage pattern as in the repo: import by name, render as<IconName className="..." />. - Custom SVG when needed: When no Lucide icon fits, add a
.svgfile underpublic/icons/(or a subfolder, e.g.public/icons/illustrations/) and import it as a React component. The repo uses@svgr/webpacksoimport MyIcon from 'public/icons/my-icon.svg'gives a component; render as<MyIcon className="..." />. Do not use<Image src="/icons/...">for icons/illustrations that need to be styled or scaled with CSS.
Iteration
Prefer higher-order functions (forEach, map, filter, reduce, some, every, find, flatMap) over for / for...of loops when the intent maps naturally to one of those operations. Use a loop only when you need early break/continue, async-sequential iteration (for await), or when a loop is genuinely clearer (e.g. building multiple outputs in one pass).
Colocate feature files
Keep all feature-specific modules in the same folder as the page that uses them: actions.ts, schema.ts, form-schema.ts, etc. Same hierarchy as the route — e.g. app/(auth)/login/actions.ts, app/(auth)/login/form-schema.ts, app/(protected)/dashboard/actions.ts. Do not put them in a global lib/ or app/ root unless they are shared across multiple routes.
Order and style
Outside the component (file-level order):
- Consts — Module-level constants at the top of the file. Every magic number must be a named const — no raw literals in logic. Common cases: timeouts (
CLEAR_ANIMATION_MS = 100), cookie durations (COOKIE_MAX_AGE_1_YEAR = 60 * 60 * 24 * 365), scoring values, size thresholds, etc. Single-file constants (only used where they're defined) stay in-file. Multi-file constants (imported by 2+ files) go inlib/constants/<topic>.ts— one file per topic (e.g.animations.ts,currency.ts,charts.ts). - Metadata — For pages only:
export const metadata = { ... }orexport async function generateMetadata() { ... }. - Props type — Always place it immediately above the component. Define it for every component that receives props/params; omit only when the component has no props.
Inside the component (declaration order):
- Session —
getSession()or similar, if the component needs it (server components). - i18n group — locale/formatters hook, then translations. When the component formats or needs the locale, the hook (
useFormatters()/useLocale(); servergetFormatters()/getLocale()) comes first, immediately followed byuseTranslations('namespace')— the specific/feature namespace, thentCommon/useTranslations('common')if needed. Keep the hook anduseTranslationsadjacent (see Formatting & locale). - Router —
useRouter()(client components). - State and refs —
useState,useForm,useRef,useWatch, etc. - Derived state / memo —
useMemo, computed values that depend on the above. - Effects —
useEffect(and similar). - Handlers and functions —
onSubmit, event handlers, and other callbacks. - Early returns — Guard clauses (e.g. loading, null checks).
- Return — The JSX return last.
Keep the page thin: put UI in components. One main component per file; small helpers can live in the same file or a sibling. Use ROUTES and translation keys consistently.
Tailwind class order (className)
Use this order so classes are predictable and easy to scan. Apply in apps/web (and in packages/ui when adding or editing components).
- Display & flex —
flex,grid,flex-col,flex-1,flex-wrap, etc. - Sizing —
size-*,w-*,min-w-*,max-w-*,h-*,min-h-*,max-h-*,min-h-screen, etc. Usesize-*whenever width and height are equal (e.g.size-4instead ofw-4 h-4). - Alignment —
items-*,justify-*,self-*,content-*. - Padding —
p-*,px-*,py-*,pt-*, etc. - Gap — Prefer
gap-x-*orgap-y-*instead ofgap-*unless the layout truly needs the same gap on both axes (e.g. a small grid where both dimensions matter). For a flex column, usegap-y-*; for a row, usegap-x-*. - Background & border —
bg-*,border,border-*. - Rounded —
rounded-*. - State & interactive —
hover:*,active:*,focus:*,focus-visible:*,disabled:*, etc. - Typography & misc —
text-*,font-*,leading-*,whitespace-*,overflow-*,relative,absolute, etc.
Example: flex flex-col min-h-screen items-center justify-center px-6 gap-y-8 bg-muted/30 rounded-lg hover:bg-muted/50 text-foreground.
Typography
Use the custom type scale — never raw Tailwind size tokens (text-sm, text-xs, text-base, text-lg, etc.) or standalone font-* weight utilities on paragraph text.
Paragraph scale (size / weight):
| Token | Size | Weight |
|---|---|---|
text-paragraph |
16px | 400 |
text-paragraph-medium |
16px | 500 |
text-paragraph-semibold |
16px | 600 |
text-paragraph-sm |
14px | 400 |
text-paragraph-sm-medium |
14px | 500 |
text-paragraph-sm-semibold |
14px | 600 |
text-paragraph-xs |
12px | 400 |
text-paragraph-xs-medium |
12px | 500 |
text-paragraph-xs-semibold |
12px | 600 |
text-paragraph-mini |
10px | 400 |
text-paragraph-mini-medium |
10px | 500 |
text-paragraph-mini-semibold |
10px | 600 |
Heading scale (all weight 600, line-height included):
text-heading-1 (48px) → text-heading-2 (32px) → text-heading-3 (24px) → text-heading-4 (20px) → text-heading-5 (18px)
Rules:
- Replace
text-sm font-medium→text-paragraph-sm-medium;text-sm font-semibold→text-paragraph-sm-semibold; and so on across all sizes and weights. - Never add
font-bold,font-semibold, orfont-mediumnext to any type-scale token — the token already encodes the weight. - Never add
font-boldorfont-semiboldnext to atext-heading-*class — headings already include weight 600. shrink-0and other flex/sizing utilities come before typography tokens in the class order.- Don't repeat defaults:
text-baseis Tailwind's default 16px class — never use it; use the type scale instead. Similarly, never writefont-normalon plain text (weight 400 is the browser default). If an element naturally inherits the correct size from its parent, you don't need to add a type-scale token just to be explicit. - Exception —
font-normalon Button-based triggers: the baseButtoncarriesfont-medium, so a select/combobox-style trigger built onButton(date pickers, filter comboboxes, column mappers) addstext-paragraph-sm font-normalto override it — the trigger's value should read like input text (weight 400), not button text. This is the one placefont-normalis load-bearing; don't "clean it up".
Rounding and design tokens
- Never sharp / fully square.
--radius0.625rem + the scale (packages/ui/src/styles/theme.css): buttons/inputsrounded-lg, cardsrounded-1.5xl(orrounded-xlcompact), badgesrounded-full(square variant only when intentional). A grouped surface/container (a section panel, a callout) rounds too — audit every box you add. - Use the oklch design tokens (
--ring,--destructive,--accent,--ghost,border-0..5, …) — never raw hex. Variants via CVA (Button/Badge): extend variants, don't fork the component.
API boundary and naming conventions
camelCase in TypeScript
All TypeScript identifiers use camelCase — interface properties, function params, variable names, form field names, Zod schema keys. This applies to everything in apps/web except:
- Uppercase constants stay as-is (e.g.
INVESTMENT_CATEGORIES,FALLBACK_PRIMARY). - String values that are API enum values stay as-is (e.g.
'real_estate','term_deposit') since they must match the backend enum. - URL param name strings passed to
URLSearchParams/searchParams.getAll(...)stay snake_case since those are API contract strings (e.g.qs.append('collection_ids', ...),searchParams.getAll('collection_ids')). - Request body object keys sent to the API stay snake_case since FastAPI expects them (e.g.
{ base_currency: values.baseCurrency }).
Mapping at the API boundary
The lib/api/ layer is the API boundary. Functions there receive raw snake_case JSON from the backend and must return camelCase TypeScript objects. Never return res.json() directly when the response contains snake_case keys — always define explicit raw types and map.
File structure order in lib/api/*.ts
1. Raw types — unexported, snake_case, match JSON exactly
2. Frontend types — exported, camelCase, what the rest of the app uses
3. Mappers — unexported functions, one per entity
4. API functions — exported, build query strings, fetch, call mappers
Raw types and mappers stay unexported unless a colocated actions.ts genuinely needs the same wire shape (e.g. it extends the raw response for a mutation-only field, or maps a single fetched row). In that case export them from lib/api/*.ts and import them — never re-declare the interface or re-inline the mapper in the actions file. Two copies of a wire shape means the next API field reaches one call site and silently misses the other.
Full example:
// --- Raw types (API JSON shape, snake_case) ---
interface InvestmentRaw {
id: number;
base_currency: string;
is_active: boolean;
created_at: string;
}
// --- Frontend types (camelCase) ---
export interface Investment {
id: number;
baseCurrency: string;
isActive: boolean;
createdAt: string;
}
// --- Mappers ---
function mapInvestment(raw: InvestmentRaw): Investment {
return {
id: raw.id,
baseCurrency: raw.base_currency,
isActive: raw.is_active,
createdAt: raw.created_at,
};
}
// --- API functions ---
export async function getInvestments(): Promise<Investment[]> {
const res = await authenticatedFetch('/investments', { method: 'GET' });
if (!res.ok) throw new Error('Failed to fetch investments');
const raw: InvestmentRaw[] = await res.json();
return raw.map(mapInvestment);
}
Actions (actions.ts) construct explicit snake_case request bodies:
// app/(protected)/investments/actions.ts
const { collectionIds, baseCurrency, isActive, ...rest } = values;
await authenticatedFetch('/investments', {
body: { ...rest, base_currency: baseCurrency, is_active: isActive },
});
API errors → localized messages
The backend returns { detail, code, …extra } on failure (English detail, stable machine code).
Never render the raw detail as the primary message — resolve the code to a localized string with
resolveApiError (@/lib/i18n/api-errors), which maps apiErrors.<code> (interpolating the extra
params) and falls back to detail for a code it doesn't map. Parse the response with
parseApiError(res); get the translator from useTranslations('apiErrors') (client) or
await getTranslations('apiErrors') (server action / server-only lib). Add a new apiErrors.<code>
key in BOTH en.json and es.json (keyset parity is tested) whenever the backend adds a code worth
localizing. Success copy is owned by the frontend per action — don't render backend success prose.
Translations
- Page-specific translations live under the page's namespace (e.g.
investments.toolbar.searchPlaceholder,snapshots.form.titleCreate). - Shared translations (used by multiple pages) live under
common(e.g.common.categories.cedears,common.allCategories). UseuseTranslations('common')ortCommonfor these. - Rule: If a translation key is needed by more than one page, move it to
common. Never import another page's namespace from a different page (e.g. don't useuseTranslations('investments')inside the snapshots page).
Placeholder wording
Use descriptive action phrases. The wording depends on the input type:
- Text inputs:
"Enter the ..."(e.g."Enter the collection name","Enter the broker"). - Search inputs:
"Search ..."(e.g."Search investments...","Search currencies..."). - Select inputs:
"Select a ..."(e.g."Select a category","Select a currency"). - Number inputs:
"Enter the ..."(e.g."Enter the target percentage"). - Optional/notes:
"Optional ..."(e.g."Optional notes...").
Only use examples ("e.g., AAPL.BA") when the input requires domain-specific knowledge that the user might not know (e.g. ticker symbols). Never use examples for standard fields like names, percentages, or amounts.
Fields with defaults
When a form field has a default value (from env vars or elsewhere):
- Placeholder: Use the default value itself (e.g.,
placeholder="50",placeholder={envPreset}). This shows the user what will be used if they leave it empty. Don't use "Enter the ..." for these. - Hint below input: Use a
"default"translation with parameterized value:"Defaults to {value} if left empty.". Always use{value}interpolation — never hardcode the default in the hint string. Don't mention "environment" — just say "defaults". - Both the placeholder and hint refer to the same default value, from the same source (env var or constant).
Formatting & locale
Never thread a locale into formatter calls, and never call the pure formatters (formatValue, formatAmount, formatDateForLocale, formatMonth, …) directly in a component. Use the locale-bound hook so a call site can't forget the locale (removing the silent en-US fallback):
- Client components:
const fmt = useFormatters()(@/lib/i18n/formatters) →fmt.value(n, opts?),fmt.amount(str, currency?),fmt.date(iso, dateFormat?),fmt.month(str),fmt.monthLong(year, month),fmt.monthYear(str)(short month + full year),fmt.weekdayDay(str),fmt.signedValue,fmt.signedPct,fmt.pct(at most ONE decimal),fmt.sharePct(a co-ownership percentage at exactly TWO, so the parts of a split still sum to 100 — at one decimal a three-way 33.33/33.33/33.34 reads 33.3/33.3/33.3 and visibly fails to add up),fmt.ratePct,fmt.ratio(n)(fixed 2 decimals, no unit),fmt.timestampDate,fmt.axisValue,fmt.list(items)(locale-aware "a, b, and c" / "a, b y c" conjunction). The hook reads the active locale from next-intl and binds it. - Async Server Components:
const fmt = await getFormatters()(@/lib/i18n/formatters-server,server-only) — the same set, resolving the locale via next-intl on the server. - Raw locale (for
localeCompare, an inlineIntl.*formatter, orgetLocaleTag(...)) comes fromfmt.locale— do NOT add a separateuseLocale()/getLocale()alongsideuseFormatters()/getFormatters(). A component that needs only the raw locale and formats nothing (e.g. a picker that justlocaleCompares codes) usesuseLocale()directly. - Declaration position. The formatters/locale hook (
useFormatters()/useLocale(); servergetFormatters()/getLocale()) sits immediately beforeuseTranslations(...)(server: beforegetTranslations(...)), so all i18n is grouped at the top of the declaration block: the hook, then the featureuseTranslations, thencommon. Any value derived from the locale (e.g.getDateFnsLocale(locale)) moves down to the derived-state section so nothing splits the hook fromuseTranslations. - User-facing lists ("a, b, and c") go through
fmt.list, never.join(', ')— the last conjunction is locale-specific. Leave non-prose comma joins alone (currency-code CSV inputs/placeholders, React keys, query strings). - Timestamps vs date-only. Use
fmt.timestampDate(iso)for a full ISO timestamp (an instant —createdAt,reconciledAt, …): it renders in the user's stored timezone so the calendar day is correct for the viewer. Usefmt.date(iso)/fmt.month(iso)for a date-onlyYYYY-MM-DDvalue: those stay on the local-midnight anchor and are never timezone-shifted (shifting a date-only value reintroduces off-by-one). Date-only labels (long month name, weekday+day, month+full-year) likewise go throughfmt—fmt.monthLong/fmt.weekdayDay/fmt.monthYear— never an inlinenew Date(x).toLocaleDateString(...). Components never calltoLocaleDateString; it lives only inside thelib/i18nformatters, and no timestamp is rendered inline — route it throughfmt.timestampDate. valueColor(sign → color class, no locale) stays a direct import from@/lib/i18n/format.- The pure formatters, the
LOCALESregistry, and the hook all live inlib/i18n/(see web-structure).
Comments
- Default: Use
//. Multiple consecutive//lines are fine for a sequence of independent remarks. - Block
/* */: Only when a single explanation is long enough that it needs to wrap across lines (e.g. a function-level description or a strategy note). Use the leading*style:/* * Long explanation that wouldn't fit on one line and forms * a single cohesive thought or paragraph. */ - Add comments where they clarify something non-obvious. Don't comment the obvious.
- End with a period. Every comment that is a sentence or description must end with
.. Three exceptions:- Inline comments on the same line as code — no period required.
- Single-line
/* ... */comments — always exempt, they read as labels/titles by nature. - Title-style
//labels formatted as section headers (e.g.// --- Auth ---,// Step name) — no period required.