@motion-proto/live-tokens 0.48.0 → 0.48.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.48.1 — Demo typography follows the theme
4
+
5
+ ### Fixed
6
+
7
+ - **The demo hero follows semantic typography.** The subtitle and supporting
8
+ tagline now take their font families from the heading and body text-style
9
+ tokens, so changing the theme's primary font pairing repaints the whole hero.
10
+
11
+ - **Floating-tag connectors stay visible.** Connector strings choose the
12
+ black or white invariant with the stronger contrast against the current page
13
+ background, including backgrounds with multiple gradient stops.
14
+
3
15
  ## 0.48.0 — Themes are documents
4
16
 
5
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.48.0",
3
+ "version": "0.48.1",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  .ftt-stage {
10
+ --ftt-contrast-color: var(--color-white);
10
11
  position: relative;
11
12
  width: 100%;
12
13
  height: 100%;
@@ -73,7 +74,7 @@
73
74
  }
74
75
 
75
76
  .ftt-string {
76
- stroke: #d4ccc6;
77
+ stroke: var(--ftt-contrast-color);
77
78
  stroke-width: 2;
78
79
  stroke-linecap: round;
79
80
  vector-effect: non-scaling-stroke;
@@ -35,6 +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
39
  // `.ftt-tag` is hand-rolled (not Badge) so editing badge-* tokens doesn't
39
40
  // repaint the playground. The dropdown uses MenuSelect on purpose.
40
41
  import './FloatingTokenTags.css';
@@ -189,10 +190,22 @@
189
190
  return name ? `var(${name})` : undefined;
190
191
  }
191
192
 
193
+ let lastPageBackground = '';
194
+ function syncConnectorContrast() {
195
+ if (!stageEl) return;
196
+ const styles = getComputedStyle(stageEl);
197
+ const pageBackground = styles.getPropertyValue('--page-bg').trim();
198
+ if (pageBackground === lastPageBackground) return;
199
+ lastPageBackground = pageBackground;
200
+ const token = contrastTokenForBackground(pageBackground);
201
+ stageEl.style.setProperty('--ftt-contrast-color', `var(${token})`);
202
+ }
203
+
192
204
  // Kite strings and energy balls are recomputed each frame from the box's
193
205
  // measured rect so intrinsic sizing drives anchor placement.
194
206
  function syncFrame() {
195
207
  if (!stageEl) return;
208
+ syncConnectorContrast();
196
209
  const stageRect = stageEl.getBoundingClientRect();
197
210
  if (stageRect.width === 0 || stageRect.height === 0) return;
198
211
 
@@ -0,0 +1,54 @@
1
+ export type ContrastColorToken = '--color-black' | '--color-white';
2
+
3
+ type Rgb = { r: number; g: number; b: number };
4
+
5
+ const HEX_RE = /#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b/gi;
6
+ const RGB_RE = /rgba?\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)(?:\s*[,/]\s*[\d.]+%?)?\s*\)/gi;
7
+
8
+ function channel(value: string): number {
9
+ const parsed = Number.parseFloat(value);
10
+ return value.endsWith('%') ? parsed * 2.55 : parsed;
11
+ }
12
+
13
+ function parseHex(hex: string): Rgb {
14
+ const full = hex.length <= 4
15
+ ? hex.slice(1).split('').map(part => part + part).join('')
16
+ : hex.slice(1, 7);
17
+ return {
18
+ r: Number.parseInt(full.slice(0, 2), 16),
19
+ g: Number.parseInt(full.slice(2, 4), 16),
20
+ b: Number.parseInt(full.slice(4, 6), 16),
21
+ };
22
+ }
23
+
24
+ function colorsIn(background: string): Rgb[] {
25
+ const colors: Rgb[] = [];
26
+ for (const match of background.matchAll(HEX_RE)) colors.push(parseHex(match[0]));
27
+ for (const match of background.matchAll(RGB_RE)) {
28
+ colors.push({ r: channel(match[1]), g: channel(match[2]), b: channel(match[3]) });
29
+ }
30
+ return colors;
31
+ }
32
+
33
+ function linearChannel(value: number): number {
34
+ const srgb = Math.max(0, Math.min(255, value)) / 255;
35
+ return srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
36
+ }
37
+
38
+ function luminance({ r, g, b }: Rgb): number {
39
+ return 0.2126 * linearChannel(r) + 0.7152 * linearChannel(g) + 0.0722 * linearChannel(b);
40
+ }
41
+
42
+ /**
43
+ * Pick the invariant black/white token with the stronger minimum contrast
44
+ * against every explicit colour in a solid or gradient page background.
45
+ */
46
+ export function contrastTokenForBackground(background: string): ContrastColorToken {
47
+ const colors = colorsIn(background);
48
+ if (colors.length === 0) return '--color-white';
49
+
50
+ const luminances = colors.map(luminance);
51
+ const blackMinimum = Math.min(...luminances.map(value => (value + 0.05) / 0.05));
52
+ const whiteMinimum = Math.min(...luminances.map(value => 1.05 / (value + 0.05)));
53
+ return blackMinimum >= whiteMinimum ? '--color-black' : '--color-white';
54
+ }