figma-plugin-utilities 0.3.0 → 0.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/CHANGELOG.md CHANGED
@@ -1,11 +1,39 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ ## [Unreleased]
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+ ## [0.4.0] - 2026-09-21
7
6
 
8
- ## [Unreleased]
7
+ ### 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):
9
+ - `createAutoLayoutFrame` — creates a `FrameNode` with auto-layout configured
10
+ - `createAutoLayoutComponent` — creates a `ComponentNode` with auto-layout configured
11
+ - `createText` — creates a styled `TextNode`
12
+ - `createTokenChip` — creates a rounded chip frame for displaying color tokens
13
+ - `createColorSwatch` — creates a color swatch frame
14
+ - `createTableCell` — creates a table cell frame
15
+ - `createTableHeader` — creates a table header frame
16
+ - `loadSpecFonts` — loads Inter and IBM Plex Mono font faces in parallel
17
+ - `specTokens` — design token constants (accent colors, font specs, light/dark themes with an optional `headerBorder`)
18
+ - `PaddingSpec`, `SpecTheme`, `NodeKind` and `NodeFor` types
19
+
20
+ ### Removed
21
+ - The dev-mode `console.warn` **FieldGroup** logged when `label` was set without `labelFor` — a `Dropdown` is a button and cannot be a `<label for>` target, so it fired on correct code. Dropped in the a11y pass, recorded late
22
+
23
+ ### Fixed
24
+ - **StatusBar** — the default `info` type sets `color: var(--figma-color-text)`. The `error`, `success` and `warning` types each set a foreground; the default one relied on inheritance, and nothing up the tree sets `color`, so the message rendered in the UA's black on the dark theme's grey bar
25
+ - **EmptyState** — the actions are a keyed `{#each}`, so swapping one action for another reuses the right button rather than repainting the row
26
+ - **docs** — `figma-frame-builders` is documented, `sanitizeInput` no longer claims to escape HTML (it stringifies, truncates, strips control characters and trims), and `formatErrorMessage`, `handleAsyncError`, `withErrorHandling` and `logError` are documented with their real signatures. `withErrorHandling(fn, operation)` calls `fn()` with no arguments and returns its result; it was documented as returning a wrapped function
27
+
28
+ ## [0.3.1] - 2026-05-13
29
+
30
+ ### Added
31
+ - ESLint configuration with TypeScript and Svelte support for code linting
32
+ - Prettier setup with Svelte plugin for consistent code formatting
33
+ - `lint` and `prettier` npm scripts for development workflow
34
+
35
+ ### Changed
36
+ - Updated **ListItem** and **StatusBar** to use icon imports from `figma-ui3-kit-svelte/icons` after the UI kit icon export restructure.
9
37
 
10
38
  ## [0.3.0] - 2026-05-06
11
39
 
package/README.md CHANGED
@@ -73,11 +73,13 @@ import { sendToPlugin, createMessageHandler } from "figma-plugin-utilities/lib";
73
73
  | `Header` | Header bar with `left`, `center`, `right` slots and optional title |
74
74
  | `Footer` | Footer with `right`, `split`, and `full` layout variants |
75
75
  | `StatusBar` | Toast notifications with auto-dismiss (info/success/error/warning) |
76
- | `EmptyState` | Empty/error states with optional icon and action buttons |
77
- | `ListItem` | Selectable list items with metadata slot and action menu |
78
- | `LoadingState` | Centered loading indicator with custom message |
79
- | `FieldGroup` | Label + input wrapper for form fields |
80
- | `CheckboxCard` | Large checkbox with card styling and better touch targets |
76
+ | `EmptyState` | Empty/error states with optional icon and action buttons; `size`, `centered`, and `role="alert"` for failures |
77
+ | `ListItem` | Selectable list items with metadata and `badge` slots, an action menu (`menuOpen`, `menuToggle`, `menuClose`) |
78
+ | `LoadingState` | Centred message as `role="status"` (text only, no spinner) |
79
+ | `FieldGroup` | Label + input wrapper; `labelFor` binds the label to a text control |
80
+ | `CheckboxCard` | Large checkbox with card styling and better touch targets; `change` event |
81
+
82
+ Every component also takes a `class` (or `className`) prop.
81
83
 
82
84
  ### Header
83
85
 
@@ -231,11 +233,14 @@ const jsonResult = validateJsonString('{"key": "value"}');
231
233
  // { valid: true, parsed: {...} } or { valid: false, error: "..." }
232
234
 
233
235
  validateEmail("user@example.com"); // { valid: true }
234
- validateNumber("42", { min: 0, max: 100 }); // { valid: true, value: 42 }
235
-
236
- const clean = sanitizeName("My Plugin!!!"); // "My Plugin"
237
- sanitizeInput("<script>alert(1)</script>"); // escaped string
238
- isEmpty(""); // true
236
+ validateNumber("42", { min: 0, max: 100, integer: true }); // { valid: true, value: 42 }
237
+ validateUrl("", { required: false }); // { valid: true } — empty is allowed
238
+ validateJsonString(text, { maxSizeKB: 512, requireObject: true });
239
+
240
+ const clean = sanitizeName("My Plugin!!!", 200); // "My Plugin" — "Untitled" if nothing survives
241
+ sanitizeInput(input, 50); // stringify, truncate to maxLength, strip control characters, trim
242
+ // Note: sanitizeInput does NOT escape HTML. Escape at the point of rendering instead.
243
+ isEmpty(""); // true — also for [] and {}
239
244
  ```
240
245
 
241
246
  ### Error Handling (`lib/errorHandling.js`)
@@ -287,6 +292,26 @@ setDefaultWidth(320);
287
292
 
288
293
  > **Note:** The `container` element passed to `autoResize` must **not** have `height: 100%` or a fixed height — it should flow naturally with its content so `scrollHeight` can be measured accurately.
289
294
 
295
+ ### Spec Frame Builders (`lib/figma-frame-builders.ts`)
296
+
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.
298
+
299
+ ```typescript
300
+ import {
301
+ specTokens, loadSpecFonts,
302
+ createAutoLayoutFrame, createAutoLayoutComponent, createText,
303
+ createTokenChip, createColorSwatch, createTableCell, createTableHeader,
304
+ } from "figma-plugin-utilities/lib/figma-frame-builders";
305
+
306
+ await loadSpecFonts(); // once, before drawing
307
+ const theme = specTokens.themes.dark;
308
+
309
+ const row = createAutoLayoutFrame({ name: "row", direction: "HORIZONTAL", spacing: 8, fill: theme.cellFill });
310
+ row.appendChild(createTokenChip({ label: "#FFFFFF", background: theme.chipBg, textColor: theme.text }));
311
+ ```
312
+
313
+ `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
+
290
315
  ### Figma Helpers (`lib/figma-helpers.ts`)
291
316
 
292
317
  For use in `code.ts`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-plugin-utilities",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared Svelte components and utilities for Figma plugins",
5
5
  "type": "module",
6
6
  "svelte": "./src/index.js",
@@ -28,6 +28,11 @@
28
28
  "types": "./src/lib/figma-helpers.ts",
29
29
  "import": "./src/lib/figma-helpers.ts",
30
30
  "default": "./src/lib/figma-helpers.ts"
31
+ },
32
+ "./lib/figma-frame-builders": {
33
+ "types": "./src/lib/figma-frame-builders.ts",
34
+ "import": "./src/lib/figma-frame-builders.ts",
35
+ "default": "./src/lib/figma-frame-builders.ts"
31
36
  }
32
37
  },
33
38
  "files": [
@@ -54,8 +59,25 @@
54
59
  ],
55
60
  "author": "Marius Roosendaal",
56
61
  "license": "MIT",
57
- "dependencies": {
58
- "figma-ui3-kit-svelte": "^0.5.0",
59
- "svelte": "^4.2.20"
62
+ "devDependencies": {
63
+ "@eslint/js": "^9.39.2",
64
+ "@figma/plugin-typings": "^1.138.0",
65
+ "@sveltejs/vite-plugin-svelte": "^3.0.2",
66
+ "@types/node": "^22.13.4",
67
+ "eslint": "^9.39.2",
68
+ "eslint-plugin-svelte": "^3.23.0",
69
+ "figma-ui3-kit-svelte": "^0.6.0",
70
+ "globals": "^17.12.0",
71
+ "prettier": "^3.9.8",
72
+ "prettier-plugin-svelte": "^3.4.0",
73
+ "svelte": "^4.2.20",
74
+ "svelte-eslint-parser": "^1.8.1",
75
+ "typescript": "^5.9.3",
76
+ "typescript-eslint": "^8.70.0",
77
+ "vite": "^5.2.0"
78
+ },
79
+ "scripts": {
80
+ "lint": "tsc --noEmit && eslint . --ext .ts,.js,.svelte && npx prettier --check \"src/**/*.{js,ts,svelte,html,css}\"",
81
+ "prettier": "npx prettier --write \"src/**/*.{js,ts,svelte,html,css}\""
60
82
  }
61
83
  }
@@ -30,9 +30,12 @@
30
30
  /** Whether to center vertically */
31
31
  export let centered = true;
32
32
 
33
- let className = '';
33
+ /** ARIA role: "status" for info messages, "alert" for errors */
34
+ export let role = "status";
35
+
36
+ let className = "";
34
37
  export { className as class };
35
- // Normalize actions
38
+
36
39
  $: normalizedActions = actions ? actions : action ? [action] : null;
37
40
  </script>
38
41
 
@@ -41,6 +44,7 @@
41
44
  class:centered
42
45
  class:small={size === "small"}
43
46
  class:large={size === "large"}
47
+ {role}
44
48
  >
45
49
  {#if icon}
46
50
  <div class="empty-state__icon" aria-hidden="true">
@@ -58,7 +62,7 @@
58
62
 
59
63
  {#if normalizedActions && normalizedActions.length > 0}
60
64
  <div class="empty-state__actions">
61
- {#each normalizedActions as actionItem}
65
+ {#each normalizedActions as actionItem (actionItem.label)}
62
66
  <Button variant="secondary" on:click={actionItem.handler}>
63
67
  {actionItem.label}
64
68
  </Button>
@@ -1,28 +1,14 @@
1
1
  <script>
2
2
  import { Label } from "figma-ui3-kit-svelte";
3
3
 
4
- /**
5
- * Field group wrapper
6
- * Wraps a form field with an optional label
7
- *
8
- * @example
9
- * <FieldGroup label="Collection">
10
- * <Dropdown menuItems={options} bind:value={selected} />
11
- * </FieldGroup>
12
- */
13
-
14
4
  /** Label text (optional) */
15
5
  export let label = "";
16
6
 
17
- /** For attribute for the label (optional) */
7
+ /** id of the associated control (optional) */
18
8
  export let labelFor = "";
19
9
 
20
10
  /** Size of the label (optional) */
21
11
  export let size = undefined;
22
-
23
- $: if (typeof window !== "undefined" && label && !labelFor) {
24
- console.warn("[FieldGroup] A label is rendered but no labelFor is set. Associate the label with its control using the labelFor prop.");
25
- }
26
12
  </script>
27
13
 
28
14
  <div class="field-group" class:small={size === "small"}>
@@ -103,6 +103,10 @@
103
103
  width: 100%;
104
104
  }
105
105
 
106
+ .footer--full :global(> *) {
107
+ flex: 1;
108
+ }
109
+
106
110
  .footer--full :global(button) {
107
111
  flex: 1;
108
112
  width: 100%;
@@ -1,6 +1,7 @@
1
1
  <script>
2
2
  import { createEventDispatcher } from "svelte";
3
- import { IconButton, IconMore, Menu } from "figma-ui3-kit-svelte";
3
+ import { IconButton, Menu } from "figma-ui3-kit-svelte";
4
+ import { IconMore } from "figma-ui3-kit-svelte/icons";
4
5
 
5
6
  /**
6
7
  * List item with optional action menu
@@ -73,7 +74,12 @@
73
74
  class="list-item"
74
75
  class:active
75
76
  on:click={handleClick}
76
- on:keydown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleClick(); } }}
77
+ on:keydown={(e) => {
78
+ if (e.key === "Enter" || e.key === " ") {
79
+ e.preventDefault();
80
+ handleClick();
81
+ }
82
+ }}
77
83
  role="button"
78
84
  tabindex="0"
79
85
  aria-pressed={active}
@@ -21,7 +21,9 @@
21
21
  </script>
22
22
 
23
23
  <div class="loading-state {className}" role="status">
24
- <Text variant="body-medium" color="--figma-color-text-secondary">{message}</Text>
24
+ <Text variant="body-medium" color="--figma-color-text-secondary"
25
+ >{message}</Text
26
+ >
25
27
  </div>
26
28
 
27
29
  <style>
@@ -1,6 +1,12 @@
1
1
  <script>
2
+ /* The auto-dismiss below trips svelte/infinite-reactive-loop: the reactive
3
+ statement writes `visible`, and the timeout it schedules writes it again
4
+ through handleClose(). Neither reads `visible`, so the statement cannot
5
+ re-trigger itself — the rule only sees the shared assignment target. */
6
+ /* eslint-disable svelte/infinite-reactive-loop */
2
7
  import { onDestroy, createEventDispatcher } from "svelte";
3
- import { IconButton, IconClose } from "figma-ui3-kit-svelte";
8
+ import { IconButton } from "figma-ui3-kit-svelte";
9
+ import { IconClose } from "figma-ui3-kit-svelte/icons";
4
10
 
5
11
  // Status bar for notifications with auto-dismiss
6
12
  // Supports types: 'info', 'success', 'error', 'warning'
@@ -42,7 +48,6 @@
42
48
  } else {
43
49
  visible = false;
44
50
  }
45
-
46
51
  function handleClose() {
47
52
  visible = false;
48
53
  clearTimeout(timeoutId);
@@ -81,6 +86,7 @@
81
86
  align-items: center;
82
87
  justify-content: space-between;
83
88
  background: var(--figma-color-bg-secondary);
89
+ color: var(--figma-color-text);
84
90
  font-size: var(--body-medium-font-size);
85
91
  font-weight: var(--body-medium-font-weight);
86
92
  letter-spacing: var(--body-medium-letter-spacing);
package/src/lib/colors.js CHANGED
@@ -39,7 +39,8 @@ export function hexToRgb(hex) {
39
39
  * @returns {number} Relative luminance (0-1)
40
40
  */
41
41
  export function getLuminance({ r, g, b }) {
42
- const adjust = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
42
+ const adjust = (c) =>
43
+ c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
43
44
  return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b);
44
45
  }
45
46
 
@@ -0,0 +1,467 @@
1
+ export type PaddingSpec =
2
+ number | { top?: number; right?: number; bottom?: number; left?: number };
3
+
4
+ export type SpecTheme = {
5
+ cellFill: RGB;
6
+ cellBorder: RGB;
7
+ text: RGB;
8
+ chipBg: RGB;
9
+ headerFill: RGB;
10
+ subheaderFill: RGB;
11
+ headingText: RGB;
12
+ /** Bottom rule under the header; the inverse header has none. */
13
+ headerBorder?: RGB;
14
+ };
15
+
16
+ export type NodeKind = "frame" | "component";
17
+ export type NodeFor<K extends NodeKind> = K extends "component"
18
+ ? ComponentNode
19
+ : FrameNode;
20
+
21
+ function rgb(r: number, g: number, b: number): RGB {
22
+ return { r, g, b };
23
+ }
24
+
25
+ function createNode<K extends NodeKind>(as?: K): NodeFor<K> {
26
+ return (
27
+ as === "component" ? figma.createComponent() : figma.createFrame()
28
+ ) as NodeFor<K>;
29
+ }
30
+
31
+ function applyPadding(
32
+ frame: FrameNode | ComponentNode,
33
+ spec: PaddingSpec,
34
+ ): void {
35
+ if (typeof spec === "number") {
36
+ frame.paddingTop = spec;
37
+ frame.paddingRight = spec;
38
+ frame.paddingBottom = spec;
39
+ frame.paddingLeft = spec;
40
+ } else {
41
+ frame.paddingTop = spec.top ?? 0;
42
+ frame.paddingRight = spec.right ?? 0;
43
+ frame.paddingBottom = spec.bottom ?? 0;
44
+ frame.paddingLeft = spec.left ?? 0;
45
+ }
46
+ }
47
+
48
+ export const specTokens = {
49
+ accentColors: {
50
+ green: rgb(0.337, 0.757, 0.396), // #56C165 — AAA
51
+ blue: rgb(0.447, 0.682, 0.988), // #72AEFC — AA
52
+ purple: rgb(0.753, 0.608, 0.965), // #C09BF6 — AA18
53
+ red: rgb(0.98, 0.553, 0.569), // #FA8D91 — DNP
54
+ },
55
+ fonts: {
56
+ body: { family: "Inter", style: "Regular", size: 14 },
57
+ bodyBold: { family: "Inter", style: "Semi Bold", size: 14 },
58
+ subheading: { family: "Inter", style: "Medium", size: 24 },
59
+ heading: { family: "Inter", style: "Medium", size: 48 },
60
+ code: { family: "IBM Plex Mono", style: "Regular", size: 12 },
61
+ },
62
+ themes: {
63
+ light: {
64
+ cellFill: rgb(1.0, 1.0, 1.0), // #FFFFFF
65
+ cellBorder: rgb(0.949, 0.949, 0.949), // #F2F2F2
66
+ text: rgb(0.09, 0.09, 0.09), // #171717
67
+ chipBg: rgb(0.949, 0.949, 0.949), // #F2F2F2
68
+ headerFill: rgb(1.0, 1.0, 1.0), // #FFFFFF
69
+ subheaderFill: rgb(0.949, 0.949, 0.949), // #F2F2F2
70
+ headingText: rgb(0.09, 0.09, 0.09), // #171717
71
+ headerBorder: rgb(0.886, 0.886, 0.886), // #E2E2E2
72
+ } satisfies SpecTheme,
73
+ dark: {
74
+ cellFill: rgb(0.09, 0.09, 0.09), // #171717
75
+ cellBorder: rgb(0.114, 0.114, 0.114), // #1D1D1D
76
+ text: rgb(1.0, 1.0, 1.0), // #FFFFFF
77
+ chipBg: rgb(0.157, 0.157, 0.157), // #282828
78
+ headerFill: rgb(0.09, 0.09, 0.09), // #171717
79
+ subheaderFill: rgb(0.157, 0.157, 0.157), // #282828
80
+ headingText: rgb(1.0, 1.0, 1.0), // #FFFFFF
81
+ } satisfies SpecTheme,
82
+ },
83
+ };
84
+
85
+ export async function loadSpecFonts(): Promise<void> {
86
+ await Promise.all([
87
+ figma.loadFontAsync({ family: "Inter", style: "Regular" }),
88
+ figma.loadFontAsync({ family: "Inter", style: "Semi Bold" }),
89
+ figma.loadFontAsync({ family: "Inter", style: "Medium" }),
90
+ figma.loadFontAsync({ family: "IBM Plex Mono", style: "Regular" }),
91
+ ]);
92
+ }
93
+
94
+ type AutoLayoutOpts = {
95
+ name: string;
96
+ direction: "HORIZONTAL" | "VERTICAL" | "NONE";
97
+ spacing?: number;
98
+ padding?: PaddingSpec;
99
+ fill?: RGB;
100
+ cornerRadius?: number;
101
+ width?: number;
102
+ height?: number;
103
+ clipsContent?: boolean;
104
+ border?: { color: RGB; width?: number };
105
+ };
106
+
107
+ function applyAutoLayout(
108
+ node: FrameNode | ComponentNode,
109
+ opts: AutoLayoutOpts,
110
+ ): void {
111
+ node.name = opts.name;
112
+ node.layoutMode = opts.direction;
113
+ node.fills = opts.fill ? [{ type: "SOLID", color: opts.fill }] : [];
114
+
115
+ if (opts.cornerRadius !== undefined) node.cornerRadius = opts.cornerRadius;
116
+ if (opts.clipsContent !== undefined) node.clipsContent = opts.clipsContent;
117
+ if (opts.padding !== undefined) applyPadding(node, opts.padding);
118
+
119
+ if (opts.direction !== "NONE") {
120
+ node.itemSpacing = opts.spacing ?? 0;
121
+ const isHorizontal = opts.direction === "HORIZONTAL";
122
+ node.primaryAxisSizingMode =
123
+ (isHorizontal ? opts.width : opts.height) !== undefined
124
+ ? "FIXED"
125
+ : "AUTO";
126
+ node.counterAxisSizingMode =
127
+ (isHorizontal ? opts.height : opts.width) !== undefined
128
+ ? "FIXED"
129
+ : "AUTO";
130
+ }
131
+
132
+ if (opts.width !== undefined || opts.height !== undefined) {
133
+ node.resize(opts.width ?? node.width, opts.height ?? node.height);
134
+ }
135
+
136
+ if (opts.border) {
137
+ node.strokes = [{ type: "SOLID", color: opts.border.color }];
138
+ node.strokeWeight = opts.border.width ?? 1;
139
+ node.strokeAlign = "CENTER";
140
+ }
141
+ }
142
+
143
+ export function createAutoLayoutFrame(opts: AutoLayoutOpts): FrameNode {
144
+ const frame = figma.createFrame();
145
+ applyAutoLayout(frame, opts);
146
+ return frame;
147
+ }
148
+
149
+ export function createAutoLayoutComponent(opts: AutoLayoutOpts): ComponentNode {
150
+ const component = figma.createComponent();
151
+ applyAutoLayout(component, opts);
152
+ return component;
153
+ }
154
+
155
+ export function createText(opts: {
156
+ characters: string;
157
+ font: { family: string; style: string; size: number };
158
+ color?: RGB;
159
+ lineHeight?: number;
160
+ letterSpacing?: number;
161
+ width?: number;
162
+ }): TextNode {
163
+ const node = figma.createText();
164
+ node.fontName = { family: opts.font.family, style: opts.font.style };
165
+ node.fontSize = opts.font.size;
166
+
167
+ if (opts.lineHeight !== undefined) {
168
+ node.lineHeight = { value: opts.lineHeight * 100, unit: "PERCENT" };
169
+ }
170
+ if (opts.letterSpacing !== undefined) {
171
+ node.letterSpacing = { value: opts.letterSpacing, unit: "PIXELS" };
172
+ }
173
+
174
+ node.characters = opts.characters;
175
+ node.fills = [
176
+ { type: "SOLID", color: opts.color ?? specTokens.themes.light.text },
177
+ ];
178
+
179
+ if (opts.width !== undefined) {
180
+ node.textAutoResize = "HEIGHT";
181
+ node.resize(opts.width, node.height);
182
+ }
183
+
184
+ return node;
185
+ }
186
+
187
+ export function createTokenChip<K extends NodeKind = "frame">(opts: {
188
+ label: string;
189
+ background: RGB;
190
+ textColor?: RGB;
191
+ width?: number;
192
+ as?: K;
193
+ }): NodeFor<K> {
194
+ const node = createNode(opts.as);
195
+ applyAutoLayout(node, {
196
+ name: "label",
197
+ direction: "VERTICAL",
198
+ padding: { right: 4, left: 4 },
199
+ fill: opts.background,
200
+ cornerRadius: 2,
201
+ height: 24,
202
+ width: opts.width,
203
+ });
204
+ node.primaryAxisAlignItems = "CENTER";
205
+ const text = createText({
206
+ characters: opts.label,
207
+ font: specTokens.fonts.code,
208
+ color: opts.textColor ?? specTokens.themes.light.text,
209
+ lineHeight: 1.4,
210
+ letterSpacing: 0.18,
211
+ });
212
+ text.name = "label";
213
+ text.textAutoResize = "WIDTH_AND_HEIGHT";
214
+ node.appendChild(text);
215
+ return node as NodeFor<K>;
216
+ }
217
+
218
+ export function createColorSwatch<K extends NodeKind = "frame">(opts: {
219
+ color: RGB;
220
+ size?: number;
221
+ cornerRadius?: number;
222
+ inverse?: boolean;
223
+ as?: K;
224
+ }): NodeFor<K> {
225
+ const size = opts.size ?? 40;
226
+ const node = createNode(opts.as);
227
+ applyAutoLayout(node, {
228
+ name: "swatch",
229
+ direction: "NONE",
230
+ fill: opts.color,
231
+ cornerRadius: opts.cornerRadius ?? 2,
232
+ width: size,
233
+ height: size,
234
+ });
235
+ node.strokes = [
236
+ {
237
+ type: "SOLID",
238
+ color: opts.inverse ? rgb(1, 1, 1) : rgb(0, 0, 0),
239
+ opacity: 0.1,
240
+ },
241
+ ];
242
+ node.strokeWeight = 1;
243
+ node.strokeAlign = "INSIDE";
244
+ return node as NodeFor<K>;
245
+ }
246
+
247
+ function chipOrInstance(
248
+ source: ComponentNode | undefined,
249
+ label: string,
250
+ background: RGB,
251
+ textColor: RGB,
252
+ ): FrameNode | InstanceNode {
253
+ if (source) return source.createInstance();
254
+ return createTokenChip({ label, background, textColor });
255
+ }
256
+
257
+ // The library cell holds its chips in a "tokens" slot, a hugging row.
258
+ function tokensRow(chip: FrameNode | InstanceNode): FrameNode {
259
+ const row = createAutoLayoutFrame({
260
+ name: "tokens",
261
+ direction: "HORIZONTAL",
262
+ spacing: 4,
263
+ });
264
+ row.appendChild(chip);
265
+ return row;
266
+ }
267
+
268
+ function swatchOrInstance(
269
+ source: ComponentNode | undefined,
270
+ color: RGB,
271
+ inverse: boolean,
272
+ ): FrameNode | InstanceNode {
273
+ if (source) return source.createInstance();
274
+ return createColorSwatch({ color, inverse });
275
+ }
276
+
277
+ export function createTableCell<K extends NodeKind = "frame">(opts: {
278
+ variant: "text" | "header" | "token";
279
+ theme?: SpecTheme;
280
+ swatch?: boolean;
281
+ text?: string;
282
+ chipLabel?: string;
283
+ chipBackground?: RGB;
284
+ swatchColor?: RGB;
285
+ chipSource?: ComponentNode;
286
+ swatchSource?: ComponentNode;
287
+ width?: number;
288
+ height?: number;
289
+ textSizing?: "fill" | "hug";
290
+ as?: K;
291
+ }): NodeFor<K> {
292
+ const theme = opts.theme ?? specTokens.themes.light;
293
+ const border = { color: theme.cellBorder };
294
+ const isTokenSwatch = opts.variant === "token" && opts.swatch;
295
+
296
+ if (isTokenSwatch) {
297
+ const node = createNode(opts.as);
298
+ applyAutoLayout(node, {
299
+ name: "table-cell",
300
+ direction: "HORIZONTAL",
301
+ padding: { top: 12, right: 20, bottom: 12, left: 20 },
302
+ fill: theme.cellFill,
303
+ width: opts.width ?? 240,
304
+ height: opts.height ?? 72,
305
+ border,
306
+ });
307
+ node.primaryAxisAlignItems = "SPACE_BETWEEN";
308
+ node.counterAxisAlignItems = "MIN";
309
+ node.appendChild(
310
+ tokensRow(
311
+ chipOrInstance(
312
+ opts.chipSource,
313
+ opts.chipLabel ?? "",
314
+ opts.chipBackground ?? theme.chipBg,
315
+ theme.text,
316
+ ),
317
+ ),
318
+ );
319
+ node.appendChild(
320
+ swatchOrInstance(
321
+ opts.swatchSource,
322
+ opts.swatchColor ?? rgb(0, 0, 0),
323
+ theme.cellFill.r < 0.5,
324
+ ),
325
+ );
326
+ return node as NodeFor<K>;
327
+ }
328
+
329
+ const isTextSwatch = opts.variant === "text" && opts.swatch;
330
+ const defaultHeight = isTextSwatch ? 72 : 56;
331
+
332
+ const node = createNode(opts.as);
333
+ applyAutoLayout(node, {
334
+ name: "table-cell",
335
+ direction: "HORIZONTAL",
336
+ spacing: opts.variant === "token" ? 8 : isTextSwatch ? 16 : 0,
337
+ padding: { top: 12, right: 20, bottom: 16, left: 20 },
338
+ fill: theme.cellFill,
339
+ width: opts.width ?? 240,
340
+ height: opts.height ?? defaultHeight,
341
+ border,
342
+ });
343
+
344
+ if (isTextSwatch) {
345
+ node.primaryAxisAlignItems = "SPACE_BETWEEN";
346
+ node.counterAxisAlignItems = "MIN";
347
+ const label = createText({
348
+ characters: opts.text ?? "",
349
+ font: specTokens.fonts.body,
350
+ color: theme.text,
351
+ lineHeight: 1.4,
352
+ width: 144,
353
+ });
354
+ label.name = "text";
355
+ node.appendChild(label);
356
+ node.appendChild(
357
+ swatchOrInstance(
358
+ opts.swatchSource,
359
+ opts.swatchColor ?? rgb(0, 0, 0),
360
+ theme.cellFill.r < 0.5,
361
+ ),
362
+ );
363
+ return node as NodeFor<K>;
364
+ }
365
+
366
+ if (opts.variant === "token") {
367
+ node.appendChild(
368
+ tokensRow(
369
+ chipOrInstance(
370
+ opts.chipSource,
371
+ opts.chipLabel ?? "",
372
+ opts.chipBackground ?? theme.chipBg,
373
+ theme.text,
374
+ ),
375
+ ),
376
+ );
377
+ return node as NodeFor<K>;
378
+ }
379
+
380
+ // "text" and "header" variants
381
+ const isBold = opts.variant === "header";
382
+ const label = createText({
383
+ characters: opts.text ?? "",
384
+ font: isBold ? specTokens.fonts.bodyBold : specTokens.fonts.body,
385
+ color: theme.text,
386
+ lineHeight: 1.4,
387
+ });
388
+ label.name = "text";
389
+ if (opts.textSizing === "hug") {
390
+ label.textAutoResize = "WIDTH_AND_HEIGHT";
391
+ } else {
392
+ label.layoutGrow = 1;
393
+ label.textAutoResize = "HEIGHT";
394
+ }
395
+ node.appendChild(label);
396
+
397
+ return node as NodeFor<K>;
398
+ }
399
+
400
+ export function createTableHeader<K extends NodeKind = "frame">(opts: {
401
+ variant: "header" | "subheader";
402
+ theme?: SpecTheme;
403
+ title?: string;
404
+ width?: number;
405
+ height?: number;
406
+ as?: K;
407
+ }): NodeFor<K> {
408
+ const theme = opts.theme ?? specTokens.themes.light;
409
+ const title = opts.title ?? "";
410
+
411
+ if (opts.variant === "header") {
412
+ const node = createNode(opts.as);
413
+ applyAutoLayout(node, {
414
+ name: "table-header",
415
+ direction: "VERTICAL",
416
+ padding: { top: 16, right: 20, bottom: 16, left: 20 },
417
+ width: opts.width ?? 960,
418
+ height: opts.height ?? 160,
419
+ fill: theme.headerFill,
420
+ });
421
+ if (theme.headerBorder) {
422
+ node.strokes = [{ type: "SOLID", color: theme.headerBorder }];
423
+ node.strokeTopWeight = 0;
424
+ node.strokeRightWeight = 0;
425
+ node.strokeBottomWeight = 1;
426
+ node.strokeLeftWeight = 0;
427
+ node.strokeAlign = "INSIDE";
428
+ }
429
+ const text = createText({
430
+ characters: title,
431
+ font: specTokens.fonts.heading,
432
+ color: theme.headingText,
433
+ lineHeight: 1,
434
+ letterSpacing: -1.92,
435
+ });
436
+ text.name = "title";
437
+ node.appendChild(text);
438
+ text.layoutSizingHorizontal = "FILL";
439
+ text.textAutoResize = "HEIGHT";
440
+ return node as NodeFor<K>;
441
+ }
442
+
443
+ // subheader: the title sits on the bottom edge
444
+ const node = createNode(opts.as);
445
+ applyAutoLayout(node, {
446
+ name: "table-subheader",
447
+ direction: "VERTICAL",
448
+ spacing: 8,
449
+ padding: { top: 16, right: 20, bottom: 16, left: 20 },
450
+ fill: theme.subheaderFill,
451
+ width: opts.width ?? 960,
452
+ height: opts.height ?? 96,
453
+ });
454
+ node.primaryAxisAlignItems = "MAX";
455
+ const text = createText({
456
+ characters: title,
457
+ font: specTokens.fonts.subheading,
458
+ color: theme.headingText,
459
+ lineHeight: 1.3,
460
+ letterSpacing: -0.48,
461
+ });
462
+ text.name = "title";
463
+ node.appendChild(text);
464
+ text.layoutSizingHorizontal = "FILL";
465
+ text.textAutoResize = "HEIGHT";
466
+ return node as NodeFor<K>;
467
+ }
@@ -9,7 +9,7 @@
9
9
  */
10
10
  export function sendToUI<T extends Record<string, unknown>>(
11
11
  type: string,
12
- data?: T
12
+ data?: T,
13
13
  ): void {
14
14
  if (data) {
15
15
  figma.ui.postMessage({ type, ...data });
@@ -32,7 +32,7 @@ export async function getCollections(): Promise<VariableCollection[]> {
32
32
  * @returns Promise resolving to array of variables
33
33
  */
34
34
  export async function getVariables(
35
- type?: VariableResolvedDataType
35
+ type?: VariableResolvedDataType,
36
36
  ): Promise<Variable[]> {
37
37
  return figma.variables.getLocalVariablesAsync(type);
38
38
  }
@@ -61,7 +61,7 @@ export function showSuccess(message: string, timeout = 3000): void {
61
61
  * @returns Array of selected nodes
62
62
  */
63
63
  export function getSelection<T extends SceneNode>(
64
- nodeType?: NodeType
64
+ nodeType?: NodeType,
65
65
  ): readonly T[] {
66
66
  const selection = figma.currentPage.selection;
67
67
  if (nodeType) {
@@ -85,10 +85,7 @@ export function focusNodes(nodes: readonly SceneNode[]): void {
85
85
  * @param family - Font family name
86
86
  * @param style - Font style (e.g., "Regular", "Bold")
87
87
  */
88
- export async function loadFont(
89
- family: string,
90
- style: string
91
- ): Promise<void> {
88
+ export async function loadFont(family: string, style: string): Promise<void> {
92
89
  await figma.loadFontAsync({ family, style });
93
90
  }
94
91
 
@@ -109,7 +106,7 @@ export async function saveToStorage<T>(key: string, value: T): Promise<void> {
109
106
  */
110
107
  export async function loadFromStorage<T>(
111
108
  key: string,
112
- defaultValue?: T
109
+ defaultValue?: T,
113
110
  ): Promise<T | undefined> {
114
111
  try {
115
112
  const value = await figma.clientStorage.getAsync(key);
package/src/lib/index.js CHANGED
@@ -1,8 +1,5 @@
1
1
  // Message utilities
2
- export {
3
- sendToPlugin,
4
- createMessageHandler,
5
- } from "./messages.js";
2
+ export { sendToPlugin, createMessageHandler } from "./messages.js";
6
3
 
7
4
  // Color utilities
8
5
  export {
package/src/lib/resize.js CHANGED
@@ -73,10 +73,10 @@ export function resizeToFit(options = {}) {
73
73
  /**
74
74
  * Set up automatic resizing when content changes
75
75
  * Uses ResizeObserver to watch for size changes
76
- *
76
+ *
77
77
  * IMPORTANT: The container element must NOT have height: 100% or fixed height.
78
78
  * Use bind:this on a wrapper element that flows naturally with content.
79
- *
79
+ *
80
80
  * @param {object} options - Auto-resize options
81
81
  * @param {HTMLElement} options.container - Container element to observe (required, must not have fixed height)
82
82
  * @param {number} [options.width] - Width in pixels (uses default if not specified)
@@ -58,7 +58,7 @@ export function validateUrl(url, options = { required: true }) {
58
58
  }
59
59
 
60
60
  return { valid: true };
61
- } catch (err) {
61
+ } catch {
62
62
  return {
63
63
  valid: false,
64
64
  error: "Invalid URL format",
@@ -148,6 +148,7 @@ export function sanitizeInput(input, maxLength) {
148
148
  }
149
149
 
150
150
  // Remove null bytes and control characters (except newlines and tabs)
151
+ // eslint-disable-next-line no-control-regex
151
152
  str = str.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, "");
152
153
 
153
154
  // Trim whitespace