launch-quickly

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

layerlives inknows about
primitivessrc/components/uinothing. A button is a button.
applicationsrc/components/apptables, page shells, statuses — not your product
featuresrc/features/*/componentsinvoices, 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 widthlist, 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.tsformatDate, 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.

presetregister
(default)balanced, moderate density
editorialserif display, warm paper, hairline borders, no elevation, roomy
densecompact controls and cells, small radius, near-monochrome
roundedthe default palette, pill controls, 1.5rem radius, soft elevation
playfulsaturated, 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 export

Light/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.

On this page