Skip to content

Agentic resources

Calm conventions for confident Vue teams

Vue Field Guide

The Agentic Pack is a portable AGENTS.md file. Drop it into a Vue 3 project. An AI coding agent reads it on every edit and applies the rules. Ask the agent to audit existing code against the same rules.

The pack is a reference library for Vue. It makes the next decision easier. Use these conventions when a choice needs a shared answer, not as a substitute for judgment. Each guide favors readable code, explicit boundaries, and patterns that are easy to explain in review.

Since 2017 the author has worked with Vue.js across projects with different setups and teams. Those years shaped the guide. The conventions carry the MIT license. Use any part of them in your own project or adapt them into a guide for your team.

Two modes

  • Follow mode. The agent reads the file on every edit and applies the rules.
  • Audit mode. Ask the agent to audit a file or PR against AGENTS.md. It reports violations grouped by file. Each entry carries a severity from MUST, SHOULD, or AVOID. Each entry cites the rule reference, such as S5 or S8.

Install

Copy AGENTS.md from the pack to your project root.

  • Cursor reads AGENTS.md at the project root.
  • Claude Code reads AGENTS.md at the project root.
  • Pi reads AGENTS.md at the project root.
  • Windsurf reads AGENTS.md at the project root.
  • GitHub Copilot reads the file at the repo root. Reference it from custom instructions when needed.
  • Other agents that support the AGENTS.md convention pick it up from the repo root.

What it covers

Four pillars from the guide:

  • Foundations. Naming, folder structure, and TypeScript choices that make a codebase legible.
  • Vue architecture. Components, composables, and routing patterns for clear boundaries.
  • Data and state. Query, mutation, and store guidance for predictable application state.
  • Quality and delivery. Testing, formatting, reviews, and documentation practices that scale.

Sections in AGENTS.md:

  • Non-negotiables
  • Project structure
  • Casing
  • Components (S5)
  • Composables (S6)
  • Pinia / state (S7)
  • Queries and mutations (S8)
  • TypeScript (S9)
  • Routing (S10)
  • Testing (S11)
  • Delivery (S12)
  • Audit checklist

Customize

Projects on different stacks adjust a few entries:

  • Keep only the Pinia Colada or TanStack row in the S8 table. Remove the other row.
  • Adjust the Pinia store id enum location.
  • Adapt the ROUTE_NAMES layout to your router.
  • Tune the tsconfig flags.

The file assumes Vue 3 with <script setup lang="ts">, Pinia, and Vue Router.

Out of scope

This AGENTS.md encodes only what the Vue Field Guide documents. It does not cover error handling, security, accessibility, i18n, form handling, performance budgets, or observability. Treat those as project discretion. Silence is not approval.

The full file

View the full AGENTS.md
md

# Vue 3 Conventions (AGENTS.md)

Drop this file at a Vue 3 project root. An AI coding agent reads it and FOLLOWS the rules while editing code. The same agent AUDITS existing code on request and reports violations grouped by file with severity and a rule reference.

Severity legend: MUST (block merge), SHOULD (block after discussion), AVOID (do not add new occurrences), MAY (allowed, follow the default). Scope: Vue 3 + `<script setup lang="ts">` + Pinia + Vue Router + the Query/Mutation layer for server state.

## Non-negotiables

1. Components use `<script setup lang="ts">` and call `defineOptions({ name: '...' })`.
2. No top-level `await` inside `<script setup>`.
3. Never mutate props. Treat every prop as read only.
4. Never destructure a store, composable, or query/mutation object. Reference members through the instance.
5. Vue component filenames are PascalCase (must end with `Modal` for modals).
6. `data-testid` attributes are kebab-case.
7. Functions are verbNoun. Never append `Async` to async function names.
8. Booleans use `is`/`has`/`can`/`should`/`needs` and read as a sentence.
9. `<style>` is scoped by default.
10. `v-if` and `v-for` never coexist on the same element. Wrap one in `<template>`.
11. `v-for` keys are mandatory and combine intent with a unique id (kebab-case).
12. The router file is the one scoped exception to the named-export rule. Everything else uses named exports.

## Project structure

```text
src/
├── assets/                  # build-processed assets
├── clients/                 # HTTP client wrappers
├── components/
│   ├── icons/               # PascalCase + Icon suffix, direct import
│   ├── layout/              # Header, Footer, Sidebar
│   └── <domain>/            # forms, feedback, navigation, etc.
├── composables/             # shared use<Name>.ts
├── config/                  # api.ts, dates.ts, pinia.ts, routes/
├── locales/                 # i18n (optional)
├── models/                  # domain types
├── pages/
│   └── <MODULE>/
│       ├── <Feature>.vue
│       ├── components/      # module-scoped components
│       └── composables/     # module-scoped composables
├── plugins/                 # app.use(...) registrations
├── queries/                 # server-state hooks (*.query.ts)
├── router/                  # routes.ts, guards/, routes/<module>.ts
├── services/
│   ├── api/                 # fetch wrappers + models/ for DTOs
│   └── <MODULE>/            # domain service functions
├── stores/<MODULE>/         # one store per module
├── types/                   # .d.ts and utility types
├── utils/                   # framework-agnostic helpers
└── main.ts
tests/
├── e2e/<ModuleName>/        # *.e2e.ts (Playwright)
├── fixtures/<module>/       # shared JSON + factories
└── helpers/                 # auth.helpers.ts, etc.
```

Rules: each module is self-contained under `pages/<MODULE>` with colocated `components/` and `composables/`. Promote a file to `src/components/<domain>/` or `src/composables/` once two modules reuse it. No bucket folders: every directory owns a single responsibility. `models/` holds domain types. `types/` holds utility types and `.d.ts` shims. `public/` is for files served verbatim. `src/assets/` is for build-processed files.

## Casing

| What | Case | Example |
|---|---|---|
| Variables, refs, functions | camelCase | `userName`, `fetchOrders`, `isLoading` |
| Constants | SCREAMING_SNAKE_CASE | `API_BASE_URL`, `MAX_RETRY_COUNT` |
| Types, interfaces, classes, enums | PascalCase | `UserDTO`, `CartStore`, `HttpMethod` |
| Vue components | PascalCase | `ProfileCard.vue`, `CartModal.vue` |
| TS, JS, JSON files (non-component) | camelCase | `userService.ts`, `config.json` |
| Test files | PascalCase | `CartStore.spec.ts`, `Auth.e2e.ts` |
| `data-testid` | kebab-case | `checkout-submit-button` |

Acronyms: in camelCase or PascalCase, capitalize only the first letter (`fetchUrl`, `HttpClient`). In SCREAMING_SNAKE_CASE, keep the acronym uppercase (`API_BASE_URL`). Collections are plural (`users`). Loop variables take the singular form (`for (const user of users)`). Env vars are SCREAMING_SNAKE_CASE; client-exposed vars MUST prefix `VITE_` and access through `import.meta.env`. API DTOs may keep backend snake_case; app domain types use PascalCase properties.

- ✅ boolean naming: `isActive`, `hasPermission`, `canSubmit`. ❌ `active`, `permissionFlag`, `submitable`.
- ✅ function naming: `getUserById`, `createOrder`, `fetchOrders`. ❌ `getUser`, `order`, `userFetch`.
- ✅ ref naming: `const user = ref(0)`. ❌ `const userRef = ref(0)`, `const Count = ref(0)`.

## Components (S5)

```vue
<script setup lang="ts">
// 1. imports
// 2. interface/types (export Props type)
// 3. configuration constants
// 4. props
// 5. emits
// 6. composable instances
// 7. non-reactive constants
// 8. inject usage
// 9. component refs
// 10. component reactive
// 11. computed
// 12. provide
// 13. watchers / watchEffect
// 14. const functions
// 15. functions (getters, actions, events)
// 16. lifecycle hooks
// 17. defineExpose
</script>
<template></template>
<style scoped></style>
```

Naming: base components use a primitive name only (`Button.vue`, `Input.vue`). Shared components live in `src/components/<domain>/` (layout, forms, feedback, navigation). Page components live at `src/pages/<Feature>/<Feature>.vue` (PascalCase, no `Index.vue` or `Page.vue` suffix). Feature components end with intent: `UserForm.vue`, `OrderList.vue`, `ProductCard.vue`, `OrderSummary.vue`. Modal components MUST end with `Modal`. Icons live in `src/components/icons/`, are PascalCase with the `Icon` suffix, and are imported directly by file (no barrel).

Template rules: `useTemplateRef` for template refs. `useId` for unique ARIA ids. Prefer `v-if` over `v-show` unless toggling frequently. Use `<template>` as wrapper for `v-for` and `v-if`. Keys combine intent + unique id: ``:key="`user-item-${user.id}`"``. Avoid array indexes unless the list is static. Props and emits are kebab-case in templates. Accessibility first: semantic HTML, ARIA attributes, alt text.

-`v-if` and `v-for` split: `<template v-if="show"><div v-for="u in users" :key="`user-${u.id}`" />`. ❌ `<div v-if="show" v-for="u in users" />`.

Attribute order: `is` / `v-for` / `v-if`/`v-else`/`v-show` / `v-pre`/`v-once` / `id` / `ref`/`key` / `v-model` / bound props / static props / native attributes / events / `v-html`/`v-text`.

Two-way binding: use `defineModel` only for true two-way reusable surfaces. Keep the default binding named `modelValue`. Prefix additional bindings with `model` (capped at 2-3; refactor beyond that).

Styling: scoped CSS by default, class selectors over element selectors, BEM-style names, utility classes preferred when a framework is present. For multiple dynamic classes use object or array syntax.

Props and events: each component exports a Props interface. Stop at six props (group the rest into an object). Never mutate props. Emit action-like lowercase names (`save`, `close`, `submit`). Declare `defineEmits` with explicit array signatures. Do not prefix callbacks with `on`.

- ✅ props limit: max 6 props (`title`, `userId`, `variant`, `disabled`, `loading`, `onClose`); group the rest into a `config` or `model` object. ❌ a single component with 12 loose props.

Slots: declare with `defineSlots` and type slot props. Single-word or camelCase names; destructure slot props in the template.

Dependency injection: type every key with `InjectionKey<T>`. Always pass a fallback to `inject`. Reserve provide/inject for plugin-level or library-internal wiring.

Perf: prefer templates over render functions; never use JSX. Virtualize or paginate lists past 200 items. Renderless components and HOCs multiply instance count; justify each one. Use Vapor mode only after profiling shows the need.

## Composables (S6)

Principles: `use` prefix, single responsibility, stable public surface, import-safe (no side effects on import).

Naming: `use<Name>` in camelCase, file mirrors the name. Domain suites prefix to avoid collisions (`useAuthSession`). Do not shadow framework names (`useRoute`, `useRouter`, `useSlots`, `useId`).

Typing: explicit return type `Use<Name>Result`. Use `Ref<T>` / `ComputedRef<T>`. When more than 1-2 params, accept a typed `<Composable>Args` object with safe defaults. Use `MaybeRefOrGetter<T>` for reactive inputs and unwrap with `toValue()`.

Stable return order: reactive `ref` first, then `computed`, then methods. Never reorder, rename, or remove keys without a plan.

Async: expose `load`/`refresh` functions or return `isLoading`, `error`, and `data` refs. Wrap API errors in the result instead of throwing unless the failure is unrecoverable.

Lifecycle: pair setup with teardown inside the composable. Never trigger lifecycle hooks at module import time. Watchers created inside the composable bind to the consumer's scope.

Usage: instantiate once per `<script setup>`. The variable name is the composable name without the `use` prefix (`const cart = useCart()`). Reference members through the instance. Never destructure the returned object.

- ✅ no destructure: `const auth = useAuth(); auth.profile`. ❌ `const { profile } = useAuth()`.

Boundary: Pinia owns global app state. Composables own domain and UI behavior and may orchestrate stores without replacing them.

## Pinia / state (S7)

Path: `src/stores/<MODULE>/<module>.store.ts`. Name: `use<Module>Store`. Pinia id: `<module>Store` in camelCase, registered in `PiniaStoreId` enum at `src/config/pinia.ts` (PascalCase members, camelCase values). Use Setup Stores. Declare exported `State*` interfaces.

Behavior: actions stay pure; delegate complex transforms to service functions in `src/services/<MODULE>/`. No index barrels under `stores/`. Compose other stores from actions or getters, never circular reads in `setup()`.

Component usage: never destructure the store directly. Use `storeToRefs(store)` for state and getters; destructure actions from the store itself. Tie `$subscribe` / `$onAction` returns to `onUnmounted`. Prefer `store.$patch()` for batched updates.

Persistence: `pinia-plugin-persistedstate`. Persist UI preferences, cart state, feature toggles. Never persist secrets, tokens, or PII. Use server-set httpOnly cookies for auth state.

Decision table:

| Scenario | Layer |
|---|---|
| UI toggle, wizard step, drawer state | Pinia store |
| Persisted preference (theme, lang) | Pinia + persistedstate |
| Entity list, detail, pagination, filters | Query/Mutation layer |
| Cache-aware invalidation and refetch | Query/Mutation layer |
| Complex payload transform | Service functions called by store |

Anti-patterns: persisting secrets or PII; mirroring server collections; injecting router or global singletons into store state.

## Queries and mutations (S8)

Path: `src/queries/<entity>.query.ts`, camelCase, named exports only. Queries: `use<Entity>Query` or `use<Entity><Variant>Query`. Mutations: `use<Entity><Action>Mutation`. Wrap API functions from `src/services/api/<entity>.ts`. Queries and related mutations live in the same file.

Keys: one factory per file (`<entity>Keys`) with structured arrays (``[`'module'`, `'entity'`, params] as const``). Never import factories across domains. Key arg interfaces end with `QueryKeyArgs`.

Hooks: accept a single Args object that merges variables with `Partial<UseQueryOptions<...>>`. Default the object to `{}`. Variables use `MaybeRefOrGetter`. Strip wrapper-controlled keys with `Omit<>` so callers cannot override them via `...options`.

Do not hardcode `enabled` inside the hook. The component controls it. Never build keys inline.

Mutations: args interfaces end with `MutationArgs`. Update the cache after success. Extract shared invalidation logic into helpers.

Usage: instantiate once per setup. Variable name mirrors the hook without the `use` prefix. Do not destructure. Wrap user actions in small handlers with `try`/`catch` around `mutateAsync`. Reserve mutation callbacks (`onSuccess`/`onError`/`onSettled`) for non-visual side effects such as cache updates and logging.

| Concern | Pinia Colada | TanStack Query |
|---|---|---|
| Key option | `key: () => ...` (getter) | `queryKey: ...` (static or getter) |
| Fetch option | `query: () => ...` | `queryFn: ...` |
| Mutation option | `mutation: ...` | `mutationFn: ...` |
| Loading flag (query) | `query.isLoading` (ShallowRef) | `query.isFetching` (covers background refetch) |
| Loading flag (mutation) | `mutation.isLoading` (ComputedRef) | `mutation.isPending` |
| Invalidate cache | `useQueryCache().invalidateQueries({ key })` | `queryClient.invalidateQueries({ queryKey })` |
| Force refetch | n/a (use `invalidateQueries`) | `queryClient.refetchQueries({ queryKey })` |
| Drop cache | n/a (use `invalidateQueries`) | `queryClient.removeQueries({ queryKey })` |
| Optimistic update | `queryCache.setQueryData` + rollback in `onError` | `queryClient.setQueryData` + rollback in `onError` |
| Paginated UX | `placeholderData: (prev) => prev` | `placeholderData: keepPreviousData` |

Delete the row that does not apply to the project's chosen library.

## TypeScript (S9)

Prefer `interface` for object shapes that may be extended or merged; prefer `type` for unions, mapped types, intersections. Use `unknown` for values not yet typed and narrow with type guards. Keep DTOs in `src/services/api/models/` with `Response` or `Request` suffix. Keep type definitions close to usage.

Use `ReturnType<typeof fn>` instead of duplicating return shapes. Use `as const` for literal configs. Document exported types with JSDoc. Generic parameters start with `T` and stay descriptive (`TItem`, `TResponse`).

`any` is strongly discouraged. If it is unavoidable, disable the lint rule inline and add a comment that explains why. Avoid the non-null assertion `!`; narrow, use optional chaining, or supply a default. Exception requires inline lint disable plus a justification comment.

- ✅ unknown over any: `function parseValue(value: unknown): number`. ❌ `function parseValue(value: any): number`.
- ✅ non-null assertion avoided: `if (el) { el.focus() }`. ❌ `el!.focus()`.

Prefer string literal unions over enums; use `enum` only when an external API or SDK forces it; avoid `const enum` in shared libraries.

Global declarations live in `src/types/`, camelCase filenames with `.d.ts` suffix, no `index.d.ts` catch-alls. Enable `strict`, `noImplicitAny`, `strictNullChecks`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `useUnknownInCatchVariables`, `isolatedModules`, `allowSyntheticDefaultImports`, `esModuleInterop`, `skipLibCheck`, `noEmit`. Use `vue-tsc --noEmit` in CI and locally.

Options object: when a function takes two or more optional params, group them in `Args` and prefer destructuring in the signature. Name the interface `<FunctionName>Args` in PascalCase.

Return types: let inference handle local or private functions. Fix the contract for exported functions, type guards, overloads, ambiguous generics, factories, and composables (`UseXxxResult`). API wrappers return domain types, never raw DTOs. Prefer `satisfies` over inline `as`.

- ✅ inline types avoided: `interface UserParams { id: string }` then `function getUser(params: UserParams)`. ❌ `function getUser(params: { id: string })`.

Prefer `@ts-expect-error` for type tests. `@ts-ignore` is allowed only with an explanatory comment and a tracker link. Strip the directive once the upstream fix lands.

Use `MaybeRefOrGetter<T>` for reactive inputs and unwrap with `toValue()`. Strip wrapper-controlled options with `Omit<>` so callers cannot override them via spread.

## Routing (S10)

Router instance lives in `src/router/` and is the one scoped exception to the named-export rule. Use `createWebHistory(import.meta.env.VITE_PUBLIC_PATH)`. Register global navigation guards in this file or split them into modules.

Routes live in `src/router/routes.ts` as an array of `RouteRecordRaw`. Each module exports a default array from `src/router/routes/<module>.ts` (camelCase), spread into the main array. Use `() => import(...)` for ordinary page boundaries. Eager-import only the shell, bootstrap path, auth callback, layout, or a Vue/server bridge when startup requires it.

Paths are kebab-case. Declare `meta` on route records. Extend `vue-router` `RouteMeta` in `src/types/route-meta.d.ts`. Prefer `redirect: { name: ROUTE_NAMES.X }` over hardcoded paths; retain path redirects only for server-owned, legacy, or external handoffs.

Route names live in `src/config/routes/<module>.ts` as `[MODULE]_ROUTE_NAMES` with `as const`. Keys are `[MODULE]_[ROUTE NAME]` in screaming case. Values are kebab-case route names. Structure stays flat. `src/config/routes/names.ts` spreads all modules into one `ROUTE_NAMES` object.

- ✅ route name values: `'user-profile'`. ❌ `'UserProfile'`.
- ✅ constant keys: `USER_PROFILE`, `AUTH_LOGIN`. ❌ `userProfile`, `AuthLogin`.

Page components live at `src/pages/<Feature>/<Feature>.vue` (PascalCase). Avoid `Index.vue` or `Page.vue` suffixes.

Guards: one per file in `src/router/guards/<intent>.ts`. Default-export each, named `<intent>Guard`. Use `beforeEach` for blocking or redirecting; `beforeEnter` for route-record checks; `beforeResolve` for async preparation after in-component guards; `afterEach` for confirmed-navigation side effects (never to block).

Permissions: declare `permissions` on `route.meta` as an array of `{ rules, redirect? }`. Rules are strings (matching a permission key or feature flag) or functions `(ctx: PermissionContext) => boolean`. One global guard evaluates them; register on `beforeResolve` when it needs data loaded by earlier resolvers, otherwise on `beforeEach`. The guard flattens `to.matched`, so a parent permission covers every child.

Avoid in-component guards (`onBeforeRouteLeave`, `beforeRouteEnter`, `beforeRouteUpdate`) unless the logic depends on component instance state. If used, document the reason with a comment.

Prefetch: declare `route.meta.prefetch` and trigger with a `v-prefetch` directive on hover. Access router through `useRouter()` / `useRoute()` inside components.

Approved meta keys: `title`, `requiresAuth`, `requiredPermission`, `permissions`, `prefetch`, `layout`, `analyticsName`, `hasServerRenderedContent`, `serverRendered`.

## Testing (S11)

Playwright for E2E. Vitest plus Testing Library for unit and integration. Use `test()` by default; reserve `describe` for grouped scenarios. Colocate specs with the subject; filenames are PascalCase (`Button.spec.ts`).

Unit tests cover shared components and utilities. Aim for 5 or more focused cases per unit; add cases only when they test real behavior. Integration tests cover pages and page-scoped components through the project's `render` helper. Vue Test Utils is reserved for low-level mechanics that Testing Library cannot express. Mock API calls with MSW. Use realistic, domain-shaped data; never `foo`/`bar`/`123`.

E2E: organize under `tests/e2e/<ModuleName>/<ModuleName>.e2e.ts`. Scope each spec to one user journey. Run against `PLAYWRIGHT_BASE_URL`; never hardcode URLs. Use controlled test accounts and isolated, seeded data. Never send test traffic to production. Use the real API for staging E2E. Cypress only as a legacy alternative.

Test titles stay under 120 chars, concise, behavior-focused; avoid vague phrases like `works correctly`. Prefer assertions over manual `throw`. Run unit and integration on every PR. Run coverage in CI with a project-configured minimum; target 75-85% coverage. Run E2E on PRs when a controlled env is available. Run a staging E2E gate before production releases.

Testing Library: prefer `getByRole`, `getByLabel`, and other accessible queries. Use `findBy*` for async elements; use `waitFor` for observable conditions. Never use `setTimeout` or manual polling. `data-testid` is a kebab-case fallback when no accessible query fits. Prefer explicit assertions over snapshots.

Mocks and fixtures: shared fixtures live in `tests/fixtures/<module>/`. Create one MSW server in shared setup (`server.listen()` in `beforeAll`, `server.resetHandlers()` in `afterEach`, `server.close()` in `afterAll`). Override handlers per test; do not mutate the shared handler list.

## Delivery (S12)

Lint and format:
- Prettier: `printWidth: 120`, `trailingComma: 'all'`, `semi: true`, `singleQuote: true`, 2-space indent, `bracketSameLine: true`, `endOfLine: 'lf'`.
- Curly braces are mandatory for all control statements.
- Inline rule suppressions MUST document why and keep scope minimal (`eslint-disable-next-line` plus a comment).
- Husky plus lint-staged run ESLint and Prettier on staged files; mirror in CI.

Commits and PRs:
- Format: `<type>(<scope>): <summary>`. Summary ≤ 100 chars, imperative, lowercase.
- Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.
- Branch names: `<type>/<short-description>` in lowercase kebab-case.
- Squash merge is mandatory. Each PR becomes one commit on `main`, with the PR number appended: `(#<PR_NUMBER>)`.
- PR size ≤ 400 LOC diff (tests excluded).

Doc comments:
- JSDoc on public surfaces: functions, composables, stores, services, DTO mappers, props, emits, slots, exported types. Explain why, not what.
- Lines stay under 120 chars.
- In TS, `@param` and `@returns` are optional; in JS they are mandatory. Use `@throws` for non-trivial error cases.
- `@deprecated` MUST explain why and point to an alternative. `@see` MUST include a valid URL.
- TODO format: `// TODO(<owner>|<team>): <actionable message> [ref: <tracker-id|url>]`. FIXME follows the same shape and marks correctness, security, or perf bugs at high priority. Both MUST include owner and tracker ref. Remove when resolved.
- Prefer `@ts-expect-error` for type tests. `@ts-ignore` only with a justification comment and a tracker link; remove once the upstream fix lands.

## Audit checklist

Walk this list when auditing. Each line cites the section number (S5-S12).

- S2: every Vue component declares `defineOptions({ name })` and uses `<script setup lang="ts">`.
- S4: every name matches the casing table; `data-testid` is kebab-case; client env vars start with `VITE_`.
- S5: script-setup order is correct; `v-if` and `v-for` never coexist; every `v-for` has a key; components stop at six props; scoped CSS by default; `useTemplateRef`/`useId` over manual patterns.
- S6: composables have a `use` prefix; a typed `UseXxxResult` exists; instance is never destructured; async composables expose `load`/`refresh` or `isLoading`/`error`/`data`.
- S7: one store per module; Setup Store; Pinia id registered in the enum; never destructure the store; never persist secrets; no router or singleton injected into store state.
- S8: queries and mutations live in `src/queries/` with named exports; one key factory per file; cache updates after mutation success; mutation callbacks reserved for non-visual side effects.
- S9: `vue-tsc --noEmit` passes; no `any` or `!` without inline justification; exported functions have explicit return types; DTOs stay in `services/api/models/`.
- S10: paths kebab-case; meta declared; router is the only default export; guards one per file with `<intent>Guard` naming; permissions declared on meta, evaluated by one global guard.
- S11: tests colocated with the subject; titles ≤ 120 chars; MSW for API mocks; `getByRole`/`getByLabel` preferred; coverage above the project's configured minimum.
- S12: Prettier settings match; commit messages ≤ 100 chars with allowed types; squash-merge only with PR number suffix; TODO/FIXME carry owner and tracker ref.
- PR diff ≤ 400 LOC; commit summary ≤ 100 chars; line length ≤ 120 chars; coverage target 75-85%; modal filename ends with `Modal`; modal events `close`/`save`/etc., never `onClose`.

**Line count: 300 lines (target 280-360, ceiling 400).**