# Design reference

Rules for every screen in this application. They are decisions already taken,
not suggestions. Where a rule and a mockup disagree, the rule wins.

## Layout

**Content fills the width it is given.** `AppShell` gives the header and the
content the full inset, and neither is capped.

Capping was the first attempt and it was wrong: a column centred at `max-w-5xl`
left a card floating far from the navigation with several hundred pixels of
nothing on either side. That is a different fault from being pinned left, not a
cure for it.

A screen that genuinely needs a reading measure sets one on its own prose, where
the constraint belongs. The shell does not decide it for every screen.

**Responsive from the first line, not checked at the end.** Every screen is
usable at 390 px. Actions stay reachable: the failure to avoid is a table whose
last columns scroll out of view, which is exactly where the action buttons live.

**No hardcoded pixels.** Sizes come from the Tailwind scale, spacing from `gap`
and the container, widths from `max-w-*` and fractions. A number typed into a
class fits the one screen it was typed on: it ignores the root font size, it
ignores a person who scales their display, and it is the reason a layout breaks
at a width nobody tested. Where a library needs intrinsic dimensions, as
`next/image` does, they stay as hints and the rendered size comes from a class.

## Legibility

**This is written for a factory floor, not for a design portfolio.** Plenty of
the people reading these screens are over forty, and several read them on a
shared terminal under strip lighting.

**Body text is the base size.** `text-sm` is for a genuinely secondary line, not
for a paragraph, a table cell or a label. `text-xs` is not for text at all.

**`text-muted-foreground` is not a text colour.** In dark mode it sits too close
to the background to read comfortably. Anything a person reads takes
`text-foreground`, or `text-foreground/80` and `text-foreground/70` when a line
must sit back without disappearing. Muted is for chrome, not content.

The test that matters: a name in a table, a checker, a time, a note. If it can
be read, it is content and it gets the contrast content gets.

**Card titles are large and bold.** A card header that whispers is a card whose
content nobody scans.

## Saving

**Live save, confirmed by a toast.** A field is saved when it is left, not when
a form is submitted. No "Save" button standing between somebody and their work.
`sonner` carries the confirmation, and it is installed when the first save
exists rather than kept waiting.

A toast confirms; it never explains. "Saved" is enough. If a save fails, the
toast says what to do about it and the value stays on screen so nothing typed is
lost.

**Creating is not editing, and the rule above does not reach it.** You cannot
save a field of something that does not exist yet, so a creation has one commit
point and a button that says what it makes: "Create the task", never "Save".

The line is the record. Before it exists, a form gathers and one act creates.
After it exists, every field saves as it is left and no button stands between
somebody and their work.

A creation form says what it is about to make, in words, next to that button. A
schedule read back as "Day 31 of every month, moved off weekends and holidays,
and on the last day of a shorter month" is worth more than the three inputs that
produced it.

**A form asks in an order, not all at once.** When one answer decides which
fields apply, ask it early and let the rest follow. Thirteen fields on screen at
once is a form nobody fills. And when an answer means the thing being created is
not this kind of thing at all, say so and stop offering to create it.

## Editing

**Avoid drawers and sheets for editing.** A side panel narrows the form on the
screen where it is already hardest to fill. If a panel is genuinely the right
shape, it becomes full width below the mobile breakpoint. Everything a person
types must be reachable with a thumb.

The floor terminals and the Android shell are the case to design for, not the
desktop.

## Filters

**A filter searches by default.** With 45 departments and 400 people, a list you
scroll is the problem, not the control.

`Combobox` with `multiple` plus `ComboboxChips` is the shape: type to narrow,
selected values become removable chips that state the result out loud. A plain
`Select` is only for a closed set the schema owns, such as an occurrence or a
status.

Filter state lives in the URL, debounced while typing and pushed on a discrete
act, so a filtered view is linkable and the browser Back button behaves.

## Tables

**Any table that can outgrow a screen ships with search and filters.** Not as an
improvement later: a bare table of 400 employees across 45 departments is
unusable on the day it is built, and every screen here is sized for those
numbers.

**A list uses `DataTable`**, built on TanStack Table in
`components/ui/data-table.tsx`. It brings sorting, pagination and column
visibility, and those three are on by default because a list here always
outgrows a screen.

**Row selection is not a default.** A checkbox in every row costs a column and
promises a bulk action. Add `selectionColumn()` when such an action exists, not
before.

**Two kinds of state, two places.** Search and the filters people share live in
the URL, so a narrowed view is a link somebody can send and Back behaves:
debounced replace while typing, push on a discrete act. Sorting, the page and
hidden columns stay in the component, because they concern only the person
looking.

The toolbar sits above the table: a search field that narrows as you type, the
filters the columns imply, the applied filters restated as removable chips, and
the count stated in words so nobody counts rows. Search is `InputGroup` with a
leading magnifier and a clear button.

## Components

**shadcn is mandatory.** Reach for the CLI and the registry before writing a
component by hand. Blocks are starting points and we own what they install, so
editing one is expected; rebuilding one from scratch is not.

**One icon library**, `@tabler/icons-react`. Two icon sets never share a grid or
a stroke weight, and it shows.

**Tokens, never literal colours.** The palette lives in `app/globals.css`. A
state is never carried by colour alone.

**An action carries a background.** Advance's colour,
`oklch(0.8032 0.1315 192.39)`, is `--primary`, so a filled button says on sight
that something can be done. Outline is for the second action beside it, ghost
for the third.

It is a light colour, `#29d9d5`, so what sits on it is dark ink:
`--primary-foreground` measures 11.29:1 against it, white measures 1.68:1 and
is never used. As *text* it reads on a dark ground (11.29:1) and fails on a
light one (1.75:1), so it stays a background and never becomes body colour.
Measured with `bun run scripts/contrast.ts`.

## Words

**No em dash.** A comma, a colon, or two sentences.

**Nothing that repeats the screen.** No line under a title restating what the
title says, no heading inside a dropdown whose trigger already names it, no
eyebrow label above a value that reads on its own. A group heading in a menu
earns its place only when it separates several groups.

**Bilingual, English and Thai.** One family, Prompt, carries both scripts, so a
screen and its translation are set in the same typeface. Nothing is fetched from
a font service: the target is a LAN with no internet.
