srcdev-nuxt-components 9.6.10 → 9.6.11

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 (108) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +4 -2
  2. package/.claude/skills/component-compliance-checklist.md +9 -3
  3. package/.claude/skills/component-inline-action-button.md +5 -4
  4. package/.claude/skills/component-naming.md +2 -1
  5. package/.claude/skills/components/{alert-mask-core.md → alert-mask.md} +38 -23
  6. package/.claude/skills/components/alert-masked-content.md +3 -3
  7. package/.claude/skills/components/container-glow.md +1 -1
  8. package/.claude/skills/components/display-theme-switch.md +8 -8
  9. package/.claude/skills/components/input-button.md +26 -7
  10. package/.claude/skills/components/input-checkbox-radio.md +2 -0
  11. package/.claude/skills/components/input-description.md +5 -0
  12. package/.claude/skills/components/input-error.md +9 -2
  13. package/.claude/skills/components/input-label.md +8 -2
  14. package/.claude/skills/components/input-number.md +2 -0
  15. package/.claude/skills/components/input-otp.md +2 -0
  16. package/.claude/skills/components/input-range.md +2 -0
  17. package/.claude/skills/components/input-select.md +2 -0
  18. package/.claude/skills/components/input-text.md +2 -0
  19. package/.claude/skills/components/input-textarea.md +2 -0
  20. package/.claude/skills/components/pending-effect.md +5 -4
  21. package/.claude/skills/components/{triple-toggle-switch.md → segmented-control.md} +34 -19
  22. package/.claude/skills/components/services-card.md +1 -1
  23. package/.claude/skills/components/tabbed-content.md +1 -1
  24. package/.claude/skills/components/toggle-switch.md +2 -0
  25. package/.claude/skills/index.md +5 -5
  26. package/.claude/skills/storybook-add-story.md +3 -1
  27. package/.claude/skills/theming-component-token-pattern.md +24 -0
  28. package/.claude/skills/theming-form-geometry-tokens.md +1 -1
  29. package/.claude/skills/theming-partial-override.md +1 -1
  30. package/.vscode/srcdev-component-alert-mask.code-snippets +41 -0
  31. package/.vscode/{srcdev-component-triple-toggle-switch.code-snippets → srcdev-component-segmented-control.code-snippets} +6 -7
  32. package/app/assets/styles/setup/03.theming/_theme-slots.css +1 -1
  33. package/app/assets/styles/setup/04.elements/forms/03.generic-input-geometry.css +4 -0
  34. package/app/components/01.atoms/alert-mask/AlertMask.vue +194 -0
  35. package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +57 -42
  36. package/app/components/01.atoms/alert-mask/stories/AlertMask.stories.ts +198 -0
  37. package/app/components/01.atoms/alert-mask/tests/AlertMask.spec.ts +233 -0
  38. package/app/components/02.molecules/alert-masked-content/AlertMaskedContent.vue +2 -2
  39. package/app/components/02.molecules/alert-masked-content/CONSUMER-STYLING.md +4 -4
  40. package/app/components/02.molecules/alert-masked-content/stories/AlertMaskedContent.stories.ts +60 -0
  41. package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +12 -12
  42. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +3 -3
  43. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +1 -1
  44. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +1 -1
  45. package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +9 -9
  46. package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +16 -16
  47. package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +53 -1
  48. package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +8 -8
  49. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +4 -4
  50. package/app/components/02.molecules/input-copy/InputCopy.vue +2 -2
  51. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +2 -2
  52. package/app/components/03.organisms/google-reviews/tests/GoogleReviews.spec.ts +4 -4
  53. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +2 -2
  54. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +2 -2
  55. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +6 -6
  56. package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +8 -0
  57. package/app/components/05.forms/form-errors/InputError.vue +43 -37
  58. package/app/components/05.forms/form-errors/stories/InputError.stories.ts +79 -1
  59. package/app/components/05.forms/form-errors/tests/InputError.spec.ts +29 -1
  60. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +80 -47
  61. package/app/components/05.forms/input-button/InputButton.vue +52 -100
  62. package/app/components/05.forms/input-button/playwright/input-button.playwright.ts +1 -1
  63. package/app/components/05.forms/input-button/stories/InputButton.stories.ts +145 -8
  64. package/app/components/05.forms/input-button/tests/InputButton.spec.ts +76 -46
  65. package/app/components/05.forms/input-button/tests/__snapshots__/InputButton.spec.ts.snap +17 -17
  66. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +9 -1
  67. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +8 -0
  68. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +2 -2
  69. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +14 -2
  70. package/app/components/05.forms/input-description/InputDescription.vue +2 -1
  71. package/app/components/05.forms/input-description/stories/InputDescription.stories.ts +70 -4
  72. package/app/components/05.forms/input-description/tests/InputDescription.spec.ts +2 -1
  73. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +8 -0
  74. package/app/components/05.forms/input-label/InputLabel.vue +1 -0
  75. package/app/components/05.forms/input-label/stories/InputLabel.stories.ts +68 -2
  76. package/app/components/05.forms/input-label/tests/InputLabel.spec.ts +13 -3
  77. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +7 -1
  78. package/app/components/05.forms/input-number/InputNumber.vue +4 -5
  79. package/app/components/05.forms/input-otp/CONSUMER-STYLING.md +7 -1
  80. package/app/components/05.forms/input-otp/InputOtp.vue +2 -2
  81. package/app/components/05.forms/input-range/InputRange.vue +1 -1
  82. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +7 -1
  83. package/app/components/05.forms/input-select/InputSelect.vue +14 -5
  84. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +7 -1
  85. package/app/components/05.forms/input-text/InputText.vue +4 -4
  86. package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +7 -1
  87. package/app/components/05.forms/input-textarea/InputTextarea.vue +2 -3
  88. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +4 -4
  89. package/app/components/05.forms/pending-effect/CONSUMER-STYLING.md +7 -3
  90. package/app/components/05.forms/pending-effect/PendingEffect.vue +3 -3
  91. package/app/components/05.forms/pending-effect/tests/PendingEffect.spec.ts +1 -1
  92. package/app/components/05.forms/segmented-control/CONSUMER-STYLING.md +125 -0
  93. package/app/components/05.forms/segmented-control/SegmentedControl.vue +231 -0
  94. package/app/components/05.forms/segmented-control/stories/SegmentedControl.stories.ts +215 -0
  95. package/app/components/05.forms/segmented-control/tests/SegmentedControl.spec.ts +202 -0
  96. package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +7 -0
  97. package/app/components/05.forms/toggle-switch/ToggleSwitch.vue +2 -0
  98. package/app/types/components/index.ts +1 -1
  99. package/package.json +1 -1
  100. package/.vscode/srcdev-component-alert-mask-core.code-snippets +0 -31
  101. package/app/components/01.atoms/alert-mask/AlertMaskCore.vue +0 -166
  102. package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +0 -105
  103. package/app/components/01.atoms/alert-mask/tests/AlertMaskCore.spec.ts +0 -342
  104. package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +0 -99
  105. package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitch.vue +0 -251
  106. package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitch.stories.ts +0 -104
  107. package/app/components/05.forms/triple-toggle-switch/tests/TripleToggleSwitch.spec.ts +0 -138
  108. /package/app/types/components/{alert-mask-core.d.ts → alert-mask.d.ts} +0 -0
@@ -206,6 +206,6 @@ No `actions` slot — the entire card renders as an anchor (`NuxtLink` for the i
206
206
  - Root markup is two rows (`grid-template-rows: auto 1fr`): `.services-card-image-wrapper`, then a `.services-card-details` (`display: flex; flex-direction: column`) holding the eyebrow, title, description, and a `.services-card-footer` wrapper. Description length is controlled by `--services-card-description-line-clamp` (default `none`) rather than a fixed-height grid row.
207
207
  - `.services-card-footer` groups the meta row and the `actions` slot and gets `margin-block-start: auto`, pinning them to the bottom of the card. Combined with the root's `1fr` details row, this means when `ServicesCardGrid`'s default `align-items: stretch` makes a card taller than its own content (to match a taller sibling in the same row), the extra height goes to `.services-card-footer`'s top margin rather than leaving whitespace below the actions slot — so the meta row and actions/button line up across a row of cards regardless of each card's description length. This does the visual job of CSS subgrid without needing a subgrid chain across `ServicesCard`/`ServicesCardGrid` (which would also break whenever cards in a row don't all render the same optional rows — `.services-card-meta` and `actions` are both conditional).
208
208
  - Image has a `3/4` aspect ratio with a subtle scale-on-hover effect, triggered by hovering the image only (not the whole card). When the card is a whole-card link styled with its own hover effect (e.g. `--services-card-transform-hover`), set `--services-card-image-zoom-transform: none` so the two effects don't stack; the "Dark Overlay Cards" story does this. Keep the zoom for the `actions`-slot layout, where the card itself has no hover state. The hover is on `.services-card-image-wrapper`, not the image, so it still fires when the overlaid titles cover the image. `.services-card-image-details` is `position: relative; z-index: 1` because the image's hover `transform` would otherwise paint over it, and `.services-card-image-wrapper` sets `isolation: isolate` so that z-index stays inside the card.
209
- - `.services-card`, `.services-card-image-wrapper`, `.services-card-details`, and `.services-card-footer` all set `min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot button label, since `InputButton`'s `.button-text` is `white-space: nowrap` with no ellipsis — forces its content's min-content width up through the flex/grid chain and widens that one card's grid column in `ServicesCardGrid` wider than its siblings (the image just rides along on the widened column; it isn't the actual cause). If you see one card/column wider than the rest with cut-off content, check for a long unbreakable string in a slot before assuming it's an image sizing issue.
209
+ - `.services-card`, `.services-card-image-wrapper`, `.services-card-details`, and `.services-card-footer` all set `min-inline-size: 0`. Without it, an unbreakable child — most commonly a long `actions` slot button label, kept on one line with `--input-button-text-white-space: nowrap` (the default until 2026-10-17) — forces its content's min-content width up through the flex/grid chain and widens that one card's grid column in `ServicesCardGrid` wider than its siblings (the image just rides along on the widened column; it isn't the actual cause). If you see one card/column wider than the rest with cut-off content, check for a long unbreakable string in a slot before assuming it's an image sizing issue.
210
210
  - Usually consumed via `ServicesCardGrid` rather than directly.
211
211
  - The "Dark Overlay Cards" story shows a full restyle using tokens only: dark panel, flush image, titles over the image, hover lift (`--services-card-transform-hover`), and EyebrowText typography tokens (`--eyebrow-text-font-style`, `-letter-spacing`, etc.) set on the card so they inherit into its eyebrow.
@@ -169,7 +169,7 @@ length, cancelling the per-frame checks while the highlight was still sliding an
169
169
  colours (dark selected label, invisible neighbour). A More-button resize also re-places both
170
170
  indicators (`syncIndicators(true)`), since its box moves even when no tab changes sides, and an
171
171
  overflow change refreshes with `refreshTabs(true)` so an in-flight move retargets instead of
172
- snapping. This replaces the per-component timing trick `TripleToggleSwitch` uses (swap
172
+ snapping. This replaces the per-component timing trick `SegmentedControl` uses (swap
173
173
  colour at half the slide), which only works for single-step moves; a tab jump passes under
174
174
  several labels.
175
175
 
@@ -141,3 +141,5 @@ error id when `field-has-error` is true.
141
141
  ```
142
142
 
143
143
  The `description` slot was removed on 2026-09-29; use `descriptionText` or `descriptionHtml`.
144
+
145
+ **2026-10-17**: resting shadow ring (`--toggle-switch-shadow`), on the track. Theme step 2 by default, so it turns red under `data-invalid`; width is the global `--form-element-shadow-width`. Hover now draws a 1px outline in the border colour with no offset, over the ring's inner edge (was a 2px outline at a 2px offset in the focus colour); focus is unchanged and sits just outside the ring.
@@ -77,8 +77,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
77
77
  ├── composable-anchored-popover.md — useAnchoredPopover: popover + anchor positioning with JS fallbacks for Safari 16 (no Popover API) and 17–18 (no anchor positioning); used by ActionMenu/SelectMenu/DeepExpandingMenu/DisplayTooltip/PopOver
78
78
  └── components/
79
79
  ├── alert-content.md — AlertContent + AlertContentInner: themed alert panel (icon/title/content/actions/dismiss) under DisplayToast, DisplayPrompt and AlertMaskedContent; showIcon, #actions row, --alert-content-* tokens, app.config icon map
80
- ├── alert-mask-core.md — AlertMaskCore: SVG border/background mask sized to slotted content via ResizeObserver, config-prop-driven geometry/colour (no CSS token API)
81
- ├── alert-masked-content.md — AlertMaskedContent: AlertContentInner inside AlertMaskCore's SVG mask (accent cut-out border + translucent fill); masked variant for DisplayToast/DisplayPrompt; --alert-masked-content-* colour tokens
80
+ ├── alert-mask.md — AlertMask (renamed from AlertMaskCore): SVG border/fill sized to slotted content via ResizeObserver; config-prop geometry, --alert-mask-* colour tokens
81
+ ├── alert-masked-content.md — AlertMaskedContent: AlertContentInner inside AlertMask's SVG mask (accent cut-out border + translucent fill); masked variant for DisplayToast/DisplayPrompt; --alert-masked-content-* colour tokens
82
82
  ├── animated-svg-text.md — AnimatedSvgText: inline SVG stroke-draw-then-fill animation, text slot, CSS token API
83
83
  ├── accordian-core.md — AccordianCore indexed dynamic slots (accordian-{n}-summary/icon/content), exclusive-open grouping
84
84
  ├── eyebrow-text.md — EyebrowText props, usage patterns, styling
@@ -123,7 +123,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
123
123
  ├── auto-grid.md — AutoGrid: auto-fit responsive grid, $slots iteration, --auto-grid-gap/min-col-size-{small,default,large} tokens, is-responsive, semantic tag + aria
124
124
  ├── display-avatar.md — DisplayAvatar (02.molecules): circular avatar with image/initials fallback (alt announced), broken-image fallback, data-size scale tokens, image-size, --display-avatar-* colours, chip badge, icon slot
125
125
  ├── profile-section.md — ProfileSection (02.molecules): picture + numbered profile-info-N slots, eyebrow/hero header (above, or over the picture top/bottom on a scrim via header-placement), profileLinks, container-query two-column layout, .location/.services highlight tokens
126
- ├── display-theme-switch.md — DisplayThemeSwitch: system/light/dark picker wrapping TripleToggleSwitch, wired to useSettingsStore, labels/icons props, small sizing variant
126
+ ├── display-theme-switch.md — DisplayThemeSwitch: system/light/dark picker wrapping SegmentedControl, wired to useSettingsStore, labels/icons props, small sizing variant
127
127
  ├── card-core.md — CardCore: generic card container, dynamic named slots as rows, 4 variants, blurred backdrop layer, full CSS token API
128
128
  ├── action-menu.md — ActionMenu + ActionMenuItem: ellipsis trigger + anchored popover menu, indexed item-{n} slots, link/button items, full CSS token API
129
129
  ├── select-menu.md — SelectMenu: v-model single-select listbox popover (ActionMenu's popover mechanics, InputSelect's selected-option semantics), icon/text/chevron trigger toggles, checkmark on selected option, options-array driven, full CSS token API
@@ -146,7 +146,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
146
146
  ├── display-tooltip-defined.md — DisplayTooltipDefined: structured title/body/action tooltip content with close button, composes DisplayTooltip
147
147
  ├── pop-over.md — PopOver: generic anchor-positioned disclosure panel, consumer-supplied trigger/content slots, placement prop, CSS token API
148
148
  ├── input-text.md — InputText (bare control), InputTextField, InputPasswordField, InputTextAsNumberField: renamed from Core/WithLabel, data-* state hooks, readonly/autocomplete props, leak-free digit filtering, wrapper attr-leak/empty-description/passthrough fixes, rounded+clamped stepping, min/max pass-through, CSS token API, stress stories
149
- ├── input-button.md — InputButton (renamed from InputButtonCore 2026-09-28): button/NuxtLink/a, variants, icon slots, readonly (aria-disabled, activation blocked), tokens
149
+ ├── input-button.md — InputButton (renamed from InputButtonCore 2026-09-28): button/NuxtLink/a; primary filled, secondary outlined, tertiary text-only, inline unstyled; data-* state hooks; shadow ring + border-colour hover; wrapping labels; icon slots; readonly (aria-disabled, activation blocked); tokens
150
150
  ├── input-range.md — InputRange: native range-slider primitive, markers/datalist slots, dead --theme-form-range-accent-color token fixed, CSS token API; Variants section covers InputRangeField; renamed from InputRangeCore/InputRangeDefault 2026-10-16, clamped stepping, useId datalist id, data-* hooks
151
151
  ├── input-number.md — InputNumber: native number-input primitive, left/right step-button slots, missing base class/placeholder/mismatched slot-selector bugs fixed, CSS token API; Variants section covers InputNumberField; clamped, rounded stepping, data-* state hooks, min/step-aware inputmode
152
152
  ├── input-otp.md — InputOtp: one-time-code digit boxes (paste/autofill spreading, Backspace/arrow/Home/End nav, Enter submits, leading zeros kept, complete event, hidden input); Variants section covers InputOtpField
@@ -163,7 +163,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
163
163
  ├── input-checkbox.md — MultipleCheckboxes/SingleCheckbox: fieldset-wrapped checkbox group (data-driven, button or labelled) and single checkbox, per-box native-required/styleClassPassthrough/data-testid bugs fixed, spacing token API, wraps hostile content (fieldset min-inline-size, useId option ids)
164
164
  ├── input-radio.md — MultipleRadiobuttons: fieldset radiogroup (data-driven, button or labelled), shared-name grouping bug fixed, dead label/placeholder/equalCols props removed, data-options-layout hook, spacing token API, useId button ids, wraps hostile content
165
165
  ├── toggle-switch.md — ToggleSwitch (bare control), ToggleSwitchField (labelled, layout="stacked"|"inline", replaces ToggleSwitchWithLabelInline): renamed from Core/WithLabel, role="switch", prefixed inner classes + data-checked, single-toggle click handling, reduced motion, round prop + border-radius token, track width from geometry, CSS token API, stress stories
166
- ├── triple-toggle-switch.md — TripleToggleSwitch (renamed from TripleToggleSwitchCore): three-option icon radio pill with sliding marker, radiogroup + ariaLabel, system/light/dark marker-gradient tokens, reduced-motion, CSS token API
166
+ ├── segmented-control.md — SegmentedControl (renamed from TripleToggleSwitch): icon radio pill for any number of options with sliding marker, radiogroup + ariaLabel, system/light/dark marker-gradient tokens, reduced-motion, CSS token API
167
167
  ├── entry-animation.md — EntryAnimation: scroll-driven entry animation wrapper (slide-in/zoom-reveal/exit-blur utility classes), skipAnimation for above-the-fold loop items, reduced-motion handled at the CSS layer
168
168
  ├── column-flow-grid.md — ColumnFlowGrid (renamed from MasonryGrid): CSS multi-column text-flow layout, named dynamic slots (no count/data prop), itemMinWidth/gap/unit sizing, CSS token API; not a true masonry, see "which one do I want?"
169
169
  ├── masonry-grid.md — MasonryGrid: real measured-height masonry (greedy shortest-column packing, animated resize) absorbed from the now-retired MasonryGridOrdered; named dynamic slots, fixedWidth/justify, CSS token API
@@ -393,7 +393,9 @@ story: "See the [CookieConsentBanner](?path=/story/molecules-cookieconsentbanner
393
393
  default `"padded"` layout insets the canvas by 1rem, which masks viewport gutters, full-bleed
394
394
  tracks and edge alignment (`PageRow`, `SliderGallery`). To swap canvas widths inside a story,
395
395
  add the `CanvasSwitcher` decorator from `PageRow.stories.ts` (container-sized components only;
396
- see `components/canvas-switcher.md`).
396
+ see `components/canvas-switcher.md`). The switcher renders its own buttons, so for a component
397
+ with Playwright tests that grab the story's only `role=button` (`InputButton`), put it on the
398
+ `StressTest` story alone, not in the meta.
397
399
  - Use `table: { category: "..." }` in `argTypes` when a component has many props — it groups
398
400
  them in the Storybook controls panel (e.g. `"Model"`, `"Basic"`, `"Validation"`, `"Styling"`, `"Slots"`).
399
401
  - Export multiple named stories (`Default`, `WithError`, `Outlined`, etc.) when you want
@@ -191,6 +191,30 @@ Three tiers deep here: instance/global override → "look like the primary butto
191
191
  slot underneath that. Each link in the chain is a real, resolvable token — nothing depends on a
192
192
  consuming app having declared anything.
193
193
 
194
+ ## Global colour knobs that follow the theme
195
+
196
+ A site-wide colour knob that should follow each element's `data-theme` (or `data-invalid`) can't be
197
+ declared on `:root` as `var(--colour-theme-2)`: a custom property holding `var()` is computed where
198
+ it's declared and inherited as that value, so every element gets the root theme's colour. Leave the
199
+ knob undeclared and fall back at the point of use instead:
200
+
201
+ ```css
202
+ /* global: only the geometry is declared */
203
+ :where(html) {
204
+ --form-element-shadow-width: 0.2rem;
205
+ }
206
+
207
+ /* component: the colour fallback is evaluated on the themed element */
208
+ .input-text-wrapper {
209
+ box-shadow: 0 0 0 var(--form-element-shadow-width)
210
+ var(--input-text-shadow, var(--form-element-shadow-colour, var(--colour-theme-2)));
211
+ }
212
+ ```
213
+
214
+ `--colour-theme-N` and the `--theme-*` slots are themselves re-declared on every `[data-theme]` and
215
+ `[data-invalid]` element (`theme-ramp.css`, `_theme-slots.css`), which is why they work at the point of
216
+ use. Rule: `css-theme-relative-globals.md`.
217
+
194
218
  ## What this does NOT change
195
219
 
196
220
  - Tier 1 theme slots (`_theme-slots.css`) stay bare `var()`, no fallback, by design.
@@ -42,7 +42,7 @@ From `00.element-defaults.css` through `09.animation-motion.css`:
42
42
  | --- | --- |
43
43
  | `01.field-layout-container-level.css` | `--field-margin-block`, `--field-gap-block`, `--field-gap-inline`, `--field-label-gap`, `--field-description-gap`, `--field-error-gap` |
44
44
  | `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` |
45
- | `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) |
45
+ | `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`, `--form-element-shadow-width` (the resting shadow ring on bordered controls and primary/secondary buttons; its colour `--form-element-shadow-colour` is deliberately undeclared so each control falls back to its own theme step 2), `--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) |
46
46
  | `04.slot-icon-system.css` | `--input-slot-width/-gap`, `--input-icon-size`, `--input-icon-slot-gap`, `--input-icon-color[-focus/-error]` |
47
47
  | `05.checkbox-radio-geometry.css` | `--control-size`, `--control-border-radius`, `--radio-border-radius`, `--control-border-width`, `--control-indicator-scale`, `--control-label-gap` |
48
48
  | `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` |
@@ -40,7 +40,7 @@ affects buttons, inputs, prompts, and toasts simultaneously:
40
40
  | --------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
41
41
  | `--theme-surface` | `--colour-theme-7` / `9` | Filled button/chip surface |
42
42
  | `--theme-surface-hover` | `--colour-theme-9` / `7` | Hover state of filled surface |
43
- | `--theme-surface-inverted` | `--colour-theme-9` / `2` | Inverted surface (`InputButton`'s `.secondary` background) |
43
+ | `--theme-surface-inverted` | `--colour-theme-9` / `2` | Inverted surface (dark panels; InputButton's secondary used it until 2026-10-17) |
44
44
  | `--theme-accent` | `--colour-theme-5` / `4` | Decorative accent strip (prompt/toast left edge) |
45
45
  | `--theme-surface-subtle` | `--colour-theme-1` / `9` | Subtle body bg for prompt/toast, outline element hover |
46
46
  | `--theme-border` | `--colour-theme-6` / `5` | Input/card border |
@@ -0,0 +1,41 @@
1
+ {
2
+ "SRCDEV AlertMask Basic": {
3
+ "description": "AlertMask: SVG border and fill sized to slotted content",
4
+ "scope": "vue,html",
5
+ "body": [
6
+ "<AlertMask>",
7
+ " $0",
8
+ "</AlertMask>"
9
+ ]
10
+ },
11
+ "SRCDEV AlertMask Custom Config": {
12
+ "description": "AlertMask with custom colour/geometry config",
13
+ "scope": "vue,html",
14
+ "body": [
15
+ "<AlertMask",
16
+ " :config=\"{",
17
+ " borderColour: '${1:var(--theme-accent)}',",
18
+ " backgroundColour: '${2:rgba(0, 0, 0, 0.3)}',",
19
+ " radiusLeft: ${3:8},",
20
+ " radiusRight: ${4:4},",
21
+ " borderLeft: ${5:6},",
22
+ " borderTop: ${6:1},",
23
+ " borderRight: ${7:1},",
24
+ " borderBottom: ${8:1},",
25
+ " }\"",
26
+ ">",
27
+ " $0",
28
+ "</AlertMask>"
29
+ ]
30
+ },
31
+ "SRCDEV AlertMask CSS Override": {
32
+ "description": "CSS override scaffold for AlertMask colour tokens",
33
+ "scope": "css",
34
+ "body": [
35
+ ".${1:my-section} {",
36
+ " --alert-mask-border-colour: $2;",
37
+ " --alert-mask-background-colour: $3;",
38
+ "}"
39
+ ]
40
+ }
41
+ }
@@ -1,18 +1,17 @@
1
1
  {
2
- "SRCDEV TripleToggleSwitch": {
3
- "description": "TripleToggleSwitch: three-option radio pill with a sliding marker",
2
+ "SRCDEV SegmentedControl": {
3
+ "description": "SegmentedControl: three-option radio pill with a sliding marker",
4
4
  "scope": "vue,html",
5
5
  "body": [
6
- "<TripleToggleSwitch",
6
+ "<SegmentedControl",
7
7
  " v-model=\"${1:selected}\"",
8
8
  " v-model:field-data=\"${2:options}\"",
9
- " name=\"${3:triple-toggle}\"",
10
- " aria-label=\"${4:Choose an option}\"",
9
+ " aria-label=\"${3:Choose an option}\"",
11
10
  "/>"
12
11
  ]
13
12
  },
14
- "SRCDEV TripleToggleSwitch Options": {
15
- "description": "IFormMultipleOptions data for TripleToggleSwitch (system/light/dark values get marker gradients)",
13
+ "SRCDEV SegmentedControl Options": {
14
+ "description": "IFormMultipleOptions data for SegmentedControl (system/light/dark values get marker gradients)",
16
15
  "scope": "typescript,vue",
17
16
  "body": [
18
17
  "const ${1:options} = ref<IFormMultipleOptions>({",
@@ -13,6 +13,6 @@
13
13
  --theme-ring: var(--colour-theme-1);
14
14
  --theme-on-surface: var(--colour-theme-0);
15
15
  --theme-text: var(--colour-theme-9);
16
- /* Light step: pairs with the dark --theme-surface-inverted (InputButton .secondary). */
16
+ /* Light step: pairs with the dark --theme-surface-inverted. */
17
17
  --theme-text-inverted: var(--colour-theme-0);
18
18
  }
@@ -20,6 +20,10 @@
20
20
  --form-element-outline-width-focus: 0.2rem;
21
21
  --form-element-outline-offset-focus: 0.2rem;
22
22
 
23
+ /* Resting shadow ring outside bordered controls. Its colour, --form-element-shadow-colour, is unset
24
+ so each control falls back to its own theme step 2 (red when invalid). */
25
+ --form-element-shadow-width: 0.2rem;
26
+
23
27
  /* Focus */
24
28
  --input-focus-ring-width: 0.2rem; /* 2px */
25
29
  --input-focus-ring-offset: 0.2rem;
@@ -0,0 +1,194 @@
1
+ <template>
2
+ <div class="alert-mask" :class="elementClasses">
3
+ <svg
4
+ class="alert-mask-decorator"
5
+ :style="{ '--_height': svgHeight + 'px' }"
6
+ xmlns="http://www.w3.org/2000/svg"
7
+ aria-hidden="true"
8
+ focusable="false"
9
+ >
10
+ <defs>
11
+ <mask :id="maskId" maskUnits="userSpaceOnUse">
12
+ <path :d="outerPath" fill="white" />
13
+ <path :d="innerPath" fill="black" />
14
+ </mask>
15
+ </defs>
16
+
17
+ <path
18
+ class="alert-mask-border"
19
+ :d="outerPath"
20
+ :style="cfg.borderColour ? { fill: cfg.borderColour } : undefined"
21
+ :mask="`url(#${maskId})`"
22
+ />
23
+ <path
24
+ class="alert-mask-background"
25
+ :d="innerPath"
26
+ :style="cfg.backgroundColour ? { fill: cfg.backgroundColour } : undefined"
27
+ />
28
+ </svg>
29
+
30
+ <div
31
+ class="alert-mask-content"
32
+ :style="{
33
+ '--_inset-left': cfg.borderLeft + 'px',
34
+ '--_inset-right': cfg.borderRight + 'px',
35
+ '--_inset-top': cfg.borderTop + 'px',
36
+ '--_inset-bottom': cfg.borderBottom + 'px',
37
+ }"
38
+ >
39
+ <div ref="alertContentRef" class="alert-mask-content-slot">
40
+ <slot name="default"></slot>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </template>
45
+
46
+ <script setup lang="ts">
47
+ import type { AlertMaskConfig } from "~/types/components";
48
+
49
+ interface Props {
50
+ config?: AlertMaskConfig;
51
+ styleClassPassthrough?: string | string[];
52
+ }
53
+
54
+ const props = withDefaults(defineProps<Props>(), {
55
+ config: () => ({}),
56
+ styleClassPassthrough: () => [],
57
+ });
58
+
59
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
60
+
61
+ watch(
62
+ () => props.styleClassPassthrough,
63
+ () => {
64
+ resetElementClasses(props.styleClassPassthrough);
65
+ }
66
+ );
67
+
68
+ const maskId = `alert-mask-${useId()}`;
69
+ const alertContentRef = useTemplateRef<HTMLElement | null>("alertContentRef");
70
+ const contentWidth = ref(0);
71
+ const contentHeight = ref(0);
72
+ const measured = ref(false);
73
+ let resizeObserver: ResizeObserver | null = null;
74
+
75
+ const toSize = (value: number | undefined, fallback: number) =>
76
+ typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : fallback;
77
+
78
+ const cfg = computed(() => ({
79
+ backgroundColour: props.config?.backgroundColour,
80
+ borderColour: props.config?.borderColour,
81
+ radiusLeft: toSize(props.config?.radiusLeft, 12),
82
+ radiusRight: toSize(props.config?.radiusRight, 12),
83
+ borderLeft: toSize(props.config?.borderLeft, 8),
84
+ borderTop: toSize(props.config?.borderTop, 8),
85
+ borderRight: toSize(props.config?.borderRight, 8),
86
+ borderBottom: toSize(props.config?.borderBottom, 8),
87
+ }));
88
+
89
+ const svgWidth = computed(() =>
90
+ measured.value ? contentWidth.value + cfg.value.borderLeft + cfg.value.borderRight : 0
91
+ );
92
+ const svgHeight = computed(() =>
93
+ measured.value ? contentHeight.value + cfg.value.borderTop + cfg.value.borderBottom : 0
94
+ );
95
+
96
+ const updateDimensions = () => {
97
+ const contentEl = alertContentRef.value;
98
+ if (!contentEl) return;
99
+ const rect = contentEl.getBoundingClientRect();
100
+ contentWidth.value = rect.width;
101
+ contentHeight.value = rect.height;
102
+ measured.value = true;
103
+ };
104
+
105
+ onMounted(() => {
106
+ nextTick(updateDimensions);
107
+ if (alertContentRef.value) {
108
+ resizeObserver = new ResizeObserver(updateDimensions);
109
+ resizeObserver.observe(alertContentRef.value);
110
+ }
111
+ });
112
+
113
+ onBeforeUnmount(() => {
114
+ resizeObserver?.disconnect();
115
+ resizeObserver = null;
116
+ });
117
+
118
+ const outerPath = computed(() => {
119
+ const width = svgWidth.value;
120
+ const height = svgHeight.value;
121
+ if (!width || !height) return "";
122
+
123
+ const rl = Math.min(cfg.value.radiusLeft, width / 2, height / 2);
124
+ const rr = Math.min(cfg.value.radiusRight, width / 2, height / 2);
125
+
126
+ return `
127
+ M ${rl} 0
128
+ L ${width - rr} 0
129
+ Q ${width} 0 ${width} ${rr}
130
+ L ${width} ${height - rr}
131
+ Q ${width} ${height} ${width - rr} ${height}
132
+ L ${rl} ${height}
133
+ Q 0 ${height} 0 ${height - rl}
134
+ L 0 ${rl}
135
+ Q 0 0 ${rl} 0 Z
136
+ `;
137
+ });
138
+
139
+ const innerPath = computed(() => {
140
+ const width = svgWidth.value;
141
+ const height = svgHeight.value;
142
+ const { borderLeft: bl, borderTop: bt, borderRight: br, borderBottom: bb } = cfg.value;
143
+ const innerWidth = width - bl - br;
144
+ const innerHeight = height - bt - bb;
145
+ if (!width || !height || innerWidth <= 0 || innerHeight <= 0) return "";
146
+
147
+ const rl = Math.min(cfg.value.radiusLeft, innerWidth / 2, innerHeight / 2);
148
+ const rr = Math.min(cfg.value.radiusRight, innerWidth / 2, innerHeight / 2);
149
+ const right = width - br;
150
+ const bottom = height - bb;
151
+
152
+ return `
153
+ M ${bl + rl} ${bt}
154
+ L ${right - rr} ${bt}
155
+ Q ${right} ${bt} ${right} ${bt + rr}
156
+ L ${right} ${bottom - rr}
157
+ Q ${right} ${bottom} ${right - rr} ${bottom}
158
+ L ${bl + rl} ${bottom}
159
+ Q ${bl} ${bottom} ${bl} ${bottom - rl}
160
+ L ${bl} ${bt + rl}
161
+ Q ${bl} ${bt} ${bl + rl} ${bt} Z
162
+ `;
163
+ });
164
+ </script>
165
+
166
+ <style lang="css">
167
+ @layer components {
168
+ .alert-mask {
169
+ display: grid;
170
+ grid-template-areas: "mask";
171
+ min-inline-size: 0;
172
+
173
+ .alert-mask-decorator {
174
+ grid-area: mask;
175
+ width: 100%;
176
+ height: var(--_height);
177
+
178
+ .alert-mask-border {
179
+ fill: var(--alert-mask-border-colour, var(--theme-accent));
180
+ }
181
+
182
+ .alert-mask-background {
183
+ fill: var(--alert-mask-background-colour, color-mix(in oklab, var(--theme-surface-subtle) 80%, transparent));
184
+ }
185
+ }
186
+
187
+ .alert-mask-content {
188
+ grid-area: mask;
189
+ min-inline-size: 0;
190
+ margin: var(--_inset-top) var(--_inset-right) var(--_inset-bottom) var(--_inset-left);
191
+ }
192
+ }
193
+ }
194
+ </style>
@@ -1,64 +1,79 @@
1
- # AlertMaskCore — Consumer Styling Guide
1
+ # AlertMask — Consumer Styling Guide
2
2
 
3
- ## No public CSS token API
3
+ > **Renamed 2026-10-17**: `AlertMaskCore` → `AlertMask`, root class `.alert-mask-core` →
4
+ > `.alert-mask`. The colours also became CSS tokens (below); `config` colours still override them.
4
5
 
5
- `AlertMaskCore` draws its border/background via an SVG `<mask>` whose path geometry is computed
6
- from measured content dimensions, so it can't expose colour/radius/thickness as CSS custom
7
- properties the way most components do — a `var()` can't be interpolated into an SVG path or
8
- resized reactively the way a `fill` attribute can. Instead, the override surface is the `config`
9
- prop (`AlertMaskConfig`):
6
+ `AlertMask` draws a border and a translucent fill around its slot as an SVG path, sized to the
7
+ measured content. Geometry (radii, per-side border widths) can't be a CSS token, because the path
8
+ is computed in script, so it comes from the `config` prop. Colours are plain CSS `fill` values, so
9
+ they're tokens.
10
10
 
11
- ```vue
12
- <AlertMaskCore
13
- :config="{
14
- backgroundColour: '#1a1a1a',
15
- borderColour: 'var(--theme-accent)',
16
- radiusLeft: 8,
17
- radiusRight: 4,
18
- borderLeft: 6,
19
- borderTop: 1,
20
- borderRight: 1,
21
- borderBottom: 1,
22
- }"
23
- >
24
- <p>Alert content</p>
25
- </AlertMaskCore>
26
- ```
11
+ ## Public token API
12
+
13
+ | Token | Default | Controls |
14
+ |---|---|---|
15
+ | `--alert-mask-border-colour` | `var(--theme-accent)` | Border fill |
16
+ | `--alert-mask-background-colour` | `color-mix(in oklab, var(--theme-surface-subtle) 80%, transparent)` | Inner fill |
17
+
18
+ A `config.borderColour` or `config.backgroundColour` is applied as an inline `fill` style on its
19
+ path and beats the token. Both read the theme of the nearest `data-theme` ancestor.
20
+
21
+ > **Changed 2026-10-17**: the colours were `config`-only, with hardcoded defaults
22
+ > (`var(--orange-08)` border, `rgba(0,0,0,0.25)` fill). They now default from the tokens above.
23
+
24
+ ### Geometry (`config`, `AlertMaskConfig`)
25
+
26
+ | Field | Default | Controls |
27
+ |---|---|---|
28
+ | `radiusLeft` / `radiusRight` | `12` | Corner radius (px) of the left and right corners |
29
+ | `borderLeft` / `borderTop` / `borderRight` / `borderBottom` | `8` | Border width (px) per side; the content is inset by the same amounts |
27
30
 
28
- Colours are applied as `fill` styles, so `borderColour`/`backgroundColour` can be `var()`
29
- references to your own tokens (a `var(--theme-*)` value needs a `data-theme` ancestor to resolve).
31
+ Sides are physical (left stays left in a right-to-left layout), matching the drawn path. Negative
32
+ or non-finite values fall back to `0` and the default respectively, and a radius larger than the
33
+ box is clamped to half its smaller side.
30
34
 
31
- All fields are optional and fall back to `backgroundColour: "rgba(0,0,0,0.25)"`,
32
- `borderColour: "var(--orange-08)"`, `radiusLeft`/`radiusRight: 12`, `borderLeft`/`borderTop`/
33
- `borderRight`/`borderBottom: 8`.
35
+ Private tokens (not public API): `--_height` (the measured SVG height) and `--_inset-left`,
36
+ `--_inset-right`, `--_inset-top`, `--_inset-bottom` (content margins from the border widths), all set
37
+ inline from script.
34
38
 
35
39
  ---
36
40
 
37
- ## Internal (non-overridable) custom properties
41
+ ## State hooks
38
42
 
39
- `--_height`, `--_inset-inline-start`, `--_inset-inline-end`, `--_inset-block-start`, and
40
- `--_inset-block-end` are set inline from measured content size and `config` border widths. They're
41
- private implementation plumbing, not a consumer override surface — use `config` instead.
43
+ None. Inner elements: `.alert-mask` > `.alert-mask-decorator` (the SVG, with `.alert-mask-border`
44
+ and `.alert-mask-background` paths) + `.alert-mask-content` > `.alert-mask-content-slot`.
45
+
46
+ ---
47
+
48
+ ## Global theming
49
+
50
+ ```css
51
+ :where(html) {
52
+ --alert-mask-border-colour: var(--theme-border);
53
+ }
54
+ ```
42
55
 
43
56
  ---
44
57
 
45
58
  ## Local overrides
46
59
 
47
- There are no public tokens, so override the classes above with direct properties, scoped to an
48
- element you own (a page or section class, or a class added with `:style-class-passthrough`). Keep
49
- the block **unlayered** (no `@layer` wrapper) so it beats the library's `@layer components`. If your
50
- own file uses `<style scoped>`, selectors that reach inside the component need `:deep()`. Patterns
51
- and examples: `.claude/skills/component-local-style-override.md`.
60
+ Set the tokens above on an element you own (a page or section class, or a class added with
61
+ `:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
62
+ `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
63
+ `<style scoped>`, tokens set on your element still work, but selectors that reach inside the
64
+ component need `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
65
+
66
+ **Caveat:** a colour passed in `config` is an inline style on the path, so it wins over the token
67
+ wherever the token is set. `AlertMaskedContent` passes both colours this way (through its own
68
+ `--alert-masked-content-*` tokens), so inside it set those instead.
52
69
 
53
70
  ---
54
71
 
55
72
  ## Class passthrough
56
73
 
57
- Use `styleClassPassthrough` to add classes to the root `.alert-mask-core` element for layout
58
- purposes (positioning, sizing) — it doesn't affect the mask's own colours or geometry, which stay
59
- governed by `config`.
74
+ `style-class-passthrough` adds classes to the root `.alert-mask` element and updates after mount.
75
+ Use it for layout (sizing, positioning) or to scope the colour tokens.
60
76
 
61
77
  ```vue
62
- <AlertMaskCore style-class-passthrough="my-alert-mask">...</AlertMaskCore>
78
+ <AlertMask style-class-passthrough="my-alert-mask">...</AlertMask>
63
79
  ```
64
-