@motion-proto/live-tokens 0.60.1 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.claude/skills/live-tokens-build-page/SKILL.md +1 -1
  2. package/.claude/skills/live-tokens-pair-fonts/SKILL.md +1 -1
  3. package/CHANGELOG.md +107 -0
  4. package/dist-plugin/{chunk-XWXIMTWZ.js → chunk-7TQQTHI6.js} +10 -1
  5. package/dist-plugin/{chunk-LW4SR7AZ.js → chunk-O6GQ6GBH.js} +1 -1
  6. package/dist-plugin/{chunk-E5QYON4L.js → chunk-OPYOK2CA.js} +77 -26
  7. package/dist-plugin/generateColorsAndType/index.js +2 -2
  8. package/dist-plugin/index.cjs +179 -25
  9. package/dist-plugin/index.js +99 -4
  10. package/dist-plugin/tokensCssMigrations/index.cjs +76 -25
  11. package/dist-plugin/tokensCssMigrations/index.js +2 -2
  12. package/package.json +6 -1
  13. package/src/app/site.css +16 -0
  14. package/src/editor/component-editor/CardEditor.svelte +7 -1
  15. package/src/editor/component-editor/ImageLightboxEditor.svelte +2 -1
  16. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +25 -2
  17. package/src/editor/core/sketch/maskField.ts +70 -34
  18. package/src/editor/core/sketch/sketchLayer.ts +24 -3
  19. package/src/editor/core/sketch/sketchPresets.ts +10 -11
  20. package/src/editor/core/sketch/sketchStore.ts +89 -18
  21. package/src/editor/docs/Docs.svelte +1 -0
  22. package/src/editor/docs/chapters.ts +1 -0
  23. package/src/editor/docs/content/light-and-dark.md +90 -0
  24. package/src/editor/docs/content/sketch-mode.md +7 -2
  25. package/src/editor/docs/content.generated.ts +2 -1
  26. package/src/editor/overlay/LiveTokensRouter.svelte +8 -0
  27. package/src/editor/ui/sections/textStyles.ts +15 -1
  28. package/src/editor/ui/sketch/SketchTab.svelte +33 -81
  29. package/src/live-tokens/data/colors-and-type/midnight-study.json +40 -30
  30. package/src/live-tokens/data/themes/autumn.json +4 -3
  31. package/src/live-tokens/data/themes/halloween.json +4 -3
  32. package/src/live-tokens/data/themes/midnight-study.json +62 -49
  33. package/src/live-tokens/data/themes/ocean.json +4 -3
  34. package/src/live-tokens/data/themes/royal-velvet.json +4 -3
  35. package/src/live-tokens/data/themes/sketchy.json +4 -3
  36. package/src/live-tokens/data/themes/spring-meadow.json +4 -3
  37. package/src/live-tokens/data/themes/sunset.json +4 -3
  38. package/src/live-tokens/data/tokens.generated.css +5 -0
  39. package/src/system/backdrop/backdrop.ts +197 -0
  40. package/src/system/backdrop/index.ts +16 -0
  41. package/src/system/backdrop/watch.ts +139 -0
  42. package/src/system/components/Card.svelte +27 -9
  43. package/src/system/components/FloatingTokenTags.svelte +1 -1
  44. package/src/system/components/ImageLightbox.svelte +8 -4
  45. package/src/system/components/SectionDivider.svelte +3 -3
  46. package/src/system/components/SegmentedControl.svelte +11 -9
  47. package/src/system/styles/tokens.css +33 -5
  48. package/template/src/pages/Home.svelte +1 -11
  49. package/src/system/internal/backgroundContrast.ts +0 -62
@@ -3179,6 +3179,7 @@
3179
3179
  "createdAt": "2026-05-26T02:03:38.863Z",
3180
3180
  "updatedAt": "2026-08-25T01:13:11.520Z",
3181
3181
  "aliases": {
3182
+ "--imagelightbox-tile-surface": "--color-transparent",
3182
3183
  "--imagelightbox-tile-radius": "--radius-none",
3183
3184
  "--imagelightbox-tile-border": "--color-transparent",
3184
3185
  "--imagelightbox-tile-border-width": "--border-width-1",
@@ -3542,7 +3543,7 @@
3542
3543
  "--sectiondivider-lg-description": "--text-alternate",
3543
3544
  "--sectiondivider-lg-eyebrow": "--text-brand",
3544
3545
  "--sectiondivider-lg-border": "--color-transparent",
3545
- "--sectiondivider-lg-title-outline-color": "--surface-canvas-lowest",
3546
+ "--sectiondivider-lg-title-outline-color": "transparent",
3546
3547
  "--sectiondivider-lg-hairline-color": "--border-brand-medium",
3547
3548
  "--sectiondivider-md-title-font-family": "--font-display",
3548
3549
  "--sectiondivider-md-title-font-weight": "--font-weight-bold",
@@ -3571,7 +3572,7 @@
3571
3572
  "--sectiondivider-md-description": "--text-secondary",
3572
3573
  "--sectiondivider-md-eyebrow": "--text-tertiary",
3573
3574
  "--sectiondivider-md-border": "--color-transparent",
3574
- "--sectiondivider-md-title-outline-color": "--surface-accent-lowest",
3575
+ "--sectiondivider-md-title-outline-color": "transparent",
3575
3576
  "--sectiondivider-md-hairline-color": "--border-brand-medium",
3576
3577
  "--sectiondivider-sm-title-font-family": "--font-display",
3577
3578
  "--sectiondivider-sm-title-font-weight": "--font-weight-medium",
@@ -3600,7 +3601,7 @@
3600
3601
  "--sectiondivider-sm-description": "--text-secondary",
3601
3602
  "--sectiondivider-sm-eyebrow": "--text-tertiary",
3602
3603
  "--sectiondivider-sm-border": "--color-transparent",
3603
- "--sectiondivider-sm-title-outline-color": "color-mix(in srgb, var(--surface-neutral-lowest) 25%, transparent)",
3604
+ "--sectiondivider-sm-title-outline-color": "transparent",
3604
3605
  "--sectiondivider-sm-hairline-color": "color-mix(in srgb, var(--border-brand-medium) 50%, transparent)"
3605
3606
  }
3606
3607
  },
@@ -3152,6 +3152,7 @@
3152
3152
  "createdAt": "2026-05-26T02:03:38.863Z",
3153
3153
  "updatedAt": "2026-08-19T22:39:32.785Z",
3154
3154
  "aliases": {
3155
+ "--imagelightbox-tile-surface": "--color-transparent",
3155
3156
  "--imagelightbox-tile-radius": "--radius-3xl",
3156
3157
  "--imagelightbox-tile-border": "--color-transparent",
3157
3158
  "--imagelightbox-tile-border-width": "--border-width-0",
@@ -3515,7 +3516,7 @@
3515
3516
  "--sectiondivider-lg-description": "--text-alternate",
3516
3517
  "--sectiondivider-lg-eyebrow": "--text-brand",
3517
3518
  "--sectiondivider-lg-border": "--color-transparent",
3518
- "--sectiondivider-lg-title-outline-color": "--surface-canvas-lowest",
3519
+ "--sectiondivider-lg-title-outline-color": "transparent",
3519
3520
  "--sectiondivider-lg-hairline-color": "--border-brand-medium",
3520
3521
  "--sectiondivider-md-title-font-family": "--font-display",
3521
3522
  "--sectiondivider-md-title-font-weight": "--font-weight-bold",
@@ -3544,7 +3545,7 @@
3544
3545
  "--sectiondivider-md-description": "--text-secondary",
3545
3546
  "--sectiondivider-md-eyebrow": "--text-tertiary",
3546
3547
  "--sectiondivider-md-border": "--color-transparent",
3547
- "--sectiondivider-md-title-outline-color": "--surface-accent-lowest",
3548
+ "--sectiondivider-md-title-outline-color": "transparent",
3548
3549
  "--sectiondivider-md-hairline-color": "--border-brand-medium",
3549
3550
  "--sectiondivider-sm-title-font-family": "--font-display",
3550
3551
  "--sectiondivider-sm-title-font-weight": "--font-weight-medium",
@@ -3573,7 +3574,7 @@
3573
3574
  "--sectiondivider-sm-description": "--text-secondary",
3574
3575
  "--sectiondivider-sm-eyebrow": "--text-tertiary",
3575
3576
  "--sectiondivider-sm-border": "--color-transparent",
3576
- "--sectiondivider-sm-title-outline-color": "color-mix(in srgb, var(--surface-neutral-lowest) 25%, transparent)",
3577
+ "--sectiondivider-sm-title-outline-color": "transparent",
3577
3578
  "--sectiondivider-sm-hairline-color": "color-mix(in srgb, var(--border-brand-medium) 50%, transparent)"
3578
3579
  }
3579
3580
  },
@@ -3204,6 +3204,7 @@
3204
3204
  "createdAt": "2026-05-26T02:03:38.863Z",
3205
3205
  "updatedAt": "2026-08-19T22:39:32.785Z",
3206
3206
  "aliases": {
3207
+ "--imagelightbox-tile-surface": "--color-transparent",
3207
3208
  "--imagelightbox-tile-radius": "--radius-3xl",
3208
3209
  "--imagelightbox-tile-border": "--color-transparent",
3209
3210
  "--imagelightbox-tile-border-width": "--border-width-0",
@@ -3567,7 +3568,7 @@
3567
3568
  "--sectiondivider-lg-description": "--text-alternate",
3568
3569
  "--sectiondivider-lg-eyebrow": "--text-brand",
3569
3570
  "--sectiondivider-lg-border": "--color-transparent",
3570
- "--sectiondivider-lg-title-outline-color": "--surface-canvas-lowest",
3571
+ "--sectiondivider-lg-title-outline-color": "transparent",
3571
3572
  "--sectiondivider-lg-hairline-color": "--border-brand-medium",
3572
3573
  "--sectiondivider-md-title-font-family": "--font-display",
3573
3574
  "--sectiondivider-md-title-font-weight": "--font-weight-bold",
@@ -3596,7 +3597,7 @@
3596
3597
  "--sectiondivider-md-description": "--text-secondary",
3597
3598
  "--sectiondivider-md-eyebrow": "--text-tertiary",
3598
3599
  "--sectiondivider-md-border": "--color-transparent",
3599
- "--sectiondivider-md-title-outline-color": "--surface-accent-lowest",
3600
+ "--sectiondivider-md-title-outline-color": "transparent",
3600
3601
  "--sectiondivider-md-hairline-color": "--border-brand-medium",
3601
3602
  "--sectiondivider-sm-title-font-family": "--font-display",
3602
3603
  "--sectiondivider-sm-title-font-weight": "--font-weight-medium",
@@ -3625,7 +3626,7 @@
3625
3626
  "--sectiondivider-sm-description": "--text-secondary",
3626
3627
  "--sectiondivider-sm-eyebrow": "--text-tertiary",
3627
3628
  "--sectiondivider-sm-border": "--color-transparent",
3628
- "--sectiondivider-sm-title-outline-color": "color-mix(in srgb, var(--surface-neutral-lowest) 25%, transparent)",
3629
+ "--sectiondivider-sm-title-outline-color": "transparent",
3629
3630
  "--sectiondivider-sm-hairline-color": "color-mix(in srgb, var(--border-brand-medium) 50%, transparent)"
3630
3631
  }
3631
3632
  },
@@ -347,3 +347,8 @@
347
347
  --font-mono: "fira-code", ui-monospace, "SF Mono", Menlo, Consolas, monospace, monospace;
348
348
  --font-editorial: "Manrope", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, sans-serif;
349
349
  }
350
+
351
+ /* Page polarity, from --page-bg */
352
+ :where(:root) {
353
+ color-scheme: dark;
354
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * What paints behind a thing, and which way it leans.
3
+ *
4
+ * A theme is not light or dark by declaration — nothing in a saved theme
5
+ * records it — so polarity is either stated on the markup (`data-backdrop`) or
6
+ * measured from the colours that actually paint. Both answers arrive through
7
+ * `polarityOf`, and every consumer that used to hand-roll one of the two reads
8
+ * it from here.
9
+ */
10
+ import { parseOklchCss, oklchToRgb255 } from '../internal/oklch';
11
+
12
+ export type Polarity = 'light' | 'dark';
13
+ export type ContrastColorToken = '--color-black' | '--color-white';
14
+
15
+ export const BACKDROP_ATTRIBUTE = 'data-backdrop';
16
+
17
+ /** Relative luminance at or above which a backdrop counts as light. Sits
18
+ between the palest shipped dark theme (~0.03) and the darkest light one
19
+ (~0.6), so no shipped look lands near the line. */
20
+ export const LIGHT_BACKDROP = 0.2;
21
+
22
+ /** A fill this see-through says nothing about what shows through it, so the
23
+ walk keeps looking up the tree. */
24
+ const OPAQUE = 0.98;
25
+
26
+ type Rgba = { r: number; g: number; b: number; a: number };
27
+
28
+ const HEX_RE = /#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b/gi;
29
+ const OKLCH_RE = /oklch\(\s*[\d.]+%?\s+[\d.]+%?\s+[\d.]+(?:deg)?\s*(?:\/\s*([\d.]+%?)\s*)?\)/gi;
30
+ const RGB_RE = /rgba?\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)(?:\s*[,/]\s*([\d.]+%?))?\s*\)/gi;
31
+ /** Anything the parsers above miss — `color-mix()`, a named colour, `lab()`.
32
+ Carries no nested parens in any value a computed style hands back. */
33
+ const EXOTIC_RE = /\b(?:color-mix|hwb|lab|lch|oklab|color)\([^()]*\)/gi;
34
+
35
+ function channel(value: string): number {
36
+ const parsed = Number.parseFloat(value);
37
+ return value.endsWith('%') ? parsed * 2.55 : parsed;
38
+ }
39
+
40
+ function alpha(value: string | undefined): number {
41
+ if (value === undefined) return 1;
42
+ const parsed = Number.parseFloat(value);
43
+ return value.endsWith('%') ? parsed / 100 : parsed;
44
+ }
45
+
46
+ function parseHex(hex: string): Rgba {
47
+ const digits = hex.slice(1);
48
+ const full = digits.length <= 4 ? digits.split('').map((part) => part + part).join('') : digits;
49
+ return {
50
+ r: Number.parseInt(full.slice(0, 2), 16),
51
+ g: Number.parseInt(full.slice(2, 4), 16),
52
+ b: Number.parseInt(full.slice(4, 6), 16),
53
+ a: full.length >= 8 ? Number.parseInt(full.slice(6, 8), 16) / 255 : 1,
54
+ };
55
+ }
56
+
57
+ /** Every colour a background value names, gradient stops included. */
58
+ export function colorsIn(background: string): Rgba[] {
59
+ const colors: Rgba[] = [];
60
+ for (const match of background.matchAll(HEX_RE)) colors.push(parseHex(match[0]));
61
+ for (const match of background.matchAll(OKLCH_RE)) {
62
+ const parsed = parseOklchCss(match[0]);
63
+ if (parsed) colors.push({ ...oklchToRgb255(parsed.l, parsed.c, parsed.h), a: alpha(match[1]) });
64
+ }
65
+ for (const match of background.matchAll(RGB_RE)) {
66
+ colors.push({
67
+ r: channel(match[1]),
68
+ g: channel(match[2]),
69
+ b: channel(match[3]),
70
+ a: alpha(match[4]),
71
+ });
72
+ }
73
+ for (const match of background.matchAll(EXOTIC_RE)) {
74
+ const resolved = viaCanvas(match[0]);
75
+ if (resolved) colors.push(resolved);
76
+ }
77
+ return colors;
78
+ }
79
+
80
+ function linearChannel(value: number): number {
81
+ const srgb = Math.max(0, Math.min(255, value)) / 255;
82
+ return srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
83
+ }
84
+
85
+ function luminance({ r, g, b }: Rgba): number {
86
+ return 0.2126 * linearChannel(r) + 0.7152 * linearChannel(g) + 0.0722 * linearChannel(b);
87
+ }
88
+
89
+ const SEED = '#010203';
90
+ let probe: CanvasRenderingContext2D | null | undefined;
91
+
92
+ /** The browser as a colour parser: a 2D canvas resolves anything it renders,
93
+ * including the `color-mix()` a computed style can still hand back, and
94
+ * reports failure rather than resolving an unknown value to black. */
95
+ function viaCanvas(value: string): Rgba | null {
96
+ if (typeof document === 'undefined') return null;
97
+ if (probe === undefined) {
98
+ probe = document.createElement('canvas').getContext?.('2d', { willReadFrequently: true }) ?? null;
99
+ }
100
+ if (!probe) return null;
101
+ probe.fillStyle = SEED;
102
+ probe.fillStyle = value;
103
+ if (probe.fillStyle === SEED) return null;
104
+ probe.clearRect(0, 0, 1, 1);
105
+ probe.fillRect(0, 0, 1, 1);
106
+ const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
107
+ return { r, g, b, a: a / 255 };
108
+ }
109
+
110
+ /** Any CSS colour the browser can render, as `#rrggbb` — for consumers that
111
+ * cannot read CSS themselves: a canvas, a WebGL uniform, an SVG attribute.
112
+ * Null for a value that is not a colour, or is see-through enough that a flat
113
+ * hex would lie about it. */
114
+ export function cssColorToHex(value: string): string | null {
115
+ const color = viaCanvas(value.trim());
116
+ if (!color || color.a < OPAQUE) return null;
117
+ const packed = (Math.round(color.r) << 16) | (Math.round(color.g) << 8) | Math.round(color.b);
118
+ return `#${packed.toString(16).padStart(6, '0')}`;
119
+ }
120
+
121
+ /** Mean luminance of a background value, or null when it names no colour or
122
+ * nothing opaque enough to hide what sits behind it. */
123
+ export function backgroundLuminance(background: string): number | null {
124
+ const opaque = colorsIn(background).filter((color) => color.a >= OPAQUE);
125
+ if (!opaque.length) return null;
126
+ return opaque.reduce((total, color) => total + luminance(color), 0) / opaque.length;
127
+ }
128
+
129
+ /** Which way a background value leans, or null when it names no opaque colour. */
130
+ export function polarityOfBackground(background: string): Polarity | null {
131
+ const measured = backgroundLuminance(background);
132
+ if (measured === null) return null;
133
+ return measured >= LIGHT_BACKDROP ? 'light' : 'dark';
134
+ }
135
+
136
+ /**
137
+ * Pick the invariant black/white token with the stronger minimum contrast
138
+ * against every colour in a solid or gradient background. Alpha is ignored:
139
+ * the caller passes an authored value, not a computed one.
140
+ */
141
+ export function contrastTokenForBackground(background: string): ContrastColorToken {
142
+ const colors = colorsIn(background);
143
+ if (colors.length === 0) return '--color-white';
144
+ const luminances = colors.map(luminance);
145
+ const blackMinimum = Math.min(...luminances.map((value) => (value + 0.05) / 0.05));
146
+ const whiteMinimum = Math.min(...luminances.map((value) => 1.05 / (value + 0.05)));
147
+ return blackMinimum >= whiteMinimum ? '--color-black' : '--color-white';
148
+ }
149
+
150
+ /** Elements a watcher stamped. Its own stamp is an answer, not a declaration,
151
+ * so the next measurement must not read it back as one. */
152
+ const stamped = new WeakSet<Element>();
153
+
154
+ export function markStamped(el: Element): void {
155
+ stamped.add(el);
156
+ }
157
+
158
+ export function clearStamped(el: Element): void {
159
+ stamped.delete(el);
160
+ }
161
+
162
+ /** The polarity the markup states: the nearest `data-backdrop` on or above the
163
+ * element that a watcher did not write. */
164
+ export function declaredPolarity(el: Element | null): Polarity | null {
165
+ for (let node: Element | null = el; node; node = node.parentElement) {
166
+ if (stamped.has(node)) continue;
167
+ const stated = node.getAttribute?.(BACKDROP_ATTRIBUTE);
168
+ if (stated === 'light' || stated === 'dark') return stated;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /** Mean luminance of whatever actually paints behind `el` — the nearest
174
+ * ancestor with an opaque fill, averaged across its gradient stops, falling
175
+ * back to the theme's page canvas. */
176
+ export function backdropLuminance(el: Element | null): number {
177
+ if (typeof document === 'undefined') return 0;
178
+ for (let node: Element | null = el ?? document.body; node; node = node.parentElement) {
179
+ const style = getComputedStyle(node);
180
+ const measured = backgroundLuminance(`${style.backgroundImage} ${style.backgroundColor}`);
181
+ if (measured !== null) return measured;
182
+ }
183
+ const page = getComputedStyle(document.documentElement).getPropertyValue('--page-bg');
184
+ return backgroundLuminance(page) ?? 0;
185
+ }
186
+
187
+ /** Which way the backdrop behind `el` leans. A `data-backdrop` on or above it
188
+ * is the answer; otherwise the paint is measured. */
189
+ export function polarityOf(el: Element | null): Polarity {
190
+ return declaredPolarity(el) ?? (backdropLuminance(el) >= LIGHT_BACKDROP ? 'light' : 'dark');
191
+ }
192
+
193
+ export const isLightBackdrop = (el: Element | null): boolean => polarityOf(el) === 'light';
194
+
195
+ /** The black/white token that reads against `el`'s backdrop. */
196
+ export const contrastTokenFor = (el: Element | null): ContrastColorToken =>
197
+ polarityOf(el) === 'light' ? '--color-black' : '--color-white';
@@ -0,0 +1,16 @@
1
+ export {
2
+ BACKDROP_ATTRIBUTE,
3
+ LIGHT_BACKDROP,
4
+ backdropLuminance,
5
+ backgroundLuminance,
6
+ contrastTokenFor,
7
+ cssColorToHex,
8
+ contrastTokenForBackground,
9
+ declaredPolarity,
10
+ isLightBackdrop,
11
+ polarityOf,
12
+ polarityOfBackground,
13
+ type ContrastColorToken,
14
+ type Polarity,
15
+ } from './backdrop';
16
+ export { backdrop, syncDocumentBackdrop, watchBackdrop, type BackdropOptions } from './watch';
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Keeping a stamp current.
3
+ *
4
+ * A page's polarity moves when the theme changes, and the editor changes a
5
+ * theme by writing inline custom properties on `:root` — no reload, no class
6
+ * flip. One observer watches for that and tells every live watcher to measure
7
+ * again, so a consumer never writes its own.
8
+ */
9
+ import {
10
+ BACKDROP_ATTRIBUTE,
11
+ clearStamped,
12
+ markStamped,
13
+ polarityOf,
14
+ polarityOfBackground,
15
+ type Polarity,
16
+ } from './backdrop';
17
+
18
+ export interface BackdropOptions {
19
+ /** Called with each new answer, including the first. */
20
+ onChange?: (polarity: Polarity) => void;
21
+ /** Write `data-backdrop` on the element, so CSS can key on it and
22
+ `light-dark()` inside it resolves the right half. Default true. */
23
+ stamp?: boolean;
24
+ }
25
+
26
+ type Watcher = () => void;
27
+
28
+ const watchers = new Set<Watcher>();
29
+ let themeObserver: MutationObserver | null = null;
30
+ let resizeBound = false;
31
+
32
+ function notify(): void {
33
+ for (const watcher of watchers) watcher();
34
+ }
35
+
36
+ function startListening(): void {
37
+ if (typeof document === 'undefined') return;
38
+ if (!themeObserver) {
39
+ // The editor writes the live theme as inline vars on <html>; a switch to a
40
+ // different stylesheet lands in <head>.
41
+ themeObserver = new MutationObserver(notify);
42
+ themeObserver.observe(document.documentElement, { attributeFilter: ['style', 'class'] });
43
+ themeObserver.observe(document.head, { childList: true });
44
+ }
45
+ if (!resizeBound) {
46
+ window.addEventListener('resize', notify, { passive: true });
47
+ resizeBound = true;
48
+ }
49
+ }
50
+
51
+ function stopListening(): void {
52
+ if (watchers.size) return;
53
+ themeObserver?.disconnect();
54
+ themeObserver = null;
55
+ if (resizeBound) {
56
+ window.removeEventListener('resize', notify);
57
+ resizeBound = false;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Track the polarity behind `el` until the returned function is called. A
63
+ * `data-backdrop` stated on or above the element wins over measurement, which
64
+ * is what lets a section declare a tone the paint cannot show — art directed
65
+ * over a photograph, or a band whose fill has not rendered yet.
66
+ */
67
+ export function watchBackdrop(el: Element, options: BackdropOptions = {}): () => void {
68
+ const { onChange, stamp = true } = options;
69
+ let last: Polarity | null = null;
70
+
71
+ const measure = () => {
72
+ const polarity = polarityOf(el);
73
+ if (polarity === last) return;
74
+ last = polarity;
75
+ if (stamp) {
76
+ markStamped(el);
77
+ el.setAttribute(BACKDROP_ATTRIBUTE, polarity);
78
+ }
79
+ onChange?.(polarity);
80
+ };
81
+
82
+ watchers.add(measure);
83
+ startListening();
84
+ measure();
85
+
86
+ return () => {
87
+ watchers.delete(measure);
88
+ if (stamp) {
89
+ clearStamped(el);
90
+ el.removeAttribute(BACKDROP_ATTRIBUTE);
91
+ }
92
+ stopListening();
93
+ };
94
+ }
95
+
96
+ /** Svelte action: `<section use:backdrop>` stamps the element and keeps the
97
+ * stamp current. Pass `{ onChange }` when JS needs the answer too. */
98
+ export function backdrop(el: Element, options: BackdropOptions = {}) {
99
+ let stop = watchBackdrop(el, options);
100
+ return {
101
+ update(next: BackdropOptions = {}) {
102
+ stop();
103
+ stop = watchBackdrop(el, next);
104
+ },
105
+ destroy() {
106
+ stop();
107
+ },
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Stamp `<html>` from the theme's page canvas, so a rule can key on
113
+ * `[data-backdrop='dark']` anywhere on the page and a section that declares its
114
+ * own tone still overrides it. Call once at boot; returns a stop function.
115
+ */
116
+ export function syncDocumentBackdrop(): () => void {
117
+ if (typeof document === 'undefined') return () => {};
118
+ const root = document.documentElement;
119
+ const stated = root.getAttribute(BACKDROP_ATTRIBUTE);
120
+ // A root the page states for itself is the page's answer; leave it alone.
121
+ if (stated === 'light' || stated === 'dark') return () => {};
122
+ const measure = () => {
123
+ const page = getComputedStyle(root).getPropertyValue('--page-bg');
124
+ const polarity = polarityOfBackground(page);
125
+ if (polarity) root.setAttribute(BACKDROP_ATTRIBUTE, polarity);
126
+ };
127
+ // The root's own stamp is an answer, not a declaration — without this every
128
+ // measurement below it would short-circuit on the page's polarity and no
129
+ // local band could ever read as its own.
130
+ markStamped(root);
131
+ watchers.add(measure);
132
+ startListening();
133
+ measure();
134
+ return () => {
135
+ watchers.delete(measure);
136
+ clearStamped(root);
137
+ stopListening();
138
+ };
139
+ }
@@ -3,6 +3,9 @@
3
3
  icon?: string;
4
4
  iconColor?: string;
5
5
  title?: string;
6
+ /** `bare` drops the header. The frame and body keep every token the default
7
+ variant uses, so padding, stroke and fill stay editable. */
8
+ variant?: 'default' | 'bare';
6
9
  size?: 'default' | 'compact';
7
10
  /** false → the card stops pinning body typography so the consumer fully owns slotted content's styling. */
8
11
  prose?: boolean;
@@ -20,6 +23,7 @@
20
23
  icon = '',
21
24
  iconColor = 'var(--text-secondary)',
22
25
  title = '',
26
+ variant = 'default',
23
27
  size = 'default',
24
28
  prose = true,
25
29
  flush = false,
@@ -35,16 +39,25 @@
35
39
  let hoverShadow = $derived(
36
40
  hover === undefined ? undefined : hover ? 'var(--card-hover-shadow)' : 'var(--card-default-shadow)',
37
41
  );
42
+
43
+ let showHeader = $derived(variant !== 'bare' && Boolean(icon || title));
44
+
45
+ $effect(() => {
46
+ if (import.meta.env.DEV && variant === 'default' && !icon && !title) {
47
+ console.warn('[live-tokens] <Card> has no icon or title. Use variant="bare" to declare a headerless card.');
48
+ }
49
+ });
38
50
  </script>
39
51
 
40
52
  <div
41
53
  class="card {className}"
54
+ class:bare={variant === 'bare'}
42
55
  class:compact={size === 'compact'}
43
56
  style:--card-color={iconColor}
44
57
  style:--card-hover-border-active={hoverBorder}
45
58
  style:--card-hover-shadow-active={hoverShadow}
46
59
  >
47
- {#if icon || title}
60
+ {#if showHeader}
48
61
  <div class="card-header">
49
62
  {#if icon}
50
63
  <i class="{icon} card-icon"></i>
@@ -68,17 +81,22 @@
68
81
  --card-default-border: var(--border-neutral);
69
82
  --card-default-border-width: var(--border-width-1);
70
83
  --card-default-radius: var(--radius-lg);
84
+ /* Every side tracks its base. A declared side always beats the fallback
85
+ themed-padding emits, so naming a literal step here left the base token
86
+ inert: merging split padding back to one value cleared the side aliases
87
+ and the card kept the old geometry. The asymmetry is a themed choice now,
88
+ carried by per-side aliases that override these outright. */
71
89
  --card-default-header-padding: var(--space-16);
72
- --card-default-header-padding-top: var(--space-12);
73
- --card-default-header-padding-right: var(--space-20);
74
- --card-default-header-padding-bottom: var(--space-12);
75
- --card-default-header-padding-left: var(--space-20);
90
+ --card-default-header-padding-top: var(--card-default-header-padding);
91
+ --card-default-header-padding-right: var(--card-default-header-padding);
92
+ --card-default-header-padding-bottom: var(--card-default-header-padding);
93
+ --card-default-header-padding-left: var(--card-default-header-padding);
76
94
  --card-default-header-gap: var(--space-8);
77
95
  --card-default-body-padding: var(--space-16);
78
- --card-default-body-padding-top: var(--space-16);
79
- --card-default-body-padding-right: var(--space-20);
80
- --card-default-body-padding-bottom: var(--space-16);
81
- --card-default-body-padding-left: var(--space-20);
96
+ --card-default-body-padding-top: var(--card-default-body-padding);
97
+ --card-default-body-padding-right: var(--card-default-body-padding);
98
+ --card-default-body-padding-bottom: var(--card-default-body-padding);
99
+ --card-default-body-padding-left: var(--card-default-body-padding);
82
100
  --card-default-shadow: var(--shadow-sm);
83
101
  --card-default-blur: var(--blur-none);
84
102
  --card-default-header-surface: color-mix(in srgb, var(--surface-neutral-lowest) 80%, transparent);
@@ -35,7 +35,7 @@
35
35
  <script lang="ts">
36
36
  import MenuSelect from './MenuSelect.svelte';
37
37
  import { SvelteMap } from 'svelte/reactivity';
38
- import { contrastTokenForBackground } from '../internal/backgroundContrast';
38
+ import { contrastTokenForBackground } from '../backdrop';
39
39
  // `.ftt-tag` is hand-rolled (not Badge) so editing badge-* tokens doesn't
40
40
  // repaint the playground. The dropdown uses MenuSelect on purpose.
41
41
  import './FloatingTokenTags.css';
@@ -630,7 +630,7 @@
630
630
  class="image-lightbox-stage"
631
631
  class:shadow-box={shadow === 'box'}
632
632
  class:shadow-content={shadow === 'content'}
633
- role="presentation"
633
+ role="presentation"
634
634
  tabindex="-1"
635
635
  onclick={onStageClick}
636
636
  onpointerdown={onPointerDown}
@@ -728,7 +728,9 @@
728
728
 
729
729
  <style>
730
730
  :global(:root) {
731
- /* thumbnail (closed inline) + animated modal stage */
731
+ /* thumbnail (closed inline) + animated modal stage; the fill is the
732
+ thumbnail's alone — the open stage rides the overlay */
733
+ --imagelightbox-tile-surface: var(--color-transparent);
732
734
  --imagelightbox-tile-radius: var(--radius-2xl);
733
735
  --imagelightbox-tile-border: var(--color-transparent);
734
736
  --imagelightbox-tile-border-width: var(--border-width-0);
@@ -759,7 +761,7 @@
759
761
  cursor: zoom-in;
760
762
  border: var(--imagelightbox-tile-border-width) solid var(--imagelightbox-tile-border);
761
763
  border-radius: var(--imagelightbox-tile-radius);
762
- background: transparent;
764
+ background: var(--imagelightbox-tile-surface);
763
765
  overflow: hidden;
764
766
  transition: transform 250ms ease;
765
767
  }
@@ -812,13 +814,15 @@
812
814
  border: 0;
813
815
  }
814
816
 
817
+ /* No tile fill here: the open stage sits on the near-opaque overlay, which
818
+ already backs transparent art. Painting the fill too would band the scrim
819
+ wherever the image's aspect leaves the stage box unfilled. */
815
820
  .image-lightbox-stage {
816
821
  position: fixed;
817
822
  z-index: var(--z-modal);
818
823
  cursor: zoom-out;
819
824
  border: var(--imagelightbox-tile-border-width) solid var(--imagelightbox-tile-border);
820
825
  border-radius: var(--imagelightbox-tile-radius);
821
- background: transparent;
822
826
  }
823
827
 
824
828
  .image-lightbox-clip {
@@ -243,7 +243,7 @@
243
243
  --sectiondivider-lg-description: var(--text-alternate);
244
244
  --sectiondivider-lg-eyebrow: var(--text-brand);
245
245
  --sectiondivider-lg-border: var(--color-transparent);
246
- --sectiondivider-lg-title-outline-color: var(--surface-canvas-lowest);
246
+ --sectiondivider-lg-title-outline-color: var(--color-transparent);
247
247
  --sectiondivider-lg-hairline-color: var(--border-brand-medium);
248
248
 
249
249
  /* Medium */
@@ -274,7 +274,7 @@
274
274
  --sectiondivider-md-description: var(--text-secondary);
275
275
  --sectiondivider-md-eyebrow: var(--text-tertiary);
276
276
  --sectiondivider-md-border: var(--color-transparent);
277
- --sectiondivider-md-title-outline-color: var(--surface-accent-lowest);
277
+ --sectiondivider-md-title-outline-color: var(--color-transparent);
278
278
  --sectiondivider-md-hairline-color: var(--border-brand-medium);
279
279
 
280
280
  /* Small */
@@ -305,7 +305,7 @@
305
305
  --sectiondivider-sm-description: var(--text-secondary);
306
306
  --sectiondivider-sm-eyebrow: var(--text-tertiary);
307
307
  --sectiondivider-sm-border: var(--color-transparent);
308
- --sectiondivider-sm-title-outline-color: color-mix(in srgb, var(--surface-neutral-lowest) 25%, transparent);
308
+ --sectiondivider-sm-title-outline-color: var(--color-transparent);
309
309
  --sectiondivider-sm-hairline-color: color-mix(in srgb, var(--border-brand-medium) 50%, transparent);
310
310
 
311
311
  /* Intrinsic defaults. These keys cascade to `:root` via the editor's
@@ -152,25 +152,27 @@
152
152
  icon size) all read through these default names, so rebinding them
153
153
  here is enough — no per-state small token explosion. Per-side padding
154
154
  overrides are rebound too so split-padding edits at small don't leak
155
- through to default size. Font-weight and color are intentionally NOT
155
+ through to default size; each carries the same fallback themed-padding
156
+ emits, so merging the small padding back to one value leaves the base
157
+ token in charge instead of binding to a name nothing sets. Font-weight and color are intentionally NOT
156
158
  rebound: weight differences are state-driven (selected = semibold),
157
159
  colors are state-driven too. */
158
160
  .segmented-control.small {
159
161
  --segmentedcontrol-bar-padding: var(--segmentedcontrol-bar-small-padding);
160
- --segmentedcontrol-bar-padding-top: var(--segmentedcontrol-bar-small-padding-top);
161
- --segmentedcontrol-bar-padding-right: var(--segmentedcontrol-bar-small-padding-right);
162
- --segmentedcontrol-bar-padding-bottom: var(--segmentedcontrol-bar-small-padding-bottom);
163
- --segmentedcontrol-bar-padding-left: var(--segmentedcontrol-bar-small-padding-left);
162
+ --segmentedcontrol-bar-padding-top: var(--segmentedcontrol-bar-small-padding-top, var(--segmentedcontrol-bar-small-padding));
163
+ --segmentedcontrol-bar-padding-right: var(--segmentedcontrol-bar-small-padding-right, var(--segmentedcontrol-bar-small-padding));
164
+ --segmentedcontrol-bar-padding-bottom: var(--segmentedcontrol-bar-small-padding-bottom, var(--segmentedcontrol-bar-small-padding));
165
+ --segmentedcontrol-bar-padding-left: var(--segmentedcontrol-bar-small-padding-left, var(--segmentedcontrol-bar-small-padding));
164
166
  --segmentedcontrol-bar-radius: var(--segmentedcontrol-bar-small-radius);
165
167
 
166
168
  --segmentedcontrol-divider-inset: var(--segmentedcontrol-small-divider-inset);
167
169
  --segmentedcontrol-divider-thickness: var(--segmentedcontrol-small-divider-thickness);
168
170
 
169
171
  --segmentedcontrol-option-padding: var(--segmentedcontrol-option-small-padding);
170
- --segmentedcontrol-option-padding-top: var(--segmentedcontrol-option-small-padding-top);
171
- --segmentedcontrol-option-padding-right: var(--segmentedcontrol-option-small-padding-right);
172
- --segmentedcontrol-option-padding-bottom: var(--segmentedcontrol-option-small-padding-bottom);
173
- --segmentedcontrol-option-padding-left: var(--segmentedcontrol-option-small-padding-left);
172
+ --segmentedcontrol-option-padding-top: var(--segmentedcontrol-option-small-padding-top, var(--segmentedcontrol-option-small-padding));
173
+ --segmentedcontrol-option-padding-right: var(--segmentedcontrol-option-small-padding-right, var(--segmentedcontrol-option-small-padding));
174
+ --segmentedcontrol-option-padding-bottom: var(--segmentedcontrol-option-small-padding-bottom, var(--segmentedcontrol-option-small-padding));
175
+ --segmentedcontrol-option-padding-left: var(--segmentedcontrol-option-small-padding-left, var(--segmentedcontrol-option-small-padding));
174
176
  --segmentedcontrol-option-gap: var(--segmentedcontrol-option-small-gap);
175
177
 
176
178
  /* Icon size has a single source-of-truth token now; text size/line-height