12 KiB
Agent instructions
Instructions for AI agents (and contributors) working in this repo. Currently
covers the otel-ui frontend design system, since that's the active area of
work; add sections here as other parts of the repo get their own conventions.
otel-ui: design system
The rule
One theme, light and dark - that's it. otel-ui/src/index.css defines a
single set of --ds-* tokens with a dark variant (default) and a light
variant (:root[data-theme="light"]). Never introduce a second palette, a
third theme variant, or a new token namespace alongside ds-*. If a screen
needs a color, it comes from this table or it doesn't exist yet - extend the
table, don't invent a one-off.
The theme toggle lives in TopBar.tsx, persists to localStorage
(otel.ui.theme), and is applied before first paint by an inline script in
index.html so there's no flash of the wrong theme.
The old MD3-style --color-surface-container-* / --color-on-surface tokens,
the Material Symbols icon font and the Geist/Inter/JetBrains fonts have been
deleted. Never reintroduce them, raw Tailwind palette colors
(slate-*, sky-*, rose-*, white/10...), gradients, or shadows. Use
lucide-react for icons.
Documented exceptions (data-viz only, never chrome): per-service span colors
in the trace waterfall (getSpanColor in otel-utils.ts) and the muted
chart-series hues for metric lines/pods (POD_COLORS).
Token reference
Source of truth is otel-ui/src/index.css (:root and :root[data-theme="light"]).
Tailwind utilities are generated as bg-ds-*, text-ds-*, border-ds-*.
| Token | Dark | Light | Used for |
|---|---|---|---|
ds-bg |
#0D1014 |
#F4F2ED |
page ground |
ds-chrome |
#0B0E12 |
#FFFFFF |
icon rail, top bar |
ds-surface |
#14181E |
#FFFFFF |
cards, panels |
ds-surface-2 |
#191E26 |
#F7F5F0 |
inputs, chips, selected row |
ds-fill |
#1D232B |
#EDEAE3 |
hairlines inside cards, chart bands, bar tracks |
ds-line |
#242B34 |
#E3E0D8 |
card/control borders |
ds-line-strong |
#2A313B |
#D6D2C8 |
chip borders, neutral badges |
ds-text |
#E8ECF2 |
#161A1F |
primary text |
ds-text-2 |
#98A3B2 |
#4F5964 |
secondary text |
ds-text-3 |
#8B96A6 |
#6B7480 |
labels, metadata, muted text - this is the accessibility floor, don't go dimmer |
ds-critical / ds-critical-bg |
#FF6F5E / #2B1512 |
#C4402C / #FBE9E5 |
breached threshold, open errors |
ds-warn / ds-warn-bg |
#F0B24A / #2A2011 |
#8A5D0E / #F9EFDA |
degraded but not (necessarily) user-visible |
ds-ok |
#4FCF9A |
#1F7A56 |
healthy, resolved, recovered |
ds-accent / ds-accent-bg |
#7B8CFF / #161B33 |
#3B4FC4 / #E9ECFB |
the one interactive accent - primary buttons, selected segment, links, focus |
ds-on-accent |
ds-chrome value |
#FFFFFF |
text on top of ds-accent |
Colour is a signal, not decoration. Chrome, borders and type are neutral.
ds-critical / ds-warn / ds-ok only appear where a real measurement is
out of its expected band or a real state changed (open vs resolved). A
severity pill's color must correspond to an actual state, never picked for
visual variety. Don't tint cards, headers or buttons "for interest."
Fonts
Three families (otel-ui/index.html loads them; index.css exposes them as
--font-display / --font-ui / --font-data, used via Tailwind's
font-[family-name:var(--font-display)] arbitrary syntax):
- Bricolage Grotesque (
--font-display) - page/card headings, big metric numbers. - Instrument Sans (
--font-ui) - all other UI text and body copy. Also the new global default (index.css's@themeoverrides--font-body), so unmigrated screens already inherit this typeface even before their colors move. - IBM Plex Mono (
--font-data) - anything a person would copy or grep: ids,key=valueattribute pairs, timestamps, durations, service names.
Never Inter, Roboto, Arial, or a new font stack.
Layout shell (already built - reuse, don't rebuild)
otel-ui/src/components/layout/IconRail.tsx- 72px fixed icon rail, global nav, onearia-labelper icon.otel-ui/src/components/layout/TopBar.tsx- 58px fixed top bar: search, time range, theme toggle, auth.otel-ui/src/components/FilterBar.tsx- generic filter bar (optional scope segmented control, removable chips, dashed add-filter, an always-visible match readout, clear-all). Reuse this on every screen that lists things instead of building a bespoke filter row.App.tsxwires the shell globally and renders the single page-level<main>. Screens render inside it - see pitfall #2 below.
Screen pattern
Established by IssuesVisualizer.tsx (the triage overview) and
RumSessionsVisualizer.tsx (real-user sessions) - follow this shape for the
remaining screens:
<FilterBar />at the top of the screen's content.- A main content column (
flex-1) plus an optional fixed-width aside (~340-420px) for supporting stats/panels. - Cards:
rounded-[14px] border border-ds-line bg-ds-surface, no shadows, no gradients - depth comes from the border and ground color alone. - No per-service rainbow badges.
getServicePaletteinotel-utils.ts(the gradient hash-to-color picker) is legacy: per the original design spec, a muted per-service palette is reserved for trace-waterfall span coloring only, never for general UI chrome like service badges. Use a neutralborder-ds-line-strong text-ds-text-2badge instead. - Real
<button>/<a href>/<input>+<label>only. NeveronClickon adivorspan[role=button].aria-labelon every icon-only control;aria-hidden="true"on decorative SVG icons (uselucide-react, not the Material Symbols icon font - see pitfall below).
Pitfalls hit migrating Issues and RUM sessions - avoid repeating these
- No nested
<button>elements. Browsers implicitly close the outer<button>the moment they hit an inner one, which silently breaks click handling on whatever's after it in the DOM (this shipped once, undetected until manual testing). If a row/card needs both "click to expand" and inner action buttons, make the expandable area its own<button>and put the action buttons as siblings, not children. - No nested
<main>landmarks.App.tsxalready renders one<main>; a screen component must not render its own - use a<div>. whitespace-nowrap+shrink-0on fixed-height pills/badges. Without them, a longer label silently wraps onto two lines and blows out the pill's height inside its flex row - easy to miss until you test with real (longer) data or a narrower viewport.- Never fabricate data. Before adding a metric, column, or button, check
whether the field actually exists in the real data model - the backend API
response, or for RUM specifically, what
static/otel-rum.jsactually sends (see the header comment inotel-ui/src/lib/rum-utils.tsfor a worked example of what's real vs. invented). If a design reference shows something with no backing data - the RUM mockup invented rage clicks, geo location, a "frustration score", and session replay, none of which this pipeline captures - drop it or replace it with the closest real, honestly-labeled equivalent. Never wire up a control that looks functional but isn't backed by anything. - Screenshots of a hidden Browser preview pane are unreliable for
position: fixedelements (stale compositor frame, even after a full reload). If a screenshot shows something that contradictsgetComputedStyle, trust the computed style, front the tab, and re-check before concluding there's a real bug.
Migration status
Whole UI is on ds-* (one theme, light + dark). The nav strip in Tabs.tsx
is gone - IconRail is the only navigation; Tabs.tsx just renders the
active screen. TelemetryFilters is built on FilterBar. The Material
Symbols icons were replaced with lucide.
Lessons from getting here:
- Merges can silently revert a redesign.
67ce419revertedIssuesVisualizer.tsxto the old styling (a parallel branch had edited the same file). After any merge touchingotel-ui/src, run the greps below - they must return nothing. - A stale
otel-ui/distor un-redeployed image hides changes. The Go server servesotel-ui/dist, else./dist, else the legacy rootindex.html. Rebuild (npm --prefix otel-ui run build) before concluding a screen isn't wired. - Still to polish (functional, but not yet re-laid-out to the screen
pattern):
TraceVisualization,MetricsVisualizer,StructuredLogTerminal,SearchAndWatcher,TrendsVisualizer,SetupPanel. They useds-*colors via a mechanical token swap but keep their old layout/type scale, useonClickon non-buttons in places, andCommandAndQueryBar/WaveformHistogramstill sit above every tab except Issues and RUM sessions (isTriageOverviewinApp.tsx). Restructure them ontoFilterBar+ the card/aside pattern when touching them.
Finding old-pattern usages
grep -rlE "surface-container|text-on-surface|primary-container|bg-error-container|material-symbols-outlined" otel-ui/src
grep -rnE "(bg|text|border|ring|from|to|via)-(slate|zinc|gray|sky|cyan|blue|violet|indigo|purple|emerald|teal|rose|red|amber|orange)-[0-9]|text-white|border-white|bg-(white|black)/" otel-ui/src
Both must print nothing.
Working order from here
Initial pass is done. Take one view at a time to the full screen pattern (FilterBar + card/aside layout, real buttons, fold or restyle the shared chrome for that screen), verify it in the browser in both themes, commit, then move to the next. Suggested order: Traces, Logs, Metrics, Search, Trends, Setup.
Local / agent development without login blocking
Quick start (agents: do this first)
node scripts/dev-auth.mjs get /api/issues # signs in if needed, then GETs
If not signed in it prints Approve in a browser: https://users.k6n.net/device?user_code=XXXX-XXXX.
Stop and give the human that link, then wait - the script polls (~10 min
window) and continues on its own. Refresh token is stored in ~/.go-auth/dev/
(0700/0600), so this happens once, not per command. Other commands:
login | token | whoami | forget. For raw curl:
curl -H "Authorization: Bearer $(node scripts/dev-auth.mjs token)" https://otel.k6n.net/api/....
Env: OTEL_URL, AUTH_DEV_BASE, AUTH_DEVICE_CLIENT_ID (default dev-console),
GO_AUTH_TOKEN (explicit-token escape hatch, never store).
Browser (npm run dev): with no session a "Dev sign-in" banner shows the same
link + code and reloads once approved. Code is src/dev/device-auth.ts, loaded
only under import.meta.env.DEV; /dev-auth/* is proxied by vite.config.ts
to the issuer's /api/auth/*.
Gotchas (each cost a failed attempt)
- Request encoding:
/api/auth/deviceand/api/auth/device/tokentake form-encoded bodies (RFC 8628); only/api/auth/device/refreshtakes JSON ({"refresh_token": ...}). JSON to the first two gives a fastinvalid_grantthat looks like a denied approval but is a malformed request. - 401
token not issued for this client: the otel server rejects the token'saud. It must containotel-debugorOTEL_OIDC_AUDIENCE(https://otel.k6n.net, set ink8s/deployment.yaml). If it 401s, the deployed server predates that setting - redeploy withbuild.sh. - Never probe the issuer or auth CRDs with ad-hoc curl/kubectl unless the user has said to; the harness blocks it. The script above is the sanctioned path.
CRD setup (already applied; reference)
Reference docs: https://users.k6n.net/dev-auth.md, https://users.k6n.net/crds.md.
k8s/auth-dev.yaml defines an AuthClient dev-console (redirectURIs: []
is required even for device-only clients; spec.audience: [https://otel.k6n.net])
plus AuthServer.spec.deviceFlow.
audienceentries must be absolute URLs.otel-debugwas rejected by the operator and flipped the AuthServer toREADY Falseuntil fixed.--dry-run=serverdoes not catch this; after applying, always checkkubectl get authserver -n ecommerce(READY True) andkubectl describe authserver auth -n ecommerce(events).- Apply:
kubectl apply --server-side --field-manager=otel-dev-auth -f k8s/auth-dev.yaml(a non-fatal "spec.issuer: Required value" warning for the partial AuthServer apply is expected). - Never commit tokens, persist them in a production build, or log them.