# DESIGN SYSTEM — locked-in visual rules

> Written up front so everyone (owner - AI - dev) produces the same look.
> Every skill in phases `3-design` and `4-prototype` must reference this file — no re-litigating it every time.

---

## 0. Three guiding principles

1. **No thinking about where to click** — the most important thing on screen must be the most prominent, and there must be only one.
2. **Never tell the user what they already know** — don't show a user their own name on their own card, don't put an "my task" label on a page that's already filtered to them.
3. **Truncated data must show the real count** — show `5 / 18`, not `5`, because a missing number looks like a bug.

---

## 1. Standard stack

| Level | Use |
|---|---|
| Prototype (owner builds it) | Single HTML file + CDN — opens straight in a browser, nothing to install |
| Real system (dev builds it) | Next.js + Tailwind v4 + shadcn/ui + Lucide icons + TypeScript strict |

### Standard CDN block for prototypes (copy as-is)

```html
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://unpkg.com/lucide@latest"></script>
```
Call icons with `<i data-lucide="shopping-cart"></i>` and close the file with `lucide.createIcons()`.

**No web font is loaded.** Type comes from fonts already on the machine — nothing to download, nothing to
wait for, no flash of unstyled text, and one less external dependency. Set this once, in `shared/style.css`:

```css
body { font-family: "Segoe UI", "Segoe Sans", "Helvetica Neue", Helvetica, Arial, "Leelawadee UI", "Noto Sans Thai", "Sukhumvit Set", sans-serif; }
```

Use the CDN so mocks are fast — open the file and see it immediately, no build, nothing to install.
**Prototype only** — the real system installs everything as normal packages.

---

## 1b. No emoji, ever

**No emoji anywhere** — on screen, in code, in comments, in delivered documents, in file names, **and in chat replies to the user** (questions, progress updates, every message).
Reason: emoji render inconsistently across devices, can't be read aloud by screen readers, don't turn up in search, and look unprofessional.

Use this instead:

| Instead of | Use |
|---|---|
| On-screen icon | Lucide icon |
| Status badge | Text + color + Lucide icon |
| Editable/locked marker in code | the literal words `แก้ได้:` and `ห้ามแก้:` |
| Document heading | plain text |

---

## 1c. Forbidden — the tells that scream "an AI made this"

AI is trained on a massive volume of work, so what it picks by default is **the statistical average of everything on the internet**.
The result looks familiar but has no identity. Clients see it and feel "this isn't us."
Everything below is **forbidden** unless the user explicitly asks for it.

### Color
- **No default Tailwind purple/indigo** — `#6366f1` (indigo-500), `#8b5cf6` (violet-500), and nearby shades.
- **No purple-to-blue gradients**, and no multi-stop neon gradients.
- Follow section 2: **one dark primary, one neutral, one accent** only. Use the accent sparingly.
- The neutral must not be an exact mid-gray — tint it slightly toward the accent, so it reads as chosen rather than default.

### Typography
- **No Inter or Roboto as the default** — the fonts an AI reaches for most, to the point they've become a tell.
- Headings and body text should differ clearly — in weight, size, or an entirely different typeface.
- Thai fonts per section 4.

### Layout
- **Do not stuff everything into cards** — a page of identical rounded cards lined up in a row is the clearest tell of all.
  Group content with whitespace, type scale, and subtle background-color shifts instead.
- **Do not default to the canned sequence** Hero -> three feature cards -> pricing -> FAQ
  unless that sequence actually matches the user's real task.
- No subheadings that add no new information, no tabs that exist just to look sophisticated.
- No heavy rounded corners (see section 3), no glassmorphism, no floating 3D blobs, no glow shadows.

### Imagery and icons
- **No generic 3D renders, no stock photos of smiling office people.**
- Icons: Lucide only (single-weight line icons, consistent) — no emoji, per section 1b.
- If there's no real image available, **leave it out** — empty space beats a meaningless picture.

### Copy
- **No invented numbers** like "10x faster" or "saves 80% of your time" unless the user actually supplied that figure.
- No floating marketing phrases — "revolutionary," "cutting-edge," "all-in-one solution."
- Write the way people actually talk — say what it does, not how great it is.

> **Self-check:** if you could strip the system's name off this screen and drop it into a completely different business without it feeling odd,
> it has no identity yet. Go back to section 1d.

---

## 1d. Brand direction — ask when you're about to apply color, not at the start

**Do not ask about color or style at the start of the conversation.** Three reasons:
1. Most people commissioning work can't describe taste in the abstract, but can critique it instantly once they see something real.
2. At the wireframe stage (level 1) color isn't applied yet anyway, so an early answer would just sit unused.
3. Color lives in a single file, `shared/style.css`, so changing it later is cheap — the cost of a wrong guess is low.

**Ask exactly once, right as you're about to move to level 2** (the point where color is actually applied). Ask with a default already stated,
so a one-word answer is enough:

> "กำลังจะใส่สีแล้ว มีโลโก้หรือสีของบริษัทที่อยากให้ใช้ไหม
> ถ้าไม่มี จะทำแบบสะอาดๆ อ่านง่ายให้ก่อน เปลี่ยนทีหลังได้"

**If existing brand material exists** (logo - website - page - an existing system - a business card), pull the primary color, secondary color, and personality from there.
**Do not invent one** — this is the highest-value information you can get, and it's free from a single question.

**If none exists, or the user says "whatever you think"** -> default to **"clean, easy to use"** and say directly that this is the default and why.
**Never go silent and guess** — a silent guess lands on the statistical average, which is exactly what section 1c forbids.

**If the user states a direction at any point** (e.g. "อยากได้ดูทางการหน่อย" — I want it to look more formal), use it immediately, don't ask again.

### Selectable personas — offer these when the user wants to specify one

| Persona | What it looks like | Fits |
|---|---|---|
| ทางการ น่าเชื่อถือ (formal, trustworthy) | Composed dark colors, crisp type, tight spacing, dense information | Finance, government, internal enterprise systems |
| สะอาด ใช้งานง่าย (clean, easy to use) | Neutral-led palette, a single accent, generous whitespace | Back-office systems, daily-use tools |
| อบอุ่น เป็นกันเอง (warm, friendly) | Warm colors, slightly softened corners, conversational copy | Shops, customer service, small teams |
| จริงจัง เน้นข้อมูล (serious, data-forward) | Numbers foregrounded, dense tables, minimal color used only for status | Reports, dashboards, analytics systems |

Record the resulting direction in `HISTORY.md` (and in `BRIEF.md` if the user gave it during the initial conversation)
so every screen, and any dev who takes over later, uses the same set — no need to ask again.

---

## 2. Color

Use **roles**, not color names — this way a theme swap never breaks anything.

| Role | Use when | Tailwind |
|---|---|---|
| `primary` | The one main button per screen | `bg-primary text-primary-foreground` |
| `secondary` | Secondary buttons, filters | `bg-secondary` |
| `muted` | Supporting text, dates, descriptions | `text-muted-foreground` |
| `destructive` | Delete, cancel, danger warnings | `bg-destructive` |
| `success` | Succeeded, paid, passed | green |
| `warning` | Due soon, pending | yellow/orange |

**Rule:** a color means one thing system-wide — if green = success, no screen anywhere may use green to mean "pending."
**Never** let color be the only signal for status — always pair it with text or an icon (so colorblind users can read it too).

---

## 3. Spacing and sizing

- Spacing uses only the step scale `4 - 8 - 12 - 16 - 24 - 32 - 48` px. No odd values like 13px.
- Main content width caps at `1280px`, centered.
- **Corner radius: avoid it** — sharp corners (`rounded-none`) are the default; the most rounding allowed is `rounded-sm` (2px).
  Never use `rounded-lg`, `rounded-xl`, `rounded-full` — heavy rounding reads as toy-like and eats real content space.
  One exception only: profile pictures may be circular.
- Shadows: very subtle (`shadow-sm`) or none at all — use borders instead. Heavy shadows read as dated.

---

## 4. Typography

| Where | Size | Weight |
|---|---|---|
| Page heading | 24-30px | 600 |
| Subheading / card heading | 16-18px | 600 |
| Body text | 14-16px | 400 |
| Supporting text | 12-13px | 400 + `muted` |
| Money / totals | 18-24px | 600 + **tabular-nums** |

- **Use the system font stack in section 1 — do not load a web font.**
  A font stack resolves **per character, not per block**: Latin picks the first font that has the glyph
  (Segoe UI, so it reads like a Microsoft product), and Thai skips past the Latin-only faces to the first
  Thai face available (Leelawadee UI on Windows, Noto Sans Thai or Sukhumvit Set on macOS).
- **Never end a stack at bare `sans-serif` with no Thai face before it** — Helvetica and Arial carry no Thai
  glyphs, so the browser would substitute whatever it finds, and the result differs on every machine.
- **Every number everywhere uses `tabular-nums`** — otherwise digits jitter inside tables.
- No text smaller than 12px anywhere.

---

## 5. On-screen language

- **All Thai**, except technical terms that have no good Thai equivalent.
- Buttons are written as a **verb naming the outcome**: `บันทึกใบสั่งซื้อ` (save the purchase order), not `ตกลง` (OK) / `Submit`.
- Dates display in Thai format `20 ส.ค. 2569` (stored in the system as `YYYY-MM-DD`, Gregorian, always).
- Money displays as `฿1,250.00`, always with thousands separators.
- Error messages must say **what to do next**, not just that something broke.
  Bad: `เกิดข้อผิดพลาด` (an error occurred). Good: `บันทึกไม่สำเร็จ เพราะยังไม่ได้เลือกลูกค้า — เลือกลูกค้าแล้วกดบันทึกอีกครั้ง` (save failed because no customer is selected — select a customer and save again).

---

## 6. All 5 states must exist (the most commonly forgotten rule)

Every screen that fetches data must design for all five:

| State | Must show |
|---|---|
| Loading | A faint skeleton — not a blank white screen, not a spinner centered on the page |
| Has data | Normal state |
| Truly empty | Why it's empty, plus one clear starting action |
| Search found nothing | Different from "truly empty" — must include a clear-filters button |
| Error | The cause, plus a retry button |

---

## 7. Mobile first

- Design at **375px width first**, then scale up.
- Tap targets (buttons, links, checkboxes) must be at least **44 x 44px**.
- Tables on mobile **must never scroll horizontally** — convert to cards instead.
- The primary mobile button sits fixed to the bottom edge, reachable by thumb.
- Breakpoints: `sm 640 - md 768 - lg 1024 - xl 1280`.

---

## 8. Forms

- Field labels sit **above the field**, always — never use a placeholder as the label.
- Required fields are marked `*`, with a note at the top stating `* คือช่องที่ต้องกรอก` (* marks required fields).
- Validate **on blur**, not on every keystroke.
- Error text sits **directly under that field**, never grouped at the top.
- The save button must not be clickable again while a save is in progress.

---

## 9. Tables and lists

- Include sort, search, and pagination once data exceeds 20 rows.
- Numbers align right - text aligns left - dates align left.
- The most important column sits leftmost; action buttons sit rightmost.
- The whole row is clickable, not just a small link inside it.
- If display is truncated, show the true total (`แสดง 5 จาก 18 รายการ` — showing 5 of 18).

### The standard four-band table screen

This layout is settled. Do not reinvent it per screen — a table screen that looks different from the
others is a bug, not a style choice.

```
+---------------------------------------------+
| title band          heading + main action    |  fixed
+---------------------------------------------+
| filter band         search + filters + clear |  fixed
| แสดง 24 / 156 รายการ                          |
+---------------------------------------------+
|                                             |
| table               header row stays put  |^||  scrolls
|                                           |v||
+---------------------------------------------+
| footer band         หน้า 2 / 7   ต่อหน้า  < >  |  fixed
+---------------------------------------------+
```

Only the table body scrolls, per section 12b. The table's own header row stays visible while its rows scroll.

**Filter bar — fixed order, left to right:** search (takes the remaining width) · date range · then the
category filters. Every control is a column: a small muted label above, control height 40px.
On a narrow screen each filter becomes full width and stacks, with search still first.

- The "no filter" choice is a **real option labelled `ทั้งหมด`**, not an empty value.
- A `ล้างตัวกรอง` button appears **only when at least one filter is active**, and clears the search box too.
- Changing any filter **resets to page 1**. Forgetting this strands the user on an empty page 5.
- Filter and page state belong in the URL (section 11c) so a filtered view can be shared and the back button works.

**Count line** — `แสดง 24 / 156 รายการ` sits directly under the filter bar. State it **once**; never repeat it in the footer.

**Pagination** sits in the fixed footer: `หน้า 2 / 7` on the left with a per-page selector (`20 / 50 / 100 / 300`),
previous and next on the right, disabled at the bounds. **No numbered page buttons** — they add width and nobody uses them.

**Two different loading states, and they are not interchangeable:**

| Situation | Show |
|---|---|
| First load, nothing on screen yet | Skeleton rows shaped like the real rows (section 13) |
| Re-fetching with rows already visible (filter or page changed) | **Keep the existing rows**, dim them, and float a small `กำลังโหลด…` pill over them |

Blanking a populated table back to skeletons on every filter change makes the screen flash and loses the
user's place.

**Empty and error states render inside the same table frame**, so the box does not collapse and the filter bar
stays reachable to undo whatever emptied it. Distinguish "no data at all" from "nothing matched your filters" —
the second one needs the clear-filters button (section 6).

**Sorting caveat that bites people:** if the table is paginated server-side, sorting in the browser only sorts
the current page, which looks like a bug to the user. Either sort on the server, or do not offer sorting on a
paginated table.

**Every interactive element carries a `data-testid`** (section 11c), and every column header that is not
self-explanatory carries a tooltip.

---

## 10. Accessibility (minimum bar)

- Text-to-background contrast ratio >= 4.5:1.
- Everything reachable via Tab, with a clearly visible focus ring.
- Images need alt text - clickable icons need an accessible name.
- Support dark mode: define all colors as variables, never hardcode `#fff`.

---

## 11. Motion

- Fast: 150-250ms, `ease-out`, animate only opacity and transform.
- No bounce, no spin, nothing longer than 300ms.
- Respect `prefers-reduced-motion` — if the user has it off, it must actually be off.

---

## 11b. Fill and balance the screen (the most commonly missed thing)

The most common failure: **short content huddled in the top-left corner, half the screen left empty.**
The user sees this and feels "this isn't finished" even though every function is there.

| Content type | How to lay it out |
|---|---|
| Short form - login page - single-search page - single-result page | **Center both horizontally and vertically**, max width `420-520px` |
| Long content - tables - dashboards | Anchor to top, max width `1280px`, centered |
| Two-part content (form + results) | Separate boxes, never stuffed into one card - side by side on wide screens, stacked on narrow ones |

**Instantly checkable rule:** open the page at 1280x800 — **if the bottom half is empty for no reason, it hasn't passed.**
The fix is centering, not force-stretching content to fill the screen.

The correct technique — make the outer container screen-height and center inside it. Works at any screen height without guessing percentages:

```css
.short-content-page {
  min-height: 100dvh;          /* 100dvh, not 100vh — on mobile the browser chrome eats space */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 24px;
}
.short-content-page > * { width: 100%; max-width: 480px; }
```

> **Never set a fixed percentage top offset** (e.g. `padding-top: 12%`).
> The taller the screen, the more empty space it leaves at the bottom — this is the single most common cause of the "content stuck at the top" symptom.
> If content exceeds screen height, `justify-content: center` will not clip it — it simply scrolls normally.

**Never show a result before the user has acted.** The result area must be empty or absent until the button is pressed.
A status box that appears the moment the page loads makes the user unsure whether they need to click something.

**External-facing screens must never link to internal screens** — no link, no menu item, no back button that leads there.
Outsiders shouldn't even know an internal side exists. Check this every time a screen serves a different user group than the rest of the app.

---

## 11c. Element reference IDs, and state in the URL

### `data-testid` — required on everything clickable or editable

So anyone can point at exactly "fix this" without describing a location in words.
Works both when a user right-clicks Inspect and copies it over, and when writing automated tests.

```html
<button data-testid="candidate-list.row.open" ...>
<input  data-testid="check-result.email" ...>
<div    data-testid="check-result.status" ...>
```

Format: **`{screen}.{section}.{thing}`**, English, kebab-case, dot-separated.

- Required on: buttons - input fields - links - table rows - tabs - result boxes - status boxes - error messages.
- **Do not change an existing value without a real reason** — tests and notes referencing it break along with it.
- Never use a framework's random auto-generated `id` (`el-8f3a`) as a reference — it changes on every render.

### Important state must live in the URL

Users must be able to **copy the link, send it to someone else, and have it open to the same view**, and the browser back button must work.

| Must be on the URL | Example |
|---|---|
| Which screen/tab is open | `?tab=round3` |
| The selected item | `?id=RC-2026-001` |
| Filters, search terms, sort order | `?status=waiting&q=สมชาย&sort=-created_at` |
| Which page number | `?page=2` |

- In-page state changes should use a technique that **doesn't reload the page**, but the URL must still update to match (see section 13).
- Opening a URL with parameters directly must restore that exact state, not bounce back to defaults.
- **Never put secrets or personal data in the URL** (passwords, ID numbers, tokens) — it persists in browser history and server logs.

---

## 12. The whole-app shell

Screens don't float independently — every screen shares one shell, or the user feels lost.

```
┌──────────────────────────────────┐
│ Top bar (navbar)   system name - menu - user │  <- always present, never disappears, never reloads
├──────────────────────────────────┤
│                                  │
│ Content (main)                    │  <- the only part that changes per screen
│                                  │
├──────────────────────────────────┤
│ Bottom bar (footer)  company name - version  │  <- optional; drop it if there's nothing to put there
└──────────────────────────────────┘
```

**The navbar must carry a readable system name — but do not ask for it up front.**
Same reasoning as the brand question in section 1d: before the user has seen anything, "what should we call it?" gets "ไม่รู้ แล้วแต่เลย" and costs a question for nothing.

- **At level 1**, derive a sensible name from what the work is about and mark it as provisional, e.g. `ระบบบันทึกสต็อกวัตถุดิบ (ชื่อชั่วคราว)`. Say once that the name is a placeholder and can be changed any time.
- **Ask for the real name only at the level 2 brand moment**, folded into the same question as the logo/colour one — one turn, not two.
- If the user names it themselves at any point, use it immediately and stop treating it as provisional.
- Never leave `{system-name}` literally in place, and never leave it blank — the user will think the system is broken.

### Two things that make the top bar "look inconsistent" even though it's the same code

**1. Always reserve space for the scrollbar**

A short-content page has no scrollbar; a long-content page does — a scrollbar takes up roughly 15px of width.
This means **the two top bars shift by different amounts**, and switching between pages makes the menu visibly jump.
The eye catches that something's off but can't say what.

```css
html { scrollbar-gutter: stable; }
```

Set this once, in the shared stylesheet — it fixes the whole system. **Never fix it by hiding the scrollbar.**

**2. Always show which page you're on**

The current page's menu item must be visibly more prominent than the others (darker color + bold + `aria-current="page"`).
If every menu item looks identical, the user has no idea where they are in the system.
And **never use color alone** as the signal — pair it with font weight or an underline too (section 2).

### Mobile top bar — choose based on menu count

| Number of top-level destinations | Use | Why |
|---|---|---|
| 5 or fewer | **Bottom tab bar, fixed to the screen edge** | Thumb-reachable, always visible, one tap to switch |
| More than 5 | **Hamburger button opening a side drawer** | Fits more items without crowding the screen |

Never use a hamburger when there are only 3 menu items — hiding things that should be visible reduces engagement for no reason.
An open drawer must be closable via its X button, tapping the dimmed area outside it, and the Esc key.

---

## 12b. Only the content region scrolls — the frame stays put

The page itself must never be the scroll container. If the whole document scrolls, the top bar and the
footer scroll away with the content, the browser's own scrollbar runs the full height of the window, and
the user loses the frame they navigate by.

```
+-----------------------------+
|  HEADER                     |  fixed — never scrolls
+-----------------------------+
|                          |^||
|  CONTENT                 ||||  this region scrolls, and only this region
|                          |v||
+-----------------------------+
|  FOOTER                     |  fixed — never scrolls
+-----------------------------+
```

```css
.app-shell  { height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
.app-header,
.app-footer { flex: none; }
.app-main   { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
```

> **`min-height: 0` is load-bearing — do not remove it.** A flex child defaults to `min-height: auto`,
> which refuses to shrink below its content. Without it the middle region grows to fit its content, the
> shell overflows, and the layout silently degrades back into a normal scrolling page that merely looks
> correct until the content gets long. This is the single most common way a fixed layout breaks.

**Two layout modes, chosen by what the page is:**

| Page type | Mode |
|---|---|
| Table, list, dashboard — the frame matters and the data is long | Fixed viewport, as above |
| Form, composer, document — the user works top to bottom | Let the whole page scroll normally |

Do not force a form into a fixed frame. A long form in a 60%-height scroll box is worse than a page that scrolls.

- `100dvh`, not `100vh` — on mobile the browser chrome eats the difference and the footer ends up off-screen.
- `overflow: hidden` on the shell is what stops the document itself from scrolling.
- `overscroll-behavior: contain` stops a scroll that reaches the end of the content from dragging the page behind it.
- A fixed footer must not cover content: the scrolling region already ends above it, so no bottom padding hack is needed.
- The mobile bottom tab bar (section 12) is part of the fixed frame, not part of the scrolling region.

**The same three-part structure applies to any bounded box that can overflow** — dialogs, drawers, side panels,
pickers, long filter lists. Header and footer stay, the middle scrolls, and that box's scroll must not leak
into the page behind it.

---

## 12c. Dialogs

**Structure is always header / scrolling body / fixed footer**, per section 12b. The action buttons must stay
visible no matter how long the content is — a user should never have to scroll to find the confirm button.

**Size is fixed and chosen up front, not grown from the content.** A dialog that resizes as its content loads
jumps under the cursor.

The size scale pairs a width with a height, so the frame is stable regardless of content:

| Size | Width | Height | Use for |
|---|---|---|---|
| `sm` | 448px | 60dvh | Confirmations, single-field prompts |
| `md` | 512px | 70dvh | Ordinary forms |
| `lg` | 672px | 75dvh | Forms with several columns |
| `xl` | 896px | 80dvh | Detail views, side-by-side comparison |
| `2xl` | 1024px | 85dvh | A table inside a dialog |

- Width is `min(<size>, calc(100vw - 32px))` so it never exceeds the viewport.
- Height never exceeds `85dvh`; the body scrolls, the dialog frame never does.
- **Pick a size from the scale.** An ad-hoc `max-width: 1100px` is how a set of dialogs stops looking like a set.
- Below 640px viewport width a dialog becomes full-screen — a centred box on a phone wastes the screen.
- Close via the X button, Escape, and clicking the backdrop. A dialog with unsaved changes asks before closing.
- Focus moves into the dialog on open and returns to the trigger on close. Tab stays trapped inside while open.

## 12d. Confirming something destructive

Match the friction to the damage. Too much friction on a trivial action trains people to click through
everything without reading, which is worse than no confirmation at all.

| How bad if it happens by mistake | What is required |
|---|---|
| Reversible, affects one item | A plain confirm dialog. Say what will happen in one sentence. |
| Hard to reverse, or affects many items | A **checkbox** the user must tick, whose label states the actual consequence. The confirm button stays disabled until it is ticked. |
| Irreversible, or touches real customer data | The user must **type the exact name of the thing** into a field before the confirm button enables. |

```
ลบผู้สมัคร 24 คนที่ค้างเกิน 90 วัน

[ ] เข้าใจแล้วว่าข้อมูลคะแนนและความเห็นของผู้ตรวจทั้งหมดจะหายถาวร กู้คืนไม่ได้

พิมพ์คำว่า  ลบผู้สมัคร 24 คน  เพื่อยืนยัน
[____________________________]

                    [ ยกเลิก ]  [ ลบถาวร ]
```

- The confirm button says what it does — `ลบถาวร`, not `ตกลง`. `ยกเลิก` sits on the left and is the default focus.
- The checkbox label must state the consequence, never just "ฉันเข้าใจ".
- The string to type is the item's real name or count, shown right above the field so it can be read and typed.
- **Never pre-tick the checkbox and never pre-fill the field.**

---

## 12e. Telling the user what happened

Every action the user takes must produce visible feedback. Silence reads as "it didn't work" and makes people
click twice. But the feedback has to match the message — a toast is the wrong place for something the user
must act on, because it disappears.

| What happened | Show it as |
|---|---|
| A field is invalid | **Inline, under that field.** Never a toast — the user cannot tell which field a toast is about. |
| Their action succeeded and needs no response | **Toast**, auto-dismiss after 3 seconds |
| Their action failed and they must do something | **Inline banner in the content area**, stays until resolved |
| Loading the screen failed | **In place of the content** (section 6), with a retry button |
| They must decide before anything continues | **Dialog** (section 12c) |

### Toast rules

- Bottom-right on desktop. On mobile, **above** the bottom tab bar, not over it.
- Success auto-dismisses after 3 seconds. **An error toast does not auto-dismiss** — it stays until dismissed.
- At most 3 at once; beyond that, replace rather than stack a wall of them.
- **A toast is confirmation, never the only evidence.** The screen itself must already show the change —
  the row appears, the number updates, the status flips. If the toast is the only proof anything happened,
  the screen is wrong.
- **Never put anything the user needs later in a toast** — an ID, a code, an error detail they must report.
  It vanishes and cannot be recovered.
- Announce it to screen readers (`role="status"` for success, `role="alert"` for errors).
- Write what happened, not that something happened: `บันทึกใบสมัครแล้ว` — not `สำเร็จ`.

### Prefer undo over asking first

For anything **reversible**, do it immediately and offer an undo in the toast, rather than blocking with a
confirm dialog:

```
ย้ายผู้สมัคร 3 คนไปรอบ 2 แล้ว                    [ ยกเลิกการย้าย ]
```

This is faster for the common case (the user meant it) and safer for the rare one (they get it back with one
click). Reserve the confirm dialogs in section 12d for things that genuinely cannot be undone. A confirm dialog
on a reversible action is friction that trains people to click through without reading.

The undo must stay available for at least 8 seconds — longer than a normal toast, because the user has to
notice the mistake first.

---

## 13. Loading and skeletons — never make it feel like an F5 refresh

### What reloads, what stays put

| Part | On first open | On button click / page change |
|---|---|---|
| Top bar - bottom bar - menu | Appears instantly, **no skeleton, no spinner** | **Never flickers, never disappears** — stays put |
| Content (main) | Skeleton per the rules below | Only this part changes |

**The principle:** the shell comes from local values already in hand — it isn't waiting on anything, so there's no reason to show a loading state for it.
Only the content is actually waiting on data — show a loading state there only.

**Never show a blank white screen and reload everything on every button click** — the user will feel like they hit F5, and lose their reading position.
A click should only change the content; the top bar stays, and scroll position is preserved if the content is still the same set.

> Acceptable exception: navigating **between separate screens** in a prototype built as separate files counts as a page change.
> But set the background color in the CSS at the very top of the file, so there's no flash of white during the switch.

### Skeleton rules

The rule depends on whether this is the **first sight** of the screen or a return to it.

| Situation | What to show |
|---|---|
| **First open of a screen in this session** — nothing on screen yet | **Always show the skeleton, and hold it for at least 500ms**, even if the data was instant |
| Returning to a screen whose data is already in hand | **No skeleton.** Show the content immediately |
| Re-fetching while rows are already visible (filter or page changed) | Keep the rows, dim them, float a small loading pill (section 9) |

- **The 500ms floor on first open is deliberate — never optimise it away.** With local mock data everything
  resolves instantly, so without a floor the screen snaps from blank to full and the eye cannot follow it.
  The floor is what makes the screen feel like it loaded rather than flickered.
- **Never show a skeleton on a return visit.** The data is already there; a skeleton is pure delay.
- A skeleton that appears for 100ms and vanishes reads as a glitch — that is what the floor prevents.
- The skeleton **must match the real shape** — same row count, column count, and heights —
  so content doesn't jump once loading finishes.
- **No spinner centered on the page** as the primary approach — reserve spinners for a button that's actively working (e.g. saving).
- On first open with no data yet -> skeleton first, then the "no data" state per section 6.

### Splash screen

**The default is: no splash screen.** It's dead time the user gets nothing back for.

Only add one when there's a genuine startup step that takes longer than 1.5 seconds (e.g. checking user permissions, loading initial config), and:
- Show only the system name and mark, on the app's background color.
- **Disappear the instant it's ready — no artificial delay.** Never exceed 2 seconds under any circumstance.
- Never use it as ad space or for a long animation.

---

## 14. Checklist before calling a screen done

- [ ] Opens at 375px with nothing overflowing the edge
- [ ] Has one clearly dominant primary button per screen
- [ ] All 5 states present (section 6)
- [ ] All Thai on screen, buttons written as outcome verbs
- [ ] Numbers use tabular-nums and thousands separators
- [ ] Tab key reaches everything, focus is visible
- [ ] No redundant information the user already knows
- [ ] Everything clickable/editable has a `data-testid` per the format in 11c
- [ ] Important state lives in the URL - copying the link and reopening it shows the same view - browser back works
- [ ] **Actually seen it rendered** (opened it / screenshotted it) — not verified from code alone

### How much to check, and when — spend the effort where things actually break

Checking costs time. Checking everything at every level makes the work crawl; checking nothing ships broken work.
**Spend the budget on mobile and on the main path — that is where breakage actually happens.**

| Situation | Check this much | Do NOT do this |
|---|---|---|
| Level 1-3 finished | **One representative screen**, viewed in the browser at 375 and at 1280 | Do not look at every screen. Screenshots taken to inspect are scratch — do not keep them in `screenshots/`; only level 4 keeps files there. |
| An edit was requested | **Only the screen that changed** | Do not re-check screens you did not touch |
| **Level 4** | **Every screen on the main path**, at 375 and at 1280, screenshots saved into `screenshots/` | Do not skip 375 — it is the width that breaks. Do not screenshot screens that are not on the main path. |
| Nothing changed since the last check | Skip it entirely | Do not re-verify just to look thorough |

Rules that keep this honest:
- **375 is never optional at level 4.** Desktop-only evidence is not evidence — mobile is where layout actually fails.
- If some screens were deliberately not checked, **say which ones and why** in the level report. Silence reads as "all of them were checked".
- If the environment cannot open a browser, **say plainly that visual verification was not possible**, list what the user must look at themselves, and do not write "tested" anywhere.
- A screen that has never been viewed at any width cannot be part of a level that is declared passed.

### Visually verify at 1280x800 — look at the render and answer every item

Code can't catch these — you must look at the rendered page. **Any "no" answer = not done.**

- [ ] Content is balanced across the whole screen — **the bottom half isn't empty for no reason** (11b)
- [ ] Short content is centered, not huddled in the top-left corner
- [ ] The app shell's top bar is present — not a floating heading on an empty page (section 12)
- [ ] The input area and the result area are visually distinct, not merged into one box
- [ ] Before any button is pressed, no result has appeared yet
- [ ] External-facing screens have no link or menu leading to the internal side
- [ ] Strip the system's name and it still reads as this specific business, not a generic template (section 1c)

### Then repeat at 375x812

- [ ] No horizontal scrolling - tables become cards
- [ ] Mobile menu matches the destination-count rule (section 12), thumb-reachable
- [ ] The top bar doesn't eat so much space that content has barely any room left
