UI — Spacing Owner Vocabulary
This skill is the composition layer between the page-level rules in
../web/SKILL.md and the atom-level token law in packages/ui/AGENTS.md.
web tells you how a page reads. packages/ui/AGENTS.md tells you how one
control looks. This file tells you the piece in between: when you need a row, a
field, a section, a tile, a disclosure, an object card, or a warning — you compose one
of the owner components below instead of hand-writing it from <div>s.
Why this exists (the one idea)
The debt this vocabulary kills is same-shape-different-code (同形异码): the same
visual shape — a settings row, a form field — written out by hand in slightly different
Tailwind classes on every page. It is invisible to grep (the classes differ) and it
drifts: one row is py-3, its twin is py-2.5, a third adds min-h-[3.75rem], and the
surface slowly stops looking like one system.
The fix is not "tokenize every gap-4." The fix is: give each recurring spatial
shape ONE owner component, and compose that. A shape with an owner cannot drift,
because there is only one copy of its spacing.
Before you write
<div class="... flex ... border-b ...">for a row, or<div class="space-y-1.5"><Label/>…for a field — stop. That shape has an owner. Compose the owner.
The core owners
Import paths are exact. As of the 2026-07 owner lift, every core owner below
lives in @felinic/ui (mostly src/components/settings/; a few in their own
component dirs) — the apps/web/src/components/ paths left in the entries are
re-export shims that keep old imports working. Import new call sites from
@felinic/ui directly; the shims die in the migration follow-up.
Structure
SettingsShell · settings-shell/index.vue — shim (impl in @felinic/ui).
The settings page width shell: centered column with the page-width LADDER
(narrow max-w-3xl · standard max-w-4xl · wide max-w-6xl · full) and the
page gutter (px-4 md:px-6). The ladder's single source is
settingsWidthClass (@felinic/ui, src/components/settings/width.ts) —
DetailPane consumes the same helper so its back row shares the content's
rails. PageShell is the OTHER page frame (title block + actions + doc gutter):
PageShell for titled settings pages, SettingsShell for master-detail panes
that carry their own inner structure.
DetailPane · settings/detail-pane.vue — shim (impl in @felinic/ui).
The in-page detail surface (list ↔ detail navigation): a back row
(BackButton) width-matched to the body, a loading skeleton mirroring the
real body (header card + 4-row form card, so the frame doesn't jump when
content swaps in), and the detail slot. Props: backLabel, width
(SettingsWidth ladder), loading. Emits back.
BackButton · settings/back-button.vue — shim (impl in @felinic/ui).
THE back affordance for in-page detail surfaces (DetailPane + bot-detail tabs
that can't use DetailPane). Ghost button, px-4, chevron + truncated label,
/85 ink. Props: label. Emits click.
SwapTransition · settings/swap-transition.vue — shim (impl in
@felinic/ui; its swap-forward/* / swap-back/* classes live in the
library's style.css). The directional push-pop for list ↔ detail swaps:
forward opens the next pane at the top, back restores the exact scroll offset
(an internal stack handles any nesting depth). Props: direction: 'forward' | 'back'. No appear — the first paint is a plain cut.
StatusDot · settings/status-dot/index.vue — shim (impl in
@felinic/ui). The tiny status dot atom (status → color), composed by
BackendCard (enabled badge) and MetricReadout — never hand-paint a colored
dot.
PageShell · page-shell/index.vue — a re-export shim; the implementation
lives in @felinic/ui (src/components/settings), shared with the component
showcase. Import new call sites from @felinic/ui. Its title block is the
shared PageHeader (same directory) — the ONE title + subtitle pair, also
composed by SectionGroup's heading mode.
The page header + centered max-w-3xl column. Props: title, description,
variant: 'page' | 'tab' (tab for a bot-detail settings tab, page for a standalone
route). Slot: #actions (header-right toolbar). Every settings surface starts here.
SettingsSection · settings/section.vue — a re-export shim; the
implementation lives in @felinic/ui (src/components/settings), shared
with the component showcase. Import new call sites from @felinic/ui.
A grey section label above a bordered white card. Props: title. Slots: #actions
(header toolbar, right of the title), default (the rows), #footer (an action band —
Save/Cancel or pagination — rendered inside the card with a top hairline). A card is a
SettingsSection; you never hand-roll rounded-… border bg-card.
SectionGroup · section-group/index.vue — a re-export shim; the
implementation lives in @felinic/ui (src/components/settings), shared
with the component showcase. Import new call sites from @felinic/ui.
A titled content group: a foreground section label (+ optional hint, #actions
toolbar) heading a BARE body — typically a BackendCard grid. Deliberately NOT
SettingsSection (muted label + bordered card): SectionGroup is the page-content tier,
its body already carries its own borders, so it adds no card and there is no
card-in-card. Use ONLY on pages stacking SEVERAL such groups (voice TTS/STT,
web-search search/fetch); a single-group gallery page lets PageShell own the
title/hint/action directly — wrapping its one group here would duplicate the page
title at a second tier. tone: 'foreground' | 'muted' (default foreground):
muted is the SettingsSection title's tier — use it when the group's level on the
page is a settings-style section (e.g. the showcase's doc spine), never mix tones
between same-level sections on one page. bare: set it when the body carries NO
bordered surface of its own (plain text, a row of buttons, a matrix) — the px-2
title inset exists relative to a CARD beneath, so a bare body drops the inset
(title/hint/body share one flush edge) and the title→body gap grows to give the
section the air the card would have provided.
Rows (things inside a section card)
SettingsRow · settings/row.vue — the workhorse. (Re-export shim; the
implementation lives in @felinic/ui (src/components/settings), shared
with the component showcase. Import new call sites from @felinic/ui.)
One horizontal row inside a section card. The canonical geometry
(min-h-[3.75rem] py-3 mx-4 border-b, inset hairline, last:border-b-0) lives here and
nowhere else. Props:
label,description— the default body.align: 'center' | 'start'—starttop-aligns a tall row (leading avatar + a multi-line body).stack: 'never' | 'sm' | 'always'—never(default) is one line;smdrops to a column below thesmbreakpoint so a label + control don't cramp;alwaysis a permanent column: a full-width control (Textarea, a schema field) under its label.
Slots: #leading (media — icon / avatar / channel mark / load skeleton), #content
(a fully custom body replacing label/description — use it when you need a <Label :for>
for focus-wiring, badges, or a status line), default (the trailing control).
ExpandableSettingsRow · settings/expandable-row.vue
A settings row whose whole header toggles a collapsible body. Props: label,
description, v-model:open. Slots: #leading, #content, #trailing (default: a
chevron that rotates on open), #expanded (the disclosed body — put a FormStack here).
Use for "Advanced" disclosures and per-item inline editors where the row owns what it
reveals. (If the toggle controls a sibling block it doesn't own, see the anti-pattern
below.)
BackendCard · settings/backend-card.vue
A clickable horizontal object-entry card (its root is a real <button>). Props:
name, subtitle, enabled (draws a status dot). Slots: #leading (icon), #trailing
(default: a chevron). For "pick a provider / backend / storage" object rows.
ModelListRow · settings/model-list-row.vue
A dense clickable navigation row inside a bordered card: name (+ optional secondary
id text when a custom display name hides it) on the left, a decorative trailing glyph
(default: gear) on the right, whole row is a real <button> (same root contract as
BackendCard). Props: label, meta?, last? (own its own trailing hairline instead of
:last-child, since the divider is a sibling element, not a border on the row — see the
component's head comment for why), disabled?. Slot: #trailing. Emits click (a
multi-root template disables Vue's attrs/listener fallthrough, so it can't rely on a bare
@click passthrough). Built to close the transcription/speech/video provider-setting.vue
"dense model-list navigation row" gap recorded in the owner-vocabulary-census (those three
hand-synced the exact shape; two were byte-identical, the third only differed by nesting
the click handler on a trailing ghost Button instead of the row itself — a trivial, not
deliberate, divergence). Do NOT reach for it for providers/model-item.vue — that row
carries inline enable/test/delete actions, a capability badge, and a status line, a
materially richer interaction contract than "click to open the edit dialog"; it stays its
own hand-built component.
Fields (vertical form controls)
FieldStack · settings/field-stack.vue
ONE vertical Label-over-control form field (space-y-1.5). Props: label, for (wires
label→control focus), help (a muted <p> under the control). Slot: #label (custom
label row — e.g. a label with an inline "clear" button), default (the control). This is
distinct from SettingsRow: SettingsRow puts the label beside the control; FieldStack
stacks it above.
FieldStack is the single owner for vertical form fields — including validated ones. When a FieldStack sits inside a vee-validate
<FormField>, it takes overFormItem's job: it provides the form-item id (so a<FormControl>wrapping the control resolves aria-invalid / aria-describedby) and renders the field's error inline. So the pattern for a validated field is:<FormField v-slot="{ componentField }" name="x"> <FieldStack :label="…" for="x-id"> <FormControl><Input id="x-id" v-bind="componentField" /></FormControl> </FieldStack> </FormField>Keep the outer
<FormField>and the<FormControl>; drop the old<FormItem>+ bare<Label>. A plain field with no validation is just<FieldStack :label>around the control. One caveat: because FieldStack now renders errors inline, any hardcoded validation message in the zod schema becomes visible — translate it (zh/en/ja) instead of shipping the English default. The older@felinic/ui<FormItem>(grid gap-2) is superseded; new code uses FieldStack.
FormStack · settings/form-stack.vue
A space-y-4 wrapper for a run of FieldStacks. Use it around a contiguous form column
(a dialog body, an #expanded panel).
Readouts & notices
DeviceCodePanel · device-code-panel/index.vue — shim (impl in
@felinic/ui).
设备码授权(RFC 8628)的"输码时刻":一句指引(caller i18n,防钓鱼警示写在 hint 里)→
英雄码(text-3xl mono,select-all,不是输入框)→ 唯一动作"复制并打开"(【先发起复制、
再同步打开,两行都不 await】——趁当前页聚焦先发起 writeText,失焦会让 Chromium 复制
失败;绝不能先开空白 tab 占位再 await 剪贴板,那是已否决的错误方向)→
可见性守卫的 mm:ss 倒计时(面板唯一活性信号;授权轮询是 caller 的事,静默)。
Props: code, verificationUri, expiresAt?, hint, retryLoading?,以及入库后
全部文案 props(copyAndOpenLabel / retryLabel / expiredLabel / expiresInLabel
/ copyFailedMessage,均 caller i18n,无 fallback);emit retry
(过期后重新签发)。"取消整个流程"属于 caller 的入口控件,与面板的 retry 语义不同,
不要合并。已知同形状旧写法:bots ACP 面板、onboarding Step4 —— 迁移前先做各自的
行为盘点(ACP 有真 cancel API 和错误态)。
LabelSwap(@felinic/ui 原语,非 apps/web owner,顺带登记防重造)
会"换字"的内容槽:每个候选 label 一个具名 slot,active 指名当前项;各 label 保持
自然宽度,切换时外壳宽度按 house spring 平滑过渡、label 交叉淡出。给 Connect↔Cancel
式的状态开关按钮用 —— 不要再用 min-w 魔法数定宽或手写叠格量宽(grid 轨道按最宽
label 定尺寸、靠左锚定会单侧裁字,这些坑都已收进组件,见其头注)。
MetricReadout · settings/metric-readout.vue
One metric tile: caption label + tabular value (or a status dot + label). Props: label,
value, sub, framed (default true — draws the tile box; false for a bare stat on
a surface), status: 'ok' | 'warn' | 'error'. Slot: #value (custom markup — relative
time, mono). The caller owns the grid; MetricReadout is a single cell.
PersonaTile · persona-tile/index.vue
A vertical, centered entity/add tile (w-52 flex-col items-center). Props: name,
variant: 'entity' | 'add'. Slots: #media, #status, #name. This is the vertical
counterpart to the horizontal BackendCard — do not confuse them.
CalloutBanner · callout-banner/index.vue
A framed warning / destructive notice. Props: tone: 'warning' | 'destructive', title,
description, clickable (whole surface becomes a button with a lead-in chevron). Slots:
#icon, default (trailing action).
Loading & placeholder (the four-rung ladder)
Loading states have exactly four homes; pick by where the loading lives, and the choices are mutually exclusive — no two rungs ever fit the same call site:
Just the glyph, no text (rare, e.g. sidebar) → <Spinner> atom, keep local
Inside a button that triggered the work → Button :loading (+ loading-mode)
An in-flow row: spinner + muted text, left-aligned → InlineLoadingRow
A whole pane/panel with nothing else to show → PanePlaceholder (centered fill)
PanePlaceholder · pane-placeholder/index.vue
The centered fill for a pane's content area, three states: loading (horizontal
spinner + text), default empty (vertical #icon + text + #action), title (emphasized
two-line block, e.g. "Select a bot"). Deliberately not the @felinic/ui Empty
(that's the dashed/solid framed card for in-page empties); PanePlaceholder is for the
frameless center of a panel that has nothing else. Parent must give it height.
InlineLoadingRow · inline-loading-row/index.vue
A left-aligned, in-flow "spinner + muted text" row that occupies one line where content
will appear (list head, settings row content, panel top). Props: size: 'sm' | 'md'
(text-xs/3.5 vs text-sm/4), bordered (the rounded-md framed variant used by backup /
import read-states), surface: 'card-row' | 'tab'. Not for centered fills — that's
PanePlaceholder.
Use surface to pick the positioning family — never hand-write it as a class again:
surface="card-row" for a loading list INSIDE a SettingsSection card (bordered row form,
reads as "a list row, loading"); surface="tab" for the whole-TAB loading form (no card
around it, e.g. bot-container). A real 2026-07-06 regression mixed these up by hand
(caller literally copied the wrong family's class string) — the card read as a half-empty
box until re-aligned. The two named forms now live in the component so a caller cannot
copy the wrong one; a genuinely different, unnamed positioning need still passes plain
class (single root node, merges with the component's base class).
Dialogs
FormDialogShell · form-dialog-shell/index.vue — shim (impl in
@felinic/ui). The Dialog-with-a-form skeleton: Dialog + form + footer band
(default sm:max-w-106.25). Compose it for any create/edit dialog whose body
is a form — never hand-roll the dialog+form+footer assembly.
ConfirmPopover · confirm-popover/index.vue — shim (impl in
@felinic/ui). The anchored confirm bubble (NOT a modal): shared popover
chrome, a title-grade question (title, or message alone), optional
supporting line, ghost cancel + action confirm. Props: title, message,
cancelText / confirmText (required — caller i18n, no fallback),
loading, variant. Emits confirm.
ConfirmDeleteDialog · confirm-delete-dialog/index.vue — shim (impl in
@felinic/ui).
The delete-confirmation dialog: sm:max-w-sm, title + optional DialogDescription,
outline cancel + destructive confirm. Props: open (v-model), title, description,
cancelLabel / confirmLabel (required — caller i18n; the old
common.confirm fallback is gone), loading (confirm shows spinner, cancel
disables so the dialog can't close mid-request). Emits confirm — the caller owns
the actual delete call and closes on success. Copy is caller i18n; the component owns
only skeleton + behavior. Do not hand-roll a Dialog+two-buttons confirm again; if a
confirm needs a form or extra body content it is not this owner.
Surface-scoped owners (live next to their surface, not in components/)
Same discipline, narrower home — compose these when working on their surface:
- onboarding (
pages/onboarding/components/):step-exit-shell.vue(the wizard exit animation wrapper — the ONLY owner of the scale+opacity exit transition; every Step*.vue template root wraps in it. It sits above StepFrame because Step3's three frame variants share one shell),step-frame.vue(35rem body + title structure; each step's title mb is deliberately hand-tuned per page and passed viatitleClass— do NOT flatten it;bodyClassis currently Step3-only for its mode-switch transition),footer-nav.vue(prev/next pair, min-w next button so long labels grow;#nextescape hatch only for CTAs that need a keyed Transition label swap),choice-tile.vue(h-16 icon+label grid tile, solid/dashed),hint-box.vue(single-line text-xs form hint, muted/warning tones, optional #icon — NOT CalloutBanner, which is the page-level title+description+action banner; forcing hints into it was adjudicated as over-merging). - app sidebar (
components/sidebar/):panel-header.vue(panel group header row — owns the label typetext-xs font-[550] tracking-tight muted/80that files-pane/schedule/sessions each hand-synced; container geometry (mt/h/min-h) and indent (pl-[11px]label column vs containermx-1 pl-[11px]chevron column) stay with callers; recents' header is an interactive DropdownMenu TextButton — different relationship, local),nav-button.vue(the h-9 icon-column action row — was byte-identical ×3 in index/panel-sessions;activeprop for the settings-row highlight; icon passed by caller assize-[18px]stroke 1.75). Deliberately NOT built: a ListRow owner for session-item/bot-switcher/search-dialog rows — the three keep different heights, radii, and hover tokens on purpose (sidebar-hover vs bot-row-tint vs ui-hover are distinct design tokens) and bot-switcher must stay a bare div for sortablejs; a class-only util owning just "flex row + rounded" would not earn its keep. - supermarket (
pages/supermarket/components/):market-item-card.vue(list card: leading icon box + title + homepage link +#actions),market-detail-header.vue(detail-page header: back/install action row +#iconbox + title + tag badges; copy is deliberately baked in — read its head comment for the fork rule if a third detail-page kind ever appears),info-item.vue(the label-over-value entry in the detail Information section — was a byte-identical inlineh()component in both detail pages). - editor / file compare (
components/diff-title-bar/index.vue):DiffTitleBar— the title strip above a Monaco diff. Owns ONLY the container (layout, hairline, padding, type, muted ink); content comes via the default slot + optional#actions. 2 callers (file-viewer compare, monaco diff); per its head comment, a third caller needing a different container should inline or fork, not grow props here. - dockview (
pages/home/components/dockview/):panel-frame.vue(DockPanelFrame — the host shell everypanel-*.vueroot wraps in:relative flex h-full w-full flex-col+ amin-h-0 flex-1content layer so panes never re-derive the flex-overflow incantation.editorSurfaceprop for thebg-surface-editorpanels (file/preview/asset);#headerfor a top strip like PanelBreadcrumb. New dock panels compose it — never hand-write the shell). - chat composer dock (
pages/home/components/): everything anchored above the chat input box is owned here —composer-dock.vue(ComposerDock, the orchestrator: owns the box-slot mutex — composer by default, the ask_user capsule while the agent is asking — plus backdrop-mask geometry and all visibility/height logic; the composer is the ONE special member, injected via slot because it owns its radius/height morph),composer-panel.vue(ComposerPanel, the stack tier: approvals / command results / composer errors as hairline-separated sections of ONE capsule), andcomposer-capsule.vue(ComposerCapsule, the ONLY dock shell — the input-group edge + surface-composer + its radius live nowhere else). Adding a new STACK-tier member (a decision surface that hugs the box — approvals, command results, errors, and their peers) = write acomposer-panel-*.vuesection component (content only, likecomposer-panel-error.vue's 17 lines) + register one branch in the panel'ssectionscomputed. A new BOX-tier member (a mutually exclusive occupant of the input slot, like the ask_user capsule) does NOT go insections— it extends ComposerDock's box-slot mutex and height logic instead, so it REPLACES the composer rather than stacking above it. Never hand-write the capsule chrome, the mask measurement, or ad-hocv-ifstacking above the composer again — that is how the four hand-shelled cards this replaced drifted. - chat tool-call (
pages/home/components/tool-detail/):empty-row.vue,preview-box.vue(max-h-48),header-row.vue(collapsible row, tone axis,nestedfor button-in-button),expand-chevron.vue,capsule.vue(compact/detail density). Read each file's head comment for its stay-local exceptions before adding new tool detail panels.
Plus one atom you already have
Empty (@felinic/ui) — the framed in-page empty state card. Fold py-12/py-16 as
needed. Loading and empty states must still draw their frame so nothing reflows.
Which owner? — a decision map
Need a bordered card holding rows? → SettingsSection
a row: label + a control on one line? → SettingsRow
a row whose header expands a body it owns? → ExpandableSettingsRow
a clickable "pick this object" row? → BackendCard
a dense clickable "open the editor" row? → ModelListRow (whole row is a <button>;
if it needs several INLINE actions
instead of one navigate-away click,
that's a different relationship —
see providers/model-item.vue)
Several titled groups of bare (self-bordered)
content on one page (provider grids)? → SectionGroup
A stacked form field (label above control)? → FieldStack (wrap a run in FormStack)
A stat / number tile? → MetricReadout (you own the grid)
A vertical, centered entity/add tile? → PersonaTile
A warning / destructive framed notice? → CalloutBanner
A device-code "enter this code" moment? → DeviceCodePanel
A label that swaps with state (Connect↔Cancel)? → LabelSwap (@felinic/ui)
The page frame itself? → PageShell
A pane with nothing to show (loading/empty)? → PanePlaceholder (centered fill)
An in-flow loading row (spinner + text)? → InlineLoadingRow
Loading inside the triggering button? → Button :loading
A delete confirmation dialog? → ConfirmDeleteDialog
An in-page framed empty state? → Empty (draw the frame)
When to STAY hand-written (do not force an owner)
Unification is the goal — but only for shapes that are the same spatial relationship. Keep a shape local when its relationship genuinely differs. The tells:
- Different geometry for a reason. A denser list item (
min-h-[3.25rem] py-2.5, a compact model/provider list) is not a3.75remsettings row. A dialog-body field withtext-xsmuted labels +h-8inputs is a tighter inline-form language than a settings-page FieldStack. Same code ≠ same relationship. - A different surface. Sidebar rows, file-tree rows, chat message rows live in
non-settings surfaces with their own row systems. Owners are for the settings/form/list
surface, not everywhere a
border-bappears. - A genuinely one-off compound block. A drag-drop upload target, a Monaco/JSON editor, a snapshot input, a link-code countdown, the single real data table — no owner covers these; hand-write them. (OAuth device-flow used to sit in this list; it graduated to DeviceCodePanel once the same shape hit three call sites — the same ratchet applies to the rest of this list.)
- A centered framed placeholder paired with a sibling state — e.g. a
min-hframed loading block whose twin is a framed empty state in the same slot (bot-container's workspace card). The frame + pairing is the relationship; PanePlaceholder (frameless pane fill) would break the pair. Keep both local. - A compound progress block (spinner + phase text + progress bar, e.g. container-create-progress) — that's a composition, not a loading row.
- A trivial muted
<p>no-results line. - A three-piece row: (label | control) line + a full-ROW-width block below (the
appearance mermaid row: label|Select with the diagram preview spanning the whole row
underneath). SettingsRow models TWO pieces — body and trailing control. Stuffing the
third piece into
#contentbounds it to the content column (left of the control) and a centered preview drifts left of the card's axis. Learned from a real 2026-07-06 visual regression: migrated, caught in review, reverted. Keep such rows local until a second instance earns a#belowslot.
If you're unsure whether two shapes share a relationship, judge by geometry + context, not by class-string similarity. When the answer is "same shape, same size, same surface" → it's a MISS, give it its owner. When it's "looks similar, but denser / different surface / one-off" → keep it local and say why.
Traps (learned the hard way)
- Tailwind scans literal source text. A runtime
`sm:${align}`or`min-h-[${x}]`class is never generated. Any conditional class must enumerate the full literal strings (seeSettingsRow'srootClasscomputed — every combination spelled out). Never build a class by string concatenation. - Don't nest a
<button>in a<button>.BackendCard's root is a real button, so you cannot drop an interactiveSwitch/menu into its trailing slot. If a row needs a trailing interactive control and a whole-row click, that's a hand-rolled stretched-overlay pattern — keep it local (this is whybot-acpstayed local). - A toggle that reveals a sibling block is not an ExpandableSettingsRow.
ExpandableSettingsRow owns its
#expandedbody. If your button toggles a separate block below it (e.g. a run of grouped rows with their own dividers), keep the toggle as a plainSettingsRowwith the button in its default slot, and leave the revealed block outside. #footerisjustify-end. It fits Save/Cancel. Ajustify-betweenpagination strip (summary span on the left, pager on the right) does not fit the current#footer— keep that footer local until the owner grows a variant.- App pages don't lint raw colors. Tokens only (
bg-card/text-foreground/border-border); for hover/selected use the overlay ladder (--ui-hover/bg-accent), never a gray or a/10alpha. Thecheck-ui-contract.mjsguard flags app-scopehover:*/bg-*injections — component chrome belongs in the component (mark a sanctioned line with a/* ui-allow-style */same-line comment inside the owner, never on a page). - The guard warns on a hand-rolled SettingsRow.
check-ui-contract.mjsrule 11 (WARN) flagsmin-h-[3.75rem]— and its bare-scale twinmin-h-15— anywhere outsidesettings/row.vueandexpandable-row.vue, because that height only appears when a row was copied out ofSettingsRowas raw divs. It's a WARN (advisory, doesn't block CI), not a HARD fail, because a genuinely denser/different row could land on that number: if a flagged line is a real settings row, compose<SettingsRow>; if it's a deliberate different shape or a loading placeholder borrowing the height, silence it withui-allow-shapeon the line — and the marker must carry its reason on the same line (ui-allow-shape: <what this is and why no owner fits>); a bare marker is itself flagged. - The guard ratchets hand-spun loaders. Rule 12 treats a literal
animate-spinin apps/web as a loader that skipped the four-rung ladder (every rung renders throughSpinner/Button :loading/InlineLoadingRow/PanePlaceholder, none of which emit that literal from app code). Existing long-tail sites are grandfathered inscripts/ui-spin-baseline.json; a NEW bareanimate-spinhard-fails — pick a rung, or mark a sanctioned exception withui-allow-spinon the line. Together these are a backstop against regression, not a debt finder — they catch the SettingsRow and loader slices (the rare literals); FieldStack / MetricReadout duplication has no such fingerprint and stays a judgment call.
Migration discipline (when converting a hand-rolled surface)
- Read
../web/SKILL.mdfirst, then this file, then the page. - Inventory every behavior before editing — every
v-model, click handler, loading/empty branch, validation, i18n key — and re-wire each after. A refactor must not regress. - Migrate only genuine owner-shapes. Judge each shape against the "stay hand-written" tells above. Don't blind-unify; don't force a different-relationship shape into an owner to look thorough.
- Don't change color, type, radius, shadow, or copy while migrating spacing. Remove now-unused imports.
- Verify:
eslint(0),vue-tsc(0 errors),node scripts/check-ui-contract.mjs(0 new violations — pre-existing debt in other files is not yours to fix here), then a human confirms the rendered page (dark + narrow +zh+ walk every old interaction). The agent's "it should work" is not verification.
Batch migrations: the shared-file hazard
When many agents migrate different pages in parallel, each .vue is safely isolated —
but a migration that adds a validation message writes to the shared locale files
(apps/web/src/i18n/locales/{zh,en,ja}.json). Parallel writers to the same JSON don't see
each other, so two agents adding a nameRequired under the same section produce a silently
duplicated key — the JSON still parses, but the later value wins and the earlier one is
lost. This actually happened: five web-search agents each inserted webSearch.nameRequired.
So when you fan out a batch:
- Treat the locale files as a contended resource. Have each agent add only its own new keys under its own file's namespace, never touch existing keys.
- After the batch, before committing, run a path-aware duplicate-key check on all three
locales (parse with a hook that flags a key appearing twice in the same object — note a
key legitimately repeats across different objects, e.g.
status, so scope the check to same-parent siblings). Collapse any true in-object duplicates, keeping one reconciled value. Do not trust "the JSON parses" — a dup parses fine. - The cleaner alternative when a batch shares keys: collect the new i18n keys from each agent's report and write them yourself in one pass, rather than letting N agents each edit the same three files.
Owner reference
The owner sources are the source of truth — read the component before composing it if a
prop is unclear. The full morphology census and the record of what shipped (and what was
confirmed stays-local) live in docs/design/spacing/owner-vocabulary-census.md.