srcdev-nuxt-components 9.1.38 → 9.1.40

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 (29) hide show
  1. package/.claude/settings.json +4 -1
  2. package/.claude/settings.local.json +6 -1
  3. package/.claude/skills/components/carousel-flip.md +188 -0
  4. package/.claude/skills/index.md +3 -1
  5. package/.claude/skills/qa-panel.md +231 -0
  6. package/.claude/skills/testing-add-unit-test.md +32 -0
  7. package/app/components/01.atoms/{grid-stack → grids/grid-stack}/stories/GridStack.stories.ts +1 -1
  8. package/app/components/02.molecules/samaritan-prompt/SamaritanPrompt.vue +230 -0
  9. package/app/components/02.molecules/samaritan-prompt/SamaritanPromptMixed.vue +234 -0
  10. package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPrompt.spec.ts +368 -0
  11. package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed.spec.ts +306 -0
  12. package/app/components/carousels/CarouselFlip.vue +211 -153
  13. package/app/components/carousels/stories/CarouselFlip.stories.ts +35 -0
  14. package/app/components/carousels/tests/CarouselFlip.spec.ts +38 -0
  15. package/app/composables/tests/useCancellableTimer.spec.ts +135 -0
  16. package/app/composables/useCancellableTimer.ts +42 -0
  17. package/app/pages/samaritan.vue +332 -0
  18. package/app/pages/ui/carousel-flip.vue +222 -17
  19. package/package.json +1 -1
  20. /package/app/components/01.atoms/{scroll-reveal-frame → animations/scroll-reveal-frame}/ScrollRevealFrame.vue +0 -0
  21. /package/app/components/01.atoms/{scroll-reveal-frame → animations/scroll-reveal-frame}/stories/ScrollRevealFrame.stories.ts +0 -0
  22. /package/app/components/01.atoms/{scroll-reveal-frame → animations/scroll-reveal-frame}/tests/ScrollRevealFrame.spec.ts +0 -0
  23. /package/app/components/01.atoms/{scroll-reveal-frame → animations/scroll-reveal-frame}/tests/__snapshots__/ScrollRevealFrame.spec.ts.snap +0 -0
  24. /package/app/components/01.atoms/{scroll-reveal-image → animations/scroll-reveal-image}/ScrollRevealImage.vue +0 -0
  25. /package/app/components/01.atoms/{scroll-reveal-image → animations/scroll-reveal-image}/stories/ScrollRevealImage.stories.ts +0 -0
  26. /package/app/components/01.atoms/{scroll-reveal-image → animations/scroll-reveal-image}/tests/ScrollRevealImage.spec.ts +0 -0
  27. /package/app/components/01.atoms/{scroll-reveal-image → animations/scroll-reveal-image}/tests/__snapshots__/ScrollRevealImage.spec.ts.snap +0 -0
  28. /package/app/components/01.atoms/{grid-stack → grids/grid-stack}/GridStack.vue +0 -0
  29. /package/app/components/01.atoms/{grid-stack → grids/grid-stack}/tests/GridStack.spec.ts +0 -0
@@ -23,7 +23,10 @@
23
23
  "Bash(npm run:*)",
24
24
  "Bash(git add:*)",
25
25
  "Bash(gh release:*)",
26
- "Bash(node -e ':*)"
26
+ "Bash(node -e ':*)",
27
+ "Bash(git ls-tree *)",
28
+ "Bash(node -p \"require\\('./package.json'\\).version\")",
29
+ "Bash(git status *)"
27
30
  ],
28
31
  "additionalDirectories": []
29
32
  }
@@ -24,7 +24,12 @@
24
24
  "Bash(git -C /Users/simoncornforth/websites/nuxt-components show 0f99d7a --stat)",
25
25
  "Bash(git -C /Users/simoncornforth/websites/nuxt-components diff app/components/parallax/SectionParallax.vue)",
26
26
  "Bash(git -C /Users/simoncornforth/websites/nuxt-components diff HEAD app/components/parallax/SectionParallax.vue)",
27
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components diff --cached)"
27
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components diff --cached)",
28
+ "Bash(SRCDEV_STANDALONE=true npx vitest --run app/components/02.molecules/samaritan-prompt)",
29
+ "Bash(SRCDEV_STANDALONE=true npx vitest --run app/composables/tests/useCancellableTimer app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed)",
30
+ "Bash(SRCDEV_STANDALONE=true npx vitest --run app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed)",
31
+ "Bash(SRCDEV_STANDALONE=true npx vitest --run app/composables/tests/useCancellableTimer)",
32
+ "Bash(SRCDEV_STANDALONE=true npm run test:run -- --reporter=verbose)"
28
33
  ],
29
34
  "additionalDirectories": [
30
35
  "/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/content-wrappers/content-width",
@@ -0,0 +1,188 @@
1
+ # CarouselFlip
2
+
3
+ ## Overview
4
+
5
+ A FLIP-animated carousel that reorders items in the DOM using CSS `order` and animates transitions with the FLIP technique (First, Last, Invert, Play). Supports swipe, keyboard navigation, and marker dots. The prev/next buttons and controls bar can be placed in several layouts via a single prop.
6
+
7
+ ---
8
+
9
+ ## Implementation guide
10
+
11
+ When a dev asks to implement CarouselFlip, work through the following questions before writing any code. Each answer maps directly to a prop or CSS decision. You do not need to ask all questions at once — use context clues where the answer is obvious.
12
+
13
+ ### Step 1 — Data source
14
+
15
+ Ask: **"Where is the carousel data coming from — a static array, an API, or a Nuxt `useFetch`?"**
16
+
17
+ - **Static array**: Define `carouselDataIds` directly as a `const`.
18
+ - **API / useFetch**: Derive `carouselDataIds` as a `computed` from the response. Gate the component with `v-if="status === 'success'"` to avoid a flash of empty slots.
19
+
20
+ ```vue
21
+ <!-- API pattern -->
22
+ const { data, status } = await useFetch<MyType>("/api/items");
23
+ const carouselDataIds = computed(() => data.value?.items.map(i => i.id) ?? []);
24
+
25
+ <CarouselFlip v-if="status === 'success'" :carousel-data-ids="carouselDataIds">
26
+ <template v-for="item in data.items" :key="item.id" #[item.id]>
27
+ <!-- slot content -->
28
+ </template>
29
+ </CarouselFlip>
30
+ ```
31
+
32
+ ### Step 2 — Button layout
33
+
34
+ Ask: **"Where should the prev/next buttons sit?"** Show these options:
35
+
36
+ | Value | Visual description |
37
+ |---|---|
38
+ | `"sides"` | Buttons float on the left and right edges of the carousel frame, centred vertically — the classic look |
39
+ | `"controls-flanking"` | Buttons move down into the controls row: prev · markers · next |
40
+ | `"controls-grouped-right"` | Markers stretch left, both buttons grouped together at the far right of the controls row |
41
+ | `"overlay"` | Buttons stay on the sides; the markers bar overlays the bottom edge of the carousel frame |
42
+
43
+ Set via `:button-layout="..."`. Default is `"sides"`.
44
+
45
+ ### Step 3 — Show or hide the markers bar
46
+
47
+ Ask: **"Do you want the dot/marker navigation bar visible?"**
48
+
49
+ - **Yes** (default): omit the prop or pass `:show-controls="true"`.
50
+ - **No**: pass `:show-controls="false"`. The element is fully removed from the DOM — no layout space, no keyboard listener.
51
+
52
+ > If `showControls` is false and `buttonLayout` is `"controls-flanking"` or `"controls-grouped-right"`, the controls row collapses entirely — only the buttons remain. Recommend switching `buttonLayout` to `"sides"` or `"overlay"` in that case.
53
+
54
+ ### Step 4 — Edge peek (overflow)
55
+
56
+ Ask: **"Should adjacent carousel items peek in from the sides?"**
57
+
58
+ - **No peek** (default): `:allow-carousel-overflow="false"` — clean, contained look.
59
+ - **Peek visible**: `:allow-carousel-overflow="true"` — items bleed slightly out of the container. Requires CSS custom properties to control how much:
60
+
61
+ ```css
62
+ .my-carousel.carousel-flip {
63
+ --_carousel-item-track-gap: 16px;
64
+ --_carousel-item-edge-preview-width: 32px; /* keep at 2× track-gap */
65
+ --_carousel-container-max-inline-size: 900px;
66
+ }
67
+ ```
68
+
69
+ ### Step 5 — Animation style
70
+
71
+ Ask: **"What animation feel are you going for?"**
72
+
73
+ | Prop | Effect |
74
+ |---|---|
75
+ | `:use-flip-animation="false"` (default) | Slides — items translate horizontally |
76
+ | `:use-flip-animation="true"` | FLIP reorder — items swap position with a physics-aware delta animation |
77
+ | `:use-spring-effect="true"` | Adds spring easing to FLIP transitions. Requires `var(--spring-easing)` in the theme |
78
+ | `:transition-speed="400"` | Duration in ms. Default `200`. Recommended range: `200`–`1200` |
79
+
80
+ ### Step 6 — Minimal working implementation
81
+
82
+ Once the above decisions are made, assemble the component:
83
+
84
+ ```vue
85
+ <CarouselFlip
86
+ :carousel-data-ids="carouselDataIds"
87
+ :allow-carousel-overflow="true"
88
+ :transition-speed="600"
89
+ :use-flip-animation="true"
90
+ :use-spring-effect="false"
91
+ button-layout="sides"
92
+ :show-controls="true"
93
+ :style-class-passthrough="['my-carousel']"
94
+ >
95
+ <template v-for="item in items" :key="item.id" #[item.id]>
96
+ <div class="my-carousel__item">
97
+ <!-- item content -->
98
+ </div>
99
+ </template>
100
+ </CarouselFlip>
101
+ ```
102
+
103
+ ### Step 7 — Style the component
104
+
105
+ Always scope overrides using `styleClassPassthrough` + a page/section wrapper class. Required tokens for a usable carousel:
106
+
107
+ ```css
108
+ .my-carousel.carousel-flip {
109
+ --_carousel-item-track-gap: 16px;
110
+ --_carousel-container-max-inline-size: 900px;
111
+ --_carousel-item-edge-preview-width: 32px; /* 2× track-gap when overflow is on */
112
+
113
+ /* Items */
114
+ .item {
115
+ border-radius: 1.2rem;
116
+ overflow: hidden;
117
+ }
118
+
119
+ /* Markers */
120
+ .btn-marker {
121
+ width: 10px;
122
+ height: 10px;
123
+ border-radius: 100vw;
124
+ background: oklch(70% 0 0);
125
+
126
+ &.active { background: white; }
127
+ }
128
+
129
+ /* Prev/next buttons */
130
+ .btn-action {
131
+ padding: 10px;
132
+ background: oklch(0% 0 0 / 0.4);
133
+ border: none;
134
+ border-radius: 100vw;
135
+ color: white;
136
+ }
137
+ }
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Props reference
143
+
144
+ | Prop | Type | Default | Description |
145
+ |---|---|---|---|
146
+ | `carouselDataIds` | `string[]` | `[]` | Ordered list of unique IDs — each becomes a named slot |
147
+ | `transitionSpeed` | `number` | `200` | Animation duration in ms |
148
+ | `allowCarouselOverflow` | `boolean` | `false` | Allows peeking items outside the container bounds |
149
+ | `useFlipAnimation` | `boolean` | `false` | Enables FLIP reorder animation on prev/next |
150
+ | `useSpringEffect` | `boolean` | `false` | Uses spring easing (`var(--spring-easing)`) instead of `ease` |
151
+ | `buttonLayout` | `"sides" \| "controls-flanking" \| "controls-grouped-right" \| "overlay"` | `"sides"` | Controls placement of prev/next buttons relative to the carousel frame and controls bar |
152
+ | `showControls` | `boolean` | `true` | Show or hide the markers/controls bar. When `false` the element is removed from the DOM; `controlsContainerRef` becomes null and its keyboard listener detaches automatically |
153
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | Classes applied to the root element |
154
+
155
+ ## CSS custom properties
156
+
157
+ | Property | Purpose |
158
+ |---|---|
159
+ | `--_carousel-item-track-gap` | Gap between carousel items (default `10px`) |
160
+ | `--_carousel-container-max-inline-size` | Max width of the visible carousel window |
161
+ | `--_carousel-item-edge-preview-width` | How much of adjacent items to reveal (edge peek). Keep at `2× --_carousel-item-track-gap` |
162
+ | `--_carousel-display-max-width` | Max width of the whole component inc. controls |
163
+
164
+ ## HTML structure
165
+
166
+ ```text
167
+ section.carousel-flip ← grid root, data-button-layout="..."
168
+ div.item-container ← grid-area: carousel — flex row of items
169
+ div.item[data-id] ← one per carouselDataIds entry
170
+ div.controls-container ← grid-area: controls — markers bar (v-if="showControls")
171
+ div.markers-container
172
+ ul.markers-list
173
+ li.markers-item
174
+ button.btn-marker
175
+ div.buttons-container ← display: contents by default (transparent to grid)
176
+ button.btn-action.btn-prev ← grid-area: prev (row 1, col 1)
177
+ button.btn-action.btn-next ← grid-area: next (row 1, col 3)
178
+ ```
179
+
180
+ `buttons-container` uses `display: contents` so `.btn-prev`/`.btn-next` participate directly in the parent grid. In the `controls-grouped-right` variant the component sets `display: flex` on it, making it the grid child instead.
181
+
182
+ ## Notes
183
+
184
+ - **Opacity fade-in**: The root starts at `opacity: 0` and gets `.mounted` (opacity 1) after `initialSetup()` completes. This prevents a flash of unstyled layout on mount.
185
+ - **z-index**: `.btn-prev`/`.btn-next` have `z-index: 1` to sit above `.item-container` which uses `isolation: isolate` (carousel items are translated and can overlap the button columns).
186
+ - **ResizeObserver**: `initialSetup()` re-runs on resize to recalculate item widths. CSS `translate` on `.item` is driven by the measured `itemWidth` via `v-bind`.
187
+ - **Spring easing**: `useSpringEffect` switches to `var(--spring-easing)`. Make sure this custom property is defined in your theme or global CSS when enabling it.
188
+ - **buttonLayout + showControls combo**: `"controls-flanking"` and `"controls-grouped-right"` place buttons in the controls row. If `showControls` is false that row collapses — use `"sides"` or `"overlay"` instead.
@@ -41,6 +41,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
41
41
  ├── icon-sets.md — icon set packages required by layer components, FOUC prevention, component→package map
42
42
  ├── robots-env-aware.md — @nuxtjs/robots: allow crawling on prod domain only, block on preview/staging via env var
43
43
  ├── new-app-scaffold.md — scaffold a new Nuxt consumer app extending this layer (package.json, nuxt.config, app structure, CLAUDE.md)
44
+ ├── qa-panel.md — collapsible dev-only panel for toggling component props live on a page (demo pages and consuming apps)
44
45
  ├── release-notes.md — produce release notes as a fenced markdown block from git log
45
46
  ├── composable-canonical-url.md — useCanonicalUrl: set <link rel="canonical"> from runtimeConfig.public.canonicalHost; layout setup, node types
46
47
  ├── composable-whatsapp.md — useWhatsApp: open pre-filled wa.me link from form payload; runtime config, security, usage
@@ -75,7 +76,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
75
76
  ├── display-qr-code.md — DisplayQrCode: QR code SVG from a string value, colour/size/variant/radius props, currentColor default
76
77
  ├── capture-qr-code.md — CaptureQrCode: live camera scanner, error state, visibility/route/KeepAlive lifecycle, media stream cleanup
77
78
  ├── decode-qr-code.md — DecodeQrCode: file picker + drag-and-drop image decoder, shared results list, CSS override points
78
- └── data-grid.md — DataGrid: auto-fit responsive grid, $slots iteration, --data-grid-columns/gap tokens, semantic tag + aria
79
+ ├── data-grid.md — DataGrid: auto-fit responsive grid, $slots iteration, --data-grid-columns/gap tokens, semantic tag + aria
80
+ └── carousel-flip.md — CarouselFlip: FLIP-animated carousel, carouselDataIds slot API, buttonLayout variants (sides/controls-flanking/controls-grouped-right/overlay), CSS tokens
79
81
  ```
80
82
 
81
83
  ## Skill file template
@@ -0,0 +1,231 @@
1
+ # QA Panel
2
+
3
+ ## Overview
4
+
5
+ A collapsible dev-only panel that lets you toggle component props live on a page — without touching the component or breaking the visual layout. Hidden in production via `import.meta.dev`. Uses a native `<details>`/`<summary>` so it takes up no space when collapsed. Useful on both demo pages in this library and on pages in consuming apps.
6
+
7
+ ## Structure
8
+
9
+ ```
10
+ <details> ← collapses the whole panel
11
+ <summary> ← always visible: title + live status line
12
+ <body> ← groups of chip buttons (and optional free-text inputs)
13
+ ```
14
+
15
+ Each group controls one prop. The status `<code>` in the summary mirrors the current state so you can see it at a glance without opening the panel.
16
+
17
+ ## Steps
18
+
19
+ ### 1. Add the reactive state to `<script setup>`
20
+
21
+ ```ts
22
+ // ── QA controls (dev only) ────────────────────────────────────────
23
+ const isDev = import.meta.dev;
24
+
25
+ // One ref per controllable prop
26
+ const qaMyBoolean = ref(true);
27
+ const qaMyNumber = ref(400);
28
+ const qaMyString = ref<"a" | "b" | "c">("a");
29
+
30
+ // Preset arrays for numeric/string chip groups
31
+ const myNumberPresets = [100, 200, 400, 800, 1600];
32
+ const myStringPresets = ["a", "b", "c"] as const;
33
+ ```
34
+
35
+ ### 2. Wire the refs to the component
36
+
37
+ ```vue
38
+ <MyComponent
39
+ :my-boolean="qaMyBoolean"
40
+ :my-number="qaMyNumber"
41
+ :my-string="qaMyString"
42
+ />
43
+ ```
44
+
45
+ ### 3. Add the panel markup
46
+
47
+ Place directly above (or below) the component being QA'd, outside any layout wrapper that clips content:
48
+
49
+ ```vue
50
+ <!-- ── QA Panel (dev only) ───────────────────────────────── -->
51
+ <div v-if="isDev" class="qa-panel">
52
+ <details class="qa-panel__details">
53
+ <summary class="qa-panel__summary">
54
+ <span class="qa-panel__title">QA — MyComponent</span>
55
+ <code class="qa-panel__status">
56
+ bool:{{ qaMyBoolean ? "on" : "off" }} · {{ qaMyNumber }}ms · {{ qaMyString }}
57
+ </code>
58
+ </summary>
59
+ <div class="qa-panel__body">
60
+
61
+ <!-- Boolean group -->
62
+ <div class="qa-panel__group">
63
+ <span class="qa-panel__label">My Boolean</span>
64
+ <div class="qa-panel__chips">
65
+ <button
66
+ v-for="opt in [true, false]"
67
+ :key="String(opt)"
68
+ class="qa-panel__chip"
69
+ :class="{ 'is-active': qaMyBoolean === opt }"
70
+ @click="qaMyBoolean = opt"
71
+ >{{ opt ? "on" : "off" }}</button>
72
+ </div>
73
+ </div>
74
+
75
+ <!-- Number group (preset chips) -->
76
+ <div class="qa-panel__group">
77
+ <span class="qa-panel__label">My Number</span>
78
+ <div class="qa-panel__chips">
79
+ <button
80
+ v-for="preset in myNumberPresets"
81
+ :key="preset"
82
+ class="qa-panel__chip"
83
+ :class="{ 'is-active': qaMyNumber === preset }"
84
+ @click="qaMyNumber = preset"
85
+ >{{ preset }}</button>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- String union group -->
90
+ <div class="qa-panel__group">
91
+ <span class="qa-panel__label">My String</span>
92
+ <div class="qa-panel__chips">
93
+ <button
94
+ v-for="opt in myStringPresets"
95
+ :key="opt"
96
+ class="qa-panel__chip"
97
+ :class="{ 'is-active': qaMyString === opt }"
98
+ @click="qaMyString = opt"
99
+ >{{ opt }}</button>
100
+ </div>
101
+ </div>
102
+
103
+ <!-- Free-text input (for strings where presets aren't enough) -->
104
+ <div class="qa-panel__group">
105
+ <span class="qa-panel__label">Custom Value</span>
106
+ <input v-model="qaMyString" placeholder="e.g. 4/3" class="qa-panel__input" />
107
+ </div>
108
+
109
+ </div>
110
+ </details>
111
+ </div>
112
+ ```
113
+
114
+ ### 4. Add the CSS
115
+
116
+ Scope inside your page body class (e.g. `.my-page`) so styles don't bleed. The panel is always dark regardless of colour scheme — it's a dev tool, not a UI element.
117
+
118
+ ```css
119
+ .my-page {
120
+ /* ── QA Panel ──────────────────────────────────────────────────── */
121
+
122
+ .qa-panel {
123
+ background: oklch(15% 0 0);
124
+ color: white;
125
+ font-size: 1.3rem;
126
+ }
127
+
128
+ .qa-panel__details {
129
+ padding: 1rem 2rem;
130
+ }
131
+
132
+ .qa-panel__summary {
133
+ cursor: pointer;
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 1.6rem;
137
+ list-style: none;
138
+ user-select: none;
139
+
140
+ &::-webkit-details-marker { display: none; }
141
+ }
142
+
143
+ .qa-panel__title {
144
+ font-weight: 600;
145
+ font-size: 1.1rem;
146
+ text-transform: uppercase;
147
+ letter-spacing: 0.08em;
148
+ }
149
+
150
+ .qa-panel__status {
151
+ font-family: monospace;
152
+ font-size: 1.2rem;
153
+ background: oklch(0% 0 0 / 0.3);
154
+ padding: 0.2rem 0.8rem;
155
+ border-radius: 0.4rem;
156
+ user-select: text;
157
+ cursor: text;
158
+ }
159
+
160
+ .qa-panel__body {
161
+ display: flex;
162
+ flex-wrap: wrap;
163
+ gap: 2.4rem;
164
+ padding-block: 1.2rem 0.4rem;
165
+ }
166
+
167
+ .qa-panel__group {
168
+ display: flex;
169
+ flex-direction: column;
170
+ gap: 0.6rem;
171
+ }
172
+
173
+ .qa-panel__label {
174
+ font-size: 1.1rem;
175
+ text-transform: uppercase;
176
+ letter-spacing: 0.08em;
177
+ opacity: 0.55;
178
+ }
179
+
180
+ .qa-panel__chips {
181
+ display: flex;
182
+ flex-wrap: wrap;
183
+ gap: 0.4rem;
184
+ }
185
+
186
+ .qa-panel__chip {
187
+ font-family: monospace;
188
+ font-size: 1.2rem;
189
+ color: white;
190
+ background: oklch(0% 0 0 / 0.25);
191
+ border: 1px solid oklch(100% 0 0 / 0.18);
192
+ padding: 0.3rem 1rem;
193
+ border-radius: 0.4rem;
194
+ cursor: pointer;
195
+ transition: background 0.15s;
196
+
197
+ &:hover { background: oklch(0% 0 0 / 0.4); }
198
+
199
+ &.is-active {
200
+ background: oklch(55% 0.18 240);
201
+ border-color: oklch(55% 0.18 240);
202
+ }
203
+ }
204
+
205
+ .qa-panel__input {
206
+ font-family: monospace;
207
+ font-size: 1.2rem;
208
+ color: white;
209
+ background: oklch(0% 0 0 / 0.25);
210
+ border: 1px solid oklch(100% 0 0 / 0.18);
211
+ padding: 0.3rem 1rem;
212
+ border-radius: 0.4rem;
213
+ width: 18rem;
214
+
215
+ &::placeholder { opacity: 0.45; }
216
+ }
217
+ }
218
+ ```
219
+
220
+ ## Notes
221
+
222
+ - **Production safety**: `import.meta.dev` is `false` in production builds — the entire `v-if="isDev"` block is tree-shaken. No runtime cost.
223
+ - **Consuming apps**: The active chip color (`oklch(55% 0.18 240)`) is a neutral blue. Replace with a brand accent token if preferred: `background: var(--color-brand-accent)`.
224
+ - **Panel placement**: Outside any `overflow: hidden` or clipping container, otherwise the panel may be clipped or push layout unexpectedly. Placing it as a direct sibling of the component row works well.
225
+ - **Computed CSS vars**: When a prop controls a CSS custom property (e.g. max-height tiers), use a `computed` that returns a style object and bind it with `:style` on the component wrapper:
226
+
227
+ ```ts
228
+ const qaStyleOverrides = computed(() => ({
229
+ "--theme-component-max-height": qaMaxHeight.value || undefined,
230
+ }));
231
+ ```
@@ -182,6 +182,38 @@ await nextTick();
182
182
  vi.runAllTimers();
183
183
  ```
184
184
 
185
+ ## `mountSuspended` flushes component async setup — initial state may be past the first `await`
186
+
187
+ `mountSuspended` calls `flushPromises()` internally, draining all pending microtasks including `await nextTick()` calls inside the component's async functions. If a component's async lifecycle starts with `await nextTick()` (e.g. to let a CSS `v-bind` update before beginning a transition), **the component will have already executed past it by the time `mountSuspended` returns**.
188
+
189
+ Assert the post-flush state, not the state before the first await:
190
+
191
+ ```ts
192
+ // Component's runEffect() starts with: await nextTick(); opacity.value = 0;
193
+ // ❌ — by the time mountSuspended returns, opacity is already 0
194
+ const wrapper = await mountSuspended(MyComponent, { props });
195
+ expect(wrapper.find(".content").attributes("style")).toContain("opacity: 1");
196
+
197
+ // ✅ — assert the state that exists after mountSuspended's flush
198
+ const wrapper = await mountSuspended(MyComponent, { props });
199
+ expect(wrapper.find(".content").attributes("style")).toContain("opacity: 0");
200
+ ```
201
+
202
+ ## `.then().catch()` requires two microtask hops; use `.then(onFulfilled, onRejected)` for one
203
+
204
+ When asserting Promise settlement with a single `await Promise.resolve()`, put both handlers in the two-argument form of `.then()`. Chaining `.then().catch()` creates an intermediate Promise that adds a second hop, so `settled` won't be `true` after only one drain:
205
+
206
+ ```ts
207
+ // ❌ needs two await Promise.resolve() to settle
208
+ promise.then(() => { settled = true; }).catch(() => { settled = true; });
209
+
210
+ // ✅ settles after one await Promise.resolve()
211
+ promise.then(
212
+ () => { settled = true; },
213
+ () => { settled = true; },
214
+ );
215
+ ```
216
+
185
217
  ## Hyphenated prop attributes in tests
186
218
 
187
219
  When a component uses a hyphenated Vue prop like `:tab-index` or `:aria-label`, Vue renders it as the literal hyphenated DOM attribute. Assert with the hyphenated form — not the camelCase equivalent:
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
2
  import GridStackComponent from "../GridStack.vue";
3
- import BannerVideoComponent from "../../banner-video/BannerVideo.vue";
3
+ import BannerVideoComponent from "../../../banner-video/BannerVideo.vue";
4
4
 
5
5
  interface GridStackArgs {
6
6
  tag: "div" | "section" | "article" | "main";