# LogicTrade — Design System & Style Guide

> **v2 — juli 2026.** Audited against the running prototype (`shared/styles.css`). Changes in this revision:
> new `--accent-blue-ring` (focus rings are now always 10% via token; `--accent-blue-glow` is hover-shadow only) ·
> `--assist-gradient` documented as AI-only · KPI-purple removed · search-input radius normalized to `--radius-md`
> (the `10px` exception is gone — the no-off-scale rule is now absolute) · gradient-bar stop percentages specified ·
> **new § 3.0 with concrete sizing values.**
>
> Values in this document are verified against the prototype. Where the design-system showcase page disagrees, this document wins.

This file is the canonical reference for matching the **Offerte** module's look, behavior, and feel in any new module of the LogicTrade ERP. It captures the full system: tokens, components, motion, UX rules, and the small conventions that make the product feel cohesive.

> **TL;DR for the next module**: stay quiet, use the ink scale (not the brand blue) for most text, make money values strong, keep animation subtle, and only use the accent magenta where the existing module does — sparingly.

---

## How to read this doc — rules vs guidelines

Two kinds of statements live below:

- **🔒 RULE** — must match exactly. Visual tokens, component specs, motion timing, click semantics, the four-surface pattern, modal-safe animation, currency / percentage / pill formatting, drag-drop feedback, toast position, pagination, page-header pattern. If something here is a rule and your module diverges, the modules will visibly drift apart.
- **🌿 GUIDELINE** — adapt to your module's content. _What_ a KPI shows, _which_ accordions sit in the side panel, _what_ fields the "paper" contains, _which_ filter chips the overview supports, _what_ the empty-state copy says. These vary because **Offerte ≠ Aftersales ≠ Inkoop**. Same shape, different contents.

By default, anything in §1–§5 is a 🔒 RULE — even when an individual heading or paragraph isn't tagged. §6 mixes both (interaction patterns are rules; defaults/copy are guidelines). §7–§12 are rules. **§13 is the consolidated per-module content guideline list — start there when planning a new module.** When in doubt, treat it as a rule.

**For future AI agents and contributors**: the absence of 🌿 GUIDELINE on a heading means it's a rule. Don't introduce new tokens, new colors, new radii, new font weights, new motion durations, or new component variants without a deliberate, documented decision. The system has been deliberately constrained — that's the whole point.

---

## 1 · Brand foundations

### 1.1 Colors 🔒 RULE

#### Brand

| Token                   | Hex                       | Role                                                                                                                                                 |
| ----------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--accent-blue`         | `#0d35a0`                 | Primary brand. Links, focus rings, active nav, drop targets, "Totaal" grand total, KPI blue card.                                                    |
| `--accent-blue-soft`    | `#e2eaff`                 | Brand fill at 100% opacity for hover/active backgrounds, focus rings, drop-target highlight.                                                         |
| `--accent-blue-glow`    | `rgba(13, 53, 160, 0.22)` | Soft blue shadow under **hover** states only (`0 4px 14px`). Never a focus ring.                                                                      |
| `--accent-blue-ring`    | `rgba(13, 53, 160, 0.1)`  | **The** focus ring. Used everywhere as `0 0 0 2px` (3px in modal context). Never hardcode this rgba — always the token.                              |
| `--accent-magenta`      | `#E6007E`                 | Secondary brand. Used very sparingly: KPI trend chips, "Niet definitief" pill, locked-state hint. **Never used as body text.**                       |
| `--accent-magenta-soft` | `#FDE5F0`                 | Magenta tint for soft backgrounds and pill fills.                                                                                                    |
| `--accent-green`        | `#009a82`                 | Success, primary CTA ("Versturen", "Klant aanmaken"), "Definitief" pill, eerstvolgend dot. **Not used for the order-made indicator** — see § 4.18.1. |
| `--accent-green-soft`   | `#e5f5f3`                 | Green fill (rare).                                                                                                                                   |

#### AI / Assist

| Token               | Value                                                                    | Role                                                                                                          |
| ------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `--assist-gradient` | `linear-gradient(135deg, #eef2ff 0%, #ede9fe 50%, #fce7f3 100%)`         | 🔒 **AI features only.** The Assist button and AI affordances. Purple/pink appears nowhere else in the system. |

> This is deliberately *not* a brand color. It marks "this is AI" and loses that meaning the moment it's reused decoratively.

#### Ink scale (text)

A flat 5-step scale. The middle is `--ink-700` (body); above is heavier emphasis, below is quieter.

| Token       | Hex       | Role                                                                                                                  |
| ----------- | --------- | --------------------------------------------------------------------------------------------------------------------- |
| `--ink-900` | `#071b55` | Strongest — page titles, big numbers (KPI values), section headers (KLANT, DOCUMENT, OPMERKINGEN), grand total label. |
| `--ink-700` | `#252525` | **Default body text.** Item names, totals values, button labels, line totals.                                         |
| `--ink-500` | `#6b7a94` | Secondary — labels, metadata, table column headers, captions.                                                         |
| `--ink-400` | `#9aa5b8` | Tertiary — placeholders, footnotes ("30 dagen"), idle icons.                                                          |
| `--ink-300` | `#cccccc` | Disabled, dashed-button borders, very faint dividers.                                                                 |

> The scale is intentionally short. Don't introduce 600/800/200/100 — we tried, they merged.

#### Surfaces

| Token             | Hex       | Role                                                            |
| ----------------- | --------- | --------------------------------------------------------------- |
| `--bg`            | `#edf0f5` | Page background (the "table" the cards sit on).                 |
| `--surface`       | `#ffffff` | Cards, modals, panels, primary "page object" surface.           |
| `--surface-alt`   | `#f4f6f9` | Subtle nested surface (KPI internals, hover bands, totals box). |
| `--surface-muted` | `#fafafd` | Near-white — group headers, item-row hover.                     |

#### Borders / strokes

| Token           | Hex       | Role                                                                    |
| --------------- | --------- | ----------------------------------------------------------------------- |
| `--border`      | `#e1e5f0` | **Default visible border** — inputs, chips, cards, container outlines.  |
| `--border-soft` | `#eef0f4` | Quieter borders — KPI cards, group cards, between-section dividers.     |
| `--border-line` | `#f1f2f3` | Hairline — between table rows, between item rows, accordion separators. |

#### Status / semantic

| Token      | Hex       | Role                                                          |
| ---------- | --------- | ------------------------------------------------------------- |
| `--danger` | `#ec5555` | Destructive actions (remove on hover, deselect klant button). |

#### Color usage rules

1. **Brand blue is reserved for "interactive / brand" things**: links, primary CTAs, focus, the grand total. It is _not_ a default text color. Default text is ink-700 / ink-900.
2. **Magenta is never body text**. Use it for backgrounds, badges, soft borders, and the "Niet definitief" status pill. It can also subtly mark a locked/read-only state.
3. **Green is for confirmed-positive actions** (Send, order created) and **the "Eerstvolgend" indicator** (next upcoming appointment dot).
4. **Section headers** (KLANT, DOCUMENT, OPMERKINGEN, VOORWAARDEN, OMSCHRIJVING column header treatment) use `--ink-900` navy. The matching uppercase + `letter-spacing: 0.6px` is part of the look.
5. **Money values** that are line totals or summary rows use `--ink-700`. Only the **grand "Totaal"** uses `--accent-blue` (signal: this is THE final number).

### 1.2 Typography 🔒 RULE

- **Single font**: `'DM Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` (`--font-sans`). DM Sans dekt de hele app — body, titels, knoppen, tabellen, KPI's én de sidebar-navigatie. Geen tweede font, ook niet voor nav-labels.
- Imported via Google Fonts: `family=DM+Sans:wght@400;500;600;700`.

#### Sizes & weights

| Use                                      | Size                                          | Weight  | Color                                                                 |
| ---------------------------------------- | --------------------------------------------- | ------- | --------------------------------------------------------------------- |
| Page title (`Offertes`, `Offerte O260…`) | 22–24px                                       | 700     | `--ink-900`                                                           |
| Section header (KLANT, DOCUMENT, …)      | 12px uppercase, 0.6px tracking                | 700     | `--ink-900`                                                           |
| Body text default                        | 13px                                          | 400–500 | `--ink-700`                                                           |
| Item name in items list                  | 12px                                          | 700     | `--ink-700`                                                           |
| Labels (Datum, Verkoper, dt)             | 12px                                          | 400     | `--ink-500`                                                           |
| Captions, footnotes                      | 11px                                          | 500     | `--ink-400`                                                           |
| Big number (KPI value)                   | 30px                                          | 700     | `--ink-900`                                                           |
| Grand total                              | 17px                                          | 700     | `--accent-blue`                                                       |
| Column headers (table & items)           | 11px uppercase, 0.5–0.6px tracking            | 500–700 | `--ink-500`                                                           |
| Status pill                              | 13px                                          | 500     | `--accent-magenta` (Niet definitief) or `--accent-green` (Definitief) |
| Number cells                             | 12–13px, `font-variant-numeric: tabular-nums` | —       | —                                                                     |

> **Always `tabular-nums` for money columns.** It keeps decimals aligned across rows.

### 1.3 Radius 🔒 RULE

Three radius tokens plus pill / circle. **Every `border-radius` must use one of these — no hard-coded pixel values, no off-scale values, no exceptions. This is a hard rule, not a guideline.**

| Token         | Value  | Use                                                                                         |
| ------------- | ------ | ------------------------------------------------------------------------------------------- |
| `--radius`    | `4px`  | Cell-edit inputs, chip closes, small interactive elements, tab indicator backgrounds.       |
| `--radius-md` | `8px`  | Buttons (all), input fields, item-group cards, modal-close, KPI hover-hint, dropdown menus. |
| `--radius-lg` | `12px` | Detail card, KPI cards, insights cards, totals box, **modal cards**.                        |
| `999px`       | —      | Pills (filter chips, KPI trend chips, status pills, switches).                              |
| `50%`         | —      | Circles (pagination buttons, dots, avatars).                                                |

> Off-scale values (`3px`, `6px`, `10px`, `18px`, etc.) are not allowed. Snap to the nearest token.

### 1.4 Shadows 🔒 RULE

| Token / value                                                                                                    | Use                                                                                                                                                                        |
| ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `4px 4px 15px rgba(14,34,85,.05)` + `1px solid #eef0f4`                                                          | **Main card shadow** — de canonieke schaduw onder alle "hoofd"-cards: overview-tabel-card, detail-A4, side panel. Radius 12px. Geen aparte hover-state.                     |
| `--shadow-card`                                                                                                  | Legacy multi-layer stack (`0 7px 16px rgba(199,202,220,.10), 0 30px 30px rgba(199,202,220,.09), 0 67px 40px rgba(199,202,220,.05)`) — nog in gebruik, migreren naar boven. |
| `--shadow-panel` = `4px 4px 15px rgba(14,34,85,.05)`                                                             | Alias voor de main-card shadow op de side panel.                                                                                                                            |
| `0 4px 14px rgba(19,80,234,.06), 0 1px 3px rgba(19,80,234,.04)` (idle) → `0 10px 24px rgba(19,80,234,.10), 0 3px 8px rgba(19,80,234,.06)` (hover) + `translateY(-2px)` | **KPI-card blauw** — subtiele kleurgebonden shadow die op hover intensiveert en licht opheft. |
| `0 4px 14px rgba(230,0,126,.09), 0 1px 3px rgba(230,0,126,.05)` → `0 10px 24px rgba(230,0,126,.15), 0 3px 8px rgba(230,0,126,.07)` | **KPI-card roze** — zelfde patroon, magenta tint.                                                                                             |
| `0 12px 32px rgba(15,22,42,.12), 0 4px 12px rgba(15,22,42,.06)` + `1px solid var(--border-soft)`                 | **Floating menu / context menu** — dropdown menus, quick-actions menus, popovers. Altijd op een witte card-achtergrond, dus met stroke tegen border-soft.                   |
| `0 24px 70px rgba(13,30,80,.18), 0 4px 16px rgba(13,30,80,.06)`                                                  | Modal cards.                                                                                                                                                                |
| `0 8px 28px rgba(15,22,42,.08), 0 1px 3px rgba(15,22,42,.04)`                                                    | Insights hero / secondaire content-cards buiten de hoofdvlakken.                                                                                                            |

> Don't introduce new shadow tokens unless a new surface tier emerges. Shadows are intentionally varied per surface importance. KPI-cards zijn het enige geval met kleurgebonden shadows én hover-intensivering — main cards blijven bewust rustig.


### 1.5 Spacing 🔒 RULE

No spacing token system — values are picked contextually but consistently:

- `4 / 6 / 8 / 10 / 12 / 14 / 16 / 20 / 24 / 28 / 32 / 36 / 40` are the common picks.
- Card padding: `28px 32px` to `32px 36px`.
- Page padding: `32px 40px 64px` (24px each side under 1280px breakpoint).
- Row padding: `12px–16px` vertical, `15px` horizontal (matches the offerte-paper inset).

---

## 2 · Module structure — the four surfaces

Every CRUD module in LogicTrade follows the same four-surface pattern. This is the most important thing for a new module to mirror — surface choices below (typography, color, motion) only land if the underlying structure matches.

```
                    ┌─────────────────────────────────────────────┐
                    │                                             │
   ┌─── Overview ───┤ KPI cards (4-up, summary + filter shortcut) │
   │                │ Filter bar (config-driven chips)            │
   │                │ Sortable table                              │
   │                │                                             │
   │  single-click  │                                             │
   │       ↓        │                                             │
   │  ┌─ Drawer ────┤ Slide-in paper preview from the right       │
   │  │             │ Read-only · quick-actions menu in corner    │
   │  │             │ Same content/layout as the detail           │
   │  │             │                                             │
   │  │ double-click│                                             │
   │  │      ↓      │                                             │
   │  │  ┌── Detail ┤ Full-bleed editable "paper" surface         │
   │  │  │  + Side  │ Right rail: accordions (Afspraken, Historie,│
   │  │  │   panel  │   Notities, Documenten)                     │
   │  │  │          │                                             │
   │  │  │ ↑        │                                             │
   │  │  │ Modals   │ Open over everything for confirms / pickers │
   └──┴──┴──────────┴─────────────────────────────────────────────┘
```

### 2.1 Overview (list)

Where the user lives between tasks. The pattern:

1. **(Optional) KPI cards row** — each card is _both_ a summary stat _and_ a filter shortcut:
   - Most cards open an inzichten / details view on click (small "Inzichten" icon hint reveals on hover, top-right).
   - One card represents the most actionable cohort and **toggles a filter** when clicked (e.g. "Openstaande offertes" sets `status = ["Niet definitief"]`). Active state shown by a magenta outline ring.
2. **Toolbar card** — a single `.card` containing the `.toolbar.toolbar--with-filters`. The toolbar order is fixed: `search → filterbar → .toolbar__pipe → .toolbar__actions`. The actions cluster on the right holds (in order) the optional view toggle (§ 4.5.1), the primary `+ Nieuw` button, and the `⋯` More menu.
3. **Sortable table** — the list itself, inside the same card as the toolbar so the toolbar and table share one chrome boundary. Row hover, stagger entrance, and the **single/double/right-click** semantic apply.

The overview is intentionally _flat_: no nesting, no tabs. Filters and KPIs are the only navigation.

#### 🔒 RULE — No separate page-header on list pages

List pages have **no page-header strip** above the toolbar — no `.page__header`, no `<h1>` with the module name, no "N items" subtitle. The module is already named in the sidebar; repeating it as a page title eats vertical space without adding signal. All controls that would otherwise live in a header (view toggle · + Nieuw · ⋯ menu) live inside the toolbar's `.toolbar__actions` cluster. This applies to offerte, service, and planning lists; new modules follow the same rule. The item count belongs in the data itself (table footer / pagination), not in a heading.

### 2.2 Drawer — the "view" option

A right-sliding overlay panel triggered by **single-clicking a row** (or `Space` on a keyboard-active row — see § 6.1). It shows a **read-only paper preview** of the item — same layout as the detail's main canvas, but:

- No editing affordances (no inputs, no remove buttons, no add CTAs).
- **No header strip, no footer.** The paper sits directly in the drawer body. All record-level actions live in the quick-actions menu in the paper's top-right corner (§ 4.15).
- Slide-in from `translateX(100%)` over 280ms; overlay fades 180ms.
- Header (on the paper itself) shows a "Definitief / Niet definitief" pill but it's not interactive in the drawer.

The drawer is **the answer to "I just want to peek"**. Users review most quotes this way and only escalate to the editor when they need to make changes. This pattern is critical to the speed of the module — don't replace it with a full route navigation.

### 2.3 Detail — the "paper" canvas

The main editing surface. Triggered by:

- **Double-clicking a row** in the overview (or `Enter` on a keyboard-active row).
- Clicking **Bewerken** in the quick-actions menu inside the drawer (the primary item, also reachable via `Enter` directly on the drawer).
- Clicking the icon-open button on a row.

Layout:

```
.detail-grid (grid: minmax(0,1fr) auto)
├─ .detail-card                ← the "paper": white, soft border, large radius
│   ├─ Page header in-card     (title + status pill + actions)
│   ├─ Klant + Document blocks (2-col info-grid)
│   ├─ Items list              (column header + rows + groups + add-split)
│   ├─ Totals box              (right-aligned, soft surface-alt fill)
│   └─ Voorwaarden + Bestanden (2-col)
└─ .side-panel--accordion      (the right rail; see § 2.4)
```

The "paper feel" is real: white surface, generous padding (32×36), soft outer border, large radius. Inputs are **invisible until hovered** — the page reads like a printable document, and editing is opt-in. This is what we mean by "paper canvas": the content _is_ the document, not a form _about_ the document.

The page-header buttons live **above** the paper, not inside it (Download, Link, Order, Versturen, More). The paper itself contains the document content.

#### 🔒 RULE — Title hierarchy on the paper

The paper has **exactly one H-style heading**: the **detail-header\_\_title** at the top (e.g. `Offerte O2600512`, `Servicemelding S26000021`). Every section below it uses **`.info-block__label`** — the small ALL-CAPS label (12px / 700 / `--ink-900` / 0.6px tracking) with content directly underneath. **Never** use a second `<h2>` / `<h3>` inside the paper for sections like "Beschrijving", "Artikelen", "Oplossing", "Bestanden". This was a real regression we caught in service-detail; the paper started reading like a CMS form instead of a document.

Two corollaries:

- **Items tables get no heading at all.** Offerte's items table is preceded only by its column-header strip (`OMSCHRIJVING · AANTAL · …`); service's artikelen table follows the same rule (`ARTIKEL · LOCATIE`). The column strip _is_ the section affordance.
- **The user-facing complaint, subject, or description is content, not a title.** In service it's labelled `MELDING` with the onderwerp as `info-block__line--strong` and the omschrijving as body text. Same pattern applies anywhere a free-text "what's going on" field exists — wrap it in an info-block, don't promote it to an H-heading just because it reads like one.

### 2.4 Side panel — contextual/related info

A right rail next to the detail-card. Holds **everything about this thing that isn't part of the document itself**:

- Afspraken (appointments related to this offerte)
- Historie (audit trail of changes / status / order events)
- Notities (internal notes)
- Documenten (related files)

Rules:

- The side panel is **always present** when there's a detail surface. Never make it a dialog or a tab.
- It uses **accordions** (`.acc`), one per category. Multiple can be open at once.
- The whole panel **collapses to a 48px icon strip**. Both expanded and collapsed states must always be in the DOM (transitions via opacity/width — `display: none` defeats the smooth collapse).
- Items inside accordions can have **state markers** (e.g. green dot for "next upcoming" appointment, dashed-blue link for an order id in history) — see § 4.7.
- The side panel **never** contains primary actions related to the document (those live on the paper). It's read + lightweight-write (add an appointment, add a note).

### 2.5 Modals — over everything

Used sparingly, for:

- **Confirmations** that need a second question (status change → "Order maken?").
- **Modal-style pickers** when an inline picker would be too constrained (Catalogus, Klantkaart, Nieuwe klant).
- **Detail editing of a configurable item** (Product detail modal).

Modals overlay the entire viewport (`position: fixed; inset: 0`) with a soft backdrop blur. They never trap the user — Esc + overlay-click both close. The canonical foot has up to three buttons with **three different weights** (`btn--ghost` cancel left + optional `btn--text` secondary action + `btn--primary` commit right) — see § 4.9 for the full rule.

> **Constraint**: any animated ancestor of a modal (`.page`, `.detail-card`, `.detail-grid`) must use opacity-only animations. Transform-keyframe animations turn the ancestor into a containing block for `position: fixed` and the modal will clip to that ancestor instead of the viewport. We hit this bug. Don't do it again.

### 2.6 The relationship rules

- **Overview → Drawer → Detail** is a _progressive escalation_ of commitment. Overview is browse, drawer is peek, detail is edit. A new module should preserve this 3-step gesture path.
- **Detail ↔ Side panel** is a _split of responsibility_: the document on the left, related context on the right. Don't mix them — putting "appointments" inside the document or putting "line items" inside an accordion both feel wrong.
- **Modals are episodic**: they appear, ask one question, and leave. Don't put long workflows inside a modal — that's what the detail page is for.
- **The status pill is the single source of truth for record state**. It's visible from overview (table cell), drawer, and detail. Always editable from the detail (and read-only from drawer). Status change drives:
  - Read-only mode in the editor (Definitief locks editing).
  - History entries (auto-logged).
  - Confirmation modals (e.g. "Order maken?" on Definitief).

### 2.7 Quote-on-yourself when starting a new module

Before designing screens, draw the four boxes:

1. What goes on the **overview**? What's the KPI row? What 1 KPI is also a filter?
2. What's the **drawer** showing — i.e., what's the read-only summary the user wants to glance at?
3. What does the **paper** contain? What's the "document" here? (Order = order lines + customer + delivery; Inkooporder = supplier + lines + receipt.)
4. What contextual info goes in the **side panel**? Appointments? Audit trail? Linked records?

If you can't answer all four, the module isn't shaped correctly yet. Stop and rethink before opening Figma.

When you sketch the paper, **mentally draw it as a single A4**: one big title at the top, then small uppercase labels for every block underneath. If you find yourself reaching for a second large heading, reread §2.3 — that almost certainly means the block belongs as an `info-block__label` instead.

---

## 3 · Layout

### 3.0 Sizing — concrete values 🔒 RULE

**These are the numbers. A new module copies them exactly — not "roughly".** Every value below is verified against the running prototype. If a size isn't listed here, it isn't yours to invent: find the existing component that already solves it.

| Surface                        | Value                                                        |
| ------------------------------ | ------------------------------------------------------------ |
| App grid columns               | `var(--sidebar-w, 230px) 1fr`                                |
| App grid rows                  | `56px 1fr` (topbar is 56px)                                  |
| Sidebar collapsed              | `56px`                                                       |
| Gradient bar                   | `3px` high, `position: fixed`, `z-index: 100`                |
| **Page padding (default)**     | `32px 40px 64px`                                             |
| **Page padding (≤1280px)**     | `24px 24px 48px`                                             |
| Detail grid                    | `grid-template-columns: minmax(0,1fr) auto; gap: 24px`       |
| Detail grid collapse           | `≤1200px` → single column (`1fr`)                            |
| **Detail card padding**        | `32px 48px`                                                  |
| Detail card radius             | `--radius-lg` (12px)                                         |
| Detail card border             | `1px solid var(--border-soft)`                               |
| Detail card shadow             | main-card shadow (§ 1.4)                                     |
| Side panel collapsed           | `48px` icon strip                                            |
| Items row inset                | `14–16px 15px` (headers, rows and totals share this edge)    |
| Modal card (wide)              | `width: min(880px, 100%)`                                    |
| Button height                  | `36px`, padding `0 16px`                                     |
| Input radius                   | `--radius-md` (8px) — all inputs, including search           |
| Focus ring                     | `0 0 0 2px var(--accent-blue-ring)` (3px in modal context)   |

> Off-scale values are not allowed (§ 1.3). Snap to the nearest token.
>
> ⚠️ The design-system showcase page (`ds-ui-paper__zone--*`) currently displays different paddings than the real `.detail-card`. **The table above is authoritative; the showcase is not.**

### 3.1 App grid

```
┌────────────────────────────────────────────────────────┐
│ ▓▓▓▓ 3px gradient bar (always on top, position: fixed) │
├────────┬───────────────────────────────────────────────┤
│        │ Topbar (56px, sticky, z-index: 30)            │
│ Side-  ├───────────────────────────────────────────────┤
│  bar   │                                               │
│ 230px  │  Main content (.page padding 32 40 64)        │
│ (56 if │                                               │
│ collapsed)                                             │
└────────┴───────────────────────────────────────────────┘
```

`grid-template-columns: var(--sidebar-w, 230px) 1fr` and `grid-template-rows: 56px 1fr` with named areas.

The 3px **gradient bar** at the top is a brand accent. It runs across every page.

```css
.app::before {
  linear-gradient(90deg, #1bc7ad 0%, #0d8c9e 28%, #0d35a0 64%, #ec4899 100%);
  height: 3px; position: fixed; z-index: 100; pointer-events: none;
}
```

> 🔒 **The stop percentages (`0 / 28 / 64 / 100`) are part of the spec.** Without them an agent guesses the distribution and the bar comes out wrong.
>
> ⚠️ `#0d8c9e` and `#ec4899` are **deliberate one-off gradient colors — not tokens, not brand colors.** They exist only in this bar. Never reuse `#ec4899` as "the pink": the brand magenta is `--accent-magenta` (`#E6007E`). Never reuse `#0d8c9e` at all.

### 3.2 Detail page grid

```
.page
└─ .page__header (back link · header actions)
└─ .detail-grid    grid: minmax(0, 1fr) auto, gap: 24px
    ├─ .detail-card           ← the offerte "paper"
    └─ .side-panel--accordion ← collapsible right rail
```

The right side panel can collapse to a 48px icon strip. Both states must always be in the DOM (transitions via opacity/width, not conditional rendering — see Animation rules).

### 3.3 The offerte "paper"

`.detail-card` is conceptually a printable A4 — the working surface. White background, soft border (`--border-soft`), `--radius-lg`, main-card shadow (§ 1.4). Internal layout is grid-driven. **Items rows align horizontally to the offerte-paper inset, not to its container.** That means `.items-head` and `.items-row` use `padding: 14–16px 15px` so column headers, totals, and group subtotals share the same right edge.

#### 3.3.1 A4 opbouw — vier universele zones 🔒 RULE

Elke A4-paper canvas in het systeem (offerte-detail, service-detail, planning-detail, en toekomstige modules) volgt dezelfde vier-zone-opbouw. De **invulling** verschilt per module; de **structuur** niet.

```text
┌─────────────────────────────────────────────┐
│ Topbar     Titel (links)        Status (rechts) │
├─────────────────────────────────────────────┤
│ Zone 1 — Hoofd-info                             │  ← klant, datum, verkoper, referentie
├─────────────────────────────────────────────┤
│ Zone 2 — Main content                           │  ← items-tabel, melding + oplossing, afspraak-meta
│                                                 │
├─────────────────────────────────────────────┤
│ Zone 3 — Ondersteunende content                 │  ← opmerkingen, bijlagen, voorwaarden
└─────────────────────────────────────────────┘
```

- **Topbar** — record-titel (bv. "Offerte O-2024-0123") links, status-pill rechts. Deelt de status-toggle-regel uit § 6.2.
- **Zone 1 — Hoofd-info** — identiteit van het record. Klant, adres, datum(s), verkoper/hulpbron, referenties naar andere records. Grid-layout, alle waardes zijn `EditableText` / `InlineSelect` (§ 4.2 borderless).
- **Zone 2 — Main content** — waar het record écht over gaat: items-tabel (offerte), melding + oplossing (service), afspraak-meta + verrichtte werkzaamheden (planning). Meestal het hoogst en het meest interactieve blok.
- **Zone 3 — Ondersteunende content** — opmerkingen, bijlagen, voorwaarden, betalingsinformatie. Alles wat "erbij hoort" maar niet nodig is om het record te begrijpen.

Regels:

- 🔒 Zone-volgorde is vast: 1 → 2 → 3. Nooit "opmerkingen bovenaan" of "status onderin".
- 🔒 Achtergrond binnen een zone-block: `var(--surface-alt)`, border `1px solid var(--border-soft)`, radius `--radius-md`. Alleen zone 2 mag optioneel op de paper-witte bg staan als de content zelf al genoeg structuur heeft (items-tabel).
- 🔒 Meta-labels binnen een zone: uppercase 11px `--ink-500` met 0.6px letter-spacing (§ 1.2).
- 🌿 Modules kiezen zelf welke velden er in zone 1 komen en of zone 3 überhaupt zichtbaar is (bij een "leeg" planning-blok bv. niet).



---

## 4 · Components

### 4.1 Buttons

| Class                             | Use                                                                                                 | Style                                                                                                                                                |
| --------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `.btn.btn--primary`               | Primary CTA ("Opslaan", "Versturen")                                                                | Filled green, height 36, padding `0 16px`, font 13px/500, radius `--radius-md`, no icon by default.                                                                                             |
| `.btn.btn--ghost`                 | Secondary action ("Annuleren")                                                                      | Outlined, transparent.                                                                                                                               |
| `.btn.btn--text` 🔒               | Tertiary action — text-only, no chrome ("Andere klant", "Verwijderen" inside modals, "Terug" links) | Transparent border + bg, `--ink-500` text, neutral hover (`--surface-alt` bg, `--ink-900` text).                                                     |
| `.btn--icon`                      | Square icon-only button                                                                             | 36×36, neutral.                                                                                                                                      |
| `.btn--icon-ghost`                | Floating icon button (header actions: Download, Link, Order maken)                                  | Same square, but truly transparent until hover.                                                                                                      |
| `.btn--icon-ghost--made`          | The order-maken button after an order exists                                                        | Green text + small green dot in top-right corner.                                                                                                    |
| `.add-row` (canonical dashed CTA) | Full-width "Regel toevoegen", "Afspraak inplannen"                                                  | 1.5px dashed `--ink-300`, `--ink-500` text, weight 600, white bg, `padding: 12px`, `margin-top: 12px`, gap 8. **All three must use this exact spec** — no inline overrides. |
| `.add-row-split`                  | Split button: "Regel toevoegen                                                                      | ⌄"                                                                                                                                                   | Composed of two `.add-row` halves with a divider; the right half opens a small menu (Tekstregel / Groep). |
| `.items-toolbar__btn`             | Pill-shaped secondary buttons (used inside the split menu / minor actions)                          | 999px radius, white bg, 1px border, 12px text, weight 500. Hover: blue accent.                                                                       |

🔒 RULE — **Three button weights, three roles.** When a surface needs more than two actions, use the full hierarchy: `btn--text` for the dismiss/back action (lightest), `btn--ghost` for a secondary action with structure (e.g. "Bestand toevoegen"), `btn--primary` for the commit. Never use two `btn--ghost`s side-by-side — they read as the same weight and force the user to read labels to find the primary.

Common rules:

- Height 36px for all primary/ghost/icon buttons.
- `--radius-md` (8px) on rectangular buttons.
- `:active { transform: scale(0.97); }` for tactile feedback (added at the cross-cutting CSS layer).
- Icons: 14–16px usually; the `+` icon **rotates 90° on hover** for `.add-row` (a small delight detail — keep).

#### `.btn--danger` / `.btn--danger-outline` 🔒

Voor destructieve commits (Verwijderen) en het afbreken van een lopende bewerking (Afbreken). **Nooit** gebruiken voor "Annuleren" — dat is `btn--text` of `btn--back`.

- `.btn--danger` — gevuld rood (`--danger`), witte tekst. Hover donkerder rood (`--danger-hover`). Voor de bevestig-actie in een verwijder-modal.
- `.btn--danger-outline` — witte bg, `--danger` border en tekst. Hover vult naar `--danger-soft`. Voor "Afbreken" op formulieren met onbewaarde wijzigingen.
- **Danger in menu** (`.menu__item--danger`): geen border, geen card, geen shadow — het item leeft in de bestaande `.menu` popover. Rode ink, hover `--danger-soft` bg. Staat achter een divider onderin het menu, nooit als eerste item.
- **Copy**: werkwoord + object (`Verwijderen`, `Afbreken`, `Loskoppelen`). Nooit "Definitief verwijderen" of "Wijziging terugdraaien" — kort blijft duidelijker.

#### `.btn--pill` / `.btn--pill-icon` 🔒 vorm-uitzondering

De **enige** ronde button in het systeem. Signaal: rechthoekig = doen (data-actie), pill = **openen** (systeemkaart / dialog).

- `.btn--pill` — als `btn--ghost`, maar volledig ronde radius (`999px`). Label = werkwoord + object, bv. "Klant bekijken", "Open klant".
- `.btn--pill-icon` — icon-only, rond, iets kleiner (28–32px). `aria-label` verplicht.
- Regels:
  - 🔒 Alleen voor systeemkaart/dialog openen. Nooit opslaan/versturen/verwijderen.
  - 🔒 Max. één pill per kaartblok.
  - 🔒 Staat in het contextblok van de kaart, niet in de header of toolbar.
  - 🔒 Binnen de geopende dialog gelden weer de gewone `btn--primary` / `btn--ghost` / `btn--text` varianten.

#### `.btn--split` — "meer" / context-menu trigger 🔒

De `⋯`-knop naast de primaire actie in een detail-header (Order maken · Versturen · ⋯). Zelfde hoogte en radius als de andere knoppen, opent een floating menu (§ 1.4 shadow: floating menu). Menu-items volgen dezelfde interactie-regels als de drawer quick-actions (§ 4.15).



### 4.2 Inputs

#### Search input (`.ds-input--search`) 🔒

De zoekinput in de toolbar en boven lijstjes is een aparte variant met een iets zachtere rand-radius dan gewone form inputs:

- Radius `--radius-md` (8px) — gelijk aan alle andere inputs. Er is **geen** afwijkende search-radius.
- 36px hoog, `--surface` bg, `--border` border. Leading `Search`-icoon in `--ink-400`.
- Placeholder-tekst: **`--ink-400` (`#9aa5b8`)** — nooit lichter, nooit `--ink-500`. Placeholder = "wat kan ik hier zoeken?", niet decoratie.
- Focus: `--accent-blue` border + `0 0 0 2px var(--accent-blue-ring)`, geen andere bg-change.

#### Filterbar (`.filterbar`) 🔒 pattern

Config-driven filter-rij, altijd direct onder de search input in een toolbar. Twee element-typen:

- **Filter-chip** (`.filter-chip`) — pill, 999px radius, witte bg, `--ink-300` border. Toont label + gekozen waarde. Icon wordt magenta zodra de filter een waarde heeft; `×` verwijdert alleen de waarde, niet de chip. Klik opent een popover met opties (single- of multi-select).
- **`+ Filter`-knop** (`.filter-add`) — dashed border, `--ink-500` tekst, zelfde 999px pill-vorm. Opent een menu met alle nog niet actieve filter-definities; klik op een item promoveert het naar een actieve chip.

De filter-definities staan centraal in `shared/filters.jsx` (`applyFilters` helper). Modules definiëren alleen hun eigen definitie-lijst; nooit de chip-styling per module overschrijven.

#### Toggle switch — groen 🔒

De `.switch`-toggle gebruikt `--accent-green` als actieve kleur, niet blauw. Reden: toggles zijn commits ("aan / uit"), geen navigatie of selectie, en delen daarmee de kleur van primaire CTA's.



#### Text inputs (`.cell-edit`) — bordered (offerte / list contexts)

- Transparent border + bg by default.
- On hover: white bg, soft grey border (`var(--border)`).
- On focus: white bg, accent-blue border, soft 2px blue ring (`box-shadow: 0 0 0 2px var(--accent-blue-ring)`).
- 4px radius, 4–6px vertical padding.

> **This is the original input look.** Still used for `.cell-edit` in offerte's items table (qty / stuksprijs / korting) and other dense edit-in-grid contexts where the border helps the user see the cell boundaries.

#### Inline editors on the paper canvas (`.lt-detail-page`) — borderless 🔒

On the detail "paper" surfaces (service-detail, planning-detail, …) the canonical hover/focus is **softer**: no border, fill on hover, ring on focus. Reason: a paper canvas easily has 6–10 inline editors close together (klant-card + meta-grid + soort + vestiging + betreft + …); the bordered version was visually loud and broke the "this reads like a printed document" feel.

- Idle: `border: 1px solid transparent`.
- On hover: `background: var(--surface-alt)`, no border, no shadow.
- On focus / open: `background: var(--surface)` (white), no border, `box-shadow: 0 0 0 2px var(--accent-blue-soft)` ring.

Apply by adding `lt-detail-page` to the root `<div className="page …">`. The shared rule lives in `shared/styles.css` and targets `.inline-select`, `.inline-combo`, `.svc-order-search__field` (and any cross-module record search built on the same shape). Every editable field on a paper canvas should adopt it — don't mix the two models on one page.

#### Alignment trick — invisible-until-hover editors

The visible value of an inline editor must align flush with the dd's left edge (matching the value position of plain-text rows). Achieve this with `padding + negative-margin` so the click target is wider than the visible glyphs without indenting them:

| Class                                | Pattern                                                                                                                        |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `.editable-text`                     | `padding: 2px 6px; margin: -2px -6px;`                                                                                         |
| `.inline-select`                     | `padding: 4px 8px; margin: -4px -8px;`                                                                                         |
| Custom (e.g. planning's `TimeInput`) | Use the same padding+negative-margin pair. Don't fix `width` or `text-align: center` — the value will appear visibly indented. |

#### Money input

A specialized text input that:

- Shows `€ 1.234,56` (formatted) when blurred.
- Switches to raw `1234,56` when focused (auto-selects).
- Parses Dutch format on blur (`parseEUR` accepts `€ 1.234,56`, `1234,56`, `1234.56`).
- In the items table, **editing a line's stuksprijs back-computes the discount** instead of changing the catalog price. (Catalog stays the reference; discount % captures any reduction.)

#### Percent input

- Shows `5%` when blurred, raw integer when focused.
- Clamped 0–100.

#### Inline editors

- `EditableText` — contentEditable span; `Enter` commits, `Esc` cancels. Used for inline-editable fields like the reference and group names.
- `InlineSelect` — dropdown styled like editable text (no native `<select>`; we render the trigger and a custom popover). Used for Verkoper, Leverwijze, etc.
- `InlineDate` — calendar popover for dates.
- All three have a **read-only variant** that renders a non-editable span with `cursor: not-allowed` and `pointer-events`-friendly behavior.

#### `FormSelect` — modal/form-context dropdown 🔒

A custom dropdown (no native `<select>`) for use **inside modals** where the OS option list breaks the visual language. The trigger is a 38px-tall input-style button with an optional leading icon; click opens a popover with hover-able options, an `accent-blue-soft` highlight on the active row, and an optional `sub` line per option (used by the layout selector to show a one-liner under each label).

- Idle: `--surface` bg, `--border`, `--ink-900` text.
- Hover: border lifts to `--ink-300`.
- Open / focus: `--accent-blue` border + `0 0 0 3px var(--accent-blue-ring)` (modal context → 3px). Caret rotates 180°.
- Options list: `--radius-md`, soft shadow, `--surface-alt` row hover, `--accent-blue-soft` active row with a checkmark.
- Closes on outside-click and `Escape`.

> Use `FormSelect` for selects inside `.modal-card`. Keep `InlineSelect` for selects on the paper canvas. Never drop a native `<select>` into a modal — it'll look OS-y and break the design language.

#### Editable grand total (artikelkaart) 🔒 pattern

The "Totaal" cell on the artikelkaart modal is a `MoneyInput` styled like the bold value at rest (transparent border, weight 700, 18px). Editing it **back-computes** the line `discountPct` from `qty × price`, clamped to 0–100. Same back-compute idea as the inline `.item-price` cell on the items table — catalog price stays the reference, korting captures the reduction.

#### `FilesDropzone` — drag-and-drop file area 🔒

Replaces the older "Bestand toevoegen" button + tile grid. The whole area is the drop target.

- Container: dashed `--border`, `--radius-md`, `min-height: 84px`. Hover lifts the bg to `--surface-alt`. Drag-over highlights `--accent-blue` border + `--accent-blue-soft` fill.
- Empty state: centered upload icon + "Sleep bestanden hier of klik om te kiezen" + a one-line sub.
- With files: tiles wrap inside the container; each `.file` gets a small × at the right that appears on hover and removes just that file (red on hover).
- Click anywhere not on a tile opens the native file picker; dropped files are appended to the list. **No separate add button.**

#### Optional sub-form toggle (`.modal-shipping-toggle`) 🌿 pattern

For optional fields that aren't always relevant (afwijkend afleveradres, extra contact, …) hide the form behind a full-width dashed-border row labelled `+ <thing>`. Expanding shows a small uppercase section head + a quiet `× Verwijderen` link, then the fields. On commit, strip those fields from the payload if the toggle is collapsed.

### 4.3 Chips & pills

- **Filter chips** (`.filter-chip`): pill-shaped, white bg, ink-300 hover border. The icon turns magenta when set. The X close button removes only the value, not the chip itself.
- **KPI movement-regel** (`.kpi__movement`): two-level text under the KPI value, geen pill-vorm. **Format: signed value op de eerste regel, reference op de tweede** (see § 4.4 en § 8.3).
- **Status pill** (`.pill--niet`, `.pill--definitief`): magenta-soft for "Niet definitief", green-soft for "Definitief". Click to toggle (with the change-to-Definitief flow triggering a "Order maken?" modal).

### 4.4 KPI cards 🔒 RULE

Three-up grid (not four). Each module's overview shows up to three KPIs. More than three becomes a dashboard, not an overview, and the strip loses its "at a glance" purpose.

#### Two card anatomies — not one

KPIs come in two shapes, decided by what the card measures:

- **Telling-KPI** (a count): "Openstaande offertes: 5". Title + value, **no movement**, no comparison. The card is a filter-ingang — clicking it filters the list to the records it counts. Adding a "2 minder dan vorige maand" line was tried and removed: it was ambiguous (omgezet? vervallen? minder binnen?) and added attention-weight to a number that exists to be clicked, not tracked over time.
- **Meetpunt-KPI** (a value or average): "Openstaande offertewaarde: € 117.523". Title + value + **movement-regel**. Movement is part of the meaning — for a pipeline value you want to see "groeit of krimpt"; for an average you want "stijgt of daalt", because an average bestaat eigenlijk alleen om vergeleken te worden.

The two anatomies sit naast elkaar in the same row at equal height. The empty space under the telling-KPI's value lines up vertically with where the movement-regel sits on the other cards — the strip reads as rust, not as a gap.

#### What the card contains

- **Title** (12.5px, `--ink-500`, weight 600, uppercase, 0.06em tracking, 18px margin-bottom)
- **Value** (40px, `--ink-900`, weight 700, -0.025em tracking, tabular numerals on money)
- **Movement-regel** (alleen op meetpunt-KPI's) — see anatomy below
- **Hoverstate**: border becomes `--ink-200`, soft shadow grows, no transform
- **Click**: filtert de lijst (telling-KPI) of opent insights (meetpunt-KPI)

No coloured left border, no per-variant background, no hover-revealed circular icon in the top-right (the older "kpi\_\_open-hint" pattern). The card is a quiet rectangle that earns attention through its number, not its chrome.

#### Movement-regel anatomie 🔒

The line under the value on meetpunt-KPI's. Two-level hierarchy, not one row of equal-weight words:

```
+ € 12.640
vs. een maand geleden
```

- **Top line**: signed value, 15px, weight 700, `--ink-900`, tabular numerals. The sign carries the direction (no extra arrow icon, no word like "meer"/"lager" — they doubled the signal).
- **Bottom line**: reference, 12px, `--ink-300`. Wegzakkend in grijs zodat de top als signaal in één oog-fixatie leest en de context er alleen is als je 'm zoekt.
- **No colour**. Geen rood/groen op stijging/daling. De movement is een observatie, geen oordeel — bij sommige cijfers is groei goed, bij andere niet, en de gebruiker beoordeelt dat zelf. Color-coding zou onterecht "goed/slecht" suggereren.
- **Border-top** boven de regel: 1px `--line-soft` op 14px padding-top, 16px margin-top from the value.

#### Tijdsframe — implicit, not labelled 🔒

No `(nu)` or `(30 dagen)` labels next to the title. Geen aparte tijdslabel-regel onder de title. The time frame zit in the zin of de movement-regel:

- Snapshot-KPI's: `vs. een maand geleden` — "dan een maand geleden" impliceert dat het getal een momentopname is.
- Periode-KPI's: titel krijgt `· 30 dagen` suffix ("Gem. offertebedrag · 30 dagen"), movement-regel zegt `vs. de 30 dagen ervoor`. Het venster wordt verraden door de zin, niet door een los label.

This is show-don't-tell applied to time. The reference period bevat alle informatie die een label zou geven, zonder een tweede regel chrome.

#### Active-toggle 🔒

The "Openstaande offertes" telling-KPI is **active-toggleable**: clicking it sets/clears the filter `status = ["Niet definitief"]`. The active state shows a magenta outline ring. Other telling-KPIs (per module) follow the same pattern when they map cleanly to a filter.

#### Wat we expliciet niet doen

- 🚫 Geen vier KPI's. Drie is genoeg; vier wordt een dashboard.
- 🚫 Geen sparkline op de periode-KPI om hem te onderscheiden van de snapshot-KPI's. Geprobeerd, te onrustig — drie kaarten waarvan één een lijntje heeft leest rommelig.
- 🚫 Geen "Klik om te filteren" affordance-hint onderaan de telling-KPI. Geprobeerd, de regel deed niets nuttigs en maakte de kaart juist drukker. Hover + cursor pointer dragen de klikbaarheid.
- 🚫 Geen kleurige kleur-coding op de movement (rood/groen). Movement is observatie, niet oordeel.

### 4.5 Tables

- Card-wrapped (main-card shadow uit § 1.4 — dus `4px 4px 15px rgba(14,34,85,.05)` + `1px solid #eef0f4`, radius 12px).
- Sticky header? No — short tables. Pagination at bottom.
- **Header**: bg `#FAFBFC` (subtiele lift ten opzichte van de card-body), hairline `--border-line` onderrand.
- **Header labels**: **Title Case, niet ALL CAPS**. Weight 600, 13px, `--ink-500`, letter-spacing 0. Uppercase-tracking is voorbehouden aan meta-labels op de A4 (§ 1.2).
- **Bedrag/Totaal-kolom**: header en waardes rechts-uitgelijnd (`text-align: right`), tabular numerals. Header en cellen delen dezelfde right-padding zodat de eenheden onder elkaar staan.
- **Row height**: `min-height: 70px`. Genoeg lucht voor twee regels content (bv. nummer + datum, klant + adres) zonder dat één-regels-rijen krap ogen.
- **Row left-inset**: eerste kolom padding-left `15px`, matcht de A4-paper-inset (§ 3.3) zodat lijst en detail visueel dezelfde linkerlijn hebben.
- **Row hover**: `--surface-muted` (warm near-white). Geen border-change, geen transform.
- Row interactions: **single-click** opens a drawer; **double-click** opens the full editable detail; **right-click** opens a context menu (open / duplicate / share / delete).
- Stagger animation on mount (25ms per row, capped at 10).
- **Nummer + datum**: waar een lijst zowel een record-nummer als een datum heeft, staat de datum **onder** het nummer in dezelfde kolom (12px, `--ink-500`), niet in een aparte datum-kolom. Bespaart horizontale ruimte en houdt "identiteit van de regel" in één blok.



#### 🔒 Card overflow + filter dropdowns — gotcha rule

`.card` has `overflow: hidden` so children with backgrounds don't bleed past the rounded corners. **This clips any popover that needs to escape downward** — most commonly the filter-chip dropdowns or the "+ Filter" picker.

We've hit this bug twice (service board, offerte list). The styleguide now codifies the fix:

```css
.card:has(.filterbar),
.card:has(.toolbar--with-filters),
.card.card--overflow { overflow: visible; }
```

**Rules**:

- 🔒 Any `<div class="card">` that contains a `.filterbar` or `.toolbar--with-filters` automatically gets `overflow: visible`. This is the auto-fix and applies module-wide.
- 🔒 If you build a card with custom popovers that escape downward but no filterbar inside, opt in manually with `class="card card--overflow"`.
- 🔒 Don't re-introduce per-module modifiers (`svc-toolbar-only { overflow: visible }`, etc.) — use the shared rule.
- 🔒 Don't set `overflow: visible` on `.card` globally — the default clipping is what gives cards their clean rounded look when they contain backgrounded children (table heads, hover rows).

#### 4.5.1 Second weergave (board · day view) 🔒 toggle pattern

Some modules need a second weergave alongside the table. Service has **List ↔ Board** (kanban by status); planning has **List ↔ Day** (per-dag-gegroepeerde kaarten). The toggle pattern is the same in both:

- 🔒 Toggle lives inside the toolbar's `.toolbar__actions` cluster (§ 2.1), immediately left of the `+ Nieuw` button. Two icon-buttons in a `.svc-view-toggle` group (List icon · Board / Calendar icon), `aria-selected` on the active one.
- 🔒 The KPI row (if any), search, and filterbar are **shared** across both views. Only the body below swaps.
- 🔒 Click semantics from §6.1 carry over to whatever cards/columns the alternate view renders — single-click → drawer, double-click → editor, right-click → context menu.
- 🌿 The day view (planning) groups by date with a small `📅 5 mei 2026 · N afspraken` header above each group's stack of cards. Each card: status-coloured left bar, soort icon tile, plannummer + linked klant (blue), meta row (🕐 tijd · 📍 adres · 👤 hulpbron), right-side soort pill.
- 🌿 The board view (service) renders one column per visible status, with cards containing id + klant + onderwerp + label chips.

#### 4.5.2 Toolbar-only card variant 🔒

When a view doesn't have a bulk-content card (day view, board), keep the toolbar (search + filter chips) inside its own card so the toolbar position stays consistent across views; the content below sits free on the page bg. Modifier class (e.g. `.pln-toolbar-only`):

- Drops the `.toolbar`'s `border-bottom` (nothing follows in the card).
- Sets `overflow: visible` (so filter-chip dropdowns can extend below the card edge — `.card`'s default `overflow: hidden` clips them).
- Adds `margin-bottom: 12px` to space from the content below.

### 4.6 Items table inside the detail card

Custom grid (not a real `<table>`):

```
grid-template-columns: 18px minmax(140px, 2fr) 60px 80px 70px 100px
                       (drag) (omschrijving)    (qty)(prijs)(kort)(totaal)
```

**Tekstregels** override to 3 columns: `18px 1fr 100px` and span the description across what would be qty/price/discount. Visually identical row height; only the description input is italic.

**Groups** wrap their items in a rounded card with a header strip (drag handle, editable name, line count, subtotal). Empty groups show a centered dashed drop zone ("Sleep een regel hierheen"). Groups can be reordered; lines drag freely between groups and the top level.

The **add-row split button** sits below the items list (see § 4.1).

### 4.7 Side panel (accordions)

- Always-rendered DOM (icon strip + content), uses opacity/width transitions to expand/collapse.
- Default sections: Afspraken, Historie, Notities, Documenten.
- Each accordion has: icon + title + count badge + caret. Open animates with `acc-in` (`opacity + translateY(-4px → 0)`, 180ms).

#### Appointments

- Sorted: upcoming ascending (soonest first, with Eerstvolgend indicator) → past descending (recent first).
- Date pill: blue (`--accent-blue-soft` / `--accent-blue`) for upcoming, grey (`--surface-alt` / `--ink-500`) for past.
- The first upcoming appointment gets a small **green static dot** (`--accent-green`) in the top-right of its date pill, with a soft surface-ring around it.

#### History

- Reverse-chronological timeline. Each entry: dot + title + meta line.
- Entries can carry a `link` (e.g. order id `B260…`) which renders as a dashed-underline blue link.
- The system writes entries automatically: status change → "Status gewijzigd naar X"; order created → "Order aangemaakt — {orderId}".

### 4.8 Drawer (paper-quote)

A read-only, "paper preview" of the offerte that slides in from the right when a row is single-clicked. Same content/layout as the detail, but no editing affordances. **Color rules match the detail** — section labels in `--ink-900`, body in `--ink-700`, totals in `--ink-700`, grand total in `--accent-blue`.

The drawer has its own enter animation (`drawer-in`, 280ms cubic-bezier) and a separate overlay fade (`drawer-overlay-in`, 180ms).

### 4.9 Modals

#### Overlay

- `position: fixed; inset: 0;` with `background: rgba(245, 245, 252, 0.55)` + `backdrop-filter: blur(10px) saturate(140%)`. This is the **canonical light-glass** overlay — same effect on `.modal-overlay`, `.drawer-overlay`, `.catalog-overlay`. Don't introduce new dark-tinted overlays.
- Padding: `80px 24px 24px` 🔒 — the 80px top reserves clearance for the 56px topbar so modal heads never tuck under it. Modal card max-height tracks this: `calc(100vh - 104px)`.
- **Critical**: no ancestor of the modal-overlay may have `transform`, `filter`, `perspective`, `backdrop-filter`, `will-change` or `contain` set, or the overlay becomes containing-block-bound and stops covering the viewport. Use opacity-only animations for `.page` and `.detail-card`.

#### Card

- `--radius-lg` (12px), white, `box-shadow: 0 24px 70px rgba(13,30,80,.18), 0 4px 16px rgba(13,30,80,.06)`, default `width: min(640px, 100%)`.
- Wider variants: artikelkaart `720px`, send modal `680px`, order modal `620px`. Catalog overlay is its own oversized modal (`min(1200px, 100vw - 64px) × min(820px, 100vh - 112px)`).
- Enter: `modal-fade` + `modal-pop` (200ms with a tiny overshoot). Esc + overlay-click both close.

#### Head

Three slots: eyebrow + title (left) and an optional **head pill** (right).

- `.modal-card__head-link` — head pill, **destructive variant**: red-tinted hover for actions like "Loskoppelen". (Not currently used after the klantkaart move; reserved for genuinely destructive head actions.)
- `.modal-card__head-link--neutral` 🔒 — head pill, **non-destructive variant**: ink-neutral hover. Used for the "leave-this-modal-go-to-full-record" exit on klantkaart and artikelkaart modals.

  **Copy formule** 🔒: `Open [entiteit]` — one verb, one entity word, nothing else. Klantkaart → "Open klant". Artikelkaart → "Open artikel". Future modals follow the same pattern (order-preview → "Open order", leverancierskaart → "Open leverancier"). No "pagina" suffix, no "Bekijk" / "Ga naar" variants — always **Open**.

  **No icon** 🔒. The original design had a `↗` external-link arrow; it was removed because (a) the arrow misleads users into expecting a new tab (which this is not — same tab navigation) and (b) it joined a chorus of chevrons and arrows elsewhere on the same screen (`<` Terug in foot, dropdown chevrons in form, accordion chevrons in side panel) and added to the noise. The word "Open" carries the meaning; the visual restraint is intentional.

  Clicking dismisses the modal and navigates to the fullscreen module for that record. The modal is a tijdelijke wijzigings-context (changes apply to this offerte only); the fullscreen module is the permanente bewerk-context. The Open-link is how users escape from the first to the second.

#### Foot — three-zone pattern 🔒

The canonical foot is a **three-zone grid** (`.modal-card__foot--zones`, `grid-template-columns: auto 1fr auto`). The zones are always in the same place; an unused zone stays empty rather than reflowing the others.

```
[Terug]                         [Andere klant]  [Opslaan]
 .zone-back                      .zone-breaking  .zone-primary
 btn--back                       btn--breaking   btn--primary
```

- **Left — `.zone-back`** → `btn--back` labelled **Terug**. Ghost styling, never green or red. Closes the modal without committing. No chevron, no icon — just the word.
- **Middle — `.zone-breaking`** (optional) → `btn--breaking` for a destructive or branch action (e.g. `Andere klant`, `Verwijderen`, `Loskoppelen`). Neutral ink in rest, fills `--danger-soft` red on hover. Always paired with a verb label and a leading icon.
- **Right — `.zone-primary`** → `btn--primary` for the commit (`Opslaan`, `Versturen`, `Download`, `Order maken`, `Klant aanmaken`). Green. Disabled until the form is valid; shows the inline spinner during async work.

**Tab order**: Terug → Breaking (if present) → Primary. Focus rings are visible on all three via `:focus-visible` (blue on Terug, red on Breaking, green on Primary).

🚫 No `×` close-icon anywhere in the modal — Esc and overlay-click are the implicit exits, **Terug** is the explicit one.
🚫 Never two ghost buttons next to each other. Each zone has its own visual weight; don't downgrade Primary to ghost "to be calm".
🚫 Don't add a fourth slot. If a modal needs more actions, it's the wrong modal — split it.

If a modal has only **Terug** (informational / choice modals like "Er bestaat al een order"), keep the grid: render empty `.zone-breaking` and `.zone-primary` divs so the Terug button stays anchored bottom-left.

#### Confirm-style mini-modals

For "are you sure?" confirmations (no editing, just yes/no): 440px width, the same three-zone foot — **Terug** left, **Primary** right, middle empty.

### 4.10 Klant picker / Artikel search

#### 4.10.0 Inline combobox (artikel search inside the items table)

Combobox pattern with search input + dropdown.

- The dropdown does **not** auto-open on focus — only on click or first keystroke.
- Empty results (no matches) show: "Geen artikelen gevonden voor '…'." + a soft-blue pill action "Maak hier een tekstregel van".
- Pressing **Enter** with no matches does the same as clicking the empty-state action.
- Dropdown items cascade fade-in (22ms stagger, capped at 8).

#### 4.10.A Klantkaart — unified modal 🔒

A single `KlantkaartModal` handles three modes with the same chrome (head + body + foot):

- **search** — initial state when no klant is linked. Search input + recent-list + a prominent `+ Nieuwe klant aanmaken` row that switches to create mode.
- **view** — selected klant, fields are editable in place; foot shows `Annuleren` (left) + `⇄ Andere klant` (text) + `Opslaan` (primary).
- **create** — new-klant form; foot shows `Annuleren` (ghost) + `← Terug` (text) + `Klant aanmaken` (primary).

Picking a klant from search or saving a new klant **commits and closes** the modal. The "Andere klant" action clears the linked klant and drops back into search mode (one-click swap, never a two-step modal).

The head pill **Open klant** (`btn--text`, no icon) is **only** for opening the external full klant page (CRM). Do not use it to open the modal — that's the overview's job. See § 4.9 for the universele copy-formule "Open [entiteit]".

#### 4.10.B Klant card on the paper canvas 🔒 RULE — same in offerte / service / planning

The block on the detail page showing the linked klant uses **two affordances on hover**, identical across all three modules. Offerte is leading; service and planning mirror it exactly:

- **Klant bekijken pill** — `👁 Klant bekijken` (uses `I.Eye`) inside the card, top-right area. Opens the `KlantkaartModal`. **Always present** on hover, regardless of record state.
- **Remove × button** — circular 26×26 button at the absolute top-right corner of the card. Surface bg + `--border` + soft shadow at rest; on hover fills `--danger` red with white icon. Visible only on card hover. Mirrors the artikel-row remove pattern (× outside-right, hover-revealed). **Hidden when the record is in a closed state** (see § 6.3).

🔒 **One pattern, three modules.** Don't invent module-specific variants like `[Open klantkaart] [Deselecteren]` (older planning) or no-action read-only blocks (older service). Same two affordances, same icons, same labels, same hover behavior. Section label may differ semantically (`KLANT` on offerte/service, `ADRES` on planning) but the card affordances are identical.

The "open the modal" affordance (Eye) and "open the external page" affordance (`I.Open`, lives in the head pill of the modal) must always use **different icons**. Don't put `I.Open` on the overview pill — readers will think both mean the same thing.

#### 4.10.C Optional sub-form: afwijkend afleveradres

Inside the create-klant form, the shipping address is hidden behind a `+ Afwijkend afleveradres` toggle (`.modal-shipping-toggle`, see § 4.2). When expanded, a small `× Verwijderen` link returns to the collapsed state and clears the shipping fields.

### 4.10.1 Cross-module record link ("Betreft" picker) 🔒 pattern

When a record can be linked to a related record from another module (planning afspraak ↔ offerte/verkooporder/servicemelding; service melding ↔ verkooporder), use the same combobox shape as `OrderInlineField` / `BetreftInlineField`. Two states, both inline in a dd cell:

**Connected state** — a small wrapper (`.svc-order-chosen`) holds:

- The record id as an `<a>` link.
- A pen icon (`I.Edit`, 12×12, `--ink-400`) revealed on hover via `.svc-order-chosen__edit { opacity: 0 }` → `:hover .svc-order-chosen:hover .svc-order-chosen__edit { opacity: 1 }`. Clicking clears the link and re-opens the search.

**Empty state** — invisible-until-hover input (`.svc-order-search__field`):

- Placeholder copy adapts to context: "Zoek record van {klantnaam}…" when a klant is set, generic otherwise.
- Dropdown (`.svc-order-search__dropdown`) below: each result shows id (left, blue tabular-nums) · klant naam · type/date/summary meta. Klant-aware ranking: when no query, the current klant's records bubble to the top.
- Keyboard: ↑↓ to navigate, Enter to pick the highlighted (or first) match, Esc to close.
- Picking a record while the parent has no klant **adopts the record's klant** automatically. (Same behavior as service's `attachOrder`.)

**Two link visuals — pick by context:**
| Visual | Class | Use |
|---|---|---|
| Loud — dashed-blue underline | `.svc-orderref` | When the link is the most important value in its row (service: "Origineel order"). Color: `--accent-blue` with a `border-bottom: 1px dashed`. Hover swaps both to `--ink-900`. |
| Quiet — plain text | `.pln-betref-link` | When the link sits in a meta column with other plain values and shouldn't out-shout them (planning: "Betreft" inside the Afspraak meta-grid). Color: `--ink-700` / `font-weight: 500` / no underline. Hover: `--ink-900` + faint `--ink-300` underline. |

The hover-revealed pen icon next to the link is the affordance in both cases; the link visual is the only difference.

### 4.10.2 Artikelkaart — unified line modal 🔒

A single `ArtikelkaartModal` handles **every** line, configurable or not. Roughly 70/30 split:

- **70% common** (always visible): small product image (left, ~180px square `--surface-alt` tile), Aantal stepper, Stuksprijs, Korting (editable %), Totaal (**editable** money input that back-computes korting), Specificaties list (`Leverancier`, `Materiaal`, …). The artikelnummer lives in the modal-card eyebrow; the name in the title.
- **30% conditional** (only for `isConfigLine`): a `Configuratie` section with three `FormSelect`-style native selects — Stof / variant, Kleur (with a color-dot prefix when picked), Onderstel.

Reached by double-clicking a line on the items table. Foot follows the canonical 3-button pattern (§ 4.9): `Annuleren` · `🗑 Verwijderen` (text) · `Opslaan` (primary). Head pill: `↗ Bekijk artikel`.

### 4.10.3 Send / Download / Order modals 🔒

Three sibling modals sharing a `LayoutSelector` (a `FormSelect` over the `LAYOUTS` constant).

**DownloadModal**

- Small (default 640px width).
- Layout selector (`.send-layout__head` "Layout" with `I.Sliders` icon).
- Bestanden section: per-file toggleable chips (see below). The offerte PDF chip itself is also toggleable.

**SendModal** — small email editor (680px width):

- `Aan` (pre-filled from `quote.customer.email`) with a `+ CC` reveal link.
- `Onderwerp` (pre-filled `Offerte ${id} · ${klantnaam}`).
- `Bericht` — multi-line textarea pre-filled with a friendly template that pulls verkoper name + quote id.
- `Bijlagen` — list of toggleable chips (offerte PDF + uploaded files), individually deselectable.
- `Layout` — same selector as Download, sitting just above the foot.
- Foot: `Annuleren` (ghost) + `Versturen` (primary, disabled until `Aan` and `Onderwerp` are non-empty).

**Toggleable attachment chip** (`.send-chip`) 🔒 pattern

- Active: solid border, `--surface-alt` bg, `--ink-700` text, `×` icon on the right.
- Inactive: dashed border, `--surface` bg, `--ink-400` text, `+` icon on the right.
- Click toggles. Reused by both the Send and Download modals — never wire two parallel patterns for the same "include / exclude this file?" decision.

**OrderModal**

- Reached from the order-icon at the top of the offerte detail OR from the auto-prompt when status flips to Definitief OR from the context menu / drawer.
- **Artikelen** section: a card listing every product line. The first row is a **tri-state header** (checkbox + `X / Y` count, no label) that selects/deselects all. Tekstregels and groepen are filtered out — they don't go to the supplier.
- **Voorwaarden** section: three `FormSelect`s with leading icons — `📅 Leverweek` (full width), `🚚 Levermethode` + `€ Betaling` (two columns). Pre-filled from the quote, editable for the order only.
- If `quote.supplierNotes` exists, a small `--surface-alt` panel labelled "Leveranciersopmerking" with `I.Truck` icon shows below the voorwaarden so the user sees what'll go out.
- Foot: `Annuleren` + `📄 Order maken` (primary, disabled until at least one line is selected).
- On confirm, patches the quote with `{ orderId, orderDate, orderStatus: "Open" }` plus any voorwaarden overrides and writes a richer history entry (`Order aangemaakt — door … · X regels`).

### 4.10.4 Opmerkingen — Klant / Leverancier tabs 🔒 pattern

Quotes carry **two** opmerkingen fields:

- `customerNotes` — sent in the offerte PDF to the klant.
- `supplierNotes` — used when the offerte is sent to a leverancier as an order; **replaces** the customer note in that channel.

Render as a single textarea with a subtle tab-pair on the right of the section head:

- `👥 Klant` / `🚚 Leverancier` — `font-weight: 400`, `--ink-400` inactive, `--ink-900` active with `--surface-alt` bg, `--radius`.
- A 6px `--accent-blue` dot appears on the inactive tab when its content is non-empty, so the other channel is never hidden from view.
- Switching tabs swaps the textarea while keeping both values intact.

### 4.11 Topbar (above main content) 🔒 RULE

A 56px-tall sticky bar at the top of the main column (`grid-area: topbar`).

- Contains the **Assist button**, centered: rounded pill with the Assist icon, label "Assist", and a `⌘K` keyboard hint.
- Assist is purple/pink-tinted via `--assist-gradient` — that's intentional: it's the **only place a non-brand color appears prominently**. It signals "AI / off-brand utility". 🔒 **This gradient is reserved exclusively for AI features. Never use purple or pink outside an AI affordance**, and never introduce them as brand colors.
- The 3px gradient bar at the very top is rendered above the topbar (`.app::before`, `position: fixed`).

🌿 The Assist trigger may evolve (different shortcut, different icon set), but its placement and visual weight do not.

### 4.12 Sidebar — module navigation 🔒 RULE structure / 🌿 GUIDELINE labels

Vertical list of modules. Each item: icon + label, ~14px gap, 13px text in DM Sans (`--font-sans`). Active module = magenta-soft background + magenta left-border + magenta icon (this is a deliberate exception to the "magenta-not-as-text" rule — it's nav state, not body text).

Order in current build (Verkoop active):
**Dashboard · Artikelen · Relaties · Inkoop · Verkoop · Logistiek · Magazijn · Planning · Facturering · Service · Inzichten**

Top: brand block (LT logo via `lt-logo.svg`). Bottom: "Inklappen" toggle that collapses the sidebar to a 56px icon-only strip. Both states are always rendered in the DOM (transitions via opacity/width).

> 🌿 When adding a new module to the sidebar, slot it semantically among the existing items. Don't reorder existing entries.

### 4.13 Page header (above the page body) 🔒 RULE

Standard composition for every overview / list page:

```
[Page title (H1)] [count subtitle, --ink-500]                [+ Nieuw (primary)] [⋯]
```

- Title: 22–24px, weight 700, `--ink-900`.
- Subtitle: small, `--ink-500`. For overviews, render the live count ("24 offertes").
- Primary CTA ("+ Nieuw"): green, top-right.
- Overflow menu (`⋯`): icon button. Standard items in current build:
  - Exporteren
  - Prullenbak
  - Nieuwe weergave (with a toggle — saves the current filter/sort as a named view)

🌿 The overflow items adapt per module. Keep Exporteren if there's data worth exporting. Skip Prullenbak if the module doesn't soft-delete. Skip Nieuwe weergave if the overview has no filter/sort worth saving.

### 4.14 Detail-page action bar 🔒 RULE

The strip above the offerte-paper canvas. Layout:

```
[← Offertes (back crumb)]                       [⬇] [🔗] [📄] [Versturen] [⋯]
```

- **Back crumb** (left): ghost button with arrow-left icon + "Offertes" label.
- **Icon-ghost cluster** (right, before primary): Download · Kopieer link · Order maken. Icon-only 36×36 with tooltips. Separated from the primary CTA by an 8px gap.
- **Primary CTA** ("Versturen"): green, **no icon by design**.
- **Order-maken state**: when an order has been generated from this record, the "Order maken" icon button switches to `.btn--icon-ghost--made` (green tint + small green dot in top-right of the button) and its tooltip changes to the order id.
- **Overflow** (`⋯`): Dupliceren · Verwijderen (in danger style).

🌿 Per module, the icon-ghost cluster adapts (e.g. "Goederen ontvangen" instead of "Order maken" for Inkoop). The Versturen CTA can be relabeled per module ("Verzenden", "Plaatsen") but stays green and stays primary.

### 4.15 Drawer view — quick actions pattern 🔒 RULE

The view drawer (`.drawer--view`) shows a **read-only paper preview** of a record and has **no header strip and no footer**. The drawer hides its own `.drawer__head` and pads the body so the paper sits directly under the topbar. All record-level actions live in a **quick-actions menu** anchored to the paper's top-right corner.

This pattern is shared across every module's view drawer (offerte, service, planning, and every module added after). The shape is a rule; the action set is partly a rule and partly a guideline — see § 13.X.

#### Anatomy

The paper's top-right corner (`.paper-quick-actions`) contains, in order:

1. **Assist trigger** — a `btn--icon-ghost` with a sparkle icon. Dispatches a `assist:open` `CustomEvent` carrying the record context (`{ context: "Offerte O260… — Amsterdam winkel", quoteId }`). Topbar's Assist listener picks it up and opens the panel pre-loaded with that context. Always present, regardless of module.
2. **Quick-actions trigger** — a `btn--icon-ghost` with the **hamburger icon** (three horizontal lines, `strokeWidth=1.75`). `aria-haspopup="menu"`, `aria-expanded` bound to open state, `aria-label="Snelle acties"`, `data-tooltip="Snelle acties"`. The active state (`.is-active`) is set while the menu is open.
3. **Status pill** — the same pill component used on the detail (e.g. `.pill--niet` / `.pill--definitief`). Non-interactive in the drawer.

Visual order on screen: status pill, then the two icon-ghost buttons, all gap 8px. CSS flexbox order: `pill = 1`, `paper-quick-actions = 2`.

#### Opening the menu

The menu is triggered by any of:

- Mouse click on the hamburger button.
- `↓` keydown on the hamburger button while menu is closed.
- **`Space` keydown** anywhere in the drawer body (not in a control, not in an input/textarea/contenteditable, not while a modal or the menu itself is already open).

The third route — Space — is the keyboard equivalent of "single-click the paper to see actions". It mirrors the list-level shortcut where Space on the active row opens the drawer.

#### The panel

`.quick-actions-panel` sits **centered over the paper** (translate-50%-50%) at z-index 30, above the glass overlay (z-index 20). It's a transparent column of `.a4-btn` rows — no card chrome, no shadow, no border-radius on the panel itself. The glass behind it provides the visual separation. Width is `max-content`, capped at `calc(100% - 64px)`.

Animation: `quickActionsPanelIn` — 240ms, `cubic-bezier(.2,.8,.2,1)`, fades in while scaling from `.98` to `1`.

#### Action items (`.a4-btn`)

Each menu item:

- Display: flex row, gap 16, padding 14px 18px.
- **Icon** — 22×22, `--ink-500` by default, `--accent-blue` on `:focus-visible` of a non-danger item.
- **Title** (`.a4-btn__title`) — 14px, weight 700, `--ink-900`, letter-spacing `-0.01em`.
- **Sub** (`.a4-btn__sub`) — 12px, `--ink-500`. One short sentence explaining what the action does ("Open de editor en pas alles aan", "Mail direct naar de klant"). Not optional — the sub-line is part of the discoverability promise of this menu.
- `:hover` — `rgba(7,27,85,.045)` background.
- `:focus-visible` — `accent-blue` 7% bg + 1.5px inset `accent-blue` 35% ring + 10px radius. The radius is the only place a `.a4-btn` shows rounded corners.
- Variants: `.a4-btn--primary` (no extra style at rest — the visual primacy is communicated by ordering, not chrome), `.a4-btn--danger` (`--danger` text + icon, red-tinted hover and focus-visible).

`.a4-divider` is a 1px line in `rgba(7,27,85,.08)` with 4px vertical margin — used between groups (typically before the danger action).

#### Action set — what to include 🔒 RULE for shape, 🌿 GUIDELINE for content

**Always (RULE)**:

- **Bewerken** is the first item. Always present, always with the same copy ("Bewerken" / "Open de editor en pas alles aan"). It's the keyboard `Enter` equivalent in menu form.
- **The detail page's primary CTA** is the second item, mirrored verbatim (offerte: "Versturen" / "Mail direct naar de klant"). Same icon, same label.
- **Verwijderen** is the last item, separated by an `.a4-divider`, styled with `.a4-btn--danger`. Triggers the paper-delete animation (§ 5.X) before the toast fires and the drawer closes.

**Module-specific (GUIDELINE)**:

- 1–3 additional actions between the primary CTA and the danger divider. Pick the actions most often performed on this record type. Offerte uses: `Order maken` (or `Verkooporder B260…` when an order exists — context-aware label), `Dupliceren`. Service / planning currently mirror with `Download`, `Kopiëren`.
- **Context-aware action label** 🔒 pattern: when a downstream record exists, the action's title becomes the downstream record id ("Verkooporder B260…") and its sub explains the relationship ("Aangemaakt 12-02 · Open"). Clicking opens / focuses that record instead of re-triggering creation.

**Maximum 6 items total** (including Bewerken, primary CTA, and Verwijderen). Beyond that the panel reads as a generic "more" menu and the discoverability advantage of the title+sub layout breaks down. If a module needs more, it needs a different surface (the detail page action bar, a side-panel section, a context menu) — not a longer quick-actions menu.

#### Closing the menu

- Mouse: click on the `.quick-actions-glass` (the overlay behind the panel), click outside both button and panel, or click any menu item.
- Keyboard: `Escape` closes the menu (and only the menu — the drawer stays open). The hamburger button regains focus.
- Implicit: any quote/record id change auto-closes the menu via the effect that depends on `[quote.id]`.

After every close path, focus restoration is mandatory — see § 6.10.

### 4.16 Toast notifications 🔒 RULE

- **Position**: bottom-right of the viewport.
- **Stack**: vertical, newest on top.
- **Style**: white card, soft shadow, 12px radius, 13px text. Optional small icon prefix.
- **Animation**: `toast-in` (200ms slide + fade).
- **Auto-dismiss**: 3–4s.
- Triggered by every meaningful mutation: line added/removed, status changed, file uploaded, copied to clipboard, sent.

🌿 **Copy guideline**: short, past-tense, confirming. "Regel verwijderd" not "Regel succesvol verwijderd!". No emojis. No "✓" prefix.

### 4.17 Lijst-laadstructuur 🔒 RULE

We use **no pagination, no page numbers**. Lists load incrementally as the user scrolls — one continuous scroll surface with a footer-strip that tracks position in the total set. The pattern is identical across every module's overview (offerte, service, planning, klant, artikel) so users learn one scrolling behavior.

#### Het gedrag in één scherm

- **Eerste batch**: 50 records bij openen
- **Auto-load trigger**: scroll bereikt 80% van de scroll-positie → laadt volgende 50 op de achtergrond
- **Throttle**: max één laad-actie per 600ms — voorkomt dat snel scrollen drie requests tegelijk afvuurt
- **Voet-strip altijd zichtbaar** onderaan de lijst: `50 van 247 offertes getoond`, met optionele "Laad meer"-knop in dezelfde voet (ghost-stijl) als fallback voor wie liever klikt of bij scroll-detectie-failures
- **Skeleton-strook van 3 rijen** verschijnt onderaan terwijl de volgende batch laadt — geen onderbrekende spinner. Zie de Loaders & empty states sectie van de design system site voor de skeleton-rij anatomie.
- **Virtualisatie aan** bij meer dan 100 records in de DOM — alleen viewport-rijen (+ overscan van ~10) worden echt gerenderd. Onder de drempel is virtualisatie overhead zonder winst.

#### De reset-regel

🔒 Bij filter, sort of search reset het zichtbare aantal naar de eerste 50. Zonder reset blijf je hangen op een eerdere batch en lijkt filteren stuk omdat het laad-aantal niet aansluit op het nieuwe filter-resultaat. Implementeer dit via een `useEffect` op de gefilterde lijst die `shownCount` terugzet naar `batchSize`.

#### Implementatie

🔒 Library: `@tanstack/react-virtual` (modern, React 18 native, variable row heights). Niet `react-window` (ouder) tenzij er een specifieke reden is.

🔒 Hook-naam: `useBatchedList(allItems, batchSize = 50)` in `shared/hooks.jsx`. Zie de Loaders & empty states pagina voor het complete code-voorbeeld.

🔒 Scroll-event: `{ passive: true }` op de listener — anders krijg je scroll-jank op trackpads.

🔒 React-keys op de items: gebruik `key={record.id}`, **nooit** `key={index}` — die laatste breekt reconciliation bij filter/sort.

#### Wat we niet doen

- 🚫 **Geen pagina-nummers** (`[1] [2] [3]`), geen `<` `>` chevrons voor paginatie, geen page-size dropdown.
- 🚫 **Geen IntersectionObserver-sentinel** voor de auto-load trigger. Scroll-percentage is robuuster en speelt beter samen met virtualisatie.
- 🚫 **Geen oneindige scroll zonder voet-strip.** De strip is essentieel — voorkomt het "vallende-bodem" gevoel en geeft positie-context in de totale set.
- 🚫 **Geen drempel-strategie** ("klein = render alles, groot = virtualiseer"). Eén patroon voor alle lijstgroottes — schaalt van 8 records (lijst korter dan de voet-strip is overhead, maar acceptabel) tot 8000 (virtualisatie houdt de DOM constant).
- 🚫 **Geen `Loading...` tekst.** Skeleton-rijen voelen vloeiend; tekst-spinners breken de scroll-ervaring.

### 4.18 Order-made indicator 🔒 RULE

In a quote-overview list, rows whose quote has been converted to an order get a small navy-filled circle with a white check, placed immediately to the right of the status pill. Clicking the indicator opens a tooltip + modal preview of the linked order.

#### Anatomie

```
[Niet definitief]  ⬤  ← navy circle (ink-900), white check inside
[Definitief]       ⬤
[Niet definitief]      ← no order made; no indicator
```

- **Shape**: 20px circle, fully filled in `--ink-900` (navy), 10px white check inside, stroke-width 2.2, stroke-linecap/linejoin round
- **Position**: 8px gap right of the status pill, same baseline-alignment as the pill
- **Hover**: background fills `--accent-blue` (the link-color in the system) — couples visually with the fact that clicking leads to another record. Geen border, geen size-verandering.
- **Cursor**: pointer. The element is interactive.
- **Tooltip on hover**: `Verkooporder {orderId}` (e.g. "Verkooporder B2600510")
- **Click**: opens the order preview modal

#### Wat we expliciet niet doen

- 🚫 **Niet groen** — al was groen het reflex (success-kleur). De order-check is geen status van de offerte maar een **relatie** met een ander record. Groen zou semantisch botsen met de "Definitief"-pill (ook groen) en suggereren dat de offerte zelf in een succes-staat is. Navy is neutraal: het claimt geen status-betekenis, alleen "er is iets dat hier verbonden is".
- 🚫 **Geen outline-cirkel of soft fill.** Geprobeerd, te onopvallend voor de 60-80% van rijen waar deze indicator naar verwacht wordt te verschijnen — gebruikers scanden er overheen.
- 🚫 **Geen "Order"-label naast de check.** Geprobeerd als pill-vorm met tekst; teveel ruimte voor wat een impliciete relatie is. Het icoon-alleen werkt; de tooltip + modal geven de details.
- 🚫 **Niet in de pill zelf integreren** (bv. "Definitief · Order"). De pill is de hoofdstatus van de offerte; de order-check is afgeleide info. Visueel onderscheid is belangrijk om hiërarchie te behouden.

#### Paper-first redenering

De navy-fill voelt als een **inkt-stempel op papier** — past bij de paper-first design taal in de rest van het systeem. Geen UI-element dat aandacht claimt, gewoon notatie. Een handgeschreven afvinking in inkt, niet een marker met highlight-kleur.

### 4.19 Empty-state placeholder pattern 🔒 RULE

For sections / modules that don't yet have real functionality (e.g. the "Catalogus" modal in current build), render a centered placeholder:

- Large outlined icon (Box / Truck / Doc / etc.) in a soft-blue circle, ~48px.
- Title in `--ink-700`, weight 600 ("Dit is de catalogus module").
- Helper text in `--ink-500`, italic-friendly tone ("Hier kun je straks…").
- Single ghost "Sluiten" or back action.

This is the canonical "future feature" treatment. Don't ship a feature with a half-built UI; ship the placeholder until it's real.

### 4.20 Loaders & contentful empty states 🌿 see design-system site

Skeleton primitives (`.skeleton`, `.skeleton--text`, `.skeleton--title`, etc.), skeleton composities (lijst-skeleton, paper-skeleton, KPI-skeleton), inline spinners, progress-strook, en de **zes contentful empty states** (first-use, filtered, resolved, no-access, error, offline) leven niet in deze file. Ze worden visueel onderhouden op de design system site onder **Loaders & empty states**: <https://design.logictrade.software/logictrade/app.html#design-loaders>.

🔒 Twee regels die hier wel vastliggen:

- **Skeleton verschijnt pas na 400ms** loading-tijd. Onder 400ms tonen we niets — vorige content blijft staan. Voorkomt geflits bij snelle responses.
- **Skeleton blijft minimaal 500ms zichtbaar** zodra hij verschenen is. Voorkomt geflikker als de data net na het verschijnen toch binnenkomt.

Beide regels worden afgedwongen via de `useDelayedLoading(isLoading, delay = 400, minDuration = 500)` hook in `shared/hooks.jsx`.

🔒 Skeletons voor _loading_; illustrated empty states (de zes hierboven) voor _resultaat-zonder-content_. Niet door elkaar gebruiken — een filter-resultaat-met-nul-items toont geen skeleton (er valt niets te laden), maar een filtered-empty illustratie.

### 4.21 History timeline 🔒 RULE

Every record's history appears as a vertical timeline in the side panel (and full-screen in the detail-page history view). The pattern is shared across all modules — offerte, service, planning, klant, order. § 13.9 covers _what to log_; this section covers _how to render it_.

The flat event log we started with — every change rendered identically in a chronological lijst — leest na een paar maanden als spam: een micro-edit ("Omschrijving aangepast") krijgt evenveel visueel gewicht als een mijlpaal ("Verkooporder aangemaakt"), en geen visuele structuur tussen sessies. Drie ingrepen lossen dat samen op.

#### Anatomie

Vertical rail with dots, optional connecting line tussen dots, sticky tijds-headers ("Vandaag", "Gisteren", "Eerder").

```
─── Vandaag · 20 mei ─────────────────
 ●  Verkooporder V26000165 aangemaakt      14:32
 │  door Lisa Olsthoorn
 │
 ○  Lisa Olsthoorn deed 3 wijzigingen      14:15 – 14:18
 │  klant · onderwerp · omschrijving  ⌄
 │
 ○  Lisa Olsthoorn voegde 3 servicelabels toe  11:38 – 11:42
 │
─── Gisteren · 19 mei ────────────────
 ○  Status gewijzigd naar "Klaar om te plannen"  16:48
```

#### Hiërarchie — milestones vs wijzigingen 🔒

Niet elk event is gelijk. Twee gewichten:

- **Milestones** — de momenten die ertoe doen: aangemaakt, verstuurd, geaccepteerd, order gemaakt, geannuleerd, verlopen. Krijgen een **gevulde dot** in een kleur die past bij het type event (zie hieronder), title in 13.5px/600 `--ink-900`, een meta-regel met `door {auteur}`.
- **Wijzigingen** — de aanpassingen onderweg: label toegevoegd, omschrijving aangepast, status veranderd, klant gekoppeld. Krijgen een **kleinere outlined dot** in `--ink-300`, title in 13px/regular `--ink-700`, geen auteur-regel (op een bundel na — zie hieronder).

Milestone-dot kleuren (drie categorieën, **niet** acht):

- `--accent-blue` — neutrale milestones: aanmaken, openen, status-progressie
- `--accent-green` — positieve afronding: order gemaakt, geaccepteerd, voltooid
- `--accent-magenta` — problemen: geannuleerd, verlopen, afgewezen

🚫 Geen kleur-per-actie-type zoals in backoffice. Acht verschillende kleuren in één lijst wordt visuele soep. Hiërarchie wordt nu gedragen door **grootte en gewicht** (gevuld vs outline, vet vs regular), niet door rainbow-color-coding. De drie milestone-kleuren zijn functioneel: positief / neutraal / problematisch.

#### Tijdgroepering 🔒

Sticky tijds-headers ("Vandaag", "Gisteren", "Eerder", later evt. maand-headers bij langere historie). Geeft direct gevoel waar je in de tijd zit zonder dat je een datum bij elke entry hoeft te lezen. De headers zijn 10.5px/600 `--ink-400` uppercase met 0.1em tracking, sticky bij scroll.

#### Bundeling van wijzigingen 🔒

Zelfde auteur + binnen 30 minuten + zelfde _type_ wijziging → één rij. "Lisa deed 3 wijzigingen · klant · onderwerp · omschrijving · 14:15 – 14:18". De meta-regel toont de namen van de gebundelde items (afgekapt bij ~4: "klant · onderwerp · omschrijving · +2 meer"). Klik op de bundel opent een ingeklapte detail-lijst eronder.

🔒 **Milestone-events breken de bundel altijd.** Als er een verkooporder aangemaakt is tussen twee wijzigingen, is het geen sessie meer maar twee aparte momenten — toon ze als losse rijen rondom de milestone.

🔒 **Verschillende wijzigings-types binnen 30 min bundel je niet samen.** "3 wijzigingen" (klant + onderwerp + omschrijving) is één bundel; "3 servicelabels toegevoegd" is een aparte bundel. Voorkomt dat "ik deed 8 willekeurige dingen" één blob wordt waar je niets meer aan hebt.

🔒 **Default state**: bundel dicht. De meta-regel ("klant · onderwerp · omschrijving") verraadt al wát er gebundeld zit zonder dat je hoeft uit te klappen. Default open zou de overview-blik kapot maken.

#### Auteur tonen — wanneer wel, wanneer niet 🔒

- **Milestones**: altijd `door {auteur}` als meta-regel onder de title. Wie deze offerte verstuurd of order gemaakt heeft is relevant.
- **Bundels**: `{auteur} deed N wijzigingen` als title-bestanddeel. De bundel-aanleiding zit in de auteur.
- **Losse wijzigingen**: **geen** auteur tonen. Het is altijd "de gebruiker die in deze offerte zat", die info voegt niets toe en maakt elke rij onnodig zwaarder.

🚫 Nooit "door logictrade" tonen — dat is een implementatie-detail (system-author voor automatische events), geen leesbare attributie.

#### Wat we expliciet niet doen

- 🚫 Geen kleurig hokje per gebeurtenis-type (rainbow).
- 🚫 Geen iconen per event-type. De dot + de label-tekst zijn genoeg; iconen zouden de rust breken.
- 🚫 Geen relatieve tijden ("3 uur geleden") als enige tijds-aanduiding — gebruik concrete tijd ("14:32") binnen een dag, datums voor oudere events. Relatief leest leuker maar wordt onbruikbaar zodra je iets specifieks zoekt.
- 🚫 Geen "Load more" om oudere events op te halen. Historie volgt dezelfde lijst-laadstructuur als § 4.17 — auto-load on scroll met voet-strip.

---

## 5 · Motion & animation

The system is **subtle**. We're not Pinterest; we're an ERP. Animations should reward attention without demanding it.

### 5.1 Easing & duration

| Use                                    | Duration  | Easing                                          |
| -------------------------------------- | --------- | ----------------------------------------------- |
| Tiny state changes (color, bg, border) | 120–180ms | `ease`                                          |
| Hover lift / shadow grow               | 150–180ms | `ease`                                          |
| Active press                           | 120ms     | `ease`                                          |
| Entrance fades                         | 280–420ms | `ease-out`                                      |
| Entrance with translate                | 200–260ms | `cubic-bezier(.2,.7,.3,1)`                      |
| Modal pop                              | 200ms     | `cubic-bezier(.2,.7,.3,1.2)` (slight overshoot) |
| Drawer slide-in                        | 280ms     | `cubic-bezier(.2,.7,.3,1)`                      |
| Accordion open                         | 180ms     | `ease`                                          |

### 5.2 Entrance choreography

| Surface                           | Animation                                                                         |
| --------------------------------- | --------------------------------------------------------------------------------- |
| Page mount (`.page`)              | Opacity-only fade, 300ms ease-out                                                 |
| Offerte paper (`.detail-card`)    | Opacity-only fade, 420ms ease-out                                                 |
| KPI cards                         | `enter-up` (10px translate + opacity), 350ms, **60ms stagger** across the 4 cards |
| Table rows (`.lt-table tbody tr`) | `enter-up`, 260ms, **25ms stagger** capped at 10 rows                             |
| Klant / artikel dropdown items    | `enter-up`, 200ms, **22ms stagger** capped at 8                                   |
| Status pill on first render       | `pop-soft` (96 → 102 → 100% scale), 260ms                                         |

> **Important constraint**: animations that include `transform` keyframes on an element make that element a containing block for `position: fixed` descendants — modals will then clip to it instead of the viewport. **Therefore `.page` and `.detail-card` (any ancestor of a modal) animate with `opacity` only.** Other elements (cards, rows, items) can use `enter-up` because they don't host modals.

### 5.3 The new-item highlight

When a new line, group, or tekstregel is added, it gets the `is-new` class, which runs a `pulse-new` keyframe: `background: --accent-blue-soft → transparent` over 900ms. CSS-driven, fires once, no JS cleanup needed.

### 5.3.1 The paper-delete animation 🔒 RULE

When a record is destructively deleted from inside a view drawer (Verwijderen action in the quick-actions menu), the paper plays the `paperQuoteOut` keyframe before the toast fires and the drawer unmounts. **Every paper-view in the system uses this same animation** — offerte, service, planning, and any future module's view drawer.

```css
@keyframes paperQuoteOut {
  0%   { opacity: 1; transform: translateX(0)    rotate(0)       scale(1); }
  10%  { opacity: 1; transform: translateX(-6px) rotate(-1.2deg) scale(1); }
  22%  { opacity: 1; transform: translateX(6px)  rotate(1.2deg)  scale(1); }
  34%  { opacity: 1; transform: translateX(-4px) rotate(-.8deg)  scale(1); }
  46%  { opacity: 1; transform: translateX(4px)  rotate(.8deg)   scale(1); }
  55%  { opacity: 1; transform: translateX(0)    rotate(0)       scale(1); }
  100% { opacity: 0; transform: translateX(0)    rotate(0)       scale(.85); }
}
.drawer--view .paper-quote--deleting {
  animation: paperQuoteOut 520ms cubic-bezier(.2,.8,.2,1) forwards;
  transform-origin: center center;
  pointer-events: none;
}
```

Shape: a short "no" head-shake (4 alternating tilts over 55%), then fade-and-shrink to 85% over the remaining 45%. Duration is 520ms — calibrated so it lands between "too short to register" (≤350ms) and "this is starting to feel slow" (≥650ms). `pointer-events: none` during the animation so the user can't re-trigger the action on the disappearing paper.

🔒 The toast must fire **after** the animation completes, not before. Use `setTimeout(() => { toast("…"); onClose(); }, 520)` or equivalent. Firing the toast immediately on action breaks the cause-effect rhythm — the paper hasn't visually left yet, but the system already claims success.

🔒 Use this animation **only** for destructive deletes triggered from a view drawer. Don't reuse it for "close drawer", "navigate away", or "soft archive" — the head-shake reads as rejection / refusal, which is the exact emotional register we want for "this record is gone".

### 5.4 Hover micro-lifts

- KPI cards: `translateY(-2px)` + shadow grow.
- Pills (`.pill`): `translateY(-1px)`.
- Icon-ghost buttons: `translateY(-1px)` + soft glow.

### 5.5 Active-state press

`transform: scale(0.97)` on `:active` for buttons, chips, KPI cards, toolbar buttons. One line, applied across the cross-cutting selector list.

### 5.6 The "spin-on-hover" plus

`.add-row:hover > svg { transform: rotate(90deg); }` — the `+` rotates 90° when you hover the dashed CTA. Tiny detail; keep it.

### 5.7 Reduced motion

```css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    transition-duration: .001ms !important;
  }
}
```

---

## 6 · UX rules & flows

### 6.1 Row interaction — click & keyboard semantics 🔒 RULE

Every overview list in the system supports both pointer and keyboard for the same three actions. Mouse and keyboard are equivalents, not alternatives — they trigger the same handlers.

| Action                      | Mouse                   | Keyboard (active row)                                  |
| --------------------------- | ----------------------- | ------------------------------------------------------ |
| Move active row             | Mouseover (visual only) | `↑` / `↓`                                              |
| Open drawer (paper preview) | Single-click            | `Space`                                                |
| Open detail (editor)        | Double-click            | `Enter`                                                |
| Open context menu           | Right-click             | _(no shortcut — context menu is mouse-only by design)_ |

The active row is marked with `.is-keyboard-active` and is scrolled into view (`scrollIntoView({ block: "nearest" })`) when it changes. The active index resets to 0 whenever the visible row set changes (search, filters, sort, pagination).

🔒 Suppress the global keyboard handler whenever any of these is true: a modal is open, a context menu is open, a drawer is open, a klantkaart/artikelkaart modal is open, or the event target is `INPUT` / `TEXTAREA` / `SELECT` / `contentEditable`. Also ignore when `metaKey`, `ctrlKey`, or `altKey` is held — those compositions belong to the OS or browser, not us.

This handler lives at the list level (`offerte-list.jsx` is the canonical reference). Service and planning lists mirror the exact same shape; do not re-implement per module.

### 6.2 Status changes

Changing status to **Definitief** triggers a "Order maken van deze offerte?" modal asking the user if they want to convert immediately. Two actions:

- **Nee, alleen status wijzigen** (ghost) — closes.
- **Ja, order maken** (primary) — closes and creates the order.

Each transition writes to history automatically: `Status gewijzigd naar {X}` with the seller name.

### 6.3 Read-only mode (closed status) 🔒 RULE — same across modules

A record is in **read-only mode** when its status is the module's closed/terminal one:

- **Offerte**: `status === "Definitief"`
- **Service**: melding `status === "Gesloten"`
- **Planning**: afspraak is `Afgerond`

When read-only, the same kinds of mutations are disabled across all three modules. Offerte's table is the canonical reference — service and planning mirror the rules:

| Disabled                                                                                                             | Still allowed                                                                                      |
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Inline editing of any field on the paper (qty/price/discount, dates, seller, reference, herkomst, voorwaarden, etc.) | Sending / forwarding the record (Versturen)                                                        |
| Adding lines / tekstregels / groups                                                                                  | Adding notes & opmerkingen (when the field is intended to be editable post-close — module decides) |
| Removing/dragging lines or groups                                                                                    | Toggling status back to open                                                                       |
| Klant change / deselect (× corner button is hidden)                                                                  | Opening the klantkaart (view-only — see below)                                                     |
| Klantkaart modal mutations (edit fields, "Andere klant" swap, "Verwijderen")                                         | Generating downstream records (e.g. order from offerte)                                            |
| Artikelkaart modal mutations (qty stepper, korting, totaal-back-compute, configuratie selects, "Verwijderen")        | Viewing files / artikel detail / klant detail                                                      |

🔒 **Modal read-only behavior** — when a parent record is closed, both `KlantkaartModal` and `ArtikelkaartModal` accept a `readOnly` prop. In that mode:

- All inputs render with `readOnly` / `disabled`.
- The footer collapses to a single right-aligned **`Sluiten`** ghost button — `Annuleren`/`Opslaan`/`Verwijderen`/`Andere klant` are hidden.
- Mode-switching (search → create) is locked out for the klantkaart; the modal stays in `view` mode.

Visual signal on the paper: read-only cells/inline editors get `cursor: not-allowed`. Clicking one fires a contextual toast ("{record-type} is {closed-status} — niet bewerkbaar"). **No banner** at the top — the cursor + toast pattern is enough.

### 6.4 Adding lines

- **+ Regel toevoegen** is a split button: clicking the main half opens an inline product search; the chevron half opens a menu with **+ Tekstregel** and **+ Groep**.
- Tekstregel is a free-form description + price (e.g. `Arbeid — € 20,00`). No SKU, qty, or discount.
- Groups are a UI-only structural device. They affect rendering and PDF output, not totals.

### 6.5 Defaults & initial focus

- New offerte: Klant search input is auto-focused, but the dropdown stays closed until the user clicks or types. (Auto-opening felt loud.)
- New tekstregel: description input auto-focuses.
- "Korting toevoegen" sets discount to **0%** (not 5%) and reveals the editable row.

### 6.6 Confirmations

We don't confirm trivial actions. We do confirm:

- Status change to Definitief (because of the order-creation question).
- Destructive operations only when truly irreversible (rare in this prototype).
- Mutations to a field that other linked records depend on — see §6.6.1.

Toasts handle the rest.

#### 6.6.1 Confirm-on-change with linked downstream 🔒

When mutating a field that an already-linked downstream record depends on, pop a 3-button confirm. Canonical example: changing or unlinking the klant on a planning afspraak that has a betreft (offerte/order/melding) — the address printed on the werkbon belongs to that bron, so changing klant can make the linked record's address diverge from the actual delivery address.

Layout (per §2.5 modal pattern):

- Magenta-soft icon-circle at the top (caution/lock signal — not danger; we're not destroying data, we're flagging downstream impact).
- Title: `Klant losmaken?` / `Klant wijzigen?` (intent-aware).
- Body copy template:
  > Aan deze {entity} zit **{linked-id}** gekoppeld. {Reason this matters in one short sentence.} Wil je het bronrecord ook loskoppelen?
- Three right-aligned buttons:
  - **Annuleren** (ghost) — cancels the mutation entirely.
  - **Alleen {entity} {action}en** (ghost) — applies the change, keeps the link.
  - **{Entity} én bron loskoppelen** (primary green) — applies the change _and_ clears the linked-record reference.

Same shape applies anywhere a record's mutation could de-sync a downstream record: changing klant on an offerte with a generated order, swapping the linked product on an aftersales record, etc. Reach for it when the question "what should happen to the link?" is genuinely meaningful — not for every linked field.

### 6.7 Empty states

- Table empty: standard message + dashed-bordered create CTA.
- Group with 0 lines: italic "Sleep een regel hierheen" centered in a dashed drop zone.
- File list empty: just the "Bestand toevoegen" dashed CTA.
- Customer empty in offerte: hero KlantPicker prominent.

**Drawer (read-only paper preview) — prefer omitting empty sections** 🔒. In the drawer, sections with no content (no omschrijving, no opmerking, no artikelen, no bestanden) are simply not rendered — no "—" placeholder, no header floating above nothing. The paper feels more like an actual sheet of paper when its bottom is naturally empty than when it's full of em-dashes. The editable detail page does the opposite: keep section headers visible so structure is discoverable.

**Editable detail — keep the column-header strip even when no rows**. The strip is the section affordance per §2.3. The "+ Artikel toevoegen" CTA below it is enough; don't add "Geen artikelen toegevoegd" placeholder text underneath the strip — that's noise. (Service does keep a `Geen artikelen toegevoegd` line in its **read-only** locked state — that's the one acceptable case, signalling that nothing got added before lock.)

### 6.8 Drag and drop

- Lines can be dragged onto other lines (reorder), onto group headers (move into group), or onto another line in a different group (move there).
- Groups can be dragged onto top-level slots (reorder).
- HTML5 drag and drop, no library. The drop target gets `--accent-blue-soft` background while a line hovers over it.

### 6.9 Keyboard interaction (drawer & quick actions) 🔒 RULE

The view drawer and its quick-actions menu have their own keyboard model. Like § 6.1, this is shared across every module — service and planning use identical handlers.

#### Drawer level

The drawer's `<aside>` is rendered with `tabIndex={-1}` so it's focusable but not in the natural tab order; on mount it's auto-focused via `requestAnimationFrame` with `preventScroll: true` (see § 6.10).

| Key      | Action                                                            | Suppressed when                                                                                               |
| -------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `Escape` | Close quick-actions menu if open, otherwise close drawer          | _(none — `Escape` always escalates)_                                                                          |
| `Space`  | Open quick-actions menu                                           | Focus in `INPUT` / `TEXTAREA` / `SELECT` / `contentEditable` / button / link · modal open · menu already open |
| `Enter`  | Open the record's full editor (equivalent to clicking "Bewerken") | Same as Space                                                                                                 |

🔒 `Space` and `Enter` are bound at the document level inside a `useEffect` that runs while the drawer is mounted. The same suppression rules from § 6.1 apply — never hijack typing.

#### Quick-actions menu level

When `.quick-actions-panel` is open, an `onKeyDown` handler on the panel itself manages roving focus across menu items:

| Key               | Action                                                      |
| ----------------- | ----------------------------------------------------------- |
| `↑`               | Focus previous `[role="menuitem"]`, wraps to last           |
| `↓`               | Focus next `[role="menuitem"]`, wraps to first              |
| `Home`            | Focus first item                                            |
| `End`             | Focus last item                                             |
| `Escape`          | Close menu, restore focus to hamburger trigger              |
| `Enter` / `Space` | _(handled by the button itself — native button activation)_ |

`[role="menuitem"]` items use `tabindex` defaults from the button element. Disabled items are skipped automatically because the panel filters via `[role="menuitem"]:not([disabled])`.

#### Hamburger trigger

The hamburger button has one custom keyboard binding beyond native button activation:

| Key | Action    | Condition      |
| --- | --------- | -------------- |
| `↓` | Open menu | Menu is closed |

Pressing `↓` on a closed dropdown to open it is a long-standing combobox/menu convention. Once open, the panel's keydown handler takes over and the first menu item is focused automatically.

🔒 **Don't introduce module-specific keyboard bindings inside the drawer or menu.** The three levels above (list / drawer / menu) are the complete keyboard surface for this pattern. Adding shortcuts per module breaks muscle memory for users who move between modules.

### 6.10 Focus management 🔒 RULE

Focus is the system's other anchor (alongside the active-row marker). Every drawer/menu transition has a defined focus target, and every close path restores focus to a defined origin. **No transition leaves focus on `document.body`** — that's the bug we're explicitly preventing.

#### Drawer mount & quote-id change

When the drawer mounts, OR when the displayed `quote.id` changes (user navigates from one drawer to another without closing):

```js
useEffect(() => {
  if (!quote) return;
  const id = requestAnimationFrame(() => {
    asideRef.current && asideRef.current.focus({ preventScroll: true });
  });
  return () => cancelAnimationFrame(id);
}, [quote && quote.id]);
```

The `<aside>` has `tabIndex={-1}` so this focus call is valid. `preventScroll: true` is non-negotiable — without it the page jumps when the drawer is opened from a row that's near the bottom of the viewport. The `requestAnimationFrame` defer is also non-negotiable: focusing during the render commit phase races with React's DOM-application and occasionally no-ops.

#### Menu open

```js
useEffect(() => {
  if (!moreOpen) return;
  const id = requestAnimationFrame(() => {
    const panel = document.querySelector(".quick-actions-panel");
    const first = panel && panel.querySelector('[role="menuitem"]:not([disabled])');
    first && first.focus();
  });
  return () => cancelAnimationFrame(id);
}, [moreOpen]);
```

The first non-disabled menu item gets focus. Same rAF defer rule — without it the panel sometimes hasn't been committed to the DOM when we query for it.

#### Menu close — restoration is mandatory

Every close path must restore focus to the hamburger trigger (`moreBtnRef`). The paths are:

| Close trigger                   | Code path                                                                                                                      |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Click on a menu item            | `setMoreOpen(false); moreBtnRef.current.focus()` after the action runs                                                         |
| Click on `.quick-actions-glass` | `setMoreOpen(false); moreBtnRef.current.focus()` in the glass's onClick                                                        |
| `Escape` inside menu            | `setMoreOpen(false); moreBtnRef.current.focus()` in the panel's keydown                                                        |
| Click outside button + panel    | `setMoreOpen(false)` in the document mousedown listener — _no explicit focus restore (the user is already pointing elsewhere)_ |
| Quote-id change                 | Menu auto-closes via the `[quote.id]` effect; focus moves to the new drawer's `<aside>`                                        |

🔒 The "click on a menu item" case is easy to forget when adding a new action. The contract is: every `.a4-btn` onClick handler must end with `moreBtnRef.current && moreBtnRef.current.focus()` after `setMoreOpen(false)`. Bewerken is an exception — it triggers `onOpen` + `onClose`, which unmounts the drawer; there's nothing to restore focus to.

#### Focus visibility

Focus rings are `:focus-visible` only, never `:focus`. This is the standard browser distinction — keyboard-driven focus shows the ring, mouse-driven focus does not. The `.a4-btn` style is the canonical example:

```css
.quick-actions-panel .a4-btn:focus { outline: none; }
.quick-actions-panel .a4-btn:focus-visible {
  outline: none;
  background: color-mix(in oklch, var(--accent-blue) 7%, transparent);
  box-shadow: inset 0 0 0 1.5px color-mix(in oklch, var(--accent-blue) 35%, transparent);
  border-radius: 10px;
}
```

🔒 Never `outline: none` without a `:focus-visible` replacement. The current focus ring spec (2–3px soft blue glow, see § 9) is the floor; menu items use the inset-ring variant above because the panel has no card chrome to outline against.

#### What we explicitly don't do

- **No focus trap inside the drawer.** Tab cycles naturally through whatever interactive elements the paper renders. The drawer is a `role="dialog"` but it's a non-modal dialog by intent — the user can tab into the topbar Assist button while the drawer is open. We tested trapping; it felt jail-like for a preview surface.
- **No focus trap inside the menu either.** The menu's roving focus handles ↑/↓; `Tab` exits the menu (closing it via the outside-click logic if focus lands on background content). Trapping inside a 4-item list is overkill.
- **No focus restoration to the list row after closing the drawer.** The drawer-close handler doesn't track which row triggered the drawer; focus falls back to body, then the list-level keyboard handler (§ 6.1) handles the next arrow key naturally. We may revisit this later if testing reveals it's disorienting.

### 6.11 Glass overlay 🔒 RULE pattern

The glass overlay (`.quick-actions-glass` for the drawer's quick-actions, conceptually the same layer for any future popover that needs to "freeze" the surface behind a menu) is a reusable pattern, not a one-off.

#### When to use it

Use a glass overlay when a menu/popover is rendered **over** a content surface and you want to:

1. **Visually pause** the surface behind without darkening it (the surface is paper, not a stage to obscure).
2. **Receive outside-clicks** as an explicit "close" gesture, without relying on document-level listeners that can race with menu-open animations.
3. **Limit the dismiss surface** to the same containing element (not the whole viewport) — so clicks on the topbar / sidebar / other drawers still hit their normal targets.

The modal overlay (§ 4.9, `rgba(245,245,252,0.55)` + `backdrop-filter: blur(10px)`) is a different beast: it covers the **viewport**, signals "modal context", and traps focus. The glass overlay is local and lighter.

#### Spec

```css
.quick-actions-glass {
  position: absolute;
  inset: 0;
  z-index: 20;
  background: linear-gradient(180deg, rgba(255,255,255,.32), rgba(255,255,255,.42));
  backdrop-filter: blur(8px) saturate(160%);
  -webkit-backdrop-filter: blur(8px) saturate(160%);
  border-radius: inherit;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.5);
  animation: quickActionsGlassIn 220ms ease-out;
}
```

🔒 Rules:

- **Container must be `position: relative`** so the `inset: 0` absolute positioning lands on the right element (the paper in this case, not the drawer or the viewport).
- **`border-radius: inherit`** so the glass follows whatever the container's radius is — no separate radius token.
- **`backdrop-filter: blur(8px) saturate(160%)`** — the saturate is what makes the surface read as "frosted, still alive" instead of "matte grey". Don't reduce it; the blur alone looks dead.
- **White-ish gradient at 32–42% opacity** — keeps the surface feeling light. Don't switch to a dark or tinted gradient; that crosses into modal-overlay territory and changes the user's read of the surface.
- **`aria-hidden="true"`** on the glass element — it's visual scaffolding, not content for assistive tech. The menu it backs is the announceable thing.
- **z-index pairing**: glass at 20, menu/popover above it at 30. Both numbers are intentional — they sit cleanly between the drawer's own z-stack (cards, paper headers around z-1 to z-10) and the global modal layer (z-100+).
- **The onClick on the glass closes the popover AND restores focus** to whatever triggered the popover. See § 6.10 for the restoration contract.

#### Outside this pattern

Don't reach for the glass overlay just because a popover exists. A dropdown next to a row, a hover-popover on a chip, a tooltip — none of those need glass. Glass is the answer to "this menu should feel like it pauses the document underneath while it's open". For the quick-actions menu that's true; the menu is intentionally a moment.

### 6.12 Saving — edit screens vs modals 🔒 RULE

Saving in LogicTrade follows two distinct patterns. Pick by surface, never mix them.

#### Edit screens (full-screen detail of a record)

Examples: `offerte-detail`, `service-detail`, klantkaart in edit-mode.

**Core principle:** auto-save. The user never clicks a save button. Every change flows straight to parent state through `onChange`.

Two modes:

1. **New record (`isNew: true`)** — exists only in memory, not yet committed.
   - Primary header action is **`Opslaan`** (filled).
   - Disabled until required couplings exist (e.g. klant on offerte/service).
   - Click commits the record (`isNew = false`), shows toast `"<record> opgeslagen"`, header switches to the existing-record layout.
   - Back-button on an empty, uncoupled new record cleans it up silently — no warning.
   - Back-button on a new record with data but no klant → inline warning + scroll to the klant section. **No popup.**
2. **Existing record (`isNew: false`)** — committed; auto-saved on every edit.
   - No save button. `onChange` continuously propagates to the parent.
   - Primary header action is contextual: `Versturen` (offerte, service), or another domain action.
   - A subtle **save-indicator** sits to the left of the primary action:
     - `saving` → spinner + `"Opslaan…"` (~450 ms).
     - `saved` → check icon + `"Opgeslagen"` (stays visible until the next edit).
     - `idle` → renders nothing (first load, or read-only).
   - Indicator is `aria-live="polite"`, small font, muted colour. It does not replace toasts — toasts remain for explicit, confirmable actions (versturen, dupliceren, status-wissel, verwijderen). Auto-saves are silent except for the indicator.

Read-only (see § 6.3): `onChange` is a no-op, indicator stays `idle`, inline editors render disabled. The primary action stays usable (re-send, download).

Header layout:

```
[← Terug naar overzicht]   [Opslaan… / ✓ Opgeslagen]      …      [primaire actie] [⋮ acties]
```

The indicator anchors left of the action group so it has one stable position, never shifting between actions.

#### Modals (verzendmodal, downloadmodal, klant koppelen, etc.)

**Core principle:** transactional. Work in local draft state inside the modal; commit only when the primary button is clicked.

Rules:

- **Local state** — the modal owns its own `useState` for inputs. Edits do not propagate to the parent until commit.
- **Initialisation** — fields are populated from the chosen template/preset on open (e.g. type-selector in the verzendmodal fills Aan / Onderwerp / Bericht).
- **Switching template/layout** inside the modal re-fills the fields and overwrites manual edits. This is intentional — switching means "new starting point".
- **Footer**, always in this order:
  - Left: secondary info (e.g. bijlagen-teller) or empty.
  - Right: `[Annuleren]` (ghost) and `[Primaire actie]` (filled, e.g. `Verstuur`, `Download`, `Koppel`).
- **Primary action**:
  - Disabled while required fields are empty (e.g. ontvanger leeg).
  - On click → short busy state (spinner + `"Bezig…"`, ~900 ms), then `onSend/onConfirm` with the assembled payload, parent toasts (`"Offerte verzonden"`, `"PDF gedownload"`), modal closes.
- **Annuleren / `Esc` / overlay click** — closes without commit. Draft state is discarded. Modals are never "draft-bewaarbaar".
- **No save-indicator** inside a modal. The primary button is the signal.
- **No auto-close** on intermediate actions (toggling a chip, switching layout). Only the primary action closes.

#### Quick comparison

| Aspect          | Edit screen                             | Modal                             |
| --------------- | --------------------------------------- | --------------------------------- |
| Persistence     | Auto-save on every change               | Commit only on primary button     |
| Feedback        | Subtle save-indicator in header         | Busy-state on the button + toast  |
| Cancel          | Doesn't exist (already saved)           | `Esc` / `Annuleren` = revert      |
| Required fields | Block primary action or back-navigation | Disable the primary button        |
| Toast           | Only on explicit actions, never on save | On commit, parent emits the toast |

---

## 7 · Iconography

A custom inline-SVG icon set (`icons.jsx`) — `stroke-width: 2; round caps + joins`. All icons take `width`/`height` props and inherit `currentColor`.

Conventions:

- Header action icons: 16px.
- Inline / row icons: 12–14px.
- Drag handle: 14px, opacity 0 by default → 1 on row/header hover.
- KPI hover-hint icon: 12px inside a 22px pill.
- Plus icon (`I.Plus`) is used **everywhere** an "add" action exists. Don't introduce alternatives.

---

## 8 · Numbers, money & language

### 8.1 Currency

- **Always show €**: every monetary value is rendered through `formatEUR()`, which returns `"€ 1.234,56"`. Never show a raw number.
- **Dutch thousand separators**: `.` for thousands, `,` for decimals.
- **Always two decimals** (even on round amounts).
- Negative discounts shown as `- € 105,60` (minus sign + space + €). The minus is a sibling, not part of the amount.

### 8.2 Percentages

- Always include `%`. Never bare numbers for a percent value.
- Whole numbers preferred (`5%`, not `5,0%`); decimals only when meaningful.
- "Percentage points" → just say `%`. We are **not** writing `pp` — most users don't know it. (`+2pp` becomes `+2%` even when technically incorrect; the audience matters more than the math here.)

### 8.3 Movement-regel (KPI cards) 🔒 RULE

The line under a value on a meetpunt-KPI. See § 4.4 for the full anatomy. Format summary:

```
+ € 12.640
vs. een maand geleden
```

- **Sign**: `+` of `−` direct gehecht aan het bedrag. Geen losse pijl, geen woord ("meer"/"lager") — die verdubbelden het signaal en maakten de regel onnodig taliger.
- **Magnitude**: het bedrag, vetgedrukt, tabular numerals.
- **Reference**: korte relatieve frase op de tweede regel, in `--ink-300`. `vs. een maand geleden` voor snapshots, `vs. de 30 dagen ervoor` voor periode-statistieken.
- **No colour**. Geen rood/groen op richting. Movement is observatie, niet oordeel — zie § 4.4.
- **Only on meetpunt-KPI's** (waarden en gemiddelden). Telling-KPI's hebben geen movement-regel; daar is de telling zelf het signaal, niet de beweging.

### 8.4 Language

- **Dutch first**. UI labels, modal titles, toasts — all in Dutch.
- Prefer plain over polite: "Versturen" not "Offerte versturen". "Order maken" not "Genereer order".
- Section headers in CAPS with 0.5–0.6px letter-spacing.

---

## 9 · Accessibility

- All icon-only buttons must have `aria-label`.
- Focus rings: `box-shadow: 0 0 0 2px var(--accent-blue-ring)` (3px in modal context). Always the token, never a literal rgba. Don't outline-suppress without replacement.
- `prefers-reduced-motion: reduce` collapses all animations (already implemented).
- Color contrast: ink-700 on white = 13.4 : 1 ✓; ink-500 on white = 5.4 : 1 ✓; ink-400 on white = 3.4 : 1 (just borderline — only used for tertiary text, not primary content).
- Drag-and-drop should be backed up by keyboard alternatives in production (current prototype is mouse-only; flag this in the new module).

---

## 10 · File / class naming

- **BEM-ish** (block / element / modifier with double-underscore and double-dash):
  - `.kpi`, `.kpi__value`, `.kpi__label`, `.kpi__open-hint`, `.kpi--blue`, `.kpi--clickable`.
- **Module-scoped names** (matches the JSX file): `klant-search`, `artikel-result`, `paper-quote__items-row`.
- State classes prefixed with `is-`: `.is-active`, `.is-open`, `.is-dragging`, `.is-drop-target`, `.is-new`, `.is-set`.
- Avoid generic class names (`.card` is acceptable as a base, but anything more specific should be scoped).

---

## 11 · Surface variants (tweaks panel)

The HUD at the bottom-right exposes three variant axes via CSS custom properties on `:root`:

- `data-density`: `compact | regular | comfortable` — drives `--tw-card-pad`, `--tw-row-pad`, `--tw-acc-pad`, `--tw-list-gap`, `--tw-font-scale`.
- `data-surface`: `default | flat | outlined` — drives card shadows/borders.
- `data-accent`: cycles brand-blue / brand-magenta / brand-green.

When building a new module, opt into the same tokens (`var(--tw-card-pad, 32px 36px)` etc.) so the user can tune the new surfaces from the same panel.

---

## 12 · The things that make this product feel like itself 🔒

Quick rules-only checklist for a new module:

1. **Always-on 3px gradient bar** at the top.
2. **Sidebar with the LT logo** that crops cleanly when collapsed (uses one SVG; no separate icon asset).
3. **Three-tier ink scale**: never use brand blue as default text; section headers in `ink-900` navy.
4. **Money is always € and Dutch-formatted** through one helper.
5. **KPI strip = drie kaarten**: één telling-KPI zonder movement, twee meetpunt-KPI's mét movement. Geen tijdslabels — frame zit in de movement-zin.
6. **Movement-regel anatomie**: `+ € 12.640` groot/vet bovenop, `vs. een maand geleden` klein/grijs eronder. Geen kleur, geen pijl-icon, geen woord "meer/lager".
7. **Dashed CTAs are uniform**: `.add-row` spec, including `+` rotation on hover.
8. **Inputs hover to white-bg + border**, focus to soft blue ring. No exceptions.
9. **Modal-safe animations**: `.page` and any modal-ancestor animate via opacity only.
10. **Subtle entrance staggers** (KPI cards, table rows, dropdown items) — never on every list everywhere.
11. **One-shot `is-new` pulse** on freshly added rows.
12. **Click & keyboard equivalents on rows**: single-click / Space = drawer; double-click / Enter = editor. Both keyboards bound at the list level.
13. **View drawer = paper + quick-actions corner menu**, no sticky header, no sticky footer. Hamburger trigger, Space-key alternative, max 6 actions.
14. **Quick-actions menu always opens with Bewerken first and the detail's primary CTA second**, ends with Verwijderen separated by a divider.
15. **Modal footer = three zones** (Terug · optional Breaking · Primary). No `×` close-icon anywhere.
16. **Modal header uitstap-knop**: copy "Open [entiteit]", no arrow icon.
17. **Focus restoration is mandatory** on every menu/drawer close path — never leave focus on `document.body`.
18. **Glass overlay** (frosted-light, blur 8 + saturate 160%) backs every quick-actions menu — local to the paper, not viewport-wide.
19. **Paper-delete uses the shared `paperQuoteOut` head-shake** before toast + close, 520ms.
20. **Lijsten laden via scroll, niet via pagination**: 50 per batch, auto-load bij 80%, voet-strip altijd zichtbaar, virtualisatie boven 100 records.
21. **Order-made indicator = navy circle + white check**, niet groen. Hover wordt brand-blue.
22. **History timeline**: milestones (gevulde dot, kleur per type) vs wijzigingen (kleine outline dot), met tijdgroepering en bundeling.
23. **Read-only mode = `cursor: not-allowed` + contextual toast**, never a banner.

---

## 13 · Per-module content guidelines 🌿

This is the explicit list of what _should differ_ between modules. Match the shape (rules above), choose the contents below for your module. **Start here when planning a new module.**

### 13.1 KPI cards (3 per overview)

Pick **three** metrics that map to the module's value — one telling-KPI (a count, filter-ingang) and two meetpunt-KPI's (a waarde en een gemiddelde). See § 4.4 for the visual anatomy.

- 🌿 Default to **drie** kaarten. Niet 2, niet 4. Drie is genoeg voor "at a glance"; vier wordt een dashboard.
- 🌿 Eén van de drie is een **telling-KPI** zonder movement: een aanklikbare cohort-filter (bij offerte: "Openstaande offertes" → filtert `status = niet definitief`).
- 🌿 Eén van de drie is een **periode-statistiek** (gemiddelde over 30 dagen of vergelijkbaar). Krijgt `· 30 dagen` (of equivalent) suffix achter de titel.
- 🌿 Eén van de drie is een **snapshot-waarde** (huidige pipeline, openstaande waarde, etc.). Krijgt movement-regel met `vs. een maand geleden`.
- 🌿 KPIs update **live** als data verandert — niet statisch.
- 🌿 Don't introduce non-percent / non-currency / non-count units unless absolutely necessary.

**Exception — time-anchored modules.** When a module's primary value is "wat speelt vandaag / deze week" (scheduling, agenda) rather than "hoeveel waarde staat open", the KPI row may be replaced by an alternative weergave that already surfaces the same answer (e.g. a day view). Planning takes this exception — see § 4.5.1.

| Module      | Telling-KPI (filter)                  | Snapshot-waarde           | Periode-statistiek            |
| ----------- | ------------------------------------- | ------------------------- | ----------------------------- |
| Offerte     | Openstaande offertes                  | Openstaande offertewaarde | Gem. offertebedrag · 30 dagen |
| Inkooporder | Te ontvangen orders                   | Openstaand inkoopbedrag   | Levertijd ø · 30 dagen        |
| Service     | Openstaande tickets                   | Vandaag ingepland         | Doorlooptijd ø · 30 dagen     |
| Aftersales  | Openstaande RMAs                      | Te beoordelen waarde      | Doorlooptijd ø · 30 dagen     |
| Planning    | _(geen — day view vervangt deze rij)_ |                           |                               |

### 13.2 Filter chips on the overview

🔒 Filters are config-driven (each defined as `{key, label, icon, kind, options, predicate}` and rendered by the same `FilterBar` component). Always include:

- **Status** (the record's primary state)
- **Datum** (range)

🌿 Then add module-specific filters: city, supplier, ticket category, magazijn, etc. Pick filters that map to columns the user filters by often. ~6–10 filter definitions is the comfortable maximum.

### 13.3 Overview table columns

🔒 Columns from left to right: id-as-link, datum, primary-relation (klant/leverancier/relatie), subject, validity-or-due-date, amount-or-count, status pill.

🌿 Replace the relation column with whatever's primary for the module (Inkooporder → Leverancier; Service → Relatie + Locatie). Replace "Geldig tot" with the equivalent due/closing date. Keep the status pill last; keep the id-link first.

### 13.4 Side panel accordions

🔒 Default set for any document module: **Afspraken · Historie · Notities · Documenten**.

| Section    | Required?              | Notes                                                       |
| ---------- | ---------------------- | ----------------------------------------------------------- |
| Afspraken  | 🌿 Most modules        | Skip only if the module truly has no scheduled events.      |
| Historie   | 🔒 **Always required** | Audit trail of every state change + linked-record creation. |
| Notities   | 🔒 Always              | The free-form workspace inside the document.                |
| Documenten | 🌿 Most modules        | Skip if the module has no file attachments (rare).          |

🌿 You may add 1–2 module-specific accordions ("Onderdelen" for service tickets, "Goederenontvangst" for inkooporders, "Garantie" for aftersales). Don't exceed 6 total — the panel becomes too tall.

🌿 **If the entity is itself a leaf in the linked-records tree, drop "Afspraken".** A planning-afspraak doesn't have sub-afspraken — putting an Afspraken accordion on its side panel would be tautological. Same logic if your record can't have appointments by definition.

🌿 **Bestanden may live on the paper instead of in the side panel** when the files are operationally part of the document (e.g. a delivery werkbon's annexes, photos of a service ticket's defect). When that's true, drop Documenten from the side panel. Decide based on whether the file is "about the doc" (side panel) or "part of what the doc IS" (paper).

🔒 **Both placements use `FilesDropzone` (§ 4.2)** — never a separate "Bestand toevoegen" add-row button. The dropzone IS the add affordance. Drop or click anywhere outside an existing tile to upload; hover a tile for an inline × to delete.

### 13.5 The "paper" contents

🔒 The detail-card layout, top to bottom, in this order:

1. **Card header** — title + subtitle + status pill
2. **Two-column meta block** — left: party (Klant/Leverancier/Service-relatie); right: document meta (Datum / Geldig tot / Verkoper / Referentie)
3. **Document body** — the lines table or equivalent
4. **Totals / summary** — only if the module deals with money
5. **Opmerkingen** — always
6. **Voorwaarden + Bestanden** — two-column

🌿 Adapt the _labels_ and _fields_ per module, but **not the order**. Users read the paper top-to-bottom; the order being predictable across modules is what makes the system feel cohesive.

### 13.6 Status set + read-only

🔒 The status pill is the single source of truth for record state. Visible from overview (table cell), drawer, and detail. Always editable from the detail; read-only from the drawer.

🔒 The read-only mode rules from §6.3 apply: editing locked, but Versturen / Notities / Opmerkingen / status toggle remain available.

🌿 Pick **2–3 statuses max**. More than 3 confuses the pill. Always include:

- A "concept / open" state.
- A "frozen / definitief / afgesloten" state that triggers read-only.
- Optional middle state for "submitted but not yet final".

| Module      | Suggested statuses                       |
| ----------- | ---------------------------------------- |
| Offerte     | Niet definitief · Definitief             |
| Inkooporder | Concept · Verzonden · Ontvangen          |
| Service     | Open · In behandeling · Afgesloten       |
| Aftersales  | Aangemeld · In behandeling · Afgehandeld |

### 13.7 Initial defaults (new record)

🔒 Always:

- Generate id from the next sequence (pick a prefix: `O260…` / `B260…` / `S260…`).
- Status = the most-open state.
- Date = today.
- "Geldig tot / Vervaldatum" = today + sensible default.
- Verkoper / Behandelaar = logged-in user.
- Vestiging / Magazijn / Service-locatie = the user's default location.
- Auto-focus the first required field — the party search.

🌿 Per module:

- Pick the validity offset (Offerte: +14 days · Inkoop: +30 days · Service: open-ended).
- Pick the default Voorwaarden values that match your module.
- Pick the placeholder copy ("Zoek klant…" / "Zoek leverancier…" / "Kies relatie…").

### 13.8 Confirmation flows

🌿 Pick the **1–2** state transitions that warrant a "are you sure / want to do follow-up?" modal. Examples:

- Offerte → Definitief → opens the **OrderModal** (line selection + voorwaarden, see § 4.10.3) — not a yes/no confirm. The user can close to leave the offerte definitief without ordering.
- Inkooporder → Ontvangen → "Goederen inboeken?"
- Service → Afgesloten → "Factuur genereren?"

🔒 Don't over-confirm. Most actions don't need a modal — a toast is enough. Only confirm transitions that meaningfully change downstream behavior. When the follow-up is _itself_ a multi-field action (like ordering — picking lines, leverweek, betaling), skip the "are you sure?" step and open the action modal directly. Cancel = no commit.

### 13.9 History entries

🔒 The auto-history pattern (events written to the timeline on mutation) applies in every module. Always log:

- Every status change (label format: "Status gewijzigd naar {X}", meta = behandelaar).
- Every linked-record creation (label format: "Order aangemaakt — {orderId}", with the id as a clickable dashed-blue link → toast/navigate to that record).

🌿 Decide per module which field-level edits are worth logging. Don't log every keystroke — only high-value changes (price, customer reassignment, supplier change). Keep the timeline scannable.

### 13.10 Toast copy

🔒 Past tense, short, confirming. No emojis. No "✓" prefix.

🌿 Match the module's domain language:

| Action          | Offerte                                   | Inkoop                                  | Service                                  |
| --------------- | ----------------------------------------- | --------------------------------------- | ---------------------------------------- |
| Add line        | "'Eetkamerstoel' toegevoegd"              | "'Inkoopregel' toegevoegd"              | "'Onderdeel' toegevoegd"                 |
| Status change   | "Status gewijzigd naar Definitief"        | "Status gewijzigd naar Verzonden"       | "Status gewijzigd naar Afgehandeld"      |
| Linked record   | "Order B260… gegenereerd"                 | "Goederenontvangst aangemaakt"          | "Factuur F260… aangemaakt"               |
| Read-only block | "Offerte is definitief — niet bewerkbaar" | "Inkoop is verzonden — niet bewerkbaar" | "Ticket is afgesloten — niet bewerkbaar" |

### 13.11 Empty states

🌿 Each empty surface needs a one-liner. Tone: factual, slightly inviting, no exclamations.

- Group with 0 lines: italic "Sleep een regel hierheen".
- Side-panel section empty: "Geen afspraken ingepland" / "Geen notities" / "Geen bestanden".
- Overview with no records: a centered placeholder + the primary "+ Nieuw" CTA.
- Customer/relation empty in detail: hero picker (large input) instead of compact card.

### 13.12 Module-specific overflow & action buttons

🌿 The page-header overflow (`⋯`) and the detail-page action bar each need a small set of module-specific actions. Re-use the existing icon-ghost spec; pick which icons make sense for your module's content. Keep the **primary CTA** ("Versturen" / "Verzenden" / "Plaatsen") green and singular.

---

## When in doubt

- If you're unsure whether something is a rule or a guideline, **default to rule**. Drift hurts more than rigidity.
- If a guideline forces you toward something that breaks a rule, **the rule wins**. Find a different content shape.
- If you find yourself wanting to introduce a new color, a new radius, a new font weight: don't. Use what's here. If it really doesn't fit, that's a signal the module is shaped wrong, not that the system needs a new token.
