Agent skill
vue
Esposter Vue 3 SFC conventions — macro ordering, template patterns, watch aliases, composable return style, component type correctness, and co-location. Apply when writing .vue files or composables.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/other/other/vue-esposter-esposter
SKILL.md
Vue Conventions
SFC Structure & Formatting
<script setup lang="ts">at the top of every SFC.- Always use
lang="scss"in Vue<style>blocks. - Use self-closing tags for components/elements without content:
<Component />. - No blank lines within Vue templates.
- No blank lines between
constassignments — group them tightly together. - No blank line before
returnwhen it immediately follows aconstassignment in a small function. - Composables that return a function directly: no blank line between the last
constassignment and thereturn— thereturnline immediately follows the last setup line with no gap. - Remove comments — make variable names descriptive instead. When comments are necessary, no blank line before or after the comment — attach it directly to the code it describes.
- Minimise blank lines; group related code tightly.
- Blank line after a closing
}of anif,for, or other block statement — unless it is the last statement in its scope or is immediately followed by another opening block.
Vue Macro Ordering
defineSlots → defineModel → defineProps → defineEmits (in this order), then all const assignments, then defineExpose last (preceded by a blank line, before any watch/lifecycle hooks).
Props Interface Naming
- Always use
interface {ComponentName}Props(e.g.interface DialogProps,interface EditDialogButtonProps) - Always call
defineProps<{ComponentName}Props>()
Inline Functions & Macros
- Inline arrow functions where argument types can be inferred from context — don't extract single-use, trivially-typed lambdas into named functions.
- Inline Vue event handlers — always write handlers directly in the template (
@submit="async (_, onComplete) => { ... }"). This lets Vue infer event argument types automatically. Only extract to a named function if the same logic is reused in multiple places (e.g. called from both a button click AND a keydown handler). Single-use handlers must always be inlined, no exceptions. - IME composition guard — when handling
@keydown.enteron text inputs, guard inline against IME composition so that confirming a CJK candidate doesn't prematurely commit:@keydown.enter.stop="!$event.isComposing && commitEdit()". defineModel: always type explicitly. For booleans, you must pass{ default: false }so the type does not implicitly includeundefined(defineModel<boolean>({ default: false })).defineSlots: only assign to a variable whenslotsis actually referenced in script —const slots = defineSlots<{ ... }>(). Ifslotsis not used in script (e.g. the template uses<slot>tags directly), calldefineSlots<...>()without assignment.- No abbreviated parameter names — use full descriptive names (e.g.
eventnote,columnnotcol,configurationnotconfig,dataSourcenotsource,relativePositionnotrelPos,positionnotpos,previousnotprev). Exception: simple iteration callbacks where the meaning is obvious from context (e.g..filter((row, index) => ...)). - No abbreviated function names — use full descriptive names (e.g.
goToPreviousnotgoToPrev,initializenotinit,calculatenotcalc). onUpdate:*handler parameters — always name the parameternew{PropName}in camelCase:'onUpdate:itemsPerPage': (newItemsPerPage) => { ... },'onUpdate:page': (newPage) => { ... },'onUpdate:modelValue': (newModelValue) => { ... }.- Never destructure event parameters — always use
(event: KeyboardEvent) => { event.key ... }not({ key }: KeyboardEvent) => { key ... }. Destructuring event methods (e.g.preventDefault,stopPropagation) causes "Illegal invocation" because they lose theirthisbinding. Keep the fulleventobject for consistency even when only accessing properties. @clickshorthands — if a click handler is a single async call, use@click="myAsyncFn(args)"directly — no need to wrap inasync () => { await myAsyncFn(args) }.- Never declare
defineModelunless the value is actually used in script (e.g. in awatch,computed, or passed somewhere). Don't create a model just to forward it — use:prop+@eventinstead.
Template Conventions
- No bare function references in
@eventbindings — always wrap in an explicit arrow function:@complete="(scene, tilemap) => useCreateTilemapAssets(scene, tilemap)"not@complete="useCreateTilemapAssets". Bare references cause accidental argument forwarding (extra Vue-internal args get passed). This mirrors the TypeScript rule: never pass a naked function reference. v-fordestructuring — always destructurev-forbindings when properties are accessed in the template:v-for="{ value, icon, title } of items"notv-for="item of items"+item.value. Only keep a full reference when the whole object is needed (e.g. passed as a prop or stored in a ref). In that case, name the loop variable to match the prop it will be passed to, enabling:propNameshorthand.- Prop shorthand naming — name local variables to match their target prop so Vue's
:propNameshorthand works without explicit assignment. For example, if the prop isdataSourceType, the local variable must also bedataSourceType. #activatoralways first — in components that use both#activatorand other slots (e.g.v-tooltip,v-menu), always place the#activatortemplate as the first child.- Slot names with dots always use dynamic binding — Vue does not support dots in static slot names, so Vuetify item slots always require the bracket syntax:
#[item.drag],#[item.actions]. Only plain names without dots can be static (e.g.#top,#activator). - Always use
:shorthand instead ofv-bind:propName— write:disabled="..."notv-bind:disabled="...". The object-spread formv-bind="object"has no shorthand and stays as-is. - Never use
.valuein templates — Vue auto-unwraps refs in template expressions. Writingref.valuein a template accesses.valueon the already-unwrapped object (not on the ref), which is almost alwaysundefined. Writefn(ref)notfn(ref.value)..valueis only needed in<script setup>(outside template expressions).
Refs & Computed
- Template refs — always use
useTemplateReffor both component and HTML element refs. Never suffix the variable withRef—const errorIcon = useTemplateRef(...)notconst errorIconRef = useTemplateRef(...).- Components:
useTemplateRef<InstanceType<typeof ComponentName>>("name") - HTML elements:
useTemplateRef("container")— no explicit type annotation needed, Vue infers it. Use a generic semantic name like"container", never the element tag name (not"spanRef", not"divRef").
- Components:
- Boolean computed naming — use
is*prefix for boolean computed refs (e.g.,isUndoable,isRedoable,isSavable). Do not usecan*. - Computed for reused expressions — extract a
computed(named to match the prop, e.g.title) when the same derived value is bound to two or more props. This enables the:propNameshorthand for one binding and avoids repeating the expression:const title = computed(() => ...)→:title :tooltip-text="title". No need for a computed if the value is only used in one place. - Inline prop values — inline prop values directly in the template to take advantage of Vue TypeScript inference. Only extract to a
computedwhen the same logic is reused in multiple places. Single-use derived values stay inline. - Map lookups over computed — when a value depends on an enum/discriminant key, use a
Map[type]lookup directly in the template instead of a computed. If multiple properties are needed from the same map entry, useMap[type].value. Only fall back to computed when the same map lookup is duplicated in two or more places.
Conditional Logic
When branching on a type/discriminant, use in this priority order:
- Map lookup —
Map[type]inline in template (preferred) switchexpression — use aswitchin script when a map is impracticalif / else if / else— explicit branches for complex conditions- Never chain standalone
ifstatements for mutually exclusive conditions. Always useelse if/elseor aswitch.
Generic SFC Components
When a component's model value type (or other prop type) depends on an enum/discriminant key, make the component generic:
<script setup lang="ts" generic="TKey extends SomeEnum">
// SomeEnum is a string enum (e.g. SomeEnum.A = "A"), so interface keys are string literals:
interface ModelValueMap {
A: boolean | null;
B: string | null;
}
const modelValue = defineModel<ModelValueMap[TKey]>({ required: true });
</script>
- Use
interface(nottype) for the value map — string enum values map directly to string literal interface keys - Define the interface locally in the component (not exported unless reused elsewhere)
- The map type drives inference at call sites where the key type is statically known
- For
as const satisfiesmaps, useRecord<Exclude<TEnum, ExcludedVariant>, ValueType>to explicitly exclude variants that use a different component path (e.g. Boolean → checkbox, not text field) - If TypeScript cannot narrow the generic type parameter
TKeyin template v-if/v-else branches (correlated generics limitation), fall back to the union type of all possible values (e.g.ColumnValue) fordefineModel— the prop type still provides inference at call sites
After Finishing Code Changes
- Run
pnpm formatfrom the repo root — formats all packages at once (~1.6s, oxfmt). - Run
pnpm typecheckinpackages/appas a background task — takes too long to block on. The user reviews results when ready.
Watch Aliases
Prefer watchDeep(source, cb) over watch(source, cb, { deep: true }) and watchImmediate(source, cb) over watch(source, cb, { immediate: true }). When both flags are needed, use watchDeep(source, cb, { immediate: true }) (alphabetical: deep before immediate).
Vue Hooks
- Always place
watch,onMounted,onUnmounted, and other Vue lifecycle hooks/watchers at the bottom of<script setup>, after allconstassignments. - Always put a blank line before them to visually separate them from regular
constassignments. - Always wrap the callback in an explicit arrow function — never pass a function reference directly. This avoids scope/binding issues and prevents accidental argument forwarding:
onUnmounted(() => { reset(); })notonUnmounted(reset). - This applies everywhere —
.map(),.filter(), event handlers, lifecycle hooks, etc. Always usearray.map((item) => fn(item))notarray.map(fn).
Unwrapping Reactive Proxies
- Always use
toRawDeepfrom@esposter/sharedinstead of Vue'stoRaw—toRawonly unwraps one level, whiletoRawDeeprecursively unwraps all nested reactive proxies. This is critical when passing reactive data to APIs that require plain objects (e.g. IndexedDBstore.put(),structuredClone, postMessage).
Resource Management
- Always clean up in
onUnmounted: intervals, timeouts, animation frames, event listeners. - Prefer
VueUsecomposables over manual event listeners where possible.
Online/Offline Detection
- Always use
useOnline()from VueUse — never usenavigator.onLinedirectly orgetIsServer()+navigator.onLineguards useOnline()returns a reactiveRef<boolean>that updates ononline/offlineevents- SSR-safe: defaults to
trueon the server (nonavigatoraccess, no crash) - For subscribables (tRPC subscriptions, WebSocket connections), use
useOnlineSubscribablewhich combinesuseOnline()+onMounted+watchImmediate+onUnmountedcleanup into a single composable — seecomposables/shared/useOnlineSubscribable.ts
Browser-Only Composables (SSR Safety)
Regular watch/watchDeep are SSR-safe — they don't fire until the source changes (which only happens client-side). Set them up directly in setup(), not inside onMounted. Vue automatically scopes them to the component and disposes them on unmount — no manual WatchHandle[] + onUnmounted cleanup needed.
export const useBrowserFeature = () => {
const store = useSomeStore();
const { someRef } = storeToRefs(store);
const online = useOnline();
// Safe: watchDeep/watch only fire on changes (client-side)
watchDeep(someRef, (value) => {
// Safe to use indexedDB, etc. here
});
watch(someOtherRef, async (value) => {
if (!value || online.value) return;
// ...
});
};
watchImmediate is the SSR concern — it executes the callback during setup(), which runs on the server. If the callback accesses browser APIs, use watchTriggerable + onMounted to defer the first execution (see useOnlineSubscribable):
const { trigger } = watchTriggerable(source, (value) => {
// Browser-only logic
});
onMounted(async () => {
await trigger();
});
Composables
- Never use
createSharedComposable— VueUse'screateSharedComposablecreates global singletons that bypass Pinia's devtools, HMR, and reactive reset behavior. All shared reactive state must live in a Pinia store (defineStore). Composables that previously usedcreateSharedComposableshould be either replaced by a store entirely, or made thin wrappers that delegate to the corresponding store. - Single-function composables return the function directly — when a composable only exposes one function, return it directly instead of wrapping in an object:
return async (...) => { ... }. Callers useconst fn = useX()instead ofconst { fn } = useX(). Promise.resolve(value)for sync-to-async — when a sync expression needs to satisfy aPromise<T>return type, usePromise.resolve(value)instead ofasync () => value.
Vuetify
See the vuetify skill for all Vuetify-specific conventions: v-btn tooltips, select items, dialog form validity, and keyboard shortcut components.
Component Type Correctness
Match each component's props and model types exactly to the data it handles — don't mix concerns by using union types and compensating with v-if + null-coalescing inside a single component.
- If logic differs per variant (e.g. date formatting for
DateColumnvs plain text forColumn<String>), split into separate focused components (FieldInputDate.vue,FieldInputText.vue) - Each component should access its props directly without defensive coalescing (e.g.
column.formatnotcolumn.type === ColumnType.Date ? column.format : "") - A dispatcher component (e.g.
FieldInput.vue) is acceptable at the routing level to delegate to the right sub-component — type casts in the dispatcher are necessary at that boundary and acceptable
Component Co-location (Folder = Auto-import Prefix)
Group components with the same prefix into a folder — Nuxt auto-imports components with the folder path as prefix, so co-located components share the prefix automatically without repeating it in filenames.
components/TableEditor/File/Row/FieldInput.vue→ auto-import:TableEditorFileRowFieldInputcomponents/TableEditor/File/Row/FieldInputDate.vue→ auto-import:TableEditorFileRowFieldInputDate- The folder
Row/provides theTableEditorFileRowprefix — no need to repeat in the filename
File Length
- Target 50–100 lines per
.vuefile — a file consistently over 100 lines is a yellow flag that a slot, sub-component, or composable extraction is overdue. - Extract toolbar/header buttons into a dedicated slot component (e.g.
TopSlot.vue), row/column action menus into anActionSlot.vue, and logically grouped controls into their own focused component. - Complex or rare layout components (e.g. a rich data table with drag-and-drop, pagination, and find/replace) may exceed 100 lines — treat it as a prompt to reconsider, not an absolute rule.
Slot Extraction (Complex Components)
When a component has many named slots where each slot's content is non-trivial, extract each slot's content into its own dedicated component. Name the component after the slot it fills (e.g. #tfoot → FooterSlot.vue, #top → TopSlot.vue, #[item.actions] → ActionSlot.vue).
The extracted component:
- Receives the minimum props needed to derive its content (e.g.
dataSource) - Pulls shared state from the same stores the parent uses (e.g.
useFilterStore) - Lives in the same folder as the parent so the auto-import prefix is shared
<!-- Before: inline slot content in Table.vue -->
<template #tfoot>
<tr>
<td v-for="column of displayColumns" :key="column.id">{{ summaries.get(column.name) }}</td>
</tr>
</template>
<!-- After: extracted to FooterSlot.vue, used in Table.vue -->
<template #tfoot>
<TableEditorFileRowFooterSlot :data-source="dataSource" />
</template>
This keeps the parent component lean and makes each slot independently readable and testable.
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?