@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.
- package/dist/{chunk-CSW5GYBU.js → chunk-IDULPYOU.js} +3997 -3734
- package/dist/chunk-IDULPYOU.js.map +1 -0
- package/dist/{chunk-UCY537V4.cjs → chunk-JIKTMXTZ.cjs} +952 -689
- package/dist/chunk-JIKTMXTZ.cjs.map +1 -0
- package/dist/components/chat/approval-request-message.d.ts.map +1 -1
- package/dist/components/chat/chat-container.d.ts.map +1 -1
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/types/message.types.d.ts +34 -0
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/features/index.cjs +14 -2
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +1 -0
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +15 -3
- package/dist/components/index.cjs +14 -2
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +13 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/providers/theme-provider.d.ts +69 -0
- package/dist/components/providers/theme-provider.d.ts.map +1 -0
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
- package/dist/index.cjs +14 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +13 -1
- package/package.json +2 -1
- package/src/components/chat/approval-request-message.tsx +106 -92
- package/src/components/chat/chat-container.tsx +8 -4
- package/src/components/chat/chat-message-enhanced.tsx +51 -9
- package/src/components/chat/chat-message-list.tsx +27 -19
- package/src/components/chat/types/message.types.ts +35 -0
- package/src/components/features/index.ts +15 -0
- package/src/components/providers/theme-provider.tsx +130 -0
- package/src/components/ui/simple-markdown-renderer.tsx +248 -2
- package/src/stories/Theme.stories.tsx +350 -0
- package/src/styles/README.md +271 -174
- package/src/styles/dark_theme.tokens.json +982 -0
- package/src/styles/light_theme.tokens.json +982 -0
- package/src/styles/ods-colors.css +225 -146
- package/src/styles/ods_color_tokens.json +1 -300
- package/dist/chunk-CSW5GYBU.js.map +0 -1
- 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 `<${slash}${tag}${rest}${selfClose}>`
|
|
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
|
-
|
|
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-
|
|
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 & 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 & 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"><html></code> flips every{' '}
|
|
261
|
+
<code className="text-ods-accent">--ods-*</code> primitive. Components below don't
|
|
262
|
+
know — and don'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
|
+
}
|