Vue Conventions
SkillAI & modelsEsposter Vue 3 SFC conventions — macro ordering, script-setup declaration order, template attribute ordering and template conventions, inlining single-use functions and handlers, v-model vs split bindings, never normalizeString in Vue, optional refs, useTemplateRef, computed by cost, identity and cadence, map lookups, the watch decision tree plus watch aliases, and every rendered date being a NuxtTime — plus deep dives on lifecycle-hook placement with browser globals via window. and SSR guards via checkIsServer, inline handlers, forms and upsert mode, the auth session, computed extraction, template gotchas (v-html, dotted slots, closure narrowing, template casts), the compiled-out Options API runtime, and date rendering. Apply when writing or reviewing .vue files, or rendering a date or time.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Vue Conventions skill
What this skill tells your AI
The instructions your AI receives, as published by esposter/esposter in .agents/skills/vue/SKILL.md and read by ahel’s review.
<script setup lang="ts">at the top of every SFC. Prefer attributify over<style>blocks; when a block is genuinely needed, thestylingskill owns its rules (scoped,lang="scss", library CSS).- Self-closing tags for empty components/elements:
<Component />. - Blank-line placement (templates, consts, returns, blocks) and comment attachment — see the
formattingskill. - Links,
:to,navigateTo, reactive route reads, route validation, page keys and route-synced tabs — see theroutingskill. All Vuetify-specific conventions — see thevuetifyskill.
Vue Macro Ordering
defineSlots → defineModel → defineProps → defineEmits, then all const assignments, then defineExpose last (preceded by a blank line, before any watch/lifecycle hooks).
defineModel: always type explicitly, and for booleans pass{ default: false }so the type excludesundefined. Never declare one unless the value is used in script (watch,computed, or passed) — otherwise use:prop+@event. An unnamed model's variable ismodelValue, nevermodelor another alias; a named model's variable matches the name (const title = defineModel<string>("title")).defineSlots: only assign toconst slotswhenslotsis referenced in script. Otherwise calldefineSlots<...>()without assignment.
Script Setup Declaration Order
- Page-metadata side-effects —
useHead,useSeoMetanear the top, above the macros when they depend on no local state; one reading reactive state sits just after that state, still above unrelated logic. - Macros — see above. No blank line between the macros and the declarations that follow.
- Framework / third-party value composables —
useNuxtApp,useRoute,useRouter,useRuntimeConfig, VueUse value composables (useVDisplay,useWindowSize, …), auth (authClient.useSession). Grouped immediately after the macros. - Custom Pinia stores —
useXStore+storeToRefs+ destructured methods; the per-store grouping order is thepiniaskill's. - Custom composables, refs, computeds, watches, functions — everything else.
useHead({ titleTemplate: ... }); // 0. static page metadata — top, may precede macros
defineSlots<{ default: () => VNode }>();
const { $trpc } = useNuxtApp(); // 2. third-party
const fooStore = useFooStore(); // 3. custom store
const { currentFoo } = storeToRefs(fooStore);
const fooName = useFooName(...); // 4. custom composable / state
Never leave a framework value composable stranded at the bottom below custom stores and refs. Exceptions that stay in category 4: useTemplateRef (a ref — group with refs), and side-effect registrations that depend on local state (useEventListener, a useSeoMeta reading store refs) which must stay after the state they depend on.
Single-use functions must be inlined — references/inline-handlers.md
Read it when naming, extracting or reviewing a function used once. A single-use function that only defers a block must be inlined; the discriminator is whether its name states its trigger (onMount, handleX — inline it, including template handlers however long) or its result (getFooType — keep it, single use is fine). The page owns every form of ceremony, the template-scope exception, and the list of legitimate keeps.
- Prefer
useEventListenerover manualaddEventListener/removeEventListener— it auto-removes on unmount, so the handler can be inlined; the target and SSR rules are on that page. - Never destructure event parameters —
(event: KeyboardEvent) => { event.key ... }, not({ key }). Destructuring event methods (preventDefault, …) causes "Illegal invocation" via lostthisbinding. Keep the fulleventobject even when only reading properties.
v-model, Inputs and Forms — references/forms.md
Read it when an input needs the split :model-value + @update:model-value form, or when a form handles both create and edit (the isCreate prop, a single values ref).
- Prefer
v-model="ref"over the split form whenever the update is a direct assignment to a single ref. - Never apply
normalizeString(or any trimming) anywhere in Vue, and trust the server schema — tRPC input schemas already normalize, validity is asafeParseof the shared schema driving:disabled, and submit handlers pass raw values with no guards. Both rules in full, including what dirty-state comparison parses, are on that page.
Template Attribute Ordering
v-model(orv-for+:key) — binding/iteration directives firstclass— static class string- UnoCSS attributify props — shorthand utilities as props (
ma-2,flex,flex-col) - Component props with values —
:prop="value"/prop="string"(alphabetical) - Shorthand boolean props — bare names defaulting to
true(clearable,autofocus) - Event handlers —
@event="..."last
<v-text-field
v-model="search"
ma-2
density="compact"
label="Search"
autofocus
clearable
@keydown.enter.stop="submit()"
/>
Template Conventions
- Truthiness —
v-if="value", notv-if="value !== null". Explicit null/undefined comparisons only when distinguishing falsy values (0valid,falsemeaningful,nullvsundefinedmatters). - No bare function references in
@eventbindings — a bare ref forwards the event object as first arg (almost always unintended). Usefn()for zero-arg calls, an arrow function when args are needed:@complete="(a, b) => useFoo(a, b)". v-fordestructuring — destructure when properties are accessed (v-for="{ value, icon, title } of items"); keep a full reference only when the whole object is needed (passed as prop or stored), naming the loop var to match the target prop for:propNameshorthand.v-bindshorthand — the:forms (including:="object"and same-name:prop) are autofixed byvue/v-bind-stylewithsameNameShorthand: "always"(packages/configuration/eslint/overrides/vueRules.js);pnpm lint:fixsettles it.- Never use
.valuein templates — Vue auto-unwraps refs, soref.valuereads.valueon the unwrapped object (usuallyundefined). Writefn(ref);.valueis for<script setup>only. - No allocating expressions in render positions —
Object.*in a:prop,v-forsource or{{ }}allocates a fresh reference every render. Enforced byvue/no-restricted-syntax, whose message states the fix. - Event modifiers over raw event methods —
@click.stop,@keydown.enter.prevent(vue/no-restricted-syntax). Raw calls stay correct where no modifier can encode the trigger: behind a runtime guard, and in programmatic listeners (useEventListener,onKeyStroke, TiptaponKeyDown).stopImmediatePropagation()is banned outright — it couples behaviour to listener registration order. - Reassigning a
defineModelvs mutating it in place is a deliberate semantic choice — don't "fix" one into the other. references/template-gotchas.md— read it when a directive or slot renders nothing, or vue-tsc cannot see a template identifier:v-htmlon a component, a dotted slot name, a guard that stops narrowing at a closure inside an inline handler, and why a type-only import is enough for a template cast.
Props, Refs & Computed
definePropstakes a locally declaredinterface Props— the file path already spells the component, so the name carries none of it, and there is nothing left to decide per folder. Theprops-interfaceoxlint plugin enforces the name and the inline-object-literal ban.- A shape another file reads is not exported from the SFC — it moves to its own
.tsbeside the component that owns it (Styled/Dialog.vue→Styled/DialogActivatorSlotProps.ts), named after its single export the way any other module is, and the component imports it back. A props shape shared outside its own subtree is a sign the shape belongs to a model rather than to a component.props-interface/no-exported-typeenforces the export half; where the file lands is yours.
- A shape another file reads is not exported from the SFC — it moves to its own
- Prop shorthand naming — when binding a simple local
ref/computeddirectly to a prop, name it to match that prop so the:propshorthand works (const fooType = ref(...)→:fooType). Doesn't apply to complex expressions (:src="session.user.image") or nameddefineModelvariables. A module-scope constant is not a local, so it keeps the constant casing and the bind is written out (:items="PIN_ITEMS",:button-props="DELETE_BUTTON_PROPS") — the casing is what says the value is fixed for the life of the process, which is worth more at the use site than the shorthand is. - Optional refs omit the initial value —
ref<string>()infersRef<string | undefined>; neverref<string | undefined>(undefined). - Template refs always use
useTemplateRef— no generic (Vue 3.5+ infers from the template), noRefsuffix, name matching theref="..."value (const video = useTemplateRef("video")). Drop any component type imported only for the generic. A generic is justified only where inference falls short: the element doesn't expose the property you want, or the inferred union is too complex to work with. - Sort at display time — apply
.toSorted()in thecomputedthat feeds the template; never in store ingestion (readX,setX, mutation helpers). Stores hold natural order; components transform for display. Exception: sort before the API call when sorted order is sent to the backend (message pagination cursors). - Computed by cost and identity, never by use count —
references/computed-extraction.md— read it before extracting or inlining anycomputed. A computed is a cache with a price, so it earns its place on reuse (binds to 2+ props), work (parses, formats, filters, maps, sorts, reduces, walks a collection), or identity (allocates an object/array/function bound to a prop). Everything else inlines: comparisons, booleans, ternaries, template literals, property reads, arithmetic, map lookups. The page owns the traps, the keeps that override cost, and the cadence question — work run per event that only changes per boundary. - Map lookups over computed — when a value depends on an enum/discriminant key, use
Map[type]directly in the template (Map[type].valuefor multiple properties). Fall back to a computed only when the lookup is duplicated in 2+ places. - Writable computed over
watch+ local ref — when a local value is entirely derived from and writes back to a store value, replace theref+watchwith acomputed({ get, set }).
Reading the auth session — references/auth-session.md
Read it when anything needs the signed-in user. Two call forms, and the access shape follows the form: await authClient.useSession(useFetch) in async SSR-relevant context (session.value?.user.id), the bare authClient.useSession() wherever you can't await (session.value.data?.user.id).
When (not) to watch — references/watch-decision-tree.md
Read it before writing any watch, or when a local ref mirrors a prop/store value. In short: a read-only derived value is a computed; form state initialized from a prop/store initializes the ref directly (watchImmediate to set an initial value is always a smell) and resyncs via useCloned; an id the instance is keyed by cannot change, so read it once in onMounted. Watching is correct for bridging imperative APIs (Phaser, Tiptap, Desmos) and for async side effects of state that genuinely varies under a live instance.
watchDeep/watchImmediatereplace the option object onwatch, andno-restricted-syntaxdecides it — in.tsas much as in.vue, since awatchsits in a store or a composable as often as in a component. What the rule cannot see is that both aliases are VueUse via Nuxt auto-imports, so they exist inapps/webonly: a published package taking on VueUse for an alias would push that dependency onto every consumer, which is why the one site inpackages/vue-phaserjskeeps the option object behind a disable. When both flags are needed the aliases still compose —watchDeep(source, cb, { immediate: true })(alphabetical: deep before immediate).- Never
watchEffect— alwayswatchwith explicit dependencies; implicit tracking is hard to audit and re-runs on unrelated changes. Wrap a prop dependency in a getter:watch(() => isActive, ...).
Lifecycle hooks and the browser — references/lifecycle-and-ssr.md
A browser global read at setup scope runs on the server too, and the failure is a render that never reaches the client. Placing a lifecycle hook, or touching window, document or anything else the server does not have, is that page.
The Options API runtime is compiled out — references/options-api.md
Ours are <script setup> only (vue/component-api-style, which bans plain defineComponent(…) with it). vue.optionsApi is off and stays off, so a dependency shipping Options API .vue components mounts and then dereferences off undefined with nothing thrown to name the cause. Read the page before adding one, or when a node_modules component renders blank — it also covers why a component test of it still passes.
Dates Are <NuxtTime> — references/dates.md
Every rendered date is a <NuxtTime>; formatDate(…), toLocaleDateString(), useTimeAgo and useDateFormat inside a .vue are vue/no-restricted-syntax errors, and a hand-written <time> is a vue/no-restricted-html-elements one. Standard: apps/web/content/docs/architecture/date-time-display.md. Read the page for what the lint rule can't say — options rather than format strings, what bare title really renders, and the client-rendered message list as the one exception.
Signals
- GitHub stars
- 23
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
vue-esposter- Source
- github.com/esposter/esposter