Agent skill

UI Architect

by openobserve in openobserve/openobserve

ALWAYS use this skill for ANY change to the OpenObserve web UI (web/) — even a single-line UI modification.

AGPL-3.0Auto-check: notesDevOps & Cloud

Install UI Architect

skills CLI
$ npx skills add openobserve/openobserve --skill ui-architect -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install openobserve/openobserve ui-architect --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/openobserve/openobserve.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.claude/skills/ui-architect .claude/skills/ui-architect && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

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.

  1. Every page/module header is OPageHeader — never a hand-rolled
  2. Build from O2 components in web/src/lib — never a bare HTML control
  3. No hardcoded px — size with rem / % / vh / vw, or Tailwind's
  4. No and no inline style=""` — style with bare Tailwind
  5. **No literal colors or sizes — reach every value through a registered token,
  6. No hardcoded user-facing text — every label, title, placeholder, tooltip,
  7. Every 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.

SKILL.md

The full file from openobserve/openobserve at commit c99e027, republished under its AGPL-3.0 licence (© openobserve). 8,135 words, ~15,909 tokens.

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.

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.

  1. Every page/module header is OPageHeader — never a hand-rolled <div class="header">…<h1> or a q-toolbar. One header contract keeps the title in the same place across list → detail → edit. Peer/section tabs need tabs-below (the slot alone renders them inline beside the title), and the header icon must be the SAME IconName the page's nav entry declares. The header's create CTA is label-only — never icon-left="add".

  2. Build from O2 components in web/src/lib — never a bare HTML control (<button>, <input>) or a third-party UI primitive when an O* equivalent exists. Drive them by intent (variant / size / state), never by appearance overrides.

  3. No hardcoded px — size with rem / % / vh / vw, or Tailwind's rem-based scale. This applies inside class arbitrary values too (w-[320px], text-[13px], gap-[6px] are all banned — convert to rem). The rule is simple: never write px. Write rem. The only exceptions are the positions in the table below, where rem is wrong or does not resolve at all — and there the px must carry an eslint-disable-next-line local/no-hardcoded-px -- <reason> at the site. A px with no annotation fails CI. That is the whole contract: new px cannot enter the codebase unnoticed, and a px that is genuinely correct only passes once someone has written down why. CI-enforced by local/no-hardcoded-px (eslint, defined in web/eslint.config.js), run by lint:ci over src/**/*.{vue,ts,js,css}. It reports line:column with the rem value and the Tailwind step, and surfaces in the editor as you type.

    • Conversion: 1rem = 16px (the app sets no html { font-size }, so root is the browser default). So px ÷ 16 → rem, and px ÷ 4 → the Tailwind scale step: 300px → 18.75rem → w-75. Fractional steps are valid (w-62.5).

    • px IS correct in these positions — do NOT "fix" them. rem there is either wrong or does not resolve at all. The rule holds no exemption list: annotate the site instead, and say why.

      js
      // eslint-disable-next-line local/no-hardcoded-px -- IntersectionObserver rootMargin
      // parses px/% only — a rem value throws SyntaxError
      { rootMargin: "200px 0px" },

      The -- <reason> is required, not decoration: it is the only record of why, and ESLint flags the directive once it stops suppressing anything, so a stale exemption surfaces instead of lingering. Where a plain next-line directive will not fit:

      • <style> block — put the directive inside the block, in CSS-comment form. The rule parses style blocks itself and honours these, and reports one that suppresses nothing or omits its reason:
        css
        /* eslint-disable-next-line local/no-hardcoded-px -- hairline: 1 device pixel */
        border-bottom: 1px solid var(--color-border-default);
        Do not hoist it to <script>, and do not park a blanket eslint-disable above the block — that form runs to end of file and silences px nobody reviewed.
      • Multi-line opening tag — wrap it in <!-- eslint-disable … --> / <!-- eslint-enable … -->; a comment inside the tag is invalid markup.
      • Multi-line template literal — block disable/enable around the statement.

      A range silences everything inside it, so make it the smallest thing that works. Open it immediately before the element that owns the px — not before a parent wrapper — and close it on the line after that element's >. A range that spans a parent plus its child, or starts before <template>, is silently covering markup nobody reviewed, and ESLint never reports an unused template directive, so it will not tell you when it stops being needed. Prefer a single-line eslint-disable-next-line whenever the px sits on a line a comment can precede; reach for the block form only when the syntax leaves no other option.

      PositionWhy 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 (or tracking-tight/-normal/-wide if the value matches)
      Shadow offsets, ring / border / outline widths, blur radiiOptical effects, not layout. Scaling them with text makes elevation bloom
      Media / container query conditions (@max-[900px]/topbar)A threshold defining when layout changes, not a rendered length
      IntersectionObserver rootMarginThe API parses px and % only — rem, em and bare 0 all throw SyntaxError from the constructor, silently killing the observer and whatever it gates (lazy-load, prefetch-ahead-of-fold). Like a query condition, it is a scroll threshold, not a rendered length. "200px 0px" — keep both units
      Zero inside calc() / clamp() — var(--x, 0px), clamp(0px, …)calc() type-checks its arithmetic: 112px + 0 is length + number, which voids the whole declaration. The unit is load-bearing. Outside calc(), plain 0 is still right — height: 0, not 0px
      User-facing copy — tooltip content, placeholder, label, template text (1 unit = 30px)Prose describing a size, not a size being applied. Converting it rewrites the sentence — usually into a falsehood, since what it describes is typically a fixed layout constant that does not scale with font-size. Readers also do not think in rem
      calc() mixing vh/vw with a lengthvh 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 consumersNo 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> attributesSVG'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.

      UtilitypxUse for
      text-3xs10chart axis micro-labels, dense table sub-text (charts only)
      text-2xs11tiny labels, chips, badge text
      text-xs12captions, metadata, timestamps
      text-compact13dense body / data tables
      text-sm14default body text (start here)
      text-base16comfortable body, form inputs
      text-lg18card / panel titles
      text-xl20section headings
      text-2xl24page / modal titles
      text-3xl30hero numbers / large display
      text-4xl36display

      Default to text-sm for body. Go smaller only for genuinely dense/secondary UI, larger only for titles. If a design needs a size not on the scale, snap to the nearest step — do not reintroduce an arbitrary text-[..].

    • Casing — never uppercase anywhere in the app, except an established short form. This is app-wide and strict. No shouting: don't bake caps into a string ("FIRST APPEARED AT {time}", "NEW TO THIS LIST") and don't force it with the uppercase utility. Write copy in sentence case ("First appeared at {time}") — capitalize the first word and proper nouns only, not Every Word. The one exception is an established short form — an acronym, initialism, or abbreviation that is conventionally written in caps (SQL, API, URL, ID, CPU, AI, HTTP, JSON) and metric tokens (p95, p99) — which keep their canonical casing inside otherwise-sentence-case copy. A full word is never a short form: DELETE, SAVE, NEW, SERVER are violations; Delete, Save, New, Server are correct. This applies everywhere text renders: micro-labels, stat/tile captions, table headers, chips, badges, buttons, tooltips, empty states. Make a label quiet with text-text-label/text-xs weight + colour, not caps. tracking-wide uppercase is not the house label style. (The transform is a legibility cost — caps runs slow the reader and break at small sizes — and a baked-in caps string also can't be sentence-cased per locale.) capitalize is acceptable only for a single data token that must render title-cased.

    • Corner radius — exactly two tiers + circle, never an arbitrary value: rounded-default (4px — controls: buttons, inputs, chips, small icon buttons), rounded-surface (12px — surfaces: dialogs, drawers, cards, panels, the app-shell content area), rounded-full (pills / avatars / dots). Per-corner variants use the same names (rounded-t-surface, rounded-s-default). Banned: bare rounded, arbitrary rounded-[10px], and the retired rounded-{sm,md,lg,xl} / var(--radius-{sm,md,lg,xl}) (deleted — they were five names for one value). Pick the tier by role, not by eye.

    • Shadow — one scale, and colour is a SEPARATE axis. Elevation is shadow-xs / sm / md / lg; the directional roles are --shadow-sticky-*, --shadow-rail, --shadow-ring-hairline, --shadow-scroll-*, --shadow-glow-*. A focus/selection ring is ring-2 ring-<token>/40, not a shadow. A 1px hairline is border-b border-<token>, not a shadow. Banned and CI-enforced (arbShadow) in all three spellings: shadow-[0_4px_12px_…], box-shadow: <literal> in CSS, and boxShadow: "<literal>" in JS. Accepted forms are var(--shadow-*), none, and an interpolated ${…} that is already a token.

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

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

      Three rules, each learned from a shipped bug:

      1. Never write var(--glow-color, <fallback>) in a :root token. A custom property is substituted against :root, where the override is unset, so the fallback wins and inherits down — a descendant setting it can never take effect. This silently no-op'd 38 sites.
      2. A bare shadow-<colour> applies in BOTH themes. A dark-only tint needs dark:shadow-<colour>; shadow-xs shadow-white/8 renders white-on-white in light mode.
      3. A new elevation step needs a -c colour token in dark.css too, or it is invisible there — black at 8% on a #101215 canvas paints nothing.
  4. No <style scoped> and no inline style="" — style with bare Tailwind utilities (no tw: prefix — it was removed). Form-field spacing is class="flex flex-col gap-5" on <OForm>; omit it and fields render cramped with no spacing (the #1 "dialog looks broken" bug).

    • No CSS preprocessor in an SFC — vue/block-lang errors on <style lang="scss|sass|less">; lang="css" or no lang only. This is not taste: postcss-scss is not installed, so stylelint silently skips a scss block entirely — the hex ban, the --o2-* ban and the .body--dark ban stop running on that file with no warning. Plain CSS is also all a surviving block needs: what a Tailwind-first template leaves behind is :deep(), pseudo-elements and @keyframes, none of which want nesting.
    • Where a rule goes when it can't be a utility: an element reset → src/styles/base-elements.css; a reusable app-level treatment (pseudo-element, a class a library adds at runtime, a gradient background) → src/styles/utilities.css as an @utility. Gradients live there, not in @theme — a @theme colour compiles to background-color: <gradient>, which is invalid and dropped; the utility sets background-image instead.
  5. No literal colors or sizes — reach every value through a registered token, via its utility class. Colour comes from a --color-* token's token-backed utility (bg-surface-base, text-text-secondary, border-border-default) — not a raw var(--color-*) in a .vue template/<style> block. A raw var() in a component is a counted bypass (rawVarInComponent) and is allowed only in the sanctioned residue: :deep(), @keyframes, color-mix(), calc(), SVG fill/stroke, and v-html/JS-generated markup. If a token has no utility, register it (@theme inline) rather than reaching for var(). One knob per decision: reuse an existing token before minting one, and never add a second name for a value that already has one — an alias is a decision made twice that silently splits adoption. The legacy --o2-* vocabulary is BANNED — never var(--o2-*), never a new --o2-*, never a .body--dark block; migrate any --o2-* you touch. Raw Tailwind palette (bg-gray-400, text-red-500) does not even compile (palette-reset.css), and British grey-*/primary-* primitives in feature code are a zero-tolerance bypass (rawProjectRamp) — use a semantic token (text-text-secondary, bg-accent), not the ramp. See references/design-tokens.md.

    All of §3–§5 are CI-enforced and FAIL the build — local/no-hardcoded-px (eslint) owns px on every file type; lint:design:strict owns the rest (hardcoded hex, arbitrary radius/shadow, retired aliases, raw palette/ramp, raw var() in a template or a <style> block, un-justified <style>, literal font stacks), plus lint:tokens, lint:token-purity, lint:styles (stylelint) and format:check (prettier) on every PR.

    Tolerance is ZERO — there is no baseline any more. design-debt-baseline.json was deleted; one occurrence of any category fails the build. --strict is accepted but ignored, and --baseline no longer exists — do not go looking for a file to regenerate. Use --list to enumerate violations. Fix the cause; a new exemption is not the answer.

    The counters scan raw text, comments included — a 16px or #fff in a <style>-block comment, or a banned class quoted verbatim, counts as debt. Word comments in rem and plain English. This bites inside a CSS-in-TS template literal too, where a comment is CSS: writing inset 4px 0 6px in prose there fails local/no-hardcoded-px.

    What the guard cannot see (so review still matters): it walks only .vue and .ts — standalone .css files are never scanned; the arbitrary-property form [background:…] has no utility prefix so it slips past; and a var() fallback (var(--color-x,#fff)) hides a read from the color-mix rules.

  6. No hardcoded user-facing text — every label, title, placeholder, tooltip, empty-state, toast, and validation message comes from useI18nTyped()'s t(), with keys added to web/src/locales/languages/en-US.json (other locales follow from there — never hand-edit them).

    Where each surface is enforced. Lint sees only <template>; everything else is enforced by the TYPES at npm run type-check:app. Both gate CI.

    SurfaceEnforced by
    Text node — <div>Save</div>vue/no-bare-strings-in-template
    Mustache literal — {{ 'Save' }}local/no-bare-bound-text-props
    v-text / v-html literallocal/no-bare-bound-text-props
    Component prop — label="Save" or :label="'Save'"I18nText type
    Any string in <script> / .tsI18nText type
    Native HTML/ARIA attr — <input placeholder="Search">vue/no-bare-strings-in-template
    t('x.y') key exists@intlify/vue-i18n/no-missing-keys
    A key stored as data (titleKey)I18nKey type

    Two consequences worth internalising:

    • Lint does NOT check component props — that is deliberate. A text-carrying prop is caught by its I18nText declaration, which is strictly stronger (it also rejects a plain string variable, which no lint rule could see). There is no TEXT_ATTRS list any more; declare the prop I18nText and you are done.
    • Native HTML/ARIA text attributes ARE linted — title, alt, aria-label (+ aria-placeholder / aria-roledescription / aria-valuetext) on any element, and placeholder on <input> / <textarea>. They get lint rather than the type because a native element has no prop to annotate. Residual gap: only the STATIC form is covered, so :title="'Delete'" still slips through — don't reach for it to dodge the error.

    Which translator — t() first, gt() only when it cannot reach.

    Where you areUse
    <script setup> / inside setup()t() from useI18nTyped()
    .ts module called from a componentthread 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:

    KindExamplesWhat broke when translated
    Values code compares or persistsa sentinel, an enum, a generated namelogic silently stops matching, non-English users only
    Product / company namesKafka, Zookeeper, NATS, Airflowshipped as Zoowärter, HORMIGAS (ants), Luftstrom (air current)
    Acronyms that are namesRUM, DAG, IAM, AGPL, P95shipped as RON (the drink), DÍA (day), SOY YO ("I am me")
    Code the user copies or typesSQL snippets, regexes, model ids, field names, env varsgpt-4.* → gpt-4. *; a pasted sample no longer runs

    The test is not "is it user-visible" — all of the above are. It is "is there one correct form worldwide?" If yes, it is not copy.

    A name INSIDE a sentence: interpolate it out, don't raw() the sentence.

    ts
    // WRONG — freezes the whole sentence in English
    raw("Route all telemetry through the OTel Collector")
    // WRONG — the translator mangles the product name
    t("traces.noData.otelCollectorDesc")
    // RIGHT — catalogue holds "…through the {product}"
    t("traces.noData.otelCollectorDesc", { product: raw("OTel Collector") })

    Same for an example token: "e.g. {example}" + { example: raw("gpt-4.*") } keeps "e.g." translatable while the token becomes unreachable. Never split a sentence into fragments you concatenate — word order is per-language.

    A string that is both a label and an identifier: split it. Give display and machine value separate fields — { label: t("iam.roleAdmin"), value: "admin" }. Before translating any label, check for a sibling value:; if the label IS the value, translating it breaks the comparison.

    Non-translatable text — the ladder. Decide in this order:

    1. Does code branch on it? ("px" | "%", "sm" | "md") → it is not text at all. Use a union type. Never an i18n concern.
    2. Everywhere else → raw("…") from @/types/i18n. This is the default and covers script data, typed props, bound expressions and text nodes: <code>{{ raw("time_bucket") }}</code>. It is type-checked, survives refactors, and grep -rn "raw(" src enumerates every exemption in the app (~1,185 of them).
    3. Only if the token is short, universal and RECURS across files → add it to the allowlist in eslint.config.js, which is split into three named groups so reviewers can apply the right scrutiny: GLYPHS_AND_UNITS (px, ms, ×, →, ●, …), SPEC_IDENTIFIERS (GET, UTC, SQL, OK), TEXT_NODE_LITERALS (1000, ./.env, trace.zip). An entry here is global, permanent and context-free — it silences that string in every file forever, so it must be genuinely universal.

    Do NOT use eslint-disable for i18n. There are zero of them left in src/ and that is deliberate — raw() says the same thing at the call site, is type-checked, and shows up in one greppable inventory. (Disables for other rules — hyphenation, x-invalid-end-tag — are fine and still present.)

    Moving a text node into raw() changes its parsing context from HTML to JavaScript. Four things bite:

    • \ becomes an escape prefix. raw("\w+") silently renders w+. Write raw("\\w+") to render \w+.
    • HTML entities stop decoding. &amp; renders literally — use the real character: raw("a & b").
    • A literal < breaks Prettier, which parses {{ raw("<Foo>") }} as a tag and hard-fails the file (format:check is a CI gate). Hoist it into <script setup>: const tag = raw("<Foo>") and interpolate {{ tag }}.
    • Surrounding whitespace is dropped. <div>\n OO\n</div> renders " OO " but <div>\n {{ raw("OO") }}\n</div> renders "OO" — invisible in normal flow, but check it inside <pre> / white-space: pre-line.

    Plurals use vue-i18n pipe syntax, never string concatenation. Write the key as "{count} occurrence | {count} occurrences" and call t("alerts.occurrence", { count: n }, n). Never {{ n }} {{ t('x') }}{{ n > 1 ? 's' : '' }} — no other language pluralises that way, and a translator reading en-US.json cannot see the appended s.

    Text in <script> — use the TYPES, not a lint rule. The three rules above only see <template>. A string in <script>/.ts — a table column label, a toast message, an i18n key stored as data — is invisible to them, because deciding "is this string user-facing?" from the string alone is guesswork. So that decision lives in the type declaration, where the author already knows the answer, and npm run type-check:app enforces it. Two types in web/src/types/i18n.ts:

    TypeUse forEffect
    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 it
    ts
    import type { I18nKey, I18nText } from "@/types/i18n";
    
    interface Column {
      label: I18nText;    // user-facing  -> must be t() or raw()
      field: string;      // data accessor -> ordinary string
    }
    interface Preset { titleKey: I18nKey }   // stores a key, not the text

    When you declare a new interface, prop type, or *.types.ts that carries UI text or an i18n key, type that field as I18nText / I18nKey — never bare string. This is the same pattern the library already uses for icons (iconLeft?: IconName): a constrained type derived from a source of truth. I18nKey is derived from en-US.json at compile time, so there is no list to maintain — add a key and it is instantly valid.

    Careful: a *Key field is not always an i18n key. OSelect.labelKey and JourneySteps.actionKey are field accessors ("which property of the row holds the label") and stay string. Read the doc comment before annotating.

    The opt-out is raw(), not an eslint-disable — it is type-checked, survives refactors, and grep -rn "raw(" src lists every exemption in the app:

    ts
    const columns = [
      { label: t("logs.timestamp"), field: "ts" },
      { label: raw("trace_id"),     field: "trace_id" },  // a field name, not prose
    ];

    Getting a t() that returns I18nText — the whole app already does this:

    • In a component → const { t } = useI18nTyped() (from @/types/i18n). Never import useI18n from vue-i18n directly; useI18nTyped() hands back the exact same composer, just typed, so everything else is unchanged.
    • Outside a setup context (a composable reached from a plain function, a util, service-layer error handling) → gt("some.key") from the same module. useI18n() may only be called during setup; gt reads the shared instance.

    Non-translatable text uses raw() — a server-provided error message, an identifier, a code token. It accepts nullish, so the usual fallback chain reads naturally and stays type-safe:

    ts
    toast({
      variant: "error",
      message: raw(err.response?.data?.message) || t("alerts.saveFailed"),
    });

    Never reach for raw() to silence the checker on real UI copy — that is exactly the bug the brand exists to catch. grep -rn "raw(" src is the review surface.

    Composed text is a type error, by design. "Deleted " + n + " rows", cond ? "Yes" : "No" and `Saved ${name}` all widen to string, so they cannot satisfy I18nText. Use vue-i18n interpolation instead — t("x.deletedRows", { count: n }) with "Deleted {count} rows" in en-US.json — and a plural message ("one | many" + t(key, params, count)) when singular and plural really differ. The same rule is enforced in <template> by local/no-bare-bound-text-props.

    Toast/notification copy added by this convention lives under toastMessages.*, grouped by module.

  7. Every page is responsive — and the laptop view does not move. New pages are built for 375 px phones and 768 px tablets from the first commit, using the method in references/responsive.md:

    • 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.

DecisionThe ruleDetail
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's staleTime 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.data-fetching
Tabular dataOTable + OTableColumnDef[]; client-side pagination unless the backend paginates a set too large to fetch wholecore-controls-table
Cut text ("…")<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.core-display · core-controls-table
Charts / graphsEvery 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.core-display
Whole-page layoutEvery 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 insetOPageLayout 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 stripsan 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 toolbarevery 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
Form containerconfirm → ConfirmDialog; short form → ODialog; tall or contextual form → ODrawer; primary multi-section flow → a full in-page view. Use ODialog / ODrawer for theseconventions
Form validationOForm + 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 nava 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 rulenavigation-menus
Keyboard shortcutsregistry-driven — declare in shortcutRegistry.ts, bind with useShortcuts([{ id, handler }]); never an ad-hoc keydown listener or a hardcoded ⌘N in a templatekeyboard-shortcuts
Cancel / Save rowcancel = variant="outline", save = variant="primary", both size="sm-action", spaced with gap-2 on the parentconventions
Responsivemax-md:/max-lg: variants + useBreakpoint() for structure; desktop unchanged; one row of header/toolbar/stat chrome; rails → anchored drawers; row actions → kebab; popups ≤ viewportresponsive
Nothing fitsbuild 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 componentcreating-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.

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 thingBudget
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 saved2, hard ceiling 3

Four consequences, each of which is a review objection on its own:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Full rules, each with the bug it came from: references/copy-and-values.md.

Pick a component

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.

© openobserve, AGPL-3.0. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

SKILL.md and 19 other files (references) in .claude/skills/ui-architect of openobserve/openobserve.

  • SKILL.md
  • references/calm-signal.md
  • references/component-catalog.md
  • references/conventions.md
  • references/copy-and-values.md
  • references/core-controls-table.md
  • references/core-display.md
  • references/creating-components.md
  • references/data-fetching.md
  • references/design-tokens.md
  • references/feedback-data.md
  • references/forms-inputs.md
  • references/forms-specialized.md
  • references/forms-validation.md
  • references/house-rules.md
  • references/keyboard-shortcuts.md
  • references/navigation-menus.md
  • references/overlay-navigation.md
  • references/page-recipes.md
  • references/responsive.md

Open the folder on GitHubat commit c99e027

Compare with similar skills

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.

UI Architect compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
UI Architect this skillopenobserve/openobserve22k—~16kAutomated safety check: NotesAGPL-3.0
Proto Backend Moduleaide-family/moon253—~4.1kAutomated safety check: PassNone
Observability Onboardingelastic/agent-skills592—~4.1kAutomated safety check: PassApache-2.0
Observability Sre Triageelastic/agent-skills592—~7.4kAutomated safety check: PassApache-2.0
Observability Testing Patternsproffesor-for-testing/agentic-qe495—~8.3kAutomated safety check: PassMIT
Borg Live Debugkaranhudia/borg-ui1.7k—~1.4kAutomated safety check: NotesAGPL-3.0

Similar skills

  • Proto Backend Module

    aide-family/moon

    Implements backend modules from proto definitions for goddess, marksman, and rabbit apps.

    253 GitHub stars~4.1k tokensUpdated 3 mo ago
    DevOps & CloudAuto-check passed
  • Observability Onboarding

    elastic/agent-skills

    Official

    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…

    592 GitHub stars~4.1k tokensUpdated 3 days ago
    DevOps & CloudAuto-check passed
  • Observability Sre Triage

    elastic/agent-skills

    Official

    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…

    592 GitHub stars~7.4k tokensUpdated 3 days ago
    DevOps & CloudAuto-check passed
  • Observability Testing Patterns

    proffesor-for-testing/agentic-qe

    Observability and monitoring validation patterns for dashboards, alerting, log aggregation, APM traces, and SLA/SLO verification.

    495 GitHub stars~8.3k tokensUpdated yesterday
    DevOps & CloudAuto-check passed
  • Borg Live Debug

    karanhudia/borg-ui

    Live Borg debugging by exec-ing into the borg-web-ui Docker container.

    1.7k GitHub stars~1.4k tokensUpdated yesterday
    DevOps & CloudAuto-check: notes
  • Code Review

    aide-family/moon

    Reviews code for correctness and potential bugs, pinpoints bug locations by file and line, and suggests concrete fixes.

    253 GitHub stars~815 tokensUpdated 3 mo ago
    DevelopmentAuto-check passed

More from openobserve/openobserve

  • O2 Review Loop

    openobserve/openobserve

    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.

    22k GitHub stars~3.7k tokensUpdated today
    Auto-check passed
  • A playbook for fixing ESLint and TypeScript errors in the OpenObserve web frontend, with rule-by-rule guidance and typing conventions.

    22k GitHub stars~5.1k tokensUpdated today
    Auto-check passed
  • OpenObserve PR Brief

    openobserve/openobserve

    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.

    22k GitHub stars~1.8k tokensUpdated today
    Auto-check passed

Works with

Questions about UI Architect

What does UI Architect do?

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.