Applies to any change under frontend/src. This is a discovery + cadence guide: the rules below exist because agents tend to generate before they look. The root AGENTS.md and the quill package guides remain authoritative — this file does not repeat them, it points at them.
Before building any UI element, search for an existing one. PostHog already has a badge, a label, a table, a tag, a card, a modal. Hand-rolling one with raw <div>/<table> + Tailwind is the single most common agent mistake here, and it produces unbounded, off-design output. Reuse is also how the product stays on-brand: Lemon/quill components carry PostHog's tokens, density, and interaction patterns, so a scene built from them looks like PostHog without anyone having to think about it.
Where to look, in order:
frontend/src/lib/lemon-ui/— the main-app default (~50Lemon*components). Grep here first, and in most cases stop here.frontend/src/lib/ui/andfrontend/src/lib/components/— older / app-specific shared pieces.
@posthog/quill is not for this tree. It targets MCP apps and the desktop app, it's deliberately more compact than LemonUI, and the main app isn't being migrated onto it, so quill components read as out of place here. A handful of files already import it; treat those as exceptions rather than a pattern to copy.
For @posthog/quill-charts consumers in the main app and product frontends, use useChartTheme from lib/charts/hooks.
Prefer its useChartConfig helper for memoized configuration.
Common reinventions and what to use instead:
| You're about to build… | Use instead |
|---|---|
a <table> |
LemonTable (lib/lemon-ui/LemonTable) — has sorting, pagination, loading, empty states |
| a colored status pill / count badge | LemonBadge |
| a small removable chip | LemonSnack or LemonTag |
| a form field label | LemonLabel |
| a dropdown menu of actions | LemonMenu with a LemonButton trigger (not a new lib/ui/DropdownMenu) |
| a select / combobox / autocomplete | LemonSelect, LemonInputSelect |
| a card / panel | LemonCard |
| a modal / confirm dialog | LemonModal / LemonDialog |
If nothing fits, say so and propose extending the existing component before adding a new one. Don't silently fork. If you do end up building custom, stay on brand: system tokens and primitives only, matching the surrounding scene's density and flatness — and none of the generic AI-generated look (purple/blue gradients, glassmorphism, gradient text, icon-tile card grids, decorative motion). The full slop-tell catalog is in the /writing-ui-components skill.
The same goes for patterns, not just components: before building a new scene or view, read 2–3 comparable ones and model yours on those that follow these rules. Precedent that violates Rule 5 or /writing-ui-components is legacy to route around, not license to repeat — conventions outrank precedent, and compliant precedent outranks invention.
LemonUI vs quill lives in the root
AGENTS.md("Code Style → Frontend (quill vs LemonUI)"). If you're working somewhere quill genuinely applies (an MCP app, the desktop app),packages/quill/packages/primitives/AGENTS.mdhas its component-choice and spacing rules, and the two libraries must not be mixed inside one component's internals.
frontend/src/scenes/<name>/ is not the default home for product UI, and an existing folder there is not evidence that new files belong in it — about 18 products currently have UI in both trees. App-level scenes (settings, onboarding, billing, max) do legitimately live here.
Before adding a file or a directory under scenes/, check where that product stands:
python3 .agents/skills/placing-product-frontend-code/scripts/scene_product_split.py <scene-dir>Invoke /placing-product-frontend-code for the decision table and for why the path matters — it drives merge-queue lane assignment, and a change anywhere under frontend/ serializes against every other frontend PR.
Django serializers are the source of truth. hogli build:openapi generates TypeScript types (suffix Api) and API functions. Never write an interface that mirrors a backend serializer — import the generated type instead.
- Types:
import type { UserAuthSessionApi } from '~/generated/core/api.schemas'(exemplar:frontend/src/scenes/settings/user/loginSessionsLogic.ts) - Request functions:
import { getExportsContentRetrieveUrl } from '~/generated/core/api'(exemplar:products/signals/frontend/inbox/components/signalCards/ScannerFindingSignalCard.tsx) - Generated output lives in
frontend/src/generated/core/andproducts/*/frontend/generated/. These files are codegen output — never edit them by hand. Change the serializer and rerun.
When touching lib/api, api.get<, api.create<, or any handwritten API interface, invoke the /adopting-generated-api-types skill first.
Covered by the root AGENTS.md (Code Style → Frontend). The discovery hint for this tree: if a scene/component has a *Logic.ts, that's where actions/reducers/selectors/listeners belong. See /writing-kea-logics and /using-kea-disposables.
Full doctrine + convert-on-sight catalog: the /writing-ui-components skill. Load it before creating, moving, splitting, or restructuring any component or frontend file, extracting a shared component, or renaming frontend symbols. The always-on core:
- One component per file, named after its export; named exports only. Every symbol has exactly one import path — no re-export shims, no new
index.tsbarrels; moving a symbol means updating every consumer in the same PR. - Extract a shared component when call sites read as content, not markup — at three occurrences, or two in one feature with a third clearly coming. If the generic needs a boolean to switch off half its behavior for one caller, it isn't one shape: leave the duplication. Keep new generics feature-local until a second feature needs them (promote via
lib/components/; never import acrossproducts/*). - Interactive elements are real
<button>/<a>— that's whatLemonButton/quill triggers render. NeveronClickon a<div>/Card. - Loading, empty, and error are three different screens. Never derive "empty" from data that hasn't resolved — branch on the loading/unknown state first.
- Renames sweep code symbols completely; wire strings stay frozen. Event names, property names/values, flag keys,
data-attrvalues, storage keys, and URL paths are API (dashboards, rollouts, and Playwright depend on them) — pin them with a comment instead of renaming.
Every surface a person reads has to hold up in a narrow scene.
The width a component gets is not the window width: the nav sidebar takes ~215px, an open side panel ~512px more, and the scene pads 32px on top.
So a 1280px window with the side panel open leaves a scene about 520px wide — narrower than a md: breakpoint ever fires at.
That is a normal working setup, not an edge case, and it is the case agents skip.
- Break on the container, not the viewport.
md:/lg:/xl:track the window, so they fire long after the real space ran out. Container queries track the space the component actually has.layout/navigation-3000/Navigation.tsxnamesmain-contentandmain-content-container, so@min-[48rem]/main-content:follows the main column. A shared component declares its own container instead, because it must respond wherever a caller places it —lib/lemon-ui/LemonBanner/LemonBanner.tsxis the pattern. - Nothing clips and nothing scrolls sideways. Rows of buttons, tags, or chips get
flex-wrap. Long strings truncate. Side-by-side halves stack. An unwrapped action row is the most common miss. - Cut decoration before content. When something has to go at a narrow width, drop the illustration or the padding, not the explanation or the primary action.
- Do not build for mobile. No phone-width layouts, no touch-sized targets, no
sm:variants for a viewport nobody runs the app at. "Narrow" means a docked panel on a laptop. - Look at it, don't reason about it. Render the surface at a few widths before calling the work done. A story with a pinned container width snapshots the narrow case, so a regression shows up in visual review.
Page-translation extensions (Chrome and Edge in-page translate, the Google Translate widget) replace each text node they translate with a <font> element.
React keeps pointing at the original node, which produces two defects — both of which land hardest on users outside English-speaking markets, since they're the ones with translation turned on:
- A crash. Removing that text node, or inserting a sibling before it, throws
NotFoundError: Failed to execute 'removeChild' on 'Node'(react#11538).ErrorBoundaryremounts the subtree instead of dropping the scene, so this degrades rather than breaks — but a remount is still a backstop, not a licence to render the shape: it discards whatever state that subtree held. - Silent staleness. A text-only update writes
nodeValueon the detached node, so the text freezes at whatever the extension translated. Live timers and countdowns just stop, and no backstop catches that.
The hazard is specifically a bare text node that has siblings, because that's the only shape React tracks as its own node:
<div>Computed {lastRefresh.fromNow()}</div> {/* hazard: two bare text nodes */}
<>{formatElapsed(seconds)}</> {/* hazard: a bare text node in the parent's children */}
<span>{lastRefresh.fromNow()}</span> {/* safe: sole child, React writes parent.textContent */}A sole text child is immune, so don't add anything there.
For the hazardous shape, either wrap the changing part in its own element, or mark it translate="no", or both:
<span>Computed </span>
<span translate="no">{lastRefresh.fromNow()}</span>Scope it to the changing part and keep the static labels around it translatable.
Nearly all of these render numbers, dates, or durations, so nothing of value goes untranslated.
queries/nodes/InsightViz/ComputationTimeWithRefresh and scenes/experiments/MetricsView/new/ElapsedTime are the exemplars.
A whole subtree of machine data (an event feed, an ID column) opts out at its container instead — scenes/activity/live/LiveEventsFeed marks every row translate="no" and leaves the headers translatable.
These are slow; run them at the right moment, not after every edit.
- TypeScript check —
pnpm --filter=@posthog/frontend typescript:check(runstsgo --noEmitover the whole app). Rely on the editor/LSP while editing; run the full check once before you call the work done, not per-edit. - kea typegen (inline types) — regenerates automatically via
typegen:watchwhile the dev server runs (./bin/start/hogli start). If you must regen one logic without the server, usepnpm --filter=@posthog/frontend typegen:file <path>. Don't routinely run the fulltypegen:write— it's a heavy whole-repo pass (multi-GB heap). All logic files use inline types by default: a generatedMakeLogicTypeblock sits above eachkea()call, marked "Generated by kea-typegen". Agents may update those blocks (typegen reconciles them anyway); humans should let the typegen commands keep them updated. Connected values/actions carry a// sourceLogiccomment and sort first, grouped by source logic. hogli build:openapi— only needed when you changed a backend serializer/viewset. A pure.tsx/.tschange never needs it. Don't run it speculatively.
Run pnpm --filter=@posthog/frontend fix before finishing. It applies safe Oxlint fixes, never suggestion fixes, and always runs Oxfmt; it fails if either tool fails. format runs Oxfmt only, while lint and format:check only verify. Config: root .oxlintrc.json. CSS, spelling, and copy-casing rules live in the root AGENTS.md (Code Style).
Adding a button/toggle/action to a scene's ScenePanel? It must also go in that scene's SceneMenuBar
(create one if the scene has none). See layout/scenes/AGENTS.md and the
/scene-menu-bar skill.
- Root
AGENTS.md— full Code Style + architecture rules (authoritative). layout/scenes/AGENTS.md(scene action surfaces:ScenePanel+SceneMenuBardual-write rule).packages/quill/packages/primitives/AGENTS.md— component selection matrix./working-with-charts: chart consumption workflow with links to the relevant examples and package docs.docs/published/handbook/engineering/conventions/frontend-coding.md— frontend conventions.- Skills:
/writing-ui-components,/placing-product-frontend-code,/adopting-generated-api-types,/writing-kea-logics,/using-kea-disposables,/writing-tests,/working-with-charts.