frontend — screens that work for every user, every state, and stay easy to change
Follow the repo's CLAUDE.md, docs/conventions.md and docs/design-system.md first; this skill
fills gaps and never overrides them. Visual work (tokens, layout, colour, CSS) belongs to
css-design; running the app as the user will and the final verdict belong to verify-app.
Pick the stack. Vue 3 and React are both first-class and follow the same rules. Use the
framework the repo already has; for a new app use the frontend default from the delivery profile
(see the repo README), else ask once at triage. Vue: TypeScript + Vite (npm create vue@latest -- --ts --router --pinia --vitest --playwright --eslint --prettier) with references/vue-patterns.md.
React: TypeScript + Vite (or the repo's meta-framework) with references/react-patterns.md. Never
mix frameworks in one app. Need SSR or SSG for SEO? Record Nuxt or Next.js in an ADR (architect);
this skill covers the client-side parts either way.
Workflow
- Load the repo's conventions and inventory. Read
CLAUDE.md,docs/conventions.md,docs/design-system.md; list reusable parts (ls src/components src/features src/composables src/hooks src/stores). Detect the data layer, router, form and i18n libraries (Vue:grep -E '"(@tanstack/vue-query|pinia|vue-router|vee-validate|vue-i18n)"' package.json; React:grep -E '"(@tanstack/react-query|@tanstack/react-router|react-router|react-hook-form|i18next|react-intl)"' package.json). Never add a second data layer, router, store, form or i18n library. Evidence: the story's notes list the components and composables/hooks reused and any new one, with the reason. - Write the state table before code. For each view: every piece of data and where it lives —
server (query), URL (route params / search), shared client (Pinia store, or React context or the
repo's store), local (
ref/useState), derived (computed/ a const in render), external (a composable oruseSyncExternalStorewrapping the browser API). Then list the four states (loading, empty, error, success) and the copy keys for each. Evidence: the table in the story. - Write the failing tests first. Vitest +
@testing-library/vueor@testing-library/react,userEvent.setup(), queries by role and accessible name, one behaviour per test, network faked at the service boundary (MSW or the repo's fake client insrc/test-utils). Cover each state, the keyboard path and one negative case. Evidence: the run shows red for the right reason. - Build inside the feature folder.
src/features/<name>/services(typed API calls, no framework) →composables/hooks(useXwrappinguseQuery/useMutationand a key factory) →components(Name.vuewith<script setup lang="ts">, orName.tsx) → the route view that composes them. Props and events typed, noany, no non-null!,@/imports. Evidence:git diff --statstays inside the feature plus deliberate shared changes. - Copy and formatting. Every visible string comes from the locale files (vue-i18n
useI18n(), or the React repo's i18n library or copy module) with the same keys in every locale; a test enforces parity. Numbers and dates through the i18n formatter orIntl. Evidence: parity test green; no hard-coded copy, enforced by lint (@intlify/vue-i18n/no-raw-text; React:react/jsx-no-literalsor the i18n plugin's rule) — cite the lint exit code. - Style it with
css-design. Tokens only, BEM block = component name, every state styled;.vuestyles linted throughpostcss-html. Evidence: Stylelint green. - Stories (component libraries). One story per state, both themes; Storybook axe passes. Evidence: the stories test and axe run.
- Gates, chained by exit code.
npm run lint && npm run type-check && npm run test:unit -- --run(Vue:vue-tsc --build; React:tsc -b), ormake ci; never judge from| tail. Evidence: exit code 0 and coverage at or above the repo threshold. - Journey in a real browser against the production build.
npm run buildand serve the output (vite preview, or the static server the repo uses). Add or extend a Playwright spec for the main path with@axe-core/playwright(tagswcag2a wcag2aa wcag21aa wcag22aa, serious and critical = 0). Capturedocs/delivery/<slug>/evidence/<view>-390.pngand-1280.png, plus dark theme if the app has one. Open every screenshot before citing it. Evidence: spec green, PNGs. - Keyboard and screen-reader pass. Tab through the new view: order, visible focus, Escape
closes overlays and focus returns to the trigger; after navigation focus lands on the new heading
and the title changed. Spot-check names and announcements with a screen reader (VoiceOver
Cmd+F5, or NVDA). Evidence: a three-line note in the story's QA section. - Hand over. Run
verify-appfor the end-to-end claim; insidedeliver-loopthe independent review grades the item. Do not restate their steps here.
Core checklist
- Nothing stored that can be derived: use
computed(React: a const in render); no watcher or effect whose only job is to set other state. - Watchers and effects only sync with the outside (DOM APIs, subscriptions, timers, third-party
widgets) and clean up (
onWatcherCleanup/onUnmounted; React: the effect's return). Work caused by a user action runs in the event handler. - Server data is never copied into local state or a store; query keys contain every input the fetch uses (in Vue as refs or getters, so a change refetches).
- Selection, filters, tabs and pagination a user would share or reload live in the URL query.
- Every data view renders loading, empty, error (with retry) and success; tests cover all four.
- Every input has a visible
<label for>(ids fromuseId()in both frameworks); errors are text tied witharia-describedby, input is kept, focus moves to the first invalid field on submit. - Native elements first (
button,a href/router link,dialog,details,input type); nodiv @click/div onClick. - Custom widgets follow the matching WAI-ARIA APG pattern, keyboard included.
- Async results that do not move focus are announced via a
role="status"/role="alert"region rendered once and empty (Vue: teleported tobody; React: a portal, if it must outlive the view). - Route change:
document.titleupdated and focus moved to the pageh1ormain(Vue: arouter.afterEach+ template ref; React: the router's equivalent). - Errors are caught per route and per risky widget (Vue: an
onErrorCapturedboundary component; React: an error boundary) and show a retry, never a blank page. - No hard-coded display strings; every locale has the same keys.
- Tests query by role/label/text, interact with
user-event, and never assert on class names, component internals (wrapper.vm, state values), or composable/hook calls. - No
v-html/dangerouslySetInnerHTMLunless sanitized with DOMPurify right before use; URLs from data are allow-listed tohttps:/mailto:/relative. shallowRef/markRaw/v-memo/KeepAlive(React:memo/useMemo/useCallback) only with a profiler measurement or a documented need.- Charts and data visualisations have an accessible name and summary, a data-table (or text) alternative, a colour-blind-safe palette with series also told apart by label, shape or pattern, and keyboard access to any interaction — whichever chart library the repo uses.
Anti-patterns
Vue:
watch(items, v => { filtered.value = v.filter(...) })— usecomputed.const { count } = reactive(state)orconst { name } = store— reactivity lost; usetoRefs/storeToRefs, or preferref.watch(props.id, ...)or passing a destructured prop to a composable — pass a getter() => id.- Mutating a prop or
useQuerydata; binding query data straight tov-model— copy into a form. v-ifandv-foron the same element;:key="index"on a reorderable list.awaitbeforeonWatcherCleanupor before registering lifecycle hooks in a composable.defineAsyncComponentfor a route component (routes take() => import(...)directly).next()in a navigation guard — returnfalse, a location, or nothing.- Tests reaching into
wrapper.vmor callingsetData.
React:
useEffect(() => setFiltered(items.filter(...)), [items])— compute in render.- Resetting a form in an effect when an id changes — pass
key={id}. - Hooks called inside conditions or loops; state mutated in place instead of replaced.
key={index}on a reorderable list; a new object or function prop on every render passed to a memoised child that was memoised for a measured reason.
Both:
- Fetching on mount with no cancel, cache, retry or error branch — use the data layer.
- Spinner with no empty or error branch; error swallowed into
console.log. staleTimeleft at 0 for data that changes daily, so every focus refetches.- Optimistic update without snapshot, rollback and invalidate on settle.
- Placeholder used as the label; colour alone marking an error;
role="button"without keys. getByTestIdeverywhere; whole-page snapshots; testing implementation details.- A feature barrel file pulling everything into the first route chunk.
:href="user.url"/href={user.url}without checking the scheme.
Evidence before "done"
- Failing-then-passing unit tests for each state and the keyboard path.
- Lint,
vue-tscortsc, tests chained, exit 0; coverage at or above threshold. - Playwright journey against the production build, axe serious/critical = 0.
- Screenshots at 390 and 1280 px (and dark theme) in
docs/delivery/<slug>/evidence/, each opened. - Keyboard and screen-reader note in the story's QA section.
- Then
verify-app.
References
references/vue-patterns.md— the Vue checklist: SFC and component API, reactivity pitfalls, computed vs watch, composables, Pinia, TanStack Query for Vue, Vue Router (URL state, guards, focus), forms, i18n, a11y specifics, performance, security, tooling; each item sourced.references/react-patterns.md— the React checklist: the same topics, plus a static-export (Next.js / Nuxt generate) variant.references/a11y-and-testing.md— WCAG 2.2 AA items for UI code, ARIA/APG, keyboard and live regions, charts, Testing Library for Vue and React, MSW, Playwright + axe, what not to test.