srcdev-nuxt-components 9.6.0 → 9.6.2

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 (121) hide show
  1. package/.claude/skills/component-compliance-checklist.md +22 -3
  2. package/.claude/skills/component-dynamic-slots.md +3 -3
  3. package/.claude/skills/component-export-types.md +10 -16
  4. package/.claude/skills/component-naming.md +1 -1
  5. package/.claude/skills/components/alert-content.md +9 -1
  6. package/.claude/skills/components/container-glow.md +21 -8
  7. package/.claude/skills/components/display-avatar.md +27 -10
  8. package/.claude/skills/components/display-chip.md +38 -24
  9. package/.claude/skills/components/display-pill.md +58 -10
  10. package/.claude/skills/components/display-prompt.md +8 -5
  11. package/.claude/skills/components/display-toast.md +21 -4
  12. package/.claude/skills/components/glowing-border.md +20 -2
  13. package/.claude/skills/components/google-reviews.md +2 -2
  14. package/.claude/skills/components/profile-section.md +59 -21
  15. package/.claude/skills/components/responsive-header.md +11 -1
  16. package/.claude/skills/components/select-menu.md +54 -5
  17. package/.claude/skills/components/site-header.md +2 -2
  18. package/.claude/skills/components/social-icons-list.md +2 -0
  19. package/.claude/skills/components/tabbed-content.md +300 -0
  20. package/.claude/skills/composable-anchor-scroll.md +16 -15
  21. package/.claude/skills/index.md +8 -7
  22. package/.claude/skills/storybook-add-story.md +55 -0
  23. package/.claude/skills/theming-component-token-pattern.md +48 -0
  24. package/.claude/skills/theming-status-tokens.md +60 -0
  25. package/.vscode/srcdev-component-display-avatar.code-snippets +9 -1
  26. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  27. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  28. package/.vscode/srcdev-component-profile-section.code-snippets +27 -4
  29. package/.vscode/srcdev-component-responsive-header.code-snippets +6 -0
  30. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  31. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  32. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  33. package/app/assets/styles/setup/03.theming/index.css +1 -0
  34. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  35. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  36. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  37. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  38. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  39. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  40. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  41. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  42. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  43. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  44. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +75 -9
  45. package/app/components/01.atoms/display-pill/DisplayPill.vue +57 -24
  46. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  47. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  48. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  49. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  50. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  51. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  52. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  53. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  54. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  55. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  56. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  57. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  58. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +33 -15
  59. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +81 -36
  60. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +108 -6
  61. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +104 -13
  62. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +4 -3
  63. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +57 -19
  64. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  65. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  66. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  67. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  68. package/app/components/02.molecules/profile-section/CONSUMER-STYLING.md +103 -13
  69. package/app/components/02.molecules/profile-section/ProfileSection.vue +123 -23
  70. package/app/components/02.molecules/profile-section/stories/ProfileSection.stories.ts +353 -115
  71. package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +144 -11
  72. package/app/components/02.molecules/profile-section/tests/__snapshots__/ProfileSection.spec.ts.snap +6 -11
  73. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  74. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  75. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  76. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  77. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  78. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  79. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  80. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  81. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  82. package/app/components/02.molecules/social-icons-list/CONSUMER-STYLING.md +6 -2
  83. package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +1 -0
  84. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  85. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  86. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  87. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  88. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  89. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  90. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  91. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +1 -1
  92. package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +6 -6
  93. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +38 -11
  94. package/app/components/03.organisms/responsive-header/NavigationItems.vue +27 -12
  95. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +35 -20
  96. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +1 -1
  97. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +1 -1
  98. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  99. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  100. package/app/composables/useTabs.ts +148 -206
  101. package/app/types/components/display-chip.d.ts +8 -4
  102. package/app/types/components/display-toast.d.ts +2 -0
  103. package/app/types/components/index.ts +0 -1
  104. package/app/types/components/select-menu.d.ts +2 -0
  105. package/app/utils/pausableTimeout.ts +41 -0
  106. package/nuxt.config.ts +1 -1
  107. package/package.json +1 -1
  108. package/.claude/skills/components/tab-navigation.md +0 -288
  109. package/.claude/skills/components/tabs-core.md +0 -187
  110. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  111. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  112. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  113. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  114. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  115. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -191
  116. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -550
  117. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -259
  118. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -379
  119. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  120. package/app/composables/useNavCollapse.ts +0 -86
  121. package/app/types/components/nav-item.d.ts +0 -11
@@ -0,0 +1,300 @@
1
+ ---
2
+ name: TabbedContent
3
+ description: TabbedContent tab list/panel widget (was TabsCore) — indexed dynamic slots, arrow-key navigation, moving hover/active/underline indicators, CSS tokens
4
+ type: reference
5
+ ---
6
+
7
+ # TabbedContent
8
+
9
+ > **Renamed 2026-10-07:** `TabsCore` → `TabbedContent`, moved from `01.atoms/navigation/tabs/` to
10
+ > `01.atoms/tabbed-content/`. Root class `.tabs-core` → `.tabbed-content`; inner classes
11
+ > `.tabs-list` → `.tabbed-content-list`, `.tabs-list-item` → `.tabbed-content-trigger`,
12
+ > `.nav__hovered`/`.nav__active`/`.nav__active-indicator` → `.tabbed-content-indicator-hover`/
13
+ > `-active`/`-underline`, `.tab-content-wrapper` → `.tabbed-content-panels`, `.tab-content` →
14
+ > `.tabbed-content-panel`. Tokens keep their `--tabs-*` names. Slot names are unchanged.
15
+
16
+ ## Overview
17
+
18
+ `TabbedContent` renders a WAI-ARIA tablist and its panels from indexed slots. The number of tabs is
19
+ set via `itemCount`; each tab's trigger and content come from `tab-{n}-trigger`/`tab-{n}-content`
20
+ slots. Indexed slots (rather than named dynamic slots) are used deliberately: `itemCount` is needed
21
+ for ARIA linking across the two parallel loops (triggers and panels), not just the slot loop itself.
22
+
23
+ Moving decorators track pointer/keyboard state: a hover highlight, an active-tab highlight, a
24
+ thick underline/sideline under the active tab, and a thin one (`--tabs-hover-underline-indicator-height`,
25
+ default `0.1rem`, added 2026-10-07) following the hovered tab. `trackHover` / `trackActive` /
26
+ `trackIndicator` turn them off; the hover underline needs both `trackHover` and `trackIndicator`.
27
+
28
+ Not navigation: for page/site navigation use `ResponsiveHeader` (which replaced `TabNavigation`).
29
+
30
+ **Location**: `app/components/01.atoms/tabbed-content/`
31
+
32
+ ## Props
33
+
34
+ | Prop | Type | Default | Notes |
35
+ |------|------|---------|-------|
36
+ | `itemCount` | `number` | — | **Required.** Number of tabs; drives both slot loops. Rounded down; 0, negative or `NaN` renders no tabs. Can change after mount (the active tab is kept if it still exists, otherwise the first tab becomes active). |
37
+ | `axis` | `"x" \| "y"` | `"x"` | Horizontal row or vertical column. Also sets `aria-orientation` and which arrow keys move between tabs. Reactive. |
38
+ | `transitionDuration` | `number` | `200` | ms duration of the moving indicators. Negative values clamp to 0. Reactive. |
39
+ | `trackHover` | `boolean` | `true` | Moving hover highlight. Reactive. |
40
+ | `trackActive` | `boolean` | `true` | Moving active-tab highlight. Reactive. |
41
+ | `trackIndicator` | `boolean` | `true` | Moving underline/sideline. Reactive. |
42
+ | `overflowMode` | `"menu" \| "scroll"` | `"menu"` | `axis="x"` only. `menu`: tabs that don't fit collapse from the end into a "More" button and menu (added 2026-10-07). `scroll`: the tab row scrolls sideways inside itself. Reactive. |
43
+ | `ariaLabel` | `string` | `"Tabs"` | `aria-label` on the tablist; override for localisation. |
44
+ | `moreLabel` | `string` | `"More tabs"` | Accessible name of the More button and its menu; override for localisation. |
45
+ | `moreIcon` | `string` | `"lucide:ellipsis"` | Icon on the More button. |
46
+ | `moreActiveIcon` | `string` | `"lucide:chevron-down"` | Icon after the active tab's label on the More button while that tab is collapsed. |
47
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | Classes on the root `.tabbed-content` (was the tab list before 2026-10-07). |
48
+
49
+ ## Slots
50
+
51
+ For each index `0..itemCount-1`:
52
+
53
+ - `tab-{n}-trigger` — content of the tab button. May contain icons or other elements; a click
54
+ anywhere inside the button activates the tab.
55
+ - `tab-{n}-content` — content of that tab's panel.
56
+
57
+ ```vue
58
+ <TabbedContent :item-count="2" aria-label="Product details">
59
+ <template #tab-0-trigger>Overview</template>
60
+ <template #tab-0-content>Overview content…</template>
61
+ <template #tab-1-trigger>Specs</template>
62
+ <template #tab-1-content>Specs content…</template>
63
+ </TabbedContent>
64
+ ```
65
+
66
+ ## Accessibility behaviour
67
+
68
+ - `role="tablist"` is a `<div>` that owns the `role="tab"` buttons directly (it used to be a
69
+ `<ul>` with plain `<li>`s in between, an ARIA required-children violation, with the indicator
70
+ `<div>`s injected into the list by JS). Indicators are `aria-hidden` `<span>`s rendered by the
71
+ template.
72
+ - `aria-orientation` follows `axis`.
73
+ - Each trigger has `aria-controls` pointing at its panel; each panel has `aria-labelledby` pointing
74
+ at its trigger. IDs are prefixed with `useId()`, so several instances on one page don't clash
75
+ (before 2026-10-07 every instance used `tab-0-trigger` etc.).
76
+ - Inactive panels carry the `hidden` attribute; the first panel is visible in the SSR markup.
77
+ Panels have `tabindex="0"` so keyboard users can reach scrollable panel content.
78
+ - Roving tabindex: only the active trigger has `tabindex="0"`.
79
+ - Arrow keys move focus and activate the target tab (automatic activation): `ArrowLeft`/`ArrowRight`
80
+ on `axis="x"`, `ArrowUp`/`ArrowDown` on `axis="y"`, wrapping at the ends; `Home`/`End` jump to
81
+ the first/last tab. Not mirrored for RTL.
82
+ - Focus ring: `--tabs-focus-ring-colour` (default `--theme-border-focus`) on triggers and panels.
83
+
84
+ ## Overflow menu (`overflowMode="menu"`, default, since 2026-10-07)
85
+
86
+ - On `axis="x"`, a ResizeObserver on the bar and the tablist measures the tabs (widths cached
87
+ while each is visible) and collapses the ones that don't fit, from the end, into a "More" button
88
+ at the end of the tab row (`--tabs-more-margin-inline-start: auto`; `0` puts it straight after
89
+ the last visible tab). Collapsed triggers get `hidden`, so they leave the tablist; the menu lists
90
+ them as `role="menuitemradio"` rows (re-rendering each tab's own `tab-{n}-trigger` slot), with
91
+ `aria-checked` on the active one. Picking one activates that tab and returns focus to the
92
+ More button.
93
+ - The More button is outside `role="tablist"` (`aria-haspopup="menu"`, `aria-label` from
94
+ `moreLabel`). While nothing has overflowed it stays in the DOM but idle (invisible,
95
+ `tabindex="-1"`) so its width can be measured.
96
+ - When the active tab is collapsed, the More button shows that tab's own trigger slot plus a
97
+ chevron (instead of `⋯`), gets `.is-active` and the active/hover indicators sit on it. A
98
+ ResizeObserver on the button re-measures, so if the wider button no longer fits, the tab before
99
+ it moves into the menu (and comes back when a visible tab is picked). The button has no
100
+ `aria-label`: `moreLabel` is visually hidden text inside it, so its name ("Shipping, More tabs")
101
+ always contains the visible label (WCAG 2.5.3). Trigger slot content renders up to three times
102
+ (tab, menu row, More button), so don't put `id`s in it.
103
+ - Indicators sit on the More button while the active tab is collapsed (`useTabs` swaps any
104
+ hidden tab for `[data-more-trigger]` when positioning). Arrow keys move between visible tabs only, and the first visible tab keeps `tabindex="0"` so
105
+ the tablist stays reachable with Tab.
106
+ - The popover uses `useAnchoredPopover` (Popover API + anchor positioning, with the Safari 16/17
107
+ fallbacks), like `SelectMenu` and `ActionMenu`. Menu keys: Arrow Up/Down (wrapping),
108
+ Home/End, Escape (light dismiss), Tab closes.
109
+ - Not on `axis="y"`: a column doesn't run out of width.
110
+
111
+ ## Layout under hostile data
112
+
113
+ - With `overflowMode="scroll"` the tab row scrolls sideways inside itself; the indicators scroll
114
+ with it. In both modes triggers never shrink, and wrap their label past
115
+ `--tabs-list-item-max-inline-size` (`24rem`).
116
+ - On `axis="y"` the tab column is capped at `--tabs-axis-y-list-max-inline-size` (`50%`).
117
+ - Panels wrap unbroken strings (`overflow-wrap: anywhere`).
118
+ - No line-clamp token: trigger and panel text is consumer slot markup, so the consumer controls it.
119
+
120
+ ## CSS custom properties
121
+
122
+ See `CONSUMER-STYLING.md` in the component folder for the full `--tabs-*` token API. Colour
123
+ defaults come from the `--theme-*` slots (changed 2026-10-07, were fixed `--slate-*` steps with a
124
+ dark panel).
125
+
126
+ ## useTabs
127
+
128
+ `app/composables/useTabs.ts` drives the behaviour and is only used by this component. Its nav ref
129
+ is `.tabbed-content-bar` (the indicators' positioning box); it also exposes `activeIndex` and
130
+ `activateTabByIndex()` for the More menu. It takes
131
+ `axis`, `transitionDuration` and `trackHover` as getters (read at use time, so prop changes apply),
132
+ and exposes `refreshTabs()`, which the component calls on mount and after any change to the tab
133
+ count, axis or indicator props. Click/hover handlers use `event.currentTarget` (the button), not
134
+ `event.target`, so clicking an icon inside a trigger works. Panels are matched to triggers by
135
+ `data-tab-index`, not by position in the `v-for` ref array, since Vue doesn't keep that array in
136
+ source order when the list changes.
137
+
138
+ ## Indicator motion: edge insets (2026-10-08)
139
+
140
+ `useTabs.placeIndicator(kind, animate)` writes each indicator's two edges as insets from the
141
+ bar's start/end along the axis (`--_{kind}-start` / `--_{kind}-end`), and the CSS transitions
142
+ `left`/`right` (or `top`/`bottom`) separately. Moving forward, the start edge gets a delay of one
143
+ duration (`--_{kind}-start-delay`); moving back, the end edge does. So the leading edge moves first
144
+ and the trailing edge catches up: the same stretch effect as before, symmetric both ways, with no
145
+ settle timer. CSS retargets each edge from its current position, so a quick second move can't
146
+ pull an edge backwards. Hover and active have separate duration/delay variables.
147
+
148
+ This replaced a `translate` + `scale` model (a bar-wide box translated to the tab's left and
149
+ scaled to its width, plus a timeout to settle after a stretch, and one shared
150
+ `--_transition-duration`). Rightward moves animated both properties together in the settle step,
151
+ and the indicator visibly stepped back mid-move (reported for hover, then n to n+x clicks); the
152
+ computed values were always correct, the composition wasn't. Avoid reintroducing a transform
153
+ pair for this.
154
+
155
+ ## Label colour under the sliding highlight (2026-10-08)
156
+
157
+ With `trackActive`, `useTabs` re-checks every animation frame while the active indicator
158
+ transitions (`trackCoverage`, for `transitionDuration` + 50ms) which visible tabs and the More
159
+ button the indicator (`[data-active-indicator]`) covers by at least half, and toggles
160
+ `data-under-active` on them (an attribute, not a class, so Vue's own class binding on the More
161
+ button can't wipe it). CSS under `.tracks-active` colours by that attribute rather than
162
+ `aria-selected`. The coverage window only ever extends (`coverageUntil`): a refresh landing
163
+ mid-move (e.g. the More button resizing straight after a click) used to restart it with zero
164
+ length, cancelling the per-frame checks while the highlight was still sliding and leaving stale
165
+ colours (dark selected label, invisible neighbour). A More-button resize also re-places both
166
+ indicators (`syncIndicators(true)`), since its box moves even when no tab changes sides, and an
167
+ overflow change refreshes with `refreshTabs(true)` so an in-flight move retargets instead of
168
+ snapping. This replaces the per-component timing trick `TripleToggleSwitch` uses (swap
169
+ colour at half the slide), which only works for single-step moves; a tab jump passes under
170
+ several labels.
171
+
172
+ ## Migration note (2026-10-07)
173
+
174
+ Fixed during the compliance pass that renamed it:
175
+
176
+ - Duplicate IDs across instances (above).
177
+ - `event.target` in the click handler: clicking an element inside a trigger set the active tab to
178
+ that inner element, so no panel matched and every panel was hidden.
179
+ - `axis`, `transitionDuration` and the `track*` flags were read once at setup, and a changed
180
+ `itemCount` was never picked up (new tabs had no roving tabindex and weren't navigable).
181
+ - Dark-on-dark panel content by default, near-invisible focus ring, `styleClassPassthrough` on
182
+ the wrong element, unprefixed inner class names (pitfall #16).
183
+ - A long label or many tabs pushed the page sideways.
184
+
185
+ ## Migration note (2026-09-15)
186
+
187
+ Previously lived at `tabs/TabsCore.vue` with options-style `defineProps`, no tier folder, and
188
+ several defects fixed in this pass:
189
+
190
+ - `querySelectorAll("[data-nav-item")` was missing its closing `]` — an invalid selector that
191
+ some browsers tolerate but that throws in strict DOM implementations (including this repo's test
192
+ environment). Fixed to `"[data-nav-item]"`.
193
+ - `aria-labelledby="channel-name"` was hardcoded on the tablist, pointing at an id that doesn't
194
+ exist anywhere in this library. Replaced with the new `ariaLabel` prop.
195
+ - `border-bottom`/`border-left` on the tab list referenced `--_tabs-border-bottom`, a private CSS
196
+ variable that was never set anywhere (dead reference — the border never rendered). Promoted to
197
+ the public `--tabs-nav-border` token with a real default.
198
+ - `tag`, `trackHover`, `trackActive`, and `trackIndicator` were all declared props with no effect
199
+ anywhere in the component or its composable (`useTabs`). `tag` had no coherent purpose (triggers
200
+ were always hardcoded `<button>`s) and was removed; the three `track*` flags were wired up to
201
+ actually gate their respective decorator elements, since removing them would have been a bigger
202
+ behavioural change than making them work as their names already implied.
203
+ - Almost every colour/spacing/typography value in the `<style>` block was hardcoded with no
204
+ override hook at all (not even a private token) — promoted to public `--tabs-*` tokens.
205
+
206
+ ## Performance notes (2026-09-15)
207
+
208
+ `useTabs` had a few real perf issues, fixed alongside the migration:
209
+
210
+ - **Layout thrashing**: `moveActiveIndicator`/`moveHoveredIndicator`/`setFinalActivePositions`/
211
+ `setFinalHoveredPositions` used to interleave `offsetLeft`/`offsetWidth`/etc. reads with
212
+ `style.setProperty` writes, forcing a synchronous reflow between each pair. All layout reads are
213
+ now batched into local variables before any writes happen.
214
+ - **`@mouseover` → `@mouseenter`**: the hover trigger listener used `mouseover`, which bubbles and
215
+ re-fires as the pointer crosses any child element inside the button. `mouseenter` fires exactly
216
+ once per tab entered.
217
+ - **Debounced settle timers**: `moveActiveIndicator`/`moveHoveredIndicator` schedule a `setTimeout`
218
+ to snap the indicator to its exact final position once the CSS transition ends. Previously each
219
+ call scheduled a new timeout without clearing the last one, so moving the pointer quickly across
220
+ several tabs stacked up redundant pending timeouts. Now the previous timeout is cleared before
221
+ scheduling the next, and both are cleared in `onUnmounted`.
222
+ - `handleTransitioningClass` called `.indexOf()` on the same two tabs repeatedly inside its loop
223
+ condition and body — now computed once per call.
224
+ - `previousActiveTab` used `useState("previousActiveTab", ...)` with a fixed, unscoped key — every
225
+ `TabsCore` instance on a page would have shared that one piece of state. Not actually a
226
+ performance issue, but a correctness bug found while auditing this file; fixed by switching to a
227
+ plain per-instance `ref`, since this state doesn't need SSR/hydration sharing.
228
+
229
+ ## Fixed: hover/active label text could become unreadable against the indicator (2026-09-15)
230
+
231
+ `--tabs-hover-indicator-text-colour` and `--tabs-active-indicator-text-colour` were set as `color`
232
+ on `.nav__hovered`/`.nav__active` — decorative, empty, absolutely-positioned divs with no text
233
+ content, so that `color` was dead CSS with no visible effect. The actual tab label
234
+ (`.tabs-list-item`) used one shared `--tabs-list-item-colour-selected` token for hover, active,
235
+ *and* transitioning alike, regardless of which of the two (potentially differently-coloured)
236
+ indicator backgrounds was actually behind it at that moment. A consumer overriding only the hover
237
+ indicator's background (or the active one) had no working lever to keep the label readable against
238
+ it, and could end up with text the same colour as the background sliding behind it — this was the
239
+ original defect noticed during this component's initial authoring, reported as "the button text
240
+ became the same colour as the track item background and became invisible."
241
+
242
+ Fixed by removing the dead `color` declarations from the two decorator divs and splitting
243
+ `.tabs-list-item`'s combined `:hover, [aria-selected="true"], .transitioning` rule so hover uses
244
+ `--tabs-hover-indicator-text-colour` and active uses `--tabs-list-item-colour-selected` falling
245
+ back to `--tabs-active-indicator-text-colour` — each state's text colour now tracks the background
246
+ actually behind it. See `CONSUMER-STYLING.md`'s "Tab-label text colour tracks the indicator behind
247
+ it" section: overriding an indicator background without its matching text-colour token can still
248
+ produce poor contrast (same tradeoff as any two independently-set colour tokens) — the fix makes
249
+ the tokens functional, it doesn't auto-compute contrast.
250
+
251
+ ## Fixed: intermediate tab labels could go invisible during a multi-tab jump (2026-09-15)
252
+
253
+ The first fix above wasn't the whole story. The exact repro that surfaced the rest of it: with tab
254
+ 1 active, hover then click tab 5 — the tabs in between briefly lost all visible text. Going the
255
+ other direction (tab 5 active, hover then click tab 1) looked fine.
256
+
257
+ Cause: `useTabs` had a `handleTransitioningClass` helper that added a `.transitioning` class to
258
+ every tab spanned by a jump (previously shared by the `[aria-selected="true"]` colour rule fixed
259
+ above), on the assumption the sliding indicator visually covers every spanned tab for the whole
260
+ transition — so it force-applied the active text colour to all of them, for the full
261
+ `transitionDuration`. It doesn't cover them the whole time: the indicator's "grow from anchor" CSS
262
+ transition (see `moveActiveIndicator`/`moveHoveredIndicator`) keeps the box's leading edge pinned
263
+ at the *old* tab's position on a forward move and only grows its width, so it only gradually sweeps
264
+ across later tabs — a tab several positions away isn't actually covered until *late* in the
265
+ transition. Recolouring it to the active text colour from the very start meant its label matched
266
+ neither its own background nor the not-yet-arrived indicator, going invisible. A backward move
267
+ animates position and width toward their final values together, so the box's rendered coverage is
268
+ more consistent throughout — which is why that direction looked fine and made the bug easy to miss
269
+ without a specific repro.
270
+
271
+ Fixed by removing `handleTransitioningClass` and the `.transitioning` class entirely — it had no
272
+ other purpose. Tabs spanned by a jump now keep their normal (inactive) styling throughout; only the
273
+ newly-active tab (`[aria-selected="true"]`) and the currently-hovered tab (`:hover`) get their
274
+ special colours. See `CONSUMER-STYLING.md`'s "Tabs spanned by a jump keep their normal styling".
275
+
276
+ ## Fixed: passed-over tab text still invisible under the active indicator specifically (2026-09-15)
277
+
278
+ Removing `.transitioning` (previous fix) uncovered a second, pre-existing defect that
279
+ `.transitioning`'s forced recolour had been accidentally masking the whole time: a non-active tab's
280
+ normal text colour (`--tabs-list-item-colour`) was **exactly the same value** as the active
281
+ indicator's background (`--tabs-active-indicator-colour`, same default) — this collision existed in
282
+ the very first authored version of this component, not something introduced by any migration
283
+ change. So whenever the active pill visually passes under (or briefly sits under) any tab that
284
+ isn't itself marked active — which happens on every transition, single-step or distant, in either
285
+ direction — that tab's text became literally the same colour as the background sliding beneath it.
286
+ `--tabs-hover-indicator-colour`'s default never had this problem since it doesn't coincide with the
287
+ inactive text default.
288
+
289
+ Fixed by changing `--tabs-active-indicator-colour`'s default to one step in from the extreme,
290
+ still clearly the boldest of the three indicator colours, but no longer numerically identical to
291
+ `--tabs-list-item-colour`'s default. `--tabs-active-indicator-text-colour`/
292
+ `--tabs-list-item-colour-selected` (used by the truly active tab) still contrast fine against the
293
+ new value. See `CONSUMER-STYLING.md` for the note on keeping these two tokens from colliding again
294
+ if you override either one.
295
+
296
+ ## Colour token defaults (2026-10-07)
297
+
298
+ Colour defaults were flat `--slate-*` steps from 2026-09-15 (when `light-dark()` pairs were
299
+ dropped) until 2026-10-07, when they moved to the `--theme-*` slots. Light-only, like the rest of
300
+ the library.
@@ -64,26 +64,27 @@ onMounted(() => {
64
64
  >{{ section.label }}</a>
65
65
  ```
66
66
 
67
- ### Active state in TabNavigation
67
+ ### Active state in ResponsiveHeader
68
68
 
69
- `TabNavigation` handles `activeHash` internally for hash nav items. No extra work needed —
70
- just pass anchor hrefs in `navItemData` and the active indicator moves automatically on click
71
- and on initial load (defaulting to the first hash item when the URL has no hash).
69
+ `ResponsiveHeader` handles `activeHash` internally for `#` nav items. No extra work needed —
70
+ just pass anchor paths in `responsiveNavLinks` and the active indicator moves automatically on
71
+ click and on initial load (defaulting to the first anchor item when the URL has no hash).
72
+ (`TabNavigation` did the same until it was removed on 2026-10-07.)
72
73
 
73
74
  ---
74
75
 
75
76
  ## Usage patterns
76
77
 
77
- ### 1. TabNavigation with a sticky site header
78
+ ### 1. ResponsiveHeader with a sticky site header
78
79
 
79
- `TabNavigation` accepts an `anchorScrollOffset` prop that is passed directly to `useAnchorScroll`.
80
+ `ResponsiveHeader` accepts an `anchorScrollOffset` prop that is passed directly to `useAnchorScroll`.
80
81
  Pass a getter so the live header height is read at scroll time — this stays correct if the
81
82
  header resizes on different viewports.
82
83
 
83
84
  ```vue
84
85
  <header ref="headerRef">
85
- <TabNavigation
86
- :nav-item-data="navItemData"
86
+ <ResponsiveHeader
87
+ :responsive-nav-links="responsiveNavLinks"
87
88
  :anchor-scroll-offset="() => headerRef?.offsetHeight ?? 0"
88
89
  />
89
90
  </header>
@@ -92,18 +93,18 @@ header resizes on different viewports.
92
93
  ```ts
93
94
  const headerRef = ref<HTMLElement | null>(null);
94
95
 
95
- const navItemData = {
96
+ const responsiveNavLinks = {
96
97
  main: [
97
- { text: "About", href: "#about" },
98
- { text: "Services", href: "#services" },
99
- { text: "Contact", href: "#contact" },
100
- { text: "Blog", href: "/blog" }, // route — NuxtLink handles as normal
98
+ { name: "About", path: "#about" },
99
+ { name: "Services", path: "#services" },
100
+ { name: "Contact", path: "#contact" },
101
+ { name: "Blog", path: "/blog" }, // route — NuxtLink handles as normal
101
102
  ],
102
103
  };
103
104
  ```
104
105
 
105
106
  **CSS alternative** — if you want all anchor links site-wide to respect the sticky header
106
- (not just the ones inside `TabNavigation`), add `scroll-padding-top` to `html` instead and
107
+ (not just the ones inside `ResponsiveHeader`), add `scroll-padding-top` to `html` instead and
107
108
  skip the prop:
108
109
 
109
110
  ```css
@@ -290,4 +291,4 @@ takes effect immediately on the next click.
290
291
  - **SSR** — both `handleNavClick` and `scrollToAnchor` guard with `import.meta.server` and return
291
292
  early. No special SSR setup is needed.
292
293
  - **Multiple instances** — each call to `useAnchorScroll` is independent. You can run a sticky
293
- section nav alongside a `TabNavigation` on the same page with different offsets.
294
+ section nav alongside a `ResponsiveHeader` on the same page with different offsets.
@@ -32,7 +32,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
32
32
  ├── theming-override-default.md — replace the entire default theme with a custom palette (set --theme-hue/--theme-chroma)
33
33
  ├── theming-partial-override.md — override a specific colour token category (palette, buttons, inputs) without a full theme replacement
34
34
  ├── theming-form-geometry-tokens.md — non-colour form/button/input tokens (padding, border-radius, gaps): full inventory, why partial override doesn't need duplicating the source files
35
- ├── theming-component-token-pattern.md — standard shape for overridable component colours: public token + inline fallback, no bare var()/no private-only indirection; the three tiers (theme slots / cross-component families / component-scoped); line-clamp tokens for overflowing text; rollout status
35
+ ├── theming-component-token-pattern.md — standard shape for overridable component colours: public token + inline fallback, no bare var()/no private-only indirection; the three tiers (theme slots / cross-component families / component-scoped); line-clamp tokens for overflowing text; image-overlay scrim tokens; rollout status
36
+ ├── theming-status-tokens.md — global --status-* colours (success/warning/danger/info/neutral: solid + surface + text), which components read them, how to override once for the whole library
36
37
  ├── theming-dark-mode.md — add a dark scheme in a consumer app (layer is light-only): enable plumbing, full dark token set without light-dark(), light-dark() alternative, light-only pinning
37
38
  ├── colour-scheme-disable.md — disable light/dark scheme support in a consumer app
38
39
  ├── component-dynamic-slots.md — named dynamic slots ($slots iteration) vs indexed dynamic slots (itemCount pattern)
@@ -62,7 +63,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
62
63
  ├── composable-zod-validation.md — useZodValidation: schema-driven form validation, error binding, submit flow, API error push
63
64
  ├── composable-colour-scheme.md — useColourScheme: reactive light/dark/auto switching, localStorage persistence, runtime config
64
65
  ├── composable-dialog-controls.md — useDialogControls: single-call setup with config object, openDialog/closeDialog API, confirm/cancel callbacks
65
- ├── composable-anchor-scroll.md — useAnchorScroll: smooth anchor scrolling with reduced-motion support, dynamic offset, and TabNavigation integration
66
+ ├── composable-anchor-scroll.md — useAnchorScroll: smooth anchor scrolling with reduced-motion support, dynamic offset, and ResponsiveHeader integration
66
67
  ├── composable-tooltips-guide.md — useTooltipsGuide: sequential popover guide with auto-start, dismiss-to-advance, manual controls
67
68
  ├── composable-cookie-consent.md — useCookieConsent: unset/granted/denied state, cookie persistence, wraps @nuxt/scripts' useScriptTriggerConsent
68
69
  ├── composable-analytics.md — useAnalytics: provider-agnostic trackEvent/page-view tracking (google-analytics only implemented), consent-gated, single call site for setup + firing events
@@ -90,7 +91,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
90
91
  ├── service-detail.md — ServiceDetail: full service detail page (hero banner, sticky sidebar booking card + related services, closing CTA), headerTag vs subheadingTag split, book-cta/sidebar-note/related-service/final-cta slots
91
92
  ├── breadcrumb.md — Breadcrumb: items (BreadcrumbItem[]) trail, link vs current-page text, CSS token API
92
93
  ├── skip-links.md — SkipLinks: focus-revealed accessibility skip-nav, links (SkipLink[]) data-driven, homeLink slot, CSS token API
93
- ├── tabs-core.md — TabsCore: tablist from indexed slots (itemCount), arrow-key nav, moving hover/active/underline indicators, CSS token API
94
+ ├── tabbed-content.md — TabbedContent (was TabsCore): tablist + panels from indexed slots (itemCount), arrow-key nav, moving hover/active/underline indicators, CSS token API
94
95
  ├── contact-section.md — ContactSection props (stepperIndicatorSize pass-through), 3-item info+form layout, slot API
95
96
  ├── stepper-list.md — StepperList dynamic slots (item-{n}/indicator-{n}), props, connector behaviour
96
97
  ├── expanding-panel.md — ExpandingPanel v-model, forceOpened, contentIsOnTop overlay mode, slots (summary/icon/content), ARIA wiring, CSS token API
@@ -107,20 +108,20 @@ Each skill is a single markdown file named `<area>-<task>.md`.
107
108
  ├── wipe-away-vertical.md — WipeAwayVertical: scroll-driven vertical wipe-away effect, pure-CSS grid overlay (no JS), indexed dynamic slots, sticky-centering-via-calc gotcha, CSS tokens
108
109
  ├── marquee-scroller.md — MarqueeScroller: infinite logo/badge scroller, per-item dynamic slots (marqueeData id), hover/focus/keyboard pause, reduced-motion, CSS tokens
109
110
  ├── rotating-carousel-image.md — RotatingCarouselImage: 3D rotating image carousel, scroll-parallax tilt, focus/keyboard/hover pause, reduced-motion, CSS tokens
110
- ├── tab-navigation.md — TabNavigation (DEPRECATED, use ResponsiveHeader): horizontal tab nav with CSS anchor-positioning indicators, anchor scroll, burger collapse, full CSS token API
111
- ├── social-icons-list.md — SocialIconsList: data-driven social icon links, ISocialIcon type, logos: icon names, CSS tokens
111
+ ├── social-icons-list.md — SocialIconsList: data-driven social icon links, ISocialIcon type, logos: icon names (dark ones vanish on dark backgrounds; mono alternatives), CSS tokens incl. --social-icons-list-icon-colour
112
112
  ├── display-qr-code.md — DisplayQrCode: QR code SVG from a string value, colour/size/variant/radius props, currentColor default
113
113
  ├── capture-qr-code.md — CaptureQrCode: live camera scanner, error state, visibility/route/KeepAlive lifecycle, media stream cleanup
114
114
  ├── decode-qr-code.md — DecodeQrCode: file picker + drag-and-drop image decoder, shared results list, CSS override points
115
115
  ├── 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
116
- ├── display-avatar.md — DisplayAvatar (02.molecules): circular avatar with image/initials fallback (alt announced), size scale tokens, --display-avatar-* colours, chip badge, icon slot
116
+ ├── 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
117
+ ├── 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
117
118
  ├── display-theme-switch.md — DisplayThemeSwitch: system/light/dark picker wrapping TripleToggleSwitch, wired to useSettingsStore, labels/icons props, small sizing variant
118
119
  ├── card-core.md — CardCore: generic card container, dynamic named slots as rows, 4 variants, blurred backdrop layer, full CSS token API
119
120
  ├── action-menu.md — ActionMenu + ActionMenuItem: ellipsis trigger + anchored popover menu, indexed item-{n} slots, link/button items, full CSS token API
120
121
  ├── select-menu.md — SelectMenu: v-model single-select listbox popover (ActionMenu's popover mechanics, InputSelectCore's selected-option semantics), icon/text/chevron trigger toggles, checkmark on selected option, options-array driven, full CSS token API
121
122
  ├── display-dialog.md — DisplayDialog: native <dialog> overlay, 5 variants (dialog/modal/confirm/alert/fullscreen), useDialogControls integration, CSS token API
122
123
  ├── display-chip.md — DisplayChip: status indicator chip overlay, CSS trig positioning, circle/square shapes, status colours, icon/label content
123
- ├── display-pill.md — DisplayPill: pill/badge label with icon slot, 6 variants, 3 sizes, reversible order, --display-pill-* tokens (renamed from --theme-pill-*; variants fall back to base colour tokens)
124
+ ├── display-pill.md — DisplayPill: pill/badge label with icon slot, 6 variants, 3 sizes, reversible order, --display-pill-* tokens (variants fall back to base colour/border/ring tokens); data-size/data-variant/data-reversed hooks (2026-10-08, were bare classes), label line-clamp token, -02 ring and outlined-monochrome recipes, StressTest story
124
125
  ├── carousel-flip.md — CarouselFlip: FLIP-animated carousel, carouselDataIds slot API, buttonLayout variants (sides/controls-flanking/controls-grouped-right/overlay), CSS tokens
125
126
  ├── samaritan-prompt.md — SamaritanPrompt + SamaritanPromptMixed: animated text prompts, typewriter/word-pulse effects, MessageConfig API, pause on hover, aria-live, CSS tokens
126
127
  ├── display-toast.md — DisplayToast (standalone v-model) + DisplayToastProvider + useToastQueue (app-wide queue): stacking, FLIP dismiss, maxVisible, SemanticTheme × 4, masked SVG glass variant
@@ -268,6 +268,8 @@ export const Default: Story = {
268
268
  Key points:
269
269
 
270
270
  - `computed` is **not** auto-imported in `.ts` story files — import it explicitly from `"vue"`.
271
+ - Components aren't auto-registered in a story's `template` string either. Every component used there, including ones you put in the component's slots (`EyebrowText`, `HeroText`, `SocialIconsList`, ...), must be imported and listed in `components: {}`. Otherwise Vue renders it as an empty unknown element (`<eyebrowtext>`) with no error. ProfileSection's header and social-icons stories rendered blank this way until 2026-10-14.
272
+ - Props in a story's `template` string aren't type-checked either. An invalid value renders silently with no warning: ProfileSection's stories passed `font-size="large"` to `HeroText`, which only has `display`/`title`/`heading`/`subheading`/`label`, so the heading fell back to unstyled text. Check every literal prop value against the component's `interface Props`.
271
273
  - Extra args must be stripped before `v-bind` — spreading unknown keys onto a component makes them unknown HTML attributes.
272
274
  - CSS custom properties set via `:style` on the component root are picked up by `var()` in the component's scoped CSS.
273
275
 
@@ -285,6 +287,59 @@ prop and try to pass it. Make it unmistakable:
285
287
 
286
288
  `PriceList.stories.ts` and `GoogleReviews.stories.ts` (line-clamp controls) follow this.
287
289
 
290
+ ## Link to the stories of components that use this one
291
+
292
+ When the component you're working on is a building block of other components (it's rendered
293
+ inside them, or they're a specialised version of it), its stories only show it in isolation. Add a
294
+ short note to each story that names the components using it, says what they add, and links to their
295
+ stories, so a reader can see it in real use. Examples: `AlertContent` is the panel inside
296
+ `DisplayToast`, `DisplayPrompt` and `CookieConsentBanner`; `AlertContentInner` is shared by
297
+ `AlertContent` and `AlertMaskedContent`.
298
+
299
+ - **Say how they're related, not just "see also".** Name the parent and what it adds, e.g. "The
300
+ #actions slot wired to real accept and reject handlers: see the CookieConsentBanner story."
301
+ - **Link to the story that shows the relevant feature**, not just any story. A story about a slot
302
+ or behaviour links to the parent story that exercises it. Other stories can share one general
303
+ note.
304
+ - **Check the parent story actually shows what the note says.** Read its component before writing
305
+ the note. `DisplayToast` doesn't fill `AlertContent`'s `#actions` slot, so a toast note that
306
+ promised action buttons would send people to the wrong place.
307
+ - **Put the note in the canvas**, as a `<p>` above the component, so it's visible on the Canvas tab
308
+ as well as Docs. If the story has a `parameters.docs.description`, add the link there too.
309
+ - Find the parent's story id from its `title` and export name: `"Molecules/CookieConsentBanner"` +
310
+ `Default` gives `molecules-cookieconsentbanner--default` (lowercase, `/` and spaces become `-`,
311
+ then `--` and the kebab-cased export name).
312
+
313
+ ```ts
314
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
315
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
316
+ const cookieNote = `
317
+ <p style="${storyNoteStyle}">
318
+ For the #actions slot wired up to real accept and reject handlers, open the
319
+ <a href="/?path=/story/molecules-cookieconsentbanner--default" target="_top">CookieConsentBanner</a> story.
320
+ </p>
321
+ `;
322
+
323
+ // In the story template:
324
+ template: `
325
+ <div style="max-width: 600px; padding: 2rem;">
326
+ ${cookieNote}
327
+ <AlertContent v-bind="componentArgs">...</AlertContent>
328
+ </div>
329
+ `,
330
+
331
+ // In parameters.docs.description (markdown): use "?path=..." with no leading slash.
332
+ story: "See the [CookieConsentBanner](?path=/story/molecules-cookieconsentbanner--default) story. ...",
333
+ ```
334
+
335
+ - Canvas links need `target="_top"` and a leading `/` (`/?path=...`). Without `target="_top"` the
336
+ whole Storybook UI loads inside the preview frame. The leading `/` assumes Storybook is served
337
+ from the root of its domain, which holds locally and on the deployed Storybook.
338
+ - Define each note once as a constant at the top of the file and interpolate it, rather than
339
+ repeating the markup in every story.
340
+
341
+ `AlertContent.stories.ts` follows this.
342
+
288
343
  ## Notes
289
344
 
290
345
  - **Colour scheme:** `.storybook/preview.ts` pins every story to the light scheme
@@ -83,6 +83,54 @@ Prefer this over a prop: line count is presentation, and CSS handles it without
83
83
  layout around it.
84
84
  - Document it in `CONSUMER-STYLING.md` with its default and the `1`/`none` options.
85
85
 
86
+ ## Text over an image: scrim tokens
87
+
88
+ Any component that puts text over a photo (a header in a picture, a card title on its image) gets
89
+ the same scrim token set, so a consumer can match the scrim to their photos. `ProfileSection`'s
90
+ `header-placement="picture-top|picture-bottom"` is the reference; `ServicesCard`'s
91
+ `titles-within-image-wrapper` has the older subset (colour and whole-background override only) and
92
+ should gain the rest when it's next touched.
93
+
94
+ | Token | Default | Purpose |
95
+ |---|---|---|
96
+ | `--{c}-scrim-colour` | `#000` | Colour the scrim starts from at the image's edge |
97
+ | `--{c}-scrim-opacity` | `75%` | Its opacity at the start |
98
+ | `--{c}-scrim-solid-stop` | `0%` | How far it stays full strength before fading, so the text sits on a clean background |
99
+ | `--{c}-scrim-target-colour` | `transparent` | What it fades into: set to roughly the photo's colour where the fade ends, so there's no grey band |
100
+ | `--{c}-scrim` | the gradient | Whole `background` override; `none` removes it |
101
+ | `--{c}-text-colour` | `contrast-color(<scrim colour>)`, `#fff` without support | Text on the scrim |
102
+
103
+ ```css
104
+ .component-header {
105
+ --_scrim-colour: var(--component-scrim-colour, #000);
106
+ --_scrim-start-colour: color-mix(in oklab, var(--_scrim-colour) var(--component-scrim-opacity, 75%), transparent);
107
+ --_scrim-solid-stop: var(--component-scrim-solid-stop, 0%);
108
+ --_scrim-target-colour: var(--component-scrim-target-colour, transparent);
109
+
110
+ color: var(--component-text-colour, #fff);
111
+ background: var(--component-scrim, linear-gradient(to top, rgb(0 0 0 / 0.75), rgb(0 0 0 / 0.75) var(--_scrim-solid-stop), var(--_scrim-target-colour)));
112
+
113
+ @supports (color: color-mix(in oklab, red, transparent)) {
114
+ background: var(--component-scrim, linear-gradient(to top, var(--_scrim-start-colour), var(--_scrim-start-colour) var(--_scrim-solid-stop), var(--_scrim-target-colour)));
115
+ }
116
+
117
+ @supports (color: contrast-color(red)) {
118
+ color: var(--component-text-colour, contrast-color(var(--_scrim-colour)));
119
+ }
120
+ }
121
+ ```
122
+
123
+ - The `@supports` fallback is for Safari before 16.2 (no `color-mix()`): it starts from 75% black
124
+ whatever the scrim colour, but keeps the solid stop and target colour.
125
+ - `contrast-color()` reads the scrim colour, not the photo. Document that a consumer who removes the
126
+ scrim must set the text colour.
127
+ - Round only the corners on the image's edge (`border-end-*-radius: inherit` for a bottom scrim),
128
+ or an opaque target colour shows rounded inner corners.
129
+ - If the text can be long, don't clip it: let the frame grow (absolutely position the image and
130
+ keep `overflow` visible so `aspect-ratio` acts as a minimum).
131
+ - Give the story a light and a dark theme built only from these tokens, each with a photo whose
132
+ edge suits the scrim.
133
+
86
134
  ## Multi-level fallback chains are fine
87
135
 
88
136
  A component that wants to *look like* another component's default (rather than duplicating a
@@ -0,0 +1,60 @@
1
+ # Status tokens
2
+
3
+ Global status colours, declared on `:root` in `app/assets/styles/setup/03.theming/_status.css`.
4
+ Components that show a status (a dot, a badge, a pill) fall back to these, so a consumer restyles
5
+ every status indicator in the library from one place.
6
+
7
+ ## The set
8
+
9
+ Each status has three roles:
10
+
11
+ | Role | Token | Use |
12
+ |---|---|---|
13
+ | Solid | `--status-<name>` | Dots, markers, small fills (mid-ramp step, visible on light and dark) |
14
+ | Surface | `--status-<name>-surface` | Tinted background of a badge or pill |
15
+ | Text | `--status-<name>-text` | Text/icon on that surface |
16
+
17
+ | Status | Solid | Surface / text | Meaning |
18
+ |---|---|---|---|
19
+ | `success` | `--green-06` | `--green-01` / `--green-10` | Good, active, online, complete |
20
+ | `warning` | `--orange-05` | `--orange-01` / `--orange-10` | Needs attention, pending, idle |
21
+ | `danger` | `--red-06` | `--red-01` / `--red-10` | Error, blocked, do not disturb |
22
+ | `info` | `--blue-06` | `--blue-01` / `--blue-09` | Informational, in progress, new |
23
+ | `neutral` | `--slate-05` | `--slate-01` / `--slate-09` | Inactive, offline, archived, draft |
24
+
25
+ Keep the set to these five. A domain-specific state (`pending`, `archived`, `in-progress`) maps
26
+ onto one of them rather than getting its own token; if a consumer genuinely needs a sixth colour,
27
+ they define it in their app and pass it wherever the component takes a colour (e.g. `dotColor`).
28
+
29
+ ## Who reads them
30
+
31
+ The component's own token always wins, then the global status token:
32
+
33
+ | Component | Mapping |
34
+ |---|---|
35
+ | `DisplayChip` | `online` → success, `idle` → warning, `dnd` → danger, no class → neutral (solid) |
36
+ | `DisplayPill` | `success`/`warning`/`danger` variants → surface + text. `primary` (brand) and `neutral` (dark, inverted) do **not** read the set |
37
+ | `SelectMenu` | Nothing automatic; options pass `dotColor: "var(--status-success)"` etc. |
38
+
39
+ When adding a component that shows a status, default its colour tokens to these
40
+ (`var(--component-colour-online, var(--status-success))`), never to a palette step or hex value
41
+ directly.
42
+
43
+ ## Overriding
44
+
45
+ ```css
46
+ /* consuming app, unlayered */
47
+ :root {
48
+ --status-success: #1f9d55;
49
+ --status-success-surface: #e6f6ec;
50
+ --status-success-text: #0f5132;
51
+ }
52
+ ```
53
+
54
+ One component only: set its own token instead (`--display-chip-colour-online: hotpink`).
55
+
56
+ ## Dark mode
57
+
58
+ Light values only, like every other layer token (no `light-dark()`). Storybook's dark values live
59
+ in `.storybook/color-scheme.css`: surfaces move to step `09`, text to step `02`; solids stay put.
60
+ A consumer app with a dark scheme copies those overrides (see `theming-dark-mode.md`).
@@ -6,6 +6,13 @@
6
6
  "<DisplayAvatar src=\"${1:/images/profile.jpg}\" alt=\"${2:Jane Smith}\" size=\"${3|md,xs,s,lg,xl|}\" />"
7
7
  ]
8
8
  },
9
+ "SRCDEV DisplayAvatar Large Image": {
10
+ "description": "DisplayAvatar: image avatar resized beyond the scale, with a matching image-size so it stays sharp",
11
+ "scope": "vue,html",
12
+ "body": [
13
+ "<DisplayAvatar src=\"${1:/images/profile.jpg}\" alt=\"${2:Jane Smith}\" :image-size=\"${3:240}\" style=\"--display-avatar-size: ${4:12rem}\" />"
14
+ ]
15
+ },
9
16
  "SRCDEV DisplayAvatar Initials": {
10
17
  "description": "DisplayAvatar: initials derived from alt (alt is announced, initials hidden from screen readers)",
11
18
  "scope": "vue,html",
@@ -21,7 +28,8 @@
21
28
  " src=\"${1:/images/profile.jpg}\"",
22
29
  " alt=\"${2:Jane Smith}\"",
23
30
  " :chip=\"{ size: '${3:12px}', maskWidth: '${4:4px}', offset: '${5:0px}', angle: '${6:45deg}' }\"",
24
- " :style-class-passthrough=\"['${7|online,offline|}']\"",
31
+ " status=\"${7|online,idle,dnd,offline|}\"",
32
+ " status-label=\"${8:Online}\"",
25
33
  "/>"
26
34
  ]
27
35
  }