srcdev-nuxt-components 9.6.6 → 9.6.8

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 (67) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +14 -0
  2. package/.claude/skills/component-compliance-checklist.md +6 -0
  3. package/.claude/skills/component-naming.md +2 -2
  4. package/.claude/skills/components/input-checkbox-radio.md +19 -3
  5. package/.claude/skills/components/input-checkbox.md +14 -3
  6. package/.claude/skills/components/input-description.md +1 -1
  7. package/.claude/skills/components/input-error.md +1 -1
  8. package/.claude/skills/components/input-label.md +2 -2
  9. package/.claude/skills/components/input-number.md +44 -13
  10. package/.claude/skills/components/input-radio.md +15 -1
  11. package/.claude/skills/components/{input-range-core.md → input-range.md} +53 -18
  12. package/.claude/skills/components/input-textarea-core.md +4 -4
  13. package/.claude/skills/components/page-hero-highlights.md +22 -18
  14. package/.claude/skills/index.md +6 -6
  15. package/.claude/skills/testing-add-unit-test.md +17 -0
  16. package/.vscode/srcdev-component-input-checkbox-radio.code-snippets +0 -1
  17. package/.vscode/srcdev-component-input-range.code-snippets +38 -18
  18. package/.vscode/srcdev-component-page-hero-highlights.code-snippets +2 -1
  19. package/app/components/04.templates/page-hero-highlights/CONSUMER-STYLING.md +32 -12
  20. package/app/components/04.templates/page-hero-highlights/PageHeroHighlights.vue +56 -47
  21. package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlights.stories.ts +158 -307
  22. package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlightsHeader.stories.ts +10 -0
  23. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +49 -27
  24. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlightsHeader.spec.ts +42 -7
  25. package/app/components/04.templates/page-hero-highlights/tests/__snapshots__/PageHeroHighlights.spec.ts.snap +12 -10
  26. package/app/components/05.forms/input-checkbox/CONSUMER-STYLING.md +10 -0
  27. package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +18 -4
  28. package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +14 -2
  29. package/app/components/05.forms/input-checkbox/stories/MultipleCheckboxes.stories.ts +146 -37
  30. package/app/components/05.forms/input-checkbox/stories/SingleCheckbox.stories.ts +107 -30
  31. package/app/components/05.forms/input-checkbox/tests/MultipleCheckboxes.spec.ts +38 -4
  32. package/app/components/05.forms/input-checkbox/tests/SingleCheckbox.spec.ts +5 -0
  33. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +10 -3
  34. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +1 -0
  35. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +11 -10
  36. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioField.vue +3 -0
  37. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadio.stories.ts +44 -17
  38. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +164 -58
  39. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioField.stories.ts +94 -21
  40. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadio.spec.ts +14 -3
  41. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioButton.spec.ts +38 -3
  42. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioField.spec.ts +14 -3
  43. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +2 -2
  44. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +2 -2
  45. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +42 -2
  46. package/app/components/05.forms/input-number/InputNumber.vue +37 -25
  47. package/app/components/05.forms/input-number/InputNumberField.vue +29 -16
  48. package/app/components/05.forms/input-number/stories/InputNumber.stories.ts +76 -25
  49. package/app/components/05.forms/input-number/stories/InputNumberField.stories.ts +116 -50
  50. package/app/components/05.forms/input-number/tests/InputNumber.spec.ts +49 -13
  51. package/app/components/05.forms/input-number/tests/InputNumberField.spec.ts +59 -5
  52. package/app/components/05.forms/input-radio/CONSUMER-STYLING.md +11 -0
  53. package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +14 -3
  54. package/app/components/05.forms/input-radio/stories/MultipleRadiobuttons.stories.ts +138 -38
  55. package/app/components/05.forms/input-radio/tests/MultipleRadioButtons.spec.ts +50 -3
  56. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +68 -26
  57. package/app/components/05.forms/input-range/InputRange.vue +247 -0
  58. package/app/components/05.forms/input-range/{InputRangeDefault.vue → InputRangeField.vue} +41 -18
  59. package/app/components/05.forms/input-range/stories/InputRange.stories.ts +187 -0
  60. package/app/components/05.forms/input-range/stories/InputRangeField.stories.ts +196 -0
  61. package/app/components/05.forms/input-range/tests/{InputRangeCore.spec.ts → InputRange.spec.ts} +39 -15
  62. package/app/components/05.forms/input-range/tests/{InputRangeDefault.spec.ts → InputRangeField.spec.ts} +71 -7
  63. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +5 -5
  64. package/package.json +1 -1
  65. package/app/components/05.forms/input-range/InputRangeCore.vue +0 -169
  66. package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +0 -126
  67. package/app/components/05.forms/input-range/stories/InputRangeDefault.stories.ts +0 -108
@@ -0,0 +1,14 @@
1
+ ---
2
+ paths:
3
+ - "app/**/*.vue"
4
+ ---
5
+
6
+ # Element ids come from `useId()`, never from data
7
+
8
+ Build `id`/`for` pairs from `useId()` (plus a loop index for repeated items), never from props or
9
+ data values like `` `${name}-${item.value}` ``.
10
+
11
+ Why: two instances sharing a `name` and values produce duplicate ids, and a value with spaces makes
12
+ an invalid one, so labels toggle the wrong control (`MultipleCheckboxes`, 2026-10-16). Test
13
+ uniqueness with both instances inside one host component: separate `mountSuspended` calls each
14
+ restart the `useId()` counter. Detail: skill `testing-add-unit-test.md` (srcdev-nuxt-components).
@@ -262,6 +262,12 @@ The ledger can't see items 6c, 9, 10, 11 and 14, so check those by hand.
262
262
  the edits in private memory. Note that an inline-size container can't size itself from its
263
263
  content: say so in `CONSUMER-STYLING.md` (`ProfileSection`).
264
264
  - Grid tracks that hold text are `minmax(0, …)` so an unbroken string can't widen the grid.
265
+ - An `auto-fit` column minimum taken from a token or a measured width is wrapped in
266
+ `min(100%, …)`, or one long item makes the column wider than its container.
267
+ - A component whose root is a `<fieldset>` sets `min-inline-size: 0` on it: a native fieldset
268
+ defaults to its min-content width and overflows a narrow column (`MultipleCheckboxes`,
269
+ 2026-10-16).
270
+ - Element ids come from `useId()`, never from data values (rule `consumer/ids-from-useid.md`).
265
271
  - Layout measured in script (connector lines, indicators) re-measures when any measured item
266
272
  resizes or changes class, not only when the container resizes (`measured-layout-observers.md`,
267
273
  pitfall #28).
@@ -83,8 +83,8 @@ external consumer, free to rename. Run `/check-component-usage` for the current
83
83
  | InputTextWithLabel | `InputTextField` | yes |
84
84
  | InputPasswordWithLabel | `InputPasswordField` | yes |
85
85
  | InputTextAsNumberWithLabel | `InputTextAsNumberField` | - |
86
- | InputRangeCore | `InputRange` | - |
87
- | InputRangeDefault | `InputRangeField` | - |
86
+ | ~~InputRangeCore~~ | ✅ `InputRange` (2026-10-16) | - |
87
+ | ~~InputRangeDefault~~ | ✅ `InputRangeField` (2026-10-16) | - |
88
88
  | InputTextareaCore | `InputTextarea` | - |
89
89
  | InputTextareaWithLabel | `InputTextareaField` | yes |
90
90
  | InputSelectCore | `InputSelect` | yes |
@@ -28,7 +28,6 @@ Use these three directly only for a custom group or layout:
28
28
 
29
29
  <InputCheckboxRadioButton
30
30
  v-for="option in options"
31
- :id="`service-${option.value}`"
32
31
  :key="option.value"
33
32
  v-model="state.services"
34
33
  type="checkbox"
@@ -85,9 +84,9 @@ Same props as the control minus `isButton` (always on), plus:
85
84
 
86
85
  | Prop | Type | Default | Notes |
87
86
  |---|---|---|---|
88
- | `id` | `string` | required | Used for both the input and `label[for]`. |
87
+ | `id` | `string` | generated | Used for both the input and `label[for]`. Optional since 2026-10-16: omitted, it comes from `useId()`. Never build it from data values (rule `ids-from-useid.md`). |
89
88
  | `label` | `string` | `""` | Replaced by the `labelContent` slot when given. |
90
- | `:options-layout` | `OptionsLayout` | `"equal-widths"` | `data-options-layout`; `inline` stops the label wrapping. |
89
+ | `:options-layout` | `OptionsLayout` | `"equal-widths"` | `data-options-layout`, a styling hook only (until 2026-10-16 `inline` made the label `nowrap`). |
91
90
  | `direction` | `"row" \| "row-reverse"` | `"row"` | `data-direction`; `row-reverse` puts the control on the right. Reacts to changes. |
92
91
  | `:is-pill` | `boolean` | `false` | `data-pill`, fully rounded. |
93
92
  | `:item-icon` | `string` | `"material-symbols:add-2"` | Decorator icon name. The `itemIcon` slot replaces it entirely. |
@@ -124,6 +123,15 @@ Full detail in `app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md
124
123
 
125
124
  ---
126
125
 
126
+ ## Long and hostile content
127
+
128
+ Both labels wrap long unbroken words and URLs (`overflow-wrap: anywhere`, `min-inline-size: 0` on
129
+ label and root), and the control and decorator icon keep their size (`flex-shrink: 0`). No
130
+ line-clamp tokens: an option label names the choice and must stay readable. The Field and Button
131
+ `StressTest` stories cover it; the bare control renders no text, so it has none.
132
+
133
+ ---
134
+
127
135
  ## History
128
136
 
129
137
  **Migrated 2026-09-27** (3/5 → full compliance):
@@ -147,3 +155,11 @@ Full detail in `app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md
147
155
  - Button radius, gap, paddings and the Field label colour became tokens; label colour default moved
148
156
  from `--colour-text-default` to `--theme-text`. Removed an undeclared `--_box-shadow` and an
149
157
  invalid unitless `min-height`.
158
+
159
+ **Stress pass 2026-10-16**:
160
+
161
+ - Removed the inline-layout `nowrap` on the button label (private `--_white-space`), which made an
162
+ unbroken label overflow the row; labels now wrap and the control/icon no longer shrink.
163
+ - `InputCheckboxRadioButton`'s `id` became optional with a `useId()` fallback.
164
+ - Specs now unmount every wrapper in `afterEach`; stories moved to `StoryObj` with Docs
165
+ descriptions, parent-story links, `CanvasSwitcher` (Field, Button) and `StressTest` stories.
@@ -24,7 +24,7 @@ instead.
24
24
 
25
25
  | Prop (template form) | Type | Default | Notes |
26
26
  |---|---|---|---|
27
- | `name` | `string` | (required) | Shared `name` on every checkbox; also used to build each checkbox `id` (`${name}-${item.value}`) and the error/description ids. |
27
+ | `name` | `string` | (required) | Shared `name` on every checkbox; also used for the error/description ids. Button-presentation checkbox ids come from `useId()` plus the option index (until 2026-10-16 they were `${name}-${item.value}`, which duplicated across two groups sharing a name and broke on values with spaces). |
28
28
  | `legend` | `string` | (required) | Fieldset `<legend>` text. |
29
29
  | `:error-message` | `string \| object` | (required) | Shown by `InputError` when `fieldHasError` is true. Pass `""` when there's no error. An array renders as a list. |
30
30
  | `:field-has-error` | `boolean` | `false` | Shows the error, sets `aria-invalid` on the fieldset and points each checkbox's `aria-describedby` at the error. |
@@ -36,7 +36,7 @@ instead.
36
36
  | `:display-as-disc` | `boolean` | `false` | Round checkbox symbol. |
37
37
  | `:theme` | `FormUiTheme` | `"default"` | Forwarded to each control. |
38
38
  | `:input-variant` | `InputUiVariant` | `"normal"` | Forwarded to each control and the error message. |
39
- | `:style-class-passthrough` | `string \| string[]` | `[]` | Classes on the root `<fieldset>`. Read once at mount. |
39
+ | `:style-class-passthrough` | `string \| string[]` | `[]` | Classes on the root `<fieldset>`; updates when the prop changes. |
40
40
  | `data-testid` | `string` | `"multiple-checkboxes"` | On the root `<fieldset>`. |
41
41
  | `label` | `string` | `""` | **Ignored.** Kept only so existing call sites that pass it don't break; it has never rendered. Use `legend` or the description slots. |
42
42
 
@@ -84,7 +84,7 @@ instead.
84
84
  | `:options-layout` | `OptionsLayout` | `"equal-widths"` | Rendered as `data-options-layout` on the items container. |
85
85
  | `:theme` | `FormUiTheme` | `"default"` | |
86
86
  | `:input-variant` | `InputUiVariant` | `"normal"` | |
87
- | `:style-class-passthrough` | `string \| string[]` | `[]` | Classes on the root `<fieldset>`. Read once at mount. |
87
+ | `:style-class-passthrough` | `string \| string[]` | `[]` | Classes on the root `<fieldset>`; updates when the prop changes. |
88
88
  | `data-testid` | `string` | `"single-checkbox"` | On the root `<fieldset>`. |
89
89
 
90
90
  ### v-model
@@ -122,6 +122,17 @@ controls' own colours are the `--input-checkbox-*` tokens in `input-checkbox-rad
122
122
 
123
123
  ---
124
124
 
125
+ ## Long and hostile content
126
+
127
+ Both root fieldsets set `min-inline-size: 0` (a native `<fieldset>` otherwise grows to its
128
+ min-content width and overflows a narrow column) and `overflow-wrap: anywhere`, which inherits
129
+ into the legend, description, labels and error, so long unbroken words and URLs wrap. Equal-width
130
+ columns are capped at `100%` of the container. Button-mode labels wrap too (`InputCheckboxRadioButton` dropped its inline `nowrap`
131
+ 2026-10-16). No line-clamp tokens: the legend, option labels and error name the choice or the
132
+ problem and must stay readable. The `StressTest` story in each story file covers this.
133
+
134
+ ---
135
+
125
136
  ## Migration notes (2026-09-25)
126
137
 
127
138
  Brought to full compliance. Behaviour changes a consumer could notice:
@@ -4,7 +4,7 @@
4
4
 
5
5
  `InputDescription` renders a form field's help text. Every `*Field` wrapper in `05.forms`
6
6
  (`InputTextWithLabel`, `InputNumberField`, `InputSelectWithLabel`, `InputTextareaWithLabel`,
7
- `InputRangeDefault`, `InputTextAsNumberWithLabel`, `SingleCheckbox`, `MultipleCheckboxes`,
7
+ `InputRangeField`, `InputTextAsNumberWithLabel`, `SingleCheckbox`, `MultipleCheckboxes`,
8
8
  `MultipleRadiobuttons`) already renders one and forwards its own `descriptionText` /
9
9
  `descriptionHtml` slots into it, so consumers normally just pass those slots to the wrapper:
10
10
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  `InputError` renders the red error message strip under a form control. Every `*Field` wrapper in
6
6
  `05.forms` (`InputTextWithLabel`, `InputNumberField`, `InputSelectWithLabel`, `InputTextareaWithLabel`,
7
- `InputRangeDefault`, `ToggleSwitchWithLabel`, `SingleCheckbox`, `MultipleCheckboxes`,
7
+ `InputRangeField`, `ToggleSwitchWithLabel`, `SingleCheckbox`, `MultipleCheckboxes`,
8
8
  `MultipleRadiobuttons`, `InputTextAsNumberWithLabel`) already renders one, so consumers rarely use
9
9
  it directly. Use it yourself only when building a custom field wrapper.
10
10
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  `InputLabel` renders a form field's `<label>`. Every labelled wrapper in `05.forms`
6
6
  (`InputTextWithLabel`, `InputTextAsNumberWithLabel`, `InputNumberField`, `InputSelectWithLabel`,
7
- `InputTextareaWithLabel`, `InputRangeDefault`, `ToggleSwitchWithLabel`,
7
+ `InputTextareaWithLabel`, `InputRangeField`, `ToggleSwitchWithLabel`,
8
8
  `ToggleSwitchWithLabelInline`) already renders one from its `label` prop, so consumers normally
9
9
  never use it directly.
10
10
 
@@ -62,7 +62,7 @@ export default defineAppConfig({
62
62
  - `"required"` mode: a marker on fields with `required`; `"optional"` mode: a marker on fields
63
63
  without it; `"none"`: nothing (default, so existing sites are unchanged).
64
64
  - `InputTextWithLabel`, `InputTextAsNumberWithLabel`, `InputNumberField`, `InputSelectWithLabel`,
65
- `InputTextareaWithLabel` and `InputRangeDefault` forward `:required`.
65
+ `InputTextareaWithLabel` and `InputRangeField` forward `:required`.
66
66
  `ToggleSwitchWithLabel`/`ToggleSwitchWithLabelInline` pass `indicator="none"`: a switch always
67
67
  has a value, so neither marker means anything there.
68
68
  - The wrappers don't expose the indicator props per field; use `app.config`.
@@ -29,17 +29,17 @@ Most consumers should reach for **InputNumberField** rather than `InputNumber` d
29
29
  | `:placeholder` | `string` | `""` | Native `placeholder`. |
30
30
  | `:required` | `boolean` | `false` | Native `required`. |
31
31
  | `:theme` | `FormUiTheme` | `"default"` | Sets `data-theme` on the wrapper. |
32
- | `:weight` | `FormWeight` | `"normal"` | Adds an `input-number--{weight}` class to the input, for prop-shape consistency with sibling `05.forms` components. **No CSS in the library currently styles this class — it's a no-op today**, same convention as `InputRangeCore`'s `weight` prop. |
33
- | `:input-variant` | `InputUiVariant` | `"normal"` | Adds the variant name as a class on the wrapper — `"normal"` (bordered box) and `"underlined"` (bottom-border only) have CSS; `"outlined"` doesn't (see CONSUMER-STYLING.md). Same prop/behaviour as `InputTextCore`. |
32
+ | `:weight` | `FormWeight` | `"normal"` | Adds an `input-number--{weight}` class to the input, for prop-shape consistency with sibling `05.forms` components. **No CSS in the library currently styles this class — it's a no-op today**, same convention as `InputRange`'s `weight` prop. |
33
+ | `:input-variant` | `InputUiVariant` | `"normal"` | Rendered as `data-input-variant` on the wrapper (a bare class until 2026-10-16) — `"normal"` (bordered box) and `"underlined"` (bottom-border only) have CSS; `"outlined"` doesn't (see CONSUMER-STYLING.md). Same prop/behaviour as `InputTextCore`. |
34
34
  | `:field-has-error` | `boolean` | `false` | Drives `data-invalid` on the wrapper. |
35
- | `:style-class-passthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the input element. |
35
+ | `:style-class-passthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the input element. Reacts to changes. |
36
36
  | `:aria-describedby` | `string` | `""` | Forwarded to the native input. |
37
37
 
38
38
  ### v-model
39
39
 
40
40
  `v-model` — `number | readonly number[]`, **required** (`defineModel({ required: true })`). Bound
41
41
  directly to the native number input. Required rather than defaulted deliberately — same reasoning
42
- as `InputRangeCore`'s `v-model` (see that skill doc) — this also satisfies `vue/require-default-prop`.
42
+ as `InputRange`'s `v-model` (see that skill doc) — this also satisfies `vue/require-default-prop`.
43
43
 
44
44
  ---
45
45
 
@@ -47,15 +47,15 @@ as `InputRangeCore`'s `v-model` (see that skill doc) — this also satisfies `vu
47
47
 
48
48
  | Slot | Notes |
49
49
  |------|-------|
50
- | `left` | Rendered before the input (e.g. a step-down button). Adds `has-left-slot` to the wrapper. |
51
- | `right` | Rendered after the input (e.g. a step-up button). Adds `has-right-slot` to the wrapper. |
50
+ | `left` | Rendered before the input (e.g. a step-down button) in `.input-number-slot-left`. Sets `data-has-left-slot` on the wrapper. |
51
+ | `right` | Rendered after the input (e.g. a step-up button) in `.input-number-slot-right`. Sets `data-has-right-slot` on the wrapper. |
52
52
 
53
- Any `.input-button` inside `.slot` gets its own border/outline stripped and is sized to
53
+ Any `.input-button` inside `.input-number-slot` gets its own border/outline stripped and is sized to
54
54
  match the input (`aspect-ratio: 1`, `width: var(--input-min-height)`) regardless of which slot
55
55
  it's in. The divider border is then added directly to that button element specifically when its
56
- slot is present — `&.has-left-slot .left-slot .input-button { border-right: ... }` and the
57
- right-hand mirror — so it sits flush against the button's own edge rather than around the
58
- containing `.slot` div. This makes step buttons read as embedded segments of the same control
56
+ slot is present (`.input-number-slot-left .input-button { border-right: ... }` and the
57
+ right-hand mirror), so it sits flush against the button's own edge rather than around the
58
+ containing `.input-number-slot` div. This makes step buttons read as embedded segments of the same control
59
59
  rather than separate floating buttons. The divider reuses the wrapper's own border colour token
60
60
  (no separate divider token), same as `InputTextCore`/`InputTextAsNumberWithLabel`.
61
61
 
@@ -83,7 +83,7 @@ score-1/5 state. Several latent bugs were fixed alongside the standard checklist
83
83
  - The native `<input>` had no static `input-number` class — only the dynamic
84
84
  `elementClasses` passthrough — so the entire `.input-number { ... }` style block (font,
85
85
  padding, colour, focus box-shadow) never matched anything. Fixed by adding the class directly.
86
- - `placeholder` was declared as a prop but never bound on the `<input>` — unlike `InputRangeCore`
86
+ - `placeholder` was declared as a prop but never bound on the `<input>` — unlike `InputRange`
87
87
  (where a range input genuinely has no placeholder concept and the no-op is intentional and
88
88
  documented), a number input *does* support `placeholder` meaningfully, so this was a real gap,
89
89
  not a deliberate no-op. Fixed by binding `:placeholder`.
@@ -107,9 +107,9 @@ score-1/5 state. Several latent bugs were fixed alongside the standard checklist
107
107
  aligned with the rest of `05.forms`.
108
108
  - `weight` was passed from `InputNumberField` to `InputNumber` but `InputNumber` had no
109
109
  matching prop, so it fell through as a raw, meaningless `weight="normal"` HTML attribute on the
110
- wrapper `<div>`. Fixed by declaring the prop on `InputNumber` too, matching `InputRangeCore`.
110
+ wrapper `<div>`. Fixed by declaring the prop on `InputNumber` too, matching `InputRange`.
111
111
  - `defineModel()` had no default, tripping `vue/require-default-prop` — fixed the same way as
112
- `InputRangeCore`: `defineModel({ required: true })`, not a rule disable or an artificial default.
112
+ `InputRange`: `defineModel({ required: true })`, not a rule disable or an artificial default.
113
113
  - A single-use private token, `--_border`, was inlined to the public
114
114
  `var(--input-number-border, var(--theme-border))` directly at its point of use — it had no
115
115
  composition or state-swap behind it, just a bare pass-through (see pitfall #20). `--_surface`
@@ -221,3 +221,34 @@ button icon content itself is consumer-supplied).
221
221
 
222
222
  Step-down is disabled (`readonly` on its `InputButton`) once the value reaches `min`, and
223
223
  step-up once it reaches `max`; clicking either button while at that boundary is a no-op.
224
+ Each press adds or subtracts `step` (its absolute value, or 1 when it's 0), rounds away
225
+ floating-point drift (0.2 + 0.1 gives 0.3) and clamps to `min`/`max`, so a step that would overshoot
226
+ lands on the limit and a typed out-of-range value is pulled back in. From an empty field the first
227
+ press sets 0, clamped into range. `:style-class-passthrough` lands on the root `.input-number-field`
228
+ only (it also reached the inner input until 2026-10-16) and reacts to changes. The root carries
229
+ `data-input-variant`, `data-theme` and `data-invalid`; its old `error`, `theme-*` and variant
230
+ classes are gone.
231
+
232
+ ---
233
+
234
+ ## Stress pass 2026-10-16
235
+
236
+ - **Stepping** (`InputNumberField`): 0.1 steps drifted (0.30000000000000004); a step that didn't
237
+ divide the range overshot `max`/`min` (8 + 5 with max 10 gave 13); an empty field turned into
238
+ `NaN`. Now rounded to the step's precision, clamped to `min`/`max`, and an empty field starts at
239
+ 0, clamped. A zero or negative `step` steps by its size, or 1.
240
+ - **Mobile keyboard**: `inputmode="numeric"` and `pattern="[0-9]+"` were hardcoded, so a negative
241
+ or decimal value couldn't be typed on phones (and number inputs ignore `pattern`). `inputmode` is
242
+ now `numeric` for whole steps, `decimal` for fractional ones, and omitted when `min` is negative;
243
+ `pattern` is gone.
244
+ - **Width**: the input's minimum width was `max`'s digit count in `em`, uncapped and blind to a
245
+ longer negative `min` or the step's decimals. It now counts the longest of `min`/`max` plus the
246
+ step's decimals, capped at `12em`; the wrapper has `max-inline-size: 100%` and the step buttons
247
+ don't shrink. The Field wraps long label, description and error copy.
248
+ - **State hooks**: bare state classes became data attributes and the slot holders got prefixed
249
+ class names (CONSUMER-STYLING.md has the mapping). Removed the non-existent `body-normal-bold`
250
+ utility class from the label and a dead `.label-description` rule. `InputError` now gets
251
+ `input-variant` like the label does.
252
+ - Specs unmount in `afterEach`; stories moved to `StoryObj` with Docs descriptions, a
253
+ `DecimalSteps` story, `CanvasSwitcher` and a `StressTest` story on the Field. The bare
254
+ control's step-button demo uses `InputButton`, not raw buttons.
@@ -41,7 +41,7 @@ option in `fieldData.data`, and an `InputError`. It's the single-choice sibling
41
41
  | `:options-layout` | `OptionsLayout` | `"equal-widths"` | `"equal-widths"` (auto-fit grid sized to the longest label), `"inline"` (wrapping row) or `"block"` (column). Rendered as `data-options-layout` on the items container. |
42
42
  | `:theme` | `FormUiTheme` | `"default"` | Passed to each radio. |
43
43
  | `:input-variant` | `InputUiVariant` | `"normal"` | Passed to each radio, the description and the error. |
44
- | `:style-class-passthrough` | `string \| string[]` | `[]` | Classes on the root `<fieldset>`. Read once at mount. |
44
+ | `:style-class-passthrough` | `string \| string[]` | `[]` | Classes on the root `<fieldset>`; updates when the prop changes. |
45
45
  | `data-testid` | `string` | `"multiple-radio-buttons"` | On the root `<fieldset>`. |
46
46
 
47
47
  ### v-model
@@ -93,3 +93,17 @@ tokens from `input-checkbox-radio`.
93
93
  to `data-options-layout`; `MultipleCheckboxes` and `SingleCheckbox` got the same change.
94
94
  - Gap and top margin are public tokens. The label-width measurement no longer includes a
95
95
  selector for a class that doesn't exist. `fieldData` is a typed required model.
96
+
97
+ **Stress pass 2026-10-16**:
98
+
99
+ - Button-mode radio ids were `${name}-${item.value}`, so two groups sharing a `name` got duplicate
100
+ ids and a value with spaces made an invalid one (CLAUDE.md pitfall 30). `MultipleRadiobuttons`
101
+ no longer passes an `id`; `InputCheckboxRadioButton` generates one with `useId()`.
102
+ - The root fieldset sets `min-inline-size: 0` and `overflow-wrap: anywhere`, and equal-width
103
+ columns are capped at `min(100%, ...)`, so long legends, labels and errors wrap inside a narrow
104
+ column (same fix as `MultipleCheckboxes`). No line-clamp tokens: the legend, option labels and
105
+ error must stay readable.
106
+ - `styleClassPassthrough` is watched instead of read once at mount.
107
+ - The spec unmounts every wrapper in `afterEach`; the story moved to `StoryObj` with Docs
108
+ descriptions, `CanvasSwitcher` and a `StressTest` story, and its gap control is labelled as a
109
+ story-only CSS token.
@@ -1,15 +1,20 @@
1
- # InputRangeCore Component
1
+ # InputRange Component
2
+
3
+ > **Renamed 2026-10-16**: `InputRangeCore` → `InputRange`, `InputRangeDefault` → `InputRangeField`
4
+ > (DOM classes `.input-range-core` → `.input-range`, `.input-range-with-label` →
5
+ > `.input-range-field`; full class list in CONSUMER-STYLING.md). Skill doc renamed from
6
+ > `input-range-core.md`. No consumer app used either old name.
2
7
 
3
8
  ## Overview
4
9
 
5
- `InputRangeCore` is the low-level native `<input type="range">` primitive for the `05.forms`
10
+ `InputRange` is the low-level native `<input type="range">` primitive for the `05.forms`
6
11
  range-slider family. It renders a single native range input with left/right button slots (for
7
12
  step-down/step-up controls), an optional `markers` slot for custom tick marks overlaid on the
8
13
  track, and an optional `datalist` slot for native tick-mark snapping. It has no label,
9
- description, or error-message rendering of its own — that's composed by the `InputRangeDefault`
14
+ description, or error-message rendering of its own — that's composed by the `InputRangeField`
10
15
  variant below.
11
16
 
12
- Most consumers should reach for **InputRangeDefault** rather than `InputRangeCore` directly.
17
+ Most consumers should reach for **InputRangeField** rather than `InputRange` directly.
13
18
 
14
19
  ---
15
20
 
@@ -20,7 +25,7 @@ Most consumers should reach for **InputRangeDefault** rather than `InputRangeCor
20
25
  | Prop (template form) | Type | Default | Notes |
21
26
  |------|------|---------|-------|
22
27
  | `:id` | `string` | (required) | Applied to the native input. |
23
- | `:name` | `string` | (required) | Applied to the native input, and used to derive the `list` id when the `datalist` slot is used (`${name}-datalist`). |
28
+ | `:name` | `string` | (required) | Applied to the native input. (Until 2026-10-16 it also built the datalist id, `${name}-datalist`; that id now comes from `useId()`.) |
24
29
  | `:min` | `number` | (required) | Native `min`. |
25
30
  | `:max` | `number` | (required) | Native `max`. |
26
31
  | `:step` | `number` | `1` | Native `step`. |
@@ -28,8 +33,8 @@ Most consumers should reach for **InputRangeDefault** rather than `InputRangeCor
28
33
  | `:placeholder` | `string` | `""` | Declared but not rendered — range inputs have no placeholder concept. Kept for prop-shape consistency with sibling `05.forms` components. |
29
34
  | `:theme` | `FormUiTheme` | `"default"` | Sets `data-theme` on the wrapper. |
30
35
  | `:weight` | `FormWeight` | `"normal"` | Adds an `input-range--{weight}` class to the input. **No CSS in the library currently styles this class — it's a no-op today**, kept for prop-shape consistency with sibling components. |
31
- | `:field-has-error` | `boolean` | `false` | Declared, but doesn't drive any visible state inside `InputRangeCore` itself — `InputRangeDefault` handles the actual error styling (`data-invalid`/`.error` class) on its own wrapper. |
32
- | `:style-class-passthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the input element. |
36
+ | `:field-has-error` | `boolean` | `false` | `data-invalid` on the wrapper and `aria-invalid="true"` on the input (it did nothing here until 2026-10-16). |
37
+ | `:style-class-passthrough` | `string \| string[]` | `[]` | Extra CSS classes applied to the input element. Reacts to changes. |
33
38
  | `:aria-describedby` | `string` | `""` | Forwarded to the native input. |
34
39
 
35
40
  ### v-model
@@ -50,8 +55,8 @@ disable, not a fake default).
50
55
  |------|-------|
51
56
  | `left` | Rendered before the track (e.g. a step-down button). |
52
57
  | `right` | Rendered after the track (e.g. a step-up button). |
53
- | `markers` | Rendered as an overlay on top of the track (`grid-area: element-stack`, same cell as the input). Adds a `has-markers` class to the input, which shrinks its visible track to a thin `0.2rem` strip and hides the native thumb, so your marker content becomes the primary visual. Expected shape: a `.input-range-markers` wrapper containing `.marker` elements (each optionally containing a `.marker-icon`) — see the component's own scoped CSS for the classes it pre-styles. |
54
- | `datalist` | Rendered after the input. Must contain a real `<datalist>` element whose `id` matches `${name}-datalist` for the browser to link it via the input's `list` attribute — the component computes that id string for the `list` attribute itself, but does not set the `id` on your slotted `<datalist>` for you. |
58
+ | `markers` | Rendered as an overlay on top of the track (`grid-area: element-stack`, same cell as the input). Sets `data-has-markers` on the wrapper: a line joining the markers is drawn behind them, the input becomes a transparent layer over the whole marker row (so the row is draggable and clickable), and the thumb is restyled as a ring the size of one marker that circles the selected one. That lines up because markers are spaced `space-between` and given an explicit size (icon size + 2 × padding), the same as the thumb. Use one marker per step (`(max - min) / step + 1`). Expected shape: a `.input-range-markers` wrapper containing `.input-range-marker` elements (each optionally containing an `.input-range-marker-icon`); those classes are pre-styled (they were `.marker`/`.marker-icon` until 2026-10-16). |
59
+ | `datalist` | Rendered after the input, with a `datalistId` slot prop: put it on your `<datalist>` (`<template #datalist="{ datalistId }"><datalist :id="datalistId">`) and the input's `list` points at it. The id comes from `useId()`, so two ranges sharing a `name` don't collide. `list` is omitted when the slot is empty. |
55
60
 
56
61
  ---
57
62
 
@@ -93,13 +98,13 @@ was itself commented out.
93
98
 
94
99
  ## Variants
95
100
 
96
- ### InputRangeDefault
101
+ ### InputRangeField
97
102
 
98
- `InputRangeDefault` (`InputRangeDefault.vue`) composes `InputRangeCore` with `InputLabel`,
103
+ `InputRangeField` (`InputRangeField.vue`) composes `InputRange` with `InputLabel`,
99
104
  `InputDescription`, and `InputError`, plus optional step-down/step-up buttons (`InputButton`)
100
105
  wired into the `left`/`right` slots.
101
106
 
102
- **Additional props over InputRangeCore:**
107
+ **Additional props over InputRange:**
103
108
 
104
109
  | Prop (template form) | Type | Default | Notes |
105
110
  |------|------|---------|-------|
@@ -110,11 +115,11 @@ wired into the `left`/`right` slots.
110
115
 
111
116
  **Slots**: `descriptionHtml`, `descriptionText` (both forwarded to `InputDescription`), plus
112
117
  `left`/`right`/`markers`/`datalist` (forwarded straight through to the underlying
113
- `InputRangeCore` — the step-down/step-up buttons only render when you use the `left`/`right`
118
+ `InputRange`; `markers` wasn't forwarded until 2026-10-16, and `datalist` passes `datalistId` on — the step-down/step-up buttons only render when you use the `left`/`right`
114
119
  slots yourself, since the button icon content itself is consumer-supplied via `#iconOnly`).
115
120
 
116
121
  ```vue
117
- <InputRangeDefault
122
+ <InputRangeField
118
123
  name="priceRange"
119
124
  label="Price range"
120
125
  v-model="price"
@@ -126,17 +131,47 @@ slots yourself, since the button icon content itself is consumer-supplied via `#
126
131
  >
127
132
  <template #left><Icon name="mdi:minus" /></template>
128
133
  <template #right><Icon name="mdi:plus" /></template>
129
- </InputRangeDefault>
134
+ </InputRangeField>
130
135
  ```
131
136
 
132
- `v-model` is `number`, **required** — same as `InputRangeCore` above.
137
+ `v-model` is `number`, **required** — same as `InputRange` above.
138
+
139
+ Each step-button press adds or subtracts `step` (its absolute value, or 1 when it's 0), rounds away
140
+ floating-point drift (0.2 + 0.1 gives 0.3) and clamps to `min`/`max`; step-down goes read-only at or
141
+ below `min`, step-up at or above `max`. `:style-class-passthrough` lands on the root
142
+ `.input-range-field` only (it also reached the inner input until 2026-10-16) and reacts to changes.
143
+ The root carries `data-theme` and `data-invalid` (its old `error` class is gone), and the label
144
+ gets `input-range-label` (it was the number field's `input-number-label`, with a non-existent
145
+ `body-normal-bold` utility).
133
146
 
134
147
  **Fixed 2026-09-21**: removed a dead `deepCssClassPassthrough` prop (declared, never consumed
135
148
  anywhere in the component or referenced elsewhere in the codebase) and a dead `<style>` block
136
149
  targeting `.input-range-label`/`.label-description` — classes that nothing in this component (or
137
150
  `InputLabel`/`InputDescription`) actually renders, so the block had no effect. Also removed two
138
151
  dead commented-out lines (`// const id = useId();`, a duplicate no-op `FormUiTheme` computed), and
139
- (same day, follow-up pass) a `const props = withDefaults(...)` in both `InputRangeCore` and
140
- `InputRangeDefault` that had become entirely unused once the dead `FormUiTheme` computed was
152
+ (same day, follow-up pass) a `const props = withDefaults(...)` in both `InputRange` and
153
+ `InputRangeField` that had become entirely unused once the dead `FormUiTheme` computed was
141
154
  removed — the template's `:id`/`:name`/etc. shorthand bindings resolve straight from `defineProps`
142
155
  automatically in `<script setup>`, no destructuring or `props.` reference needed.
156
+
157
+ ---
158
+
159
+ ## Stress pass 2026-10-16
160
+
161
+ - **Stepping** (`InputRangeField`): 0.1 steps drifted (0.30000000000000004) and a step that didn't
162
+ divide the range overshot `min`/`max`; the read-only checks used `===`, so an overshot value
163
+ never disabled the button. Now rounded, clamped, and `<=`/`>=`. A zero or negative `step`
164
+ steps by its size, or 1.
165
+ - **Datalist id**: was `${name}-datalist`, so two ranges sharing a `name` linked to the same list
166
+ (rule `ids-from-useid.md`). Now `useId()`, handed to the slot as `datalistId`.
167
+ - **Error state**: `fieldHasError` did nothing on `InputRange`; it now sets `data-invalid` and
168
+ `aria-invalid`.
169
+ - **Slots**: the Field didn't forward `markers`.
170
+ - **Classes**: bare/generic classes became prefixed classes or data attributes, and the marker
171
+ defaults moved from dark-page colours to `--theme-*` tokens (CONSUMER-STYLING.md has the
172
+ mapping). The Field's passthrough no longer reaches the inner input as well as its root.
173
+ - **Overflow**: the slider container gets `min-inline-size: 0` and the step buttons don't
174
+ shrink; the Field wraps long label, description and error copy.
175
+ - Specs unmount in `afterEach`; stories moved to `StoryObj` with Docs descriptions, a
176
+ `DecimalSteps` story, `CanvasSwitcher` and a `StressTest` story on the Field. The bare
177
+ control's step-button demo uses `InputButton`, not raw buttons.
@@ -33,7 +33,7 @@ directly.
33
33
 
34
34
  - `v-model` — `string | number | readonly string[] | null | undefined`, **required**
35
35
  (`defineModel({ required: true })`, satisfying `vue/require-default-prop` the same way as
36
- `InputRangeCore`/`InputNumber` — see `project_ledger_eslint_issues_column` in memory).
36
+ `InputRange`/`InputNumber` — see `project_ledger_eslint_issues_column` in memory).
37
37
  "Required" here means a consumer must bind `v-model`, not that the value can't be an empty
38
38
  string — `""` is a perfectly valid textarea value.
39
39
  - `v-model:is-dirty` — `boolean`. Set once by `InputTextareaWithLabel` the first time the value
@@ -50,7 +50,7 @@ directly.
50
50
  | `left` | Rendered before the textarea (decorative — e.g. an icon). Adds `has-left-slot` to the wrapper. |
51
51
  | `right` | Rendered after the textarea (decorative). Adds `has-right-slot` to the wrapper. |
52
52
 
53
- Unlike `InputNumber`/`InputRangeCore`, these slots have no built-in `InputButton`
53
+ Unlike `InputNumber`/`InputRange`, these slots have no built-in `InputButton`
54
54
  divider/embedded-button styling — they're plain decorative content (see the Storybook stories,
55
55
  which use emoji), not a stepper control.
56
56
 
@@ -75,7 +75,7 @@ the full table with defaults.
75
75
  score-1/5 state (no public tokens, no CONSUMER-STYLING.md, no tests, no skill doc, no snippet).
76
76
 
77
77
  - **Silently broken styling from undefined CSS custom properties** (same bug class as pitfall #21
78
- and `InputRangeCore`'s dead `--theme-form-range-accent-color`): the wrapper's `padding-inline`
78
+ and `InputRange`'s dead `--theme-form-range-accent-color`): the wrapper's `padding-inline`
79
79
  read `--element-decorator-padding-inline`, and the placeholder's `font-size`/`font-style`/
80
80
  `line-height`/`font-weight` read `--input-placeholder-font-size`/`-font-style`/`-line-height`/
81
81
  `-font-weight` — **none of these five custom properties were declared anywhere in the
@@ -105,7 +105,7 @@ score-1/5 state (no public tokens, no CONSUMER-STYLING.md, no tests, no skill do
105
105
  `InputLabel` (which does have a real `label` concept) already renders it correctly. Covered by
106
106
  a regression test.
107
107
  - **`defineModel()` had no default**, tripping `vue/require-default-prop` in both files — fixed
108
- with `{ required: true }`, same pattern as `InputRangeCore`/`InputNumber` (see the v-model
108
+ with `{ required: true }`, same pattern as `InputRange`/`InputNumber` (see the v-model
109
109
  note above for why this is still correct even though an empty string is a valid value here).
110
110
  - **`InputTextareaWithLabel.stories.ts` used `reactive()` in two render functions
111
111
  (`AllVariants`/`FormExamples`) without importing it** — only `computed` was imported from
@@ -1,5 +1,7 @@
1
1
  # PageHeroHighlights Component
2
2
 
3
+ > **Changed 2026-10-16:** `widthConstrained` was inverted in the CSS since it was first added (`true` gave full width, `false` capped the column); it now does what this doc always said. The tablet/desktop gutters never applied (their `@container` queries had no container): the root is now a named `page-hero-highlights` inline-size container and the rows sit in a `.page-hero-highlights-grid` wrapper. Colour defaults are light theme tokens (were `darkblue`/`white`/`black`/`--slate-*`). Equal-width highlights wrap below `--page-hero-highlights-item-min-width` (12rem). Grid tracks are `minmax(0, …)` and items get `overflow-wrap: anywhere`.
4
+
3
5
  > **Changed 2026-09-28:** styles are now in `@layer components` (they were unlayered). Every token is `--page-hero-highlights-*` with its default as a `var()` fallback, so it can be set on the component or any ancestor (previously declared on the root with generic names like `--max-width`, `--highlight-border`, so ancestor values never landed). Inner classes are prefixed (`.page-hero-highlights-header-row`, `-header-slot`, `-strip`, `-content-row`, `-content-slot`); the content-align root classes are `.content-align-start`/`.content-align-center` (were bare `.start`/`.center`); the highlight contract classes are `.page-hero-highlights-item`, `-item-title`, `-item-body` (were `.highlight`, `.title`, `.body`); PageHeroHighlightsHeader uses `.page-hero-highlights-header-start`/`-end` and `--page-hero-highlights-header-*` tokens (were `.phh-*` / `--phh-*`). Full token reference: `CONSUMER-STYLING.md` in the component folder.
4
6
 
5
7
  ## Overview
@@ -19,7 +21,7 @@ The layout uses a 4-row CSS Grid with `subgrid` — no `translate`, negative mar
19
21
  | `tag` | `"div" \| "section" \| "main"` | `"div"` | Root element tag |
20
22
  | `highlightsEqualWidths` | `boolean` | `false` | Equal-width grid columns for highlight items |
21
23
  | `highlightsJustify` | `"start" \| "center" \| "end" \| "space-between" \| "space-around"` | `"start"` | Alignment of highlight items along the main axis |
22
- | `widthConstrained` | `boolean` | `false` | When `true`, caps the central column at `--width-constrained` (default `1064px`). Gutters grow responsively to enforce the constraint. |
24
+ | `widthConstrained` | `boolean` | `false` | When `true`, caps the central column at `--page-hero-highlights-max-width` (default `1064px`). Gutters grow responsively to enforce the constraint. |
23
25
  | `contentAlign` | `"start" \| "center"` | `"center"` | When `widthConstrained` is `true`: `"center"` grows gutters equally; `"start"` pins content to the left with a fixed left gutter. |
24
26
  | `contentPanel` | `boolean` | `true` | When `true`, renders a decorative panel behind the content slot and offsets the highlights strip. Set to `false` for a flat layout with no backdrop. |
25
27
  | `highlightTitleBaseline`| `boolean` | `false` | When `true`, fixes the highlight title row to a set height so titles align at a common baseline. Override `--page-hero-highlights-title-height` to tune. |
@@ -166,7 +168,7 @@ See [component-aria-landmark.md](../component-aria-landmark.md) for the full pat
166
168
 
167
169
  ## Equal-width highlights
168
170
 
169
- By default, highlight items size to their content (`flex-wrap`). Pass `:highlights-equal-widths="true"` to switch to a grid where all items share equal column widths:
171
+ By default, highlight items size to their content (`flex-wrap`). Pass `:highlights-equal-widths="true"` to switch to a grid where all items share equal column widths (`repeat(auto-fit, minmax(min(100%, --page-hero-highlights-item-min-width), 1fr))`), wrapping onto new rows when they'd get narrower than the min width (default `12rem`):
170
172
 
171
173
  ```vue
172
174
  <PageHeroHighlights :highlights-equal-widths="true">
@@ -176,10 +178,10 @@ By default, highlight items size to their content (`flex-wrap`). Pass `:highligh
176
178
 
177
179
  ## Constraining the central column width
178
180
 
179
- Pass `:width-constrained="true"` to cap the content column at `--width-constrained` (default `1064px`). The gutters grow responsively to enforce it — full-bleed backgrounds are unaffected and `subgrid` continues to work. Use `content-align` to pin to the left or centre:
181
+ Pass `:width-constrained="true"` to cap the content column at `--page-hero-highlights-max-width` (default `1064px`). The gutters grow responsively to enforce it — full-bleed backgrounds are unaffected and `subgrid` continues to work. Use `content-align` to pin to the left or centre:
180
182
 
181
183
  ```vue
182
- <!-- Centred, capped at --width-constrained (1064px) -->
184
+ <!-- Centred, capped at --page-hero-highlights-max-width (1064px) -->
183
185
  <PageHeroHighlights :width-constrained="true" content-align="center">...</PageHeroHighlights>
184
186
 
185
187
  <!-- Left-pinned (right side takes remaining space) -->
@@ -190,12 +192,12 @@ The maximum width value and gutter sizes are all CSS tokens — override them vi
190
192
 
191
193
  ```css
192
194
  .my-page-hero {
193
- --width-constrained: 1200px;
195
+ --page-hero-highlights-max-width: 1200px;
194
196
  --page-hero-highlights-gutter-desktop: 48px;
195
197
  }
196
198
  ```
197
199
 
198
- See [css-grid-width-constrained-gutters.md](../css-grid-width-constrained-gutters.md) for the underlying pattern explanation.
200
+ See [css-grid-max-width-gutters.md](../css-grid-max-width-gutters.md) for the underlying pattern explanation.
199
201
 
200
202
  ## Local style override scaffold
201
203
 
@@ -215,40 +217,41 @@ See [component-local-style-override.md](../component-local-style-override.md) fo
215
217
  .page-hero-highlights {
216
218
  &.my-page-hero {
217
219
  /* Grid layout */
218
- /* --width-constrained: 1064px; */
220
+ /* --page-hero-highlights-max-width: 1064px; */
219
221
  /* --page-hero-highlights-gutter-mobile: 16px; */
220
222
  /* --page-hero-highlights-gutter-tablet: 40px; */
221
223
  /* --page-hero-highlights-gutter-desktop: 32px; */
222
224
 
223
225
  /* Header zone */
224
- /* --page-hero-highlights-header-background: darkblue; */
226
+ /* --page-hero-highlights-header-background: var(--theme-surface-subtle); */
225
227
 
226
228
  /* Highlights strip */
227
229
  /* --page-hero-highlights-strip-gap: 1rem; */
228
230
  /* --page-hero-highlights-strip-inset: 1.2rem; */
231
+ /* --page-hero-highlights-item-min-width: 12rem; */ /* equal-widths wrap point */
229
232
 
230
233
  /* Highlight cards */
231
234
  /* --page-hero-highlights-item-rows-gap: 1.2rem; */
232
235
  /* --page-hero-highlights-title-height: 1fr; */ /* see: highlight-title-baseline prop */
233
236
  /* --page-hero-highlights-item-padding-block-start: 1.2rem; */
234
237
  /* --page-hero-highlights-item-padding: 1.2rem; */
235
- /* --page-hero-highlights-item-background: white; */
236
- /* --page-hero-highlights-item-border: 1px solid black; */
238
+ /* --page-hero-highlights-item-background: var(--page-bg, Canvas); */
239
+ /* --page-hero-highlights-item-border: 1px solid var(--theme-border); */
237
240
  /* --page-hero-highlights-item-border-radius: 8px; */
238
- /* --page-hero-highlights-item-colour: black; */
241
+ /* --page-hero-highlights-item-colour: var(--theme-text); */
239
242
 
240
243
  /* Content zone */
241
- /* --page-hero-highlights-content-background: var(--slate-01); */ /* transparent */
244
+ /* --page-hero-highlights-content-background: transparent; */
242
245
  /* --page-hero-highlights-content-start-gap: 1.2rem; */
243
246
  /* --page-hero-highlights-content-end-gap: 1.2rem; */
244
247
 
245
248
  /* Content slot decorative border */
246
249
  /* --page-hero-highlights-content-margin-block-start: 2.4rem; */
247
250
  /* --page-hero-highlights-content-margin: var(--page-hero-highlights-strip-inset); */
248
- /* --page-hero-highlights-panel-background: var(--slate-00); */
249
- /* --page-hero-highlights-panel-border: 1px solid var(--slate-06); */
251
+ /* --page-hero-highlights-panel-background: var(--theme-surface-subtle); */
252
+ /* --page-hero-highlights-panel-border: 1px solid var(--theme-border); */
250
253
  /* --page-hero-highlights-panel-border-radius: 0.8rem; */
251
- /* --page-hero-highlights-panel-outline: 1px solid var(--slate-02); */
254
+ /* --page-hero-highlights-panel-outline: none; */
252
255
 
253
256
  /* When using :highlight-title-baseline="true" */
254
257
  /* --page-hero-highlights-title-height-baseline: 4rem; */ /* proportional value preferred */
@@ -268,10 +271,10 @@ row3: highlights bottom half
268
271
  row4: page content (never underflows highlights)
269
272
  ```
270
273
 
271
- `.page-hero-highlights-header-row` spans cols 1–3, rows 1–2 (edge-to-edge bg). `.page-hero-highlights-header-slot` is placed in row 1 only.
274
+ All of these sit in `.page-hero-highlights-grid`, the grid inside the root (the root is the size container). `.page-hero-highlights-header-row` spans cols 1–3, rows 1–2 (edge-to-edge bg). `.page-hero-highlights-header-slot` is placed in row 1 only.
272
275
  `.page-hero-highlights-content-row` spans cols 1–3, rows 3–4 (bg fills behind highlights; `.page-hero-highlights-content-slot` is placed in row 4 only). The decorative border behind `.page-hero-highlights-content-slot` is rendered via `.page-hero-highlights-content-row::before` — there is no separate DOM element for it.
273
276
 
274
- Grid columns are determined entirely by CSS — no `v-bind`. The `widthConstrained` and `contentAlign` props add CSS classes (`width-constrained`, `start`, `center`) which select the appropriate `grid-template-columns` rule.
277
+ Grid columns are determined entirely by CSS — no `v-bind`. The `widthConstrained` and `contentAlign` props add CSS classes (`width-constrained`, `content-align-start`, `content-align-center`) on the root, which select the `grid-template-columns` rule on `.page-hero-highlights-grid`.
275
278
 
276
279
  ## Layout pitfall: do not use `grid-template-rows: subgrid` inside `.page-hero-highlights-strip`
277
280
 
@@ -300,4 +303,5 @@ The `.page-hero-highlights-strip` element spans rows 2–3 of the parent grid (t
300
303
  - Lives in `app/components/04.templates/page-hero-highlights/`.
301
304
  - Storybook title: `"Templates/PageHeroHighlights"`.
302
305
  - **`contentAlign` has no effect when `widthConstrained` is `false`** — both gutters hold their responsive default.
303
- - **Gutter sizes are CSS tokens** — `--page-hero-highlights-gutter-mobile/tablet/desktop` are all overridable. The responsive switching between them (via `@container`) is handled internally and cannot be overridden.
306
+ - **Gutter sizes are CSS tokens** — `--page-hero-highlights-gutter-mobile/tablet/desktop` are all overridable. They switch at 768px and 1024px of the component's own width (`@container page-hero-highlights`); the breakpoints themselves aren't tokens.
307
+ - **The root is an inline-size container**, so it can't size from its content: as a flex or grid item give it `inline-size: 100%` or a track. A grid on the root itself was moved to `.page-hero-highlights-grid` for this (an element can't query its own size).