The design system
Three component layers, five structural themes, and one place that formats a date — so two products built on this template do not look like the same product.
Three products were built on this template by independent agents, each from a
real scaffold, each correct. src/components/ and src/app/globals.css came
out byte-identical across all three. Every page in every one of them was
max-w-3xl. Two of the three rendered every status as the same grey badge, all
three used a raw <select> while the Radix one sat unused, Card was used
zero times and hand-rolled as rounded-lg border p-4 instead, and the three
apps shipped three different date formats.
That is not a failure of the people (or agents) who built them. An agent builds what the template makes easy, and the template made exactly one thing easy. This page is what changed.
Three layers
| layer | lives in | knows about |
|---|---|---|
| primitives | src/components/ui | nothing. A button is a button. |
| application | src/components/app | tables, page shells, statuses — not your product |
| feature | src/features/*/components | invoices, tickets, bookings |
The middle layer is the one most starters do not have, and it is where the design decisions live. It is composed and opinionated but product-agnostic, so it can ship with the template and improve across releases.
src/components/app may import primitives and its own siblings. It may not
import a feature — the boundaries graph rejects that edge, for the same reason
it rejects it for ui: a PageHeader that knows what a project is cannot be
reused by the next product and cannot be regenerated.
It is also managed, which the primitives are not. lq upgrade delivers
improvements to it; if you edited a file, you get a diff instead of an
overwrite. A project scaffolded before a component existed gets it added on the
next upgrade.
What is in it
Page structure. PageShell takes a width — list, detail, form or
full — because how wide a page should be is a property of what is on it. A
four-column table at 768px wastes a third of a laptop screen; a form at 768px
stretches its inputs until they read as search boxes. PageHeader renders the
single <h1>, the description, the actions and any badges.
Lists. DataTable frames the table and sticks the header. SortableHead
puts the sort in the URL alongside the filters and the page number, announces
it with aria-sort, and resets the page when the order changes. RowActions
is the per-row menu. TableFooterBar carries the result count, which is the
only thing that distinguishes "no results" from "results, but you are on page 7
of them".
Filters. FilterBar, FilterSearch, FilterSelect and FilterChip.
The chips are the part that gets skipped everywhere: without them, a shared URL
carrying three filters shows a short list with no explanation.
One record. DetailLayout splits the page into a primary column and a
metadata rail, replacing the <dl> dump where a memo carried the same visual
weight as an amount. DetailFacts, DetailFact and DetailSection fill it.
Dashboards. StatCard takes a value, a label, an optional delta, an icon
and — this is the one worth copying — an href, so the whole tile links to its
own pre-filtered list. A number you can click is a number you can act on.
ChartCard, BarChart and CategoryBars draw small charts from the
--chart-* tokens with no charting library; install recharts when you outgrow
them and the colours will still match.
The rest. SectionCard (a titled panel, so nobody hand-rolls a card box
again), EmptyState (with an icon and a sentence), ConfirmDialog (works from
a row menu, stays open while the request runs so a failure has somewhere to be
read), DrawerForm (a quick edit that does not cost you your scroll position),
StatusBadge.
Statuses carry meaning
Declare one tone map per enum, next to the feature that owns it:
export const invoiceTone: StatusToneMap<InvoiceStatus> = {
draft: 'neutral',
sent: 'info',
paid: 'success',
void: 'muted',
};StatusToneMap is a Record over the union, so adding a value to the database
enum and forgetting it here is a type error, not a badge that quietly
renders grey. Tones rather than colours, so a theme can restyle them. And never
colour alone — muted also dims, and roughly one man in twelve cannot tell your
green from your red.
One place that formats a date
src/lib/format.ts — formatDate, formatDateTime, formatRelative,
formatMoney, formatNumber, formatEnum, formatBytes. Calling
toLocaleDateString() in a component gets you the server's default locale,
which differs between machines; three products doing exactly that is how three
date formats happened. Change LOCALE and CURRENCY once and the whole app
follows.
formatDate parses a YYYY-MM-DD column value as UTC, so a due date never
renders as the previous day for a reader west of whoever entered it.
Four themes, and they are structural
Presets live in src/app/globals.css and change the display face, elevation
scale, control density and border weight as well as colour — a preset that only
rotates a hue is the same product in a different paint.
| preset | register |
|---|---|
| (default) | balanced, moderate density |
editorial | serif display, warm paper, hairline borders, no elevation, roomy |
dense | compact controls and cells, small radius, near-monochrome |
rounded | the default palette, pill controls, 1.5rem radius, soft elevation |
playful | saturated, 2px borders, hard offset shadows, bold display face |
lq theme list
lq theme set dense
lq theme import brand --from ./theme.css # a shadcnstudio or tweakcn exportLight/dark is separate and stays user-controlled.
Every component, and every theme
This page is the argument. Components is the reference — thirty-one primitives, thirteen application components, thirty exports and what each is for. Themes shows the same screen under all four presets, which is the only honest way to demonstrate that a preset is structural rather than a repaint.
Where to look in the code
src/features/projects is the reference slice, rebuilt on this layer: a sorted,
filtered table with row actions and a matching skeleton, a detail page with a
metadata rail, two distinct empty states, and a dashboard of linked stat tiles
over a chart. It is meant to be read and then deleted.
0013 — The stack choices, including the four nobody wrote down
Drizzle, Better Auth, Server Actions and Inngest. What the code proves about each, and where the honest answer is that the reasoning was never recorded.
Components
Thirty-one shadcn primitives, thirteen application components with thirty exports between them, thirteen marketing blocks, and the rule about which layer you reach for.