---
name: Edgecom Design System
description: >-
  The visual language and usage rules for Edgecom Energy's product UI — an
  energy management & monitoring platform (dataTrack™, pTrack®, NeuraCharge™).
  Read this before generating or designing any Edgecom UI, then follow it.
version: 1.0
# @@GENERATED:tokens — colors/typography/rounded are generated from src/app/globals.css by scripts/gen-design-md.mjs (run `pnpm docs:gen`); do not hand-edit until the closing marker. Light (canonical) OKLCH values; dark deltas are in the body.
colors:
  background: oklch(1 0 0)
  foreground: oklch(0.16 0.01 254)
  card: oklch(1 0 0)
  card-foreground: oklch(0.16 0.01 254)
  popover: oklch(1 0 0)
  popover-foreground: oklch(0.16 0.01 254)
  elevated: oklch(0.985 0.002 254)
  elevated-foreground: oklch(0.16 0.01 254)
  scrim: oklch(0.16 0.01 254 / 0.4)
  # Brand blue — deliberately identical in light and dark
  primary: oklch(0.514 0.161 254.3)
  primary-foreground: oklch(0.985 0 0)
  primary-emphasis: oklch(0.5 0.161 254.3)
  primary-subtle: oklch(0.965 0.025 254)
  secondary: oklch(0.967 0.003 254)
  secondary-foreground: oklch(0.21 0.01 254)
  muted: oklch(0.968 0.004 254)
  muted-foreground: oklch(0.55 0.02 254)
  accent: oklch(0.965 0.012 254)
  accent-foreground: oklch(0.35 0.03 254)
  # Status — each also has -foreground / -subtle-foreground (see body)
  success: oklch(0.536 0.1 176.6)
  success-emphasis: oklch(0.5 0.12 177.8)
  success-subtle: oklch(0.965 0.032 177.8)
  warning: oklch(0.802 0.171 73.3)
  warning-emphasis: oklch(0.52 0.12 70)
  warning-subtle: oklch(0.965 0.05 85)
  info: oklch(0.53 0.13 220)
  info-emphasis: oklch(0.5 0.13 220)
  info-subtle: oklch(0.965 0.032 220)
  destructive: oklch(0.577 0.245 27.325)
  destructive-emphasis: oklch(0.505 0.2 27)
  destructive-subtle: oklch(0.965 0.028 27)
  border: oklch(0.922 0.005 254)
  input: oklch(0.922 0.005 254)
  ring: oklch(0.514 0.161 254.3)
  # Commodity categorical ramp (chart series) — mode-independent
  chart-electricity: oklch(0.75 0.15 75)
  chart-water: oklch(0.514 0.161 254.3)
  chart-gas: oklch(0.62 0.2 25)
  chart-temperature: oklch(0.68 0.12 178)
  chart-emissions: oklch(0.58 0.16 10)
  chart-misc: oklch(0.55 0.18 300)
  # LEGACY line palette — migration of existing plots only, never a new feature (light values; dark re-tunes in the body)
  chart-legacy-line-teal: oklch(0.483 0.082 201.5)
  chart-legacy-line-green: oklch(0.649 0.157 142.6)
  chart-legacy-line-brown: oklch(0.564 0.126 60.4)
  chart-legacy-line-orange: oklch(0.672 0.143 70.7)
  chart-legacy-line-red: oklch(0.612 0.227 29.2)
  chart-legacy-line-magenta: oklch(0.626 0.268 322.6)
  chart-legacy-line-sky: oklch(0.659 0.135 236.6)
  chart-legacy-line-yellow: oklch(0.662 0.139 104.2)
  chart-legacy-line-lime: oklch(0.56 0.16 133.9)
  chart-legacy-line-wine: oklch(0.452 0.158 4.9)
  chart-legacy-line-olive: oklch(0.452 0.098 110.5)
  chart-legacy-line-steel: oklch(0.588 0.099 245.7)
  chart-legacy-line-emerald: oklch(0.65 0.157 156.2)
  chart-legacy-line-cyan: oklch(0.657 0.112 205)
  chart-legacy-line-purple: oklch(0.421 0.193 328.4)
  chart-legacy-peak-1: oklch(0.626 0.268 322.6)
  chart-legacy-peak-2: oklch(0.659 0.135 236.6)
  chart-legacy-peak-3: oklch(0.612 0.227 29.2)
  chart-legacy-peak-4: oklch(0.662 0.139 104.2)
  chart-legacy-peak-5: oklch(0.56 0.16 133.9)
  chart-legacy-peak-6: oklch(0.452 0.158 4.9)
  chart-legacy-peak-7: oklch(0.452 0.098 110.5)
  chart-legacy-window-high: oklch(0.612 0.176 23.9)
  chart-legacy-window-normal: oklch(0.704 0.128 20.8)
  chart-legacy-window-low: oklch(0.861 0.057 18.3)
typography:
  font-family: SF Pro / system-ui sans (var --font-sans)
  font-family-mono: SF Mono / ui-monospace (var --font-mono)
  caption: { size: 0.75rem, line-height: 1rem, weight: 500 }
  body-sm: { size: 0.875rem, line-height: 1.25rem, weight: 400 }
  body: { size: 0.875rem, line-height: 1.375rem, weight: 400 }
  body-lg: { size: 1rem, line-height: 1.5rem, weight: 400 }
  title: { size: 1.125rem, line-height: 1.5rem, weight: 600 }
  heading: { size: 1.5rem, line-height: 1.875rem, weight: 600, tracking: -0.01em }
  display: { size: 2.25rem, line-height: 2.5rem, weight: 700, tracking: -0.02em }
rounded:
  base: 0.625rem   # --radius
  sm: 0.375rem   # base * 0.6
  md: 0.5rem   # base * 0.8
  lg: 0.625rem   # base
  xl: 0.875rem   # base * 1.4
  2xl: 1.125rem   # base * 1.8
  3xl: 1.375rem   # base * 2.2
  4xl: 1.625rem   # base * 2.6
# @@GENERATED:end
spacing:
  scale: Tailwind default (0.25rem step) — use the scale, never arbitrary px
components:
  button:
    background: "{colors.primary}"
    foreground: "{colors.primary-foreground}"
    rounded: "{rounded.md}"
    notes: Ships hover + focus-ring states. Must show cursor:pointer.
  badge:
    default-variant: outline
    rounded: "{rounded.sm}"
    notes: Tone is semantic, not decorative — see Components → Badge.
  card:
    background: "{colors.card}"
    foreground: "{colors.card-foreground}"
    rounded: "{rounded.xl}"
    padding: 1rem   # --card-spacing; 0.75rem at size="sm"
  dialog:
    background: "{colors.popover}"
    scrim: "{colors.scrim}"
    notes: Short (1–4 field) create actions. Always dismissible (close X).
  sheet:
    width: 420px
    notes: Longer / many-field edit actions. Always dismissible (close X).
  input:
    border: "{colors.input}"
    ring: "{colors.ring}"
    invalid: destructive border + ring from aria-invalid
  active-highlight:
    surface: "{colors.accent}"
    foreground: "{colors.foreground}"
    notes: Neutral light-blue tint — never brand blue for routine active states.
---

# Edgecom Design System — Design Language & Usage

> **How to use this file:** read it before generating or designing Edgecom UI, then follow it. It defines the visual language (colors, type, spacing, elevation, shape) and the **usage rules** for building correct, accessible, on-brand screens. It is tool-agnostic — for whatever tool generates Edgecom UI, and as the shared "what/why" behind the code guardrails.
>
> **Related docs:** [agents.md](https://design.edgecom.ai/agents.md) — implementing with the component registry in a consuming app. [REGISTRY.md](https://github.com/edgecom-ai/design-system/blob/main/REGISTRY.md) — install mechanics. [MAINTAINERS.md](https://github.com/edgecom-ai/design-system/blob/main/MAINTAINERS.md) — developing the design-system repo itself. Live reference: [design.edgecom.ai](https://design.edgecom.ai).

## Overview

Edgecom Energy is an energy management & monitoring platform (products: **dataTrack™**, **pTrack®**, **NeuraCharge™**). The UI is data-dense — dashboards, tables, charts, metric tiles — so the system optimizes for **legibility, scannability, and consistent status semantics** across light and dark.

Core principles:

- **Semantic tokens, never raw values.** Every color, size, and radius is a named token with light + dark values. Never hardcode a hex or a magic px. This is what makes the whole system adapt to dark mode and stay consistent.
- **Status means something.** Color is not decorative — `success`/`warning`/`info`/`destructive` and the commodity hues each carry meaning. Use them strictly by meaning.
- **Reuse over reinvention.** Compose the existing components; don't hand-roll parallel versions. (Implementation: see [agents.md](https://design.edgecom.ai/agents.md).)
- **Accessible by default.** WCAG AA minimum, AAA where the palette allows — in both themes.

## Colors

Colors are semantic tokens. Reach for the token by **role**, not by how it looks.

### Token families

- **Surfaces:** `background`, `card`, `popover`, `elevated` (each with a `-foreground` for text on it). Stacking order (lightness) matters in dark mode — see *Elevation*.
- **Brand / neutral:** `primary` (+ `-foreground`, `-emphasis`, `-subtle`, `-subtle-foreground`), `secondary`, `muted`, `accent`.
- **Status:** `success`, `warning`, `info`, `destructive` — each with the full set `{ base, -foreground, -emphasis, -subtle, -subtle-foreground }`.
- **Form / outline:** `border`, `input`, `ring`.
- **Interaction:** `ghost-hover` (quiet controls with no resting fill — `button`/`badge` `ghost`, `badge` `outline`, `toggle`), `outline-surface` + `outline-hover` (`button` `outline`; `checkbox` and `radio-group` rest on `outline-surface` alone, on every surface, because a 16px box has nothing but its face to read by), `input-surface` + `input-hover` (the field triggers — `select`, `native-select`, which rest transparent so a tinted row shows through; a field, or an outline `toggle`, on the `muted` page body takes `surface="page"` and the outline pair instead — see *Toolbars — pick the surface by what is underneath*). Each is one theme-aware token rather than a light value plus a `dark:` override, so an app can re-tint a quiet control with a single `bg-*` / `hover:bg-*` and have it win in **both** themes. The hovers are **relative** — a foreground wash in light, a white wash in dark, over whatever is behind the control — so a hover steps from every surface a control is allowed to sit on: `background`, `card`, `muted`, and `popover` inside an overlay. An absolute `muted` hover landed on its own value on a `muted` panel, where a ghost button showed nothing and an outline button's white resting fill vanished into the page. The same token paints an **open** menu trigger (`aria-expanded`): open is a held hover. A hover is a neutral grey step; the blue-tinted `accent` stays the pressed/selected fill (`toggle`), so a hovered control never reads as pressed.
- **Relative surfaces:** `track` + `track-active` — the sunken segmented strip and the tab raised on it, in the `tabs` `adaptive` variant. Unlike every other surface token these are an **alpha over whatever is behind them**, not a fixed value: a strip that must read as "one step below my parent" has no absolute lightness, so an opaque one collapses to 1.00:1 the moment it lands on a surface of its own value. Light darkens, dark lightens (dark `background` is already near-black and has nowhere to go). They are the second colour set for that strip, not a replacement — the `default` variant's absolute `muted` track stays the right choice on a plain surface. `table-header` is the same kind of surface, lighter than the track: the `table` `strong` header band, a light step from whatever the table sits on — a `card` included — that stays a band rather than a block.
- **Charts / commodities:** `chart-1..5` alias the `500` step of the five named commodity ramps `chart-{electricity,water,gas,temperature,emissions}-{100..900}` (one hue per commodity); `chart-misc-{100..900}` is a sixth ramp (violet) for miscellaneous/uncategorized data, outside the `chart-1..5` rotation. All commodity ramps are mode-independent — `100` is the lightest step in both themes.
- **Legacy line palette (migration only):** `chart-legacy-line-{teal,green,brown,orange,red,magenta,sky,yellow,lime,wine,olive,steel,emerald,cyan,purple}`, `chart-legacy-peak-1..7`, `chart-legacy-window-{high,normal,low}` — the hues a product's *existing* plots are already read by, owned here so that product can delete its literals. Never for a new feature; no Tailwind utility exposes them (see *Legacy line palette*).
- **Sidebar:** `sidebar`, `sidebar-foreground`, `sidebar-primary`, `sidebar-accent`, `sidebar-border`, `sidebar-ring` (+ foregrounds).

### Which shade to use

- **`base` fill** with its **`-foreground`** for text/icons *on* that fill (e.g. a filled button, a solid status chip).
- **`-emphasis`** when a status/brand color is rendered **as text or a thin icon** on a neutral surface — it's tuned for contrast.
- **`-subtle`** (+ **`-subtle-foreground`**) for **tinted surfaces** — banners, selected rows, pills. Never use a `base` fill color directly as a text color.

### Status colors — strictly by meaning

`destructive` = errors & destructive actions · `success` = positive confirmation · `warning` = caution / negative-but-expected · `info` = information. Apply consistently across every surface (badges, alerts, toasts, text, icons). Never an arbitrary red/green/yellow, and never a status color used decoratively.

`destructive` is the palette's **only** red that means "error or destructive action" — it is reserved for that meaning and nothing else. Two commodity ramps (`chart-gas`, `chart-emissions`) now also live in the warm-red band; they tag data, never status. Never reach for `destructive` to color a chart series because it looks red, and never repurpose a commodity red to signal an error — pick the token by meaning, not by hue. See *Commodities* for the three-way distinction.

### Commodities

`electricity`, `water`, `gas`, `temperature`, `emissions` tag *that commodity* only. Electricity-derived metrics (voltage, current, THD, power factor) may use `electricity`. `chart-misc` (violet) is the catch-all for **miscellaneous / uncategorized data** — IAQ, occupancy, and any series that isn't one of the five named commodities; it is a sixth ramp, not part of the `chart-1..5` categorical rotation.

Three families sit in the warm-red band and must not be substituted for one another. `chart-gas` (hue 27) tags the gas commodity; `chart-emissions` (hue 10) tags carbon and emissions series; `destructive` (hue 27.3) means an error or a destructive action and is never a chart series. They are close in hue and lightness, so:

* Never use `destructive` to color an emissions or gas series, and never use a commodity red to signal an error — the meaning, not the appearance, picks the token.
* Where gas and emissions appear in the same chart, separate them in the categorical order so they are not adjacent segments, and rely on the legend rather than hue alone to tell them apart.
* Emissions as text or a thin icon uses `chart-emissions-700`; the `500` step is for fills.

### Legacy line palette — migration only

A handful of existing demand plots, and a second set of existing plots, are read by colour: operators have identified each line by its hue across years of screenshots and reports, and those series tag no commodity, so the commodity ramp could not replace them. The design system owns that palette **only so the product porting those plots can delete its hex literals**. It is not a palette for new work.

- **A new feature never draws from it.** New charts take the commodity ramp (`chart-*` by meaning, `chart-misc` for uncategorized data, the `chart-1..5` rotation for categorical series); every other colour is a semantic token. If a new chart seems to need one of these hues, that is a sign it should be using the ramp — the `legacy` in every name is there to make the exception visible at the call site.
- **The guardrails are mechanical.** There is deliberately no `--color-*` mapping for these tokens, so no `stroke-chart-legacy-*` / `bg-chart-legacy-*` utility exists to reach for from a class; they are read as CSS variables only, by the code that ports a plot. `registry:check` fails the build if any shipped primitive references a `--chart-legacy-*` token. **The set is closed to new features.** It grows only to absorb existing plots that operators already read by colour (`emerald`, `cyan` and `purple` joined for the second set), never for a new feature, and no new role is built on it — a ported plot that grows a new series gives that series a ramp colour.
- **Named by colour on purpose.** Which hue a given series takes is the porting product's own convention, so a token here carries no meaning of its own; that product keeps its series-to-hue mapping in one place and takes each line by hue — never a literal, and never a hue chosen ad hoc at a call site. The one exception is `chart-legacy-peak-1..7`, the ranked peak lines (`magenta, sky, red, yellow, lime, wine, olive` in that order): rank *is* the identity, so the order never rotates and a peak line draws through its rank token.
- **The bar is 3:1 on `card`, in both themes.** These are 1.5–2 px plot lines, not text. Light values are the legacy hues; the ones that fell under 3:1 on white were lowered in **lightness only** (hue kept, chroma clipped only where the gamut forced it) — `orange`, `sky`, `yellow`, `lime`, `emerald`, `cyan` — and `lime` is held below `green` so the two greens stay apart. Dark keeps each hue as known, lifting only the lines a dark card swallowed (`teal`, `brown`, `wine`, `olive`, `purple`). Don't re-tune a hue in a product; tune the token here.
- **Two pairs sit closer than the rest, and never on one plot.** In light mode `emerald` is 0.037 from `green` and `cyan` 0.071 from `sky` (OKLab distance), against 0.082 for the next closest pair. Each pair spans the two sets of plots, and no existing plot draws both of a pair, so they were tuned like every other line rather than pushed apart. Keep it that way: never draw `emerald` beside `green`, or `cyan` beside `sky`, on one plot.
- **Reds tag data here, not status.** `chart-legacy-line-red` (hue 29) — which also stands for the second set's two reds, 0.033 and 0.045 from it — and the `chart-legacy-window-*` reds sit in `destructive`'s band, alongside `chart-gas` and `chart-emissions`. They mean a line and a peak window, never an error — and `destructive` is never a line on a plot.
- **Window bands are fills, not lines.** `chart-legacy-window-{high,normal,low}` grade the peak-window bands by rank (ranks 1–2, rank 3, rank 4 and past). They are read against the lines drawn over them, so they carry no contrast bar of their own; light grades toward white, dark toward the card, so the softest band never becomes the brightest thing on a dark plot. A plot with a single unranked window draws it with `window-low`: that is the nearest band to its legacy colour (0.064 away on white), and it keeps the softest grade in both themes.

### Brand blue is mode-independent

The brand blue (`primary`, used for `bg-primary` fills and buttons) is deliberately the **same value in light and dark** — don't lighten or re-tune it in dark the way most tokens shift. If you need a lighter primary as **text** in dark mode, use `primary-emphasis`, never a lightened base.

### Dark mode (the deltas)

Dark is the `.dark` class on the root; portaled content (dropdowns, popovers, tooltips, toasts) inherits it — never build light-only components. Light is the complete palette; dark overrides a subset. Key differences to respect:

- **Surfaces invert and stack:** `background` `oklch(0.16 …)`, `card` `0.205`, `popover` `0.265`, `elevated` `0.29`. Overlays must sit **above** cards (see *Elevation*).
- **Scrim deepens:** light `oklch(0.16 0.01 254 / 0.4)` → dark `oklch(0.08 0.008 254 / 0.7)`.
- **`primary` unchanged**; `primary-emphasis` lightens to `oklch(0.72 0.14 254)` for text-on-dark.
- **The active/hover highlight (`accent` / `sidebar-accent`) must stay a subtle light-blue tint** in dark, not a near-neutral grey — if it looks flat, tune `--accent` in `.dark`, not per-component.
- **`-subtle` status pairs must clear 4.5:1 in dark too.** A `-subtle` surface + its `-subtle-foreground` must clear 4.5:1 in dark as well as light; dark often needs its own explicit `-subtle-foreground` (the light `-emphasis` is usually too dark on a dark tint). e.g. dark destructive: surface `oklch(0.27 0.072 27)`, text `oklch(0.87 0.11 22)`.

**Always verify new or tuned colors in both themes** (the live *Semantic colors* page has a contrast meter).

## Typography

Use the **semantic type scale** — one token sets size + line-height + weight + tracking. Don't reach for ad-hoc `text-xs`/`text-2xl` or pin sizes to px.

| Token | Size / line-height | Use |
|---|---|---|
| `caption` | 0.75rem / 1rem | Labels, meta, table captions, shortcuts (carries weight 500) |
| `body-sm` | 0.875rem / 1.25rem | **Dense chrome** — controls, menu items, table cells, tabs, single-line UI text |
| `body` | 0.875rem / 1.375rem | **Running prose** — descriptions, helper and error text, empty states |
| `body-lg` | 1rem / 1.5rem | Emphasized body, lead-in |
| `title` | 1.125rem / 1.5rem | Card / section titles (weight 600) |
| `heading` | 1.5rem / 1.875rem | Page headings (weight 600) |
| `display` | 2.25rem / 2.5rem | Hero / display numerals (weight 700) |

`body-sm` and `body` are the **same size on different line boxes**. Pick by what the text is, not how big it is: anything that wraps to a second line and is meant to be *read* takes `body`; anything that sits on one line inside a control takes `body-sm`. Two slots that swap in the same position (a `FieldDescription` giving way to a `FieldError`) must take the *same* token, or the layout shifts under the user.

The tokens carry their own line-height and weight — **don't pair them with `leading-*`**, and don't restate a weight the token already sets. `leading-snug` is Tailwind's unitless ratio 1.375, not `1.375rem`; reaching for it where a token was meant is a silent 3px error.

**Emphasis inside body text is `font-medium`** — a table row's primary line, an item or field label, a count beside a bulk action: `text-body-sm font-medium`. It is the weight the primitives themselves use on `body-sm` and `body-lg` (`Button`, `Label`, `ItemTitle`, `TableHead`, `CardTitle`). Write it as the class, never as an inline `font-weight`.

The scale is in `rem` — it respects user font settings. Font family is SF Pro / system sans via `--font-sans`; `--font-mono` is a real monospace stack, for code, identifiers, and tabular figures. Both tokens are defaults — an app that wants its own face redefines them in its own `:root`.

## Layout

- **Mobile-first.** Design for small screens first, layer up with breakpoints (`sm`/`md`/`lg`/`xl`/`2xl`). No desktop-only layouts.
- **Relative units.** Use the `rem` type scale and the spacing / radius scales — never fixed px for type or container dimensions.
- **Fluid, reflowing.** Content must never clip or force horizontal page scroll. Wide content (tables, code) scrolls inside its own `overflow-x-auto` container.
- **Density → hover.** In data-dense tables, metric tiles, and charts, keep the **primary value visible** and move secondary/detail info into a `tooltip` (brief) or `hover-card` (richer). Don't cram, shrink, or clip.
- **Tables:**
  - Text-first cells — no icons in cells unless explicitly asked.
  - The first/last cell in a row (`th`/`td`) aligns to the **card's content padding** on its outer edge — `--card-spacing`, 1rem on a default card and 0.75rem on `size="sm"` — not the 0.5rem cell padding. Give each head and cell of a table that sits flush in a `Card` `first:pl-(--card-spacing) last:pr-(--card-spacing)`, so headers, edge values, and totals sit on the card title's line, never flush to the border. A table inside content that is already padded (`CardContent`, a padded panel) adds no edge padding of its own.
  - Body rows get a subtle light-blue (`primary` tint) hover highlight — rely on the primitive's built-in one so every table matches.
  - **Header surface — `strong` is the default fill.** `TableHeader` sits on the table's own surface by default. To set the header row apart as a band — for long, scrolling, or many-column tables — fill it with `variant="strong"`, on any host, a `card` included: the relative `table-header` token darkens whatever is under it in light and lightens it in dark, a light band that steps from every surface. `variant="muted"` is for a table on `background` only. It is an absolute fill, so on a `card` it repeats the `muted` page the card sits on, on a `muted` page or panel it lands on its own value and vanishes, and in a dark overlay it sits within a hair of `popover`. Give every table on a view the same header, and never hand-tint one with a `bg-*` override — switch the variant.
  - **Pinned columns — the last responsive step, never the first.** When a table has more columns than fit *and* a column that says what the row is (a name, an ID, a site), pin that column at the left edge with `pinned="left"` on its head and cells, so the row keeps its identity while the rest scrolls. The selection checkbox may pin beside it, and a row-actions or total column may pin at the right edge — at most two columns on the left and one on the right, never a metric column. Hide low-priority columns first (column visibility), then pin; never shrink. A pinned cell has to be opaque or the columns scrolling under it show through, so it paints the **host surface** — name it with `surface` on `Table` (`card`, the default; `background`; `popover`) — under the row's own hover, selected, header, and footer tint, so it reads identical to the cells beside it. Never give a pinned cell a `bg-*` of its own. Its divider is a hairline that appears only while columns are hidden past that edge and goes away at the scroll start and end — a table that does not scroll shows no edge, and never a drop shadow. On small screens a pinned column takes at most about 40% of the container's width: truncate the value in a `truncate` span and put the full text in a `tooltip`; past that, don't pin. Several pinned columns on one side stack on their own — the container measures each pinned cell and offsets the next, so don't compute offsets from a column model's declared sizes (auto table layout doesn't render them). With TanStack, `column.getIsPinned()` is the whole mapping.

## Elevation

Surfaces stack by lightness, and the stack must stay legible in **dark mode**, where surfaces are close in value:

- `background` < `card` < `popover` < `elevated` (raised menus / hover panels).
- **Overlays sit _above_ cards.** `popover` must be lighter than `card` in dark — an overlay at the same lightness as the cards behind it reads flat and the dialog edge disappears. (Dark: `card` 0.205, `popover` 0.265, `elevated` 0.29.)
- **Modal scrims use the `scrim` token**, never an inline `oklch(… / 0.4)`. Dark needs the deeper scrim because surfaces are close in lightness.
- **No accent bars.** Never add a colored strip along an edge/border (e.g. `border-l-4 border-l-destructive`) to signal status — use the component's own variant (a `-subtle` surface with `-emphasis` text/icon) so status reads consistently and adapts to dark.

### Stacking order (z-index)

Portalled surfaces mount at the end of `<body>`, so **within a layer the most recently opened surface paints on top** — a `popover` or `combobox` opened from inside a `dialog` is a later sibling and wins without needing a higher z-index. Only three layers exist; don't invent a fourth:

| Layer | `z` | Surfaces |
|---|---|---|
| Page | `0`–`20` | Sticky headers, action bars, table chrome — a pinned `table` cell is `z-10`, a `sticky` `TableHeader` `z-20` so it passes over the pinned cells scrolling under it |
| Overlay | `z-50` | `dialog`, `alert-dialog`, `sheet`, `drawer` (scrim + popup), and every anchored surface: `popover`, `select`, `dropdown-menu`, `combobox`, `context-menu`, `hover-card`, `navigation-menu` |
| Tooltip | `z-60` | `tooltip` only — transient, never interactive, must never be occluded by the overlay it annotates |

**Put the z-index on the element that establishes the stacking context.** A Base UI `Positioner` is `position: absolute` with a `transform`; the `Popup` inside it computes `position: static`, so a z-index on the popup is inert and the surface paints at `auto` — losing to any positioned page content. Every portalled component sets `isolate z-*` on its **Positioner**.

## Shapes

Radius scale, all derived from `--radius` (0.625rem): `sm` (0.6×), `md` (0.8×), `lg` (1×), `xl` (1.4×), `2xl` (1.8×), `3xl` (2.2×), `4xl` (2.6×). Use the scale — don't invent radii. Cards use `xl`; buttons/inputs `md`; pills/badges `sm`.

## Components (usage conventions)

**Same job → same component.** Pick one component for a given job and reuse it everywhere — the same kind of control, in the same placement, size, and behaviour, across every screen and graph. Consistency of *kind* matters as much as consistency of *style*: a data-series selector, a date range, or a row-action menu should be the **identical** component wherever it appears — never a `select` in one place and a segmented control or `dropdown-menu` in another for the same task. If a job already has a component elsewhere in the product, reuse that one rather than introducing a parallel pattern.

### Badge — pick by meaning; default to `outline`

- **Neutral labels → `outline`.** When unsure, use `outline`.
- **Key highlight → `default` (primary).** Reserve the primary-colored `default` for a *single* key highlight (e.g. the current period) — not routine labels.
- **Commodities → the commodity variants only** (`electricity`/`water`/`gas`/`temperature`/`emissions`).
- **Info → `info`** (and `success`/`warning`/`destructive` for genuine status).

### Buttons & interaction

- **Real hover + focus states, always.** The `button` ships them; any custom clickable control must have a visible `hover:` state and a focus ring (`ring`). Never a flat, stateless button. Buttons must show `cursor: pointer`.
- **Secondary/utility actions use icon buttons.** On table toolbars and page headers, secondary actions (export, upload, download, refresh, filter, print, …) are **icon buttons** (`size="icon-sm"`/`icon-lg`, usually `outline`/`ghost`) with an `aria-label` + ideally a `tooltip` — not full text buttons. Reserve a labeled button for the single primary action. (This is chrome — distinct from the no-icons-in-cells rule.)
- **Destructive row actions use the `ghost-destructive` button variant.** A delete/remove action inside a table row has to stay quiet until the pointer reaches it: `destructive` is a solid fill and `destructive-subtle` a permanent tint, so either paints a red chip on every row. `ghost-destructive` is the quiet form — `destructive-emphasis` label at rest, `destructive-subtle` surface with `destructive-subtle-foreground` label on hover — and it already handles all four states in both themes. **Don't hand-compose it from `ghost` plus destructive utilities**: `ghost` re-asserts `hover:text-foreground`, so the label goes neutral at exactly the moment the tint turns red. (Still gate the action behind an `alert-dialog` — see *Destructive actions*.)

### Toolbars — one height, one size

A filter bar or table toolbar mixes fields, selects and buttons, and they read as one row only when they share a height. `input`, `select` (its trigger), `native-select` and `button` all offer the same two sizes — `default` (2rem) and `sm` (1.75rem) — so pick **one size for the whole row** and pass it to every control in it. Never a `default` search field beside `sm` filters, and never a `className` height override to make up the difference: if a control seems to need a size the row's neighbours don't have, the row is on the wrong size. `size` is a **variant axis** on every component that offers it, so a design cites it like any other variant.

### Toolbars — pick the surface by what is underneath

A field rests clear — `input-surface`, transparent in light — so a tinted row or panel shows through it. That is right inside a `card`, `sheet`, `dialog` or table row, and wrong straight on the shell's `muted` content area: there a clear field has no face and dissolves into the grey, while the outline `button` beside it keeps its white one. So the fields take a **`surface` variant axis**, kept orthogonal to `size`:

- **On the `muted` page body → `surface="page"`** on every field and trigger in the row — `input`, `input-group`, `textarea`, `phone-input`, `select` (its trigger), `native-select`, `combobox`, `multi-select`, `tags-input` — and on an outline `toggle` or `toggle-group` beside them. It is the outline button's face (`outline-surface`, and `outline-hover` on the triggers that hover), so a search box, its filters, an interval switcher and the buttons beside them read as one surface in both themes. A `date-picker` trigger and every button need nothing: they are outline buttons already. Nor does a ghost `toggle`: like a ghost button it has no resting face, so `surface` leaves the `default` variant alone. A connected `toggle-group` (`spacing={0}`) puts the face on its items; a spaced one puts it on its frame.
- **Inside a `card`, `sheet`, `dialog` or table row → `default`** — set nothing.
- **Never re-tint a field by hand** to make it read on the page — no `bg-background`, `bg-card` or `bg-white`. In dark `background` is *darker* than `card`, so a field painted with it sinks below the cards beside it; `outline-surface` stays a light wash over whatever is behind. Switch the variant.

It is the same principle as *Tabs — pick the variant by the surface underneath*: a control whose look depends on what is behind it names that surface rather than overriding its own colours. The rendered control carries `data-surface`, so a design and an audit can both read the choice.

### Overlays — dialog vs. sheet

- **Short create actions (1–4 fields) → modal `dialog`** — quick, centered, focused.
- **Longer edit/update actions (many fields) → right-side `sheet`** — default width **420px**, full-width on mobile; go wider only when content genuinely needs it.
- **Dialogs & sheets keep a close (X) in the top-right corner** — the `dialog`/`sheet` primitives render it there by default (`showCloseButton`, positioned `absolute top-2 right-2`); never pass `showCloseButton={false}`, move it elsewhere, or hand-roll a modal without one. A modal must always be dismissible. The **only** exception is the `alert-dialog`, which omits the X so a destructive/confirmation flow forces an explicit choice.
- Overlays are **hidden by default** and **mounted at the app/template root**, so their visibility never depends on the current view.
- **A dialog never outgrows the viewport.** `dialog` and `alert-dialog` stop 1rem short of each edge and scroll inside, so on a short screen the dialog scrolls to its footer instead of cutting it off. Don't re-add a viewport cap at the call site (`max-h-[90vh] overflow-y-auto`) — a `max-h-*` there replaces the built-in one. A dialog with its own inner scroll region takes `flex flex-col`, with `min-h-0` on that region.

### Feedback — toasts

- **Confirm with a toast, matching the semantic variant.** Any interaction needing feedback fires a `toast`; immediate actions especially (a `switch`/toggle must confirm on/off), and a completed save/submit should `toast.success`. **Match variant to outcome:** `toast.success` = confirmation, `toast.warning` = caution / negative-but-expected (e.g. a declined request), `toast.error` = genuine failure only. Never a neutral `toast()` for a success/error result.
- **One toast surface per app** (a single `<Toaster>`), and **every toast carries a title** (the description row collapses when empty).

### Destructive actions — double confirmation

Any destructive/irreversible action (delete, remove, disconnect, reset, purge) passes through an explicit confirmation before it runs — an `alert-dialog` with a `destructive`-variant confirm button and copy that names what's affected and that it can't be undone. Never wire a destructive action straight to its trigger. For high-impact/bulk actions add a stronger barrier (type-to-confirm the resource name). Surface the outcome with a `toast`.

### Validation & states — never fail silently

- **Inline, specific errors.** If a required field is empty, show inline error text that names what's missing ("Site name is required") next to the field — not just a generic toast or a silently-blocked button. Wire it through the `field` component's `FieldError` slot + `aria-invalid` on the input (the `input`/`select` primitives render the destructive border + ring from `aria-invalid`). Use the destructive token (`-emphasis` for text) and pair color with text/icon — never color alone.
- **Loading / empty / error.** Use the provided `skeleton`, `spinner`, and `empty` components — never raw loading gaps or unhandled empty/error states.
- **Async failures.** On a failed request, render an explicit error state with a **retry** action (an `empty` state + retry `button`) — never a blank screen or an endless spinner. For a failed *action* (e.g. a save), surface a `toast` and keep the user's input intact.

### Navigation & menus

- **Active/selected states use the built-in neutral highlight — not brand blue.** An active nav/menu/tab/sidebar item is signalled by the component's own active styling (`isActive`/`data-active` → the neutral `accent`/`sidebar-accent` surface) while its **text and icon stay `foreground`**. Brand blue is reserved for CTAs and a single key highlight, not routine active states. **The one exception is an underline bar** — the `tabs` `line` variant's active bar and `TabsIndicator` are `primary` (`primary-emphasis` in dark, where a 2px hairline of the mode-independent brand blue clears only ~3.9:1). A 2px rule under a label is not a surface or a label recolour; the rule above governs fills, text, and icons. Compose navigation from the primitive's parts (e.g. `SidebarGroupLabel`, `SidebarMenuButton isActive`, `SidebarMenuSub`) — don't hand-roll the highlight.
- **Keep a gap between menu items** (e.g. `gap-1`) in `sidebar`, `dropdown-menu`, `command`, and similar lists so an active item and a hovered neighbor never merge into one block.
- **A `dropdown-menu` sizes to its widest item, not to its trigger.** The menu shrink-wraps its content (`w-auto`, floored at a small `min-w`), so the width fits the items inside. Don't stretch it to the trigger width or pad items to a fixed width: both leave trailing whitespace, and a wide icon button opening a menu of short actions should still give a compact menu. (Contrast `select`, whose list intentionally matches the trigger width so options line up under the shown value.)
- **A `dropdown-menu` is portaled to the app root, so no parent container ever clips it.** The menu mounts at the app root rather than inside the trigger's box, so an ancestor with `overflow: hidden` / `overflow-x-auto` / `overflow-y-auto` (a card, a table, a `scroll-area`, a toolbar) can never crop it. Its positioner also flips and shifts to stay inside the viewport, and caps its own height to the available space (scrolling internally) instead of overflowing the screen. Don't disable portalling, render the menu inline inside a clipping or scrolling container, or pin a side that lets it run off-screen. The same holds for every other anchored surface — `select`, `combobox`, `context-menu`, `popover`, `hover-card`, `navigation-menu`.

### Tabs — pick the variant by the surface underneath

The segmented strip reads as a group by **lightness alone** — a sunken track with the active tab raised on it, roughly 1.1–1.4:1 per step. That whole budget belongs to the strip, so what's *under* it decides the variant:

- **Plain surface (`background`, `card`) → `default`.** The absolute `muted` track is correct here and is what every existing strip uses.
- **Tinted surface or an overlay → `adaptive` or `line`.** A `muted`/`secondary` panel, a `bg-muted/50` card footer, a selected (`accent`) row — and every overlay, since dark `popover` (0.265) and `elevated` (0.29) sit within a hair of `muted` (0.27). A `default` track there lands on its own value (1.00–1.07:1) and the strip vanishes, leaving loose labels and, in dark, an active tab identifiable only by its border. `adaptive` is the same strip in the relative `track`/`track-active` pair (plus a hairline), so both steps survive any host; `line` drops the strip for an underline bar and needs no step at all. Prefer `line` where an underline suits the layout, `adaptive` where the segmented look is doing work (a control cluster in a chart header, a toolbar).
- **Never re-tint a strip by hand** to fix this — no `bg-*` override on `TabsList`, and no `-subtle` status surface as a track. Switch the variant.
- **One tab strip, one job.** A `tabs` is for a small fixed set of panels or series; a longer or growing set is a `select` (see *Same job → same component*).

### Select — no checkmark on the selected item

- **A single-select `select` shows its value in the trigger and highlights the current option in the list — it does _not_ mark the selected item with a check/tick icon.** The chosen value in the trigger plus the option's built-in highlight already communicate the selection; **don't add a checkmark unless explicitly asked.** (Design tools tend to add one by default — don't; the primitive deliberately omits it.)
- **Multi-select is the only exception.** When several values can be chosen at once, show each option's on/off state (a check or checkbox) so the selected set is legible — that's the one place a check belongs.

### Tooltips

A `tooltip` **shrink-wraps its content**, sizing to its text — don't add a `max-w` or fixed width that forces wrapping. Each item/row should sit on a **single line**; widen only to keep a label–value **row of data** together (via `whitespace-nowrap` on the rows), never a hardcoded width.

- **Viewport-aware positioning.** The tooltip must stay inside the current viewport: if it would overflow the **right** edge it flips to the **left** of the trigger/cursor (and likewise flips top↔bottom), and shifts along the edge to stay fully visible. The `tooltip` primitive's positioner does this collision handling by default — keep it; never pin a side that lets the tooltip run off-screen.
- **Never clipped by its container.** A tooltip is **portaled to the app root**, so it escapes the triggering element's box — an `overflow: hidden` / `overflow-x-auto` ancestor (a card, a table, a `scroll-area`) must never clip it. Don't render a tooltip inline inside a clipping container or with portalling disabled.

### Charts & graphs

Every chart/graph carries a **consistent control cluster** in its header — the same set across all charts on a view, present **by default unless a chart explicitly opts out**:

- **Shared date range.** All charts on a view are driven by **one** range control — the `date-picker` in range mode plus quick presets (`1W` / `1M` / …). In the chart header the picker defaults to its **compact icon-button** trigger (the calendar icon), opening the same range calendar, so the header stays chrome-light. Never give each chart its own range that drifts out of sync; changing the range updates every chart together.
- **Interpolation toggle — smooth vs. step.** Line/area charts expose a toggle to switch between a smooth curve and stepped segments. Default to **smooth** for continuous series; use step for discrete/stateful data.
- **Statistical overlays.** Offer the analytics tools as toggleable overlays — period average (a reference line labelled with its value in the legend), min/max, and trend — so a reading carries context. Draw reference lines in a **neutral/`muted`** tone (dashed), never a status or commodity color.
- **Export / download.** Always a single **icon button** in the header (never a text button or a row of buttons) opening a `dropdown-menu`. The default menu is the **same on every graph, in this order**: **Download PNG**, **Download SVG**, **Download PDF**, **Download CSV**, **Print**. Keep this set and order identical across all charts so the export affordance is the same everywhere; omit an item only when a chart genuinely cannot produce it. (This is header chrome, an icon button per *Buttons & interaction*.)
- **Series / metric selector — identical across every graph.** Choosing which data series or metric a chart shows uses the **same** control on every graph: the same component (a `select` for one choice; a multi-select for several; `tabs` only for a small fixed set), in the same place and size. A user should never meet a dropdown on one chart and a segmented control on another for the same job (see *Same job → same component* above).

Only omit one of these five when the spec explicitly says so, or when the chart is an event-window chart.

**Event-window chart — the event sets the window.** A chart that shows the reading around **one event** — an alarm's trigger, an outage, a demand peak — inside that event's detail (a sheet, a dialog, a detail page). Its window comes from the event, not the reader: a lead-in before the event, through to its end, or to now while it is still open. It is a record of what happened, so it carries **only the export menu**:

- **No date range**, and it does not join the view's shared one — moving the window off the event defeats the chart, and no page filter (a list's time filter, the dashboard's range) moves it.
- **No statistical overlays.** Its reference line is the **threshold the event crossed**, labelled with its value and drawn like any reference line — dashed, neutral/`muted`, never `destructive`, however severe the event.
- **No smooth/step toggle.** Draw it one way, by the interpolation default above — smooth for a continuous reading, step for discrete or stateful data — so everyone who opens the event sees the same line.
- **No series selector** — it plots the one metric the event is about.
- **Keeps the export menu**, with the standard items in the standard order.

If the reader can change the window, it is an ordinary chart and carries all five. An event-window chart is the rule, not an exception to it.

**Header chrome is icon buttons.** The date-range picker and the export control both sit in the header as **icon buttons** by default (the compact `date-picker` trigger and the export `dropdown-menu` icon button), not full-width fields or text buttons, so the header reads as a tight row of affordances. Reserve a labeled control for a genuine primary action only.

**Legend — always centered.** Center the legend horizontally relative to the plot on **every** chart (below the plot by default), and keep that placement identical across all charts on a view. A centered legend keeps single- and multi-series charts balanced and consistent; never left- or right-align it on one chart and center it on another.

**Legible bars — size the bar width and gap to the data density.** A bar chart reads clearly only when bars keep a comfortable width and never touch. Think in terms of each category's **slot** (plot width ÷ number of bars): the bar fills part of the slot, the rest is the gap. Default to a bar at **~75% of its slot** (≈25% gap), then adjust by how many bars there are:

| Bars (categories) | Bar width | Gap between bars | Also |
|---|---|---|---|
| **Few** (≤ 8) | cap at **~48–64px** — don't let them balloon | wide — bar ≤ ~55% of the slot | center the set; a handful of bars shouldn't become fat blocks |
| **Normal** (9–30) | ~70–80% of the slot | ~20–30% of the slot, **≥ 2px** | the default ratio |
| **Dense** (31–60) | thinner, but **≥ ~4px** | **≥ ~2px** | drop per-bar value labels → move them to a `tooltip` / `hover-card` |
| **Very dense** (> 60, or bars would fall below the min width/gap) | hold the min width | hold the min gap | **stop shrinking** — aggregate/bucket (daily → weekly) *or* scroll horizontally in an `overflow-x-auto` container at a fixed per-bar width |

Never a **zero gap** — touching bars read as one solid block. **Round only the top corners.** Bars grow up from the axis, so give the two **top** corners a small, uniform radius (**≈2–4px**, `radius-sm`) and keep the **bottom square** on the baseline — never fully-rounded or pill-shaped bars, and never round the bottom. Reduce the radius toward **≤ ~2px** at high density so rounding doesn't eat the bar width, and apply the **same** radius (and the same slot ratio) across **every** bar chart so they read as one system. For **grouped / stacked** bars, make the gap **between groups** larger than the gap **within** a group so each group reads as a unit; round only the topmost segment of a stack. The goal is constant: a reader can pick out an individual bar and its neighbours at a glance — if they can't, widen the gap, cap the width, aggregate, or scroll; never shrink bars into indistinct slivers.

- **Colors, detail, themes.** Tag series with the commodity hues by meaning (`chart-*`); move point/detail readouts into a `tooltip` / `hover-card` (see *Layout → Density*); keep axis labels, grid lines, and the value legend legible in **both** themes.

## Accessibility

- **WCAG AA minimum** — 4.5:1 for body text, 3:1 for large text / UI — and **AAA where the palette allows**. Light and dark held to the same bar.
- **Use real interactive primitives** (Base UI) — don't reimplement widgets and lose keyboard/ARIA support.
- **Visible focus ring** via the `ring` token — never `outline: none` with no replacement.
- **Label form fields; alt text on images.** Pair color with text/icon for status — never color alone.
- Respect `prefers-reduced-motion`; use motion sparingly.

## Do's and Don'ts

**Do**
- Use semantic tokens for every color; test in light **and** dark.
- Pick status/commodity colors strictly by meaning.
- Colour a new chart from the commodity ramp (`chart-*` by meaning, `chart-misc`, `chart-1..5`) and semantic tokens only; touch `chart-legacy-*` solely when porting an existing plot, through the product's one series-to-hue mapping, never a literal.
- Default badges to `outline`; reserve primary `default` for one key highlight.
- Use the rem type, spacing, and radius scales.
- Keep overlays dismissible — a top-right close (X) on every dialog/sheet, except the `alert-dialog`.
- Confirm actions with the matching semantic `toast` variant; always give a toast a title.
- Gate destructive actions behind an `alert-dialog` (type-to-confirm for high-impact).
- Show inline, specific validation errors with `aria-invalid`.
- Move secondary/detail info into tooltips/hover-cards in dense UI.
- Let tooltips shrink-wrap (one line per row) and flip/shift within the viewport; keep them portaled so no container clips them.
- Use icon buttons for secondary table/page actions, and `ghost-destructive` for a quiet destructive row action.
- Give every control in a toolbar or filter bar the same `size` (`default` or `sm`) so the row sits on one height.
- Give every field, trigger and outline toggle in a filter bar on the `muted` page body `surface="page"`, so it shares the outline button's face; inside a card, set nothing.
- Let a `dropdown-menu` shrink-wrap to its widest item; never stretch it to the trigger width or pad items with trailing whitespace.
- Keep dropdowns and other anchored surfaces (`select`, `combobox`, `context-menu`, `popover`, `hover-card`) portaled so no `overflow` ancestor clips them; let them flip/shift within the viewport.
- Give every chart the standard controls by default: one shared date range, a smooth/step toggle, statistical overlays, and one export icon-button dropdown (Download PNG / SVG / PDF / CSV / Print).
- Give an event-window chart — one event's reading, over a window the event sets — only the export menu, with the threshold it crossed as its one reference line.
- In a chart header, default to icon buttons for the date-range picker and export control, and center every chart's legend.
- Use the same component for the same job everywhere (e.g. one series-selector control shared across all charts).
- Size bar width and gap to the data density (bar ~75% of its slot; gap ≥ 2px; cap width for few bars; aggregate or scroll past ~60).
- Round only the top corners of bars (small, uniform ~2–4px radius); keep the bottom square on the baseline, and use the same radius/thickness across every bar chart.

**Don't**
- Don't hardcode hex or ship light-only UI — every color is a light+dark token.
- Don't lighten `primary` in dark mode — the brand blue is mode-independent; use `-emphasis` for lighter primary *text*.
- Don't use a base fill color as a text color — use `-emphasis` for text, `-subtle`(+`-subtle-foreground`) for tinted surfaces.
- Don't recolor active/selected nav items with primary/blue text or icons — use the built-in neutral highlight; keep text/icon `foreground` (a `tabs` underline bar is the one brand-blue exception).
- Don't leave a segmented `tabs` strip on `variant="default"` over a tinted surface or inside an overlay — the `muted` track has no lightness step left there; switch to `adaptive` or `line`.
- Don't hand-tint a `TabsList` with a `bg-*` override to make it visible on its host — that's what the `adaptive` colour set is for.
- Don't make dark `popover` equal to `card`, and don't hardcode a modal scrim — overlays sit above cards; scrims use the `scrim` token.
- Don't add accent bars (colored border strips) to signal status — use the component's `-subtle`/`-emphasis` variant.
- Don't put icons in table cells unless asked; don't let edge cells sit flush to the card border (align them to the card's `--card-spacing`).
- Don't give tooltips a `max-w`/fixed width that wraps the text; don't pin a side that lets one run off-screen (let it flip/shift within the viewport); don't let a container clip one (keep it portaled).
- Don't fix a row menu clipped by an `overflow-x-auto` table with `min-height` or by switching the wrapper to `overflow: visible` — the menu portals to the app root, so nothing clips it; and a table with pinned columns needs its scroll container intact.
- Don't pin a metric column, more than two columns on the left or one on the right, or a column that would take more than ~40% of a small screen; don't give a pinned cell its own `bg-*` — name the host with `surface` on `Table`.
- Don't stretch a `dropdown-menu` to its trigger width or pad its items with trailing whitespace; it shrink-wraps to its widest item.
- Don't render a `dropdown-menu` (or other anchored surface) inline in an `overflow-hidden` / scrolling container or with portalling disabled; it must portal to the app root so no parent clips it and stay within the viewport.
- Don't give charts different export sets, or a row of export buttons: use one icon-button dropdown with the standard Download PNG / SVG / PDF / CSV / Print items on every graph.
- Don't put a full-width date-picker field or a text export button in a chart header, and don't left- or right-align a chart legend: use compact icon buttons and center the legend.
- Don't fire a neutral `toast()` for a success/failure; don't mount two toasters or ship a titleless toast.
- Don't wire a destructive action straight to its trigger.
- Don't block a user on validation without inline, specific error text.
- Don't leave async failures as a blank screen or endless spinner — show an error + retry.
- Don't pin sizes to px; don't invent new sizes or override a component's built-in classes — use its variants / `size` props.
- Don't mix sizes in one toolbar (a `default` input beside `sm` selects and buttons) or fix the mismatch with a height override — put the whole row on one `size`.
- Don't give a field or an outline toggle on the `muted` page body `bg-background`, `bg-card` or `bg-white` to make it visible — in dark `background` sinks below the cards beside it; set `surface="page"`.
- Don't ship a button or clickable control without a visible hover + focus state (and `cursor: pointer`).
- Don't hand-compose a quiet destructive button from `ghost` + destructive utilities — use the `ghost-destructive` variant, which keeps the label red on hover.
- Don't cram a long, many-field form into a centered modal — use a right `sheet`.
- Don't add a checkmark to the selected item in a single-select `select` — the trigger value plus the highlighted option already show it; reserve checks for multi-select.
- Don't ship a dialog/sheet without its top-right close (X), or move it off the top-right corner (`showCloseButton={false}`) — only the `alert-dialog` omits it.
- Don't ship a chart without the standard controls, or give each chart its own date range that drifts out of sync with the others on the view.
- Don't give an event-window chart a date range or tie it to the page's range — the event sets its window. A chart whose window the reader can change is not an event-window chart and carries the full set.
- Don't solve the same job with different components across screens or graphs (a `select` here, a segmented control there) — pick one and reuse it.
- Don't pack many bars edge-to-edge into a solid block — widen the inter-bar gap (or aggregate) so they stay legible.
- Don't fully-round, pill-shape, or bottom-round bars, and don't vary the bar radius or thickness between charts — round only the top corners with one uniform radius.
- Don't color a statistical reference line (period average / min / max) with a status or commodity hue — keep it neutral/muted.
- Don't put a commodity red (`chart-gas`, `chart-emissions`) and `destructive` in the same visual role — a red series next to a red error state reads as one signal.
- Don't use a `chart-legacy-*` hue in a new feature, add a hue or role to that set, re-tune one locally, or hardcode a ported plot's hues — the legacy palette exists only to migrate existing plots and is not a second categorical palette.
