@arbor-education/design-system.components 0.13.0 → 0.13.1

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 (142) hide show
  1. package/.agent-memory/blanche-designspert/MEMORY.md +189 -0
  2. package/.agent-memory/dorothy-fact-checker/MEMORY.md +228 -0
  3. package/.agent-memory/dorothy-fact-checker/numberinput_component.md +53 -0
  4. package/.agent-memory/dorothy-fact-checker/progress_component.md +36 -0
  5. package/.agent-memory/rose-storybookspert/MEMORY.md +105 -0
  6. package/.agent-memory/sophia-componentspert/MEMORY.md +34 -0
  7. package/{.claude/agent-memory → .agent-memory}/sophia-componentspert/components.md +170 -17
  8. package/{.claude → .gather}/agents/blanche-designspert.md +7 -2
  9. package/{.claude → .gather}/agents/dorothy-fact-checker.md +7 -2
  10. package/{.claude → .gather}/agents/rose-storybookspert.md +80 -11
  11. package/{.claude → .gather}/agents/sophia-componentspert.md +9 -4
  12. package/.gather/gather.yaml +9 -0
  13. package/{CLAUDE.md → .gather/instructions/project-overview.md} +42 -9
  14. package/{.claude → .gather}/skills/analyze-design/README.md +5 -0
  15. package/{.claude → .gather}/skills/analyze-design/SKILL.md +1 -1
  16. package/.gather/skills/analyze-design/meta.md +4 -0
  17. package/{.claude → .gather}/skills/create-page/README.md +5 -0
  18. package/{.claude → .gather}/skills/create-page/design-analysis-template.md +5 -0
  19. package/.gather/skills/create-page/meta.md +4 -0
  20. package/{.claude → .gather}/skills/create-page/page-template.scss +5 -0
  21. package/{.claude → .gather}/skills/create-page/page-template.tsx +5 -0
  22. package/{.claude → .gather}/skills/map-legacy/README.md +5 -0
  23. package/.gather/skills/map-legacy/meta.md +4 -0
  24. package/{.claude → .gather}/skills/migrate-page/README.md +5 -0
  25. package/.gather/skills/migrate-page/meta.md +4 -0
  26. package/.gather/skills/write-stories/README.md +157 -0
  27. package/.gather/skills/write-stories/SKILL.md +841 -0
  28. package/.gather/skills/write-stories/meta.md +4 -0
  29. package/.ralph/storybook-upgrade/knowledge.md +308 -0
  30. package/.ralph/storybook-upgrade/prd.json +777 -0
  31. package/.ralph/storybook-upgrade/progress.md +342 -0
  32. package/.storybook/DocsTemplate.tsx +122 -0
  33. package/.storybook/preview.ts +40 -0
  34. package/.stylelintignore +2 -0
  35. package/CHANGELOG.md +6 -0
  36. package/{.claude/component-library.md → component-library.md} +27 -10
  37. package/dist/components/badge/Badge.stories.d.ts +85 -6
  38. package/dist/components/badge/Badge.stories.d.ts.map +1 -1
  39. package/dist/components/badge/Badge.stories.js +626 -27
  40. package/dist/components/badge/Badge.stories.js.map +1 -1
  41. package/dist/components/banner/Banner.stories.d.ts +129 -63
  42. package/dist/components/banner/Banner.stories.d.ts.map +1 -1
  43. package/dist/components/banner/Banner.stories.js +855 -39
  44. package/dist/components/banner/Banner.stories.js.map +1 -1
  45. package/dist/components/button/Button.stories.d.ts +148 -8
  46. package/dist/components/button/Button.stories.d.ts.map +1 -1
  47. package/dist/components/button/Button.stories.js +1089 -80
  48. package/dist/components/button/Button.stories.js.map +1 -1
  49. package/dist/components/dot/Dot.stories.d.ts +46 -11
  50. package/dist/components/dot/Dot.stories.d.ts.map +1 -1
  51. package/dist/components/dot/Dot.stories.js +504 -15
  52. package/dist/components/dot/Dot.stories.js.map +1 -1
  53. package/dist/components/dropdown/Dropdown.stories.d.ts +89 -14
  54. package/dist/components/dropdown/Dropdown.stories.d.ts.map +1 -1
  55. package/dist/components/dropdown/Dropdown.stories.js +769 -17
  56. package/dist/components/dropdown/Dropdown.stories.js.map +1 -1
  57. package/dist/components/formField/FormField.stories.d.ts +95 -35
  58. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  59. package/dist/components/formField/FormField.stories.js +1174 -69
  60. package/dist/components/formField/FormField.stories.js.map +1 -1
  61. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.d.ts +96 -9
  62. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.d.ts.map +1 -1
  63. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.js +717 -10
  64. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.js.map +1 -1
  65. package/dist/components/formField/inputs/number/NumberInput.stories.d.ts +149 -11
  66. package/dist/components/formField/inputs/number/NumberInput.stories.d.ts.map +1 -1
  67. package/dist/components/formField/inputs/number/NumberInput.stories.js +624 -10
  68. package/dist/components/formField/inputs/number/NumberInput.stories.js.map +1 -1
  69. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.d.ts +74 -1
  70. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.d.ts.map +1 -1
  71. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.js +673 -44
  72. package/dist/components/formField/inputs/radio/RadioButtonInput.stories.js.map +1 -1
  73. package/dist/components/formField/inputs/text/TextInput.stories.d.ts +119 -1
  74. package/dist/components/formField/inputs/text/TextInput.stories.d.ts.map +1 -1
  75. package/dist/components/formField/inputs/text/TextInput.stories.js +549 -10
  76. package/dist/components/formField/inputs/text/TextInput.stories.js.map +1 -1
  77. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts +129 -4
  78. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts.map +1 -1
  79. package/dist/components/formField/inputs/textArea/TextArea.stories.js +577 -3
  80. package/dist/components/formField/inputs/textArea/TextArea.stories.js.map +1 -1
  81. package/dist/components/heading/Heading.stories.d.ts +449 -50
  82. package/dist/components/heading/Heading.stories.d.ts.map +1 -1
  83. package/dist/components/heading/Heading.stories.js +536 -60
  84. package/dist/components/heading/Heading.stories.js.map +1 -1
  85. package/dist/components/icon/Icon.stories.d.ts +81 -10
  86. package/dist/components/icon/Icon.stories.d.ts.map +1 -1
  87. package/dist/components/icon/Icon.stories.js +979 -8
  88. package/dist/components/icon/Icon.stories.js.map +1 -1
  89. package/dist/components/pill/Pill.stories.d.ts +71 -19
  90. package/dist/components/pill/Pill.stories.d.ts.map +1 -1
  91. package/dist/components/pill/Pill.stories.js +573 -14
  92. package/dist/components/pill/Pill.stories.js.map +1 -1
  93. package/dist/components/progress/Progress.stories.d.ts +75 -298
  94. package/dist/components/progress/Progress.stories.d.ts.map +1 -1
  95. package/dist/components/progress/Progress.stories.js +449 -52
  96. package/dist/components/progress/Progress.stories.js.map +1 -1
  97. package/dist/components/separator/Separator.stories.d.ts +58 -5
  98. package/dist/components/separator/Separator.stories.d.ts.map +1 -1
  99. package/dist/components/separator/Separator.stories.js +443 -4
  100. package/dist/components/separator/Separator.stories.js.map +1 -1
  101. package/dist/components/tag/Tag.stories.d.ts +116 -5
  102. package/dist/components/tag/Tag.stories.d.ts.map +1 -1
  103. package/dist/components/tag/Tag.stories.js +581 -28
  104. package/dist/components/tag/Tag.stories.js.map +1 -1
  105. package/dist/index.css +8 -1
  106. package/dist/index.css.map +1 -1
  107. package/eslint.config.mts +5 -1
  108. package/package.json +3 -3
  109. package/src/components/badge/Badge.stories.tsx +869 -42
  110. package/src/components/banner/Banner.stories.tsx +1081 -63
  111. package/src/components/button/Button.stories.tsx +1394 -99
  112. package/src/components/dot/Dot.stories.tsx +723 -32
  113. package/src/components/dropdown/Dropdown.stories.tsx +1174 -35
  114. package/src/components/formField/FormField.stories.tsx +1522 -105
  115. package/src/components/formField/inputs/checkbox/CheckboxInput.stories.tsx +1020 -15
  116. package/src/components/formField/inputs/number/NumberInput.stories.tsx +908 -15
  117. package/src/components/formField/inputs/radio/RadioButtonInput.stories.tsx +932 -51
  118. package/src/components/formField/inputs/text/TextInput.stories.tsx +773 -13
  119. package/src/components/formField/inputs/textArea/TextArea.stories.tsx +756 -8
  120. package/src/components/heading/Heading.stories.tsx +752 -120
  121. package/src/components/icon/Icon.stories.tsx +1446 -12
  122. package/src/components/pill/Pill.stories.tsx +867 -21
  123. package/src/components/progress/Progress.stories.tsx +625 -58
  124. package/src/components/separator/Separator.stories.tsx +730 -8
  125. package/src/components/separator/separator.scss +12 -3
  126. package/src/components/tag/Tag.stories.tsx +755 -53
  127. package/.claude/agent-memory/blanche-designspert/MEMORY.md +0 -64
  128. package/.claude/agent-memory/dorothy-fact-checker/MEMORY.md +0 -129
  129. package/.claude/agent-memory/rose-storybookspert/MEMORY.md +0 -29
  130. package/.claude/agent-memory/sophia-componentspert/MEMORY.md +0 -14
  131. package/.claude/design-assessment-daily-attendance-2026-04-10.md +0 -566
  132. package/.claude/figma-assessment-7154-58899.md +0 -404
  133. package/.claude/figma-assessment-UKQfcxnT4rlHHNuiumt4o1-11086-97537.md +0 -392
  134. package/.claude/figma-assessment-UKQfcxnT4rlHHNuiumt4o1-551-41974.md +0 -474
  135. package/.claude/figma-assessment-UKQfcxnT4rlHHNuiumt4o1-551-43094.md +0 -462
  136. package/.claude/figma-assessment-fcFK4CGzkz2fVyY3koX8ZE-7154-59061.md +0 -440
  137. package/.claude/migration-report-custom-report-writer-2026-02-19.md +0 -591
  138. /package/{.claude/agent-memory → .agent-memory}/blanche-designspert/token-review-patterns.md +0 -0
  139. /package/{.claude/agent-memory → .agent-memory}/rose-storybookspert/patterns.md +0 -0
  140. /package/{.claude → .gather}/skills/create-page/SKILL.md +0 -0
  141. /package/{.claude → .gather}/skills/map-legacy/SKILL.md +0 -0
  142. /package/{.claude → .gather}/skills/migrate-page/SKILL.md +0 -0
@@ -1,96 +1,1114 @@
1
- import type { Meta } from '@storybook/react-vite';
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import {
4
+ Controls,
5
+ Heading as DocHeading,
6
+ Markdown,
7
+ Primary as DocPrimary,
8
+ Stories,
9
+ Subtitle,
10
+ Title,
11
+ } from '@storybook/addon-docs/blocks';
12
+ import { fn } from 'storybook/test';
2
13
  import { Banner, BANNER_LEVEL } from './Banner';
3
14
 
4
- const meta: Meta<typeof Banner> = {
5
- tags: ['autodocs'],
15
+ // Banner is Object.assign(BannerRoot, {...}) — Storybook reads the internal
16
+ // function name 'BannerRoot' for code generation. Setting displayName here
17
+ // ensures the global source transform uses the correct public export name.
18
+ (Banner as unknown as { displayName: string }).displayName = 'Banner';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Docs page content
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const DESCRIPTION_INTRO = [
25
+ 'Banner is a full-width horizontal strip placed at the top of a page or surface to deliver page-level state,',
26
+ 'system guidance, or high-risk alerts. Unlike inline notifications, a Banner addresses the entire surface',
27
+ '— think of it as the town crier of your UI.',
28
+ ].join('\n');
29
+
30
+ const USAGE_GUIDANCE = [
31
+ '### When to use',
32
+ '',
33
+ '- **Page-level state** — information that applies to everything the user sees on the current surface',
34
+ '- **System problems** — connectivity issues, failed background syncs, service degradation',
35
+ '- **High-risk situations** — data that is locked, read-only, or from a prior academic year',
36
+ '- **Essential page guidance** — critical "how this page works" context a user must not miss',
37
+ '',
38
+ '---',
39
+ '',
40
+ '### When NOT to use',
41
+ '',
42
+ '| Situation | Use instead |',
43
+ '|---|---|',
44
+ '| Inline field errors | Form validation (field-level error messages) |',
45
+ '| Success messages / snackbars | [`Toast`](?path=/docs/components-toast--docs) |',
46
+ '| More than one alert on the same surface | Consolidate, prioritise, or use a different pattern |',
47
+ '| Marketing / promotional copy | A dedicated promotional component |',
48
+ '',
49
+ '---',
50
+ '',
51
+ '### Design guidance',
52
+ '',
53
+ '**Four semantic variants:**',
54
+ '',
55
+ '- `INFO` (blue) — helpful context the user should know but does not need to act on immediately',
56
+ '- `NEUTRAL` (transparent background) — non-urgent notes that do not carry urgency or risk',
57
+ '- `WARNING` (amber) — something is risky or incomplete, but the user can still proceed',
58
+ '- `DESTRUCTIVE` (red) — a serious error or locked state **(legacy only — see critical notes below)**',
59
+ '',
60
+ '**Tone of voice:** Confident, straightforward, positive. Short sentences, plain language, active voice.',
61
+ 'State impact before action. CTAs must use active verbs ("Fix settings", "Review exclusions") — never "Click here".',
62
+ ].join('\n');
63
+
64
+ const DEVELOPER_NOTES = [
65
+ '### Critical notes',
66
+ '',
67
+ '#### `DESTRUCTIVE` = "Error" in design docs',
68
+ '',
69
+ 'The source enum uses developer naming (`BANNER_LEVEL.DESTRUCTIVE`) but the Confluence design spec',
70
+ 'calls this variant **"Error"**. They are the same thing. If you are reading the design spec and see',
71
+ '"Error banner", reach for `BANNER_LEVEL.DESTRUCTIVE` in code.',
72
+ '',
73
+ '#### Error banners are legacy-only',
74
+ '',
75
+ 'Per current design guidance, `BANNER_LEVEL.DESTRUCTIVE` should only appear in legacy surfaces.',
76
+ 'For new features, prefer `WARNING` (risky-but-reversible state) or a page-level empty state.',
77
+ 'Error notifications should be backed by reliable, confirmed system/data state — not manual admin notes.',
78
+ '',
79
+ '#### One banner per surface — hard rule',
80
+ '',
81
+ 'Never render more than one Banner on the same page or surface at a time. The `AllVariants` story',
82
+ 'in this file is a **visual reference only**. In a real application, pick the one Banner that',
83
+ 'matters most and surface only that.',
84
+ '',
85
+ '#### Persistent — no built-in dismiss',
86
+ '',
87
+ 'Banner has no close button and no auto-dismiss timer. It stays visible until the underlying condition',
88
+ 'resolves. If your product requirement calls for a dismissible banner, wrap `Banner` in your own',
89
+ '`useState` + conditional render (see the `DismissiblePattern` story for the recommended approach).',
90
+ '',
91
+ '#### No automatic `role="alert"`',
92
+ '',
93
+ 'Banner does **not** set `role="alert"` or `role="status"` automatically. Static banners (present on',
94
+ 'page load) do not need these roles. If your banner appears dynamically in response to a user action',
95
+ '(e.g. after a failed save), you are responsible for adding `role="alert"` via the `className` prop',
96
+ 'or by wrapping the Banner and managing ARIA outside the component.',
97
+ '',
98
+ '---',
99
+ '',
100
+ '### Accessibility',
101
+ '',
102
+ '- **Static banners** (present on load) need no special ARIA. Screen readers encounter them in normal',
103
+ ' document flow as heading + paragraph content.',
104
+ '- **Dynamic banners** (appear after user action): add `role="alert"` externally. Do not overuse alert',
105
+ ' roles — they interrupt screen reader flow and should only fire when the banner content is truly',
106
+ ' time-sensitive.',
107
+ '- **Colour alone** must not be the only signal of severity. The banner text must describe the impact.',
108
+ '- **Icons** receive `aria-hidden="true"` by default (handled by the `Icon` component) when they',
109
+ ' duplicate the meaning already expressed in text.',
110
+ '- **Focus order:** page title → banner content → interactive controls. Do not break this by',
111
+ ' positioning the banner after the main content area.',
112
+ '- **CTA button keyboard access:** the optional text-link button is keyboard-focusable and activated',
113
+ ' by Enter/Space. Its label must make sense out of context — "Fix settings" beats "Click here".',
114
+ '',
115
+ '---',
116
+ '',
117
+ '### Component tokens',
118
+ '',
119
+ 'Banner exposes a structured set of CSS custom properties for theming. The naming pattern is:',
120
+ '',
121
+ '`--banner-{level}-color-{role}` where `{level}` is `info | neutral | warning | destructive`',
122
+ 'and `{role}` is `background | border | text | icon`.',
123
+ '',
124
+ 'Example: `--banner-info-color-background` controls the Info variant background.',
125
+ '',
126
+ 'These tokens wrap the semantic colour system (`--color-semantic-info-*`, etc.) — always use the',
127
+ 'banner-specific tokens rather than the underlying semantic ones so that theme overrides propagate',
128
+ 'correctly. Note that the Neutral variant intentionally has no background token — the transparent',
129
+ 'background is by design.',
130
+ '',
131
+ 'Layout tokens: `--banner-spacing-vertical`, `--banner-spacing-horizontal`, `--banner-spacing-gap`,',
132
+ '`--banner-radius`.',
133
+ '',
134
+ '---',
135
+ '',
136
+ '### TypeScript types',
137
+ '',
138
+ '```ts',
139
+ "import { Banner, BANNER_LEVEL } from '@arbor-education/design-system.components';",
140
+ '',
141
+ 'function MyBanner(props: Banner.Props) { ... }',
142
+ '```',
143
+ '',
144
+ '| Type | Description |',
145
+ '|---|---|',
146
+ '| `Banner.Props` | Full props interface |',
147
+ '| `Banner.Level` | Enum — use as `Banner.Level.INFO`, `.NEUTRAL`, `.WARNING`, `.DESTRUCTIVE`. Replaces the old flat `BANNER_LEVEL` import. |',
148
+ ].join('\n');
149
+
150
+ const RELATED_COMPONENTS = [
151
+ '## Related components',
152
+ '',
153
+ '[Toast](?path=/docs/components-toast--docs) · [Modal](?path=/docs/components-modals-modal--docs) · [Button](?path=/docs/components-button--docs) · [Icon](?path=/docs/components-icon--docs)',
154
+ ].join('\n');
155
+
156
+ const PROPS_INTRO = 'The preview below is wired to the **Controls** panel — tweak any prop to see the story update in real time.';
157
+
158
+ function BannerDocsPage() {
159
+ return (
160
+ <>
161
+ <Title />
162
+ <Subtitle />
163
+ <Markdown>{DESCRIPTION_INTRO}</Markdown>
164
+ <DocHeading>Interactive example</DocHeading>
165
+ <Markdown>{PROPS_INTRO}</Markdown>
166
+ <DocPrimary />
167
+ <Controls />
168
+ <DocHeading>Usage guidance</DocHeading>
169
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
170
+ <DocHeading>Developer notes</DocHeading>
171
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
172
+ <DocHeading>Examples</DocHeading>
173
+ <Stories title="" />
174
+ <Markdown>{RELATED_COMPONENTS}</Markdown>
175
+ </>
176
+ );
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Curated icon options for the Controls panel.
181
+ // Only icons that make semantic sense in a banner context are listed.
182
+ // All names verified against src/components/icon/allowedIcons.tsx.
183
+ // ---------------------------------------------------------------------------
184
+
185
+ const BANNER_ICON_OPTIONS: Array<string | undefined> = [
186
+ undefined,
187
+ 'info',
188
+ 'triangle-alert',
189
+ 'circle-alert',
190
+ 'circle-check',
191
+ 'lock',
192
+ 'lock-open',
193
+ 'sparkles',
194
+ 'lightbulb',
195
+ 'mail',
196
+ 'user',
197
+ 'date',
198
+ 'settings',
199
+ 'flag',
200
+ 'clock-3',
201
+ ];
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Meta
205
+ // ---------------------------------------------------------------------------
206
+
207
+ const meta = {
6
208
  title: 'Components/Banner',
7
209
  component: Banner,
210
+ tags: ['autodocs'],
211
+ parameters: {
212
+ layout: 'padded',
213
+ docs: {
214
+ page: BannerDocsPage,
215
+ },
216
+ },
8
217
  argTypes: {
9
218
  level: {
10
- control: { type: 'select' },
219
+ control: 'select',
11
220
  options: Object.values(BANNER_LEVEL),
221
+ description: [
222
+ 'Semantic variant controlling the colour scheme and default icon.',
223
+ '`INFO` (blue) — helpful context.',
224
+ '`NEUTRAL` (transparent) — non-urgent notes.',
225
+ '`WARNING` (amber) — risky but recoverable.',
226
+ '`DESTRUCTIVE` (red) — serious error or locked state.',
227
+ 'Note: the design spec calls `DESTRUCTIVE` the "Error" variant — they are the same thing.',
228
+ ].join(' '),
229
+ table: {
230
+ type: { summary: 'BANNER_LEVEL' },
231
+ defaultValue: { summary: 'BANNER_LEVEL.NEUTRAL' },
232
+ },
233
+ },
234
+ title: {
235
+ control: 'text',
236
+ description: [
237
+ 'Optional heading rendered as an `<h3>` inside the banner.',
238
+ 'Use a short noun phrase (e.g. "Session expiring soon") rather than a full sentence.',
239
+ 'Keep it under 8 words — longer headings push the CTA button onto an awkward line.',
240
+ ].join(' '),
241
+ table: {
242
+ type: { summary: 'string' },
243
+ defaultValue: { summary: 'undefined' },
244
+ },
245
+ },
246
+ text: {
247
+ control: 'text',
248
+ description: [
249
+ 'Body copy for the banner. 1–2 sentences is ideal.',
250
+ 'State the impact before the action: "The census period is closed. Export data before Friday."',
251
+ 'If body copy fills more than 3 lines, consider a Modal instead.',
252
+ ].join(' '),
253
+ table: {
254
+ type: { summary: 'string' },
255
+ defaultValue: { summary: 'undefined' },
256
+ },
257
+ },
258
+ icon: {
259
+ control: 'select',
260
+ options: BANNER_ICON_OPTIONS,
261
+ description: [
262
+ 'Override the default icon for this level.',
263
+ 'Default icons: `info` for INFO and NEUTRAL, `triangle-alert` for WARNING, `circle-alert` for DESTRUCTIVE.',
264
+ 'Use a more specific metaphor when it helps (e.g. `lock` for a locked record, `circle-check` for a completed state).',
265
+ 'Icon colour is controlled by the level token, not the icon choice.',
266
+ 'All options in this control are verified against the allowedIcons set.',
267
+ ].join(' '),
268
+ table: {
269
+ type: { summary: 'IconName' },
270
+ defaultValue: { summary: 'Auto per level' },
271
+ },
272
+ },
273
+ hideIcon: {
274
+ control: 'boolean',
275
+ description: [
276
+ 'When `true`, completely suppresses the icon — no space is reserved and the central container',
277
+ 'expands to fill the full width.',
278
+ 'Use sparingly: icons reinforce the semantic level at a glance for users who scan.',
279
+ ].join(' '),
280
+ table: {
281
+ type: { summary: 'boolean' },
282
+ defaultValue: { summary: 'false' },
283
+ },
284
+ },
285
+ buttonText: {
286
+ control: 'text',
287
+ description: [
288
+ 'Label for the optional text-link CTA button rendered at the trailing edge of the banner.',
289
+ 'Must use an active verb: "Fix settings", "Review exclusions", "Switch to current year".',
290
+ 'Keep it short — the button has `text-wrap: nowrap` and the banner does not wrap to a second row.',
291
+ 'Do not use "Click here" — it conveys no information to screen reader users navigating by links.',
292
+ ].join(' '),
293
+ table: {
294
+ type: { summary: 'string' },
295
+ defaultValue: { summary: 'undefined' },
296
+ },
297
+ },
298
+ buttonOnClick: {
299
+ control: false,
300
+ description: [
301
+ 'Click handler for the CTA button.',
302
+ 'Required when `buttonText` is provided — otherwise the button renders with no action.',
303
+ 'Receives the native `React.MouseEvent<HTMLButtonElement>` so you can call `e.preventDefault()` if needed.',
304
+ ].join(' '),
305
+ table: {
306
+ type: { summary: '(e: React.MouseEvent<HTMLButtonElement>) => void' },
307
+ defaultValue: { summary: 'undefined' },
308
+ },
309
+ },
310
+ className: {
311
+ control: false,
312
+ description: [
313
+ 'Additional CSS class names appended to the root `<div>` via CVA.',
314
+ 'Merges cleanly with the `ds-banner--{level}` modifier — no specificity conflicts.',
315
+ 'Primary use: add `role="alert"` via a utility class, or apply a custom top-margin for layout.',
316
+ 'Do not use this to override banner colours — use the `--banner-*` CSS tokens instead.',
317
+ ].join(' '),
318
+ table: {
319
+ type: { summary: 'string' },
320
+ defaultValue: { summary: 'undefined' },
321
+ },
12
322
  },
13
323
  },
14
324
  args: {
15
- buttonOnClick: () => { console.log('click!'); },
325
+ buttonOnClick: fn(),
16
326
  },
17
- };
327
+ } satisfies Meta<typeof Banner>;
18
328
 
19
- export const Neutral = {
20
- args: {
21
- text: 'This is a neutral banner message with some useful information for the user.',
329
+ export default meta;
330
+ type Story = StoryObj<typeof Banner>;
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Helper: attach a per-story description to docs
334
+ // ---------------------------------------------------------------------------
335
+
336
+ const withDescription = (story: Story, description: string): Story => ({
337
+ ...story,
338
+ parameters: {
339
+ ...story.parameters,
340
+ docs: {
341
+ ...story.parameters?.docs,
342
+ description: {
343
+ story: description,
344
+ },
345
+ },
22
346
  },
347
+ });
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Template components for stateful or compositional stories.
351
+ // Named components avoid react-hooks lint issues — the react-hooks ESLint
352
+ // plugin is NOT configured in this project, so do NOT add eslint-disable.
353
+ // ---------------------------------------------------------------------------
354
+
355
+ const DismissibleTemplate = () => {
356
+ const [visible, setVisible] = useState(true);
357
+
358
+ return (
359
+ <div style={{ padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
360
+ <p className="ds-text" style={{ margin: 0, color: 'var(--color-grey-600)' }}>
361
+ Banner has no built-in dismiss. Consumers implement it with useState + conditional render:
362
+ </p>
363
+ {/* Banner is conditionally rendered — when visible is false, it disappears entirely.
364
+ The consumer owns this state. If the dismissed state should persist across page loads,
365
+ store it in localStorage or your app state layer. */}
366
+ {visible && (
367
+ <Banner
368
+ level={BANNER_LEVEL.WARNING}
369
+ text="Some marksheet entries have not been saved. Please review before leaving the page."
370
+ buttonText="Dismiss"
371
+ buttonOnClick={() => setVisible(false)}
372
+ />
373
+ )}
374
+ {!visible && (
375
+ <p className="ds-text" style={{ margin: 0, color: 'var(--color-grey-600)', fontStyle: 'italic' }}>
376
+ Banner dismissed. In a real app, reload the page or resolve the underlying condition to show it again.
377
+ </p>
378
+ )}
379
+ </div>
380
+ );
23
381
  };
24
382
 
25
- export const Info = {
26
- args: {
27
- level: BANNER_LEVEL.INFO,
28
- text: 'Your session will expire in 10 minutes. Save your work to avoid losing changes.',
383
+ const AllVariantsTemplate = () => (
384
+ <div
385
+ style={{
386
+ padding: 'var(--spacing-xlarge)',
387
+ display: 'flex',
388
+ flexDirection: 'column',
389
+ gap: 'var(--spacing-xxlarge)',
390
+ }}
391
+ >
392
+ <p className="ds-text" style={{ margin: 0, color: 'var(--color-semantic-warning-600)', fontStyle: 'italic' }}>
393
+ Reference layout only — never ship multiple banners on one surface.
394
+ </p>
395
+
396
+ {/* INFO */}
397
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
398
+ <p className="ds-text" style={{ margin: 0, color: 'var(--color-grey-600)' }}>INFO</p>
399
+ <Banner
400
+ level={BANNER_LEVEL.INFO}
401
+ text="Your session will expire in 10 minutes. Save your work to avoid losing changes."
402
+ />
403
+ </div>
404
+
405
+ {/* NEUTRAL — wrapped in a grey-100 swatch so the transparent background is visible */}
406
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
407
+ <p className="ds-text" style={{ margin: 0, color: 'var(--color-grey-600)' }}>
408
+ NEUTRAL (shown against a grey-100 backdrop — the banner itself is transparent)
409
+ </p>
410
+ <div style={{ background: 'var(--color-grey-100)', borderRadius: 'var(--border-radius-small)', padding: 'var(--spacing-large)' }}>
411
+ <Banner
412
+ level={BANNER_LEVEL.NEUTRAL}
413
+ text="You are viewing last year's data. Switch to the current academic year to make changes."
414
+ />
415
+ </div>
416
+ </div>
417
+
418
+ {/* WARNING */}
419
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
420
+ <p className="ds-text" style={{ margin: 0, color: 'var(--color-grey-600)' }}>WARNING</p>
421
+ <Banner
422
+ level={BANNER_LEVEL.WARNING}
423
+ text="The marksheet has unsaved entries. Review before leaving to avoid data loss."
424
+ />
425
+ </div>
426
+
427
+ {/* DESTRUCTIVE */}
428
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
429
+ <p className="ds-text" style={{ margin: 0, color: 'var(--color-grey-600)' }}>
430
+ DESTRUCTIVE (called "Error" in design spec — legacy use only)
431
+ </p>
432
+ <Banner
433
+ level={BANNER_LEVEL.DESTRUCTIVE}
434
+ text="This student record is locked because the reporting period has been closed."
435
+ />
436
+ </div>
437
+ </div>
438
+ );
439
+
440
+ const ContentGuidelinesTemplate = () => (
441
+ <div
442
+ style={{
443
+ padding: 'var(--spacing-xlarge)',
444
+ display: 'flex',
445
+ flexDirection: 'column',
446
+ gap: 'var(--spacing-xxlarge)',
447
+ }}
448
+ >
449
+ {/* IDEAL */}
450
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
451
+ <p className="ds-text" style={{ margin: 0, color: 'var(--color-semantic-success-600)' }}>
452
+ Ideal — short title, 1–2 line body, active verb CTA
453
+ </p>
454
+ <Banner
455
+ level={BANNER_LEVEL.WARNING}
456
+ title="Reporting period closing soon"
457
+ text="The census submission deadline is Friday 17 April at 5pm."
458
+ buttonText="Review exclusions"
459
+ buttonOnClick={() => undefined}
460
+ />
461
+ </div>
462
+
463
+ {/* TOO LONG */}
464
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
465
+ <p className="ds-text" style={{ margin: 0, color: 'var(--color-semantic-destructive-600)' }}>
466
+ Too long — paragraph-length body belongs in a Modal, not a Banner
467
+ </p>
468
+ <Banner
469
+ level={BANNER_LEVEL.WARNING}
470
+ title="Action required before the reporting period closes"
471
+ text="The census submission window closes at 5pm on Friday 17 April. You must review all exclusion records, verify that attendance data has been finalised for every year group, and confirm that your designated senior leader has approved the submission. Failure to complete these steps before the deadline will require an extension request to your local authority, which may take up to 10 working days to process."
472
+ buttonText="Learn more"
473
+ buttonOnClick={() => undefined}
474
+ />
475
+ </div>
476
+ </div>
477
+ );
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // Stories
481
+ // ---------------------------------------------------------------------------
482
+
483
+ export const Default: Story = withDescription(
484
+ {
485
+ args: {
486
+ level: BANNER_LEVEL.NEUTRAL,
487
+ text: "You are viewing last year's data. Switch to the current academic year to make changes.",
488
+ },
489
+ render: args => <Banner {...args} />,
29
490
  },
30
- };
491
+ [
492
+ 'The interactive canvas story — every prop is wired to the **Controls** panel below.',
493
+ 'Use the controls to explore all four levels, toggle `hideIcon`, add a `title`, provide `buttonText`,',
494
+ 'and override the default `icon`. Start by changing `level` to see the colour scheme shift.',
495
+ ].join(' '),
496
+ );
31
497
 
32
- export const Warning = {
33
- args: {
34
- level: BANNER_LEVEL.WARNING,
35
- text: 'This action may affect student records. Please review before proceeding.',
498
+ export const Info: Story = withDescription(
499
+ {
500
+ args: {
501
+ level: BANNER_LEVEL.INFO,
502
+ text: 'Your session will expire in 10 minutes. Save your work to avoid losing changes.',
503
+ },
504
+ parameters: {
505
+ docs: {
506
+ source: {
507
+ language: 'tsx',
508
+ code: `
509
+ import { Banner } from '@arbor-education/design-system.components';
510
+
511
+ function BannerInfoExample() {
512
+ return (
513
+ <Banner
514
+ level={Banner.Level.INFO}
515
+ text="Your session will expire in 10 minutes. Save your work to avoid losing changes."
516
+ />
517
+ );
518
+ }
519
+
520
+ export default BannerInfoExample;
521
+ `.trim(),
522
+ },
523
+ },
524
+ },
36
525
  },
37
- };
526
+ [
527
+ '**Info (blue)** — helpful context the user should know but does not need to act on right now.',
528
+ 'Use for non-urgent system messages like session warnings, scheduled maintenance, or general guidance.',
529
+ 'The default icon is `info`. The blue colour scheme communicates "pay attention" without alarm.',
530
+ ].join(' '),
531
+ );
38
532
 
39
- export const Destructive = {
40
- args: {
41
- level: BANNER_LEVEL.DESTRUCTIVE,
42
- text: 'Something went wrong. Please contact your system administrator.',
533
+ export const Neutral: Story = withDescription(
534
+ {
535
+ args: {
536
+ level: BANNER_LEVEL.NEUTRAL,
537
+ text: "You are viewing last year's data. Switch to the current academic year to make changes.",
538
+ },
539
+ parameters: {
540
+ docs: {
541
+ source: {
542
+ language: 'tsx',
543
+ code: `
544
+ import { Banner } from '@arbor-education/design-system.components';
545
+
546
+ function BannerNeutralExample() {
547
+ return (
548
+ <Banner
549
+ level={Banner.Level.NEUTRAL}
550
+ text="You are viewing last year's data. Switch to the current academic year to make changes."
551
+ />
552
+ );
553
+ }
554
+
555
+ export default BannerNeutralExample;
556
+ `.trim(),
557
+ },
558
+ },
559
+ },
43
560
  },
44
- };
561
+ [
562
+ '**Neutral (transparent)** — contextual notes that carry no urgency or risk.',
563
+ 'The banner background is transparent, inheriting the page surface behind it.',
564
+ 'Use for orientation messages: "you are in read-only view", "this is archived data", "filters are active".',
565
+ 'The `AllVariants` story wraps this in a grey-100 backdrop so the transparent background is visible against white.',
566
+ ].join(' '),
567
+ );
45
568
 
46
- export const WithTitle = {
47
- args: {
48
- level: BANNER_LEVEL.INFO,
49
- title: 'Important Update',
50
- text: 'A new version of the system is available. Refresh the page to apply updates.',
569
+ export const Warning: Story = withDescription(
570
+ {
571
+ args: {
572
+ level: BANNER_LEVEL.WARNING,
573
+ text: 'The marksheet has unsaved entries. Review before leaving to avoid data loss.',
574
+ },
575
+ parameters: {
576
+ docs: {
577
+ source: {
578
+ language: 'tsx',
579
+ code: `
580
+ import { Banner } from '@arbor-education/design-system.components';
581
+
582
+ function BannerWarningExample() {
583
+ return (
584
+ <Banner
585
+ level={Banner.Level.WARNING}
586
+ text="The marksheet has unsaved entries. Review before leaving to avoid data loss."
587
+ />
588
+ );
589
+ }
590
+
591
+ export default BannerWarningExample;
592
+ `.trim(),
593
+ },
594
+ },
595
+ },
51
596
  },
52
- };
597
+ [
598
+ '**Warning (amber)** — something is risky or incomplete, but the user can still proceed.',
599
+ 'Reserve Warning for situations where continuing without action could lead to a recoverable bad outcome:',
600
+ 'unsaved data, missing required setup, outdated records, approaching deadlines.',
601
+ 'The amber colour is attention-grabbing without implying a hard stop.',
602
+ ].join(' '),
603
+ );
53
604
 
54
- export const WithButton = {
55
- args: {
56
- level: BANNER_LEVEL.WARNING,
57
- text: 'You have unsaved changes that will be lost.',
58
- buttonText: 'Undo changes',
59
- buttonOnClick: () => { console.log('Banner button clicked'); },
605
+ export const Destructive: Story = withDescription(
606
+ {
607
+ args: {
608
+ level: BANNER_LEVEL.DESTRUCTIVE,
609
+ text: 'This student record is locked because the reporting period has been closed.',
610
+ },
611
+ parameters: {
612
+ docs: {
613
+ source: {
614
+ language: 'tsx',
615
+ code: `
616
+ import { Banner } from '@arbor-education/design-system.components';
617
+
618
+ function BannerDestructiveExample() {
619
+ return (
620
+ <Banner
621
+ level={Banner.Level.DESTRUCTIVE}
622
+ text="This student record is locked because the reporting period has been closed."
623
+ />
624
+ );
625
+ }
626
+
627
+ export default BannerDestructiveExample;
628
+ `.trim(),
629
+ },
630
+ },
631
+ },
60
632
  },
61
- };
633
+ [
634
+ '**Destructive / "Error" (red) — legacy use only.**',
635
+ '',
636
+ 'The source enum is `BANNER_LEVEL.DESTRUCTIVE` but the Confluence design spec calls this variant "Error".',
637
+ 'They are the same thing — if you see "Error banner" in design docs, use `BANNER_LEVEL.DESTRUCTIVE` in code.',
638
+ '',
639
+ 'Per current design guidance, this variant is for **legacy surfaces only**. On new features,',
640
+ 'reach for `WARNING` (risky-but-reversible) or a page-level empty state instead.',
641
+ 'Error banners should be backed by reliable, confirmed system/data state — not manual admin notes.',
642
+ ].join(' '),
643
+ );
62
644
 
63
- export const WithTitleAndButton = {
64
- args: {
65
- level: BANNER_LEVEL.INFO,
66
- title: 'Scheduled Maintenance',
67
- text: 'The system will be unavailable on Saturday 1st March between 2am–4am.',
68
- buttonText: 'Learn more',
69
- buttonOnClick: () => { console.log('Banner button clicked'); },
645
+ export const WithTitle: Story = withDescription(
646
+ {
647
+ args: {
648
+ level: BANNER_LEVEL.INFO,
649
+ title: 'Session expiring soon',
650
+ text: 'Your session will expire in 10 minutes. Save your work to avoid losing changes.',
651
+ },
652
+ parameters: {
653
+ docs: {
654
+ source: {
655
+ language: 'tsx',
656
+ code: `
657
+ import { Banner } from '@arbor-education/design-system.components';
658
+
659
+ function BannerWithTitleExample() {
660
+ return (
661
+ <Banner
662
+ level={Banner.Level.INFO}
663
+ title="Session expiring soon"
664
+ text="Your session will expire in 10 minutes. Save your work to avoid losing changes."
665
+ />
666
+ );
667
+ }
668
+
669
+ export default BannerWithTitleExample;
670
+ `.trim(),
671
+ },
672
+ },
673
+ },
70
674
  },
71
- };
675
+ [
676
+ 'The canonical structure when both a heading and body copy are needed.',
677
+ 'The `title` renders as an `<h3 class="ds-banner__title">` — keep it under 8 words.',
678
+ 'Use a short noun phrase, not a full sentence. The body copy fills in the detail.',
679
+ 'When title and text are both present, the title acts as a visual anchor for users scanning the page.',
680
+ ].join(' '),
681
+ );
72
682
 
73
- export const TitleOnly = {
74
- args: {
75
- level: BANNER_LEVEL.NEUTRAL,
76
- title: 'No results found for the selected filters.',
683
+ export const WithButton: Story = withDescription(
684
+ {
685
+ args: {
686
+ level: BANNER_LEVEL.WARNING,
687
+ text: "The census submission deadline is Friday 17 April at 5pm. Don't leave it to the last minute.",
688
+ buttonText: 'Review exclusions',
689
+ buttonOnClick: fn(),
690
+ },
691
+ parameters: {
692
+ docs: {
693
+ source: {
694
+ language: 'tsx',
695
+ code: `
696
+ import { Banner } from '@arbor-education/design-system.components';
697
+
698
+ function BannerWithButtonExample() {
699
+ return (
700
+ <Banner
701
+ level={Banner.Level.WARNING}
702
+ text="The census submission deadline is Friday 17 April at 5pm. Don't leave it to the last minute."
703
+ buttonText="Review exclusions"
704
+ buttonOnClick={() => {}}
705
+ />
706
+ );
707
+ }
708
+
709
+ export default BannerWithButtonExample;
710
+ `.trim(),
711
+ },
712
+ },
713
+ },
77
714
  },
78
- };
715
+ [
716
+ 'A banner with an optional text-link CTA at the trailing edge.',
717
+ 'The button uses `color: inherit`, so its text colour matches the level — amber on Warning, blue on Info, etc.',
718
+ 'Always use an active verb: "Fix settings", "Review exclusions", "Switch to current year".',
719
+ 'Keep button labels short — the button has `text-wrap: nowrap` and the banner does not wrap to a second row.',
720
+ ].join(' '),
721
+ );
79
722
 
80
- export const CustomIcon = {
81
- args: {
82
- level: BANNER_LEVEL.INFO,
83
- icon: 'circle-check',
84
- text: 'Your changes have been saved successfully.',
723
+ export const WithTitleAndButton: Story = withDescription(
724
+ {
725
+ args: {
726
+ level: BANNER_LEVEL.INFO,
727
+ title: 'Scheduled maintenance',
728
+ text: 'The system will be unavailable on Saturday 19 April between 2am–4am.',
729
+ buttonText: 'Learn more',
730
+ buttonOnClick: fn(),
731
+ },
732
+ parameters: {
733
+ docs: {
734
+ source: {
735
+ language: 'tsx',
736
+ code: `
737
+ import { Banner } from '@arbor-education/design-system.components';
738
+
739
+ function BannerWithTitleAndButtonExample() {
740
+ return (
741
+ <Banner
742
+ level={Banner.Level.INFO}
743
+ title="Scheduled maintenance"
744
+ text="The system will be unavailable on Saturday 19 April between 2am–4am."
745
+ buttonText="Learn more"
746
+ buttonOnClick={() => {}}
747
+ />
748
+ );
749
+ }
750
+
751
+ export default BannerWithTitleAndButtonExample;
752
+ `.trim(),
753
+ },
754
+ },
755
+ },
85
756
  },
86
- };
757
+ [
758
+ 'The full Banner structure: icon + title + body + CTA.',
759
+ 'This is the maximum content a banner should carry. If you find yourself needing more,',
760
+ 'consider a Modal — the banner flex layout is `row nowrap` and will not wrap to a second line.',
761
+ 'The CTA button inherits the level text colour and sits flush to the trailing edge.',
762
+ ].join(' '),
763
+ );
87
764
 
88
- export const HideIcon = {
89
- args: {
90
- level: BANNER_LEVEL.WARNING,
91
- hideIcon: true,
92
- text: 'This banner has its icon hidden.',
765
+ export const TitleOnly: Story = withDescription(
766
+ {
767
+ args: {
768
+ level: BANNER_LEVEL.NEUTRAL,
769
+ title: 'No students match the selected filters.',
770
+ },
771
+ parameters: {
772
+ docs: {
773
+ source: {
774
+ language: 'tsx',
775
+ code: `
776
+ import { Banner } from '@arbor-education/design-system.components';
777
+
778
+ function BannerTitleOnlyExample() {
779
+ return (
780
+ <Banner
781
+ level={Banner.Level.NEUTRAL}
782
+ title="No students match the selected filters."
783
+ />
784
+ );
785
+ }
786
+
787
+ export default BannerTitleOnlyExample;
788
+ `.trim(),
789
+ },
790
+ },
791
+ },
93
792
  },
94
- };
793
+ [
794
+ 'A banner with only a `title` — no body copy.',
795
+ 'Valid for situations where the heading is self-explanatory and body text would be redundant.',
796
+ 'If the message requires any clarification or a call-to-action, add `text` and/or `buttonText`.',
797
+ ].join(' '),
798
+ );
95
799
 
96
- export default meta;
800
+ export const TextOnly: Story = withDescription(
801
+ {
802
+ args: {
803
+ level: BANNER_LEVEL.INFO,
804
+ text: 'The census submission deadline is Friday 17 April at 5pm.',
805
+ },
806
+ parameters: {
807
+ docs: {
808
+ source: {
809
+ language: 'tsx',
810
+ code: `
811
+ import { Banner } from '@arbor-education/design-system.components';
812
+
813
+ function BannerTextOnlyExample() {
814
+ return (
815
+ <Banner
816
+ level={Banner.Level.INFO}
817
+ text="The census submission deadline is Friday 17 April at 5pm."
818
+ />
819
+ );
820
+ }
821
+
822
+ export default BannerTextOnlyExample;
823
+ `.trim(),
824
+ },
825
+ },
826
+ },
827
+ },
828
+ [
829
+ 'A banner with body copy only — no title heading.',
830
+ 'Appropriate for short, self-contained messages that do not need a heading to orient the user.',
831
+ 'When the message is 1 clear sentence, `title` is optional overhead.',
832
+ ].join(' '),
833
+ );
834
+
835
+ export const CustomIcon: Story = withDescription(
836
+ {
837
+ args: {
838
+ level: BANNER_LEVEL.INFO,
839
+ icon: 'lock',
840
+ title: 'Record locked',
841
+ text: 'This student record is read-only. The reporting period for this academic year has been closed.',
842
+ },
843
+ parameters: {
844
+ docs: {
845
+ source: {
846
+ language: 'tsx',
847
+ code: `
848
+ import { Banner } from '@arbor-education/design-system.components';
849
+
850
+ function BannerCustomIconExample() {
851
+ return (
852
+ <Banner
853
+ level={Banner.Level.INFO}
854
+ icon="lock"
855
+ title="Record locked"
856
+ text="This student record is read-only. The reporting period for this academic year has been closed."
857
+ />
858
+ );
859
+ }
860
+
861
+ export default BannerCustomIconExample;
862
+ `.trim(),
863
+ },
864
+ },
865
+ },
866
+ },
867
+ [
868
+ 'Override the default icon with a more specific metaphor.',
869
+ 'Here `lock` (instead of the default `info`) communicates the locked-record context at a glance.',
870
+ 'The icon colour still comes from the level token — swapping the icon shape does not change the colour.',
871
+ 'Good overrides: `lock` / `lock-open` for access states, `circle-check` for confirmed states,',
872
+ '`sparkles` for AI-assisted features, `date` for deadline-related messages.',
873
+ ].join(' '),
874
+ );
875
+
876
+ export const HideIcon: Story = withDescription(
877
+ {
878
+ args: {
879
+ level: BANNER_LEVEL.WARNING,
880
+ hideIcon: true,
881
+ text: 'You are viewing data for a student who has left this school. Some fields may be read-only.',
882
+ },
883
+ parameters: {
884
+ docs: {
885
+ source: {
886
+ language: 'tsx',
887
+ code: `
888
+ import { Banner } from '@arbor-education/design-system.components';
889
+
890
+ function BannerHideIconExample() {
891
+ return (
892
+ <Banner
893
+ level={Banner.Level.WARNING}
894
+ hideIcon
895
+ text="You are viewing data for a student who has left this school. Some fields may be read-only."
896
+ />
897
+ );
898
+ }
899
+
900
+ export default BannerHideIconExample;
901
+ `.trim(),
902
+ },
903
+ },
904
+ },
905
+ },
906
+ [
907
+ 'Set `hideIcon={true}` to completely suppress the icon.',
908
+ 'No space is reserved — the central container expands to fill the full width.',
909
+ 'Use sparingly: the icon provides a quick visual severity signal for users who scan.',
910
+ 'A valid case is when the banner sits in a context where the surrounding chrome already conveys severity,',
911
+ 'and the icon would be visually redundant.',
912
+ ].join(' '),
913
+ );
914
+
915
+ export const AllVariants: Story = withDescription(
916
+ {
917
+ render: AllVariantsTemplate,
918
+ parameters: {
919
+ docs: {
920
+ source: {
921
+ language: 'tsx',
922
+ code: `
923
+ import { Banner } from '@arbor-education/design-system.components';
924
+
925
+ function BannerAllVariantsExample() {
926
+ return (
927
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xxlarge)' }}>
928
+ {/* INFO */}
929
+ <Banner
930
+ level={Banner.Level.INFO}
931
+ text="Your session will expire in 10 minutes. Save your work to avoid losing changes."
932
+ />
933
+
934
+ {/* NEUTRAL — wrap in a grey-100 container so transparent background is visible */}
935
+ <div style={{ background: 'var(--color-grey-100)', borderRadius: 'var(--border-radius-small)', padding: 'var(--spacing-large)' }}>
936
+ <Banner
937
+ level={Banner.Level.NEUTRAL}
938
+ text="You are viewing last year's data. Switch to the current academic year to make changes."
939
+ />
940
+ </div>
941
+
942
+ {/* WARNING */}
943
+ <Banner
944
+ level={Banner.Level.WARNING}
945
+ text="The marksheet has unsaved entries. Review before leaving to avoid data loss."
946
+ />
947
+
948
+ {/* DESTRUCTIVE — legacy use only */}
949
+ <Banner
950
+ level={Banner.Level.DESTRUCTIVE}
951
+ text="This student record is locked because the reporting period has been closed."
952
+ />
953
+ </div>
954
+ );
955
+ }
956
+
957
+ export default BannerAllVariantsExample;
958
+ `.trim(),
959
+ },
960
+ },
961
+ },
962
+ },
963
+ [
964
+ 'All four variants stacked for visual comparison.',
965
+ '',
966
+ '**Important:** This layout is a Storybook-only reference. Never ship multiple banners on the same surface.',
967
+ 'Pick the one that matters most and surface only that. The Neutral variant is shown against a',
968
+ 'grey-100 backdrop to make its transparent background visible.',
969
+ ].join(' '),
970
+ );
971
+
972
+ export const ContentGuidelines: Story = withDescription(
973
+ {
974
+ render: ContentGuidelinesTemplate,
975
+ parameters: {
976
+ docs: {
977
+ source: {
978
+ language: 'tsx',
979
+ code: `
980
+ import { Banner } from '@arbor-education/design-system.components';
981
+
982
+ function BannerContentGuidelinesExample() {
983
+ return (
984
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xxlarge)' }}>
985
+ {/* Ideal — short title, 1–2 line body, active verb CTA */}
986
+ <Banner
987
+ level={Banner.Level.WARNING}
988
+ title="Reporting period closing soon"
989
+ text="The census submission deadline is Friday 17 April at 5pm."
990
+ buttonText="Review exclusions"
991
+ buttonOnClick={() => {}}
992
+ />
993
+
994
+ {/* Too long — paragraph-length body belongs in a Modal, not a Banner */}
995
+ <Banner
996
+ level={Banner.Level.WARNING}
997
+ title="Action required before the reporting period closes"
998
+ text="The census submission window closes at 5pm on Friday 17 April. You must review all exclusion records, verify that attendance data has been finalised for every year group, and confirm that your designated senior leader has approved the submission."
999
+ buttonText="Learn more"
1000
+ buttonOnClick={() => {}}
1001
+ />
1002
+ </div>
1003
+ );
1004
+ }
1005
+
1006
+ export default BannerContentGuidelinesExample;
1007
+ `.trim(),
1008
+ },
1009
+ },
1010
+ },
1011
+ },
1012
+ [
1013
+ 'Content length guidelines: what good looks like vs what should be a Modal instead.',
1014
+ '',
1015
+ 'The **ideal** banner (top) has a short title, a 1–2 sentence body, and an active-verb CTA.',
1016
+ 'The **too long** banner (bottom) carries a multi-sentence paragraph that would be better served by a Modal',
1017
+ 'or a dedicated help page. A Banner is a strip, not a notice board.',
1018
+ ].join(' '),
1019
+ );
1020
+
1021
+ export const DismissiblePattern: Story = withDescription(
1022
+ {
1023
+ render: DismissibleTemplate,
1024
+ parameters: {
1025
+ docs: {
1026
+ source: {
1027
+ language: 'tsx',
1028
+ code: `
1029
+ import { useState } from 'react';
1030
+ import { Banner } from '@arbor-education/design-system.components';
1031
+
1032
+ function DismissibleBanner() {
1033
+ const [visible, setVisible] = useState(true);
1034
+
1035
+ if (!visible) return null;
1036
+
1037
+ return (
1038
+ <Banner
1039
+ level={Banner.Level.WARNING}
1040
+ text="Some marksheet entries have not been saved. Please review before leaving the page."
1041
+ buttonText="Dismiss"
1042
+ buttonOnClick={() => setVisible(false)}
1043
+ />
1044
+ );
1045
+ }
1046
+
1047
+ export default DismissibleBanner;
1048
+ `.trim(),
1049
+ },
1050
+ },
1051
+ },
1052
+ },
1053
+ [
1054
+ 'The recommended consumer pattern for a dismissible banner.',
1055
+ '',
1056
+ 'Banner has **no built-in dismiss** — it is intentionally persistent so it stays visible until',
1057
+ 'the underlying condition is resolved. When your requirement calls for user-dismissal, wrap Banner',
1058
+ 'in `useState` + conditional render in your own component. The CTA button triggers `setVisible(false)`.',
1059
+ '',
1060
+ 'If the dismissed state should persist across page reloads, store the flag in `localStorage`',
1061
+ 'or your application state layer — the Banner component itself has no opinion about persistence.',
1062
+ ].join(' '),
1063
+ );
1064
+
1065
+ export const LegacyErrorWarning: Story = withDescription(
1066
+ {
1067
+ args: {
1068
+ level: BANNER_LEVEL.DESTRUCTIVE,
1069
+ icon: 'circle-alert',
1070
+ title: 'Submission failed',
1071
+ text: 'The exclusion could not be submitted. Contact your system administrator if this problem persists.',
1072
+ buttonText: 'Fix settings',
1073
+ buttonOnClick: fn(),
1074
+ },
1075
+ parameters: {
1076
+ docs: {
1077
+ source: {
1078
+ language: 'tsx',
1079
+ code: `
1080
+ import { Banner } from '@arbor-education/design-system.components';
1081
+
1082
+ function BannerLegacyErrorWarningExample() {
1083
+ return (
1084
+ /* Legacy use only — prefer Banner.Level.WARNING on new surfaces */
1085
+ <Banner
1086
+ level={Banner.Level.DESTRUCTIVE}
1087
+ icon="circle-alert"
1088
+ title="Submission failed"
1089
+ text="The exclusion could not be submitted. Contact your system administrator if this problem persists."
1090
+ buttonText="Fix settings"
1091
+ buttonOnClick={() => {}}
1092
+ />
1093
+ );
1094
+ }
1095
+
1096
+ export default BannerLegacyErrorWarningExample;
1097
+ `.trim(),
1098
+ },
1099
+ },
1100
+ },
1101
+ },
1102
+ [
1103
+ '**Legacy use only.** This story demonstrates the `DESTRUCTIVE` ("Error") variant.',
1104
+ '',
1105
+ 'Per design guidance: Error banners should only appear on **legacy surfaces**.',
1106
+ 'For new alerts on modern pages, prefer:',
1107
+ '',
1108
+ '- `WARNING` — for risky-but-reversible situations ("some data has not been saved")',
1109
+ '- Page-level empty states — when there is nothing to show due to an error condition',
1110
+ '- [`Toast`](?path=/docs/components-toast--docs) — for ephemeral success/error feedback after a user action',
1111
+ '',
1112
+ 'Error banners must be backed by reliable, confirmed system state — not advisory text added manually.',
1113
+ ].join(' '),
1114
+ );