@apliteni/apliteni-ui 0.4.0 → 0.6.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.
@@ -0,0 +1,126 @@
1
+ // Success / confirmation surface — the emotional high point of a flow, done
2
+ // with craft. One factory, three layouts and three backdrops, an SVG check
3
+ // that draws itself in, optional confetti and an optional auto-redirect
4
+ // countdown. Every motion path is reduced-motion safe (static check, no burst,
5
+ // no confetti, no sweep). Accent-aware via the kit tokens; the success mark
6
+ // stays on the --green family. Styles ship in styles/success.css.
7
+ //
8
+ // container.innerHTML = success({
9
+ // title: 'Feedback sent',
10
+ // body: 'It goes straight to the strategy owner.',
11
+ // actions: [
12
+ // { label: 'Back to strategy', variant: 'primary', icon: 'compass' },
13
+ // { label: 'Send another', variant: 'ghost' },
14
+ // ],
15
+ // });
16
+ //
17
+ // The check animation is pure CSS, so string-rendered markup animates on its
18
+ // own once mounted. A live countdown (ticking numbers + redirect) is opt-in via
19
+ // wireSuccess(); the markup alone shows the ring sweep + a static number.
20
+ import { esc, button } from './index.js';
21
+
22
+ // Self-drawing check: a faint track disc, a filled accent disc that springs in,
23
+ // the tick that strokes on via dash-offset, and a burst ring that expands and
24
+ // fades. viewBox 0 0 52 52; the tick path length is ~34 (dasharray tuned to it).
25
+ export function successCheck() {
26
+ // width/height attributes keep the base svg:not([width]) fallback from
27
+ // sizing us; the real size is set per-layout in success.css.
28
+ return `<svg class="ui-sx__check" width="52" height="52" viewBox="0 0 52 52" aria-hidden="true" focusable="false">
29
+ <circle class="ui-sx__ring" cx="26" cy="26" r="24"/>
30
+ <circle class="ui-sx__disc" cx="26" cy="26" r="24"/>
31
+ <path class="ui-sx__tick" d="M15 27l7.5 7.5L37 18"/>
32
+ </svg>`;
33
+ }
34
+
35
+ // A deterministic confetti field (opt-in). Decorative + aria-hidden; each piece
36
+ // carries its own drift/rotation/colour via custom props so the CSS can scatter
37
+ // them without inline keyframes. Deterministic so string renders stay stable
38
+ // (and the a11y snapshot is reproducible).
39
+ const CONFETTI = [
40
+ { x: 8, d: 0.00, r: -24, t: 'a', s: 1.0 }, { x: 20, d: 0.14, r: 40, t: 'g', s: 0.8 },
41
+ { x: 31, d: 0.06, r: 12, t: 'c', s: 1.1 }, { x: 42, d: 0.20, r: -52, t: 'p', s: 0.9 },
42
+ { x: 50, d: 0.02, r: 28, t: 'a', s: 1.0 }, { x: 58, d: 0.18, r: -16, t: 'k', s: 0.75 },
43
+ { x: 67, d: 0.09, r: 60, t: 'g', s: 1.05 },{ x: 76, d: 0.24, r: -36, t: 'c', s: 0.85 },
44
+ { x: 85, d: 0.05, r: 20, t: 'a', s: 1.0 }, { x: 92, d: 0.16, r: -48, t: 'p', s: 0.9 },
45
+ { x: 14, d: 0.30, r: 44, t: 'k', s: 0.8 }, { x: 37, d: 0.34, r: -28, t: 'g', s: 1.0 },
46
+ { x: 62, d: 0.28, r: 52, t: 'a', s: 0.9 }, { x: 80, d: 0.36, r: -20, t: 'c', s: 1.05 },
47
+ ];
48
+ function confettiField() {
49
+ const pieces = CONFETTI.map((p) =>
50
+ `<i class="ui-sx__piece ui-sx__piece--${p.t}" style="left:${p.x}%;--sx-d:${p.d}s;--sx-r:${p.r}deg;--sx-s:${p.s}"></i>`,
51
+ ).join('');
52
+ return `<div class="ui-sx__confetti" aria-hidden="true">${pieces}</div>`;
53
+ }
54
+
55
+ // Optional auto-redirect countdown. Markup-only here (a conic ring sweeps and a
56
+ // static number shows); wireSuccess() turns it into a live ticking counter.
57
+ function countdownEl({ seconds = 5, label = 'Redirecting' } = {}) {
58
+ return `<div class="ui-sx__count" data-sx-count style="--sx-secs:${seconds}s">
59
+ <span class="ui-sx__count-ring" aria-hidden="true"></span>
60
+ <span class="ui-sx__count-text">${esc(label)} in <b data-sx-num>${seconds}</b>s</span>
61
+ </div>`;
62
+ }
63
+
64
+ export function success({
65
+ layout = 'hero', // 'hero' | 'split' | 'compact'
66
+ backdrop = 'aurora', // 'aurora' | 'glow' | 'flat'
67
+ eyebrow = '',
68
+ title = 'All done',
69
+ body = '',
70
+ actions = [], // [{ label, variant, href, icon, iconRight, size }]
71
+ confetti = false, // opt-in particle burst (reduced-motion safe)
72
+ countdown = null, // { seconds, label } | null
73
+ className = '',
74
+ } = {}) {
75
+ const cls = ['ui-sx', `ui-sx--${layout}`, `ui-sx--bd-${backdrop}`, confetti && 'ui-sx--confetti', className]
76
+ .filter(Boolean).join(' ');
77
+
78
+ const bd = backdrop === 'aurora'
79
+ ? `<div class="ui-sx__aurora" aria-hidden="true">
80
+ <span class="ui-sx__glow ui-sx__glow--a"></span>
81
+ <span class="ui-sx__glow ui-sx__glow--b"></span>
82
+ </div>`
83
+ : backdrop === 'glow'
84
+ ? `<span class="ui-glow ui-glow--green ui-sx__bg-glow" aria-hidden="true"></span>`
85
+ : '';
86
+
87
+ const eyebrowEl = eyebrow ? `<div class="ui-sx__eyebrow">${esc(eyebrow)}</div>` : '';
88
+ const bodyEl = body ? `<p class="ui-sx__body">${esc(body)}</p>` : '';
89
+ const actionsEl = actions.length
90
+ ? `<div class="ui-sx__actions">${actions.map((a) => button({ size: 'md', ...a })).join('')}</div>`
91
+ : '';
92
+ const countEl = countdown ? countdownEl(countdown) : '';
93
+
94
+ return `<div class="${cls}" role="status" aria-live="polite">
95
+ ${bd}
96
+ ${confetti ? confettiField() : ''}
97
+ <div class="ui-sx__inner">
98
+ <div class="ui-sx__visual">${successCheck()}</div>
99
+ <div class="ui-sx__content">
100
+ ${eyebrowEl}
101
+ <h3 class="ui-sx__title">${esc(title)}</h3>
102
+ ${bodyEl}
103
+ ${actionsEl}
104
+ ${countEl}
105
+ </div>
106
+ </div>
107
+ </div>`;
108
+ }
109
+
110
+ // Live countdown wiring (opt-in). Ticks the number down once a second and calls
111
+ // onDone() at zero — e.g. to navigate. Respects reduced-motion only for the
112
+ // visual sweep (owned by CSS); the counter itself is content, so it still runs.
113
+ // Returns a stop() to cancel (unmount / user interaction).
114
+ // const stop = wireSuccess(el, { onDone: () => location.assign('/strategy') });
115
+ export function wireSuccess(root, { onDone } = {}) {
116
+ const box = root && root.querySelector('[data-sx-count]');
117
+ if (!box) return () => {};
118
+ const numEl = box.querySelector('[data-sx-num]');
119
+ let n = parseInt(box.style.getPropertyValue('--sx-secs'), 10) || parseInt(numEl?.textContent, 10) || 5;
120
+ const id = setInterval(() => {
121
+ n -= 1;
122
+ if (numEl) numEl.textContent = String(Math.max(0, n));
123
+ if (n <= 0) { clearInterval(id); if (typeof onDone === 'function') onDone(); }
124
+ }, 1000);
125
+ return () => clearInterval(id);
126
+ }
@@ -0,0 +1,94 @@
1
+ // Toast stack controller — entrance/exit, auto-dismiss timers, swipe-to-dismiss.
2
+ //
3
+ // The visual markup comes from toast() in index.js; this wires behaviour onto a
4
+ // container of them, or lets you push new ones onto a live stack:
5
+ //
6
+ // import { toast } from '@apliteni/apliteni-ui';
7
+ // import { wireToastStack, pushToast } from '@apliteni/apliteni-ui/toasts';
8
+ // stack.innerHTML = toast({ variant: 'info', title: 'Saved', timer: 5 });
9
+ // wireToastStack(stack);
10
+ // pushToast(stack, { variant: 'success', title: 'Done', timer: 5 });
11
+ //
12
+ // The kit owns the animation + interaction; your app decides when to push and
13
+ // what each toast says. Reduced-motion is respected (no slide, instant remove).
14
+ import { toast } from './index.js';
15
+
16
+ const reduceMotion = () =>
17
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
18
+
19
+ // Slide-and-fade a toast out, then remove it. Idempotent.
20
+ export function dismissToast(el) {
21
+ if (!el || el.dataset.leaving) return;
22
+ el.dataset.leaving = '1';
23
+ const remove = () => el.remove();
24
+ if (reduceMotion()) return remove();
25
+ el.classList.add('is-leaving');
26
+ el.addEventListener('animationend', remove, { once: true });
27
+ setTimeout(remove, 260); // fallback if animationend never fires
28
+ }
29
+
30
+ // Wire one toast: close button, auto-dismiss timer (pauses on hover),
31
+ // swipe-to-dismiss via pointer drag.
32
+ function wireToast(el) {
33
+ if (el.dataset.wired) return;
34
+ el.dataset.wired = '1';
35
+
36
+ el.querySelector('.ui-toast__close')?.addEventListener('click', () => dismissToast(el));
37
+
38
+ const bar = el.querySelector('[data-toast-timer]');
39
+ if (bar && !reduceMotion()) {
40
+ bar.classList.add('is-running');
41
+ const dur = parseFloat(getComputedStyle(el).getPropertyValue('--toast-dur')) || 5;
42
+ let timer = setTimeout(() => dismissToast(el), dur * 1000);
43
+ el.addEventListener('mouseenter', () => { clearTimeout(timer); bar.style.animationPlayState = 'paused'; });
44
+ el.addEventListener('mouseleave', () => {
45
+ bar.style.animationPlayState = 'running';
46
+ const left = (parseFloat(getComputedStyle(bar).transform.split(',')[0].slice(7)) || 1);
47
+ timer = setTimeout(() => dismissToast(el), dur * 1000 * left);
48
+ });
49
+ } else if (bar && reduceMotion()) {
50
+ // No animated bar under reduced motion, but still auto-dismiss on time.
51
+ const dur = parseFloat(getComputedStyle(el).getPropertyValue('--toast-dur')) || 5;
52
+ setTimeout(() => dismissToast(el), dur * 1000);
53
+ }
54
+
55
+ // Swipe-to-dismiss (ignore drags that start on a button).
56
+ let x0 = null;
57
+ el.addEventListener('pointerdown', (e) => {
58
+ if (e.target.closest('button')) return;
59
+ x0 = e.clientX; el.setPointerCapture(e.pointerId); el.style.transition = 'none';
60
+ });
61
+ el.addEventListener('pointermove', (e) => {
62
+ if (x0 == null) return;
63
+ const dx = e.clientX - x0;
64
+ el.style.transform = `translateX(${dx}px)`;
65
+ el.style.opacity = String(Math.max(0, 1 - Math.abs(dx) / 240));
66
+ });
67
+ const settle = (e) => {
68
+ if (x0 == null) return;
69
+ const dx = e.clientX - x0; x0 = null;
70
+ if (Math.abs(dx) > 90) return dismissToast(el);
71
+ el.style.transition = 'transform 0.18s ease, opacity 0.18s ease';
72
+ el.style.transform = ''; el.style.opacity = '';
73
+ };
74
+ el.addEventListener('pointerup', settle);
75
+ el.addEventListener('pointercancel', settle);
76
+ }
77
+
78
+ // Wire every toast currently inside a container (string selector or element).
79
+ export function wireToastStack(container) {
80
+ const root = typeof container === 'string' ? document.querySelector(container) : container;
81
+ if (!root) return null;
82
+ root.querySelectorAll('.ui-toast').forEach(wireToast);
83
+ return root;
84
+ }
85
+
86
+ // Build a toast from opts, prepend it to a live stack (newest on top), wire it.
87
+ export function pushToast(container, opts = {}) {
88
+ const root = typeof container === 'string' ? document.querySelector(container) : container;
89
+ if (!root) return null;
90
+ root.insertAdjacentHTML('afterbegin', toast(opts));
91
+ const el = root.firstElementChild;
92
+ wireToast(el);
93
+ return el;
94
+ }
@@ -3,6 +3,7 @@
3
3
  import { brand } from '../assets/brand.js';
4
4
  import { icon, sun, moon } from '../assets/icons.js';
5
5
  import { esc } from './index.js';
6
+ import { wireDropdown } from './dropdown.js';
6
7
 
7
8
  const THEME_KEY = 'apliteni-strategy-theme';
8
9
 
@@ -16,15 +17,18 @@ export function deckTextSwitch(active = 'deck') {
16
17
  `<a${active === 'text' ? ' class="cur" aria-current="page"' : ''} href="#text">Text</a></div>`;
17
18
  }
18
19
 
20
+ // Thin consumer of the shared dropdown wiring (wireDropdown): keeps its own
21
+ // bespoke .vsw/.vopt classes for a pixel-identical look, but emits the generic
22
+ // [data-dropdown] hooks so there's ONE open/close/keyboard implementation.
19
23
  export function versionSwitcher(versions = [], activeIdx = 0) {
20
24
  const cur = versions[activeIdx]?.label || '';
21
25
  const opts = versions.map((v, i) =>
22
- `<div class="vopt" role="option" aria-selected="${i === activeIdx}" data-active="${i === activeIdx ? '1' : '0'}">` +
26
+ `<div class="vopt" role="option" data-dd-item tabindex="-1" aria-selected="${i === activeIdx}" data-active="${i === activeIdx ? '1' : '0'}">` +
23
27
  `<span><div class="vname">${v.label}</div><div class="vmeta">${v.meta || ''}</div></span>` +
24
28
  `<span class="vbadge ${v.badge === 'live' ? 'live' : 'arch'}">${v.badge || 'archive'}</span></div>`).join('');
25
- return `<div class="vsw" data-vsw><button class="vsw__btn" aria-haspopup="listbox" aria-expanded="false" aria-label="Version — ${esc(cur)}">` +
29
+ return `<div class="vsw" data-dropdown><button type="button" class="vsw__btn" data-dropdown-trigger aria-haspopup="listbox" aria-expanded="false" aria-label="Version — ${esc(cur)}">` +
26
30
  `<span class="lbl">version:</span><span class="cur">${cur}</span><span class="car"></span></button>` +
27
- `<div class="vsw__menu" role="listbox" aria-label="Version">${opts}</div></div>`;
31
+ `<div class="vsw__menu" data-dropdown-panel role="listbox" aria-label="Version">${opts}</div></div>`;
28
32
  }
29
33
 
30
34
  // `nav` ([id, icon, label, href?, target?][]) mirrors the account sidebar so the
@@ -33,14 +37,15 @@ export function accountMenu({ name = 'Ada Lovelace', email = 'ada@apliteni.com',
33
37
  const ini = (email.split('@')[0].split(/[._-]+/).filter(Boolean).map((w) => w[0]).slice(0, 2).join('') || '?').toUpperCase();
34
38
  const items = nav && nav.length ? nav : [['prefs', 'gear', 'Preferences'], ['access', 'key', 'Access &amp; agents']];
35
39
  const it = ([id, ic, label, href, target]) =>
36
- `<a href="${href || '#' + id}"${target ? ` target="${target}"` : ''}${active === id ? ' class="cur"' : ''} role="menuitem">${icon(ic)}${label}</a>`;
40
+ `<a href="${href || '#' + id}"${target ? ` target="${target}"` : ''} data-dd-item tabindex="-1"${active === id ? ' class="cur"' : ''} role="menuitem">${icon(ic)}${label}</a>`;
37
41
  // `on` so the menu is visible in Storybook / standalone use (no /auth/me gate).
38
- return `<div class="acct on" data-acct>` +
39
- `<button class="avatar" data-acct-btn aria-haspopup="menu" aria-expanded="false" aria-label="Account">${ini}</button>` +
40
- `<div class="amenu" role="menu">` +
42
+ // Consumes the shared dropdown wiring via the generic [data-dropdown] hooks.
43
+ return `<div class="acct on" data-dropdown>` +
44
+ `<button class="avatar" data-dropdown-trigger aria-haspopup="menu" aria-expanded="false" aria-label="Account">${ini}</button>` +
45
+ `<div class="amenu" data-dropdown-panel role="menu">` +
41
46
  `<div class="ahead"><span class="avatar">${ini}</span><span class="aw"><span class="anm">${name}</span><span class="aem" title="${email}">${email}</span></span></div>` +
42
47
  items.map(it).join('') +
43
- `<div class="asep"></div><a class="aout" href="#logout" role="menuitem">${icon('logout')}Sign out</a>` +
48
+ `<div class="asep"></div><a class="aout" href="#logout" data-dd-item tabindex="-1" role="menuitem">${icon('logout')}Sign out</a>` +
44
49
  `</div></div>`;
45
50
  }
46
51
 
@@ -109,13 +114,9 @@ export function wireTopbar(root = document) {
109
114
  b.setAttribute('aria-selected', 'true');
110
115
  });
111
116
  });
112
- // Version switcher + account menu (open/close)
113
- const toggleOpen = (el, btn) => (e) => { e.stopPropagation(); const o = el.classList.toggle('open'); btn?.setAttribute('aria-expanded', o ? 'true' : 'false'); };
114
- root.querySelectorAll('[data-vsw]').forEach((vsw) => { const b = vsw.querySelector('.vsw__btn'); b?.addEventListener('click', toggleOpen(vsw, b)); });
115
- root.querySelectorAll('[data-acct]').forEach((ac) => { const b = ac.querySelector('[data-acct-btn]'); b?.addEventListener('click', toggleOpen(ac, b)); });
116
- document.addEventListener('click', () => {
117
- root.querySelectorAll('[data-vsw].open,[data-acct].open').forEach((el) => el.classList.remove('open'));
118
- });
117
+ // Version switcher + account menu — the shared dropdown wiring (open/close +
118
+ // click-outside + Esc + keyboard nav). One implementation for the whole kit.
119
+ wireDropdown(root);
119
120
  // Accent pickers
120
121
  root.querySelectorAll('[data-accent-pick]').forEach((chip) => {
121
122
  chip.addEventListener('click', () => {
package/src/index.css CHANGED
@@ -3,18 +3,25 @@
3
3
  * Import once (`import 'apliteni-ui/css'`) or cherry-pick from src/styles/*.
4
4
  * Requires the Poppins font (loaded by the host page or Storybook preview).
5
5
  * ========================================================================== */
6
+ @import "./tokens/brand.generated.css";
6
7
  @import "./tokens/tokens.css";
7
8
  @import "./tokens/accents.css";
8
9
  @import "./styles/base.css";
9
- @import "./styles/aurora.css";
10
+ @import "./styles/motion.css";
10
11
  @import "./styles/button.css";
11
12
  @import "./styles/card.css";
12
13
  @import "./styles/badge.css";
13
14
  @import "./styles/segmented.css";
14
15
  @import "./styles/input.css";
16
+ @import "./styles/dropdown.css";
17
+ @import "./styles/nav.css";
18
+ @import "./styles/drawer.css";
15
19
  @import "./styles/table.css";
20
+ @import "./styles/empty.css";
16
21
  @import "./styles/callout.css";
17
22
  @import "./styles/code.css";
18
23
  @import "./styles/topbar.css";
24
+ @import "./styles/footer.css";
19
25
  @import "./styles/layout.css";
20
26
  @import "./styles/feedback.css";
27
+ @import "./styles/success.css";
package/src/index.js CHANGED
@@ -1,8 +1,13 @@
1
1
  // apliteni-ui — public entry.
2
2
  // Styles ship separately: `import 'apliteni-ui/css'`.
3
3
  export * from './components/index.js';
4
+ export * from './components/dropdown.js';
5
+ export * from './components/nav.js';
6
+ export * from './components/drawer.js';
4
7
  export * from './components/topbar.js';
5
8
  export * from './components/shell.js';
6
9
  export * from './components/feedback.js';
10
+ export * from './components/toasts.js';
7
11
  export * from './assets/icons.js';
8
12
  export * from './assets/brand.js';
13
+ export * from './motion.js';
package/src/inline.js CHANGED
@@ -11,9 +11,13 @@ import { readFileSync } from 'node:fs';
11
11
 
12
12
  const read = (rel) => readFileSync(new URL(`./${rel}`, import.meta.url), 'utf8');
13
13
 
14
- // Tokens = base scale + dark/light + accent sub-themes. The single source for
15
- // every `:root{ --… }` definition. Replaces hand-inlined token blocks.
16
- export const tokensCss = read('tokens/tokens.css') + '\n' + read('tokens/accents.css');
14
+ // Tokens = brand primitives (synced from design-system) + base scale +
15
+ // dark/light + accent sub-themes. The single source for every `:root{ --… }`
16
+ // definition. brand.generated.css goes first so the cascade is right.
17
+ export const tokensCss =
18
+ read('tokens/brand.generated.css') + '\n' +
19
+ read('tokens/tokens.css') + '\n' +
20
+ read('tokens/accents.css');
17
21
 
18
22
  // Base reset, ambient glow, focus ring, default icon sizing.
19
23
  export const baseCss = read('styles/base.css');
@@ -24,34 +28,44 @@ export const topbarCss = read('styles/topbar.css');
24
28
  // Individual component stylesheets, addressable by name.
25
29
  export const styles = {
26
30
  base: baseCss,
27
- aurora: read('styles/aurora.css'),
31
+ motion: read('styles/motion.css'),
28
32
  button: read('styles/button.css'),
29
33
  card: read('styles/card.css'),
30
34
  badge: read('styles/badge.css'),
31
35
  segmented: read('styles/segmented.css'),
32
36
  input: read('styles/input.css'),
37
+ dropdown: read('styles/dropdown.css'),
38
+ nav: read('styles/nav.css'),
39
+ drawer: read('styles/drawer.css'),
33
40
  table: read('styles/table.css'),
34
41
  callout: read('styles/callout.css'),
35
42
  code: read('styles/code.css'),
36
43
  topbar: topbarCss,
44
+ footer: read('styles/footer.css'),
37
45
  layout: read('styles/layout.css'),
38
46
  feedback: read('styles/feedback.css'),
47
+ success: read('styles/success.css'),
39
48
  };
40
49
 
41
50
  // Everything, in the same order as index.css. `tokensCss` first so cascade is right.
42
51
  export const cssText = [
43
52
  tokensCss,
44
53
  styles.base,
45
- styles.aurora,
54
+ styles.motion,
46
55
  styles.button,
47
56
  styles.card,
48
57
  styles.badge,
49
58
  styles.segmented,
50
59
  styles.input,
60
+ styles.dropdown,
61
+ styles.nav,
62
+ styles.drawer,
51
63
  styles.table,
52
64
  styles.callout,
53
65
  styles.code,
54
66
  styles.topbar,
67
+ styles.footer,
55
68
  styles.layout,
56
69
  styles.feedback,
70
+ styles.success,
57
71
  ].join('\n');
package/src/motion.js ADDED
@@ -0,0 +1,68 @@
1
+ // apliteni-ui — motion hook.
2
+ //
3
+ // The one small, optional piece of JS behind the CSS motion library: a
4
+ // scroll-reveal activator plus a replay helper. Everything else is pure CSS
5
+ // (src/styles/motion.css). Framework-agnostic, no dependencies, guarded so it
6
+ // no-ops cleanly under `node --test` / SSR (no window, no IntersectionObserver).
7
+ //
8
+ // import { initReveal } from 'apliteni-ui/motion'
9
+ // initReveal(); // wire every [data-reveal] on the page
10
+ // initReveal(myContainer); // scope to a subtree
11
+ //
12
+ // Stagger a group by giving siblings --reveal-i: 0, 1, 2, … (see motion.css).
13
+
14
+ /** True only when the user asked the OS to reduce motion. Safe off-DOM. */
15
+ export function prefersReducedMotion() {
16
+ return (
17
+ typeof matchMedia === 'function' &&
18
+ matchMedia('(prefers-reduced-motion: reduce)').matches
19
+ );
20
+ }
21
+
22
+ /** Stagger delay for the Nth item given a per-step delay. Pure + testable. */
23
+ export function staggerDelay(index, stepMs = 120) {
24
+ return Math.max(0, index) * stepMs;
25
+ }
26
+
27
+ /**
28
+ * Reveal every [data-reveal] under `root` as it scrolls into view.
29
+ * - Marks <html class="js-reveal"> so the CSS hides them only when JS is live.
30
+ * - Reduced-motion or no IntersectionObserver → reveal everything at once.
31
+ * Returns the observer (or undefined when there's nothing to observe).
32
+ */
33
+ export function initReveal(root) {
34
+ if (typeof document === 'undefined') return undefined;
35
+ const scope = root || document;
36
+ const els = scope.querySelectorAll('[data-reveal]');
37
+ if (!els.length) return undefined;
38
+
39
+ document.documentElement.classList.add('js-reveal');
40
+ const show = (el) => el.classList.add('is-revealed');
41
+
42
+ if (prefersReducedMotion() || typeof IntersectionObserver === 'undefined') {
43
+ els.forEach(show);
44
+ return undefined;
45
+ }
46
+
47
+ const io = new IntersectionObserver(
48
+ (entries, obs) => {
49
+ for (const entry of entries) {
50
+ if (entry.isIntersecting) {
51
+ show(entry.target);
52
+ obs.unobserve(entry.target);
53
+ }
54
+ }
55
+ },
56
+ { rootMargin: '0px 0px -10% 0px', threshold: 0.05 },
57
+ );
58
+ els.forEach((el) => io.observe(el));
59
+ return io;
60
+ }
61
+
62
+ /** Restart the CSS animation on an element (for a "replay" control). */
63
+ export function replay(el) {
64
+ if (!el || !el.style) return;
65
+ el.style.animation = 'none';
66
+ void el.offsetWidth; // force reflow so the next frame re-triggers
67
+ el.style.animation = '';
68
+ }
@@ -66,12 +66,6 @@ a {
66
66
  .ui-bg-spotlight::before {
67
67
  background: radial-gradient(620px 340px at 50% -12%, var(--glow-purple), transparent 70%);
68
68
  }
69
- /* Aurora — layered accent + cyan glows for a rich hero backdrop. */
70
- .ui-bg-aurora::before {
71
- background:
72
- radial-gradient(520px 400px at 10% -12%, var(--glow-purple), transparent 60%),
73
- radial-gradient(460px 360px at 94% 6%, var(--glow-cyan), transparent 62%);
74
- }
75
69
  /* Grid — a faint token-coloured grid, masked to fade at the edges. */
76
70
  .ui-bg-grid::before {
77
71
  background-image:
@@ -89,8 +89,12 @@
89
89
  pointer-events: none;
90
90
  }
91
91
 
92
- /* Busy: keep the label, run a gradient-bar loader along the base, and the
93
- button is disabled (not clickable). Pass busy + the .ui-btn__bars markup. */
92
+ /* Busy: keep the label, run an indeterminate accent shimmer along the base, and
93
+ the button is disabled (not clickable). Pass busy + the .ui-btn__bars markup.
94
+ The track is inset into a rounded pill so it sits INSIDE the button — not
95
+ flush against the clipped corners — and tints from the live --accent so it
96
+ reads as an intentional progress element in every theme (and mutes uniformly
97
+ with the button in the disabled state). */
94
98
  .ui-btn[aria-busy="true"] {
95
99
  pointer-events: none;
96
100
  position: relative;
@@ -98,21 +102,46 @@
98
102
  }
99
103
  .ui-btn__bars {
100
104
  position: absolute;
101
- left: 0; right: 0; bottom: 0;
105
+ left: 10px; right: 10px; bottom: 5px;
102
106
  height: 3px;
103
- background: color-mix(in srgb, currentColor 20%, transparent);
107
+ border-radius: 999px;
108
+ background: color-mix(in srgb, var(--accent) 22%, transparent);
104
109
  overflow: hidden;
110
+ /* Clean entrance: grow + fade in on state change, once. */
111
+ transform-origin: center;
112
+ animation: ui-bars-in 180ms var(--ease) both;
105
113
  }
114
+ .ui-btn--sm .ui-btn__bars { left: 7px; right: 7px; bottom: 4px; }
115
+ .ui-btn--lg .ui-btn__bars { left: 14px; right: 14px; bottom: 7px; }
106
116
  .ui-btn__bars i {
107
117
  position: absolute;
108
118
  height: 100%;
109
- border-radius: 3px;
110
- background: linear-gradient(90deg, var(--purple-light), var(--cyan));
119
+ border-radius: 999px;
120
+ background: linear-gradient(90deg,
121
+ color-mix(in srgb, var(--accent) 30%, transparent), var(--accent));
122
+ }
123
+ /* On the filled primary the surface IS the accent, so shimmer in the contrast
124
+ colour instead — otherwise the bars vanish into the button. */
125
+ .ui-btn--primary .ui-btn__bars {
126
+ background: color-mix(in srgb, var(--accent-contrast) 26%, transparent);
127
+ }
128
+ .ui-btn--primary .ui-btn__bars i {
129
+ background: linear-gradient(90deg,
130
+ color-mix(in srgb, var(--accent-contrast) 35%, transparent),
131
+ var(--accent-contrast));
132
+ }
133
+ .ui-btn__bars i:nth-child(1) { width: 38%; animation: ui-bars1 2s cubic-bezier(0.65, 0.05, 0.35, 1) infinite; }
134
+ .ui-btn__bars i:nth-child(2) { width: 22%; animation: ui-bars2 2s cubic-bezier(0.65, 0.05, 0.35, 1) 0.8s infinite; }
135
+ @keyframes ui-bars1 { 0% { left: -45%; } 100% { left: 110%; } }
136
+ @keyframes ui-bars2 { 0% { left: -28%; } 100% { left: 120%; } }
137
+ @keyframes ui-bars-in { from { opacity: 0; transform: scaleX(0.72); } to { opacity: 1; transform: scaleX(1); } }
138
+ /* Reduced motion: no entrance, no sweep — just a static, intentional accent
139
+ fill so the busy state still reads clearly. */
140
+ @media (prefers-reduced-motion: reduce) {
141
+ .ui-btn__bars { animation: none; opacity: 1; transform: none; }
142
+ .ui-btn__bars i { animation: none; }
143
+ .ui-btn__bars i:nth-child(1) { left: 0; width: 100%; }
144
+ .ui-btn__bars i:nth-child(2) { display: none; }
111
145
  }
112
- .ui-btn__bars i:nth-child(1) { width: 35%; animation: ui-bars1 2s cubic-bezier(0.65, 0.05, 0.35, 1) infinite; }
113
- .ui-btn__bars i:nth-child(2) { width: 20%; animation: ui-bars2 2s cubic-bezier(0.65, 0.05, 0.35, 1) 0.8s infinite; }
114
- @keyframes ui-bars1 { 0% { left: -40%; } 100% { left: 110%; } }
115
- @keyframes ui-bars2 { 0% { left: -25%; } 100% { left: 120%; } }
116
- @media (prefers-reduced-motion: reduce) { .ui-btn__bars i { animation: none; } .ui-btn__bars i:nth-child(1) { left: 0; } }
117
146
 
118
147
  @keyframes ui-spin { to { transform: rotate(360deg); } }