Appearance
UI / Tailwind (project conventions)
Mobile/PWA is a first-class priority — design mobile-first with comfortable touch targets.
Behaviour patterns (the observability contract, error/empty-state recipes, the anti-patterns catalogue) live in
docs/agents/ui-patterns.md; the visual direction (palette, type, layout) indocs/design.md. Read those when building or fixing a feature's UI.
ALL UI work — components, templates, pages, new or edited — follows
docs/design.md§ Fundamentals and § The intentional design protocol. In brief: state the surface's job in one sentence; rank content by importance and assign visual weight to match (rank-then-weight — contrast/weight/colour, never size alone); the gap is the grouping signal (outer gap > inner gap); containment ladder whitespace → tint → shadow → border; labels are a last resort; every edit is ONE promote/demote/align intent executed through the weight-carrying properties. Variants are named hypotheses, never layout shuffles; after a variant is marked "close", one intent per round on the frozen base.
Visual states are authored as Storybook stories — the prop-driven look of a component (each structural state + content stress on its dynamic props), beside the component as
Foo.stories.ts. The authoring conventions (the three-way Vitest / Pest-browser / Storybook boundary, the content-provenance taxonomy, the two-axis state matrix) live indocs/agents/storybook.md. storify-on-touch: when you change a component's appearance, add or update its story in the same change — a restyle without a story update is drift the VRT net can't catch.
Wireframe first — converge in an Artifact before touching Vue
For a new UI surface or a layout rework (not a copy tweak, restyle, or bugfix), the design conversation happens in a throwaway HTML wireframe published as a claude.ai Artifact — and code starts only once the maintainer approves a direction there. Failing in a wireframe costs minutes; failing in a built page costs a PR round-trip. (Established in the Today-page alerts rework, #840.)
The wireframe contract:
Real content, not lorem — populate with the feature's actual data shapes so layout problems surface.
Mobile-width frames first, plus a desktop treatment note; show the key states (busy, quiet/empty, error) — the busy state is usually the one that breaks a design.
Multiple directions when the problem is open (2–3 options with trade-offs and a recommended default); iterate the same artifact URL as feedback lands, one labelled version per round.
A feature-preservation map when consolidating or moving UI — every existing affordance gets a named new home, so nothing is silently dropped.
Throwaway by design — the wireframe lives in the session scratchpad, never committed; the approved direction is captured by the PR/issue body, and the built UI is then proven by stories + VRT as usual.
Utility-first: compose from utility classes on the markup. Don't reach for
@applyto recreate component classes. Extract a Vue partial when the same markup appears a 2nd time, or when a class list grows past a glance (~8–10 utilities) — name it for what it is (TaskCard.vue, notBox.vue). Duplication in markup is fine until it repeats.Reuse the shadcn-vue kit first.
components/ui/*already shipsButton,Dialog,Input,Select,Sheet,Alert,Skeleton,Sonner, … — check there before hand-rolling one, and keep utility overrides on a kit component minimal (a class or two, not a reskin).Destructive actions confirm through the shared
ConfirmDialog(built on the shadcnDialogkit) — never nativewindow.confirm. Drive it off aref, run the action in the confirm handler:vue<ConfirmDialog v-model:open="confirmingDelete" title="Delete this person?" description="This can't be undone." @confirm="destroy" />Stay on the scale tokens.
p-4/text-sm/rounded-lg, never arbitraryp-[17px]/text-[13px]; colours, shadows and radii come from thedesign.md§ Foundations token tiers (bg-card,shadow-card,rounded-card), neverbg-[#…]orshadow-[…].npm run lint:designenforces this in CI — an off-scale colour/shadow/radius/spacing/font-size arbitrary fails the build (sanctioned exceptions:active:scale-[0.96],text-[10px]micro-labels, anyvar()/calc()token reference; a genuine one-off carries adesign-lint-allowline comment). Layout arbitraries (w-[…],ring-[3px],transition-[…]) are out of scope. Classes are sorted byprettier-plugin-tailwindcss— don't hand-order.Mobile-first responsive. Base utilities target the phone; layer up at
sm:/md:/lg:— never style desktop then undo it downward. Keep primary actions (Done / Punt / send) at ~44px touch targets. Use semantic elements (<button>,<nav>,<label>), visible focus states, and labels tied to inputs.Form inputs render ≥16px on mobile (
text-basewithsm:/md:text-smto shrink on desktop) — a smaller font on an<input>/<textarea>triggers iOS Safari's auto-zoom-on-focus, which yanks the layout. Never suppress it withmaximum-scale=1(breaks pinch-zoom, fails WCAG resize). The shadcnInputkit already does this — keep the pattern when composing forms. This is a functional iOS quirk for inputs only — not a floor on UI text, which floors at ~12px (10px for sanctioned micro-labels); seedesign.md§ Type scale.Gate:
bun run format,bun run lint:check,bun run types:check.Generate Wayfinder before running the FE gate locally —
resources/js/{actions,routes,wayfinder}are gitignored/generated, solint:check/types:checkthrow misleadingimport/order+TS2307 Cannot find module '@/routes/…'errors until you runphp artisan wayfinder:generate --with-form(CI does this first). The errors are missing generated files, not your change.
Component emits & props
Declare every custom event in
defineEmits. A listener the parent binds (@linked) that the child hasn't declared falls through as a native DOM attribute. On a component with a fragment or text root — more than one root node, or a bare text root — Vue has no single element to attach it to and warns at runtime: "Extraneous non-emits event listeners (…) were passed to component but could not be automatically inherited because component renders fragment or text root nodes." The fix is to declare the event, not to silence the warning.vue<!-- PersonaLinkButton.vue renders TWO roots — a <button> and <PersonaLinkModal> — --> <!-- so it's a fragment root and MUST declare what it emits, or @linked/@unlinked warn. --> <script setup lang="ts"> const emit = defineEmits<{ linked: [link: PersonLink]; unlinked: []; }>(); </script>A single-root component auto-inherits undeclared attrs/listeners onto its root — fine until you don't want that forwarding, in which case set
inheritAttrs: falseand bind explicitly. A fragment root has no such fallback, so its events must be declared.Type props with
defineProps<{…}>()(no runtimeprops: {}object); keep shared prop shapes in@/types/*, not inline anonymous types.A local
refseeded from a prop snapshots once —const x = ref(props.x)won't update when fresh props arrive (an Inertia redirect after a server-side mutation, e.g. a chat-driven assign). Either derive it withcomputed(no copy), or pair therefwithwatch(() => props.x, v => (x.value = v))to re-sync. A chip that kept its pre-mutation value is this bug (PersonaLinkButton.vue).Bind the kit
Switch(and other reka-ui kit toggles) withv-model, neverv-model:checked. reka-ui'sSwitchRootexposesmodelValue/update:modelValue— there is nocheckedprop orupdate:checkedevent.v-model:checked(or:checked+@update:checked) is a silently dead binding: the toggle renders its default state regardless of the data and never writes back, with no warning. This bug recurred across#274,#354,#426— bindv-model, and assert the rendereddata-state(checked/unchecked) in a browser test against a non-default initial value. Seedocs/learnings/2026-06-12-reka-switch-v-model-checked-dead.md.
Motion & interaction polish
Tempo is low-motion by design (design.md MOTION dial 2). Motion is for feedback, never ambience — added movement is a regression, not polish (see the anti-patterns catalogue). So:
- The one sanctioned micro-interaction is a press:
active:scale-[0.96]on primary buttons for tactile feedback (never below0.95). That's the ceiling — no stagger-in, blur-in, or slide-in entrance animations, no page-load animation on first render. - Respect
prefers-reduced-motion. A global@media (prefers-reduced-motion: reduce)guard inapp.cssneutralises transitions + animations (including the press) for users who ask the OS for less motion — calm by default, calmer on request. Never author motion that ignores it. - Transition specific properties, never
transition: all—transition-colors,transition-[box-shadow], etc.allanimates layout/paint props you didn't mean to and is the usual cause of janky hover. - Interruptible by default: CSS transitions for interactive state (the user can reverse them mid-flight); reserve
@keyframesfor a one-shot staged sequence. Don't reach forwill-changeunless you've measured a need.