Appearance
Inertia (server + client)
Success/failure/empty surfaces (toasts, inline errors, empty states) follow the observability contract in
docs/agents/ui-patterns.md— every mutation must report its outcome there.
Controllers return pages, not JSON:
Inertia::render('People/Index', [...]). After a successfulstore/update/destroy, redirect back (PRG) —return to_route('people.index'), never re-render the page inline (a refresh would re-POST).Generate URLs from Wayfinder named routes — import the action/route (
import { update } from '@/actions/…'or@/routes/*) and call it; never hardcode/people/1. Use<Link :href="…">for internal navigation; reserve<a>for external links.Drive forms with
useForm; submit through a Wayfinder action and read server validation fromform.errors— the server is the source of truth, don't duplicate the rules client-side:tsconst form = useForm({ name: '' }); form.submit(store()); // Wayfinder action → POST // template: <InputError :message="form.errors.name" />Shared data (in
HandleInertiaRequests::share) stays minimal: lazily-evaluated closures,only(...)the fields the client needs — never whole models.Pages are thin + typed (
defineProps<{...}>()), composed fromcomponents/andlayouts/. PassApp\DataDTOs, not loose arrays, so the page props carry a stable typed shape.Force
forceFormDataonly when a file is actually attached (forceFormData: files.length > 0), never unconditionally. Inertia auto-switches to FormData when the payload contains a File, so forcing multipart on a text-only submit is needless and can drop plain fields server-side (it droppedmessagein the in-process pest-browser server —docs/learnings/2026-06-11-inertia-forceformdata-text-only-multipart.md).
Consume Tempo's domain layer, not raw Todoist
The frontend renders Tempo's DTO/label layer, never raw Todoist fields. The DTO (app/Data/*) does the mapping once, server-side; the page consumes the mapped value. Reaching past it to a raw Todoist field re-implements (and gets wrong) a translation the DTO already owns.
The canonical trap is priority (bug #2): Todoist's raw priority int is inverted — 1 = natural/default … 4 = most urgent — whereas Tempo's client-facing codes run p1 (most urgent) … p4. So the raw int read straight into the UI is upside-down.
vue
<!-- ✗ raw Todoist field — inverted, unmapped -->
<span>{{ task.priority }}</span>
<!-- ✓ Tempo's mapped value off the App\Data\Task DTO -->
<span>{{ task.priorityLabel }}</span>Same rule for the rest of the task shape: use priorityLabel, displayLabels (raw labels minus the duration estimate), estimate() / the duration band (rendered as "X min", never a chip), and TaskDue / deadline — never the raw label array, raw duration, or raw date strings. If a mapped value you need is missing, add it to the DTO — don't map in the template.