Web — Page Development & Design Language
Non-negotiables — read this even if you skim the rest
The ten rules that break a page if you miss them. The rest of this file is the why and
the how; these are the must.
- Copy the new, never the legacy — and copy the contract, not a page's raw classes. Open
a refactored reference page (§ Reference map in
reference.md) and mirror its structure.
Never pattern-match off a dirty / un-refactored page — and never inherit a reference page's
stray font-[NNN] / text-[Npx] / text-foreground/NN: some references still carry
pre-contract token debt (about is one — it ships off-scale size, arbitrary weight, and
hand-mixed alpha in one line), so the token law in packages/ui/AGENTS.md outranks any
single page's classes when they disagree.
- A refactor must not regress. Before touching anything, inventory every behavior,
feature, state, and path the old page has — the new page keeps all of them unless you
deliberately drop one and say so.
- Never hand-write a color — tokens only.
bg-card / text-foreground / border-border,
etc. Dark mode is purely the result of this, and nothing lints raw colors in app pages,
so one bg-white / #hex / text-gray-* ships visibly broken in dark. For any hover /
selected / pressed / subtle tint, use the neutral overlay ladder (--ui-hover /
--ui-selected / bg-accent) — never a gray or a /10 alpha.
- Build from the shared shell + primitives. Centered
max-w-3xl with gutters;
SettingsSection / SettingsRow white cards — one hairline, role-map radius, inset
dividers, deliberate spacing rhythm, and no hover-rise on cards. The full owner
vocabulary — which recurring shapes (row, field, section, tile, banner, loading/empty
state, delete confirm, page frame…) have an owner component, the decision map, and when a
shape deliberately stays hand-written — lives in ../ui-owners/SKILL.md;
read it before building any of those shapes.
- Reuse a component — never hand-write one. Compose from the real
@felinic/ui atoms
(Select / Combobox / Tooltip / icon Button / Empty) and the existing shared parts; never
re-skin one, hand-roll an equivalent, or rebuild a control out of raw <div>s. Menus
included: dropdown / context / action menus use DropdownMenu / ContextMenu and their
*Item / *Separator / *Label slots — never hand-written <button> rows, never <hr> /
border-b dividers inside a popover. The same rule applies to every other surface: if
@felinic/ui (or an existing app component) already covers it, use it — hand-built markup
drifts out of the token contract and reads inconsistent page to page. If a layout repeats,
extract it into one shared component instead of pasting it twice. A genuinely new component
is a last resort — clear it with the developer before building it.
The component system is not modelling clay. SettingsSection / SettingsRow / the cards
are not a blank canvas you reshape to taste — each has one sanctioned use, and you compose
with them, you do not knead them into a new shape. The tell that you've started treating the
system as clay: you're putting a hand-rolled grid / multi-column layout / freeform <div>
stack inside a SettingsSection to "arrange things nicer" (a 2-col stat grid jammed into
the white card is the canonical offence — see reference.md dirty→clean). When the shape you
want isn't one the primitives already give you, that is not license to deform a card from
the inside — it means you've picked the wrong primitive. Stop, find the component whose one
job is that shape (a value-in-a-card is a SettingsRow; a label-on-top/number-below readout
is a section-level stat tile, never a grid inside a card), or clear a real new component
with the developer. Squeezing the clay is always the wrong move, even when it renders fine.
- Earn every word and every block. Cut copy that doesn't guide; hide blocks that aren't
actionable; empty and loading states must still draw their frame (no layout jump).
No stray fragments: every visible piece must sit in a named region (PageShell
#actions, a row's control column, a dialog footer, a toast) — never a lone status line
floating where it aligns with nothing. If it can be removed, remove it; if it must stay,
anchor it and reuse an existing pattern from a reference page.
- You are not done until you verify the rendered page: grep for raw colors → flip to
dark → shrink to narrow +
zh → walk every old interaction → mise run lint.
- Draw it before you build it, then audit the nesting. Sketch the page as an ASCII
wireframe first — and again when it looks done — and read it like space-complexity: no
card-in-card, no decorative icon stacked in a card, no nesting layer that isn't earning its
keep. Fewer boxes, shallower depth.
- The root surface answers "is it working," not "configure everything" (the 99/1 rule).
The first page a user lands on serves the 99% who came to glance at state — it must not
make them carry the visual weight of inputs, button piles, or history lists that only the 1%
want. But the 1% are not browsing; they arrive with a purpose and will hunt for the
button — so deep/rare operations (limits, snapshots/history, destructive actions) live behind
a named entry point (a one-line summary row + a button) that opens a focused form showing
the data and the action. Never spill them onto the root, and never bury them in an in-card
"Advanced" disclosure that mixes diagnostics with real operations (§ 12).
- A change is not committed until a human verifies it. Run lint / grep / type checks
yourself, but the rendered result (visual + interaction) must be checked by a human
before any
commit / amend / push — do not amend or squash unverified work into a
commit on your own say-so. "It should work" from the agent is not verification; the
human's eyes on the rendered page is. Follow-up tweaks to an already-committed change
re-verify before re-amending.
This skill is the page-level companion to the atom-level contract in
packages/ui/AGENTS.md. That file governs how a single control looks; this file
governs how you compose controls into a page that reads like the already-refactored
surfaces (Overview, Appearance, Profile, About, Web Search) and never like the
legacy ones.
It exists because the refactor kept slowing down: each page re-derived the same
decisions from scratch and re-made the same mistakes. The point of this skill is to
make that experience reusable — so refactoring "the next page" is a procedure, not
a re-invention.
Prime directive
Copy the new language. Never copy the legacy. When unsure how something should
look or behave, open a refactored reference page and mirror it — do not pattern-match
off an un-refactored page (even one you are mid-refactor on).
Two non-negotiable first steps before you touch a page:
- Read
packages/ui/AGENTS.md in full. It is the law for tokens, radius, borders,
color, motion, and the "clean vs dirty" rule. This skill assumes it.
- Open one refactored reference + the page you're replacing side by side. See
reference.md § Reference map for which page to copy for each page shape, and the
dirty→clean table for diagnosing what to strip.
A refactor is behavior-preserving — interrogate what it breaks
The Prime directive covers the look; this covers the behavior. Changing how a page looks
must not silently change what it does. The most common refactor failure is not an ugly page —
it's a page that quietly lost an affordance that was buried in the old messy layout. Before
and during a refactor, stop and ask what the refactor could break:
- What is the user's path here? (This is the § 1 copy question, upstream of pixels.) Why
does the user come to this page, what are they trying to do, how do they get in and out?
The visual exists to serve that path — so derive the path first, then build to it.
- Does each remaining control's interaction logic need to change — and if you change it,
is it still complete? A control is not just its look. It carries behavior: a select that
filters, an input that debounces, a toggle that triggers auto-save, a context menu, keyboard
handling, a drag, a hover-to-reveal action, an empty/loading/error branch. When you swap a
legacy control for a refactored one, re-wire every behavior it had — don't just port the
markup.
- Did the refactor drop functionality? Inventory everything the old page could do — every
button, menu item, edge action, state, shortcut — and confirm the new page can still do all
of it, or that you deliberately removed it and said why. Never lose a capability by
accident.
- Is there a better path? A refactor is the moment to question whether the old flow was even
right: a step that can be removed, a dialog that can be inlined, two redundant controls that
can merge, a shorter route in/out. Improve the path, don't just repaint it.
- A new page is all of the above, from zero. With no old page to inventory, you must derive
the path, the required behaviors, and the complete feature set from the requirement itself.
The risk inverts: not "losing" an old behavior, but never specifying one you needed —
so think the full interaction surface (states, edges, empties, exits) up front.
Engineering correctness — the dirt the eye can't see
A page can pass every visual rule and still be wrong. The most expensive debt isn't an
ugly card — it's behavior that breaks because two modules quietly disagree about a contract.
This is invisible in a screenshot and survives review, so it gets its own pass. Treat it as
part of "clean," not a separate concern.
- A cross-module assumption must be enforced or eliminated — never just commented. The
back-affordance bug is the cautionary tale:
useSyncedQueryParam switched tabs with
router.replace under a comment claiming "replace won't bury the previous page," while
installBackHistory's afterEach never distinguished replace from push — so replace did
overwrite previous, and the back button started reading the bot's raw bot-<uuid> slug.
Both comments looked reasonable; together they were wrong. If module A leans on module B
behaving a certain way, lock it with a type or a test, or remove the assumption. A comment
asserting the contract is not enforcement of it.
- Layout size must never be driven by content. A
w-fit sidebar
(master-detail-sidebar-layout) let one too-long back label stretch the whole panel — so a
bad string became a visibly wider sidebar. Pin widths and let text truncate inside a fixed
box; a locale change, longer data, or an upstream bug must never move the frame.
- In-page state syncs with
replace; whatever reads "the previous page" must honor that.
Tab/filter swaps are not navigations — they router.replace. A history reader that counts
replace transitions will treat a tab switch as a place to step "back" to.
- One root cause often wears two faces. The slug label and the widened sidebar were the
same bug. When two oddities appear together on the same action, hunt one upstream cause
before patching each symptom in place.
The design language in one breath
The refactor is not new chrome. It is a switch to a calmer language whose body is
defined by a single hairline stroke + an inherited white surface, and whose
interaction is read through color/fill change in place — never by lifting, scaling,
shadowing, or bordering something "to make it nicer."
What concretely changed, before → after:
- Floating white cards. Content lives in
bg-card cards with one border-border
hairline and the shell radius. The section title sits above the card as quiet muted
text. Use the shared SettingsSection / SettingsRow primitives — do not hand-roll a card.
- Unified stroke. One hairline,
border-border. Never border-border/50,
border-*/40, or a structural border on a control body.
- Unified radius. Only the role-map scale (card 14 / menu-shell 12 / control 8 /
badge·tooltip 6). Never a bare
rounded or an off-scale rounded-lg on a control.
- Unified color. Black/white/gray is ~90% of the UI (the skeleton). Charcoal is the
high-emphasis CTA; blue means "selected"; purple is scarce.
success/warning/
destructive are rationed signals, not surface decoration — never tint a whole
card bg-success/5.
- Unified components. Use the refactored
@felinic/ui atoms as-is. Do not re-skin
them or inject classes that fight their contract (the canonical "weird Select" bug).
- No hover-rise, ever. Cards and rows do not lift / scale-up / grow a shadow on
hover or press. Press-scale belongs only to buttons and sidebar rail items — never to a
large content card (a bot card does not shrink when you press it).
The shell & spacing rhythm
This is the part that most often gets skipped and is the fastest tell of an un-refactored
page. The refactored pages (Appearance / Profile / About) are not full-bleed — they all
sit inside the same shell, and nothing ever touches an edge or another element.
- The shell. Content is a centered column inside the right pane, not stretched edge to
edge:
mx-auto max-w-3xl caps the width (~768px) and centers it, px-6 keeps a left/right
gutter so nothing glues to the pane edge, pt-10 pushes the title down off the top, pb-12
leaves room at the bottom. A page that runs full-width or starts flush against the top is
immediately off-language. (About is the one exception: being sparse, it centers its group
vertically with a slight upward bias instead of top-aligning.)
- Spacing is a hierarchy of gaps, not free-styled margins. Each level of structure has
its own consistent breathing room, and you reuse the same rung instead of inventing values:
- title → content:
mb-6 (Profile uses mb-8)
- card group → card group:
space-y-8 — the big, generous gap that separates sections
- section label → its card:
space-y-2.5
- row → row inside a card: a
border-b hairline divider + py-3, each row min-h-[3.75rem]
- label → its description:
mt-0.5
- inside a padded card block:
p-4/p-5 with space-y-4
- Text is never glued — to edges, to the top, or to each other. Every label has air above
and below it; the title has air under it; cards have air between them. When something feels
cramped, the fix is almost always "use the next rung of the spacing hierarchy," not a
one-off margin.
Concrete shell + primitives (exact recipes + the full spacing ladder live in reference.md):
- Page shell:
mx-auto max-w-3xl px-6 pt-10 pb-12, title mb-6 px-2 text-lg font-semibold,
sections stacked with space-y-8.
- Card:
SettingsSection = overflow-hidden rounded-[var(--radius-menu-shell)] border border-border bg-card,
optional title above as px-2 text-[13px] font-medium text-muted-foreground.
- Row:
SettingsRow = label (text-sm font-medium) + description (text-xs text-muted-foreground)
on the left, the control on the right, rows split by border-b border-border last:border-b-0.
Dividers — inset inside a card, full-bleed everywhere else
A divider has two different jobs and two different widths; using the wrong one is a tell.
- Separating rows inside one white card → inset. The hairline must not reach the
card's left/right edges. This is done by putting the border on a horizontally-margined row
(the
mx-4 on SettingsRow), never on the card itself, and dropping it on the last row
(last:border-b-0). An edge-to-edge line would visually slice the rounded card into stacked
tiles and break the "this is one continuous surface" reading. Corollary: borders go on
rows, never on the invisible wrapper <div> you put a v-if block in — a wrapper with
border-b that ends up the last child of the card doubles its hairline onto the card's own
bottom stroke (the recurring "fights the stroke" bug). See reference.md § Dividers.
- Structurally splitting a container → full-bleed. A Dialog header/footer band, a
section-heading underline, or a standalone
Separator between blocks divides the whole
container, so the line spans edge to edge while the content keeps its own inner padding.
The test: is this line separating items within one surface (inset) or splitting the
container itself (full-bleed)? Answer that before you place a divider.
A "divider I never drew" is usually a misplaced #footer. If a hairline appears to float
under a single row over an empty strip, you almost certainly put a SettingsSection #footer
(Save band) on a root-page card — its full-bleed border-t plus the lone row's own inset
border-b read as a stray line. The line is real chrome in the wrong home: a root page's Save
belongs in PageShell #actions, not a card footer (§ 8). Fix the home, not the line.
Dark mode is not a task — it is the absence of hardcoded color
Read this twice. This is the single most-skipped requirement, and nothing will catch it for
you. You do not "add dark mode" at the end. Dark mode is the automatic result of using
only semantic tokens; it breaks the moment you hardcode one raw color. So there is exactly one
rule, applied from the first line: never write a raw color — use a semantic token.
- Raw colors that silently break dark mode:
bg-white, bg-black, text-white, text-black,
any *-gray-* / *-zinc-* / *-slate-* / *-neutral-*, any #hex, any bg-[#…] /
text-[#…], any inline style="color: …" / background: …. Use bg-card, bg-background,
text-foreground, text-muted-foreground, border-border, bg-accent, etc. instead.
- For tints and subtle layering, prefer the neutral overlay ladder — it is the dark-safe way
to add "color." When you need a hover / selected / pressed shade, or a faint layer to set
something apart, reach for the interaction-overlay tokens (
--ui-hover / --ui-selected /
--ui-pressed, the --overlay-* rungs, or bg-accent which maps into them) — never a
solid fill, a hand-mixed gray, or an alpha hack (bg-black/5, hover:bg-gray-100). The
overlays are chroma-0 and composite over whatever surface they sit on, so they are the same
token in light and dark (light = a black wash, dark = a white wash) and identical across
every color scheme — no dark: variant, no per-scheme override, and they cannot break the way
a baked color does. (Full ladder in packages/ui/AGENTS.md § Color → Interaction overlay.)
- A
dark: override is a smell, not a fix. Themed tokens auto-switch with no dark:
prefix. If you're reaching for dark:bg-… to patch a page, it means you started from a raw
light color — go back and replace the base color with a token; don't band-aid it per-mode.
- There is no safety net for app pages. The UI-contract guard (
mise run lint) only scans
packages/ui — apps/web pages are explicitly out of scope, and there is no ESLint rule for
hardcoded colors. So a raw color in a page is caught by nothing; lint passes, and the page
ships broken in dark. The discipline below is the only defense — treat it as mandatory.
- Before you finish, do two things, every time: (1) grep the page for raw colors
(
bg-white, text-black, text-gray-, bg-gray-, #, dark:, inline style=); (2)
actually flip the app to dark and look at the rendered page. The only sanctioned bg-white
is a physical knob (Switch / Slider thumb) over a colored track. Canvas content (charts) can't
read tokens — reuse the token→concrete-color resolve the reference pages already do, re-run on
theme change.
Narrow screens reflow, never overflow
A settings page is a centered max-w-3xl column, but the pane is resizable and the desktop
window can be narrow. Multi-column grids collapse with responsive prefixes (grid-cols-1 sm:grid-cols-2, stat rows grid-cols-2 sm:grid-cols-4); same-row control clusters (search +
button) must not break or clip. Always check the narrowest realistic width, not just the wide
default — and remember Chinese copy is wider, so the narrow + zh combination is the real worst
case (see § 1).
When a component must adapt to a resizable pane, viewport breakpoints are the wrong tool.
sm: / md: watch the window — but a dockview / master-detail pane changes width while the
window doesn't, so a sm: grid won't react when the same component sits in a narrow vs wide
pane. Reach for a container query (@container) so the component responds to its own
container's width, not the viewport. (Page-level max-w-3xl columns still use viewport
prefixes; this is only for components that live inside variable-width panes.)
Pane width is only one of three "bigger" axes; the page must also hold up under browser zoom
and a larger root/OS font. The defence is the same discipline: lay out with the spacing
ladder and flex/grid gaps (never a margin tuned to one string), size inline-with-text icons in
em so they grow with the text while standalone control icons keep the size-* rem ladder, cap
width with max-w-* + centre so a wide screen never stretches a line, and let any line that can
outgrow its box truncate. Full rules + the verify pass (zoom 50→200%, narrow + zh, ultra-wide)
live in reference.md § Scaling & zoom.
Scroll ownership
Know who owns the scroll before you add overflow-* anywhere. The desktop shell locks body
overflow, so a page that needs to scroll must own its own scroll container (the dev wall does
this with h-dvh overflow-y-auto); a settings page instead scrolls inside the section's
existing scroll area. The failure modes are symmetric: a page that forgets to own its scroll is
un-scrollable inside the desktop shell, and a page that adds a stray overflow-* creates a
nested scroll container (a scrollbar inside a scrollbar) or a surprise horizontal scrollbar.
When a transform nudges content sideways (the list↔detail swap pushes panes ±24px), clip it
with overflow-x-clip — not overflow-x-hidden, which would turn the element into a vertical
scroll container and steal scrolling from the ancestor. Don't introduce a new scroll container
unless you mean to.
Every page-level scroll container that holds a centered max-w-3xl column must reserve the
scrollbar gutter — [scrollbar-gutter:stable]. The shell centers content with mx-auto, so
its left/right margins are computed from the pane's available width. When a classic
(space-consuming) scrollbar appears, it eats that width and the whole centered column — title,
card edges, everything — shifts sideways. The tell is real and confusing: two sibling tabs look
"only similar," because a long tab scrolls (narrower pane) while a short one doesn't (wider
pane), so the title and card edges land in different spots as you switch between them. A page
that doesn't scroll today will the day its content grows — so this is not optional on the
scroller, it's structural. Reserving the gutter keeps the available width constant whether or
not the scrollbar is visible, so every page that shares (or mirrors) the scroller stays aligned.
There are only a handful of these page-level scrollers (the settings section's router-view
pane; any master-detail surface that runs its own inner scroll pane, e.g. the bot-detail tab
pane) — put the rule on the scroll container itself, never on each page, so all pages it hosts
inherit it for free. Bounded inner scrollers (a tool-call detail body, a dropdown list, a log
pane) are left-aligned and don't need it.
Component discipline
Reuse first; build new only with sign-off. The default is always to find and reuse an
existing component, then to compose existing atoms — never to hand-write a control out of raw
markup. The most expensive page is the one where the agent quietly re-built from zero what
already existed. Three rules, in order:
- Hand-writing a component is forbidden. A clickable
<div> that re-implements a Button, a
bespoke popover list that re-implements a Select, a <div>-grid that re-implements a Table —
all banned. They can't receive the size / token / focus / a11y contract, and they drift. If
@felinic/ui (or an existing app component) has it, use it as-is.
- A composition that can repeat must be extracted, not pasted. Even when every piece is a
properly reused atom, if the arrangement could appear in more than one place (a provider
row, a card header, an empty tile, a field cluster), lift it into one shared component and
reuse that. Copy-pasted markup is duplication waiting to drift out of sync — and a reused
composition dropped into a spot where the same shape recurs is the signal to extract it.
- A genuinely new component needs the developer's OK first. When nothing fits and no
composition will do, stop and say so — name what's missing and why — get agreement, then
build it once in the shared layer. Never silently spawn a one-off component mid-page.
A component is a component — patterns that co-star are not families. Each component is a
standalone contract with its own identity; two components appearing together in a house
pattern does NOT make one belong to the other. Worked example: the focused-dialog family
(DialogPanel / DialogViewHeader / DialogBody) and ActionCard almost always ship
together — a named entry card opening a focused dialog — yet they are ORTHOGONAL: the dialog
anatomy belongs to Dialog and opens from any trigger (a toolbar button opens the bot-mcp
Import panel; a shortcut could too), and ActionCard is just one opener that emits a click and
doesn't know what opens. Filing the dialog primitives "under ActionCard" would have invented
a false dependency — a future agent would think "no ActionCard, so I can't use DialogPanel"
and hand-roll a dialog shell, or worse, bolt an ActionCard on just to unlock the dialog. The
general tests, because the NEXT case won't look like this one:
- Independence test: can A be used, correctly and completely, without B ever existing?
If yes, A is not B's child — don't name it, file it, or document it as one.
- Ownership test: when A and B co-star, who owns the seam? The answer is "a PATTERN in
this skill" (a documented composition, like the ActionCard → focused-dialog skeletons) —
never a component absorbed into the other's namespace, props, or docs section.
- Coupling smells to reject on sight: a component whose props exist only to serve one
sibling (
forDialog, insideCard); a component importing a sibling it doesn't render;
docs/exports that nest one standalone contract under another's heading; a name that bakes
in the co-star (ActionCardDialog) when both halves are independently reusable.
Patterns live in this skill as compositions of named parts; components live in the library
as parts that don't know their co-stars. Keep those two layers straight and the next
accidental marriage never happens.
Then pick the right component instead of bending the wrong one. See reference.md §
Component picker for the full decision table and the icon/badge/tooltip rules. The
recurring failures to avoid:
- Menus (dropdown / context / overflow / kebab):
DropdownMenu or ContextMenu as the
shell; each action is DropdownMenuItem / ContextMenuItem (or checkbox/radio variants when
needed); group labels use *MenuLabel; splits use *MenuSeparator — never a raw <button>,
clickable <div>, or <hr> / border-b / h-px bg-border standing in for menu chrome.
The trigger is <Button> / TextButton / DropdownMenuTrigger as-child, not a bespoke
clickable span. Submenus use *MenuSub + *MenuSubTrigger + *MenuSubContent. All menu
surfaces share lib/menu.ts (menuItemClass, menuSeparatorClass) — hand-building rows
bypasses that contract and is the fastest path to "this menu looks different from every other
menu."
- Choosers:
Select (pick one value from a menu) · Combobox (searchable, single
or multiple) · SegmentedControl (a mode/filter, no panels) · Tabs (switch panels).
Do not hand-roll a searchable dropdown when Combobox exists; do not inject custom
classes into a Select trigger that fight the field-edge contract.
- Icon buttons:
<Button variant="ghost" size="icon"> in a toolbar, variant="outline"
standalone. Icons are lucide components (<Plus/>), never a typed glyph ("+"),
and never free-sized — let the size-4 control ladder apply. Never scale-90 a control
to "fix" its size.
- Icon position in a text button is semantics, not decoration. On a compound-action
button, the icon's placement declares which verb dominates: a leading glyph names the
action's identity ("this is a copy button"), a trailing
ExternalLink/chevron names the
outcome/destination ("pressing this leaves to a page / drills in"). Worked case: the
device-code "Copy & Open" button with a leading Copy glyph read as copy-only and users
never guessed it opened the browser; moving to a trailing ExternalLink fixed the
expectation without a word of extra copy. Pick the position by asking "what should the
user expect to happen?" — never by symmetry or habit. And spacing between a Button's
direct children belongs to the Button (gap-2, gap-1.5 on sm, plus has-[>svg]
padding compensation): hand-adding ml-*/mr-* on the icon stacks onto that gap and
visibly unbalances the button (the recurring "icon drifted right" bug).
- Default to no icon — an icon is a cost, not a freebie. An icon must earn its place by
carrying meaning — a brand/provider mark, a status, or a clear action glyph on a button. It is
never free: a boxed icon drags in a surface (and its shadow), one more color, and a "does this
glyph even fit our language?" judgment call. So a generic lucide glyph dropped beside a title,
floated atop a "No X" empty block, or stacked inside a card is decoration, not signal — it
reads as cheap chrome and cheapens the page. Ship none by default; when a spot genuinely seems
to want one, clear it with the developer before adding it rather than sprinkling icons on
your own judgment.
- Never reuse a bordered/filled primitive as a leading icon inside a card that already has its
own border.
ItemMedia variant="icon" and any similar tile primitive bake in their own
border — that is correct where they're designed to stand alone, but dropping one inside a card
that already carries border-border stacks TWO strokes on one visual unit, visible at once. This
is the "chrome layers stacked on a control" violation (§ The one rule — clean vs dirty) hiding
behind an otherwise-correct instinct ("reuse, don't hand-roll") — it slips past review because the
primitive is real and contract-listed, not hand-rolled CSS. When you need an icon at a specific
footprint (e.g. matching an adjacent control's size so two rows carry comparable weight), borrow
only the size (flex items-center justify-center at whatever size fits) — never the primitive's
border/bg. See reference.md § Dirty → clean for the full case (ActionCard's icon slot).
BadgeCount: destructive red dot pinned to an icon corner = alert/unread; default
neutral count rides a tab/filter/segment; a flat list row uses a plain muted numeral, no bubble.
Tooltip: always the @felinic/ui Tooltip. A hand-rolled or legacy tooltip is a bug.
- An empty state keeps the populated skeleton — it is the same page with no rows yet. The
worst empty-state failure is letting "there's no data" rearrange the page into a different
shape. Keep the exact frame the populated state uses (the same
SettingsSection card, the same
grid container) and drop the message inside it, so entering an empty page vs a full one never
jolts the layout. The model is the Plugins tab: its empty state is the very white card it
shows when populated — just py-12 centered title + description + the one guiding action (an
outline "+ Add" / "Supermarket" button). Two hard rules ride on top:
border-dashed is NOT an empty-state look. Dashed is reserved for the "+ Add another"
tile that sits beside real items in an already-populated list/grid, where adding one more
is the secondary affordance. A completely-empty surface takes the solid frame its
populated form has — the section card, or a solid-border framed block for a standalone grid
— never a dashed box, and never bare floating muted text. (This refines the older "outermost
Empty earns a dashed border" guidance: it does not — outermost empties are solid-framed.)
- No decorative icon. An
EmptyMedia variant="icon" glyph tile, or any big lucide glyph
stacked above the title, is banned: it is both card-in-card and the icon-abuse below. Just
title + description + action. (An action button keeps its own small action glyph — that is
not a decorative tile.) This page-type attracts icon abuse — a giant glyph crammed in front
of a list item or empty block — so default to none everywhere except a button's own glyph.
- Destructive actions: a filled
<Button variant="destructive">, gated behind a
confirmation (ConfirmPopover, or a dialog for heavier deletes) — never a bare one-click
delete, never a ghost button with manual red text. Group truly dangerous actions in a danger
card kept at the bottom of the page. Confirm covers interruption, not just deletion: any
action that ends running work or severs a live connection — Stop a runtime, Terminate a
session, Disconnect — earns the same confirm step, because "it stops what it was doing" is a
consequence the user must opt into. Skip the confirm only for cheap, reversible actions.
- Long lists / big dropdowns virtualize. A list or chooser that can hold hundreds of rows
(sessions, models, searchable selects) must virtualize, not render every node — otherwise the
refactor that "looks fine" with 5 rows jank-scrolls with 500. Reuse the existing virtualized
patterns instead of a plain
v-for over an unbounded list.
Compose, don't style — the extension boundary
First, the root principle everything below derives from. A component library and its
design tokens exist for exactly one reason: callers write no magic values, so the
system's maintainer can change ONE number in ONE place and every caller benefits. Every
magic string a caller must hand-copy — a grid-rows-[…] recipe, a raw hex, an arbitrary
h-[37px], a prop pairing that only works if you remember it — is a defect in the SYSTEM,
not a chore for the caller. It means N call sites now pin that value, the maintainer's
one-place edit no longer reaches them, and each copy is one more chance to mis-copy a
fragment and resurrect a solved bug. This cuts both ways:
- When USING a component: if correct usage requires you to hand-write a layout/style
string or memorize an unenforced pairing, do not dutifully copy it — the component is
incomplete. (Live example:
DialogPanel exists because the focused-dialog shell was a
copy-me class string, max-h-[80dvh] grid-rows-[auto_minmax(0,1fr)]… plus a
remember-to-disable-the-corner-close rule; every consumer had to transcribe it
perfectly. The fix was never "copy it more carefully" — it was making the recipe BE the
component, with the pairing enforced by a prop.)
- When DESIGNING a component: the acceptance test is "a caller who has never read the
implementation fills in content — title, icon, fields — and hand-writes zero
layout/appearance CSS." Knobs are enumerated props (
width="2xl" | "3xl"), never
free-text class strings: an enum forces the next rung to be added in the library,
deliberately, instead of invented per page.
- When you FIND a violation you cannot fix in this task: say so to the human you are
working with, explicitly — "this component still requires callers to hand-write X, which
breaks the one-place-to-change guarantee." That escalation is not noise; it is the most
fundamental defect class in this codebase, and the human decides whether to stop and fix
the system or knowingly take the debt. Never silently absorb it into your page.
This is the page-layer half of packages/ui/AGENTS.md § Compose, don't style (read it for
the ownership table + the four override planes). Component discipline above says which
component to use; this says how you are allowed to add to one — because the moment you
can't, the only exit left is injecting CSS, and injected CSS is the single largest source of
page debt: it fights the component's ::before fill / field-edge, breaks dark mode, and
nothing lints it on an app page.
"I want to add something" has exactly five exits — four need no CSS, the fifth is an upgrade:
| I want to… |
The sanctioned exit |
| add content (icon / badge / suffix) |
a slot |
| change size / density |
the size prop |
| change meaning (emphasis / danger / selected) |
the variant prop |
| change outer layout (width / alignment / outer margin) |
a layout-only className (see the red line) |
| want a look the component doesn't offer |
upgrade the component (add a variant/slot, or extract a pure-style component) — never inject in place |
The className red line — the outer box is yours, the body is the component's:
- Allowed on a component (it only positions the outer box):
w-full, flex-1, grid,
gap-*, outer margin (mt-* / mx-auto), max-w-*.
- Forbidden on a component (it reaches into the body and fights
style.css): bg-*,
hover:*, active:*, border-*, shadow-* / shadow-none!, ring-*, h-[Npx]. If you
just typed one of these onto a <Button> / <Select> / <TextButton>, stop — pick the
right variant/size, or upgrade the component. (Canonical offender: the 6×-pasted "add
provider" button carrying bg-background border-border hover:bg-accent shadow-none! on a
real <Button>.)
The agent workflow — find, reuse, compose, upgrade; never style:
- Find before you write — and do not trust grep. Ten near-identical controls can wear a
hundred names, and they all share similar CSS, so "I didn't match it" does not mean it
doesn't exist — the odds it already exists under another name are high. Check the component
map /
reference.md § Component picker before assuming nothing fits. Re-deriving an existing
component is the #1 debt source.
- Priority is an order, not a suggestion: reuse > compose > upgrade > (never) hand-write style.
- Copy only a gold-standard reference, never a dirty page. few-shot copies what it sees;
some good-looking pages are already off-contract, so their markup is poison — confirm a page
is clean before mirroring it.
- Red lights — STOP and ask, do not improvise: you need a new component, a new token,
to edit
style.css, or an a11y / RTL trade-off. Improvising past any of these means
hand-writing past the boundary — exactly the move this whole contract exists to prevent.
The debt taxonomy — name it before you decide to fix it
"Is this debt?" stops being a vibe once the failure has a name. Three axes turn the adjectives
maintainable / reliable / clean into a checklist; when unsure whether something is worth
flagging, match it here. This is a diagnostic lens, not new r
…(truncated)
1---2name: web3description: Primary Web development skill for apps/web — white-floating-card design language, disciplined @felinic/ui usage, deliberate copy, honest empty states, aligned controls, and restrained motion. Never hand-write controls or menus; never leave stray fragments (orphan status labels, misaligned save hints). Compose from @felinic/ui primitives and reuse existing save/feedback patterns from reference pages. Use for any apps/web UI work — new pages, settings/list/detail surfaces, chat components, polish passes — not only legacy page migrations. Read this skill before writing or changing Web frontend code.4---56# Web — Page Development & Design Language78## Non-negotiables — read this even if you skim the rest910The ten rules that break a page if you miss them. The rest of this file is the *why* and11the *how*; these are the *must*.12131. **Copy the new, never the legacy — and copy the *contract*, not a page's raw classes.** Open14 a refactored reference page (§ Reference map in `reference.md`) and mirror its *structure*.15 Never pattern-match off a dirty / un-refactored page — and never inherit a reference page's16 stray `font-[NNN]` / `text-[Npx]` / `text-foreground/NN`: some references still carry17 pre-contract token debt (`about` is one — it ships off-scale size, arbitrary weight, and18 hand-mixed alpha in one line), so the token law in `packages/ui/AGENTS.md` outranks any19 single page's classes when they disagree.202. **A refactor must not regress.** *Before* touching anything, inventory every behavior,21 feature, state, and path the old page has — the new page keeps all of them unless you22 deliberately drop one and say so.233. **Never hand-write a color — tokens only.** `bg-card` / `text-foreground` / `border-border`,24 etc. Dark mode is purely the result of this, and **nothing lints raw colors in app pages**,25 so one `bg-white` / `#hex` / `text-gray-*` ships visibly broken in dark. For any hover /26 selected / pressed / subtle tint, use the neutral **overlay ladder** (`--ui-hover` /27 `--ui-selected` / `bg-accent`) — never a gray or a `/10` alpha.284. **Build from the shared shell + primitives.** Centered `max-w-3xl` with gutters;29 `SettingsSection` / `SettingsRow` white cards — one hairline, role-map radius, inset30 dividers, deliberate spacing rhythm, and **no hover-rise** on cards. The full **owner31 vocabulary** — which recurring shapes (row, field, section, tile, banner, loading/empty32 state, delete confirm, page frame…) have an owner component, the decision map, and when a33 shape deliberately stays hand-written — lives in `../ui-owners/SKILL.md`;34 read it before building any of those shapes.355. **Reuse a component — never hand-write one.** Compose from the real `@felinic/ui` atoms36 (Select / Combobox / Tooltip / icon `Button` / `Empty`) and the existing shared parts; never37 re-skin one, hand-roll an equivalent, or rebuild a control out of raw `<div>`s. **Menus38 included:** dropdown / context / action menus use `DropdownMenu` / `ContextMenu` and their39 `*Item` / `*Separator` / `*Label` slots — never hand-written `<button>` rows, never `<hr>` /40 `border-b` dividers inside a popover. The same rule applies to every other surface: if41 `@felinic/ui` (or an existing app component) already covers it, use it — hand-built markup42 drifts out of the token contract and reads inconsistent page to page. If a layout repeats,43 extract it into one shared component instead of pasting it twice. A genuinely new component44 is a last resort — clear it with the developer *before* building it.45 **The component system is not modelling clay.** `SettingsSection` / `SettingsRow` / the cards46 are not a blank canvas you reshape to taste — each has *one* sanctioned use, and you compose47 *with* them, you do not knead them into a new shape. The tell that you've started treating the48 system as clay: you're putting a hand-rolled `grid` / multi-column layout / freeform `<div>`49 stack **inside** a `SettingsSection` to "arrange things nicer" (a 2-col stat grid jammed into50 the white card is the canonical offence — see `reference.md` dirty→clean). When the shape you51 want isn't one the primitives already give you, that is **not** license to deform a card from52 the inside — it means you've picked the wrong primitive. Stop, find the component whose *one*53 job is that shape (a value-in-a-card is a `SettingsRow`; a label-on-top/number-below readout54 is a **section-level stat tile**, never a grid inside a card), or clear a real new component55 with the developer. Squeezing the clay is always the wrong move, even when it renders fine.566. **Earn every word and every block.** Cut copy that doesn't guide; hide blocks that aren't57 actionable; empty *and* loading states must still draw their frame (no layout jump).58 **No stray fragments:** every visible piece must sit in a named region (PageShell59 `#actions`, a row's control column, a dialog footer, a toast) — never a lone status line60 floating where it aligns with nothing. If it can be removed, remove it; if it must stay,61 anchor it and reuse an existing pattern from a reference page.627. **You are not done until you verify the *rendered* page:** grep for raw colors → flip to63 **dark** → shrink to **narrow + `zh`** → walk **every old interaction** → `mise run lint`.648. **Draw it before you build it, then audit the nesting.** Sketch the page as an ASCII65 wireframe first — and again when it looks done — and read it like space-complexity: no66 card-in-card, no decorative icon stacked in a card, no nesting layer that isn't earning its67 keep. Fewer boxes, shallower depth.689. **The root surface answers "is it working," not "configure everything" (the 99/1 rule).**69 The first page a user lands on serves the 99% who came to *glance at state* — it must not70 make them carry the visual weight of inputs, button piles, or history lists that only the 1%71 want. But the 1% are not browsing; they **arrive with a purpose** and will hunt for the72 button — so deep/rare operations (limits, snapshots/history, destructive actions) live behind73 a **named entry point** (a one-line summary row + a button) that opens a focused form showing74 the data *and* the action. Never spill them onto the root, and never bury them in an in-card75 "Advanced" disclosure that mixes diagnostics with real operations (§ 12).7610. **A change is not committed until a human verifies it.** Run lint / grep / type checks77 yourself, but the **rendered** result (visual + interaction) must be checked by a human78 before any `commit` / `amend` / `push` — do not amend or squash unverified work into a79 commit on your own say-so. "It should work" from the agent is not verification; the80 human's eyes on the rendered page is. Follow-up tweaks to an already-committed change81 re-verify before re-amending.8283---8485This skill is the **page-level** companion to the **atom-level** contract in86`packages/ui/AGENTS.md`. That file governs how a single control looks; this file87governs how you compose controls into a page that reads like the already-refactored88surfaces (Overview, Appearance, Profile, About, Web Search) and never like the89legacy ones.9091It exists because the refactor kept slowing down: each page re-derived the same92decisions from scratch and re-made the same mistakes. The point of this skill is to93make that experience reusable — so refactoring "the next page" is a procedure, not94a re-invention.9596## Prime directive9798> **Copy the new language. Never copy the legacy.** When unsure how something should99> look or behave, open a refactored reference page and mirror it — do not pattern-match100> off an un-refactored page (even one you are mid-refactor on).101102Two non-negotiable first steps before you touch a page:1031041. **Read `packages/ui/AGENTS.md` in full.** It is the law for tokens, radius, borders,105 color, motion, and the "clean vs dirty" rule. This skill assumes it.1062. **Open one refactored reference + the page you're replacing side by side.** See107 `reference.md` § Reference map for which page to copy for each page shape, and the108 dirty→clean table for diagnosing what to strip.109110## A refactor is behavior-preserving — interrogate what it breaks111112The Prime directive covers the *look*; this covers the *behavior*. Changing how a page looks113must not silently change what it does. The most common refactor failure is not an ugly page —114it's a page that quietly **lost** an affordance that was buried in the old messy layout. Before115and during a refactor, stop and ask what the refactor could break:1161171. **What is the user's path here?** (This is the § 1 copy question, upstream of pixels.) Why118 does the user come to this page, what are they trying to do, how do they get in and out?119 The visual exists to serve that path — so derive the path first, then build to it.1202. **Does each remaining control's *interaction logic* need to change — and if you change it,121 is it still complete?** A control is not just its look. It carries behavior: a select that122 filters, an input that debounces, a toggle that triggers auto-save, a context menu, keyboard123 handling, a drag, a hover-to-reveal action, an empty/loading/error branch. When you swap a124 legacy control for a refactored one, **re-wire every behavior it had** — don't just port the125 markup.1263. **Did the refactor drop functionality?** Inventory everything the old page could *do* — every127 button, menu item, edge action, state, shortcut — and confirm the new page can still do all128 of it, or that you **deliberately** removed it and said why. Never lose a capability by129 accident.1304. **Is there a better path?** A refactor is the moment to question whether the old flow was even131 right: a step that can be removed, a dialog that can be inlined, two redundant controls that132 can merge, a shorter route in/out. Improve the path, don't just repaint it.1335. **A new page is all of the above, from zero.** With no old page to inventory, you must derive134 the path, the required behaviors, and the complete feature set from the requirement itself.135 The risk inverts: not "losing" an old behavior, but **never specifying** one you needed —136 so think the full interaction surface (states, edges, empties, exits) up front.137138## Engineering correctness — the dirt the eye can't see139140A page can pass every visual rule and still be **wrong**. The most expensive debt isn't an141ugly card — it's behavior that breaks because two modules quietly disagree about a contract.142This is invisible in a screenshot and survives review, so it gets its own pass. Treat it as143part of "clean," not a separate concern.144145- **A cross-module assumption must be enforced or eliminated — never just commented.** The146 back-affordance bug is the cautionary tale: `useSyncedQueryParam` switched tabs with147 `router.replace` under a comment claiming "replace won't bury the previous page," while148 `installBackHistory`'s `afterEach` never distinguished replace from push — so replace *did*149 overwrite `previous`, and the back button started reading the bot's raw `bot-<uuid>` slug.150 Both comments looked reasonable; together they were wrong. If module A leans on module B151 behaving a certain way, lock it with a type or a test, or remove the assumption. A comment152 asserting the contract is not enforcement of it.153- **Layout size must never be driven by content.** A `w-fit` sidebar154 (`master-detail-sidebar-layout`) let one too-long back label stretch the whole panel — so a155 bad string became a visibly wider sidebar. Pin widths and let text `truncate` inside a fixed156 box; a locale change, longer data, or an upstream bug must never move the frame.157- **In-page state syncs with `replace`; whatever reads "the previous page" must honor that.**158 Tab/filter swaps are not navigations — they `router.replace`. A history reader that counts159 replace transitions will treat a tab switch as a place to step "back" to.160- **One root cause often wears two faces.** The slug label and the widened sidebar were the161 same bug. When two oddities appear together on the same action, hunt one upstream cause162 before patching each symptom in place.163164## The design language in one breath165166The refactor is **not** new chrome. It is a switch to a calmer language whose body is167defined by a **single hairline stroke + an inherited white surface**, and whose168interaction is read through **color/fill change in place** — never by lifting, scaling,169shadowing, or bordering something "to make it nicer."170171What concretely changed, before → after:172173- **Floating white cards.** Content lives in `bg-card` cards with **one** `border-border`174 hairline and the shell radius. The section title sits *above* the card as quiet muted175 text. Use the shared `SettingsSection` / `SettingsRow` primitives — do not hand-roll a card.176- **Unified stroke.** One hairline, `border-border`. Never `border-border/50`,177 `border-*/40`, or a structural border on a control body.178- **Unified radius.** Only the role-map scale (card 14 / menu-shell 12 / control 8 /179 badge·tooltip 6). Never a bare `rounded` or an off-scale `rounded-lg` on a control.180- **Unified color.** Black/white/gray is ~90% of the UI (the skeleton). Charcoal is the181 high-emphasis CTA; blue means "selected"; purple is scarce. `success`/`warning`/182 `destructive` are **rationed signals**, not surface decoration — never tint a whole183 card `bg-success/5`.184- **Unified components.** Use the refactored `@felinic/ui` atoms as-is. Do not re-skin185 them or inject classes that fight their contract (the canonical "weird Select" bug).186- **No hover-rise, ever.** Cards and rows do **not** lift / scale-up / grow a shadow on187 hover or press. Press-scale belongs only to buttons and sidebar rail items — never to a188 large content card (a bot card does not shrink when you press it).189190### The shell & spacing rhythm191192This is the part that most often gets skipped and is the fastest tell of an un-refactored193page. The refactored pages (Appearance / Profile / About) are **not full-bleed** — they all194sit inside the same shell, and nothing ever touches an edge or another element.195196- **The shell.** Content is a centered column inside the right pane, not stretched edge to197 edge: `mx-auto max-w-3xl` caps the width (~768px) and centers it, `px-6` keeps a left/right198 gutter so nothing glues to the pane edge, `pt-10` pushes the title down off the top, `pb-12`199 leaves room at the bottom. A page that runs full-width or starts flush against the top is200 immediately off-language. (About is the one exception: being sparse, it centers its group201 vertically with a slight upward bias instead of top-aligning.)202- **Spacing is a hierarchy of gaps, not free-styled margins.** Each level of structure has203 its own consistent breathing room, and you reuse the same rung instead of inventing values:204 - title → content: `mb-6` (Profile uses `mb-8`)205 - card group → card group: `space-y-8` — the big, generous gap that separates sections206 - section label → its card: `space-y-2.5`207 - row → row inside a card: a `border-b` hairline divider + `py-3`, each row `min-h-[3.75rem]`208 - label → its description: `mt-0.5`209 - inside a padded card block: `p-4`/`p-5` with `space-y-4`210- **Text is never glued — to edges, to the top, or to each other.** Every label has air above211 and below it; the title has air under it; cards have air between them. When something feels212 cramped, the fix is almost always "use the next rung of the spacing hierarchy," not a213 one-off margin.214215Concrete shell + primitives (exact recipes + the full spacing ladder live in `reference.md`):216217- Page shell: `mx-auto max-w-3xl px-6 pt-10 pb-12`, title `mb-6 px-2 text-lg font-semibold`,218 sections stacked with `space-y-8`.219- Card: `SettingsSection` = `overflow-hidden rounded-[var(--radius-menu-shell)] border border-border bg-card`,220 optional title above as `px-2 text-[13px] font-medium text-muted-foreground`.221- Row: `SettingsRow` = label (`text-sm font-medium`) + description (`text-xs text-muted-foreground`)222 on the left, the control on the right, rows split by `border-b border-border last:border-b-0`.223224### Dividers — inset inside a card, full-bleed everywhere else225226A divider has two different jobs and two different widths; using the wrong one is a tell.227228- **Separating rows *inside* one white card → inset.** The hairline must **not** reach the229 card's left/right edges. This is done by putting the border on a horizontally-margined row230 (the `mx-4` on `SettingsRow`), never on the card itself, and dropping it on the last row231 (`last:border-b-0`). An edge-to-edge line would visually slice the rounded card into stacked232 tiles and break the "this is one continuous surface" reading. **Corollary:** borders go on233 *rows*, never on the invisible wrapper `<div>` you put a `v-if` block in — a wrapper with234 `border-b` that ends up the **last child of the card** doubles its hairline onto the card's own235 bottom stroke (the recurring "fights the stroke" bug). See reference.md § Dividers.236- **Structurally splitting a container → full-bleed.** A Dialog header/footer band, a237 section-heading underline, or a standalone `Separator` between blocks divides the *whole*238 container, so the line spans edge to edge while the content keeps its own inner padding.239240The test: is this line separating **items within one surface** (inset) or **splitting the241container itself** (full-bleed)? Answer that before you place a divider.242243**A "divider I never drew" is usually a misplaced `#footer`.** If a hairline appears to float244under a single row over an empty strip, you almost certainly put a `SettingsSection #footer`245(Save band) on a *root-page* card — its full-bleed `border-t` plus the lone row's own inset246`border-b` read as a stray line. The line is real chrome in the wrong home: a root page's Save247belongs in `PageShell #actions`, not a card footer (§ 8). Fix the home, not the line.248249### Dark mode is not a task — it is the absence of hardcoded color250251**Read this twice. This is the single most-skipped requirement, and nothing will catch it for252you.** You do **not** "add dark mode" at the end. Dark mode is the *automatic* result of using253only semantic tokens; it breaks the moment you hardcode one raw color. So there is exactly one254rule, applied from the first line: **never write a raw color — use a semantic token.**255256- Raw colors that silently break dark mode: `bg-white`, `bg-black`, `text-white`, `text-black`,257 any `*-gray-*` / `*-zinc-*` / `*-slate-*` / `*-neutral-*`, any `#hex`, any `bg-[#…]` /258 `text-[#…]`, any inline `style="color: …"` / `background: …`. Use `bg-card`, `bg-background`,259 `text-foreground`, `text-muted-foreground`, `border-border`, `bg-accent`, etc. instead.260- **For tints and subtle layering, prefer the neutral overlay ladder — it is the dark-safe way261 to add "color."** When you need a hover / selected / pressed shade, or a faint layer to set262 something apart, reach for the interaction-overlay tokens (`--ui-hover` / `--ui-selected` /263 `--ui-pressed`, the `--overlay-*` rungs, or `bg-accent` which maps into them) — **never** a264 solid fill, a hand-mixed gray, or an alpha hack (`bg-black/5`, `hover:bg-gray-100`). The265 overlays are chroma-0 and composite over whatever surface they sit on, so they are the **same266 token in light and dark** (light = a black wash, dark = a white wash) and identical across267 every color scheme — no `dark:` variant, no per-scheme override, and they cannot break the way268 a baked color does. (Full ladder in `packages/ui/AGENTS.md` § Color → Interaction overlay.)269- **A `dark:` override is a smell, not a fix.** Themed tokens auto-switch with **no** `dark:`270 prefix. If you're reaching for `dark:bg-…` to patch a page, it means you started from a raw271 light color — go back and replace the base color with a token; don't band-aid it per-mode.272- **There is no safety net for app pages.** The UI-contract guard (`mise run lint`) only scans273 `packages/ui` — `apps/web` pages are explicitly out of scope, and there is no ESLint rule for274 hardcoded colors. So a raw color in a page is caught by *nothing*; lint passes, and the page275 ships broken in dark. The discipline below is the only defense — treat it as mandatory.276- **Before you finish, do two things, every time:** (1) grep the page for raw colors277 (`bg-white`, `text-black`, `text-gray-`, `bg-gray-`, `#`, `dark:`, inline `style=`); (2)278 actually **flip the app to dark and look at the rendered page**. The only sanctioned `bg-white`279 is a physical knob (Switch / Slider thumb) over a colored track. Canvas content (charts) can't280 read tokens — reuse the token→concrete-color resolve the reference pages already do, re-run on281 theme change.282283### Narrow screens reflow, never overflow284285A settings page is a centered `max-w-3xl` column, but the pane is resizable and the desktop286window can be narrow. Multi-column grids collapse with responsive prefixes (`grid-cols-1287sm:grid-cols-2`, stat rows `grid-cols-2 sm:grid-cols-4`); same-row control clusters (search +288button) must not break or clip. Always check the narrowest realistic width, not just the wide289default — and remember Chinese copy is wider, so the narrow + `zh` combination is the real worst290case (see § 1).291292**When a component must adapt to a resizable pane, viewport breakpoints are the wrong tool.**293`sm:` / `md:` watch the *window* — but a dockview / master-detail pane changes width while the294window doesn't, so a `sm:` grid won't react when the same component sits in a narrow vs wide295pane. Reach for a **container query** (`@container`) so the component responds to *its own296container's* width, not the viewport. (Page-level `max-w-3xl` columns still use viewport297prefixes; this is only for components that live inside variable-width panes.)298299Pane width is only one of three "bigger" axes; the page must also hold up under **browser zoom**300and a **larger root/OS font**. The defence is the same discipline: lay out with the spacing301ladder and flex/grid gaps (never a margin tuned to one string), size inline-with-text icons in302`em` so they grow with the text while standalone control icons keep the `size-*` rem ladder, cap303width with `max-w-*` + centre so a wide screen never stretches a line, and let any line that can304outgrow its box `truncate`. Full rules + the verify pass (zoom 50→200%, narrow + `zh`, ultra-wide)305live in `reference.md` § Scaling & zoom.306307### Scroll ownership308309Know who owns the scroll before you add `overflow-*` anywhere. The desktop shell **locks body310overflow**, so a page that needs to scroll must own its own scroll container (the dev wall does311this with `h-dvh overflow-y-auto`); a settings page instead scrolls inside the section's312existing scroll area. The failure modes are symmetric: a page that forgets to own its scroll is313un-scrollable inside the desktop shell, and a page that adds a stray `overflow-*` creates a314*nested* scroll container (a scrollbar inside a scrollbar) or a surprise horizontal scrollbar.315When a transform nudges content sideways (the list↔detail swap pushes panes ±24px), clip it316with `overflow-x-clip` — not `overflow-x-hidden`, which would turn the element into a vertical317scroll container and steal scrolling from the ancestor. Don't introduce a new scroll container318unless you mean to.319320**Every page-level scroll container that holds a centered `max-w-3xl` column must reserve the321scrollbar gutter — `[scrollbar-gutter:stable]`.** The shell centers content with `mx-auto`, so322its left/right margins are computed from the pane's *available* width. When a classic323(space-consuming) scrollbar appears, it eats that width and the whole centered column — title,324card edges, everything — shifts sideways. The tell is real and confusing: two sibling tabs look325"only similar," because a long tab scrolls (narrower pane) while a short one doesn't (wider326pane), so the title and card edges land in different spots as you switch between them. A page327that doesn't scroll *today* will the day its content grows — so this is not optional on the328scroller, it's structural. Reserving the gutter keeps the available width constant whether or329not the scrollbar is visible, so every page that shares (or mirrors) the scroller stays aligned.330There are only a handful of these page-level scrollers (the settings section's `router-view`331pane; any master-detail surface that runs its *own* inner scroll pane, e.g. the bot-detail tab332pane) — put the rule on the scroll container itself, never on each page, so all pages it hosts333inherit it for free. Bounded inner scrollers (a tool-call detail body, a dropdown list, a log334pane) are left-aligned and don't need it.335336## Component discipline337338**Reuse first; build new only with sign-off.** The default is always to *find and reuse* an339existing component, then to *compose* existing atoms — never to hand-write a control out of raw340markup. The most expensive page is the one where the agent quietly re-built from zero what341already existed. Three rules, in order:3423431. **Hand-writing a component is forbidden.** A clickable `<div>` that re-implements a Button, a344 bespoke popover list that re-implements a Select, a `<div>`-grid that re-implements a Table —345 all banned. They can't receive the size / token / focus / a11y contract, and they drift. If346 `@felinic/ui` (or an existing app component) has it, use it as-is.3472. **A composition that can repeat must be extracted, not pasted.** Even when every piece is a348 properly reused atom, if the *arrangement* could appear in more than one place (a provider349 row, a card header, an empty tile, a field cluster), lift it into one shared component and350 reuse that. Copy-pasted markup is duplication waiting to drift out of sync — and a reused351 composition dropped into a spot where the same shape recurs is the signal to extract it.3523. **A genuinely new component needs the developer's OK first.** When nothing fits and no353 composition will do, stop and say so — name what's missing and why — get agreement, then354 build it once in the shared layer. Never silently spawn a one-off component mid-page.355356**A component is a component — patterns that co-star are not families.** Each component is a357standalone contract with its own identity; two components appearing together in a house358pattern does NOT make one belong to the other. Worked example: the focused-dialog family359(`DialogPanel` / `DialogViewHeader` / `DialogBody`) and `ActionCard` almost always ship360together — a named entry card opening a focused dialog — yet they are ORTHOGONAL: the dialog361anatomy belongs to `Dialog` and opens from any trigger (a toolbar button opens the bot-mcp362Import panel; a shortcut could too), and ActionCard is just one opener that emits a click and363doesn't know what opens. Filing the dialog primitives "under ActionCard" would have invented364a false dependency — a future agent would think "no ActionCard, so I can't use DialogPanel"365and hand-roll a dialog shell, or worse, bolt an ActionCard on just to unlock the dialog. The366general tests, because the NEXT case won't look like this one:367368- **Independence test:** can A be used, correctly and completely, without B ever existing?369 If yes, A is not B's child — don't name it, file it, or document it as one.370- **Ownership test:** when A and B co-star, who owns the seam? The answer is "a PATTERN in371 this skill" (a documented composition, like the ActionCard → focused-dialog skeletons) —372 never a component absorbed into the other's namespace, props, or docs section.373- **Coupling smells to reject on sight:** a component whose props exist only to serve one374 sibling (`forDialog`, `insideCard`); a component importing a sibling it doesn't render;375 docs/exports that nest one standalone contract under another's heading; a name that bakes376 in the co-star (`ActionCardDialog`) when both halves are independently reusable.377378Patterns live in this skill as *compositions of named parts*; components live in the library379as *parts that don't know their co-stars*. Keep those two layers straight and the next380accidental marriage never happens.381382Then pick the right component instead of bending the wrong one. See `reference.md` §383Component picker for the full decision table and the icon/badge/tooltip rules. The384recurring failures to avoid:385386- **Menus (dropdown / context / overflow / kebab):** `DropdownMenu` or `ContextMenu` as the387 shell; each action is `DropdownMenuItem` / `ContextMenuItem` (or checkbox/radio variants when388 needed); group labels use `*MenuLabel`; splits use `*MenuSeparator` — never a raw `<button>`,389 clickable `<div>`, or `<hr>` / `border-b` / `h-px bg-border` standing in for menu chrome.390 The trigger is `<Button>` / `TextButton` / `DropdownMenuTrigger as-child`, not a bespoke391 clickable span. Submenus use `*MenuSub` + `*MenuSubTrigger` + `*MenuSubContent`. All menu392 surfaces share `lib/menu.ts` (`menuItemClass`, `menuSeparatorClass`) — hand-building rows393 bypasses that contract and is the fastest path to "this menu looks different from every other394 menu."395- **Choosers:** `Select` (pick one value from a menu) · `Combobox` (searchable, single396 *or* `multiple`) · `SegmentedControl` (a mode/filter, no panels) · `Tabs` (switch panels).397 Do not hand-roll a searchable dropdown when `Combobox` exists; do not inject custom398 classes into a `Select` trigger that fight the field-edge contract.399- **Icon buttons:** `<Button variant="ghost" size="icon">` in a toolbar, `variant="outline"`400 standalone. Icons are **lucide components** (`<Plus/>`), never a typed glyph (`"+"`),401 and never free-sized — let the `size-4` control ladder apply. Never `scale-90` a control402 to "fix" its size.403- **Icon position in a text button is semantics, not decoration.** On a compound-action404 button, the icon's placement declares which verb dominates: a *leading* glyph names the405 action's identity ("this is a copy button"), a *trailing* `ExternalLink`/chevron names the406 *outcome/destination* ("pressing this leaves to a page / drills in"). Worked case: the407 device-code "Copy & Open" button with a leading `Copy` glyph read as copy-only and users408 never guessed it opened the browser; moving to a trailing `ExternalLink` fixed the409 expectation without a word of extra copy. Pick the position by asking "what should the410 user expect to *happen*?" — never by symmetry or habit. And **spacing between a Button's411 direct children belongs to the Button** (`gap-2`, `gap-1.5` on `sm`, plus `has-[>svg]`412 padding compensation): hand-adding `ml-*`/`mr-*` on the icon stacks onto that gap and413 visibly unbalances the button (the recurring "icon drifted right" bug).414- **Default to no icon — an icon is a cost, not a freebie.** An icon must earn its place by415 carrying meaning — a brand/provider mark, a status, or a clear action glyph on a button. It is416 never free: a boxed icon drags in a surface (and its shadow), one more color, and a "does this417 glyph even fit our language?" judgment call. So a generic lucide glyph dropped beside a title,418 floated atop a "No X" empty block, or **stacked inside a card** is decoration, not signal — it419 reads as cheap chrome and cheapens the page. Ship none by default; when a spot genuinely seems420 to want one, **clear it with the developer before adding it** rather than sprinkling icons on421 your own judgment.422- **Never reuse a bordered/filled primitive as a leading icon inside a card that already has its423 own border.** `ItemMedia variant="icon"` and any similar tile primitive bake in their own424 `border` — that is correct where they're designed to stand alone, but dropping one inside a card425 that already carries `border-border` stacks TWO strokes on one visual unit, visible at once. This426 is the "chrome layers stacked on a control" violation (§ The one rule — clean vs dirty) hiding427 behind an otherwise-correct instinct ("reuse, don't hand-roll") — it slips past review because the428 primitive is real and contract-listed, not hand-rolled CSS. When you need an icon at a specific429 *footprint* (e.g. matching an adjacent control's size so two rows carry comparable weight), borrow430 only the size (`flex items-center justify-center` at whatever size fits) — never the primitive's431 border/bg. See `reference.md` § Dirty → clean for the full case (`ActionCard`'s icon slot).432- **`BadgeCount`:** `destructive` red dot pinned to an icon corner = alert/unread; `default`433 neutral count rides a tab/filter/segment; a flat list row uses a plain muted numeral, no bubble.434- **`Tooltip`:** always the `@felinic/ui` `Tooltip`. A hand-rolled or legacy tooltip is a bug.435- **An empty state keeps the populated skeleton — it is the same page with no rows yet.** The436 worst empty-state failure is letting "there's no data" rearrange the page into a *different*437 shape. Keep the exact frame the populated state uses (the same `SettingsSection` card, the same438 grid container) and drop the message *inside* it, so entering an empty page vs a full one never439 jolts the layout. The model is the **Plugins tab**: its empty state is the very white card it440 shows when populated — just `py-12` centered title + description + the one guiding action (an441 outline "+ Add" / "Supermarket" button). Two hard rules ride on top:442 - **`border-dashed` is NOT an empty-state look.** Dashed is reserved for the **"+ Add another"443 tile** that sits *beside real items in an already-populated list/grid*, where adding one more444 is the secondary affordance. A completely-empty surface takes the **solid** frame its445 populated form has — the section card, or a solid-`border` framed block for a standalone grid446 — never a dashed box, and never bare floating muted text. (This refines the older "outermost447 Empty earns a dashed border" guidance: it does not — outermost empties are solid-framed.)448 - **No decorative icon.** An `EmptyMedia variant="icon"` glyph tile, or any big lucide glyph449 stacked above the title, is banned: it is both card-in-card and the icon-abuse below. Just450 title + description + action. (An action *button* keeps its own small action glyph — that is451 not a decorative tile.) This page-type attracts icon abuse — a giant glyph crammed in front452 of a list item or empty block — so default to **none** everywhere except a button's own glyph.453- **Destructive actions:** a filled `<Button variant="destructive">`, gated behind a454 confirmation (`ConfirmPopover`, or a dialog for heavier deletes) — never a bare one-click455 delete, never a ghost button with manual red text. Group truly dangerous actions in a danger456 card kept at the bottom of the page. **Confirm covers interruption, not just deletion:** any457 action that ends running work or severs a live connection — Stop a runtime, Terminate a458 session, Disconnect — earns the same confirm step, because "it stops what it was doing" is a459 consequence the user must opt into. Skip the confirm only for cheap, reversible actions.460- **Long lists / big dropdowns virtualize.** A list or chooser that can hold hundreds of rows461 (sessions, models, searchable selects) must virtualize, not render every node — otherwise the462 refactor that "looks fine" with 5 rows jank-scrolls with 500. Reuse the existing virtualized463 patterns instead of a plain `v-for` over an unbounded list.464465## Compose, don't style — the extension boundary466467**First, the root principle everything below derives from.** A component library and its468design tokens exist for exactly one reason: **callers write no magic values, so the469system's maintainer can change ONE number in ONE place and every caller benefits.** Every470magic string a caller must hand-copy — a `grid-rows-[…]` recipe, a raw hex, an arbitrary471`h-[37px]`, a prop pairing that only works if you remember it — is a defect in the SYSTEM,472not a chore for the caller. It means N call sites now pin that value, the maintainer's473one-place edit no longer reaches them, and each copy is one more chance to mis-copy a474fragment and resurrect a solved bug. This cuts both ways:475476- **When USING a component:** if correct usage requires you to hand-write a layout/style477 string or memorize an unenforced pairing, do not dutifully copy it — the component is478 incomplete. (Live example: `DialogPanel` exists because the focused-dialog shell was a479 copy-me class string, `max-h-[80dvh] grid-rows-[auto_minmax(0,1fr)]…` plus a480 remember-to-disable-the-corner-close rule; every consumer had to transcribe it481 perfectly. The fix was never "copy it more carefully" — it was making the recipe BE the482 component, with the pairing enforced by a prop.)483- **When DESIGNING a component:** the acceptance test is "a caller who has never read the484 implementation fills in content — title, icon, fields — and hand-writes zero485 layout/appearance CSS." Knobs are **enumerated props** (`width="2xl" | "3xl"`), never486 free-text class strings: an enum forces the next rung to be added in the library,487 deliberately, instead of invented per page.488- **When you FIND a violation you cannot fix in this task:** say so to the human you are489 working with, explicitly — "this component still requires callers to hand-write X, which490 breaks the one-place-to-change guarantee." That escalation is not noise; it is the most491 fundamental defect class in this codebase, and the human decides whether to stop and fix492 the system or knowingly take the debt. Never silently absorb it into your page.493494This is the page-layer half of `packages/ui/AGENTS.md` § *Compose, don't style* (read it for495the ownership table + the four override planes). Component discipline above says *which*496component to use; this says how you are allowed to **add to** one — because the moment you497can't, the only exit left is injecting CSS, and injected CSS is the single largest source of498page debt: it fights the component's `::before` fill / field-edge, breaks dark mode, and499nothing lints it on an app page.500501**"I want to add something" has exactly five exits — four need no CSS, the fifth is an upgrade:**502503| I want to… | The sanctioned exit |504|---|---|505| add content (icon / badge / suffix) | a **slot** |506| change size / density | the **`size` prop** |507| change meaning (emphasis / danger / selected) | the **`variant` prop** |508| change *outer* layout (width / alignment / outer margin) | a **layout-only className** (see the red line) |509| want a look the component doesn't offer | **upgrade the component** (add a `variant`/slot, or extract a pure-style component) — never inject in place |510511**The className red line — the outer box is yours, the body is the component's:**512513- **Allowed on a component** (it only positions the outer box): `w-full`, `flex-1`, `grid`,514 `gap-*`, outer margin (`mt-*` / `mx-auto`), `max-w-*`.515- **Forbidden on a component** (it reaches into the body and fights `style.css`): `bg-*`,516 `hover:*`, `active:*`, `border-*`, `shadow-*` / `shadow-none!`, `ring-*`, `h-[Npx]`. If you517 just typed one of these onto a `<Button>` / `<Select>` / `<TextButton>`, stop — pick the518 right `variant`/`size`, or upgrade the component. (Canonical offender: the 6×-pasted "add519 provider" button carrying `bg-background border-border hover:bg-accent shadow-none!` on a520 real `<Button>`.)521522**The agent workflow — find, reuse, compose, upgrade; never style:**5235241. **Find before you write — and do not trust grep.** Ten near-identical controls can wear a525 hundred names, and they all share similar CSS, so "I didn't match it" does **not** mean it526 doesn't exist — the odds it already exists under another name are high. Check the component527 map / `reference.md` § Component picker before assuming nothing fits. Re-deriving an existing528 component is the #1 debt source.5292. **Priority is an order, not a suggestion:** reuse > compose > upgrade > (never) hand-write style.5303. **Copy only a gold-standard reference, never a dirty page.** few-shot copies what it sees;531 some good-looking pages are already off-contract, so their markup is poison — confirm a page532 is clean before mirroring it.5334. **Red lights — STOP and ask, do not improvise:** you need a *new component*, a *new token*,534 to *edit `style.css`*, or an *a11y / RTL* trade-off. Improvising past any of these means535 hand-writing past the boundary — exactly the move this whole contract exists to prevent.536537## The debt taxonomy — name it before you decide to fix it538539"Is this debt?" stops being a vibe once the failure has a name. Three axes turn the adjectives540*maintainable / reliable / clean* into a checklist; when unsure whether something is worth541flagging, match it here. This is a diagnostic lens, not new r542543…(truncated)