@motion-proto/live-tokens 0.30.0 → 0.31.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.
@@ -106,6 +106,7 @@ The authoritative recognised list lives in `bin/check-component.mjs` (`KNOWN_SUF
106
106
 
107
107
  ### Rules that bite
108
108
 
109
+ - **Fixed overlays must portal to `<body>`.** Any `position: fixed` layer (modal, lightbox, full-screen backdrop) is trapped — clipped or painted under other chrome — by a transformed / `isolation` / `contain` / `will-change` ancestor, which real consumer pages (and the editor's own preview pane) commonly have. Render the fixed layer with `use:portal` from `src/system/internal/portal.ts` so it escapes to `<body>`; pass `use:portal={enabled}` to keep an in-flow preview variant in place (see `Dialog`). `check:overlay-portal` fails the build if a component has `position: fixed` without it. Anchored popovers (`position: absolute` relative to a trigger, like `Tooltip`) are exempt. Two consequences of moving to `<body>`: DOM events from the layer no longer bubble to a consumer ancestor (use component callbacks, as `Dialog` does), and a subtree-scoped CSS-variable theme no longer reaches it (this library themes via `:root`, so fine here). If the layer is a modal, also give it `role="dialog"` + `aria-modal`, move focus in on open and restore it on close, and trap `Tab` (see `ImageLightbox`).
109
110
  - **State before property.** `--mywidget-button-hover-surface` ✓ — `--mywidget-button-surface-hover` ✗ (breaks sibling matching).
110
111
  - **Defaults reference theme tokens, never raw values.** `var(--surface-primary)` ✓ — `#6a4ce8` ✗.
111
112
  - **No abbreviations.** `bg` → `surface`; `fg` → `text`; component ids are never abbreviated.
@@ -173,6 +173,15 @@ var tokensCssMigration_2026_06_03_transformScaleAdditions = {
173
173
  }
174
174
  };
175
175
 
176
+ // vite-plugin/tokensCssMigrations/migrations/2026-06-04-remove-dead-size-icon-scale.ts
177
+ var tokensCssMigration_2026_06_04_removeDeadSizeIconScale = {
178
+ id: "2026-06-04-remove-dead-size-icon-scale",
179
+ description: "Remove the unused --size-icon-* scale (live scale is --icon-size-*)",
180
+ apply(css) {
181
+ return removeTokensMatching(css, (name) => name.startsWith("--size-icon-"));
182
+ }
183
+ };
184
+
176
185
  // vite-plugin/files/dataPaths.ts
177
186
  import fs from "fs";
178
187
  import path from "path";
@@ -223,7 +232,8 @@ function resolveDataDirs(opts = {}) {
223
232
  var TOKENS_CSS_MIGRATIONS = [
224
233
  tokensCssMigration_2026_05_29_typographyScaleAdditions,
225
234
  tokensCssMigration_2026_05_29_sectiondividerLegacyAxisCleanup,
226
- tokensCssMigration_2026_06_03_transformScaleAdditions
235
+ tokensCssMigration_2026_06_03_transformScaleAdditions,
236
+ tokensCssMigration_2026_06_04_removeDeadSizeIconScale
227
237
  ];
228
238
  function runTokensCssMigrations(css) {
229
239
  let out = css;
@@ -797,11 +797,21 @@ var tokensCssMigration_2026_06_03_transformScaleAdditions = {
797
797
  }
798
798
  };
799
799
 
800
+ // vite-plugin/tokensCssMigrations/migrations/2026-06-04-remove-dead-size-icon-scale.ts
801
+ var tokensCssMigration_2026_06_04_removeDeadSizeIconScale = {
802
+ id: "2026-06-04-remove-dead-size-icon-scale",
803
+ description: "Remove the unused --size-icon-* scale (live scale is --icon-size-*)",
804
+ apply(css) {
805
+ return removeTokensMatching(css, (name) => name.startsWith("--size-icon-"));
806
+ }
807
+ };
808
+
800
809
  // vite-plugin/tokensCssMigrations/index.ts
801
810
  var TOKENS_CSS_MIGRATIONS = [
802
811
  tokensCssMigration_2026_05_29_typographyScaleAdditions,
803
812
  tokensCssMigration_2026_05_29_sectiondividerLegacyAxisCleanup,
804
- tokensCssMigration_2026_06_03_transformScaleAdditions
813
+ tokensCssMigration_2026_06_03_transformScaleAdditions,
814
+ tokensCssMigration_2026_06_04_removeDeadSizeIconScale
805
815
  ];
806
816
  function runTokensCssMigrations(css) {
807
817
  let out = css;
@@ -4,7 +4,7 @@ import {
4
4
  resolveDataDirs,
5
5
  runTokensCssMigrations,
6
6
  validateTokensCss
7
- } from "./chunk-2TW77U3O.js";
7
+ } from "./chunk-Y3K6NR6V.js";
8
8
 
9
9
  // vite-plugin/themeFileApi.ts
10
10
  import fs2 from "fs";
@@ -218,6 +218,15 @@ var tokensCssMigration_2026_06_03_transformScaleAdditions = {
218
218
  }
219
219
  };
220
220
 
221
+ // vite-plugin/tokensCssMigrations/migrations/2026-06-04-remove-dead-size-icon-scale.ts
222
+ var tokensCssMigration_2026_06_04_removeDeadSizeIconScale = {
223
+ id: "2026-06-04-remove-dead-size-icon-scale",
224
+ description: "Remove the unused --size-icon-* scale (live scale is --icon-size-*)",
225
+ apply(css) {
226
+ return removeTokensMatching(css, (name) => name.startsWith("--size-icon-"));
227
+ }
228
+ };
229
+
221
230
  // vite-plugin/files/dataPaths.ts
222
231
  var import_fs = __toESM(require("fs"), 1);
223
232
  var import_path = __toESM(require("path"), 1);
@@ -255,7 +264,8 @@ function readLiveTokensConfig() {
255
264
  var TOKENS_CSS_MIGRATIONS = [
256
265
  tokensCssMigration_2026_05_29_typographyScaleAdditions,
257
266
  tokensCssMigration_2026_05_29_sectiondividerLegacyAxisCleanup,
258
- tokensCssMigration_2026_06_03_transformScaleAdditions
267
+ tokensCssMigration_2026_06_03_transformScaleAdditions,
268
+ tokensCssMigration_2026_06_04_removeDeadSizeIconScale
259
269
  ];
260
270
  function runTokensCssMigrations(css) {
261
271
  let out = css;
@@ -9,7 +9,7 @@ import {
9
9
  renameToken,
10
10
  runTokensCssMigrations,
11
11
  validateTokensCss
12
- } from "../chunk-2TW77U3O.js";
12
+ } from "../chunk-Y3K6NR6V.js";
13
13
  export {
14
14
  TOKENS_CSS_MIGRATIONS,
15
15
  collectDefinedTokens,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motion-proto/live-tokens",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "type": "module",
5
5
  "description": "Design token editor with live CSS variable editing. Svelte 5 + Vite 8.",
6
6
  "keywords": [
@@ -95,6 +95,7 @@
95
95
  "deploy:local": "bash scripts/deploy-local.sh",
96
96
  "check:no-style-imports": "node scripts/check-no-style-imports.mjs",
97
97
  "check:slot-prose": "node scripts/check-slot-prose.mjs",
98
+ "check:overlay-portal": "node scripts/check-overlay-portal.mjs",
98
99
  "check:editor-font-isolation": "node scripts/check-editor-font-isolation.mjs",
99
100
  "check:component-defaults": "node scripts/sync-component-defaults.mjs --check",
100
101
  "check:production-is-default": "node scripts/check-production-is-default.mjs",
@@ -104,7 +105,7 @@
104
105
  "collapse:manifest": "node scripts/collapse-manifest-to-default.mjs",
105
106
  "check:smoke-install": "bash scripts/smoke-install.sh",
106
107
  "check:smoke-create": "bash scripts/smoke-create.sh",
107
- "prepublishOnly": "npm run check:no-style-imports && npm run check:slot-prose && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:smoke-install && npm run check:smoke-create"
108
+ "prepublishOnly": "npm run check:no-style-imports && npm run check:slot-prose && npm run check:overlay-portal && npm run check:editor-font-isolation && npm run check:component-defaults && npm run check:production-is-default && npm run check:docs-content && npm run build:lib && npm run check:smoke-install && npm run check:smoke-create"
108
109
  },
109
110
  "peerDependencies": {
110
111
  "@sveltejs/vite-plugin-svelte": "^7.0",
@@ -33,18 +33,34 @@
33
33
  import ImageLightbox from '../../system/components/ImageLightbox.svelte';
34
34
  import VariantGroup from './scaffolding/VariantGroup.svelte';
35
35
  import ComponentEditorBase from './scaffolding/ComponentEditorBase.svelte';
36
- import demoImageUrl from '../../system/assets/offering.webp';
36
+ import offeringUrl from '../../system/assets/offering.webp';
37
+ import newspaperUrl from '../../system/assets/newspaper.webp';
38
+
39
+ const demoImages = [
40
+ // Offering carries explicit dimensions (the no-reflow path); Newspaper omits
41
+ // them to exercise self-measure from the loaded image.
42
+ { src: offeringUrl, alt: 'Offering', width: 1455, height: 970 },
43
+ { src: newspaperUrl, alt: 'Newspaper' },
44
+ ];
45
+
46
+ let multiple = $state(true);
37
47
  </script>
38
48
 
39
49
  <ComponentEditorBase
40
50
  {component}
41
51
  title="Image Lightbox"
42
- description="Click an inline image to expand it into a centered modal with a backdrop. Extended mode adds zoom controls and drag panning."
52
+ description="Click an inline image to expand it into a centered modal with a backdrop. Pass multiple images for a gallery (chevrons + counter). Extended mode adds zoom controls and drag panning."
43
53
  tokens={allTokens}
44
54
  >
45
55
  <VariantGroup name="imagelightbox" title="Image Lightbox" {states} {component}>
56
+ {#snippet stateActions()}
57
+ <label class="preview-toggle">
58
+ <input type="checkbox" checked={multiple} onchange={(e) => (multiple = e.currentTarget.checked)} />
59
+ <span>Multiple images</span>
60
+ </label>
61
+ {/snippet}
46
62
  <div class="preview-frame">
47
- <ImageLightbox src={demoImageUrl} alt="Demo image" width={1024} height={640} extended />
63
+ <ImageLightbox images={multiple ? demoImages : [demoImages[0]]} extended />
48
64
  </div>
49
65
  </VariantGroup>
50
66
  </ComponentEditorBase>
@@ -55,4 +71,12 @@
55
71
  max-width: 28rem;
56
72
  margin: 0 auto;
57
73
  }
74
+ .preview-toggle {
75
+ display: inline-flex;
76
+ align-items: center;
77
+ gap: var(--ui-space-4);
78
+ font-size: var(--ui-font-size-sm);
79
+ color: var(--ui-text-secondary);
80
+ cursor: pointer;
81
+ }
58
82
  </style>
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { createEventDispatcher, tick } from 'svelte';
3
3
  import type { Snippet } from 'svelte';
4
+ import { portal } from '../internal/portal';
4
5
  import Button from './Button.svelte';
5
6
  import type { ButtonVariant, DialogButtonSpec } from './types';
6
7
 
@@ -105,7 +106,9 @@
105
106
  </script>
106
107
 
107
108
  {#if show}
108
- <div class="dialog-backdrop" class:inline>
109
+ <!-- The fixed backdrop portals to <body> to escape transformed/isolated
110
+ ancestors; the inline preview variant stays in flow. -->
111
+ <div class="dialog-backdrop" class:inline use:portal={!inline}>
109
112
  <div class="dialog" style="width: {width}; max-width: {width};">
110
113
  <div class="dialog-content">
111
114
  {#if title}
@@ -1,47 +1,118 @@
1
1
  <script lang="ts">
2
2
  import { onMount, tick } from 'svelte';
3
+ import { portal } from '../internal/portal';
3
4
 
4
- interface Props {
5
+ interface GalleryImage {
5
6
  src: string;
6
7
  alt: string;
8
+ width?: number;
9
+ height?: number;
10
+ }
11
+
12
+ interface Props {
13
+ src?: string;
14
+ alt?: string;
7
15
  width?: number | undefined;
8
16
  height?: number | undefined;
17
+ /** Pass two or more to turn the lightbox into a gallery: left/right chevrons
18
+ and an `i / n` counter appear once open, plus arrow-key navigation. A
19
+ single-entry array behaves exactly like a lone `src`. */
20
+ images?: GalleryImage[];
9
21
  maxWidth?: number | string | undefined;
22
+ /** Closed-thumbnail object-fit. `cover` crops the thumbnail to fill its box;
23
+ the expanded modal always uses `contain` so the whole image stays visible.
24
+ `cover` only crops when the thumbnail has its own box (an aspect from
25
+ `width`/`height`, or a CSS-constrained container). */
26
+ fit?: 'contain' | 'cover';
10
27
  /** When true, shows a bottom toolbar (zoom in/out + percent) and a top-right close button, and enables wheel/drag zoom inside the open modal. When false, click anywhere closes. */
11
28
  extended?: boolean;
12
29
  }
13
30
 
14
31
  let {
15
- src,
16
- alt,
32
+ src = undefined,
33
+ alt = undefined,
17
34
  width = undefined,
18
35
  height = undefined,
36
+ images = undefined,
19
37
  maxWidth = undefined,
38
+ fit = 'contain',
20
39
  extended = false,
21
40
  }: Props = $props();
22
41
 
42
+ const items = $derived(
43
+ images && images.length
44
+ ? images
45
+ : src != null
46
+ ? [{ src, alt: alt ?? '', width, height }]
47
+ : [],
48
+ );
49
+
50
+ let index = $state(0);
51
+ const current = $derived(items[index]);
52
+ const cover = $derived(items[0]);
53
+ const isGallery = $derived(items.length > 1);
54
+
55
+ const dialogLabel = $derived(
56
+ isGallery
57
+ ? `Image ${index + 1} of ${items.length}${current?.alt ? `: ${current.alt}` : ''}`
58
+ : current?.alt || 'Image',
59
+ );
60
+
23
61
  const MIN_SCALE = 1;
24
62
  const MAX_SCALE = 5;
25
63
  const ZOOM_STEP = 1.5;
26
64
  const TRANSITION_MS = 350;
27
65
  const TRANSITION_EASE = 'cubic-bezier(0.65, 0, 0.35, 1)';
28
66
 
29
- let wrapperEl: HTMLDivElement;
30
- let tileEl: HTMLDivElement;
31
- let transformEl: HTMLDivElement;
32
- let overlayEl: HTMLDivElement;
67
+ // Gallery navigation: outgoing slides out + shrinks + fades; incoming slides
68
+ // in from the opposite side, staggered. Easings mirror --ease-in/out-cubic.
69
+ const NAV_MS = 250;
70
+ const NAV_STAGGER_MS = 125;
71
+ const NAV_SHIFT_PX = 32;
72
+ const NAV_SCALE = 0.95;
73
+ const EASE_IN = 'cubic-bezier(0.32, 0, 0.67, 0)';
74
+ const EASE_OUT = 'cubic-bezier(0.33, 1, 0.68, 1)';
75
+
76
+ let thumbEl: HTMLButtonElement | undefined = $state();
77
+ let modalEl: HTMLDivElement | undefined = $state();
78
+ let stageEl: HTMLDivElement | undefined = $state();
79
+ let transformEl: HTMLDivElement | undefined = $state();
80
+ let overlayEl: HTMLDivElement | undefined = $state();
33
81
  let toolbarEl: HTMLDivElement | undefined = $state();
34
82
  let closeBtnEl: HTMLButtonElement | undefined = $state();
35
-
83
+ let prevBtnEl: HTMLButtonElement | undefined = $state();
84
+ let nextBtnEl: HTMLButtonElement | undefined = $state();
85
+ let counterEl: HTMLDivElement | undefined = $state();
86
+ let incomingEl: HTMLImageElement | undefined = $state();
87
+ let outgoingEl: HTMLImageElement | undefined = $state();
88
+
89
+ // `mounted` keeps the portaled modal in the DOM through the close animation;
90
+ // `open` is the visual state the chrome reacts to.
91
+ let mounted = $state(false);
36
92
  let open = $state(false);
37
93
  let scale = $state(1);
38
94
  let offset = { x: 0, y: 0 };
39
95
 
96
+ // The image leaving during a gallery transition; rendered as a second layer
97
+ // until its slide-out finishes, then dropped.
98
+ let outgoing = $state<GalleryImage | null>(null);
99
+ let navigating = false;
100
+
101
+ // Aspect ratio per image, keyed by src: explicit width/height when given, else
102
+ // measured from the loaded <img>. The thumbnail reads the cover's entry and the
103
+ // modal reads the current image's, so they are never confused for each other.
104
+ let measured = $state<Record<string, number>>({});
105
+ let reducedMotion = $state(false);
106
+ const dur = (ms = TRANSITION_MS) => (reducedMotion ? 0 : ms);
107
+
40
108
  // Pointer drag for pan (extended + zoomed only).
41
109
  let dragState: { startX: number; startY: number; baseX: number; baseY: number; pointerId: number } | null = null;
42
110
  let didDrag = false;
43
111
 
44
- const aspect = $derived(width && height ? width / height : undefined);
112
+ const aspectOf = (it?: GalleryImage) =>
113
+ it ? (it.width && it.height ? it.width / it.height : measured[it.src]) : undefined;
114
+ const coverAspect = $derived(aspectOf(cover)); // inline thumbnail box
115
+ const aspect = $derived(aspectOf(current)); // open modal box
45
116
 
46
117
  function viewportTarget() {
47
118
  const vw = window.innerWidth;
@@ -62,8 +133,8 @@
62
133
  }
63
134
 
64
135
  function clampOffset(x: number, y: number, s: number) {
65
- if (!tileEl) return { x: 0, y: 0 };
66
- const r = tileEl.getBoundingClientRect();
136
+ if (!stageEl) return { x: 0, y: 0 };
137
+ const r = stageEl.getBoundingClientRect();
67
138
  const maxX = Math.max(0, (r.width * s - r.width) / 2);
68
139
  const maxY = Math.max(0, (r.height * s - r.height) / 2);
69
140
  return {
@@ -101,10 +172,8 @@
101
172
  function cancelAnimations() {
102
173
  // Commit each animation's current value to inline styles before cancelling,
103
174
  // so a mid-flight cancel doesn't visually snap the element back to its
104
- // pre-animation state. After cancel, the WAAPI effect is gone and the
105
- // committed inline values are what we'll either animate from next or
106
- // clear via cssText = ''.
107
- for (const el of [tileEl, overlayEl, toolbarEl, closeBtnEl]) {
175
+ // pre-animation state.
176
+ for (const el of [stageEl, overlayEl, toolbarEl, closeBtnEl, prevBtnEl, nextBtnEl, counterEl]) {
108
177
  if (!el) continue;
109
178
  for (const a of el.getAnimations()) {
110
179
  try { a.commitStyles(); } catch {}
@@ -114,100 +183,218 @@
114
183
  }
115
184
 
116
185
  async function openLightbox() {
117
- if (open || !tileEl) return;
118
- cancelAnimations();
119
- const start = tileEl.getBoundingClientRect();
120
- const target = viewportTarget();
186
+ if (open || !thumbEl) return;
187
+ const start = thumbEl.getBoundingClientRect();
121
188
 
189
+ mounted = true;
122
190
  open = true;
123
191
  document.body.style.overflow = 'hidden';
124
192
  hideEditorOverlay();
125
193
  await tick();
194
+ if (!stageEl || !overlayEl) return;
126
195
 
127
- Object.assign(tileEl.style, {
128
- position: 'fixed',
196
+ const target = viewportTarget();
197
+ Object.assign(stageEl.style, {
129
198
  top: `${start.top}px`,
130
199
  left: `${start.left}px`,
131
200
  width: `${start.width}px`,
132
201
  height: `${start.height}px`,
133
- zIndex: 'var(--z-modal)',
134
202
  });
135
203
 
136
- tileEl.animate(
204
+ stageEl.animate(
137
205
  [
138
206
  { top: `${start.top}px`, left: `${start.left}px`, width: `${start.width}px`, height: `${start.height}px` },
139
207
  { top: `${target.top}px`, left: `${target.left}px`, width: `${target.width}px`, height: `${target.height}px` },
140
208
  ],
141
- { duration: TRANSITION_MS, easing: TRANSITION_EASE, fill: 'forwards' },
209
+ { duration: dur(), easing: TRANSITION_EASE, fill: 'forwards' },
142
210
  );
143
211
 
144
212
  overlayEl.animate([{ opacity: 0 }, { opacity: 1 }], {
145
- duration: TRANSITION_MS,
213
+ duration: dur(),
146
214
  easing: TRANSITION_EASE,
147
215
  fill: 'forwards',
148
216
  });
149
217
 
218
+ fadeChrome(true);
150
219
  if (extended) {
151
220
  toolbarEl?.animate([{ opacity: 0, transform: 'translate(-50%, 16px)' }, { opacity: 1, transform: 'translate(-50%, 0)' }], {
152
- duration: TRANSITION_MS,
221
+ duration: dur(),
153
222
  easing: TRANSITION_EASE,
154
223
  fill: 'forwards',
155
224
  delay: 80,
156
225
  });
157
- closeBtnEl?.animate([{ opacity: 0 }, { opacity: 1 }], {
158
- duration: TRANSITION_MS,
226
+ }
227
+
228
+ // Move focus into the modal so keyboard users land inside the dialog; the
229
+ // stage (tabindex -1) is the fallback when there is no chrome to focus.
230
+ (closeBtnEl ?? stageEl)?.focus();
231
+ }
232
+
233
+ // Close button, gallery chevrons, and counter all share a plain opacity fade;
234
+ // the zoom toolbar keeps its bespoke slide so it's handled separately.
235
+ function chromeFadeEls(): HTMLElement[] {
236
+ const els: (HTMLElement | undefined)[] = [];
237
+ if (extended || isGallery) els.push(closeBtnEl);
238
+ if (isGallery) els.push(prevBtnEl, nextBtnEl, counterEl);
239
+ return els.filter((el): el is HTMLElement => !!el);
240
+ }
241
+
242
+ function fadeChrome(showing: boolean) {
243
+ for (const el of chromeFadeEls()) {
244
+ el.animate([{ opacity: showing ? 0 : 1 }, { opacity: showing ? 1 : 0 }], {
245
+ duration: dur(),
159
246
  easing: TRANSITION_EASE,
160
247
  fill: 'forwards',
161
- delay: 80,
248
+ delay: showing ? 80 : 0,
162
249
  });
163
250
  }
164
251
  }
165
252
 
166
253
  function closeLightbox() {
167
- if (!open || !tileEl || !wrapperEl) return;
254
+ if (!open || !stageEl || !thumbEl || !overlayEl) return;
168
255
  cancelAnimations();
169
- const target = wrapperEl.getBoundingClientRect();
170
- const current = tileEl.getBoundingClientRect();
256
+ const target = thumbEl.getBoundingClientRect();
257
+ const from = stageEl.getBoundingClientRect();
171
258
 
172
- const anim = tileEl.animate(
259
+ const anim = stageEl.animate(
173
260
  [
174
- { top: `${current.top}px`, left: `${current.left}px`, width: `${current.width}px`, height: `${current.height}px` },
261
+ { top: `${from.top}px`, left: `${from.left}px`, width: `${from.width}px`, height: `${from.height}px` },
175
262
  { top: `${target.top}px`, left: `${target.left}px`, width: `${target.width}px`, height: `${target.height}px` },
176
263
  ],
177
- { duration: TRANSITION_MS, easing: TRANSITION_EASE, fill: 'forwards' },
264
+ { duration: dur(), easing: TRANSITION_EASE, fill: 'forwards' },
178
265
  );
179
266
 
180
267
  overlayEl.animate([{ opacity: 1 }, { opacity: 0 }], {
181
- duration: TRANSITION_MS,
268
+ duration: dur(),
182
269
  easing: TRANSITION_EASE,
183
270
  fill: 'forwards',
184
271
  });
185
272
 
273
+ fadeChrome(false);
186
274
  if (extended) {
187
275
  toolbarEl?.animate([{ opacity: 1 }, { opacity: 0 }], {
188
- duration: TRANSITION_MS,
189
- easing: TRANSITION_EASE,
190
- fill: 'forwards',
191
- });
192
- closeBtnEl?.animate([{ opacity: 1 }, { opacity: 0 }], {
193
- duration: TRANSITION_MS,
276
+ duration: dur(),
194
277
  easing: TRANSITION_EASE,
195
278
  fill: 'forwards',
196
279
  });
197
280
  }
198
281
 
199
282
  anim.onfinish = () => {
200
- cancelAnimations();
201
- tileEl.style.cssText = '';
202
- transformEl.style.transform = '';
203
283
  scale = 1;
204
284
  offset = { x: 0, y: 0 };
285
+ index = 0;
205
286
  open = false;
287
+ mounted = false;
206
288
  document.body.style.overflow = '';
207
289
  restoreEditorOverlay();
290
+ thumbEl?.focus();
208
291
  };
209
292
  }
210
293
 
294
+ // Re-fit the open modal to the viewport — used on resize (snap) and when the
295
+ // gallery image or its measured aspect changes (animated morph).
296
+ function fitToViewport(animate: boolean) {
297
+ if (!open || !stageEl) return;
298
+ const target = viewportTarget();
299
+ if (animate) {
300
+ const from = stageEl.getBoundingClientRect();
301
+ for (const a of stageEl.getAnimations()) {
302
+ try { a.commitStyles(); } catch {}
303
+ a.cancel();
304
+ }
305
+ stageEl.animate(
306
+ [
307
+ { top: `${from.top}px`, left: `${from.left}px`, width: `${from.width}px`, height: `${from.height}px` },
308
+ { top: `${target.top}px`, left: `${target.left}px`, width: `${target.width}px`, height: `${target.height}px` },
309
+ ],
310
+ { duration: dur(), easing: TRANSITION_EASE, fill: 'forwards' },
311
+ );
312
+ } else {
313
+ Object.assign(stageEl.style, {
314
+ top: `${target.top}px`,
315
+ left: `${target.left}px`,
316
+ width: `${target.width}px`,
317
+ height: `${target.height}px`,
318
+ });
319
+ }
320
+ offset = clampOffset(offset.x, offset.y, scale);
321
+ applyTransform(scale, offset);
322
+ }
323
+
324
+ function settleNav() {
325
+ incomingEl?.getAnimations().forEach((a) => a.cancel());
326
+ outgoingEl?.getAnimations().forEach((a) => a.cancel());
327
+ outgoing = null;
328
+ navigating = false;
329
+ }
330
+
331
+ // dir: +1 when paging via the right chevron (content exits left, the next
332
+ // image enters from the right), -1 for the left chevron.
333
+ async function goTo(target: number, dir: number) {
334
+ if (!isGallery) return;
335
+ const n = ((target % items.length) + items.length) % items.length;
336
+ if (n === index) return;
337
+ if (navigating) settleNav();
338
+
339
+ scale = 1;
340
+ offset = { x: 0, y: 0 };
341
+ applyTransform(scale, offset);
342
+
343
+ outgoing = current;
344
+ index = n;
345
+ navigating = true;
346
+ await tick();
347
+
348
+ const exit = -NAV_SHIFT_PX * dir;
349
+ outgoingEl?.animate(
350
+ [
351
+ { transform: 'translateX(0) scale(1)', opacity: 1 },
352
+ { transform: `translateX(${exit}px) scale(${NAV_SCALE})`, opacity: 0 },
353
+ ],
354
+ { duration: dur(NAV_MS), easing: EASE_IN, fill: 'forwards' },
355
+ );
356
+
357
+ const enter = incomingEl?.animate(
358
+ [
359
+ { transform: `translateX(${-exit}px) scale(${NAV_SCALE})`, opacity: 0 },
360
+ { transform: 'translateX(0) scale(1)', opacity: 1 },
361
+ ],
362
+ // `fill: both` holds the opacity-0 start state through the stagger delay,
363
+ // otherwise the incoming image flashes at full opacity before sliding in.
364
+ { duration: dur(NAV_MS), easing: EASE_OUT, fill: 'both', delay: dur(NAV_STAGGER_MS) },
365
+ );
366
+
367
+ fitToViewport(true); // resize the stage if the incoming aspect differs; a no-op when it matches
368
+
369
+ if (enter) enter.onfinish = settleNav;
370
+ else settleNav();
371
+ }
372
+
373
+ const next = () => goTo(index + 1, 1);
374
+ const prev = () => goTo(index - 1, -1);
375
+
376
+ function record(src: string, e: Event) {
377
+ const img = e.currentTarget as HTMLImageElement;
378
+ if (img.naturalWidth && img.naturalHeight) measured[src] = img.naturalWidth / img.naturalHeight;
379
+ }
380
+
381
+ function onCoverLoad(e: Event) {
382
+ if (cover) record(cover.src, e);
383
+ }
384
+
385
+ function onImgLoad(e: Event) {
386
+ if (current) record(current.src, e);
387
+ if (open) fitToViewport(true);
388
+ }
389
+
390
+ $effect(() => {
391
+ if (!isGallery || typeof Image === 'undefined') return;
392
+ for (const d of [index + 1, index - 1]) {
393
+ const it = items[((d % items.length) + items.length) % items.length];
394
+ if (it) new Image().src = it.src;
395
+ }
396
+ });
397
+
211
398
  function zoomTo(nextScale: number, anchor?: { x: number; y: number }) {
212
399
  const s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, nextScale));
213
400
  if (s <= MIN_SCALE) {
@@ -216,18 +403,18 @@
216
403
  applyTransform(scale, offset);
217
404
  return;
218
405
  }
219
- let next: { x: number; y: number };
220
- if (anchor && tileEl) {
221
- const r = tileEl.getBoundingClientRect();
406
+ let nextOffset: { x: number; y: number };
407
+ if (anchor && stageEl) {
408
+ const r = stageEl.getBoundingClientRect();
222
409
  const dx = anchor.x - (r.left + r.width / 2);
223
410
  const dy = anchor.y - (r.top + r.height / 2);
224
411
  const ratio = scale > 0 ? s / scale : 1;
225
- next = clampOffset(dx * (1 - ratio) + offset.x * ratio, dy * (1 - ratio) + offset.y * ratio, s);
412
+ nextOffset = clampOffset(dx * (1 - ratio) + offset.x * ratio, dy * (1 - ratio) + offset.y * ratio, s);
226
413
  } else {
227
- next = clampOffset(offset.x, offset.y, s);
414
+ nextOffset = clampOffset(offset.x, offset.y, s);
228
415
  }
229
416
  scale = s;
230
- offset = next;
417
+ offset = nextOffset;
231
418
  applyTransform(scale, offset);
232
419
  }
233
420
 
@@ -267,44 +454,63 @@
267
454
  }
268
455
  }
269
456
 
270
- function onTileClick() {
457
+ function onStageClick() {
271
458
  if (didDrag) {
272
459
  didDrag = false;
273
460
  return;
274
461
  }
275
- if (!open) {
276
- openLightbox();
277
- } else if (!extended) {
278
- closeLightbox();
279
- }
462
+ if (!extended && !isGallery) closeLightbox();
280
463
  }
281
464
 
282
- function onTileKeyDown(e: KeyboardEvent) {
283
- if (e.key === 'Enter' || e.code === 'Space') {
465
+ // Keep Tab inside the open dialog. The stage (tabindex -1) is excluded, so an
466
+ // image-only lightbox with no chrome simply holds focus on the stage.
467
+ function trapTab(e: KeyboardEvent) {
468
+ if (!modalEl) return;
469
+ const f = [
470
+ ...modalEl.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'),
471
+ ];
472
+ if (f.length === 0) {
473
+ e.preventDefault();
474
+ stageEl?.focus();
475
+ return;
476
+ }
477
+ const first = f[0];
478
+ const last = f[f.length - 1];
479
+ const active = document.activeElement as HTMLElement | null;
480
+ const inside = active && modalEl.contains(active);
481
+ if (e.shiftKey && (!inside || active === first)) {
482
+ e.preventDefault();
483
+ last.focus();
484
+ } else if (!e.shiftKey && (!inside || active === last)) {
284
485
  e.preventDefault();
285
- onTileClick();
486
+ first.focus();
286
487
  }
287
488
  }
288
489
 
289
490
  onMount(() => {
290
491
  const onKey = (e: KeyboardEvent) => {
291
- if (e.key === 'Escape' && open) closeLightbox();
292
- };
293
- const onResize = () => {
294
- if (!open || !tileEl) return;
295
- const target = viewportTarget();
296
- Object.assign(tileEl.style, {
297
- top: `${target.top}px`,
298
- left: `${target.left}px`,
299
- width: `${target.width}px`,
300
- height: `${target.height}px`,
301
- });
302
- offset = clampOffset(offset.x, offset.y, scale);
303
- applyTransform(scale, offset);
492
+ if (!open) return;
493
+ if (e.key === 'Escape') {
494
+ closeLightbox();
495
+ } else if (e.key === 'Tab') {
496
+ trapTab(e);
497
+ } else if (isGallery && e.key === 'ArrowRight') {
498
+ e.preventDefault();
499
+ next();
500
+ } else if (isGallery && e.key === 'ArrowLeft') {
501
+ e.preventDefault();
502
+ prev();
503
+ }
304
504
  };
505
+ const onResize = () => fitToViewport(false);
506
+ const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
507
+ reducedMotion = motionQuery.matches;
508
+ const onMotionChange = (e: MediaQueryListEvent) => { reducedMotion = e.matches; };
509
+ motionQuery.addEventListener('change', onMotionChange);
305
510
  document.addEventListener('keydown', onKey);
306
511
  window.addEventListener('resize', onResize);
307
512
  return () => {
513
+ motionQuery.removeEventListener('change', onMotionChange);
308
514
  document.removeEventListener('keydown', onKey);
309
515
  window.removeEventListener('resize', onResize);
310
516
  document.body.style.overflow = '';
@@ -316,97 +522,151 @@
316
522
  </script>
317
523
 
318
524
  <div
319
- bind:this={wrapperEl}
320
525
  class="image-lightbox-wrapper"
321
- style:aspect-ratio={aspect ? `${width} / ${height}` : undefined}
526
+ style:aspect-ratio={coverAspect ? `${coverAspect}` : undefined}
322
527
  style:max-width={typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth}
323
528
  >
324
- <div
325
- bind:this={tileEl}
326
- class="image-lightbox-tile"
327
- class:open
328
- role="button"
329
- tabindex="0"
330
- aria-label={open ? `Close image: ${alt}` : `Expand image: ${alt}`}
331
- onclick={onTileClick}
332
- onkeydown={onTileKeyDown}
333
- onpointerdown={onPointerDown}
334
- onpointermove={onPointerMove}
335
- onpointerup={onPointerUp}
336
- onpointercancel={onPointerUp}
337
- onwheel={onWheel}
338
- >
339
- <div class="image-lightbox-clip">
340
- <div bind:this={transformEl} class="image-lightbox-transform">
341
- <img {src} {alt} draggable="false" />
342
- </div>
343
- </div>
344
- </div>
345
- </div>
346
-
347
- <div
348
- bind:this={overlayEl}
349
- class="image-lightbox-overlay"
350
- class:active={open}
351
- aria-hidden="true"
352
- onclick={closeLightbox}
353
- role="presentation"
354
- ></div>
355
-
356
- {#if extended}
357
529
  <button
358
- bind:this={closeBtnEl}
359
- class="image-lightbox-chrome image-lightbox-close"
360
- class:active={open}
530
+ bind:this={thumbEl}
531
+ class="image-lightbox-thumb"
532
+ style:--imagelightbox-tile-object-fit={fit}
361
533
  type="button"
362
- aria-label="Close"
363
- onclick={closeLightbox}
534
+ aria-label={cover?.alt ? `Expand image: ${cover.alt}` : 'Expand image'}
535
+ onclick={openLightbox}
364
536
  >
365
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
366
- <path d="M18 6L6 18" />
367
- <path d="M6 6l12 12" />
368
- </svg>
537
+ <img src={cover?.src} alt={cover?.alt} draggable="false" onload={onCoverLoad} />
369
538
  </button>
539
+ </div>
370
540
 
541
+ {#if mounted}
371
542
  <div
372
- bind:this={toolbarEl}
373
- class="image-lightbox-toolbar"
374
- class:active={open}
543
+ bind:this={modalEl}
544
+ class="image-lightbox-modal"
545
+ role="dialog"
546
+ aria-modal="true"
547
+ aria-label={dialogLabel}
548
+ use:portal
375
549
  >
376
- <button
377
- class="image-lightbox-chrome-button"
378
- type="button"
379
- aria-label="Zoom out"
380
- disabled={scale <= MIN_SCALE + 0.001}
381
- onclick={() => zoomTo(scale / ZOOM_STEP)}
550
+ <div
551
+ bind:this={overlayEl}
552
+ class="image-lightbox-overlay"
553
+ class:active={open}
554
+ aria-hidden="true"
555
+ onclick={closeLightbox}
556
+ role="presentation"
557
+ ></div>
558
+
559
+ {#if isGallery}
560
+ <div class="image-lightbox-sr" aria-live="polite">{`Image ${index + 1} of ${items.length}`}</div>
561
+ {/if}
562
+
563
+ <div
564
+ bind:this={stageEl}
565
+ class="image-lightbox-stage"
566
+ role="presentation"
567
+ tabindex="-1"
568
+ onclick={onStageClick}
569
+ onpointerdown={onPointerDown}
570
+ onpointermove={onPointerMove}
571
+ onpointerup={onPointerUp}
572
+ onpointercancel={onPointerUp}
573
+ onwheel={onWheel}
382
574
  >
383
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
384
- <path d="M5 12h14" />
385
- </svg>
386
- </button>
387
- <span class="image-lightbox-toolbar-label">{percentLabel}</span>
388
- <button
389
- class="image-lightbox-chrome-button"
390
- type="button"
391
- aria-label="Zoom in"
392
- disabled={scale >= MAX_SCALE - 0.001}
393
- onclick={() => zoomTo(scale * ZOOM_STEP)}
394
- >
395
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
396
- <path d="M12 5v14" />
397
- <path d="M5 12h14" />
398
- </svg>
399
- </button>
575
+ <div class="image-lightbox-clip">
576
+ <div bind:this={transformEl} class="image-lightbox-transform">
577
+ {#if outgoing}
578
+ <img bind:this={outgoingEl} class="image-lightbox-layer image-lightbox-layer-exit" src={outgoing.src} alt="" draggable="false" />
579
+ {/if}
580
+ <img bind:this={incomingEl} class="image-lightbox-layer" src={current?.src} alt={current?.alt} draggable="false" onload={onImgLoad} />
581
+ </div>
582
+ </div>
583
+ </div>
584
+
585
+ {#if extended || isGallery}
586
+ <button
587
+ bind:this={closeBtnEl}
588
+ class="image-lightbox-chrome image-lightbox-close"
589
+ class:active={open}
590
+ type="button"
591
+ aria-label="Close"
592
+ onclick={closeLightbox}
593
+ >
594
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
595
+ <path d="M18 6L6 18" />
596
+ <path d="M6 6l12 12" />
597
+ </svg>
598
+ </button>
599
+ {/if}
600
+
601
+ {#if isGallery}
602
+ <button
603
+ bind:this={prevBtnEl}
604
+ class="image-lightbox-chrome image-lightbox-nav image-lightbox-nav-prev"
605
+ class:active={open}
606
+ type="button"
607
+ aria-label="Previous image"
608
+ onclick={prev}
609
+ >
610
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
611
+ <path d="M15 18l-6-6 6-6" />
612
+ </svg>
613
+ </button>
614
+ <button
615
+ bind:this={nextBtnEl}
616
+ class="image-lightbox-chrome image-lightbox-nav image-lightbox-nav-next"
617
+ class:active={open}
618
+ type="button"
619
+ aria-label="Next image"
620
+ onclick={next}
621
+ >
622
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
623
+ <path d="M9 6l6 6-6 6" />
624
+ </svg>
625
+ </button>
626
+ <div bind:this={counterEl} class="image-lightbox-counter" aria-hidden="true">
627
+ {index + 1} / {items.length}
628
+ </div>
629
+ {/if}
630
+
631
+ {#if extended}
632
+ <div bind:this={toolbarEl} class="image-lightbox-toolbar" class:active={open}>
633
+ <button
634
+ class="image-lightbox-chrome-button"
635
+ type="button"
636
+ aria-label="Zoom out"
637
+ disabled={scale <= MIN_SCALE + 0.001}
638
+ onclick={() => zoomTo(scale / ZOOM_STEP)}
639
+ >
640
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
641
+ <path d="M5 12h14" />
642
+ </svg>
643
+ </button>
644
+ <span class="image-lightbox-toolbar-label">{percentLabel}</span>
645
+ <button
646
+ class="image-lightbox-chrome-button"
647
+ type="button"
648
+ aria-label="Zoom in"
649
+ disabled={scale >= MAX_SCALE - 0.001}
650
+ onclick={() => zoomTo(scale * ZOOM_STEP)}
651
+ >
652
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
653
+ <path d="M12 5v14" />
654
+ <path d="M5 12h14" />
655
+ </svg>
656
+ </button>
657
+ </div>
658
+ {/if}
400
659
  </div>
401
660
  {/if}
402
661
 
403
662
  <style>
404
663
  :global(:root) {
405
- /* tile (closed inline + animated modal surface) */
664
+ /* thumbnail (closed inline) + animated modal stage */
406
665
  --imagelightbox-tile-radius: var(--radius-2xl);
407
666
  --imagelightbox-tile-border: var(--color-transparent);
408
667
  --imagelightbox-tile-border-width: var(--border-width-0);
409
668
  --imagelightbox-tile-shadow: var(--shadow-md);
669
+ --imagelightbox-tile-object-fit: contain;
410
670
 
411
671
  /* overlay */
412
672
  --imagelightbox-overlay-surface: color-mix(in srgb, var(--color-neutral-950) 76%, transparent);
@@ -425,31 +685,63 @@
425
685
  width: 100%;
426
686
  }
427
687
 
428
- .image-lightbox-tile {
688
+ .image-lightbox-thumb {
429
689
  position: absolute;
430
690
  inset: 0;
691
+ padding: 0;
431
692
  cursor: zoom-in;
432
693
  border: var(--imagelightbox-tile-border-width) solid var(--imagelightbox-tile-border);
433
694
  border-radius: var(--imagelightbox-tile-radius);
434
695
  box-shadow: var(--imagelightbox-tile-shadow);
435
696
  background: transparent;
436
- overflow: visible;
697
+ overflow: hidden;
437
698
  transition: transform 250ms ease;
438
699
  }
439
700
 
440
- .image-lightbox-tile:hover:not(.open) {
701
+ .image-lightbox-thumb:hover {
441
702
  transform: scale(1.02);
442
703
  }
443
704
 
444
- .image-lightbox-tile.open {
445
- cursor: zoom-out;
446
- }
447
-
448
- .image-lightbox-tile:focus-visible {
705
+ .image-lightbox-thumb:focus-visible {
449
706
  outline: 2px solid var(--border-brand-medium);
450
707
  outline-offset: 2px;
451
708
  }
452
709
 
710
+ .image-lightbox-thumb img {
711
+ width: 100%;
712
+ height: 100%;
713
+ object-fit: var(--imagelightbox-tile-object-fit);
714
+ object-position: center;
715
+ user-select: none;
716
+ display: block;
717
+ }
718
+
719
+ .image-lightbox-modal {
720
+ display: contents;
721
+ }
722
+
723
+ .image-lightbox-sr {
724
+ position: fixed;
725
+ width: 1px;
726
+ height: 1px;
727
+ padding: 0;
728
+ margin: -1px;
729
+ overflow: hidden;
730
+ clip: rect(0 0 0 0);
731
+ white-space: nowrap;
732
+ border: 0;
733
+ }
734
+
735
+ .image-lightbox-stage {
736
+ position: fixed;
737
+ z-index: var(--z-modal);
738
+ cursor: zoom-out;
739
+ border: var(--imagelightbox-tile-border-width) solid var(--imagelightbox-tile-border);
740
+ border-radius: var(--imagelightbox-tile-radius);
741
+ box-shadow: var(--imagelightbox-tile-shadow);
742
+ background: transparent;
743
+ }
744
+
453
745
  .image-lightbox-clip {
454
746
  position: absolute;
455
747
  inset: 0;
@@ -464,14 +756,26 @@
464
756
  transform-origin: center center;
465
757
  }
466
758
 
467
- .image-lightbox-transform img {
759
+ .image-lightbox-layer {
760
+ position: absolute;
761
+ inset: 0;
468
762
  width: 100%;
469
763
  height: 100%;
470
764
  object-fit: contain;
471
765
  object-position: center;
766
+ transform-origin: center center;
472
767
  user-select: none;
473
768
  pointer-events: none;
474
769
  display: block;
770
+ /* Incoming layer sits above the exiting one so the new image fades in over
771
+ the old; own compositor layer keeps the crossfade from flickering. */
772
+ z-index: 1;
773
+ will-change: transform, opacity;
774
+ backface-visibility: hidden;
775
+ }
776
+
777
+ .image-lightbox-layer-exit {
778
+ z-index: 0;
475
779
  }
476
780
 
477
781
  .image-lightbox-overlay {
@@ -509,6 +813,7 @@
509
813
  .image-lightbox-close {
510
814
  top: var(--space-24);
511
815
  right: var(--space-24);
816
+ /* 2.75rem = 44px, the min touch-target floor for primary nav. */
512
817
  width: 2.75rem;
513
818
  height: 2.75rem;
514
819
  display: flex;
@@ -521,6 +826,48 @@
521
826
  background: var(--imagelightbox-chrome-hover-surface);
522
827
  }
523
828
 
829
+ .image-lightbox-nav {
830
+ top: 50%;
831
+ transform: translateY(-50%);
832
+ /* 2.75rem = 44px, the min touch-target floor for primary nav. */
833
+ width: 2.75rem;
834
+ height: 2.75rem;
835
+ display: flex;
836
+ align-items: center;
837
+ justify-content: center;
838
+ cursor: pointer;
839
+ }
840
+
841
+ .image-lightbox-nav-prev {
842
+ left: var(--space-24);
843
+ }
844
+
845
+ .image-lightbox-nav-next {
846
+ right: var(--space-24);
847
+ }
848
+
849
+ .image-lightbox-nav:hover {
850
+ background: var(--imagelightbox-chrome-hover-surface);
851
+ }
852
+
853
+ .image-lightbox-counter {
854
+ position: fixed;
855
+ right: var(--space-24);
856
+ bottom: var(--space-24);
857
+ z-index: var(--z-modal);
858
+ padding: var(--space-8) var(--space-16);
859
+ background: var(--imagelightbox-chrome-surface);
860
+ border: var(--imagelightbox-chrome-border-width) solid var(--imagelightbox-chrome-border);
861
+ border-radius: var(--imagelightbox-chrome-radius);
862
+ color: var(--imagelightbox-chrome-icon);
863
+ backdrop-filter: blur(var(--blur-md));
864
+ font-family: var(--font-mono);
865
+ font-size: var(--font-size-xs);
866
+ letter-spacing: var(--letter-spacing-wider);
867
+ opacity: 0;
868
+ pointer-events: none;
869
+ }
870
+
524
871
  .image-lightbox-toolbar {
525
872
  position: fixed;
526
873
  left: 50%;
@@ -0,0 +1,18 @@
1
+ // Move a `position: fixed` overlay to <body> so it escapes any transformed /
2
+ // `isolation` / `contain` / scrolling ancestor that would otherwise clip it or
3
+ // trap its stacking order (the editor's `.content` pane is one such ancestor;
4
+ // consumer pages have countless others). Every shipped component that renders a
5
+ // fixed overlay uses this — see check-overlay-portal.
6
+ //
7
+ // `enabled` is read once, at mount: pass `false` to leave the node in flow
8
+ // (Dialog's `inline` preview). It is intentionally not reactive — every consumer
9
+ // sets it statically, so there is no move-it-back path to get wrong.
10
+
11
+ export function portal(node: HTMLElement, enabled = true) {
12
+ if (enabled) document.body.appendChild(node);
13
+ return {
14
+ destroy() {
15
+ if (enabled) node.remove();
16
+ },
17
+ };
18
+ }
@@ -415,15 +415,6 @@
415
415
  --border-width-20: 20px;
416
416
  --border-width-24: 24px;
417
417
 
418
- /* Icon sizing (square) */
419
- --size-icon-sm: 1rem; /* 16px */
420
- --size-icon-md: 1.25rem; /* 20px */
421
- --size-icon-lg: 1.5rem; /* 24px */
422
- --size-icon-xl: 2rem; /* 32px */
423
- --size-icon-2xl: 4rem; /* 64px */
424
- --size-icon-3xl: 6rem; /* 96px */
425
- --size-icon-4xl: 8rem; /* 128px */
426
-
427
418
  /* Spacing */
428
419
  --space-0: 0;
429
420
  --space-2: 0.125rem; /* 2px */