figma-plugin-utilities 0.3.1 → 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,29 @@
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
9
27
 
10
28
  ## [0.3.1] - 2026-05-13
11
29
 
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.1",
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": [
@@ -55,20 +60,20 @@
55
60
  "author": "Marius Roosendaal",
56
61
  "license": "MIT",
57
62
  "devDependencies": {
58
- "@eslint/js": "^9.39.1",
59
- "@figma/plugin-typings": "^1.51.0",
63
+ "@eslint/js": "^9.39.2",
64
+ "@figma/plugin-typings": "^1.138.0",
60
65
  "@sveltejs/vite-plugin-svelte": "^3.0.2",
61
66
  "@types/node": "^22.13.4",
62
- "eslint": "^9.39.1",
63
- "eslint-plugin-svelte": "^2.46.0",
64
- "figma-ui3-kit-svelte": "file:..//_packages/figma-ui3-kit-svelte",
65
- "globals": "^16.5.0",
66
- "prettier": "3.7.3",
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",
67
72
  "prettier-plugin-svelte": "^3.4.0",
68
73
  "svelte": "^4.2.20",
69
- "svelte-eslint-parser": "^0.39.0",
74
+ "svelte-eslint-parser": "^1.8.1",
70
75
  "typescript": "^5.9.3",
71
- "typescript-eslint": "^8.48.0",
76
+ "typescript-eslint": "^8.70.0",
72
77
  "vite": "^5.2.0"
73
78
  },
74
79
  "scripts": {
@@ -62,7 +62,7 @@
62
62
 
63
63
  {#if normalizedActions && normalizedActions.length > 0}
64
64
  <div class="empty-state__actions">
65
- {#each normalizedActions as actionItem}
65
+ {#each normalizedActions as actionItem (actionItem.label)}
66
66
  <Button variant="secondary" on:click={actionItem.handler}>
67
67
  {actionItem.label}
68
68
  </Button>
@@ -1,4 +1,9 @@
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
8
  import { IconButton } from "figma-ui3-kit-svelte";
4
9
  import { IconClose } from "figma-ui3-kit-svelte/icons";
@@ -43,7 +48,6 @@
43
48
  } else {
44
49
  visible = false;
45
50
  }
46
-
47
51
  function handleClose() {
48
52
  visible = false;
49
53
  clearTimeout(timeoutId);
@@ -82,6 +86,7 @@
82
86
  align-items: center;
83
87
  justify-content: space-between;
84
88
  background: var(--figma-color-bg-secondary);
89
+ color: var(--figma-color-text);
85
90
  font-size: var(--body-medium-font-size);
86
91
  font-weight: var(--body-medium-font-weight);
87
92
  letter-spacing: var(--body-medium-letter-spacing);
@@ -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
+ }