figma-plugin-utilities 0.4.0 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -2,10 +2,50 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.1] - 2026-10-07
6
+
7
+ ### Changed
8
+ - Built against **figma-ui3-kit-svelte** 0.7.0, which `confirmDiscardChanges` needs for Modal's `beforeClose`, and **ConfirmModal** over another modal for Escape to close only the top one
9
+
10
+ ## [0.5.0] - 2026-10-07
11
+
12
+ ### Added
13
+ - **ConfirmModal** with `confirmAction`, from Data Mapper — a confirmation in a small modal in place of the browser's `confirm()`, resolving true or false, its buttons stacked at full width in a window too narrow for them side by side — and `confirmDiscardChanges`, the "Discard changes?" prompt before unsaved edits go, for Modal's `beforeClose`
14
+ - **MappingChip** — one side of a source → target row as a filled 24px chip: a button with a lead icon or chit, a truncating `label`, an optional `preview` and `count`. `tone` is `default`, `secondary` or `component`; `selected` draws the selection border
15
+ - **FieldGrid** — fields side by side in `columns` equal columns (2 by default) that shrink below their content
16
+ - **SteppedField** — a field with − and + icon buttons after it, named by `downLabel` and `upLabel`, firing `step` with -1 or 1
17
+ - **LadderBadges** — a scale's sizes as badges, outlined where used and `archived` where not, each with a tooltip
18
+ - **RampCurve** — a ramp's quadratic Bézier at one breakpoint, the others faint behind it. At the smallest and largest breakpoint the ends and the bend are draggable or stepped with the arrow keys. Fires `change` and `select`
19
+ - **CodeExportModal** — read-only code in a modal with a copy button that reads "Copied" for two seconds, and a `controls` slot for options above the code
20
+ - **Section** — a titled group of fields as in Figma's panels, with icon buttons in an `actions` slot. It pads its own content and shrinks with its container
21
+ - **DataTable** — named rows with a cell per column. Selectable rows open an `editor` slot; with `selectable={false}` it's a read-only table. Rows take badges with tooltips, and cells are badges, variable chips or text, colored as new, changed or danger
22
+ - `lib/scale` — responsive scale math with no Figma API: the ramp's Bézier (`bezier`, `clampPosition`, `levelT`), `blendAt`, `valueAtWidth`, fallback breakpoint widths (`FALLBACK_WIDTHS`, `isBreakpointName`, `widthsFor`), `fluidClamp`, `trimNumber`, `lerp` and `roundHalfDown`
23
+ - `lib/figma-variables` — `isVariableAlias`, `isColorValue`, `toRgba`, `getVariableLookup`, and `resolveVariableValue` and `resolveVariableValueAsync`, which follow aliases at a mode; the async one follows library variables too
24
+ - **figma-helpers** — `fontsOf`, `loadFontOnce`, `loadNodeFonts` and `setText` write text in the layer's own fonts, loading each font once per run; `createSettingsStore` keeps settings in clientStorage, cleaned by one `sanitize` on load and on save
25
+ - `isValidHex` — six HEX digits in either case, the "#" required unless `requireHash` is false
26
+ - `plural` and `joinList` — "3 layers", "a, b and c"; from the root, or `lib/format` in code.ts
27
+ - **figma-helpers** — `showNotice`, a regular notification for a run with nothing to do, where `showError` is for failures
28
+ - `UNDO` — "Press Ctrl/Cmd+Z to undo.", the last sentence of a success notification for a change to the file
29
+
30
+ ### Changed
31
+ - **ListItem** — an `actions` slot puts buttons inside the item, after its text and outside its clickable area, in the Figma component too (**Actions slot**)
32
+ - **Header** — `level` sets the title's heading level (1 by default). The left padding is 8px when the left slot has content and 16px before a title alone, in the Figma component too
33
+ - **Footer** — a kit `Text` at the edge of a split footer sits 16px in, where buttons sit 8px in
34
+ - `rgbToHex` takes `lowercase`, `hash` and `alpha` options and clamps channels to 0–1
35
+ - The color utilities are TypeScript, with their own `lib/colors` entry for code.ts
36
+ - `loadFont` loads each font once per run
37
+ - `showError` and `showSuccess` stay up long enough to read by default, about 60ms a character, and at least 5s and 3s
38
+ - **figma-frame-builders** — `createTokenChip` pads its label 6px on each side, and `specTokens.accentColors.green` is #40C459, as in the Vitrine spec library
39
+
40
+ ### Removed
41
+ - `getCollections`, `getVariables` and `getSelection` — call the Figma API directly
42
+ - `saveToStorage` and `loadFromStorage` — use `createSettingsStore`
43
+ - `notifyError`, `notifySuccess` and `notifyWarning`, which did nothing in the UI — use `showError` and `showSuccess` in code.ts
44
+
5
45
  ## [0.4.0] - 2026-09-21
6
46
 
7
47
  ### Added
8
- - `figma-frame-builders.ts` — new module with Figma frame and component builder utilities, imported from `figma-plugin-utilities/lib/figma-frame-builders` (its own export entry). They mirror the Vitrine spec library — colours, typography, spacing and layer names (`label` chip and text, a `tokens` row in token cells, `title` in both header variants):
48
+ - `figma-frame-builders.ts` — new module with Figma frame and component builder utilities, imported from `figma-plugin-utilities/lib/figma-frame-builders` (its own export entry). They mirror the Vitrine spec library — colors, typography, spacing and layer names (`label` chip and text, a `tokens` row in token cells, `title` in both header variants):
9
49
  - `createAutoLayoutFrame` — creates a `FrameNode` with auto-layout configured
10
50
  - `createAutoLayoutComponent` — creates a `ComponentNode` with auto-layout configured
11
51
  - `createText` — creates a styled `TextNode`
package/README.md CHANGED
@@ -24,6 +24,14 @@ import {
24
24
  LoadingState,
25
25
  FieldGroup,
26
26
  CheckboxCard,
27
+ Section,
28
+ DataTable,
29
+ FieldGrid,
30
+ SteppedField,
31
+ LadderBadges,
32
+ CodeExportModal,
33
+ RampCurve,
34
+ MappingChip,
27
35
  // Messages
28
36
  sendToPlugin,
29
37
  createMessageHandler,
@@ -44,9 +52,6 @@ import {
44
52
  // Error handling
45
53
  safeAsync,
46
54
  parseJsonSafe,
47
- notifyError,
48
- notifySuccess,
49
- notifyWarning,
50
55
  // Resize
51
56
  setDefaultWidth,
52
57
  getContentHeight,
@@ -78,6 +83,14 @@ import { sendToPlugin, createMessageHandler } from "figma-plugin-utilities/lib";
78
83
  | `LoadingState` | Centred message as `role="status"` (text only, no spinner) |
79
84
  | `FieldGroup` | Label + input wrapper; `labelFor` binds the label to a text control |
80
85
  | `CheckboxCard` | Large checkbox with card styling and better touch targets; `change` event |
86
+ | `Section` | Titled group of fields as in Figma's panels: a `Header` with the title and an `actions` slot, content padded by the section itself |
87
+ | `DataTable` | Named rows with a cell per column (a set at each breakpoint, a style before and after): columns with their own width and alignment, a read-only mode with table roles, row selection with an `editor` slot, notes as badges, with tooltips, and a `+N` count past two, an `action` slot, values as badges or variable chips, removed rows and a marked column |
88
+ | `FieldGrid` | Fields side by side in equal columns (`columns`, default 2) that shrink below their content |
89
+ | `SteppedField` | A field with − and + icon buttons after it, as one grid cell; `step` event with -1 or 1 |
90
+ | `LadderBadges` | A scale's sizes as badges, outlined where used and archived where not, each with the caller's tooltip |
91
+ | `RampCurve` | A ramp's Bézier at the breakpoint shown, in the caller's units: handles for the ends and the bend at the smallest and largest breakpoint, blends between; `change` and `select` events |
92
+ | `MappingChip` | One side of a source → target row: a filled 24px chip with a lead icon or chit, a truncating label, a `preview` after a dot and a trailing `count`; `click` event |
93
+ | `CodeExportModal` | Read-only code in a modal with a copy button that reads "Copied" for 2s; a `controls` slot above the code |
81
94
 
82
95
  Every component also takes a `class` (or `className`) prop.
83
96
 
@@ -195,6 +208,103 @@ Large checkbox with card-style background and better touch targets.
195
208
  </CheckboxCard>
196
209
  ```
197
210
 
211
+ ### FieldGrid
212
+
213
+ ```svelte
214
+ <FieldGrid columns={3}>
215
+ <FieldGroup label="Base">…</FieldGroup>
216
+ <FieldGroup label="Ratio">…</FieldGroup>
217
+ <FieldGroup label="Steps">…</FieldGroup>
218
+ </FieldGrid>
219
+ ```
220
+
221
+ ### SteppedField
222
+
223
+ ```svelte
224
+ <SteppedField
225
+ downLabel="Step {set.name} down"
226
+ upLabel="Step {set.name} up"
227
+ on:step={(e) => setOffset(set.offset + e.detail)}
228
+ >
229
+ <FieldGroup label="Steps off the curve" labelFor="offset" size="small">
230
+ <NumericInput id="offset" value={set.offset} precision={0} />
231
+ </FieldGroup>
232
+ </SteppedField>
233
+ ```
234
+
235
+ ### LadderBadges
236
+
237
+ ```svelte
238
+ <LadderBadges
239
+ ariaLabel="Ladder sizes"
240
+ badges={ladder.map((value, i) => ({
241
+ value,
242
+ used: used.has(i),
243
+ title: used.has(i) ? "Used by a style" : "Unused",
244
+ }))}
245
+ />
246
+ ```
247
+
248
+ Each badge's accessible name is `"{value}px, used"` or `"{value}px, unused"`.
249
+
250
+ ### MappingChip
251
+
252
+ ```svelte
253
+ <MappingChip
254
+ label={row.sourceName}
255
+ count={row.uses}
256
+ tone="secondary"
257
+ title="Select these icons"
258
+ on:click={() => reveal(row)}
259
+ />
260
+ ```
261
+
262
+ `iconName` or `chit` (which wins) adds a lead that hangs into the padding; `preview` follows the label as "label · preview"; `tone` is `default`, `secondary` or `component`; `selected` draws the selection border. The `lead` slot takes a marker ahead of the lead, and `element` binds the button.
263
+
264
+ ### RampCurve
265
+
266
+ ```svelte
267
+ <RampCurve
268
+ {ramp}
269
+ curves={breakpoints.map((_, b) => rampAt(ramp, b))}
270
+ span={[lo, hi]}
271
+ grid={ladder.map((size, rung) => ({ key: rung, y: size, label: size }))}
272
+ dots={levels.map((size, k) => ({ key: k, x: k / (levels.length - 1), y: size }))}
273
+ {breakpoints}
274
+ {selected}
275
+ rungCount={ladder.length}
276
+ rungAt={(px) => nearestRung(ladder, px)}
277
+ format={(px) => `${Math.round(px)}px`}
278
+ ariaLabel="Heading ramp"
279
+ bottomLabel="Smallest level"
280
+ topLabel="Largest level"
281
+ along="levels"
282
+ on:change={(e) => (ramp = { ...ramp, ...e.detail })}
283
+ on:select={(e) => (selected = e.detail)}
284
+ />
285
+ ```
286
+
287
+ `ramp` holds the ends as rungs and the bends at each end (`bottomSm`, `topSm`, `bendSm`, `bottomLg`, `topLg`, `bendLg`) and `bendPosition`; `change` patches those keys. Everything else on the y axis is in the caller's units.
288
+
289
+ ### CodeExportModal
290
+
291
+ ```svelte
292
+ <CodeExportModal
293
+ isOpen={exportOpen}
294
+ title="Export CSS"
295
+ value={css}
296
+ ariaLabel="Exported CSS"
297
+ copyLabel="Copy CSS"
298
+ onClose={() => (exportOpen = false)}
299
+ >
300
+ <svelte:fragment slot="controls">
301
+ <SegmentedControl …/>
302
+ </svelte:fragment>
303
+ </CodeExportModal>
304
+ ```
305
+
306
+ Copies with `execCommand`, since the plugin iframe isn't granted clipboard-write. `position` (default `"bottom"`), `width` (`"medium"`) and `height` (`"auto"`) pass through to `Modal`.
307
+
198
308
  ## Utilities
199
309
 
200
310
  ### Messages (`lib/messages.js`)
@@ -260,11 +370,6 @@ if (result.ok) {
260
370
  // Parse JSON safely
261
371
  const parsed = parseJsonSafe(jsonString);
262
372
  // { ok: true, value: {...} } or { ok: false, error: "..." }
263
-
264
- // Figma notifications
265
- notifySuccess("Done!");
266
- notifyError("Something went wrong");
267
- notifyWarning("Check your input");
268
373
  ```
269
374
 
270
375
  ### Resize (`lib/resize.js`)
@@ -294,7 +399,7 @@ setDefaultWidth(320);
294
399
 
295
400
  ### Spec Frame Builders (`lib/figma-frame-builders.ts`)
296
401
 
297
- Typed builders for canvas frames in a spec or documentation generator — auto-layout frames and components, text, token chips, colour swatches, table cells and headers, with light and dark palettes.
402
+ Typed builders for canvas frames in a spec or documentation generator — auto-layout frames and components, text, token chips, color swatches, table cells and headers, with light and dark palettes.
298
403
 
299
404
  ```typescript
300
405
  import {
@@ -312,6 +417,14 @@ row.appendChild(createTokenChip({ label: "#FFFFFF", background: theme.chipBg, te
312
417
 
313
418
  `specTokens` carries `accentColors`, `fonts` and `themes` (`light`, `dark`). Builders that can return either node take `as: "component"` for a `ComponentNode` instead of a `FrameNode`. Exported types: `PaddingSpec`, `SpecTheme`, `NodeKind`, `NodeFor`.
314
419
 
420
+ ### Scale Math (`lib/scale.ts`)
421
+
422
+ Pure math for scales shaped across breakpoints — the ramp's quadratic Bézier (`bezier`, `clampPosition`, `levelT`), blending by viewport width (`blendAt`), values between breakpoints (`valueAtWidth`), fallback widths (`FALLBACK_WIDTHS`, `isBreakpointName`, `widthsFor`) and fluid CSS (`fluidClamp`, `trimNumber`), plus `lerp` and `roundHalfDown`. No Figma API: both threads may import it, provided the plugin builds each thread separately.
423
+
424
+ ```typescript
425
+ import { widthsFor, blendAt, fluidClamp } from "figma-plugin-utilities/lib/scale";
426
+ ```
427
+
315
428
  ### Figma Helpers (`lib/figma-helpers.ts`)
316
429
 
317
430
  For use in `code.ts`:
@@ -321,13 +434,10 @@ import {
321
434
  sendToUI,
322
435
  showError,
323
436
  showSuccess,
324
- getCollections,
325
- getVariables,
326
- getSelection,
327
437
  focusNodes,
328
438
  loadFont,
329
- saveToStorage,
330
- loadFromStorage,
439
+ setText,
440
+ createSettingsStore,
331
441
  handleResize,
332
442
  } from "figma-plugin-utilities/lib/figma-helpers";
333
443
 
@@ -338,23 +448,21 @@ sendToUI("success", { message: "Done!" });
338
448
  showError("Something went wrong");
339
449
  showSuccess("Created!");
340
450
 
341
- // Variables
342
- const collections = await getCollections();
343
- const colorVars = await getVariables("COLOR");
344
-
345
- // Selection
346
- const selected = getSelection(); // all selected nodes
347
- const frames = getSelection("FRAME"); // filtered by type
348
-
349
451
  // Focus viewport on nodes
350
452
  focusNodes(figma.currentPage.selection);
351
453
 
352
- // Load font before using
454
+ // Load a font before using it (once per run)
353
455
  await loadFont("Inter", "Regular");
354
456
 
355
- // Client storage
356
- await saveToStorage("settings", { theme: "dark" });
357
- const settings = await loadFromStorage("settings", { theme: "light" });
457
+ // Set a text layer's characters in its own fonts
458
+ await setText(textNode, "Hello");
459
+
460
+ // Settings in client storage, cleaned on load and on save
461
+ const store = createSettingsStore("settings", (raw) => ({
462
+ theme: (raw as { theme?: string })?.theme === "dark" ? "dark" : "light",
463
+ }));
464
+ const settings = await store.load();
465
+ await store.save({ ...settings, theme: "dark" });
358
466
 
359
467
  // Handle resize message from UI (call in your message handler)
360
468
  if (msg.type === "resize") handleResize(msg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-plugin-utilities",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Shared Svelte components and utilities for Figma plugins",
5
5
  "type": "module",
6
6
  "svelte": "./src/index.js",
@@ -33,6 +33,31 @@
33
33
  "types": "./src/lib/figma-frame-builders.ts",
34
34
  "import": "./src/lib/figma-frame-builders.ts",
35
35
  "default": "./src/lib/figma-frame-builders.ts"
36
+ },
37
+ "./lib/figma-variables": {
38
+ "types": "./src/lib/figma-variables.ts",
39
+ "import": "./src/lib/figma-variables.ts",
40
+ "default": "./src/lib/figma-variables.ts"
41
+ },
42
+ "./lib/colors": {
43
+ "types": "./src/lib/colors.ts",
44
+ "import": "./src/lib/colors.ts",
45
+ "default": "./src/lib/colors.ts"
46
+ },
47
+ "./lib/scale": {
48
+ "types": "./src/lib/scale.ts",
49
+ "import": "./src/lib/scale.ts",
50
+ "default": "./src/lib/scale.ts"
51
+ },
52
+ "./lib/format": {
53
+ "types": "./src/lib/format.ts",
54
+ "import": "./src/lib/format.ts",
55
+ "default": "./src/lib/format.ts"
56
+ },
57
+ "./lib/confirm": {
58
+ "types": "./src/lib/confirm.ts",
59
+ "import": "./src/lib/confirm.ts",
60
+ "default": "./src/lib/confirm.ts"
36
61
  }
37
62
  },
38
63
  "files": [
@@ -66,7 +91,7 @@
66
91
  "@types/node": "^22.13.4",
67
92
  "eslint": "^9.39.2",
68
93
  "eslint-plugin-svelte": "^3.23.0",
69
- "figma-ui3-kit-svelte": "^0.6.0",
94
+ "figma-ui3-kit-svelte": "^0.7.0",
70
95
  "globals": "^17.12.0",
71
96
  "prettier": "^3.9.8",
72
97
  "prettier-plugin-svelte": "^3.4.0",
@@ -0,0 +1,98 @@
1
+ <!--
2
+ Read-only code in a modal, with a button that copies it. Each modal keeps
3
+ its own "Copied" state, so copying one never marks the other.
4
+ -->
5
+ <script>
6
+ import { onDestroy } from "svelte";
7
+ import { Button, Modal, Textarea } from "figma-ui3-kit-svelte";
8
+
9
+ export let isOpen = false;
10
+ export let title;
11
+ export let value = "";
12
+ /** Names the code for assistive tech, e.g. "Exported token JSON". */
13
+ export let ariaLabel;
14
+ /** The copy button's label, e.g. "Copy JSON". */
15
+ export let copyLabel;
16
+ export let position = "bottom";
17
+ export let width = "medium";
18
+ export let height = "auto";
19
+ export let onClose = null;
20
+
21
+ let copied = false;
22
+ let copyTimer = null;
23
+ onDestroy(() => clearTimeout(copyTimer));
24
+
25
+ // Reopened, the button reads as it would before a copy.
26
+ $: if (isOpen) copied = false;
27
+
28
+ // execCommand, not the Clipboard API: the plugin iframe isn't granted
29
+ // clipboard-write.
30
+ function handleCopy() {
31
+ try {
32
+ const prevActive = document.activeElement;
33
+ const ta = document.createElement("textarea");
34
+ ta.value = value;
35
+ ta.style.position = "fixed";
36
+ ta.style.left = "-999999px";
37
+ ta.style.top = "-999999px";
38
+ document.body.appendChild(ta);
39
+ ta.focus();
40
+ ta.select();
41
+ const ok = document.execCommand("copy");
42
+ ta.remove();
43
+ if (prevActive instanceof HTMLElement) prevActive.focus();
44
+ if (ok) {
45
+ copied = true;
46
+ clearTimeout(copyTimer);
47
+ copyTimer = setTimeout(() => (copied = false), 2000);
48
+ }
49
+ } catch {
50
+ // execCommand unavailable — nothing to do.
51
+ }
52
+ }
53
+ </script>
54
+
55
+ <Modal
56
+ {isOpen}
57
+ {title}
58
+ {position}
59
+ {width}
60
+ {height}
61
+ overlayPadding="0px"
62
+ {onClose}
63
+ >
64
+ <div class="export-content">
65
+ <!-- Options for what's exported, above the code. -->
66
+ <slot name="controls" />
67
+ <Textarea {value} readonly {ariaLabel} variant="code" />
68
+ </div>
69
+ <svelte:fragment slot="footer-right">
70
+ <Button variant="primary" on:click={handleCopy}>
71
+ {copied ? "Copied" : copyLabel}
72
+ </Button>
73
+ </svelte:fragment>
74
+ </Modal>
75
+
76
+ <style>
77
+ .export-content {
78
+ flex: 1;
79
+ display: flex;
80
+ flex-direction: column;
81
+ gap: var(--size-xsmall);
82
+ min-height: 0;
83
+ }
84
+
85
+ .export-content :global(.textarea) {
86
+ flex: 1;
87
+ display: flex;
88
+ flex-direction: column;
89
+ min-height: 0;
90
+ }
91
+
92
+ .export-content :global(textarea) {
93
+ flex: 1;
94
+ min-height: 0;
95
+ resize: none;
96
+ white-space: pre;
97
+ }
98
+ </style>
@@ -0,0 +1,66 @@
1
+ <!--
2
+ The dialog confirmAction() opens: mount one in the plugin's UI, after
3
+ everything else, so it shows above any modal that asks.
4
+ -->
5
+ <script>
6
+ import { Modal, Button, Text } from "figma-ui3-kit-svelte";
7
+ import { confirmRequest, answerConfirm } from "../lib/confirm";
8
+ </script>
9
+
10
+ <Modal
11
+ isOpen={!!$confirmRequest}
12
+ title={$confirmRequest?.title ?? ""}
13
+ width="small"
14
+ position="center"
15
+ on:close={() => answerConfirm(false)}
16
+ >
17
+ <Text>{$confirmRequest?.message ?? ""}</Text>
18
+
19
+ <svelte:fragment slot="footer-full">
20
+ <div class="confirm-footer">
21
+ <div class="confirm-actions">
22
+ <Button variant="secondary" on:click={() => answerConfirm(false)}>
23
+ {$confirmRequest?.cancelLabel || "Cancel"}
24
+ </Button>
25
+ <Button
26
+ variant={$confirmRequest?.destructive ? "destructive" : "primary"}
27
+ on:click={() => answerConfirm(true)}
28
+ >
29
+ {$confirmRequest?.confirmLabel ?? ""}
30
+ </Button>
31
+ </div>
32
+ </div>
33
+ </svelte:fragment>
34
+ </Modal>
35
+
36
+ <style>
37
+ /* The kit's footer is 40px tall; this one grows with stacked buttons. */
38
+ :global(.modal-footer:has(.confirm-footer)) {
39
+ height: auto;
40
+ }
41
+
42
+ /* The footer's width, which the buttons are laid out by. */
43
+ .confirm-footer {
44
+ container-type: inline-size;
45
+ }
46
+
47
+ .confirm-actions {
48
+ display: flex;
49
+ justify-content: flex-end;
50
+ gap: var(--size-xxsmall);
51
+ width: 100%;
52
+ }
53
+
54
+ /* In a narrow window, such as a 240px plugin, the buttons don't fit side
55
+ by side: they stack at full width, the confirming one on top. */
56
+ @container (max-width: 215px) {
57
+ .confirm-actions {
58
+ flex-direction: column-reverse;
59
+ align-items: stretch;
60
+ }
61
+
62
+ .confirm-actions > :global(*) {
63
+ width: 100%;
64
+ }
65
+ }
66
+ </style>