# UI Architect

> Authoring guardrails for building ANY new frontend UI in the OpenObserve web app (web/) — new views, pages, panels, dialogs, feature components, or edits to existing ones. Enforce seven house rules the moment you write Vue/template markup: (1) use OPageHeader for every page/module header, (2) build UI from O2 library components in web/src/lib — never bare HTML controls when an O2 equivalent exists, (3) NEVER write px — always rem, including inside Tailwind class arbitrary values ([320px] is banned; 1rem = 16px, so px/16 = rem and px/4 = the Tailwind step). px is allowed ONLY where it is the genuinely correct unit — hairlines, shadow/ring widths, query conditions, IntersectionObserver rootMargin, zero inside calc()/clamp(), user-facing copy, canvas/email consumers — and there it MUST carry an eslint-disable-next-line local/no-hardcoded-px with a `-- <reason>` at the site, never a side-file exemption (in a <style> block the directive goes inside the block as a CSS comment). The local/no-hardcoded-px rule runs i

- Skill: `openobserve/ui-architect` (Agent Skill, multi-file: 18 files)
- Install (CLI): `npx skillmds@latest add openobserve/ui-architect`
- Raw SKILL.md: https://api.skillmd.com/api/skills/openobserve/ui-architect/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: openobserve (https://skillmd.com/u/openobserve)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/openobserve/ui-architect

---


# UI Architect — Frontend UI Guardrails (OpenObserve `web/`)

Use this skill whenever you build or modify user-facing UI in `web/`. It is a
**pre-flight contract**: apply it before and while you write template markup, not
as a cleanup pass afterward. The goal is that every new screen looks like it was
built by the same team on the same day — one header, one component library, one
token system, one spacing scale.

This skill governs **feature/app UI** — views under `web/src/views` and components
under `web/src/components` — built from the shared **O2 component library** in
`web/src/lib`. This page is the **contract + map**: the seven laws and the
recurring structural decisions, each in a line or two, each pointing to the
reference that carries the full rationale, examples, and per-component detail.
Open the linked reference before you implement that specific thing — don't guess a
prop, a class string, or a path.

---

## The seven house rules

The always-true laws. Each is stated here in brief; the full **what / why / how +
code** for rules 1–6 is in [references/house-rules.md](references/house-rules.md), and
rule 7 has its own [references/responsive.md](references/responsive.md) —
read it once, it is the backbone of everything below.

1. **Every page/module header is `OPageHeader`** — never a hand-rolled
   `<div class="header">…<h1>` or a `q-toolbar`. One header contract keeps the
   title in the same place across list → detail → edit. Peer/section tabs need
   **`tabs-below`** (the slot alone renders them inline beside the title), and the
   header `icon` must be the SAME `IconName` the page's nav entry declares. The
   header's **create CTA is label-only — never `icon-left="add"`**.
2. **Build from O2 components in `web/src/lib`** — never a bare HTML control
   (`<button>`, `<input>`) or a third-party UI primitive when an `O*` equivalent
   exists. Drive them by **intent**
   (`variant` / `size` / state), never by appearance overrides.
3. **No hardcoded `px`** — size with `rem` / `%` / `vh` / `vw`, or Tailwind's
   rem-based scale. This applies **inside class arbitrary values too** (`w-[320px]`,
   `text-[13px]`, `gap-[6px]` are all banned — convert to `rem`).
   **The rule is simple: never write `px`. Write `rem`.** The only exceptions are the
   positions in the table below, where rem is wrong or does not resolve at all — and
   there the px must carry an `eslint-disable-next-line local/no-hardcoded-px --
   <reason>` at the site. **A px with no annotation fails CI.** That is the whole
   contract: new px cannot enter the codebase unnoticed, and a px that is genuinely
   correct only passes once someone has written down why.
   **CI-enforced by `local/no-hardcoded-px`** (eslint, defined in `web/eslint.config.js`),
   run by `lint:ci` over `src/**/*.{vue,ts,js,css}`. It reports line:column with the rem value
   and the Tailwind step, and surfaces in the editor as you type.
   - **Conversion:** `1rem = 16px` (the app sets no `html { font-size }`, so root is
     the browser default). So `px ÷ 16` → rem, and `px ÷ 4` → the Tailwind scale
     step: `300px` → `18.75rem` → `w-75`. Fractional steps are valid (`w-62.5`).
   - **`px` IS correct in these positions — do NOT "fix" them.** rem there is either
     wrong or does not resolve at all. The rule holds **no exemption list**: annotate the
     site instead, and say why.
     ```js
     // eslint-disable-next-line local/no-hardcoded-px -- IntersectionObserver rootMargin
     // parses px/% only — a rem value throws SyntaxError
     { rootMargin: "200px 0px" },
     ```
     The `-- <reason>` is required, not decoration: it is the only record of why, and
     ESLint flags the directive once it stops suppressing anything, so a stale exemption
     surfaces instead of lingering. Where a plain next-line directive will not fit:
     - **`<style>` block** — put the directive *inside the block*, in CSS-comment form.
       The rule parses style blocks itself and honours these, and reports one that
       suppresses nothing or omits its reason:
       ```css
       /* eslint-disable-next-line local/no-hardcoded-px -- hairline: 1 device pixel */
       border-bottom: 1px solid var(--color-border-default);
       ```
       Do **not** hoist it to `<script>`, and do not park a blanket `eslint-disable`
       above the block — that form runs to end of file and silences px nobody reviewed.
     - **Multi-line opening tag** — wrap it in `<!-- eslint-disable … -->` /
       `<!-- eslint-enable … -->`; a comment inside the tag is invalid markup.
     - **Multi-line template literal** — block disable/enable around the statement.

     **A range silences everything inside it, so make it the smallest thing that works.**
     Open it immediately before the element that owns the px — not before a parent
     wrapper — and close it on the line after that element's `>`. A range that spans a
     parent plus its child, or starts before `<template>`, is silently covering markup
     nobody reviewed, and **ESLint never reports an unused template directive**, so it
     will not tell you when it stops being needed. Prefer a single-line
     `eslint-disable-next-line` whenever the px sits on a line a comment can precede;
     reach for the block form only when the syntax leaves no other option.
     | Position | Why px |
     | --- | --- |
     | Hairlines and sub-pixel geometry `≤1.5px` (borders, dividers, rings, half-hairline offsets, gradient dot radii) | A 1-device-pixel rule must not scale with text, or it anti-aliases into a smear — or drops out entirely — at non-integer zoom and DPR |
     | **Exception:** `letter-spacing` / `word-spacing` / `tracking-[…]` at ANY size | Tracking is *typographic* — it must scale with the type it tracks, so it never earns the sub-pixel exemption. `tracking-[0.5px]` is a violation; use rem (or `tracking-tight`/`-normal`/`-wide` if the value matches) |
     | Shadow offsets, ring / border / outline widths, blur radii | Optical effects, not layout. Scaling them with text makes elevation bloom |
     | Media / container query **conditions** (`@max-[900px]/topbar`) | A threshold defining *when* layout changes, not a rendered length |
     | `IntersectionObserver` `rootMargin` | The API parses **px and % only** — `rem`, `em` **and bare `0`** all throw `SyntaxError` from the constructor, silently killing the observer and whatever it gates (lazy-load, prefetch-ahead-of-fold). Like a query condition, it is a scroll threshold, not a rendered length. `"200px 0px"` — keep both units |
     | Zero **inside** `calc()` / `clamp()` — `var(--x, 0px)`, `clamp(0px, …)` | `calc()` type-checks its arithmetic: `112px + 0` is *length + number*, which voids the whole declaration. The unit is load-bearing. Outside `calc()`, plain `0` is still right — `height: 0`, not `0px` |
     | **User-facing copy** — tooltip `content`, `placeholder`, `label`, template text (`1 unit = 30px`) | Prose *describing* a size, not a size being applied. Converting it rewrites the sentence — usually into a falsehood, since what it describes is typically a fixed layout constant that does not scale with font-size. Readers also do not think in rem |
     | `calc()` mixing `vh`/`vw` with a length | `vh` tracks the window, `rem` tracks font-size — converting one term makes the result depend on two independent variables |
     | `calc(var(--x) * 1px)` | A unit-conversion *operator* attaching a unit to a unitless JS-computed number, not a chosen dimension |
     | Canvas / ECharts / email consumers | No CSS cascade exists there — a detached measurement `<canvas>` has no root to resolve `rem` against, and an email resolves against the *recipient's* mail client |
     | `<svg width>` / `<img width>` attributes | SVG's attribute length grammar doesn't reliably accept `rem`; HTML dimension attributes take a bare integer |
     | Comments (`--text-xs: 0.75rem; /* 12px */`) | The px annotation is the *point* — nobody reads `0.75rem` and pictures a size |
   - **A size that JS parses with `parseInt` must stay px.** `parseInt("18.75rem")`
     is `18`, not `300` — a silent 16× shrink with no error and no failing test. If a
     value is read back by JS arithmetic, leave it in px rather than converting it.
   - **Prove the swap emits what you think — a utility is not always the literal.**
     Compile the real entry and diff the declarations rather than reasoning about it
     (postcss + `@tailwindcss/postcss`, `@import "./tailwind.css"` + `@source` a probe
     file, then compare `getComputedStyle` old vs new). Three ways this bites, all of
     which shipped as regressions before being caught:
     - A utility may resolve **through a variable**: `z-1` emits
       `z-index: var(--z-index-1)`, which is dead if that token is unregistered.
     - Bare `border` paints Tailwind's **default border colour, not `currentColor`** —
       replacing `border: 1.5px solid` silently recolours it. Add `border-current`.
     - Two utilities setting one property fight by **stylesheet order, not class
       order**. `w-22` loses to a `w-full` already on the element — while the inline
       `width` it replaced always won. Moving `style=""` to a class can therefore lose
       a cascade fight the original never had; add `!` only once you have measured it.
   - **A token existing does not mean its utility exists.** Registration in
     `@theme inline` is what generates the class; an unregistered token compiles to
     nothing and the property silently falls back (`border` → `currentColor`).
     `--color-border-subtle`/`-strong` used to be unregistered for exactly this
     reason — they are registered now, so `border-border-subtle` and
     `bg-border-strong` work. Check before you assume either way; if a token has no
     utility, register it rather than reaching for `var()`.
   - **Tailwind only emits class strings it can literally see.** JIT scans source
     text, so a class built at runtime (`` `bg-${color}` ``) is never generated —
     that is why per-row colouring goes through an inline style with a token, not a
     computed class. It also means a spelling you never wrote does not exist: the
     source may use `border-border-strong/10` while bare `border-border-strong`
     was never emitted.
   - **Font size — never `text-[..px/rem]`; pick the type-scale utility by role.**
     Only the px spelling is caught mechanically (`local/no-hardcoded-px` fails
     `text-[13px]`); **`text-[0.8125rem]` compiles silently**, so the rem form is a
     review item, not a CI gate. Both are equally banned — an arbitrary text size
     bypasses the scale whichever unit it uses.

     | Utility | px | Use for |
     |---|---|---|
     | `text-3xs` | 10 | chart axis micro-labels, dense table sub-text (charts only) |
     | `text-2xs` | 11 | tiny labels, chips, badge text |
     | `text-xs` | 12 | captions, metadata, timestamps |
     | `text-compact` | 13 | dense body / data tables |
     | `text-sm` | 14 | **default body text** (start here) |
     | `text-base` | 16 | comfortable body, form inputs |
     | `text-lg` | 18 | card / panel titles |
     | `text-xl` | 20 | section headings |
     | `text-2xl` | 24 | page / modal titles |
     | `text-3xl` | 30 | hero numbers / large display |
     | `text-4xl` | 36 | display |

     Default to `text-sm` for body. Go smaller only for genuinely dense/secondary UI,
     larger only for titles. If a design needs a size not on the scale, snap to the
     nearest step — do **not** reintroduce an arbitrary `text-[..]`.
   - **Casing — never uppercase anywhere in the app, except an established short
     form.** This is app-wide and strict. No shouting: don't bake caps into a string
     (`"FIRST APPEARED AT {time}"`, `"NEW TO THIS LIST"`) and don't force it with the
     `uppercase` utility. Write copy in **sentence case** (`"First appeared at {time}"`)
     — capitalize the first word and proper nouns only, not Every Word.
     **The one exception is an established short form** — an acronym, initialism, or
     abbreviation that is conventionally written in caps (`SQL`, `API`, `URL`, `ID`,
     `CPU`, `AI`, `HTTP`, `JSON`) and metric tokens (`p95`, `p99`) — which keep their
     canonical casing inside otherwise-sentence-case copy. A full word is never a short
     form: `DELETE`, `SAVE`, `NEW`, `SERVER` are violations; `Delete`, `Save`, `New`,
     `Server` are correct. This applies everywhere text renders: micro-labels,
     stat/tile captions, table headers, chips, badges, buttons, tooltips, empty states.
     Make a label quiet with `text-text-label`/`text-xs` weight + colour, not caps.
     `tracking-wide uppercase` is **not** the house label style. (The transform is a
     legibility cost — caps runs slow the reader and break at small sizes — and a
     baked-in caps string also can't be sentence-cased per locale.) `capitalize` is
     acceptable only for a single data token that must render title-cased.
   - **Corner radius — exactly two tiers + circle, never an arbitrary value:**
     `rounded-default` (**4px** — controls: buttons, inputs, chips, small icon
     buttons), `rounded-surface` (**12px** — surfaces: dialogs, drawers, cards,
     panels, the app-shell content area), `rounded-full` (pills / avatars / dots).
     Per-corner variants use the same names (`rounded-t-surface`, `rounded-s-default`).
     **Banned:** bare `rounded`, arbitrary `rounded-[10px]`, and the retired
     `rounded-{sm,md,lg,xl}` / `var(--radius-{sm,md,lg,xl})` (deleted — they were
     five names for one value). Pick the tier by role, not by eye.
   - **Shadow — one scale, and colour is a SEPARATE axis.** Elevation is
     `shadow-xs / sm / md / lg`; the directional roles are `--shadow-sticky-*`,
     `--shadow-rail`, `--shadow-ring-hairline`, `--shadow-scroll-*`, `--shadow-glow-*`.
     A focus/selection ring is `ring-2 ring-<token>/40`, not a shadow. A 1px hairline
     is `border-b border-<token>`, not a shadow.
     **Banned and CI-enforced (`arbShadow`) in all three spellings:**
     `shadow-[0_4px_12px_…]`, `box-shadow: <literal>` in CSS, and
     `boxShadow: "<literal>"` in JS. Accepted forms are `var(--shadow-*)`, `none`,
     and an interpolated `${…}` that is already a token.

     In a **template**, compose the two axes: `shadow-md shadow-ai-accent/35`.
     In a **stylesheet or JS** no utility exists, so the token layer publishes a
     colourless geometry half and you append the colour at the use site:

     ```css
     box-shadow: var(--shadow-glow-md-geom) color-mix(in srgb, var(--color-ai-accent) 35%, transparent);
     ```
     ```ts
     { boxShadow: `var(--shadow-rail-geom) ${color}` }   // colour chosen at runtime
     ```

     Three rules, each learned from a shipped bug:
     1. **Never write `var(--glow-color, <fallback>)` in a `:root` token.** A custom
        property is substituted against `:root`, where the override is unset, so the
        fallback wins and inherits down — a descendant setting it can never take
        effect. This silently no-op'd 38 sites.
     2. **A bare `shadow-<colour>` applies in BOTH themes.** A dark-only tint needs
        `dark:shadow-<colour>`; `shadow-xs shadow-white/8` renders white-on-white in
        light mode.
     3. **A new elevation step needs a `-c` colour token in `dark.css` too**, or it
        is invisible there — black at 8% on a `#101215` canvas paints nothing.
4. **No `<style scoped>` and no inline `style=""`** — style with **bare** Tailwind
   utilities (no `tw:` prefix — it was removed). Form-field spacing is
   `class="flex flex-col gap-5"` on `<OForm>`;
   omit it and fields render cramped with no spacing (the #1 "dialog looks broken"
   bug).
   - **No CSS preprocessor in an SFC** — `vue/block-lang` errors on
     `<style lang="scss|sass|less">`; `lang="css"` or no `lang` only. This is not
     taste: `postcss-scss` is not installed, so stylelint silently **skips** a scss
     block entirely — the hex ban, the `--o2-*` ban and the `.body--dark` ban stop
     running on that file with no warning. Plain CSS is also all a surviving block
     needs: what a Tailwind-first template leaves behind is `:deep()`,
     pseudo-elements and `@keyframes`, none of which want nesting.
   - **Where a rule goes when it can't be a utility:** an element reset →
     `src/styles/base-elements.css`; a reusable app-level treatment (pseudo-element,
     a class a library adds at runtime, a gradient background) →
     `src/styles/utilities.css` as an `@utility`. Gradients live there, **not** in
     `@theme` — a `@theme` colour compiles to `background-color: <gradient>`, which
     is invalid and dropped; the utility sets `background-image` instead.
5. **No literal colors or sizes — reach every value through a registered token,
   via its utility class.** Colour comes from a `--color-*` token's **token-backed
   utility** (`bg-surface-base`, `text-text-secondary`, `border-border-default`) —
   **not** a raw `var(--color-*)` in a `.vue` template/`<style>` block. A raw
   `var()` in a component is a counted bypass (`rawVarInComponent`) and is allowed
   **only** in the sanctioned residue: `:deep()`, `@keyframes`, `color-mix()`,
   `calc()`, SVG `fill`/`stroke`, and `v-html`/JS-generated markup. If a token has
   no utility, register it (`@theme inline`) rather than reaching for `var()`.
   **One knob per decision:** reuse an existing token before minting one, and never
   add a second name for a value that already has one — an alias is a decision made
   twice that silently splits adoption. The legacy **`--o2-*` vocabulary is
   BANNED** — never `var(--o2-*)`, never a new `--o2-*`, never a `.body--dark`
   block; migrate any `--o2-*` you touch. Raw Tailwind palette (`bg-gray-400`,
   `text-red-500`) does not even compile (`palette-reset.css`), and British
   `grey-*`/`primary-*` primitives in feature code are a zero-tolerance bypass
   (`rawProjectRamp`) — use a semantic token (`text-text-secondary`, `bg-accent`),
   not the ramp. See [references/design-tokens.md](references/design-tokens.md).

   > **All of §3–§5 are CI-enforced and FAIL the build** — `local/no-hardcoded-px`
   > (eslint) owns **px on every file type**; `lint:design:strict` owns the rest
   > (hardcoded hex, arbitrary radius/shadow, retired aliases, raw palette/ramp,
   > raw `var()` in a template *or* a `<style>` block, un-justified `<style>`,
   > literal font stacks), plus `lint:tokens`, `lint:token-purity`, `lint:styles`
   > (stylelint) and `format:check` (prettier) on every PR.
   >
   > **Tolerance is ZERO — there is no baseline any more.** `design-debt-baseline.json`
   > was deleted; one occurrence of any category fails the build. `--strict` is
   > accepted but ignored, and `--baseline` no longer exists — do not go looking for
   > a file to regenerate. Use `--list` to enumerate violations. Fix the cause; a new
   > exemption is not the answer.
   >
   > The counters scan **raw text, comments included** — a `16px` or `#fff` in a
   > `<style>`-block comment, or a banned class quoted verbatim, counts as debt.
   > Word comments in rem and plain English. This bites inside a CSS-in-TS template
   > literal too, where a comment is CSS: writing `inset 4px 0 6px` in prose there
   > fails `local/no-hardcoded-px`.
   >
   > **What the guard cannot see** (so review still matters): it walks only `.vue`
   > and `.ts` — standalone `.css` files are never scanned; the arbitrary-property
   > form `[background:…]` has no utility prefix so it slips past; and a `var()`
   > fallback (`var(--color-x,#fff)`) hides a read from the `color-mix` rules.
6. **No hardcoded user-facing text** — every label, title, placeholder, tooltip,
   empty-state, toast, and validation message comes from `useI18nTyped()`'s `t()`,
   with keys added to `web/src/locales/languages/en-US.json` (other locales follow
   from there — never hand-edit them).

   > **Where each surface is enforced.** Lint sees only `<template>`; everything
   > else is enforced by the TYPES at `npm run type-check:app`. Both gate CI.
   >
   > | Surface | Enforced by |
   > |---|---|
   > | Text node — `<div>Save</div>` | `vue/no-bare-strings-in-template` |
   > | Mustache literal — `{{ 'Save' }}` | `local/no-bare-bound-text-props` |
   > | `v-text` / `v-html` literal | `local/no-bare-bound-text-props` |
   > | Component prop — `label="Save"` **or** `:label="'Save'"` | **`I18nText` type** |
   > | Any string in `<script>` / `.ts` | **`I18nText` type** |
   > | Native HTML/ARIA attr — `<input placeholder="Search">` | `vue/no-bare-strings-in-template` |
   > | `t('x.y')` key exists | `@intlify/vue-i18n/no-missing-keys` |
   > | A key stored as data (`titleKey`) | **`I18nKey` type** |
   >
   > Two consequences worth internalising:
   > - **Lint does NOT check component props** — that is deliberate. A text-carrying
   >   prop is caught by its `I18nText` declaration, which is strictly stronger (it
   >   also rejects a plain `string` variable, which no lint rule could see). There
   >   is no `TEXT_ATTRS` list any more; **declare the prop `I18nText` and you are
   >   done.**
   > - **Native HTML/ARIA text attributes ARE linted** — `title`, `alt`, `aria-label`
   >   (+ `aria-placeholder` / `aria-roledescription` / `aria-valuetext`) on any
   >   element, and `placeholder` on `<input>` / `<textarea>`. They get lint rather
   >   than the type because a native element has no prop to annotate. Residual gap:
   >   only the STATIC form is covered, so `:title="'Delete'"` still slips through —
   >   don't reach for it to dodge the error.
   >
   > **Which translator — `t()` first, `gt()` only when it cannot reach.**
   >
   > | Where you are | Use |
   > |---|---|
   > | `<script setup>` / inside `setup()` | **`t()`** from `useI18nTyped()` |
   > | `.ts` module called from a component | **thread `t` in** as a parameter |
   > | Module scope, nothing to thread from (route guards, registries, import-time singletons) | **`gt()`** |
   > | A key stored as DATA, resolved later (`titleKey`, `labelKey`) | neither — store `I18nKey`, resolve with `t()` at render |
   >
   > `gt()` is the escape hatch, not the default — before reaching for it, ask whether
   > the function can take `t: TranslateFn` as an argument; usually it can, and the
   > caller already has one. At module scope, put `gt()` behind a **getter** so it
   > resolves at read time, not import time:
   >
   > ```ts
   > // WRONG — frozen at whatever locale was loaded when this module was imported
   > export const destination = { name: gt("alerts.email") };
   > // RIGHT — resolves when the picker renders
   > export const destination = { get name() { return gt("alerts.email"); } };
   > ```
   >
   > **What must NEVER enter the catalogue.** A key is a promise that translating the
   > string is safe. These break that promise — `raw()` them and keep them out:
   >
   > | Kind | Examples | What broke when translated |
   > |---|---|---|
   > | Values code compares or persists | a sentinel, an enum, a generated name | logic silently stops matching, non-English users only |
   > | Product / company names | `Kafka`, `Zookeeper`, `NATS`, `Airflow` | shipped as `Zoowärter`, `HORMIGAS` (ants), `Luftstrom` (air current) |
   > | Acronyms that are names | `RUM`, `DAG`, `IAM`, `AGPL`, `P95` | shipped as `RON` (the drink), `DÍA` (day), `SOY YO` ("I am me") |
   > | Code the user copies or types | SQL snippets, regexes, model ids, field names, env vars | `gpt-4.*` → `gpt-4. *`; a pasted sample no longer runs |
   >
   > The test is **not** "is it user-visible" — all of the above are. It is **"is there
   > one correct form worldwide?"** If yes, it is not copy.
   >
   > **A name INSIDE a sentence: interpolate it out, don't `raw()` the sentence.**
   >
   > ```ts
   > // WRONG — freezes the whole sentence in English
   > raw("Route all telemetry through the OTel Collector")
   > // WRONG — the translator mangles the product name
   > t("traces.noData.otelCollectorDesc")
   > // RIGHT — catalogue holds "…through the {product}"
   > t("traces.noData.otelCollectorDesc", { product: raw("OTel Collector") })
   > ```
   >
   > Same for an example token: `"e.g. {example}"` + `{ example: raw("gpt-4.*") }` keeps
   > *"e.g."* translatable while the token becomes unreachable. Never split a sentence
   > into fragments you concatenate — word order is per-language.
   >
   > **A string that is both a label and an identifier: split it.** Give display and
   > machine value separate fields — `{ label: t("iam.roleAdmin"), value: "admin" }`.
   > Before translating any label, check for a sibling `value:`; if the label IS the
   > value, translating it breaks the comparison.
   >
   > **Non-translatable text — the ladder.** Decide in this order:
   >
   > 1. **Does code branch on it?** (`"px" | "%"`, `"sm" | "md"`) → it is not text at
   >    all. Use a **union type**. Never an i18n concern.
   > 2. **Everywhere else → `raw("…")`** from `@/types/i18n`. This is the default and
   >    covers script data, typed props, bound expressions **and** text nodes:
   >    `<code>{{ raw("time_bucket") }}</code>`. It is type-checked, survives
   >    refactors, and `grep -rn "raw(" src` enumerates every exemption in the app
   >    (~1,185 of them).
   > 3. **Only if the token is short, universal and RECURS across files** → add it to
   >    the allowlist in `eslint.config.js`, which is split into three named groups so
   >    reviewers can apply the right scrutiny:
   >    `GLYPHS_AND_UNITS` (`px`, `ms`, `×`, `→`, `●`, `…`), `SPEC_IDENTIFIERS`
   >    (`GET`, `UTC`, `SQL`, `OK`), `TEXT_NODE_LITERALS` (`1000`, `./.env`,
   >    `trace.zip`). An entry here is **global, permanent and context-free** — it
   >    silences that string in every file forever, so it must be genuinely universal.
   >
   > **Do NOT use `eslint-disable` for i18n.** There are **zero** of them left in
   > `src/` and that is deliberate — `raw()` says the same thing at the call site, is
   > type-checked, and shows up in one greppable inventory. (Disables for *other*
   > rules — hyphenation, `x-invalid-end-tag` — are fine and still present.)
   >
   > **Moving a text node into `raw()` changes its parsing context from HTML to
   > JavaScript.** Four things bite:
   > - `\` becomes an escape prefix. `raw("\w+")` silently renders `w+`. Write
   >   `raw("\\w+")` to render `\w+`.
   > - HTML entities stop decoding. `&amp;` renders literally — use the real
   >   character: `raw("a & b")`.
   > - A literal `<` **breaks Prettier**, which parses `{{ raw("<Foo>") }}` as a tag
   >   and hard-fails the file (`format:check` is a CI gate). Hoist it into
   >   `<script setup>`: `const tag = raw("<Foo>")` and interpolate `{{ tag }}`.
   > - Surrounding whitespace is dropped. `<div>\n  OO\n</div>` renders `" OO "` but
   >   `<div>\n  {{ raw("OO") }}\n</div>` renders `"OO"` — invisible in normal flow,
   >   but check it inside `<pre>` / `white-space: pre-line`.
   >
   > **Plurals use vue-i18n pipe syntax, never string concatenation.** Write the key
   > as `"{count} occurrence | {count} occurrences"` and call
   > `t("alerts.occurrence", { count: n }, n)`. Never
   > `{{ n }} {{ t('x') }}{{ n > 1 ? 's' : '' }}` — no other language pluralises that
   > way, and a translator reading en-US.json cannot see the appended `s`.

   > **Text in `<script>` — use the TYPES, not a lint rule.** The three rules above
   > only see `<template>`. A string in `<script>`/`.ts` — a table column `label`, a
   > toast `message`, an i18n key stored as data — is invisible to them, because
   > deciding "is this string user-facing?" from the string alone is guesswork. So
   > that decision lives in the **type declaration**, where the author already knows
   > the answer, and `npm run type-check:app` enforces it. Two types in
   > **`web/src/types/i18n.ts`**:
   >
   > | Type | Use for | Effect |
   > |---|---|---|
   > | **`I18nKey`** | a field holding an i18n **key** (`titleKey`, `labelKey`) | only real en-US.json paths compile; a typo gets a "Did you mean…?" |
   > | **`I18nText`** | a field holding **resolved user-facing text** (`label`, `message`, `title`) | a bare string literal is a compile error; only `t()` / `raw()` satisfy it |
   >
   > ```ts
   > import type { I18nKey, I18nText } from "@/types/i18n";
   >
   > interface Column {
   >   label: I18nText;    // user-facing  -> must be t() or raw()
   >   field: string;      // data accessor -> ordinary string
   > }
   > interface Preset { titleKey: I18nKey }   // stores a key, not the text
   > ```
   >
   > **When you declare a new interface, prop type, or `*.types.ts` that carries UI
   > text or an i18n key, type that field as `I18nText` / `I18nKey` — never bare
   > `string`.** This is the same pattern the library already uses for icons
   > (`iconLeft?: IconName`): a constrained type derived from a source of truth.
   > `I18nKey` is derived from en-US.json at compile time, so there is no list to
   > maintain — add a key and it is instantly valid.
   >
   > Careful: a `*Key` field is **not** always an i18n key. `OSelect.labelKey` and
   > `JourneySteps.actionKey` are *field accessors* ("which property of the row holds
   > the label") and stay `string`. Read the doc comment before annotating.
   >
   > The opt-out is **`raw()`**, not an eslint-disable — it is type-checked, survives
   > refactors, and `grep -rn "raw(" src` lists every exemption in the app:
   > ```ts
   > const columns = [
   >   { label: t("logs.timestamp"), field: "ts" },
   >   { label: raw("trace_id"),     field: "trace_id" },  // a field name, not prose
   > ];
   > ```
   > **Getting a `t()` that returns `I18nText`** — the whole app already does this:
   > - **In a component** → `const { t } = useI18nTyped()` (from `@/types/i18n`).
   >   Never import `useI18n` from `vue-i18n` directly; `useI18nTyped()` hands back
   >   the exact same composer, just typed, so everything else is unchanged.
   > - **Outside a setup context** (a composable reached from a plain function, a
   >   util, service-layer error handling) → `gt("some.key")` from the same module.
   >   `useI18n()` may only be called during setup; `gt` reads the shared instance.
   >
   > **Non-translatable text uses `raw()`** — a server-provided error message, an
   > identifier, a code token. It accepts nullish, so the usual fallback chain reads
   > naturally and stays type-safe:
   > ```ts
   > toast({
   >   variant: "error",
   >   message: raw(err.response?.data?.message) || t("alerts.saveFailed"),
   > });
   > ```
   > Never reach for `raw()` to silence the checker on real UI copy — that is exactly
   > the bug the brand exists to catch. `grep -rn "raw(" src` is the review surface.
   >
   > **Composed text is a type error, by design.** `"Deleted " + n + " rows"`,
   > `cond ? "Yes" : "No"` and `` `Saved ${name}` `` all widen to `string`, so they
   > cannot satisfy `I18nText`. Use vue-i18n interpolation instead —
   > `t("x.deletedRows", { count: n })` with `"Deleted {count} rows"` in en-US.json —
   > and a plural message (`"one | many"` + `t(key, params, count)`) when singular and
   > plural really differ. The same rule is enforced in `<template>` by
   > `local/no-bare-bound-text-props`.
   >
   > Toast/notification copy added by this convention lives under `toastMessages.*`,
   > grouped by module.

7. **Every page is responsive — and the laptop view does not move.** New pages
   are built for 375 px phones and 768 px tablets from the first commit, using the
   method in [references/responsive.md](references/responsive.md):
   - **Desktop is frozen.** Responsive rules are additive below a breakpoint —
     `max-md:` (phone), `max-lg:` / `md:max-lg:` (tablet) — or a JS branch on
     `useBreakpoint()`'s `isMobile` / `!lgUp`. An unprefixed class change or a bare
     `md:`/`lg:` class that alters ≥1024 px is a blocker. JS is only for structure
     (rail → drawer, toggle strip → dropdown, splitter locked shut).
   - **One row of chrome.** Header: primaries in `#actions`, secondaries in
     `#actions-overflow` (inline on desktop, one ⋮ below md; `overflow-first` when
     they precede the CTA). Toolbar: wrapper `max-md:contents`, filter
     `OToggleGroup mobile-dropdown`, search `min-w-0 flex-1 max-md:min-w-40`.
     Stat tiles: `OStatStrip` / `KpiCard` (compact to icon + value below lg).
   - **Side panels open from their own row.** `OPageLayout #sidebar` and
     `FolderList` already become drawers; any other rail renders in an
     `ODrawer` with `anchor` set to its trigger's row. Only the main nav opens from
     the top.
   - **Tables keep every column** and scroll within the frame (OTable does it);
     inline row actions get `max-md:hidden` plus one `md:hidden` kebab mirroring them
     with `<data-test>-menu` items; the footer count is `max-md:hidden`.
   - **Nothing clipped, nothing hover-only.** Popups use library components and
     `min(<w>, calc(100vw - 1.5rem))` widths; an `h-full` pane beside a stacked
     sibling gets `max-md:h-auto max-md:min-h-0`; hover-revealed controls get
     `max-md:opacity-100`.
   - **Verify** at 375 / 360 / 768 / 1280 in the in-app browser, and at 1280 compare
     against main — identical is the bar.

## Structural decisions

*What* to reach for and *where the code lives* — the recurring calls that
otherwise get answered differently on every screen. One line each; the full
reasoning, spacing patterns, the cancel/save standard, layering, and the
form-container split are in [references/conventions.md](references/conventions.md),
and each domain has its own reference below.

| Decision | The rule | Detail |
| --- | --- | --- |
| **Tabular data** | `OTable` + `OTableColumnDef[]`; client-side pagination unless the backend paginates a set too large to fetch whole | [core-controls-table](references/core-controls-table.md) |
| **Charts / graphs** | **Every data chart renders through the shared dashboard engine — never mount a charting lib in a feature page.** Time-series, category, scatter, geo/map, gauge, pie → **`PanelSchemaRenderer`** (`web/src/components/dashboards/PanelSchemaRenderer.vue`) with a panel schema: it runs the query, applies the app's unit/theme/annotation formatting, and owns the loading/error ladder. **Banned in feature code:** `echarts.init` / a raw `<v-chart>` / ApexCharts / D3 / Chart.js / a hand-rolled `<canvas>` or `<svg>` plot. The low-level **`panels/ChartRenderer.vue`** (raw ECharts option) is the ONLY sanctioned escape hatch, and ONLY when you need chart-`@click` forwarding `PanelSchemaRenderer` doesn't re-emit — annotate the site with why, and convert once the schema renderer forwards clicks. **Not charts** (do NOT force these through the renderer): in-row trend lines are **`OSparkline`**, single-value share bars are **`OProgressBar`**, in-cell data bars are the table's **`ODataBarCell`**, and a decorative topology/diagram is bespoke SVG. | [core-display](references/core-display.md) |
| **Whole-page layout** | **Every routed view is a `OPageLayout`.** It's the ONE page component — it owns the full-height column, the header (from `:title`/`:icon`/`:subtitle`/`:back` props + `#actions`/`#header-tabs`, the latter needing **`tabs-below`** to land in row 2 instead of inline), an optional `#subnav` strip, an optional `#sidebar` rail (fixed or `resizable`), and the body's inset. You plug in data; there's no place to hand-roll a padded `<div>`. Body is inset to the page-edge grid by default — pass **`bleed`** for a full-bleed body (an `OTable`, a chart, a `router-view` shell), or **`constrained`** for a centered reading column (forms). The `#header` slot is a rare escape hatch only. | [page-recipes](references/page-recipes.md) |
| **Content inset** | `OPageLayout` already insets the body. Anywhere else (a panel, a dialog section, one tab's content) wrap it in **`OContent`** (bakes the one `px-page-edge` grid line, the primitive `OPageLayout` uses internally) instead of hand-picking `px-2`/`px-4`/`p-2.5`; pass `bleed` (or `bleed-x`/`bleed-y`) for full-bleed content that owns its own edge — same escape-hatch idea as `ODrawer`/`ODialog` `bleed`. Never hand-roll a content inset. | [conventions](references/conventions.md) |
| **Tab strips** | an `OTabs` strip needs **no** horizontal wrapper padding — the first tab's label self-aligns to the `px-page-edge` grid, so it lines up with the `OContent` body below it. Put the strip's bottom divider on the strip (`border-b`) and give it no `px-*`; wrapping a tab strip in `px-page-edge` double-insets the labels. | [conventions](references/conventions.md) |
| **Listing toolbar** | every list carries three affordances — search + filters (`#toolbar`), refresh (`#toolbar-trailing`), and the auto-injected column-visibility toggle; empty state is one `OEmptyState` with `:filtered`, in the `#empty` slot only, plus `:forbidden` on the table so a 403 shows "You don't have access" instead of "create your first…" | [page-recipes](references/page-recipes.md) |
| **Data fetching** | view → domain service (`src/services`, via the `http.ts` wrapper) → Vuex (shared/cached) or local `ref` (ephemeral); never call `http`/axios from a component | [conventions](references/conventions.md) |
| **Form container** | confirm → `ConfirmDialog`; short form → `ODialog`; tall or contextual form → `ODrawer`; primary multi-section flow → a full in-page view. Use `ODialog` / `ODrawer` for these | [conventions](references/conventions.md) |
| **Form validation** | `OForm` + a colocated Zod `<Form>.schema.ts`; fields are `OForm*` bound **only by `name=`** (no `v-model`/`ref` mirror, no `formData`); submit + loading automatic; payload built with explicit keys; field arrays use `:key="index"` | [forms-validation](references/forms-validation.md) |
| **New page in nav** | a route **+ exactly one** surface (rail item / flyout child / Settings / IAM sub-page) **+** an env/role gate — the route condition, the nav-entry gate, and the SectionRail `visible` all express the same rule | [navigation-menus](references/navigation-menus.md) |
| **Keyboard shortcuts** | registry-driven — declare in `shortcutRegistry.ts`, bind with `useShortcuts([{ id, handler }])`; never an ad-hoc `keydown` listener or a hardcoded `⌘N` in a template | [keyboard-shortcuts](references/keyboard-shortcuts.md) |
| **Cancel / Save row** | cancel = `variant="outline"`, save = `variant="primary"`, both `size="sm-action"`, spaced with `gap-2` on the parent | [conventions](references/conventions.md) |
| **Responsive** | `max-md:`/`max-lg:` variants + `useBreakpoint()` for structure; desktop unchanged; one row of header/toolbar/stat chrome; rails → anchored drawers; row actions → kebab; popups ≤ viewport | [responsive](references/responsive.md) |
| **Nothing fits** | build a reusable component — generic primitive → a new `O*` in `web/src/lib`; app-specific composition → a named component in `web/src/components`. Never hand-assemble `<div>` + utility classes to fake a component | [creating-components](references/creating-components.md) |

**Dark mode is automatic** — every O2 component and token resolves correctly in
both themes. Never branch on `store.state.appTheme` around an O2 component; if
something looks wrong in dark mode, the fix is a token value in `dark.css`, not a
per-component conditional.

## Colour that means something — the "Calm Signal" language

`design-tokens.md` (rule 5) is *how* to colour; this is *when* and *what*. One
rule: **colour is information, never decoration** — a calm neutral canvas, with
saturated colour spent only on the one signal each screen exists to surface.
Which signal that is changes by page type (**monitoring** = state/severity;
**catalog** = category/recency/ownership; **access** = role; **forms** =
progress/validity), and you colour it with the shared toolkit — `OStatStrip` /
`OStatCard` summary tiles (optionally filter tiles, via `OTable`'s `#subheader`

…(truncated)
