srcdev-nuxt-components 9.4.4 → 9.4.5

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 (84) hide show
  1. package/.claude/commands/migrate-component.md +68 -0
  2. package/.claude/component-ledger/artifact-url.txt +1 -0
  3. package/.claude/component-ledger/audit.json +1 -0
  4. package/.claude/component-ledger/build.mjs +111 -0
  5. package/.claude/component-ledger/output.html +559 -0
  6. package/.claude/component-ledger/template.html +559 -0
  7. package/.claude/hooks/refresh-component-ledger.sh +32 -0
  8. package/.claude/settings.json +10 -0
  9. package/.claude/skills/components/animated-svg-text.md +78 -0
  10. package/.claude/skills/components/canvas-switcher.md +61 -0
  11. package/.claude/skills/components/clip-element.md +67 -0
  12. package/.claude/skills/components/clipped-panel.md +64 -0
  13. package/.claude/skills/components/deep-expanding-menu-classic.md +94 -0
  14. package/.claude/skills/components/deep-expanding-menu.md +92 -0
  15. package/.claude/skills/components/display-banner.md +60 -0
  16. package/.claude/skills/components/display-tooltip-defined.md +136 -0
  17. package/.claude/skills/components/display-tooltip.md +84 -0
  18. package/.claude/skills/composable-tooltips-guide.md +10 -0
  19. package/.claude/skills/index.md +7 -1
  20. package/.claude/skills/storybook-add-story.md +30 -0
  21. package/.vscode/srcdev-component-animated-svg-text.code-snippets +15 -0
  22. package/.vscode/srcdev-component-canvas-switcher.code-snippets +9 -0
  23. package/.vscode/srcdev-component-clip-element.code-snippets +20 -0
  24. package/.vscode/srcdev-component-clipped-panel.code-snippets +23 -0
  25. package/.vscode/srcdev-component-deep-expanding-menu-classic.code-snippets +9 -0
  26. package/.vscode/srcdev-component-deep-expanding-menu.code-snippets +9 -0
  27. package/.vscode/srcdev-component-display-banner.code-snippets +30 -0
  28. package/.vscode/srcdev-component-display-tooltip-defined.code-snippets +14 -0
  29. package/.vscode/srcdev-component-display-tooltip.code-snippets +27 -0
  30. package/app/components/01.atoms/animations/animated-svg-text/AnimatedSvgText.vue +58 -0
  31. package/app/components/01.atoms/animations/animated-svg-text/CONSUMER-STYLING.md +34 -0
  32. package/app/components/01.atoms/animations/animated-svg-text/stories/AnimatedSvgText.stories.ts +69 -0
  33. package/app/components/01.atoms/animations/animated-svg-text/tests/AnimatedSvgText.spec.ts +63 -0
  34. package/app/components/01.atoms/animations/animated-svg-text/tests/__snapshots__/AnimatedSvgText.spec.ts.snap +7 -0
  35. package/app/components/01.atoms/animations/clip-element/CONSUMER-STYLING.md +27 -0
  36. package/app/components/{clip-element → 01.atoms/animations/clip-element}/ClipElement.vue +16 -11
  37. package/app/components/01.atoms/animations/clip-element/stories/ClipElement.stories.ts +133 -0
  38. package/app/components/01.atoms/animations/clip-element/tests/ClipElement.spec.ts +70 -0
  39. package/app/components/01.atoms/animations/clip-element/tests/__snapshots__/ClipElement.spec.ts.snap +7 -0
  40. package/app/components/01.atoms/canvas-switcher/CONSUMER-STYLING.md +27 -0
  41. package/app/components/01.atoms/canvas-switcher/CanvasSwitcher.vue +111 -0
  42. package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +75 -0
  43. package/app/components/01.atoms/canvas-switcher/tests/CanvasSwitcher.spec.ts +67 -0
  44. package/app/components/01.atoms/clipped-panel/CONSUMER-STYLING.md +39 -0
  45. package/app/components/01.atoms/clipped-panel/ClippedPanel.vue +68 -0
  46. package/app/components/01.atoms/clipped-panel/stories/ClippedPanel.stories.ts +105 -0
  47. package/app/components/01.atoms/clipped-panel/tests/ClippedPanel.spec.ts +67 -0
  48. package/app/components/01.atoms/clipped-panel/tests/__snapshots__/ClippedPanel.spec.ts.snap +3 -0
  49. package/app/components/01.atoms/display-banner/CONSUMER-STYLING.md +35 -0
  50. package/app/components/{display-banner → 01.atoms/display-banner}/DisplayBanner.vue +13 -35
  51. package/app/components/01.atoms/display-banner/stories/DisplayBanner.stories.ts +100 -0
  52. package/app/components/01.atoms/display-banner/tests/DisplayBanner.spec.ts +78 -0
  53. package/app/components/01.atoms/display-banner/tests/__snapshots__/DisplayBanner.spec.ts.snap +8 -0
  54. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +62 -0
  55. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +185 -0
  56. package/app/components/01.atoms/display-tooltip/stories/DisplayTooltip.stories.ts +94 -0
  57. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +75 -0
  58. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +12 -0
  59. package/app/components/02.molecules/display-tooltip-defined/CONSUMER-STYLING.md +42 -0
  60. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +103 -0
  61. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +126 -0
  62. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +66 -0
  63. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +17 -0
  64. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +44 -0
  65. package/app/components/{deep-expanding-menu → 02.molecules/navigation/deep-expanding-menu}/DeepExpandingMenu.vue +53 -64
  66. package/app/components/02.molecules/navigation/deep-expanding-menu/stories/DeepExpandingMenu.stories.ts +79 -0
  67. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +92 -0
  68. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +17 -0
  69. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +40 -0
  70. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +200 -0
  71. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/stories/DeepExpandingMenuClassic.stories.ts +79 -0
  72. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/DeepExpandingMenuClassic.spec.ts +82 -0
  73. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/__snapshots__/DeepExpandingMenuClassic.spec.ts.snap +18 -0
  74. package/app/types/components/display-tooltip-defined.d.ts +10 -0
  75. package/app/types/components/index.ts +1 -0
  76. package/package.json +1 -1
  77. package/app/components/animated-svg-text/AnimatedSvgText.vue +0 -89
  78. package/app/components/canvas-switcher/CanvasSwitcher.vue +0 -104
  79. package/app/components/clipped-panels/ClippedPanel.vue +0 -87
  80. package/app/components/deep-expanding-menu/DeepExpandingMenuOld.vue +0 -218
  81. package/app/components/display-details/DisplayDetailsCore.vue +0 -122
  82. package/app/components/display-tooltip/DisplayTooltip.vue +0 -170
  83. package/app/components/display-tooltip/DisplayTooltipDefined.vue +0 -103
  84. package/app/components/magnetic-navigation/MagneticNavigation.vue +0 -162
@@ -89,6 +89,16 @@ await startGuide()
89
89
  | `stopGuide()` | `() => void` | Close any open popovers and stop the guide |
90
90
  | `initializePopovers()` | `() => void` | Re-scan the container for `[popover]` elements; call if popovers are added dynamically |
91
91
 
92
+ ## Example with DisplayTooltip / DisplayTooltipDefined
93
+
94
+ [`DisplayTooltip`](components/display-tooltip.md) and
95
+ [`DisplayTooltipDefined`](components/display-tooltip-defined.md) already emit the
96
+ `[popover]`/`popovertarget`/`popovertargetaction` markup this composable expects, so no extra
97
+ wiring is needed beyond passing their shared container's ref in. See the `GuidedTour` story in
98
+ `DisplayTooltipDefined.stories.ts` for a full working example — it recreates the old
99
+ `app/pages/ui/tooltips.vue` demo page (removed 2026-08-23 when Storybook became the only demo
100
+ surface), mixing one of each component as guide steps.
101
+
92
102
  ## Notes
93
103
 
94
104
  - If no trigger button is found for a popover, `togglePopover(true)` is called directly as a fallback — anchor positioning may not apply in this case.
@@ -63,6 +63,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
63
63
  ├── composable-analytics.md — useAnalytics: provider-agnostic trackEvent/page-view tracking (google-analytics only implemented), consent-gated, single call site for setup + firing events
64
64
  └── components/
65
65
  ├── alert-content-inner.md — AlertContentInner: shared icon/body/dismiss molecule under AlertContent + AlertMaskedContent; app.config icon map (alertContent.icons + dismissIcon) covers all consumers
66
+ ├── animated-svg-text.md — AnimatedSvgText: inline SVG stroke-draw-then-fill animation, text slot, CSS token API
66
67
  ├── accordian-core.md — AccordianCore indexed dynamic slots (accordian-{n}-summary/icon/content), exclusive-open grouping
67
68
  ├── eyebrow-text.md — EyebrowText props, usage patterns, styling
68
69
  ├── hero-text.md — HeroText props, usage patterns, styling
@@ -109,7 +110,12 @@ Each skill is a single markdown file named `<area>-<task>.md`.
109
110
  ├── site-header.md — SiteHeader: PageRow + SkipLinks + ResponsiveHeader composition, #branding/#secondaryNavigation slots, dual styleClassPassthrough hooks
110
111
  ├── responsive-header.md — ResponsiveHeader: overflow-collapsing adaptive nav, measurement-pipeline gotchas (unsized icons, vw font-size drift), full CSS token API
111
112
  ├── navigation-items.md — NavigationItems: internal overflow-panel renderer for ResponsiveHeader, complement-visibility logic, not used standalone
112
- └── cookie-consent-banner.md — CookieConsentBanner: fixed non-modal Accept/Reject banner driven by useCookieConsent, message/acceptLabel/rejectLabel slots, CSS token API
113
+ ├── cookie-consent-banner.md — CookieConsentBanner: fixed non-modal Accept/Reject banner driven by useCookieConsent, message/acceptLabel/rejectLabel slots, CSS token API
114
+ ├── display-banner.md — DisplayBanner: canvas/content stacked overlay banner, conditional slot wrappers, CSS token API
115
+ ├── deep-expanding-menu.md — DeepExpandingMenu: anchor-positioned popover nav panels, browser support caveat, CSS token API
116
+ ├── deep-expanding-menu-classic.md — DeepExpandingMenuClassic: <details>-based fallback nav, click-outside close, CSS token API
117
+ ├── display-tooltip.md — DisplayTooltip: anchor-positioned popover trigger, browser support caveat, CSS token API
118
+ └── display-tooltip-defined.md — DisplayTooltipDefined: structured title/body/action tooltip content with close button, composes DisplayTooltip
113
119
  ```
114
120
 
115
121
  ## Skill file template
@@ -240,3 +240,33 @@ Key points:
240
240
  them in the Storybook controls panel (e.g. `"Model"`, `"Basic"`, `"Validation"`, `"Styling"`, `"Slots"`).
241
241
  - Export multiple named stories (`Default`, `WithError`, `Outlined`, etc.) when you want
242
242
  Playwright to test distinct visual states via separate story URLs.
243
+
244
+ ## Scroll/animation-driven effects need surrounding chrome, not just the bare component
245
+
246
+ A story that renders a scroll- or timer-driven component with no other markup often fails to
247
+ show the effect at all — not because the component is broken, but because the demo gives the
248
+ viewer nothing to judge it against. Things that make an animated/scroll-driven effect illegible
249
+ in isolation:
250
+
251
+ - The effect resolves over a short distance/time relative to the page (e.g. a 100px scroll
252
+ window on an otherwise-long page) — easy to scroll straight past it.
253
+ - The visual change is a subtle crop/shift on photographic content, where the eye has no
254
+ reference point to notice a boundary moving.
255
+ - There's no indicator of *where* or *when* the effect completes.
256
+
257
+ Add scaffolding around the component to fix this, rather than assuming a bigger/slower prop
258
+ value alone solves it:
259
+
260
+ - A fixed marker (a line, label) at the point in the viewport where the effect's key transition
261
+ happens (e.g. `position: fixed; top: 0` for a component that resolves when its own top hits
262
+ the viewport top).
263
+ - A ruled/striped overlay or contrasting background behind the animated content so a boundary
264
+ (clip edge, wipe line, fade) is visible against it, not just a crop of photo pixels.
265
+ - Short on-page instructions telling the viewer what to do and what to watch for ("scroll
266
+ slowly — the image clips in over the last 100px before the red line").
267
+ - A second story with an exaggerated prop value (larger distance/duration) purely so the effect
268
+ is easy to preview without precise scrolling/timing — see `ClipElement`'s `LargeClipDistance`
269
+ story for the pattern (`.claude/skills/components/clip-element.md`).
270
+
271
+ This is a documentation-only concern — it doesn't change the component's default props, just
272
+ how the story demonstrates it.
@@ -0,0 +1,15 @@
1
+ {
2
+ "SRCDEV AnimatedSvgText": {
3
+ "description": "AnimatedSvgText — stroke-draw-then-fill animation for inline SVG text/path content",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<AnimatedSvgText>",
7
+ " <template #text>",
8
+ " <svg viewBox=\"0 0 $1600 $2150\">",
9
+ " <path d=\"$3\" />",
10
+ " </svg>",
11
+ " </template>",
12
+ "</AnimatedSvgText>"
13
+ ]
14
+ }
15
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "SRCDEV CanvasSwitcher": {
3
+ "description": "CanvasSwitcher — v-model:canvas-name icon button group for MediaCanvas preview widths",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<CanvasSwitcher v-model:canvas-name=\"${1:canvasName}\" />"
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "SRCDEV ClipElement Basic": {
3
+ "description": "ClipElement — scroll-driven clip-path reveal for a single image or block",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<ClipElement :max-clip=\"$1100\">",
7
+ " <img src=\"$2/images/hero.jpg\" alt=\"$3Hero\" style=\"width: 100%; display: block;\" />",
8
+ "</ClipElement>"
9
+ ]
10
+ },
11
+ "SRCDEV ClipElement With Passthrough": {
12
+ "description": "ClipElement with styleClassPassthrough for a custom wrapper class",
13
+ "scope": "vue,html",
14
+ "body": [
15
+ "<ClipElement :max-clip=\"$1150\" style-class-passthrough=\"$2my-clip-wrapper\">",
16
+ " $0",
17
+ "</ClipElement>"
18
+ ]
19
+ }
20
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "SRCDEV ClippedPanel Basic": {
3
+ "description": "ClippedPanel — fixed-size notched/cutout shape panel",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<ClippedPanel variant=\"$1square\">",
7
+ " $0",
8
+ "</ClippedPanel>"
9
+ ]
10
+ },
11
+ "SRCDEV ClippedPanel Custom Colours": {
12
+ "description": "ClippedPanel with background/outline colour overrides",
13
+ "scope": "vue,html",
14
+ "body": [
15
+ "<ClippedPanel",
16
+ " variant=\"$1square\"",
17
+ " style=\"--clipped-panel-background-colour: $2#fef3e7; --clipped-panel-outline-colour: $3#b5651d;\"",
18
+ ">",
19
+ " $0",
20
+ "</ClippedPanel>"
21
+ ]
22
+ }
23
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "SRCDEV DeepExpandingMenuClassic Basic": {
3
+ "description": "DeepExpandingMenuClassic — <details>-based fallback nav",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<DeepExpandingMenuClassic :nav-links=\"$1navLinks\" />"
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "SRCDEV DeepExpandingMenu Basic": {
3
+ "description": "DeepExpandingMenu — anchor-positioned popover nav panels",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<DeepExpandingMenu :nav-links=\"$1navLinks\" />"
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "SRCDEV DisplayBanner Basic": {
3
+ "description": "DisplayBanner — canvas/content stacked overlay banner",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<DisplayBanner>",
7
+ " <template #canvas>",
8
+ " $1",
9
+ " </template>",
10
+ " <template #content>",
11
+ " $0",
12
+ " </template>",
13
+ "</DisplayBanner>"
14
+ ]
15
+ },
16
+ "SRCDEV DisplayBanner Min Height": {
17
+ "description": "DisplayBanner with min-height override",
18
+ "scope": "vue,html",
19
+ "body": [
20
+ "<DisplayBanner style=\"--display-banner-min-height: $140rem;\">",
21
+ " <template #canvas>",
22
+ " $2",
23
+ " </template>",
24
+ " <template #content>",
25
+ " $0",
26
+ " </template>",
27
+ "</DisplayBanner>"
28
+ ]
29
+ }
30
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "SRCDEV DisplayTooltipDefined Basic": {
3
+ "description": "DisplayTooltipDefined — structured title/body/action tooltip",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<DisplayTooltipDefined",
7
+ " :content-text=\"{",
8
+ " tooltipTitle: { tag: 'h4', text: '$1' },",
9
+ " tooltipContent: { tag: 'p', text: '$0' },",
10
+ " }\"",
11
+ "/>"
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "SRCDEV DisplayTooltip Basic": {
3
+ "description": "DisplayTooltip — anchor-positioned popover trigger",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<DisplayTooltip>",
7
+ " <template #tooltipContent>",
8
+ " <p>$0</p>",
9
+ " </template>",
10
+ "</DisplayTooltip>"
11
+ ]
12
+ },
13
+ "SRCDEV DisplayTooltip With Trigger Content": {
14
+ "description": "DisplayTooltip with a triggerContent label",
15
+ "scope": "vue,html",
16
+ "body": [
17
+ "<DisplayTooltip>",
18
+ " <template #triggerContent>",
19
+ " <span>$1</span>",
20
+ " </template>",
21
+ " <template #tooltipContent>",
22
+ " <p>$0</p>",
23
+ " </template>",
24
+ "</DisplayTooltip>"
25
+ ]
26
+ }
27
+ }
@@ -0,0 +1,58 @@
1
+ <template>
2
+ <div class="animated-svg-text" :class="[elementClasses]">
3
+ <slot name="text"></slot>
4
+ </div>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ interface Props {
9
+ styleClassPassthrough?: string | string[];
10
+ }
11
+
12
+ const props = withDefaults(defineProps<Props>(), {
13
+ styleClassPassthrough: () => [],
14
+ });
15
+
16
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
17
+
18
+ watch(
19
+ () => props.styleClassPassthrough,
20
+ () => {
21
+ resetElementClasses(props.styleClassPassthrough);
22
+ }
23
+ );
24
+ </script>
25
+
26
+ <style lang="css">
27
+ @layer components {
28
+
29
+ @keyframes animatedSvgText {
30
+ 0% {
31
+ fill: transparent;
32
+ stroke-dashoffset: var(--animated-svg-text-stroke-dasharray, 1000);
33
+ stroke-width: var(--animated-svg-text-stroke-width, 0.3);
34
+ }
35
+ 70% {
36
+ fill: transparent;
37
+ stroke-width: var(--animated-svg-text-stroke-width, 0.3);
38
+ }
39
+ 100% {
40
+ fill: var(--animated-svg-text-fill-colour, var(--theme-text));
41
+ stroke-dashoffset: 0;
42
+ stroke-width: 0;
43
+ }
44
+ }
45
+
46
+ .animated-svg-text {
47
+ svg path,
48
+ svg text {
49
+ stroke: var(--animated-svg-text-stroke-colour, var(--theme-text));
50
+ stroke-width: var(--animated-svg-text-stroke-width, 0.3);
51
+ stroke-dasharray: var(--animated-svg-text-stroke-dasharray, 1000);
52
+ stroke-dashoffset: var(--animated-svg-text-stroke-dasharray, 1000);
53
+ animation: animatedSvgText var(--animated-svg-text-animation-duration, 2s) linear 1 forwards;
54
+ }
55
+ }
56
+
57
+ }
58
+ </style>
@@ -0,0 +1,34 @@
1
+ # AnimatedSvgText — Consumer Styling
2
+
3
+ `AnimatedSvgText` has no props of its own beyond `styleClassPassthrough` — the SVG markup is
4
+ provided via the `text` slot, either as `<path>` outlines of the text or a plain SVG `<text>`
5
+ element (both support `stroke`/`fill`/`stroke-dasharray` per the SVG spec). All visual control is
6
+ through public CSS custom properties, consumed directly on `.animated-svg-text svg path, .animated-svg-text svg text`.
7
+
8
+ ## Public tokens
9
+
10
+ | Property | Default | Description |
11
+ | -------- | ------- | ------------ |
12
+ | `--animated-svg-text-stroke-colour` | `var(--theme-text)` | Stroke colour during the draw-on phase. |
13
+ | `--animated-svg-text-fill-colour` | `var(--theme-text)` | Fill colour once the draw-on animation completes. |
14
+ | `--animated-svg-text-stroke-width` | `0.3` | Stroke width (in the SVG's own coordinate units) during the draw-on phase. |
15
+ | `--animated-svg-text-stroke-dasharray` | `1000` | Dash length used to drive the "draw" effect. Set this to roughly the total path length of your SVG — too small and the stroke completes before covering the full outline; too large and the animation appears to pause at the start. |
16
+ | `--animated-svg-text-animation-duration` | `2s` | Duration of the full draw-fill animation. |
17
+
18
+ ```vue
19
+ <AnimatedSvgText style="--animated-svg-text-stroke-colour: #e63946; --animated-svg-text-animation-duration: 3.5s;">
20
+ <template #text>
21
+ <svg viewBox="0 0 400 100"><path d="..." /></svg>
22
+ </template>
23
+ </AnimatedSvgText>
24
+ ```
25
+
26
+ ## Notes
27
+
28
+ - The animation runs once on mount (`forwards`, `1` iteration) — it does not loop or replay on re-entry.
29
+ - `--animated-svg-text-stroke-dasharray` is the one token worth tuning per SVG: it depends on the
30
+ total length of the animated content, which varies with font, text length, and the SVG's
31
+ coordinate scale (`viewBox`). The defaults (`0.3` stroke-width, `1000` dasharray) suit path data
32
+ traced at a small/normalised coordinate scale — a larger `viewBox` (e.g. plain SVG `<text>` at a
33
+ large `font-size`) needs a proportionally larger `--animated-svg-text-stroke-width` and
34
+ `--animated-svg-text-stroke-dasharray`, or the stroke renders too thin/short to see.
@@ -0,0 +1,69 @@
1
+ import AnimatedSvgText from "../AnimatedSvgText.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+
4
+ const meta: Meta<typeof AnimatedSvgText> = {
5
+ title: "Atoms/Animations/AnimatedSvgText",
6
+ component: AnimatedSvgText,
7
+ argTypes: {
8
+ styleClassPassthrough: {
9
+ table: { disable: true },
10
+ },
11
+ },
12
+ parameters: {
13
+ docs: {
14
+ description: {
15
+ component:
16
+ "Animates inline SVG text outlines with a stroke-draw-then-fill effect on mount. Provide the SVG markup via the `text` slot. Styling is entirely via CSS custom properties — see CONSUMER-STYLING.md.",
17
+ },
18
+ },
19
+ },
20
+ };
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof AnimatedSvgText>;
24
+
25
+ const svgSlot = `
26
+ <svg viewBox="0 0 600 150" xmlns="http://www.w3.org/2000/svg" style="width: 100%; height: auto;">
27
+ <text x="0" y="110" font-size="120" font-family="sans-serif" font-weight="700" fill="none">Hello</text>
28
+ </svg>
29
+ `;
30
+
31
+ // The default tokens (stroke-width 0.3, dasharray 1000) are tuned for path data traced at a
32
+ // small/normalised coordinate scale. This demo's viewBox is much larger, so both stories override
33
+ // stroke-width/dasharray to suit — see CONSUMER-STYLING.md: dasharray should roughly match the
34
+ // total path length of the SVG being animated.
35
+ const demoScaleTokens = "--animated-svg-text-stroke-width: 2; --animated-svg-text-stroke-dasharray: 2200;";
36
+
37
+ export const Default: Story = {
38
+ render: () => ({
39
+ components: { AnimatedSvgText },
40
+ template: `<AnimatedSvgText style="${demoScaleTokens}"><template #text>${svgSlot}</template></AnimatedSvgText>`,
41
+ }),
42
+ parameters: {
43
+ docs: {
44
+ description: {
45
+ story: "Default draw-on animation using the theme's text colour for both stroke and fill.",
46
+ },
47
+ },
48
+ },
49
+ };
50
+
51
+ export const CustomColourAndSpeed: Story = {
52
+ render: () => ({
53
+ components: { AnimatedSvgText },
54
+ template: `
55
+ <AnimatedSvgText
56
+ style="${demoScaleTokens} --animated-svg-text-stroke-colour: #e63946; --animated-svg-text-fill-colour: #e63946; --animated-svg-text-animation-duration: 3.5s;"
57
+ >
58
+ <template #text>${svgSlot}</template>
59
+ </AnimatedSvgText>
60
+ `,
61
+ }),
62
+ parameters: {
63
+ docs: {
64
+ description: {
65
+ story: "Overriding `--animated-svg-text-stroke-colour`, `--animated-svg-text-fill-colour`, and slowing the animation via `--animated-svg-text-animation-duration`.",
66
+ },
67
+ },
68
+ },
69
+ };
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import AnimatedSvgText from "../AnimatedSvgText.vue";
4
+
5
+ describe("AnimatedSvgText", () => {
6
+ // ─── Mount ───────────────────────────────────────────────────────────────
7
+
8
+ it("mounts without error", async () => {
9
+ const wrapper = await mountSuspended(AnimatedSvgText);
10
+ expect(wrapper.vm).toBeTruthy();
11
+ });
12
+
13
+ it("renders correct HTML structure", async () => {
14
+ const wrapper = await mountSuspended(AnimatedSvgText, {
15
+ slots: { text: '<svg viewBox="0 0 100 100"><path d="M0 0 L100 100" /></svg>' },
16
+ });
17
+ expect(wrapper.html()).toMatchSnapshot();
18
+ });
19
+
20
+ // ─── Root element ────────────────────────────────────────────────────────
21
+
22
+ it("renders a div with the animated-svg-text class", async () => {
23
+ const wrapper = await mountSuspended(AnimatedSvgText);
24
+ expect(wrapper.element.tagName).toBe("DIV");
25
+ expect(wrapper.classes()).toContain("animated-svg-text");
26
+ });
27
+
28
+ // ─── Slot ────────────────────────────────────────────────────────────────
29
+
30
+ it("renders the text slot content", async () => {
31
+ const wrapper = await mountSuspended(AnimatedSvgText, {
32
+ slots: { text: '<svg data-testid="my-svg"><path d="M0 0" /></svg>' },
33
+ });
34
+ expect(wrapper.find('[data-testid="my-svg"]').exists()).toBe(true);
35
+ });
36
+
37
+ // ─── styleClassPassthrough ───────────────────────────────────────────────
38
+
39
+ it("applies a single styleClassPassthrough string to the root", async () => {
40
+ const wrapper = await mountSuspended(AnimatedSvgText, {
41
+ props: { styleClassPassthrough: "hero-svg-text" },
42
+ });
43
+ expect(wrapper.classes()).toContain("hero-svg-text");
44
+ });
45
+
46
+ it("applies multiple styleClassPassthrough classes from an array", async () => {
47
+ const wrapper = await mountSuspended(AnimatedSvgText, {
48
+ props: { styleClassPassthrough: ["hero-svg-text", "mbe-32"] },
49
+ });
50
+ expect(wrapper.classes()).toContain("hero-svg-text");
51
+ expect(wrapper.classes()).toContain("mbe-32");
52
+ });
53
+
54
+ it("updates classes when styleClassPassthrough prop changes", async () => {
55
+ const wrapper = await mountSuspended(AnimatedSvgText, {
56
+ props: { styleClassPassthrough: ["original"] },
57
+ });
58
+ expect(wrapper.classes()).toContain("original");
59
+ await wrapper.setProps({ styleClassPassthrough: ["updated"] });
60
+ expect(wrapper.classes()).not.toContain("original");
61
+ expect(wrapper.classes()).toContain("updated");
62
+ });
63
+ });
@@ -0,0 +1,7 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`AnimatedSvgText > renders correct HTML structure 1`] = `
4
+ "<div class="animated-svg-text"><svg viewBox="0 0 100 100">
5
+ <path d="M0 0 L100 100"></path>
6
+ </svg></div>"
7
+ `;
@@ -0,0 +1,27 @@
1
+ # ClipElement — Consumer Styling Guide
2
+
3
+ ## No public CSS token API
4
+
5
+ `ClipElement` has no `--clip-element-*` custom properties. Its only visual output is the
6
+ `clip-path` inset applied to `.clipped-element`, and that value is entirely computed at runtime
7
+ from the `maxClip` prop and the element's scroll position — there is no fixed appearance value a
8
+ consumer would plausibly want to override via CSS.
9
+
10
+ ## Override surface
11
+
12
+ Use `styleClassPassthrough` to apply your own classes to the root `.clip-element-wrapper`:
13
+
14
+ ```vue
15
+ <ClipElement :max-clip="150" style-class-passthrough="my-clip-wrapper">
16
+ <img src="/images/hero.jpg" alt="Hero" />
17
+ </ClipElement>
18
+ ```
19
+
20
+ ```css
21
+ .my-clip-wrapper {
22
+ border-radius: 1.6rem;
23
+ }
24
+ ```
25
+
26
+ Style the slotted content directly for anything beyond the clip effect itself (sizing, object-fit,
27
+ borders, etc.) — `ClipElement` does not constrain it.
@@ -7,18 +7,23 @@
7
7
  </template>
8
8
 
9
9
  <script lang="ts" setup>
10
- const props = defineProps({
11
- maxClip: {
12
- type: Number,
13
- default: 100,
14
- },
15
- styleClassPassthrough: {
16
- type: [String, Array] as PropType<string | string[]>,
17
- default: () => [],
18
- },
19
- })
10
+ interface Props {
11
+ /** Scroll distance (px) over which the element clips in/out. */
12
+ maxClip?: number;
13
+ styleClassPassthrough?: string | string[];
14
+ }
15
+
16
+ const props = withDefaults(defineProps<Props>(), {
17
+ maxClip: 100,
18
+ styleClassPassthrough: () => [],
19
+ });
20
+
21
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
20
22
 
21
- const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
23
+ watch(
24
+ () => props.styleClassPassthrough,
25
+ () => resetElementClasses(props.styleClassPassthrough)
26
+ );
22
27
 
23
28
  const container = ref(null)
24
29
  const clipElement = ref<HTMLDivElement | null>(null)