srcdev-nuxt-components 9.6.2 → 9.6.4

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 (58) hide show
  1. package/.claude/rules/consumer/consumer-overrides.md +17 -0
  2. package/.claude/rules/consumer/css-comment-slash.md +13 -0
  3. package/.claude/rules/consumer/css-native-nesting.md +13 -0
  4. package/.claude/rules/consumer/css-no-deep.md +13 -0
  5. package/.claude/rules/consumer/css-rem-base.md +11 -0
  6. package/.claude/rules/consumer/css-surface-text-pairing.md +13 -0
  7. package/.claude/rules/consumer/css-webkit-support.md +15 -0
  8. package/.claude/rules/consumer/explicit-vue-imports.md +11 -0
  9. package/.claude/rules/consumer/nuxt-img-dimensions.md +12 -0
  10. package/.claude/rules/consumer/srcdev-skills-location.md +9 -0
  11. package/.claude/rules/consumer/story-args.md +12 -0
  12. package/.claude/rules/consumer/story-register-components.md +12 -0
  13. package/.claude/rules/consumer/vue-auto-imports.md +14 -0
  14. package/.claude/rules/consumer/vue-no-self-closing.md +12 -0
  15. package/.claude/rules/consumer/vue-prop-hyphenation.md +11 -0
  16. package/.claude/rules/consumer/vue-props-pattern.md +13 -0
  17. package/.claude/rules/consumer/vue-slots-no-computed.md +14 -0
  18. package/.claude/rules/consumer/vue-video-source.md +12 -0
  19. package/.claude/skills/component-compliance-checklist.md +31 -3
  20. package/.claude/skills/component-inline-action-button.md +33 -2
  21. package/.claude/skills/component-naming.md +1 -1
  22. package/.claude/skills/components/cookie-consent-banner.md +9 -6
  23. package/.claude/skills/components/input-copy.md +47 -100
  24. package/.claude/skills/components/pricing-card.md +68 -84
  25. package/.claude/skills/composable-cookie-consent.md +1 -1
  26. package/.claude/skills/index.md +11 -5
  27. package/.claude/skills/new-app-scaffold.md +4 -2
  28. package/.claude/skills/setup-postinstall.md +5 -4
  29. package/.claude/skills/storybook-add-story.md +44 -0
  30. package/.claude/skills/testing-add-unit-test.md +19 -0
  31. package/.claude/skills/theming-component-token-pattern.md +47 -1
  32. package/.claude/skills/theming-partial-override.md +1 -1
  33. package/.vscode/srcdev-component-cookie-consent-banner.code-snippets +7 -5
  34. package/.vscode/srcdev-component-input-copy.code-snippets +24 -42
  35. package/.vscode/srcdev-component-pricing-card.code-snippets +46 -84
  36. package/README.md +5 -5
  37. package/app/components/01.atoms/animations/scroll-reveal-frame/stories/ScrollRevealFrame.stories.ts +2 -0
  38. package/app/components/01.atoms/animations/scroll-reveal-image/stories/ScrollRevealImage.stories.ts +2 -0
  39. package/app/components/01.atoms/animations/section-parallax/stories/SectionParallax.stories.ts +4 -0
  40. package/app/components/01.atoms/animations/view-timeline/stories/WipeAwayVertical.stories.ts +2 -0
  41. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +13 -4
  42. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +23 -14
  43. package/app/components/02.molecules/cookie-consent-banner/stories/CookieConsentBanner.stories.ts +108 -5
  44. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +33 -11
  45. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +82 -124
  46. package/app/components/02.molecules/input-copy/InputCopy.vue +110 -85
  47. package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +204 -48
  48. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +147 -171
  49. package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +96 -149
  50. package/app/components/02.molecules/pricing-card/PricingCard.vue +84 -44
  51. package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +241 -104
  52. package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +149 -123
  53. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +4 -0
  54. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +4 -0
  55. package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabel.vue +1 -1
  56. package/app/composables/useAriaDescribedById.ts +1 -1
  57. package/app/types/components/cookie-consent-banner.d.ts +2 -0
  58. package/package.json +2 -1
@@ -4,97 +4,65 @@
4
4
  "scope": "vue,html",
5
5
  "body": [
6
6
  "<PricingCard",
7
- " planName=\"$1Single Site\"",
7
+ " plan-name=\"$1Single Site\"",
8
8
  " :price=\"$2199\"",
9
- " billingPeriod=\"$3one-time\"",
10
- " description=\"$4Perfect for freelancers and solo practitioners.\"",
11
- " :features=\"[",
12
- " '$5Feature 1',",
13
- " '$6Feature 2',",
14
- " '$7Feature 3',",
15
- " ]\"",
16
- " @select=\"$8handleSelect\"",
9
+ " currency-symbol=\"$3£\"",
10
+ " billing-period=\"$4one-time\"",
11
+ " description=\"$5Perfect for freelancers and solo practitioners.\"",
12
+ " :features=\"['$6Feature 1', '$7Feature 2', '$8Feature 3']\"",
13
+ " @select=\"$9handleSelect\"",
17
14
  "/>"
18
15
  ]
19
16
  },
20
17
  "SRCDEV PricingCard Highlighted": {
21
- "description": "PricingCard with 'Most Popular' highlight and custom button",
18
+ "description": "PricingCard with 'Most Popular' highlight",
22
19
  "scope": "vue,html",
23
20
  "body": [
24
21
  "<PricingCard",
25
- " planName=\"$1Agency\"",
22
+ " plan-name=\"$1Agency\"",
26
23
  " :price=\"$2499\"",
27
- " billingPeriod=\"$3one-time\"",
24
+ " billing-period=\"$3one-time\"",
28
25
  " description=\"$4Best for growing teams.\"",
29
- " :is-highlighted=\"true\"",
30
- " :features=\"[",
31
- " '$5Feature 1',",
32
- " '$6Feature 2',",
33
- " '$7Feature 3',",
34
- " ]\"",
35
- " @select=\"$8handleSelect\"",
36
- ">",
37
- " <template #cta=\"{ ctaText, isDisabled, onSelect }\">",
38
- " <InputButton",
39
- " :button-text=\"ctaText\"",
40
- " variant=\"$9primary\"",
41
- " :readonly=\"isDisabled\"",
42
- " @click=\"onSelect\"",
43
- " />",
44
- " </template>",
45
- "</PricingCard>"
26
+ " is-highlighted",
27
+ " badge-text=\"$5Most Popular\"",
28
+ " :features=\"['$6Feature 1', '$7Feature 2', '$8Feature 3']\"",
29
+ " @select=\"$9handleSelect\"",
30
+ "/>"
46
31
  ]
47
32
  },
48
33
  "SRCDEV PricingCard Grid": {
49
- "description": "Three-card pricing comparison grid",
34
+ "description": "Pricing comparison grid from a plans array",
50
35
  "scope": "vue,html",
51
36
  "body": [
52
- "<div class=\"pricing-cards-grid\">",
53
- " <PricingCard",
54
- " planName=\"Single Site\"",
55
- " :price=\"$1199\"",
56
- " billingPeriod=\"one-time\"",
57
- " description=\"$2For freelancers.\"",
58
- " :features=\"['$3Feature 1', '$4Feature 2']\"",
59
- " @select=\"handleSelect\"",
60
- " />",
61
- " <PricingCard",
62
- " planName=\"Agency\"",
63
- " :price=\"$5499\"",
64
- " billingPeriod=\"one-time\"",
65
- " description=\"$6For growing teams.\"",
66
- " :is-highlighted=\"true\"",
67
- " :features=\"['$7Feature 1', '$8Feature 2', '$9Feature 3']\"",
68
- " @select=\"handleSelect\"",
69
- " />",
37
+ "<div class=\"$1pricing-grid\">",
70
38
  " <PricingCard",
71
- " planName=\"Unlimited\"",
72
- " :price=\"$10999\"",
73
- " billingPeriod=\"one-time\"",
74
- " description=\"$11Enterprise plan.\"",
75
- " :features=\"['$12Feature 1', '$13Feature 2', '$14Feature 3', '$15Feature 4']\"",
76
- " @select=\"handleSelect\"",
39
+ " v-for=\"plan in $2plans\"",
40
+ " :key=\"plan.id\"",
41
+ " :plan-name=\"plan.name\"",
42
+ " :price=\"plan.price\"",
43
+ " :description=\"plan.description\"",
44
+ " :features=\"plan.features\"",
45
+ " :is-highlighted=\"plan.id === '$3pro'\"",
46
+ " heading-tag=\"$4h2\"",
47
+ " @select=\"$5handleSelect\"",
77
48
  " />",
78
49
  "</div>"
79
50
  ]
80
51
  },
81
52
  "SRCDEV PricingCard Custom Button": {
82
- "description": "PricingCard with custom button variant via #cta slot",
53
+ "description": "PricingCard with custom button via #cta slot",
83
54
  "scope": "vue,html",
84
55
  "body": [
85
56
  "<PricingCard",
86
- " planName=\"$1Premium\"",
57
+ " plan-name=\"$1Premium\"",
87
58
  " :price=\"$2799\"",
88
- " billingPeriod=\"$3one-time\"",
89
- " description=\"$4All features included.\"",
90
- " :features=\"['$5Feature 1', '$6Feature 2']\"",
91
- " @select=\"$7handleSelect\"",
59
+ " :features=\"['$3Feature 1', '$4Feature 2']\"",
60
+ " @select=\"$5handleSelect\"",
92
61
  ">",
93
62
  " <template #cta=\"{ ctaText, isDisabled, onSelect }\">",
94
63
  " <InputButton",
95
64
  " :button-text=\"ctaText\"",
96
- " variant=\"$8secondary\"",
97
- " theme=\"$9default\"",
65
+ " variant=\"$6secondary\"",
98
66
  " :readonly=\"isDisabled\"",
99
67
  " @click=\"onSelect\"",
100
68
  " />",
@@ -107,17 +75,12 @@
107
75
  "scope": "vue,html",
108
76
  "body": [
109
77
  "<PricingCard",
110
- " planName=\"$1Annual\"",
78
+ " plan-name=\"$1Annual\"",
111
79
  " :price=\"$249\"",
112
- " billingPeriod=\"$3per year\"",
113
- " description=\"$4Early adopter pricing on the annual plan.\"",
114
- " ribbonText=\"$5EARLYBIRD50 · 50% off\"",
115
- " :features=\"[",
116
- " '$6Feature 1',",
117
- " '$7Feature 2',",
118
- " '$8Feature 3',",
119
- " ]\"",
120
- " @select=\"$9handleSelect\"",
80
+ " billing-period=\"$3per year\"",
81
+ " ribbon-text=\"$4EARLYBIRD50 · 50% off\"",
82
+ " :features=\"['$5Feature 1', '$6Feature 2']\"",
83
+ " @select=\"$7handleSelect\"",
121
84
  "/>"
122
85
  ]
123
86
  },
@@ -127,21 +90,20 @@
127
90
  "body": [
128
91
  ".$1pricing-page {",
129
92
  " .pricing-card {",
130
- " --pricing-card-border-radius: $2;",
131
- " --pricing-card-padding: $3;",
132
- " --pricing-card-gap: $4;",
133
- " --pricing-card-shadow: $5;",
93
+ " --pricing-card-background: $2;",
94
+ " --pricing-card-border-radius: $3;",
95
+ " --pricing-card-padding: $4;",
96
+ " --pricing-card-gap: $5;",
134
97
  " --pricing-card-name-color: $6;",
135
- " --pricing-card-amount-color: $7;",
136
- " --pricing-card-cta-bg: $8;",
137
- " --pricing-card-cta-bg-hover: $9;",
138
- " --pricing-card-ribbon-bg: $10;",
139
- " --pricing-card-ribbon-text: $11;",
98
+ " --pricing-card-description-line-clamp: $7;",
99
+ " --pricing-card-feature-icon-color: $8;",
100
+ " --pricing-card-ribbon-bg: $9;",
101
+ " --input-button-primary-surface: $10;",
140
102
  "",
141
103
  " &.is-highlighted {",
142
- " --pricing-card-highlight-border: $12;",
143
- " --pricing-card-highlight-shadow: $13;",
144
- " --pricing-card-highlight-scale: $14;",
104
+ " --pricing-card-highlight-border: $11;",
105
+ " --pricing-card-highlight-scale: $12;",
106
+ " --pricing-card-badge-bg: $13;",
145
107
  " }",
146
108
  " }",
147
109
  "}"
package/README.md CHANGED
@@ -25,12 +25,12 @@ defineNuxtConfig({
25
25
 
26
26
  ## Claude Code Skills
27
27
 
28
- This package ships Claude Code skills — reference docs for components and development tasks — in the `.claude/` directory.
28
+ This package ships Claude Code skills (reference docs for components and development tasks) and a curated set of Claude Code rules (short constraints for working with the layer: CSS nesting, overrides, props, NuxtImg, Storybook args) in the `.claude/` directory.
29
29
 
30
30
  To make them available in your project, add this script to your `package.json`:
31
31
 
32
32
  ```json
33
- "setup:claude": "cp -r node_modules/srcdev-nuxt-components/.claude/skills .claude/skills/srcdev-nuxt-components"
33
+ "setup:claude": "rm -rf .claude/skills/srcdev-nuxt-components .claude/rules/srcdev-nuxt-components && mkdir -p .claude/skills/srcdev-nuxt-components .claude/rules/srcdev-nuxt-components && cp -r node_modules/srcdev-nuxt-components/.claude/skills/. .claude/skills/srcdev-nuxt-components && cp -r node_modules/srcdev-nuxt-components/.claude/rules/consumer/. .claude/rules/srcdev-nuxt-components"
34
34
  ```
35
35
 
36
36
  Then run it after install:
@@ -39,7 +39,7 @@ Then run it after install:
39
39
  npm run setup:claude
40
40
  ```
41
41
 
42
- Skills are copied into `.claude/skills/srcdev-nuxt-components/` so they never conflict with or overwrite skills your own project defines. Re-running the script after a package update is safe.
42
+ Skills are copied into `.claude/skills/srcdev-nuxt-components/` and rules into `.claude/rules/srcdev-nuxt-components/`, so they never conflict with or overwrite your own project's. Re-running the script after a package update is safe: it replaces only those two `srcdev-nuxt-components` folders, so skills and rules dropped or renamed upstream are removed too, and your own skills and rules are never touched.
43
43
 
44
44
  ### Automate with postinstall (recommended)
45
45
 
@@ -47,7 +47,7 @@ To ensure skills are always up to date, VSCode snippets are installed, and `nuxt
47
47
 
48
48
  ```json
49
49
  "scripts": {
50
- "setup:claude": "mkdir -p .claude/skills/srcdev-nuxt-components && cp -r node_modules/srcdev-nuxt-components/.claude/skills/. .claude/skills/srcdev-nuxt-components",
50
+ "setup:claude": "rm -rf .claude/skills/srcdev-nuxt-components .claude/rules/srcdev-nuxt-components && mkdir -p .claude/skills/srcdev-nuxt-components .claude/rules/srcdev-nuxt-components && cp -r node_modules/srcdev-nuxt-components/.claude/skills/. .claude/skills/srcdev-nuxt-components && cp -r node_modules/srcdev-nuxt-components/.claude/rules/consumer/. .claude/rules/srcdev-nuxt-components",
51
51
  "postinstall": "node node_modules/srcdev-nuxt-components/scripts/copy-snippets.mjs && nuxt prepare && npm run setup:claude"
52
52
  }
53
53
  ```
@@ -56,7 +56,7 @@ To ensure skills are always up to date, VSCode snippets are installed, and `nuxt
56
56
 
57
57
  1. `copy-snippets.mjs` — copies VSCode snippets (`.code-snippets` files) from the layer to your `.vscode/` folder
58
58
  2. `nuxt prepare` — generates Nuxt type declarations
59
- 3. `setup:claude` — copies skills into `.claude/skills/srcdev-nuxt-components/`
59
+ 3. `setup:claude` — copies skills into `.claude/skills/srcdev-nuxt-components/` and rules into `.claude/rules/srcdev-nuxt-components/`
60
60
 
61
61
  > **Note**: The snippet copy must run from your consumer app's postinstall, not the layer's. npm doesn't invoke postinstall hooks for dependencies, so each consumer app is responsible for copying snippets into its own `.vscode/` folder. Don't set `SRCDEV_STANDALONE` in a consumer app: that flag is only for running this layer on its own (it enables dev-only modules and the ramp watcher).
62
62
 
@@ -25,7 +25,9 @@ const meta: Meta<typeof ScrollRevealFrame> = {
25
25
  },
26
26
  },
27
27
  parameters: {
28
+ // Own iframe on the Docs page: the effect depends on page scroll.
28
29
  docs: {
30
+ story: { inline: false, height: "600px" },
29
31
  description: {
30
32
  component:
31
33
  "Generic clipping frame that pans any slot content vertically as it scrolls through the viewport — driven entirely by CSS Scroll-driven Animations. No scroll listeners, no JS. Use this when you need the parallax reveal with arbitrary content (grids, video, etc.). For a single NuxtImg with optimisation and focal-point control, use ScrollRevealImage instead.",
@@ -50,7 +50,9 @@ const meta: Meta<typeof ScrollRevealImage> = {
50
50
  },
51
51
  },
52
52
  parameters: {
53
+ // Own iframe on the Docs page: the effect depends on page scroll.
53
54
  docs: {
55
+ story: { inline: false, height: "600px" },
54
56
  description: {
55
57
  component:
56
58
  "Single-image parallax reveal built on ScrollRevealFrame. Adds NuxtImg optimisation (src, alt, intrinsic dimensions) and a horizontal focal-point prop (focalX). For arbitrary slot content — grids, video, etc. — use ScrollRevealFrame directly.",
@@ -45,6 +45,10 @@ export default {
45
45
  showOverlay: true,
46
46
  overlayText: "A quiet moment.",
47
47
  },
48
+ parameters: {
49
+ // Own iframe on the Docs page: the effect depends on page scroll and vh heights.
50
+ docs: { story: { inline: false, height: "600px" } },
51
+ },
48
52
  } as Meta<StoryArgs>;
49
53
 
50
54
  const Template: StoryFn<StoryArgs> = (args) => ({
@@ -20,7 +20,9 @@ const meta: Meta<typeof WipeAwayVertical> = {
20
20
  styleClassPassthrough: { table: { disable: true } },
21
21
  },
22
22
  parameters: {
23
+ // Own iframe on the Docs page: the effect depends on page scroll and vh heights.
23
24
  docs: {
25
+ story: { inline: false, height: "600px" },
24
26
  description: {
25
27
  component:
26
28
  "Scroll-driven vertical wipe effect: each sticky panel wipes away to reveal the next as its paired scrolling section crosses the viewport. Pure CSS — a single-column grid overlays `.sticky-items-container` across all `.scrolling-section` rows via `animation-timeline: view()`, so there's no scroll listener or JS measurement involved. Consumers supply `stickyItem-{n}` slots for each of `itemCount` panels, and `scrollingItem-{n}` slots for only the first `itemCount - 1` — the last panel has nothing left to reveal, so it has no wipe animation or scrolling section of its own, just a small built-in trailing buffer to release cleanly. The sticky panel's own visible height is `--wipe-away-vertical-height` (default `100vh`) — leave the root's own `height` unset (`auto`) so it sizes itself from its children. Browsers without `animation-timeline: view()` support (via `@supports not (...)`) just get the sticky items stacked in normal document flow instead.",
@@ -6,6 +6,11 @@ colour, border, radius, padding, accent stripe, icon, title and message sizes, a
6
6
  `app/components/02.molecules/alert-content/CONSUMER-STYLING.md`. This guide covers what the banner
7
7
  adds: position, motion and the two buttons.
8
8
 
9
+ > **Changed 2026-10-15**: the closed state class `.closed` is now `.cookie-consent-banner-closed`,
10
+ > and the buttons are `InputButton`s (`variant="inline"`, so the banner's tokens still style them):
11
+ > each button is `.cookie-consent-banner-accept.input-button` / `.cookie-consent-banner-reject.input-button`
12
+ > with the label in an inner `.button-text`. The button tokens below are unchanged.
13
+ >
9
14
  > **Changed 2026-09-27**: rebuilt on `AlertContent`. The banner's own panel tokens
10
15
  > (`--cookie-consent-banner-padding`, `-gap`, `-border-radius`, `-border`, `-accent-border-width`,
11
16
  > `-background`, `-text-colour`, `-actions-gap`) are gone; use the matching `--alert-content-*`
@@ -41,7 +46,10 @@ adds: position, motion and the two buttons.
41
46
  | `--cookie-consent-banner-accept-text-colour` | `var(--theme-on-surface)` | Accept button text |
42
47
 
43
48
  The gap between the buttons and their alignment (right by default) are `AlertContent`'s
44
- `--alert-content-actions-gap` and `--alert-content-actions-justify`.
49
+ `--alert-content-actions-gap` and `--alert-content-actions-justify`. Long labels wrap inside the
50
+ buttons (`InputButton`'s `.button-text`), and the buttons wrap onto their own lines when the row is full.
51
+ The label keeps the banner's font size and case: `InputButton`'s `--button-font-size` and
52
+ `--button-text-transform` don't apply here.
45
53
 
46
54
  Private tokens (not public API): `--_gutter`, `--_transition-duration`, `--_accent` on the root,
47
55
  each resolving a public token once for reuse.
@@ -50,11 +58,12 @@ each resolving a public token once for reuse.
50
58
 
51
59
  - `data-theme` on the root and on the inner `.alert-content`, from the `theme` prop or `app.config`
52
60
  (default `info`). It drives every `--theme-*` default, for the panel and the buttons.
53
- - `.closed` on the root once consent is granted or denied. The banner fades out and becomes
61
+ - `.cookie-consent-banner-closed` on the root once consent is granted or denied. The banner fades out and becomes
54
62
  `visibility: hidden`, so its buttons leave the tab order and the accessibility tree.
55
63
  - Classes: `.cookie-consent-banner` > `.cookie-consent-banner-inner` (the `role="region"`) >
56
64
  `.alert-content` (see its guide for the inner structure); the buttons are
57
- `.cookie-consent-banner-reject` and `.cookie-consent-banner-accept` inside `.alert-content-actions`.
65
+ `InputButton`s with `.cookie-consent-banner-reject` and `.cookie-consent-banner-accept` (plus
66
+ `.input-button.inline`) inside `.alert-content-actions`.
58
67
  - `data-test-id`: `cookie-consent-banner`, `cookie-consent-banner-reject`, `cookie-consent-banner-accept`,
59
68
  plus `AlertContent`'s `alert-icon`, `alert-title`, `alert-content`, `alert-actions`.
60
69
 
@@ -91,5 +100,5 @@ The pre-2026-09-27 look (white panel, dark text, no tint), scoped to the banner:
91
100
  ## Class passthrough
92
101
 
93
102
  `style-class-passthrough` adds classes to the teleported root `.cookie-consent-banner`, next to
94
- `data-theme` and `.closed`. It's reachable in normal use and is the most reliable place to set
103
+ `data-theme` and `.cookie-consent-banner-closed`. It's reachable in normal use and is the most reliable place to set
95
104
  tokens for this component, since ancestor wrappers don't contain it.
@@ -2,7 +2,7 @@
2
2
  <Teleport to="body">
3
3
  <div
4
4
  class="cookie-consent-banner"
5
- :class="[{ closed: isClosed }, elementClasses]"
5
+ :class="[{ 'cookie-consent-banner-closed': isClosed }, elementClasses]"
6
6
  :data-theme="resolved.theme"
7
7
  data-test-id="cookie-consent-banner"
8
8
  >
@@ -15,22 +15,22 @@
15
15
  <slot name="message">This site uses cookies to understand how it's used. You can accept or reject them.</slot>
16
16
  </template>
17
17
  <template #actions>
18
- <button
18
+ <InputButton
19
19
  type="button"
20
+ variant="inline"
20
21
  class="cookie-consent-banner-reject"
21
22
  data-test-id="cookie-consent-banner-reject"
23
+ :button-text="rejectLabel"
22
24
  @click="rejectAll()"
23
- >
24
- <slot name="rejectLabel">Reject</slot>
25
- </button>
26
- <button
25
+ />
26
+ <InputButton
27
27
  type="button"
28
+ variant="inline"
28
29
  class="cookie-consent-banner-accept"
29
30
  data-test-id="cookie-consent-banner-accept"
31
+ :button-text="acceptLabel"
30
32
  @click="acceptAll()"
31
- >
32
- <slot name="acceptLabel">Accept</slot>
33
- </button>
33
+ />
34
34
  </template>
35
35
  </AlertContent>
36
36
  </div>
@@ -44,6 +44,8 @@ import type { CookieConsentBannerProps } from "../../../types/components";
44
44
  const props = withDefaults(defineProps<CookieConsentBannerProps>(), {
45
45
  theme: undefined,
46
46
  ariaLabel: "Cookie consent",
47
+ acceptLabel: "Accept",
48
+ rejectLabel: "Reject",
47
49
  icon: "material-symbols:cookie-outline",
48
50
  showIcon: true,
49
51
  styleClassPassthrough: () => [],
@@ -91,7 +93,7 @@ const isClosed = computed(() => status.value !== "unset");
91
93
  opacity var(--_transition-duration) ease-in-out,
92
94
  visibility var(--_transition-duration);
93
95
 
94
- &.closed {
96
+ &.cookie-consent-banner-closed {
95
97
  grid-template-rows: 0fr;
96
98
  opacity: 0;
97
99
  visibility: hidden;
@@ -107,8 +109,10 @@ const isClosed = computed(() => status.value !== "unset");
107
109
  overflow: hidden;
108
110
  }
109
111
 
110
- .cookie-consent-banner-reject,
111
- .cookie-consent-banner-accept {
112
+ .cookie-consent-banner-reject.input-button,
113
+ .cookie-consent-banner-accept.input-button {
114
+ box-sizing: border-box;
115
+ min-height: auto;
112
116
  padding: var(--cookie-consent-banner-button-padding, 0.8rem 1.6rem);
113
117
  border-radius: var(--cookie-consent-banner-button-border-radius, 0.4rem);
114
118
  border: 0.1rem solid transparent;
@@ -122,9 +126,14 @@ const isClosed = computed(() => status.value !== "unset");
122
126
  outline: 0.2rem solid var(--cookie-consent-banner-focus-ring, var(--theme-border-focus));
123
127
  outline-offset: 0.2rem;
124
128
  }
129
+
130
+ .button-text {
131
+ font-size: inherit;
132
+ text-transform: none;
133
+ }
125
134
  }
126
135
 
127
- .cookie-consent-banner-reject {
136
+ .cookie-consent-banner-reject.input-button {
128
137
  background-color: transparent;
129
138
  color: inherit;
130
139
  border-color: var(--cookie-consent-banner-reject-border-colour, var(--theme-border));
@@ -135,7 +144,7 @@ const isClosed = computed(() => status.value !== "unset");
135
144
  }
136
145
  }
137
146
 
138
- .cookie-consent-banner-accept {
147
+ .cookie-consent-banner-accept.input-button {
139
148
  background-color: var(--cookie-consent-banner-accept-background, var(--_accent));
140
149
  color: var(--cookie-consent-banner-accept-text-colour, var(--theme-on-surface));
141
150
 
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryFn } from "@nuxtjs/storybook";
2
+ import { computed } from "vue";
2
3
  import StorybookComponent from "../CookieConsentBanner.vue";
3
4
 
4
5
  export default {
@@ -21,6 +22,16 @@ export default {
21
22
  description: "Render the icon column",
22
23
  table: { category: "Appearance" },
23
24
  },
25
+ acceptLabel: {
26
+ control: "text",
27
+ description: "Accept button text",
28
+ table: { category: "Content" },
29
+ },
30
+ rejectLabel: {
31
+ control: "text",
32
+ description: "Reject button text",
33
+ table: { category: "Content" },
34
+ },
24
35
  ariaLabel: {
25
36
  control: "text",
26
37
  description: "Accessible name for the banner region",
@@ -35,19 +46,26 @@ export default {
35
46
  args: {
36
47
  theme: "info",
37
48
  ariaLabel: "Cookie consent",
49
+ acceptLabel: "Accept",
50
+ rejectLabel: "Reject",
38
51
  icon: "material-symbols:cookie-outline",
39
52
  showIcon: true,
40
53
  styleClassPassthrough: [],
41
54
  },
42
55
  parameters: {
56
+ // Own iframe on the Docs page: the banner is fixed to the viewport.
43
57
  docs: {
58
+ story: { inline: false, height: "400px" },
44
59
  description: {
45
60
  // Drives itself from the real useCookieConsent()/useCookie() state, so
46
61
  // once accepted/rejected in this browser it stays hidden across story
47
62
  // reloads. Each story below has "Show banner again" (resets state
48
63
  // in-place) and "Delete cookie" (clears document.cookie directly, for
49
64
  // verifying the cookie itself is actually gone, e.g. in devtools).
50
- component: "Reads/writes a real 'privacy-notice-consent' cookie via useCookieConsent(). Use the story's reset controls to bring the banner back.",
65
+ component:
66
+ "Fixed, non-modal cookie consent banner built on AlertContent, with Accept/Reject InputButtons wired to useCookieConsent(). " +
67
+ "Copy comes from the title and message slots and the acceptLabel, rejectLabel and ariaLabel props; theme, icon and showIcon set the look. " +
68
+ "It reads/writes a real 'privacy-notice-consent' cookie, so use the story's reset controls to bring the banner back.",
51
69
  },
52
70
  },
53
71
  },
@@ -82,7 +100,7 @@ const Template: StoryFn<typeof StorybookComponent> = (args) => ({
82
100
  <div style="padding: 2rem; min-height: 240px; position: relative;">
83
101
  <button type="button" @click="showAgain">Show banner again</button>
84
102
  <button type="button" @click="deleteCookie" style="margin-inline-start: 0.8rem;">Delete cookie</button>
85
- <StorybookComponent :theme="args.theme" :icon="args.icon" :show-icon="args.showIcon" :aria-label="args.ariaLabel" :style-class-passthrough="args.styleClassPassthrough">
103
+ <StorybookComponent :theme="args.theme" :icon="args.icon" :show-icon="args.showIcon" :aria-label="args.ariaLabel" :accept-label="args.acceptLabel" :reject-label="args.rejectLabel" :style-class-passthrough="args.styleClassPassthrough">
86
104
  <template #message>This site uses cookies for analytics. You can accept or reject them.</template>
87
105
  </StorybookComponent>
88
106
  </div>
@@ -101,12 +119,97 @@ export const CustomCopy: StoryFn<typeof StorybookComponent> = (args) => ({
101
119
  <div style="padding: 2rem; min-height: 240px; position: relative;">
102
120
  <button type="button" @click="showAgain">Show banner again</button>
103
121
  <button type="button" @click="deleteCookie" style="margin-inline-start: 0.8rem;">Delete cookie</button>
104
- <StorybookComponent :theme="args.theme" :icon="args.icon" :show-icon="args.showIcon" :aria-label="args.ariaLabel">
122
+ <StorybookComponent :theme="args.theme" :icon="args.icon" :show-icon="args.showIcon" :aria-label="args.ariaLabel" :accept-label="args.acceptLabel" :reject-label="args.rejectLabel">
105
123
  <template #title>Your privacy</template>
106
124
  <template #message>We use cookies to understand traffic to this site. No personal data is sold.</template>
107
- <template #acceptLabel>Allow cookies</template>
108
- <template #rejectLabel>No thanks</template>
109
125
  </StorybookComponent>
110
126
  </div>
111
127
  `,
112
128
  });
129
+ CustomCopy.args = { acceptLabel: "Allow cookies", rejectLabel: "No thanks" };
130
+
131
+ const stressCases = {
132
+ german: {
133
+ title: "Datenschutzeinstellungen",
134
+ message:
135
+ "Diese Website verwendet Cookies, um zu verstehen, wie sie genutzt wird, und um Ihnen ein besseres Erlebnis zu bieten. Weitere Informationen finden Sie in unserer Datenschutzerklärung.",
136
+ acceptLabel: "Alle Cookies akzeptieren und fortfahren",
137
+ rejectLabel: "Alle nicht notwendigen Cookies ablehnen",
138
+ dir: "ltr",
139
+ },
140
+ unbroken: {
141
+ title: "Datenschutzgrundverordnungskonformitätsbestätigungseinstellungen",
142
+ message: "https://example.com/a-very-long-path-segment-without-any-spaces-at-all-in-it/privacy-and-cookie-policy",
143
+ acceptLabel: "Cookieeinwilligungsbestätigung",
144
+ rejectLabel: "Cookieeinwilligungsablehnung",
145
+ dir: "ltr",
146
+ },
147
+ emojiHtml: {
148
+ title: "🍪 <strong>Cookies</strong>",
149
+ message: "<script>alert('xss')</script> We use cookies 🍪🍪🍪 & <em>similar</em> technologies.",
150
+ acceptLabel: "✅ <b>Yes</b>",
151
+ rejectLabel: "❌ <i>No</i>",
152
+ dir: "ltr",
153
+ },
154
+ rtl: {
155
+ title: "ملفات تعريف الارتباط",
156
+ message: "يستخدم هذا الموقع ملفات تعريف الارتباط لفهم كيفية استخدامه. يمكنك قبولها أو رفضها.",
157
+ acceptLabel: "قبول",
158
+ rejectLabel: "رفض",
159
+ dir: "rtl",
160
+ },
161
+ minimal: {
162
+ title: "",
163
+ message: "C",
164
+ acceptLabel: "✓",
165
+ rejectLabel: "✗",
166
+ dir: "ltr",
167
+ },
168
+ } as const;
169
+
170
+ type StressCase = keyof typeof stressCases;
171
+
172
+ export const StressTest: StoryFn<typeof StorybookComponent> = (args) => ({
173
+ components: { StorybookComponent },
174
+ setup() {
175
+ const { showAgain, deleteCookie } = useResetConsent();
176
+ const copy = computed(() => stressCases[(args as { stressCase?: StressCase }).stressCase ?? "german"]);
177
+ return { args, copy, showAgain, deleteCookie };
178
+ },
179
+ template: `
180
+ <div style="padding: 2rem; min-height: 320px; position: relative;">
181
+ <button type="button" @click="showAgain">Show banner again</button>
182
+ <button type="button" @click="deleteCookie" style="margin-inline-start: 0.8rem;">Delete cookie</button>
183
+ <StorybookComponent :theme="args.theme" :icon="args.icon" :show-icon="args.showIcon" :aria-label="args.ariaLabel" :accept-label="copy.acceptLabel" :reject-label="copy.rejectLabel" :style-class-passthrough="args.styleClassPassthrough">
184
+ <template #title><span :dir="copy.dir">{{ copy.title }}</span></template>
185
+ <template #message><span :dir="copy.dir">{{ copy.message }}</span></template>
186
+ </StorybookComponent>
187
+ </div>
188
+ `,
189
+ });
190
+ StressTest.storyName = "Stress Test (Worst-Case Data)";
191
+ StressTest.argTypes = {
192
+ stressCase: {
193
+ name: "Stress case",
194
+ control: { type: "select" },
195
+ options: Object.keys(stressCases),
196
+ description:
197
+ "**Story control, not a prop.** Picks which worst-case copy fills the title and message slots and the acceptLabel/rejectLabel props (overriding their controls).",
198
+ table: { category: "Story only" },
199
+ },
200
+ } as Meta<typeof StorybookComponent>["argTypes"];
201
+ StressTest.args = { stressCase: "german" } as typeof StressTest.args;
202
+ StressTest.parameters = {
203
+ docs: {
204
+ story: { inline: false, height: "480px" },
205
+ description: {
206
+ story:
207
+ "Hostile slot content, switched with the Stress case control: long German copy and button labels, an unbroken compound title " +
208
+ "with a long URL as the message and unbroken button labels, emoji and HTML-like text (must render as text, never as markup), " +
209
+ "right-to-left Arabic, and a minimal case with an empty title and one-character copy. The banner is fixed to the viewport, so " +
210
+ "use Storybook's viewport toolbar (not a canvas width) to check narrow screens. Check that the banner stays inside the gutter " +
211
+ "and caps at 64rem, unbroken text wraps instead of being clipped (including inside the buttons), and the buttons wrap onto " +
212
+ "their own lines rather than overflowing.",
213
+ },
214
+ },
215
+ };