@rogieking/figui3 6.20.1 → 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++;
@@ -834,6 +865,9 @@ customElements.define("fig-dropdown", FigDropdown);
834
865
  * @attr {string} theme - Optional theme passed to the underlying popup (e.g. "brand").
835
866
  * @attr {string} pointer - "false" to hide the beak.
836
867
  * @attr {boolean} show - When set, force-show the tooltip (ignores hide).
868
+ *
869
+ * Call {@link FigTooltip.dismissHoverTooltips} when opening menus/dialogs so
870
+ * hover tips do not stay visible over interactive overlays.
837
871
  */
838
872
  class FigTooltip extends HTMLElement {
839
873
  static #lastShownAt = 0;
@@ -1042,13 +1076,13 @@ class FigTooltip extends HTMLElement {
1042
1076
  // - Without popover support, fall back to today's behavior: nearest open
1043
1077
  // <dialog> ancestor if present, else document.body.
1044
1078
  if (supportsPopover) {
1045
- (figGetOverlayRoot() ?? document.body).append(this.popup);
1079
+ (figGetOverlayRoot(this) ?? document.body).append(this.popup);
1046
1080
  } else {
1047
1081
  const parentDialog = this.closest("dialog");
1048
1082
  if (parentDialog && parentDialog.open) {
1049
1083
  parentDialog.append(this.popup);
1050
1084
  } else {
1051
- (figGetOverlayRoot() ?? document.body).append(this.popup);
1085
+ (figGetOverlayRoot(this) ?? document.body).append(this.popup);
1052
1086
  }
1053
1087
  }
1054
1088
 
@@ -1331,7 +1365,7 @@ class FigTooltip extends HTMLElement {
1331
1365
  FigTooltip.#documentExitListenersReady = true;
1332
1366
 
1333
1367
  const handlePointerLeftDocument = () => {
1334
- FigTooltip.#dismissHoverTooltipsOnDocumentExit();
1368
+ FigTooltip.dismissHoverTooltips();
1335
1369
  };
1336
1370
  const handleViewportChange = () => {
1337
1371
  FigTooltip.#dismissTooltipsOnViewportChange();
@@ -1372,13 +1406,20 @@ class FigTooltip extends HTMLElement {
1372
1406
  });
1373
1407
  }
1374
1408
 
1375
- static #dismissHoverTooltipsOnDocumentExit() {
1409
+ /**
1410
+ * Dismiss open/pending hover tooltips and programmatic {@link FigTooltip.show} tips.
1411
+ * Skips `show`-persisted and `action="click"` tooltips. Call when opening menus/dialogs.
1412
+ */
1413
+ static dismissHoverTooltips() {
1376
1414
  for (const node of document.querySelectorAll("fig-tooltip")) {
1377
1415
  if (!(node instanceof FigTooltip)) continue;
1378
1416
  if (node.action !== "hover") continue;
1379
1417
  if (node.#showPersisted) continue;
1380
1418
  if (node.isOpen || node.timeout) node.hidePopup();
1381
1419
  }
1420
+ for (const anchor of Array.from(FigTooltip.#programmaticAnchors)) {
1421
+ FigTooltip.hide(anchor);
1422
+ }
1382
1423
  }
1383
1424
 
1384
1425
  static #programmatic = new WeakMap();
@@ -1423,13 +1464,13 @@ class FigTooltip extends HTMLElement {
1423
1464
  popup.append(content);
1424
1465
 
1425
1466
  if (supportsPopover) {
1426
- (figGetOverlayRoot() ?? document.body).append(popup);
1467
+ (figGetOverlayRoot(anchor) ?? document.body).append(popup);
1427
1468
  } else {
1428
1469
  const parentDialog = anchor.closest?.("dialog");
1429
1470
  if (parentDialog && parentDialog.open) {
1430
1471
  parentDialog.append(popup);
1431
1472
  } else {
1432
- (figGetOverlayRoot() ?? document.body).append(popup);
1473
+ (figGetOverlayRoot(anchor) ?? document.body).append(popup);
1433
1474
  }
1434
1475
  }
1435
1476
 
@@ -1660,6 +1701,7 @@ class FigDialog extends HTMLDialogElement {
1660
1701
  // other FigUI overlays (same helper as fig-popup).
1661
1702
  this._assignStackingOrder();
1662
1703
  this.addEventListener("close", this._boundRestoreFocus, { once: true });
1704
+ FigTooltip.dismissHoverTooltips();
1663
1705
  return super.show();
1664
1706
  }
1665
1707
 
@@ -1668,6 +1710,7 @@ class FigDialog extends HTMLDialogElement {
1668
1710
  // Top layer owns stacking; clear any prior non-modal inline z-index.
1669
1711
  this._clearStackingOrder();
1670
1712
  this.addEventListener("close", this._boundRestoreFocus, { once: true });
1713
+ FigTooltip.dismissHoverTooltips();
1671
1714
  return super.showModal();
1672
1715
  }
1673
1716
 
@@ -2651,6 +2694,11 @@ class FigPopup extends HTMLDialogElement {
2651
2694
 
2652
2695
  const anchor = this.resolveAnchor();
2653
2696
  if (anchor?.classList) anchor.classList.add("has-popup-open");
2697
+
2698
+ // Tooltips lose to interactive overlays (menus, selects, popovers).
2699
+ if (this.getAttribute("variant") !== "tooltip") {
2700
+ FigTooltip.dismissHoverTooltips();
2701
+ }
2654
2702
  }
2655
2703
 
2656
2704
  hidePopup() {
@@ -8017,6 +8065,7 @@ const GRADIENT_INTERPOLATION_SPACES = [
8017
8065
  "display-p3",
8018
8066
  "oklab",
8019
8067
  "oklch",
8068
+ "hsl",
8020
8069
  ];
8021
8070
  const GRADIENT_HUE_INTERPOLATIONS = [
8022
8071
  "shorter",
@@ -8024,6 +8073,7 @@ const GRADIENT_HUE_INTERPOLATIONS = [
8024
8073
  "increasing",
8025
8074
  "decreasing",
8026
8075
  ];
8076
+ const GRADIENT_HUE_SPACES = new Set(["oklch", "hsl"]);
8027
8077
 
8028
8078
  const GRADIENT_PICKER_SPACES = ["srgb", "srgb-linear", "oklab", "oklch"];
8029
8079
 
@@ -8052,7 +8102,7 @@ function gradientToValueShape(gradient) {
8052
8102
  ...normalized,
8053
8103
  interpolationSpace: normalized.interpolationSpace,
8054
8104
  };
8055
- if (normalized.interpolationSpace === "oklch") {
8105
+ if (GRADIENT_HUE_SPACES.has(normalized.interpolationSpace)) {
8056
8106
  output.hueInterpolation = normalized.hueInterpolation;
8057
8107
  } else {
8058
8108
  delete output.hueInterpolation;
@@ -8065,8 +8115,8 @@ function gradientInterpolationClause(gradient) {
8065
8115
  if (normalized.interpolationSpace === "srgb") {
8066
8116
  return "";
8067
8117
  }
8068
- if (normalized.interpolationSpace === "oklch") {
8069
- return `in oklch ${normalized.hueInterpolation} hue`;
8118
+ if (GRADIENT_HUE_SPACES.has(normalized.interpolationSpace)) {
8119
+ return `in ${normalized.interpolationSpace} ${normalized.hueInterpolation} hue`;
8070
8120
  }
8071
8121
  return `in ${normalized.interpolationSpace}`;
8072
8122
  }
@@ -8215,6 +8265,25 @@ function figSampleGradientAt(
8215
8265
  r = rgb.r;
8216
8266
  g = rgb.g;
8217
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);
8218
8287
  } else {
8219
8288
  const lab1 = figRGBToOklab(c1.r, c1.g, c1.b);
8220
8289
  const lab2 = figRGBToOklab(c2.r, c2.g, c2.b);
@@ -8230,6 +8299,33 @@ function figSampleGradientAt(
8230
8299
  return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
8231
8300
  }
8232
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
+
8233
8329
  function hslToP3(h, s, l) {
8234
8330
  const sRGB = hslToSRGB(h, s, l);
8235
8331
  return sRGB.map((c) => +(c / 255).toFixed(4));
@@ -8294,6 +8390,7 @@ class FigInputFill extends HTMLElement {
8294
8390
  angle: 180,
8295
8391
  interpolationSpace: "srgb",
8296
8392
  hueInterpolation: "shorter",
8393
+ opacity: 1,
8297
8394
  stops: [
8298
8395
  { position: 0, color: "#D9D9D9", opacity: 100 },
8299
8396
  { position: 100, color: "#737373", opacity: 100 },
@@ -8354,6 +8451,15 @@ class FigInputFill extends HTMLElement {
8354
8451
  ...this.#gradient,
8355
8452
  ...parsed.gradient,
8356
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
+ );
8357
8463
  }
8358
8464
  break;
8359
8465
  case "image":
@@ -8429,10 +8535,20 @@ class FigInputFill extends HTMLElement {
8429
8535
  }
8430
8536
  }
8431
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
+
8432
8546
  #fillPickerSwatchAlpha() {
8433
8547
  switch (this.#fillType) {
8434
8548
  case "solid":
8435
8549
  return this.#solid.alpha;
8550
+ case "gradient":
8551
+ return this.#gradient.opacity ?? 1;
8436
8552
  case "image":
8437
8553
  return this.#image.opacity ?? 1;
8438
8554
  case "video":
@@ -8490,9 +8606,9 @@ class FigInputFill extends HTMLElement {
8490
8606
  const opacityHtml = (value) =>
8491
8607
  showAlpha
8492
8608
  ? `<fig-tooltip text="Opacity">
8493
- <fig-input-number
8609
+ <fig-input-number
8494
8610
  class="fig-input-fill-opacity"
8495
- placeholder="##"
8611
+ placeholder="##"
8496
8612
  min="0"
8497
8613
  max="100"
8498
8614
  value="${value}"
@@ -8522,7 +8638,8 @@ class FigInputFill extends HTMLElement {
8522
8638
  this.#gradient.type.charAt(0).toUpperCase() +
8523
8639
  this.#gradient.type.slice(1);
8524
8640
  controlsHtml = `
8525
- <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))}`;
8526
8643
  break;
8527
8644
  }
8528
8645
 
@@ -8662,6 +8779,7 @@ class FigInputFill extends HTMLElement {
8662
8779
  this.#solid.alpha = alpha;
8663
8780
  break;
8664
8781
  case "gradient":
8782
+ this.#gradient.opacity = alpha;
8665
8783
  break;
8666
8784
  case "image":
8667
8785
  this.#image.opacity = alpha;
@@ -8710,6 +8828,12 @@ class FigInputFill extends HTMLElement {
8710
8828
  this.#gradient.type.slice(1);
8711
8829
  label.textContent = newLabel;
8712
8830
  }
8831
+ if (this.#opacityInput) {
8832
+ this.#opacityInput.setAttribute(
8833
+ "value",
8834
+ Math.round((this.#gradient.opacity ?? 1) * 100),
8835
+ );
8836
+ }
8713
8837
  break;
8714
8838
  }
8715
8839
  case "image":
@@ -8786,7 +8910,18 @@ class FigInputFill extends HTMLElement {
8786
8910
  this.#gradient.type.charAt(0).toUpperCase() +
8787
8911
  this.#gradient.type.slice(1);
8788
8912
  controlsHtml = `
8789
- <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>` : ""}`;
8790
8925
  break;
8791
8926
  }
8792
8927
  case "image":
@@ -8882,6 +9017,7 @@ class FigInputFill extends HTMLElement {
8882
9017
  this.#solid.alpha = alpha;
8883
9018
  break;
8884
9019
  case "gradient":
9020
+ this.#gradient.opacity = alpha;
8885
9021
  break;
8886
9022
  case "image":
8887
9023
  this.#image.opacity = alpha;
@@ -9726,6 +9862,11 @@ class FigInputGradient extends HTMLElement {
9726
9862
  .join(", ");
9727
9863
  const interp = gradientInterpolationClause(gradient);
9728
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
+ }
9729
9870
  if (gradient.type === "radial") {
9730
9871
  return `radial-gradient(circle at ${gradient.centerX}% ${gradient.centerY}%${interpolation}, ${stops})`;
9731
9872
  }
@@ -11196,6 +11337,9 @@ customElements.define("fig-swatch", FigSwatch);
11196
11337
  * @attr {string} aria-label - Accessible label forwarded to generated video
11197
11338
  * @attr {string} aria-labelledby - Accessible label reference forwarded to generated video
11198
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
+ *
11199
11343
  * Sizing model:
11200
11344
  * - Default: host shrinkwraps to its inner <img>/<video> intrinsic size.
11201
11345
  * - `size` attribute applies a token-sized square.
@@ -11534,12 +11678,41 @@ class FigMedia extends HTMLElement {
11534
11678
  this.#mediaEl.playsInline = true;
11535
11679
  this.#syncMediaAccessibility();
11536
11680
  this.#syncControlsVisibility();
11681
+ this.#syncControlsDisabled();
11537
11682
  }
11538
11683
 
11539
11684
  get mediaEl() {
11540
11685
  return this.#mediaEl;
11541
11686
  }
11542
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
+
11543
11716
  #syncControlsVisibility() {
11544
11717
  if (this.mediaKind !== "video") {
11545
11718
  this.#removeControls();
@@ -11554,6 +11727,7 @@ class FigMedia extends HTMLElement {
11554
11727
  this.#controlsEl = userControls;
11555
11728
  }
11556
11729
  this.#wireControlsToMedia();
11730
+ this.#syncControlsDisabled();
11557
11731
  return;
11558
11732
  }
11559
11733
  if (this.#isEnabledAttr("controls", false)) {
@@ -11573,6 +11747,7 @@ class FigMedia extends HTMLElement {
11573
11747
  if (existingControls) {
11574
11748
  this.#controlsEl = existingControls;
11575
11749
  this.#wireControlsToMedia();
11750
+ this.#syncControlsDisabled();
11576
11751
  return;
11577
11752
  }
11578
11753
  const controls = document.createElement("fig-media-controls");
@@ -11580,6 +11755,7 @@ class FigMedia extends HTMLElement {
11580
11755
  this.append(controls);
11581
11756
  this.#controlsEl = controls;
11582
11757
  this.#wireControlsToMedia();
11758
+ this.#syncControlsDisabled();
11583
11759
  }
11584
11760
 
11585
11761
  #wireControlsToMedia() {
@@ -11588,6 +11764,7 @@ class FigMedia extends HTMLElement {
11588
11764
  this.#controlsWiredFor === this.#mediaEl &&
11589
11765
  this.#controlsWiredControls === this.#controlsEl
11590
11766
  ) {
11767
+ this.#syncControlsDisabled();
11591
11768
  return;
11592
11769
  }
11593
11770
  this.#unwireControls();
@@ -11599,6 +11776,8 @@ class FigMedia extends HTMLElement {
11599
11776
 
11600
11777
  let pendingSeekTime = null;
11601
11778
  const syncFromVideo = () => {
11779
+ this.#syncControlsDisabled();
11780
+ if (!this.#hasPlayableVideo()) return;
11602
11781
  controls.playing = !video.paused && !video.ended;
11603
11782
  if (Number.isFinite(video.duration)) controls.duration = video.duration;
11604
11783
  if (pendingSeekTime !== null) {
@@ -11611,11 +11790,13 @@ class FigMedia extends HTMLElement {
11611
11790
  controls.time = video.currentTime || 0;
11612
11791
  };
11613
11792
  const onPlay = () => {
11793
+ if (!this.#hasPlayableVideo()) return;
11614
11794
  const p = video.play?.();
11615
11795
  if (p && typeof p.catch === "function") p.catch(() => {});
11616
11796
  };
11617
11797
  const onPause = () => video.pause?.();
11618
11798
  const onSeek = (e) => {
11799
+ if (!this.#hasPlayableVideo()) return;
11619
11800
  const next = Number(e?.detail?.time);
11620
11801
  if (!Number.isFinite(next)) return;
11621
11802
  pendingSeekTime = next;
@@ -12189,13 +12370,14 @@ customElements.define("fig-card", FigCard);
12189
12370
  * - `playing` (boolean presence) — current play/pause state
12190
12371
  * - `duration` (number, seconds) — total track length
12191
12372
  * - `time` (number, seconds) — current playhead position
12373
+ * - `disabled` (boolean) — disables play/seek interaction
12192
12374
  *
12193
12375
  * Events:
12194
12376
  * - `play` — emitted when the user toggles playback on (detail: { playing: true })
12195
12377
  * - `pause` — emitted when the user toggles playback off (detail: { playing: false })
12196
12378
  * - `seek` — emitted when the user drags the scrubber (detail: { time })
12197
12379
  *
12198
- * Properties: `playing`, `duration`, `time` mirror the attributes.
12380
+ * Properties: `playing`, `duration`, `time`, `disabled` mirror the attributes.
12199
12381
  */
12200
12382
  class FigMediaControls extends HTMLElement {
12201
12383
  #playBtn = null;
@@ -12206,13 +12388,14 @@ class FigMediaControls extends HTMLElement {
12206
12388
  #rendered = false;
12207
12389
 
12208
12390
  static get observedAttributes() {
12209
- return ["playing", "duration", "time"];
12391
+ return ["playing", "duration", "time", "disabled"];
12210
12392
  }
12211
12393
 
12212
12394
  connectedCallback() {
12213
12395
  this.#render();
12214
12396
  this.#syncPlayingUi();
12215
12397
  this.#syncTimeUi();
12398
+ this.#syncDisabled();
12216
12399
  }
12217
12400
 
12218
12401
  get playing() {
@@ -12249,10 +12432,19 @@ class FigMediaControls extends HTMLElement {
12249
12432
  this.setAttribute("time", String(n));
12250
12433
  }
12251
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
+
12252
12443
  attributeChangedCallback(name) {
12253
12444
  if (!this.#rendered) return;
12254
12445
  if (name === "playing") this.#syncPlayingUi();
12255
12446
  if (name === "duration" || name === "time") this.#syncTimeUi();
12447
+ if (name === "disabled") this.#syncDisabled();
12256
12448
  }
12257
12449
 
12258
12450
  #render() {
@@ -12298,6 +12490,11 @@ class FigMediaControls extends HTMLElement {
12298
12490
  timeEl.textContent = this.#formatTime(this.time);
12299
12491
 
12300
12492
  const handleSeek = (e) => {
12493
+ if (this.disabled) {
12494
+ e.preventDefault?.();
12495
+ e.stopPropagation();
12496
+ return;
12497
+ }
12301
12498
  const host = e.currentTarget;
12302
12499
  const next = Number(host?.value);
12303
12500
  if (!Number.isFinite(next)) return;
@@ -12323,6 +12520,20 @@ class FigMediaControls extends HTMLElement {
12323
12520
  this.#playTooltip = tooltip;
12324
12521
  this.#timeSlider = slider;
12325
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
+ }
12326
12537
  }
12327
12538
 
12328
12539
  #formatTime(seconds) {
@@ -12368,6 +12579,7 @@ class FigMediaControls extends HTMLElement {
12368
12579
  }
12369
12580
 
12370
12581
  toggle() {
12582
+ if (this.disabled) return;
12371
12583
  const next = !this.playing;
12372
12584
  this.playing = next;
12373
12585
  this.dispatchEvent(
@@ -12380,12 +12592,12 @@ class FigMediaControls extends HTMLElement {
12380
12592
  }
12381
12593
 
12382
12594
  play() {
12383
- if (this.playing) return;
12595
+ if (this.disabled || this.playing) return;
12384
12596
  this.toggle();
12385
12597
  }
12386
12598
 
12387
12599
  pause() {
12388
- if (!this.playing) return;
12600
+ if (this.disabled || !this.playing) return;
12389
12601
  this.toggle();
12390
12602
  }
12391
12603
  }
@@ -15410,6 +15622,314 @@ class FigPreview extends HTMLElement {
15410
15622
  }
15411
15623
  customElements.define("fig-preview", FigPreview);
15412
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
+
15413
15933
  /** @type {Record<string, string | { medium: string, small: string }>} */
15414
15934
  const FIG_ICON_TOKENS = {
15415
15935
  chevron: "--icon-16-chevron",