@flamingo-stack/openframe-frontend-core 0.0.201 → 0.0.202-snapshot.20260521221224

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 (130) hide show
  1. package/dist/{chunk-OII2IERE.cjs → chunk-25LVV26X.cjs} +4 -4
  2. package/dist/chunk-25LVV26X.cjs.map +1 -0
  3. package/dist/{chunk-UCY537V4.cjs → chunk-3YH2M76N.cjs} +1565 -1146
  4. package/dist/chunk-3YH2M76N.cjs.map +1 -0
  5. package/dist/{chunk-55HF462A.js → chunk-CPXLQ57U.js} +6 -7
  6. package/dist/chunk-CPXLQ57U.js.map +1 -0
  7. package/dist/{chunk-CSW5GYBU.js → chunk-E6Q6UGDK.js} +4603 -4184
  8. package/dist/chunk-E6Q6UGDK.js.map +1 -0
  9. package/dist/{chunk-3B43AHYE.cjs → chunk-RMB5DVED.cjs} +6 -7
  10. package/dist/chunk-RMB5DVED.cjs.map +1 -0
  11. package/dist/{chunk-4ML3NA2L.js → chunk-XGL5FKIK.js} +4 -4
  12. package/dist/chunk-XGL5FKIK.js.map +1 -0
  13. package/dist/components/chat/approval-request-message.d.ts.map +1 -1
  14. package/dist/components/chat/chat-container.d.ts.map +1 -1
  15. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  16. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  17. package/dist/components/chat/chat-ticket-item.d.ts.map +1 -1
  18. package/dist/components/chat/types/message.types.d.ts +34 -0
  19. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  20. package/dist/components/features/index.cjs +16 -4
  21. package/dist/components/features/index.cjs.map +1 -1
  22. package/dist/components/features/index.d.ts +1 -0
  23. package/dist/components/features/index.d.ts.map +1 -1
  24. package/dist/components/features/index.js +17 -5
  25. package/dist/components/features/select-button.d.ts.map +1 -1
  26. package/dist/components/index.cjs +18 -4
  27. package/dist/components/index.cjs.map +1 -1
  28. package/dist/components/index.js +17 -3
  29. package/dist/components/navigation/index.cjs +4 -4
  30. package/dist/components/navigation/index.js +3 -3
  31. package/dist/components/navigation/navigation-sidebar.d.ts.map +1 -1
  32. package/dist/components/providers/theme-provider.d.ts +69 -0
  33. package/dist/components/providers/theme-provider.d.ts.map +1 -0
  34. package/dist/components/resizable.d.ts +1 -1
  35. package/dist/components/ui/button/split-button.d.ts.map +1 -1
  36. package/dist/components/ui/data-table/data-table-row.d.ts +16 -4
  37. package/dist/components/ui/data-table/data-table-row.d.ts.map +1 -1
  38. package/dist/components/ui/file-manager/index.cjs +52 -52
  39. package/dist/components/ui/file-manager/index.cjs.map +1 -1
  40. package/dist/components/ui/file-manager/index.js +3 -3
  41. package/dist/components/ui/file-manager/index.js.map +1 -1
  42. package/dist/components/ui/floating-tooltip.d.ts +3 -1
  43. package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
  44. package/dist/components/ui/index.cjs +6 -4
  45. package/dist/components/ui/index.cjs.map +1 -1
  46. package/dist/components/ui/index.d.ts +1 -0
  47. package/dist/components/ui/index.d.ts.map +1 -1
  48. package/dist/components/ui/index.js +5 -3
  49. package/dist/components/ui/input-trigger.d.ts.map +1 -1
  50. package/dist/components/ui/radio-group.d.ts.map +1 -1
  51. package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
  52. package/dist/components/ui/ticket-info-section.d.ts.map +1 -1
  53. package/dist/components/ui/ticket-note-card.d.ts.map +1 -1
  54. package/dist/components/ui/truncate-text.d.ts +33 -0
  55. package/dist/components/ui/truncate-text.d.ts.map +1 -0
  56. package/dist/components/user-summary-stub.d.ts.map +1 -1
  57. package/dist/hooks/index.cjs +2 -2
  58. package/dist/hooks/index.js +1 -1
  59. package/dist/index.cjs +18 -4
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.js +17 -3
  62. package/package.json +2 -1
  63. package/src/components/chat/approval-request-message.tsx +106 -92
  64. package/src/components/chat/chat-container.tsx +10 -6
  65. package/src/components/chat/chat-message-enhanced.tsx +51 -9
  66. package/src/components/chat/chat-message-list.tsx +27 -19
  67. package/src/components/chat/chat-ticket-item.tsx +2 -3
  68. package/src/components/chat/types/message.types.ts +35 -0
  69. package/src/components/features/board/ticket-card.tsx +2 -2
  70. package/src/components/features/filters-dropdown.tsx +1 -1
  71. package/src/components/features/index.ts +15 -0
  72. package/src/components/features/notifications/notification-tile.tsx +2 -2
  73. package/src/components/features/policy-configuration-panel.tsx +1 -1
  74. package/src/components/features/push-button-selector.tsx +1 -1
  75. package/src/components/features/select-button.tsx +2 -3
  76. package/src/components/features/video-bites-display.tsx +1 -1
  77. package/src/components/features/waitlist-form.tsx +1 -1
  78. package/src/components/filter-chip.tsx +1 -1
  79. package/src/components/layout/title-block.tsx +2 -2
  80. package/src/components/navigation/header-organization-filter.tsx +1 -1
  81. package/src/components/navigation/navigation-sidebar.tsx +107 -54
  82. package/src/components/platform/ScriptInfoSection.tsx +1 -1
  83. package/src/components/providers/theme-provider.tsx +130 -0
  84. package/src/components/shared/onboarding/onboarding-step-card.tsx +2 -2
  85. package/src/components/shared/product-release/product-release-card.tsx +6 -6
  86. package/src/components/shared/product-release/release-detail-page.tsx +1 -1
  87. package/src/components/ui/assignee-dropdown.tsx +3 -3
  88. package/src/components/ui/autocomplete.tsx +2 -2
  89. package/src/components/ui/button/split-button.tsx +3 -5
  90. package/src/components/ui/checkbox-block.tsx +1 -1
  91. package/src/components/ui/data-table/data-table-row.tsx +82 -48
  92. package/src/components/ui/device-card-compact.tsx +2 -2
  93. package/src/components/ui/device-card.tsx +2 -2
  94. package/src/components/ui/entity-image.tsx +1 -1
  95. package/src/components/ui/field-wrapper.tsx +1 -1
  96. package/src/components/ui/file-manager/file-manager-table-row.tsx +2 -2
  97. package/src/components/ui/file-upload.tsx +2 -2
  98. package/src/components/ui/filter-list.tsx +1 -1
  99. package/src/components/ui/floating-tooltip.tsx +9 -5
  100. package/src/components/ui/hidden-tags-popup.tsx +1 -1
  101. package/src/components/ui/index.ts +1 -0
  102. package/src/components/ui/info-card.tsx +2 -2
  103. package/src/components/ui/input-trigger.tsx +1 -2
  104. package/src/components/ui/organization-card.tsx +3 -3
  105. package/src/components/ui/radio-group.tsx +2 -3
  106. package/src/components/ui/search-input.tsx +2 -2
  107. package/src/components/ui/service-card.tsx +3 -3
  108. package/src/components/ui/simple-markdown-renderer.tsx +248 -2
  109. package/src/components/ui/tag.tsx +1 -1
  110. package/src/components/ui/tags-manager.tsx +2 -2
  111. package/src/components/ui/ticket-attachments-list.tsx +1 -1
  112. package/src/components/ui/ticket-info-section.tsx +2 -3
  113. package/src/components/ui/ticket-note-card.tsx +4 -1
  114. package/src/components/ui/toaster.tsx +3 -3
  115. package/src/components/ui/truncate-text.tsx +116 -0
  116. package/src/components/user-summary-stub.tsx +32 -26
  117. package/src/components/vendor-display-button.tsx +1 -1
  118. package/src/stories/SplitButton.stories.tsx +7 -1
  119. package/src/stories/Theme.stories.tsx +350 -0
  120. package/src/styles/README.md +271 -174
  121. package/src/styles/dark_theme.tokens.json +982 -0
  122. package/src/styles/light_theme.tokens.json +982 -0
  123. package/src/styles/ods-colors.css +225 -146
  124. package/src/styles/ods_color_tokens.json +1 -300
  125. package/dist/chunk-3B43AHYE.cjs.map +0 -1
  126. package/dist/chunk-4ML3NA2L.js.map +0 -1
  127. package/dist/chunk-55HF462A.js.map +0 -1
  128. package/dist/chunk-CSW5GYBU.js.map +0 -1
  129. package/dist/chunk-OII2IERE.cjs.map +0 -1
  130. 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}
@@ -92,7 +92,7 @@ function Tag({
92
92
  {icon}
93
93
  </span>
94
94
  )}
95
- <span className={cn("truncate", labelClassName)}>{label}</span>
95
+ <span className={cn("truncate", labelClassName)} title={typeof label === 'string' ? label : undefined}>{label}</span>
96
96
  {onClose && (
97
97
  <button
98
98
  type="button"
@@ -329,7 +329,7 @@ export function TagsManager({
329
329
  }}
330
330
  >
331
331
  <div className="flex items-center justify-between w-full">
332
- <span className="truncate">{tag.name}</span>
332
+ <span className="truncate" title={tag.name}>{tag.name}</span>
333
333
  <div className="flex items-center gap-1 shrink-0">
334
334
  {isSelected && (
335
335
  <CheckIcon
@@ -401,7 +401,7 @@ export function TagsManager({
401
401
  size={16}
402
402
  className="text-ods-accent shrink-0"
403
403
  />
404
- <span className="text-ods-accent truncate">
404
+ <span className="text-ods-accent truncate" title={`Create "${search.trim()}"`}>
405
405
  Create &ldquo;{search.trim()}&rdquo;
406
406
  </span>
407
407
  </div>
@@ -44,7 +44,7 @@ export function TicketAttachmentsList({ attachments, className }: TicketAttachme
44
44
  </div>
45
45
  )}
46
46
  <div className="flex-1 min-w-0 overflow-hidden">
47
- <p className="text-h4 text-ods-text-primary truncate">{attachment.fileName}</p>
47
+ <p className="text-h4 text-ods-text-primary truncate" title={attachment.fileName}>{attachment.fileName}</p>
48
48
  <p className="text-h6 text-ods-text-secondary">{attachment.fileSize}</p>
49
49
  </div>
50
50
  {attachment.onDownload && (
@@ -89,12 +89,11 @@ function InfoCell({ value, label, icon, onClick }: {
89
89
  <button
90
90
  type="button"
91
91
  onClick={onClick}
92
- className="text-h4 text-ods-text-primary truncate hover:text-ods-accent transition-colors cursor-pointer text-left"
93
- >
92
+ className="text-h4 text-ods-text-primary truncate hover:text-ods-accent transition-colors cursor-pointer text-left" title={value}>
94
93
  {value}
95
94
  </button>
96
95
  ) : (
97
- <span className="text-h4 text-ods-text-primary truncate">{value}</span>
96
+ <span className="text-h4 text-ods-text-primary truncate" title={value}>{value}</span>
98
97
  )}
99
98
  </div>
100
99
  <span className="text-h6 text-ods-text-secondary truncate">{label}</span>
@@ -96,7 +96,10 @@ export function TicketNoteCard({ note, onEdit, onDelete, className }: TicketNote
96
96
  ) : (
97
97
  <>
98
98
  <p className="text-h4 text-ods-text-primary">{note.text}</p>
99
- <p className="text-h6 text-ods-text-secondary truncate">
99
+ <p
100
+ className="text-h6 text-ods-text-secondary truncate"
101
+ title={`${note.authorName} • ${note.createdAt}`}
102
+ >
100
103
  {note.authorName} &bull; {note.createdAt}
101
104
  </p>
102
105
  </>
@@ -61,10 +61,10 @@ function ToastHeader({
61
61
 
62
62
  <div className="flex min-w-0 flex-1 flex-col justify-center font-['DM_Sans'] font-medium">
63
63
  {title ? (
64
- <p className="truncate pr-5 text-[18px] leading-6 text-ods-text-primary">{title}</p>
64
+ <p className="truncate pr-5 text-[18px] leading-6 text-ods-text-primary" title={typeof title === 'string' ? title : undefined}>{title}</p>
65
65
  ) : null}
66
66
  {description ? (
67
- <p className="text-[14px] leading-5 text-ods-text-secondary line-clamp-3">{description}</p>
67
+ <p className="text-[14px] leading-5 text-ods-text-secondary line-clamp-3" title={typeof description === 'string' ? description : undefined}>{description}</p>
68
68
  ) : null}
69
69
  </div>
70
70
 
@@ -206,7 +206,7 @@ export function CommandApprovalToast({
206
206
  >
207
207
  <div className="overflow-hidden">
208
208
  <div className="flex h-11 w-full items-center gap-2 border-b border-ods-border bg-ods-card px-3 py-2">
209
- <p className="min-w-0 flex-1 truncate font-['DM_Sans'] text-[14px] font-medium leading-5 text-ods-text-primary">
209
+ <p className="min-w-0 flex-1 truncate font-['DM_Sans'] text-[14px] font-medium leading-5 text-ods-text-primary" title={command}>
210
210
  {command}
211
211
  </p>
212
212
  {toolType ? <ToolIcon toolType={toolType} size={16} /> : null}
@@ -0,0 +1,116 @@
1
+ 'use client'
2
+
3
+ import React, { type ReactNode, useEffect, useRef, useState } from 'react'
4
+ import { cn } from '../../utils/cn'
5
+ import { FloatingTooltip } from './floating-tooltip'
6
+
7
+ /** ODS typography variants. Maps to the `.text-h1`…`.text-h6` utilities. */
8
+ export type TruncateTextVariant = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
9
+
10
+ /** ODS text tone. Maps to the `text-ods-text-*` colour utilities. */
11
+ export type TruncateTextTone = 'primary' | 'secondary'
12
+
13
+ export interface TruncateTextProps {
14
+ children: string
15
+ /** Tooltip content; defaults to `children`. */
16
+ tooltip?: ReactNode
17
+ /** Extra classes merged after the variant/tone defaults. */
18
+ className?: string
19
+ side?: 'top' | 'right' | 'bottom' | 'left'
20
+ /** Max visible lines. `1` uses `truncate` (single-line ellipsis); higher values use `line-clamp-N`. */
21
+ lines?: 1 | 2 | 3 | 4 | 5 | 6
22
+ /** ODS typography token. Default: `'h4'` (body). */
23
+ variant?: TruncateTextVariant
24
+ /** ODS text tone. Default: `'primary'`. */
25
+ tone?: TruncateTextTone
26
+ /** Force the monospace (heading) font family — preserves the variant's size while swapping family. */
27
+ mono?: boolean
28
+ }
29
+
30
+ const VARIANT_CLASS: Record<TruncateTextVariant, string> = {
31
+ h1: 'text-h1',
32
+ h2: 'text-h2',
33
+ h3: 'text-h3',
34
+ h4: 'text-h4',
35
+ h5: 'text-h5',
36
+ h6: 'text-h6',
37
+ }
38
+
39
+ const TONE_CLASS: Record<TruncateTextTone, string> = {
40
+ primary: 'text-ods-text-primary',
41
+ secondary: 'text-ods-text-secondary',
42
+ }
43
+
44
+ const LINE_CLAMP_CLASS: Record<2 | 3 | 4 | 5 | 6, string> = {
45
+ 2: 'line-clamp-2',
46
+ 3: 'line-clamp-3',
47
+ 4: 'line-clamp-4',
48
+ 5: 'line-clamp-5',
49
+ 6: 'line-clamp-6',
50
+ }
51
+
52
+ /**
53
+ * Truncated text bound to the ODS typography system. Shows a `FloatingTooltip`
54
+ * with the full value when (and only when) the content overflows.
55
+ *
56
+ * ```tsx
57
+ * <TruncateText>{name}</TruncateText> // h4 / primary
58
+ * <TruncateText variant="h6" tone="secondary">{email}</TruncateText> // caption
59
+ * <TruncateText lines={3}>{description}</TruncateText> // 3-line clamp
60
+ * ```
61
+ */
62
+ export function TruncateText({
63
+ children,
64
+ tooltip,
65
+ className,
66
+ side = 'top',
67
+ lines = 1,
68
+ variant = 'h4',
69
+ tone = 'primary',
70
+ mono = false,
71
+ }: TruncateTextProps) {
72
+ const ref = useRef<HTMLSpanElement>(null)
73
+ const [isTruncated, setIsTruncated] = useState(false)
74
+ const isMultiLine = lines > 1
75
+
76
+ useEffect(() => {
77
+ const el = ref.current
78
+ if (!el) return
79
+ const check = () => {
80
+ const overflows = isMultiLine
81
+ ? el.scrollHeight > el.clientHeight + 1
82
+ : el.scrollWidth > el.clientWidth + 1
83
+ setIsTruncated(overflows)
84
+ }
85
+ check()
86
+ const ro = new ResizeObserver(check)
87
+ ro.observe(el)
88
+ return () => ro.disconnect()
89
+ }, [children, isMultiLine])
90
+
91
+ const clampClass = isMultiLine
92
+ ? LINE_CLAMP_CLASS[lines as Exclude<typeof lines, 1>]
93
+ : 'truncate block'
94
+
95
+ return (
96
+ <FloatingTooltip
97
+ content={tooltip ?? children}
98
+ side={side}
99
+ disabled={!isTruncated}
100
+ className="max-w-xs whitespace-pre-line [overflow-wrap:anywhere]"
101
+ >
102
+ <span
103
+ ref={ref}
104
+ className={cn(
105
+ VARIANT_CLASS[variant],
106
+ TONE_CLASS[tone],
107
+ mono && '[font-family:var(--font-family-heading)]',
108
+ clampClass,
109
+ className,
110
+ )}
111
+ >
112
+ {children}
113
+ </span>
114
+ </FloatingTooltip>
115
+ )
116
+ }
@@ -102,13 +102,16 @@ export function UserSummary({
102
102
  )}
103
103
  </div>
104
104
  <div className="min-w-0 flex-1">
105
- <p className="text-h4 text-ods-text-primary truncate">
105
+ <p
106
+ className="text-h4 text-ods-text-primary truncate"
107
+ title={mspPreview?.name ? `${name} • ${mspPreview.name}` : name}
108
+ >
106
109
  {name}
107
110
  {mspPreview?.name && (
108
111
  <span className="text-ods-text-secondary"> • {mspPreview.name}</span>
109
112
  )}
110
113
  </p>
111
- <p className="text-h6 text-ods-text-secondary truncate">
114
+ <p className="text-h6 text-ods-text-secondary truncate" title={subtitle && subtitle.trim().length > 0 ? subtitle : (email && email.trim().length > 0 ? email : '\u00A0')}>
112
115
  {subtitle && subtitle.trim().length > 0 ? subtitle : (email && email.trim().length > 0 ? email : '\u00A0')}
113
116
  </p>
114
117
  </div>
@@ -154,34 +157,37 @@ export function UserSummary({
154
157
  <div className="flex-1 grid grid-cols-[1fr_auto] gap-4">
155
158
  {/* LEFT : text stack */}
156
159
  <div className="min-h-[6rem] flex flex-col justify-center space-y-3 truncate">
157
- <p className="text-h2 text-ods-text-primary leading-none truncate">
160
+ <p className="text-h2 text-ods-text-primary leading-none truncate" title={name}>
158
161
  {name}
159
162
  </p>
160
- <p className="text-h4 text-ods-text-secondary break-all truncate">
163
+ <p className="text-h4 text-ods-text-secondary break-all truncate" title={(subtitle && subtitle.trim().length > 0) ? subtitle : (email && email.trim().length > 0 ? email : '\u00A0')}>
161
164
  {(subtitle && subtitle.trim().length > 0) ? subtitle : (email && email.trim().length > 0 ? email : '\u00A0')}
162
165
  </p>
163
- {mspPreview && (
164
- <p className="text-h6 text-ods-text-primary truncate">
165
- {/* Build string with separators */}
166
- {[
167
- mspPreview.name ?? '—',
168
- typeof mspPreview.seatCount === 'number'
169
- ? `${formatNumber(mspPreview.seatCount)} Seats`
170
- : null,
171
- typeof mspPreview.technicianCount === 'number'
172
- ? `${formatNumber(mspPreview.technicianCount)} Technicians`
173
- : null,
174
- typeof mspPreview.annualRevenue === 'number'
175
- ? `$${formatNumber(mspPreview.annualRevenue)}`
176
- : null,
177
- ]
178
- .filter(Boolean)
179
- .flatMap((txt, idx) => (idx === 0 ? [txt] : [' • ', txt]))
180
- .map((seg, idx) => (
181
- <span key={idx} className={seg === ' ' ? 'text-ods-text-secondary' : ''}>{seg}</span>
182
- ))}
183
- </p>
184
- )}
166
+ {mspPreview && (() => {
167
+ const mspSegments = [
168
+ mspPreview.name ?? '—',
169
+ typeof mspPreview.seatCount === 'number'
170
+ ? `${formatNumber(mspPreview.seatCount)} Seats`
171
+ : null,
172
+ typeof mspPreview.technicianCount === 'number'
173
+ ? `${formatNumber(mspPreview.technicianCount)} Technicians`
174
+ : null,
175
+ typeof mspPreview.annualRevenue === 'number'
176
+ ? `$${formatNumber(mspPreview.annualRevenue)}`
177
+ : null,
178
+ ].filter(Boolean) as string[];
179
+ const mspTitle = mspSegments.join(' • ');
180
+ return (
181
+ <p className="text-h6 text-ods-text-primary truncate" title={mspTitle}>
182
+ {/* Build string with separators */}
183
+ {mspSegments
184
+ .flatMap((txt, idx) => (idx === 0 ? [txt] : [' ', txt]))
185
+ .map((seg, idx) => (
186
+ <span key={idx} className={seg === ' • ' ? 'text-ods-text-secondary' : ''}>{seg}</span>
187
+ ))}
188
+ </p>
189
+ );
190
+ })()}
185
191
  </div>
186
192
 
187
193
  {/* RIGHT (desktop) */}
@@ -79,7 +79,7 @@ export function VendorDisplayButton({ vendor, onClick, variant = 'default', exte
79
79
  </span>
80
80
  </div>
81
81
  )}
82
- <span className="text-h4 text-ods-text-primary truncate min-w-0">
82
+ <span className="text-h4 text-ods-text-primary truncate min-w-0" title={vendor.title}>
83
83
  {vendor.title}
84
84
  </span>
85
85
  </button>
@@ -1,5 +1,11 @@
1
1
  import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
- import { ChevronDown, ExternalLink, MoreVertical, Save, Trash2 } from 'lucide-react'
2
+ import {
3
+ Chevron01DownIcon as ChevronDown,
4
+ Ellipsis02Icon as MoreVertical,
5
+ ExternalLinkIcon as ExternalLink,
6
+ FloppyDiscIcon as Save,
7
+ TrashIcon as Trash2,
8
+ } from '../components/icons-v2-generated'
3
9
  import React from 'react'
4
10
  import { SplitButton } from '../components/ui/button'
5
11