@bkstg/design-systems 1.0.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 (126) hide show
  1. package/README.md +2 -0
  2. package/bkstg/latest/README.md +46 -0
  3. package/bkstg/latest/bkstg.css +1668 -0
  4. package/bkstg/latest/bkstg.js +703 -0
  5. package/bkstg/latest/charts-core.mjs +349 -0
  6. package/bkstg/latest/charts.js +58 -0
  7. package/bkstg/latest/motion.js +168 -0
  8. package/bkstg/latest/react/ActivityFeed.jsx +18 -0
  9. package/bkstg/latest/react/Alert.jsx +28 -0
  10. package/bkstg/latest/react/Annotations.jsx +24 -0
  11. package/bkstg/latest/react/AppShell.jsx +87 -0
  12. package/bkstg/latest/react/Avatar.jsx +19 -0
  13. package/bkstg/latest/react/Badge.jsx +14 -0
  14. package/bkstg/latest/react/Banner.jsx +17 -0
  15. package/bkstg/latest/react/Breadcrumbs.jsx +23 -0
  16. package/bkstg/latest/react/Button.jsx +31 -0
  17. package/bkstg/latest/react/ButtonGroup.jsx +29 -0
  18. package/bkstg/latest/react/Calendar.jsx +57 -0
  19. package/bkstg/latest/react/CardHeader.jsx +14 -0
  20. package/bkstg/latest/react/Chart.jsx +42 -0
  21. package/bkstg/latest/react/Chat.jsx +34 -0
  22. package/bkstg/latest/react/Checkbox.jsx +32 -0
  23. package/bkstg/latest/react/CodeSnippet.jsx +17 -0
  24. package/bkstg/latest/react/ColorPicker.jsx +35 -0
  25. package/bkstg/latest/react/CommandMenu.jsx +54 -0
  26. package/bkstg/latest/react/DatePicker.jsx +34 -0
  27. package/bkstg/latest/react/DeviceFrame.jsx +12 -0
  28. package/bkstg/latest/react/Dialog.jsx +43 -0
  29. package/bkstg/latest/react/Divider.jsx +10 -0
  30. package/bkstg/latest/react/Editor.jsx +85 -0
  31. package/bkstg/latest/react/EmptyState.jsx +13 -0
  32. package/bkstg/latest/react/Field.jsx +32 -0
  33. package/bkstg/latest/react/FileUpload.jsx +38 -0
  34. package/bkstg/latest/react/Filters.jsx +19 -0
  35. package/bkstg/latest/react/Infographics.jsx +54 -0
  36. package/bkstg/latest/react/InlineCTA.jsx +14 -0
  37. package/bkstg/latest/react/LineChart.jsx +23 -0
  38. package/bkstg/latest/react/Marketing.jsx +161 -0
  39. package/bkstg/latest/react/Menu.jsx +76 -0
  40. package/bkstg/latest/react/Metric.jsx +28 -0
  41. package/bkstg/latest/react/PageHeader.jsx +22 -0
  42. package/bkstg/latest/react/Pagination.jsx +32 -0
  43. package/bkstg/latest/react/Progress.jsx +26 -0
  44. package/bkstg/latest/react/RadioGroup.jsx +27 -0
  45. package/bkstg/latest/react/Reveal.jsx +17 -0
  46. package/bkstg/latest/react/SectionHeader.jsx +24 -0
  47. package/bkstg/latest/react/Select.jsx +19 -0
  48. package/bkstg/latest/react/Sheet.jsx +25 -0
  49. package/bkstg/latest/react/SideNav.jsx +37 -0
  50. package/bkstg/latest/react/Skeleton.jsx +16 -0
  51. package/bkstg/latest/react/Slider.jsx +27 -0
  52. package/bkstg/latest/react/Spinner.jsx +14 -0
  53. package/bkstg/latest/react/Steps.jsx +22 -0
  54. package/bkstg/latest/react/Switch.jsx +11 -0
  55. package/bkstg/latest/react/Table.jsx +24 -0
  56. package/bkstg/latest/react/Tabs.jsx +80 -0
  57. package/bkstg/latest/react/Tag.jsx +34 -0
  58. package/bkstg/latest/react/Tooltip.jsx +25 -0
  59. package/bkstg/latest/react/Tree.jsx +41 -0
  60. package/bkstg/latest/react/VideoPlayer.jsx +57 -0
  61. package/bkstg/latest/react/cx.js +4 -0
  62. package/bkstg/latest/react/index.js +59 -0
  63. package/bkstg/latest/system.json +24 -0
  64. package/bkstg/v1/README.md +46 -0
  65. package/bkstg/v1/bkstg.css +1668 -0
  66. package/bkstg/v1/bkstg.js +703 -0
  67. package/bkstg/v1/charts-core.mjs +349 -0
  68. package/bkstg/v1/charts.js +58 -0
  69. package/bkstg/v1/motion.js +168 -0
  70. package/bkstg/v1/react/ActivityFeed.jsx +18 -0
  71. package/bkstg/v1/react/Alert.jsx +28 -0
  72. package/bkstg/v1/react/Annotations.jsx +24 -0
  73. package/bkstg/v1/react/AppShell.jsx +87 -0
  74. package/bkstg/v1/react/Avatar.jsx +19 -0
  75. package/bkstg/v1/react/Badge.jsx +14 -0
  76. package/bkstg/v1/react/Banner.jsx +17 -0
  77. package/bkstg/v1/react/Breadcrumbs.jsx +23 -0
  78. package/bkstg/v1/react/Button.jsx +31 -0
  79. package/bkstg/v1/react/ButtonGroup.jsx +29 -0
  80. package/bkstg/v1/react/Calendar.jsx +57 -0
  81. package/bkstg/v1/react/CardHeader.jsx +14 -0
  82. package/bkstg/v1/react/Chart.jsx +42 -0
  83. package/bkstg/v1/react/Chat.jsx +34 -0
  84. package/bkstg/v1/react/Checkbox.jsx +32 -0
  85. package/bkstg/v1/react/CodeSnippet.jsx +17 -0
  86. package/bkstg/v1/react/ColorPicker.jsx +35 -0
  87. package/bkstg/v1/react/CommandMenu.jsx +54 -0
  88. package/bkstg/v1/react/DatePicker.jsx +34 -0
  89. package/bkstg/v1/react/DeviceFrame.jsx +12 -0
  90. package/bkstg/v1/react/Dialog.jsx +43 -0
  91. package/bkstg/v1/react/Divider.jsx +10 -0
  92. package/bkstg/v1/react/Editor.jsx +85 -0
  93. package/bkstg/v1/react/EmptyState.jsx +13 -0
  94. package/bkstg/v1/react/Field.jsx +32 -0
  95. package/bkstg/v1/react/FileUpload.jsx +38 -0
  96. package/bkstg/v1/react/Filters.jsx +19 -0
  97. package/bkstg/v1/react/Infographics.jsx +54 -0
  98. package/bkstg/v1/react/InlineCTA.jsx +14 -0
  99. package/bkstg/v1/react/LineChart.jsx +23 -0
  100. package/bkstg/v1/react/Marketing.jsx +161 -0
  101. package/bkstg/v1/react/Menu.jsx +76 -0
  102. package/bkstg/v1/react/Metric.jsx +28 -0
  103. package/bkstg/v1/react/PageHeader.jsx +22 -0
  104. package/bkstg/v1/react/Pagination.jsx +32 -0
  105. package/bkstg/v1/react/Progress.jsx +26 -0
  106. package/bkstg/v1/react/RadioGroup.jsx +27 -0
  107. package/bkstg/v1/react/Reveal.jsx +17 -0
  108. package/bkstg/v1/react/SectionHeader.jsx +24 -0
  109. package/bkstg/v1/react/Select.jsx +19 -0
  110. package/bkstg/v1/react/Sheet.jsx +25 -0
  111. package/bkstg/v1/react/SideNav.jsx +37 -0
  112. package/bkstg/v1/react/Skeleton.jsx +16 -0
  113. package/bkstg/v1/react/Slider.jsx +27 -0
  114. package/bkstg/v1/react/Spinner.jsx +14 -0
  115. package/bkstg/v1/react/Steps.jsx +22 -0
  116. package/bkstg/v1/react/Switch.jsx +11 -0
  117. package/bkstg/v1/react/Table.jsx +24 -0
  118. package/bkstg/v1/react/Tabs.jsx +80 -0
  119. package/bkstg/v1/react/Tag.jsx +34 -0
  120. package/bkstg/v1/react/Tooltip.jsx +25 -0
  121. package/bkstg/v1/react/Tree.jsx +41 -0
  122. package/bkstg/v1/react/VideoPlayer.jsx +57 -0
  123. package/bkstg/v1/react/cx.js +4 -0
  124. package/bkstg/v1/react/index.js +59 -0
  125. package/bkstg/v1/system.json +24 -0
  126. package/package.json +14 -0
@@ -0,0 +1,349 @@
1
+ // charts-core.mjs: the kit's charts as pure geometry. One function turns data into a tree of SVG and HTML nodes;
2
+ // toHtml() writes it as a string (charts.js, for any site), toReact() builds the same nodes as React elements
3
+ // (react/Chart.jsx), so both draw the same chart. check-react.mjs renders one chart both ways and compares them.
4
+ //
5
+ // Every chart is drawn complete; animate() only plays it in (bars grow, lines draw, rings and donuts sweep, numbers
6
+ // count) when the motion level allows, so Still, the device's reduce-motion setting, or a stalled observer all
7
+ // leave the finished chart showing. Each chart carries a data table for screen readers; the SVG is decoration.
8
+ //
9
+ // Colors: one series uses the primary (--chart-line); several use --series-1.. in order, never cycled; a tone
10
+ // (good, warn, neg) is for data that means good or bad. Fills sit 2px apart in the card's color (WCAG 1.4.11:
11
+ // each then only needs 3:1 against the card). Sizes are in real pixels: the caller passes the width it has.
12
+
13
+ export const VIZ_TYPES = ["bar", "line", "area", "donut", "pie", "spark", "ring", "meter", "gauge", "heatmap", "funnel"];
14
+ const GAP = 2, R = 4; // the gap between fills, the rounding of a bar's free end
15
+
16
+ // ── data ─────────────────────────────────────────────────────────────────────
17
+ /** Read a chart from data attributes (any site, no code). Returns { ok, spec } or { ok: false, error }.
18
+ * data-ui-viz bar | line | area | donut | pie | spark | ring | meter
19
+ * data-label what the chart shows (the table's caption and the figure's name)
20
+ * data-labels categories, comma separated: "Jan,Feb,Mar"
21
+ * data-values one series: "12,18,9" or data-series several: "Desktop: 12,18,9; Mobile: 4,6,8"
22
+ * data-stacked stack the series (bar) data-horizontal bars run sideways (bar)
23
+ * data-value, data-max a ring's value and its whole (max defaults to 100)
24
+ * data-prefix, data-suffix, data-decimals how numbers read: "$", "%", 1
25
+ * data-tone good | warn | neg for one series that means good or bad
26
+ * data-center the words under a donut's or ring's middle number ("Leads")
27
+ * data-bars a sparkline of bars instead of a line; data-max on a meter: the whole (the rest shows as free)
28
+ * gauge data-value and data-max, like a ring, drawn as a half circle
29
+ * heatmap data-series, one row each ("Mon: 1,4,2; Tue: 3,0,5"), data-labels for the columns
30
+ * funnel data-values per stage, data-labels for the stages, widest first */
31
+ export function specFrom(get) {
32
+ const type = (get("data-ui-viz") || "").trim();
33
+ if (!VIZ_TYPES.includes(type)) return { ok: false, error: `data-ui-viz must be one of: ${VIZ_TYPES.join(", ")}.` };
34
+ const num = s => { const n = Number(String(s).trim()); return Number.isFinite(n) ? n : NaN; };
35
+ const labels = (get("data-labels") || "").split(",").map(s => s.trim()).filter(Boolean);
36
+ let series = [];
37
+ const raw = get("data-series");
38
+ if (raw) {
39
+ for (const part of raw.split(";").map(s => s.trim()).filter(Boolean)) {
40
+ const i = part.indexOf(":"); if (i < 1) return { ok: false, error: `data-series: "${part.slice(0, 30)}" needs a name, a colon, then numbers.` };
41
+ const values = part.slice(i + 1).split(",").map(num);
42
+ if (values.some(Number.isNaN)) return { ok: false, error: `data-series: ${part.slice(0, i)} has something that is not a number.` };
43
+ series.push({ name: part.slice(0, i).trim(), values });
44
+ }
45
+ } else if (get("data-values")) {
46
+ const values = get("data-values").split(",").map(num);
47
+ if (values.some(Number.isNaN)) return { ok: false, error: "data-values: use numbers separated by commas." };
48
+ series = [{ name: get("data-label") || "Value", values }];
49
+ }
50
+ const spec = { type, label: get("data-label") || "Chart", labels, series, stacked: get("data-stacked") !== null && get("data-stacked") !== undefined,
51
+ horizontal: get("data-horizontal") !== null && get("data-horizontal") !== undefined, bars: get("data-bars") !== null && get("data-bars") !== undefined, tone: get("data-tone") || "", center: get("data-center") || "",
52
+ prefix: get("data-prefix") || "", suffix: get("data-suffix") || "", decimals: +(get("data-decimals") || 0) };
53
+ if (type === "ring" || type === "gauge") { spec.value = num(get("data-value")); spec.max = get("data-max") ? num(get("data-max")) : 100;
54
+ if (Number.isNaN(spec.value) || Number.isNaN(spec.max) || spec.max <= 0) return { ok: false, error: "A ring or gauge needs data-value (and data-max, if not 100) as numbers." }; }
55
+ else if (!series.length || !series[0].values.length) return { ok: false, error: "Give data-values or data-series." };
56
+ if (type === "meter" && get("data-max")) { spec.max = num(get("data-max")); if (!(spec.max > 0)) return { ok: false, error: "data-max must be a number above 0." }; }
57
+ if (["donut", "pie", "meter", "funnel"].includes(type) && series.length > 1) return { ok: false, error: `A ${type} shows one set of parts: use data-values with data-labels.` };
58
+ if (["donut", "pie", "meter", "funnel", "heatmap"].includes(type) && series.some(x => x.values.some(v => v < 0))) return { ok: false, error: `A ${type}'s parts cannot be negative.` };
59
+ return { ok: true, spec };
60
+ }
61
+
62
+ // ── helpers ──────────────────────────────────────────────────────────────────
63
+ const el = (tag, attrs = {}, ...children) => ({ tag, attrs, children: children.flat().filter(c => c !== null && c !== undefined && c !== false) });
64
+ const f1 = n => Math.round(n * 10) / 10;
65
+ export const formatValue = (spec, v) => spec.prefix + Number(v).toLocaleString("en-US", { minimumFractionDigits: spec.decimals, maximumFractionDigits: spec.decimals }) + spec.suffix;
66
+ const shortNum = v => Math.abs(v) >= 1e6 ? f1(v / 1e6) + "M" : Math.abs(v) >= 1e3 ? f1(v / 1e3) + "k" : String(f1(v));
67
+ // one hue, light to dark, for amounts (heatmap) and stages in order (funnel): the primary mixed into the card.
68
+ // The darkest step is the primary itself (3:1 on the card); lighter steps rely on labels, hover and the table.
69
+ export const RAMP = [22, 40, 58, 78, 100];
70
+ const rampColor = pct => pct >= 100 ? "var(--chart-line)" : `color-mix(in srgb, var(--chart-line) ${pct}%, var(--p))`;
71
+ const colorOf = (spec, i) => spec.series.length > 1 && spec.type !== "heatmap" || ["donut", "pie", "meter"].includes(spec.type) ? `var(--series-${(i % 5) + 1})`
72
+ : spec.tone === "good" ? "var(--good)" : spec.tone === "warn" ? "var(--warn)" : spec.tone === "neg" ? "var(--neg)" : "var(--chart-line)";
73
+ // a tidy top for the value axis: 1, 2, 2.5 or 5 times a power of ten
74
+ function niceMax(v) { if (v <= 0) return 1; const p = 10 ** Math.floor(Math.log10(v)), m = v / p; return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p; }
75
+ // a bar: rounded at its free end only (the end away from the baseline), flat on the baseline
76
+ function barPath(x, y, w, h, horizontal, round) {
77
+ if (w <= 0 || h <= 0) return "";
78
+ const r = Math.min(round ? R : 0, horizontal ? w : h, horizontal ? h / 2 : w / 2);
79
+ if (horizontal) return `M${f1(x)} ${f1(y)}H${f1(x + w - r)}Q${f1(x + w)} ${f1(y)} ${f1(x + w)} ${f1(y + r)}V${f1(y + h - r)}Q${f1(x + w)} ${f1(y + h)} ${f1(x + w - r)} ${f1(y + h)}H${f1(x)}Z`;
80
+ return `M${f1(x)} ${f1(y + h)}V${f1(y + r)}Q${f1(x)} ${f1(y)} ${f1(x + r)} ${f1(y)}H${f1(x + w - r)}Q${f1(x + w)} ${f1(y)} ${f1(x + w)} ${f1(y + r)}V${f1(y + h)}Z`;
81
+ }
82
+
83
+ // ── the pieces around the plot: legend and data table ──────────────────────────
84
+ function legend(spec, names) {
85
+ if (names.length < 2) return null;
86
+ return el("ul", { class: "ui-viz-legend" }, names.map((n, i) => el("li", {}, el("span", { class: "ui-viz-key", style: `background:${colorOf(spec, i)}`, "aria-hidden": "true" }), n)));
87
+ }
88
+ function table(spec) {
89
+ const cap = el("caption", {}, spec.label);
90
+ if (spec.type === "ring" || spec.type === "gauge") return el("table", { class: "ui-sr-only" }, cap, el("tbody", {}, el("tr", {}, el("th", { scope: "row" }, spec.center || "Value"), el("td", {}, `${formatValue(spec, spec.value)} of ${formatValue(spec, spec.max)}`))));
91
+ const s = spec.series, cats = spec.labels.length ? spec.labels : s[0].values.map((_, i) => String(i + 1));
92
+ if (spec.type === "funnel") {
93
+ const first = s[0].values[0] || 1;
94
+ return el("table", { class: "ui-sr-only" }, cap, el("thead", {}, el("tr", {}, el("th", { scope: "col" }, "Stage"), el("th", { scope: "col" }, "Value"), el("th", { scope: "col" }, "Of the first"))),
95
+ el("tbody", {}, s[0].values.map((v, i) => el("tr", {}, el("th", { scope: "row" }, cats[i] || String(i + 1)), el("td", {}, formatValue(spec, v)), el("td", {}, `${Math.round(v / first * 100)}%`)))));
96
+ }
97
+ if (["donut", "pie", "meter"].includes(spec.type)) {
98
+ const total = s[0].values.reduce((a, b) => a + b, 0) || 1;
99
+ return el("table", { class: "ui-sr-only" }, cap, el("thead", {}, el("tr", {}, el("th", { scope: "col" }, "Part"), el("th", { scope: "col" }, "Value"), el("th", { scope: "col" }, "Share"))),
100
+ el("tbody", {}, s[0].values.map((v, i) => el("tr", {}, el("th", { scope: "row" }, cats[i] || String(i + 1)), el("td", {}, formatValue(spec, v)), el("td", {}, `${Math.round(v / total * 100)}%`)))));
101
+ }
102
+ return el("table", { class: "ui-sr-only" }, cap, el("thead", {}, el("tr", {}, el("th", { scope: "col" }, ""), s.map(x => el("th", { scope: "col" }, x.name)))),
103
+ el("tbody", {}, cats.map((c, i) => el("tr", {}, el("th", { scope: "row" }, c), s.map(x => el("td", {}, x.values[i] === undefined ? "" : formatValue(spec, x.values[i])))))));
104
+ }
105
+
106
+ // ── the plots ────────────────────────────────────────────────────────────────
107
+ function axisPlot(spec, width, height, type) {
108
+ const s = spec.series, n = Math.max(...s.map(x => x.values.length)), cats = spec.labels.length ? spec.labels : Array.from({ length: n }, (_, i) => String(i + 1));
109
+ const horizontal = type === "bar" && spec.horizontal, stacked = type === "bar" && spec.stacked && s.length > 1;
110
+ const top = stacked ? Math.max(...cats.map((_, i) => s.reduce((a, x) => a + Math.max(0, x.values[i] || 0), 0))) : Math.max(0, ...s.flatMap(x => x.values));
111
+ const max = niceMax(top), ticks = [0, 0.25, 0.5, 0.75, 1].map(k => k * max);
112
+ const pad = horizontal ? { t: 4, r: 12, b: 22, l: Math.min(140, 12 + 7 * Math.max(...cats.map(c => c.length))) } : { t: 10, r: 8, b: 26, l: 12 + 7 * Math.max(...ticks.map(t => shortNum(t).length)) };
113
+ const W = Math.max(120, width), H = Math.max(80, height), pw = W - pad.l - pad.r, ph = H - pad.t - pad.b;
114
+ const kids = [];
115
+ // grid and ticks, recessive
116
+ for (const t of ticks) {
117
+ if (horizontal) { const x = pad.l + t / max * pw; kids.push(el("line", { class: "ui-viz-grid", x1: f1(x), x2: f1(x), y1: pad.t, y2: f1(pad.t + ph) }), el("text", { class: "ui-viz-tick", x: f1(x), y: f1(H - 6), "text-anchor": "middle" }, shortNum(t))); }
118
+ else { const y = pad.t + ph - t / max * ph; kids.push(el("line", { class: "ui-viz-grid", x1: pad.l, x2: f1(W - pad.r), y1: f1(y), y2: f1(y) }), el("text", { class: "ui-viz-tick", x: pad.l - 8, y: f1(y + 4), "text-anchor": "end" }, shortNum(t))); }
119
+ }
120
+ const band = (horizontal ? ph : pw) / cats.length;
121
+ // show every Nth label so they never touch: about 7px a character at caption size, plus a 10px gap (no DOM measuring, so HTML and React match)
122
+ const every = Math.max(1, Math.ceil((Math.max(...cats.map(c => String(c).length)) * 7 + 10) / band));
123
+ cats.forEach((c, i) => {
124
+ if (horizontal) kids.push(el("text", { class: "ui-viz-tick", x: pad.l - 8, y: f1(pad.t + band * (i + 0.5) + 4), "text-anchor": "end" }, c));
125
+ else if (i % every === 0) kids.push(el("text", { class: "ui-viz-tick", x: f1(pad.l + band * (i + 0.5)), y: f1(H - 8), "text-anchor": "middle" }, c));
126
+ });
127
+ if (type === "bar") {
128
+ const group = band * (s.length > 1 && !stacked ? 0.78 : 0.56), k = stacked ? 1 : s.length, bw = Math.max(2, (group - GAP * (k - 1)) / k);
129
+ cats.forEach((c, i) => {
130
+ let acc = 0;
131
+ s.forEach((x, j) => {
132
+ const v = Math.max(0, x.values[i] || 0), len = v / max * (horizontal ? pw : ph), last = !stacked || j === s.length - 1 || s.slice(j + 1).every(y => !(y.values[i] > 0));
133
+ const start = band * i + (band - group) / 2 + (stacked ? 0 : j * (bw + GAP));
134
+ // stacked: every segment but the base gives up GAP at its start, so fills never touch
135
+ const lead = stacked && acc > 0 ? GAP : 0, sz = Math.max(0, len - lead);
136
+ const d = horizontal ? barPath(pad.l + acc + lead, pad.t + start, sz, bw, true, last) : barPath(pad.l + start, pad.t + ph - acc - len, bw, sz, false, last);
137
+ if (d) kids.push(el("path", { class: "ui-viz-mark ui-viz-bar", d, fill: colorOf(spec, j), "data-i": i, "data-s": j, "data-tip": `${c}${s.length > 1 ? ", " + x.name : ""}: ${formatValue(spec, x.values[i] || 0)}` }));
138
+ if (stacked) acc += len; // side by side, every bar starts at the baseline
139
+ });
140
+ });
141
+ } else {
142
+ // line and area: one path per series; the area fills to the baseline under its line
143
+ s.forEach((x, j) => {
144
+ const pts = x.values.map((v, i) => [pad.l + (cats.length === 1 ? pw / 2 : i / (cats.length - 1) * pw), pad.t + ph - Math.max(0, v) / max * ph]);
145
+ const line = pts.map((p, i) => (i ? "L" : "M") + f1(p[0]) + " " + f1(p[1])).join("");
146
+ if (type === "area") kids.push(el("path", { class: "ui-viz-area", d: `${line}L${f1(pts[pts.length - 1][0])} ${f1(pad.t + ph)}L${f1(pts[0][0])} ${f1(pad.t + ph)}Z`, fill: colorOf(spec, j) }));
147
+ kids.push(el("path", { class: "ui-viz-mark ui-viz-line", d: line, stroke: colorOf(spec, j), fill: "none", "data-s": j }));
148
+ });
149
+ kids.push(el("line", { class: "ui-viz-cross", x1: 0, x2: 0, y1: pad.t, y2: f1(pad.t + ph), hidden: "" }));
150
+ }
151
+ return { svg: el("svg", { class: "ui-viz-svg", width: W, height: H, viewBox: `0 0 ${W} ${H}`, "aria-hidden": "true", focusable: "false", "data-pad": `${pad.l},${pad.r}`, "data-n": cats.length }, kids), names: s.map(x => x.name) };
152
+ }
153
+
154
+ function roundPlot(spec, width, height) {
155
+ // donut, pie and ring: circles with a dash per part (a sweep is a dash growing), parts GAP apart
156
+ const size = Math.max(80, Math.min(width, height)), c = size / 2, ring = spec.type === "ring";
157
+ const thick = spec.type === "pie" ? c - 2 : ring ? Math.max(8, size * 0.1) : Math.max(14, size * 0.17), r = spec.type === "pie" ? (c - 2) / 2 : c - thick / 2 - 2;
158
+ const L = 2 * Math.PI * r, kids = [el("circle", { class: "ui-viz-track", cx: c, cy: c, r: f1(r), "stroke-width": f1(thick), fill: "none" })];
159
+ let middle = null;
160
+ if (ring) {
161
+ const part = Math.max(0, Math.min(1, spec.value / spec.max));
162
+ kids.push(el("circle", { class: "ui-viz-mark ui-viz-sweep", cx: c, cy: c, r: f1(r), fill: "none", stroke: colorOf(spec, 0), "stroke-width": f1(thick), "stroke-linecap": part > 0 && part < 1 ? "round" : "butt",
163
+ "stroke-dasharray": `${f1(part * L)} ${f1(L)}`, transform: `rotate(-90 ${c} ${c})`, "data-tip": `${formatValue(spec, spec.value)} of ${formatValue(spec, spec.max)}` }));
164
+ middle = { value: Math.round(part * 100), suffix: "%", label: spec.center };
165
+ } else {
166
+ const vals = spec.series[0].values, total = vals.reduce((a, b) => a + b, 0) || 1, cats = spec.labels;
167
+ let at = 0; const gap = vals.filter(v => v > 0).length > 1 ? GAP : 0;
168
+ vals.forEach((v, i) => {
169
+ const len = v / total * L;
170
+ if (len > gap) kids.push(el("circle", { class: "ui-viz-mark ui-viz-sweep", cx: c, cy: c, r: f1(r), fill: "none", stroke: colorOf(spec, i), "stroke-width": f1(thick),
171
+ "stroke-dasharray": `${f1(len - gap)} ${f1(L - len + gap)}`, "stroke-dashoffset": f1(-at), transform: `rotate(-90 ${c} ${c})`, "data-i": i, "data-tip": `${cats[i] || i + 1}: ${formatValue(spec, v)} (${Math.round(v / total * 100)}%)` }));
172
+ at += len;
173
+ });
174
+ if (spec.type === "donut") middle = { value: total, prefix: spec.prefix, suffix: spec.suffix, label: spec.center };
175
+ }
176
+ const svg = el("svg", { class: "ui-viz-svg", width: size, height: size, viewBox: `0 0 ${size} ${size}`, "aria-hidden": "true", focusable: "false" }, kids);
177
+ const mid = middle ? el("div", { class: "ui-viz-middle", "aria-hidden": "true" }, el("strong", { "data-count-to": middle.value, "data-count-prefix": middle.prefix || "", "data-count-suffix": middle.suffix || "" }, (middle.prefix || "") + Number(middle.value).toLocaleString("en-US") + (middle.suffix || "")), middle.label ? el("span", {}, middle.label) : null) : null;
178
+ return { svg: el("div", { class: "ui-viz-round", style: `width:${size}px;height:${size}px` }, svg, mid), names: ring ? [] : spec.labels };
179
+ }
180
+
181
+ function sparkPlot(spec, width, height) {
182
+ const v = spec.series[0].values, W = Math.max(40, width), H = Math.max(16, height), max = Math.max(...v, 0) || 1, min = Math.min(...v, 0);
183
+ const kids = [];
184
+ if (spec.horizontal || spec.stacked) { /* not meaningful for a sparkline; ignored */ }
185
+ if (spec.bars) {
186
+ const bw = Math.max(2, (W - GAP * (v.length - 1)) / v.length);
187
+ v.forEach((x, i) => { const h = Math.max(1, (x - min) / (max - min || 1) * (H - 2)); kids.push(el("path", { class: "ui-viz-mark ui-viz-bar", d: barPath(i * (bw + GAP), H - h, bw, h, false, true), fill: colorOf(spec, 0), "data-i": i, "data-tip": `${spec.labels[i] || i + 1}: ${formatValue(spec, x)}` })); });
188
+ } else {
189
+ const pts = v.map((x, i) => [v.length === 1 ? W / 2 : i / (v.length - 1) * (W - 4) + 2, 2 + (H - 4) * (1 - (x - min) / (max - min || 1))]);
190
+ kids.push(el("path", { class: "ui-viz-mark ui-viz-line", d: pts.map((p, i) => (i ? "L" : "M") + f1(p[0]) + " " + f1(p[1])).join(""), fill: "none", stroke: colorOf(spec, 0) }));
191
+ }
192
+ return { svg: el("svg", { class: "ui-viz-svg", width: W, height: H, viewBox: `0 0 ${W} ${H}`, "aria-hidden": "true", focusable: "false" }, kids), names: [] };
193
+ }
194
+
195
+ function meterPlot(spec, width) {
196
+ // one bar of parts, GAP apart, rounded at the two outer ends; the rest of the whole is the track
197
+ const v = spec.series[0].values, W = Math.max(80, width), H = 10, total = spec.max || v.reduce((a, b) => a + b, 0) || 1;
198
+ const kids = [el("rect", { class: "ui-viz-trackbar", x: 0, y: 0, width: W, height: H, rx: H / 2 })];
199
+ let x = 0; const shown = v.filter(n => n > 0).length;
200
+ v.forEach((n, i) => {
201
+ const len = n / total * W; if (len <= 0) return;
202
+ const idx = v.slice(0, i).filter(k => k > 0).length, first = idx === 0, last = idx === shown - 1, w = Math.max(0, len - (last ? 0 : GAP));
203
+ kids.push(el("rect", { class: "ui-viz-mark ui-viz-seg", x: f1(x), y: 0, width: f1(w), height: H, rx: first || last ? H / 2 : 0, fill: colorOf(spec, i), "data-i": i, "data-tip": `${spec.labels[i] || i + 1}: ${formatValue(spec, n)}` }));
204
+ x += len;
205
+ });
206
+ return { svg: el("svg", { class: "ui-viz-svg", width: W, height: H, viewBox: `0 0 ${W} ${H}`, "aria-hidden": "true", focusable: "false" }, kids), names: spec.labels };
207
+ }
208
+
209
+
210
+ function gaugePlot(spec, width) {
211
+ // a half ring: the track, then the value from the left end; the number sits in the middle of the arc
212
+ const W = Math.max(120, Math.min(width, 280)), thick = Math.max(10, W * 0.09), r = W / 2 - thick / 2 - 2, cx = W / 2, cy = W / 2, H = Math.round(cy + thick / 2 + 4);
213
+ const L = Math.PI * r, part = Math.max(0, Math.min(1, spec.value / spec.max)), arc = `M${f1(cx - r)} ${f1(cy)}A${f1(r)} ${f1(r)} 0 0 1 ${f1(cx + r)} ${f1(cy)}`;
214
+ const kids = [el("path", { class: "ui-viz-track", d: arc, fill: "none", "stroke-width": f1(thick), "stroke-linecap": "round" })];
215
+ if (part > 0) kids.push(el("path", { class: "ui-viz-mark ui-viz-sweep", d: arc, fill: "none", stroke: colorOf(spec, 0), "stroke-width": f1(thick), "stroke-linecap": "round",
216
+ "stroke-dasharray": `${f1(part * L)} ${f1(L)}`, "data-tip": `${formatValue(spec, spec.value)} of ${formatValue(spec, spec.max)}` }));
217
+ const svg = el("svg", { class: "ui-viz-svg", width: W, height: H, viewBox: `0 0 ${W} ${H}`, "aria-hidden": "true", focusable: "false" }, kids);
218
+ const mid = el("div", { class: "ui-viz-middle ui-viz-middle--gauge", "aria-hidden": "true" }, el("strong", { "data-count-to": spec.value, "data-count-prefix": spec.prefix, "data-count-suffix": spec.suffix }, formatValue(spec, spec.value)), spec.center ? el("span", {}, spec.center) : null);
219
+ return { svg: el("div", { class: "ui-viz-round ui-viz-round--gauge", style: `width:${W}px;height:${H}px` }, svg, mid), names: [] };
220
+ }
221
+
222
+ function heatPlot(spec, width) {
223
+ // rows by columns of cells, GAP apart; each cell's step is its value's share of the largest
224
+ const rows = spec.series, cols = spec.labels.length ? spec.labels : rows[0].values.map((_, i) => String(i + 1));
225
+ const lw = Math.min(110, 10 + 7 * Math.max(...rows.map(r => r.name.length))), W = Math.max(160, width), cw = (W - lw) / cols.length, ch = Math.min(28, Math.max(14, cw));
226
+ const max = Math.max(1, ...rows.flatMap(r => r.values)), H = rows.length * ch + 18, kids = [];
227
+ rows.forEach((r, y) => {
228
+ kids.push(el("text", { class: "ui-viz-tick", x: lw - 8, y: f1(y * ch + ch / 2 + 4), "text-anchor": "end" }, r.name));
229
+ cols.forEach((c, x) => {
230
+ const v = r.values[x] || 0, step = RAMP[Math.min(RAMP.length - 1, Math.floor(v / max * (RAMP.length - 0.001)))];
231
+ kids.push(el("rect", { class: "ui-viz-mark ui-viz-cell", x: f1(lw + x * cw), y: f1(y * ch), width: f1(Math.max(1, cw - GAP)), height: f1(ch - GAP), rx: 3,
232
+ fill: v > 0 ? rampColor(step) : "var(--mid)", "data-i": x, "data-tip": `${r.name}, ${c}: ${formatValue(spec, v)}` }));
233
+ });
234
+ });
235
+ cols.forEach((c, x) => { if (cols.length <= 14 || x % Math.ceil(cols.length / 12) === 0) kids.push(el("text", { class: "ui-viz-tick", x: f1(lw + x * cw + cw / 2), y: f1(H - 4), "text-anchor": "middle" }, c)); });
236
+ const svg = el("svg", { class: "ui-viz-svg", width: W, height: H, viewBox: `0 0 ${W} ${H}`, "aria-hidden": "true", focusable: "false" }, kids);
237
+ const key = el("div", { class: "ui-viz-ramp", "aria-hidden": "true" }, el("span", {}, "Less"), RAMP.map(p => el("i", { style: `background:${rampColor(p)}` })), el("span", {}, "More"));
238
+ return { svg, names: [], extra: key };
239
+ }
240
+
241
+ function funnelPlot(spec, width) {
242
+ // stages as centered bars, widest first, each a darker-to-lighter step of one hue; the label and value sit beside it
243
+ const v = spec.series[0].values, cats = spec.labels, W = Math.max(160, width), first = v[0] || 1, bh = 30, H = v.length * (bh + GAP * 3);
244
+ const lw = Math.min(150, 12 + 7 * Math.max(...cats.map(c => c.length), 4)), pw = W - lw - 90, kids = [];
245
+ v.forEach((n, i) => {
246
+ const w = Math.max(2, n / first * pw), x = lw + (pw - w) / 2, y = i * (bh + GAP * 3), step = RAMP[Math.max(0, RAMP.length - 1 - i)];
247
+ kids.push(el("text", { class: "ui-viz-tick ui-viz-tick--strong", x: lw - 10, y: f1(y + bh / 2 + 4), "text-anchor": "end" }, cats[i] || String(i + 1)));
248
+ kids.push(el("rect", { class: "ui-viz-mark ui-viz-seg", x: f1(x), y: f1(y), width: f1(w), height: bh, rx: R, fill: rampColor(step), "data-i": i, "data-tip": `${cats[i] || i + 1}: ${formatValue(spec, n)} (${Math.round(n / first * 100)}% of ${cats[0] || "the first"})` }));
249
+ kids.push(el("text", { class: "ui-viz-tick", x: f1(lw + pw + 10), y: f1(y + bh / 2 + 4), "text-anchor": "start" }, `${formatValue(spec, n)} · ${Math.round(n / first * 100)}%`));
250
+ });
251
+ return { svg: el("svg", { class: "ui-viz-svg", width: W, height: H, viewBox: `0 0 ${W} ${H}`, "aria-hidden": "true", focusable: "false" }, kids), names: [] };
252
+ }
253
+
254
+ /** The whole chart for a width (and height, for axis charts): a list of nodes to put inside the figure */
255
+ export function chartNodes(spec, width = 600, height = 220) {
256
+ const t = spec.type;
257
+ const plot = t === "bar" || t === "line" || t === "area" ? axisPlot(spec, width, height, t) : t === "spark" ? sparkPlot(spec, width, height)
258
+ : t === "meter" ? meterPlot(spec, width) : t === "gauge" ? gaugePlot(spec, width) : t === "heatmap" ? heatPlot(spec, width) : t === "funnel" ? funnelPlot(spec, width) : roundPlot(spec, width, height);
259
+ return [el("div", { class: "ui-viz-plot" }, plot.svg, el("span", { class: "ui-viz-tip", "aria-hidden": "true", hidden: "" })), plot.extra || null, legend(spec, plot.names), table(spec)];
260
+ }
261
+ /** An error, shown where the chart would be, so a typo is never a silent blank */
262
+ export const errorNodes = msg => [el("p", { class: "ui-viz-error", role: "note" }, "Chart data could not be read: " + msg)];
263
+
264
+ // ── writing the tree ─────────────────────────────────────────────────────────
265
+ const escText = s => String(s).replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
266
+ const escAttr = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
267
+ export function toHtml(nodes) {
268
+ return nodes.filter(Boolean).map(n => typeof n === "object" ? `<${n.tag}${Object.entries(n.attrs).map(([k, v]) => ` ${k}="${escAttr(v)}"`).join("")}>${toHtml(n.children)}</${n.tag}>` : escText(n)).join("");
269
+ }
270
+ const REACT_NAME = { class: "className", "stroke-width": "strokeWidth", "stroke-dasharray": "strokeDasharray", "stroke-dashoffset": "strokeDashoffset", "stroke-linecap": "strokeLinecap", "text-anchor": "textAnchor", viewBox: "viewBox" };
271
+ const styleObj = s => Object.fromEntries(s.split(";").filter(Boolean).map(p => { const [k, ...v] = p.split(":"); return [k.trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase()), v.join(":").trim()]; }));
272
+ /** The same tree as React elements: h is React.createElement */
273
+ export function toReact(nodes, h) {
274
+ return nodes.filter(Boolean).map((n, i) => {
275
+ if (typeof n !== "object") return n;
276
+ const props = { key: i };
277
+ for (const [k, v] of Object.entries(n.attrs)) {
278
+ if (k === "style") props.style = styleObj(v);
279
+ else if (k === "hidden") props.hidden = true;
280
+ else props[REACT_NAME[k] || k] = v;
281
+ }
282
+ return h(n.tag, props, ...toReact(n.children, h));
283
+ });
284
+ }
285
+
286
+ // ── hover: one value under the pointer (lines: the nearest category, every series) ──────────────────────────
287
+ export function hideTip(fig) {
288
+ if (!fig) return; const t = fig.querySelector(".ui-viz-tip"), x = fig.querySelector(".ui-viz-cross");
289
+ if (t) t.hidden = true; if (x) x.setAttribute("hidden", "");
290
+ }
291
+ export function showTip(fig, spec, e) {
292
+ const tip = fig.querySelector(".ui-viz-tip"), plot = fig.querySelector(".ui-viz-plot"); if (!tip || !plot) return;
293
+ const box = plot.getBoundingClientRect(); let text = "";
294
+ const mark = e.target.closest && e.target.closest("[data-tip]");
295
+ if (mark) text = mark.getAttribute("data-tip");
296
+ else if (spec.type === "line" || spec.type === "area") {
297
+ const svg = fig.querySelector(".ui-viz-svg"), [pl, pr] = (svg.getAttribute("data-pad") || "0,0").split(",").map(Number), n = +svg.getAttribute("data-n");
298
+ const w = svg.getBoundingClientRect().width, i = Math.round((e.clientX - box.left - pl) / Math.max(1, w - pl - pr) * (n - 1));
299
+ if (i >= 0 && i < n) {
300
+ const cx = pl + (n === 1 ? (w - pl - pr) / 2 : i / (n - 1) * (w - pl - pr)), cross = svg.querySelector(".ui-viz-cross");
301
+ cross.setAttribute("x1", cx); cross.setAttribute("x2", cx); cross.removeAttribute("hidden");
302
+ text = (spec.labels[i] ? spec.labels[i] + ": " : "") + spec.series.map(x => (spec.series.length > 1 ? x.name + " " : "") + (x.values[i] === undefined ? "" : formatValue(spec, x.values[i]))).join(", ");
303
+ }
304
+ }
305
+ if (!text) { hideTip(fig); return; }
306
+ tip.textContent = text; tip.hidden = false;
307
+ const x = e.clientX - box.left, y = e.clientY - box.top;
308
+ tip.style.left = Math.max(0, Math.min(box.width - tip.offsetWidth, x - tip.offsetWidth / 2)) + "px";
309
+ tip.style.top = Math.max(0, y - tip.offsetHeight - 12) + "px";
310
+ }
311
+
312
+ // ── motion: play a drawn chart in ────────────────────────────────────────────
313
+ /** level: "full" | "calm" | "still". Reads the kit's motion tokens; does nothing it cannot undo. */
314
+ export function animate(fig, level) {
315
+ if (!fig || typeof fig.animate !== "function" || level === "still") return;
316
+ if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) return;
317
+ const css = getComputedStyle(fig), ms = (n, d) => { const v = css.getPropertyValue(n).trim(); return v ? (v.endsWith("ms") ? parseFloat(v) : parseFloat(v) * 1000) : d; };
318
+ const slow = ms("--dur-slow", 600), fast = ms("--dur-fast", 150), stagger = ms("--motion-stagger", 40), ease = css.getPropertyValue("--ease-enter").trim() || "cubic-bezier(.16,1,.3,1)";
319
+ const svg = fig.querySelector(".ui-viz-svg"); if (!svg) return;
320
+ if (level === "calm") { fig.querySelector(".ui-viz-plot")?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: fast, easing: "ease-out" }); return; }
321
+ const horizontal = fig.classList.contains("ui-viz--horizontal");
322
+ const funnel = fig.classList.contains("ui-viz--funnel");
323
+ fig.querySelectorAll(".ui-viz-cell").forEach(c => c.animate([{ opacity: 0 }, { opacity: 1 }], { duration: fast * 2, delay: +(c.getAttribute("data-i") || 0) * (stagger / 2), easing: "ease-out", fill: "backwards" }));
324
+ fig.querySelectorAll(".ui-viz-bar, .ui-viz-seg").forEach(b => {
325
+ const i = +(b.getAttribute("data-i") || 0);
326
+ b.style.transformBox = "fill-box"; b.style.transformOrigin = funnel ? "center center" : horizontal || b.classList.contains("ui-viz-seg") ? "left center" : "center bottom";
327
+ b.animate([{ transform: horizontal || b.classList.contains("ui-viz-seg") ? "scaleX(0)" : "scaleY(0)" }, { transform: "none" }], { duration: slow, delay: i * stagger, easing: ease, fill: "backwards" });
328
+ });
329
+ fig.querySelectorAll(".ui-viz-line").forEach(p => {
330
+ const len = typeof p.getTotalLength === "function" ? p.getTotalLength() : 0; if (!len) return;
331
+ p.animate([{ strokeDasharray: `${len} ${len}`, strokeDashoffset: len }, { strokeDasharray: `${len} ${len}`, strokeDashoffset: 0 }], { duration: slow * 1.4, easing: ease, fill: "backwards" });
332
+ });
333
+ fig.querySelectorAll(".ui-viz-area").forEach(a => a.animate([{ opacity: 0 }, { opacity: 1 }], { duration: slow, delay: slow * 0.4, easing: "ease-out", fill: "backwards" }));
334
+ fig.querySelectorAll(".ui-viz-sweep").forEach((c, i) => {
335
+ const [len, rest] = (c.getAttribute("stroke-dasharray") || "0 0").split(" ").map(Number);
336
+ c.animate([{ strokeDasharray: `0 ${len + rest}` }, { strokeDasharray: `${len} ${rest}` }], { duration: slow, delay: i * stagger, easing: ease, fill: "backwards" });
337
+ });
338
+ // the middle number counts up; the finished number is already in the text
339
+ fig.querySelectorAll("[data-count-to]").forEach(n => {
340
+ const to = +n.getAttribute("data-count-to"), pre = n.getAttribute("data-count-prefix") || "", suf = n.getAttribute("data-count-suffix") || "", t0 = performance.now(), dur = slow * 1.2;
341
+ const step = now => { const k = Math.min(1, (now - t0) / dur), e = 1 - (1 - k) ** 3; n.textContent = pre + Math.round(to * e).toLocaleString("en-US") + suf; if (k < 1) requestAnimationFrame(step); };
342
+ requestAnimationFrame(step);
343
+ });
344
+ }
345
+ /** The motion level of the page: the Look's setting, then the device's */
346
+ export function motionLevel() {
347
+ const m = typeof document !== "undefined" && document.documentElement.getAttribute("data-motion");
348
+ return m === "still" || m === "calm" ? m : "full";
349
+ }
@@ -0,0 +1,58 @@
1
+ // charts.js: the kit's charts on any page, no code. Load it once as a module:
2
+ // <script type="module" src="charts.js"></script>
3
+ // then describe a chart with attributes (see charts-core.mjs, specFrom, for every one):
4
+ // <figure data-ui-viz="bar" data-label="Monthly expenses" data-labels="Jan,Feb,Mar" data-values="4,7,5" data-prefix="$"></figure>
5
+ // It draws at the figure's real width, redraws when that changes, plays in once when it scrolls into view (only as
6
+ // far as the motion level allows), shows a value on hover, and keeps a data table for screen readers.
7
+ // window.UIViz = { init(scope), draw(figure) } for pages that add charts later (the workbench does).
8
+ import { specFrom, chartNodes, errorNodes, toHtml, animate, motionLevel, showTip, hideTip } from "./charts-core.mjs";
9
+
10
+ function draw(fig) {
11
+ fig.classList.add("ui-viz");
12
+ const r = specFrom(n => fig.getAttribute(n));
13
+ if (!r.ok) { fig.innerHTML = toHtml(errorNodes(r.error)); fig._viz = null; return; }
14
+ const spec = r.spec, css = getComputedStyle(fig);
15
+ fig._viz = spec;
16
+ fig.classList.add("ui-viz--" + spec.type);
17
+ fig.classList.toggle("ui-viz--horizontal", !!spec.horizontal);
18
+ if (fig.tagName !== "FIGURE" && !fig.hasAttribute("role")) fig.setAttribute("role", "figure");
19
+ fig.setAttribute("aria-label", spec.label);
20
+ const w = Math.round(fig.clientWidth) || 600, h = parseFloat(css.getPropertyValue("--viz-h")) || (spec.type === "spark" ? 40 : 220);
21
+ fig.innerHTML = toHtml(chartNodes(spec, w, h));
22
+ fig._vizW = w;
23
+ }
24
+
25
+ // play in once, when at least a quarter of the chart is on screen
26
+ const seen = typeof IntersectionObserver === "function" ? new IntersectionObserver(entries => {
27
+ for (const e of entries) if (e.isIntersecting) { seen.unobserve(e.target); animate(e.target, motionLevel()); }
28
+ }, { threshold: 0.25 }) : null;
29
+ // redraw when the width changes, without playing in again
30
+ const sized = typeof ResizeObserver === "function" ? new ResizeObserver(entries => {
31
+ for (const e of entries) { const fig = e.target, w = Math.round(e.contentRect.width); if (fig._viz && Math.abs(w - (fig._vizW || 0)) > 8) draw(fig); }
32
+ }) : null;
33
+
34
+ function init(scope = document) {
35
+ const list = scope.matches && scope.matches("[data-ui-viz]") ? [scope] : Array.from(scope.querySelectorAll("[data-ui-viz]"));
36
+ for (const fig of list) {
37
+ if (fig._vizWired) { draw(fig); continue; }
38
+ fig._vizWired = true; draw(fig);
39
+ if (sized) sized.observe(fig);
40
+ // data-viz-motion="none": the page animates it (GSAP, its own); the kit only draws it
41
+ if (seen && fig._viz && fig.getAttribute("data-viz-motion") !== "none") seen.observe(fig);
42
+ }
43
+ }
44
+
45
+ // hover: the value of the bar, part or point under the pointer (lines: the nearest category, every series)
46
+ document.addEventListener("pointermove", e => {
47
+ const fig = e.target.closest && e.target.closest(".ui-viz");
48
+ document.querySelectorAll(".ui-viz-tip:not([hidden])").forEach(t => { const f = t.closest(".ui-viz"); if (f !== fig) hideTip(f); });
49
+ if (fig && fig._viz) showTip(fig, fig._viz, e);
50
+ });
51
+
52
+ // charts added later (a workbench card, a page that renders on demand) are found and drawn too
53
+ if (typeof MutationObserver === "function") new MutationObserver(records => {
54
+ for (const r of records) for (const n of r.addedNodes) if (n.nodeType === 1 && (n.matches("[data-ui-viz]") || n.querySelector("[data-ui-viz]"))) init(n);
55
+ }).observe(document.documentElement, { childList: true, subtree: true });
56
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => init()); else init();
57
+
58
+ window.UIViz = { init, draw };
@@ -0,0 +1,168 @@
1
+ // motion.js: the kit's animation engine. Plain script, no library, no build: the browser's own Web Animations API
2
+ // and an IntersectionObserver. Load it after the markup (or with defer). Nothing here is required: without it, every
3
+ // element simply shows.
4
+ //
5
+ // No-code use: put attributes on any element.
6
+ // data-reveal="fade-up" an entrance (see PRESETS); plays when it scrolls into view
7
+ // data-reveal-trigger="load" …or on load, or "hover" (plays each time the pointer arrives)
8
+ // data-reveal-duration="500" ms; default the --dur-slow token
9
+ // data-reveal-delay="100" ms
10
+ // data-reveal-ease="enter" standard | enter | exit, or any CSS easing; default the --ease-enter token
11
+ // data-reveal-distance="24" px of travel; default the --motion-distance token
12
+ // data-reveal-stagger="60" ms between children (each child animates in turn); default none
13
+ // data-reveal-split="words" words | chars | lines: split the text and stagger the pieces (the words are still
14
+ // read as one sentence: the original text stays as the element's accessible name)
15
+ // data-reveal-repeat replay every time it comes into view (default: once)
16
+ // data-count-to="18240" a number that counts up from 0 (data-count-prefix, -suffix, -decimals)
17
+ //
18
+ // Colin's Motion setting decides how much plays: html[data-motion="full"] everything; "calm" short opacity-only fades,
19
+ // counters land on their number; "still", or the device's reduce-motion setting, nothing moves and everything shows.
20
+ //
21
+ // window.UIMotion = { init(scope), play(el), keyframes(preset, opts), level(), PRESETS } for the workbench playground.
22
+ (function () {
23
+ "use strict";
24
+ var root = document.documentElement;
25
+ var reduce = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
26
+ function level() {
27
+ if (reduce && reduce.matches) return "still";
28
+ var m = root.getAttribute("data-motion");
29
+ return m === "still" || m === "calm" ? m : "full";
30
+ }
31
+ function token(name, fallback) { var v = getComputedStyle(root).getPropertyValue(name).trim(); return v || fallback; }
32
+ function ms(v, fallback) { if (v == null || v === "") return fallback; var n = parseFloat(v); return /ms$/.test(v) || !/s$/.test(v) ? n : n * 1000; }
33
+ var EASE = { standard: "--ease-standard", enter: "--ease-enter", exit: "--ease-exit" };
34
+ function easing(v) { return EASE[v] ? token(EASE[v], "ease") : v || token("--ease-enter", "ease-out"); }
35
+
36
+ // Each preset: the "from" state; every animation ends on the element's own resting state
37
+ var PRESETS = {
38
+ "fade": function (d) { return { opacity: 0 }; },
39
+ "fade-up": function (d) { return { opacity: 0, transform: "translateY(" + d + "px)" }; },
40
+ "fade-down": function (d) { return { opacity: 0, transform: "translateY(" + -d + "px)" }; },
41
+ "slide-left": function (d) { return { opacity: 0, transform: "translateX(" + d * 2 + "px)" }; },
42
+ "slide-right": function (d) { return { opacity: 0, transform: "translateX(" + -d * 2 + "px)" }; },
43
+ "scale": function (d) { return { opacity: 0, transform: "scale(.92)" }; },
44
+ "blur": function (d) { return { opacity: 0, filter: "blur(12px)" }; },
45
+ "wipe-up": function (d) { return { clipPath: "inset(100% 0 0 0)" }; },
46
+ "wipe-right": function (d) { return { clipPath: "inset(0 100% 0 0)" }; },
47
+ "iris": function (d) { return { clipPath: "circle(0% at 50% 50%)" }; },
48
+ "zoom-out": function (d) { return { opacity: 0, transform: "scale(1.12)" }; },
49
+ "rise": function (d) { return { opacity: 0, transform: "translateY(110%)" }; }
50
+ };
51
+ var REST = { opacity: 1, transform: "none", filter: "blur(0)", clipPath: { "wipe-up": "inset(0 0 0 0)", "wipe-right": "inset(0 0 0 0)", iris: "circle(75% at 50% 50%)" } };
52
+ /** keyframes for a preset at a level: Calm keeps only the fade; Still has none */
53
+ function keyframes(preset, opts) {
54
+ opts = opts || {};
55
+ var lvl = opts.level || level(), from = (PRESETS[preset] || PRESETS.fade)(opts.distance != null ? opts.distance : parseFloat(token("--motion-distance", "16")));
56
+ if (lvl === "still") return null;
57
+ if (lvl === "calm") from = { opacity: 0 };
58
+ var to = {};
59
+ Object.keys(from).forEach(function (k) { to[k] = k === "clipPath" ? REST.clipPath[preset] : REST[k]; });
60
+ if (lvl === "calm" && from.opacity === undefined) return null;
61
+ return [from, to];
62
+ }
63
+ function timing(el, opts, i) {
64
+ var lvl = (opts && opts.level) || level();
65
+ var dur = ms(el.getAttribute("data-reveal-duration"), ms(token("--dur-slow", "500ms")));
66
+ return {
67
+ duration: lvl === "calm" ? Math.min(dur, ms(token("--dur-base", "250ms"))) : dur,
68
+ delay: ms(el.getAttribute("data-reveal-delay"), 0) + (i || 0) * ms(el.getAttribute("data-reveal-stagger"), ms(token("--motion-stagger", "60ms"))),
69
+ easing: easing(el.getAttribute("data-reveal-ease")), fill: "both"
70
+ };
71
+ }
72
+
73
+ // split text into words, characters or lines; the original sentence stays the accessible name
74
+ function split(el, how) {
75
+ if (el._uiSplit === how) return el._uiParts;
76
+ var text = el._uiText || (el._uiText = el.textContent.trim());
77
+ el.setAttribute("aria-label", text);
78
+ var tokens = how === "chars" ? Array.from(text) : text.split(/(\s+)/);
79
+ el.innerHTML = "";
80
+ var parts = [];
81
+ tokens.forEach(function (t) {
82
+ if (/^\s+$/.test(t)) { el.appendChild(document.createTextNode(t)); return; }
83
+ var wrap = document.createElement("span"); wrap.className = "ui-split"; wrap.setAttribute("aria-hidden", "true");
84
+ var inner = document.createElement("span"); inner.className = "ui-split-in"; inner.textContent = t;
85
+ wrap.appendChild(inner); el.appendChild(wrap); parts.push(inner);
86
+ });
87
+ if (how === "lines") {
88
+ // group words that share a line, so each line rises as one
89
+ var lines = [], top = null;
90
+ parts.forEach(function (p) { var y = p.parentNode.offsetTop; if (y !== top) { lines.push([]); top = y; } lines[lines.length - 1].push(p); });
91
+ parts = lines.map(function (ws) { return { line: ws }; });
92
+ }
93
+ el._uiSplit = how; el._uiParts = parts;
94
+ return parts;
95
+ }
96
+
97
+ function animateOne(target, preset, t, opts) {
98
+ var k = keyframes(preset, opts);
99
+ if (!k || !target.animate) return null;
100
+ return target.animate(k, t);
101
+ }
102
+ /** play an element's entrance now; returns its animations */
103
+ function play(el, opts) {
104
+ opts = opts || {};
105
+ var preset = opts.preset || el.getAttribute("data-reveal") || "fade-up", lvl = opts.level || level(), anims = [];
106
+ el.getAnimations && el.getAnimations().forEach(function (a) { a.cancel(); });
107
+ var how = el.getAttribute("data-reveal-split"), kids;
108
+ if (how) {
109
+ var parts = split(el, how);
110
+ parts.forEach(function (p, i) {
111
+ (p.line || [p]).forEach(function (w) { var a = animateOne(w, preset === "fade-up" && how !== "chars" ? "rise" : preset, timing(el, opts, i), { level: lvl, distance: opts.distance }); if (a) anims.push(a); });
112
+ });
113
+ } else if (el.hasAttribute("data-reveal-stagger")) {
114
+ kids = Array.prototype.slice.call(el.children);
115
+ kids.forEach(function (c, i) { var a = animateOne(c, preset, timing(el, opts, i), { level: lvl, distance: opts.distance }); if (a) anims.push(a); });
116
+ } else {
117
+ var a = animateOne(el, preset, timing(el, opts, 0), { level: lvl, distance: opts.distance }); if (a) anims.push(a);
118
+ }
119
+ el.setAttribute("data-revealed", "");
120
+ return anims;
121
+ }
122
+
123
+ // counters: count from 0 to the number, eased; Calm and Still show the number at once
124
+ function count(el) {
125
+ var to = parseFloat(el.getAttribute("data-count-to")), dec = +(el.getAttribute("data-count-decimals") || 0);
126
+ var pre = el.getAttribute("data-count-prefix") || "", suf = el.getAttribute("data-count-suffix") || "";
127
+ var show = function (n) { el.textContent = pre + n.toLocaleString(undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec }) + suf; };
128
+ if (isNaN(to)) return;
129
+ el.setAttribute("aria-label", pre + to.toLocaleString(undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec }) + suf);
130
+ if (level() !== "full") { show(to); return; }
131
+ var dur = ms(el.getAttribute("data-reveal-duration"), ms(token("--dur-slow", "500ms")) * 2.4), t0 = null;
132
+ function step(t) { if (t0 === null) t0 = t; var p = Math.min(1, (t - t0) / dur), e = 1 - Math.pow(1 - p, 3); show(to * e); if (p < 1) requestAnimationFrame(step); }
133
+ requestAnimationFrame(step);
134
+ el.setAttribute("data-revealed", "");
135
+ }
136
+
137
+ var io = "IntersectionObserver" in window ? new IntersectionObserver(function (entries) {
138
+ entries.forEach(function (en) {
139
+ var el = en.target;
140
+ if (en.isIntersecting) {
141
+ if (el.hasAttribute("data-count-to")) count(el); else play(el);
142
+ if (!el.hasAttribute("data-reveal-repeat")) io.unobserve(el);
143
+ } else if (el.hasAttribute("data-reveal-repeat") && !el.hasAttribute("data-count-to")) {
144
+ var k = keyframes(el.getAttribute("data-reveal") || "fade-up");
145
+ if (k && el.animate) el.animate([k[0], k[0]], { duration: 1, fill: "both" });
146
+ }
147
+ });
148
+ }, { rootMargin: "0px 0px -10% 0px", threshold: 0.15 }) : null;
149
+
150
+ function init(scope) {
151
+ scope = scope || document;
152
+ Array.prototype.forEach.call(scope.querySelectorAll("[data-reveal], [data-count-to]"), function (el) {
153
+ if (el._uiMotion) return;
154
+ el._uiMotion = true;
155
+ var trig = el.getAttribute("data-reveal-trigger") || "scroll";
156
+ if (level() === "still" && !el.hasAttribute("data-count-to")) { el.setAttribute("data-revealed", ""); return; }
157
+ if (trig === "load") { el.hasAttribute("data-count-to") ? count(el) : play(el); }
158
+ else if (trig === "hover") { el.addEventListener("pointerenter", function () { play(el); }); el.setAttribute("data-revealed", ""); }
159
+ else if (io) io.observe(el);
160
+ else { el.hasAttribute("data-count-to") ? count(el) : play(el); }
161
+ });
162
+ }
163
+ // until it is ready, an element waiting to reveal is hidden; if the script never runs, nothing is hidden
164
+ root.classList.add("ui-motion-ready");
165
+ window.UIMotion = { init: init, play: play, keyframes: keyframes, level: level, PRESETS: Object.keys(PRESETS) };
166
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { init(); });
167
+ else init();
168
+ })();
@@ -0,0 +1,18 @@
1
+ import { Avatar } from "./Avatar.jsx";
2
+
3
+ /**
4
+ * <ActivityFeed label="Recent activity" items={[{ id, name: "Grace Lee", action: "approved the poster", time: "2h ago" }]} />
5
+ */
6
+ export function ActivityFeed({ label, items = [], className }) {
7
+ return (
8
+ <ul className={["ui-feed", className].filter(Boolean).join(" ")} aria-label={label}>
9
+ {items.map((it) => (
10
+ <li key={it.id} className="ui-feed-item">
11
+ <Avatar name={it.name} size="sm" />
12
+ <p className="ui-feed-text"><strong>{it.name}</strong> {it.action}</p>
13
+ <span className="ui-feed-time">{it.time}</span>
14
+ </li>
15
+ ))}
16
+ </ul>
17
+ );
18
+ }