@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,161 @@
1
+ import { useId, useState } from "react";
2
+ import { cx } from "./cx.js";
3
+
4
+ // Marketing sections. Each lays itself out by its own width (container queries in components.css), so it works in a
5
+ // column as well as a full page. Content is yours: these take text, links and children, never images they invent.
6
+
7
+ /** <Section alt eyebrow="Lineup" title="Three stages, one weekend" lead="…" center>{children}</Section> */
8
+ export function Section({ eyebrow, title, lead, center = false, alt = false, as: H = "h2", children, className, ...rest }) {
9
+ return (
10
+ <section className={cx("ui-section", alt && "ui-section--alt", className)} {...rest}>
11
+ <div className="ui-section-inner">
12
+ {title ? (
13
+ <div className={cx("ui-section-head", center && "ui-section-head--center")}>
14
+ {eyebrow ? <span className="ui-eyebrow">{eyebrow}</span> : null}
15
+ <H className="ui-section-title">{title}</H>
16
+ {lead ? <p className="ui-section-lead">{lead}</p> : null}
17
+ </div>
18
+ ) : null}
19
+ {children}
20
+ </div>
21
+ </section>
22
+ );
23
+ }
24
+
25
+ /** <Media label="Crowd at the main stage" square /> an image's place until a real one goes in (role="img") */
26
+ export function Media({ label, square = false, className }) {
27
+ return <div className={cx("ui-media", square && "ui-media--square", className)} role="img" aria-label={label}><span aria-hidden="true">Image</span></div>;
28
+ }
29
+
30
+ /** <SiteNav brand="BKSTG" links={[{ href: "/", label: "Lineup", current: true }]} actions={<Button variant="primary">Tickets</Button>} /> */
31
+ export function SiteNav({ brand, brandMark, links, actions, label = "Main", className }) {
32
+ const [open, setOpen] = useState(false);
33
+ const id = useId();
34
+ return (
35
+ <header className={cx("ui-sitenav", className)}>
36
+ <div className="ui-sitenav-bar">
37
+ <a className="ui-sitenav-brand" href="/">{brandMark}{brand}</a>
38
+ <nav aria-label={label}><ul className="ui-sitenav-links">{links.map((l) => <li key={l.href}><a href={l.href} aria-current={l.current ? "page" : undefined}>{l.label}</a></li>)}</ul></nav>
39
+ <div className="ui-sitenav-actions">{actions}</div>
40
+ <button type="button" className="ui-btn ui-btn--icon ui-btn--ghost ui-sitenav-toggle" aria-expanded={open} aria-controls={id} aria-label={open ? "Close menu" : "Open menu"} onClick={() => setOpen((o) => !o)}>
41
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">{open ? <path d="M18 6 6 18M6 6l12 12" /> : <path d="M4 6h16M4 12h16M4 18h16" />}</svg>
42
+ </button>
43
+ </div>
44
+ <nav className="ui-sitenav-panel" id={id} aria-label={`${label}, menu`} hidden={!open}>
45
+ {links.map((l) => <a key={l.href} href={l.href} aria-current={l.current ? "page" : undefined}>{l.label}</a>)}
46
+ {actions}
47
+ </nav>
48
+ </header>
49
+ );
50
+ }
51
+
52
+ /** <Hero eyebrow="April 17–19" title="Spring 2026" lead="…" actions={<Button variant="primary">Get tickets</Button>} media={<Media label="…" />} /> */
53
+ export function Hero({ eyebrow, title, lead, actions, media, center = false, className }) {
54
+ return (
55
+ <div className={cx("ui-hero", center ? "ui-hero--center" : media && "ui-hero--split", className)}>
56
+ <div className="ui-hero-text">
57
+ {eyebrow ? <span className="ui-eyebrow">{eyebrow}</span> : null}
58
+ <h1 className="ui-hero-title">{title}</h1>
59
+ {lead ? <p className="ui-hero-lead">{lead}</p> : null}
60
+ {actions ? <div className="ui-hero-actions">{actions}</div> : null}
61
+ </div>
62
+ {media}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ /** <FeatureCard icon={<MusicIcon />} title="Live set times" text="…" /> */
68
+ export function FeatureCard({ icon, title, text, children, className }) {
69
+ return <div className={cx("ui-mk-card", className)}>{icon ? <span className="ui-feature-icon" aria-hidden="true">{icon}</span> : null}<h3>{title}</h3>{text ? <p>{text}</p> : null}{children}</div>;
70
+ }
71
+
72
+ /** <Stats items={[{ value: "42K", label: "Fans", note: "Across three days" }]} /> */
73
+ export function Stats({ items, className }) {
74
+ return <dl className={cx("ui-stats", className)}>{items.map((s) => <div key={s.label} className="ui-stat"><dt className="ui-stat-label">{s.label}</dt><dd className="ui-stat-value" style={{ margin: 0, order: -1 }}>{s.value}</dd>{s.note ? <dd className="ui-stat-note" style={{ margin: 0 }}>{s.note}</dd> : null}</div>)}</dl>;
75
+ }
76
+
77
+ /** <LogoCloud caption="Trusted by" logos={[<img … />, …]} /> */
78
+ export function LogoCloud({ caption, logos, className }) {
79
+ return <div className={cx("ui-logocloud", className)}>{caption ? <p>{caption}</p> : null}<ul>{logos.map((l, i) => <li key={i}>{l}</li>)}</ul></div>;
80
+ }
81
+
82
+ /** <Testimonial quote="…" name="Grace Lee" role="Head of Production" avatar={<Avatar name="Grace Lee" />} large /> */
83
+ export function Testimonial({ quote, name, role, avatar, large = false, className }) {
84
+ return <figure className={cx("ui-quote", large && "ui-quote--large", className)}><blockquote><p style={{ margin: 0 }}>{quote}</p></blockquote><figcaption>{avatar}<span><strong>{name}</strong>{role}</span></figcaption></figure>;
85
+ }
86
+
87
+ /** <TeamMember name="Ana Ruiz" role="Front of house" bio="…" media={<Media label="Ana Ruiz" square />} /> */
88
+ export function TeamMember({ name, role, bio, media, className }) {
89
+ return <div className={cx("ui-person", className)}>{media}<h3>{name}</h3><p className="ui-person-role">{role}</p>{bio ? <p>{bio}</p> : null}</div>;
90
+ }
91
+
92
+ /** <PostCard href="/blog/x" title="…" excerpt="…" meta={<><Badge>News</Badge> · 4 min read</>} media={<Media label="…" />} /> */
93
+ export function PostCard({ href, title, excerpt, meta, media, className }) {
94
+ return <a className={cx("ui-post", className)} href={href}>{media}<span className="ui-post-meta">{meta}</span><h3>{title}</h3>{excerpt ? <p>{excerpt}</p> : null}</a>;
95
+ }
96
+
97
+ /** <JobList jobs={[{ href: "/jobs/1", title: "Stage manager", team: "Production", tags: ["Seasonal", "San Diego"] }]} /> */
98
+ export function JobList({ jobs, className }) {
99
+ return <ul className={cx("ui-jobs", className)}>{jobs.map((j) => <li key={j.href} className="ui-job"><div className="ui-job-title"><a href={j.href}>{j.title}</a><span>{j.team}</span></div><div className="ui-job-tags">{(j.tags || []).map((t) => <span key={t} className="ui-badge ui-badge--outline">{t}</span>)}</div></li>)}</ul>;
100
+ }
101
+
102
+ /** <FAQ items={[{ q: "Can I re-enter?", a: "Until 6 PM." }]} /> native <details>: no script needed */
103
+ export function FAQ({ items, className }) {
104
+ return <div className={cx("ui-faq", className)}>{items.map((it, i) => <details key={i} open={it.open}><summary>{it.q}</summary><div>{it.a}</div></details>)}</div>;
105
+ }
106
+
107
+ /** <Plan name="VIP" price="$389" per="2 days" text="…" features={["…"]} featured badge="Most popular" action={<Button variant="primary">Choose VIP</Button>} /> */
108
+ export function Plan({ name, badge, price, per, text, features, action, featured = false, className }) {
109
+ return (
110
+ <div className={cx("ui-plan", featured && "ui-plan--featured", className)}>
111
+ <h3>{name}{badge ? <span className="ui-badge ui-badge--brand">{badge}</span> : null}</h3>
112
+ <p className="ui-plan-price"><strong>{price}</strong>{per ? <span>{per}</span> : null}</p>
113
+ {text ? <p>{text}</p> : <p />}
114
+ <ul aria-label={`${name} includes`}>{features.map((f) => <li key={f}>{f}</li>)}</ul>
115
+ {action}
116
+ </div>
117
+ );
118
+ }
119
+
120
+ /** <CTABand title="See you in April" text="…" actions={<><Button>Lineup</Button><Button variant="primary">Get tickets</Button></>} /> */
121
+ export function CTABand({ title, text, actions, className }) {
122
+ return <section className={cx("ui-cta-band", className)}><h2>{title}</h2>{text ? <p>{text}</p> : null}{actions ? <div className="ui-hero-actions">{actions}</div> : null}</section>;
123
+ }
124
+
125
+ /**
126
+ * <Newsletter title="Lineup news, first" text="…" onSubscribe={(email) => …} />
127
+ * A real form: the email field is required and typed, and the result is announced.
128
+ */
129
+ export function Newsletter({ title, text, onSubscribe, note = "One email a month. Unsubscribe anytime.", className }) {
130
+ const id = useId();
131
+ const [done, setDone] = useState("");
132
+ return (
133
+ <div className={cx("ui-newsletter", className)}>
134
+ <div className="ui-section-head"><h2 className="ui-section-title">{title}</h2>{text ? <p className="ui-section-lead">{text}</p> : null}</div>
135
+ <form onSubmit={(e) => { e.preventDefault(); const v = new FormData(e.currentTarget).get("email"); onSubscribe?.(v); setDone(`Thanks. Check ${v} to confirm.`); e.currentTarget.reset(); }}>
136
+ <div className="ui-newsletter-row"><label className="ui-sr-only" htmlFor={id}>Email</label><input className="ui-input" id={id} name="email" type="email" required autoComplete="email" placeholder="you@example.com" /><button type="submit" className="ui-btn ui-btn--primary">Subscribe</button></div>
137
+ <small role="status">{done || note}</small>
138
+ </form>
139
+ </div>
140
+ );
141
+ }
142
+
143
+ /** <Prose>{article}</Prose> long-form text: headings, lists, quotes and figures in a readable measure */
144
+ export function Prose({ children, className }) {
145
+ return <div className={cx("ui-prose", className)}>{children}</div>;
146
+ }
147
+
148
+ /** <Footer brand="BKSTG" blurb="…" columns={[{ title: "Festival", links: [{ href: "/lineup", label: "Lineup" }] }]} legal="© 2026 …" /> */
149
+ export function Footer({ brand, blurb, columns, legal, bottom, className }) {
150
+ return (
151
+ <footer className={cx("ui-footer", className)}>
152
+ <div className="ui-footer-inner">
153
+ <div className="ui-footer-top">
154
+ <div className="ui-footer-brand"><strong>{brand}</strong>{blurb ? <p>{blurb}</p> : null}</div>
155
+ {columns.map((c) => <nav key={c.title} aria-label={c.title}><h2>{c.title}</h2><ul>{c.links.map((l) => <li key={l.href}><a href={l.href}>{l.label}</a></li>)}</ul></nav>)}
156
+ </div>
157
+ <div className="ui-footer-bottom"><small>{legal}</small>{bottom}</div>
158
+ </div>
159
+ </footer>
160
+ );
161
+ }
@@ -0,0 +1,76 @@
1
+ import { useEffect, useId, useRef, useState } from "react";
2
+ import { cx } from "./cx.js";
3
+
4
+ const CHEVRON = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>;
5
+
6
+ /**
7
+ * <Menu label="Options" items={[
8
+ * { label: "Edit", icon: <PencilIcon />, hint: "⌘E", onSelect: edit },
9
+ * { separator: true },
10
+ * { label: "Delete", danger: true, onSelect: remove } ]} />
11
+ * The menu button pattern: Enter, Space or ArrowDown opens on the first item, ArrowUp on the last; arrows,
12
+ * Home and End move; a letter jumps; Escape closes and focus returns to the button. A { heading } item labels a section.
13
+ */
14
+ export function Menu({ label, items, align, buttonClassName, className }) {
15
+ const id = useId();
16
+ const [open, setOpen] = useState(false);
17
+ const [flip, setFlip] = useState(false);
18
+ const btn = useRef(null), menu = useRef(null), start = useRef("first");
19
+ const enabled = () => [...(menu.current?.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])') || [])];
20
+
21
+ useEffect(() => {
22
+ if (!open) return;
23
+ const list = enabled();
24
+ (start.current === "last" ? list[list.length - 1] : list[0])?.focus();
25
+ setFlip(align === "end" || (menu.current && menu.current.getBoundingClientRect().right > window.innerWidth - 8));
26
+ const outside = (e) => { if (!menu.current?.contains(e.target) && !btn.current?.contains(e.target)) setOpen(false); };
27
+ document.addEventListener("mousedown", outside);
28
+ return () => document.removeEventListener("mousedown", outside);
29
+ }, [open]);
30
+
31
+ function close(back) { setOpen(false); if (back) btn.current?.focus(); }
32
+ function onButtonKey(e) {
33
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); start.current = e.key === "ArrowUp" ? "last" : "first"; setOpen(true); }
34
+ }
35
+ function onMenuKey(e) {
36
+ const list = enabled(), i = list.indexOf(document.activeElement);
37
+ let next = null;
38
+ if (e.key === "ArrowDown") next = list[(i + 1) % list.length];
39
+ else if (e.key === "ArrowUp") next = list[(i - 1 + list.length) % list.length];
40
+ else if (e.key === "Home") next = list[0];
41
+ else if (e.key === "End") next = list[list.length - 1];
42
+ else if (e.key === "Escape") { e.preventDefault(); return close(true); }
43
+ else if (e.key === "Tab") return close(false);
44
+ else if (e.key.length === 1 && /\S/.test(e.key)) {
45
+ const order = [...list.slice(i + 1), ...list.slice(0, i + 1)];
46
+ next = order.find((x) => x.textContent.trim().toLowerCase().startsWith(e.key.toLowerCase())) || null;
47
+ }
48
+ if (next) { e.preventDefault(); next.focus(); }
49
+ }
50
+
51
+ return (
52
+ <span className={cx("ui-menu-wrap", className)} data-align={flip ? "end" : undefined}>
53
+ <button
54
+ ref={btn} type="button" className={cx("ui-btn ui-menu-trigger", buttonClassName)}
55
+ aria-haspopup="menu" aria-expanded={open} aria-controls={id}
56
+ onClick={() => { start.current = "first"; setOpen((o) => !o); }} onKeyDown={onButtonKey}
57
+ >
58
+ {label}{CHEVRON}
59
+ </button>
60
+ <div ref={menu} id={id} className="ui-menu" role="menu" aria-label={typeof label === "string" ? label : undefined} hidden={!open} onKeyDown={onMenuKey}>
61
+ {items.map((it, i) => it.separator ? <div key={i} className="ui-menu-sep" role="separator" />
62
+ : it.heading ? <div key={i} className="ui-menu-label" role="presentation">{it.heading}</div>
63
+ : (
64
+ <button
65
+ key={i} type="button" role="menuitem" tabIndex={-1}
66
+ className={cx("ui-menu-item", it.danger && "ui-menu-item--danger")}
67
+ aria-disabled={it.disabled || undefined}
68
+ onClick={() => { if (it.disabled) return; it.onSelect?.(); close(true); }}
69
+ >
70
+ {it.icon}{it.label}{it.hint ? <span className="ui-menu-hint" aria-hidden="true">{it.hint}</span> : null}
71
+ </button>
72
+ ))}
73
+ </div>
74
+ </span>
75
+ );
76
+ }
@@ -0,0 +1,28 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /**
4
+ * <Metric label="Tickets sold" value="18,880" change="+7.4%" /> change starting with "-" reads as down.
5
+ * <Metric label="Refund requests" value="12" change="-2.0%" goodWhen="down" /> for numbers where less is better.
6
+ * The arrow is decoration; the change is spoken as "up 7.4%, better" or "down 2%, better"; its color says good or bad.
7
+ */
8
+ export function Metric({ label, value, change, goodWhen = "up", className }) {
9
+ const down = typeof change === "string" && change.trim().startsWith("-");
10
+ const good = down === (goodWhen === "down");
11
+ const amount = change ? change.replace(/^[+-]/, "") : "";
12
+ return (
13
+ <div className={cx("ui-metric", className)}>
14
+ <span className="ui-metric-label">{label}</span>
15
+ <span className="ui-metric-row">
16
+ <span className="ui-metric-value">{value}</span>
17
+ {change ? (
18
+ <span className={cx("ui-metric-delta", down && "ui-metric-delta--down", good ? "ui-metric-delta--good" : "ui-metric-delta--bad")}>
19
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
20
+ {down ? <path d="M7 7l10 10M17 7v10H7" /> : <path d="M7 17 17 7M7 7h10v10" />}
21
+ </svg>
22
+ <span className="ui-sr-only">{down ? "down" : "up"} </span>{amount}<span className="ui-sr-only">, {good ? "better" : "worse"}</span>
23
+ </span>
24
+ ) : null}
25
+ </span>
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,22 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /**
4
+ * <PageHeader breadcrumbs={<Breadcrumbs items={...} />} title="Spring 2026" description="Three stages, two days."
5
+ * actions={<><Button>Share</Button><Button variant="primary">Publish</Button></>} tabs={<Tabs ... />} />
6
+ * The page's one h1.
7
+ */
8
+ export function PageHeader({ breadcrumbs, title, description, actions, tabs, className }) {
9
+ return (
10
+ <header className={cx("ui-pagehead", className)}>
11
+ {breadcrumbs}
12
+ <div className="ui-pagehead-row">
13
+ <div className="ui-pagehead-text">
14
+ <h1 className="ui-pagehead-title">{title}</h1>
15
+ {description ? <p className="ui-pagehead-desc">{description}</p> : null}
16
+ </div>
17
+ {actions ? <div className="ui-pagehead-actions">{actions}</div> : null}
18
+ </div>
19
+ {tabs}
20
+ </header>
21
+ );
22
+ }
@@ -0,0 +1,32 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ 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>;
4
+
5
+ /** The page numbers to show: always the first and last, the current one and its neighbors, gaps as null */
6
+ export function pageList(page, count) {
7
+ const want = new Set([1, count, page - 1, page, page + 1].filter((n) => n >= 1 && n <= count));
8
+ const out = [];
9
+ [...want].sort((a, b) => a - b).forEach((n, i, all) => { if (i && n - all[i - 1] > 1) out.push(null); out.push(n); });
10
+ return out;
11
+ }
12
+
13
+ /**
14
+ * <Pagination page={page} count={12} onChange={setPage} />
15
+ * Previous and Next, then the numbers; in a narrow container the numbers give way to "Page 2 of 12".
16
+ */
17
+ export function Pagination({ page, count, onChange, label = "Pagination", className }) {
18
+ const go = (n) => () => onChange?.(n);
19
+ return (
20
+ <nav className={cx("ui-pagination", className)} aria-label={label}>
21
+ <button type="button" className="ui-btn ui-btn--sm" onClick={go(page - 1)} disabled={page <= 1}>{ARROW("m15 18-6-6 6-6")}Previous</button>
22
+ <ul className="ui-pagination-pages">
23
+ {pageList(page, count).map((n, i) => (
24
+ <li key={i}>{n === null ? <span className="ui-page-gap" aria-hidden="true">…</span>
25
+ : <button type="button" className="ui-page" aria-current={n === page ? "page" : undefined} aria-label={`Page ${n}`} onClick={go(n)}>{n}</button>}</li>
26
+ ))}
27
+ </ul>
28
+ <span className="ui-pagination-info" aria-live="polite">{`Page ${page} of ${count}`}</span>
29
+ <button type="button" className="ui-btn ui-btn--sm" onClick={go(page + 1)} disabled={page >= count}>Next{ARROW("m9 18 6-6-6-6")}</button>
30
+ </nav>
31
+ );
32
+ }
@@ -0,0 +1,26 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /**
4
+ * <Progress value={62} label="Uploading" /> 0 to 100
5
+ * <Progress label="Loading" /> no value: indeterminate
6
+ * label is required: it is the progress bar's accessible name.
7
+ */
8
+ export function Progress({ value, label, showValue = true, className }) {
9
+ const known = typeof value === "number";
10
+ const v = known ? Math.max(0, Math.min(100, value)) : undefined;
11
+ return (
12
+ <div className={className}>
13
+ {showValue ? <div className="ui-progress-label"><span>{label}</span>{known ? <span>{Math.round(v)}%</span> : null}</div> : null}
14
+ <div
15
+ className={cx("ui-progress", !known && "ui-progress--indeterminate")}
16
+ role="progressbar"
17
+ aria-label={label}
18
+ aria-valuemin={known ? 0 : undefined}
19
+ aria-valuemax={known ? 100 : undefined}
20
+ aria-valuenow={known ? Math.round(v) : undefined}
21
+ >
22
+ <div className="ui-progress-bar" style={known ? { "--value": `${v}%` } : undefined} />
23
+ </div>
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,27 @@
1
+ import { useId } from "react";
2
+ import { cx } from "./cx.js";
3
+
4
+ /**
5
+ * <RadioGroup legend="Ticket type" value={type} onChange={setType} options={[
6
+ * { value: "ga", label: "General admission", hint: "Both days" }, { value: "vip", label: "VIP" } ]} />
7
+ * Native radios in a fieldset: the arrow keys move the choice, Tab leaves the group.
8
+ */
9
+ export function RadioGroup({ legend, name, value, defaultValue, onChange, options, className }) {
10
+ const auto = useId();
11
+ const group = name || auto;
12
+ return (
13
+ <fieldset className={cx("ui-choices", className)}>
14
+ {legend ? <legend className="ui-label">{legend}</legend> : null}
15
+ {options.map((o) => (
16
+ <label key={o.value} className="ui-check-row">
17
+ <input
18
+ type="radio" className="ui-radio" name={group} value={o.value} disabled={o.disabled}
19
+ {...(value !== undefined ? { checked: value === o.value } : { defaultChecked: defaultValue === o.value })}
20
+ onChange={(e) => onChange?.(e.target.value, e)}
21
+ />
22
+ {o.hint ? <span className="ui-check-text">{o.label}<small>{o.hint}</small></span> : o.label}
23
+ </label>
24
+ ))}
25
+ </fieldset>
26
+ );
27
+ }
@@ -0,0 +1,17 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ /**
4
+ * <Reveal effect="fade-up" stagger={60}>…</Reveal> the kit's own engine (motion.js, no library) from React.
5
+ * Load motion.js once in the app. Props mirror the no-code attributes: effect, trigger ("scroll" | "load" | "hover"),
6
+ * duration, delay, ease, distance, stagger, split ("words" | "chars" | "lines"), repeat. It follows the visitor's
7
+ * reduce-motion setting and the system's Motion level. For app-style animation (layout, gestures, exit), use Motion.
8
+ */
9
+ export function Reveal({ as: As = "div", effect = "fade-up", trigger, duration, delay, ease, distance, stagger, split, repeat, children, ...rest }) {
10
+ const ref = useRef(null);
11
+ useEffect(() => { if (ref.current && window.UIMotion) window.UIMotion.init(ref.current.parentElement || document); }, []);
12
+ return (
13
+ <As ref={ref} data-reveal={effect} data-reveal-trigger={trigger} data-reveal-duration={duration} data-reveal-delay={delay}
14
+ data-reveal-ease={ease} data-reveal-distance={distance} data-reveal-stagger={stagger} data-reveal-split={split}
15
+ data-reveal-repeat={repeat ? "" : undefined} {...rest}>{children}</As>
16
+ );
17
+ }
@@ -0,0 +1,24 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /** <SectionHeader title="Team" description="Who can see and change this event." actions={<Button size="sm">Invite</Button>} /> */
4
+ export function SectionHeader({ title, description, actions, as: H = "h2", className }) {
5
+ return (
6
+ <div className={cx("ui-sectionhead", className)}>
7
+ <div className="ui-sectionhead-text">
8
+ <H className="ui-sectionhead-title">{title}</H>
9
+ {description ? <p className="ui-sectionhead-desc">{description}</p> : null}
10
+ </div>
11
+ {actions ? <div className="ui-sectionhead-actions">{actions}</div> : null}
12
+ </div>
13
+ );
14
+ }
15
+
16
+ /** <SectionFooter note="Last saved 2 minutes ago" actions={<><Button>Cancel</Button><Button variant="primary">Save</Button></>} /> */
17
+ export function SectionFooter({ note, actions, className }) {
18
+ return (
19
+ <div className={cx("ui-sectionfoot", className)}>
20
+ {note ? <p className="ui-sectionfoot-note">{note}</p> : null}
21
+ {actions ? <div className="ui-sectionfoot-actions">{actions}</div> : null}
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,19 @@
1
+ import { forwardRef } from "react";
2
+ import { cx } from "./cx.js";
3
+
4
+ /**
5
+ * <Select value={v} onChange={...} options={[{ value: "a", label: "A" }]} />
6
+ * A native <select>: the phone's own picker, full keyboard support, works in forms.
7
+ */
8
+ export const Select = forwardRef(function Select({ options = [], className, children, ...rest }, ref) {
9
+ return (
10
+ <span className={cx("ui-select", className)}>
11
+ <select ref={ref} className="ui-input" {...rest}>
12
+ {options.map((o) => (
13
+ <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>
14
+ ))}
15
+ {children}
16
+ </select>
17
+ </span>
18
+ );
19
+ });
@@ -0,0 +1,25 @@
1
+ import { useEffect, useId, useRef } from "react";
2
+ import { cx } from "./cx.js";
3
+
4
+ const X = <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>;
5
+
6
+ /**
7
+ * <Sheet open={open} onClose={() => setOpen(false)} title="Set details" footer={<Button onClick={save}>Save</Button>}>…</Sheet>
8
+ * A slide-out panel on the right: a modal dialog, so focus stays inside; Escape and the backdrop close it.
9
+ */
10
+ export function Sheet({ open, onClose, title, children, footer, className }) {
11
+ const ref = useRef(null), id = useId();
12
+ useEffect(() => {
13
+ const d = ref.current; if (!d) return;
14
+ if (open && !d.open) d.showModal();
15
+ if (!open && d.open) d.close();
16
+ }, [open]);
17
+ return (
18
+ <dialog ref={ref} className={cx("ui-sheet", className)} aria-labelledby={`${id}-t`} onClose={onClose} onClick={(e) => { if (e.target === ref.current) onClose?.(); }}>
19
+ <div className="ui-sheet-head"><h2 className="ui-sheet-title" id={`${id}-t`}>{title}</h2>
20
+ <button type="button" className="ui-dialog-close" aria-label="Close" onClick={onClose}>{X}</button></div>
21
+ <div className="ui-sheet-body">{children}</div>
22
+ {footer ? <div className="ui-sheet-foot">{footer}</div> : null}
23
+ </dialog>
24
+ );
25
+ }
@@ -0,0 +1,37 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /**
4
+ * <SideNav label="Main" brand={<Logo />} items={[{ href: "/", label: "Overview", icon: <HomeIcon />, current: true, count: 8 }]}
5
+ * footer={[...]} user={{ name: "Sean Marshall", email: "…" }} />
6
+ * The current page gets aria-current="page". Icons are decoration (aria-hidden); the label names the link.
7
+ */
8
+ export function SideNav({ label = "Main", brand, items = [], footer = [], user, children, className }) {
9
+ const list = (links) => (
10
+ <ul className="ui-sidenav-list">
11
+ {links.map((it) => (
12
+ <li key={it.href + it.label}>
13
+ <a className="ui-sidenav-link" href={it.href} aria-current={it.current ? "page" : undefined}>
14
+ {it.icon ? <span aria-hidden="true">{it.icon}</span> : null}
15
+ {it.label}
16
+ {it.count != null ? <span className="ui-badge">{it.count}</span> : null}
17
+ </a>
18
+ </li>
19
+ ))}
20
+ </ul>
21
+ );
22
+ return (
23
+ <nav className={cx("ui-sidenav", className)} aria-label={label}>
24
+ {brand ? <div className="ui-sidenav-brand">{brand}</div> : null}
25
+ {list(items)}
26
+ <div className="ui-sidenav-spacer" />
27
+ {footer.length ? list(footer) : null}
28
+ {children}
29
+ {user ? (
30
+ <div className="ui-sidenav-user">
31
+ <span className="ui-avatar ui-avatar--sm" aria-hidden="true">{user.name.split(" ").map((w) => w[0]).join("").slice(0, 2)}</span>
32
+ <span><strong>{user.name}</strong>{user.email ? <small>{user.email}</small> : null}</span>
33
+ </div>
34
+ ) : null}
35
+ </nav>
36
+ );
37
+ }
@@ -0,0 +1,16 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /**
4
+ * <Skeleton width="60%" height="1rem" /> <Skeleton circle width={40} height={40} />
5
+ * A placeholder while content loads. Hidden from screen readers: announce loading elsewhere (a Spinner,
6
+ * or aria-busy on the region). Carries .ambient so the Calm motion level stops the shimmer.
7
+ */
8
+ export function Skeleton({ width, height, circle = false, className, style }) {
9
+ return (
10
+ <span
11
+ className={cx("ui-skeleton", circle && "ui-skeleton--circle", "ambient", className)}
12
+ aria-hidden="true"
13
+ style={{ width, height, ...style }}
14
+ />
15
+ );
16
+ }
@@ -0,0 +1,27 @@
1
+ import { useId, useState } from "react";
2
+ import { cx } from "./cx.js";
3
+
4
+ /**
5
+ * <Slider label="Capacity" min={0} max={5000} step={100} value={cap} onChange={setCap} format={(v) => `${v} people`} />
6
+ * A native range input: arrow keys, Page Up/Down, Home and End all work. The value is shown beside the label
7
+ * and spoken through aria-valuetext when format is given.
8
+ */
9
+ export function Slider({ label, min = 0, max = 100, step = 1, value, defaultValue, onChange, format, scale, className, ...rest }) {
10
+ const id = useId();
11
+ const [own, setOwn] = useState(defaultValue ?? min);
12
+ const v = value ?? own;
13
+ const pct = ((v - min) / (max - min || 1)) * 100;
14
+ const text = format ? format(v) : Number(v).toLocaleString();
15
+ return (
16
+ <div className={className}>
17
+ {label ? <div className="ui-slider-head"><label className="ui-label" htmlFor={id}>{label}</label><output htmlFor={id}>{text}</output></div> : null}
18
+ <input
19
+ id={id} type="range" className="ui-slider" min={min} max={max} step={step} value={v}
20
+ aria-valuetext={format ? text : undefined} style={{ "--value": `${pct}%` }}
21
+ onChange={(e) => { const n = Number(e.target.value); setOwn(n); onChange?.(n, e); }}
22
+ {...rest}
23
+ />
24
+ {scale ? <div className="ui-slider-scale" aria-hidden="true">{scale.map((s) => <span key={s}>{s}</span>)}</div> : null}
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,14 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /**
4
+ * <Spinner label="Loading results" size="sm" />
5
+ * role="status" so a screen reader announces the label; the ring itself is decoration.
6
+ */
7
+ export function Spinner({ label = "Loading", size = "md", className }) {
8
+ return (
9
+ <span role="status" className={className}>
10
+ <span className={cx("ui-spinner", size !== "md" && `ui-spinner--${size}`)} aria-hidden="true" />
11
+ <span className="ui-sr-only">{label}</span>
12
+ </span>
13
+ );
14
+ }
@@ -0,0 +1,22 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /**
4
+ * <Steps current={1} steps={[{ label: "Details", desc: "Name and dates" }, { label: "Stages" }, { label: "Publish" }]} />
5
+ * Steps before current are complete, current is marked aria-current="step". vertical stacks them.
6
+ */
7
+ export function Steps({ steps, current = 0, vertical = false, label = "Progress", className }) {
8
+ return (
9
+ <ol className={cx("ui-steps", vertical && "ui-steps--vertical", className)} aria-label={label}>
10
+ {steps.map((s, i) => {
11
+ const state = i < current ? "complete" : i === current ? "current" : "upcoming";
12
+ return (
13
+ <li key={i} className="ui-step" data-state={state} aria-current={state === "current" ? "step" : undefined}>
14
+ <span className="ui-step-marker" aria-hidden="true" />
15
+ <span className="ui-step-label">{s.label}{state === "complete" ? <span className="ui-sr-only"> (done)</span> : null}</span>
16
+ {s.desc ? <span className="ui-step-desc">{s.desc}</span> : null}
17
+ </li>
18
+ );
19
+ })}
20
+ </ol>
21
+ );
22
+ }
@@ -0,0 +1,11 @@
1
+ import { forwardRef } from "react";
2
+ import { cx } from "./cx.js";
3
+
4
+ /**
5
+ * <Switch checked={on} onChange={(e) => setOn(e.target.checked)}>Notifications</Switch>
6
+ * A real checkbox with role="switch": Space toggles it, forms submit it.
7
+ */
8
+ export const Switch = forwardRef(function Switch({ children, className, ...rest }, ref) {
9
+ const input = <input ref={ref} type="checkbox" role="switch" className={cx("ui-switch", className)} {...rest} />;
10
+ return children ? <label className="ui-switch-row">{input}{children}</label> : input;
11
+ });
@@ -0,0 +1,24 @@
1
+ import { cx } from "./cx.js";
2
+
3
+ /**
4
+ * <Table caption="Upcoming set times" columns={[{ key: "artist", label: "Artist", strong: true }, { key: "time", label: "Time", numeric: true }]} rows={rows} />
5
+ * A real <table> with a caption and column headers, in a wrapper that scrolls sideways on narrow screens.
6
+ * A cell can be a string or a React node (a Badge, for example). rows need an id.
7
+ */
8
+ export function Table({ caption, columns = [], rows = [], className }) {
9
+ return (
10
+ <div className={cx("ui-table-wrap", className)}>
11
+ <table className="ui-table">
12
+ {caption ? <caption>{caption}</caption> : null}
13
+ <thead><tr>{columns.map((c) => <th key={c.key} scope="col" className={c.numeric ? "ui-num" : undefined}>{c.label}</th>)}</tr></thead>
14
+ <tbody>
15
+ {rows.map((r) => (
16
+ <tr key={r.id}>
17
+ {columns.map((c) => <td key={c.key} className={cx(c.strong && "ui-table-strong", c.numeric && "ui-num") || undefined}>{r[c.key]}</td>)}
18
+ </tr>
19
+ ))}
20
+ </tbody>
21
+ </table>
22
+ </div>
23
+ );
24
+ }