Skip to content

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) in docs/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 in docs/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 @apply to 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, not Box.vue). Duplication in markup is fine until it repeats.

  • Reuse the shadcn-vue kit first. components/ui/* already ships Button, 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 shadcn Dialog kit) — never native window.confirm. Drive it off a ref, 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 arbitrary p-[17px]/text-[13px]; colours, shadows and radii come from the design.md scale (bg-card, shadow-card, --radius), never bg-[#…] or shadow-[…]. Classes are sorted by prettier-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 locallyresources/js/{actions,routes,wayfinder} are gitignored/generated, so lint:check/types:check throw misleading import/order + TS2307 Cannot find module '@/routes/…' errors until you run php 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: false and bind explicitly. A fragment root has no such fallback, so its events must be declared.

  • Type props with defineProps<{…}>() (no runtime props: {} object); keep shared prop shapes in @/types/*, not inline anonymous types.

  • A local ref seeded from a prop snapshots onceconst 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 with computed (no copy), or pair the ref with watch(() => 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) with v-model, never v-model:checked. reka-ui's SwitchRoot exposes modelValue / update:modelValue — there is no checked prop or update:checked event. 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 — bind v-model, and assert the rendered data-state (checked/unchecked) in a browser test against a non-default initial value. See docs/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 below 0.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: alltransition-colors, transition-[box-shadow], etc. all animates 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 @keyframes for a one-shot staged sequence. Don't reach for will-change unless you've measured a need.