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.
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.
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-xl, never arbitraryp-[17px]/text-[13px]; colours, shadows and radii come from thedesign.mdscale (bg-card,shadow-card,--radius), neverbg-[#…]orshadow-[…]. 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.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. - 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.