@gallopsystems/agent-skills 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/plugins/nuxt-nitro-api/.claude-plugin/plugin.json +1 -1
  4. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/SKILL.md +23 -8
  5. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/auth-patterns.md +1 -0
  6. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/caching.md +68 -0
  7. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/composables-utils.md +31 -1
  8. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/error-handling.md +74 -0
  9. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/fetch-patterns.md +18 -0
  10. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/formatters.md +11 -0
  11. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/layers.md +40 -0
  12. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/page-structure.md +1 -0
  13. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/route-rules.md +41 -0
  14. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/server-runtime.md +97 -0
  15. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/ssr-client.md +60 -0
  16. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/state-management.md +68 -0
  17. package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/storage.md +59 -0
  18. package/plugins/volt-primevue/skills/volt-primevue/SKILL.md +56 -12
  19. package/plugins/volt-primevue/skills/volt-primevue/config.md +51 -0
  20. package/plugins/volt-primevue/skills/volt-primevue/gotchas.md +49 -0
  21. package/plugins/volt-primevue/skills/volt-primevue/theming.md +20 -6
  22. package/plugins/vue-nuxt/.claude-plugin/plugin.json +8 -0
  23. package/plugins/vue-nuxt/skills/vue-nuxt/SKILL.md +48 -0
  24. package/plugins/vue-nuxt/skills/vue-nuxt/auto-imports.md +48 -0
  25. package/plugins/vue-nuxt/skills/vue-nuxt/component-authoring.md +159 -0
  26. package/plugins/vue-nuxt/skills/vue-nuxt/composables.md +95 -0
  27. package/plugins/vue-nuxt/skills/vue-nuxt/reactivity.md +133 -0
  28. package/plugins/vue-nuxt/skills/vue-nuxt/slots.md +139 -0
  29. package/plugins/vue-nuxt/skills/vue-nuxt/template-idioms.md +142 -0
  30. package/plugins/vue-nuxt/skills/vue-nuxt/v-model.md +106 -0
  31. package/plugins/vue-nuxt/skills/vue-nuxt/watch.md +194 -0
@@ -0,0 +1,95 @@
1
+ # Authoring a composable
2
+
3
+ How to *write* a composable's reactive surface. The **decision** of composable vs
4
+ plain util lives in `nuxt-nitro-api/composables-utils.md` (logic that touches Vue
5
+ reactivity/lifecycle → composable; pure transform → util). This file is the
6
+ Vue-shaped mechanics: argument shape, what to return, and cleanup.
7
+
8
+ ## Accept ref-or-getter-or-value; normalize with `toValue`
9
+
10
+ A reactive input should accept a plain value, a `ref`, **or** a getter — typed
11
+ `MaybeRefOrGetter<T>` and read through `toValue()` inside the effect. Don't branch
12
+ on `isRef`/`unref`, and don't read `.value` once at the top (that snapshots and
13
+ loses reactivity):
14
+
15
+ ```ts
16
+ import { toValue, type MaybeRefOrGetter } from 'vue'
17
+
18
+ export function useDoubled(input: MaybeRefOrGetter<number>) {
19
+ // re-reads through toValue on every recompute → tracks input if it's reactive
20
+ return computed(() => toValue(input) * 2) // caller passes 3, ref(3), or () => count.value
21
+ }
22
+ ```
23
+
24
+ `toValue(x)` returns `x` for a plain value, `x.value` for a ref, `x()` for a
25
+ getter. This is the VueUse-shaped contract and the single biggest "make a
26
+ composable ergonomic" move — callers shouldn't have to wrap a literal in `ref()`
27
+ just to call you.
28
+
29
+ ## Return a plain object of refs — not `reactive()`
30
+
31
+ Return refs/computeds in a **plain object**. A caller destructures the result, and
32
+ a plain object of refs survives destructuring; a `reactive()` return does not (the
33
+ properties detach into plain values). See [reactivity.md](./reactivity.md) for the
34
+ `ref`-over-`reactive` rule this mirrors.
35
+
36
+ ```ts
37
+ export function usePager(total: MaybeRefOrGetter<number>) {
38
+ const page = ref(1)
39
+ const atEnd = computed(() => page.value >= toValue(total))
40
+ return { page, atEnd } // ✅ const { page } = usePager(n) stays reactive
41
+ // ❌ return reactive({ page, atEnd }) → const { page } = … is a dead number
42
+ }
43
+ ```
44
+
45
+ ## Thin composable: pure core, reactive shell
46
+
47
+ Keep business logic in plain functions (no Vue import — trivially unit-testable,
48
+ no Nuxt context needed) and let the composable be a thin reactive wrapper that
49
+ wires that logic to `ref`/`computed`. This is "functional core, imperative shell"
50
+ applied to composables, and it matters here specifically: plain `*.test.ts` files
51
+ **don't** get Nuxt's auto-import context (see [auto-imports.md](./auto-imports.md)),
52
+ so pure functions test without `mountSuspended`.
53
+
54
+ ```ts
55
+ // core.ts — pure, no Vue, unit-test directly
56
+ export const clampPage = (p: number, total: number) => Math.min(Math.max(p, 1), total)
57
+
58
+ // usePager.ts — thin shell
59
+ export function usePager(total: MaybeRefOrGetter<number>) {
60
+ const page = ref(1)
61
+ return { page, go: (p: number) => (page.value = clampPage(p, toValue(total))) }
62
+ }
63
+ ```
64
+
65
+ ## Clean up with `onScopeDispose` — not only `onUnmounted`
66
+
67
+ A composable that starts a listener/timer/observer must tear it down. Inside a
68
+ composable, register cleanup with **`onScopeDispose`** rather than `onUnmounted`:
69
+ it fires on component unmount too, but *also* works when the composable runs in a
70
+ detached `effectScope()` (a shared singleton, a manually-stopped scope) where
71
+ there's no component instance and `onUnmounted` would silently no-op.
72
+
73
+ ```ts
74
+ export function useNow(intervalMs = 1000) {
75
+ const now = ref(Date.now())
76
+ const id = setInterval(() => (now.value = Date.now()), intervalMs)
77
+ onScopeDispose(() => clearInterval(id)) // fires on unmount OR scope.stop()
78
+ return { now }
79
+ }
80
+ ```
81
+
82
+ For a composable that itself creates watchers/computeds you want to dispose as a
83
+ unit (a store you stand up and tear down by hand), wrap them in an
84
+ **`effectScope()`** and stop the whole scope at once:
85
+
86
+ ```ts
87
+ const scope = effectScope()
88
+ scope.run(() => { /* watchers/computeds created here */ })
89
+ // later: scope.stop() → disposes every effect created inside, and runs onScopeDispose
90
+ ```
91
+
92
+ `useTemplateRef`, `MaybeRefOrGetter`, and the "return refs" rule together let a
93
+ composable own DOM refs and reactive inputs internally instead of demanding the
94
+ caller thread them in — see [reactivity.md](./reactivity.md) and
95
+ [component-authoring.md](./component-authoring.md).
@@ -0,0 +1,133 @@
1
+ # Reactivity gotchas
2
+
3
+ Derivation, prop sync, DOM measurement, remounting, and cleanup. For the
4
+ `watch`-vs-`computed` decision and the `watch` smell catalog, see
5
+ [watch.md](./watch.md).
6
+
7
+ ## Keep computed getters pure
8
+
9
+ A `computed` getter derives and returns — nothing else. Mutating another ref/Set,
10
+ firing an async request, or touching the DOM inside a getter makes it
11
+ order-dependent, can re-trigger itself, and is undebuggable. Move side effects to
12
+ a `watch` or an event handler. (And never mutate a computed's *return* value —
13
+ it's a read-only snapshot; update the source state instead.)
14
+
15
+ ## Mutate a ref's object in place — don't reassign
16
+
17
+ When a ref's object is bound to `v-model` inputs, update it **in place**;
18
+ reassigning a fresh literal swaps the proxied target the template tracks, and
19
+ later programmatic writes to the old reference are lost:
20
+
21
+ ```ts
22
+ // ❌ form.value = { ...next } // swaps the tracked proxy
23
+ // ✅
24
+ Object.assign(form.value, next) // or per-key assignment
25
+ ```
26
+
27
+ ## Default to `ref`; reach for `reactive` rarely
28
+
29
+ `ref` is the default for all state. Use `reactive` only to **group** tightly
30
+ related fields or to wrap a non-Vue object (`Map`/`Set`). `reactive`'s traps are
31
+ why: it loses reactivity when **destructured** (the keys become plain values —
32
+ `toRefs` to recover) and when **reassigned wholesale** (the proxy identity swaps).
33
+ A `ref` has neither problem — `.value` is always the live cell.
34
+
35
+ ```ts
36
+ const state = reactive({ count: 0 })
37
+ const { count } = state // ❌ plain number now, not reactive
38
+ const { count } = toRefs(state) // ✅ if you must destructure a reactive
39
+ // Rule: default ref(); reactive() only to group state or wrap Map/Set.
40
+ ```
41
+
42
+ ## Template refs: prefer `useTemplateRef`
43
+
44
+ To reach a DOM element or child component, use **`useTemplateRef('name')`** (Vue
45
+ 3.5 / Nuxt 4) — pass the string that matches `ref="name"` in the template. It
46
+ self-documents that the value is an element/component handle, infers the type, and
47
+ lets a composable own the ref internally. The legacy "declare `const el =
48
+ ref(null)` whose variable name must match `ref="el"`" form still works but is
49
+ fragile to rename drift — prefer `useTemplateRef` in new code.
50
+
51
+ ```ts
52
+ const input = useTemplateRef('input') // <input ref="input">
53
+ onMounted(() => input.value?.focus())
54
+ // v-for: useTemplateRef returns an ARRAY, and its order is NOT guaranteed —
55
+ // key/sort yourself rather than trusting index alignment.
56
+ ```
57
+
58
+ ## DOM-measured computeds need a re-measure signal
59
+
60
+ DOM size isn't reactive, so a `computed` reading live geometry
61
+ (`offsetLeft`/`offsetWidth`) won't recompute on resize or a breakpoint flip. Add
62
+ an explicit version ref and bump it from a `ResizeObserver`:
63
+
64
+ ```ts
65
+ const container = useTemplateRef('container') // <div ref="container">
66
+ const layoutVersion = ref(0)
67
+ let ro: ResizeObserver | null = null
68
+ onMounted(() => { ro = new ResizeObserver(() => layoutVersion.value++); ro.observe(container.value!) })
69
+ onBeforeUnmount(() => ro?.disconnect())
70
+
71
+ const indicator = computed(() => {
72
+ void layoutVersion.value // subscribe to re-measure
73
+ const b = buttons.value[active.value]
74
+ return b ? { left: `${b.offsetLeft}px`, width: `${b.offsetWidth}px` } : { left: '0px', width: '0px' }
75
+ })
76
+ ```
77
+
78
+ Measure conditionally-mounted elements only **after `await nextTick()`** (and hold
79
+ them via a null-guarded template ref) — right after toggling `visible`, the
80
+ element isn't laid out and a sync `getBoundingClientRect()` reads 0.
81
+
82
+ ## React to prop changes with a watch on a getter
83
+
84
+ Props aren't directly watchable — watch a getter, and seed local state with
85
+ `{ immediate: true }`:
86
+
87
+ ```ts
88
+ watch(() => props.thing, (next) => { local.value = next }, { immediate: true })
89
+ ```
90
+
91
+ The legitimate prop→local case is **owning an editable draft**: a parent owns
92
+ server data via `useFetch` (keep `refresh`), passes it as a prop; the child clones
93
+ it into a local draft (`structuredClone(toRaw(prop))`), re-syncs on a watch of the
94
+ prop, and emits `@saved`/`@updated` so the parent re-fetches. Copy into local
95
+ state — never mutate the prop. (A bare `local = ref(props.x)` + sync watch with no
96
+ emit-back is the `prop-sync` smell — use `defineModel`; see [v-model.md](./v-model.md).)
97
+
98
+ ## Remount on identity change with `:key`
99
+
100
+ ```vue
101
+ <UserDetail :key="route.params.id" :id="route.params.id" />
102
+ ```
103
+
104
+ Bind `:key` to the **identifying value** so the component remounts cleanly when
105
+ identity changes. Do NOT abuse an incrementing `:key="bump++"` to force a data
106
+ re-pull — that destroys child state/scroll; use a watcher or `refresh()` instead.
107
+ When a *full remount* genuinely is the goal (reset all of a child's internal
108
+ state), bumping `:key` is the right tool — and `$forceUpdate()` is the wrong one
109
+ (it re-renders but skips computeds and bypasses the reactivity graph; if you
110
+ "need" it, you have a reactivity bug to fix instead).
111
+
112
+ ## Always pair setup with teardown
113
+
114
+ Anything started in `onMounted` — `addEventListener`, `setInterval`,
115
+ `ResizeObserver`, `MutationObserver` — must be torn down in
116
+ `onUnmounted`/`onBeforeUnmount` (`removeEventListener`, `clearInterval`,
117
+ `disconnect`). Keep the handle in a module-scope `let` so teardown can reach it.
118
+ Inside a **composable**, register teardown with `onScopeDispose` instead so it
119
+ also fires for a detached `effectScope` — see [composables.md](./composables.md).
120
+
121
+ ## `shallowRef` for large or wholesale-replaced data
122
+
123
+ Deep reactivity has a cost: `ref`/`reactive` recursively proxy every nested
124
+ property. For a large list, a third-party class instance (a `Map`, an editor/map
125
+ object you drive imperatively), or data you always **replace wholesale** rather
126
+ than mutate, use `shallowRef` (or `shallowReactive`) — only `.value` reassignment
127
+ triggers, nested mutation does not.
128
+
129
+ ```ts
130
+ const rows = shallowRef<Row[]>([])
131
+ rows.value = [...rows.value, next] // ✅ replace .value — triggers
132
+ // rows.value.push(next) // ❌ no trigger under shallowRef (use triggerRef if you must mutate)
133
+ ```
@@ -0,0 +1,139 @@
1
+ # Slots & reusability
2
+
3
+ Slots are how a component accepts **markup** from its caller. Reach for a slot
4
+ whenever a component is a *container* for caller-provided content — cards, panels,
5
+ menus, layouts, list rows, buttons.
6
+
7
+ ## Slots beat props for markup and open-endedness
8
+
9
+ A prop can carry a string, but not a chunk of markup, and it forces you to
10
+ pre-plan every variant. If a `<Button>` takes `type: 'primary' | 'secondary'`, a
11
+ caller can't add a third look without you editing `Button`. A slot is
12
+ open-ended — the caller passes whatever they need:
13
+
14
+ ```vue
15
+ <!-- ❌ prop must anticipate every case -->
16
+ <Button label="Save" type="primary" />
17
+ <!-- ✅ slot accepts any content -->
18
+ <Button @click="save">Save <Spinner v-if="saving" /></Button>
19
+ ```
20
+
21
+ Rule of thumb: **prop for data, slot for markup.** If you find yourself adding a
22
+ prop just to toggle a bit of caller-specific UI, that UI belongs in a slot.
23
+
24
+ ## Named slots + the `#` shorthand
25
+
26
+ ```vue
27
+ <!-- Card.vue -->
28
+ <template>
29
+ <article>
30
+ <header><slot name="header" /></header>
31
+ <slot /> <!-- default slot -->
32
+ <footer><slot name="footer" /></footer>
33
+ </article>
34
+ </template>
35
+
36
+ <!-- caller -->
37
+ <Card>
38
+ <template #header><h2>Title</h2></template> <!-- #header == v-slot:header -->
39
+ Body content goes in the default slot.
40
+ <template #footer><Button>OK</Button></template>
41
+ </Card>
42
+ ```
43
+
44
+ ## Fallback (default) content
45
+
46
+ Content between the `<slot>` tags renders when the caller provides nothing:
47
+
48
+ ```vue
49
+ <slot name="empty">No results yet.</slot>
50
+ ```
51
+
52
+ ## Scoped slots — the mental model
53
+
54
+ A scoped slot is **a function the parent supplies that returns markup; the child
55
+ calls it with data.** The child exposes values by binding them on `<slot>`; the
56
+ caller receives them via the slot prop:
57
+
58
+ ```vue
59
+ <!-- List.vue — child owns iteration, caller owns each row's markup -->
60
+ <template>
61
+ <ul>
62
+ <li v-for="item in items" :key="item.id">
63
+ <slot :item="item" :index="index" /> <!-- expose data to the slot -->
64
+ </li>
65
+ </ul>
66
+ </template>
67
+
68
+ <!-- caller -->
69
+ <List :items="invoices">
70
+ <template #default="{ item }">
71
+ <strong>{{ item.number }}</strong> — {{ item.total }}
72
+ </template>
73
+ </List>
74
+ ```
75
+
76
+ This is the core reusability move: the child encapsulates *logic* (fetching,
77
+ iterating, state) while the caller decides *presentation*.
78
+
79
+ ## Typing & inspecting slots (Composition API)
80
+
81
+ - **Type** the slots a component accepts with `defineSlots` (compile-time only,
82
+ like `defineProps`):
83
+
84
+ ```ts
85
+ defineSlots<{
86
+ default(props: { item: Invoice; index: number }): any
87
+ header(): any
88
+ }>()
89
+ ```
90
+
91
+ - **Inspect** which slots the caller actually passed with `useSlots()` — use it to
92
+ avoid rendering an empty wrapper:
93
+
94
+ ```vue
95
+ <script setup lang="ts">
96
+ const slots = useSlots()
97
+ </script>
98
+ <template>
99
+ <!-- only render the styled footer wrapper if a footer slot was given -->
100
+ <footer v-if="slots.footer" class="border-t p-4"><slot name="footer" /></footer>
101
+ </template>
102
+ ```
103
+
104
+ (In a template you can also test `$slots.footer` directly.) Without the guard
105
+ you ship an empty `<footer>` whose padding/border still affects layout.
106
+
107
+ ## Forwarding & splitting slots
108
+
109
+ - **Forward** a slot through a wrapper so the inner component receives it:
110
+
111
+ ```vue
112
+ <Inner>
113
+ <template v-for="(_, name) in $slots" #[name]="scope">
114
+ <slot :name="name" v-bind="scope" />
115
+ </template>
116
+ </Inner>
117
+ ```
118
+
119
+ - **Split** one incoming slot into two render positions by branching with `v-if`
120
+ on a condition, each `<slot>` keeping its own fallback.
121
+
122
+ ## Slot transitions need keyed content
123
+
124
+ Wrapping a `<slot>` in `<Transition>` only animates if the slotted content is
125
+ **keyed**, so Vue can tell one state from the next:
126
+
127
+ ```vue
128
+ <Transition name="fade" mode="out-in">
129
+ <component :is="current" :key="current" />
130
+ </Transition>
131
+ ```
132
+
133
+ ## Reusable ≠ big
134
+
135
+ Small components are worth extracting too. A three-line `OverflowMenu` that always
136
+ pairs the same trigger icon + a11y wiring is worth a component: every use stays
137
+ identical, and a change happens in one place. Extract the genuinely shared
138
+ surface; leave caller-specific chrome in the caller (see the "what to extract"
139
+ note in [component-authoring.md](./component-authoring.md)).
@@ -0,0 +1,142 @@
1
+ # Template & vue-tsc idioms
2
+
3
+ Smaller Vue-shaped template patterns and the compile errors they prevent.
4
+
5
+ ## Never two handlers of the same event on one element
6
+
7
+ Two `@keyup.*` (or two `@keydown.*`) modifier handlers on one element both compile
8
+ to a single `onKeyup` object property → vue-tsc fails with **TS1117 "duplicate
9
+ property"**, and only one wires up at runtime. Use a single handler and branch:
10
+
11
+ ```vue
12
+ <!-- ❌ <input @keyup.enter="submit" @keyup.esc="cancel" /> -->
13
+ <input @keyup="onKey" />
14
+ <!-- function onKey(e) { if (e.key === 'Enter') submit(); else if (e.key === 'Escape') cancel() } -->
15
+ ```
16
+
17
+ ## Scoped-CSS reach: `:deep()`, `:slotted()`, `:global()`
18
+
19
+ Scoped CSS adds a data attribute that only matches the component's own elements.
20
+ Three pseudo-selectors reach past that boundary:
21
+
22
+ ```vue
23
+ <style scoped>
24
+ .prose :deep(table) { @apply w-full; } /* child/3rd-party-rendered HTML (markdown, rich text) */
25
+ :slotted(p) { @apply mt-2; } /* content the PARENT passed into a <slot> */
26
+ :global(body) { @apply antialiased; } /* escape scope to a global rule */
27
+ </style>
28
+ ```
29
+
30
+ `:deep()` for descendants the component renders dynamically, `:slotted()` for
31
+ slot content the caller supplied (scoped styles don't reach it by default), and
32
+ `:global()` for the occasional global rule without a second `<style>` block.
33
+
34
+ ## Click-outside: match a unique marker class
35
+
36
+ Scope document click-outside detection to a **purpose-named marker class** added
37
+ solely for it — never `target.closest('.relative.flex')` or other structural
38
+ Tailwind selectors that silently match unrelated elements:
39
+
40
+ ```vue
41
+ <div class="send-menu relative …">
42
+ <!-- if (!e.target.closest('.send-menu')) close() -->
43
+ ```
44
+
45
+ ## `<NuxtLink>` and a thin `app.vue`
46
+
47
+ Use `<NuxtLink to="…">` for internal navigation (client routing + prefetch), not a
48
+ raw `<a>`. Keep `app.vue` a thin shell — `<NuxtLayout><NuxtPage /></NuxtLayout>`
49
+ plus app-global hosts (a `<Toast />`, a confirm dialog). `NuxtLink`/`NuxtLayout`/
50
+ `NuxtPage` are auto-imported.
51
+
52
+ ## Per-page `<head>` with `useHead`
53
+
54
+ Set the document title/head reactively and SSR-safely with `useHead` in
55
+ `<script setup>` — never `document.title`. Set a default in `app.vue` and override
56
+ per page:
57
+
58
+ ```ts
59
+ useHead({ title: 'Invoices' }) // page
60
+ // app.vue: useHead({ titleTemplate: (t) => (t ? `${t} · Acme` : 'Acme') })
61
+ ```
62
+
63
+ ## Reset a file input after reading it
64
+
65
+ `<input type="file">` won't re-fire `change` for the same file twice — clear it
66
+ after handling so re-selecting the same file works:
67
+
68
+ ```ts
69
+ function onFile(e: Event) { const t = e.target as HTMLInputElement; /* …read t.files… */ t.value = '' }
70
+ ```
71
+
72
+ ## Status → label/severity lookup
73
+
74
+ Map enums to labels/severities with a small `Record` lookup + `||` fallback,
75
+ called as a plain function (functions, not `computed`, when they take a per-row
76
+ argument):
77
+
78
+ ```ts
79
+ const SEV: Record<Status, string> = { paid: 'success', overdue: 'danger' }
80
+ const sev = (s: Status) => SEV[s] || 'secondary'
81
+ ```
82
+
83
+ ## `v-bind` same-name shorthand
84
+
85
+ When a bound attribute matches the variable name (Vue 3.4+), drop the value —
86
+ `:src` expands to `:src="src"`. Terser, and worth recognizing when reading code:
87
+
88
+ ```vue
89
+ <img :src :alt /> <!-- ≡ :src="src" :alt="alt" -->
90
+ ```
91
+
92
+ ## `useId()` for stable, SSR-safe element IDs
93
+
94
+ Wiring `<label for>` / `aria-describedby` needs an ID that's stable across SSR and
95
+ hydration — a hand-rolled counter or `Math.random()` causes a hydration mismatch.
96
+ `useId()` (Vue 3.5, auto-imported) gives an app-unique, hydration-stable ID:
97
+
98
+ ```ts
99
+ const id = useId() // <label :for="id">Email</label> <input :id :aria-describedby="`${id}-help`">
100
+ ```
101
+
102
+ ## `<Teleport>` overlays out to `body`
103
+
104
+ Render modals/dropdowns/toasts to `<body>` so they escape a parent's
105
+ `overflow: hidden`, `z-index`, or `transform` stacking context (the usual cause of
106
+ a dialog clipped inside a scrolling panel):
107
+
108
+ ```vue
109
+ <Teleport to="body"><div class="modal">…</div></Teleport>
110
+ ```
111
+
112
+ `<Teleport>` is auto-imported. (Volt/PrimeVue overlays already teleport
113
+ internally; this is for your own hand-rolled overlays.)
114
+
115
+ ## `<KeepAlive>` caches toggled-component state
116
+
117
+ Wrap a dynamic `<component>`/`v-if` swap in `<KeepAlive>` to preserve a toggled
118
+ child's state (a half-filled form, scroll position) instead of remounting it.
119
+ Teardown gotcha: a cached component fires `onActivated`/`onDeactivated`, **not**
120
+ `onMounted`/`onUnmounted` — put pause/resume logic there, not in the mount hooks:
121
+
122
+ ```vue
123
+ <KeepAlive :include="['EditForm']"><component :is="currentTab" /></KeepAlive>
124
+ ```
125
+
126
+ ## `v-memo` / `v-once` for expensive render subtrees
127
+
128
+ Declarative render-skipping for big lists/tables. `v-once` renders a subtree once
129
+ and never updates it; `v-memo="[deps]"` skips a `v-for` row's re-render unless a
130
+ dep changes. Reach for these only when a large grid actually shows up in a profile
131
+ — not by default.
132
+
133
+ ```vue
134
+ <div v-for="row in rows" :key="row.id" v-memo="[row.id === selectedId]">…</div>
135
+ ```
136
+
137
+ ## One-off caveats (not general conventions)
138
+
139
+ These surfaced once each — apply only if they bite, don't treat as rules:
140
+
141
+ - **Confirm which motion library is installed** before animating: `motion-v` uses the component API (`<motion.div :initial :animate>`), while `@vueuse/motion` uses the `v-motion` directive (`:visible-once`). They're different packages.
142
+ - **A parent's `whitespace-nowrap`** (common on table `th`/`td`) is inherited by a child tooltip/popover and forces it onto one overflowing line — reset with `whitespace-normal break-words` on the bubble.
@@ -0,0 +1,106 @@
1
+ # `v-model` on components
2
+
3
+ Two sanctioned patterns for two-way binding. Default to `defineModel` for new
4
+ code; the computed-proxy is for forwarding an existing component's model.
5
+
6
+ ## `defineModel` — preferred (Vue 3.4+ / Nuxt 4)
7
+
8
+ ```ts
9
+ const model = defineModel<boolean>() // parent: v-model
10
+ const visible = defineModel<boolean>('visible', { required: true }) // parent: v-model:visible
11
+ ```
12
+
13
+ Read/write `model.value` directly — no `props` + `emit` boilerplate. Give
14
+ non-primitive models a factory default like any prop.
15
+
16
+ **Named models** give one component multiple independent two-way bindings:
17
+
18
+ ```ts
19
+ const years = defineModel<string>('years')
20
+ const months = defineModel<string>('months')
21
+ // parent: <DurationInput v-model:years="y" v-model:months="m" />
22
+ ```
23
+
24
+ **Transform on the boundary with `get`/`set`.** For a component's *own* model,
25
+ `defineModel` takes `get`/`set` transformers directly — no separate writable
26
+ `computed` needed (reserve that for forwarding *someone else's* model, below):
27
+
28
+ ```ts
29
+ const model = defineModel<string>({ get: (v) => v.toUpperCase(), set: (v) => v.trim() })
30
+ ```
31
+
32
+ **Custom `v-model` modifiers** (`v-model.capitalize`) arrive via the
33
+ `[model, modifiers]` tuple:
34
+
35
+ ```ts
36
+ const [model, modifiers] = defineModel<string>({
37
+ set: (v) => (modifiers.capitalize ? v[0].toUpperCase() + v.slice(1) : v),
38
+ })
39
+ ```
40
+
41
+ ## The computed-proxy — for forwarding an existing model
42
+
43
+ When you wrap a component that already has its own `v-model` (a Volt/PrimeVue
44
+ `Dialog`'s `visible`, say), or you need a named local handle or a value
45
+ transform, use a writable `computed` over `props` + `emit`:
46
+
47
+ ```ts
48
+ const props = defineProps<{ visible: boolean }>()
49
+ const emit = defineEmits<{ 'update:visible': [v: boolean] }>()
50
+ const visible = computed({ get: () => props.visible, set: (v) => emit('update:visible', v) })
51
+ // <Dialog v-model:visible="visible" />
52
+ ```
53
+
54
+ A `computed({ get, set })` transforms a *forwarded* value shape (date string ↔
55
+ `Date`) before writing back — for a component's **own** model, prefer
56
+ `defineModel`'s `get`/`set` (above) over a separate computed. Either way, do NOT
57
+ reach for two mirror `watch`es to do this; that's the `prop-sync` smell (see
58
+ [watch.md](./watch.md)).
59
+
60
+ ## Mutually-exclusive paired fields
61
+
62
+ When setting one field must clear its sibling, expose **two** typed `update:`
63
+ events and bind the inner widget with `:modelValue` + `@update:modelValue` (NOT
64
+ `v-model`) so your handler can emit one update and null the other in the same tick:
65
+
66
+ ```ts
67
+ const emit = defineEmits<{ 'update:years': [v: string | null]; 'update:months': [v: string | null] }>()
68
+ function setYears(v: string) { emit('update:years', v); emit('update:months', null) }
69
+ ```
70
+
71
+ A single `v-model` can't express "set A, clear B" atomically.
72
+
73
+ ## Controlled / uncontrolled: work standalone OR be parent-driven
74
+
75
+ For a component that should manage its own state **unless** a parent supplies the
76
+ value (a toggle that works alone but a parent can override), detect whether the
77
+ control prop was passed and fall back to internal state per render:
78
+
79
+ ```ts
80
+ const props = defineProps<{ open?: boolean }>() // undefined ⇒ uncontrolled
81
+ const emit = defineEmits<{ 'update:open': [v: boolean] }>()
82
+ const internal = ref(false)
83
+ const controlled = computed(() => props.open !== undefined)
84
+ const open = computed(() => (controlled.value ? props.open! : internal.value))
85
+ function toggle() {
86
+ if (controlled.value) emit('update:open', !props.open)
87
+ else internal.value = !internal.value
88
+ }
89
+ ```
90
+
91
+ This relies on `undefined` meaning "unset", so the control prop must **not** be a
92
+ bare `boolean` (the Boolean-casting trap coerces absent → `false`; see
93
+ [component-authoring.md](./component-authoring.md)) — type it `boolean | undefined`
94
+ and give it no `withDefaults` default.
95
+
96
+ ## Reset / lazy-load on open
97
+
98
+ To reset transient state or lazy-load when a dialog opens, **watch the bound
99
+ flag** (it covers every close path — overlay click, ESC, programmatic) — see the
100
+ "re-seed local state on dialog open" case in [watch.md](./watch.md).
101
+
102
+ ## Consistency caveat
103
+
104
+ A repo with hundreds of `props`+`emit`+`computed` `v-model`s and zero
105
+ `defineModel` means matching the manual pattern in that file. Introduce
106
+ `defineModel` deliberately, not as a drive-by in an otherwise-consistent file.