Install the "ui-architect" agent skill from https://github.com/openobserve/openobserve/tree/main/.claude/skills/ui-architect into .claude/skills/ui-architect/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "ui-architect", then confirm the skill loads.
Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
Type this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
skills CLI
$ npx skills add openobserve/openobserve --skill ui-architect -a codex
Project install goes to .agents/skills/; add -g for ~/.codex/skills/.
Install the "ui-architect" agent skill from https://github.com/openobserve/openobserve/tree/main/.claude/skills/ui-architect into .agents/skills/ui-architect/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "ui-architect", then confirm the skill loads.
Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
skills CLI
$ npx skills add openobserve/openobserve --skill ui-architect -a cursor
Project install goes to .agents/skills/; add -g for ~/.cursor/skills/.
Install the "ui-architect" agent skill from https://github.com/openobserve/openobserve/tree/main/.claude/skills/ui-architect into .cursor/skills/ui-architect/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "ui-architect", then confirm the skill loads.
Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
skills CLI
$ npx skills add openobserve/openobserve --skill ui-architect -a gemini-cli
Project install goes to .agents/skills/; add -g for ~/.gemini/skills/.
Install the "ui-architect" agent skill from https://github.com/openobserve/openobserve/tree/main/.claude/skills/ui-architect into .gemini/skills/ui-architect/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "ui-architect", then confirm the skill loads.
Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
Installs for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
skills CLI
$ npx skills add openobserve/openobserve --skill ui-architect -a github-copilot
Project install goes to .agents/skills/; add -g for ~/.copilot/skills/.
Install the "ui-architect" agent skill from https://github.com/openobserve/openobserve/tree/main/.claude/skills/ui-architect into .github/skills/ui-architect/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "ui-architect", then confirm the skill loads.
GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
skills CLI
$ npx skills add openobserve/openobserve --skill ui-architect -a opencode
OpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
Install the "ui-architect" agent skill from https://github.com/openobserve/openobserve/tree/main/.claude/skills/ui-architect into .opencode/skills/ui-architect/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "ui-architect", then confirm the skill loads.
OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
Facts
Skill name
ui-architect
GitHub stars
22k
Token cost
~16k tokens
SKILL.md length
8,135 words
Files
20 (incl. references)
Skills in repo
4
Repo updated
First seen
Licence
AGPL-3.0
At a glance
ALWAYS use this skill for ANY change to the OpenObserve web UI (web/) — even a single-line UI modification.
Works in 7 steps: Every page/module header is OPageHeader… → Build from O2 components in web/src/lib… → No hardcoded px — size with rem / % / vh… → …
ANY change to the OpenObserve web UI (web/) — even a single-line UI modification
SKILL.md covers The seven house rules, Structural decisions, Colour that means something —… and Interaction cost — the click…, plus 4 more sections
Calls npm
What it does
UI Architect is an agent skill from openobserve/openobserve. ALWAYS use this skill for ANY change to the OpenObserve web UI (web/) — even a single-line UI modification.
Its SKILL.md is about 16k tokens, which your agent loads only when the skill is triggered. The skill folder holds 20 other files, including reference files (for example `references/calm-signal.md`, `references/component-catalog.md` and `references/conventions.md`).
It sits in DevOps & Cloud, covering Frontend development and Monitoring and alerting. It works with Elasticsearch. The repository describes itself as: Open source observability platform for logs, metrics, traces, RUM (web, android, ios), Session replay, pipelines, SLO and LLM observability. A sophisticated, simple and highly… The licence is AGPL-3.0.
When your agent uses it
ANY change to the OpenObserve web UI (web/) — even a single-line UI modification
Tasks that involve Frontend development
Tasks that involve Monitoring and alerting
Example prompts
“Use the ui-architect skill to alway use this skill for ANY change to the OpenObserve web UI (web/) — even a single-line UI modification”
“/ui-architect”
Workflow steps
7 steps, taken from the first numbered list in SKILL.md.
1Every page/module header is OPageHeader — never a hand-rolled
2Build from O2 components in web/src/lib — never a bare HTML control
3No hardcoded px — size with rem / % / vh / vw, or Tailwind's
4No and no inline style=""` — style with bare Tailwind
5**No literal colors or sizes — reach every value through a registered token,
6No hardcoded user-facing text — every label, title, placeholder, tooltip,
7Every page is responsive — and the laptop view does not move. New pages
What it can do on your machine
Read from SKILL.md and the folder at commit c99e027. It shows what the files ask for, not the result of running them.
Tool permissions
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
Runs code
Shell commands in SKILL.md call:
npm
From the folder's file list and the shell code blocks in SKILL.md.
Network
No URLs in SKILL.md. Its commands use npm, which can reach the network depending on how they are called.
From URLs in SKILL.md, links to its own repository left out.
Credentials
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Context cost
UI Architect loads about 16k tokens when it runs, and up to ~120k if it reads all its reference files. Until then it costs about 30 tokens; SKILL.md has 8,135 words of instructions outside code blocks.
Always· name and description, kept in context so the agent knows when to use it
~30
When it runs· the whole SKILL.md, loaded when a task matches
~16k
With references· SKILL.md plus every file in references/, read only if the agent opens them
~120k
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
Safety
Auto-check: notes
The automated check noted patterns worth knowing about, such as sudo or a known installer.
NoteMentions a .env fileSKILL.md:368
`OK`), `TEXT_NODE_LITERALS` (`1000`, `./.env`,
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
Download SKILL.mdSave it as .claude/skills/ui-architect/SKILL.md (or your agent's skills folder). This skill also uses 19 other files; get the full folder from GitHub.
name
ui-architect
description
ALWAYS use this skill for ANY change to the OpenObserve web UI (web/) — even a single-line UI modification.
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 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".
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.
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
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.
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
IntersectionObserverrootMargin
The API parses px and % only — rem, emand 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 insidecalc() / 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.
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:
{ boxShadow: `var(--shadow-rail-geom) ${color}` } // colour chosen at runtime
Three rules, each learned from a shipped bug:
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.
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.
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.
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.
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.
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.
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.
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:
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, and grep -rn "raw(" src enumerates 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-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. & 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:
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.
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 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 table footer is never hand-built.OTable draws the one bar every
list shares: the pager on the end edge and, on the start edge, nothing —
unless rows are selected (the "N of M selected" count plus the page's bulk
actions from #selection-actions) or the page has something the pager cannot
say (#footer-note). There is no total label; the pager's "x – y of z" is the
count. Below md the count and actions take a row above the pager and a note
takes its own row; a note whose root is max-md:hidden leaves no row.
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,
and each domain has its own reference below.
Decision
The rule
Detail
Server data (fetch & cache)
Every read is a declared queryOptions() in services/<domain>.queries.ts (reuse the existing one if the list is already declared), keyed with orgKey, on its module'sstaleTime tier from cachePolicy.ts; components useQuery it (rows as a computed) — never http/axios, never a Vuex copy of a server list. Writes are mutationOptions() with meta.invalidates. A user refresh forces every read on the view; mount, paging and search read the cache.
<OTruncatedText> wherever you'd write truncate / line-clamp-* — it shows the full text in a tooltip only while the text is actually cut. :tooltip="false" for secrets (tokens, keys, webhook/signed URLs) and for text the user can already read in full another way (printed below, expand, a side panel). In OTable cells plain text needs nothing — the table's shared tooltip handles it; turn it off per column (meta.cellOverflowTooltip: false) for secrets or per table (:cell-overflow-tooltip="false") where Wrap / row expansion is the reveal. Never repeat the visible text in a title or OTooltip.
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 in two cases: chart-@click forwarding PanelSchemaRenderer doesn't re-emit (convert once the schema renderer forwards clicks), or a chart needing a fixed grid that keeps empty rows/columns, or box-selection mapping from category indices back to values, which the dashboard converters cannot express (e.g. TracesLatencyHeatmap.vue). Annotate the site with a one-line why. 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.
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.
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/ODialogbleed. Never hand-roll a content inset.
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.
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…"
confirm → ConfirmDialog; short form → ODialog; tall or contextual form → ODrawer; primary multi-section flow → a full in-page view. Use ODialog / ODrawer for these
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"
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
registry-driven — declare in shortcutRegistry.ts, bind with useShortcuts([{ id, handler }]); never an ad-hoc keydown listener or a hardcoded ⌘N in a template
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
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.
Show full SKILL.md (2,874 more words)Show less
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
slot), OTag chips from badgeGroups.ts, a row state rail + light
exception-only highlight, recency-aware OTimeCell, OUserCell avatars. Keep
everything else quiet: highlight exceptions not the norm, muted 0/—,
border-not-fill selection, one primary action, no layout shift. Full playbook +
per-archetype recipes: references/calm-signal.md.
Reference implementation: the Alerts list
(web/src/components/alerts/AlertList.vue).
Interaction cost — the click budget
Colour decides what a screen says; this decides what it costs. One rule:
the shortest path to the thing the screen exists for is one click, and no
complete flow exceeds two — three at the absolute limit.
Kind of thing
Budget
An answer the product already knows (a status, a count, a "would this work")
0 — it is on the screen you already opened, not behind a drill-in
A screen's primary action (acknowledge, resolve, enable, add)
1 from the list the user is already looking at
A complete flow, start to saved
2, hard ceiling 3
Four consequences, each of which is a review objection on its own:
A navigation step is a click. Rail → flyout → page → tab → button is four
spent before the user acts. A deep-linkable tab is shareable, not free.
Act from the row. If a list can render a thing it can act on it — inline
row actions plus a bulk action on selection, not a mandatory drill-in.
Reference: the Alerts list, and OnCallResponses.vue's inline
acknowledge/resolve.
Pre-fill from context. A form opened from a row, a gap, or a failing item
arrives with everything that row already knew filled in. The click worth
removing is the one where the user re-types what the app is displaying.
No modal chains. One ODialog/ODrawer per flow. A dialog that opens a
dialog has spent the budget on chrome. If a form needs a second surface, it
needs a better default instead — see below.
When a flow genuinely cannot fit, the fix is a better default, not a
shorter form: a preset/template that turns construction into pick a shape →
confirm, a sensible value for every field the user has no opinion about, and a
preview instead of a wizard step. Do not hit the budget by hiding required
fields behind "Advanced" — that moves the click, it does not remove it.
Copy and values — read the screen with real data
Lint proves a string is translated; it cannot prove the sentence it produces is
English, that a number means what its label claims, or that a blank is not a
zero. Before a screen is done, read it with real data in every state (loading,
never set up, stopped, partly there, failed, populated). The recurring defects:
Casing reaches past your template — shared component copy and en-US.json
values are sentence case too; a library never forces caps.
An interpolated slot takes a noun phrase or a value, never another
sentence; every {count} message is a plural called with the count.
Say it once, by the label on screen — no chip and text repeating each
other, no two headings for one thing, no help text naming a control by a word
the control does not show.
Names, not ids; what differs, first — an org label, not its identifier; the
database name, not the host every row shares.
Minute-precision times, whole-number counts, — for unknown, skeleton (never
0) while loading; one failure look (#error → load-error with Retry).
Warning icons only on problems; a page publishes only its own facts into
any state its sibling tabs share.
Density: a screen states, the reader opens the rest — explanatory copy opens
from an "About …" info popover beside its control instead of a paragraph or a
truncated sentence; a banner is one line (what + since when + action); a list
shows one line per item and each item opens on its own; one entity is one row;
a column header carries no qualifier (it goes in meta.headerTooltip); "All
clear" is never followed by "0 of 0".
Rewording a shared key rewords every screen using it — search its callers and
add a new key instead; every new key gets all 16 locales.
The scenario → component index and the per-file catalog (what each O* is,
when to reach for it, and which reference holds its exact props / slots / emits)
are in references/component-catalog.md. Open the
relevant reference before writing markup — the props are the source of truth,
don't guess a name.
Pre-flight checklist
Run this in your head before writing template markup, and again before
considering the UI done:
Page/module header is OPageHeader (not a hand-built header bar).
The header's create CTA is label-only — no icon-left="add". The +
stays on in-section adders, icon-only buttons, and never applies to
utility actions (Import/Refresh/Edit keep their semantic icons).
Peer/section tabs pass tabs-below so the strip is the full-width
row-2 band — the bare #header-tabs/#tabs slot renders them inline
beside the title, where they shift as the title's width changes.
The header icon matches the page's nav entry verbatim
(navGroups.ts / linksList / settingsItems / SectionRail). A module
showing one glyph in the rail and another in its header reads as two
places — see navigation-menus.
Any data the page reads is a queryOptions() in
services/<domain>.queries.ts, consumed with useQuery — not a service
call with a hand-rolled loading ref. Durations come from
cachePolicy.ts (the module's tier), never a bare number; no persister,
no per-query gcTime. Writes are mutationOptions() declaring
meta.invalidates; a component never calls invalidateQueries itself.
Refresh (button, r, section icons, Retry) forces every read on the
view through a named handler. See
references/data-fetching.md.
Every interactive control is an O2 component if one exists in
web/src/lib — no bare HTML controls or third-party primitives with an O2 equivalent.
A self-contained/repeated UI element with no matching component was
built as a reusable component (generic → O* in web/src/lib;
app-specific → named component in web/src/components), not hand-assembled
from <div> + utility classes. Classes are for layout only.
Tabular data uses OTable with OTableColumnDef[] columns; server mode
only for backend-paginated data.
Cut text is OTruncatedText — no bare truncate / line-clamp-*, and no
title / OTooltip that only repeats the visible text. :tooltip="false"
on secrets and on text readable another way (shown below, expand, side
panel). In tables: secret column → meta.cellOverflowTooltip: false, a
Wrap/expansion table → :cell-overflow-tooltip="false", and a cell with
several tags/badges gives its own joined tooltip text.
Every data chart goes through PanelSchemaRenderer (panel schema) — no
echarts.init / <v-chart> / ApexCharts / D3 / hand-rolled <canvas>/<svg>
plot in a feature page. Low-level panels/ChartRenderer.vue only as the
annotated escape hatch for chart-click forwarding, or for a fixed grid / box
mapping the converters can't express (TracesLatencyHeatmap.vue).
Sparklines/progress/data bars stay OSparkline/OProgressBar/ODataBarCell
(not charts).
Server mode was checked against the backend: every sortable: true
column has a real sort key in the handler (an unknown key falls back
silently and orders by something else), and any page-relative device
(ODataBarCell bars, a #subheader count strip) is on a client-paginated
table only. No hand-rolled # index column (show-index), no positional
columns.splice, and column size fits the header + sort chevron.
See core-controls-table.
A figure/label that already exists on another screen reuses that screen's
formatter and i18n key (promote a component-local formatter into
utils/formatters.ts rather than copying it).
Listing page uses the full-height flush skeleton (root
flex flex-col h-full p-0, header shrink-0 border-b — OPageHeader bakes
in its own px-page-edge, never add a px-*, table wrapper
card-container flex-1 min-h-0 overflow-hidden, OTable :frame="false") —
not a p-6 padded container; table runs flush.
Listing page has all three toolbar affordances: search + filters
(#toolbar, :show-global-filter="false"), refresh
(#toolbar-trailing, wired to fetch), and the column show/hide toggle
(:persist-columns + table-id + a hideable column). Non-essential
columns hidden by default via :column-visibility.
The table footer is OTable's, never hand-built — bulk actions on
selected rows are in #selection-actions (the buttons only: no wrapper, no
v-if on the selection length, no margin/height/padding classes, every
button size="sm", a destructive action last, no count in a label); a line
the pager cannot say (a cap, partial data, "filtered x of y") is in
#footer-note, with the v-if on the <template> so it renders only while
it says more; nothing restates the row total. See
core-controls-table § Footer.
Every empty/zero state is a single OEmptyState (never a hand-rolled
<div> + centered text + button). Use a preset + :filtered
(search/filter active) + @action resetting on clear-filters; #error if
fetch can fail. Its actions use the standard layout — an EmptyStateActionCard
(actions prop / #actions slot) for a primary CTA, or actionLabel, never
a custom button row.
Page is registered in navigation (route + one of rail item / Settings
sub-page / flyout child) and gated for env/role
(config.isEnterprise / config.isCloud / zoConfig.*), with the route,
nav-entry gate, and SectionRail visible all in sync
(see navigation-menus.md).
Server data reaches the component through a declared query (never raw
http), and no server list is copied into Vuex or a load-once map;
ephemeral view state stays in local refs.
Form container matches weight: confirm → ConfirmDialog, short form →
ODialog, large/contextual form → ODrawer, primary multi-section flow →
full page.
Click budget holds: the screen's primary action is 1 click from the
list (inline row action, not a mandatory drill-in), the whole flow is ≤ 2
clicks (3 only with a reason), a form opened from a row arrives
pre-filled from that row, and no dialog opens another dialog. Count
navigation steps as clicks. If it doesn't fit, add a better default or a
preset — not an "Advanced" section.
Validated form uses OForm + a colocated Zod <Form>.schema.ts; every
control inside is an OForm* addressed only by name= (no v-model/ref
mirror, no formData), required via the required prop.
Save is type="submit" (inline) or form-id↔OForm id (overlay); no
manual useLoading/:loading and Save is not disabled on invalid.
Payload built with explicit keys (not { ...value }); numeric inputs
coerced. Field arrays use :key="index" + a non-last-row delete test.
Zero px values outside the sanctioned positions (§3 exemption table) —
including inside class arbitrary values (w-[320px], text-[13px]). Sizes
use rem / % / vh / vw or Tailwind's rem scale.
Spacing copies a sibling, never invented per page — padding/margins/
gaps and card surface classes are taken verbatim from the sibling panel
family the screen joins (card-container alone styles nothing — cards
need explicit bg-* border-* rounded-* classes). A page of stacked
panels uses the full-height split (flex-1 min-h-0 cards + fill-height
tables) so the page itself never scrolls — see
conventions § Cards & stacked-panel pages.
Corner radius is rounded-default / rounded-surface / rounded-full
only — no bare rounded, no rounded-[..], no retired
rounded-{sm,md,lg,xl}.
No uppercase anywhere except established short forms — no caps baked
into a string and no uppercase utility on labels/headers/chips/badges/
buttons/tooltips. Copy is sentence case; the only caps allowed are acronyms/
abbreviations (SQL, API, URL, ID, AI) and metric tokens (p95).
A full word (DELETE, SAVE, NEW) is never a short form. Make labels
quiet with size/weight/colour, not capitals.
No <style scoped> block added. No style="…" attribute added.
No literal colors anywhere. Colours come from --color-* token utilities
(bg-surface-base, text-text-secondary) — not a raw var(--color-*) in a
component (that's a rawVarInComponent bypass, allowed only in :deep,
keyframes, color-mix, calc, SVG fill/stroke, v-html). No raw
Tailwind palette (bg-gray-*) and no raw grey-*/primary-* ramp — use a
semantic token.
Text colour matches its semantic role — heading (titles/emphasis),
body (main content), secondary (labels, captions, metadata, units),
muted ONLY for disabled/absent content (disabled control, em-dash /
"none" / "not measured" placeholder). Never muted on a live value or a
label; make text quieter by dropping one tier + size/weight, not opacity.
See design-tokens § text colour.
Calm Signal — the screen's primary signal is coloured (state /
category / role / progress) via the shared toolkit (OStatStrip/OStatCard,
OTag chips, row rail + exception tint, relative OTimeCell), and the rest
stays calm: exceptions highlighted not the norm, muted 0/—,
border-not-fill selection, no layout shift. See
references/calm-signal.md.
cd web && npm run lint:design:strict passes (zero tolerance — no
raw-token bypass anywhere; one occurrence fails the build).
No --o2-* anywhere — no var(--o2-*), no new --o2-* definition, no
.body--dark block. Any --o2-* in code you touched was migrated to its
--color-* equivalent.
Any new color/size needed was registered as a --color-* token (light
:root + @theme inline + dark under .dark) before use.
No hardcoded user-facing text and no t() key missing from the locale
file — every label, title, placeholder, message, and validation string (whether
a text node, a static prop label="…", a bound prop :label="'…'", a
{{ '…' }} mustache, or v-text) uses
t() with the key added to web/src/locales/languages/en-US.json in the same
change. Text NODES, {{ '…' }} mustaches, v-text/v-html, and native
HTML/ARIA attributes (title, alt, aria-label, placeholder) are caught
by ESLint (no-missing-keys, vue/no-bare-strings-in-template,
local/no-bare-bound-text-props — all error); COMPONENT props (static or
bound) are caught by declaring them I18nText, not by any lint rule. New
text-carrying component prop → type it I18nText; there is no TEXT_ATTRS
list any more.
Non-translatable strings use raw("…") — never an eslint-disable.
Any new type / interface / *.types.ts field that carries UI text or an
i18n key is declared I18nText / I18nKey (from @/types/i18n), not bare
string — that is what guards <script>, which the ESLint rules cannot see.
Non-translatable values use raw("…"). Verified by npm run type-check:app.
Translation is obtained via useI18nTyped() (components) or gt()
(outside setup) — never useI18n imported straight from vue-i18n, which
returns unbranded string and silently defeats the check.
data-test on every interactive and key output element, pattern
<module>-<filename>-<descriptor> (see the project FE rules).
New component uses <script setup lang="ts">, no // @ts-nocheck.
Responsive, desktop untouched — every new/changed class that affects
layout is max-md: / max-lg: gated (run the grep in
responsive § The contract); the page
was checked at 375, 768 and 1280 and 1280 matches main.
Phone chrome is one row — secondary header actions in
#actions-overflow, toolbar filter OToggleGroup mobile-dropdown, search with
a max-md:min-w-* floor, stat tiles via OStatStrip / KpiCard.
Rails and row actions adapt — side rails render in an ODrawer anchored
to their trigger's row (or come from OPageLayout #sidebar / FolderList);
inline row actions are max-md:hidden with a md:hidden kebab mirroring them
(<data-test>-menu); no hover-only affordance without max-md:opacity-100.
Copy and values read right with real data — sentence case in the
rendered screen (shared copy included); interpolated slots read as a
sentence; every {count} is a plural; controls named by their visible
label; nothing said twice; labels instead of ids; times to the minute,
counts without decimals, — for unknown. See
copy-and-values.
Every state was seen with real data — loading (skeletons, never 0),
never set up, stopped/stale, partly there, failed (#error → load-error
with Retry, never the raw red bar) and populated — in light and dark.
Nothing is clipped, not just nothing scrolls — text runs past no cell
or container at 375 / 768 / 1024 / 1280 (an overflow-hidden ancestor hides
it from a page-width check); right-aligned cells checked on their left edge;
every ellipsis read (a cut title or header is a bug, a cut SQL statement is not).
Nothing is sparse or doubled — the page edge is applied once (bleed
when the body insets itself); a one-line disclosure is sized to its label with
its info control beside it, not a full-width bar; the primary table column has
an explicit size; a status screen fits 1366×768 and 375 collapsed; tiles that
sit six to a row wrap and keep two title lines so their values line up.
Same job, same component — refresh is ORefreshButton, an explainer is
the OPopover info recipe, an expandable row is OCollapsible, a toolbar view
toggle is OToggleGroup mobile-dropdown; never a hand-built <button>. See
conventions § The same affordance.
A UI pass changes UI only — no new capability in a shared engine; a shared
component's visual change is gated below lg or on a flag only your page sets,
and a regular page using it is unchanged at ≥1024. See
conventions § A UI pass changes UI only.
Comments are one line, or none — the why of a non-obvious constraint,
never layout narration ("< md this wraps"), a re-telling of the code, or the
history of the PR that added it (no ticket ids, "review finding", "as
discussed"). Same in specs. See
conventions § Comments stay short.
cd web && npm run lint && npm run type-check:app pass. type-check:app,
not type-check — the latter runs tsconfig.vitest.json, whose include
is only src/**/*.spec.{ts,js}, so it never checks a .vue file and a
green run says nothing about the component you just wrote. type-check:app
(tsconfig.app.json) is the one that covers src/**/*.vue.
When a rule can't be satisfied
Don't quietly break a rule — the fix is almost always "extend the shared thing,
not the call site":
Missing O2 component → build a new reusable component, don't reconstruct it
from divs + classes at the call site. Generic primitive → a new O* in
web/src/lib; app-specific composition → a named component in
web/src/components. When migrating an existing element and can't build it
yet, keep the current element in place and flag it. Never
substitute a bare <div>. See
references/creating-components.md and the
"No component fits?" section of references/conventions.md.
Missing O2 variant → add the variant to the component source, then use it.
Missing token → register it in the token CSS (rule 5), then use it.
OPageHeader can't express the header → change OPageHeader, not the page.
If you genuinely believe a rule shouldn't apply to a specific case, say so
explicitly and explain why, rather than silently introducing a px, a hex, or a
scoped style.
UI Architect next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
Onboard an application into Elastic Observability with the Elastic Distribution of OpenTelemetry (EDOT): route on language and runtime, detect and replace a classic Elastic APM agent, apply the…
Triage a degraded or suspect service end to end: read SLO status and burn rate, check active alerting rules and ML anomalies, measure throughput, latency, and error rate, assess dependency health…
Splits a change into planner, coder and independent reviewer roles: you confirm a spec, a subagent implements it, and a separate reviewer checks each round's local WIP commit.
Produces a read-only morning brief of your open pull requests across the openobserve GitHub org, with a next step for each and a reminder for idle ones.
ALWAYS use this skill for ANY change to the OpenObserve web UI (web/) — even a single-line UI modification. UI Architect is an agent skill from openobserve/openobserve. ALWAYS use this skill for ANY change to the OpenObserve web UI (web/) — even a single-line UI modification.
When should I use UI Architect?
UI Architect fits situations like: ANY change to the OpenObserve web UI (web/) — even a single-line UI modification; tasks that involve Frontend development; tasks that involve Monitoring and alerting.
How do I install UI Architect in Claude Code?
Run `npx skills add openobserve/openobserve --skill ui-architect -a claude-code`. Or copy the skill folder (.claude/skills/ui-architect in openobserve/openobserve) into .claude/skills/ui-architect in your project. Claude Code loads it when a task matches its description.
How do I install UI Architect in Codex?
Run `npx skills add openobserve/openobserve --skill ui-architect -a codex`. Or copy the skill folder (.claude/skills/ui-architect in openobserve/openobserve) into .agents/skills/ui-architect in your project. Codex loads it when a task matches its description.
Can I use UI Architect in Cursor, Gemini CLI or GitHub Copilot?
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add openobserve/openobserve --skill ui-architect -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/ui-architect, .gemini/skills/ui-architect, .github/skills/ui-architect and .opencode/skills/ui-architect in your project.
What does UI Architect need to run?
Going by SKILL.md and its folder, UI Architect needs the command-line tools its instructions call (npm).
Does UI Architect access the network?
SKILL.md contains no URLs. Its commands use npm, which can reach the network depending on how they are called. This is read from the text; nothing was executed.
Is UI Architect safe to install?
Our automated static check of SKILL.md found notes only (mentions a .env file), nothing it rates as a warning. It is not a guarantee. Review the folder before installing.
What licence does UI Architect use?
UI Architect is published under the AGPL-3.0 licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
How many tokens does UI Architect use?
About 16k tokens (SKILL.md is roughly 64k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full. Its references folder adds about 104k tokens, read only when the agent opens those files.
What are the alternatives to UI Architect?
Skills that share tags, products or a category with UI Architect: Proto Backend Module (aide-family/moon, 253 stars), Observability Onboarding (elastic/agent-skills, 592 stars), Observability Sre Triage (elastic/agent-skills, 592 stars) and Observability Testing Patterns (proffesor-for-testing/agentic-qe, 495 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
Who maintains UI Architect?
openobserve (a GitHub organization) maintains it in openobserve/openobserve, which has 22,312 GitHub stars. The repository holds 4 skills in this directory. The repository was last updated on October 10, 2026.
Source: openobserve/openobserve on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.