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, and rule 7 has its own references/responsive.md — read it once, it is the backbone of everything below.
Every page/module header is
OPageHeader— never a hand-rolled<div class="header">…<h1>or aq-toolbar. One header contract keeps the title in the same place across list → detail → edit. Peer/section tabs needtabs-below(the slot alone renders them inline beside the title), and the headericonmust be the SAMEIconNamethe page's nav entry declares. The header's create CTA is label-only — nevericon-left="add".Build from O2 components in
web/src/lib— never a bare HTML control (<button>,<input>) or a third-party UI primitive when anO*equivalent exists. Drive them by intent (variant/size/ state), never by appearance overrides.No hardcoded
px— size withrem/%/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 torem). The rule is simple: never writepx. Writerem. 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 aneslint-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 bylocal/no-hardcoded-px(eslint, defined inweb/eslint.config.js), run bylint:cioversrc/**/*.{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 nohtml { font-size }, so root is the browser default). Sopx ÷ 16→ rem, andpx ÷ 4→ the Tailwind scale step:300px→18.75rem→w-75. Fractional steps are valid (w-62.5).pxIS 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.// 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:
Do not hoist it to/* eslint-disable-next-line local/no-hardcoded-px -- hairline: 1 device pixel */ border-bottom: 1px solid var(--color-border-default);<script>, and do not park a blanketeslint-disableabove 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-lineeslint-disable-next-linewhenever 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 sizeTracking 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 (ortracking-tight/-normal/-wideif 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 IntersectionObserverrootMarginThe API parses px and % only — rem,emand bare0all throwSyntaxErrorfrom 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 unitsZero inside calc()/clamp()—var(--x, 0px),clamp(0px, …)calc()type-checks its arithmetic:112px + 0is length + number, which voids the whole declaration. The unit is load-bearing. Outsidecalc(), plain0is still right —height: 0, not0pxUser-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()mixingvh/vwwith a lengthvhtracks the window,remtracks font-size — converting one term makes the result depend on two independent variablescalc(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 resolveremagainst, and an email resolves against the recipient's mail client<svg width>/<img width>attributesSVG's attribute length grammar doesn't reliably accept rem; HTML dimension attributes take a bare integerComments ( --text-xs: 0.75rem; /* 12px */)The px annotation is the point — nobody reads 0.75remand pictures a sizeA size that JS parses with
parseIntmust stay px.parseInt("18.75rem")is18, not300— 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"+@sourcea probe file, then comparegetComputedStyleold vs new). Three ways this bites, all of which shipped as regressions before being caught:- A utility may resolve through a variable:
z-1emitsz-index: var(--z-index-1), which is dead if that token is unregistered. - Bare
borderpaints Tailwind's default border colour, notcurrentColor— replacingborder: 1.5px solidsilently recolours it. Addborder-current. - Two utilities setting one property fight by stylesheet order, not class
order.
w-22loses to aw-fullalready on the element — while the inlinewidthit replaced always won. Movingstyle=""to a class can therefore lose a cascade fight the original never had; add!only once you have measured it.
- A utility may resolve through a variable:
A token existing does not mean its utility exists. Registration in
@theme inlineis what generates the class; an unregistered token compiles to nothing and the property silently falls back (border→currentColor).--color-border-subtle/-strongused to be unregistered for exactly this reason — they are registered now, soborder-border-subtleandbg-border-strongwork. Check before you assume either way; if a token has no utility, register it rather than reaching forvar().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 useborder-border-strong/10while bareborder-border-strongwas 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-pxfailstext-[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-3xs10 chart axis micro-labels, dense table sub-text (charts only) text-2xs11 tiny labels, chips, badge text text-xs12 captions, metadata, timestamps text-compact13 dense body / data tables text-sm14 default body text (start here) text-base16 comfortable body, form inputs text-lg18 card / panel titles text-xl20 section headings text-2xl24 page / modal titles text-3xl30 hero numbers / large display text-4xl36 display Default to
text-smfor 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 arbitrarytext-[..].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 theuppercaseutility. 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,SERVERare violations;Delete,Save,New,Serverare correct. This applies everywhere text renders: micro-labels, stat/tile captions, table headers, chips, badges, buttons, tooltips, empty states. Make a label quiet withtext-text-label/text-xsweight + colour, not caps.tracking-wide uppercaseis 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.)capitalizeis 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: barerounded, arbitraryrounded-[10px], and the retiredrounded-{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 isring-2 ring-<token>/40, not a shadow. A 1px hairline isborder-b border-<token>, not a shadow. Banned and CI-enforced (arbShadow) in all three spellings:shadow-[0_4px_12px_…],box-shadow: <literal>in CSS, andboxShadow: "<literal>"in JS. Accepted forms arevar(--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:box-shadow: var(--shadow-glow-md-geom) color-mix(in srgb, var(--color-ai-accent) 35%, transparent);{ boxShadow: `var(--shadow-rail-geom) ${color}` } // colour chosen at runtimeThree rules, each learned from a shipped bug:
- Never write
var(--glow-color, <fallback>)in a:roottoken. 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. - A bare
shadow-<colour>applies in BOTH themes. A dark-only tint needsdark:shadow-<colour>;shadow-xs shadow-white/8renders white-on-white in light mode. - A new elevation step needs a
-ccolour token indark.csstoo, or it is invisible there — black at 8% on a#101215canvas paints nothing.
- Never write
No
<style scoped>and no inlinestyle=""— style with bare Tailwind utilities (notw:prefix — it was removed). Form-field spacing isclass="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-langerrors on<style lang="scss|sass|less">;lang="css"or nolangonly. This is not taste:postcss-scssis not installed, so stylelint silently skips a scss block entirely — the hex ban, the--o2-*ban and the.body--darkban 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.cssas an@utility. Gradients live there, not in@theme— a@themecolour compiles tobackground-color: <gradient>, which is invalid and dropped; the utility setsbackground-imageinstead.
- No CSS preprocessor in an SFC —
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 rawvar(--color-*)in a.vuetemplate/<style>block. A rawvar()in a component is a counted bypass (rawVarInComponent) and is allowed only in the sanctioned residue::deep(),@keyframes,color-mix(),calc(), SVGfill/stroke, andv-html/JS-generated markup. If a token has no utility, register it (@theme inline) rather than reaching forvar(). 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 — nevervar(--o2-*), never a new--o2-*, never a.body--darkblock; migrate any--o2-*you touch. Raw Tailwind palette (bg-gray-400,text-red-500) does not even compile (palette-reset.css), and Britishgrey-*/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.All of §3–§5 are CI-enforced and FAIL the build —
local/no-hardcoded-px(eslint) owns px on every file type;lint:design:strictowns the rest (hardcoded hex, arbitrary radius/shadow, retired aliases, raw palette/ramp, rawvar()in a template or a<style>block, un-justified<style>, literal font stacks), pluslint:tokens,lint:token-purity,lint:styles(stylelint) andformat:check(prettier) on every PR.Tolerance is ZERO — there is no baseline any more.
design-debt-baseline.jsonwas deleted; one occurrence of any category fails the build.--strictis accepted but ignored, and--baselineno longer exists — do not go looking for a file to regenerate. Use--listto enumerate violations. Fix the cause; a new exemption is not the answer.The counters scan raw text, comments included — a
16pxor#fffin 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: writinginset 4px 0 6pxin prose there failslocal/no-hardcoded-px.What the guard cannot see (so review still matters): it walks only
.vueand.ts— standalone.cssfiles are never scanned; the arbitrary-property form[background:…]has no utility prefix so it slips past; and avar()fallback (var(--color-x,#fff)) hides a read from thecolor-mixrules.No hardcoded user-facing text — every label, title, placeholder, tooltip, empty-state, toast, and validation message comes from
useI18nTyped()'st(), with keys added toweb/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 atnpm run type-check:app. Both gate CI.Surface Enforced by Text node — <div>Save</div>vue/no-bare-strings-in-templateMustache literal — {{ 'Save' }}local/no-bare-bound-text-propsv-text/v-htmlliterallocal/no-bare-bound-text-propsComponent prop — label="Save"or:label="'Save'"I18nTexttypeAny string in <script>/.tsI18nTexttypeNative HTML/ARIA attr — <input placeholder="Search">vue/no-bare-strings-in-templatet('x.y')key exists@intlify/vue-i18n/no-missing-keysA key stored as data ( titleKey)I18nKeytypeTwo consequences worth internalising:
- Lint does NOT check component props — that is deliberate. A text-carrying
prop is caught by its
I18nTextdeclaration, which is strictly stronger (it also rejects a plainstringvariable, which no lint rule could see). There is noTEXT_ATTRSlist any more; declare the propI18nTextand you are done. - Native HTML/ARIA text attributes ARE linted —
title,alt,aria-label(+aria-placeholder/aria-roledescription/aria-valuetext) on any element, andplaceholderon<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>/ insidesetup()t()fromuseI18nTyped().tsmodule called from a componentthread tin as a parameterModule 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 witht()at rendergt()is the escape hatch, not the default — before reaching for it, ask whether the function can taket: TranslateFnas an argument; usually it can, and the caller already has one. At module scope, putgt()behind a getter so it resolves at read time, not import time:// 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,Airflowshipped as Zoowärter,HORMIGAS(ants),Luftstrom(air current)Acronyms that are names RUM,DAG,IAM,AGPL,P95shipped 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 runsThe 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.// 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 siblingvalue:; if the label IS the value, translating it breaks the comparison.Non-translatable text — the ladder. Decide in this order:
- Does code branch on it? (
"px" | "%","sm" | "md") → it is not text at all. Use a union type. Never an i18n concern. - 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, andgrep -rn "raw(" srcenumerates every exemption in the app (~1,185 of them). - 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-disablefor i18n. There are zero of them left insrc/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 rendersw+. Writeraw("\\w+")to render\w+.- HTML entities stop decoding.
&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:checkis 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 callt("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 appendeds.Text in
<script>— use the TYPES, not a lint rule. The three rules above only see<template>. A string in<script>/.ts— a table columnlabel, a toastmessage, 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, andnpm run type-check:appenforces it. Two types inweb/src/types/i18n.ts:Type Use for Effect I18nKeya field holding an i18n key ( titleKey,labelKey)only real en-US.json paths compile; a typo gets a "Did you mean…?" I18nTexta field holding resolved user-facing text ( label,message,title)a bare string literal is a compile error; only t()/raw()satisfy itimport 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 textWhen you declare a new interface, prop type, or
*.types.tsthat carries UI text or an i18n key, type that field asI18nText/I18nKey— never barestring. This is the same pattern the library already uses for icons (iconLeft?: IconName): a constrained type derived from a source of truth.I18nKeyis 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
*Keyfield is not always an i18n key.OSelect.labelKeyandJourneySteps.actionKeyare field accessors ("which property of the row holds the label") and staystring. Read the doc comment before annotating.The opt-out is
raw(), not an eslint-disable — it is type-checked, survives refactors, andgrep -rn "raw(" srclists every exemption in the app:const columns = [ { label: t("logs.timestamp"), field: "ts" }, { label: raw("trace_id"), field: "trace_id" }, // a field name, not prose ];Getting a
t()that returnsI18nText— the whole app already does this:- In a component →
const { t } = useI18nTyped()(from@/types/i18n). Never importuseI18nfromvue-i18ndirectly;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;gtreads 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: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(" srcis the review surface.Composed text is a type error, by design.
"Deleted " + n + " rows",cond ? "Yes" : "No"and`Saved ${name}`all widen tostring, so they cannot satisfyI18nText. 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>bylocal/no-bare-bound-text-props.Toast/notification copy added by this convention lives under
toastMessages.*, grouped by module.- Lint does NOT check component props — that is deliberate. A text-carrying
prop is caught by its
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:
- Desktop is frozen. Responsive rules are additive below a breakpoint —
max-md:(phone),max-lg:/md:max-lg:(tablet) — or a JS branch onuseBreakpoint()'sisMobile/!lgUp. An unprefixed class change or a baremd:/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-firstwhen they precede the CTA). Toolbar: wrappermax-md:contents, filterOToggleGroup mobile-dropdown, searchmin-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 #sidebarandFolderListalready become drawers; any other rail renders in anODrawerwithanchorset 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:hiddenplus onemd:hiddenkebab mirroring them with<data-test>-menuitems; the footer count ismax-md:hidden. - Nothing clipped, nothing hover-only. Popups use library components and
min(<w>, calc(100vw - 1.5rem))widths; anh-fullpane beside a stacked sibling getsmax-md:h-auto max-md:min-h-0; hover-revealed controls getmax-md:opacity-100. - Verify at 375 / 360 / 768 / 1280 in the in-app browser, and at 1280 compare against main — identical is the bar.
- Desktop is frozen. Responsive rules are additive below a breakpoint —
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, 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| Cancel / Save row | cancel = variant="outline", save = variant="primary", both size="sm-action", spaced with gap-2 on the parent |
conventions |
| 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 |
| 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 |
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)