@rogieking/figui3 6.20.2 → 6.21.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/fig.js CHANGED
@@ -228,7 +228,7 @@ function figUniqueId() {
228
228
  }
229
229
 
230
230
  /** Zero-size portal for fixed overlays so they never affect body layout metrics. */
231
- function figGetOverlayRoot() {
231
+ function figGetOverlayRoot(source) {
232
232
  if (!document.body) return null;
233
233
  const attr = "data-figui-overlay-root";
234
234
  let root = document.body.querySelector(`:scope > [${attr}]`);
@@ -237,9 +237,40 @@ function figGetOverlayRoot() {
237
237
  root.setAttribute(attr, "");
238
238
  document.body.append(root);
239
239
  }
240
+ figSyncOverlayThemeFromSource(root, source);
240
241
  return root;
241
242
  }
242
243
 
244
+ /** Copy PropsKit / FigUI theme onto the overlay portal so portaled popups stay themed. */
245
+ function figSyncOverlayThemeFromSource(overlayRoot, source) {
246
+ if (!overlayRoot) return;
247
+ const panel =
248
+ (source instanceof Element
249
+ ? source.closest(".figui-root")
250
+ : null) || document.querySelector(".figui-root");
251
+ if (!panel) return;
252
+
253
+ const themeAttr = panel.getAttribute("theme");
254
+ const theme =
255
+ themeAttr === "light" || themeAttr === "dark" || themeAttr === "system"
256
+ ? themeAttr
257
+ : panel.classList.contains("figma-dark")
258
+ ? "dark"
259
+ : panel.classList.contains("figma-light")
260
+ ? "light"
261
+ : "system";
262
+
263
+ overlayRoot.classList.toggle("figma-light", theme === "light");
264
+ overlayRoot.classList.toggle("figma-dark", theme === "dark");
265
+ if (theme === "system") {
266
+ overlayRoot.style.setProperty("color-scheme", "light dark");
267
+ } else {
268
+ overlayRoot.style.setProperty("color-scheme", theme);
269
+ }
270
+ if (themeAttr) overlayRoot.setAttribute("theme", theme);
271
+ else overlayRoot.removeAttribute("theme");
272
+ }
273
+
243
274
  let _figZCounter = 10000;
244
275
  function figGetHighestZIndex() {
245
276
  return _figZCounter++;
@@ -1045,13 +1076,13 @@ class FigTooltip extends HTMLElement {
1045
1076
  // - Without popover support, fall back to today's behavior: nearest open
1046
1077
  // <dialog> ancestor if present, else document.body.
1047
1078
  if (supportsPopover) {
1048
- (figGetOverlayRoot() ?? document.body).append(this.popup);
1079
+ (figGetOverlayRoot(this) ?? document.body).append(this.popup);
1049
1080
  } else {
1050
1081
  const parentDialog = this.closest("dialog");
1051
1082
  if (parentDialog && parentDialog.open) {
1052
1083
  parentDialog.append(this.popup);
1053
1084
  } else {
1054
- (figGetOverlayRoot() ?? document.body).append(this.popup);
1085
+ (figGetOverlayRoot(this) ?? document.body).append(this.popup);
1055
1086
  }
1056
1087
  }
1057
1088
 
@@ -1433,13 +1464,13 @@ class FigTooltip extends HTMLElement {
1433
1464
  popup.append(content);
1434
1465
 
1435
1466
  if (supportsPopover) {
1436
- (figGetOverlayRoot() ?? document.body).append(popup);
1467
+ (figGetOverlayRoot(anchor) ?? document.body).append(popup);
1437
1468
  } else {
1438
1469
  const parentDialog = anchor.closest?.("dialog");
1439
1470
  if (parentDialog && parentDialog.open) {
1440
1471
  parentDialog.append(popup);
1441
1472
  } else {
1442
- (figGetOverlayRoot() ?? document.body).append(popup);
1473
+ (figGetOverlayRoot(anchor) ?? document.body).append(popup);
1443
1474
  }
1444
1475
  }
1445
1476
 
@@ -8034,6 +8065,7 @@ const GRADIENT_INTERPOLATION_SPACES = [
8034
8065
  "display-p3",
8035
8066
  "oklab",
8036
8067
  "oklch",
8068
+ "hsl",
8037
8069
  ];
8038
8070
  const GRADIENT_HUE_INTERPOLATIONS = [
8039
8071
  "shorter",
@@ -8041,6 +8073,7 @@ const GRADIENT_HUE_INTERPOLATIONS = [
8041
8073
  "increasing",
8042
8074
  "decreasing",
8043
8075
  ];
8076
+ const GRADIENT_HUE_SPACES = new Set(["oklch", "hsl"]);
8044
8077
 
8045
8078
  const GRADIENT_PICKER_SPACES = ["srgb", "srgb-linear", "oklab", "oklch"];
8046
8079
 
@@ -8069,7 +8102,7 @@ function gradientToValueShape(gradient) {
8069
8102
  ...normalized,
8070
8103
  interpolationSpace: normalized.interpolationSpace,
8071
8104
  };
8072
- if (normalized.interpolationSpace === "oklch") {
8105
+ if (GRADIENT_HUE_SPACES.has(normalized.interpolationSpace)) {
8073
8106
  output.hueInterpolation = normalized.hueInterpolation;
8074
8107
  } else {
8075
8108
  delete output.hueInterpolation;
@@ -8082,8 +8115,8 @@ function gradientInterpolationClause(gradient) {
8082
8115
  if (normalized.interpolationSpace === "srgb") {
8083
8116
  return "";
8084
8117
  }
8085
- if (normalized.interpolationSpace === "oklch") {
8086
- return `in oklch ${normalized.hueInterpolation} hue`;
8118
+ if (GRADIENT_HUE_SPACES.has(normalized.interpolationSpace)) {
8119
+ return `in ${normalized.interpolationSpace} ${normalized.hueInterpolation} hue`;
8087
8120
  }
8088
8121
  return `in ${normalized.interpolationSpace}`;
8089
8122
  }
@@ -8232,6 +8265,25 @@ function figSampleGradientAt(
8232
8265
  r = rgb.r;
8233
8266
  g = rgb.g;
8234
8267
  b = rgb.b;
8268
+ } else if (space === "hsl") {
8269
+ const hsl1 = figRgbToHsl(c1.r, c1.g, c1.b);
8270
+ const hsl2 = figRgbToHsl(c2.r, c2.g, c2.b);
8271
+ const H = figInterpolateHue(
8272
+ hsl1.h,
8273
+ hsl2.h,
8274
+ t,
8275
+ hueInterpolation || "shorter",
8276
+ );
8277
+ const S = hsl1.s + (hsl2.s - hsl1.s) * t;
8278
+ const L = hsl1.l + (hsl2.l - hsl1.l) * t;
8279
+ const rgb = hslToSRGB(H, S, L);
8280
+ r = rgb[0];
8281
+ g = rgb[1];
8282
+ b = rgb[2];
8283
+ } else if (space === "srgb") {
8284
+ r = Math.round(c1.r + (c2.r - c1.r) * t);
8285
+ g = Math.round(c1.g + (c2.g - c1.g) * t);
8286
+ b = Math.round(c1.b + (c2.b - c1.b) * t);
8235
8287
  } else {
8236
8288
  const lab1 = figRGBToOklab(c1.r, c1.g, c1.b);
8237
8289
  const lab2 = figRGBToOklab(c2.r, c2.g, c2.b);
@@ -8247,6 +8299,33 @@ function figSampleGradientAt(
8247
8299
  return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
8248
8300
  }
8249
8301
 
8302
+ function figRgbToHsl(r, g, b) {
8303
+ const R = r / 255;
8304
+ const G = g / 255;
8305
+ const B = b / 255;
8306
+ const max = Math.max(R, G, B);
8307
+ const min = Math.min(R, G, B);
8308
+ const l = (max + min) / 2;
8309
+ if (max === min) {
8310
+ return { h: 0, s: 0, l: l * 100 };
8311
+ }
8312
+ const d = max - min;
8313
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
8314
+ let h;
8315
+ switch (max) {
8316
+ case R:
8317
+ h = ((G - B) / d + (G < B ? 6 : 0)) / 6;
8318
+ break;
8319
+ case G:
8320
+ h = ((B - R) / d + 2) / 6;
8321
+ break;
8322
+ default:
8323
+ h = ((R - G) / d + 4) / 6;
8324
+ break;
8325
+ }
8326
+ return { h: h * 360, s: s * 100, l: l * 100 };
8327
+ }
8328
+
8250
8329
  function hslToP3(h, s, l) {
8251
8330
  const sRGB = hslToSRGB(h, s, l);
8252
8331
  return sRGB.map((c) => +(c / 255).toFixed(4));
@@ -8311,6 +8390,7 @@ class FigInputFill extends HTMLElement {
8311
8390
  angle: 180,
8312
8391
  interpolationSpace: "srgb",
8313
8392
  hueInterpolation: "shorter",
8393
+ opacity: 1,
8314
8394
  stops: [
8315
8395
  { position: 0, color: "#D9D9D9", opacity: 100 },
8316
8396
  { position: 100, color: "#737373", opacity: 100 },
@@ -8371,6 +8451,15 @@ class FigInputFill extends HTMLElement {
8371
8451
  ...this.#gradient,
8372
8452
  ...parsed.gradient,
8373
8453
  });
8454
+ this.#gradient.opacity = this.#normalizeFillOpacity(
8455
+ parsed.gradient.opacity ?? parsed.opacity ?? parsed.alpha,
8456
+ this.#gradient.opacity ?? 1,
8457
+ );
8458
+ } else if (parsed.opacity !== undefined || parsed.alpha !== undefined) {
8459
+ this.#gradient.opacity = this.#normalizeFillOpacity(
8460
+ parsed.opacity ?? parsed.alpha,
8461
+ this.#gradient.opacity ?? 1,
8462
+ );
8374
8463
  }
8375
8464
  break;
8376
8465
  case "image":
@@ -8446,10 +8535,20 @@ class FigInputFill extends HTMLElement {
8446
8535
  }
8447
8536
  }
8448
8537
 
8538
+ #normalizeFillOpacity(value, fallback = 1) {
8539
+ if (value === undefined || value === null || value === "") return fallback;
8540
+ const n = Number(value);
8541
+ if (!Number.isFinite(n)) return fallback;
8542
+ if (n > 1) return Math.max(0, Math.min(1, n / 100));
8543
+ return Math.max(0, Math.min(1, n));
8544
+ }
8545
+
8449
8546
  #fillPickerSwatchAlpha() {
8450
8547
  switch (this.#fillType) {
8451
8548
  case "solid":
8452
8549
  return this.#solid.alpha;
8550
+ case "gradient":
8551
+ return this.#gradient.opacity ?? 1;
8453
8552
  case "image":
8454
8553
  return this.#image.opacity ?? 1;
8455
8554
  case "video":
@@ -8507,9 +8606,9 @@ class FigInputFill extends HTMLElement {
8507
8606
  const opacityHtml = (value) =>
8508
8607
  showAlpha
8509
8608
  ? `<fig-tooltip text="Opacity">
8510
- <fig-input-number
8609
+ <fig-input-number
8511
8610
  class="fig-input-fill-opacity"
8512
- placeholder="##"
8611
+ placeholder="##"
8513
8612
  min="0"
8514
8613
  max="100"
8515
8614
  value="${value}"
@@ -8539,7 +8638,8 @@ class FigInputFill extends HTMLElement {
8539
8638
  this.#gradient.type.charAt(0).toUpperCase() +
8540
8639
  this.#gradient.type.slice(1);
8541
8640
  controlsHtml = `
8542
- <label class="fig-input-fill-label">${figEscapeAttribute(gradientLabel)}</label>`;
8641
+ <label class="fig-input-fill-label">${figEscapeAttribute(gradientLabel)}</label>
8642
+ ${opacityHtml(Math.round((this.#gradient.opacity ?? 1) * 100))}`;
8543
8643
  break;
8544
8644
  }
8545
8645
 
@@ -8679,6 +8779,7 @@ class FigInputFill extends HTMLElement {
8679
8779
  this.#solid.alpha = alpha;
8680
8780
  break;
8681
8781
  case "gradient":
8782
+ this.#gradient.opacity = alpha;
8682
8783
  break;
8683
8784
  case "image":
8684
8785
  this.#image.opacity = alpha;
@@ -8727,6 +8828,12 @@ class FigInputFill extends HTMLElement {
8727
8828
  this.#gradient.type.slice(1);
8728
8829
  label.textContent = newLabel;
8729
8830
  }
8831
+ if (this.#opacityInput) {
8832
+ this.#opacityInput.setAttribute(
8833
+ "value",
8834
+ Math.round((this.#gradient.opacity ?? 1) * 100),
8835
+ );
8836
+ }
8730
8837
  break;
8731
8838
  }
8732
8839
  case "image":
@@ -8803,7 +8910,18 @@ class FigInputFill extends HTMLElement {
8803
8910
  this.#gradient.type.charAt(0).toUpperCase() +
8804
8911
  this.#gradient.type.slice(1);
8805
8912
  controlsHtml = `
8806
- <label class="fig-input-fill-label">${figEscapeAttribute(gradientLabel)}</label>`;
8913
+ <label class="fig-input-fill-label">${figEscapeAttribute(gradientLabel)}</label>
8914
+ ${showAlpha ? `<fig-tooltip text="Opacity">
8915
+ <fig-input-number
8916
+ class="fig-input-fill-opacity"
8917
+ placeholder="##"
8918
+ min="0"
8919
+ max="100"
8920
+ value="${Math.round((this.#gradient.opacity ?? 1) * 100)}"
8921
+ units="%"
8922
+ ${disabled ? "disabled" : ""}>
8923
+ </fig-input-number>
8924
+ </fig-tooltip>` : ""}`;
8807
8925
  break;
8808
8926
  }
8809
8927
  case "image":
@@ -8899,6 +9017,7 @@ class FigInputFill extends HTMLElement {
8899
9017
  this.#solid.alpha = alpha;
8900
9018
  break;
8901
9019
  case "gradient":
9020
+ this.#gradient.opacity = alpha;
8902
9021
  break;
8903
9022
  case "image":
8904
9023
  this.#image.opacity = alpha;
@@ -9743,6 +9862,11 @@ class FigInputGradient extends HTMLElement {
9743
9862
  .join(", ");
9744
9863
  const interp = gradientInterpolationClause(gradient);
9745
9864
  const interpolation = interp ? ` ${interp}` : "";
9865
+ // Inline stop editor always previews L→R so handles match the track axis,
9866
+ // regardless of linear angle / radial / angular type.
9867
+ if (this.#editMode === "true") {
9868
+ return `linear-gradient(to right${interpolation}, ${stops})`;
9869
+ }
9746
9870
  if (gradient.type === "radial") {
9747
9871
  return `radial-gradient(circle at ${gradient.centerX}% ${gradient.centerY}%${interpolation}, ${stops})`;
9748
9872
  }
@@ -11213,6 +11337,9 @@ customElements.define("fig-swatch", FigSwatch);
11213
11337
  * @attr {string} aria-label - Accessible label forwarded to generated video
11214
11338
  * @attr {string} aria-labelledby - Accessible label reference forwarded to generated video
11215
11339
  *
11340
+ * When `controls` are shown and no video `src` is loaded, fig-media sets
11341
+ * `disabled` on the associated `fig-media-controls` until media is available.
11342
+ *
11216
11343
  * Sizing model:
11217
11344
  * - Default: host shrinkwraps to its inner <img>/<video> intrinsic size.
11218
11345
  * - `size` attribute applies a token-sized square.
@@ -11551,12 +11678,41 @@ class FigMedia extends HTMLElement {
11551
11678
  this.#mediaEl.playsInline = true;
11552
11679
  this.#syncMediaAccessibility();
11553
11680
  this.#syncControlsVisibility();
11681
+ this.#syncControlsDisabled();
11554
11682
  }
11555
11683
 
11556
11684
  get mediaEl() {
11557
11685
  return this.#mediaEl;
11558
11686
  }
11559
11687
 
11688
+ #hasPlayableVideo() {
11689
+ if (this.mediaKind !== "video") return false;
11690
+ return Boolean(this.#currentMediaSrc());
11691
+ }
11692
+
11693
+ #syncControlsDisabled() {
11694
+ const controls =
11695
+ this.#controlsEl ||
11696
+ this.querySelector(":scope > fig-media-controls");
11697
+ if (!controls) return;
11698
+ if (this.mediaKind !== "video") return;
11699
+
11700
+ if (this.#hasPlayableVideo()) {
11701
+ // Only clear disabled when we set it for the empty state.
11702
+ if (controls.hasAttribute("data-fig-empty-disabled")) {
11703
+ controls.removeAttribute("disabled");
11704
+ controls.removeAttribute("data-fig-empty-disabled");
11705
+ }
11706
+ return;
11707
+ }
11708
+
11709
+ controls.setAttribute("disabled", "");
11710
+ controls.setAttribute("data-fig-empty-disabled", "");
11711
+ controls.playing = false;
11712
+ controls.time = 0;
11713
+ controls.duration = 0;
11714
+ }
11715
+
11560
11716
  #syncControlsVisibility() {
11561
11717
  if (this.mediaKind !== "video") {
11562
11718
  this.#removeControls();
@@ -11571,6 +11727,7 @@ class FigMedia extends HTMLElement {
11571
11727
  this.#controlsEl = userControls;
11572
11728
  }
11573
11729
  this.#wireControlsToMedia();
11730
+ this.#syncControlsDisabled();
11574
11731
  return;
11575
11732
  }
11576
11733
  if (this.#isEnabledAttr("controls", false)) {
@@ -11590,6 +11747,7 @@ class FigMedia extends HTMLElement {
11590
11747
  if (existingControls) {
11591
11748
  this.#controlsEl = existingControls;
11592
11749
  this.#wireControlsToMedia();
11750
+ this.#syncControlsDisabled();
11593
11751
  return;
11594
11752
  }
11595
11753
  const controls = document.createElement("fig-media-controls");
@@ -11597,6 +11755,7 @@ class FigMedia extends HTMLElement {
11597
11755
  this.append(controls);
11598
11756
  this.#controlsEl = controls;
11599
11757
  this.#wireControlsToMedia();
11758
+ this.#syncControlsDisabled();
11600
11759
  }
11601
11760
 
11602
11761
  #wireControlsToMedia() {
@@ -11605,6 +11764,7 @@ class FigMedia extends HTMLElement {
11605
11764
  this.#controlsWiredFor === this.#mediaEl &&
11606
11765
  this.#controlsWiredControls === this.#controlsEl
11607
11766
  ) {
11767
+ this.#syncControlsDisabled();
11608
11768
  return;
11609
11769
  }
11610
11770
  this.#unwireControls();
@@ -11616,6 +11776,8 @@ class FigMedia extends HTMLElement {
11616
11776
 
11617
11777
  let pendingSeekTime = null;
11618
11778
  const syncFromVideo = () => {
11779
+ this.#syncControlsDisabled();
11780
+ if (!this.#hasPlayableVideo()) return;
11619
11781
  controls.playing = !video.paused && !video.ended;
11620
11782
  if (Number.isFinite(video.duration)) controls.duration = video.duration;
11621
11783
  if (pendingSeekTime !== null) {
@@ -11628,11 +11790,13 @@ class FigMedia extends HTMLElement {
11628
11790
  controls.time = video.currentTime || 0;
11629
11791
  };
11630
11792
  const onPlay = () => {
11793
+ if (!this.#hasPlayableVideo()) return;
11631
11794
  const p = video.play?.();
11632
11795
  if (p && typeof p.catch === "function") p.catch(() => {});
11633
11796
  };
11634
11797
  const onPause = () => video.pause?.();
11635
11798
  const onSeek = (e) => {
11799
+ if (!this.#hasPlayableVideo()) return;
11636
11800
  const next = Number(e?.detail?.time);
11637
11801
  if (!Number.isFinite(next)) return;
11638
11802
  pendingSeekTime = next;
@@ -12206,13 +12370,14 @@ customElements.define("fig-card", FigCard);
12206
12370
  * - `playing` (boolean presence) — current play/pause state
12207
12371
  * - `duration` (number, seconds) — total track length
12208
12372
  * - `time` (number, seconds) — current playhead position
12373
+ * - `disabled` (boolean) — disables play/seek interaction
12209
12374
  *
12210
12375
  * Events:
12211
12376
  * - `play` — emitted when the user toggles playback on (detail: { playing: true })
12212
12377
  * - `pause` — emitted when the user toggles playback off (detail: { playing: false })
12213
12378
  * - `seek` — emitted when the user drags the scrubber (detail: { time })
12214
12379
  *
12215
- * Properties: `playing`, `duration`, `time` mirror the attributes.
12380
+ * Properties: `playing`, `duration`, `time`, `disabled` mirror the attributes.
12216
12381
  */
12217
12382
  class FigMediaControls extends HTMLElement {
12218
12383
  #playBtn = null;
@@ -12223,13 +12388,14 @@ class FigMediaControls extends HTMLElement {
12223
12388
  #rendered = false;
12224
12389
 
12225
12390
  static get observedAttributes() {
12226
- return ["playing", "duration", "time"];
12391
+ return ["playing", "duration", "time", "disabled"];
12227
12392
  }
12228
12393
 
12229
12394
  connectedCallback() {
12230
12395
  this.#render();
12231
12396
  this.#syncPlayingUi();
12232
12397
  this.#syncTimeUi();
12398
+ this.#syncDisabled();
12233
12399
  }
12234
12400
 
12235
12401
  get playing() {
@@ -12266,10 +12432,19 @@ class FigMediaControls extends HTMLElement {
12266
12432
  this.setAttribute("time", String(n));
12267
12433
  }
12268
12434
 
12435
+ get disabled() {
12436
+ return figBooleanAttribute(this, "disabled");
12437
+ }
12438
+ set disabled(value) {
12439
+ if (value) this.setAttribute("disabled", "");
12440
+ else this.removeAttribute("disabled");
12441
+ }
12442
+
12269
12443
  attributeChangedCallback(name) {
12270
12444
  if (!this.#rendered) return;
12271
12445
  if (name === "playing") this.#syncPlayingUi();
12272
12446
  if (name === "duration" || name === "time") this.#syncTimeUi();
12447
+ if (name === "disabled") this.#syncDisabled();
12273
12448
  }
12274
12449
 
12275
12450
  #render() {
@@ -12315,6 +12490,11 @@ class FigMediaControls extends HTMLElement {
12315
12490
  timeEl.textContent = this.#formatTime(this.time);
12316
12491
 
12317
12492
  const handleSeek = (e) => {
12493
+ if (this.disabled) {
12494
+ e.preventDefault?.();
12495
+ e.stopPropagation();
12496
+ return;
12497
+ }
12318
12498
  const host = e.currentTarget;
12319
12499
  const next = Number(host?.value);
12320
12500
  if (!Number.isFinite(next)) return;
@@ -12340,6 +12520,20 @@ class FigMediaControls extends HTMLElement {
12340
12520
  this.#playTooltip = tooltip;
12341
12521
  this.#timeSlider = slider;
12342
12522
  this.#timeEl = timeEl;
12523
+ this.#syncDisabled();
12524
+ }
12525
+
12526
+ #syncDisabled() {
12527
+ const disabled = this.disabled;
12528
+ this.setAttribute("aria-disabled", disabled ? "true" : "false");
12529
+ if (this.#playBtn) {
12530
+ if (disabled) this.#playBtn.setAttribute("disabled", "");
12531
+ else this.#playBtn.removeAttribute("disabled");
12532
+ }
12533
+ if (this.#timeSlider) {
12534
+ if (disabled) this.#timeSlider.setAttribute("disabled", "");
12535
+ else this.#timeSlider.removeAttribute("disabled");
12536
+ }
12343
12537
  }
12344
12538
 
12345
12539
  #formatTime(seconds) {
@@ -12385,6 +12579,7 @@ class FigMediaControls extends HTMLElement {
12385
12579
  }
12386
12580
 
12387
12581
  toggle() {
12582
+ if (this.disabled) return;
12388
12583
  const next = !this.playing;
12389
12584
  this.playing = next;
12390
12585
  this.dispatchEvent(
@@ -12397,12 +12592,12 @@ class FigMediaControls extends HTMLElement {
12397
12592
  }
12398
12593
 
12399
12594
  play() {
12400
- if (this.playing) return;
12595
+ if (this.disabled || this.playing) return;
12401
12596
  this.toggle();
12402
12597
  }
12403
12598
 
12404
12599
  pause() {
12405
- if (!this.playing) return;
12600
+ if (this.disabled || !this.playing) return;
12406
12601
  this.toggle();
12407
12602
  }
12408
12603
  }
@@ -15427,6 +15622,314 @@ class FigPreview extends HTMLElement {
15427
15622
  }
15428
15623
  customElements.define("fig-preview", FigPreview);
15429
15624
 
15625
+ /**
15626
+ * Compact swatch previewing gradient color-space interpolation.
15627
+ * Polar: CSS conic-gradient masked (SVG data-URL, round linecaps) to an arc.
15628
+ * Non-polar: CSS linear-gradient masked to a horizontal round-capped stroke.
15629
+ * Accepts the same `value` shape as fig-input-gradient / fig-fill-picker.
15630
+ *
15631
+ * @element fig-interpolation-swatch
15632
+ * @attr {string} value - JSON `{ type: "gradient", gradient: { … } }` (or a bare gradient object)
15633
+ * @attr {string} size - `small` (default, 24px) or `large` (32px)
15634
+ */
15635
+ class FigInterpolationSwatch extends HTMLElement {
15636
+ static #HUE_SPACES = new Set(["oklch", "hsl"]);
15637
+ static #CX = 10;
15638
+ static #CY = 10;
15639
+ static #R = 8;
15640
+ static #STROKE = 3;
15641
+ // Polar endpoints ≈ 10 o'clock → 2 o'clock (SVG deg: 0 = east, CW).
15642
+ // CSS conic `from` is 0 = north; convert with +90.
15643
+ static #START_DEG = 210;
15644
+ static #DEFAULT_GRADIENT = {
15645
+ type: "linear",
15646
+ angle: 135,
15647
+ interpolationSpace: "srgb",
15648
+ hueInterpolation: "shorter",
15649
+ stops: [
15650
+ { color: "#FF0000", position: 0, opacity: 100 },
15651
+ { color: "#4F9EFF", position: 100, opacity: 100 },
15652
+ ],
15653
+ };
15654
+
15655
+ #rendered = false;
15656
+ #svgEl = null;
15657
+ #fillEl = null;
15658
+ #gradient = { ...FigInterpolationSwatch.#DEFAULT_GRADIENT };
15659
+
15660
+ static get observedAttributes() {
15661
+ return ["value"];
15662
+ }
15663
+
15664
+ get value() {
15665
+ return {
15666
+ type: "gradient",
15667
+ gradient: { ...this.#gradient },
15668
+ };
15669
+ }
15670
+
15671
+ set value(val) {
15672
+ if (val == null || val === "") {
15673
+ this.removeAttribute("value");
15674
+ return;
15675
+ }
15676
+ if (typeof val === "string") {
15677
+ this.setAttribute("value", val);
15678
+ return;
15679
+ }
15680
+ this.setAttribute("value", JSON.stringify(val));
15681
+ }
15682
+
15683
+ connectedCallback() {
15684
+ this.#ensureA11y();
15685
+ this.#parseValue();
15686
+ this.#render();
15687
+ this.#updatePreview();
15688
+ }
15689
+
15690
+ attributeChangedCallback(name, oldValue, newValue) {
15691
+ if (oldValue === newValue) return;
15692
+ if (name !== "value") return;
15693
+ this.#parseValue();
15694
+ if (this.#rendered) this.#updatePreview();
15695
+ }
15696
+
15697
+ #ensureA11y() {
15698
+ const named =
15699
+ this.hasAttribute("aria-label") || this.hasAttribute("aria-labelledby");
15700
+ if (!named && !this.hasAttribute("aria-hidden")) {
15701
+ this.setAttribute("aria-hidden", "true");
15702
+ }
15703
+ }
15704
+
15705
+ #parseValue() {
15706
+ const valueAttr = this.getAttribute("value");
15707
+ if (!valueAttr) {
15708
+ this.#gradient = {
15709
+ ...FigInterpolationSwatch.#DEFAULT_GRADIENT,
15710
+ stops: FigInterpolationSwatch.#DEFAULT_GRADIENT.stops.map((s) => ({
15711
+ ...s,
15712
+ })),
15713
+ };
15714
+ return;
15715
+ }
15716
+ try {
15717
+ const parsed = JSON.parse(valueAttr);
15718
+ const gradient = parsed?.type === "gradient" && parsed.gradient
15719
+ ? parsed.gradient
15720
+ : parsed?.gradient
15721
+ ? parsed.gradient
15722
+ : parsed;
15723
+ if (!gradient || typeof gradient !== "object") return;
15724
+ this.#gradient = this.#normalizeGradient({
15725
+ ...FigInterpolationSwatch.#DEFAULT_GRADIENT,
15726
+ ...gradient,
15727
+ });
15728
+ } catch {
15729
+ // Keep current/default gradient on invalid JSON.
15730
+ }
15731
+ }
15732
+
15733
+ #normalizeGradient(gradient) {
15734
+ const next = { ...(gradient ?? {}) };
15735
+ const interpolationSpace = String(
15736
+ next.interpolationSpace ?? "srgb",
15737
+ ).toLowerCase();
15738
+ const hueInterpolation = String(
15739
+ next.hueInterpolation ?? "shorter",
15740
+ ).toLowerCase();
15741
+ const stops = Array.isArray(next.stops)
15742
+ ? next.stops.map((stop) => ({
15743
+ color: String(stop?.color || "#D9D9D9").replace(
15744
+ /^(#(?:[0-9a-f]{6})).*/i,
15745
+ "$1",
15746
+ ),
15747
+ position: stop?.position ?? 0,
15748
+ opacity: stop?.opacity ?? 100,
15749
+ }))
15750
+ : FigInterpolationSwatch.#DEFAULT_GRADIENT.stops.map((s) => ({ ...s }));
15751
+ if (stops.length < 2) {
15752
+ return {
15753
+ ...FigInterpolationSwatch.#DEFAULT_GRADIENT,
15754
+ stops: FigInterpolationSwatch.#DEFAULT_GRADIENT.stops.map((s) => ({
15755
+ ...s,
15756
+ })),
15757
+ };
15758
+ }
15759
+ return {
15760
+ type: ["linear", "radial", "angular"].includes(next.type)
15761
+ ? next.type
15762
+ : "linear",
15763
+ angle: Number.isFinite(Number(next.angle)) ? Number(next.angle) : 135,
15764
+ interpolationSpace,
15765
+ hueInterpolation,
15766
+ stops,
15767
+ };
15768
+ }
15769
+
15770
+ #isPolar() {
15771
+ return FigInterpolationSwatch.#HUE_SPACES.has(
15772
+ this.#gradient.interpolationSpace || "srgb",
15773
+ );
15774
+ }
15775
+
15776
+ #hueForColor(color) {
15777
+ const { r, g, b } = figHexToRGB(color);
15778
+ if (this.#gradient.interpolationSpace === "hsl") {
15779
+ return figRgbToHsl(r, g, b).h;
15780
+ }
15781
+ const lab = figRGBToOklab(r, g, b);
15782
+ const hue = figOklabToOklch(lab.l, lab.a, lab.b).h;
15783
+ return ((hue % 360) + 360) % 360;
15784
+ }
15785
+
15786
+ #polarArcGeometry() {
15787
+ const stops = this.#sortedStops();
15788
+ const startHue = this.#hueForColor(stops[0]?.color || "#FF0000");
15789
+ const endHue = this.#hueForColor(
15790
+ stops[stops.length - 1]?.color || "#4F9EFF",
15791
+ );
15792
+ const startDeg = FigInterpolationSwatch.#START_DEG - startHue;
15793
+ const endDeg = FigInterpolationSwatch.#START_DEG - endHue;
15794
+ const clockwiseSweep = ((endDeg - startDeg) % 360 + 360) % 360;
15795
+ const counterclockwiseSweep =
15796
+ clockwiseSweep === 0 ? 0 : clockwiseSweep - 360;
15797
+ const method = this.#gradient.hueInterpolation || "shorter";
15798
+
15799
+ let sweepDeg;
15800
+ if (method === "increasing") {
15801
+ sweepDeg = counterclockwiseSweep;
15802
+ } else if (method === "decreasing") {
15803
+ sweepDeg = clockwiseSweep;
15804
+ } else if (method === "longer") {
15805
+ sweepDeg =
15806
+ clockwiseSweep < 180 ? counterclockwiseSweep : clockwiseSweep;
15807
+ } else {
15808
+ sweepDeg =
15809
+ clockwiseSweep <= 180 ? clockwiseSweep : counterclockwiseSweep;
15810
+ }
15811
+
15812
+ // A round cap extends beyond the path endpoint. Inset the centerline so
15813
+ // the visible cap edges, rather than their centers, land on the hues.
15814
+ const direction = Math.sign(sweepDeg);
15815
+ const capAngle =
15816
+ (Math.asin(FigInterpolationSwatch.#STROKE / 2 / FigInterpolationSwatch.#R) *
15817
+ 180) /
15818
+ Math.PI;
15819
+ const inset = Math.min(
15820
+ capAngle,
15821
+ Math.max(0, (Math.abs(sweepDeg) - 0.01) / 2),
15822
+ );
15823
+ return {
15824
+ startDeg: startDeg + direction * inset,
15825
+ sweepDeg: sweepDeg - direction * inset * 2,
15826
+ };
15827
+ }
15828
+
15829
+ #pointOnCircle(deg) {
15830
+ const rad = (deg * Math.PI) / 180;
15831
+ return {
15832
+ x: FigInterpolationSwatch.#CX + FigInterpolationSwatch.#R * Math.cos(rad),
15833
+ y: FigInterpolationSwatch.#CY + FigInterpolationSwatch.#R * Math.sin(rad),
15834
+ };
15835
+ }
15836
+
15837
+ #arcMaskPath(startDeg, sweepDeg) {
15838
+ const endDeg = startDeg + sweepDeg;
15839
+ const start = this.#pointOnCircle(startDeg);
15840
+ const end = this.#pointOnCircle(endDeg);
15841
+ const largeArc = Math.abs(sweepDeg) > 180 ? 1 : 0;
15842
+ const sweepFlag = sweepDeg >= 0 ? 1 : 0;
15843
+ return `M ${start.x} ${start.y} A ${FigInterpolationSwatch.#R} ${FigInterpolationSwatch.#R} 0 ${largeArc} ${sweepFlag} ${end.x} ${end.y}`;
15844
+ }
15845
+
15846
+ #lineMaskPath() {
15847
+ const start = this.#pointOnCircle(180);
15848
+ const end = this.#pointOnCircle(0);
15849
+ return `M ${start.x} ${start.y} L ${end.x} ${end.y}`;
15850
+ }
15851
+
15852
+ #maskImageForPath(d) {
15853
+ const stroke = FigInterpolationSwatch.#STROKE;
15854
+ const svg =
15855
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">` +
15856
+ `<path d="${d}" fill="none" stroke="white" stroke-width="${stroke}" ` +
15857
+ `stroke-linecap="round" stroke-linejoin="round"/>` +
15858
+ `</svg>`;
15859
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
15860
+ }
15861
+
15862
+ #sortedStops() {
15863
+ return [...this.#gradient.stops].sort(
15864
+ (a, b) => (a.position ?? 0) - (b.position ?? 0),
15865
+ );
15866
+ }
15867
+
15868
+ #cssInterpolationClause() {
15869
+ const space = this.#gradient.interpolationSpace || "srgb";
15870
+ if (space === "srgb") return "";
15871
+ if (FigInterpolationSwatch.#HUE_SPACES.has(space)) {
15872
+ return ` in ${space} ${this.#gradient.hueInterpolation || "shorter"} hue`;
15873
+ }
15874
+ return ` in ${space}`;
15875
+ }
15876
+
15877
+ #previewBackground() {
15878
+ const stops = this.#sortedStops();
15879
+ const clause = this.#cssInterpolationClause();
15880
+ if (this.#isPolar()) {
15881
+ // Fixed hue wheel. The mask maps gradient endpoint hues onto this wheel.
15882
+ const cssFrom = (FigInterpolationSwatch.#START_DEG + 90) % 360;
15883
+ const space = this.#gradient.interpolationSpace;
15884
+ const wheelColor = (hue) =>
15885
+ space === "oklch"
15886
+ ? `oklch(65% 0.25 ${hue})`
15887
+ : `hsl(${hue} 100% 50%)`;
15888
+ const wheelStops = [0, 300, 240, 180, 120, 60, 0]
15889
+ .map(wheelColor)
15890
+ .join(", ");
15891
+ return `conic-gradient(from ${cssFrom}deg in ${space} decreasing hue, ${wheelStops})`;
15892
+ }
15893
+ const stopList = stops
15894
+ .map((s) => `${s.color} ${s.position ?? 0}%`)
15895
+ .join(", ");
15896
+ return `linear-gradient(90deg${clause}, ${stopList})`;
15897
+ }
15898
+
15899
+ #render() {
15900
+ if (this.#rendered) return;
15901
+ const stroke = FigInterpolationSwatch.#STROKE;
15902
+ this.innerHTML = `
15903
+ <svg class="fig-interpolation-swatch-svg" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
15904
+ <circle class="fig-interpolation-swatch-rim" cx="10" cy="10" r="8" fill="none" stroke="currentColor" stroke-width="${stroke}"/>
15905
+ </svg>
15906
+ <div class="fig-interpolation-swatch-fill" aria-hidden="true"></div>
15907
+ `;
15908
+ this.#svgEl = this.querySelector(".fig-interpolation-swatch-svg");
15909
+ this.#fillEl = this.querySelector(".fig-interpolation-swatch-fill");
15910
+ this.#rendered = true;
15911
+ }
15912
+
15913
+ #updatePreview() {
15914
+ if (!this.#fillEl) return;
15915
+
15916
+ const polar = this.#isPolar();
15917
+ if (this.#svgEl) this.#svgEl.style.display = polar ? "" : "none";
15918
+
15919
+ const d = polar
15920
+ ? (() => {
15921
+ const { startDeg, sweepDeg } = this.#polarArcGeometry();
15922
+ return this.#arcMaskPath(startDeg, sweepDeg);
15923
+ })()
15924
+ : this.#lineMaskPath();
15925
+ const mask = this.#maskImageForPath(d);
15926
+ this.#fillEl.style.setProperty("-webkit-mask-image", mask);
15927
+ this.#fillEl.style.maskImage = mask;
15928
+ this.#fillEl.style.background = this.#previewBackground();
15929
+ }
15930
+ }
15931
+ customElements.define("fig-interpolation-swatch", FigInterpolationSwatch);
15932
+
15430
15933
  /** @type {Record<string, string | { medium: string, small: string }>} */
15431
15934
  const FIG_ICON_TOKENS = {
15432
15935
  chevron: "--icon-16-chevron",