@rogieking/figui3 8.2.1 → 8.4.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-editor.js CHANGED
@@ -32,6 +32,123 @@ function figEditorCreateIcon(name, options = {}) {
32
32
  return icon;
33
33
  }
34
34
 
35
+ function figEditorAppendChildren(parent, children) {
36
+ const append = (child) => {
37
+ if (child === null || child === undefined || child === false) return;
38
+ if (Array.isArray(child)) {
39
+ child.forEach(append);
40
+ return;
41
+ }
42
+ parent.append(child instanceof Node ? child : String(child));
43
+ };
44
+ append(children);
45
+ return parent;
46
+ }
47
+
48
+ function figEditorSetAttributes(element, attributes = {}) {
49
+ for (const [name, value] of Object.entries(attributes)) {
50
+ if (value === null || value === undefined || value === false) continue;
51
+ if (name === "className") {
52
+ element.className = String(value);
53
+ } else if (value === true) {
54
+ element.setAttribute(name, "");
55
+ } else {
56
+ element.setAttribute(name, String(value));
57
+ }
58
+ }
59
+ return element;
60
+ }
61
+
62
+ function figEditorCreateElement(tagName, attributes, children) {
63
+ const element = document.createElement(tagName);
64
+ figEditorSetAttributes(element, attributes);
65
+ return figEditorAppendChildren(element, children);
66
+ }
67
+
68
+ const FIG_EDITOR_SVG_NAMESPACE = "http://www.w3.org/2000/svg";
69
+
70
+ function figEditorCreateSvgElement(tagName, attributes, children) {
71
+ const element = document.createElementNS(FIG_EDITOR_SVG_NAMESPACE, tagName);
72
+ for (const [name, value] of Object.entries(attributes || {})) {
73
+ if (value === null || value === undefined || value === false) continue;
74
+ if (name === "className") {
75
+ element.setAttribute("class", String(value));
76
+ } else if (value === true) {
77
+ element.setAttribute(name, "");
78
+ } else {
79
+ element.setAttribute(name, String(value));
80
+ }
81
+ }
82
+ return figEditorAppendChildren(element, children);
83
+ }
84
+
85
+ function figEditorHexToRgb(hex) {
86
+ const h = String(hex || "").replace(/^#/, "");
87
+ return {
88
+ r: parseInt(h.substring(0, 2), 16) || 0,
89
+ g: parseInt(h.substring(2, 4), 16) || 0,
90
+ b: parseInt(h.substring(4, 6), 16) || 0,
91
+ };
92
+ }
93
+
94
+ function figEditorRgbToHsl(r, g, b) {
95
+ const R = r / 255;
96
+ const G = g / 255;
97
+ const B = b / 255;
98
+ const max = Math.max(R, G, B);
99
+ const min = Math.min(R, G, B);
100
+ const l = (max + min) / 2;
101
+ if (max === min) return { h: 0, s: 0, l: l * 100 };
102
+ const d = max - min;
103
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
104
+ let h;
105
+ switch (max) {
106
+ case R:
107
+ h = ((G - B) / d + (G < B ? 6 : 0)) / 6;
108
+ break;
109
+ case G:
110
+ h = ((B - R) / d + 2) / 6;
111
+ break;
112
+ default:
113
+ h = ((R - G) / d + 4) / 6;
114
+ break;
115
+ }
116
+ return { h: h * 360, s: s * 100, l: l * 100 };
117
+ }
118
+
119
+ function figEditorRgbToLinear(c) {
120
+ const s = c / 255;
121
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
122
+ }
123
+
124
+ function figEditorRgbToOklab(r, g, b) {
125
+ const lr = figEditorRgbToLinear(r);
126
+ const lg = figEditorRgbToLinear(g);
127
+ const lb = figEditorRgbToLinear(b);
128
+ const l_ = Math.cbrt(
129
+ 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb,
130
+ );
131
+ const m_ = Math.cbrt(
132
+ 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb,
133
+ );
134
+ const s_ = Math.cbrt(
135
+ 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb,
136
+ );
137
+ return {
138
+ l: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
139
+ a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
140
+ b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
141
+ };
142
+ }
143
+
144
+ function figEditorOklabToOklch(L, a, b) {
145
+ return {
146
+ l: L,
147
+ c: Math.sqrt(a * a + b * b),
148
+ h: (Math.atan2(b, a) * 180) / Math.PI,
149
+ };
150
+ }
151
+
35
152
  function figEditorCreateOverflowButtons({
36
153
  owner,
37
154
  onStart,
@@ -534,8 +651,8 @@ class FigSelect extends HTMLElement {
534
651
  #initialize() {
535
652
  this.#initialized = true;
536
653
  const shadow = this.attachShadow({ mode: "open" });
537
- shadow.innerHTML = `
538
- <style>
654
+ const style = document.createElement("style");
655
+ style.textContent = `
539
656
  :host {
540
657
  display: inline-flex;
541
658
  position: relative;
@@ -622,8 +739,8 @@ class FigSelect extends HTMLElement {
622
739
  min-height: 0;
623
740
  max-height: inherit;
624
741
  }
625
- </style>
626
742
  `;
743
+ shadow.appendChild(style);
627
744
 
628
745
  const button = document.createElement("fig-button");
629
746
  button.className = "fig-select-trigger";
@@ -1434,6 +1551,59 @@ function parseGradientInterpolationSelectValue(val) {
1434
1551
  let figFillPickerDialogId = 0;
1435
1552
 
1436
1553
  class FigFillPicker extends HTMLElement {
1554
+ // One segment per color space; `advanced` variants live in the overflow menu
1555
+ // and retarget their space's segment when picked.
1556
+ static #GRADIENT_INTERPOLATION_MODES = [
1557
+ { value: "srgb", space: "srgb", title: "Classic", subtitle: "sRGB Linear" },
1558
+ { value: "oklab", space: "oklab", title: "Smooth", subtitle: "OKLab" },
1559
+ {
1560
+ value: "oklch-increasing",
1561
+ space: "oklch",
1562
+ title: "Vibrant",
1563
+ subtitle: "OKLCH Increasing",
1564
+ },
1565
+ {
1566
+ value: "hsl-increasing",
1567
+ space: "hsl",
1568
+ title: "Vivid",
1569
+ subtitle: "HSL Increasing",
1570
+ },
1571
+ {
1572
+ value: "oklch-decreasing",
1573
+ space: "oklch",
1574
+ title: "Vibrant",
1575
+ subtitle: "OKLCH Decreasing",
1576
+ advanced: true,
1577
+ },
1578
+ {
1579
+ value: "hsl-decreasing",
1580
+ space: "hsl",
1581
+ title: "Vivid",
1582
+ subtitle: "HSL Decreasing",
1583
+ advanced: true,
1584
+ },
1585
+ ];
1586
+
1587
+ static #gradientInterpolationMode(value) {
1588
+ const modes = FigFillPicker.#GRADIENT_INTERPOLATION_MODES;
1589
+ const exact = modes.find((mode) => mode.value === value);
1590
+ if (exact) return exact;
1591
+ // External values (e.g. "oklch-shorter") still label against their space.
1592
+ const parsed = parseGradientInterpolationSelectValue(value);
1593
+ const base = modes.find((mode) => mode.space === parsed.interpolationSpace);
1594
+ const spaceLabel =
1595
+ base?.subtitle.split(" ")[0] ?? parsed.interpolationSpace.toUpperCase();
1596
+ const hue = parsed.hueInterpolation;
1597
+ return {
1598
+ value,
1599
+ space: parsed.interpolationSpace,
1600
+ title: base?.title ?? "Custom",
1601
+ subtitle: GRADIENT_HUE_SPACES.has(parsed.interpolationSpace)
1602
+ ? `${spaceLabel} ${hue.charAt(0).toUpperCase()}${hue.slice(1)}`
1603
+ : spaceLabel,
1604
+ };
1605
+ }
1606
+
1437
1607
  #trigger = null;
1438
1608
  #swatch = null;
1439
1609
  #dialog = null;
@@ -1737,6 +1907,9 @@ class FigFillPicker extends HTMLElement {
1737
1907
  bg = "";
1738
1908
  }
1739
1909
  break;
1910
+ case "webcam":
1911
+ bg = this.#webcam.snapshot ? `url(${this.#webcam.snapshot})` : "";
1912
+ break;
1740
1913
  default:
1741
1914
  const slot = this.#customSlots[this.#fillType];
1742
1915
  bg = slot?.element?.getAttribute("swatch-background") || "#D9D9D9";
@@ -1914,40 +2087,58 @@ class FigFillPicker extends HTMLElement {
1914
2087
 
1915
2088
  let headerContent;
1916
2089
  if (allowedModes.length === 1) {
1917
- headerContent = `<h3 class="fig-fill-picker-type-label">${figEditorEscapeAttribute(modeLabels[allowedModes[0]])}</h3>`;
2090
+ headerContent = figEditorCreateElement(
2091
+ "h3",
2092
+ { className: "fig-fill-picker-type-label" },
2093
+ modeLabels[allowedModes[0]],
2094
+ );
1918
2095
  } else {
1919
- const options = allowedModes
1920
- .map(
1921
- (m) =>
1922
- `<fig-select-option value="${figEditorEscapeAttribute(m)}">${figEditorEscapeAttribute(modeLabels[m])}</fig-select-option>`,
1923
- )
1924
- .join("\n ");
1925
- headerContent = `<fig-select class="fig-fill-picker-type" label="Fill type" value="${figEditorEscapeAttribute(this.#fillType)}">
1926
- <fig-select-options>
1927
- ${options}
1928
- </fig-select-options>
1929
- </fig-select>`;
2096
+ const options = figEditorCreateElement(
2097
+ "fig-select-options",
2098
+ {},
2099
+ allowedModes.map((modeName) =>
2100
+ figEditorCreateElement(
2101
+ "fig-select-option",
2102
+ { value: modeName },
2103
+ modeLabels[modeName],
2104
+ ),
2105
+ ),
2106
+ );
2107
+ headerContent = figEditorCreateElement(
2108
+ "fig-select",
2109
+ {
2110
+ className: "fig-fill-picker-type",
2111
+ label: "Fill type",
2112
+ value: this.#fillType,
2113
+ },
2114
+ options,
2115
+ );
1930
2116
  }
1931
2117
 
1932
2118
  // Generate tab containers for all allowed modes
1933
- const tabDivs = allowedModes
1934
- .map(
1935
- (m) =>
1936
- `<div class="fig-fill-picker-tab" data-tab="${figEditorEscapeAttribute(m)}"></div>`,
1937
- )
1938
- .join("\n ");
1939
-
1940
- this.#dialog.innerHTML = `
1941
- <fig-header>
1942
- ${headerContent}
1943
- <fig-button icon variant="ghost" class="fig-fill-picker-close" aria-label="Close fill picker">
1944
- <fig-icon name="close"></fig-icon>
1945
- </fig-button>
1946
- </fig-header>
1947
- <fig-content>
1948
- ${tabDivs}
1949
- </fig-content>
1950
- `;
2119
+ const tabDivs = allowedModes.map((modeName) =>
2120
+ figEditorCreateElement("div", {
2121
+ className: "fig-fill-picker-tab",
2122
+ "data-tab": modeName,
2123
+ }),
2124
+ );
2125
+ const closeButton = figEditorCreateElement(
2126
+ "fig-button",
2127
+ {
2128
+ icon: true,
2129
+ variant: "ghost",
2130
+ className: "fig-fill-picker-close",
2131
+ "aria-label": "Close fill picker",
2132
+ },
2133
+ figEditorCreateIcon("close"),
2134
+ );
2135
+ this.#dialog.replaceChildren(
2136
+ figEditorCreateElement("fig-header", {}, [
2137
+ headerContent,
2138
+ closeButton,
2139
+ ]),
2140
+ figEditorCreateElement("fig-content", {}, tabDivs),
2141
+ );
1951
2142
 
1952
2143
  document.body.appendChild(this.#dialog);
1953
2144
 
@@ -2080,7 +2271,7 @@ class FigFillPicker extends HTMLElement {
2080
2271
 
2081
2272
  // Update tab-specific UI after visibility change
2082
2273
  if (tabName === "gradient") {
2083
- // Use RAF to ensure layout is complete before updating angle input
2274
+ // Use RAF to ensure layout is complete before refreshing gradient UI
2084
2275
  this.#scheduleFrame(() => {
2085
2276
  this.#updateGradientUI();
2086
2277
  const barInput = tab.querySelector(".fig-fill-picker-gradient-bar-input");
@@ -2136,51 +2327,106 @@ class FigFillPicker extends HTMLElement {
2136
2327
  const container = this.#dialog.querySelector('[data-tab="solid"]');
2137
2328
  const showAlpha = this.getAttribute("alpha") !== "false";
2138
2329
 
2139
- container.innerHTML = `
2140
- <fig-preview class="fig-fill-picker-color-area">
2141
- <canvas width="200" height="200"></canvas>
2142
- <fig-handle
2143
- aria-label="Color saturation and brightness"
2144
- role="slider"
2145
- aria-valuemin="0"
2146
- aria-valuemax="100"
2147
- type="color"
2148
- color="${this.#hsvToHex({ ...this.#color, a: 1 })}"
2149
- data-no-color-picker
2150
- drag
2151
- drag-surface=".fig-fill-picker-color-area"
2152
- drag-axes="x,y"
2153
- drag-snapping="modifier"
2154
- ></fig-handle>
2155
- </fig-preview>
2156
- <div class="fig-fill-picker-sliders${showAlpha ? "" : " is-hue-only"}">
2157
- <fig-tooltip text="Sample color"><fig-button icon variant="ghost" class="fig-fill-picker-eyedropper" aria-label="Sample color"><fig-icon name="eyedropper"></fig-icon></fig-button></fig-tooltip>
2158
- <fig-slider type="hue" variant="classic" text="false" min="0" max="360" aria-label="Hue" value="${
2159
- this.#color.h
2160
- }"></fig-slider>
2161
- ${
2162
- showAlpha
2163
- ? `<fig-slider type="opacity" variant="classic" text="false" min="0" max="100" aria-label="Opacity" value="${
2164
- this.#color.a * 100
2165
- }" color="${this.#hsvToHex(this.#color)}"></fig-slider>`
2166
- : ""
2167
- }
2168
- </div>
2169
- <fig-field class="fig-fill-picker-inputs">
2170
- <fig-select class="fig-fill-picker-input-mode" label="Color value format" value="${figEditorEscapeAttribute(this.#colorInputMode)}">
2171
- <fig-select-options>
2172
- <fig-select-option value="hex">Hex</fig-select-option>
2173
- <fig-select-option value="rgb">RGB</fig-select-option>
2174
- <fig-select-option value="css">CSS</fig-select-option>
2175
- <fig-select-option value="hsl">HSL</fig-select-option>
2176
- <fig-select-option value="hsb">HSB</fig-select-option>
2177
- <fig-select-option value="lab">LAB</fig-select-option>
2178
- <fig-select-option value="lch">LCH</fig-select-option>
2179
- </fig-select-options>
2180
- </fig-select>
2181
- <span class="fig-fill-picker-input-fields"></span>
2182
- </fig-field>
2183
- `;
2330
+ const canvas = figEditorCreateElement("canvas", {
2331
+ width: "200",
2332
+ height: "200",
2333
+ });
2334
+ const colorHandle = figEditorCreateElement("fig-handle", {
2335
+ "aria-label": "Color saturation and brightness",
2336
+ role: "slider",
2337
+ "aria-valuemin": "0",
2338
+ "aria-valuemax": "100",
2339
+ type: "color",
2340
+ color: this.#hsvToHex({ ...this.#color, a: 1 }),
2341
+ "data-no-color-picker": true,
2342
+ drag: true,
2343
+ "drag-surface": ".fig-fill-picker-color-area",
2344
+ "drag-axes": "x,y",
2345
+ "drag-snapping": "modifier",
2346
+ });
2347
+ const preview = figEditorCreateElement(
2348
+ "fig-preview",
2349
+ { className: "fig-fill-picker-color-area" },
2350
+ [canvas, colorHandle],
2351
+ );
2352
+ const eyedropperControl = figEditorCreateElement(
2353
+ "fig-tooltip",
2354
+ { text: "Sample color" },
2355
+ figEditorCreateElement(
2356
+ "fig-button",
2357
+ {
2358
+ icon: true,
2359
+ variant: "ghost",
2360
+ className: "fig-fill-picker-eyedropper",
2361
+ "aria-label": "Sample color",
2362
+ },
2363
+ figEditorCreateIcon("eyedropper"),
2364
+ ),
2365
+ );
2366
+ const sliders = figEditorCreateElement(
2367
+ "div",
2368
+ {
2369
+ className: `fig-fill-picker-sliders${showAlpha ? "" : " is-hue-only"}`,
2370
+ },
2371
+ [
2372
+ eyedropperControl,
2373
+ figEditorCreateElement("fig-slider", {
2374
+ type: "hue",
2375
+ variant: "classic",
2376
+ text: "false",
2377
+ min: "0",
2378
+ max: "360",
2379
+ "aria-label": "Hue",
2380
+ value: this.#color.h,
2381
+ }),
2382
+ showAlpha
2383
+ ? figEditorCreateElement("fig-slider", {
2384
+ type: "opacity",
2385
+ variant: "classic",
2386
+ text: "false",
2387
+ min: "0",
2388
+ max: "100",
2389
+ "aria-label": "Opacity",
2390
+ value: this.#color.a * 100,
2391
+ color: this.#hsvToHex(this.#color),
2392
+ })
2393
+ : null,
2394
+ ],
2395
+ );
2396
+ const formatOptions = figEditorCreateElement(
2397
+ "fig-select-options",
2398
+ {},
2399
+ [
2400
+ ["hex", "Hex"],
2401
+ ["rgb", "RGB"],
2402
+ ["css", "CSS"],
2403
+ ["hsl", "HSL"],
2404
+ ["hsb", "HSB"],
2405
+ ["lab", "LAB"],
2406
+ ["lch", "LCH"],
2407
+ ].map(([value, label]) =>
2408
+ figEditorCreateElement("fig-select-option", { value }, label),
2409
+ ),
2410
+ );
2411
+ const inputs = figEditorCreateElement(
2412
+ "fig-field",
2413
+ { className: "fig-fill-picker-inputs" },
2414
+ [
2415
+ figEditorCreateElement(
2416
+ "fig-select",
2417
+ {
2418
+ className: "fig-fill-picker-input-mode",
2419
+ label: "Color value format",
2420
+ value: this.#colorInputMode,
2421
+ },
2422
+ formatOptions,
2423
+ ),
2424
+ figEditorCreateElement("span", {
2425
+ className: "fig-fill-picker-input-fields",
2426
+ }),
2427
+ ],
2428
+ );
2429
+ container.replaceChildren(preview, sliders, inputs);
2184
2430
 
2185
2431
  // Setup color area
2186
2432
  this.#colorArea = container.querySelector("canvas");
@@ -2469,11 +2715,18 @@ class FigFillPicker extends HTMLElement {
2469
2715
  );
2470
2716
  if (!container) return;
2471
2717
 
2472
- const wrap = (tooltip, html) =>
2473
- `<fig-tooltip text="${tooltip}">${html}</fig-tooltip>`;
2718
+ const wrap = (tooltip, child) =>
2719
+ figEditorCreateElement("fig-tooltip", { text: tooltip }, child);
2474
2720
 
2475
2721
  const num = (cls, label, min, max, step, units) =>
2476
- `<fig-input-number class="${cls}" aria-label="${label}" min="${min}" max="${max}"${step != null ? ` step="${step}"` : ""}${units ? ` units="${units}"` : ""}></fig-input-number>`;
2722
+ figEditorCreateElement("fig-input-number", {
2723
+ className: cls,
2724
+ "aria-label": label,
2725
+ min,
2726
+ max,
2727
+ step,
2728
+ units,
2729
+ });
2477
2730
 
2478
2731
  const showAlpha = this.getAttribute("alpha") !== "false";
2479
2732
  const alphaField = () =>
@@ -2482,64 +2735,119 @@ class FigFillPicker extends HTMLElement {
2482
2735
  "Alpha",
2483
2736
  num("fig-fill-picker-ci-a", "Alpha", 0, 100, 0.1, "%"),
2484
2737
  )
2485
- : "";
2738
+ : null;
2739
+ const combo = (children, extraClass = "") =>
2740
+ figEditorCreateElement(
2741
+ "div",
2742
+ {
2743
+ className: ["input-combo", extraClass].filter(Boolean).join(" "),
2744
+ },
2745
+ children,
2746
+ );
2486
2747
 
2487
- let html;
2748
+ let content;
2488
2749
  switch (this.#colorInputMode) {
2489
2750
  case "rgb":
2490
- html = `<div class="input-combo">
2491
- ${wrap("Red", num("fig-fill-picker-ci-r", "Red", 0, 255))}
2492
- ${wrap("Green", num("fig-fill-picker-ci-g", "Green", 0, 255))}
2493
- ${wrap("Blue", num("fig-fill-picker-ci-b", "Blue", 0, 255))}
2494
- ${alphaField()}
2495
- </div>`;
2751
+ content = combo([
2752
+ wrap("Red", num("fig-fill-picker-ci-r", "Red", 0, 255)),
2753
+ wrap("Green", num("fig-fill-picker-ci-g", "Green", 0, 255)),
2754
+ wrap("Blue", num("fig-fill-picker-ci-b", "Blue", 0, 255)),
2755
+ alphaField(),
2756
+ ]);
2496
2757
  break;
2497
2758
  case "hsl":
2498
- html = `<div class="input-combo">
2499
- ${wrap("Hue", num("fig-fill-picker-ci-h", "Hue", 0, 360))}
2500
- ${wrap("Saturation", num("fig-fill-picker-ci-s", "Saturation", 0, 100))}
2501
- ${wrap("Lightness", num("fig-fill-picker-ci-l", "Lightness", 0, 100))}
2502
- ${alphaField()}
2503
- </div>`;
2759
+ content = combo([
2760
+ wrap("Hue", num("fig-fill-picker-ci-h", "Hue", 0, 360)),
2761
+ wrap(
2762
+ "Saturation",
2763
+ num("fig-fill-picker-ci-s", "Saturation", 0, 100),
2764
+ ),
2765
+ wrap(
2766
+ "Lightness",
2767
+ num("fig-fill-picker-ci-l", "Lightness", 0, 100),
2768
+ ),
2769
+ alphaField(),
2770
+ ]);
2504
2771
  break;
2505
2772
  case "hsb":
2506
- html = `<div class="input-combo">
2507
- ${wrap("Hue", num("fig-fill-picker-ci-h", "Hue", 0, 360))}
2508
- ${wrap("Saturation", num("fig-fill-picker-ci-s", "Saturation", 0, 100))}
2509
- ${wrap("Brightness", num("fig-fill-picker-ci-v", "Brightness", 0, 100))}
2510
- ${alphaField()}
2511
- </div>`;
2773
+ content = combo([
2774
+ wrap("Hue", num("fig-fill-picker-ci-h", "Hue", 0, 360)),
2775
+ wrap(
2776
+ "Saturation",
2777
+ num("fig-fill-picker-ci-s", "Saturation", 0, 100),
2778
+ ),
2779
+ wrap(
2780
+ "Brightness",
2781
+ num("fig-fill-picker-ci-v", "Brightness", 0, 100),
2782
+ ),
2783
+ alphaField(),
2784
+ ]);
2512
2785
  break;
2513
2786
  case "lab":
2514
- html = `<div class="input-combo">
2515
- ${wrap("Lightness", num("fig-fill-picker-ci-okl", "Lightness", 0, 100))}
2516
- ${wrap("Green-Red axis", num("fig-fill-picker-ci-oka", "Green-Red axis", -0.4, 0.4, 0.001))}
2517
- ${wrap("Blue-Yellow axis", num("fig-fill-picker-ci-okb", "Blue-Yellow axis", -0.4, 0.4, 0.001))}
2518
- ${alphaField()}
2519
- </div>`;
2787
+ content = combo([
2788
+ wrap(
2789
+ "Lightness",
2790
+ num("fig-fill-picker-ci-okl", "Lightness", 0, 100),
2791
+ ),
2792
+ wrap(
2793
+ "Green-Red axis",
2794
+ num(
2795
+ "fig-fill-picker-ci-oka",
2796
+ "Green-Red axis",
2797
+ -0.4,
2798
+ 0.4,
2799
+ 0.001,
2800
+ ),
2801
+ ),
2802
+ wrap(
2803
+ "Blue-Yellow axis",
2804
+ num(
2805
+ "fig-fill-picker-ci-okb",
2806
+ "Blue-Yellow axis",
2807
+ -0.4,
2808
+ 0.4,
2809
+ 0.001,
2810
+ ),
2811
+ ),
2812
+ alphaField(),
2813
+ ]);
2520
2814
  break;
2521
2815
  case "lch":
2522
- html = `<div class="input-combo">
2523
- ${wrap("Lightness", num("fig-fill-picker-ci-okl", "Lightness", 0, 100))}
2524
- ${wrap("Chroma", num("fig-fill-picker-ci-okc", "Chroma", 0, 0.4, 0.001))}
2525
- ${wrap("Hue", num("fig-fill-picker-ci-okh", "Hue", 0, 360))}
2526
- ${alphaField()}
2527
- </div>`;
2816
+ content = combo([
2817
+ wrap(
2818
+ "Lightness",
2819
+ num("fig-fill-picker-ci-okl", "Lightness", 0, 100),
2820
+ ),
2821
+ wrap(
2822
+ "Chroma",
2823
+ num("fig-fill-picker-ci-okc", "Chroma", 0, 0.4, 0.001),
2824
+ ),
2825
+ wrap("Hue", num("fig-fill-picker-ci-okh", "Hue", 0, 360)),
2826
+ alphaField(),
2827
+ ]);
2528
2828
  break;
2529
2829
  case "css":
2530
- html = `<fig-input-text class="fig-fill-picker-ci-css" aria-label="CSS color" placeholder="rgba(0, 0, 0, 1)"></fig-input-text>`;
2830
+ content = figEditorCreateElement("fig-input-text", {
2831
+ className: "fig-fill-picker-ci-css",
2832
+ "aria-label": "CSS color",
2833
+ placeholder: "rgba(0, 0, 0, 1)",
2834
+ });
2531
2835
  break;
2532
2836
  default: // hex
2533
- html = showAlpha
2534
- ? `<div class="input-combo fig-fill-picker-ci-hex-row">
2535
- <fig-input-text class="fig-fill-picker-ci-hex" aria-label="Hex color" placeholder="FFFFFF"></fig-input-text>
2536
- ${alphaField()}
2537
- </div>`
2538
- : `<fig-input-text class="fig-fill-picker-ci-hex" aria-label="Hex color" placeholder="FFFFFF"></fig-input-text>`;
2837
+ {
2838
+ const hexInput = figEditorCreateElement("fig-input-text", {
2839
+ className: "fig-fill-picker-ci-hex",
2840
+ "aria-label": "Hex color",
2841
+ placeholder: "FFFFFF",
2842
+ });
2843
+ content = showAlpha
2844
+ ? combo([hexInput, alphaField()], "fig-fill-picker-ci-hex-row")
2845
+ : hexInput;
2846
+ }
2539
2847
  break;
2540
2848
  }
2541
2849
 
2542
- container.innerHTML = html;
2850
+ container.replaceChildren(content);
2543
2851
  this.#wireColorInputEvents();
2544
2852
  this.#scheduleFrame(() => this.#updateColorInputs());
2545
2853
  }
@@ -2728,124 +3036,222 @@ class FigFillPicker extends HTMLElement {
2728
3036
  #initGradientTab() {
2729
3037
  const container = this.#dialog.querySelector('[data-tab="gradient"]');
2730
3038
  const interpolationValue = gradientInterpolationSelectValue(this.#gradient);
2731
-
2732
- container.innerHTML = `
2733
- <fig-field class="fig-fill-picker-gradient-header">
2734
- <fig-select class="fig-fill-picker-gradient-type" label="Gradient type" value="${figEditorEscapeAttribute(this.#gradient.type)}">
2735
- <fig-select-options>
2736
- <fig-select-option value="linear">Linear</fig-select-option>
2737
- <fig-select-option value="radial">Radial</fig-select-option>
2738
- <fig-select-option value="angular">Angular</fig-select-option>
2739
- </fig-select-options>
2740
- </fig-select>
2741
- <fig-tooltip text="Gradient angle">
2742
- <fig-input-number class="fig-fill-picker-gradient-angle" aria-label="Gradient angle" value="${
2743
- (this.#gradient.angle - 90 + 360) % 360
2744
- }" min="0" max="360" units="°" wrap></fig-input-number>
2745
- </fig-tooltip>
2746
- <div class="fig-fill-picker-gradient-center input-combo" style="display: none;">
2747
- <fig-input-number min="0" max="100" aria-label="Gradient center X" value="${
2748
- this.#gradient.centerX
2749
- }" units="%" class="fig-fill-picker-gradient-cx"></fig-input-number>
2750
- <fig-input-number min="0" max="100" aria-label="Gradient center Y" value="${
2751
- this.#gradient.centerY
2752
- }" units="%" class="fig-fill-picker-gradient-cy"></fig-input-number>
2753
- </div>
2754
- <div class="fig-fill-picker-gradient-actions">
2755
- <fig-tooltip text="Flip gradient">
2756
- <fig-button icon variant="ghost" class="fig-fill-picker-gradient-flip" aria-label="Flip gradient">
2757
- <fig-icon name="swap"></fig-icon>
2758
- </fig-button>
2759
- </fig-tooltip>
2760
- <fig-tooltip text="Rotate gradient">
2761
- <fig-button icon variant="ghost" class="fig-fill-picker-gradient-rotate" aria-label="Rotate gradient">
2762
- <fig-icon name="rotate"></fig-icon>
2763
- </fig-button>
2764
- </fig-tooltip>
2765
- </div>
2766
- </fig-field>
2767
- <fig-preview class="fig-fill-picker-gradient-preview">
2768
- <fig-input-gradient class="fig-fill-picker-gradient-bar-input" aria-label="Gradient stops" edit="true" mode="tip" size="large" value='${JSON.stringify({ type: "gradient", gradient: gradientToValueShape(this.#gradient) })}'></fig-input-gradient>
2769
- </fig-preview>
2770
- <div class="fig-fill-picker-gradient-stops">
2771
- <fig-header class="fig-fill-picker-gradient-stops-header" borderless>
2772
- <span>Stops</span>
2773
- <fig-button icon variant="ghost" class="fig-fill-picker-gradient-add" aria-label="Add gradient stop" title="Add stop">
2774
- <fig-icon name="add"></fig-icon>
2775
- </fig-button>
2776
- </fig-header>
2777
- <div class="fig-fill-picker-gradient-stops-list">
2778
- <fig-reorder></fig-reorder>
2779
- </div>
2780
- </div>
2781
- <div class="fig-fill-picker-gradient-interpolation">
2782
- <fig-header class="fig-fill-picker-gradient-interpolation-header" borderless>
2783
- <span>Color interpolation</span>
2784
- </fig-header>
2785
- <fig-field class="fig-fill-picker-gradient-interpolation-field">
2786
- <fig-select class="fig-fill-picker-gradient-space" label="Color interpolation" full value="${figEditorEscapeAttribute(interpolationValue)}">
2787
- <fig-select-options>
2788
- ${this.#gradientInterpolationOptionsMarkup()}
2789
- </fig-select-options>
2790
- </fig-select>
2791
- </fig-field>
2792
- </div>
2793
- `;
2794
-
2795
- this.#updateGradientUI();
2796
- this.#setupGradientEvents(container);
2797
- }
2798
-
2799
- #gradientInterpolationOptionsMarkup() {
2800
- const hueMethods = ["shorter", "longer", "increasing", "decreasing"];
2801
- const groups = [
3039
+ const gradientType = figEditorCreateElement(
3040
+ "fig-select",
2802
3041
  {
2803
- label: "Linear",
2804
- options: [
2805
- { value: "srgb", label: "sRGB" },
2806
- ],
3042
+ className: "fig-fill-picker-gradient-type",
3043
+ label: "Gradient type",
3044
+ value: this.#gradient.type,
2807
3045
  },
3046
+ figEditorCreateElement(
3047
+ "fig-select-options",
3048
+ {},
3049
+ [
3050
+ ["linear", "Linear"],
3051
+ ["radial", "Radial"],
3052
+ ["angular", "Angular"],
3053
+ ].map(([value, label]) =>
3054
+ figEditorCreateElement("fig-select-option", { value }, label),
3055
+ ),
3056
+ ),
3057
+ );
3058
+ const createAction = (text, className, iconName) =>
3059
+ figEditorCreateElement(
3060
+ "fig-tooltip",
3061
+ { text },
3062
+ figEditorCreateElement(
3063
+ "fig-button",
3064
+ {
3065
+ icon: true,
3066
+ variant: "ghost",
3067
+ className,
3068
+ "aria-label": text,
3069
+ },
3070
+ figEditorCreateIcon(iconName),
3071
+ ),
3072
+ );
3073
+ const actions = figEditorCreateElement(
3074
+ "div",
3075
+ { className: "fig-fill-picker-gradient-actions" },
3076
+ [
3077
+ createAction(
3078
+ "Flip gradient",
3079
+ "fig-fill-picker-gradient-flip",
3080
+ "swap",
3081
+ ),
3082
+ createAction(
3083
+ "Rotate gradient",
3084
+ "fig-fill-picker-gradient-rotate",
3085
+ "rotate",
3086
+ ),
3087
+ ],
3088
+ );
3089
+ const header = figEditorCreateElement(
3090
+ "fig-field",
3091
+ { className: "fig-fill-picker-gradient-header" },
3092
+ [gradientType, actions],
3093
+ );
3094
+ const gradientBar = figEditorCreateElement("fig-input-gradient", {
3095
+ className: "fig-fill-picker-gradient-bar-input",
3096
+ "aria-label": "Gradient stops",
3097
+ edit: "true",
3098
+ mode: "tip",
3099
+ size: "large",
3100
+ value: JSON.stringify({
3101
+ type: "gradient",
3102
+ gradient: gradientToValueShape(this.#gradient),
3103
+ }),
3104
+ });
3105
+ const preview = figEditorCreateElement(
3106
+ "fig-preview",
3107
+ { className: "fig-fill-picker-gradient-preview" },
3108
+ gradientBar,
3109
+ );
3110
+ const addButton = figEditorCreateElement(
3111
+ "fig-button",
2808
3112
  {
2809
- label: "",
2810
- options: [{ value: "oklab", label: "OKLAB" }],
3113
+ icon: true,
3114
+ variant: "ghost",
3115
+ className: "fig-fill-picker-gradient-add",
3116
+ "aria-label": "Add gradient stop",
3117
+ title: "Add stop",
2811
3118
  },
3119
+ figEditorCreateIcon("add"),
3120
+ );
3121
+ const stops = figEditorCreateElement(
3122
+ "div",
3123
+ { className: "fig-fill-picker-gradient-stops" },
3124
+ [
3125
+ figEditorCreateElement(
3126
+ "fig-header",
3127
+ {
3128
+ className: "fig-fill-picker-gradient-stops-header",
3129
+ borderless: true,
3130
+ },
3131
+ [figEditorCreateElement("span", {}, "Stops"), addButton],
3132
+ ),
3133
+ figEditorCreateElement(
3134
+ "div",
3135
+ { className: "fig-fill-picker-gradient-stops-list" },
3136
+ document.createElement("fig-reorder"),
3137
+ ),
3138
+ ],
3139
+ );
3140
+ const interpolationModes = figEditorCreateElement(
3141
+ "fig-segmented-control",
2812
3142
  {
2813
- label: "Polar",
2814
- options: hueMethods.map((method) => ({
2815
- value: `oklch-${method}`,
2816
- label: `OKLCH ${method.charAt(0).toUpperCase()}${method.slice(1)}`,
2817
- })),
3143
+ className: "fig-fill-picker-gradient-interpolation-modes",
3144
+ "aria-label": "Color interpolation",
3145
+ value: interpolationValue,
2818
3146
  },
3147
+ FigFillPicker.#GRADIENT_INTERPOLATION_MODES.filter(
3148
+ (mode) => !mode.advanced,
3149
+ ).map(({ value, space, title, subtitle }) =>
3150
+ figEditorCreateElement(
3151
+ "fig-tooltip",
3152
+ { text: `${title} — ${subtitle}` },
3153
+ figEditorCreateElement(
3154
+ "fig-segment",
3155
+ {
3156
+ value,
3157
+ "data-space": space,
3158
+ "aria-label": `${title} — ${subtitle}`,
3159
+ },
3160
+ figEditorCreateElement("fig-interpolation-swatch", {
3161
+ "aria-hidden": "true",
3162
+ }),
3163
+ ),
3164
+ ),
3165
+ ),
3166
+ );
3167
+ const interpolationSelect = figEditorCreateElement(
3168
+ "fig-select",
2819
3169
  {
2820
- label: "",
2821
- separator: true,
2822
- options: hueMethods.map((method) => ({
2823
- value: `hsl-${method}`,
2824
- label: `HSL ${method.charAt(0).toUpperCase()}${method.slice(1)}`,
2825
- })),
3170
+ className: "fig-fill-picker-gradient-space",
3171
+ label: "Color interpolation",
3172
+ value: interpolationValue,
2826
3173
  },
3174
+ figEditorCreateElement(
3175
+ "fig-select-options",
3176
+ {},
3177
+ this.#createGradientInterpolationOptions(),
3178
+ ),
3179
+ );
3180
+ // The select trigger overlays the icon button so its menu anchors correctly.
3181
+ const interpolationMore = figEditorCreateElement(
3182
+ "div",
3183
+ { className: "fig-fill-picker-gradient-interpolation-more" },
3184
+ [
3185
+ figEditorCreateElement(
3186
+ "fig-button",
3187
+ {
3188
+ icon: true,
3189
+ variant: "ghost",
3190
+ "aria-hidden": "true",
3191
+ tabindex: "-1",
3192
+ },
3193
+ figEditorCreateIcon("more"),
3194
+ ),
3195
+ interpolationSelect,
3196
+ ],
3197
+ );
3198
+ const interpolation = figEditorCreateElement(
3199
+ "fig-field",
3200
+ { className: "fig-fill-picker-gradient-interpolation-field" },
3201
+ [interpolationModes, interpolationMore],
3202
+ );
3203
+ container.replaceChildren(header, preview, interpolation, stops);
3204
+
3205
+ this.#updateGradientUI();
3206
+ this.#setupGradientEvents(container);
3207
+ }
3208
+
3209
+ #createGradientInterpolationOptions() {
3210
+ const createOption = ({ value, title, subtitle, advanced = false }) =>
3211
+ figEditorCreateElement(
3212
+ "fig-select-option",
3213
+ {
3214
+ value,
3215
+ label: `${title} — ${subtitle}`,
3216
+ className: advanced
3217
+ ? "fig-fill-picker-gradient-interpolation-advanced"
3218
+ : null,
3219
+ },
3220
+ [
3221
+ figEditorCreateElement("fig-interpolation-swatch", {
3222
+ slot: "prepend",
3223
+ size: "large",
3224
+ "aria-hidden": "true",
3225
+ }),
3226
+ figEditorCreateElement(
3227
+ "span",
3228
+ { className: "fig-fill-picker-gradient-interpolation-label" },
3229
+ [
3230
+ figEditorCreateElement(
3231
+ "span",
3232
+ { className: "fig-fill-picker-gradient-interpolation-title" },
3233
+ title,
3234
+ ),
3235
+ figEditorCreateElement(
3236
+ "span",
3237
+ { className: "fig-fill-picker-gradient-interpolation-subtitle" },
3238
+ subtitle,
3239
+ ),
3240
+ ],
3241
+ ),
3242
+ ],
3243
+ );
3244
+
3245
+ const separator = figEditorCreateElement("fig-separator", {
3246
+ className: "fig-fill-picker-gradient-interpolation-advanced",
3247
+ });
3248
+
3249
+ const modes = FigFillPicker.#GRADIENT_INTERPOLATION_MODES;
3250
+ return [
3251
+ ...modes.filter((mode) => !mode.advanced).map(createOption),
3252
+ separator,
3253
+ ...modes.filter((mode) => mode.advanced).map(createOption),
2827
3254
  ];
2828
- return groups
2829
- .map((group) => {
2830
- const options = group.options
2831
- .map((opt) => {
2832
- const methodLabel = opt.method
2833
- ? opt.method.charAt(0).toUpperCase() + opt.method.slice(1)
2834
- : "";
2835
- const appendLabel = opt.append || methodLabel;
2836
- return `<fig-select-option value="${figEditorEscapeAttribute(opt.value)}" label="${figEditorEscapeAttribute(opt.label)}">
2837
- <fig-interpolation-swatch slot="prepend" size="large" aria-hidden="true"></fig-interpolation-swatch>
2838
- ${figEditorEscapeAttribute(opt.label)}
2839
- ${appendLabel ? `<span slot="append">${figEditorEscapeAttribute(appendLabel)}</span>` : ""}
2840
- </fig-select-option>`;
2841
- })
2842
- .join("");
2843
- const separator = group.label || group.separator
2844
- ? `<fig-separator${group.label ? ` label="${figEditorEscapeAttribute(group.label)}"` : ""}></fig-separator>`
2845
- : "";
2846
- return `${separator}${options}`;
2847
- })
2848
- .join("");
2849
3255
  }
2850
3256
 
2851
3257
  #setupGradientEvents(container) {
@@ -2885,6 +3291,93 @@ class FigFillPicker extends HTMLElement {
2885
3291
  const interpolationSelect = container.querySelector(
2886
3292
  ".fig-fill-picker-gradient-space",
2887
3293
  );
3294
+ const interpolationPanel = interpolationSelect?.querySelector(
3295
+ "fig-select-options",
3296
+ );
3297
+ // Collapsed list clips the advanced options so fig-select-options' own
3298
+ // overflow chevron is the "show all" affordance; clicking it expands.
3299
+ const COLLAPSED_CLASS = "fig-fill-picker-gradient-interpolation-collapsed";
3300
+ const firstAdvancedOption = interpolationPanel?.querySelector(
3301
+ ".fig-fill-picker-gradient-interpolation-advanced",
3302
+ );
3303
+ const isInterpolationCollapsed = () =>
3304
+ Boolean(interpolationPanel?.classList.contains(COLLAPSED_CLASS));
3305
+ const setOverflowChevronLabel = (collapsed) => {
3306
+ interpolationPanel
3307
+ ?.querySelector(".fig-overflow-end")
3308
+ ?.setAttribute(
3309
+ "aria-label",
3310
+ collapsed ? "Show all color interpolation options" : "Scroll down",
3311
+ );
3312
+ };
3313
+ const expandInterpolationOptions = () => {
3314
+ if (!interpolationPanel) return;
3315
+ interpolationPanel.classList.remove(COLLAPSED_CLASS);
3316
+ interpolationPanel.style.removeProperty("max-height");
3317
+ setOverflowChevronLabel(false);
3318
+ interpolationPanel.syncOverflow?.();
3319
+ };
3320
+ const collapseInterpolationOptions = () => {
3321
+ if (!interpolationPanel || !firstAdvancedOption) return;
3322
+ interpolationPanel.style.removeProperty("max-height");
3323
+ interpolationPanel.classList.add(COLLAPSED_CLASS);
3324
+ interpolationPanel.scrollTop = 0;
3325
+ const panelTop = interpolationPanel.getBoundingClientRect().top;
3326
+ const advancedTop = firstAdvancedOption.getBoundingClientRect().top;
3327
+ const chevronHeight =
3328
+ interpolationPanel.querySelector(".fig-overflow-end")?.offsetHeight || 0;
3329
+ const visibleHeight = advancedTop - panelTop;
3330
+ if (visibleHeight > 0) {
3331
+ interpolationPanel.style.maxHeight = `${Math.round(visibleHeight + chevronHeight)}px`;
3332
+ }
3333
+ setOverflowChevronLabel(true);
3334
+ interpolationPanel.syncOverflow?.();
3335
+ };
3336
+ const isAdvancedInterpolationValue = (value) =>
3337
+ Boolean(
3338
+ interpolationPanel?.querySelector(
3339
+ `fig-select-option.fig-fill-picker-gradient-interpolation-advanced[value="${value}"]`,
3340
+ ),
3341
+ );
3342
+ const syncInterpolationCollapse = () => {
3343
+ if (
3344
+ isAdvancedInterpolationValue(
3345
+ gradientInterpolationSelectValue(this.#gradient),
3346
+ )
3347
+ ) {
3348
+ expandInterpolationOptions();
3349
+ } else {
3350
+ collapseInterpolationOptions();
3351
+ }
3352
+ };
3353
+ interpolationPanel?.addEventListener(
3354
+ "click",
3355
+ (event) => {
3356
+ if (!isInterpolationCollapsed()) return;
3357
+ if (!event.target?.closest?.(".fig-overflow-end")) return;
3358
+ event.preventDefault();
3359
+ event.stopPropagation();
3360
+ expandInterpolationOptions();
3361
+ },
3362
+ true,
3363
+ );
3364
+ // Arrow-key focus lands on a clipped option — reveal the rest instead.
3365
+ interpolationPanel?.addEventListener("focusin", (event) => {
3366
+ if (!isInterpolationCollapsed()) return;
3367
+ if (
3368
+ event.target?.closest?.(
3369
+ ".fig-fill-picker-gradient-interpolation-advanced",
3370
+ )
3371
+ ) {
3372
+ expandInterpolationOptions();
3373
+ }
3374
+ });
3375
+ // Menu alignment nudges the scroller; collapsed list must stay at the top.
3376
+ interpolationPanel?.addEventListener("scroll", () => {
3377
+ if (isInterpolationCollapsed() && interpolationPanel.scrollTop !== 0) {
3378
+ interpolationPanel.scrollTop = 0;
3379
+ }
3380
+ });
2888
3381
  interpolationSelect
2889
3382
  ?.querySelectorAll("fig-select-option")
2890
3383
  .forEach((option) => {
@@ -2913,6 +3406,8 @@ class FigFillPicker extends HTMLElement {
2913
3406
  this.#gradientInterpolationOpenObserver = new MutationObserver(() => {
2914
3407
  if (!interpolationSelect.hasAttribute("open")) {
2915
3408
  restoreGradientBarPreview();
3409
+ } else {
3410
+ this.#scheduleFrame(syncInterpolationCollapse);
2916
3411
  }
2917
3412
  });
2918
3413
  this.#gradientInterpolationOpenObserver.observe(interpolationSelect, {
@@ -2920,8 +3415,11 @@ class FigFillPicker extends HTMLElement {
2920
3415
  attributeFilter: ["open"],
2921
3416
  });
2922
3417
  }
2923
- interpolationSelect?.addEventListener("change", (e) => {
2924
- const val = getSelectValue(e);
3418
+ const interpolationModes = container.querySelector(
3419
+ ".fig-fill-picker-gradient-interpolation-modes",
3420
+ );
3421
+ interpolationModes?.addEventListener("change", (e) => {
3422
+ const val = typeof e.detail === "string" ? e.detail : e.target?.value;
2925
3423
  if (!val) return;
2926
3424
  const parsed = parseGradientInterpolationSelectValue(val);
2927
3425
  this.#gradient = normalizeGradientConfig({
@@ -2931,31 +3429,29 @@ class FigFillPicker extends HTMLElement {
2931
3429
  this.#updateGradientUI();
2932
3430
  this.#emitInput();
2933
3431
  });
2934
-
2935
- // Angle input
2936
- const angleInput = container.querySelector(
2937
- ".fig-fill-picker-gradient-angle",
2938
- );
2939
- angleInput.addEventListener("input", (e) => {
2940
- const pickerAngle = parseFloat(e.target.value) || 0;
2941
- this.#gradient.angle = (pickerAngle + 90) % 360;
2942
- this.#updateGradientPreview();
2943
- this.#emitInput();
2944
- });
2945
-
2946
- // Center X/Y inputs
2947
- const cxInput = container.querySelector(".fig-fill-picker-gradient-cx");
2948
- const cyInput = container.querySelector(".fig-fill-picker-gradient-cy");
2949
- cxInput?.addEventListener("input", (e) => {
2950
- const value = Number.parseFloat(e.target.value);
2951
- this.#gradient.centerX = Number.isFinite(value) ? value : 50;
2952
- this.#updateGradientPreview();
2953
- this.#emitInput();
3432
+ interpolationModes?.querySelectorAll("fig-segment").forEach((segment) => {
3433
+ const previewSegment = () => {
3434
+ setGradientBarPreview(
3435
+ normalizeGradientConfig({
3436
+ ...this.#gradient,
3437
+ ...parseGradientInterpolationSelectValue(
3438
+ segment.getAttribute("value") || "srgb",
3439
+ ),
3440
+ }),
3441
+ );
3442
+ };
3443
+ segment.addEventListener("pointerenter", previewSegment);
3444
+ segment.addEventListener("pointerleave", restoreGradientBarPreview);
2954
3445
  });
2955
- cyInput?.addEventListener("input", (e) => {
2956
- const value = Number.parseFloat(e.target.value);
2957
- this.#gradient.centerY = Number.isFinite(value) ? value : 50;
2958
- this.#updateGradientPreview();
3446
+ interpolationSelect?.addEventListener("change", (e) => {
3447
+ const val = getSelectValue(e);
3448
+ if (!val) return;
3449
+ const parsed = parseGradientInterpolationSelectValue(val);
3450
+ this.#gradient = normalizeGradientConfig({
3451
+ ...this.#gradient,
3452
+ ...parsed,
3453
+ });
3454
+ this.#updateGradientUI();
2959
3455
  this.#emitInput();
2960
3456
  });
2961
3457
 
@@ -3175,37 +3671,32 @@ class FigFillPicker extends HTMLElement {
3175
3671
  if (!container) return;
3176
3672
  this.#gradient = normalizeGradientConfig(this.#gradient);
3177
3673
 
3178
- // Show/hide angle vs center inputs
3179
- const angleInput = container.querySelector(
3180
- ".fig-fill-picker-gradient-angle",
3181
- );
3182
- const rotateBtn = container.querySelector(
3183
- ".fig-fill-picker-gradient-rotate",
3184
- );
3185
- const centerInputs = container.querySelector(
3186
- ".fig-fill-picker-gradient-center",
3187
- );
3188
-
3189
- if (this.#gradient.type === "radial") {
3190
- angleInput.style.display = "none";
3191
- if (rotateBtn) rotateBtn.style.display = "none";
3192
- centerInputs.style.display = "flex";
3193
- } else {
3194
- angleInput.style.removeProperty("display");
3195
- rotateBtn?.style.removeProperty("display");
3196
- centerInputs.style.display = "none";
3197
- // Sync angle input value (convert CSS angle to picker angle)
3198
- const pickerAngle = (this.#gradient.angle - 90 + 360) % 360;
3199
- angleInput.setAttribute("value", pickerAngle);
3200
- }
3201
-
3674
+ const interpolationValue = gradientInterpolationSelectValue(this.#gradient);
3202
3675
  const interpolationSelect = container.querySelector(
3203
3676
  ".fig-fill-picker-gradient-space",
3204
3677
  );
3205
3678
  if (interpolationSelect) {
3206
- interpolationSelect.value = gradientInterpolationSelectValue(
3207
- this.#gradient,
3208
- );
3679
+ interpolationSelect.value = interpolationValue;
3680
+ }
3681
+ const interpolationModes = container.querySelector(
3682
+ ".fig-fill-picker-gradient-interpolation-modes",
3683
+ );
3684
+ if (interpolationModes) {
3685
+ const mode = FigFillPicker.#gradientInterpolationMode(interpolationValue);
3686
+ // Menu-only variants retarget the segment for their color space.
3687
+ const segment =
3688
+ interpolationModes.querySelector(
3689
+ `fig-segment[value="${interpolationValue}"]`,
3690
+ ) ||
3691
+ interpolationModes.querySelector(`fig-segment[data-space="${mode.space}"]`);
3692
+ if (segment) {
3693
+ const label = `${mode.title} — ${mode.subtitle}`;
3694
+ segment.setAttribute("value", interpolationValue);
3695
+ segment.setAttribute("aria-label", label);
3696
+ const tip = segment.closest("fig-tooltip");
3697
+ if (tip) tip.setAttribute("text", label);
3698
+ interpolationModes.value = interpolationValue;
3699
+ }
3209
3700
  }
3210
3701
 
3211
3702
  this.#updateGradientInterpolationSwatches();
@@ -3238,7 +3729,9 @@ class FigFillPicker extends HTMLElement {
3238
3729
  .querySelectorAll("fig-interpolation-swatch")
3239
3730
  .forEach((swatch) => {
3240
3731
  const optionVal =
3241
- swatch.closest("fig-select-option")?.getAttribute("value") || "srgb";
3732
+ swatch
3733
+ .closest("fig-select-option, fig-segment")
3734
+ ?.getAttribute("value") || "srgb";
3242
3735
  const parsed = parseGradientInterpolationSelectValue(optionVal);
3243
3736
  const gradient = {
3244
3737
  type: "linear",
@@ -3313,25 +3806,46 @@ class FigFillPicker extends HTMLElement {
3313
3806
  const reorder = list.querySelector("fig-reorder");
3314
3807
  if (!reorder) return;
3315
3808
 
3316
- reorder.innerHTML = this.#gradient.stops
3317
- .map(
3318
- (stop, index) => `
3319
- <fig-field class="fig-fill-picker-gradient-stop-row" data-index="${index}">
3320
- <fig-input-number class="fig-fill-picker-stop-position" aria-label="Gradient stop position" min="0" max="100" value="${
3321
- stop.position
3322
- }" units="%"></fig-input-number>
3323
- <fig-input-color class="fig-fill-picker-stop-color" aria-label="Gradient stop color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${this.#formatStopColorValue(
3324
- stop,
3325
- )}"></fig-input-color>
3326
- <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
3327
- this.#gradient.stops.length <= 2 ? "disabled" : ""
3328
- } aria-label="Remove gradient stop">
3329
- <fig-icon name="minus"></fig-icon>
3330
- </fig-button>
3331
- </fig-field>
3332
- `,
3333
- )
3334
- .join("");
3809
+ const rows = this.#gradient.stops.map((stop, index) =>
3810
+ figEditorCreateElement(
3811
+ "fig-field",
3812
+ {
3813
+ className: "fig-fill-picker-gradient-stop-row",
3814
+ "data-index": index,
3815
+ },
3816
+ [
3817
+ figEditorCreateElement("fig-input-number", {
3818
+ className: "fig-fill-picker-stop-position",
3819
+ "aria-label": "Gradient stop position",
3820
+ min: "0",
3821
+ max: "100",
3822
+ value: stop.position,
3823
+ units: "%",
3824
+ }),
3825
+ figEditorCreateElement("fig-input-color", {
3826
+ className: "fig-fill-picker-stop-color",
3827
+ "aria-label": "Gradient stop color",
3828
+ text: "true",
3829
+ alpha: "true",
3830
+ picker: "figma",
3831
+ "picker-dialog-position": "right",
3832
+ value: this.#formatStopColorValue(stop),
3833
+ }),
3834
+ figEditorCreateElement(
3835
+ "fig-button",
3836
+ {
3837
+ icon: true,
3838
+ variant: "ghost",
3839
+ className: "fig-fill-picker-stop-remove",
3840
+ disabled: this.#gradient.stops.length <= 2,
3841
+ "aria-label": "Remove gradient stop",
3842
+ },
3843
+ figEditorCreateIcon("minus"),
3844
+ ),
3845
+ ],
3846
+ ),
3847
+ );
3848
+ reorder.replaceChildren(...rows);
3335
3849
 
3336
3850
  reorder
3337
3851
  .querySelectorAll(".fig-fill-picker-gradient-stop-row")
@@ -3440,24 +3954,52 @@ class FigFillPicker extends HTMLElement {
3440
3954
  #initImageTab() {
3441
3955
  const container = this.#dialog.querySelector('[data-tab="image"]');
3442
3956
 
3443
- container.innerHTML = `
3444
- <fig-field class="fig-fill-picker-media-header">
3445
- <fig-select class="fig-fill-picker-scale-mode" label="Image scale mode" value="${figEditorEscapeAttribute(this.#image.scaleMode)}">
3446
- <fig-select-options>
3447
- <fig-select-option value="fill">Fill</fig-select-option>
3448
- <fig-select-option value="fit">Fit</fig-select-option>
3449
- <fig-select-option value="crop">Crop</fig-select-option>
3450
- <fig-select-option value="tile">Tile</fig-select-option>
3451
- </fig-select-options>
3452
- </fig-select>
3453
- <fig-input-number class="fig-fill-picker-scale" aria-label="Image tile scale" min="1" max="200" value="${
3454
- this.#image.scale
3455
- }" units="%" ${
3456
- this.#image.scaleMode === "tile" ? "" : 'style="display: none;"'
3457
- }></fig-input-number>
3458
- </fig-field>
3459
- <fig-image class="fig-fill-picker-media-preview fig-fill-picker-image-preview" upload="true" label="Upload from computer" alt="Image fill preview" size="auto" aspect-ratio="1/1" fit="cover" checkerboard="true"></fig-image>
3460
- `;
3957
+ const scaleMode = figEditorCreateElement(
3958
+ "fig-select",
3959
+ {
3960
+ className: "fig-fill-picker-scale-mode",
3961
+ label: "Image scale mode",
3962
+ value: this.#image.scaleMode,
3963
+ },
3964
+ figEditorCreateElement(
3965
+ "fig-select-options",
3966
+ {},
3967
+ [
3968
+ ["fill", "Fill"],
3969
+ ["fit", "Fit"],
3970
+ ["crop", "Crop"],
3971
+ ["tile", "Tile"],
3972
+ ].map(([value, label]) =>
3973
+ figEditorCreateElement("fig-select-option", { value }, label),
3974
+ ),
3975
+ ),
3976
+ );
3977
+ const scale = figEditorCreateElement("fig-input-number", {
3978
+ className: "fig-fill-picker-scale",
3979
+ "aria-label": "Image tile scale",
3980
+ min: "1",
3981
+ max: "200",
3982
+ value: this.#image.scale,
3983
+ units: "%",
3984
+ });
3985
+ if (this.#image.scaleMode !== "tile") scale.style.display = "none";
3986
+ const header = figEditorCreateElement(
3987
+ "fig-field",
3988
+ { className: "fig-fill-picker-media-header" },
3989
+ [scaleMode, scale],
3990
+ );
3991
+ const preview = figEditorCreateElement("fig-image", {
3992
+ className:
3993
+ "fig-fill-picker-media-preview fig-fill-picker-image-preview",
3994
+ upload: "true",
3995
+ label: "Upload from computer",
3996
+ alt: "Image fill preview",
3997
+ size: "auto",
3998
+ "aspect-ratio": "1/1",
3999
+ fit: "cover",
4000
+ checkerboard: "true",
4001
+ });
4002
+ container.replaceChildren(header, preview);
3461
4003
 
3462
4004
  this.#setupImageEvents(container);
3463
4005
  }
@@ -3606,18 +4148,47 @@ class FigFillPicker extends HTMLElement {
3606
4148
  #initVideoTab() {
3607
4149
  const container = this.#dialog.querySelector('[data-tab="video"]');
3608
4150
 
3609
- container.innerHTML = `
3610
- <fig-field class="fig-fill-picker-media-header">
3611
- <fig-select class="fig-fill-picker-scale-mode" label="Video scale mode" value="${figEditorEscapeAttribute(this.#video.scaleMode)}">
3612
- <fig-select-options>
3613
- <fig-select-option value="fill">Fill</fig-select-option>
3614
- <fig-select-option value="fit">Fit</fig-select-option>
3615
- <fig-select-option value="crop">Crop</fig-select-option>
3616
- </fig-select-options>
3617
- </fig-select>
3618
- </fig-field>
3619
- <fig-media class="fig-fill-picker-media-preview fig-fill-picker-video-preview" type="video" upload="true" label="Upload from computer" aria-label="Video fill preview" size="auto" aspect-ratio="1/1" fit="cover" checkerboard="true" autoplay="true" controls muted="true" loop="true"></fig-media>
3620
- `;
4151
+ const scaleMode = figEditorCreateElement(
4152
+ "fig-select",
4153
+ {
4154
+ className: "fig-fill-picker-scale-mode",
4155
+ label: "Video scale mode",
4156
+ value: this.#video.scaleMode,
4157
+ },
4158
+ figEditorCreateElement(
4159
+ "fig-select-options",
4160
+ {},
4161
+ [
4162
+ ["fill", "Fill"],
4163
+ ["fit", "Fit"],
4164
+ ["crop", "Crop"],
4165
+ ].map(([value, label]) =>
4166
+ figEditorCreateElement("fig-select-option", { value }, label),
4167
+ ),
4168
+ ),
4169
+ );
4170
+ const header = figEditorCreateElement(
4171
+ "fig-field",
4172
+ { className: "fig-fill-picker-media-header" },
4173
+ scaleMode,
4174
+ );
4175
+ const preview = figEditorCreateElement("fig-media", {
4176
+ className:
4177
+ "fig-fill-picker-media-preview fig-fill-picker-video-preview",
4178
+ type: "video",
4179
+ upload: "true",
4180
+ label: "Upload from computer",
4181
+ "aria-label": "Video fill preview",
4182
+ size: "auto",
4183
+ "aspect-ratio": "1/1",
4184
+ fit: "cover",
4185
+ checkerboard: "true",
4186
+ autoplay: "true",
4187
+ controls: true,
4188
+ muted: "true",
4189
+ loop: "true",
4190
+ });
4191
+ container.replaceChildren(header, preview);
3621
4192
 
3622
4193
  this.#setupVideoEvents(container);
3623
4194
  }
@@ -3663,24 +4234,64 @@ class FigFillPicker extends HTMLElement {
3663
4234
  #initWebcamTab() {
3664
4235
  const container = this.#dialog.querySelector('[data-tab="webcam"]');
3665
4236
 
3666
- container.innerHTML = `
3667
- <fig-field class="fig-fill-picker-webcam-camera" style="display: none;">
3668
- <fig-select class="fig-fill-picker-camera-select" label="Camera" full>
3669
- <fig-select-options></fig-select-options>
3670
- </fig-select>
3671
- </fig-field>
3672
- <fig-video class="fig-fill-picker-webcam-preview" aria-label="Webcam preview" aspect-ratio="1/1" fit="cover" checkerboard="true" autoplay="true" muted="true">
3673
- <video class="fig-fill-picker-webcam-video" autoplay muted playsinline></video>
3674
- <div class="fig-fill-picker-webcam-status" role="status" aria-live="polite">
3675
- <span>Camera access required</span>
3676
- </div>
3677
- </fig-video>
3678
- <div class="fig-fill-picker-webcam-controls">
3679
- <fig-button class="fig-fill-picker-webcam-capture" variant="secondary" full disabled>
3680
- Capture
3681
- </fig-button>
3682
- </div>
3683
- `;
4237
+ const cameraField = figEditorCreateElement(
4238
+ "fig-field",
4239
+ { className: "fig-fill-picker-webcam-camera" },
4240
+ figEditorCreateElement(
4241
+ "fig-select",
4242
+ {
4243
+ className: "fig-fill-picker-camera-select",
4244
+ label: "Camera",
4245
+ full: true,
4246
+ },
4247
+ document.createElement("fig-select-options"),
4248
+ ),
4249
+ );
4250
+ cameraField.style.display = "none";
4251
+ const video = figEditorCreateElement("video", {
4252
+ className: "fig-fill-picker-webcam-video",
4253
+ autoplay: true,
4254
+ muted: true,
4255
+ playsinline: true,
4256
+ });
4257
+ video.muted = true;
4258
+ const status = figEditorCreateElement(
4259
+ "div",
4260
+ {
4261
+ className: "fig-fill-picker-webcam-status",
4262
+ role: "status",
4263
+ "aria-live": "polite",
4264
+ },
4265
+ figEditorCreateElement("span", {}, "Camera access required"),
4266
+ );
4267
+ const preview = figEditorCreateElement(
4268
+ "fig-video",
4269
+ {
4270
+ className: "fig-fill-picker-webcam-preview",
4271
+ "aria-label": "Webcam preview",
4272
+ "aspect-ratio": "1/1",
4273
+ fit: "cover",
4274
+ checkerboard: "true",
4275
+ autoplay: "true",
4276
+ muted: "true",
4277
+ },
4278
+ [video, status],
4279
+ );
4280
+ const controls = figEditorCreateElement(
4281
+ "div",
4282
+ { className: "fig-fill-picker-webcam-controls" },
4283
+ figEditorCreateElement(
4284
+ "fig-button",
4285
+ {
4286
+ className: "fig-fill-picker-webcam-capture",
4287
+ variant: "secondary",
4288
+ full: true,
4289
+ disabled: true,
4290
+ },
4291
+ "Capture",
4292
+ ),
4293
+ );
4294
+ container.replaceChildren(cameraField, preview, controls);
3684
4295
 
3685
4296
  this.#setupWebcamEvents(container);
3686
4297
  }
@@ -3837,8 +4448,19 @@ class FigFillPicker extends HTMLElement {
3837
4448
  );
3838
4449
  if (imagePreview) this.#updateImagePreview(imagePreview);
3839
4450
 
3840
- // Switch to image tab to show result
3841
- this.#switchTab("image");
4451
+ const hasImageTab = Array.from(
4452
+ this.#dialog.querySelectorAll(".fig-fill-picker-tab"),
4453
+ ).some((candidate) => candidate.dataset.tab === "image");
4454
+
4455
+ if (hasImageTab) {
4456
+ // Switch to image tab to show result
4457
+ this.#switchTab("image");
4458
+ } else {
4459
+ // Webcam-only pickers keep the webcam type and report the snapshot
4460
+ this.#updateSwatch();
4461
+ this.#emitInput();
4462
+ }
4463
+ this.#emitChange();
3842
4464
  });
3843
4465
  }
3844
4466
 
@@ -4259,3 +4881,330 @@ class FigFillPicker extends HTMLElement {
4259
4881
  }
4260
4882
  }
4261
4883
  figEditorDefineElement("fig-fill-picker", FigFillPicker);
4884
+
4885
+ /**
4886
+ * Compact swatch previewing gradient color-space interpolation.
4887
+ * Polar: CSS conic-gradient masked (SVG data-URL, round linecaps) to an arc.
4888
+ * Non-polar: CSS linear-gradient masked to a horizontal round-capped stroke.
4889
+ * Accepts the same `value` shape as fig-input-gradient / fig-fill-picker.
4890
+ *
4891
+ * @element fig-interpolation-swatch
4892
+ * @attr {string} value - JSON `{ type: "gradient", gradient: { … } }` (or a bare gradient object)
4893
+ * @attr {string} size - `small` (default, 24px) or `large` (32px)
4894
+ */
4895
+ class FigInterpolationSwatch extends HTMLElement {
4896
+ static #HUE_SPACES = new Set(["oklch", "hsl"]);
4897
+ static #CX = 10;
4898
+ static #CY = 10;
4899
+ static #R = 8;
4900
+ static #STROKE = 3;
4901
+ // Polar endpoints ≈ 10 o'clock → 2 o'clock (SVG deg: 0 = east, CW).
4902
+ // CSS conic `from` is 0 = north; convert with +90.
4903
+ static #START_DEG = 210;
4904
+ static #DEFAULT_GRADIENT = {
4905
+ type: "linear",
4906
+ angle: 135,
4907
+ interpolationSpace: "srgb",
4908
+ hueInterpolation: "shorter",
4909
+ stops: [
4910
+ { color: "#FF0000", position: 0, opacity: 100 },
4911
+ { color: "#4F9EFF", position: 100, opacity: 100 },
4912
+ ],
4913
+ };
4914
+
4915
+ #rendered = false;
4916
+ #svgEl = null;
4917
+ #fillEl = null;
4918
+ #gradient = { ...FigInterpolationSwatch.#DEFAULT_GRADIENT };
4919
+
4920
+ static get observedAttributes() {
4921
+ return ["value"];
4922
+ }
4923
+
4924
+ get value() {
4925
+ return {
4926
+ type: "gradient",
4927
+ gradient: { ...this.#gradient },
4928
+ };
4929
+ }
4930
+
4931
+ set value(val) {
4932
+ if (val == null || val === "") {
4933
+ this.removeAttribute("value");
4934
+ return;
4935
+ }
4936
+ if (typeof val === "string") {
4937
+ this.setAttribute("value", val);
4938
+ return;
4939
+ }
4940
+ this.setAttribute("value", JSON.stringify(val));
4941
+ }
4942
+
4943
+ connectedCallback() {
4944
+ this.#ensureA11y();
4945
+ this.#parseValue();
4946
+ this.#render();
4947
+ this.#updatePreview();
4948
+ }
4949
+
4950
+ attributeChangedCallback(name, oldValue, newValue) {
4951
+ if (oldValue === newValue) return;
4952
+ if (name !== "value") return;
4953
+ this.#parseValue();
4954
+ if (this.#rendered) this.#updatePreview();
4955
+ }
4956
+
4957
+ #ensureA11y() {
4958
+ const named =
4959
+ this.hasAttribute("aria-label") || this.hasAttribute("aria-labelledby");
4960
+ if (!named && !this.hasAttribute("aria-hidden")) {
4961
+ this.setAttribute("aria-hidden", "true");
4962
+ }
4963
+ }
4964
+
4965
+ #parseValue() {
4966
+ const valueAttr = this.getAttribute("value");
4967
+ if (!valueAttr) {
4968
+ this.#gradient = {
4969
+ ...FigInterpolationSwatch.#DEFAULT_GRADIENT,
4970
+ stops: FigInterpolationSwatch.#DEFAULT_GRADIENT.stops.map((s) => ({
4971
+ ...s,
4972
+ })),
4973
+ };
4974
+ return;
4975
+ }
4976
+ try {
4977
+ const parsed = JSON.parse(valueAttr);
4978
+ const gradient = parsed?.type === "gradient" && parsed.gradient
4979
+ ? parsed.gradient
4980
+ : parsed?.gradient
4981
+ ? parsed.gradient
4982
+ : parsed;
4983
+ if (!gradient || typeof gradient !== "object") return;
4984
+ this.#gradient = this.#normalizeGradient({
4985
+ ...FigInterpolationSwatch.#DEFAULT_GRADIENT,
4986
+ ...gradient,
4987
+ });
4988
+ } catch {
4989
+ // Keep current/default gradient on invalid JSON.
4990
+ }
4991
+ }
4992
+
4993
+ #normalizeGradient(gradient) {
4994
+ const next = { ...(gradient ?? {}) };
4995
+ const interpolationSpace = String(
4996
+ next.interpolationSpace ?? "srgb",
4997
+ ).toLowerCase();
4998
+ const hueInterpolation = String(
4999
+ next.hueInterpolation ?? "shorter",
5000
+ ).toLowerCase();
5001
+ const stops = Array.isArray(next.stops)
5002
+ ? next.stops.map((stop) => ({
5003
+ color: String(stop?.color || "#D9D9D9").replace(
5004
+ /^(#(?:[0-9a-f]{6})).*/i,
5005
+ "$1",
5006
+ ),
5007
+ position: stop?.position ?? 0,
5008
+ opacity: stop?.opacity ?? 100,
5009
+ }))
5010
+ : FigInterpolationSwatch.#DEFAULT_GRADIENT.stops.map((s) => ({ ...s }));
5011
+ if (stops.length < 2) {
5012
+ return {
5013
+ ...FigInterpolationSwatch.#DEFAULT_GRADIENT,
5014
+ stops: FigInterpolationSwatch.#DEFAULT_GRADIENT.stops.map((s) => ({
5015
+ ...s,
5016
+ })),
5017
+ };
5018
+ }
5019
+ return {
5020
+ type: ["linear", "radial", "angular"].includes(next.type)
5021
+ ? next.type
5022
+ : "linear",
5023
+ angle: Number.isFinite(Number(next.angle)) ? Number(next.angle) : 135,
5024
+ interpolationSpace,
5025
+ hueInterpolation,
5026
+ stops,
5027
+ };
5028
+ }
5029
+
5030
+ #isPolar() {
5031
+ return FigInterpolationSwatch.#HUE_SPACES.has(
5032
+ this.#gradient.interpolationSpace || "srgb",
5033
+ );
5034
+ }
5035
+
5036
+ #hueForColor(color) {
5037
+ const { r, g, b } = figEditorHexToRgb(color);
5038
+ if (this.#gradient.interpolationSpace === "hsl") {
5039
+ return figEditorRgbToHsl(r, g, b).h;
5040
+ }
5041
+ const lab = figEditorRgbToOklab(r, g, b);
5042
+ const hue = figEditorOklabToOklch(lab.l, lab.a, lab.b).h;
5043
+ return ((hue % 360) + 360) % 360;
5044
+ }
5045
+
5046
+ #polarArcGeometry() {
5047
+ const stops = this.#sortedStops();
5048
+ const startHue = this.#hueForColor(stops[0]?.color || "#FF0000");
5049
+ const endHue = this.#hueForColor(
5050
+ stops[stops.length - 1]?.color || "#4F9EFF",
5051
+ );
5052
+ const startDeg = FigInterpolationSwatch.#START_DEG - startHue;
5053
+ const endDeg = FigInterpolationSwatch.#START_DEG - endHue;
5054
+ const clockwiseSweep = ((endDeg - startDeg) % 360 + 360) % 360;
5055
+ const counterclockwiseSweep =
5056
+ clockwiseSweep === 0 ? 0 : clockwiseSweep - 360;
5057
+ const method = this.#gradient.hueInterpolation || "shorter";
5058
+
5059
+ let sweepDeg;
5060
+ if (method === "increasing") {
5061
+ sweepDeg = counterclockwiseSweep;
5062
+ } else if (method === "decreasing") {
5063
+ sweepDeg = clockwiseSweep;
5064
+ } else if (method === "longer") {
5065
+ sweepDeg =
5066
+ clockwiseSweep < 180 ? counterclockwiseSweep : clockwiseSweep;
5067
+ } else {
5068
+ sweepDeg =
5069
+ clockwiseSweep <= 180 ? clockwiseSweep : counterclockwiseSweep;
5070
+ }
5071
+
5072
+ // A round cap extends beyond the path endpoint. Inset the centerline so
5073
+ // the visible cap edges, rather than their centers, land on the hues.
5074
+ const direction = Math.sign(sweepDeg);
5075
+ const capAngle =
5076
+ (Math.asin(FigInterpolationSwatch.#STROKE / 2 / FigInterpolationSwatch.#R) *
5077
+ 180) /
5078
+ Math.PI;
5079
+ const inset = Math.min(
5080
+ capAngle,
5081
+ Math.max(0, (Math.abs(sweepDeg) - 0.01) / 2),
5082
+ );
5083
+ return {
5084
+ startDeg: startDeg + direction * inset,
5085
+ sweepDeg: sweepDeg - direction * inset * 2,
5086
+ };
5087
+ }
5088
+
5089
+ #pointOnCircle(deg) {
5090
+ const rad = (deg * Math.PI) / 180;
5091
+ return {
5092
+ x: FigInterpolationSwatch.#CX + FigInterpolationSwatch.#R * Math.cos(rad),
5093
+ y: FigInterpolationSwatch.#CY + FigInterpolationSwatch.#R * Math.sin(rad),
5094
+ };
5095
+ }
5096
+
5097
+ #arcMaskPath(startDeg, sweepDeg) {
5098
+ const endDeg = startDeg + sweepDeg;
5099
+ const start = this.#pointOnCircle(startDeg);
5100
+ const end = this.#pointOnCircle(endDeg);
5101
+ const largeArc = Math.abs(sweepDeg) > 180 ? 1 : 0;
5102
+ const sweepFlag = sweepDeg >= 0 ? 1 : 0;
5103
+ return `M ${start.x} ${start.y} A ${FigInterpolationSwatch.#R} ${FigInterpolationSwatch.#R} 0 ${largeArc} ${sweepFlag} ${end.x} ${end.y}`;
5104
+ }
5105
+
5106
+ #lineMaskPath() {
5107
+ const start = this.#pointOnCircle(180);
5108
+ const end = this.#pointOnCircle(0);
5109
+ return `M ${start.x} ${start.y} L ${end.x} ${end.y}`;
5110
+ }
5111
+
5112
+ #maskImageForPath(d) {
5113
+ const stroke = FigInterpolationSwatch.#STROKE;
5114
+ const svg =
5115
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">` +
5116
+ `<path d="${d}" fill="none" stroke="white" stroke-width="${stroke}" ` +
5117
+ `stroke-linecap="round" stroke-linejoin="round"/>` +
5118
+ `</svg>`;
5119
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
5120
+ }
5121
+
5122
+ #sortedStops() {
5123
+ return [...this.#gradient.stops].sort(
5124
+ (a, b) => (a.position ?? 0) - (b.position ?? 0),
5125
+ );
5126
+ }
5127
+
5128
+ #cssInterpolationClause() {
5129
+ const space = this.#gradient.interpolationSpace || "srgb";
5130
+ if (space === "srgb") return "";
5131
+ if (FigInterpolationSwatch.#HUE_SPACES.has(space)) {
5132
+ return ` in ${space} ${this.#gradient.hueInterpolation || "shorter"} hue`;
5133
+ }
5134
+ return ` in ${space}`;
5135
+ }
5136
+
5137
+ #previewBackground() {
5138
+ const stops = this.#sortedStops();
5139
+ const clause = this.#cssInterpolationClause();
5140
+ if (this.#isPolar()) {
5141
+ // Fixed hue wheel. The mask maps gradient endpoint hues onto this wheel.
5142
+ const cssFrom = (FigInterpolationSwatch.#START_DEG + 90) % 360;
5143
+ const space = this.#gradient.interpolationSpace;
5144
+ const wheelColor = (hue) =>
5145
+ space === "oklch"
5146
+ ? `oklch(65% 0.25 ${hue})`
5147
+ : `hsl(${hue} 100% 50%)`;
5148
+ const wheelStops = [0, 300, 240, 180, 120, 60, 0]
5149
+ .map(wheelColor)
5150
+ .join(", ");
5151
+ return `conic-gradient(from ${cssFrom}deg in ${space} decreasing hue, ${wheelStops})`;
5152
+ }
5153
+ const stopList = stops
5154
+ .map((s) => `${s.color} ${s.position ?? 0}%`)
5155
+ .join(", ");
5156
+ return `linear-gradient(90deg${clause}, ${stopList})`;
5157
+ }
5158
+
5159
+ #render() {
5160
+ if (this.#rendered) return;
5161
+ const stroke = FigInterpolationSwatch.#STROKE;
5162
+ const svg = figEditorCreateSvgElement(
5163
+ "svg",
5164
+ {
5165
+ className: "fig-interpolation-swatch-svg",
5166
+ width: "20",
5167
+ height: "20",
5168
+ viewBox: "0 0 20 20",
5169
+ fill: "none",
5170
+ "aria-hidden": "true",
5171
+ },
5172
+ figEditorCreateSvgElement("circle", {
5173
+ className: "fig-interpolation-swatch-rim",
5174
+ cx: "10",
5175
+ cy: "10",
5176
+ r: "8",
5177
+ fill: "none",
5178
+ stroke: "currentColor",
5179
+ "stroke-width": stroke,
5180
+ }),
5181
+ );
5182
+ const fill = figEditorCreateElement("div", {
5183
+ className: "fig-interpolation-swatch-fill",
5184
+ "aria-hidden": "true",
5185
+ });
5186
+ this.replaceChildren(svg, fill);
5187
+ this.#svgEl = svg;
5188
+ this.#fillEl = fill;
5189
+ this.#rendered = true;
5190
+ }
5191
+
5192
+ #updatePreview() {
5193
+ if (!this.#fillEl) return;
5194
+
5195
+ const polar = this.#isPolar();
5196
+ if (this.#svgEl) this.#svgEl.style.display = polar ? "" : "none";
5197
+
5198
+ const d = polar
5199
+ ? (() => {
5200
+ const { startDeg, sweepDeg } = this.#polarArcGeometry();
5201
+ return this.#arcMaskPath(startDeg, sweepDeg);
5202
+ })()
5203
+ : this.#lineMaskPath();
5204
+ const mask = this.#maskImageForPath(d);
5205
+ this.#fillEl.style.setProperty("-webkit-mask-image", mask);
5206
+ this.#fillEl.style.maskImage = mask;
5207
+ this.#fillEl.style.background = this.#previewBackground();
5208
+ }
5209
+ }
5210
+ figEditorDefineElement("fig-interpolation-swatch", FigInterpolationSwatch);