@motion-proto/live-tokens 0.95.0 → 0.97.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 (36) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/bin/set-colors.mjs +1 -0
  3. package/bin/setup-claude.mjs +2 -2
  4. package/dist-plugin/setColors/index.cjs +7 -1
  5. package/dist-plugin/setColors/index.d.cts +9 -4
  6. package/dist-plugin/setColors/index.d.ts +9 -4
  7. package/dist-plugin/setColors/index.js +7 -1
  8. package/dist-plugin/setGeometry/index.d.cts +1 -1
  9. package/dist-plugin/setGeometry/index.d.ts +1 -1
  10. package/dist-plugin/setType/index.d.cts +1 -1
  11. package/dist-plugin/setType/index.d.ts +1 -1
  12. package/dist-plugin/{themeTypes-B8_Idrp4.d.cts → themeTypes-BaTQUyac.d.cts} +10 -1
  13. package/dist-plugin/{themeTypes-B8_Idrp4.d.ts → themeTypes-BaTQUyac.d.ts} +10 -1
  14. package/package.json +4 -2
  15. package/src/demo/TestingLoops.svelte +39 -16
  16. package/src/editor/component-editor/scaffolding/VariantGroup.svelte +0 -7
  17. package/src/editor/core/store/editorPersistence.ts +9 -1
  18. package/src/editor/core/store/editorRenderer.ts +2 -0
  19. package/src/editor/core/store/editorStore.ts +25 -0
  20. package/src/editor/core/store/editorTypes.ts +9 -0
  21. package/src/editor/core/themes/buildColors.ts +16 -5
  22. package/src/editor/core/themes/slices/customColors.ts +226 -0
  23. package/src/editor/core/themes/themeTypes.ts +10 -0
  24. package/src/editor/docs/content/editing-tokens.md +28 -0
  25. package/src/editor/docs/content.generated.ts +1 -1
  26. package/src/editor/pages/EditorShell.svelte +1 -0
  27. package/src/editor/ui/CustomColorReplaceStep.svelte +118 -0
  28. package/src/editor/ui/UIPaletteSelector.svelte +338 -15
  29. package/src/editor/ui/UITokenSelector.svelte +7 -12
  30. package/src/editor/ui/VariablesTab.svelte +3 -0
  31. package/src/editor/ui/anchorToTrigger.ts +63 -0
  32. package/src/editor/ui/sections/CustomColorsSection.svelte +254 -0
  33. package/src/system/components/ImageLightbox.svelte +17 -3
  34. package/src/testing-js/page-compliance.contract.js +31 -5
  35. package/src/testing-js/page-compliance.contract.js.map +1 -1
  36. package/src/editor/ui/keepInViewport.ts +0 -84
@@ -0,0 +1,254 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+ import { get } from 'svelte/store';
4
+ import {
5
+ editorState,
6
+ beginScope,
7
+ commitScope,
8
+ cancelScope,
9
+ beginSliderGesture,
10
+ setCustomColor,
11
+ removeCustomColor,
12
+ replaceAndRemoveCustomColor,
13
+ type Scope,
14
+ } from '../../core/store/editorStore';
15
+ import type { Oklch } from '../../core/palettes/oklch';
16
+ import ColorEditPanel from '../ColorEditPanel.svelte';
17
+ import CustomColorReplaceStep from '../CustomColorReplaceStep.svelte';
18
+
19
+ interface Props {
20
+ copiedVar?: string | null;
21
+ oncopy?: (variable: string) => void;
22
+ }
23
+
24
+ let { copiedVar = null, oncopy }: Props = $props();
25
+
26
+ const PREFIX = '--color-custom-';
27
+ const customNumber = (v: string) => v.slice(PREFIX.length);
28
+
29
+ let tokens = $derived(
30
+ [...$editorState.customColors.tokens]
31
+ .sort((a, b) => Number(customNumber(a.variable)) - Number(customNumber(b.variable))),
32
+ );
33
+
34
+ /** An open edit of one custom colour, or the replace step of its remove. */
35
+ interface Session {
36
+ mode: 'edit' | 'replace';
37
+ variable: string;
38
+ draft: Oklch;
39
+ }
40
+ let session = $state<Session | null>(null);
41
+ // Held outside $state: the store matches an open scope by identity, and a
42
+ // proxied handle would never match.
43
+ let sessionScope: Scope | null = null;
44
+
45
+ function tokenColor(variable: string): Oklch | undefined {
46
+ return get(editorState).customColors.tokens.find((t) => t.variable === variable)?.color;
47
+ }
48
+
49
+ function openEdit(variable: string) {
50
+ const color = tokenColor(variable);
51
+ if (!color) return;
52
+ sessionScope = beginScope({ label: `edit custom colour ${variable}`, collapseToOne: true, clipUndoFloor: true });
53
+ session = { mode: 'edit', variable, draft: { ...color } };
54
+ }
55
+
56
+ function editDraft(h: number, c: number, lPct: number) {
57
+ if (!session) return;
58
+ session.draft = { l: lPct / 100, c, h };
59
+ setCustomColor(session.variable, session.draft);
60
+ }
61
+
62
+ function closeEdit(commit: boolean) {
63
+ if (sessionScope) {
64
+ if (commit) commitScope(sessionScope);
65
+ else cancelScope(sessionScope);
66
+ }
67
+ sessionScope = null;
68
+ session = null;
69
+ }
70
+
71
+ function requestRemove(variable: string) {
72
+ if (removeCustomColor(variable)) return;
73
+ const color = tokenColor(variable);
74
+ if (color) session = { mode: 'replace', variable, draft: { ...color } };
75
+ }
76
+
77
+ function confirmReplace(replacement: string) {
78
+ if (!session) return;
79
+ const { variable } = session;
80
+ session = null;
81
+ replaceAndRemoveCustomColor(variable, replacement);
82
+ }
83
+
84
+ onDestroy(() => {
85
+ if (sessionScope) cancelScope(sessionScope);
86
+ });
87
+ </script>
88
+
89
+ <section class="section" id="custom-colors">
90
+ <h2 class="section-title">Custom Colours</h2>
91
+ <p class="editor-intro">A custom colour holds its own OKLCH value, and every slot that names it follows its edits. Add one from the Custom row of any colour picker. Removing a colour in use first asks which token takes its place.</p>
92
+ {#if tokens.length === 0}
93
+ <p class="custom-empty">This theme has no custom colours.</p>
94
+ {:else}
95
+ <div class="custom-grid">
96
+ {#each tokens as token (token.variable)}
97
+ {@const active = session?.variable === token.variable}
98
+ <div class={['custom-item', { active }]}>
99
+ <div class="custom-box" style="background: var({token.variable});"></div>
100
+ <div class="token-info">
101
+ <button class={['token-variable copyable', { copied: copiedVar === token.variable }]} onclick={() => oncopy?.(token.variable)}>{copiedVar === token.variable ? 'copied!' : token.variable}</button>
102
+ {#if !active}
103
+ <div class="custom-actions">
104
+ <button
105
+ class="custom-btn"
106
+ aria-label="Edit {token.variable}"
107
+ disabled={session !== null}
108
+ onclick={() => openEdit(token.variable)}
109
+ >Edit</button>
110
+ <button
111
+ class="custom-btn"
112
+ aria-label="Remove {token.variable}"
113
+ disabled={session !== null}
114
+ onclick={() => requestRemove(token.variable)}
115
+ >Remove</button>
116
+ </div>
117
+ {/if}
118
+ </div>
119
+ {#if active && session}
120
+ <div class="custom-editor-host">
121
+ {#if session.mode === 'edit'}
122
+ <ColorEditPanel
123
+ title="Custom › {customNumber(session.variable)}"
124
+ hue={session.draft.h}
125
+ chroma={session.draft.c}
126
+ lightness={session.draft.l * 100}
127
+ onHueChromaChange={editDraft}
128
+ onSliderStart={() => beginSliderGesture(`edit ${session?.variable ?? 'custom colour'}`)}
129
+ onConfirm={() => closeEdit(true)}
130
+ onCancel={() => closeEdit(false)}
131
+ />
132
+ {:else}
133
+ <CustomColorReplaceStep
134
+ variable={session.variable}
135
+ color={session.draft}
136
+ onconfirm={confirmReplace}
137
+ oncancel={() => (session = null)}
138
+ />
139
+ {/if}
140
+ </div>
141
+ {/if}
142
+ </div>
143
+ {/each}
144
+ </div>
145
+ {/if}
146
+ </section>
147
+
148
+ <style>
149
+ .section {
150
+ display: flex;
151
+ flex-direction: column;
152
+ gap: var(--ui-space-24);
153
+ }
154
+
155
+ .section-title {
156
+ font-size: var(--ui-font-size-2xl);
157
+ font-weight: var(--ui-font-weight-semibold);
158
+ color: var(--ui-text-primary);
159
+ margin: 0;
160
+ padding-bottom: var(--ui-space-8);
161
+ border-bottom: 2px solid var(--ui-border-high);
162
+ }
163
+
164
+ .editor-intro,
165
+ .custom-empty {
166
+ font-size: var(--ui-font-size-md);
167
+ color: var(--ui-text-tertiary);
168
+ margin: 0;
169
+ }
170
+
171
+ .token-variable.copyable {
172
+ all: unset;
173
+ font-size: var(--ui-font-size-md);
174
+ color: var(--ui-text-tertiary);
175
+ font-family: var(--ui-font-mono);
176
+ cursor: pointer;
177
+ transition: color var(--ui-transition-fast);
178
+ }
179
+
180
+ .token-variable.copyable:hover {
181
+ color: var(--ui-text-accent);
182
+ }
183
+
184
+ .token-variable.copyable.copied {
185
+ color: var(--ui-text-success);
186
+ }
187
+
188
+ .custom-grid {
189
+ display: grid;
190
+ grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr));
191
+ gap: var(--ui-space-16);
192
+ }
193
+
194
+ .custom-item {
195
+ display: flex;
196
+ flex-direction: column;
197
+ gap: var(--ui-space-8);
198
+ padding: var(--ui-space-12);
199
+ border: 1px solid var(--ui-border-low);
200
+ border-radius: var(--ui-radius-lg);
201
+ background: var(--ui-surface-lowest);
202
+ min-width: 0;
203
+ }
204
+
205
+ .custom-item.active {
206
+ grid-column: 1 / -1;
207
+ border-color: var(--ui-text-primary);
208
+ }
209
+
210
+ .custom-box {
211
+ height: 3rem;
212
+ border-radius: var(--ui-radius-md);
213
+ }
214
+
215
+ .token-info {
216
+ display: flex;
217
+ align-items: center;
218
+ justify-content: space-between;
219
+ gap: var(--ui-space-8);
220
+ }
221
+
222
+ .custom-actions {
223
+ display: flex;
224
+ gap: var(--ui-space-4);
225
+ }
226
+
227
+ .custom-btn {
228
+ padding: var(--ui-space-2) var(--ui-space-10);
229
+ background: var(--ui-surface-low);
230
+ border: 1px solid var(--ui-border-low);
231
+ border-radius: var(--ui-radius-sm);
232
+ color: var(--ui-text-secondary);
233
+ font-size: var(--ui-font-size-xs);
234
+ cursor: pointer;
235
+ font-family: inherit;
236
+ }
237
+
238
+ .custom-btn:hover:not(:disabled) {
239
+ color: var(--ui-text-primary);
240
+ border-color: var(--ui-border);
241
+ }
242
+
243
+ .custom-btn:disabled {
244
+ cursor: default;
245
+ opacity: 0.5;
246
+ }
247
+
248
+ .custom-editor-host {
249
+ max-width: 28rem;
250
+ margin-top: var(--ui-space-8);
251
+ padding-top: var(--ui-space-12);
252
+ border-top: 1px dashed var(--ui-border-low);
253
+ }
254
+ </style>
@@ -12,6 +12,8 @@
12
12
  props: {
13
13
  thumbAspect:
14
14
  'the ratio the thumbnail box holds, when it should not be the image\'s own. The image still opens whole, at its own ratio.',
15
+ thumbSrc:
16
+ 'a smaller file for the closed thumbnail, when the full image is far larger than the tile. The lightbox still opens `src`.',
15
17
  },
16
18
  } satisfies CatalogueEntry;
17
19
  </script>
@@ -39,6 +41,8 @@
39
41
  maxWidth?: number | string | undefined;
40
42
  /** The thumbnail box's ratio, when it differs from the image's own. */
41
43
  thumbAspect?: number | undefined;
44
+ /** A smaller file for the closed thumbnail; the modal still opens `src`. */
45
+ thumbSrc?: string | undefined;
42
46
  /** Closed-thumbnail object-fit. `cover` crops the thumbnail to fill its box;
43
47
  the expanded modal always uses `contain` so the whole image stays visible.
44
48
  `cover` only crops when the thumbnail box differs from the image's shape
@@ -70,7 +74,7 @@
70
74
  The modal portals to <body>, out of reach of a wrapper around the tile,
71
75
  so a page that wants its own scrim has to say so here. */
72
76
  scrim?: string | undefined;
73
- /** When true, shows a bottom toolbar (zoom in/out + percent) and enables wheel/drag zoom inside the open modal. Either way, a click on the unzoomed image closes. */
77
+ /** When true, shows a bottom toolbar (zoom in/out + percent) and enables wheel/drag zoom inside the open modal. Either way, a click on the unzoomed image closes. The percent measures the image against its natural resolution: 100% shows one source pixel per CSS pixel, so a large image opens fitted below 100%. Until the natural size is known it measures against the fitted view. */
74
78
  extended?: boolean;
75
79
  /** Maximum zoom, as a multiple of the image's natural resolution: `1` = 100%
76
80
  of the source's real pixels (1 source px = 1 screen px), `2` = 200%. The
@@ -106,6 +110,7 @@
106
110
  images = undefined,
107
111
  maxWidth = undefined,
108
112
  thumbAspect = undefined,
113
+ thumbSrc = undefined,
109
114
  fit = 'contain',
110
115
  shadow = 'box',
111
116
  surface = undefined,
@@ -664,7 +669,14 @@
664
669
  };
665
670
  });
666
671
 
667
- let percentLabel = $derived(`${Math.round(scale * 100)}%`);
672
+ // `scale` is relative to the fitted stage; the readout reports the share of
673
+ // the source's real pixels, the unit `maxZoom` takes.
674
+ let percentLabel = $derived.by(() => {
675
+ const nw = naturalWidthOf(current);
676
+ const fitW = viewportTarget().width;
677
+ const ratio = nw && fitW ? (scale * fitW) / nw : scale;
678
+ return `${Math.round(ratio * 100)}%`;
679
+ });
668
680
  </script>
669
681
 
670
682
  <div
@@ -687,7 +699,9 @@
687
699
  aria-expanded={open}
688
700
  onclick={openLightbox}
689
701
  >
690
- <img src={cover?.src} alt={cover?.alt} draggable="false" onload={onCoverLoad} />
702
+ <!-- A thumbnail's pixels understate the full image, so it never feeds the
703
+ measured size behind the tile aspect and the zoom caps. -->
704
+ <img src={thumbSrc ?? cover?.src} alt={cover?.alt} draggable="false" onload={thumbSrc ? undefined : onCoverLoad} />
691
705
  </button>
692
706
  {#if overlay}
693
707
  <!-- Beside the thumb, not in it: the thumb clips its corners. Hidden while
@@ -311,6 +311,23 @@ function variantPattern(label) {
311
311
  const keys = [.../* @__PURE__ */ new Set([words.join("-"), ...words])].map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
312
312
  return `(?:^|-)(?:${keys.join("|")})$`;
313
313
  }
314
+ function stateMarkers(contract) {
315
+ if (isInapplicable(contract.states)) return [];
316
+ const markers = [];
317
+ for (const state of contract.states) {
318
+ if (restingState(state.state)) continue;
319
+ const attributes = [];
320
+ for (const [part, attrs] of Object.entries(state.attributes ?? {})) {
321
+ const declared = contract.parts[part];
322
+ if (declared === void 0 || typeof declared !== "string" && declared.portal) continue;
323
+ const selector = part === contract.root ? null : typeof declared === "string" ? declared : declared.selector;
324
+ for (const [name, value] of Object.entries(attrs)) attributes.push({ selector, name, value });
325
+ }
326
+ if (!state.forceClass && !attributes.some((a) => a.value !== null)) continue;
327
+ markers.push({ forceClass: state.forceClass ?? null, attributes });
328
+ }
329
+ return markers;
330
+ }
314
331
  function paintChecks(contract, map) {
315
332
  const checks = [];
316
333
  for (const [part, properties] of Object.entries(map)) {
@@ -354,7 +371,7 @@ function restingPaintSpec(contract) {
354
371
  }
355
372
  }
356
373
  if (entries.length === 0) return null;
357
- return { id: contract.id, root: rootSelector, entries };
374
+ return { id: contract.id, root: rootSelector, entries, away: stateMarkers(contract) };
358
375
  }
359
376
  var TEXT_AXES = ["fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing"];
360
377
  var BLOCK_DISPLAYS = ["block", "flow-root", "list-item", "flex", "grid", "table-cell", "table-caption"];
@@ -523,24 +540,33 @@ var PageHarness = class _PageHarness {
523
540
  let asserted = 0;
524
541
  const root = document.querySelector(container);
525
542
  if (!root) return { instances, asserted, failures, uncovered };
526
- const normalize = (node, css, raw) => {
543
+ const normalize = (node, css, raw, rendered = false) => {
527
544
  const probe = document.createElement("div");
528
- probe.style.display = "none";
545
+ if (rendered) {
546
+ probe.style.visibility = "hidden";
547
+ probe.style.position = getComputedStyle(node).position;
548
+ } else probe.style.display = "none";
529
549
  probe.style.borderStyle = "solid";
530
550
  probe.style.outlineStyle = "solid";
531
551
  probe.style.fontSize = getComputedStyle(node).fontSize;
532
552
  (node.parentElement ?? document.body).appendChild(probe);
533
553
  probe.style.setProperty(css.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`), raw);
534
- const computed = getComputedStyle(probe)[css];
554
+ const computed = (getComputedStyle(probe)[css] ?? "").trim();
535
555
  probe.remove();
536
- return (computed ?? "").trim();
556
+ return !rendered && computed.endsWith("%") ? normalize(node, css, raw, true) : computed;
537
557
  };
558
+ const atRest = (element, markers) => !markers.some((marker) => (marker.forceClass === null || element.classList.contains(marker.forceClass)) && marker.attributes.every(({ selector, name, value }) => {
559
+ const part = selector === null ? element : element.querySelector(selector);
560
+ if (!part) return false;
561
+ return value === null ? !part.hasAttribute(name) : part.getAttribute(name) === value;
562
+ }));
538
563
  for (const spec of list) {
539
564
  const patterns = spec.entries.map((entry) => entry.pattern === null ? null : new RegExp(entry.pattern));
540
565
  const variants = [...new Set(spec.entries.map((entry) => entry.variant).filter((v) => v !== null))];
541
566
  for (const element of root.querySelectorAll(spec.root)) {
542
567
  if (element.closest(chrome)) continue;
543
568
  instances++;
569
+ if (!atRest(element, spec.away)) continue;
544
570
  const classes = [...element.classList];
545
571
  const line = window.__liveTokensPageLine(element, source);
546
572
  let covered = false;