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 +32 -4
- package/README.md +35 -10
- package/package.json +26 -4
- package/src/components/EmptyState.svelte +7 -3
- package/src/components/FieldGroup.svelte +1 -15
- package/src/components/Footer.svelte +4 -0
- package/src/components/ListItem.svelte +8 -2
- package/src/components/LoadingState.svelte +3 -1
- package/src/components/StatusBar.svelte +8 -2
- package/src/lib/colors.js +2 -1
- package/src/lib/figma-frame-builders.ts +467 -0
- package/src/lib/figma-helpers.ts +5 -8
- package/src/lib/index.js +1 -4
- package/src/lib/resize.js +2 -2
- package/src/lib/validation.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
| `LoadingState` |
|
|
79
|
-
| `FieldGroup` | Label + input wrapper
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
"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
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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"}>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { createEventDispatcher } from "svelte";
|
|
3
|
-
import { IconButton,
|
|
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) => {
|
|
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"
|
|
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
|
|
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) =>
|
|
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
|
+
}
|
package/src/lib/figma-helpers.ts
CHANGED
|
@@ -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
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)
|
package/src/lib/validation.js
CHANGED
|
@@ -58,7 +58,7 @@ export function validateUrl(url, options = { required: true }) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
return { valid: true };
|
|
61
|
-
} catch
|
|
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
|