@alacris/ui 0.0.0 → 0.1.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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -1
  3. package/package.json +75 -3
  4. package/src/components/base.js +46 -0
  5. package/src/components/ui-accordion-item.js +157 -0
  6. package/src/components/ui-accordion.js +58 -0
  7. package/src/components/ui-alert.js +168 -0
  8. package/src/components/ui-app-bar.js +119 -0
  9. package/src/components/ui-autocomplete.js +371 -0
  10. package/src/components/ui-avatar.js +104 -0
  11. package/src/components/ui-backdrop.js +66 -0
  12. package/src/components/ui-badge.js +95 -0
  13. package/src/components/ui-bottom-app-bar.js +78 -0
  14. package/src/components/ui-bottom-nav.js +83 -0
  15. package/src/components/ui-breadcrumbs.js +100 -0
  16. package/src/components/ui-button-group.js +49 -0
  17. package/src/components/ui-button.js +143 -0
  18. package/src/components/ui-card.js +86 -0
  19. package/src/components/ui-carousel-item.js +53 -0
  20. package/src/components/ui-carousel.js +301 -0
  21. package/src/components/ui-checkbox.js +165 -0
  22. package/src/components/ui-chip-set.js +90 -0
  23. package/src/components/ui-chip.js +226 -0
  24. package/src/components/ui-container.js +59 -0
  25. package/src/components/ui-date-picker.js +774 -0
  26. package/src/components/ui-dialog.js +179 -0
  27. package/src/components/ui-divider.js +58 -0
  28. package/src/components/ui-drawer.js +169 -0
  29. package/src/components/ui-fab-menu.js +122 -0
  30. package/src/components/ui-fab.js +117 -0
  31. package/src/components/ui-icon-button.js +112 -0
  32. package/src/components/ui-icon.js +64 -0
  33. package/src/components/ui-list-item.js +166 -0
  34. package/src/components/ui-list.js +38 -0
  35. package/src/components/ui-loading-indicator.js +74 -0
  36. package/src/components/ui-menu-item.js +117 -0
  37. package/src/components/ui-menu.js +192 -0
  38. package/src/components/ui-nav-item.js +138 -0
  39. package/src/components/ui-nav-rail.js +111 -0
  40. package/src/components/ui-option.js +85 -0
  41. package/src/components/ui-pagination.js +173 -0
  42. package/src/components/ui-progress.js +100 -0
  43. package/src/components/ui-radio-group.js +104 -0
  44. package/src/components/ui-radio.js +143 -0
  45. package/src/components/ui-rating.js +126 -0
  46. package/src/components/ui-search.js +318 -0
  47. package/src/components/ui-select.js +418 -0
  48. package/src/components/ui-sheet.js +205 -0
  49. package/src/components/ui-side-sheet.js +221 -0
  50. package/src/components/ui-skeleton.js +89 -0
  51. package/src/components/ui-slider.js +266 -0
  52. package/src/components/ui-snackbar.js +233 -0
  53. package/src/components/ui-spinner.js +101 -0
  54. package/src/components/ui-split-button.js +158 -0
  55. package/src/components/ui-stack.js +37 -0
  56. package/src/components/ui-step.js +91 -0
  57. package/src/components/ui-stepper.js +86 -0
  58. package/src/components/ui-surface.js +51 -0
  59. package/src/components/ui-switch.js +153 -0
  60. package/src/components/ui-tab-panel.js +54 -0
  61. package/src/components/ui-tab.js +116 -0
  62. package/src/components/ui-table-footer.js +141 -0
  63. package/src/components/ui-table-toolbar.js +219 -0
  64. package/src/components/ui-table.js +735 -0
  65. package/src/components/ui-tabs.js +178 -0
  66. package/src/components/ui-text-field.js +319 -0
  67. package/src/components/ui-text.js +36 -0
  68. package/src/components/ui-time-picker.js +753 -0
  69. package/src/components/ui-toggle-button.js +170 -0
  70. package/src/components/ui-toggle-group.js +111 -0
  71. package/src/components/ui-toolbar.js +64 -0
  72. package/src/components/ui-tooltip.js +148 -0
  73. package/src/index.js +121 -0
  74. package/src/motion/animate.js +145 -0
  75. package/src/motion/flip.js +46 -0
  76. package/src/motion/index.js +4 -0
  77. package/src/motion/presence.js +104 -0
  78. package/src/motion/ripple.js +108 -0
  79. package/src/theme/apply-theme.js +142 -0
  80. package/src/theme/create-theme.js +70 -0
  81. package/src/theme/index.js +5 -0
  82. package/src/tokens/color.js +237 -0
  83. package/src/tokens/index.js +14 -0
  84. package/src/tokens/sys.js +76 -0
  85. package/src/tokens/system.js +114 -0
  86. package/src/tokens/typography.js +189 -0
  87. package/src/util/focus.js +89 -0
  88. package/src/util/form.js +69 -0
  89. package/src/util/icons.js +161 -0
  90. package/src/util/keys.js +110 -0
  91. package/src/util/position.js +102 -0
  92. package/src/util/table.js +230 -0
  93. package/types/elements.d.ts +78 -0
  94. package/types/index.d.ts +168 -0
@@ -0,0 +1,145 @@
1
+ // Motion — one ergonomic layer over the Web Animations API.
2
+ //
3
+ // Principles:
4
+ // - Durations and easings come from the motion tokens, so JS-driven motion
5
+ // obeys the theme's `motion.scale` exactly like CSS transitions do —
6
+ // `animate(el, fx.fadeIn, { duration: 'medium2' })` resolves the token at
7
+ // call time.
8
+ // - `prefers-reduced-motion` is honored everywhere: animations jump to their
9
+ // end state instead of playing. CSS transitions get the same guard from
10
+ // `base.js`.
11
+ // - No timeouts, no rAF bookkeeping: WAAPI runs off the main thread where
12
+ // the browser can manage it, and `.finished` sequences the rest.
13
+
14
+ import { DURATIONS, EASINGS } from '../tokens/system.js';
15
+
16
+ const camelToToken = (s) => s.replace(/([A-Z]|\d+$)/g, (c) => '-' + c.toLowerCase());
17
+
18
+ /** True when the user asked for reduced motion (checked live). */
19
+ export const prefersReducedMotion = () =>
20
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
21
+
22
+ /**
23
+ * Resolve a duration token ('medium2', 'extraLong1') to milliseconds, reading
24
+ * the live CSS token so a theme's motion scale applies. Numbers pass through.
25
+ */
26
+ export function duration(key) {
27
+ if (typeof key === 'number') return key;
28
+ const token = camelToToken(key);
29
+ if (typeof getComputedStyle === 'function') {
30
+ const raw = getComputedStyle(document.documentElement).getPropertyValue(`--ui-duration-${token}`);
31
+ const ms = parseFloat(raw);
32
+ if (!Number.isNaN(ms)) return ms;
33
+ }
34
+ return DURATIONS[token] ?? 200;
35
+ }
36
+
37
+ /** Resolve an easing token ('standard', 'emphasizedDecelerate') to its curve. */
38
+ export const easing = (key) => EASINGS[camelToToken(key)] || key;
39
+
40
+ /**
41
+ * animate(el, keyframes, { duration: 'short4'|ms, easing: 'standard'|curve,
42
+ * fill: 'both', ...WAAPI options })
43
+ *
44
+ * Returns the Animation. Under reduced motion the animation still runs (so
45
+ * `.finished` chains keep working) but with ~zero duration — the element
46
+ * simply lands in its final state.
47
+ */
48
+ export function animate(el, keyframes, opts = {}) {
49
+ const { duration: d = 'short4', easing: e = 'standard', fill = 'both', ...rest } = opts;
50
+ // Environments without WAAPI (simulated DOMs in tests) get an
51
+ // already-finished stand-in so `.finished` sequencing still works.
52
+ if (typeof el?.animate !== 'function') {
53
+ return { finished: Promise.resolve(), cancel() {}, finish() {}, play() {}, pause() {} };
54
+ }
55
+ const ms = prefersReducedMotion() ? 1 : duration(d);
56
+ const run = () => {
57
+ const frames = typeof keyframes === 'function' ? keyframes(el) : keyframes;
58
+ const anim = el.animate(frames, { duration: ms, easing: easing(e), fill, ...rest });
59
+ anim.finished.catch(() => {}); // cancellation is normal control flow, not an error
60
+ // Hidden/occluded tabs freeze the animation timeline, which would leave
61
+ // `.finished` chains (presence unmounts, `closed` events, snackbar queues)
62
+ // waiting forever. setTimeout still ticks there, so force-finish on the
63
+ // wall clock; in a visible tab the animation wins the race and this no-ops.
64
+ const delay = Number(rest.delay) || 0;
65
+ const guard = setTimeout(() => {
66
+ try { if (anim.playState === 'running' || anim.pending) anim.finish(); } catch { /* infinite/cancelled */ }
67
+ }, ms + delay + 100);
68
+ anim.finished.then(() => clearTimeout(guard), () => clearTimeout(guard));
69
+ return anim;
70
+ };
71
+ if (el.isConnected) return run();
72
+
73
+ // A `ref` fires while the element is still in its render fragment, before
74
+ // insertion. An animation created on a disconnected subtree can wedge with
75
+ // its first keyframe applied but its timeline never advancing — so start on
76
+ // the next frame, once the element is in the document. The facade keeps
77
+ // `.finished`/`.cancel` working either way. If rAF never fires (hidden tab),
78
+ // the wall-clock fallback settles without animating — the element simply
79
+ // rests in its CSS end state.
80
+ let anim = null;
81
+ let cancelled = false;
82
+ let started = false;
83
+ let settle;
84
+ const finished = new Promise((resolve) => (settle = resolve));
85
+ const start = () => {
86
+ if (cancelled || started) return;
87
+ started = true;
88
+ if (!el.isConnected) return settle();
89
+ anim = run();
90
+ anim.finished.then(settle, () => settle());
91
+ };
92
+ requestAnimationFrame(start);
93
+ const fallback = setTimeout(() => (started ? null : (started = true, settle())), ms + 200);
94
+ finished.then(() => clearTimeout(fallback));
95
+ return {
96
+ finished,
97
+ cancel() { cancelled = true; anim?.cancel(); settle(); },
98
+ finish() { anim?.finish(); },
99
+ play() { anim?.play(); },
100
+ pause() { anim?.pause(); },
101
+ };
102
+ }
103
+
104
+ /** Await an animation without throwing when it is cancelled mid-flight. */
105
+ export const settled = (anim) => anim.finished.catch(() => {});
106
+
107
+ // ------------------------------------------------------------------ presets
108
+ //
109
+ // Keyframe presets tuned to the Material motion spec. Enter presets pair with
110
+ // 'emphasizedDecelerate', exits with 'emphasizedAccelerate' — `animate`'s
111
+ // defaults are fine for simple fades.
112
+
113
+ export const fx = {
114
+ fadeIn: [{ opacity: 0 }, { opacity: 1 }],
115
+ fadeOut: [{ opacity: 1 }, { opacity: 0 }],
116
+ // 0.8 is the MD3 container-transform start scale (dialogs, menus).
117
+ scaleIn: [{ opacity: 0, transform: 'scale(0.8)' }, { opacity: 1, transform: 'scale(1)' }],
118
+ scaleOut: [{ opacity: 1, transform: 'scale(1)' }, { opacity: 0, transform: 'scale(0.8)' }],
119
+ slideInUp: [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'translateY(0)' }],
120
+ slideOutDown: [{ opacity: 1, transform: 'translateY(0)' }, { opacity: 0, transform: 'translateY(16px)' }],
121
+ slideInDown: [{ opacity: 0, transform: 'translateY(-16px)' }, { opacity: 1, transform: 'translateY(0)' }],
122
+ slideOutUp: [{ opacity: 1, transform: 'translateY(0)' }, { opacity: 0, transform: 'translateY(-16px)' }],
123
+ expandDown: (el) => {
124
+ const h = el?.scrollHeight || 0;
125
+ return [
126
+ { blockSize: '0px', opacity: 0, overflow: 'hidden' },
127
+ { blockSize: `${h}px`, opacity: 1, overflow: 'hidden' },
128
+ ];
129
+ },
130
+ collapseUp: (el) => {
131
+ const h = el?.offsetHeight || el?.scrollHeight || 0;
132
+ return [
133
+ { blockSize: `${h}px`, opacity: 1, overflow: 'hidden' },
134
+ { blockSize: '0px', opacity: 0, overflow: 'hidden' },
135
+ ];
136
+ },
137
+ slideInLeft: [{ transform: 'translateX(-100%)' }, { transform: 'translateX(0)' }],
138
+ slideOutLeft: [{ transform: 'translateX(0)' }, { transform: 'translateX(-100%)' }],
139
+ slideInRight: [{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }],
140
+ slideOutRight: [{ transform: 'translateX(0)' }, { transform: 'translateX(100%)' }],
141
+ // Bottom-sheet cover: the panel travels its full height, not a 16px nudge.
142
+ sheetIn: [{ transform: 'translateY(100%)' }, { transform: 'translateY(0)' }],
143
+ sheetOut: [{ transform: 'translateY(0)' }, { transform: 'translateY(100%)' }],
144
+ collapse: [{ blockSize: 'var(--ui-collapse-size, auto)', opacity: 1 }, { blockSize: '0', opacity: 0 }],
145
+ };
@@ -0,0 +1,46 @@
1
+ // withFlip — animate a list reorder with the FLIP technique.
2
+ //
3
+ // Alacris's `each` moves the DOM nodes that changed position, synchronously.
4
+ // Wrap the mutation and every child that moved glides to its new place;
5
+ // children that appeared fade in. (Removed children are already gone by the
6
+ // time we can look — exits inside a hot list are a deliberate non-goal; use
7
+ // `presence` for individually dismissable items.)
8
+ //
9
+ // withFlip(listEl, () => state.rows.sort(byName));
10
+ //
11
+ // Keyed by node identity, which is exactly what `each` keeps stable.
12
+
13
+ import { animate, fx } from './animate.js';
14
+
15
+ /**
16
+ * withFlip(container, mutate, { duration, easing, stagger })
17
+ * Returns whatever `mutate` returns.
18
+ */
19
+ export function withFlip(container, mutate, opts = {}) {
20
+ const before = new Map();
21
+ for (const el of container.children) before.set(el, el.getBoundingClientRect());
22
+
23
+ const result = mutate();
24
+
25
+ let i = 0;
26
+ for (const el of container.children) {
27
+ const was = before.get(el);
28
+ const delay = opts.stagger ? i++ * opts.stagger : 0;
29
+ if (!was) {
30
+ animate(el, fx.fadeIn, { duration: opts.duration ?? 'medium1', delay, fill: 'backwards' });
31
+ continue;
32
+ }
33
+ const now = el.getBoundingClientRect();
34
+ const dx = was.left - now.left;
35
+ const dy = was.top - now.top;
36
+ if (dx || dy) {
37
+ animate(el, [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
38
+ duration: opts.duration ?? 'medium3',
39
+ easing: opts.easing ?? 'emphasized',
40
+ delay,
41
+ fill: 'backwards',
42
+ });
43
+ }
44
+ }
45
+ return result;
46
+ }
@@ -0,0 +1,4 @@
1
+ export { animate, settled, duration, easing, fx, prefersReducedMotion } from './animate.js';
2
+ export { presence } from './presence.js';
3
+ export { withFlip } from './flip.js';
4
+ export { ripple } from './ripple.js';
@@ -0,0 +1,104 @@
1
+ // presence — enter/exit animation for conditional templates.
2
+ //
3
+ // Alacris removes a conditional subtree the instant its condition turns false,
4
+ // which is correct — and too soon for an exit animation. `presence` gives a
5
+ // condition a lifecycle: mount + enter animation when it becomes truthy, exit
6
+ // animation *then* unmount when it becomes falsy.
7
+ //
8
+ // setup({ open }) {
9
+ // return html`
10
+ // <div class="host">
11
+ // ${presence(open, () => html`<div class="sheet">…</div>`, {
12
+ // enter: fx.slideInUp,
13
+ // exit: fx.slideOutDown,
14
+ // })}
15
+ // </div>`;
16
+ // }
17
+ //
18
+ // The returned node is a `display: contents` anchor, so it is invisible to
19
+ // layout. Call `presence` in a child position during `setup` (or under an
20
+ // active `root()`), because it creates an effect that must be owned.
21
+
22
+ import { effect, onCleanup, render } from '@alacris/core';
23
+ import { animate, fx } from './animate.js';
24
+
25
+ /**
26
+ * presence(when, view, opts)
27
+ *
28
+ * when — signal/thunk; truthy shows the view
29
+ * view — () => template (rebuilt fresh on every entry)
30
+ * opts — enter, exit: keyframes or false to disable (default fade)
31
+ * enterDuration/exitDuration, enterEasing/exitEasing: token names
32
+ * target: CSS selector for the element to animate (default: the
33
+ * view's first element)
34
+ * onEntered: called after the enter animation finishes
35
+ * onExited: called after the exit animation finishes
36
+ */
37
+ export function presence(when, view, opts = {}) {
38
+ const anchor = document.createElement('span');
39
+ anchor.style.display = 'contents';
40
+
41
+ const enterFx = opts.enter === false ? null : opts.enter || fx.fadeIn;
42
+ const exitFx = opts.exit === false ? null : opts.exit || fx.fadeOut;
43
+ const target = () => (opts.target ? anchor.querySelector(opts.target) : anchor.firstElementChild);
44
+
45
+ let dispose = null; // active render
46
+ let exiting = null; // in-flight exit animation
47
+
48
+ effect(() => {
49
+ const show = !!when();
50
+ if (show) {
51
+ // Re-entering while the exit plays: cut it short and remount fresh.
52
+ if (exiting) {
53
+ exiting.cancel();
54
+ exiting = null;
55
+ dispose?.();
56
+ dispose = null;
57
+ }
58
+ if (!dispose) {
59
+ dispose = render(view(), anchor);
60
+ const el = target();
61
+ if (el && enterFx) {
62
+ const anim = animate(el, enterFx, {
63
+ duration: opts.enterDuration ?? 'medium2',
64
+ easing: opts.enterEasing ?? 'emphasizedDecelerate',
65
+ });
66
+ anim.finished.then(() => {
67
+ try { anim.cancel(); } catch {}
68
+ opts.onEntered?.();
69
+ }, () => {});
70
+ } else if (opts.onEntered) {
71
+ opts.onEntered();
72
+ }
73
+ }
74
+ } else if (dispose && !exiting) {
75
+ const el = target();
76
+ const done = () => {
77
+ if (!exiting) return; // re-entry already tore this down
78
+ exiting = null;
79
+ dispose?.();
80
+ dispose = null;
81
+ opts.onExited?.();
82
+ };
83
+ if (el && exitFx) {
84
+ exiting = animate(el, exitFx, {
85
+ duration: opts.exitDuration ?? 'short4',
86
+ easing: opts.exitEasing ?? 'emphasizedAccelerate',
87
+ });
88
+ exiting.finished.then(done, () => {});
89
+ } else {
90
+ dispose();
91
+ dispose = null;
92
+ }
93
+ }
94
+ });
95
+
96
+ onCleanup(() => {
97
+ exiting?.cancel();
98
+ exiting = null;
99
+ dispose?.();
100
+ dispose = null;
101
+ });
102
+
103
+ return anchor;
104
+ }
@@ -0,0 +1,108 @@
1
+ // ripple — the Material press ripple.
2
+ //
3
+ // Attach it to any positioned element (the conventions' `.control` recipe sets
4
+ // `position: relative`); it appends a clipped overlay and expands a wave from
5
+ // the pointer on press, from the center on keyboard activation. Colors ride
6
+ // `currentColor` at the pressed-state opacity token, so the ripple is always
7
+ // legible on its surface and themes with everything else. Reduced motion gets
8
+ // an instant press tint instead of a wave (handled inside `animate`).
9
+ //
10
+ // html`<button class="control" ref=${(el) => ripple(el, { disabled })}>…`
11
+
12
+ import { animate, settled } from './animate.js';
13
+
14
+ /**
15
+ * ripple(el, { disabled, centered })
16
+ *
17
+ * disabled — boolean or thunk/signal; suppresses new ripples while truthy
18
+ * centered — always expand from the center (icon buttons, switches)
19
+ *
20
+ * Idempotent per element. Returns the element for ref-chaining.
21
+ */
22
+ export function ripple(el, opts = {}) {
23
+ if (el.__uiRipple) return el;
24
+ el.__uiRipple = 1;
25
+
26
+ const holder = document.createElement('span');
27
+ holder.setAttribute('aria-hidden', 'true');
28
+ Object.assign(holder.style, {
29
+ position: 'absolute',
30
+ inset: '0',
31
+ overflow: 'hidden',
32
+ borderRadius: 'inherit',
33
+ pointerEvents: 'none',
34
+ });
35
+ el.append(holder);
36
+
37
+ const off = () =>
38
+ (typeof opts.disabled === 'function' ? !!opts.disabled() : !!opts.disabled) || el.disabled;
39
+
40
+ let release = null;
41
+
42
+ const spawn = (clientX, clientY, centered) => {
43
+ const rect = el.getBoundingClientRect();
44
+ const w = rect.width || 48;
45
+ const h = rect.height || 48;
46
+ const cx = centered ? w / 2 : clientX - rect.left;
47
+ const cy = centered ? h / 2 : clientY - rect.top;
48
+ // Soft-edge padding matches material-web: the wave slightly overruns the
49
+ // container so the edge never looks clipped at rest.
50
+ const radius = Math.hypot(Math.max(cx, w - cx), Math.max(cy, h - cy)) + 10;
51
+
52
+ const dot = document.createElement('span');
53
+ Object.assign(dot.style, {
54
+ position: 'absolute',
55
+ left: `${cx - radius}px`,
56
+ top: `${cy - radius}px`,
57
+ width: `${radius * 2}px`,
58
+ height: `${radius * 2}px`,
59
+ borderRadius: '50%',
60
+ background: 'currentColor',
61
+ opacity: 'var(--ui-state-pressed, 0.1)',
62
+ willChange: 'transform, opacity',
63
+ });
64
+ holder.append(dot);
65
+
66
+ const pressedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
67
+ // material-web: grow from 20% of the final size over 450ms (long1) with
68
+ // standard easing; stay visible at least 225ms (short4 + a beat).
69
+ const grow = animate(dot, [{ transform: 'scale(0.2)' }, { transform: 'scale(1)' }], {
70
+ duration: 'long1',
71
+ easing: 'standard',
72
+ });
73
+
74
+ let released = false;
75
+ release = () => {
76
+ if (released) return;
77
+ released = true;
78
+ const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - pressedAt;
79
+ const rest = Math.max(0, 225 - elapsed);
80
+ const fade = () => {
81
+ const out = animate(dot, [{ opacity: 'var(--ui-state-pressed, 0.1)' }, { opacity: '0' }], {
82
+ duration: 'medium1',
83
+ easing: 'linear',
84
+ });
85
+ settled(out).then(() => dot.remove());
86
+ };
87
+ Promise.all([settled(grow), rest ? new Promise((r) => setTimeout(r, rest)) : null]).then(fade);
88
+ };
89
+ };
90
+
91
+ el.addEventListener('pointerdown', (e) => {
92
+ if (off() || e.button !== 0) return;
93
+ spawn(e.clientX, e.clientY, opts.centered);
94
+ const done = () => release?.();
95
+ window.addEventListener('pointerup', done, { once: true });
96
+ window.addEventListener('pointercancel', done, { once: true });
97
+ });
98
+
99
+ el.addEventListener('keydown', (e) => {
100
+ if (off() || e.repeat) return;
101
+ if (e.key === 'Enter' || e.key === ' ') {
102
+ spawn(0, 0, true);
103
+ window.addEventListener('keyup', () => release?.(), { once: true });
104
+ }
105
+ });
106
+
107
+ return el;
108
+ }
@@ -0,0 +1,142 @@
1
+ // applyTheme — one constructed stylesheet for the whole design system.
2
+ //
3
+ // All tokens live on `:root`, so they inherit through every shadow boundary
4
+ // and a re-theme is a single `replaceSync` — no component re-renders, no
5
+ // sheet re-adoption, every element on the page updates at once.
6
+ //
7
+ // Scheme selection is layered so the same sheet serves all three modes:
8
+ // - light tokens on `:root` (the default)
9
+ // - dark tokens under `prefers-color-scheme: dark` unless the app pinned
10
+ // light (`<html data-ui-scheme="light">`)
11
+ // - dark tokens whenever the app pinned dark (`data-ui-scheme="dark"`)
12
+
13
+ import { signal, computed } from '@alacris/core';
14
+ import { createTheme } from './create-theme.js';
15
+
16
+ // `color-scheme` is a real CSS property riding along in the token map (it
17
+ // keeps scrollbars and form controls in scheme); everything else gets `--ui-`.
18
+ const decl = (name, value) => (name === 'color-scheme' ? name : `--ui-${name}`) + `:${value};`;
19
+
20
+ const block = (tokens) => {
21
+ let s = '';
22
+ for (const name in tokens) s += decl(name, tokens[name]);
23
+ return s;
24
+ };
25
+
26
+ // Face on `:root` so native text and un-typed markup inherit the system
27
+ // plain typeface. Components then set a type-role shorthand on top.
28
+ const FACE = 'font-family:var(--ui-font-plain);font-optical-sizing:auto;font-synthesis:none;';
29
+
30
+ export function themeCss(theme) {
31
+ const { common, schemes } = theme;
32
+ return [
33
+ `:root{${FACE}${block(common)}${block(schemes.light)}}`,
34
+ `:root[data-ui-scheme="dark"]{${block(schemes.dark)}}`,
35
+ `@media (prefers-color-scheme: dark){:root:not([data-ui-scheme="light"]){${block(schemes.dark)}}}`,
36
+ ].join('\n');
37
+ }
38
+
39
+ let sheet = null;
40
+ let styleEl = null;
41
+
42
+ /** The currently applied theme (a signal; null until applyTheme runs). */
43
+ export const activeTheme = signal(null);
44
+
45
+ /**
46
+ * Apply a theme to the document. Accepts a theme from `createTheme` or a
47
+ * config object (which is passed through `createTheme` for you). Calling it
48
+ * again rewrites the same stylesheet in place.
49
+ *
50
+ * Faces referenced by the theme are loaded automatically (a Google Fonts
51
+ * stylesheet for presets and `family` names). Pass `loadFonts: false` to
52
+ * skip — useful when the files are self-hosted or already on the page.
53
+ */
54
+ export function applyTheme(themeOrConfig = {}) {
55
+ const theme = themeOrConfig.schemes ? themeOrConfig : createTheme(themeOrConfig);
56
+ const text = themeCss(theme);
57
+ if (!sheet && !styleEl) {
58
+ if (document.adoptedStyleSheets && typeof CSSStyleSheet.prototype.replaceSync === 'function') {
59
+ sheet = new CSSStyleSheet();
60
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
61
+ } else {
62
+ styleEl = document.createElement('style');
63
+ document.head.append(styleEl);
64
+ }
65
+ }
66
+ if (sheet) sheet.replaceSync(text);
67
+ else styleEl.textContent = text;
68
+ loadThemeFonts(themeOrConfig.loadFonts === false ? { fonts: { href: null } } : theme);
69
+ activeTheme.set(theme);
70
+ return theme;
71
+ }
72
+
73
+ const FONT_ATTR = 'data-ui-font';
74
+ const PRECONNECT_ATTR = 'data-ui-font-preconnect';
75
+
76
+ /**
77
+ * Sync the document's typeface stylesheet to `theme.fonts.href`. A null href
78
+ * removes a previously injected link. Safe to call repeatedly; one `<link>`
79
+ * is reused in place. Constructed stylesheets cannot `@import`, so this is a
80
+ * real element — the same one `themeCss` consumers add by hand for static CSS.
81
+ */
82
+ export function loadThemeFonts(theme, doc = document) {
83
+ const head = doc?.head;
84
+ if (!head) return;
85
+ const href = theme?.fonts?.href || null;
86
+ const existing = head.querySelector(`link[${FONT_ATTR}]`);
87
+ if (!href) {
88
+ existing?.remove();
89
+ return;
90
+ }
91
+ if (/fonts\.googleapis\.com|fonts\.gstatic\.com/.test(href)) ensurePreconnect(head);
92
+ if (existing) {
93
+ if (existing.getAttribute('href') !== href) existing.setAttribute('href', href);
94
+ return;
95
+ }
96
+ const link = doc.createElement('link');
97
+ link.rel = 'stylesheet';
98
+ link.setAttribute('href', href);
99
+ link.setAttribute(FONT_ATTR, '');
100
+ head.append(link);
101
+ }
102
+
103
+ function ensurePreconnect(head) {
104
+ if (head.querySelector(`link[${PRECONNECT_ATTR}]`)) return;
105
+ for (const href of ['https://fonts.googleapis.com', 'https://fonts.gstatic.com']) {
106
+ const l = head.ownerDocument.createElement('link');
107
+ l.rel = 'preconnect';
108
+ l.href = href;
109
+ if (href.includes('gstatic')) l.crossOrigin = 'anonymous';
110
+ l.setAttribute(PRECONNECT_ATTR, '');
111
+ head.append(l);
112
+ }
113
+ }
114
+
115
+ // ------------------------------------------------------------------ scheme
116
+
117
+ /** 'light' | 'dark' | 'auto' — what the app asked for. */
118
+ export const schemePreference = signal('auto');
119
+
120
+ const prefersDark =
121
+ typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : null;
122
+ const osDark = signal(!!prefersDark?.matches);
123
+ prefersDark?.addEventListener?.('change', (e) => osDark.set(e.matches));
124
+
125
+ /** The scheme actually in effect right now: 'light' | 'dark'. */
126
+ export const scheme = computed(() => {
127
+ const pref = schemePreference();
128
+ return pref === 'auto' ? (osDark() ? 'dark' : 'light') : pref;
129
+ });
130
+
131
+ /** Pin the scheme, or return to following the OS with 'auto'. */
132
+ export function setScheme(pref /* 'light' | 'dark' | 'auto' */) {
133
+ schemePreference.set(pref);
134
+ const el = document.documentElement;
135
+ if (pref === 'auto') el.removeAttribute('data-ui-scheme');
136
+ else el.setAttribute('data-ui-scheme', pref);
137
+ }
138
+
139
+ /** Flip between light and dark (leaves 'auto' by pinning the opposite). */
140
+ export function toggleScheme() {
141
+ setScheme(scheme() === 'dark' ? 'light' : 'dark');
142
+ }
@@ -0,0 +1,70 @@
1
+ // createTheme — one config object in, complete token maps out.
2
+ //
3
+ // A theme is data: `{ config, palettes, common, schemes, fonts }`
4
+ // where `common` and each scheme are flat maps of token name (without the
5
+ // `--ui-` prefix) → CSS value, and `fonts` is `{ href, preset }` for the
6
+ // typeface stylesheet `applyTheme` injects. `applyTheme` turns that into one
7
+ // document-level stylesheet; nothing here touches the DOM, so themes can be
8
+ // built, diffed, serialized, or generated ahead of time.
9
+
10
+ import { makePalettes, makeScheme, COLOR_ROLES } from '../tokens/color.js';
11
+ import { typographyTokens, resolveTypography } from '../tokens/typography.js';
12
+ import {
13
+ shapeTokens, elevationTokens, motionTokens, spacingTokens,
14
+ stateTokens, focusTokens, zTokens, densityTokens,
15
+ } from '../tokens/system.js';
16
+
17
+ const kebab = (s) => s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
18
+
19
+ /**
20
+ * createTheme({
21
+ * seed: '#e8ad18', // one color → a whole scheme, or:
22
+ * colors: { primary, secondary, tertiary, neutral, neutralVariant,
23
+ * error, success, warning, info }, // explicit palette seeds
24
+ * typography: 'google-sans-flex' | 'google-sans' | 'roboto' | 'system'
25
+ * | { preset, family, brand, plain, code, scale, load },
26
+ * shape: { radius }, // multiplier: 0 = square, 2 = extra round
27
+ * motion: { scale }, // 0 = instant, 1 = default
28
+ * density: 0, // 0 … -2 (each step: -4px control height)
29
+ * overrides: { // raw token overrides, applied last
30
+ * common: { 'radius-md': '10px' },
31
+ * light: { 'color-primary': '#0b57d0' },
32
+ * dark: { 'color-surface': '#101014' },
33
+ * },
34
+ * })
35
+ */
36
+ export function createTheme(config = {}) {
37
+ const palettes = makePalettes(config);
38
+ const type = resolveTypography(config.typography);
39
+
40
+ const common = {
41
+ ...typographyTokens(type),
42
+ ...shapeTokens(config.shape),
43
+ ...elevationTokens(),
44
+ ...motionTokens(config.motion),
45
+ ...spacingTokens(),
46
+ ...stateTokens(),
47
+ ...focusTokens(),
48
+ ...zTokens(),
49
+ ...densityTokens(config),
50
+ ...(config.overrides?.common || {}),
51
+ };
52
+
53
+ const schemeTokens = (scheme) => {
54
+ const roles = makeScheme(palettes, scheme);
55
+ const out = {};
56
+ for (const role of COLOR_ROLES) out[`color-${kebab(role)}`] = roles[role];
57
+ // Deepen shadows on dark surfaces, keep native widgets in scheme.
58
+ out['shadow-rgb'] = '0 0 0';
59
+ out['color-scheme'] = scheme;
60
+ return { ...out, ...(config.overrides?.[scheme] || {}) };
61
+ };
62
+
63
+ return {
64
+ config,
65
+ palettes,
66
+ common,
67
+ schemes: { light: schemeTokens('light'), dark: schemeTokens('dark') },
68
+ fonts: { href: type.href, preset: type.preset },
69
+ };
70
+ }
@@ -0,0 +1,5 @@
1
+ export { createTheme } from './create-theme.js';
2
+ export {
3
+ applyTheme, themeCss, activeTheme, loadThemeFonts,
4
+ scheme, schemePreference, setScheme, toggleScheme,
5
+ } from './apply-theme.js';