@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.
- package/dist/{chunk-OII2IERE.cjs → chunk-25LVV26X.cjs} +4 -4
- package/dist/chunk-25LVV26X.cjs.map +1 -0
- package/dist/{chunk-UCY537V4.cjs → chunk-3YH2M76N.cjs} +1565 -1146
- package/dist/chunk-3YH2M76N.cjs.map +1 -0
- package/dist/{chunk-55HF462A.js → chunk-CPXLQ57U.js} +6 -7
- package/dist/chunk-CPXLQ57U.js.map +1 -0
- package/dist/{chunk-CSW5GYBU.js → chunk-E6Q6UGDK.js} +4603 -4184
- package/dist/chunk-E6Q6UGDK.js.map +1 -0
- package/dist/{chunk-3B43AHYE.cjs → chunk-RMB5DVED.cjs} +6 -7
- package/dist/chunk-RMB5DVED.cjs.map +1 -0
- package/dist/{chunk-4ML3NA2L.js → chunk-XGL5FKIK.js} +4 -4
- package/dist/chunk-XGL5FKIK.js.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/chat-ticket-item.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 +16 -4
- 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 +17 -5
- package/dist/components/features/select-button.d.ts.map +1 -1
- package/dist/components/index.cjs +18 -4
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +17 -3
- package/dist/components/navigation/index.cjs +4 -4
- package/dist/components/navigation/index.js +3 -3
- package/dist/components/navigation/navigation-sidebar.d.ts.map +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/resizable.d.ts +1 -1
- package/dist/components/ui/button/split-button.d.ts.map +1 -1
- package/dist/components/ui/data-table/data-table-row.d.ts +16 -4
- package/dist/components/ui/data-table/data-table-row.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +52 -52
- package/dist/components/ui/file-manager/index.cjs.map +1 -1
- package/dist/components/ui/file-manager/index.js +3 -3
- package/dist/components/ui/file-manager/index.js.map +1 -1
- package/dist/components/ui/floating-tooltip.d.ts +3 -1
- package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +6 -4
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +5 -3
- package/dist/components/ui/input-trigger.d.ts.map +1 -1
- package/dist/components/ui/radio-group.d.ts.map +1 -1
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
- package/dist/components/ui/ticket-info-section.d.ts.map +1 -1
- package/dist/components/ui/ticket-note-card.d.ts.map +1 -1
- package/dist/components/ui/truncate-text.d.ts +33 -0
- package/dist/components/ui/truncate-text.d.ts.map +1 -0
- package/dist/components/user-summary-stub.d.ts.map +1 -1
- package/dist/hooks/index.cjs +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/index.cjs +18 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +17 -3
- package/package.json +2 -1
- package/src/components/chat/approval-request-message.tsx +106 -92
- package/src/components/chat/chat-container.tsx +10 -6
- package/src/components/chat/chat-message-enhanced.tsx +51 -9
- package/src/components/chat/chat-message-list.tsx +27 -19
- package/src/components/chat/chat-ticket-item.tsx +2 -3
- package/src/components/chat/types/message.types.ts +35 -0
- package/src/components/features/board/ticket-card.tsx +2 -2
- package/src/components/features/filters-dropdown.tsx +1 -1
- package/src/components/features/index.ts +15 -0
- package/src/components/features/notifications/notification-tile.tsx +2 -2
- package/src/components/features/policy-configuration-panel.tsx +1 -1
- package/src/components/features/push-button-selector.tsx +1 -1
- package/src/components/features/select-button.tsx +2 -3
- package/src/components/features/video-bites-display.tsx +1 -1
- package/src/components/features/waitlist-form.tsx +1 -1
- package/src/components/filter-chip.tsx +1 -1
- package/src/components/layout/title-block.tsx +2 -2
- package/src/components/navigation/header-organization-filter.tsx +1 -1
- package/src/components/navigation/navigation-sidebar.tsx +107 -54
- package/src/components/platform/ScriptInfoSection.tsx +1 -1
- package/src/components/providers/theme-provider.tsx +130 -0
- package/src/components/shared/onboarding/onboarding-step-card.tsx +2 -2
- package/src/components/shared/product-release/product-release-card.tsx +6 -6
- package/src/components/shared/product-release/release-detail-page.tsx +1 -1
- package/src/components/ui/assignee-dropdown.tsx +3 -3
- package/src/components/ui/autocomplete.tsx +2 -2
- package/src/components/ui/button/split-button.tsx +3 -5
- package/src/components/ui/checkbox-block.tsx +1 -1
- package/src/components/ui/data-table/data-table-row.tsx +82 -48
- package/src/components/ui/device-card-compact.tsx +2 -2
- package/src/components/ui/device-card.tsx +2 -2
- package/src/components/ui/entity-image.tsx +1 -1
- package/src/components/ui/field-wrapper.tsx +1 -1
- package/src/components/ui/file-manager/file-manager-table-row.tsx +2 -2
- package/src/components/ui/file-upload.tsx +2 -2
- package/src/components/ui/filter-list.tsx +1 -1
- package/src/components/ui/floating-tooltip.tsx +9 -5
- package/src/components/ui/hidden-tags-popup.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/info-card.tsx +2 -2
- package/src/components/ui/input-trigger.tsx +1 -2
- package/src/components/ui/organization-card.tsx +3 -3
- package/src/components/ui/radio-group.tsx +2 -3
- package/src/components/ui/search-input.tsx +2 -2
- package/src/components/ui/service-card.tsx +3 -3
- package/src/components/ui/simple-markdown-renderer.tsx +248 -2
- package/src/components/ui/tag.tsx +1 -1
- package/src/components/ui/tags-manager.tsx +2 -2
- package/src/components/ui/ticket-attachments-list.tsx +1 -1
- package/src/components/ui/ticket-info-section.tsx +2 -3
- package/src/components/ui/ticket-note-card.tsx +4 -1
- package/src/components/ui/toaster.tsx +3 -3
- package/src/components/ui/truncate-text.tsx +116 -0
- package/src/components/user-summary-stub.tsx +32 -26
- package/src/components/vendor-display-button.tsx +1 -1
- package/src/stories/SplitButton.stories.tsx +7 -1
- 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-3B43AHYE.cjs.map +0 -1
- package/dist/chunk-4ML3NA2L.js.map +0 -1
- package/dist/chunk-55HF462A.js.map +0 -1
- package/dist/chunk-CSW5GYBU.js.map +0 -1
- package/dist/chunk-OII2IERE.cjs.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}
|
|
@@ -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 “{search.trim()}”
|
|
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
|
|
99
|
+
<p
|
|
100
|
+
className="text-h6 text-ods-text-secondary truncate"
|
|
101
|
+
title={`${note.authorName} • ${note.createdAt}`}
|
|
102
|
+
>
|
|
100
103
|
{note.authorName} • {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
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
mspPreview.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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 {
|
|
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
|
|