@motion-proto/live-tokens 0.90.0 → 0.91.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.91.0 — Overlay for ImageLightbox
4
+
5
+ ### Added
6
+
7
+ - **ImageLightbox takes an `overlay`.** The snippet pins content such as a
8
+ CornerBadge to the image's frame. It renders on the thumbnail and travels
9
+ with the image when the lightbox opens, growing in proportion to the frame,
10
+ then shrinks back on close. It sits outside the thumbnail's clip and the
11
+ open view's zoom, so it can reach past the frame's edges and stays put while
12
+ the reader zooms and pans.
13
+
14
+ ## 0.90.1 — Sketch mode spares the editor overlay
15
+
16
+ ### Fixed
17
+
18
+ - **Sketch mode leaves the editor overlay's layout alone.** The layer picks
19
+ out components by bare class name, and the overlay's pull tab shares
20
+ TabBar's `.tab`. With a sketchstyle on, a pill docked to an edge lost its
21
+ tab's positioning and background, which left nothing on screen to pull it
22
+ back with. Every rule that paints or moves an element now skips anything
23
+ inside `[data-live-tokens-chrome]`. A sketch scope nested inside chrome
24
+ still draws, so a preview stage placed there keeps previewing. The guard
25
+ sits in `:where()` and adds no specificity, so page overrides of the layer
26
+ keep their order.
27
+
3
28
  ## 0.90.0 — Thumbnail ratio for ImageLightbox
4
29
 
5
30
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.90.0",
3
+ "version": "0.91.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -370,6 +370,15 @@ const UNCLIPPED = PART_SPECS.filter((p) => !p.clips).map((p) => p.sel).join(', '
370
370
  const CLIPPED = PART_SPECS.filter((p) => p.clips).map((p) => p.sel).join(', ');
371
371
  const UNMASKED = PART_SPECS.filter((p) => p.unmasked).map((p) => p.sel).join(', ');
372
372
 
373
+ /** Keeps the layer off the editor's own UI on the host page. The part
374
+ selectors are bare class names, and the overlay's `.tab` matched TabBar's:
375
+ the docked pill lost its positioning and its pull tab drew invisible. A
376
+ scope nested inside chrome draws again, so a preview stage placed there
377
+ still previews. `:where` adds no weight, so overrides keep their order. */
378
+ const OUTSIDE_CHROME =
379
+ ':where(:not([data-live-tokens-chrome], [data-live-tokens-chrome] *),' +
380
+ ' [data-live-tokens-chrome] [data-sketch] *)';
381
+
373
382
  export function buildDefsMarkup(s: SketchStyleSettings): string {
374
383
  /**
375
384
  * `warp` is the shape stage: one wave of noise whose wavelength spans a whole
@@ -697,9 +706,9 @@ const WARP_FREQUENCY = 0.08;
697
706
 
698
707
  export function buildStylesheet(s: SketchStyleSettings): string {
699
708
  const on = '[data-sketch]';
700
- const parts = `:is(${PARTS})`;
709
+ const parts = `:is(${PARTS})${OUTSIDE_CHROME}`;
701
710
  const el = `${on} ${parts}`;
702
- const strokeEl = `${on} :is(${STROKE_PARTS})`;
711
+ const strokeEl = `${on} :is(${STROKE_PARTS})${OUTSIDE_CHROME}`;
703
712
 
704
713
  // `--radius-none` is a bare `0`, so a part the theme leaves square hands the
705
714
  // corner maths a number where it needs a length: `calc(0 + 0.95 * 16px)` is
@@ -780,7 +789,7 @@ export function buildStylesheet(s: SketchStyleSettings): string {
780
789
  const iconSel = `[class*="fa-"], svg:not([${DEFS_ATTR}])`;
781
790
  const iconsOn = s.iconTravel > 0 || s.iconMaskOn;
782
791
  const icons = iconsOn
783
- ? `${on} :is(${iconSel}){` +
792
+ ? `${on} :is(${iconSel})${OUTSIDE_CHROME}{` +
784
793
  (s.iconTravel > 0
785
794
  ? `filter:var(--sketch-icon-off, var(--sketch-icon-filter, url(#${ID}-icon-0)));`
786
795
  : '') +
@@ -876,14 +885,14 @@ export function buildStylesheet(s: SketchStyleSettings): string {
876
885
  `background:transparent !important;border-color:transparent !important;` +
877
886
  `box-shadow:none !important;` +
878
887
  `}` +
879
- `${on} :is(${UNCLIPPED}){overflow:visible !important;}` +
888
+ `${on} :is(${UNCLIPPED})${OUTSIDE_CHROME}{overflow:visible !important;}` +
880
889
  // The five that keep their clip take the drawn radii on the host, so what
881
890
  // the clip cuts turns the same way the ink does at the corners. It cannot
882
891
  // follow the displacement, which is a filter and has no geometry to clip to.
883
892
  `${el}:is(${CLIPPED}){${corners}}` +
884
893
  // Only parts that sit in flow. Forcing this onto an absolutely-positioned
885
894
  // part would drop it back to its flow position.
886
- `${on} :is(${FLOW_PARTS}){position:relative;}`;
895
+ `${on} :is(${FLOW_PARTS})${OUTSIDE_CHROME}{position:relative;}`;
887
896
 
888
897
  // Fill layer. Own seed, own offset, sits behind the content.
889
898
  //
@@ -988,7 +997,7 @@ export function buildStylesheet(s: SketchStyleSettings): string {
988
997
  // per-instance cycle as the fill offset, so no two components retrace alike.
989
998
  const retraceStep = s.retraceOffset.toFixed(2);
990
999
  const retrace = s.retracePass === 'copy'
991
- ? `[data-sketch][data-sketch-passes='double'] :is(${STROKE_PARTS})::after{` +
1000
+ ? `[data-sketch][data-sketch-passes='double'] :is(${STROKE_PARTS})${OUTSIDE_CHROME}::after{` +
992
1001
  `--sketch-retrace:drop-shadow(` +
993
1002
  `calc(var(--sketch-jx, 0.6) * ${retraceStep}px)` +
994
1003
  ` calc(var(--sketch-jy, -0.4) * ${retraceStep}px)` +
@@ -17,7 +17,7 @@
17
17
  </script>
18
18
 
19
19
  <script lang="ts">
20
- import { onMount, tick } from 'svelte';
20
+ import { onMount, tick, type Snippet } from 'svelte';
21
21
  import { portal } from '../internal/portal';
22
22
 
23
23
  interface GalleryImage {
@@ -91,6 +91,11 @@
91
91
  (from `width`/`height`, or the loaded image); until that's known the image
92
92
  fits the viewport, then snaps to the cap once measured. */
93
93
  capNatural?: boolean | number;
94
+ /** Content pinned to the image's frame, such as a CornerBadge. It renders
95
+ on the thumbnail and travels with the image into the open view, growing
96
+ in proportion to the frame. Zoom and pan leave it in place, and it may
97
+ extend past the frame's edges. */
98
+ overlay?: Snippet;
94
99
  }
95
100
 
96
101
  let {
@@ -110,6 +115,7 @@
110
115
  extended = false,
111
116
  maxZoom = undefined,
112
117
  capNatural = false,
118
+ overlay = undefined,
113
119
  }: Props = $props();
114
120
 
115
121
  const items = $derived(
@@ -158,6 +164,10 @@
158
164
  let counterEl: HTMLDivElement | undefined = $state();
159
165
  let incomingEl: HTMLImageElement | undefined = $state();
160
166
  let outgoingEl: HTMLImageElement | undefined = $state();
167
+ let attachedEl: HTMLDivElement | undefined = $state();
168
+ // The thumbnail width the overlay is laid out at; the open view scales it up
169
+ // from there so it grows with the frame instead of reflowing.
170
+ let attachedBase = 1;
161
171
 
162
172
  // `mounted` keeps the portaled modal in the DOM through the close animation;
163
173
  // `open` is the visual state the chrome reacts to.
@@ -267,11 +277,35 @@
267
277
  prevOverlayVisibility = null;
268
278
  }
269
279
 
280
+ // Width and easing match the stage's own keyframes, so a scale that is linear
281
+ // in width keeps the overlay on the frame for the whole flight.
282
+ function attachedFrame(rect: { width: number; height: number }) {
283
+ const s = rect.width / attachedBase;
284
+ return {
285
+ width: `${attachedBase}px`,
286
+ height: `${rect.height / s}px`,
287
+ transform: `scale(${s})`,
288
+ };
289
+ }
290
+
291
+ function animateAttached(from: { width: number; height: number } | null, to: { width: number; height: number }) {
292
+ if (!attachedEl) return;
293
+ if (!from) {
294
+ Object.assign(attachedEl.style, attachedFrame(to));
295
+ return;
296
+ }
297
+ attachedEl.animate([attachedFrame(from), attachedFrame(to)], {
298
+ duration: dur(),
299
+ easing: TRANSITION_EASE,
300
+ fill: 'forwards',
301
+ });
302
+ }
303
+
270
304
  function cancelAnimations() {
271
305
  // Commit each animation's current value to inline styles before cancelling,
272
306
  // so a mid-flight cancel doesn't visually snap the element back to its
273
307
  // pre-animation state.
274
- for (const el of [stageEl, overlayEl, toolbarEl, closeBtnEl, prevBtnEl, nextBtnEl, counterEl]) {
308
+ for (const el of [stageEl, attachedEl, overlayEl, toolbarEl, closeBtnEl, prevBtnEl, nextBtnEl, counterEl]) {
275
309
  if (!el) continue;
276
310
  for (const a of el.getAnimations()) {
277
311
  try { a.commitStyles(); } catch {}
@@ -306,6 +340,8 @@
306
340
  ],
307
341
  { duration: dur(), easing: TRANSITION_EASE, fill: 'forwards' },
308
342
  );
343
+ attachedBase = start.width || 1;
344
+ animateAttached(start, target);
309
345
 
310
346
  overlayEl.animate([{ opacity: 0 }, { opacity: 1 }], {
311
347
  duration: dur(),
@@ -360,6 +396,8 @@
360
396
  ],
361
397
  { duration: dur(), easing: TRANSITION_EASE, fill: 'forwards' },
362
398
  );
399
+ attachedBase = target.width || 1;
400
+ animateAttached(from, target);
363
401
 
364
402
  overlayEl.animate([{ opacity: 1 }, { opacity: 0 }], {
365
403
  duration: dur(),
@@ -395,10 +433,13 @@
395
433
  const target = viewportTarget();
396
434
  if (animate) {
397
435
  const from = stageEl.getBoundingClientRect();
398
- for (const a of stageEl.getAnimations()) {
399
- try { a.commitStyles(); } catch {}
400
- a.cancel();
436
+ for (const el of [stageEl, attachedEl]) {
437
+ for (const a of el?.getAnimations() ?? []) {
438
+ try { a.commitStyles(); } catch {}
439
+ a.cancel();
440
+ }
401
441
  }
442
+ animateAttached(from, target);
402
443
  stageEl.animate(
403
444
  [
404
445
  { top: `${from.top}px`, left: `${from.left}px`, width: `${from.width}px`, height: `${from.height}px` },
@@ -413,6 +454,7 @@
413
454
  width: `${target.width}px`,
414
455
  height: `${target.height}px`,
415
456
  });
457
+ animateAttached(null, target);
416
458
  }
417
459
  if (scale > maxScale) scale = maxScale; // a larger viewport lowers the natural-size cap
418
460
  offset = clampOffset(offset.x, offset.y, scale);
@@ -647,6 +689,13 @@
647
689
  >
648
690
  <img src={cover?.src} alt={cover?.alt} draggable="false" onload={onCoverLoad} />
649
691
  </button>
692
+ {#if overlay}
693
+ <!-- Beside the thumb, not in it: the thumb clips its corners. Hidden while
694
+ open, so the copy in the stage reads as the same one in flight. -->
695
+ <div class="image-lightbox-attached" class:away={mounted}>
696
+ {@render overlay()}
697
+ </div>
698
+ {/if}
650
699
  </div>
651
700
 
652
701
  {#if mounted}
@@ -694,6 +743,11 @@
694
743
  <img bind:this={incomingEl} class="image-lightbox-layer" src={current?.src} alt={current?.alt} draggable="false" onload={onImgLoad} />
695
744
  </div>
696
745
  </div>
746
+ {#if overlay}
747
+ <div bind:this={attachedEl} class="image-lightbox-attached image-lightbox-attached-stage">
748
+ {@render overlay()}
749
+ </div>
750
+ {/if}
697
751
  </div>
698
752
 
699
753
  <button
@@ -829,6 +883,29 @@
829
883
  transform: scale(1.02);
830
884
  }
831
885
 
886
+ .image-lightbox-attached {
887
+ position: absolute;
888
+ inset: 0;
889
+ pointer-events: none;
890
+ transition: transform 250ms ease;
891
+ }
892
+
893
+ .image-lightbox-thumb:hover + .image-lightbox-attached {
894
+ transform: scale(1.02);
895
+ }
896
+
897
+ .image-lightbox-attached.away {
898
+ visibility: hidden;
899
+ }
900
+
901
+ .image-lightbox-attached-stage {
902
+ inset: auto;
903
+ top: 0;
904
+ left: 0;
905
+ transform-origin: 0 0;
906
+ transition: none;
907
+ }
908
+
832
909
  .image-lightbox-thumb:focus-visible {
833
910
  outline: 2px solid var(--border-brand-medium);
834
911
  outline-offset: 2px;