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/profileand/settings/billing, a plain prefix match lights both.AppSidebarreduces to the longest matchinghref; 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/billingbreadcrumb as "Profile / Billing". It moved to/settings/profileand bare/settingsredirects. If your new section has an index page, give it its own segment.
Breadcrumbs are derived, not declared
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.
| layer | lives in | what it is |
|---|---|---|
| primitives | src/components/ui | shadcn. A button, an input, a dialog. No opinion about your product. |
| application components | src/components/app | composed and opinionated, but product-agnostic: page shells, tables, filters, detail layouts, status badges. |
| feature components | src/features/*/components | yours. Knows what an invoice is. |
src/components/app is managed — lq 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:
| component | use it for |
|---|---|
PageShell | every page. Picks the width from the page type. |
PageHeader | the title block: title, description, actions, badges. |
DataTable + SortableHead + RowActions + TableFooterBar | any list of records. |
FilterBar + FilterSearch + FilterSelect + FilterChip | the controls above a list. |
EmptyState | both empty cases — filtered-empty and never-had-any. |
DetailLayout + DetailFacts + DetailFact + DetailSection | one record: body column plus metadata rail. |
StatCard + StatCardGrid | dashboard numbers. Give every tile an href. |
ChartCard + BarChart + CategoryBars | a small chart, no charting library, on the --chart-* tokens. |
SectionCard | a titled panel: settings groups, form sections, summaries. |
ConfirmDialog | anything destructive. |
DrawerForm | a quick edit from inside a list, where a full page would lose your place. |
StatusBadge + StatusToneMap | any 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:listfor tables and dashboards,detailfor one record,formfor a form or settings page,fullfor split views and boards. Every route in three shipped products wasmax-w-3xl, which is a dead gutter beside a table and far too wide for a form. - Exactly one
<h1>per page —PageHeaderrenders 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(), parsesearchParamswith the feature's schema, render feature components. Composition only — components live insrc/features/<name>/components. - Format through
@/lib/format—formatDate,formatDateTime,formatRelative,formatMoney,formatNumber,formatEnum,formatBytes. Never calltoLocaleDateString()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:
| preset | register |
|---|---|
| (default) | balanced violet, 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 # 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 exportLight/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.editorialsets all four to none and lets a rule do the work.--control-height,--control-height-sm— button, input and select heights. Read them ash-control/h-control-sm.--cell-padding-x,--cell-padding-y— table rhythm, aspx-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.
Forms
How to build a form in this project — useActionForm, the Field primitive, where the schema comes from, and how server-side field errors get back to the right input. Use when adding or changing any form, or when RHF's error types will not line up.
Designing app UI
Which application component to use for which job, how to lay out a page, and the hierarchy, density and copy rules that make a screen look finished. Use when building or reviewing any signed-in screen.