@rogieking/figui3 2.24.0 → 2.25.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/components.css CHANGED
@@ -601,6 +601,7 @@ fig-dropdown,
601
601
  display: flex;
602
602
  align-items: center;
603
603
  flex: 1;
604
+ width: 100%;
604
605
  }
605
606
 
606
607
  /* Chevron icon using mask-image for light-dark support */
@@ -721,6 +722,7 @@ fig-button {
721
722
 
722
723
  &:active {
723
724
  background-color: var(--figma-color-bg-secondary);
725
+ color: var(--figma-color-text);
724
726
  }
725
727
 
726
728
  &:focus-visible {
@@ -2985,6 +2987,10 @@ fig-shimmer:not([playing="false"]) > * {
2985
2987
  /* Fill Picker */
2986
2988
  fig-fill-picker {
2987
2989
  display: contents;
2990
+
2991
+ > [slot^="mode-"] {
2992
+ display: none;
2993
+ }
2988
2994
  }
2989
2995
 
2990
2996
  .fig-fill-picker-dialog {
package/fig.js CHANGED
@@ -3384,16 +3384,29 @@ class FigInputColor extends HTMLElement {
3384
3384
  return this.getAttribute("picker") || "native";
3385
3385
  }
3386
3386
 
3387
+ #buildFillPickerAttrs() {
3388
+ const attrs = {};
3389
+ const experimental = this.getAttribute("experimental");
3390
+ if (experimental) attrs["experimental"] = experimental;
3391
+ // picker-* attributes forwarded to fill picker (except anchor, handled programmatically)
3392
+ for (const { name, value } of this.attributes) {
3393
+ if (name.startsWith("picker-") && name !== "picker-anchor") {
3394
+ attrs[name.slice(7)] = value;
3395
+ }
3396
+ }
3397
+ if (!attrs["dialog-position"]) attrs["dialog-position"] = "left";
3398
+ return Object.entries(attrs)
3399
+ .map(([k, v]) => `${k}="${v}"`)
3400
+ .join(" ");
3401
+ }
3402
+
3387
3403
  connectedCallback() {
3388
3404
  this.#setValues(this.getAttribute("value"));
3389
3405
 
3390
3406
  const useFigmaPicker = this.picker === "figma";
3391
3407
  const hidePicker = this.picker === "false";
3392
3408
  const showAlpha = this.getAttribute("alpha") === "true";
3393
- const experimental = this.getAttribute("experimental");
3394
- const expAttr = experimental ? `experimental="${experimental}"` : "";
3395
- const dialogPos = this.getAttribute("dialog-position") || "left";
3396
- const dialogPosAttr = `dialog-position="${dialogPos}"`;
3409
+ const fpAttrs = this.#buildFillPickerAttrs();
3397
3410
 
3398
3411
  let html = ``;
3399
3412
  if (this.getAttribute("text")) {
@@ -3418,7 +3431,7 @@ class FigInputColor extends HTMLElement {
3418
3431
  let swatchElement = "";
3419
3432
  if (!hidePicker) {
3420
3433
  swatchElement = useFigmaPicker
3421
- ? `<fig-fill-picker mode="solid" ${dialogPosAttr} ${expAttr} ${
3434
+ ? `<fig-fill-picker mode="solid" ${fpAttrs} ${
3422
3435
  showAlpha ? "" : 'alpha="false"'
3423
3436
  } value='{"type":"solid","color":"${this.hexOpaque}","opacity":${
3424
3437
  this.alpha
@@ -3436,7 +3449,7 @@ class FigInputColor extends HTMLElement {
3436
3449
  html = ``;
3437
3450
  } else {
3438
3451
  html = useFigmaPicker
3439
- ? `<fig-fill-picker mode="solid" ${dialogPosAttr} ${expAttr} ${
3452
+ ? `<fig-fill-picker mode="solid" ${fpAttrs} ${
3440
3453
  showAlpha ? "" : 'alpha="false"'
3441
3454
  } value='{"type":"solid","color":"${this.hexOpaque}","opacity":${
3442
3455
  this.alpha
@@ -3460,6 +3473,13 @@ class FigInputColor extends HTMLElement {
3460
3473
 
3461
3474
  // Setup fill picker (figma picker)
3462
3475
  if (this.#fillPicker) {
3476
+ const anchor = this.getAttribute("picker-anchor");
3477
+ if (anchor === "self") {
3478
+ this.#fillPicker.anchorElement = this;
3479
+ } else if (anchor) {
3480
+ const el = document.querySelector(anchor);
3481
+ if (el) this.#fillPicker.anchorElement = el;
3482
+ }
3463
3483
  if (this.hasAttribute("disabled")) {
3464
3484
  this.#fillPicker.setAttribute("disabled", "");
3465
3485
  }
@@ -3628,7 +3648,7 @@ class FigInputColor extends HTMLElement {
3628
3648
  }
3629
3649
 
3630
3650
  static get observedAttributes() {
3631
- return ["value", "style", "mode", "picker", "experimental", "dialog-position"];
3651
+ return ["value", "style", "mode", "picker", "experimental"];
3632
3652
  }
3633
3653
 
3634
3654
  get mode() {
@@ -3863,6 +3883,25 @@ class FigInputFill extends HTMLElement {
3863
3883
  }
3864
3884
  }
3865
3885
 
3886
+ #buildFillPickerAttrs() {
3887
+ const attrs = {};
3888
+ // Backward-compat: direct attributes forwarded to fill picker
3889
+ const mode = this.getAttribute("mode");
3890
+ if (mode) attrs["mode"] = mode;
3891
+ const experimental = this.getAttribute("experimental");
3892
+ if (experimental) attrs["experimental"] = experimental;
3893
+ // picker-* overrides (except anchor, handled programmatically)
3894
+ for (const { name, value } of this.attributes) {
3895
+ if (name.startsWith("picker-") && name !== "picker-anchor") {
3896
+ attrs[name.slice(7)] = value;
3897
+ }
3898
+ }
3899
+ if (!attrs["dialog-position"]) attrs["dialog-position"] = "left";
3900
+ return Object.entries(attrs)
3901
+ .map(([k, v]) => `${k}="${v}"`)
3902
+ .join(" ");
3903
+ }
3904
+
3866
3905
  #render() {
3867
3906
  const disabled = this.hasAttribute("disabled");
3868
3907
  const fillPickerValue = JSON.stringify(this.value);
@@ -3960,13 +3999,12 @@ class FigInputFill extends HTMLElement {
3960
3999
  break;
3961
4000
  }
3962
4001
 
3963
- const modeAttr = this.getAttribute("mode");
3964
- const experimentalAttr = this.getAttribute("experimental");
4002
+ const fpAttrs = this.#buildFillPickerAttrs();
3965
4003
  this.innerHTML = `
3966
4004
  <div class="input-combo">
3967
- <fig-fill-picker dialog-position="left" value='${fillPickerValue}' ${
4005
+ <fig-fill-picker ${fpAttrs} value='${fillPickerValue}' ${
3968
4006
  disabled ? "disabled" : ""
3969
- } ${modeAttr ? `mode="${modeAttr}"` : ""} ${experimentalAttr ? `experimental="${experimentalAttr}"` : ""}></fig-fill-picker>
4007
+ }></fig-fill-picker>
3970
4008
  ${controlsHtml}
3971
4009
  </div>`;
3972
4010
 
@@ -3991,6 +4029,14 @@ class FigInputFill extends HTMLElement {
3991
4029
  }
3992
4030
 
3993
4031
  if (this.#fillPicker) {
4032
+ const anchor = this.getAttribute("picker-anchor");
4033
+ if (!anchor || anchor === "self") {
4034
+ this.#fillPicker.anchorElement = this;
4035
+ } else {
4036
+ const el = document.querySelector(anchor);
4037
+ if (el) this.#fillPicker.anchorElement = el;
4038
+ }
4039
+
3994
4040
  this.#fillPicker.addEventListener("input", (e) => {
3995
4041
  e.stopPropagation();
3996
4042
  const detail = e.detail;
@@ -6223,6 +6269,7 @@ class FigFillPicker extends HTMLElement {
6223
6269
  #chit = null;
6224
6270
  #dialog = null;
6225
6271
  #activeTab = "solid";
6272
+ anchorElement = null;
6226
6273
 
6227
6274
  // Fill state
6228
6275
  #fillType = "solid";
@@ -6241,6 +6288,10 @@ class FigFillPicker extends HTMLElement {
6241
6288
  #video = { url: null, scaleMode: "fill", scale: 50 };
6242
6289
  #webcam = { stream: null, snapshot: null };
6243
6290
 
6291
+ // Custom mode slots and data
6292
+ #customSlots = {};
6293
+ #customData = {};
6294
+
6244
6295
  // DOM references for solid tab
6245
6296
  #colorArea = null;
6246
6297
  #colorAreaHandle = null;
@@ -6275,7 +6326,9 @@ class FigFillPicker extends HTMLElement {
6275
6326
  }
6276
6327
 
6277
6328
  #setupTrigger() {
6278
- const child = this.firstElementChild;
6329
+ const child = Array.from(this.children).find(
6330
+ (el) => !el.getAttribute("slot")?.startsWith("mode-")
6331
+ );
6279
6332
 
6280
6333
  if (!child) {
6281
6334
  // Scenario 1: Empty - create fig-chit
@@ -6315,6 +6368,8 @@ class FigFillPicker extends HTMLElement {
6315
6368
  const valueAttr = this.getAttribute("value");
6316
6369
  if (!valueAttr) return;
6317
6370
 
6371
+ const builtinTypes = ["solid", "gradient", "image", "video", "webcam"];
6372
+
6318
6373
  try {
6319
6374
  const parsed = JSON.parse(valueAttr);
6320
6375
  if (parsed.type) this.#fillType = parsed.type;
@@ -6337,6 +6392,12 @@ class FigFillPicker extends HTMLElement {
6337
6392
  this.#gradient = { ...this.#gradient, ...parsed.gradient };
6338
6393
  if (parsed.image) this.#image = { ...this.#image, ...parsed.image };
6339
6394
  if (parsed.video) this.#video = { ...this.#video, ...parsed.video };
6395
+
6396
+ // Store full parsed data for custom (non-built-in) types
6397
+ if (parsed.type && !builtinTypes.includes(parsed.type)) {
6398
+ const { type, ...rest } = parsed;
6399
+ this.#customData[parsed.type] = rest;
6400
+ }
6340
6401
  } catch (e) {
6341
6402
  // If not JSON, treat as hex color
6342
6403
  if (valueAttr.startsWith("#")) {
@@ -6387,7 +6448,8 @@ class FigFillPicker extends HTMLElement {
6387
6448
  }
6388
6449
  break;
6389
6450
  default:
6390
- bg = "#D9D9D9";
6451
+ const slot = this.#customSlots[this.#fillType];
6452
+ bg = slot?.element?.getAttribute("chit-background") || "#D9D9D9";
6391
6453
  }
6392
6454
 
6393
6455
  this.#chit.setAttribute("background", bg);
@@ -6434,6 +6496,18 @@ class FigFillPicker extends HTMLElement {
6434
6496
  }
6435
6497
 
6436
6498
  #createDialog() {
6499
+ // Collect slotted custom mode content before any DOM changes
6500
+ this.#customSlots = {};
6501
+ this.querySelectorAll('[slot^="mode-"]').forEach((el) => {
6502
+ const modeName = el.getAttribute("slot").slice(5);
6503
+ this.#customSlots[modeName] = {
6504
+ element: el,
6505
+ label:
6506
+ el.getAttribute("label") ||
6507
+ modeName.charAt(0).toUpperCase() + modeName.slice(1),
6508
+ };
6509
+ });
6510
+
6437
6511
  this.#dialog = document.createElement("dialog", { is: "fig-popup" });
6438
6512
  this.#dialog.setAttribute("is", "fig-popup");
6439
6513
  this.#dialog.setAttribute("drag", "true");
@@ -6441,14 +6515,12 @@ class FigFillPicker extends HTMLElement {
6441
6515
  this.#dialog.setAttribute("autoresize", "false");
6442
6516
  this.#dialog.classList.add("fig-fill-picker-dialog");
6443
6517
 
6444
- this.#dialog.anchor = this.#trigger;
6518
+ this.#dialog.anchor = this.anchorElement || this.#trigger;
6445
6519
  const dialogPosition = this.getAttribute("dialog-position") || "left";
6446
6520
  this.#dialog.setAttribute("position", dialogPosition);
6447
6521
 
6448
- // Check for allowed modes (supports comma-separated values like "solid,gradient")
6449
- const mode = this.getAttribute("mode");
6450
- const allModes = ["solid", "gradient", "image", "video", "webcam"];
6451
- const modeLabels = {
6522
+ const builtinModes = ["solid", "gradient", "image", "video", "webcam"];
6523
+ const builtinLabels = {
6452
6524
  solid: "Solid",
6453
6525
  gradient: "Gradient",
6454
6526
  image: "Image",
@@ -6456,24 +6528,33 @@ class FigFillPicker extends HTMLElement {
6456
6528
  webcam: "Webcam",
6457
6529
  };
6458
6530
 
6459
- // Parse allowed modes
6460
- let allowedModes = allModes;
6531
+ // Build allowed modes: built-ins filtered normally, custom names accepted if slot exists
6532
+ const mode = this.getAttribute("mode");
6533
+ let allowedModes;
6461
6534
  if (mode) {
6462
- const requestedModes = mode.split(",").map((m) => m.trim().toLowerCase());
6463
- allowedModes = requestedModes.filter((m) => allModes.includes(m));
6464
- if (allowedModes.length === 0) allowedModes = allModes;
6535
+ const requested = mode.split(",").map((m) => m.trim().toLowerCase());
6536
+ allowedModes = requested.filter(
6537
+ (m) => builtinModes.includes(m) || this.#customSlots[m]
6538
+ );
6539
+ if (allowedModes.length === 0) allowedModes = [...builtinModes];
6540
+ } else {
6541
+ allowedModes = [...builtinModes];
6542
+ }
6543
+
6544
+ // Build labels map: built-in labels + custom slot labels
6545
+ const modeLabels = { ...builtinLabels };
6546
+ for (const [name, { label }] of Object.entries(this.#customSlots)) {
6547
+ modeLabels[name] = label;
6465
6548
  }
6466
6549
 
6467
- // If current fillType not in allowed modes, switch to first allowed
6468
6550
  if (!allowedModes.includes(this.#fillType)) {
6469
6551
  this.#fillType = allowedModes[0];
6470
6552
  this.#activeTab = allowedModes[0];
6471
6553
  }
6472
6554
 
6473
- // Build header content - label if single mode, dropdown if multiple
6474
6555
  const experimental = this.getAttribute("experimental");
6475
6556
  const expAttr = experimental ? `experimental="${experimental}"` : "";
6476
-
6557
+
6477
6558
  let headerContent;
6478
6559
  if (allowedModes.length === 1) {
6479
6560
  headerContent = `<h3 class="fig-fill-picker-type-label">${modeLabels[allowedModes[0]]}</h3>`;
@@ -6486,6 +6567,11 @@ class FigFillPicker extends HTMLElement {
6486
6567
  </fig-dropdown>`;
6487
6568
  }
6488
6569
 
6570
+ // Generate tab containers for all allowed modes
6571
+ const tabDivs = allowedModes
6572
+ .map((m) => `<div class="fig-fill-picker-tab" data-tab="${m}"></div>`)
6573
+ .join("\n ");
6574
+
6489
6575
  this.#dialog.innerHTML = `
6490
6576
  <fig-header>
6491
6577
  ${headerContent}
@@ -6494,16 +6580,33 @@ class FigFillPicker extends HTMLElement {
6494
6580
  </fig-button>
6495
6581
  </fig-header>
6496
6582
  <div class="fig-fill-picker-content">
6497
- <div class="fig-fill-picker-tab" data-tab="solid"></div>
6498
- <div class="fig-fill-picker-tab" data-tab="gradient"></div>
6499
- <div class="fig-fill-picker-tab" data-tab="image"></div>
6500
- <div class="fig-fill-picker-tab" data-tab="video"></div>
6501
- <div class="fig-fill-picker-tab" data-tab="webcam"></div>
6583
+ ${tabDivs}
6502
6584
  </div>
6503
6585
  `;
6504
6586
 
6505
6587
  document.body.appendChild(this.#dialog);
6506
6588
 
6589
+ // Populate custom tab containers and emit modeready
6590
+ for (const [modeName, { element }] of Object.entries(this.#customSlots)) {
6591
+ const container = this.#dialog.querySelector(
6592
+ `[data-tab="${modeName}"]`
6593
+ );
6594
+ if (!container) continue;
6595
+
6596
+ // Move children (not the element itself) for vanilla HTML usage
6597
+ while (element.firstChild) {
6598
+ container.appendChild(element.firstChild);
6599
+ }
6600
+
6601
+ // Emit modeready so frameworks can render into the container
6602
+ this.dispatchEvent(
6603
+ new CustomEvent("modeready", {
6604
+ bubbles: true,
6605
+ detail: { mode: modeName, container },
6606
+ })
6607
+ );
6608
+ }
6609
+
6507
6610
  // Setup type dropdown switching (only if not locked)
6508
6611
  const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
6509
6612
  if (typeDropdown) {
@@ -6518,34 +6621,50 @@ class FigFillPicker extends HTMLElement {
6518
6621
  this.#dialog.open = false;
6519
6622
  });
6520
6623
 
6521
- // Emit change on close
6522
6624
  this.#dialog.addEventListener("close", () => {
6523
6625
  this.#emitChange();
6524
6626
  });
6525
6627
 
6526
- // Initialize tabs
6527
- this.#initSolidTab();
6528
- this.#initGradientTab();
6529
- this.#initImageTab();
6530
- this.#initVideoTab();
6531
- this.#initWebcamTab();
6532
- }
6533
-
6534
- #switchTab(tabName) {
6535
- // Check for allowed modes - prevent switching to disallowed mode
6536
- const mode = this.getAttribute("mode");
6537
- const allModes = ["solid", "gradient", "image", "video", "webcam"];
6538
-
6539
- let allowedModes = allModes;
6540
- if (mode) {
6541
- const requestedModes = mode.split(",").map((m) => m.trim().toLowerCase());
6542
- allowedModes = requestedModes.filter((m) => allModes.includes(m));
6543
- if (allowedModes.length === 0) allowedModes = allModes;
6628
+ // Initialize built-in tabs (skip any overridden by custom slots)
6629
+ const builtinInits = {
6630
+ solid: () => this.#initSolidTab(),
6631
+ gradient: () => this.#initGradientTab(),
6632
+ image: () => this.#initImageTab(),
6633
+ video: () => this.#initVideoTab(),
6634
+ webcam: () => this.#initWebcamTab(),
6635
+ };
6636
+ for (const [name, init] of Object.entries(builtinInits)) {
6637
+ if (!this.#customSlots[name] && allowedModes.includes(name)) init();
6544
6638
  }
6545
6639
 
6546
- if (!allowedModes.includes(tabName)) {
6547
- return; // Don't allow switching to disallowed mode
6640
+ // Listen for input/change from custom tab content
6641
+ for (const modeName of Object.keys(this.#customSlots)) {
6642
+ if (builtinModes.includes(modeName)) continue;
6643
+ const container = this.#dialog.querySelector(
6644
+ `[data-tab="${modeName}"]`
6645
+ );
6646
+ if (!container) continue;
6647
+ container.addEventListener("input", (e) => {
6648
+ if (e.target === this) return;
6649
+ e.stopPropagation();
6650
+ if (e.detail) this.#customData[modeName] = e.detail;
6651
+ this.#emitInput();
6652
+ });
6653
+ container.addEventListener("change", (e) => {
6654
+ if (e.target === this) return;
6655
+ e.stopPropagation();
6656
+ if (e.detail) this.#customData[modeName] = e.detail;
6657
+ this.#emitChange();
6658
+ });
6548
6659
  }
6660
+ }
6661
+
6662
+ #switchTab(tabName) {
6663
+ // Only allow switching to modes that have a tab container in the dialog
6664
+ const tab = this.#dialog?.querySelector(
6665
+ `.fig-fill-picker-tab[data-tab="${tabName}"]`
6666
+ );
6667
+ if (!tab) return;
6549
6668
 
6550
6669
  this.#activeTab = tabName;
6551
6670
  this.#fillType = tabName;
@@ -6566,6 +6685,12 @@ class FigFillPicker extends HTMLElement {
6566
6685
  }
6567
6686
  });
6568
6687
 
6688
+ // Zero out content padding for custom mode tabs
6689
+ const contentEl = this.#dialog.querySelector(".fig-fill-picker-content");
6690
+ if (contentEl) {
6691
+ contentEl.style.padding = this.#customSlots[tabName] ? "0" : "";
6692
+ }
6693
+
6569
6694
  // Update tab-specific UI after visibility change
6570
6695
  if (tabName === "gradient") {
6571
6696
  // Use RAF to ensure layout is complete before updating angle input
@@ -6987,7 +7112,7 @@ class FigFillPicker extends HTMLElement {
6987
7112
  <fig-input-number class="fig-fill-picker-stop-position" min="0" max="100" value="${
6988
7113
  stop.position
6989
7114
  }" units="%"></fig-input-number>
6990
- <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" dialog-position="right" value="${
7115
+ <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${
6991
7116
  stop.color
6992
7117
  }"></fig-input-color>
6993
7118
  <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
@@ -7015,9 +7140,18 @@ class FigFillPicker extends HTMLElement {
7015
7140
  this.#emitInput();
7016
7141
  });
7017
7142
 
7018
- row
7019
- .querySelector(".fig-fill-picker-stop-color")
7020
- .addEventListener("input", (e) => {
7143
+ const stopColor = row.querySelector(".fig-fill-picker-stop-color");
7144
+ const stopFillPicker = stopColor.querySelector("fig-fill-picker");
7145
+ if (stopFillPicker) {
7146
+ stopFillPicker.anchorElement = this.#dialog;
7147
+ } else {
7148
+ requestAnimationFrame(() => {
7149
+ const fp = stopColor.querySelector("fig-fill-picker");
7150
+ if (fp) fp.anchorElement = this.#dialog;
7151
+ });
7152
+ }
7153
+
7154
+ stopColor.addEventListener("input", (e) => {
7021
7155
  this.#gradient.stops[index].color =
7022
7156
  e.target.hexOpaque || e.target.value;
7023
7157
  const parsedAlpha = parseFloat(e.target.alpha);
@@ -7711,7 +7845,7 @@ class FigFillPicker extends HTMLElement {
7711
7845
  image: { url: this.#webcam.snapshot, scaleMode: "fill", scale: 50 },
7712
7846
  };
7713
7847
  default:
7714
- return base;
7848
+ return { ...base, ...this.#customData[this.#fillType] };
7715
7849
  }
7716
7850
  }
7717
7851
 
package/index.html CHANGED
@@ -9,6 +9,8 @@
9
9
  <link rel="stylesheet"
10
10
  type="text/css"
11
11
  href="fig.css">
12
+ <link rel="stylesheet"
13
+ href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css">
12
14
  <script src="fig.js"></script>
13
15
  <style>
14
16
  * {
@@ -161,6 +163,97 @@
161
163
  color: var(--figma-color-text);
162
164
  }
163
165
 
166
+ pre[class*="language-"] {
167
+ background: var(--figma-color-bg-secondary);
168
+ border: 1px solid var(--figma-color-border);
169
+ border-radius: 6px;
170
+ padding: 12px 16px;
171
+ margin: 0;
172
+ overflow-x: auto;
173
+ }
174
+
175
+ pre[class*="language-"]>code[class*="language-"] {
176
+ display: block;
177
+ background: transparent;
178
+ padding: 0;
179
+ font-family: "SF Mono", Monaco, Consolas, monospace;
180
+ font-size: 12px;
181
+ line-height: 1.5;
182
+ color: var(--figma-color-text);
183
+ text-shadow: none;
184
+ }
185
+
186
+ :root {
187
+ --code-token-comment: #6b7280;
188
+ --code-token-punctuation: #374151;
189
+ --code-token-primary: #b42318;
190
+ --code-token-string: #0f766e;
191
+ --code-token-operator: #1d4ed8;
192
+ --code-token-keyword: #6d28d9;
193
+ --code-token-function: #9a3412;
194
+ }
195
+
196
+ html[style*="color-scheme: dark"] {
197
+ --code-token-comment: #8b93a7;
198
+ --code-token-punctuation: #c7cfdd;
199
+ --code-token-primary: #ff8f8f;
200
+ --code-token-string: #6dd3b6;
201
+ --code-token-operator: #7fb4ff;
202
+ --code-token-keyword: #b79bff;
203
+ --code-token-function: #ffc27a;
204
+ }
205
+
206
+ /* Keep token colors aligned with theme and improve contrast in light mode */
207
+ .token.comment,
208
+ .token.prolog,
209
+ .token.doctype,
210
+ .token.cdata {
211
+ color: var(--code-token-comment);
212
+ }
213
+
214
+ .token.punctuation {
215
+ color: var(--code-token-punctuation);
216
+ }
217
+
218
+ .token.property,
219
+ .token.tag,
220
+ .token.boolean,
221
+ .token.number,
222
+ .token.constant,
223
+ .token.symbol,
224
+ .token.deleted {
225
+ color: var(--code-token-primary);
226
+ }
227
+
228
+ .token.selector,
229
+ .token.attr-name,
230
+ .token.string,
231
+ .token.char,
232
+ .token.builtin,
233
+ .token.inserted {
234
+ color: var(--code-token-string);
235
+ }
236
+
237
+ .token.operator,
238
+ .token.entity,
239
+ .token.url,
240
+ .language-css .token.string,
241
+ .style .token.string {
242
+ color: var(--code-token-operator);
243
+ background: transparent;
244
+ }
245
+
246
+ .token.atrule,
247
+ .token.attr-value,
248
+ .token.keyword {
249
+ color: var(--code-token-keyword);
250
+ }
251
+
252
+ .token.function,
253
+ .token.class-name {
254
+ color: var(--code-token-function);
255
+ }
256
+
164
257
  .toolbelt {
165
258
  position: fixed;
166
259
  bottom: 0.75rem;
@@ -1650,9 +1743,208 @@
1650
1743
  </fig-fill-picker>
1651
1744
 
1652
1745
  <h3>Custom Trigger</h3>
1746
+ <p class="description">Any child element can act as the trigger. If the child is not a
1747
+ <code>fig-chit</code>, it becomes click-only — the fill picker won't update its appearance.
1748
+ </p>
1749
+
1750
+ <h4>Button Trigger</h4>
1653
1751
  <fig-fill-picker value='{"type":"solid","color":"#95E1D3"}'>
1654
1752
  <fig-button>Edit Fill</fig-button>
1655
1753
  </fig-fill-picker>
1754
+ <pre
1755
+ style="background: var(--figma-color-bg-secondary); padding: 12px 16px; border-radius: 6px; overflow-x: auto; margin: 0;"><code style="font-family: monospace; font-size: 12px; color: var(--figma-color-text);">&lt;fig-fill-picker value='{"type":"solid","color":"#95E1D3"}'&gt;
1756
+ &lt;fig-button&gt;Edit Fill&lt;/fig-button&gt;
1757
+ &lt;/fig-fill-picker&gt;</code></pre>
1758
+
1759
+ <h4>Icon Button Trigger</h4>
1760
+ <fig-fill-picker value='{"type":"solid","color":"#667eea"}'>
1761
+ <fig-button icon
1762
+ variant="ghost"
1763
+ title="Pick a fill">
1764
+ <span class="fig-mask-icon"
1765
+ style="--icon: var(--icon-eyedropper)"></span>
1766
+ </fig-button>
1767
+ </fig-fill-picker>
1768
+ <pre
1769
+ style="background: var(--figma-color-bg-secondary); padding: 12px 16px; border-radius: 6px; overflow-x: auto; margin: 0;"><code style="font-family: monospace; font-size: 12px; color: var(--figma-color-text);">&lt;fig-fill-picker value='{"type":"solid","color":"#667eea"}'&gt;
1770
+ &lt;fig-button icon variant="ghost" title="Pick a fill"&gt;
1771
+ &lt;span class="fig-mask-icon" style="--icon: var(--icon-eyedropper)"&gt;&lt;/span&gt;
1772
+ &lt;/fig-button&gt;
1773
+ &lt;/fig-fill-picker&gt;</code></pre>
1774
+
1775
+ <h4>Inline Text Trigger</h4>
1776
+ <fig-fill-picker value='{"type":"solid","color":"#F38181"}'>
1777
+ <span style="cursor: pointer; text-decoration: underline; color: var(--figma-color-text-brand)">Change
1778
+ background</span>
1779
+ </fig-fill-picker>
1780
+ <pre
1781
+ style="background: var(--figma-color-bg-secondary); padding: 12px 16px; border-radius: 6px; overflow-x: auto; margin: 0;"><code style="font-family: monospace; font-size: 12px; color: var(--figma-color-text);">&lt;fig-fill-picker value='{"type":"solid","color":"#F38181"}'&gt;
1782
+ &lt;span style="cursor: pointer"&gt;Change background&lt;/span&gt;
1783
+ &lt;/fig-fill-picker&gt;</code></pre>
1784
+
1785
+ <h4>Swatch with Custom Size</h4>
1786
+ <fig-fill-picker id="fill-picker-custom-swatch"
1787
+ value='{"type":"gradient","gradient":{"type":"linear","angle":135,"stops":[{"position":0,"color":"#f093fb","opacity":100},{"position":100,"color":"#f5576c","opacity":100}]}}'>
1788
+ <div id="custom-swatch-preview"
1789
+ style="width: 3rem; height: 3rem; border-radius: 0.5rem; cursor: pointer; background: linear-gradient(135deg, #f093fb, #f5576c)">
1790
+ </div>
1791
+ </fig-fill-picker>
1792
+ <pre
1793
+ style="background: var(--figma-color-bg-secondary); padding: 12px 16px; border-radius: 6px; overflow-x: auto; margin: 0;"><code style="font-family: monospace; font-size: 12px; color: var(--figma-color-text);">&lt;fig-fill-picker value='{"type":"gradient",...}'&gt;
1794
+ &lt;div style="width: 3rem; height: 3rem; border-radius: 0.5rem;
1795
+ cursor: pointer; background: linear-gradient(...)"&gt;
1796
+ &lt;/div&gt;
1797
+ &lt;/fig-fill-picker&gt;</code></pre>
1798
+ <script>
1799
+ (() => {
1800
+ const picker = document.getElementById('fill-picker-custom-swatch');
1801
+ const swatch = document.getElementById('custom-swatch-preview');
1802
+ if (!picker || !swatch) return;
1803
+
1804
+ const getSizingForMedia = (scaleMode, scale) => {
1805
+ switch (scaleMode) {
1806
+ case 'fit':
1807
+ return { size: 'contain', position: 'center' };
1808
+ case 'tile':
1809
+ return { size: `${scale || 50}%`, position: 'top left' };
1810
+ case 'fill':
1811
+ case 'crop':
1812
+ default:
1813
+ return { size: 'cover', position: 'center' };
1814
+ }
1815
+ };
1816
+
1817
+ const updateSwatchPreview = (fill) => {
1818
+ if (!fill) return;
1819
+
1820
+ let background = 'transparent';
1821
+ let backgroundSize = 'cover';
1822
+ let backgroundPosition = 'center';
1823
+
1824
+ if (fill.type === 'solid') {
1825
+ background = fill.color || '#D9D9D9';
1826
+ } else if (fill.type === 'gradient') {
1827
+ background = fill.css || background;
1828
+ } else if (fill.type === 'image' && fill.image?.url) {
1829
+ background = `url(${fill.image.url})`;
1830
+ const sizing = getSizingForMedia(fill.image.scaleMode, fill.image.scale);
1831
+ backgroundSize = sizing.size;
1832
+ backgroundPosition = sizing.position;
1833
+ } else if (fill.type === 'video' && fill.video?.url) {
1834
+ background = `url(${fill.video.url})`;
1835
+ const sizing = getSizingForMedia(fill.video.scaleMode, fill.video.scale);
1836
+ backgroundSize = sizing.size;
1837
+ backgroundPosition = sizing.position;
1838
+ } else if (fill.type === 'webcam' && fill.image?.url) {
1839
+ background = `url(${fill.image.url})`;
1840
+ }
1841
+
1842
+ swatch.style.background = background;
1843
+ swatch.style.backgroundSize = backgroundSize;
1844
+ swatch.style.backgroundPosition = backgroundPosition;
1845
+ };
1846
+
1847
+ picker.addEventListener('input', (e) => updateSwatchPreview(e.detail));
1848
+ picker.addEventListener('change', (e) => updateSwatchPreview(e.detail));
1849
+ updateSwatchPreview(picker.value);
1850
+ })();
1851
+ </script>
1852
+
1853
+ <h3>Custom Mode (Slots)</h3>
1854
+ <p class="description">Add custom modes via <code>slot="mode-{name}"</code> children. The mode name must
1855
+ also appear in the <code>mode</code> attribute. Custom slot content manages its own UI; dispatch
1856
+ <code>input</code>/<code>change</code> events with a <code>detail</code> payload to relay values back.
1857
+ </p>
1858
+
1859
+ <h4>Custom "Shader" Mode</h4>
1860
+ <fig-fill-picker mode="solid,shader"
1861
+ value='{"type":"solid","color":"#95E1D3"}'
1862
+ experimental="modern">
1863
+ <div slot="mode-shader"
1864
+ label="Shader">
1865
+ <fig-field label="Fragment Shader">
1866
+ <fig-input-text multiline
1867
+ placeholder="void main() { ... }"
1868
+ value="void main() {&#10; gl_FragColor = vec4(1.0, 0.0, 0.5, 1.0);&#10;}"></fig-input-text>
1869
+ </fig-field>
1870
+ </div>
1871
+ </fig-fill-picker>
1872
+ <pre
1873
+ style="background: var(--figma-color-bg-secondary); padding: 12px 16px; border-radius: 6px; overflow-x: auto; margin: 0;"><code style="font-family: monospace; font-size: 12px; color: var(--figma-color-text);">&lt;fig-fill-picker mode="solid,shader"&gt;
1874
+ &lt;div slot="mode-shader" label="Shader"&gt;
1875
+ &lt;fig-field label="Fragment Shader"&gt;
1876
+ &lt;fig-input-text multiline
1877
+ placeholder="void main() { ... }"
1878
+ value="..."&gt;&lt;/fig-input-text&gt;
1879
+ &lt;/fig-field&gt;
1880
+ &lt;/div&gt;
1881
+ &lt;/fig-fill-picker&gt;</code></pre>
1882
+
1883
+ <h4>React Custom Mode (via <code>modeready</code>)</h4>
1884
+ <p class="description">Frameworks like React can listen for the <code>modeready</code> event and render
1885
+ directly into the provided container. The DOM is never moved — React owns its tree from the start.</p>
1886
+ <fig-fill-picker id="react-picker" mode="solid,react-demo">
1887
+ <div slot="mode-react-demo" label="React"></div>
1888
+ </fig-fill-picker>
1889
+ <script type="module">
1890
+ import React from 'https://esm.sh/react@18';
1891
+ import { createRoot } from 'https://esm.sh/react-dom@18/client';
1892
+
1893
+ const h = React.createElement;
1894
+ const picker = document.getElementById('react-picker');
1895
+
1896
+ picker.addEventListener('modeready', (e) => {
1897
+ if (e.detail.mode !== 'react-demo') return;
1898
+
1899
+ function ColorButtons() {
1900
+ const [color, setColor] = React.useState('#667eea');
1901
+ const colors = ['#FF6B6B', '#4ECDC4', '#667eea', '#f093fb', '#95E1D3'];
1902
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
1903
+ h('p', {
1904
+ style: {
1905
+ fontSize: 11,
1906
+ color: 'var(--figma-color-text-secondary)',
1907
+ margin: 0,
1908
+ }
1909
+ }, `Selected: ${color}`),
1910
+ h('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap' } },
1911
+ colors.map((c) =>
1912
+ h('button', {
1913
+ key: c,
1914
+ onClick: () => setColor(c),
1915
+ style: {
1916
+ width: 28,
1917
+ height: 28,
1918
+ borderRadius: 4,
1919
+ border: c === color ? '2px solid var(--figma-color-text)' : '1px solid var(--figma-color-border)',
1920
+ background: c,
1921
+ cursor: 'pointer',
1922
+ padding: 0,
1923
+ }
1924
+ })
1925
+ )
1926
+ )
1927
+ );
1928
+ }
1929
+
1930
+ createRoot(e.detail.container).render(h(ColorButtons));
1931
+ });
1932
+ </script>
1933
+ <pre
1934
+ style="background: var(--figma-color-bg-secondary); padding: 12px 16px; border-radius: 6px; overflow-x: auto; margin: 0;"><code style="font-family: monospace; font-size: 12px; color: var(--figma-color-text);">&lt;fig-fill-picker id="react-picker" mode="solid,react-demo"&gt;
1935
+ &lt;div slot="mode-react-demo" label="React"&gt;&lt;/div&gt;
1936
+ &lt;/fig-fill-picker&gt;
1937
+
1938
+ &lt;script type="module"&gt;
1939
+ import React from 'https://esm.sh/react@18';
1940
+ import { createRoot } from 'https://esm.sh/react-dom@18/client';
1941
+
1942
+ const picker = document.getElementById('react-picker');
1943
+ picker.addEventListener('modeready', (e) =&gt; {
1944
+ if (e.detail.mode !== 'react-demo') return;
1945
+ createRoot(e.detail.container).render(&lt;MyComponent /&gt;);
1946
+ });
1947
+ &lt;/script&gt;</code></pre>
1656
1948
 
1657
1949
  <h3>Without Alpha</h3>
1658
1950
  <fig-fill-picker alpha="false"
@@ -3274,9 +3566,11 @@
3274
3566
  &lt;/dialog&gt;</code></pre>
3275
3567
 
3276
3568
  <h3>Viewport Margin</h3>
3277
- <p class="description">Use <code>viewport-margin</code> to define safe areas the popup should avoid (e.g. a bottom toolbar). Uses CSS margin shorthand: <code>top right bottom left</code>.</p>
3569
+ <p class="description">Use <code>viewport-margin</code> to define safe areas the popup should avoid (e.g. a
3570
+ bottom toolbar). Uses CSS margin shorthand: <code>top right bottom left</code>.</p>
3278
3571
  <fig-button id="popup-open-viewport-margin"
3279
- onclick="document.getElementById('popup-viewport-margin').open = true">Open (64px bottom margin)</fig-button>
3572
+ onclick="document.getElementById('popup-viewport-margin').open = true">Open (64px bottom
3573
+ margin)</fig-button>
3280
3574
  <dialog id="popup-viewport-margin"
3281
3575
  is="fig-popup"
3282
3576
  anchor="#popup-open-viewport-margin"
@@ -3285,7 +3579,9 @@
3285
3579
  viewport-margin="8 8 64 8">
3286
3580
  <vstack style="min-width: 14rem;">
3287
3581
  <strong style="padding: 0 var(--spacer-1);">Viewport Margin</strong>
3288
- <p style="padding: 0 var(--spacer-1); margin: 0; font-size: var(--font-size-small); color: var(--figma-color-text-secondary);">This popup won't overlap the bottom 64px of the viewport.</p>
3582
+ <p
3583
+ style="padding: 0 var(--spacer-1); margin: 0; font-size: var(--font-size-small); color: var(--figma-color-text-secondary);">
3584
+ This popup won't overlap the bottom 64px of the viewport.</p>
3289
3585
  <fig-input-text placeholder="Try scrolling down"></fig-input-text>
3290
3586
  </vstack>
3291
3587
  </dialog>
@@ -4565,7 +4861,50 @@ button.addEventListener('click', () => {
4565
4861
  </section>
4566
4862
  </div>
4567
4863
 
4864
+ <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js"></script>
4865
+ <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-markup.min.js"></script>
4866
+ <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-css.min.js"></script>
4867
+ <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-clike.min.js"></script>
4868
+ <script src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-javascript.min.js"></script>
4568
4869
  <script>
4870
+ function detectCodeLanguage(codeText) {
4871
+ const source = codeText.trim();
4872
+ if (!source) return 'javascript';
4873
+
4874
+ if (
4875
+ source.startsWith('<') ||
4876
+ source.startsWith('&lt;') ||
4877
+ source.includes('&lt;/') ||
4878
+ source.includes('</')
4879
+ ) {
4880
+ return 'markup';
4881
+ }
4882
+
4883
+ if (
4884
+ /(^|[\n\r])\s*[@.#a-zA-Z][\w\s.#:[\]-]*\{/.test(source) ||
4885
+ /--[\w-]+\s*:/.test(source)
4886
+ ) {
4887
+ return 'css';
4888
+ }
4889
+
4890
+ return 'javascript';
4891
+ }
4892
+
4893
+ function highlightCodeBlocks() {
4894
+ const blocks = document.querySelectorAll('pre > code');
4895
+ blocks.forEach((code) => {
4896
+ if (!code.className.includes('language-')) {
4897
+ const language = detectCodeLanguage(code.textContent || '');
4898
+ code.classList.add(`language-${language}`);
4899
+ code.parentElement?.classList.add(`language-${language}`);
4900
+ }
4901
+ });
4902
+
4903
+ if (window.Prism) {
4904
+ window.Prism.highlightAll();
4905
+ }
4906
+ }
4907
+
4569
4908
  // Highlight nav item based on hash
4570
4909
  function updateActiveNav() {
4571
4910
  const hash = location.hash || '#avatar';
@@ -4610,6 +4949,7 @@ button.addEventListener('click', () => {
4610
4949
 
4611
4950
  // Initial state
4612
4951
  window.addEventListener('load', () => {
4952
+ highlightCodeBlocks();
4613
4953
  updateActiveNav();
4614
4954
  if (location.hash) {
4615
4955
  document.querySelector(location.hash)?.scrollIntoView();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "2.24.0",
3
+ "version": "2.25.0",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "MIT",