# Cenergi Design System — Editorial Minimalism for Operators

*A design language for Safety Hub, AssetX, Biogas Management, and all Cenergi internal apps.*

---

## 0. Thesis

Cenergi runs palm oil mills, biogas plants, and solar farms — work that is physical, consequential, and unforgiving. The software that supports it should feel the same: confident, quiet, and honest. No decoration that doesn't earn its weight. No color that doesn't carry meaning. No motion that doesn't communicate state.

This system is built on three ideas:

1. **Editorial minimalism.** The page defers to the content. Headlines are compressed to the edge of legibility. Body copy breathes. Color is rationed: primary blue for brand chrome, green for action and success, bright blue for info and data.
2. **Shadow-as-border.** Every border in the system is a `0 0 0 1px` box-shadow — not a CSS border. This lets rounded corners stay clean, transitions stay smooth, and the same primitive scale from inputs to cards to modals without re-engineering the box model.
3. **Crisp, not soft.** No gradients on surfaces, no glassmorphism, no backdrop blur, no glow on accents, no easing past 200ms. Softness lies to operators about system state. This system always tells the truth.

The canvas is faintly blue-tinted (`#E5F1F5`) — calm enough to disappear, close enough to the brand primary to feel Cenergi. The type is Geist with aggressive negative tracking. The official anchors are `#00698F` for brand chrome, `#00A66C` for action and success, and `#009DDB` for info and data. `#71BF43` stays out of UI chrome; reserve it for illustrations and brand artwork.

---

## 1. Visual Theme & Atmosphere

Cenergi is a green-ops company — biogas, solar, palm-oil sustainability — and the design system lives inside a blue-tinted canvas (`#E5F1F5` light, `#0D1815` dark) rather than full white. The tint is barely there but consistently present: every screen, every modal, every email header belongs to the same atmosphere.

The typographic voice is Geist — Vercel's open-source workhorse — pushed to its compressed limit. Display headlines run at 42–56px with `-0.055em` letter-spacing, creating the sensation of words that have been engineered rather than typeset. Body copy relaxes to `-0.01em` tracking. Mono labels in Geist Mono at 10px uppercase with `0.1em` tracking serve as the "operator voice" — field labels, timestamps, plant codes, audit lines. This three-voice system (display-compressed / body-comfortable / mono-operator) gives every interface the same rhythm.

Every border in the system is a `box-shadow: 0 0 0 1px rgba(22,32,29,0.08)` — never a traditional CSS border. Cards layer additional shadow values beneath this ring (a 2px soft shadow, sometimes a 4px deeper one) to create the feeling of panels that are *set into* the canvas rather than floating above it. Crucially: no shadow in the system exceeds `0.14` opacity. The depth is whispered, not spoken.

**Key Characteristics:**

- Blue-tinted canvas (`#E5F1F5` light / `#0D1815` dark) — atmosphere over purity
- Geist Sans at display sizes with `-0.055em` letter-spacing — compression as identity
- Geist Mono uppercase for operator labels — timestamps, plant codes, status codes
- Shadow-as-border universally: `0 0 0 1px rgba(22,32,29,0.08)` replaces all CSS borders
- Primary blue `#00698F` reserved for brand chrome and navigation
- Action green `#00A66C` reserved for primary action, live values, and success states only
- Info blue `#009DDB` used for informational states and data series — never decoratively
- Semantic colors (danger red, warning amber, success green, info blue) used exclusively for their meanings
- Auto theme switching on Malaysia Time: dark 19:00–06:00 MYT, light otherwise, manual override persists
- No gradients on UI surfaces, no glassmorphism, no backdrop blur — crispness is the thesis

---

## 2. Color Palette & Roles

### Cenergi Primary — Brand / Navigation Ramp

Anchored on `#00698F` (Pantone 7706 C). This is the corporate primary for app chrome, headers, and navigation. It is not a success color.

| Token | Hex | Role |
|-------|-----|------|
| `primary.50` | `#E5F1F5` | Primary tint backgrounds |
| `primary.100` | `#BFDCE7` | Hover tint on primary surfaces |
| `primary.200` | `#8FC1D2` | Subtle dividers on primary contexts |
| `primary.300` | `#5FA6BE` | Dark-mode primary support |
| `primary.400` | `#2F8BA9` | Navigation emphasis |
| `primary.500` | `#00698F` | **Brand primary · app chrome · navigation** |
| `primary.600` | `#005A7A` | Hover state for `500` |
| `primary.700` | `#004A65` | Active/pressed, text on primary-50 |
| `primary.800` | `#003A50` | Dark primary surface |
| `primary.900` | `#002A3B` | Deep header backgrounds |
| `primary.950` | `#001A26` | Darkest primary extreme |

### Cenergi Green — Action / Success Ramp

Anchored on `#00A66C` (Pantone 3405 C). The ramp is built for action surfaces, success states, and live values. `500` is canonical action green; `300` is the dark-mode substitute where `500` would fail contrast on `#0D1815`.

| Token | Hex | Role |
|-------|-----|------|
| `green.50` | `#E8F7F1` | Surface tint, pill backgrounds (light) |
| `green.100` | `#C8F0DE` | Hover tint on green surfaces |
| `green.200` | `#8FE0C0` | Decorative, heatmap mid-low |
| `green.300` | `#5CD8A8` | **Dark-mode primary accent** |
| `green.400` | `#2ECA8E` | Success emphasis, chart series 2 |
| `green.500` | `#00A66C` | **Action accent · CTA · success** |
| `green.600` | `#008A5C` | Hover state for `500` |
| `green.700` | `#00724D` | Active/pressed, text on green-50 |
| `green.800` | `#005A3D` | Rare — dark-theme surface over canvas |
| `green.900` | `#00422E` | Rare — deep header backgrounds |
| `green.950` | `#002A1E` | Darkest green extreme |

### Cenergi Blue — Info / Data Ramp

Anchored on `#009DDB` (Pantone 2925 C). Used for informational states and data series after green. It does not replace brand primary blue or action green.

| Token | Hex | Role |
|-------|-----|------|
| `blue.50` | `#E6F4FA` | Info tint backgrounds |
| `blue.100` | `#C0E6F5` | Hover tints on blue surfaces |
| `blue.200` | `#8DD4ED` | Decorative chart support |
| `blue.300` | `#5CC8F0` | Dark-mode info accent |
| `blue.400` | `#2EB6E8` | Info emphasis, chart series |
| `blue.500` | `#009DDB` | **Info solid · data accent** |
| `blue.600` | `#0085BA` | Hover state for `500` |
| `blue.700` | `#006D98` | Active/pressed, text on blue-50 |
| `blue.800` | `#005577` | Dark info surface |
| `blue.900` | `#003D56` | Deep info backgrounds |
| `blue.950` | `#002535` | Darkest blue extreme |

### Neutral — Warmed for Mint Canvas

A slightly green-desaturated gray ramp. Using pure gray against the blue-tinted canvas produces a cold seam; this ramp resolves it.

| Token | Hex | Role |
|-------|-----|------|
| `neutral.50` | `#E5F1F5` | **Light canvas** |
| `neutral.100` | `#E4EDE8` | Subtle surface tint, divider on light |
| `neutral.200` | `#D0DDD6` | Grid lines, dashed chart rules (light) |
| `neutral.300` | `#B0C4BA` | Disabled borders |
| `neutral.400` | `#8B9F96` | Placeholder text, disabled foreground |
| `neutral.500` | `#6B7D74` | Tertiary text, mono label color (light) |
| `neutral.600` | `#4E5E56` | Secondary body text |
| `neutral.700` | `#3A4842` | Strong secondary text, muted heading |
| `neutral.800` | `#26302C` | Dark-theme chrome, dashed grid (dark) |
| `neutral.900` | `#16201D` | **Primary text (light) · surface (dark)** |
| `neutral.950` | `#0D1815` | **Dark canvas** |

### Semantic — Status

Each semantic color has a solid value, a tint value (light mode), and a tint value (dark mode, `rgba` based). Never use these decoratively.

| Token | Solid | Tint (light) | Tint (dark) | Role |
|-------|-------|--------------|-------------|------|
| `danger` | `#D64545` | `#FDE4E4` | `rgba(214,69,69,0.20)` | Critical alarms, destructive actions, rejected reports |
| `warning` | `#E89C2A` | `#FDF1D9` | `rgba(232,156,42,0.18)` | Minor alarms, SLA near-breach, pending review |
| `success` | `#00A66C` | `#E8F7F1` | `rgba(0,166,108,0.16)` | Resolved, verified, closed — shares action green |
| `info` | `#009DDB` | `#E6F4FA` | `rgba(0,157,219,0.18)` | Neutral info, used sparingly |

### Theme Tokens

Apps consume the system via semantic tokens, not raw hex. The resolution layer swaps values per theme.

| Semantic Token | Light | Dark |
|----------------|-------|------|
| `--bg-canvas` | `#E5F1F5` (neutral.50) | `#0D1815` (neutral.950) |
| `--bg-surface` | `#FCFFFD` | `#16201D` (neutral.900) |
| `--bg-surface-raised` | `#F8FCFA` | `#26302C` (neutral.800) |
| `--fg-primary` | `#16201D` (neutral.900) | `#E4EDE8` (neutral.100) |
| `--fg-secondary` | `#4E5E56` (neutral.600) | `#B0C4BA` (neutral.300) |
| `--fg-muted` | `#6B7D74` (neutral.500) | `#8B9F96` (neutral.400) |
| `--fg-brand` | `#00698F` (primary.500) | `#5CC8F0` (blue.300) |
| `--fg-accent` | `#00A66C` (green.500) | `#5CD8A8` (green.300) |
| `--border-shadow` | `rgba(22,32,29,0.08)` | `rgba(228,237,232,0.08)` |
| `--border-shadow-strong` | `rgba(22,32,29,0.14)` | `rgba(228,237,232,0.14)` |
| `--ring-focus` | `#00A66C` (green.500) | `#5CD8A8` (green.300) |

---

## 3. Typography Rules

### Font Family

- **Sans:** `Geist, "Inter", system-ui, -apple-system, "Segoe UI", sans-serif`
- **Mono:** `"Geist Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace`
- **OpenType features:** `"liga" 1` enabled globally on all Geist text. `"tnum" 1` on numeric KPIs and tables (tabular figures).
- **Loading:** Self-hosted via `next/font` with `font-display: swap`. Never block render on font fetch.

### Hierarchy

| Role | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|--------|-------------|----------------|-------|
| Display XL | 56px / 3.5rem | 600 | 1.0 | -0.06em | Landing heroes only |
| Display L | 42px / 2.625rem | 600 | 1.02 | -0.055em | Page heroes, auth pages |
| Heading 1 | 32px / 2rem | 600 | 1.12 | -0.045em | Section titles |
| Heading 2 | 24px / 1.5rem | 600 | 1.2 | -0.035em | Card titles, subsections |
| Heading 3 | 18px / 1.125rem | 500 | 1.33 | -0.02em | Small card titles |
| Body Large | 16px / 1rem | 400 | 1.6 | -0.01em | Introductions |
| Body | 14px / 0.875rem | 400 | 1.55 | -0.005em | Default UI body |
| Body Small | 13px / 0.813rem | 400 | 1.5 | normal | Dense tables, form hints |
| Button | 13px / 0.813rem | 500 | 1.0 | -0.01em | All buttons, links |
| Caption | 12px / 0.75rem | 400 | 1.4 | normal | Metadata |
| Mono Label | 10px / 0.625rem | 500 | 1.0 | 0.1em (+) | **Uppercase**. Operator labels, timestamps |
| Mono Caption | 11px / 0.688rem | 400 | 1.4 | 0.06em (+) | Inline code, values in tooltips |
| Numeric Hero | 36px / 2.25rem | 600 | 1.0 | -0.055em | KPI values (use `"tnum"`) |

### Principles

- **Three weights only.** 400 (reading), 500 (interactive), 600 (announcing). Never use 700 except for chart legend keys. Hierarchy comes from *size and tracking*, not weight.
- **Tracking scales with size.** -0.06em at 56px, -0.055em at 42px, -0.045em at 32px, -0.035em at 24px, -0.02em at 18px, -0.01em at 14px, normal below. Positive tracking exists only on uppercase mono labels.
- **Uppercase is a mono responsibility.** If text is uppercase, it must be Geist Mono with `0.06em`–`0.14em` tracking. Never uppercase Geist Sans — the aggressive negative tracking fights the wide caps.
- **Numbers are tabular.** Any KPI, stat, table column, or time value uses `font-variant-numeric: tabular-nums`. Operators compare numbers top-to-bottom; tabular prevents the jitter.
- **Line lengths.** Long-form body capped at 66ch. Card descriptions capped at 42ch. Headlines break on semantic phrases, not width.

---

## 4. Theme Switching

The system ships two full themes — light and dark — tied to Malaysia Time (`Asia/Kuala_Lumpur`, MYT, GMT+8) by default, with an explicit user override.

### Auto Rules

- **Light:** `06:00 ≤ MYT < 19:00`
- **Dark:** `19:00 ≤ MYT < 06:00`

The resolution happens on the server first (SSR-safe, no flash of wrong theme) using the request's MYT offset, then re-verifies in the browser and dispatches a `theme-change` event if it drifts (e.g., user opens laptop at 18:55 and the page lives past 19:00).

### User Override

A three-state toggle in the profile menu: **Auto · Light · Dark**. Selecting Light or Dark persists to `localStorage.theme` and to the user's DB record (so the choice follows them across devices). Selecting Auto clears both and re-enters the MYT rule.

### Implementation Contract

- Root element receives `data-theme="light" | "dark"` — never a class toggle.
- All theme-sensitive values read from CSS custom properties (see §2 — Theme Tokens).
- Never read the raw hex in a component. A component should not know that green-500 is `#00A66C`; it should know it wants `var(--fg-accent)`.
- No theme-specific component variants. The same JSX renders in both themes; only token values change.

### Exceptions

- PDF reports render in **light only** — paper is never dark. Generated PDFs force light tokens regardless of user preference.
- Status badges (`danger`, `warning`) retain their hue identity across themes — only the tint backgrounds swap.

---

## 5. Component Stylings

### Buttons

All buttons: `padding: 7px 14px`, `border-radius: 6px`, `font: Geist 13px/1.0 weight-500 tracking-(-0.01em)`. **One size only.** No `btn-sm`, no `btn-lg` — a button is a button. Icon-only buttons use `padding: 7px`, fixed `32px × 32px`. No icon spacing larger than 6px. No loading spinners inside buttons — disable and replace label instead.

**No transform on hover.** The hover signal is shadow or background intensity — never motion. Crisp systems don't shove buttons around under the cursor.

**Primary** (the green one — one per screen, maximum)
- Background: `green.700` (`#00724D`) · Text: `#FCFFFD`
- Hover: darken one step (`green.800` light / `green.400` dark) — no translate
- Focus: outline `2px solid var(--ring-focus)` at 1px offset
- Disabled: `neutral.300` bg, `neutral.500` text, no shadow

**Secondary** (shadow-bordered, the workhorse)
- Background: `var(--bg-surface)` · Text: `var(--fg-primary)`
- Shadow: `var(--border-shadow) 0 0 0 1px, rgba(0,0,0,0.04) 0 1px 2px`
- Hover: shadow ring intensifies to `var(--border-shadow-strong)` — no translate, no ambient layer change

**Ghost** (lowest weight — cancel, dismiss)
- Background: transparent · Text: `var(--fg-secondary)`
- Hover: background shifts to `neutral.100` (light) / `neutral.800` (dark)

**Danger** (outlined, never filled — filled danger is only for confirm-dialogs)
- Background: `var(--bg-surface)` · Text: `danger.solid`
- Shadow: `rgba(214,69,69,0.35) 0 0 0 1px`
- Hover: background shifts to `danger.tint`

### Cards & Panels

- Background: `var(--bg-surface)`
- Radius: **8px** (standard). `12px` reserved for image/media cards.
- Shadow stack (standard): `var(--border-shadow) 0 0 0 1px, rgba(0,0,0,0.04) 0 2px 4px`
- Shadow stack (raised modal/popover): `var(--border-shadow-strong) 0 0 0 1px, rgba(0,0,0,0.08) 0 8px 24px`
- Padding: `16px` standard, `20px` on dashboard panels, `24px` on empty-state hero cards, `40px` vertical on **vertical CTA cards** (see below)
- No hover state on non-interactive cards

#### Vertical CTA cards

Quick-action tiles, dashboard-grid CTAs, and "primary action" cards that stack a centered icon tile + heading + 1-line subtitle vertically. Used in 2- to 4-column grids on dashboards.

- **Vertical padding: `40px` — write as explicit `pt-10 pb-10 sm:pt-10 sm:pb-10`, never `py-10`.** Our `<CardContent>` base classes include `p-4 pt-0 sm:p-5 sm:pt-0`, where `pt-0` and `sm:pt-0` are single-side utilities under different responsive variants. `tailwind-merge` treats `sm:pt-0` and an unprefixed `pt-10` as separate variants and keeps both — so at `≥sm` breakpoints the base `sm:pt-0` reasserts and the icon tile sits flush against the top border (looks fine on phone, breaks on every other viewport). The override **must include the `sm:` prefix too**. Same trap applies to empty-state cards using `py-12 text-center` — write `pt-12 pb-12 sm:pt-12 sm:pb-12` instead.
- Horizontal padding: inherits the parent card padding (or `px-6` if standalone).
- Icon tile: 56×56, `rounded-lg`, primary-tint background for the active CTA, `bg-muted` for disabled/coming-soon siblings.
- Heading: 18px Geist semibold, `tracking-[-0.02em]`.
- Subtitle: 12px muted-foreground, single line, no truncation needed (copy is bounded).
- Active CTA gets the **green-tinted shadow border** (`shadow-[var(--fg-accent)_0_0_0_1px,...]`) — exactly one card per group. Siblings use the standard `--border-shadow` token.
- Hover: shadow intensifies; icon tile flips to filled action green. No translate, no scale.
- Disabled "Coming soon" siblings: `opacity-60`, no hover state, `aria-disabled="true"` on the wrapping link/button.

#### Cards in narrow columns (sidebar, side-panel, two-col dashboard right rail)

At `md+` breakpoints, dashboards collapse into 2-col layouts where the side rail is ≈260-300 px wide. Cards inside that rail will encounter content that's longer than they are wide. The rule:

- **Cards grow with content. Never fixed height for variable content.** A memo card whose body is 6 lines long must be 6 lines tall, not clipped by a CSS `max-height`. The exception is fixed-shape primitives (KPI tiles, status pills) where the data itself is bounded.
- **For genuinely long body text** (memo entries, descriptions, comments) — apply `line-clamp-3` and surface a "Read more" affordance that opens a drill-in or expands inline. Never `overflow: hidden` without an affordance — that is silent truncation, the same anti-pattern as a clipped table column.
- **Variable-length list cards** (recent items, attachments, assignees) follow the table list-column rule: cap at 3-5 visible rows with a "View all (N)" link to the detail screen. Don't render an unbounded `<ul>` inside a sidebar card.
- **Title text** uses `truncate` (single-line ellipsis) — titles are bounded labels and a tooltip on hover surfaces the full string.
- **Numerical and date content** never truncates. If a number doesn't fit, the column is wrong, not the rendering.

**DON'T:** ship a sidebar card with `max-height` + `overflow: hidden`. The screenshot users send back will always include one card with its bottom line clipped mid-word, and the operator won't know what they're missing.

### Inputs

- Height: 36px · padding `8px 12px` · radius 6px · font Geist 13px
- Background: `var(--bg-surface)` · text `var(--fg-primary)`
- Border: `var(--border-shadow) 0 0 0 1px` — never a traditional `border`
- Focus: outline `2px solid var(--ring-focus)` at 1px offset, shadow stays
- Error: outline `2px solid danger.solid` — not a red border
- Disabled: `neutral.100` (light) / `neutral.800` (dark) bg, `neutral.400` text
- Placeholder: `var(--fg-muted)` — never lighter than 4.5:1

**Select, Combobox, Date:** same dimensions as Input. The dropdown panel uses the raised card shadow stack.

**Checkbox, Radio:** 16×16px, 4px radius for checkbox (circle for radio). Unchecked state uses shadow-as-border. Checked state: `var(--fg-accent)` fill, `green.950` check glyph. Focus outline on the wrapper, not the box.

### Pills & Badges

- Radius: `999px` (full pill)
- Padding: `2px 10px`
- Font: Geist 11px weight 500 tracking `-0.005em`
- Optional leading dot: `6px × 6px` circle at semantic solid color

Badge variants match semantic tokens: `pill-ok`, `pill-warn`, `pill-bad`, `pill-info`, `pill-neutral`. Backgrounds use the `.tint` value, text uses a darkened version of `.solid` (for light) or a lightened version (for dark).

### Iconography

Icons are a typography-adjacent system. They inherit color, sit at aligned baselines, and come from one source.

**Library: [Lucide](https://lucide.dev)** — 1.5px stroke outline icons, MIT-licensed, React-native. Matches our chart stroke (1.5px), matches our shadow-as-border language (no fills, just strokes), and scales cleanly from 12 to 32px.

**Forbidden:** emoji as icons, mixing libraries (no Heroicons + Lucide + Phosphor in the same project), custom SVGs for anything Lucide already covers.

**Size scale — exactly 4 values:**

| Size | Use | Stroke |
|------|-----|--------|
| `14px` | Inline with body text, form-field prefixes, pill leading indicators | 1.5px |
| `16px` | Default UI icon size — inputs, buttons, inline actions | 1.5px |
| `20px` | Sidebar nav items, section headers, tab prefixes | 1.5px |
| `24px` | Empty-state glyphs, hero decorations, modal title icons | 1.5px |

No `18px`, no `22px`, no `32px` icons. Scale breaks consistency.

**Color rule:** always `currentColor`. Never hardcode an icon color. The icon inherits from its text parent, so theme-switching, status colors, and hover states flow through automatically. Exceptions: status-indicator dots (these are not icons, they're tokens).

**Pairing rule:** icons pair with labels unless the meaning is unambiguous (X = close, ← = back, ⚙ = settings). Every icon-only button has `aria-label`. Every icon-with-label pair aligns the icon's optical center, not bounding-box, to the text baseline — that's 6px gap between icon and label, not 8px.

**Do not:**
- Fill icons. Our system is outline-only.
- Layer icons on a colored chip/badge unless it's a semantic-state indicator (toast icon tile — see §10)
- Rotate, flip, or animate icons on hover. The icon is a label, not a toy.
- Use `<emoji>` as a stand-in for Lucide coverage. If Lucide doesn't have it, request it or use a Geist Mono label.

**Default icon set for Cenergi apps:** `layout-dashboard` (Dashboard), `file-text` (Reports), `check-square` (Tasks), `bar-chart-3` (Analytics), `users` (User management), `settings` (Settings), `bell` (Notifications), `search` (Search), `plus` (Create), `x` (Close), `chevron-down/up/left/right` (Disclose/nav), `alert-triangle` (Warning), `alert-circle` (Danger), `check-circle` (Success), `info` (Info), `edit-3` (Edit), `trash-2` (Delete), `external-link` (External), `log-out` (Sign out).

### Navigation

- Sidebar: `240px` wide on desktop, collapses to `56px` icons-only
- Background: `var(--bg-surface)` with `var(--border-shadow) 1px 0 0 0` right-edge ring
- Active item: `var(--fg-accent)` text, `green.50`/`green.950` background, 6px radius
- Inactive: `var(--fg-secondary)` text, transparent background
- Section labels: Mono Label style, `neutral.500` color, `16px 0 8px` padding
- Mobile: slide-over drawer with 8px radius top-left corner

### Tabs

- **Underline style, not pill-group.** Horizontal row, each tab a button with `padding: 10px 2px`, 6px gap from neighbor to neighbor, no container background.
- Active tab: `var(--fg-primary)` text, `2px solid var(--fg-accent)` bottom border at 0 offset.
- Inactive tab: `var(--fg-secondary)` text, `2px solid transparent` bottom border.
- Hover: `var(--fg-primary)` text, no border change.
- Row wrapper: `1px` bottom shadow-border `var(--border-shadow)` to form the baseline the active underline sits on.
- Label: Geist 13px weight 500. Optional leading 16px Lucide icon, 6px gap.

Pill-group tabs are forbidden — the container softens the edges and reads as iOS-default. We use the underline.

### Tables

Tables are the workhorse of Cenergi apps. Every report list, every plant roster, every user table runs on this primitive. Get it right once; reuse everywhere.

#### Anatomy (always)

- Header row: Mono Label style (10px weight 500 uppercase `0.1em`), `neutral.500`, bottom shadow `var(--border-shadow) 0 1px 0 0`
- Body rows: 14px Body, `44px` min height (comfortable), `var(--border-shadow)` bottom ring on all rows except the last
- Hover row: `neutral.50`/`neutral.900` background, applied whether the row is clickable or not
- Numeric columns: right-aligned, `font-variant-numeric: tabular-nums`, weight 500
- Container: `var(--bg-surface)`, radius 8px, Level 2 elevation, `overflow: hidden` so rounded corners hold

#### Density modes

Two densities, user-togglable per table in a `☰` menu at the top-right of the table header. Setting persists per-user, per-table.

| Mode | Row height | Cell padding | Use |
|------|------------|--------------|-----|
| Comfortable (default) | 44px | 12px 14px | Dashboards, desktop main working range |
| Compact | 36px | 8px 12px | Admin lists, bulk operations, field tablets |

Density only affects spacing — never font size, never icon size.

#### Variants

**Sortable** — click header to sort. Sort indicator: Lucide `arrow-up` / `arrow-down` at 14px, inline with label, `neutral.400` inactive → `fg-accent` active. Multi-column sort forbidden — always single-column. Default sort arrow hidden on hover until clicked.

**Selectable** — leading column `36px` wide, checkbox centered. Header checkbox toggles select-all for the visible page. Selected rows: `green.50` / `green.950` background, persists on hover. Bulk-action bar slides in above the table when selection > 0 (Level 3 elevation, Mono Label count + action buttons).

**Row actions** — trailing column `48px` wide, icon-only buttons OR a `⋯` (MoreHorizontal) kebab opening a Level 3 popover with actions. Inline icons for 1–2 actions; kebab for 3+. Actions always align right, never scatter across columns.

**Expandable rows** — leading `chevron-right` icon at 14px that rotates 90° on expand. Expanded content uses the same column grid, inset 24px left, `neutral.50`/`neutral.900` background, 16px padding top/bottom. Never nest expandable rows deeper than one level.

**Grouped rows** — category header row spans full width, `neutral.100`/`neutral.800` background, 36px height, Mono Label 11px weight 500 color `fg-secondary`. Groups are collapsible (leading chevron on header).

**Sticky header** — for tables >10 rows or scrollable containers. `position: sticky; top: 0`, background `var(--bg-surface)`, bottom shadow to separate from scrolled content.

**Sticky first column** — for wide tables (e.g., plant-wide KPI matrix). Column 1 `position: sticky; left: 0`, right-edge shadow `1px 0 0 0 var(--border-shadow)`.

**Status rows (subtle)** — when a row's severity is meaningful at a glance, add a `2px` left border in the semantic color — never a full-row background. Full-row tints read as buggy.

**Empty state (in-table)** — occupies full width of tbody, minimum 240px tall, centered: Lucide icon 24px (`file-text` or context-specific), heading 16px, body 13px, optional primary action button. Never render an empty `<table>` — always the empty state.

**Loading (in-table)** — replace body rows with 5 skeleton rows matching the column count. Each cell contains a skeleton block whose width roughly matches expected content (short for IDs, long for titles). No shimmer.

#### Pagination

Below the table, 16px gap. Row: left side shows `Showing X–Y of Z` in Mono Caption. Right side shows `◀ Prev · Page input · Next ▶` — the page number is an editable `36px × 36px` input (type number), not a pill list. For ≤ 2 pages, hide pagination entirely. For ≥ 100 pages, add a `Jump to page` with enter-to-submit.

Server-side pagination only. Never load > 100 rows client-side for virtualization — use a real backend cursor (see user-management-server-side-pagination pattern already shipped in Safety Hub).

#### Rules

- No zebra stripes — shadow-as-border rows give enough separation
- No vertical column dividers — only horizontal row shadows
- No `<tr>` with `cursor: pointer` unless clicking the row navigates. Decorative hover without action is a lie.
- Column widths: first 1–2 columns can be fixed (ID, date); remaining columns distribute via `auto`. Never use fixed pixel widths on content columns.
- Wrap behavior: long text truncates with `ellipsis`, full value surfaces via tooltip on hover/focus. Never line-wrap inside a table cell — it breaks row rhythm.
- Copy behavior: cells that hold code, IDs, or hashes are `Geist Mono` and click-to-copy with a 1s "Copied" toast confirmation.

### KPI Card Variants

One KPI per panel. If you're tempted to put two metrics in one card, they're different metrics — use two cards.

#### Shared anatomy

- Background: `var(--bg-surface)`, radius 8px, Level 2 elevation, padding 16px
- Eyebrow: Mono Label, `fg-muted`, paired with an optional 14px Lucide icon
- Value: Numeric Hero (36px weight 600 tracking -0.055em), `font-variant-numeric: tabular-nums`
- Unit: 16px weight 400, `opacity: 0.55`, inline with the value
- Delta/meta line: Mono Caption (10px uppercase), `fg-accent` for positive, `danger-body` for negative, directional Lucide icon (14px) leading

#### Variants

**1 · Simple** — eyebrow + value + unit. Nothing else. For values that don't have comparison or trend context (e.g., "Plants online: 14/14").

**2 · Value + delta** — adds one line: directional arrow + magnitude + comparison period ("▲ 1.4 vs prev 30d"). Always state the period — never bare "+4".

**3 · Value + sparkline** — adds a 1.5px line chart below the value, 40–50px tall, full card width, no axis, no grid. Filled 3px endpoint dot. Color: `fg-accent` by default; `danger` when the metric is trending wrong direction.

**4 · Value + target / progress** — adds a 10px progress bar, 999px radius. Fill color: `fg-accent` when on track, `warning` when 70–89% of target at period end, `danger` when <70% at period end. Caption below: `"86 / 100 reports closed · target 90%"`.

**5 · Value + breakdown** — value on top, horizontal stacked bar below (10px tall, 999px radius), semantic-colored segments, Mono Caption legend. Great for "Open reports by status" at-a-glance.

**6 · Comparison (period-over-period)** — single panel with current period value (Numeric Hero) and previous period value smaller beside it (20px weight 500 `fg-muted`). Delta line color-coded. Optional sparkline below showing both periods overlaid.

**7 · Alert KPI** — a normal KPI that flips visual when the value crosses a threshold: value color changes to `warning` or `danger`, leading 16px alert icon replaces the eyebrow icon, 2px left border in the semantic color. Used for SLA breach counts, critical alarm counts, etc.

**8 · Compact (inline row)** — horizontal layout: eyebrow left, value center, delta right. `32px` row height, no internal padding beyond 8px 12px. Used inside panels and popovers, never on the main dashboard.

**9 · Clickable KPI** — identical to any above variant, but `cursor: pointer` + hover state (shadow ring intensifies to `--border-shadow-strong`). Must navigate on click — never open a tooltip or toast. Trailing 14px `arrow-up-right` icon signals the click affordance. Keyboard accessible via `<a>` or `<button>` wrapper.

**10 · Multi-metric grid** — when stakeholders demand density, allow a 3- or 4-up mini-KPI grid inside one card. Each mini has its own eyebrow + value, separated by `1px` shadow dividers. Maximum 4 mini-KPIs per card, maximum one grid per dashboard section. This is the *only* exception to "one KPI per card."

#### KPI rules

- Never show a KPI without a timeframe. "Open reports: 12" is ambiguous — "Open reports · this week: 12" is useful.
- Never round the primary value beyond what the user would in conversation. `96.1%` good; `96%` good; `96.06%` bad.
- Never use a color delta without also using a direction arrow. Color alone fails WCAG (§9).
- Never put the KPI title below the value. Eyebrow always first.
- Never animate the value counting up from zero. The value just is what it is.

### PWA Install & Push-Permission Banners

Every Cenergi app ships as an installable PWA with optional web push (see `pwa-web-push` skill for plumbing). The UI surfaces for asking the user to install and to allow notifications are standardized here — do not re-invent them per app.

Two distinct banners, two distinct moments. Never both at once. Never on first visit.

#### Shared rules

- **Never modal.** Banners are inline, dismissible, non-blocking. If the user doesn't want it, they close it and keep working.
- **Never popover-on-load.** No auto-triggered prompt in the first 10 seconds of any session. Earn the ask.
- **Never re-ask after hard dismiss.** If the user clicks "Don't ask again" or has denied at the OS level, the banner never returns. Re-enabling moves to Settings under user control.
- **Never misrepresent.** Copy describes the actual value ("Get notified when a hazard is assigned to you") — not the mechanism ("Enable push notifications").
- Storage: dismissal state in `localStorage` keyed by banner type (`pwa.install.dismissed_until` / `pwa.push.dismissed_until`) with ISO timestamps. Also surface a "Remind me later" = 14d. Hard dismiss writes `9999-01-01`.
- Respect `prefers-reduced-motion` — no entrance slide, just fade 120ms.

#### 1 · Install banner

**When to show:**
- `beforeinstallprompt` event fired (Chromium/Edge/Android) OR `isIos() && !isStandalonePwa()` (iOS manual path)
- AND user is ≥ 3 sessions deep OR has spent ≥ 5 minutes cumulative in the app
- AND not currently `display-mode: standalone`
- AND not dismissed within the last 14 days (or ever, if hard-dismissed)
- AND not on a critical-path screen (submit-report form, active wizard step)

**Placement:** Top of the main content area, below the app bar but above the page heading. Full-width, `56px` tall, flows with the content — not sticky.

**Appearance:**
- Background: `green.50` light / `rgba(46,165,131,0.14)` dark
- Leading 20px Lucide icon (`download` or `smartphone`) in `fg-accent`
- Copy: one line, `fg-primary`, 14px weight 500 — e.g. *"Install Safety Hub on your phone for offline reporting."*
- Actions right-aligned: `btn-primary` ("Install") + `btn-ghost` ("Not now") + trailing `x` icon-button ("Don't ask again")
- On mobile: stack the two text+actions vertically; "Don't ask again" kebab collapses into a menu
- No shadow beyond a 1px bottom ring `var(--border-shadow) 0 1px 0 0`

**Android / Chromium flow:**
- Click "Install" → `window.deferredPrompt.prompt()` → on accept, track install, toast success, remove banner
- Click "Not now" → dismiss 14d
- Click "Don't ask again" (x) → hard dismiss

**iOS flow (no programmatic install):**
- Click "Install" → open a Level 3 bottom sheet titled *"Add Safety Hub to your Home Screen"*
- Sheet content: 3 numbered steps with inline Lucide icons
  1. *Tap the Share button* (with iOS share-icon glyph, not Lucide)
  2. *Scroll down, then tap "Add to Home Screen"*
  3. *Tap "Add" in the top-right*
- A single "Got it" ghost button closes the sheet; no "Install" button because iOS cannot programmatically install

#### 2 · Push-permission banner

**When to show (gated hard):**
- `Notification.permission === 'default'` (never re-prompt if `denied`)
- AND PushManager is available (`isStandalonePwa()` is required on iOS 16.4+)
- AND user has completed **at least one meaningful action** (submitted a report, acknowledged a hazard, been assigned a task) — never on empty first visit
- AND ≥ 1 day since sign-up
- AND not dismissed within the last 14 days

**Placement:**
- **Desktop:** inline card on the dashboard, Level 2 elevation, between Stats and Recent Activity
- **Mobile:** inline card at the top of the dashboard body, above the first KPI

**Appearance:**
- Level 2 panel, 16px padding, 8px radius
- Leading 24px Lucide `bell` in `fg-accent` inside a 40px `green.50` tile
- Heading (Geist 16px weight 500): *"Stay on top of what matters."*
- Body (Geist 13px `fg-secondary`): one sentence naming 2–3 specific notifications that will fire — e.g. *"We'll notify you when a hazard is assigned to you, a critical alarm is raised at your plant, or an SLA is about to breach."*
- Actions: `btn-primary` ("Turn on notifications") + `btn-ghost` ("Not now") + trailing `x` ("Don't ask again")

**Flow:**
- Click "Turn on" → must be synchronous from the click handler → `Notification.requestPermission()` → on `granted`, call `pushManager.subscribe()` → POST to `/api/push/subscribe` → success toast
- On `denied` → banner transitions to a persistent small inline note on the dashboard: *"Notifications blocked. Re-enable in your browser settings → [Open help]."* Link opens a help modal with OS-specific steps; we never re-prompt
- On `default` (user dismissed the system prompt) → same as "Not now", dismissed 14d

**Browser/OS caveats (not shown to user, but the UI must handle them):**
- Safari (macOS + iOS standalone): only prompt from a click handler — no `await` between click and `requestPermission()`
- Firefox: `Notification.requestPermission()` does not fire permission dialog if PushManager isn't used; our flow calls `pushManager.subscribe()` immediately after grant to guarantee the full stack
- Incognito: `PushManager` is undefined — hide the banner entirely, no error state
- Enterprise-managed device with notifications disabled: permission is always `denied` from the start → banner never shows, no inline note needed

#### Dedicated Settings surface

Both banners have a corresponding section in `/settings` or `/profile` under **"Notifications"**:

- **Install** — if not installed: show the same "Install" CTA with iOS-aware copy. If installed: show "Installed on this device · [Device name]" with a "Remove" action pointing users to browser/OS removal
- **Push** — show the current permission state clearly (Granted / Denied / Not requested), a toggle if Granted (Enable/Disable in-app), and a "Send test" button (targets current device only per §10 toast rules). Below: per-notification-type checklist so users opt-in/out of specific types (SLA warning, Assignment, Critical alarm, etc.)

#### Copy examples (reference)

| Context | Banner copy |
|---------|-------------|
| Install (desktop, Chromium) | *"Install Safety Hub to keep your reports one tap away."* |
| Install (iOS, manual) | *"Add Safety Hub to your Home Screen for offline reporting."* |
| Push (HSE user) | *"Stay on top of what matters.  · We'll notify you when a hazard is assigned to you, a critical alarm is raised, or an SLA is about to breach."* |
| Push (field user) | *"Get notified when your report moves forward.  · We'll ping you when it's triaged, actioned, or resolved."* |
| Push denied fallback | *"Notifications blocked. Re-enable them in your browser settings to get hazard alerts."* |

### Modals & Overlays

- Backdrop: `rgba(13,24,21,0.5)` light / `rgba(0,0,0,0.7)` dark — never blur
- Modal panel: `var(--bg-surface-raised)` background, 12px radius, raised card shadow stack
- Max width: 520px standard, 720px for forms, full-viewport at <640px
- Close button: ghost icon button top-right, 32px hit target

---

## 6. Layout Principles

### Spacing Scale

Base unit: **4px**. Allowed values: `2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 32, 40, 48, 64, 80, 96, 128`. No `3px`, no `5px`, no `28px`. If a design needs a forbidden value, the design is wrong, not the scale.

### Container Widths

- `max-content-narrow`: 640px — auth pages, single-column forms
- `max-content`: 960px — documentation, long-form pages
- `max-content-wide`: 1280px — dashboards, analytics
- `max-content-full`: no cap — data tables, admin lists
- Horizontal page padding: `24px` mobile · `40px` tablet · `64px` desktop

### Grid

- 12-column grid at desktop, 8-gutter (`16px`)
- Breakpoints collapse to 6-col → 4-col → 2-col → 1-col
- Dashboards use explicit `grid-template-columns` per panel — never generic column counts

### Whitespace

- Section vertical rhythm: `64px` desktop / `40px` mobile between major blocks
- Card internal rhythm: `16px` between direct children
- Never use `margin-top` — always `margin-bottom` or flex `gap`. Simplifies vertical rhythm debugging

### Radius Scale

- `2px` — inline code, tags-within-text
- `4px` — checkboxes
- `6px` — buttons, inputs, tab items
- `8px` — cards, list rows (when interactive)
- `12px` — modals, featured cards, image containers
- `999px` — pills, badges, avatar containers

No other radii allowed. No `10px`, no `16px`, no `20px` corners.

---

## 7. Depth & Elevation

Six levels. Every elevated surface picks exactly one — no mixing.

| Level | Shadow Stack | Use |
|-------|--------------|-----|
| **0 · Flat** | none | Canvas, text blocks, inline elements |
| **1 · Ring** | `var(--border-shadow) 0 0 0 1px` | Shadow-as-border only; inputs at rest |
| **2 · Panel** | `var(--border-shadow) 0 0 0 1px, rgba(0,0,0,0.04) 0 2px 4px` | Standard cards, tiles, list items |
| **3 · Raised** | `var(--border-shadow-strong) 0 0 0 1px, rgba(0,0,0,0.06) 0 4px 12px` | Popovers, dropdowns, active modals |
| **4 · Floating** | `var(--border-shadow-strong) 0 0 0 1px, rgba(0,0,0,0.10) 0 12px 24px` | Command palette, top-level dialogs |
| **Focus** | `2px solid var(--ring-focus)` at 1px offset | Overlays all levels on keyboard focus |

### Shadow Philosophy

Shadows never exceed `0.14` opacity for the ring layer or `0.12` for the lift layer. Above those values, the interface starts to *float* — which is dishonest. Real surfaces sit on real canvases. Our shadows should imply weight, not levitation.

Never combine multiple lift layers. Level 2 does not "stack" with Level 3 — you pick one.

### Motion

- Button press: `transform: translateY(-1px)` on hover, `0` on press. Duration: `100ms` · easing: `ease-out`
- Panel entry (modals, popovers): `opacity 0 → 1, translateY(4px → 0)`. Duration: `150ms` · easing: `cubic-bezier(0.22, 1, 0.36, 1)`
- Theme switch: `200ms` color crossfade on tokens, no other easing
- **No motion exceeds 200ms.** No spring physics. No bounces. No shimmer, shine, or pulse on resting elements.

### What the System Does Not Have

- No gradients on UI surfaces (acceptable only inside illustrations and marketing hero backgrounds)
- No glassmorphism — `backdrop-filter: blur()` is forbidden on chrome
- No glow on accents — the green is the green
- No skeuomorphic textures, patterns, or noise
- No scroll-linked animations — parallax, reveal-on-scroll are explicitly banned
- No hover-only state changes on mobile

---

## 8. Charts & Visualizations

**Libraries:**
- **Recharts** (default) — analytics, KPI lines, time-series bars, simple comparisons. Lightweight React-native; keeps continuity with biogas-management. **Use this unless you have a concrete reason to reach for the alternative below.**
- **Apache ECharts** via `echarts-for-react` (advanced) — operational/SCADA monitoring views, real-time dashboards, dual-axis bar+line composites, gauges, radar/spider, heatmaps with tooltips, large datasets (>1k points), markLine/markArea overlays. Used in Biogas Hub `/monitoring` (engine load gauges, gas health radar, generation trend with capacity + budget mark lines).
- **Custom SVG** acceptable for KPI sparklines, bespoke decorative visuals, anything outside both libraries' sweet spot.

### Library Selection

| If the chart needs… | Use |
|---------------------|-----|
| Single line/bar, ≤500 points, standard tooltip | **Recharts** |
| KPI sparkline, status stacked bar, simple time-series | **Recharts** |
| Real-time refresh (polling SCADA, live data) | **ECharts** (`notMerge` + `lazyUpdate` perf) |
| Dual y-axis (e.g. monthly bars + daily lines on shared x) | **ECharts** |
| Gauge / radar / dial / heatmap with tooltips | **ECharts** (operational scope only — see §8 forbidden carve-out below) |
| `markLine` (capacity, budget, threshold) or `markArea` (miss zones) | **ECharts** |
| >1,000 data points without jank | **ECharts** (canvas renderer) |

Both libraries follow the same **palette, stroke widths, grid, axis-label typography, tooltip style, and forbidden patterns** below. The library is an implementation detail; the visual language is not.

### Series Palette (ordered)

Use in this order. Beyond 5 series, rethink the chart.

1. `green.500` `#00A66C` (light) / `green.300` `#5CD8A8` (dark)
2. `green.300` `#5CD8A8` (light) / `green.200` `#8FE0C0` (dark)
3. `warning.solid` `#E89C2A`
4. `info.solid` `#009DDB`
5. `danger.solid` `#D64545`

### Rules

- **Stroke width:** `1.5px` for lines and sparklines. `1px` for axes and grid.
- **Stroke caps & joins:** `stroke-linecap: butt`, `stroke-linejoin: miter`. Crisp lines end where data ends — no rounded softening.
- **Fills:** None under line charts. Bar charts use solid fills. Area charts forbidden in v1.
- **Grid:** dashed `2 3`, color `neutral.200` (light) / `neutral.800` (dark). Only horizontal gridlines — no vertical unless the X axis is categorical.
- **Axes:** Label font Geist Mono 10px uppercase `0.08em` tracking, color `neutral.500`/`neutral.400`. No axis lines themselves — labels float.
- **Data endpoints:** Filled 3px circle at the latest data point of sparklines and single-series lines.
- **Tooltips:** Panel (Level 2) with Geist Mono uppercase label + Geist Sans value. Never the Recharts or ECharts default tooltip — both must be customized to match.
- **Legends:** Bottom-aligned, Geist Mono 10px uppercase. Line indicator is a 10×2px colored block — never a square, never a dot.
- **Animation on mount:** allowed, max 300ms, ease-out. No animation on update.

### Chart Selection Matrix

Pick the chart by the data shape, not the vibe. If the answer isn't on this table, the visualization is probably wrong.

| Data shape | Use | Avoid |
|------------|-----|-------|
| Single value over time (1 series) | Sparkline + KPI · or single-line chart | Gauge, radial, donut |
| Multiple values over time (2–5 series) | Multi-line chart | Stacked area (noisy), pie |
| Multiple values over time (>5 series) | Small multiples (one mini-line per series) | A single chart with 6+ lines |
| Categorical counts (≤8 categories) | Horizontal bar chart | Pie, donut, treemap |
| Categorical counts (>8 categories) | Bar chart with "Other" bucket + drill-down | Trying to show all of them |
| Proportions / parts-of-whole | **Stacked bar (horizontal, 10px tall)** | Pie, donut — forbidden in v1 |
| Two-variable correlation | Scatter plot, 1.5px stroke, filled dots 3px | Bubble chart unless size is meaningful |
| Density across two axes | Heatmap, green ramp 50→700 | 3D surface chart |
| Single progress toward goal | Horizontal progress bar (10px, 999px radius) | Radial progress, gauge |
| Flow / pipeline stages | Horizontal stepped bar with stage labels | Funnel chart (deceptive shapes) |
| Geographic distribution | Map with dot overlay, green intensity | Choropleth rainbow (colorblind-hostile) |
| Distribution / spread | Histogram or box plot | Pie split by buckets |
| Before/after comparison | Paired bar chart (2 bars per category) | 3D clustered bar |

**Universal rules that override the table:**
- If the user will compare values, use position (bars) not angle (pie) or area (bubbles)
- If ≤3 values fit in a sentence, write the sentence — no chart needed
- If the chart has a title that restates what you'd see at a glance, delete the title

### Specialty Visualizations

- **KPI + sparkline:** KPI at Numeric Hero scale, sparkline below, filled endpoint dot, no axis, no grid.
- **Status stacked bar:** 10px tall, 999px radius, semantic color per segment, legend beneath.
- **Heatmap:** green ramp `50 → 700`, 2px gap between cells, 2px radius per cell. Values intensity in hue, never in opacity.
- **Gauges, radial progress, donut charts:** forbidden in **analytics & marketing surfaces** — use a stat card with target delta instead.
  - **Operational/SCADA exception:** half-circle gauges are *permitted* on real-time monitoring dashboards (engine load, plant capacity utilization, gas health bands) where operators read at a glance from across the room. Render via ECharts `series.type: 'gauge'`, no pointer, no tick labels, semantic arc color from §8 palette. Donuts and full-circle radials remain forbidden everywhere.

### Forbidden

- 3D charts (bar, pie, anything)
- Pie and donut charts — replace with stacked bar or small-multiples
- Gradient fills under lines
- Textured patterns inside bars
- Drop shadows on chart elements

---

## 9. Accessibility & WCAG 2.1 AA

The system targets **WCAG 2.1 AA compliance on every surface, every theme, every state**. Accessibility is a token-level responsibility — you should not be able to assemble an inaccessible component out of accessible parts.

### Contrast Requirements

- Normal text (<18px, or <14px bold): minimum **4.5:1**
- Large text (≥18px, or ≥14px bold): minimum **3.0:1**
- UI chrome (borders, focus rings, icon buttons): minimum **3.0:1**

### Verified Token Pairings

| Foreground | Background | Ratio | AA Normal | AA Large |
|------------|------------|-------|-----------|----------|
| `neutral.900` `#16201D` | `neutral.50` `#E5F1F5` | 14.0:1 | ✓ | ✓ |
| `neutral.700` `#3A4842` | `neutral.50` `#E5F1F5` | 7.6:1 | ✓ | ✓ |
| `neutral.600` `#4E5E56` | `neutral.50` `#E5F1F5` | 5.7:1 | ✓ | ✓ |
| `neutral.500` `#6B7D74` | `neutral.50` `#E5F1F5` | 3.7:1 | **fails** | ✓ |
| `surface` `#FCFFFD` | `green.700` `#00724D` | 5.4:1 | ✓ | ✓ |
| `green.700` `#00724D` | `green.50` `#E8F7F1` | 5.4:1 | ✓ | ✓ |
| `neutral.100` `#E4EDE8` | `neutral.950` `#0D1815` | 14.2:1 | ✓ | ✓ |
| `neutral.300` `#B0C4BA` | `neutral.950` `#0D1815` | 8.1:1 | ✓ | ✓ |
| `green.300` `#5CD8A8` | `neutral.950` `#0D1815` | 7.3:1 | ✓ | ✓ |
| `danger.solid` `#D64545` | `#FCFFFD` | 4.0:1 | **fails** — use `#AA3838` body | ✓ |
| `warning.solid` `#E89C2A` | `#FCFFFD` | 2.4:1 | **fails** — use `#7A5210` body | **fails** |

**Rules:**
- `neutral.500` is reserved for UI chrome (Mono Labels, axis text, captions ≥ 14px weight 500). Never use it for body copy on canvas.
- Body text in danger context uses `#AA3838` (5.2:1 on white); `danger.solid` is restricted to pill dots, icons, and text on the `danger.tint` background.
- Warning text always uses `#7A5210` (the `pill-warn` text color). `warning.solid` is only for dots, icons, and chart fills.

### Focus Order & Keyboard

- Every interactive element is keyboard-reachable via `Tab`
- Focus order follows DOM order — `tabindex > 0` is forbidden
- Focus ring visible only on keyboard via `:focus-visible` — `2px solid var(--ring-focus)` at 1px offset
- `Escape` dismisses modals, popovers, command palettes
- `Enter` activates primary action; `Space` activates buttons and toggles
- Arrow keys navigate composite widgets (menus, tabs, radio groups, comboboxes)
- Every page starts with a visually-hidden "Skip to main content" link as first focusable element

### Screen Reader Semantics

- Every icon-only button has `aria-label`
- Every form input has a programmatic `<label for>` — placeholder text is never a substitute
- Toasts use `role="status"` (info/success) or `role="alert"` (error/warning)
- Loading regions set `aria-busy="true"`
- Modal panels: `role="dialog"` + `aria-modal="true"` + `aria-labelledby` pointing to the heading
- Required fields: `aria-required="true"` + visible asterisk (color is not the signal)
- Errors: `aria-describedby` links the input to the error message, announced on focus

### Motion & Sensory

- `prefers-reduced-motion: reduce` disables every `translate`, `scale`, and spring easing. Color crossfades survive but at 80ms max
- No meaning is conveyed by color alone — every semantic state pairs color with a symbol, label, or icon
- No auto-playing motion on mount above `prefers-reduced-motion`
- No content flashes more than 3 times per second (WCAG 2.3.1)

### Forms & Validation

- Inline validation fires on `blur`, not on `input` — avoid mid-typing corrections
- Error messages are specific and actionable ("Enter an email with @" — not "Invalid")
- Success states are announced once (not persistently), via `role="status"`
- Multi-step forms expose current step to screen readers via `aria-current="step"`

---

## 10. Feedback & Microinteractions

Every user action produces a visible response within 100ms. Silence is a bug.

### Interaction Affordance

- Every clickable element ships with `cursor: pointer` — buttons, links, table rows, pill badges used as filters, tab items, expandable panels
- Non-clickable elements never carry `cursor: pointer` (including read-only KPI cards, static badges, table cells)
- Links within body copy are `var(--fg-accent)` with `text-decoration: underline; text-underline-offset: 2px`
- Focus states (keyboard-only, `:focus-visible`) always visible as `2px solid var(--ring-focus)` at 1px offset — never rely on hover as a focus substitute

### Microinteraction Budget

- **Hover:** ≤ 150ms color/shadow crossfade. No `translate`, no `scale`.
- **Press:** instant visual change (color or shadow), no delay.
- **Focus ring:** appears instantly — no fade-in.
- **Panel entry:** 150ms `opacity + translateY(4px → 0)` with ease-out.
- **Toast enter/exit:** 200ms max.
- **Theme switch:** 200ms color crossfade on tokens.
- **Everything else:** if it needs to move, question whether it needs to exist.

Under `prefers-reduced-motion: reduce`, all durations drop to 80ms and all translate/scale is suppressed — color-only crossfades remain.

### Response Tiers

| Action | Visible Response | Timing |
|--------|------------------|--------|
| Button press | `transform: translateY(-1px) → 0` | 100ms ease-out |
| Input focus | Focus ring appears | Instant |
| Field blur (with error) | Icon + inline message | Instant |
| Form submit (<300ms) | Button disables, label → "Saving…" | Instant |
| Form submit (≥300ms) | Inline spinner next to label | After 300ms delay |
| Submit success | Toast (bottom-right) + navigate/clear | On response |
| Destructive action | Confirmation dialog | Before action |
| Background sync | Silent unless it fails | N/A |
| Connection lost | Persistent top banner (dismissible) | On detection |

### Toast Pattern

- Position: bottom-right desktop, top-center mobile
- Stack: maximum 3 visible, FIFO
- Duration: 4s default · 8s for errors · sticky for critical/network errors
- Structure: `[icon] [message] [optional action link] [close]`
- Variants align with semantic tokens (`success`, `warning`, `danger`, `info`)
- Never a replacement for inline form errors — only for out-of-band feedback

### Loading Thresholds

| Duration | Treatment |
|----------|-----------|
| <200ms | No spinner — response feels instant |
| 200–1000ms | Inline spinner at the action point (inside button, adjacent to field) |
| 1000–3000ms | Skeleton UI replacing the loading region (2–3 blocks max, no shimmer) |
| >3000ms | Progress indicator with current step or estimated time |

Never block the entire page for async work — only the affected region.

### Optimistic Updates

- Allowed for user-owned mutations (toggle my setting, delete my comment)
- **Forbidden** for workflow state (don't optimistically mark a hazard report "Resolved" — the backend owns that truth)
- On rollback: toast explains what reverted and why

---

## 11. Error Prevention & Recovery

Make mistakes hard. When they happen, make recovery easy.

### Destructive Actions

Any action that permanently deletes data, breaks a workflow, or affects other users requires a **confirmation dialog** — never a toast-with-undo shortcut. Pattern:

- Modal with specific heading ("Delete report SH-00123?") — not "Are you sure?"
- 1-sentence consequence statement
- Two buttons: primary = the danger verb (filled `danger.solid`), secondary = cancel (ghost)
- Danger button is disabled for 500ms after the modal opens — prevents click-through
- Danger button label repeats the action verb ("Delete report") — never "OK" or "Confirm"

### Disabled States Must Explain Themselves

A disabled control communicates **why** it is disabled:
- Tooltip on hover/focus ("Requires admin role", "Save the draft first")
- Inline hint below the control for forms
- Never a disabled state with no explanation available

### Undo

- Surfaced as a toast with an "Undo" action link, 8s duration
- Available on non-destructive state changes (archived, assigned, marked resolved)
- Destructive deletes do not get undo — they use confirmation dialogs instead

### Draft Preservation

- Form drafts auto-save to `localStorage` every 30s and on blur
- On page reload or crash, the form offers "Restore your draft from HH:MM" as a one-click action
- Drafts persist across sessions until explicitly submitted or discarded

---

## 12. Progressive Disclosure

Show the minimum that lets a user make a decision. Hide the rest behind one clear interaction.

### Patterns

- **Expandable sections.** Advanced options default-collapsed, labeled ("Advanced", "Show 3 more"). Chevron rotates on expand.
- **Wizards.** Multi-step flows (submit report, create plant) break into ≤5 screens with a visible progress indicator. Each step commits independently when possible.
- **Inline expand.** Table rows expand in place for details rather than navigating away.
- **Tooltip definitions.** Technical terms (SLA, MTTR, CH₄) get a dotted underline and tooltip on hover/focus — never a modal.

### Density Rules

- Primary surface: 3–5 pieces of information maximum per card
- List rows: title + 1 line of meta + 1 primary action — anything more collapses behind "View details"
- Dashboards: one KPI per panel, one chart per panel — no cramming

---

## 13. Semantic HTML & SEO

Internal Cenergi apps live behind auth and do not need indexing. Public-facing surfaces (landing, legal, documentation, marketing) do. This section's semantic-HTML rules apply to **both contexts**; SEO-specific rules apply only to public surfaces.

### Every page (internal or public)

- Exactly one `<h1>` per route
- Heading hierarchy is linear: `h1 → h2 → h3` — never skip levels
- Landmark elements: `<header>`, `<nav>`, `<main>`, `<aside>`, `<footer>` — exactly one `<main>` per page
- Images have meaningful `alt` text (or `alt=""` when purely decorative — never omit the attribute)
- Navigating elements use `<a>`; acting elements use `<button>` — never swap
- Repeating items use `<ul>`/`<ol>`, not `<div>` stacks
- Form controls pair with `<label>` via `for`/`id`
- Page `<title>`: `{Page Title} · {App Name}` pattern, ≤ 60 chars

### Public pages only

- Meta description: 150–160 chars, action-led
- Open Graph: `og:title`, `og:description`, `og:image` (1200×630px), `og:url`, `og:type`
- Twitter card: `summary_large_image`
- Canonical URL on every page (`<link rel="canonical">`)
- `<meta name="robots" content="index, follow">`
- JSON-LD structured data where applicable (`Organization` on landing, `FAQPage` on legal)
- `sitemap.xml` generated at build, `robots.txt` references it
- Font preload for above-the-fold: `<link rel="preload" as="font" crossorigin>` for Geist 600
- Core Web Vitals targets: LCP < 2.5s · CLS < 0.1 · INP < 200ms

### Internal pages

- `<meta name="robots" content="noindex, nofollow">`
- Skip-to-content link mandatory (it's the first focusable element)
- Every page keyboard-navigable and tested

### Route-level metadata (Next.js App Router)

Every Cenergi internal app runs on Next.js App Router. Each route must declare its metadata at the route level — no `<Head>` tags inside components, no client-side title manipulation. The framework hoists `metadata` exports into the document head; this is the only correct way to set per-route titles, descriptions, and OG tags.

**Every `page.tsx` exports `metadata`** (static) **or `generateMetadata`** (dynamic, for routes whose title depends on data — e.g. `/reports/[id]`).

#### Required fields — internal pages

```ts
// app/(dashboard)/reports/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Reports · Safety Hub",                    // pattern: "{Page} · {App}"
  description: "Hazard reports, triage, and CAPs.",  // 1 sentence, used in browser tab + bookmarks
  robots: { index: false, follow: false },           // internal — never indexed
};
```

- **Title** follows the `"{Page} · {App}"` pattern. ≤ 60 chars. Visible in browser tabs, OS task switchers, and bookmarks — make it scannable.
- **Description** is a single concrete sentence. Even on internal pages — it shows in browser bookmark managers and shared-link previews.
- **Robots** — every internal route is `{ index: false, follow: false }`. Set this on each `page.tsx`, never rely on a single root-level fallback.

#### Required fields — public pages (landing, legal, login, /docs if public)

```ts
// app/(public)/login/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Sign in · Cenergi Safety Hub",
  description: "Sign in to Cenergi Safety Hub to report hazards and review safety incidents.",
  robots: { index: true, follow: true },
  alternates: { canonical: "https://safetyhub.my/login" },
  openGraph: {
    title: "Sign in · Cenergi Safety Hub",
    description: "Sign in to Cenergi Safety Hub to report hazards and review safety incidents.",
    url: "https://safetyhub.my/login",
    siteName: "Cenergi Safety Hub",
    images: [{ url: "https://safetyhub.my/og.png", width: 1200, height: 630 }],
    type: "website",
    locale: "en_MY",
  },
  twitter: { card: "summary_large_image" },
};
```

- All §13 "Public pages only" requirements (canonical, OG, Twitter card, structured data) are expressed via `metadata`, not hand-rolled `<meta>` tags.
- OG image is a static asset under `/public/og/<route>.png`, 1200×630 px. Use a per-route image when the route's content warrants one; otherwise the app-level default.
- For `og:locale` use `en_MY` — Cenergi audiences are Malaysian English unless the route is explicitly Malay (`ms_MY`).

#### Dynamic routes — `generateMetadata`

For detail routes whose title depends on data, use `generateMetadata` instead of `metadata`. It runs server-side, can fetch, and shares Next's per-request cache with the page itself.

```ts
// app/(dashboard)/reports/[id]/page.tsx
import type { Metadata } from "next";

export async function generateMetadata(
  { params }: { params: Promise<{ id: string }> }
): Promise<Metadata> {
  const { id } = await params;
  const report = await getReport(id);
  return {
    title: `${report.referenceNo} · Reports · Safety Hub`,
    description: `Hazard report ${report.referenceNo} — ${report.classification} severity.`,
    robots: { index: false, follow: false },
  };
}
```

- The fetch inside `generateMetadata` MUST be the same call (or the same cached call) as the page body — Next dedupes when the request is identical. Don't run two queries.
- If the resource is missing, return a fallback title (`"Report not found · Safety Hub"`) — never throw, the framework will render a 404.

#### App-level defaults — `app/layout.tsx`

The root layout sets the metadata floor that every route inherits unless overridden:

```ts
// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: { default: "Cenergi Safety Hub", template: "%s · Safety Hub" },
  description: "Cenergi SEA hazard reporting and safety management.",
  applicationName: "Safety Hub",
  authors: [{ name: "Cenergi SEA" }],
  metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL ?? "https://safetyhub.my"),
  icons: {
    icon: "/favicon.ico",
    apple: "/apple-touch-icon.png",
  },
  manifest: "/manifest.webmanifest",                 // PWA — required (see PWA Banners §5)
  themeColor: "#00698F",                             // matches Cenergi brand primary
  formatDetection: { telephone: false, email: false, address: false },
  // Default robots — overridden per-route as needed
  robots: { index: false, follow: false },
};
```

- Use `template: "%s · Safety Hub"` so per-route `title: "Reports"` becomes `"Reports · Safety Hub"` automatically.
- `metadataBase` is required for resolving relative OG image URLs — set it from the env var so dev/staging/prod each compute their own absolute URLs.
- Internal apps default to `noindex, nofollow` at the root; public routes opt in.

#### Anti-patterns

- **`<Head>` from `next/head`** — does not exist in App Router. Use the metadata API exclusively.
- **`document.title = …` from a client component** — flashes the wrong title before the swap, breaks back-button restoration. Always set in metadata.
- **Repeated metadata on every page when the layout would do** — if 30 pages share the same description, set it on the segment layout, override only where it differs.
- **OG image generated on the fly in dev** — slows page generation. Generate at build time or pre-place the static asset.
- **Missing `metadataBase`** — relative OG URLs silently break in production. Always set it at the root.

---

## 14. Do's and Don'ts

### Do

- Use `box-shadow 0 0 0 1px` as every border in the system
- Pin Geist at three weights: 400 body, 500 interactive, 600 announcing
- Apply negative tracking at every display size — `-0.055em` at 42px, scaling down
- Reserve `#00A66C` for primary action, live values, and success only
- Use Geist Mono uppercase for every operator label, timestamp, and plant code
- Read theme values via CSS custom properties — never raw hex in components
- Force tabular figures (`"tnum"`) on every KPI, time, and table-column number
- Auto-switch theme on MYT 19:00/06:00 unless the user has overridden
- Keep shadow opacity at or below `0.14` (ring) and `0.12` (lift)
- Cap section motion at 200ms with simple ease-out

### Don't

- Don't use CSS `border` — ever — outside of decorative illustrations
- Don't use weight 700 or 300 anywhere in the UI chrome
- Don't uppercase Geist Sans; uppercase is Geist Mono's job only
- Don't use gradients, glassmorphism, glow, shimmer, or parallax in the product
- Don't use pie, donut, radial, or 3D charts
- Don't mix elevation levels on one surface (no Level 2 + Level 3 stacks)
- Don't animate color-value tokens directly — animate the token reference via theme transition only
- Don't use the accent green decoratively (e.g., as a background on a generic hero section)
- Don't introduce new semantic colors — `danger / warning / success / info` is the complete set
- Don't design dark-only or light-only screens; every component renders both

---

## 15. Responsive Behavior

### Breakpoints — aligned to Tailwind

Every Cenergi app ships on Tailwind CSS. Breakpoints match the framework defaults so mental model, codebase, and design system agree.

| Token | Min Width | Tailwind | Columns | Padding | Key Changes |
|-------|-----------|----------|---------|---------|-------------|
| *(base)* | 0 | default | 1 | 16px | Compact density, hamburger drawer, stacked everything |
| `sm` | 640px | `sm:` | 1 | 24px | Standard mobile landscape, pill-wide buttons |
| `md` | 768px | `md:` | 2 | 32px | 2-col dashboards, side panels appear |
| `lg` | 1024px | `lg:` | 3 | 48px | Full sidebar, main working range |
| `xl` | 1280px | `xl:` | 3–4 | 64px | Max-content 1280 cap engages |
| `2xl` | 1536px | `2xl:` | 4 | 64px (centered) | Generous margins, large displays |

Mobile-first: styles cascade up. Never write `max-width` media queries; always min-width via Tailwind's prefix.

### Mobile patterns (base · sm)

At `<md` (under 768px) the system switches to a distinct mobile layout. Not a scaled-down desktop — a different arrangement.

**Navigation — bottom tab bar is canonical.** Five tabs maximum: four primary destinations + "More". No hamburger menu, no edge-drawer, no gestures. Everything reachable in one thumb-tap.

- Height 56px + 20px home-indicator safe area = 76px total footprint
- Each tab: 20px Lucide icon + 9px Geist Mono uppercase label (`0.06em`), 44px minimum hit area
- Active tab: `fg-accent` color, inactive: `fg-muted`
- Background: `var(--bg-surface)`, top shadow `0 -1px 0 0 var(--border-shadow)`
- The 5th tab is always **"More"** using the `menu` icon — opens a full-screen list (not a sheet) with grouped sections: Admin, HSE tools, Account, Sign out

**Top app bar.** 56px height, no leading icon on primary tabs (title sits left-aligned). Leading slot used only on drill-in screens — always a back `chevron-left`, never a hamburger. Trailing slot holds at most one contextual action (bell, filter, overflow).

**Modals → bottom sheets.** On mobile, destructive confirmations and content modals slide up from the bottom:

- Border radius `16px 16px 0 0`, padding `20px 20px 32px` (bottom pad clears the home indicator)
- 36×4px grab handle centered at top (not interactive — signal only)
- Full-width action buttons at the bottom, stacked or side-by-side (max 2)
- Backdrop `rgba(13,24,21,0.5)` light / `rgba(0,0,0,0.6)` dark — no blur
- Dismissible by tap-backdrop, swipe-down, or back gesture

**Toasts — top-center on mobile.** Desktop bottom-right conflicts with thumb reach and the home indicator. On mobile, stack from the top edge with 10px offset, left/right 16px inset, max 3 visible, same content spec (§10).

**Tables — responsive ladder.** Every Cenergi table is responsive across the full breakpoint range. Columns drop progressively as the viewport shrinks; at `<md` the table-tag itself dies and rows transpose to cards. There is no horizontal scroll on user-facing data tables (sticky-first-column horizontal scroll is allowed only for admin matrices like KPI heatmaps — see "Wide matrices" below).

**Column priority taxonomy.** When you add a column, assign it a priority. The priority maps to a Tailwind hide class. Columns drop in priority order as the viewport narrows.

| Priority | Always visible at | Tailwind class on the `<th>` and `<td>` | Use for |
|---|---|---|---|
| **P0** essential | all breakpoints | *(no hide class)* | Primary identifier, status |
| **P1** important | `md` and up | `hidden md:table-cell` | Owner, plant, role, severity |
| **P2** useful | `lg` and up | `hidden lg:table-cell` | Timestamps, SLA, last-updated |
| **P3** supplementary | `xl` only | `hidden xl:table-cell` | Long lists, secondary metadata, IDs |

Every table must have **exactly one** P0 identifier column. Status badge can be P0 or P1 depending on whether the row is actionable from the list (P0 if yes).

**Breakpoint ladder.** The table renders progressively across viewport sizes:

| Viewport | Tailwind | Renders |
|---|---|---|
| `xl` (≥1280) | `xl:` | All P0 + P1 + P2 + P3 columns |
| `lg` (1024-1279) | `lg:` | P0 + P1 + P2 (P3 hidden) |
| `md` (768-1023) | `md:` | P0 + P1 (P2 + P3 hidden) |
| `sm` (640-767) | `sm:` | P0 only + chevron drill-in column |
| `base` (<640) | — | Card-per-row stack (`<table>` forbidden) |

**Variable-length list columns** (permissions, assignees, tags, attachments) render as `<count> · View all` regardless of viewport — never inline a list whose length the operator can't bound. Treat as P3 minimum. The count is a tappable affordance that opens a popover (desktop) or drill-in (mobile).

**Card-per-row at `<md`.** A `<table>` at `<md` is forbidden. Transpose each row to a stacked card:

- Primary line: ID (Mono Label 10px) + title (14px weight 500)
- Badge: rightmost on primary line, status pill
- Meta row: P1 columns rendered as small mono chips separated by dots
- Optional severity left-border 2px in semantic color (same rule as desktop §5 status rows)
- Entire card is tappable and navigates — no secondary row actions; actions live on the detail screen
- Variable-length lists collapse to a `<count> items` chip in the meta row

**Worked example — Roles table** (the one in admin/user-management):

```jsx
{/* Desktop / lg+ : table with 4 columns. Mobile: card-per-row */}
<>
  {/* Desktop+tablet table (md and up) */}
  <table className="hidden md:table w-full">
    <thead>
      <tr>
        <th className="text-left">Role</th>
        <th className="text-left">Code</th>
        <th className="hidden lg:table-cell text-left">Description</th>
        <th className="text-right">Permissions</th>
      </tr>
    </thead>
    <tbody>
      {roles.map(r => (
        <tr key={r.id}>
          <td>{r.name}</td>
          <td><Mono>{r.code}</Mono></td>
          <td className="hidden lg:table-cell text-muted">{r.description}</td>
          <td className="text-right">
            <button className="link" onClick={() => openPerms(r)}>
              {r.permissions.length} · View all
            </button>
          </td>
        </tr>
      ))}
    </tbody>
  </table>

  {/* Mobile card-per-row (<md) */}
  <ul className="md:hidden divide-y">
    {roles.map(r => (
      <li key={r.id}>
        <Link href={`/admin/roles/${r.id}`} className="flex items-center gap-3 py-3">
          <ShieldIcon className="h-4 w-4 text-accent" />
          <div className="flex-1">
            <div className="text-sm font-medium">{r.name}</div>
            <div className="mt-0.5 flex gap-2 text-xs text-muted">
              <Mono>{r.code}</Mono>
              <span>·</span>
              <span>{r.permissions.length} permissions</span>
            </div>
          </div>
          <ChevronRightIcon className="h-4 w-4 text-muted" />
        </Link>
      </li>
    ))}
  </ul>
</>
```

The `Description` column drops at `<lg`. The `Permissions` column is variable-length, so it always renders as `count · View all`, never inline. Below `md` the entire structure switches to a card list — the `<table>` element is gone.

**Wide matrices** (e.g., plant-by-month KPI grid, audit cross-tab) — the only allowed exception. These can use horizontal scroll with `position: sticky; left: 0` on the first column at all breakpoints. Tag with `data-table-variant="matrix"` so reviewers know the responsive ladder is intentionally bypassed.

**DON'T:** ship a desktop `<table>` without column-priority `hidden md:` / `hidden lg:` classes. Without them, columns overflow and clip on tablet and phone — the third column gets sliced mid-word, the operator can't see what they're acting on, and the layout looks broken. Every column needs a declared priority before it ships.

**Pagination → "Load more".** Pagination row is forbidden on mobile. Replace with a centered `btn-secondary` button reading `Load more · N remaining`. On scroll-end-reach, auto-load if user-preferred; otherwise manual. Never infinite-scroll data a user will want to search — it breaks the back button and scroll position.

**Tabs — horizontal scroll.** Underline tabs at mobile: same style as desktop, but the row scrolls horizontally with `scrollbar-width: none`. Active tab auto-scrolls into view on mount. Never wrap tabs to a second row.

**Wizards.** Compact step indicator at the top (20px bullets + short labels, horizontal scroll if needed). Sticky action footer at the bottom with Back (ghost, flex:1) + Continue (primary, flex:2). Each step's content scrolls between the two.

**Forms.** Inputs grow to 44px height, 14px font. Labels above inputs (never floating, never right-aligned). Full-width buttons in stacked groups. Select elements use the native picker — no custom dropdowns on mobile.

**Banner (connectivity, draft-mode).** Full-width at the very top, above the app bar. `36px` height, 12px font, semantic-tinted background. Dismissible only when non-critical.

**Density.** Mobile ships in comfortable density only — compact is desktop/tablet-only. Field users on gloves need the tap targets.

### Breakpoint-by-breakpoint behavior

| Component | base (<640) | sm–md | lg+ |
|-----------|-------------|-------|-----|
| Primary nav | Bottom tab bar (5) | Bottom tab bar | Sidebar (240px) |
| Secondary nav | "More" tab → full-screen list | "More" tab | Sidebar sections |
| Tables | Card-per-row | P0 + P1 columns | P0 + P1 + P2 (lg) → all (xl) |
| Modals | Bottom sheet | Bottom sheet | Centered dialog |
| Toasts | Top-center stack | Top-center | Bottom-right |
| Pagination | Load more button | Load more | Numeric paging |
| Tabs | Horizontal scroll | Horizontal scroll | Fixed row |
| Dashboard grid | 1 column | 2 columns | 3–4 columns |
| Forms | Stacked fields | Stacked fields | 2-col grid |
| Charts | Maintain aspect, legend wraps | Maintain aspect | Native size |

### Touch targets

Minimum `44px × 44px` at `<md`. Buttons ship at 44px height. Icon-only buttons are 44×44. Inputs are 44px tall. Checkbox/radio hit areas extend to 44px via padded label, even though the visual control is 16px. Adjacent tap targets need `8px` gap minimum.

### What we do NOT do on mobile

- Hamburger menu — replaced by bottom "More" tab
- Edge-swipe gestures for nav — unreliable on Android, interferes with iOS system gestures
- Tiny 32px icon buttons — everything touch-accessible is 44px
- Long-press menus — discoverable only to power users; use explicit tap affordances
- Pinch-zoom on custom UI — rely on browser zoom if needed
- Fullscreen overlays on drill-ins — slide-in screens via router, not modals
- Bottom-right FAB floating over content — primary action belongs in the app bar or sticky footer

### Chart Collapsing

- Sparklines: survive untouched to mobile S
- Line and bar charts: maintain aspect ratio down to 320px width; rotate X labels 45° below 600px
- Legends move below chart at <640px

### Density Variants

Two density modes, user-togglable in profile:
- **Comfortable** (default): 36px input height, 44px row height, 16px card padding
- **Compact** (field tablets, control-room screens): 32px input height, 36px row height, 12px card padding

Density only affects spacing — never font size, never color.

---

## 16. Agent Prompt Guide

### Quick Token Reference

- Canvas: `#E5F1F5` light / `#0D1815` dark
- Brand primary: `#00698F` light / `#5CC8F0` dark
- Action accent: `#00A66C` light / `#5CD8A8` dark
- Primary text: `#16201D` light / `#E4EDE8` dark
- Secondary text: `#4E5E56` light / `#B0C4BA` dark
- Border (shadow): `rgba(22,32,29,0.08)` light / `rgba(228,237,232,0.08)` dark
- Focus ring: `2px solid var(--ring-focus)` at 1px offset

### Example Component Prompts

- *"Build a hero on the blue-tinted canvas (`#E5F1F5`). Eyebrow in Geist Mono 10px uppercase letter-spacing `0.14em` color `#6B7D74`. Headline in Geist 42px weight 600 letter-spacing `-0.055em` line-height 1.02 color `#16201D`, with the last phrase in `#00A66C`. Subtitle Geist 16px weight 400 letter-spacing `-0.01em` color `#4E5E56` max-width 42ch. Primary button (`#00724D` bg, `#FCFFFD` text, 6px radius, 7px 14px padding) and secondary button (`#FCFFFD` bg, shadow `rgba(22,32,29,0.08) 0 0 0 1px`)."*

- *"Build a KPI panel on off-white surface, 8px radius, shadow stack `rgba(22,32,29,0.08) 0 0 0 1px, rgba(0,0,0,0.04) 0 2px 4px`, 16px padding. Eyebrow in Geist Mono 10px uppercase color `#6B7D74`. Number at 36px Geist weight 600 letter-spacing `-0.055em` tabular-nums color `#16201D`, with unit suffix at 16px weight 400 opacity 0.55. Delta line in Geist Mono 10px uppercase color `#00A66C` for positive, `#D64545` for negative."*

- *"Design a status pill: 11px Geist weight 500, 2px/10px padding, 999px radius, leading 6px dot. For 'Online': bg `#E8F7F1`, text `#00724D`, dot `#2ECA8E`. For 'Alarm': bg `#FDE4E4`, text `#871C1C`, dot `#D64545`. For 'Pending': bg `#FDF1D9`, text `#7A5210`, dot `#E89C2A`."*

- *"Build a line chart with 3 series. Stroke 1.5px. Series 1 `#00A66C`, series 2 `#5CD8A8`, series 3 `#E89C2A`. Horizontal gridlines dashed `2 3` color `#D0DDD6`. Axis labels Geist Mono 10px uppercase letter-spacing `0.08em` color `#6B7D74`, floating — no axis lines. Tooltip is a Level 2 panel with Geist Mono uppercase label and Geist Sans value. Filled 3px endpoint dot on the latest point of each line."*

- *"Build a sidebar nav. 240px wide, off-white background (`var(--bg-surface)`), right-edge shadow `rgba(22,32,29,0.08) 1px 0 0 0`. Section label in Geist Mono 10px uppercase letter-spacing `0.1em` color `#6B7D74`, 16px top / 8px bottom padding. Nav item at 13px Geist weight 500, 8px/12px padding, 6px radius. Active state: `#00A66C` text on `#E8F7F1` background. Inactive: `#4E5E56` text, transparent background."*

### Iteration Guide

1. **Every border is a shadow.** `box-shadow: 0 0 0 1px rgba(22,32,29,0.08)` replaces every `border: 1px solid`. This is not a stylistic choice — it is the system's structural foundation.
2. **Tracking scales with size.** At 42px → `-0.055em`. At 24px → `-0.035em`. At 14px → `-0.005em`. Never zero. Never positive (except uppercase mono).
3. **Three weights only.** 400 reads, 500 interacts, 600 announces. No 700. No 300.
4. **Green is a resource, not a decoration.** Primary CTA, live KPI, success state. That's it. If you're reaching for green for anything else, reach for `neutral.700` or the warning amber instead.
5. **Theme tokens are the API.** A component should never ship with a hex value. It ships with `var(--fg-accent)` and trusts the token resolution.
6. **Crisp means no softness.** No gradient, no blur, no glow, no shimmer. If it moves, it completes in under 200ms with a single ease-out.
7. **Geist Mono uppercase is the operator voice.** Timestamps, plant codes, field labels, status codes, legends, axis labels. Everything field-shaped runs through it.

---

---

## 17. Tech Stack Binding

The design system is not theoretical — it maps to a single reference stack. Every Cenergi internal app uses this stack unless a documented exception exists.

| Layer | Technology | Version | Notes |
|-------|------------|---------|-------|
| Framework | Next.js | 16.x | App Router, Server Components default |
| UI runtime | React | 19.x | Server Components + use client boundaries |
| Styling | Tailwind CSS | v4.x | `@theme` tokens, no `tailwind.config.js` extensions beyond tokens |
| Design tokens | CSS custom properties | — | Tokens defined in `app/styles/tokens.css`, referenced via `@theme` |
| Primitives | shadcn/ui (selective) | latest | Dialog, Popover, Dropdown, Tabs, Select — no full shadcn install, copy as needed |
| Icons | Lucide | latest | 1.5px stroke, `currentColor`, sizes 14/16/20/24 only |
| Charts (default) | Recharts | 2.x | Analytics, KPIs, simple time-series. Customize — never the defaults |
| Charts (advanced) | ECharts via `echarts-for-react` | 5.x / 3.x wrapper | Operational dashboards, gauges, radar, dual-axis, real-time, large datasets. Same visual rules as Recharts (§8) |
| Fonts | Geist + Geist Mono | Self-hosted | Via `next/font`, subset Latin, `swap` display |
| Auth | NextAuth + Cenergi Auth Hub | — | OIDC via `auth.rujilabs.com` |
| Database | Prisma + MySQL 8 | — | Tokens never touch DB |
| State | React Query | 5.x | Server state; local UI state uses React hooks |
| Forms | React Hook Form + Zod | latest | Inline validation on blur, schema per endpoint |
| Toasts | Custom (per §10) | — | Not `react-hot-toast` default — rewrite to match spec |
| Testing | Vitest + Playwright | — | Unit + e2e |
| A11y tooling | `eslint-plugin-jsx-a11y`, `@axe-core/react`, `pa11y-ci` | — | Baseline 0 errors, 0 warnings |

**Exceptions:** If an app must deviate (older Next, legacy Recharts v1, a third chart library, etc.), document in that app's `docs/design-exceptions.md` with sunset date. Mixing Recharts + ECharts in the same app is *not* an exception — it is the documented pattern when the use cases call for both.

---

## 18. Industry Anti-Patterns — HSE & Industrial Ops Context

We build tools for plant operators, HSE officers, and safety leaders in palm oil, biogas, and solar operations. Users make decisions that affect fires, gas leaks, injuries. This is not a consumer app and it must never feel like one.

### Absolutely forbidden

- **Gamification on safety workflows.** No streaks, no badges, no "congrats!" confetti for submitting a hazard report. The reward is that the report helped someone. Dopamine loops cheapen the work.
- **AI-aesthetic gradients.** Purple-to-pink glow, mesh gradients, "auroraborealis" backgrounds. These signal consumer LLM wrappers and are incompatible with industrial seriousness.
- **Consumer marketing patterns.** No countdown timers, social-proof popups ("12 people reported today!"), progress shame ("only 40% complete!"), sticky upsell banners.
- **Anthropomorphic AI avatars.** No chatbot faces, no cartoon mascots. If we expose AI assistance, it's text-only, clearly labeled, and inline with the task.
- **Dark patterns.** No pre-checked marketing consents, no hidden destructive actions behind friendly verbs, no "Are you sure?" interrupts that are really confirmation for *opting in* to something.
- **Decorative 3D, glassmorphism, parallax.** These soften what must stay sharp. Hazard reports aren't Dribbble shots.
- **Auto-playing sound or video.** Field users are often in shared or sensitive environments.
- **Skeletons that shimmer.** Skeleton UI itself is fine (§10) — the shimmer animation is the consumer tell.

### Context-specific don'ts

- **Don't hide critical info behind hover.** Field users work on tablets, gloves, and phones. Hover is not a primary interaction. Any meaningful state must be visible at rest.
- **Don't use red/green alone.** Color-blindness is real; site contrast is worse at midday on a plant floor. Every semantic state pairs color with a symbol or label (§9).
- **Don't require perfect connectivity.** Offline-safe forms, draft preservation, retry-on-reconnect. A blown-out toast "Network error" with no data saved is a Sev-1 UX bug.
- **Don't ship long text without line-length limits.** Operators read at 3 AM after a 12-hour shift. 66ch max for reading; 42ch for card copy.
- **Don't localize units silently.** kW, °C, kg — always show the unit. Never rely on implicit context.

### Context-specific do's

- **Lead with the action, then the detail.** Dashboard cards: "12 open reports" big, then the breakdown.
- **Let the data breathe.** One KPI per panel. One chart per card. If the screen feels dense, it is.
- **Name the user.** "rhse@cenergi-sea.com assigned to SH-00123" — not "Assignee: user_0412". Operators know each other.
- **Show timestamps in MYT.** Relative when fresh ("3 min ago"), absolute when older ("2026-04-22 14:32 MYT"). Never UTC in-product.

---

## 19. Pre-Flight Checklist — Definition of Done

Run this against every screen before ship. If any answer is "no," the screen is not done.

### Visual & Content

- [ ] Exactly one `<h1>` on the page
- [ ] Headline tracking matches the size tier (§3)
- [ ] No hex values in components — everything reads from CSS custom properties
- [ ] All borders are `box-shadow 0 0 0 1px`, not `border: 1px solid`
- [ ] Only the green ramp, neutral ramp, and 4 semantic colors appear
- [ ] Geist Mono uppercase for all labels, timestamps, plant codes, axis labels
- [ ] All numeric displays use tabular figures (`"tnum"`)
- [ ] Body text never uses `neutral.500` (contrast fail)
- [ ] Danger body text uses `#AA3838`, warning body text uses `#7A5210`

### Interaction

- [ ] Every clickable element has `cursor: pointer`
- [ ] Every icon-only button has `aria-label`
- [ ] Every form input has a `<label for>` — not just a placeholder
- [ ] Destructive actions go through a confirmation dialog (§11)
- [ ] Hover signals via color/shadow — no `translate`, no `scale`
- [ ] Press feedback visible within 100ms
- [ ] Disabled controls explain why they're disabled
- [ ] Form drafts auto-save every 30s and on blur

### Accessibility

- [ ] Tab order follows DOM order — no `tabindex > 0`
- [ ] Focus ring visible on every interactive element via `:focus-visible`
- [ ] Every color-coded state also has a symbol or label
- [ ] Contrast ≥ 4.5:1 for body text, ≥ 3:1 for UI chrome (verify with axe)
- [ ] `prefers-reduced-motion` kills translate/scale and clamps durations to 80ms
- [ ] Skip-to-content link is the first focusable element
- [ ] Modal has `role="dialog"`, `aria-modal="true"`, `aria-labelledby`

### Theme & Responsive

- [ ] Screen renders in both light and dark themes without override
- [ ] Auto theme switch respects MYT 19:00/06:00 rule
- [ ] Layout works at `base`, `md` (768px), and `lg` (1024px)
- [ ] Tables transpose to card-per-row at `base` breakpoint
- [ ] Touch targets ≥ 44×44px on `base` / `sm`

### Data & Charts

- [ ] No pie, donut, radial, or 3D chart anywhere
- [ ] Chart stroke is 1.5px, caps `butt`, joins `miter`
- [ ] Chart tooltip uses Level 2 panel style (§7)
- [ ] Empty states have a specific message and next action — not "No data"
- [ ] Loading >200ms shows a spinner; >1s shows skeleton
- [ ] Error states offer a retry action

### SEO & Semantic (public pages only)

- [ ] `<title>` follows `{Page} · {App}` pattern, ≤ 60 chars
- [ ] Meta description 150–160 chars, action-led
- [ ] OG image 1200×630
- [ ] Canonical URL set
- [ ] `robots` meta set to `index, follow` (internal pages: `noindex, nofollow`)
- [ ] LCP < 2.5s, CLS < 0.1, INP < 200ms (verify with Lighthouse/PageSpeed)

### Final pass

- [ ] Run `axe` — 0 violations
- [ ] Run `pa11y-ci` — 0 errors
- [ ] Run Lighthouse — Performance ≥ 90, Accessibility = 100
- [ ] Screenshot both themes for PR description
- [ ] Keyboard-only walkthrough: every interactive reachable and usable

---

*Design system v1.0 · Locked 2026-04-25 · Owned by Daus · Applies to every Cenergi internal app going forward.*
