@bkstg/design-systems 1.0.0 → 1.0.1

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 (125) hide show
  1. package/package.json +1 -1
  2. package/tether/latest/README.md +44 -0
  3. package/tether/latest/charts-core.mjs +349 -0
  4. package/tether/latest/charts.js +58 -0
  5. package/tether/latest/motion.js +168 -0
  6. package/tether/latest/react/ActivityFeed.jsx +18 -0
  7. package/tether/latest/react/Alert.jsx +28 -0
  8. package/tether/latest/react/Annotations.jsx +24 -0
  9. package/tether/latest/react/AppShell.jsx +87 -0
  10. package/tether/latest/react/Avatar.jsx +19 -0
  11. package/tether/latest/react/Badge.jsx +14 -0
  12. package/tether/latest/react/Banner.jsx +17 -0
  13. package/tether/latest/react/Breadcrumbs.jsx +23 -0
  14. package/tether/latest/react/Button.jsx +31 -0
  15. package/tether/latest/react/ButtonGroup.jsx +29 -0
  16. package/tether/latest/react/Calendar.jsx +57 -0
  17. package/tether/latest/react/CardHeader.jsx +14 -0
  18. package/tether/latest/react/Chart.jsx +42 -0
  19. package/tether/latest/react/Chat.jsx +34 -0
  20. package/tether/latest/react/Checkbox.jsx +32 -0
  21. package/tether/latest/react/CodeSnippet.jsx +17 -0
  22. package/tether/latest/react/ColorPicker.jsx +35 -0
  23. package/tether/latest/react/CommandMenu.jsx +54 -0
  24. package/tether/latest/react/DatePicker.jsx +34 -0
  25. package/tether/latest/react/DeviceFrame.jsx +12 -0
  26. package/tether/latest/react/Dialog.jsx +43 -0
  27. package/tether/latest/react/Divider.jsx +10 -0
  28. package/tether/latest/react/Editor.jsx +85 -0
  29. package/tether/latest/react/EmptyState.jsx +13 -0
  30. package/tether/latest/react/Field.jsx +32 -0
  31. package/tether/latest/react/FileUpload.jsx +38 -0
  32. package/tether/latest/react/Filters.jsx +19 -0
  33. package/tether/latest/react/Infographics.jsx +54 -0
  34. package/tether/latest/react/InlineCTA.jsx +14 -0
  35. package/tether/latest/react/LineChart.jsx +23 -0
  36. package/tether/latest/react/Marketing.jsx +161 -0
  37. package/tether/latest/react/Menu.jsx +76 -0
  38. package/tether/latest/react/Metric.jsx +28 -0
  39. package/tether/latest/react/PageHeader.jsx +22 -0
  40. package/tether/latest/react/Pagination.jsx +32 -0
  41. package/tether/latest/react/Progress.jsx +26 -0
  42. package/tether/latest/react/RadioGroup.jsx +27 -0
  43. package/tether/latest/react/Reveal.jsx +17 -0
  44. package/tether/latest/react/SectionHeader.jsx +24 -0
  45. package/tether/latest/react/Select.jsx +19 -0
  46. package/tether/latest/react/Sheet.jsx +25 -0
  47. package/tether/latest/react/SideNav.jsx +37 -0
  48. package/tether/latest/react/Skeleton.jsx +16 -0
  49. package/tether/latest/react/Slider.jsx +27 -0
  50. package/tether/latest/react/Spinner.jsx +14 -0
  51. package/tether/latest/react/Steps.jsx +22 -0
  52. package/tether/latest/react/Switch.jsx +11 -0
  53. package/tether/latest/react/Table.jsx +24 -0
  54. package/tether/latest/react/Tabs.jsx +80 -0
  55. package/tether/latest/react/Tag.jsx +34 -0
  56. package/tether/latest/react/Tooltip.jsx +25 -0
  57. package/tether/latest/react/Tree.jsx +41 -0
  58. package/tether/latest/react/VideoPlayer.jsx +57 -0
  59. package/tether/latest/react/cx.js +4 -0
  60. package/tether/latest/react/index.js +59 -0
  61. package/tether/latest/system.json +44 -0
  62. package/tether/latest/tether.css +1661 -0
  63. package/tether/latest/tether.js +703 -0
  64. package/tether/v1/README.md +44 -0
  65. package/tether/v1/charts-core.mjs +349 -0
  66. package/tether/v1/charts.js +58 -0
  67. package/tether/v1/motion.js +168 -0
  68. package/tether/v1/react/ActivityFeed.jsx +18 -0
  69. package/tether/v1/react/Alert.jsx +28 -0
  70. package/tether/v1/react/Annotations.jsx +24 -0
  71. package/tether/v1/react/AppShell.jsx +87 -0
  72. package/tether/v1/react/Avatar.jsx +19 -0
  73. package/tether/v1/react/Badge.jsx +14 -0
  74. package/tether/v1/react/Banner.jsx +17 -0
  75. package/tether/v1/react/Breadcrumbs.jsx +23 -0
  76. package/tether/v1/react/Button.jsx +31 -0
  77. package/tether/v1/react/ButtonGroup.jsx +29 -0
  78. package/tether/v1/react/Calendar.jsx +57 -0
  79. package/tether/v1/react/CardHeader.jsx +14 -0
  80. package/tether/v1/react/Chart.jsx +42 -0
  81. package/tether/v1/react/Chat.jsx +34 -0
  82. package/tether/v1/react/Checkbox.jsx +32 -0
  83. package/tether/v1/react/CodeSnippet.jsx +17 -0
  84. package/tether/v1/react/ColorPicker.jsx +35 -0
  85. package/tether/v1/react/CommandMenu.jsx +54 -0
  86. package/tether/v1/react/DatePicker.jsx +34 -0
  87. package/tether/v1/react/DeviceFrame.jsx +12 -0
  88. package/tether/v1/react/Dialog.jsx +43 -0
  89. package/tether/v1/react/Divider.jsx +10 -0
  90. package/tether/v1/react/Editor.jsx +85 -0
  91. package/tether/v1/react/EmptyState.jsx +13 -0
  92. package/tether/v1/react/Field.jsx +32 -0
  93. package/tether/v1/react/FileUpload.jsx +38 -0
  94. package/tether/v1/react/Filters.jsx +19 -0
  95. package/tether/v1/react/Infographics.jsx +54 -0
  96. package/tether/v1/react/InlineCTA.jsx +14 -0
  97. package/tether/v1/react/LineChart.jsx +23 -0
  98. package/tether/v1/react/Marketing.jsx +161 -0
  99. package/tether/v1/react/Menu.jsx +76 -0
  100. package/tether/v1/react/Metric.jsx +28 -0
  101. package/tether/v1/react/PageHeader.jsx +22 -0
  102. package/tether/v1/react/Pagination.jsx +32 -0
  103. package/tether/v1/react/Progress.jsx +26 -0
  104. package/tether/v1/react/RadioGroup.jsx +27 -0
  105. package/tether/v1/react/Reveal.jsx +17 -0
  106. package/tether/v1/react/SectionHeader.jsx +24 -0
  107. package/tether/v1/react/Select.jsx +19 -0
  108. package/tether/v1/react/Sheet.jsx +25 -0
  109. package/tether/v1/react/SideNav.jsx +37 -0
  110. package/tether/v1/react/Skeleton.jsx +16 -0
  111. package/tether/v1/react/Slider.jsx +27 -0
  112. package/tether/v1/react/Spinner.jsx +14 -0
  113. package/tether/v1/react/Steps.jsx +22 -0
  114. package/tether/v1/react/Switch.jsx +11 -0
  115. package/tether/v1/react/Table.jsx +24 -0
  116. package/tether/v1/react/Tabs.jsx +80 -0
  117. package/tether/v1/react/Tag.jsx +34 -0
  118. package/tether/v1/react/Tooltip.jsx +25 -0
  119. package/tether/v1/react/Tree.jsx +41 -0
  120. package/tether/v1/react/VideoPlayer.jsx +57 -0
  121. package/tether/v1/react/cx.js +4 -0
  122. package/tether/v1/react/index.js +59 -0
  123. package/tether/v1/system.json +44 -0
  124. package/tether/v1/tether.css +1661 -0
  125. package/tether/v1/tether.js +703 -0
@@ -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
+ }
@@ -0,0 +1,28 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ const ICONS = {
4
+ info: <><circle cx="12" cy="12" r="10" /><path d="M12 16v-4M12 8h.01" /></>,
5
+ ok: <><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></>,
6
+ warn: <><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /><path d="M12 9v4M12 17h.01" /></>,
7
+ critical: <><circle cx="12" cy="12" r="10" /><path d="m15 9-6 6M9 9l6 6" /></>,
8
+ };
9
+
10
+ /**
11
+ * <Alert tone="warn" title="Set times move Thursday">Check the schedule before you publish.</Alert>
12
+ * tone: "info" (default) | "ok" | "warn" | "critical". Critical uses role="alert" (announced at once),
13
+ * the others role="status". onClose adds a close button.
14
+ */
15
+ export function Alert({ tone = "info", title, children, onClose, closeLabel = "Dismiss", className }) {
16
+ return (
17
+ <div className={cx("ui-alert", tone !== "info" && `ui-alert--${tone}`, className)} role={tone === "critical" ? "alert" : "status"}>
18
+ <svg className="ui-alert-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{ICONS[tone] || ICONS.info}</svg>
19
+ {title ? <p className="ui-alert-title">{title}</p> : null}
20
+ {children ? <p className="ui-alert-body">{children}</p> : null}
21
+ {onClose ? (
22
+ <button type="button" className="ui-alert-close" aria-label={closeLabel} onClick={onClose}>
23
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
24
+ </button>
25
+ ) : null}
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,24 @@
1
+ import { useState } from "react";
2
+ import { cx } from "./cx.js";
3
+
4
+ /**
5
+ * <Annotations notes={[{ x: "12%", y: "16%", title: "One primary action", text: "…" }]}>{the design}</Annotations>
6
+ * Numbered pins over the design, each a button that lights up its note in the list beside it.
7
+ */
8
+ export function Annotations({ notes, children, label = "Notes", className }) {
9
+ const [on, setOn] = useState(null);
10
+ return (
11
+ <div className={cx("ui-annot", className)}>
12
+ <div className="ui-annot-canvas">
13
+ {children}
14
+ {notes.map((n, i) => (
15
+ <button key={i} type="button" className="ui-annot-pin" aria-pressed={on === i} aria-label={`Note ${i + 1}: ${n.title}`}
16
+ style={{ left: n.x, top: n.y }} onClick={() => setOn(on === i ? null : i)}>{i + 1}</button>
17
+ ))}
18
+ </div>
19
+ <ol className="ui-annot-notes" aria-label={label}>
20
+ {notes.map((n, i) => <li key={i} data-active={on === i ? "" : undefined}><strong>{n.title}</strong><span>{n.text}</span></li>)}
21
+ </ol>
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,87 @@
1
+ import { useEffect, useId, useRef, useState } from "react";
2
+ import { cx } from "./cx.js";
3
+
4
+ const NARROW = 720; // keep in step with the @container query in components.css
5
+ const Icon = ({ d }) => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{d}</svg>;
6
+ const PANEL = <Icon d={<><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 3v18" /></>} />;
7
+ const MENU = <Icon d={<path d="M4 6h16M4 12h16M4 18h16" />} />;
8
+ const CHEV = <Icon d={<path d="m6 9 6 6 6-6" />} />;
9
+
10
+ /**
11
+ * The app sidebar with a flyout, after the BKSTG workbench's own. Wide, it collapses to an icon rail; under
12
+ * 720px it slides over the page as a drawer (Escape or a tap outside closes it).
13
+ * <AppShell brand="BKSTG" title="Overview" label="Main" items={[
14
+ * { href: "/", label: "Overview", icon: <HomeIcon />, current: true },
15
+ * { href: "/lineup", label: "Lineup", icon: <MusicIcon />, count: 24 },
16
+ * { label: "Production", icon: <TruckIcon />, open: true, items: [{ href: "/stages", label: "Stages" }] },
17
+ * { heading: "Departments" },
18
+ * { href: "/booking", label: "Booking", icon: <MicIcon /> },
19
+ * ]} footer={[{ href: "/settings", label: "Settings", icon: <GearIcon /> }]}>…the page…</AppShell>
20
+ */
21
+ export function AppShell({ brand, title, label = "Main", items = [], footer = [], defaultCollapsed = false, children, className }) {
22
+ const id = useId().replace(/:/g, ""), sbId = `sb-${id}`;
23
+ const root = useRef(null), side = useRef(null), opener = useRef(null);
24
+ const [narrow, setNarrow] = useState(false);
25
+ const [open, setOpen] = useState(!defaultCollapsed);
26
+ const [groups, setGroups] = useState(() => new Set(items.filter((it) => it.items && it.open).map((it) => it.label)));
27
+ useEffect(() => {
28
+ if (!root.current || typeof ResizeObserver === "undefined") return undefined;
29
+ const ro = new ResizeObserver(([e]) => { const n = e.contentRect.width <= NARROW; setNarrow(n); if (n) setOpen(false); });
30
+ ro.observe(root.current);
31
+ return () => ro.disconnect();
32
+ }, []);
33
+ useEffect(() => {
34
+ if (!(narrow && open)) return undefined;
35
+ side.current?.querySelector(".ui-sidebar-toggle, .ui-sidebar-item")?.focus();
36
+ const key = (e) => { if (e.key === "Escape") { setOpen(false); opener.current?.focus(); } };
37
+ const tap = (e) => { if (side.current && !side.current.contains(e.target) && !opener.current?.contains(e.target)) setOpen(false); };
38
+ document.addEventListener("keydown", key); document.addEventListener("mousedown", tap);
39
+ return () => { document.removeEventListener("keydown", key); document.removeEventListener("mousedown", tap); };
40
+ }, [narrow, open]);
41
+ const toggleGroup = (name) => {
42
+ if (!open && !narrow) { setOpen(true); setGroups((s) => new Set(s).add(name)); return; }
43
+ setGroups((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
44
+ };
45
+ const link = (it, sub) => (
46
+ <a key={it.href + it.label} className="ui-sidebar-item" href={it.href} title={sub ? undefined : it.label} aria-current={it.current ? "page" : undefined}>
47
+ {it.icon ? <span aria-hidden="true" style={{ display: "contents" }}>{it.icon}</span> : null}
48
+ <span className="ui-sidebar-label">{it.label}</span>
49
+ {it.count != null ? <span className="ui-sidebar-count">{it.count}</span> : null}
50
+ </a>
51
+ );
52
+ const entry = (it) => {
53
+ if (it.heading) return <p key={"h-" + it.heading} className="ui-sidebar-head">{it.heading}</p>;
54
+ if (!it.items) return link(it);
55
+ const bodyId = `${sbId}-${it.label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`, isOpen = groups.has(it.label) && (open || narrow);
56
+ return (
57
+ <div key={"g-" + it.label} className="ui-sidebar-group">
58
+ <button type="button" className="ui-sidebar-item" aria-expanded={isOpen} aria-controls={bodyId} title={it.label} onClick={() => toggleGroup(it.label)}>
59
+ {it.icon ? <span aria-hidden="true" style={{ display: "contents" }}>{it.icon}</span> : null}
60
+ <span className="ui-sidebar-label">{it.label}</span><span className="ui-sidebar-chev">{CHEV}</span>
61
+ </button>
62
+ <div className="ui-sidebar-group-body" id={bodyId} hidden={!isOpen}>{it.items.map((s) => link(s, true))}</div>
63
+ </div>
64
+ );
65
+ };
66
+ const toggleLabel = narrow ? "Close menu" : open ? "Collapse sidebar" : "Expand sidebar";
67
+ return (
68
+ <div ref={root} className={cx("ui-app", className)}>
69
+ <nav ref={side} className="ui-sidebar" id={sbId} data-state={open ? "expanded" : "collapsed"} aria-label={label}>
70
+ <div className="ui-sidebar-top">
71
+ {brand ? <span className="ui-sidebar-brand">{brand}</span> : null}
72
+ <button type="button" className="ui-sidebar-toggle" aria-controls={sbId} aria-expanded={open} aria-label={toggleLabel}
73
+ onClick={() => { setOpen(!open); if (open && narrow) opener.current?.focus(); }}>{PANEL}</button>
74
+ </div>
75
+ {items.map(entry)}
76
+ {footer.length ? <div className="ui-sidebar-foot">{footer.map((it) => link(it))}</div> : null}
77
+ </nav>
78
+ <div className="ui-app-main">
79
+ <div className="ui-app-bar">
80
+ <button ref={opener} type="button" className="ui-sidebar-open" aria-controls={sbId} aria-expanded={open} aria-label="Open menu" onClick={() => setOpen(true)}>{MENU}</button>
81
+ {title ? <strong>{title}</strong> : null}
82
+ </div>
83
+ {children}
84
+ </div>
85
+ </div>
86
+ );
87
+ }
@@ -0,0 +1,19 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /**
4
+ * <Avatar name="Grace Lee" src={url} size="lg" online />
5
+ * Without src, shows initials. name is the accessible name either way.
6
+ */
7
+ export function Avatar({ name, src, size = "md", online = false, className }) {
8
+ const initials = (name || "").split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0].toUpperCase()).join("");
9
+ return (
10
+ <span className={cx("ui-avatar", size !== "md" && `ui-avatar--${size}`, className)} role="img" aria-label={name + (online ? ", online" : "")}>
11
+ {src ? <img src={src} alt="" /> : <span aria-hidden="true">{initials}</span>}
12
+ {online ? <span className="ui-avatar-status" aria-hidden="true" /> : null}
13
+ </span>
14
+ );
15
+ }
16
+
17
+ export function AvatarGroup({ label, className, children }) {
18
+ return <span className={cx("ui-avatar-group", className)} role="group" aria-label={label}>{children}</span>;
19
+ }
@@ -0,0 +1,14 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /**
4
+ * <Badge tone="ok" dot>Live</Badge>
5
+ * tone: "neutral" (default) | "ok" | "warn" | "critical" | "brand" | "outline"
6
+ */
7
+ export function Badge({ tone = "neutral", dot = false, className, children, ...rest }) {
8
+ return (
9
+ <span className={cx("ui-badge", tone !== "neutral" && `ui-badge--${tone}`, className)} {...rest}>
10
+ {dot ? <span className="ui-badge-dot" aria-hidden="true" /> : null}
11
+ {children}
12
+ </span>
13
+ );
14
+ }
@@ -0,0 +1,17 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ const X = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>;
4
+
5
+ /**
6
+ * <Banner tone="fill" link={{ href: "/schedule", label: "Open the schedule" }} onClose={hide}>Set times are live.</Banner>
7
+ * tone: default (inverted) | "fill" | "subtle"; card puts it in a rounded box. A named region, so it can be found.
8
+ */
9
+ export function Banner({ children, tone, card = false, link, onClose, label = "Announcement", className }) {
10
+ return (
11
+ <div className={cx("ui-banner", tone && `ui-banner--${tone}`, card && "ui-banner--card", className)} role="region" aria-label={label}>
12
+ <span>{children}</span>
13
+ {link ? <a href={link.href}>{link.label}</a> : null}
14
+ {onClose ? <button type="button" className="ui-banner-close" aria-label={`Dismiss ${label.toLowerCase()}`} onClick={onClose}>{X}</button> : null}
15
+ </div>
16
+ );
17
+ }
@@ -0,0 +1,23 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /**
4
+ * <Breadcrumbs items={[{ href: "/", label: "Home", icon: <HomeIcon /> }, { href: "/events", label: "Events" }, { label: "Spring 2026" }]} />
5
+ * The last item is the current page: plain text with aria-current="page", never a link.
6
+ */
7
+ export function Breadcrumbs({ items, label = "Breadcrumb", className }) {
8
+ return (
9
+ <nav className={cx("ui-breadcrumb", className)} aria-label={label}>
10
+ <ol>
11
+ {items.map((it, i) => {
12
+ const last = i === items.length - 1;
13
+ return (
14
+ <li key={i}>
15
+ {last ? <span aria-current="page">{it.icon}{it.label}</span>
16
+ : <a href={it.href} aria-label={it.icon && !it.label ? it.ariaLabel : undefined}>{it.icon}{it.label}</a>}
17
+ </li>
18
+ );
19
+ })}
20
+ </ol>
21
+ </nav>
22
+ );
23
+ }
@@ -0,0 +1,31 @@
1
+ import { forwardRef } from "react";
2
+ import { cx } from "./cx.js";
3
+
4
+ /**
5
+ * <Button variant="primary" size="sm">Save</Button>
6
+ * variant: "secondary" (default) | "primary" | "ghost" | "destructive" | "link"
7
+ * size: "md" (default) | "sm" | "lg" | "icon" (icon needs an aria-label)
8
+ * loading: shows a spinner, sets aria-busy, blocks clicks
9
+ * Same markup and classes as the HTML version; the look lives in components.css.
10
+ */
11
+ export const Button = forwardRef(function Button(
12
+ { variant = "secondary", size = "md", loading = false, className, type = "button", children, ...rest },
13
+ ref
14
+ ) {
15
+ return (
16
+ <button
17
+ ref={ref}
18
+ type={type}
19
+ aria-busy={loading || undefined}
20
+ className={cx(
21
+ "ui-btn",
22
+ variant !== "secondary" && `ui-btn--${variant}`,
23
+ size !== "md" && `ui-btn--${size}`,
24
+ className
25
+ )}
26
+ {...rest}
27
+ >
28
+ {children}
29
+ </button>
30
+ );
31
+ });
@@ -0,0 +1,29 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /**
4
+ * <ButtonGroup label="View" value={view} onChange={setView} items={[
5
+ * { value: "list", label: "List" }, { value: "board", label: "Board" } ]} />
6
+ * One pressed at a time. With multiple, value is an array and each button toggles on its own.
7
+ * Each button reports its state with aria-pressed.
8
+ */
9
+ export function ButtonGroup({ label, items, value, onChange, multiple = false, className }) {
10
+ const on = (v) => (multiple ? (value || []).includes(v) : value === v);
11
+ function press(v) {
12
+ if (!onChange) return;
13
+ if (!multiple) return onChange(v);
14
+ const list = value || [];
15
+ onChange(list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
16
+ }
17
+ return (
18
+ <div className={cx("ui-btngroup", className)} role="group" aria-label={label}>
19
+ {items.map((it) => (
20
+ <button
21
+ key={it.value} type="button" className="ui-btngroup-item" disabled={it.disabled}
22
+ aria-pressed={on(it.value)} aria-label={it.ariaLabel} onClick={() => press(it.value)}
23
+ >
24
+ {it.icon}{it.label}
25
+ </button>
26
+ ))}
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,57 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { cx } from "./cx.js";
3
+
4
+ const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
5
+ const DOW = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], DOW_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
6
+ export const toIso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
7
+ export const fromIso = (s) => { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s || ""); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null; };
8
+ const Arrow = ({ d }) => <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>;
9
+
10
+ /**
11
+ * <Calendar value="2026-04-18" onChange={setDate} events={["2026-04-11"]} />
12
+ * A month as a grid: arrows move a day, Page Up/Down a month, Home/End the week. Dates are "YYYY-MM-DD" strings.
13
+ */
14
+ export function Calendar({ value, onChange, events = [], today = new Date(), autoFocus = false, label, className }) {
15
+ const [cursor, setCursor] = useState(() => fromIso(value) || today);
16
+ const focus = useRef(autoFocus), grid = useRef(null);
17
+ useEffect(() => { if (focus.current) grid.current?.querySelector('[tabindex="0"]')?.focus(); focus.current = false; });
18
+ const y = cursor.getFullYear(), mo = cursor.getMonth(), start = new Date(y, mo, 1 - new Date(y, mo, 1).getDay());
19
+ const move = (days, months) => { focus.current = true; setCursor((c) => (months ? new Date(c.getFullYear(), c.getMonth() + months, Math.min(c.getDate(), 28)) : new Date(c.getFullYear(), c.getMonth(), c.getDate() + days))); };
20
+ function onKey(e) {
21
+ const m = { ArrowLeft: [-1], ArrowRight: [1], ArrowUp: [-7], ArrowDown: [7], Home: [-cursor.getDay()], End: [6 - cursor.getDay()], PageUp: [0, -1], PageDown: [0, 1] }[e.key];
22
+ if (m) { e.preventDefault(); move(m[0], m[1]); }
23
+ }
24
+ const titleId = `cal-${y}-${mo}`;
25
+ return (
26
+ <div className={cx("ui-calendar", className)}>
27
+ <div className="ui-calendar-head">
28
+ <button type="button" className="ui-btn ui-btn--sm ui-btn--icon ui-btn--ghost" aria-label="Previous month" onClick={() => setCursor(new Date(y, mo - 1, 1))}><Arrow d="m15 18-6-6 6-6" /></button>
29
+ <h3 className="ui-calendar-title" id={titleId} aria-live="polite">{`${MONTHS[mo]} ${y}`}</h3>
30
+ <button type="button" className="ui-btn ui-btn--sm ui-btn--icon ui-btn--ghost" aria-label="Next month" onClick={() => setCursor(new Date(y, mo + 1, 1))}><Arrow d="m9 18 6-6-6-6" /></button>
31
+ </div>
32
+ <table role="grid" aria-labelledby={titleId} aria-label={label} ref={grid} onKeyDown={onKey}>
33
+ <thead><tr>{DOW.map((d, i) => <th key={d} scope="col" abbr={DOW_LONG[i]}>{d}</th>)}</tr></thead>
34
+ <tbody>
35
+ {Array.from({ length: 6 }, (_, w) => (
36
+ <tr key={w}>
37
+ {Array.from({ length: 7 }, (_, d) => {
38
+ const day = new Date(start.getFullYear(), start.getMonth(), start.getDate() + w * 7 + d), s = toIso(day), ev = events.includes(s);
39
+ return (
40
+ <td key={s} role="gridcell">
41
+ <button
42
+ type="button" className="ui-day" tabIndex={s === toIso(cursor) ? 0 : -1}
43
+ aria-label={`${DOW_LONG[day.getDay()]}, ${MONTHS[day.getMonth()]} ${day.getDate()}${ev ? ", has events" : ""}`}
44
+ aria-current={s === toIso(today) ? "date" : undefined} aria-selected={s === value}
45
+ data-outside={day.getMonth() !== mo ? "" : undefined} data-event={ev ? "" : undefined}
46
+ onClick={() => { setCursor(day); onChange?.(s); }}
47
+ >{day.getDate()}</button>
48
+ </td>
49
+ );
50
+ })}
51
+ </tr>
52
+ ))}
53
+ </tbody>
54
+ </table>
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,14 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /** <CardHeader title="Ticket sales" badge={<Badge>Live</Badge>} description="Updated every minute." actions={<Button size="sm">Export</Button>} /> */
4
+ export function CardHeader({ title, badge, description, actions, as: H = "h3", className }) {
5
+ return (
6
+ <div className={cx("ui-cardhead", className)}>
7
+ <div className="ui-cardhead-text">
8
+ <H className="ui-cardhead-title">{title}{badge}</H>
9
+ {description ? <p className="ui-cardhead-desc">{description}</p> : null}
10
+ </div>
11
+ {actions ? <div className="ui-cardhead-actions">{actions}</div> : null}
12
+ </div>
13
+ );
14
+ }
@@ -0,0 +1,42 @@
1
+ import { createElement, useEffect, useRef, useState } from "react";
2
+ import { chartNodes, toReact, animate, motionLevel, showTip, hideTip } from "../charts-core.mjs";
3
+ import { cx } from "./cx.js";
4
+
5
+ /**
6
+ * The kit's charts in React: the same geometry as charts.js (charts-core.mjs), so both draw the same chart.
7
+ * <Chart type="bar" label="Monthly expenses" labels={["Jan", "Feb"]} values={[4200, 7100]} prefix="$" />
8
+ * <Chart type="bar" stacked label="File transfer" labels={months} series={[{ name: "Desktop", values }, { name: "Mobile", values }]} />
9
+ * <Chart type="donut" label="Leads by source" center="Leads" labels={sources} values={counts} />
10
+ * <Chart type="ring" label="Target reached" value={48} center="of target" />
11
+ * type: bar | line | area | donut | pie | spark | ring | meter. horizontal, stacked (bar); bars (spark); max (ring, meter);
12
+ * prefix, suffix, decimals; tone good | warn | neg for one series that means good or bad.
13
+ * animate={false} when Motion (or anything else) plays it in instead.
14
+ * Draws at its real width, plays in once when it scrolls into view (as far as the motion level allows), and keeps a
15
+ * data table for screen readers.
16
+ */
17
+ export function Chart({ type, label = "Chart", labels = [], values, series, stacked = false, horizontal = false, bars = false, value, max,
18
+ prefix = "", suffix = "", decimals = 0, tone = "", center = "", height, animate: play = true, className }) {
19
+ const ref = useRef(null);
20
+ const [width, setWidth] = useState(600);
21
+ const spec = { type, label, labels, series: series || (values ? [{ name: label, values }] : []), stacked, horizontal, bars, tone, center,
22
+ prefix, suffix, decimals, value, max: max ?? (type === "ring" ? 100 : undefined) };
23
+ useEffect(() => {
24
+ const fig = ref.current; if (!fig || typeof ResizeObserver !== "function") return undefined;
25
+ const ro = new ResizeObserver(([e]) => { const w = Math.round(e.contentRect.width); setWidth(prev => Math.abs(w - prev) > 8 ? w : prev); });
26
+ ro.observe(fig);
27
+ return () => ro.disconnect();
28
+ }, []);
29
+ useEffect(() => {
30
+ const fig = ref.current; if (!play || !fig || typeof IntersectionObserver !== "function") return undefined;
31
+ const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { io.disconnect(); animate(fig, motionLevel()); } }, { threshold: 0.25 });
32
+ io.observe(fig);
33
+ return () => io.disconnect();
34
+ }, []);
35
+ const h = height ?? (type === "spark" ? 40 : 220);
36
+ return (
37
+ <figure ref={ref} className={cx("ui-viz", `ui-viz--${type}`, horizontal && "ui-viz--horizontal", className)} aria-label={label}
38
+ onPointerMove={e => showTip(ref.current, spec, e)} onPointerLeave={() => hideTip(ref.current)}>
39
+ {toReact(chartNodes(spec, width, h), createElement)}
40
+ </figure>
41
+ );
42
+ }
@@ -0,0 +1,34 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { cx } from "./cx.js";
3
+
4
+ const SEND = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m22 2-7 20-4-9-9-4z" /><path d="M22 2 11 13" /></svg>;
5
+ const initials = (n) => n.split(" ").map((w) => w[0]).join("").slice(0, 2);
6
+
7
+ /**
8
+ * <Chat label="Production chat" messages={[{ id: 1, from: "Grace Lee", text: "Doors at noon?", time: "9:41" }, { id: 2, mine: true, text: "Yes.", time: "9:42" }]} onSend={send} />
9
+ * Messages read out as they arrive (role="log"). Enter sends, Shift+Enter adds a line.
10
+ */
11
+ export function Chat({ label, messages, onSend, placeholder = "Write a message", className }) {
12
+ const [text, setText] = useState("");
13
+ const log = useRef(null);
14
+ useEffect(() => { if (log.current) log.current.scrollTop = log.current.scrollHeight; }, [messages.length]);
15
+ const send = () => { const t = text.trim(); if (!t) return; onSend?.(t); setText(""); };
16
+ return (
17
+ <section className={cx("ui-chat", className)} aria-label={label}>
18
+ <ol className="ui-chat-log" role="log" aria-live="polite" ref={log}>
19
+ {messages.map((m) => (
20
+ <li key={m.id} className={cx("ui-msg", m.mine && "ui-msg--out")}>
21
+ {!m.mine ? <span className="ui-avatar ui-avatar--sm" role="img" aria-label={m.from}><span aria-hidden="true">{initials(m.from)}</span></span> : null}
22
+ <div className="ui-msg-bubble">{m.text}</div>
23
+ <span className="ui-msg-meta">{m.mine ? "You" : m.from} · {m.time}</span>
24
+ </li>
25
+ ))}
26
+ </ol>
27
+ <div className="ui-chat-compose">
28
+ <textarea className="ui-input" rows={1} aria-label="Message" placeholder={placeholder} value={text} onChange={(e) => setText(e.target.value)}
29
+ onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(); } }} />
30
+ <button type="button" className="ui-btn ui-btn--primary ui-btn--icon" aria-label="Send" onClick={send}>{SEND}</button>
31
+ </div>
32
+ </section>
33
+ );
34
+ }