This document provides context and best practices for AI assistance on this Nuxt component library project.
Framework: Nuxt 4.3.0 with TypeScript Testing: Vitest 3.2.4 with @nuxt/test-utils Architecture: Component library with comprehensive testing and CI/CD
ref,reactive,computed,watch,nextTickuseState,useRoute,useRouter- Custom composables from
~/composables/
Exception: Only import type definitions and external packages explicitly.
<!-- ✅ CORRECT -->
<script setup lang="ts">
import type { PropType } from "vue";
const count = ref(0); // auto-imported
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough); // auto-imported
</script>
<!-- ❌ INCORRECT -->
<script setup lang="ts">
import { ref, computed } from "vue"; // Unnecessary in Nuxt
import type { PropType } from "vue";
</script>- Extend
BaseCheckboxRadioPropsinterface from~/types/forms/types.forms - Use
defineModel<T>()for v-model support with proper typing - Include
fieldHasError,theme,size,styleClassPassthroughprops - Apply theme via
data-themeattribute - Comprehensive test coverage with
mountSuspended
- Use
useStyleClassPassthrough()composable for styling flexibilityelementClasses: Computed reactive class stringupdateElementClasses(classes): Toggle classes on/off dynamicallyresetElementClasses(props.styleClassPassthrough): Reset to initial prop value- Watch prop changes and reset classes accordingly
- Dynamic slot patterns (e.g.,
component-{index}-{type}) — default to named dynamic slots (v-for="(_, name) in $slots", consumer controls slot names). Only use indexed slots (itemCountprop) when the count is needed for logic beyond the slot loop itself (e.g. aria linking across two parallel loops, z-index math). See.claude/skills/component-dynamic-slots.mdfor the full decision guide and a third "prefixed slot inference" pattern. - Leverage existing components when possible (composition over creation)
- CSS custom properties with
v-bind()for dynamic values - Functional base styles, allow HOC customization
<script setup lang="ts">
// Basic usage
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
// Advanced usage with dynamic class management
const { elementClasses, updateElementClasses, resetElementClasses } = useStyleClassPassthrough(
props.styleClassPassthrough
);
// Toggle classes conditionally (e.g., based on slots or state)
updateElementClasses(["has-left-button", "has-right-button"]);
// Watch for prop changes and reset
watch(
() => props.styleClassPassthrough,
() => {
resetElementClasses(props.styleClassPassthrough);
}
);
</script>Framework: Vitest with @nuxt/test-utils/runtime
Mount Function: Always use mountSuspended() for Nuxt components
Coverage: Test props, slots, reactivity, accessibility, error states
Location: Tests in {component-folder}/tests/ directory
// ✅ Standard test pattern
import { describe, it, expect, vi } from "vitest";
import { mountSuspended } from "@nuxt/test-utils/runtime";
import ComponentName from "../ComponentName.vue";
describe("ComponentName", () => {
it("mounts without error", async () => {
const wrapper = await mountSuspended(ComponentName);
expect(wrapper.vm).toBeTruthy();
});
});Component Instance Access:
// ✅ Proper TypeScript casting for component internals
interface ComponentInstance {
computedProp: { value: string };
refProp: HTMLElement | null;
}
const vm = wrapper.vm as unknown as ComponentInstance;
expect(vm.computedProp.value).toBe("expected");Fake Timers:
test/vitest.setup.ts calls vi.useFakeTimers() globally and cleans up in afterEach. Never call vi.useFakeTimers(), vi.useRealTimers(), or vi.runAllTimers() inside a test file — it conflicts with the global setup.
Use vi.advanceTimersByTime(ms) rather than vi.runAllTimers() to avoid firing auto-run timer chains that re-queue themselves (which would loop infinitely).
nextTick must be explicitly imported from "vue" in test files — auto-imports are component-only.
// ✅ Async component with image preloading
import { nextTick } from "vue";
let mockImage: { src: string; onload: (() => void) | null; onerror: (() => void) | null };
beforeEach(() => {
mockImage = { src: "", onload: null, onerror: null };
vi.stubGlobal("Image", vi.fn(() => mockImage));
// ⚠️ Do NOT call vi.unstubAllGlobals() in afterEach —
// it removes the global stubs from vitest.setup.ts ($fetch, etc.)
});
// Helper: mount then simulate first image load
async function mountAndLoad(wrapper) {
mockImage.onload?.();
await nextTick(); // let onMounted resume after Promise.race
vi.advanceTimersByTime(500); // fire loading timeout, not the 7s auto-run
await nextTick(); // let Vue update DOM
return wrapper;
}
// onerror needs one extra tick vs onload — callback → resolve → Promise.race → await resume
mockImage.onerror?.();
await nextTick();
await nextTick(); // extra tick for onerror promise chain
vi.advanceTimersByTime(500);
await nextTick();Browser API Mocking:
// ✅ Mock browser APIs (ResizeObserver, IntersectionObserver, etc.)
const mockResizeObserver = vi.fn(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
vi.stubGlobal("ResizeObserver", mockResizeObserver);DOM Element Testing:
// ✅ Test CSS custom properties and DOM manipulation
const element = wrapper.find(".component");
const style = (element.element as HTMLElement).style;
expect(style.getPropertyValue("--custom-prop")).toBe("expected-value");SVG and Dynamic Content:
// ✅ Test dynamically generated content (SVG paths, computed styles)
const vm = wrapper.vm as unknown as ComponentInstance;
vm.width = 200; // Set reactive property
await nextTick();
expect(vm.generatedPath.length).toBeGreaterThan(0);Architecture: CSS custom properties with BEM-like structure
Approach: Functional base styles, custom design via HOC style blocks
Theme System: data-theme attributes with CSS custom property overrides
Responsive: CSS Grid/Flexbox with container queries where supported
rem base: html font-size is set to 62.5%, making 1rem = 10px. Use this when calculating rem values (e.g. 1.6rem = 16px, 2.4rem = 24px).
/* ✅ Component styling pattern */
.component-name {
--_border-radius: var(--theme-border-radius, 0.5rem);
--_transition-duration: var(--theme-transition-duration, 300ms);
/* Functional base styles */
border-radius: var(--_border-radius);
transition: all var(--_transition-duration) ease;
}app/
├── components/
│ ├── component-name/
│ │ ├── ComponentName.vue
│ │ └── tests/
│ │ └── ComponentName.spec.ts
├── composables/
├── types/
│ ├── components/
│ └── forms/
└── pages/
- Strict mode: All components must pass TypeScript strict checks
- Interface definitions: Store in
~/types/{category}/directories - defineModel typing: Use union types for arrays/single values
- Consumer-facing component types: If a component defines an interface consumers need to import (e.g. a data-shape prop), don't leave it inline in the
.vuefile — inline types aren't importable by consuming apps. Move it toapp/types/components/<component-name>.d.tsand export it fromapp/types/components/index.tsso it's reachable asimport type { X } from "srcdev-nuxt-components". See.claude/skills/component-export-types.md.
Always use interface Props + withDefaults(defineProps<Props>(), {...}). Never use options-style defineProps({ propName: { type: ..., default: ... } }).
// ✅ Correct — modern typed props
interface Props {
tag?: "div" | "section"; // optional with union literal types
label?: string;
itemCount: number; // required — no `?`
columnCount?: 2 | 3 | 4 | 5 | 6;
gap?: string;
styleClassPassthrough?: string | string[];
}
const props = withDefaults(defineProps<Props>(), {
tag: "div",
label: "",
columnCount: 2,
gap: "1rem",
styleClassPassthrough: () => [], // array/object defaults use factory functions
});
// ✅ Proper defineModel typing
const model = defineModel<(string | number)[] | string | undefined>();- Prop hyphenation: ESLint (
vue/attribute-hyphenation) requires camelCase props to be written hyphenated in templates. Always use:item-count,:column-count,:style-class-passthrough— never the camelCase form. - Self-closing elements: Already covered in pitfalls — use explicit closing tags everywhere.
- Linting workflow: For ESLint auto-fixable issues after an edit, save the file and let IDE auto-fix run first. Only attempt manual corrections if issues remain.
- Hyphenated attributes in tests: When a component uses a hyphenated Vue prop like
:tab-index, Vue renders it as the literaltab-indexDOM attribute — nottabindex. Assert withattributes("tab-index"), notattributes("tabindex").
@nuxt/image auto-detects Vercel and generates /_vercel/image?url=... URLs. In deployed Storybook (storybook-static/), source images aren't present so this fails. Three changes are required together:
-
.storybook/main.ts— setprocess.env.STORYBOOK = "true"at the top of the file, and addstaticDirs: ["../public"]inside the config. -
nuxt.config.ts— setimage: { provider: process.env.STORYBOOK ? "none" : undefined }. The"none"provider passes src through unchanged;undefinedauto-detects (uses Vercel provider in production). -
NuxtImgtags — always add explicitwidthandheightprops to avoid aw=1536fallback (not in Vercel's allowed widths: 640, 750, 828, 1080, 1200, 1920, 2048, 3840).
@nuxt/fonts is disabled in Storybook. Fonts are served instead via .storybook/fonts.css (imported in .storybook/preview.ts). Font files live in .storybook/public/_fonts/ and are served as static assets.
| Context | Font source |
|---|---|
| Nuxt app | @nuxt/fonts (bunny CDN) |
| Storybook | .storybook/fonts.css + static files in .storybook/public/_fonts/ |
See .claude/skills/storybook-add-font.md for the step-by-step process to add a new font (including curl script to download woff2 files from bunny CDN).
MCP Reference: Structured documentation in .mcp/component-patterns.json
Purpose: AI agent integration and pattern reference
Coverage: Component APIs, styling systems, common tasks, best practices
- Manual Vue imports: Don't import
ref,computed, etc. (auto-imported) - Self-closing HTML elements: ESLint (
vue/html-self-closing) disallows self-closing non-void elements — always use explicit closing tags:<slot name="foo"></slot>,<span></span>, not<slot name="foo" />or<span /> - Missing test coverage: Every component needs comprehensive tests
- Hardcoded styles: Use CSS custom properties for flexibility
- PropType runtime imports: Import as type only
- Missing accessibility: Include proper ARIA attributes
- Inconsistent naming: Follow established slot/prop naming patterns
- Incorrect type casting: Use
as unknown as CustomTypefor component instances - Unmocked browser APIs: Always mock ResizeObserver, IntersectionObserver, etc.
- Missing DOM element casting: Cast to HTMLElement when accessing style properties
- Sass-style BEM nesting in native CSS: Never use
&__childor&-modifierconcatenation — this is Sass syntax and does not work in native CSS. esbuild silently converts&__footo:is(__foo)which matches nothing. Use& .block__child(descendant selector) or a top-level.block__child {}rule instead. See.claude/skills/css-nesting-conventions.md. :srcon<video>: Binding:srcdirectly on a<video>element silently skips the browser fetch when Vue patches it on client-side navigation — no error, no network request, poster just sits there. Always use a<source :src="src">child instead, combined with:key="src"and an explicitvideoEl.load()call. See.claude/skills/vue-video-autoplay.md.- Pairing
--theme-text/--theme-text-invertedwith--theme-surface-hover/--theme-surface-inverted: these two "text" tokens are meant for text sitting on a light surface (their light-mode branch is a dark colour step) — but--theme-surface-hoverand--theme-surface-inverted's light-mode branches are themselves dark steps (by design, so buttons stay bold/coloured even in light mode). Pairing them produces invisible dark-on-dark text in light mode (or any consumer app pinningcolor-scheme: light, e.g. viadata-color-scheme="light"). Always pair a dark/bold surface with--theme-on-surfaceinstead — it's a fixed light value in both modes.InputButtonCore's.secondary:hoverandAlertContentInner's dismiss-button hover already do this correctly;InputButtonCore's base and.tertiaryhover states didn't (fixed 2026-08-03) — check any new hover/inverted state against this pattern before shipping it.
- Plan: Check existing patterns in MCP documentation
- Create: Follow established component patterns
- Style: Functional base styles with CSS custom properties
- Test: Comprehensive test suite with
mountSuspended - Document: Update MCP reference for new patterns
- Snippet: Create or update
.vscode/srcdev-component-{name}.code-snippets— required for every new or changed component - Skill: Update
.claude/skills/components/<component-name>.md— required for every new or changed component, same as the snippet in step 6. Covers any change to props/slots/models, defaults, new behaviour, or a bug fix that changes what the component observably does (e.g. it now closes on outside click when it didn't before). Skills are what an AI agent (including Claude Code, in this repo or a consumer app) reads to know how to use the component correctly — a stale skill silently teaches wrong usage. If the component has no skill doc yet, create one following the pattern of an existing one in.claude/skills/components/. - Verify: Ensure TypeScript strict mode compliance
- GitHub Actions: Automated testing on Node 20/22
- Type Checking: TypeScript strict mode validation
- Test Suite: All tests must pass before merge
- Badge Status: Green badges indicate healthy codebase
Remember: This is a production-ready component library. Maintain high standards for code quality, testing, and documentation.