launch-quickly
Patterns

UI and navigation

How the signed-in shell works — nav.ts as the single navigation declaration, permission-gated sidebar, breadcrumbs, theme presets, and how a new page becomes reachable. Use when adding a route, changing navigation, or theming.

Generated from .claude/skills/ui-and-navigation/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.

Everything a signed-in user can click into is declared once, in src/config/nav.ts. The sidebar renders it, the breadcrumbs label from it, and lq generate feature inserts into it. If you add a page and do not add a nav entry, you have shipped a page nobody finds — this template once shipped a billing page with zero inbound links, which is why the rule exists.

The one declaration

// src/config/nav.ts
{
  id: 'projects',           // stable — the layout's permission filter hands ids to the client
  label: 'Projects',
  href: '/projects',
  icon: FolderKanban,       // a Lucide component
  permission: 'projects.read', // optional; omit for always-visible items
},

nav.ts stays a plain data module — no JSX, no 'use client' — so both the server layout (for filtering) and the client sidebar (for rendering) can import it.

Keep the // lq:generated-nav anchor comment. It is where the generator inserts entries for new features; delete it and generated slices stop registering themselves (the generator skips rather than guesses). Hand-written entries go above the anchor.

Where permission filtering happens

In src/app/(app)/layout.tsx, server-side, with the same can() the data layer uses:

const allowedIds = NAV_ITEMS.filter(
  (item) => !item.permission || can(ctx, item.permission),
).map((item) => item.id);

The client sidebar receives only ids. This is UX, not security — the data layer still refuses on its own; hiding the link just spares a member a FORBIDDEN they were never going to get past. Never move authorization into the sidebar, and never gate a nav item with a role comparison — name a permission.

The (app) layout is the session boundary AND the shell

A new route under (app) is protected because the layout requires a user, and reachable because the sidebar renders nav.ts. You get both by putting the page in the right group; you get neither by hand-rolling either one inside a page.

The layout hands footer (user menu) and actions (theme switcher) to the shell as slots. That is layering, not style: sign-out lives in features/auth, and a shared component in src/components may not import feature code — the boundaries graph enforces it.

Two traps the shell already solves — do not reintroduce them

  • Active-item matching is most-specific-match, not startsWith. With items at /settings/profile and /settings/billing, a plain prefix match lights both. AppSidebar reduces to the longest matching href; keep new items' hrefs unambiguous rather than special-casing the matcher.
  • Never put a page at the path prefix of its siblings. Profile used to live at /settings, which made /settings/billing breadcrumb as "Profile / Billing". It moved to /settings/profile and bare /settings redirects. If your new section has an index page, give it its own segment.

AppHeader splits the pathname. The first segment takes its label from nav.ts (so it always matches the sidebar); deeper segments are humanized; ids collapse to "Detail" — naming the record would need a data fetch in a breadcrumb, which is not what breadcrumbs are for. You should never need to touch this: fix labels in nav.ts, not in the header.

The three component layers

Reach for the highest layer that fits. Building a page out of primitives when an application component exists is how three products built on this template ended up looking like one product.

layerlives inwhat it is
primitivessrc/components/uishadcn. A button, an input, a dialog. No opinion about your product.
application componentssrc/components/appcomposed and opinionated, but product-agnostic: page shells, tables, filters, detail layouts, status badges.
feature componentssrc/features/*/componentsyours. Knows what an invoice is.

src/components/app is managedlq upgrade improves it, and edits you make are shown to you as a diff rather than overwritten. It may import primitives and its own siblings; it may not import a feature. The boundaries graph enforces that, for the same reason it does for ui: a PageHeader that knows what a project is cannot be reused and cannot be regenerated.

What is there, and when to use it:

componentuse it for
PageShellevery page. Picks the width from the page type.
PageHeaderthe title block: title, description, actions, badges.
DataTable + SortableHead + RowActions + TableFooterBarany list of records.
FilterBar + FilterSearch + FilterSelect + FilterChipthe controls above a list.
EmptyStateboth empty cases — filtered-empty and never-had-any.
DetailLayout + DetailFacts + DetailFact + DetailSectionone record: body column plus metadata rail.
StatCard + StatCardGriddashboard numbers. Give every tile an href.
ChartCard + BarChart + CategoryBarsa small chart, no charting library, on the --chart-* tokens.
SectionCarda titled panel: settings groups, form sections, summaries.
ConfirmDialoganything destructive.
DrawerForma quick edit from inside a list, where a full page would lose your place.
StatusBadge + StatusToneMapany enum a human reads. See below.

Statuses get a tone map, not a ternary

Declare one map per enum, next to the feature that owns it (the shipped example is src/features/projects/components/project-badges.tsx), and import it from the row, the detail page and anywhere else the value is shown:

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 rather than a badge that silently renders grey. Never colour alone: muted also dims, and anything critical should carry an icon.

Page conventions

  • Wrap page content in <PageShell width="…">. The width is a property of the content, not a number you pick per page: list for tables and dashboards, detail for one record, form for a form or settings page, full for split views and boards. Every route in three shipped products was max-w-3xl, which is a dead gutter beside a table and far too wide for a form.
  • Exactly one <h1> per page — PageHeader renders it, so use that rather than writing the heading by hand.
  • Give the header a description. A sentence saying what the page is for is the cheapest usability win available, and it is the first thing that gets skipped.
  • Route files stay thin: requireTenant(), parse searchParams with the feature's schema, render feature components. Composition only — components live in src/features/<name>/components.
  • Format through @/lib/formatformatDate, formatDateTime, formatRelative, formatMoney, formatNumber, formatEnum, formatBytes. Never call toLocaleDateString() in a component: three products doing that shipped three different date formats, one of them the server's default locale.

Theming

Tokens live in src/app/globals.css under @theme inline — there is no tailwind.config.ts. Use token classes (bg-background, text-muted-foreground); hardcoded hex colours are a lint error.

Five presets ship, and they are structural — each changes the display face, elevation, density and border weight as well as colour, because a preset that only rotates a hue produces the same product in a different paint:

presetregister
(default)balanced violet, 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          # what this project has, and which is active
lq theme set dense     # edits data-theme on the root element
lq theme import brand --from ./theme.css   # a shadcnstudio/tweakcn export

Light/dark is separate and user-controlled via next-themes (ThemeSwitcher in the header). Sidebar tokens alias the core palette, so a brand swap restyles the sidebar for free.

The tokens a preset can move

Colour is the obvious one. These are the others, and they are what make two presets look like two products:

  • --elevation-xs|sm|md|lg — the shadow scale. editorial sets all four to none and lets a rule do the work.
  • --control-height, --control-height-sm — button, input and select heights. Read them as h-control / h-control-sm.
  • --cell-padding-x, --cell-padding-y — table rhythm, as px-cell-x / py-cell-y.
  • --section-gap — vertical rhythm between page sections.
  • --font-display-face, --display-tracking, --display-weight — the heading face and how it is set.

A face a preset switches to must be loaded in src/app/layout.tsx, and the font variables live on the ROOT element rather than on body: custom properties inherit downward only, so a preset that sets --font-display-face from :root cannot see a variable defined one level lower. Getting this wrong is silent — the heading simply keeps the old face.

Adding a whole feature

Do not wire any of this by hand for a new feature — lq generate feature emits the pages, registers the nav entry (gated on the new .read permission), and declares the permissions in one pass. This skill is for editing what exists and for the rare hand-written page.

On this page