@flamingo-stack/openframe-frontend-core 0.0.201 → 0.0.202

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 (45) hide show
  1. package/dist/{chunk-CSW5GYBU.js → chunk-IDULPYOU.js} +3997 -3734
  2. package/dist/chunk-IDULPYOU.js.map +1 -0
  3. package/dist/{chunk-UCY537V4.cjs → chunk-JIKTMXTZ.cjs} +952 -689
  4. package/dist/chunk-JIKTMXTZ.cjs.map +1 -0
  5. package/dist/components/chat/approval-request-message.d.ts.map +1 -1
  6. package/dist/components/chat/chat-container.d.ts.map +1 -1
  7. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  8. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  9. package/dist/components/chat/types/message.types.d.ts +34 -0
  10. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  11. package/dist/components/features/index.cjs +14 -2
  12. package/dist/components/features/index.cjs.map +1 -1
  13. package/dist/components/features/index.d.ts +1 -0
  14. package/dist/components/features/index.d.ts.map +1 -1
  15. package/dist/components/features/index.js +15 -3
  16. package/dist/components/index.cjs +14 -2
  17. package/dist/components/index.cjs.map +1 -1
  18. package/dist/components/index.js +13 -1
  19. package/dist/components/navigation/index.cjs +2 -2
  20. package/dist/components/navigation/index.js +1 -1
  21. package/dist/components/providers/theme-provider.d.ts +69 -0
  22. package/dist/components/providers/theme-provider.d.ts.map +1 -0
  23. package/dist/components/ui/index.cjs +2 -2
  24. package/dist/components/ui/index.js +1 -1
  25. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
  26. package/dist/index.cjs +14 -2
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.js +13 -1
  29. package/package.json +2 -1
  30. package/src/components/chat/approval-request-message.tsx +106 -92
  31. package/src/components/chat/chat-container.tsx +8 -4
  32. package/src/components/chat/chat-message-enhanced.tsx +51 -9
  33. package/src/components/chat/chat-message-list.tsx +27 -19
  34. package/src/components/chat/types/message.types.ts +35 -0
  35. package/src/components/features/index.ts +15 -0
  36. package/src/components/providers/theme-provider.tsx +130 -0
  37. package/src/components/ui/simple-markdown-renderer.tsx +248 -2
  38. package/src/stories/Theme.stories.tsx +350 -0
  39. package/src/styles/README.md +271 -174
  40. package/src/styles/dark_theme.tokens.json +982 -0
  41. package/src/styles/light_theme.tokens.json +982 -0
  42. package/src/styles/ods-colors.css +225 -146
  43. package/src/styles/ods_color_tokens.json +1 -300
  44. package/dist/chunk-CSW5GYBU.js.map +0 -1
  45. package/dist/chunk-UCY537V4.cjs.map +0 -1
@@ -7,10 +7,153 @@ import remarkGfm from 'remark-gfm';
7
7
  import remarkBreaks from 'remark-breaks';
8
8
  import rehypeHighlight from 'rehype-highlight';
9
9
  import rehypeRaw from 'rehype-raw';
10
+ import { visit } from 'unist-util-visit';
10
11
  import Image from 'next/image';
11
12
  import { AlertCircleIcon } from '../icons-v2-generated';
12
13
  import { cn } from '../../utils/cn';
13
14
 
15
+ // ---------------------------------------------------------------------------
16
+ // rehype HAST sanitizer — runs AFTER rehype-raw to strip XSS vectors
17
+ // ---------------------------------------------------------------------------
18
+ /**
19
+ * Minimal HAST sanitizer. Runs AFTER `rehype-raw` (which parses raw HTML
20
+ * embedded in markdown) and BEFORE `rehype-highlight`. Strips the
21
+ * attack surfaces that rehype-raw leaves wide open:
22
+ *
23
+ * - `on*` event handlers (onerror, onload, onclick, …) on ANY element
24
+ * - href / src / formaction / xlink:href / poster pointing at
25
+ * `javascript:` (case + whitespace tolerant)
26
+ * - `iframe srcdoc` (full-document XSS)
27
+ * - `<script>`, `<style>`, `<noscript>`, `<noembed>` elements (drop)
28
+ * - `data:` URIs on src-ish attrs (SVG-with-embedded-JS class of bug)
29
+ *
30
+ * Why custom (vs `rehype-sanitize`): the OSS-lib build environment
31
+ * doesn't have `rehype-sanitize` in its `node_modules` (sandbox
32
+ * restriction), but `unist-util-visit` is already a transitive dep of
33
+ * `rehype-raw`. This plugin is ~60 lines, ships nothing new, and is
34
+ * tighter than the default sanitize schema for our threat model
35
+ * (we want LLM-emitted markdown to be safe; we don't need full HTML5
36
+ * fidelity).
37
+ *
38
+ * The text-level `escapeUnknownHtmlTags` pre-pass below is still useful
39
+ * for catching `<their>`-style accidental tag emissions that React 19
40
+ * rejects, but it is NOT a security boundary. THIS is.
41
+ */
42
+ const EVENT_HANDLER_ATTR_RE = /^on[a-z]+$/i
43
+ const JAVASCRIPT_URL_RE = /^[\s\x00-\x1f]*javascript:/i
44
+ const DATA_URL_RE = /^[\s\x00-\x1f]*data:/i
45
+ const URL_ATTRS = new Set([
46
+ 'href',
47
+ 'src',
48
+ // `srcset` accepts `javascript:` on legacy browsers — same guard
49
+ // as the canonical hast-util-sanitize default schema's attr set.
50
+ // Multi-candidate scanning lives in `srcsetHasUnsafeCandidate` below;
51
+ // a single-URL check would miss a malicious second candidate like
52
+ // `"https://safe.png 1x, javascript:alert(1) 2x"`.
53
+ 'srcset',
54
+ 'formaction',
55
+ 'xlink:href',
56
+ 'poster',
57
+ 'data',
58
+ 'action',
59
+ 'background',
60
+ ])
61
+
62
+ /**
63
+ * Returns true if any candidate in an `srcset` attribute has a dangerous
64
+ * URL scheme. Per the HTML spec, srcset is a comma-separated list of
65
+ * candidates; each candidate is `<url> <descriptor>?` (e.g.
66
+ * `"https://x/a.png 2x"`). The single-URL `JAVASCRIPT_URL_RE.test(v)`
67
+ * check only inspects the FIRST candidate — so a malicious second
68
+ * candidate (`"https://safe 1x, javascript:alert(1) 2x"`) would slip
69
+ * through. This helper splits on `,`, trims each candidate, takes the
70
+ * leading whitespace-delimited token (the URL), and tests that.
71
+ *
72
+ * Splitting on every comma over-matches the rare-in-practice case of
73
+ * commas inside URL paths. That's the correct error bias for a
74
+ * sanitizer: over-strip is safe, under-strip is not.
75
+ */
76
+ function srcsetHasUnsafeCandidate(srcset: string): boolean {
77
+ for (const candidate of srcset.split(',')) {
78
+ const url = candidate.trim().split(/\s+/)[0] ?? ''
79
+ if (JAVASCRIPT_URL_RE.test(url) || DATA_URL_RE.test(url)) return true
80
+ }
81
+ return false
82
+ }
83
+ // Element-level drop list. The text-pre-pass `escapeUnknownHtmlTags`
84
+ // already escapes any `<tag>` whose name isn't on its allow-list, so
85
+ // `<object>`, `<embed>`, `<applet>`, `<base>`, `<meta>` normally never
86
+ // reach `rehype-raw` as elements. Re-stripping at the HAST layer
87
+ // removes the cross-layer dependency: this sanitizer is sufficient
88
+ // on its own even if the text pre-pass is bypassed.
89
+ const STRIP_ELEMENTS = new Set([
90
+ 'script',
91
+ 'style',
92
+ 'noscript',
93
+ 'noembed',
94
+ 'object',
95
+ 'embed',
96
+ 'applet',
97
+ 'base',
98
+ 'meta',
99
+ ])
100
+
101
+ function rehypeStripUnsafe() {
102
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
+ return (tree: any) => {
104
+ visit(tree, 'element', (node: any, index: number | undefined, parent: any) => {
105
+ const tag = String(node.tagName ?? '').toLowerCase()
106
+ if (STRIP_ELEMENTS.has(tag)) {
107
+ if (parent && typeof index === 'number') {
108
+ parent.children.splice(index, 1)
109
+ // Return the numeric index (`unist-util-visit` treats it as
110
+ // `[CONTINUE, index]`) so the walker resumes at the slot the
111
+ // removed node vacated. Don't return `[SKIP, index]`:
112
+ // skip-descendants is meaningless for a node we just removed,
113
+ // and SKIP+index conflates two signals.
114
+ return index
115
+ }
116
+ // Root-level strip element (parent undefined). Rare, but
117
+ // possible if `rehype-raw` ever lifts one. Neutralize in
118
+ // place by clearing children + retagging as a blank span.
119
+ node.children = []
120
+ node.tagName = 'span'
121
+ node.properties = {}
122
+ return
123
+ }
124
+ if (!node.properties || typeof node.properties !== 'object') return
125
+ for (const key of Object.keys(node.properties)) {
126
+ // 1. event handlers
127
+ if (EVENT_HANDLER_ATTR_RE.test(key)) {
128
+ delete node.properties[key]
129
+ continue
130
+ }
131
+ // 2. dangerous URL schemes on URL-bearing attrs. `srcset` is
132
+ // multi-candidate so it routes through `srcsetHasUnsafeCandidate`
133
+ // which inspects every URL in the list (not just the first).
134
+ if (URL_ATTRS.has(key.toLowerCase())) {
135
+ const raw = node.properties[key]
136
+ const v = Array.isArray(raw) ? raw[0] : raw
137
+ if (typeof v === 'string') {
138
+ const unsafe =
139
+ key.toLowerCase() === 'srcset'
140
+ ? srcsetHasUnsafeCandidate(v)
141
+ : JAVASCRIPT_URL_RE.test(v) || DATA_URL_RE.test(v)
142
+ if (unsafe) {
143
+ delete node.properties[key]
144
+ continue
145
+ }
146
+ }
147
+ }
148
+ // 3. iframe srcdoc — full-document XSS vector
149
+ if (tag === 'iframe' && key.toLowerCase() === 'srcdoc') {
150
+ delete node.properties[key]
151
+ }
152
+ }
153
+ })
154
+ }
155
+ }
156
+
14
157
  /**
15
158
  * URL transformer that extends react-markdown's default safe-protocol
16
159
  * allowlist with `card://` — the non-standard scheme `remarkCardLinks`
@@ -35,6 +178,90 @@ function cardAwareUrlTransform(url: string, key: string): string {
35
178
  return defaultUrlTransform(url);
36
179
  }
37
180
 
181
+ // ---------------------------------------------------------------------------
182
+ // LLM-output sanitizer — escape unknown HTML-style tags
183
+ // ---------------------------------------------------------------------------
184
+ /**
185
+ * Tags `rehype-raw` is allowed to forward to React as-is. Anything
186
+ * outside this set gets its angle brackets escaped so it renders as
187
+ * plain text rather than reaching React as an unrecognized custom
188
+ * element.
189
+ *
190
+ * Why this matters: chat output is LLM-generated markdown. An LLM that
191
+ * accidentally wraps a word in angle brackets ("share <their> settings")
192
+ * or echoes a system-prompt XML tag ("the <ticket> element above")
193
+ * makes `rehype-raw` mint a `<their>` / `<ticket>` JSX element. React
194
+ * 18 logs "tag is unrecognized in this browser" for every unknown tag;
195
+ * React 19 throws on tags with reserved kebab-case forms. We pre-escape
196
+ * to keep the renderer pristine without losing legitimate inline HTML
197
+ * (details/summary, video, iframe, kbd, mark, etc.).
198
+ *
199
+ * The allow-list mirrors HTML5 + the elements the chat shell wires
200
+ * component overrides for. Kept lower-case; matched case-insensitively.
201
+ */
202
+ const SAFE_HTML_TAGS = new Set([
203
+ // Block + inline text
204
+ 'a', 'abbr', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', 'blockquote',
205
+ 'br', 'caption', 'cite', 'code', 'col', 'colgroup', 'data', 'dd', 'del',
206
+ 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer',
207
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'ins',
208
+ 'kbd', 'li', 'main', 'mark', 'nav', 'ol', 'p', 'pre', 'q', 'rp', 'rt',
209
+ 'ruby', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary',
210
+ 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u',
211
+ 'ul', 'var', 'wbr',
212
+ // Media
213
+ 'img', 'picture', 'source', 'audio', 'video', 'iframe', 'track',
214
+ // Forms (rehype-raw allows them; mostly harmless for chat output)
215
+ 'button', 'input', 'label', 'select', 'option', 'optgroup', 'textarea', 'form', 'fieldset', 'legend',
216
+ ])
217
+
218
+ /**
219
+ * Match an opening / closing HTML tag in markdown source. Captures:
220
+ * 1 — optional `/` for closing tags
221
+ * 2 — tag name (must start with a letter)
222
+ * 3 — everything between the name and the closing `>` (attrs etc.)
223
+ * 4 — optional `/` for void-element self-close
224
+ */
225
+ const TAG_LIKE_REGEX = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)([^>]*?)(\/?)>/g
226
+
227
+ function escapeUnknownHtmlTags(text: string): string {
228
+ if (!text || text.indexOf('<') === -1) return text
229
+ // Carve out fenced code blocks AND inline-backtick spans so we don't
230
+ // corrupt `<their>` examples that legitimately live inside code. The
231
+ // regex matches in priority order: triple-fence first (greediest),
232
+ // then inline single-backtick. Each protected span is preserved
233
+ // verbatim; everything between/around them runs through
234
+ // `escapeOutsideFences`.
235
+ const parts: string[] = []
236
+ let cursor = 0
237
+ // Triple-fence ``` … ``` OR single-backtick `…` (one line, no
238
+ // embedded newlines). Backtick group is non-greedy and forbids
239
+ // inner newlines per CommonMark inline-code semantics.
240
+ const PROTECTED_SPAN_RE = /```[\s\S]*?```|`[^`\n]+`/g
241
+ let span: RegExpExecArray | null
242
+ while ((span = PROTECTED_SPAN_RE.exec(text)) !== null) {
243
+ if (span.index > cursor) {
244
+ parts.push(escapeOutsideFences(text.slice(cursor, span.index)))
245
+ }
246
+ parts.push(span[0])
247
+ cursor = span.index + span[0].length
248
+ }
249
+ if (cursor < text.length) {
250
+ parts.push(escapeOutsideFences(text.slice(cursor)))
251
+ }
252
+ return parts.join('')
253
+ }
254
+
255
+ function escapeOutsideFences(segment: string): string {
256
+ return segment.replace(TAG_LIKE_REGEX, (match, slash, tag, rest, selfClose) => {
257
+ const lower = (tag as string).toLowerCase()
258
+ if (SAFE_HTML_TAGS.has(lower)) return match
259
+ // Unknown tag — escape so it renders as text instead of reaching
260
+ // React as a custom element.
261
+ return `&lt;${slash}${tag}${rest}${selfClose}&gt;`
262
+ })
263
+ }
264
+
38
265
  // ---------------------------------------------------------------------------
39
266
  // Mermaid styles (responsive)
40
267
  // ---------------------------------------------------------------------------
@@ -396,7 +623,19 @@ export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({
396
623
  }, [sectionIds, sectionIdMap]);
397
624
 
398
625
  // ---- preprocess ----
399
- const processedContent = preprocessContent ? preprocessContent(content) : content;
626
+ // Run the optional caller-supplied transform first, then defensively
627
+ // escape unknown HTML-style tags so an LLM that wrote `<their>` or
628
+ // accidentally echoed a system-prompt XML tag like `<ticket>` doesn't
629
+ // make `rehype-raw` hand React an unrecognized custom element (which
630
+ // logs a noisy "tag is unrecognized in this browser" warning AND can
631
+ // crash the SimpleMarkdownRenderer when the tag has a kebab-case
632
+ // form React rejects outright). Allow-listed tags pass through
633
+ // unchanged so legitimate inline HTML (details/summary, video,
634
+ // iframe, etc.) still renders.
635
+ const processedContent = useMemo(
636
+ () => escapeUnknownHtmlTags(preprocessContent ? preprocessContent(content) : content),
637
+ [preprocessContent, content],
638
+ );
400
639
 
401
640
  // ---- heading factory ----
402
641
  const makeHeading = useCallback(
@@ -499,7 +738,7 @@ export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({
499
738
  return (
500
739
  <span className="text-ods-accent cursor-not-allowed">
501
740
  {children}
502
- <sup className="ml-1 text-xs font-bold text-red-500">[BROKEN]</sup>
741
+ <sup className="ml-1 text-xs font-bold text-ods-attention-red-error">[BROKEN]</sup>
503
742
  </span>
504
743
  );
505
744
  }
@@ -617,7 +856,14 @@ export const SimpleMarkdownRenderer: React.FC<SimpleMarkdownRendererProps> = ({
617
856
  <ReactMarkdown
618
857
  remarkPlugins={[remarkGfm, remarkBreaks, ...(additionalRemarkPlugins ?? [])]}
619
858
  rehypePlugins={[
859
+ // ORDER MATTERS: rehype-raw parses the raw HTML embedded
860
+ // in the source markdown into HAST nodes; rehypeStripUnsafe
861
+ // then walks the HAST tree and drops XSS vectors (on*
862
+ // event handlers, javascript: URLs, script/style/iframe-srcdoc,
863
+ // data: URIs). Reversing the order would have nothing to
864
+ // sanitize (raw HTML would still be strings).
620
865
  rehypeRaw,
866
+ rehypeStripUnsafe,
621
867
  [rehypeHighlight, { detect: true, ignoreMissing: true }],
622
868
  ]}
623
869
  urlTransform={cardAwareUrlTransform}
@@ -0,0 +1,350 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
+ import { Moon, Sun } from 'lucide-react'
3
+ import React from 'react'
4
+ import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert'
5
+ import { Badge } from '../components/ui/badge'
6
+ import { Button } from '../components/ui/button'
7
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'
8
+ import { Input } from '../components/ui/input'
9
+ import { ThemeProvider, useThemeToggle } from '../components/providers/theme-provider'
10
+
11
+ const meta = {
12
+ title: 'Foundations/Theme',
13
+ parameters: {
14
+ layout: 'fullscreen',
15
+ docs: {
16
+ description: {
17
+ component:
18
+ 'Demonstrates the ODS light/dark theme system. The `ThemeProvider` (wrapping `next-themes`) sets `data-theme="light|dark"` on `<html>`, and `src/styles/ods-colors.css` swaps the `--ods-*` primitives accordingly. Use `useThemeToggle()` to build your own toggle UI.',
19
+ },
20
+ },
21
+ },
22
+ } satisfies Meta
23
+
24
+ export default meta
25
+ type Story = StoryObj<typeof meta>
26
+
27
+ /* ------------------------------------------------------------------ */
28
+ /* Helpers */
29
+ /* ------------------------------------------------------------------ */
30
+
31
+ function ThemeToggleButton() {
32
+ const { isDark, toggle, mounted } = useThemeToggle()
33
+ return (
34
+ <Button
35
+ variant="outline"
36
+ onClick={toggle}
37
+ leftIcon={mounted ? (isDark ? <Sun /> : <Moon />) : undefined}
38
+ aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
39
+ >
40
+ {mounted ? (isDark ? 'Switch to light' : 'Switch to dark') : 'Toggle theme'}
41
+ </Button>
42
+ )
43
+ }
44
+
45
+ function ThemeStatusBar() {
46
+ const { theme, isDark, setTheme, toggle, mounted } = useThemeToggle()
47
+ return (
48
+ <div className="flex flex-wrap items-center gap-3 rounded-lg border border-ods-border bg-ods-card p-4">
49
+ <span className="text-body-sm text-ods-text-secondary">Current theme:</span>
50
+ <Badge variant={isDark ? 'secondary' : 'outline'} className="uppercase">
51
+ {mounted ? theme : '…'}
52
+ </Badge>
53
+ <div className="ml-auto flex flex-wrap gap-2">
54
+ <Button size="small" variant="outline" onClick={() => setTheme('light')}>
55
+ Set light
56
+ </Button>
57
+ <Button size="small" variant="outline" onClick={() => setTheme('dark')}>
58
+ Set dark
59
+ </Button>
60
+ <Button size="small" variant="accent" onClick={toggle}>
61
+ Toggle
62
+ </Button>
63
+ </div>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ interface SwatchProps {
69
+ name: string
70
+ /** Tailwind class that consumes the token (e.g. `bg-ods-bg`). */
71
+ className: string
72
+ /** Render dark text instead of light (for very light tokens). */
73
+ darkLabel?: boolean
74
+ }
75
+
76
+ function Swatch({ name, className, darkLabel }: SwatchProps) {
77
+ return (
78
+ <div className="flex flex-col gap-1">
79
+ <div
80
+ className={`${className} h-16 w-full rounded-md border border-ods-border flex items-end justify-start p-2`}
81
+ >
82
+ <span
83
+ className={`text-caption font-mono ${
84
+ darkLabel ? 'text-black/70' : 'text-white/80'
85
+ }`}
86
+ >
87
+ {name.replace(/^bg-|^text-|^border-/, '')}
88
+ </span>
89
+ </div>
90
+ <span className="text-caption text-ods-text-secondary font-mono">{name}</span>
91
+ </div>
92
+ )
93
+ }
94
+
95
+ function TokenGrid() {
96
+ return (
97
+ <section className="space-y-6">
98
+ <div>
99
+ <h3 className="text-h5 text-ods-text-primary mb-3">Surfaces</h3>
100
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
101
+ <Swatch name="bg-ods-bg" className="bg-ods-bg" />
102
+ <Swatch name="bg-ods-card" className="bg-ods-card" />
103
+ <Swatch name="bg-ods-bg-surface" className="bg-ods-bg-surface" />
104
+ <Swatch name="bg-ods-bg-hover" className="bg-ods-bg-hover" />
105
+ </div>
106
+ </div>
107
+
108
+ <div>
109
+ <h3 className="text-h5 text-ods-text-primary mb-3">Text</h3>
110
+ <div className="rounded-lg border border-ods-border bg-ods-card p-4 space-y-1">
111
+ <p className="text-ods-text-primary">text-ods-text-primary — primary text</p>
112
+ <p className="text-ods-text-secondary">text-ods-text-secondary — secondary text</p>
113
+ <p className="text-ods-text-tertiary">text-ods-text-tertiary — tertiary text</p>
114
+ <p className="text-ods-text-muted">text-ods-text-muted — muted text</p>
115
+ <p className="text-ods-text-disabled">text-ods-text-disabled — disabled text</p>
116
+ </div>
117
+ </div>
118
+
119
+ <div>
120
+ <h3 className="text-h5 text-ods-text-primary mb-3">Accent &amp; status</h3>
121
+ <div className="grid grid-cols-2 md:grid-cols-5 gap-3">
122
+ <Swatch name="bg-ods-accent" className="bg-ods-accent" darkLabel />
123
+ <Swatch name="bg-ods-success" className="bg-ods-success" />
124
+ <Swatch name="bg-ods-error" className="bg-ods-error" />
125
+ <Swatch name="bg-ods-warning" className="bg-ods-warning" darkLabel />
126
+ <Swatch name="bg-ods-info" className="bg-ods-info" darkLabel />
127
+ </div>
128
+ </div>
129
+
130
+ <div>
131
+ <h3 className="text-h5 text-ods-text-primary mb-3">Borders</h3>
132
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
133
+ <div className="h-16 rounded-md border-2 border-ods-border bg-ods-card flex items-center justify-center text-caption font-mono text-ods-text-secondary">
134
+ border-ods-border
135
+ </div>
136
+ <div className="h-16 rounded-md border-2 border-ods-border-hover bg-ods-card flex items-center justify-center text-caption font-mono text-ods-text-secondary">
137
+ border-ods-border-hover
138
+ </div>
139
+ <div className="h-16 rounded-md border-2 border-ods-border-focus bg-ods-card flex items-center justify-center text-caption font-mono text-ods-text-secondary">
140
+ border-ods-border-focus
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </section>
145
+ )
146
+ }
147
+
148
+ function ComponentsShowcase() {
149
+ return (
150
+ <section className="space-y-6">
151
+ <div>
152
+ <h3 className="text-h5 text-ods-text-primary mb-3">Buttons</h3>
153
+ <div className="flex flex-wrap gap-3">
154
+ <Button variant="accent">Accent</Button>
155
+ <Button variant="outline">Outline</Button>
156
+ <Button variant="transparent">Transparent</Button>
157
+ <Button variant="destructive">Destructive</Button>
158
+ <Button variant="outline" disabled>
159
+ Disabled
160
+ </Button>
161
+ <Button variant="accent" loading>
162
+ Loading
163
+ </Button>
164
+ </div>
165
+ </div>
166
+
167
+ <div>
168
+ <h3 className="text-h5 text-ods-text-primary mb-3">Inputs</h3>
169
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-w-xl">
170
+ <Input placeholder="Default input" />
171
+ <Input placeholder="With value" defaultValue="user@flamingo.cx" />
172
+ <Input placeholder="Invalid" invalid error="Required" />
173
+ <Input placeholder="Disabled" disabled />
174
+ </div>
175
+ </div>
176
+
177
+ <div>
178
+ <h3 className="text-h5 text-ods-text-primary mb-3">Badges</h3>
179
+ <div className="flex flex-wrap gap-2">
180
+ <Badge>Default</Badge>
181
+ <Badge variant="secondary">Secondary</Badge>
182
+ <Badge variant="outline">Outline</Badge>
183
+ <Badge variant="success">Success</Badge>
184
+ <Badge variant="destructive">Destructive</Badge>
185
+ </div>
186
+ </div>
187
+
188
+ <div>
189
+ <h3 className="text-h5 text-ods-text-primary mb-3">Cards</h3>
190
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
191
+ <Card>
192
+ <CardHeader>
193
+ <CardTitle>Card title</CardTitle>
194
+ <CardDescription>
195
+ Card surface and text adapt to the active theme.
196
+ </CardDescription>
197
+ </CardHeader>
198
+ <CardContent>
199
+ <p className="text-body-sm text-ods-text-secondary">
200
+ All colors come from <code className="text-ods-accent">--ods-*</code> tokens — the
201
+ same markup renders correctly in both themes.
202
+ </p>
203
+ </CardContent>
204
+ </Card>
205
+ <Card>
206
+ <CardHeader>
207
+ <CardTitle>Inputs &amp; actions</CardTitle>
208
+ <CardDescription>Try interacting — focus rings flip too.</CardDescription>
209
+ </CardHeader>
210
+ <CardContent className="space-y-3">
211
+ <Input placeholder="Type something…" />
212
+ <div className="flex gap-2">
213
+ <Button size="small" variant="accent">
214
+ Save
215
+ </Button>
216
+ <Button size="small" variant="outline">
217
+ Cancel
218
+ </Button>
219
+ </div>
220
+ </CardContent>
221
+ </Card>
222
+ </div>
223
+ </div>
224
+
225
+ <div>
226
+ <h3 className="text-h5 text-ods-text-primary mb-3">Alerts</h3>
227
+ <div className="space-y-3 max-w-2xl">
228
+ <Alert>
229
+ <AlertTitle>Informational</AlertTitle>
230
+ <AlertDescription>
231
+ Default alert surface — uses card background and primary text tokens.
232
+ </AlertDescription>
233
+ </Alert>
234
+ </div>
235
+ </div>
236
+ </section>
237
+ )
238
+ }
239
+
240
+ /* ------------------------------------------------------------------ */
241
+ /* Stories */
242
+ /* ------------------------------------------------------------------ */
243
+
244
+ /**
245
+ * Full showcase — toggle the theme and watch every primitive flip in place.
246
+ *
247
+ * The whole story is wrapped in `<ThemeProvider>`. `useThemeToggle()` is used
248
+ * to drive the toggle button (and `setTheme('light' | 'dark')` is wired to the
249
+ * explicit "Set light / Set dark" buttons). All visible color comes from ODS
250
+ * tokens, so nothing is hardcoded.
251
+ */
252
+ export const Showcase: Story = {
253
+ render: () => (
254
+ <ThemeProvider>
255
+ <div className="min-h-screen bg-ods-bg text-ods-text-primary p-6 md:p-10 space-y-8 transition-colors">
256
+ <header className="space-y-2">
257
+ <h1 className="text-h2 text-ods-text-primary">ODS theme switching</h1>
258
+ <p className="text-body text-ods-text-secondary max-w-2xl">
259
+ One <code className="text-ods-accent">data-theme</code> attribute on{' '}
260
+ <code className="text-ods-accent">&lt;html&gt;</code> flips every{' '}
261
+ <code className="text-ods-accent">--ods-*</code> primitive. Components below don&apos;t
262
+ know — and don&apos;t care — which theme is active; they read tokens.
263
+ </p>
264
+ </header>
265
+
266
+ <ThemeStatusBar />
267
+
268
+ <TokenGrid />
269
+ <ComponentsShowcase />
270
+ </div>
271
+ </ThemeProvider>
272
+ ),
273
+ }
274
+
275
+ /**
276
+ * Minimal example: just the toggle button and a single card.
277
+ *
278
+ * Useful as a copy-paste reference for what consumer apps need to do to add a
279
+ * theme switch: wrap once in `<ThemeProvider>`, then build any button you like
280
+ * around `useThemeToggle()`.
281
+ */
282
+ export const ToggleOnly: Story = {
283
+ render: () => (
284
+ <ThemeProvider>
285
+ <div className="min-h-screen bg-ods-bg text-ods-text-primary p-10 flex items-center justify-center transition-colors">
286
+ <Card className="w-full max-w-md">
287
+ <CardHeader>
288
+ <CardTitle>Theme toggle</CardTitle>
289
+ <CardDescription>
290
+ Click the button below — the entire surface, text and border swap themes.
291
+ </CardDescription>
292
+ </CardHeader>
293
+ <CardContent className="flex justify-center">
294
+ <ThemeToggleButton />
295
+ </CardContent>
296
+ </Card>
297
+ </div>
298
+ </ThemeProvider>
299
+ ),
300
+ }
301
+
302
+ /**
303
+ * Side-by-side: force light and dark on two halves of the screen at once using
304
+ * the `.theme-light` / `.theme-dark` class escape hatches (no provider needed
305
+ * for these — they directly scope the primitive overrides). Handy for visual
306
+ * diffing without flipping the document.
307
+ */
308
+ export const SideBySide: Story = {
309
+ render: () => (
310
+ <div className="grid grid-cols-1 md:grid-cols-2 min-h-screen">
311
+ {(['light', 'dark'] as const).map((mode) => (
312
+ <div
313
+ key={mode}
314
+ className={`theme-${mode} bg-ods-bg text-ods-text-primary p-6 space-y-4 border-r border-ods-border`}
315
+ >
316
+ <div className="flex items-center gap-2">
317
+ <Badge variant="outline" className="uppercase">
318
+ {mode}
319
+ </Badge>
320
+ <span className="text-body-sm text-ods-text-secondary">
321
+ scoped via <code className="text-ods-accent">.theme-{mode}</code>
322
+ </span>
323
+ </div>
324
+ <Card>
325
+ <CardHeader>
326
+ <CardTitle>Same component, different theme</CardTitle>
327
+ <CardDescription>
328
+ Both halves render identical JSX — only the wrapping class differs.
329
+ </CardDescription>
330
+ </CardHeader>
331
+ <CardContent className="space-y-3">
332
+ <Input placeholder="Email" defaultValue="hello@flamingo.cx" />
333
+ <div className="flex gap-2 flex-wrap">
334
+ <Button size="small" variant="accent">
335
+ Primary
336
+ </Button>
337
+ <Button size="small" variant="outline">
338
+ Secondary
339
+ </Button>
340
+ <Badge>Default</Badge>
341
+ <Badge variant="success">Success</Badge>
342
+ <Badge variant="destructive">Error</Badge>
343
+ </div>
344
+ </CardContent>
345
+ </Card>
346
+ </div>
347
+ ))}
348
+ </div>
349
+ ),
350
+ }