@charcuterie/ui 4.18.3 → 4.20.0

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 (68) hide show
  1. package/README.md +2 -0
  2. package/dist/DataTable/DataTable.d.ts +6 -1
  3. package/dist/DataTable/DataTable.d.ts.map +1 -1
  4. package/dist/DataTable/DataTable.js +104 -8
  5. package/dist/DataTable/DataTable.js.map +1 -1
  6. package/dist/MetricCard/MetricCard.d.ts +14 -0
  7. package/dist/MetricCard/MetricCard.d.ts.map +1 -0
  8. package/dist/MetricCard/MetricCard.js +10 -0
  9. package/dist/MetricCard/MetricCard.js.map +1 -0
  10. package/dist/SortableTableHeader/SortableTableHeader.js +1 -1
  11. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
  12. package/dist/htmlEditor/HtmlEditor.d.ts +25 -0
  13. package/dist/htmlEditor/HtmlEditor.d.ts.map +1 -0
  14. package/dist/htmlEditor/HtmlEditor.js +231 -0
  15. package/dist/htmlEditor/HtmlEditor.js.map +1 -0
  16. package/dist/htmlEditor/HtmlView.d.ts +10 -0
  17. package/dist/htmlEditor/HtmlView.d.ts.map +1 -0
  18. package/dist/htmlEditor/HtmlView.js +28 -0
  19. package/dist/htmlEditor/HtmlView.js.map +1 -0
  20. package/dist/htmlEditor/htmlContentStyles.d.ts +3 -0
  21. package/dist/htmlEditor/htmlContentStyles.d.ts.map +1 -0
  22. package/dist/htmlEditor/htmlContentStyles.js +3 -0
  23. package/dist/htmlEditor/htmlContentStyles.js.map +1 -0
  24. package/dist/htmlEditor/index.d.ts +8 -0
  25. package/dist/htmlEditor/index.d.ts.map +1 -0
  26. package/dist/htmlEditor/index.js +4 -0
  27. package/dist/htmlEditor/index.js.map +1 -0
  28. package/dist/htmlEditor/safeHtml.d.ts +12 -0
  29. package/dist/htmlEditor/safeHtml.d.ts.map +1 -0
  30. package/dist/htmlEditor/safeHtml.js +137 -0
  31. package/dist/htmlEditor/safeHtml.js.map +1 -0
  32. package/dist/htmlEditor/view.d.ts +6 -0
  33. package/dist/htmlEditor/view.d.ts.map +1 -0
  34. package/dist/htmlEditor/view.js +3 -0
  35. package/dist/htmlEditor/view.js.map +1 -0
  36. package/dist/index.d.ts +3 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/styles.css +46 -0
  41. package/package.json +52 -2
  42. package/src/DataTable/DataTable.mdx +9 -2
  43. package/src/DataTable/DataTable.stories.tsx +50 -0
  44. package/src/DataTable/DataTable.test.tsx +82 -0
  45. package/src/DataTable/DataTable.tsx +194 -17
  46. package/src/MetricCard/MetricCard.mdx +26 -0
  47. package/src/MetricCard/MetricCard.stories.tsx +104 -0
  48. package/src/MetricCard/MetricCard.test.tsx +64 -0
  49. package/src/MetricCard/MetricCard.tsx +51 -0
  50. package/src/SortableTableHeader/SortableTableHeader.tsx +1 -1
  51. package/src/htmlEditor/HtmlEditor.mdx +61 -0
  52. package/src/htmlEditor/HtmlEditor.stories.tsx +192 -0
  53. package/src/htmlEditor/HtmlEditor.test.tsx +296 -0
  54. package/src/htmlEditor/HtmlEditor.tsx +420 -0
  55. package/src/htmlEditor/HtmlView.mdx +37 -0
  56. package/src/htmlEditor/HtmlView.stories.tsx +119 -0
  57. package/src/htmlEditor/HtmlView.test.tsx +88 -0
  58. package/src/htmlEditor/HtmlView.tsx +71 -0
  59. package/src/htmlEditor/htmlContentStyles.ts +3 -0
  60. package/src/htmlEditor/htmlDocument.storyHelpers.ts +7 -0
  61. package/src/htmlEditor/index.ts +7 -0
  62. package/src/htmlEditor/safeHtml.test.ts +123 -0
  63. package/src/htmlEditor/safeHtml.ts +187 -0
  64. package/src/htmlEditor/view.ts +5 -0
  65. package/src/index.ts +3 -0
  66. package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +8 -4
  67. package/src/sourceRules.test.ts +24 -1
  68. package/src/styles.css +46 -0
@@ -0,0 +1,71 @@
1
+ import type { CSSProperties, ReactNode } from "react"
2
+ import { createElement } from "react"
3
+
4
+ import { TextLink } from "../TextLink/TextLink.tsx"
5
+ import { toClassName } from "../toClassName.ts"
6
+ import { HTML_CONTENT_CLASS } from "./htmlContentStyles.ts"
7
+ import type { HtmlContentNode } from "./safeHtml.ts"
8
+ import { toHtmlContent } from "./safeHtml.ts"
9
+
10
+ export type HtmlViewProps = {
11
+ className?: string
12
+ /** The readable document's accessible name. */
13
+ label: string
14
+ value: string
15
+ }
16
+
17
+ const toStyle = (
18
+ value: string | undefined,
19
+ ): CSSProperties =>
20
+ Object.fromEntries(
21
+ (value ?? "")
22
+ .split(";")
23
+ .filter(Boolean)
24
+ .map((declaration) => {
25
+ const [name, content] = declaration.split(":")
26
+ return [
27
+ name === "text-align" ? "textAlign" : name,
28
+ content,
29
+ ]
30
+ }),
31
+ )
32
+
33
+ const renderNode = (
34
+ node: HtmlContentNode,
35
+ key: number,
36
+ ): ReactNode => {
37
+ if ("text" in node) return node.text
38
+ const { style, ...attributes } = node.attributes
39
+ const children = node.children.map(renderNode)
40
+ if (node.tag === "a" && attributes.href !== undefined)
41
+ return (
42
+ <TextLink
43
+ href={attributes.href}
44
+ key={key}
45
+ style={toStyle(style)}
46
+ title={attributes.title}
47
+ >
48
+ {children}
49
+ </TextLink>
50
+ )
51
+ return createElement(
52
+ node.tag,
53
+ { ...attributes, key, style: toStyle(style) },
54
+ children.length ? children : undefined,
55
+ )
56
+ }
57
+
58
+ /** HTML is a distinct stored format. No editor and no innerHTML. */
59
+ export const HtmlView = ({
60
+ className,
61
+ label,
62
+ value,
63
+ }: HtmlViewProps): ReactNode => (
64
+ <div
65
+ aria-label={label}
66
+ className={toClassName(HTML_CONTENT_CLASS, className)}
67
+ role="document"
68
+ >
69
+ {toHtmlContent(value).map(renderNode)}
70
+ </div>
71
+ )
@@ -0,0 +1,3 @@
1
+ /** Identical document paint in the reader and the editing surface. */
2
+ export const HTML_CONTENT_CLASS =
3
+ "min-w-0 wrap-anywhere text-content-primary [&_p]:my-2 [&_div]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:ps-6 [&_li]:my-1 [&_blockquote]:my-3 [&_blockquote]:border-s-2 [&_blockquote]:border-border-default [&_blockquote]:ps-4 [&_blockquote]:text-content-secondary [&_pre]:whitespace-pre-wrap [&_pre]:rounded-md [&_pre]:bg-surface-raised [&_pre]:p-3 [&_code]:font-mono [&_h1]:my-3 [&_h1]:text-2xl [&_h1]:font-bold [&_h2]:my-3 [&_h2]:text-xl [&_h2]:font-bold [&_h3]:my-3 [&_h3]:text-lg [&_h3]:font-bold [&_h4]:my-2 [&_h4]:font-semibold [&_h5]:my-2 [&_h5]:font-semibold [&_h6]:my-2 [&_h6]:font-semibold [&_a]:text-intent-accent-content [&_a]:underline [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-md [&_hr]:my-3 [&_hr]:border-border-default"
@@ -0,0 +1,7 @@
1
+ export const HTML_DOCUMENT = `<p><strong>Workshop fixture</strong> &amp; setup notes.</p><p>Keep the <em>revision</em> and <u>material</u> together.</p><ul><li>Inspect the mounting holes.</li><li>Store the source file.</li></ul><ol start="3"><li>Build a fresh output.</li><li>Record the result.</li></ol><p style="text-align:center"><a href="https://example.invalid/recipe">Read the recipe</a></p><blockquote><p>Keep a traceable print recipe.</p></blockquote>`
2
+
3
+ export const ORIGINAL_HTML = `<DIV data-old-format='retained'><p class='legacy'>Original &amp; untouched</p></DIV>`
4
+
5
+ export const HOSTILE_HTML = `<p onclick="alert(1)">Visible notes</p><script>alert(1)</script><style>body{display:none}</style><iframe src="https://example.invalid/embed"></iframe><a href="java&#x73;cript:alert(1)">Unsafe link</a><img src="data:text/html,attack" onerror="alert(1)"><span style="color:currentcolor;background:url(https://example.invalid/tracker);position:fixed">Only safe text styling</span><svg><a href="javascript:alert(1)">Foreign content</a></svg>`
6
+
7
+ export const DOCUMENT_IMAGE = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="240" height="80"><rect width="240" height="80" rx="8" fill="steelblue"/><circle cx="120" cy="40" r="24" fill="white"/></svg>')}`
@@ -0,0 +1,7 @@
1
+ /** Opt-in rich HTML surfaces; no HTML conversion in markdown components. */
2
+ export type { HtmlEditorProps } from "./HtmlEditor.tsx"
3
+ export { HtmlEditor } from "./HtmlEditor.tsx"
4
+ export type { HtmlViewProps } from "./HtmlView.tsx"
5
+ export { HtmlView } from "./HtmlView.tsx"
6
+ export type { HtmlContentNode } from "./safeHtml.ts"
7
+ export { toHtmlContent, toSafeHtml } from "./safeHtml.ts"
@@ -0,0 +1,123 @@
1
+ import { createElement } from "react"
2
+ import { renderToStaticMarkup } from "react-dom/server"
3
+ import { expect, test } from "vitest"
4
+
5
+ import { HtmlView } from "./HtmlView.tsx"
6
+
7
+ import {
8
+ toHtmlContent,
9
+ toSafeHtml,
10
+ toSafeHtmlColour,
11
+ } from "./safeHtml.ts"
12
+
13
+ test("the reader renders safe HTML on the server without a DOM or editor runtime", () => {
14
+ expect(typeof window).toBe("undefined")
15
+ const html = renderToStaticMarkup(
16
+ createElement(HtmlView, {
17
+ label: "Server notes",
18
+ value:
19
+ '<p onclick="attack()">Stored &amp; safe</p><script>attack()</script><a href="javascript:attack()">Read</a>',
20
+ }),
21
+ )
22
+ expect(html).toContain("Stored &amp; safe")
23
+ expect(html).toContain('role="document"')
24
+ expect(html).not.toMatch(
25
+ /onclick|<script|href="javascript/,
26
+ )
27
+ })
28
+
29
+ test("keeps readable formatting while removing executable HTML and arbitrary source props", () => {
30
+ expect(
31
+ toSafeHtml(
32
+ '<div id="clobber" class="overlay"><p onclick="attack()"><strong>Keep</strong> &amp; read.</p><script>attack()</script><style>body{display:none}</style><iframe src="/embed">hidden</iframe><svg><text>hidden</text></svg><math><mtext>hidden</mtext></math></div>',
33
+ ),
34
+ ).toBe(
35
+ "<div><p><strong>Keep</strong> &amp; read.</p></div>",
36
+ )
37
+ })
38
+
39
+ test.each([
40
+ "javascript:attack()",
41
+ "java&#x73;cript:attack()",
42
+ "JaVa&#x09;Script:attack()",
43
+ "data:text/html,attack",
44
+ "file:///private",
45
+ "vbscript:attack()",
46
+ ])(
47
+ "decodes and rejects an unsafe link scheme: %s",
48
+ (href) => {
49
+ expect(
50
+ toSafeHtml(
51
+ `<a href="${href}" target="_blank" onclick="attack()">Read</a>`,
52
+ ),
53
+ ).toBe('<a rel="noopener noreferrer">Read</a>')
54
+ },
55
+ )
56
+
57
+ test.each([
58
+ "https://example.invalid/notes",
59
+ "/notes",
60
+ "./notes",
61
+ "#section",
62
+ "mailto:help@example.invalid",
63
+ "tel:1234",
64
+ ])("keeps a permitted link: %s", (href) => {
65
+ expect(toSafeHtml(`<a href="${href}">Read</a>`)).toBe(
66
+ `<a href="${href}" rel="noopener noreferrer">Read</a>`,
67
+ )
68
+ })
69
+
70
+ test("image attributes are closed and rejected image sources disappear", () => {
71
+ expect(
72
+ toSafeHtml(
73
+ '<img src="/photo.png" alt="A &amp; B" onerror="attack()" srcset="/tracker 2x" width="999999"><img src="javascript:attack()"><img src="data:text/html,attack"><img src="">',
74
+ ),
75
+ ).toBe('<img src="/photo.png" alt="A &amp; B">')
76
+ })
77
+
78
+ test("retains only document colour and alignment; CSS cannot load resources or escape the surface", () => {
79
+ expect(
80
+ toSafeHtml(
81
+ '<p style="position:fixed;inset:0;background:url(/tracker);color:rgb(20, 30, 40);text-align:center;font-size:9999px">Notes</p>',
82
+ ),
83
+ ).toBe(
84
+ '<p style="color:rgb(20, 30, 40);text-align:center">Notes</p>',
85
+ )
86
+ expect(
87
+ toSafeHtml(
88
+ '<span style="color:expression(attack());background-image:url(/tracker)">Notes</span>',
89
+ ),
90
+ ).toBe("<span>Notes</span>")
91
+ expect(
92
+ toSafeHtmlColour("red;position:fixed"),
93
+ ).toBeUndefined()
94
+ expect(toSafeHtmlColour("url(/tracker)")).toBeUndefined()
95
+ })
96
+
97
+ test("an HTML5 parse, rather than regex replacement, contains malformed foreign content", () => {
98
+ const safe = toSafeHtml(
99
+ '<math><mtext><table><mglyph><style><!--</style><img title="--><img src=x onerror=attack()>">',
100
+ )
101
+ expect(safe).not.toMatch(/onerror|<math|<style|<mglyph/)
102
+ expect(toSafeHtml(safe)).toBe(safe)
103
+ })
104
+
105
+ test("text entities and repeated sanitization retain visible meaning without writing back to the input", () => {
106
+ const original =
107
+ "<P class='old'>5 &lt; 6 &amp; \"quoted\"</P>"
108
+ const safe = toSafeHtml(original)
109
+ expect(safe).toBe(
110
+ "<p>5 &lt; 6 &amp; &quot;quoted&quot;</p>",
111
+ )
112
+ expect(toSafeHtml(safe)).toBe(safe)
113
+ expect(toHtmlContent(original)).toEqual([
114
+ {
115
+ tag: "p",
116
+ attributes: {},
117
+ children: [{ text: '5 < 6 & "quoted"' }],
118
+ },
119
+ ])
120
+ expect(original).toBe(
121
+ "<P class='old'>5 &lt; 6 &amp; \"quoted\"</P>",
122
+ )
123
+ })
@@ -0,0 +1,187 @@
1
+ import type { DefaultTreeAdapterMap } from "parse5"
2
+ import { parseFragment } from "parse5"
3
+
4
+ import {
5
+ toSafeImageUrl,
6
+ toSafeLinkUrl,
7
+ } from "../safeUrls.ts"
8
+
9
+ export type HtmlContentNode =
10
+ | { text: string }
11
+ | {
12
+ attributes: Record<string, string>
13
+ children: HtmlContentNode[]
14
+ tag: string
15
+ }
16
+
17
+ // One format policy for rendering, initial editor content, paste,
18
+ // and serialized edits. Parsing HTML5 before filtering also decodes
19
+ // character references in URLs; an encoded scheme cannot evade the
20
+ // shared URL guard. Nothing from the source becomes a React prop.
21
+ const TAGS = new Set([
22
+ "p",
23
+ "div",
24
+ "br",
25
+ "strong",
26
+ "b",
27
+ "em",
28
+ "i",
29
+ "u",
30
+ "s",
31
+ "strike",
32
+ "span",
33
+ "a",
34
+ "img",
35
+ "ul",
36
+ "ol",
37
+ "li",
38
+ "blockquote",
39
+ "h1",
40
+ "h2",
41
+ "h3",
42
+ "h4",
43
+ "h5",
44
+ "h6",
45
+ "pre",
46
+ "code",
47
+ "hr",
48
+ ])
49
+ const DROP_CONTENT = new Set([
50
+ "script",
51
+ "style",
52
+ "template",
53
+ "iframe",
54
+ "object",
55
+ "embed",
56
+ "svg",
57
+ "math",
58
+ "noscript",
59
+ ])
60
+ const VOID_TAGS = new Set(["br", "hr", "img"])
61
+
62
+ export const toSafeHtmlColour = (
63
+ value: string,
64
+ ): string | undefined => {
65
+ const colour = value.trim().toLowerCase()
66
+ return /^(#[\da-f]{3}|#[\da-f]{4}|#[\da-f]{6}|#[\da-f]{8}|[a-z]+|(?:rgb|rgba|hsl|hsla)\([\d.,%\s+-]+\))$/.test(
67
+ colour,
68
+ )
69
+ ? colour
70
+ : undefined
71
+ }
72
+
73
+ const toSafeStyle = (value: string): string =>
74
+ value
75
+ .split(";")
76
+ .flatMap((declaration) => {
77
+ const [rawName, ...parts] = declaration.split(":")
78
+ const name = rawName?.trim().toLowerCase()
79
+ const content = parts.join(":").trim().toLowerCase()
80
+
81
+ if (
82
+ name === "text-align" &&
83
+ /^(start|end|left|right|center|justify)$/.test(
84
+ content,
85
+ )
86
+ ) {
87
+ return [`text-align:${content}`]
88
+ }
89
+ if (
90
+ name === "color" &&
91
+ toSafeHtmlColour(content) !== undefined
92
+ ) {
93
+ return [`color:${content}`]
94
+ }
95
+ return []
96
+ })
97
+ .join(";")
98
+
99
+ const toSafeAttributes = (
100
+ tag: string,
101
+ attributes: DefaultTreeAdapterMap["element"]["attrs"],
102
+ ): Record<string, string> => {
103
+ const source = Object.fromEntries(
104
+ attributes
105
+ .filter((attribute) => !attribute.namespace)
106
+ .map(({ name, value }) => [name, value]),
107
+ )
108
+ const result: Record<string, string> = {}
109
+ const style = toSafeStyle(source.style ?? "")
110
+ if (style !== "") result.style = style
111
+
112
+ if (tag === "a") {
113
+ const href = toSafeLinkUrl(source.href ?? "")
114
+ if (source.href !== undefined && href !== undefined)
115
+ result.href = href
116
+ if (source.title) result.title = source.title
117
+ // Source target/rel attributes never reach the page.
118
+ result.rel = "noopener noreferrer"
119
+ }
120
+ if (tag === "img") {
121
+ const src = toSafeImageUrl(source.src ?? "")
122
+ if (src) result.src = src
123
+ result.alt = source.alt ?? ""
124
+ if (source.title) result.title = source.title
125
+ }
126
+ if (tag === "ol" && /^\d{1,6}$/.test(source.start ?? ""))
127
+ result.start = source.start ?? "1"
128
+ return result
129
+ }
130
+
131
+ const toSafeNodes = (
132
+ nodes: DefaultTreeAdapterMap["childNode"][],
133
+ ): HtmlContentNode[] =>
134
+ nodes.flatMap((node): HtmlContentNode[] => {
135
+ if (node.nodeName === "#text" && "value" in node)
136
+ return [{ text: node.value }]
137
+ if (!("tagName" in node)) return []
138
+ if (
139
+ DROP_CONTENT.has(node.tagName) ||
140
+ node.namespaceURI !== "http://www.w3.org/1999/xhtml"
141
+ )
142
+ return []
143
+ const children = toSafeNodes(node.childNodes)
144
+ if (!TAGS.has(node.tagName)) return children
145
+ const attributes = toSafeAttributes(
146
+ node.tagName,
147
+ node.attrs,
148
+ )
149
+ // A rejected image is absent, rather than a broken image or an
150
+ // empty src that makes another request for the document itself.
151
+ if (node.tagName === "img" && !attributes.src) return []
152
+ return [{ attributes, children, tag: node.tagName }]
153
+ })
154
+
155
+ export const toHtmlContent = (
156
+ value: string,
157
+ ): HtmlContentNode[] =>
158
+ toSafeNodes(parseFragment(value).childNodes)
159
+
160
+ const escapeHtml = (value: string) =>
161
+ value.replace(
162
+ /[&<>"]/g,
163
+ (character) =>
164
+ ({
165
+ "&": "&amp;",
166
+ "<": "&lt;",
167
+ ">": "&gt;",
168
+ '"': "&quot;",
169
+ })[character] ?? character,
170
+ )
171
+
172
+ const serialize = (node: HtmlContentNode): string => {
173
+ if ("text" in node) return escapeHtml(node.text)
174
+ const attributes = Object.entries(node.attributes)
175
+ .map(
176
+ ([name, value]) => ` ${name}="${escapeHtml(value)}"`,
177
+ )
178
+ .join("")
179
+ const start = `<${node.tag}${attributes}>`
180
+ return VOID_TAGS.has(node.tag)
181
+ ? start
182
+ : `${start}${node.children.map(serialize).join("")}</${node.tag}>`
183
+ }
184
+
185
+ /** A display/edit projection, never an instruction to rewrite stored HTML. */
186
+ export const toSafeHtml = (value: string): string =>
187
+ toHtmlContent(value).map(serialize).join("")
@@ -0,0 +1,5 @@
1
+ /** Read HTML without importing or installing the rich editor core. */
2
+ export type { HtmlViewProps } from "./HtmlView.tsx"
3
+ export { HtmlView } from "./HtmlView.tsx"
4
+ export type { HtmlContentNode } from "./safeHtml.ts"
5
+ export { toHtmlContent, toSafeHtml } from "./safeHtml.ts"
package/src/index.ts CHANGED
@@ -158,6 +158,7 @@ export {
158
158
  export type {
159
159
  DataTableColumn,
160
160
  DataTableProps,
161
+ DataTableReflowAt,
161
162
  DataTableSelection,
162
163
  DataTableSort,
163
164
  } from "./DataTable/DataTable.tsx"
@@ -316,6 +317,8 @@ export type {
316
317
  MenuProps,
317
318
  } from "./Menu/Menu.tsx"
318
319
  export { Menu } from "./Menu/Menu.tsx"
320
+ export type { MetricCardProps } from "./MetricCard/MetricCard.tsx"
321
+ export { MetricCard } from "./MetricCard/MetricCard.tsx"
319
322
  export type { ModalProps } from "./Modal/Modal.tsx"
320
323
  export { Modal } from "./Modal/Modal.tsx"
321
324
  export type {
@@ -367,10 +367,14 @@ test.each([
367
367
  async (_case, name) => {
368
368
  const { canvas } = await mountStory(NestedLinkText)
369
369
 
370
- const link = expectAgentDrivable(canvas, {
371
- name,
372
- role: "link",
373
- })
370
+ // CodeMirror finishes its viewport decorations on a scheduled frame.
371
+ // Wait for the accessible anchor rather than assuming mount means paint.
372
+ const link = await waitFor(() =>
373
+ expectAgentDrivable(canvas, {
374
+ name,
375
+ role: "link",
376
+ }),
377
+ )
374
378
 
375
379
  await expect(link.tagName).toBe("A")
376
380
  },
@@ -621,7 +621,8 @@ test("the barrel is the only place components are re-exported", async () => {
621
621
  // ValuePairs owns compact label-over-value summaries.
622
622
  // Chart shares portable SVG geometry with Preact and image consumers.
623
623
  // TableViewport owns readable comparisons and full-window expansion.
624
- expect(componentNames.length).toBe(74)
624
+ // MetricCard shares prominent values and category-coloured icons through Card.
625
+ expect(componentNames.length).toBe(75)
625
626
 
626
627
  for (const name of componentNames) {
627
628
  expect(barrel).toContain(`export { ${name} }`)
@@ -781,6 +782,28 @@ const ENTRY_POINT_RUNTIMES: Record<
781
782
  // silently, which is how it shipped broken in three repos.
782
783
  "tailwind-merge",
783
784
  ],
785
+ // Retained HTML has its own format family and optional rich editor
786
+ // peers. The main entry never reaches this parser or editor core.
787
+ "./html-view": [
788
+ "@charcuterie/tokens",
789
+ "parse5",
790
+ "react",
791
+ "tailwind-merge",
792
+ ],
793
+ "./html-editor": [
794
+ "@charcuterie/logic",
795
+ "@charcuterie/logic/browser",
796
+ "@charcuterie/tokens",
797
+ "@floating-ui/react",
798
+ "@tiptap/extension-image",
799
+ "@tiptap/extension-text-align",
800
+ "@tiptap/extension-text-style",
801
+ "@tiptap/react",
802
+ "@tiptap/starter-kit",
803
+ "parse5",
804
+ "react",
805
+ "tailwind-merge",
806
+ ],
784
807
  // The opt-in live-preview editor. Every `@codemirror/*` and
785
808
  // `@lezer/*` package appears **only** here, as an optional peer,
786
809
  // which is the entire reason it is a subpath: measured at ~176 KB
package/src/styles.css CHANGED
@@ -260,3 +260,49 @@
260
260
  th:first-child {
261
261
  z-index: 3;
262
262
  }
263
+
264
+ /* Column width utilities describe comparison columns, never labelled list cells.
265
+ These rules are unlayered, like the component's existing structural styles,
266
+ so a consumer's cq-lg:w-32 cannot crush a list reflowing at cq-xl. */
267
+ .charcuterie-data-table[data-layout="stacked"]
268
+ > table
269
+ > :is(thead, tbody)
270
+ > tr
271
+ > :is(th, td) {
272
+ inline-size: auto;
273
+ min-inline-size: 0;
274
+ max-inline-size: none;
275
+ }
276
+ @container (width < 32rem) {
277
+ .charcuterie-data-table[data-reflow-at="md"]
278
+ > table
279
+ > :is(thead, tbody)
280
+ > tr
281
+ > :is(th, td) {
282
+ inline-size: auto;
283
+ min-inline-size: 0;
284
+ max-inline-size: none;
285
+ }
286
+ }
287
+ @container (width < 48rem) {
288
+ .charcuterie-data-table[data-reflow-at="lg"]
289
+ > table
290
+ > :is(thead, tbody)
291
+ > tr
292
+ > :is(th, td) {
293
+ inline-size: auto;
294
+ min-inline-size: 0;
295
+ max-inline-size: none;
296
+ }
297
+ }
298
+ @container (width < 64rem) {
299
+ .charcuterie-data-table[data-reflow-at="xl"]
300
+ > table
301
+ > :is(thead, tbody)
302
+ > tr
303
+ > :is(th, td) {
304
+ inline-size: auto;
305
+ min-inline-size: 0;
306
+ max-inline-size: none;
307
+ }
308
+ }