srcdev-nuxt-components 9.2.7 → 9.2.9

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 (22) hide show
  1. package/.claude/commands/create-commit-message.md +13 -0
  2. package/.claude/commands/create-release-notes.md +34 -0
  3. package/.claude/commands/run-commit.md +13 -0
  4. package/.claude/settings.json +8 -2
  5. package/.claude/skills/index.md +2 -2
  6. package/.claude/skills/theming-form-geometry-tokens.md +71 -0
  7. package/.claude/skills/theming-partial-override.md +19 -15
  8. package/.vscode/srcdev-component-pricing-card.code-snippets +24 -3
  9. package/app/assets/styles/setup/03.theming/_theme-slots.css +6 -1
  10. package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +7 -2
  11. package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +28 -7
  12. package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +1 -0
  13. package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +29 -0
  14. package/app/components/02.molecules/pricing-card/PricingCard.vue +48 -14
  15. package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +21 -0
  16. package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +25 -0
  17. package/app/components/03.organisms/services/services-section/tests/__snapshots__/ServicesSection.spec.ts.snap +4 -1
  18. package/app/components/05.forms/input-button/InputButtonCore.vue +11 -1
  19. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +1 -0
  20. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioWithLabel.vue +1 -0
  21. package/package.json +1 -1
  22. package/.claude/skills/release-notes.md +0 -48
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Draft a commit message from staged changes only — never runs git add/commit/push
3
+ ---
4
+
5
+ Draft a commit message for the currently staged changes. Do not run `git add`, `git commit`, or `git push` — this command only produces the message text for review.
6
+
7
+ Steps:
8
+ 1. Run `git status` and `git diff --staged` to see what's staged. If nothing is staged, say so and stop.
9
+ 2. Run `git log --oneline -10` to match this repo's existing commit message style (e.g. `type(scope): summary`).
10
+ 3. Draft a concise message: a short summary line, plus a body only if the change needs context not obvious from the diff. Focus on *why*, not a restatement of the diff.
11
+ 4. Present the message in a fenced code block. Do not commit it.
12
+
13
+ If the user's request also explicitly says "commit this" or "run it", that's authorization to act — use the `/run-commit` flow instead of stopping at the draft.
@@ -0,0 +1,34 @@
1
+ ---
2
+ description: Produce release notes as a fenced markdown block, scoped to commits since the last release
3
+ ---
4
+
5
+ Produce release notes for the commits since the last `chore(release):` commit. Always output them as a single fenced ` ```markdown ` code block so the content can be copied directly into a git tag, GitHub release, or changelog — never render it as plain markdown.
6
+
7
+ Steps:
8
+
9
+ 1. **Determine the range.** Run `git log --oneline -20` and find the most recent `chore(release): release vX.Y.Z` commit — that's the boundary. Also check `package.json` for the current version if you need to confirm what's already shipped vs pending.
10
+ 2. **Review each commit since that boundary.** Run `git show <commit> --stat` (and the message) for each one to understand what changed. Skip other `chore(release):` commits themselves, and skip purely internal/tooling commits (e.g. adding slash commands, promoting doc gotchas) unless they're user-facing.
11
+ 3. **Draft the notes** using the format below. Only include sections that have content — omit empty headings. Keep each bullet to one line where possible. Lead with the most user-facing changes (New, Fixed) before internal ones (Changed, Documentation). Fold minor follow-up commits (e.g. a test/docs completion commit for a feature shipped a commit earlier) into the same bullet as the feature rather than giving them their own line — unless the follow-up is substantial enough to warrant one.
12
+ 4. **Output** the result wrapped in a ` ```markdown ` fence. Do not commit, tag, or push anything — this command only produces text for review.
13
+
14
+ ## Format
15
+
16
+ ```markdown
17
+ ## vX.Y.Z
18
+
19
+ ### New
20
+
21
+ - **`ComponentOrComposableName`** — one-line description of what it does and why it exists
22
+
23
+ ### Fixed
24
+
25
+ - Short description of what was wrong and what was corrected
26
+
27
+ ### Changed
28
+
29
+ - Short description of intentional behaviour or API changes
30
+
31
+ ### Documentation
32
+
33
+ - **`skill-name` skill** — new/updated: what it covers
34
+ ```
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Stage named files, commit, and push — the full flow (explicit authorization to act)
3
+ ---
4
+
5
+ Run the full commit flow: stage, commit, and push. Unlike `/create-commit-message`, this command is explicit authorization to execute git commands, not just draft text.
6
+
7
+ Steps:
8
+ 1. Run `git status`, `git diff`, and `git diff --staged` to see the full picture before staging anything.
9
+ 2. Stage files by name (never `git add -A` or `git add .`) so nothing unexpected — stray build output, `.env`, credentials — gets swept in. Confirm with the user if it's unclear which changed files belong in this commit.
10
+ 3. Draft the commit message following the repo's existing style (`git log --oneline -10`), and create the commit with a HEREDOC so formatting is preserved, ending with the `Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>` trailer.
11
+ 4. Push to the current branch's tracked remote. If the branch has no upstream yet, ask before setting one with `-u`. Never force-push without explicit user instruction.
12
+
13
+ Never use `--no-verify`, `--no-gpg-sign`, or otherwise skip hooks/signing. If a pre-commit hook fails, fix the underlying issue and create a **new** commit — never amend to work around a failed hook.
@@ -93,12 +93,18 @@
93
93
  "Bash(SRCDEV_STANDALONE=true npx playwright test app/components/01.atoms/content-wrappers/docs-pages/playwright/content-docs.playwright.ts --project=chromium)",
94
94
  "Bash(git rm *)",
95
95
  "Bash(awk '{print $1}')",
96
- "Bash(git reset *)"
96
+ "Bash(git reset *)",
97
+ "Read(//Users/simoncornforth/.claude/**)",
98
+ "Bash(git push *)",
99
+ "Bash(node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('.vscode/srcdev-component-pricing-card.code-snippets','utf8'\\)\\); console.log\\('valid json'\\)\")",
100
+ "Bash(rm /Users/simoncornforth/websites/nuxt-components/.claude/skills/create-release-notes.md)",
101
+ "Bash(node -e \"console.log\\('sanity: file exists and readable'\\)\")"
97
102
  ],
98
103
  "additionalDirectories": [
99
104
  "/Users/simoncornforth/websites/instepreflexology",
100
105
  "/Users/simoncornforth/websites/nuxt-components/.vscode",
101
- "/Users/simoncornforth/websites/nuxt-components/.claude/skills/components"
106
+ "/Users/simoncornforth/websites/nuxt-components/.claude/skills/components",
107
+ "/Users/simoncornforth/websites/nuxt-components/.claude/skills"
102
108
  ]
103
109
  }
104
110
  }
@@ -30,7 +30,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
30
30
  ├── theming-colour-ramps.md — parametric oklch ramp system: formula, named palettes, semantic slots, generator, consumer setup
31
31
  ├── theming-typography-tokens.md — --step-N fluid font-size scale, 62.5% root reset gotcha (1rem=10px, not 16px), page-heading-*/page-body-* utility classes
32
32
  ├── theming-override-default.md — replace the entire default theme with a custom palette (set --theme-hue/--theme-chroma)
33
- ├── theming-partial-override.md — override a specific token category (palette, buttons, inputs) without a full theme replacement
33
+ ├── theming-partial-override.md — override a specific colour token category (palette, buttons, inputs) without a full theme replacement
34
+ ├── theming-form-geometry-tokens.md — non-colour form/button/input tokens (padding, border-radius, gaps): full inventory, why partial override doesn't need duplicating the source files
34
35
  ├── colour-scheme-disable.md — disable light/dark scheme support in a consumer app
35
36
  ├── component-dynamic-slots.md — named dynamic slots ($slots iteration) vs indexed dynamic slots (itemCount pattern)
36
37
  ├── component-local-style-override.md — styleClassPassthrough + scoped style block for per-usage visual customisation
@@ -47,7 +48,6 @@ Each skill is a single markdown file named `<area>-<task>.md`.
47
48
  ├── robots-env-aware.md — @nuxtjs/robots: allow crawling on prod domain only, block on preview/staging via env var
48
49
  ├── new-app-scaffold.md — scaffold a new Nuxt consumer app extending this layer (package.json, nuxt.config, app structure, CLAUDE.md)
49
50
  ├── qa-panel.md — collapsible panel for toggling component props live on a page (always on for /pages/ui/ demo pages; gate with isDev in consuming apps)
50
- ├── release-notes.md — produce release notes as a fenced markdown block from git log
51
51
  ├── pull-request-description.md — produce a PR description as a fenced markdown block from git diff vs main
52
52
  ├── using-component-skills.md — discover and use component skills in consumer apps: where skills land, browsing patterns, workflow for deciding build vs. compose
53
53
  ├── composable-canonical-url.md — useCanonicalUrl: set <link rel="canonical"> from runtimeConfig.public.canonicalHost; layout setup, node types
@@ -0,0 +1,71 @@
1
+ # Form Geometry Token Override
2
+
3
+ ## Overview
4
+
5
+ `setup/04.elements/forms/*.css` defines every non-colour form/button/input token — padding,
6
+ border-radius, gaps, font-size, focus-ring width, transition duration. This is the geometry
7
+ counterpart to the colour tokens covered in `theming-partial-override.md` and the type-scale
8
+ tokens in `theming-typography-tokens.md`.
9
+
10
+ **You do not need to duplicate these files wholesale to override them.** Every value here is a
11
+ plain custom property declared on `:where(html)` inside the `form-tokens` cascade layer (see
12
+ `@import "./04.elements/" layer(form-tokens);` in `setup/index.css`). A consumer app's own CSS —
13
+ as long as it isn't itself wrapped in a named `@layer` — always wins over layered CSS regardless
14
+ of source order, because unlayered rules are cascade-ordered after every named layer per the CSS
15
+ Cascade Layers spec. Concretely: it does not matter whether the consumer's override file is
16
+ imported before or after this layer's `main.css` in `nuxt.config.ts`'s `css` array — unlayered
17
+ always wins either way. `theming-override-default.md`/`theming-partial-override.md` already rely
18
+ on this same mechanism for colour tokens; it applies identically here.
19
+
20
+ So: to change one geometry value, redeclare just that one custom property in your app's own CSS
21
+ (e.g. `app/assets/styles/setup/03.theming/_default.css`, or a new file imported the same way).
22
+ There is no dependency between these tokens — overriding `--button-padding-inline` alone does not
23
+ require also redeclaring `--button-border-radius` or anything else in the same source file.
24
+
25
+ ## The token inventory
26
+
27
+ From `00.element-defaults.css` through `09.animation-motion.css`:
28
+
29
+ | File | Tokens |
30
+ | --- | --- |
31
+ | `01.field-layout-container-level.css` | `--field-margin-block`, `--field-gap-block`, `--field-gap-inline`, `--field-label-gap`, `--field-description-gap`, `--field-error-gap` |
32
+ | `02.typography.css` | `--field-label-font-size/-weight/-line-height`, `--input-font-size`, `--input-line-height`, `--theme-input-placeholder-font-size`, `--button-font-size/-line-height/-weight/-text-transform`, `--field-description-font-size/-line-height`, `--field-error-font-size/-weight` |
33
+ | `03.generic-input-geometry.css` | `--input-padding-block/-inline`, `--input-padding-inline-with-icon`, `--form-element-border-width`, `--form-element-border-bottom-width-underlined`, `--form-input-border-radius[-underlined]`, `--form-element-outline-width[-focus]`, `--form-element-outline-offset-focus`, `--input-focus-ring-width/-offset`, `--input-underline-width`, `--input-min-height`, `--input-element-line-height`, plus per-element-type geometry (textarea/select/range/checkbox/toggle — see file for the full set) |
34
+ | `04.slot-icon-system.css` | `--input-slot-width/-gap`, `--input-icon-size`, `--input-icon-slot-gap`, `--input-icon-color[-focus/-error]` |
35
+ | `05.checkbox-radio-geometry.css` | `--control-size`, `--control-border-radius`, `--radio-border-radius`, `--control-border-width`, `--control-indicator-scale`, `--control-label-gap` |
36
+ | `06.button-geometry.css` | `--button-padding-block/-inline`, `--button-border-radius[-icon-only]`, `--button-border-width`, `--button-outline-width`, `--button-focus-ring-width/-offset`, `--button-icon-gap` |
37
+ | `07.validation-error-block-layout.css` | `--error-icon-size/-gap`, `--error-border-radius`, `--error-padding-block/-inline` |
38
+ | `09.animation-motion.css` | `--control-transition-duration/-ease`, `--theme-form-transition-duration` |
39
+
40
+ `00.element-defaults.css` is the one exception — it's not tokens, it's a real selector-based rule
41
+ (browser autofill background/text-colour reset via `input:autofill` etc.). It reads
42
+ `var(--theme-surface)` and the component-private `--_input-text-core-color`, so changing
43
+ `--theme-surface` (per `theming-partial-override.md`) changes autofill colours too — no separate
44
+ override needed there. If you need to change that rule's *behaviour* itself (not just its colour
45
+ inputs), you'd need a competing unlayered selector of your own targeting the same pseudo-classes,
46
+ not a token.
47
+
48
+ ## Example
49
+
50
+ ```css
51
+ /* app/assets/styles/setup/03.theming/_default.css, or any unlayered file registered in nuxt.config.ts */
52
+ :where(html) {
53
+ --button-padding-block: 1.2rem;
54
+ --button-padding-inline: 2.4rem;
55
+ --button-border-radius: 0.8rem;
56
+ }
57
+ ```
58
+
59
+ ## Notes
60
+
61
+ - Uses the layer's `62.5%` root font-size reset — see `theming-typography-tokens.md` before
62
+ hand-writing any raw `rem` value here.
63
+ - `--theme-surface`, `--theme-input-surface`, etc. referenced by these geometry tokens are colour
64
+ tokens covered in `theming-partial-override.md`, not this skill — geometry and colour are
65
+ overridden the same mechanical way, just different token names.
66
+ - Found and fixed while writing this skill (2026-08-03): `00.element-defaults.css` referenced a
67
+ `--theme-button-surface` token that was never declared anywhere in this codebase — a
68
+ since-corrected typo for `--theme-surface`, present since the rule was first introduced. Silently
69
+ broke autofill background/box-shadow (an undefined `var()` with no fallback resolves to the
70
+ property's initial value). A reminder that a dangling `var()` reference produces no error/warning
71
+ of any kind — it just quietly does nothing.
@@ -4,7 +4,9 @@
4
4
 
5
5
  Use this skill when you need to tweak a specific colour role (page background, text, buttons,
6
6
  inputs) without replacing the entire default theme. For a full palette swap see
7
- `theming-override-default.md`. For the full architecture see `theming-colour-ramps.md`.
7
+ `theming-override-default.md`. For the full architecture see `theming-colour-ramps.md`. For
8
+ non-colour form geometry (padding, border-radius, gaps, etc.) see `theming-form-geometry-tokens.md`
9
+ — same override mechanism, different token set.
8
10
 
9
11
  ## How it works
10
12
 
@@ -14,20 +16,22 @@ after the layer styles.
14
16
 
15
17
  ## Semantic slots — the tokens to override
16
18
 
17
- All themed components share a 9-slot vocabulary. Overriding these affects buttons, inputs,
18
- prompts, and toasts simultaneously:
19
-
20
- | Token | Default (light → dark) | Role |
21
- |-------------------------|----------------------------|-----------------------------------|
22
- | `--theme-surface` | `--colour-theme-7` / `9` | Filled button/chip surface |
23
- | `--theme-surface-hover` | `--colour-theme-9` / `7` | Hover state of filled surface |
24
- | `--theme-accent` | `--colour-theme-5` / `4` | Decorative accent strip (prompt/toast left edge) |
25
- | `--theme-surface-subtle`| `--colour-theme-1` / `9` | Subtle body bg for prompt/toast, outline element hover |
26
- | `--theme-border` | `--colour-theme-6` / `5` | Input/card border |
27
- | `--theme-border-focus` | `--colour-theme-4` / `3` | Focused border |
28
- | `--theme-ring` | `--colour-theme-1` / `9` | Focus ring (outline) |
29
- | `--theme-on-surface` | `--colour-theme-0` | Text/icon on filled surface |
30
- | `--theme-text` | `--colour-theme-9` / `2` | Text on page, outline element text|
19
+ All themed components share an 11-slot vocabulary (see `_theme-slots.css`). Overriding these
20
+ affects buttons, inputs, prompts, and toasts simultaneously:
21
+
22
+ | Token | Default (light → dark) | Role |
23
+ | --------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
24
+ | `--theme-surface` | `--colour-theme-7` / `9` | Filled button/chip surface |
25
+ | `--theme-surface-hover` | `--colour-theme-9` / `7` | Hover state of filled surface |
26
+ | `--theme-surface-inverted` | `--colour-theme-9` / `2` | Inverted surface (`InputButtonCore`'s `.secondary` background) |
27
+ | `--theme-accent` | `--colour-theme-5` / `4` | Decorative accent strip (prompt/toast left edge) |
28
+ | `--theme-surface-subtle` | `--colour-theme-1` / `9` | Subtle body bg for prompt/toast, outline element hover |
29
+ | `--theme-border` | `--colour-theme-6` / `5` | Input/card border |
30
+ | `--theme-border-focus` | `--colour-theme-4` / `3` | Focused border |
31
+ | `--theme-ring` | `--colour-theme-1` / `9` | Focus ring (outline) |
32
+ | `--theme-on-surface` | `--colour-theme-0` | Text/icon on filled/inverted surface (fixed light value in both modes — pair this with any dark-in-light-mode surface, not `--theme-text`) |
33
+ | `--theme-text` | `--colour-theme-9` / `2` | Text on page, outline element text (assumes a *light* surface — don't pair with `--theme-surface-hover`/`-inverted`, see pitfall #13 in `Claude.md`) |
34
+ | `--theme-text-inverted` | `--colour-theme-0` / `7` | Text on `--theme-surface-inverted` (fixed to a light value in light mode as of 2026-08-03 — was `--colour-theme-9`, identical to the surface it sits on) |
31
35
 
32
36
  Input-specific tokens:
33
37
 
@@ -102,6 +102,25 @@
102
102
  "</PricingCard>"
103
103
  ]
104
104
  },
105
+ "SRCDEV PricingCard Ribbon": {
106
+ "description": "PricingCard with a diagonal discount ribbon",
107
+ "scope": "vue,html",
108
+ "body": [
109
+ "<PricingCard",
110
+ " planName=\"$1Annual\"",
111
+ " :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\"",
121
+ "/>"
122
+ ]
123
+ },
105
124
  "SRCDEV PricingCard CSS Override": {
106
125
  "description": "CSS override scaffold for PricingCard tokens",
107
126
  "scope": "css",
@@ -116,11 +135,13 @@
116
135
  " --pricing-card-amount-color: $7;",
117
136
  " --pricing-card-cta-bg: $8;",
118
137
  " --pricing-card-cta-bg-hover: $9;",
138
+ " --pricing-card-ribbon-bg: $10;",
139
+ " --pricing-card-ribbon-text: $11;",
119
140
  "",
120
141
  " &.is-highlighted {",
121
- " --pricing-card-highlight-border: $10;",
122
- " --pricing-card-highlight-shadow: $11;",
123
- " --pricing-card-highlight-scale: $12;",
142
+ " --pricing-card-highlight-border: $12;",
143
+ " --pricing-card-highlight-shadow: $13;",
144
+ " --pricing-card-highlight-scale: $14;",
124
145
  " }",
125
146
  " }",
126
147
  "}"
@@ -12,5 +12,10 @@
12
12
  --theme-ring: light-dark(var(--colour-theme-1), var(--colour-theme-9));
13
13
  --theme-on-surface: var(--colour-theme-0);
14
14
  --theme-text: light-dark(var(--colour-theme-9), var(--colour-theme-2));
15
- --theme-text-inverted: light-dark(var(--colour-theme-9), var(--colour-theme-7));
15
+ /* Light-mode branch was --colour-theme-9 — identical to --theme-surface-inverted's own
16
+ light-mode branch (line 6), its only pairing (InputButtonCore's .secondary, background +
17
+ color), so secondary-button text was invisible (dark-on-dark) in light mode. Should be a
18
+ light step to contrast against that dark inverted surface, matching how --theme-on-surface
19
+ (line 13) contrasts against --theme-surface the same way in both modes. */
20
+ --theme-text-inverted: light-dark(var(--colour-theme-0), var(--colour-theme-7));
16
21
  }
@@ -1,13 +1,18 @@
1
+ /* Was --theme-button-surface, a token never declared anywhere in this codebase (confirmed via
2
+ git blame — introduced in 065f254 alongside this rule, before _theme-slots.css even existed,
3
+ and never matched afterwards) — a plain typo/omission, not an intentional distinct token.
4
+ Autofill background/box-shadow silently had no effect since an undefined var() with no
5
+ fallback resolves to the property's initial value. Fixed to the real token, 2026-08-03. */
1
6
  :where(html) {
2
7
  input:autofill,
3
8
  input:-internal-autofill-selected,
4
9
  input:-webkit-autofill-strong-password,
5
10
  input:-webkit-autofill-strong-password-viewable,
6
11
  input:-webkit-autofill-and-obscured {
7
- background-color: var(--theme-button-surface) !important;
12
+ background-color: var(--theme-surface) !important;
8
13
  background-image: none !important;
9
14
  color: var(--_input-text-core-color) !important;
10
- -webkit-box-shadow: 0 0 0rem 1000px var(--theme-button-surface) inset;
15
+ -webkit-box-shadow: 0 0 0rem 1000px var(--theme-surface) inset;
11
16
  -webkit-text-fill-color: var(--_input-text-core-color);
12
17
  transition: background-color 5000s ease-in-out 0s;
13
18
  }
@@ -4,7 +4,10 @@ exports[`AccordianCore > renders correct HTML structure with all props set 1`] =
4
4
  "<div class="display-accordian extra-class">
5
5
  <div class="expanding-panel accordian-item" icon-size="medium">
6
6
  <details class="expanding-panel-details" name="snapshot-accordian">
7
- <summary id="id-snapshot-accordian-trigger" class="expanding-panel-summary" aria-controls="id-snapshot-accordian-content" aria-expanded="false"><span class="label-wrapper"></span><span class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></span></summary>
7
+ <summary id="id-snapshot-accordian-trigger" class="expanding-panel-summary" aria-controls="id-snapshot-accordian-content" aria-expanded="false">
8
+ <div class="label-wrapper"></div>
9
+ <div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
10
+ </summary>
8
11
  </details>
9
12
  <div id="id-snapshot-accordian-content" class="expanding-panel-content" aria-labelledby="id-snapshot-accordian-trigger" role="region">
10
13
  <div class="inner"></div>
@@ -12,7 +15,10 @@ exports[`AccordianCore > renders correct HTML structure with all props set 1`] =
12
15
  </div>
13
16
  <div class="expanding-panel accordian-item" icon-size="medium">
14
17
  <details class="expanding-panel-details" name="snapshot-accordian">
15
- <summary id="id-snapshot-accordian-trigger" class="expanding-panel-summary" aria-controls="id-snapshot-accordian-content" aria-expanded="false"><span class="label-wrapper"></span><span class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></span></summary>
18
+ <summary id="id-snapshot-accordian-trigger" class="expanding-panel-summary" aria-controls="id-snapshot-accordian-content" aria-expanded="false">
19
+ <div class="label-wrapper"></div>
20
+ <div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
21
+ </summary>
16
22
  </details>
17
23
  <div id="id-snapshot-accordian-content" class="expanding-panel-content" aria-labelledby="id-snapshot-accordian-trigger" role="region">
18
24
  <div class="inner"></div>
@@ -20,7 +26,10 @@ exports[`AccordianCore > renders correct HTML structure with all props set 1`] =
20
26
  </div>
21
27
  <div class="expanding-panel accordian-item" icon-size="medium">
22
28
  <details class="expanding-panel-details" name="snapshot-accordian">
23
- <summary id="id-snapshot-accordian-trigger" class="expanding-panel-summary" aria-controls="id-snapshot-accordian-content" aria-expanded="false"><span class="label-wrapper"></span><span class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></span></summary>
29
+ <summary id="id-snapshot-accordian-trigger" class="expanding-panel-summary" aria-controls="id-snapshot-accordian-content" aria-expanded="false">
30
+ <div class="label-wrapper"></div>
31
+ <div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
32
+ </summary>
24
33
  </details>
25
34
  <div id="id-snapshot-accordian-content" class="expanding-panel-content" aria-labelledby="id-snapshot-accordian-trigger" role="region">
26
35
  <div class="inner"></div>
@@ -33,7 +42,10 @@ exports[`AccordianCore > renders correct HTML structure with default props 1`] =
33
42
  "<div class="display-accordian">
34
43
  <div class="expanding-panel accordian-item" icon-size="medium">
35
44
  <details class="expanding-panel-details" name="v-0-0-0">
36
- <summary id="id-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-0-content" aria-expanded="false"><span class="label-wrapper"></span><span class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></span></summary>
45
+ <summary id="id-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-0-content" aria-expanded="false">
46
+ <div class="label-wrapper"></div>
47
+ <div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
48
+ </summary>
37
49
  </details>
38
50
  <div id="id-v-0-0-0-content" class="expanding-panel-content" aria-labelledby="id-v-0-0-0-trigger" role="region">
39
51
  <div class="inner"></div>
@@ -41,7 +53,10 @@ exports[`AccordianCore > renders correct HTML structure with default props 1`] =
41
53
  </div>
42
54
  <div class="expanding-panel accordian-item" icon-size="medium">
43
55
  <details class="expanding-panel-details" name="v-0-0-1">
44
- <summary id="id-v-0-0-1-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-1-content" aria-expanded="false"><span class="label-wrapper"></span><span class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></span></summary>
56
+ <summary id="id-v-0-0-1-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-1-content" aria-expanded="false">
57
+ <div class="label-wrapper"></div>
58
+ <div class="icon-wrapper"><span class="iconify i-bi:caret-down-fill icon mi-12" aria-hidden="true"></span></div>
59
+ </summary>
45
60
  </details>
46
61
  <div id="id-v-0-0-1-content" class="expanding-panel-content" aria-labelledby="id-v-0-0-1-trigger" role="region">
47
62
  <div class="inner"></div>
@@ -54,7 +69,10 @@ exports[`AccordianCore > renders correct HTML structure with populated slots 1`]
54
69
  "<div class="display-accordian">
55
70
  <div class="expanding-panel accordian-item" icon-size="medium">
56
71
  <details class="expanding-panel-details" name="v-0-0-0">
57
- <summary id="id-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-0-content" aria-expanded="false"><span class="label-wrapper"><span>Summary 0</span></span><span class="icon-wrapper"><span>Icon 0</span></span></summary>
72
+ <summary id="id-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-0-content" aria-expanded="false">
73
+ <div class="label-wrapper"><span>Summary 0</span></div>
74
+ <div class="icon-wrapper"><span>Icon 0</span></div>
75
+ </summary>
58
76
  </details>
59
77
  <div id="id-v-0-0-0-content" class="expanding-panel-content" aria-labelledby="id-v-0-0-0-trigger" role="region">
60
78
  <div class="inner"><span>Content 0</span></div>
@@ -62,7 +80,10 @@ exports[`AccordianCore > renders correct HTML structure with populated slots 1`]
62
80
  </div>
63
81
  <div class="expanding-panel accordian-item" icon-size="medium">
64
82
  <details class="expanding-panel-details" name="v-0-0-1">
65
- <summary id="id-v-0-0-1-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-1-content" aria-expanded="false"><span class="label-wrapper"><span>Summary 1</span></span><span class="icon-wrapper"><span>Icon 1</span></span></summary>
83
+ <summary id="id-v-0-0-1-trigger" class="expanding-panel-summary" aria-controls="id-v-0-0-1-content" aria-expanded="false">
84
+ <div class="label-wrapper"><span>Summary 1</span></div>
85
+ <div class="icon-wrapper"><span>Icon 1</span></div>
86
+ </summary>
66
87
  </details>
67
88
  <div id="id-v-0-0-1-content" class="expanding-panel-content" aria-labelledby="id-v-0-0-1-trigger" role="region">
68
89
  <div class="inner"><span>Content 1</span></div>
@@ -103,6 +103,7 @@ const onDetailsToggle = (event: Event) => {
103
103
  flex-direction: row;
104
104
  gap: var(--expanding-panel-summary-gap, 1rem);
105
105
  list-style: none;
106
+ user-select: none;
106
107
 
107
108
  padding-block: var(--expanding-panel-summary-padding-block, 0.5rem);
108
109
 
@@ -21,6 +21,9 @@ override it — there is no locale-aware formatting, just a literal prefix chara
21
21
  | `--pricing-card-highlight-scale` | `1.05` | Scale transform when highlighted |
22
22
  | `--pricing-card-badge-bg` | `var(--teal-06)` | "Most Popular" badge background |
23
23
  | `--pricing-card-badge-text` | `var(--teal-00)` | Badge text colour |
24
+ | `--pricing-card-ribbon-bg` | `var(--red-06)` | Diagonal corner ribbon background (rendered when `ribbon-text` is set) |
25
+ | `--pricing-card-ribbon-text` | `var(--red-00, white)` | Ribbon text colour |
26
+ | `--pricing-card-ribbon-size` | `20rem` | Width/height of the ribbon's clipping box — controls how far it extends from the top-right corner |
24
27
  | `--pricing-card-name-font-size` | `1.8rem` | Plan name heading size |
25
28
  | `--pricing-card-name-color` | `#1a1a1a` | Plan name text colour |
26
29
  | `--pricing-card-amount-font-size` | `3.2rem` | Price amount size |
@@ -128,6 +131,32 @@ When displaying multiple cards side-by-side (common in pricing pages), wrap in a
128
131
 
129
132
  ---
130
133
 
134
+ ## Discount ribbon
135
+
136
+ Pass `ribbon-text` to render a diagonal corner ribbon (top-right, 45°) for marketing callouts —
137
+ leave it unset for no ribbon. It renders independently of `isHighlighted`, so both can be combined.
138
+
139
+ ```vue
140
+ <PricingCard
141
+ planName="Annual"
142
+ :price="49"
143
+ billing-period="per year"
144
+ ribbon-text="EARLYBIRD50 · 50% off"
145
+ @select="handleSelect"
146
+ />
147
+ ```
148
+
149
+ ```css
150
+ .pricing-page {
151
+ .pricing-card {
152
+ --pricing-card-ribbon-bg: var(--brand-accent);
153
+ --pricing-card-ribbon-text: white;
154
+ }
155
+ }
156
+ ```
157
+
158
+ ---
159
+
131
160
  ## Feature checkmark colour
132
161
 
133
162
  The feature list checkmarks use `--teal-06` by default. Override globally or per-instance:
@@ -2,6 +2,10 @@
2
2
  <component :is="tag" class="pricing-card" :class="[elementClasses, { 'is-highlighted': isHighlighted }]">
3
3
  <div v-if="isHighlighted" class="pricing-card__badge">Most Popular</div>
4
4
 
5
+ <div v-if="ribbonText" class="pricing-card__ribbon-clip">
6
+ <span class="pricing-card__ribbon">{{ ribbonText }}</span>
7
+ </div>
8
+
5
9
  <h3 class="pricing-card__name">{{ planName }}</h3>
6
10
 
7
11
  <div class="pricing-card__price">
@@ -20,18 +24,8 @@
20
24
  </ul>
21
25
 
22
26
  <div class="pricing-card__cta">
23
- <slot
24
- name="cta"
25
- :cta-text="ctaText"
26
- :is-disabled="ctaDisabled"
27
- :plan-name="planName"
28
- :on-select="handleSelect"
29
- >
30
- <InputButtonCore
31
- :button-text="ctaText"
32
- :readonly="ctaDisabled"
33
- @click="handleSelect"
34
- />
27
+ <slot name="cta" :cta-text="ctaText" :is-disabled="ctaDisabled" :plan-name="planName" :on-select="handleSelect">
28
+ <InputButtonCore :button-text="ctaText" :readonly="ctaDisabled" @click="handleSelect" />
35
29
  </slot>
36
30
  </div>
37
31
  </component>
@@ -49,6 +43,7 @@ interface Props {
49
43
  isHighlighted?: boolean;
50
44
  ctaText?: string;
51
45
  ctaDisabled?: boolean;
46
+ ribbonText?: string;
52
47
  styleClassPassthrough?: string | string[];
53
48
  }
54
49
 
@@ -61,6 +56,7 @@ const props = withDefaults(defineProps<Props>(), {
61
56
  isHighlighted: false,
62
57
  ctaText: "Get started",
63
58
  ctaDisabled: false,
59
+ ribbonText: undefined,
64
60
  styleClassPassthrough: () => [],
65
61
  });
66
62
 
@@ -78,7 +74,7 @@ watch(
78
74
  () => props.styleClassPassthrough,
79
75
  () => {
80
76
  resetElementClasses(props.styleClassPassthrough);
81
- },
77
+ }
82
78
  );
83
79
  </script>
84
80
 
@@ -93,12 +89,16 @@ watch(
93
89
  --_card-gap: var(--pricing-card-gap, 1.2rem);
94
90
 
95
91
  --_highlight-border: var(--pricing-card-highlight-border, 2px solid var(--teal-06));
96
- --_highlight-shadow: var(--pricing-card-highlight-shadow, 0 8px 24px oklch(from var(--teal-06) l c h / 0.20));
92
+ --_highlight-shadow: var(--pricing-card-highlight-shadow, 0 8px 24px oklch(from var(--teal-06) l c h / 0.2));
97
93
  --_highlight-scale: var(--pricing-card-highlight-scale, 1.05);
98
94
 
99
95
  --_badge-bg: var(--pricing-card-badge-bg, var(--teal-06));
100
96
  --_badge-text: var(--pricing-card-badge-text, var(--teal-00));
101
97
 
98
+ --_ribbon-bg: var(--pricing-card-ribbon-bg, var(--red-06));
99
+ --_ribbon-text: var(--pricing-card-ribbon-text, var(--red-00, white));
100
+ --_ribbon-size: var(--pricing-card-ribbon-size, 20rem);
101
+
102
102
  --_name-font-size: var(--pricing-card-name-font-size, 1.8rem);
103
103
  --_name-color: var(--pricing-card-name-color, #1a1a1a);
104
104
 
@@ -141,6 +141,9 @@ watch(
141
141
  top: -0.6rem;
142
142
  left: 50%;
143
143
  transform: translateX(-50%);
144
+ /* Above .pricing-card__ribbon-clip (z-index: 1) — without this the badge has no stacking
145
+ context of its own and the ribbon, which does, paints over it regardless of DOM order. */
146
+ z-index: 2;
144
147
 
145
148
  display: inline-block;
146
149
  padding: 0.4rem 1rem;
@@ -153,6 +156,37 @@ watch(
153
156
  letter-spacing: 0.05em;
154
157
  }
155
158
 
159
+ .pricing-card__ribbon-clip {
160
+ position: absolute;
161
+ top: 0;
162
+ right: 0;
163
+ width: var(--_ribbon-size);
164
+ height: var(--_ribbon-size);
165
+ overflow: hidden;
166
+ pointer-events: none;
167
+ /* Sits above card content but stays clear of the top-centred "Most Popular" badge. */
168
+ z-index: 1;
169
+
170
+ .pricing-card__ribbon {
171
+ position: absolute;
172
+ top: 1.6rem;
173
+ right: -1.4rem;
174
+ width: calc(var(--_ribbon-size) + 2rem);
175
+ transform: rotate(23deg);
176
+ transform-origin: center;
177
+
178
+ display: block;
179
+ padding: 0.6rem 1.4rem 0.6rem 0;
180
+ background-color: var(--_ribbon-bg);
181
+ color: var(--_ribbon-text);
182
+ text-align: right;
183
+ font-size: 1.2rem;
184
+ font-weight: 700;
185
+ text-transform: uppercase;
186
+ letter-spacing: 0.02em;
187
+ }
188
+ }
189
+
156
190
  .pricing-card__name {
157
191
  margin: 0;
158
192
  font-size: var(--_name-font-size);
@@ -13,6 +13,7 @@ const meta: Meta<typeof PricingCard> = {
13
13
  isHighlighted: { control: "boolean" },
14
14
  ctaText: { control: "text" },
15
15
  ctaDisabled: { control: "boolean" },
16
+ ribbonText: { control: "text" },
16
17
  },
17
18
  args: {
18
19
  planName: "Single Site",
@@ -23,6 +24,7 @@ const meta: Meta<typeof PricingCard> = {
23
24
  isHighlighted: false,
24
25
  ctaText: "Get started",
25
26
  ctaDisabled: false,
27
+ ribbonText: undefined,
26
28
  },
27
29
  parameters: {
28
30
  docs: {
@@ -30,6 +32,8 @@ const meta: Meta<typeof PricingCard> = {
30
32
  component:
31
33
  "A SaaS pricing plan card displaying plan name, price, features, and a CTA button. " +
32
34
  "Supports a 'Most Popular' highlight state with scaled/emphasized styling. " +
35
+ "Supports an optional `ribbonText` prop that renders a diagonal corner ribbon (top-right, " +
36
+ "45°) for marketing callouts like a discount — leave it unset for no ribbon. " +
33
37
  "Emits a `select` event when the CTA button is clicked. " +
34
38
  "Use multiple cards in a grid layout to build a pricing comparison table.",
35
39
  },
@@ -116,6 +120,23 @@ export const Disabled: Story = {
116
120
  },
117
121
  };
118
122
 
123
+ export const WithDiscountRibbon: Story = {
124
+ args: {
125
+ planName: "Annual",
126
+ price: 49,
127
+ billingPeriod: "per year",
128
+ description: "Early adopter pricing on the annual plan.",
129
+ features: ["Embed on one domain", "Unlimited consultations", "Email support", "Priced annually"],
130
+ ribbonText: "EARLYBIRD50 · 50% off",
131
+ },
132
+ render: (args) => {
133
+ const handleSelect = (planName: string) => {
134
+ console.log(`Selected plan: ${planName}`);
135
+ };
136
+ return storyWrapper(args, handleSelect);
137
+ },
138
+ };
139
+
119
140
  export const ThreeCardComparison: Story = {
120
141
  render: () => ({
121
142
  components: { PricingCard },
@@ -150,4 +150,29 @@ describe("PricingCard", () => {
150
150
 
151
151
  expect(wrapper.element.tagName).toBe("SECTION");
152
152
  });
153
+
154
+ it("does not render a ribbon when ribbonText is unset", () => {
155
+ const wrapper = mount(PricingCard, {
156
+ props: {
157
+ planName: "Test Plan",
158
+ price: 100,
159
+ },
160
+ });
161
+
162
+ expect(wrapper.find(".pricing-card__ribbon-clip").exists()).toBe(false);
163
+ });
164
+
165
+ it("renders ribbon text when ribbonText is provided", () => {
166
+ const wrapper = mount(PricingCard, {
167
+ props: {
168
+ planName: "Test Plan",
169
+ price: 100,
170
+ ribbonText: "EARLYBIRD50 · 50% off",
171
+ },
172
+ });
173
+
174
+ const ribbon = wrapper.find(".pricing-card__ribbon-clip");
175
+ expect(ribbon.exists()).toBe(true);
176
+ expect(ribbon.text()).toContain("EARLYBIRD50 · 50% off");
177
+ });
153
178
  });
@@ -63,7 +63,10 @@ exports[`ServicesSection > renders correct HTML structure 1`] = `
63
63
  <div class="display-accordian services-section__faq" id="faq">
64
64
  <div class="expanding-panel accordian-item" icon-size="medium">
65
65
  <details class="expanding-panel-details" name="faq-v-0-0-0">
66
- <summary id="id-faq-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-faq-v-0-0-0-content" aria-expanded="false"><span class="label-wrapper">FAQ question?</span><span class="icon-wrapper"><span class="iconify i-mdi:chevron-down" aria-hidden="true"></span></span></summary>
66
+ <summary id="id-faq-v-0-0-0-trigger" class="expanding-panel-summary" aria-controls="id-faq-v-0-0-0-content" aria-expanded="false">
67
+ <div class="label-wrapper">FAQ question?</div>
68
+ <div class="icon-wrapper"><span class="iconify i-mdi:chevron-down" aria-hidden="true"></span></div>
69
+ </summary>
67
70
  </details>
68
71
  <div id="id-faq-v-0-0-0-content" class="expanding-panel-content" aria-labelledby="id-faq-v-0-0-0-trigger" role="region">
69
72
  <div class="inner">
@@ -111,6 +111,7 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
111
111
  padding-inline: var(--button-padding-inline);
112
112
  padding-block: var(--button-padding-block);
113
113
  touch-action: manipulation;
114
+ user-select: none;
114
115
  overflow: hidden;
115
116
  transition: all var(--control-transition-duration) var(--control-transition-ease);
116
117
 
@@ -129,7 +130,13 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
129
130
  &:hover,
130
131
  &:focus-visible {
131
132
  background-color: var(--theme-surface-hover);
132
- color: var(--theme-text);
133
+ /* --theme-on-surface, not --theme-text: --theme-surface-hover's light-mode branch is a
134
+ dark step (by design — this button stays a bold colour even in light mode), and
135
+ --theme-text's light-mode branch is dark too (it's meant for text on a light surface
136
+ elsewhere, e.g. AlertContentInner). Pairing them made hover text invisible in light
137
+ mode. --theme-on-surface is a fixed light value regardless of scheme, matching how
138
+ AlertContentInner's own dismiss-button hover and .secondary:hover below already do it. */
139
+ color: var(--theme-on-surface);
133
140
  border-color: var(--theme-border-focus);
134
141
  outline-color: var(--theme-ring);
135
142
  }
@@ -163,6 +170,9 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
163
170
  &:hover,
164
171
  &:focus-visible {
165
172
  background-color: var(--theme-surface-hover);
173
+ /* Same fix as the base :hover above — background goes dark in light mode, so text
174
+ needs to switch off --theme-text (dark in light mode) to stay visible. */
175
+ color: var(--theme-on-surface);
166
176
  border-color: var(--theme-border-focus);
167
177
  outline-color: var(--theme-border-focus);
168
178
  }
@@ -90,6 +90,7 @@ const flexDirection = ref(props.direction);
90
90
  justify-content: space-between;
91
91
  gap: 1rem;
92
92
  border-radius: 0.4rem;
93
+ user-select: none;
93
94
  transition: all 0.2s ease-in-out;
94
95
 
95
96
  &.is-pill {
@@ -80,6 +80,7 @@ const id = useId();
80
80
  align-items: center;
81
81
  gap: var(--input-checked-icon-gap);
82
82
  min-height: var(--input-min-height);
83
+ user-select: none;
83
84
 
84
85
  transition: all var(--theme-form-transition-duration) ease-in-out;
85
86
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.2.7",
4
+ "version": "9.2.9",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",
@@ -1,48 +0,0 @@
1
- # Release Notes
2
-
3
- ## Overview
4
-
5
- When asked to create release notes, always produce a fenced markdown code block (` ```markdown `) so the content can be copied and pasted directly into a git tag, GitHub release, or changelog.
6
-
7
- ## Steps
8
-
9
- ### 1. Determine the version
10
-
11
- Run `git log --oneline -8` to find the most recent release commit and the commits since the previous release.
12
-
13
- ### 2. Review the diff
14
-
15
- Run `git show <commit> --stat` for each commit since the last release to understand what changed.
16
-
17
- ### 3. Produce a fenced markdown block
18
-
19
- Always wrap the output in a ` ```markdown ` code fence — never render it as plain markdown. This ensures the user can copy the raw text without formatting being stripped.
20
-
21
- ## Format
22
-
23
- ```markdown
24
- ## vX.Y.Z
25
-
26
- ### New
27
-
28
- - **`ComponentOrComposableName`** — one-line description of what it does and why it exists
29
-
30
- ### Fixed
31
-
32
- - Short description of what was wrong and what was corrected
33
-
34
- ### Changed
35
-
36
- - Short description of intentional behaviour or API changes
37
-
38
- ### Documentation
39
-
40
- - **`skill-name` skill** — new/updated: what it covers
41
- ```
42
-
43
- ## Notes
44
-
45
- - Only include sections that have content — omit empty headings
46
- - Keep each bullet to one line where possible
47
- - Lead with the most user-facing changes (New, Fixed) before internal ones (Documentation, Tests)
48
- - Test additions warrant their own section only when substantial; otherwise fold into the relevant New/Fixed bullet