figma-plugin-utilities 0.3.1 → 0.5.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 +57 -4
- package/README.md +168 -35
- package/package.json +40 -10
- package/src/components/CodeExportModal.svelte +98 -0
- package/src/components/ConfirmModal.svelte +66 -0
- package/src/components/DataTable.svelte +349 -0
- package/src/components/EmptyState.svelte +1 -1
- package/src/components/FieldGrid.svelte +18 -0
- package/src/components/Footer.svelte +10 -0
- package/src/components/Header.svelte +6 -1
- package/src/components/LadderBadges.svelte +35 -0
- package/src/components/ListItem.svelte +53 -28
- package/src/components/MappingChip.svelte +150 -0
- package/src/components/RampCurve.svelte +383 -0
- package/src/components/Section.svelte +49 -0
- package/src/components/StatusBar.svelte +6 -1
- package/src/components/SteppedField.svelte +52 -0
- package/src/components/index.js +9 -0
- package/src/index.js +19 -3
- package/src/lib/colors.ts +99 -0
- package/src/lib/confirm.ts +55 -0
- package/src/lib/errorHandling.js +0 -39
- package/src/lib/figma-frame-builders.ts +467 -0
- package/src/lib/figma-helpers.ts +103 -59
- package/src/lib/figma-variables.ts +151 -0
- package/src/lib/format.ts +19 -0
- package/src/lib/index.js +13 -4
- package/src/lib/scale.ts +147 -0
- package/src/lib/colors.js +0 -80
package/src/lib/figma-helpers.ts
CHANGED
|
@@ -18,56 +18,43 @@ export function sendToUI<T extends Record<string, unknown>>(
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*
|
|
24
|
-
*/
|
|
25
|
-
export async function getCollections(): Promise<VariableCollection[]> {
|
|
26
|
-
return figma.variables.getLocalVariableCollectionsAsync();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Get all local variables of a specific type
|
|
31
|
-
* @param type - Variable type to filter by
|
|
32
|
-
* @returns Promise resolving to array of variables
|
|
33
|
-
*/
|
|
34
|
-
export async function getVariables(
|
|
35
|
-
type?: VariableResolvedDataType,
|
|
36
|
-
): Promise<Variable[]> {
|
|
37
|
-
return figma.variables.getLocalVariablesAsync(type);
|
|
38
|
-
}
|
|
21
|
+
// Long enough to read: about 60ms a character, and never under `minimum`.
|
|
22
|
+
const readingTime = (message: string, minimum: number) =>
|
|
23
|
+
Math.max(minimum, message.length * 60);
|
|
39
24
|
|
|
40
25
|
/**
|
|
41
|
-
* Show an error notification
|
|
42
|
-
* @param message -
|
|
43
|
-
* @param timeout - How long to show
|
|
26
|
+
* Show an error notification: something failed, or the file needs fixing
|
|
27
|
+
* @param message - What went wrong, then what to do
|
|
28
|
+
* @param timeout - How long to show it (ms); by default long enough to read,
|
|
29
|
+
* 5s at least
|
|
44
30
|
*/
|
|
45
|
-
export function showError(message: string, timeout
|
|
46
|
-
figma.notify(message, {
|
|
31
|
+
export function showError(message: string, timeout?: number): void {
|
|
32
|
+
figma.notify(message, {
|
|
33
|
+
error: true,
|
|
34
|
+
timeout: timeout ?? readingTime(message, 5000),
|
|
35
|
+
});
|
|
47
36
|
}
|
|
48
37
|
|
|
49
38
|
/**
|
|
50
|
-
* Show a success notification to the
|
|
51
|
-
*
|
|
52
|
-
* @param
|
|
39
|
+
* Show a success notification: the change is made. End a change to the file
|
|
40
|
+
* with `UNDO` from `lib/format`.
|
|
41
|
+
* @param message - What changed, with a count
|
|
42
|
+
* @param timeout - How long to show it (ms); by default long enough to read,
|
|
43
|
+
* 3s at least
|
|
53
44
|
*/
|
|
54
|
-
export function showSuccess(message: string, timeout
|
|
55
|
-
figma.notify(message, { timeout });
|
|
45
|
+
export function showSuccess(message: string, timeout?: number): void {
|
|
46
|
+
figma.notify(message, { timeout: timeout ?? readingTime(message, 3000) });
|
|
56
47
|
}
|
|
57
48
|
|
|
58
49
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* @
|
|
50
|
+
* Show a regular notification for a run with nothing to do: an empty
|
|
51
|
+
* selection, nothing to change. Not an error; say what to select or set.
|
|
52
|
+
* @param message - Why nothing happened, then what to do
|
|
53
|
+
* @param timeout - How long to show it (ms); by default long enough to read,
|
|
54
|
+
* 4s at least
|
|
62
55
|
*/
|
|
63
|
-
export function
|
|
64
|
-
|
|
65
|
-
): readonly T[] {
|
|
66
|
-
const selection = figma.currentPage.selection;
|
|
67
|
-
if (nodeType) {
|
|
68
|
-
return selection.filter((node) => node.type === nodeType) as T[];
|
|
69
|
-
}
|
|
70
|
-
return selection as readonly T[];
|
|
56
|
+
export function showNotice(message: string, timeout?: number): void {
|
|
57
|
+
figma.notify(message, { timeout: timeout ?? readingTime(message, 4000) });
|
|
71
58
|
}
|
|
72
59
|
|
|
73
60
|
/**
|
|
@@ -85,35 +72,92 @@ export function focusNodes(nodes: readonly SceneNode[]): void {
|
|
|
85
72
|
* @param family - Font family name
|
|
86
73
|
* @param style - Font style (e.g., "Regular", "Bold")
|
|
87
74
|
*/
|
|
88
|
-
export
|
|
89
|
-
|
|
75
|
+
export function loadFont(family: string, style: string): Promise<void> {
|
|
76
|
+
return loadFontOnce({ family, style });
|
|
90
77
|
}
|
|
91
78
|
|
|
92
79
|
/**
|
|
93
|
-
*
|
|
94
|
-
* @param key - Storage key
|
|
95
|
-
* @param value - Value to store (must be JSON-serializable)
|
|
80
|
+
* Every font a text node uses: its own when uniform, each run's when mixed.
|
|
96
81
|
*/
|
|
97
|
-
export
|
|
98
|
-
|
|
82
|
+
export function fontsOf(node: TextNode): FontName[] {
|
|
83
|
+
if (node.fontName !== figma.mixed) return [node.fontName];
|
|
84
|
+
return node.characters.length > 0
|
|
85
|
+
? node.getRangeAllFontNames(0, node.characters.length)
|
|
86
|
+
: [];
|
|
99
87
|
}
|
|
100
88
|
|
|
89
|
+
const fontLoads = new Map<string, Promise<void>>();
|
|
90
|
+
|
|
101
91
|
/**
|
|
102
|
-
* Load
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* @returns Stored value or default
|
|
92
|
+
* Load a font once per plugin run: later calls for it share the first load,
|
|
93
|
+
* so hundreds of text layers wait once per font rather than a round trip
|
|
94
|
+
* each. A load that fails is forgotten, so the next call tries again.
|
|
106
95
|
*/
|
|
107
|
-
export
|
|
108
|
-
key
|
|
109
|
-
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
96
|
+
export function loadFontOnce(font: FontName): Promise<void> {
|
|
97
|
+
const key = `${font.family}\u0000${font.style}`;
|
|
98
|
+
let load = fontLoads.get(key);
|
|
99
|
+
if (!load) {
|
|
100
|
+
load = figma.loadFontAsync(font).catch((error: unknown) => {
|
|
101
|
+
fontLoads.delete(key);
|
|
102
|
+
throw error;
|
|
103
|
+
});
|
|
104
|
+
fontLoads.set(key, load);
|
|
116
105
|
}
|
|
106
|
+
return load;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Load every font a text node uses, each once per run — Figma refuses to
|
|
111
|
+
* write to text whose fonts aren't loaded. Rejects when one won't load, as a
|
|
112
|
+
* missing font doesn't.
|
|
113
|
+
*/
|
|
114
|
+
export async function loadNodeFonts(node: TextNode): Promise<void> {
|
|
115
|
+
await Promise.all(fontsOf(node).map(loadFontOnce));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Set a text node's characters in its own fonts, loading them first. Rejects,
|
|
120
|
+
* leaving the text as it was, when one of its fonts won't load.
|
|
121
|
+
*/
|
|
122
|
+
export async function setText(
|
|
123
|
+
node: TextNode,
|
|
124
|
+
characters: string,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
await loadNodeFonts(node);
|
|
127
|
+
node.characters = characters;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Settings kept in clientStorage under `key`, cleaned by one `sanitize` on
|
|
132
|
+
* the way in and on the way out, so what's saved is always what a load would
|
|
133
|
+
* accept. `sanitize` takes anything — a value an older version stored, a
|
|
134
|
+
* message from the UI, undefined on first run — and returns complete
|
|
135
|
+
* settings. `save` resolves to the settings it stored. Storage errors are
|
|
136
|
+
* logged, not thrown: a load falls back to `sanitize(undefined)`.
|
|
137
|
+
*/
|
|
138
|
+
export function createSettingsStore<T>(
|
|
139
|
+
key: string,
|
|
140
|
+
sanitize: (raw: unknown) => T,
|
|
141
|
+
): { load(): Promise<T>; save(raw: unknown): Promise<T> } {
|
|
142
|
+
return {
|
|
143
|
+
async load() {
|
|
144
|
+
try {
|
|
145
|
+
return sanitize(await figma.clientStorage.getAsync(key));
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(`Couldn't read "${key}" from clientStorage:`, error);
|
|
148
|
+
return sanitize(undefined);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
async save(raw) {
|
|
152
|
+
const settings = sanitize(raw);
|
|
153
|
+
try {
|
|
154
|
+
await figma.clientStorage.setAsync(key, settings);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error(`Couldn't save "${key}" to clientStorage:`, error);
|
|
157
|
+
}
|
|
158
|
+
return settings;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
117
161
|
}
|
|
118
162
|
|
|
119
163
|
/**
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variable helpers for plugin code (code.ts): alias and color guards, and a
|
|
3
|
+
* variable's value through any aliases.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Whether a variable value is an alias to another variable. */
|
|
7
|
+
export function isVariableAlias(value: unknown): value is VariableAlias {
|
|
8
|
+
return (
|
|
9
|
+
typeof value === "object" &&
|
|
10
|
+
value !== null &&
|
|
11
|
+
(value as VariableAlias).type === "VARIABLE_ALIAS"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Whether a variable value is a color, RGB or RGBA with 0–1 channels. */
|
|
16
|
+
export function isColorValue(value: unknown): value is RGB | RGBA {
|
|
17
|
+
return (
|
|
18
|
+
typeof value === "object" &&
|
|
19
|
+
value !== null &&
|
|
20
|
+
typeof (value as RGB).r === "number" &&
|
|
21
|
+
typeof (value as RGB).g === "number" &&
|
|
22
|
+
typeof (value as RGB).b === "number"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A color value as RGBA, alpha 1 where it has none, or null for anything else. */
|
|
27
|
+
export function toRgba(value: unknown): RGBA | null {
|
|
28
|
+
return isColorValue(value) ? { a: 1, ...value } : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The file's local variables and collections by id, read once for many lookups. */
|
|
32
|
+
export interface VariableLookup {
|
|
33
|
+
variables: Map<string, Variable>;
|
|
34
|
+
collections: Map<string, VariableCollection>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function getVariableLookup(): Promise<VariableLookup> {
|
|
38
|
+
const [variables, collections] = await Promise.all([
|
|
39
|
+
figma.variables.getLocalVariablesAsync(),
|
|
40
|
+
figma.variables.getLocalVariableCollectionsAsync(),
|
|
41
|
+
]);
|
|
42
|
+
return {
|
|
43
|
+
variables: new Map(variables.map((v): [string, Variable] => [v.id, v])),
|
|
44
|
+
collections: new Map(
|
|
45
|
+
collections.map((c): [string, VariableCollection] => [c.id, c]),
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A variable's value at a mode, or at its collection's default mode where it
|
|
51
|
+
// has none there (null asks for the default).
|
|
52
|
+
function valueAt(
|
|
53
|
+
variable: Variable,
|
|
54
|
+
modeId: string | null,
|
|
55
|
+
collection: VariableCollection | null | undefined,
|
|
56
|
+
): VariableValue | undefined {
|
|
57
|
+
return (
|
|
58
|
+
(modeId !== null ? variable.valuesByMode[modeId] : undefined) ??
|
|
59
|
+
(collection
|
|
60
|
+
? variable.valuesByMode[collection.defaultModeId]
|
|
61
|
+
: undefined) ??
|
|
62
|
+
Object.values(variable.valuesByMode)[0]
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The mode to read an alias's target at: the same mode where its collection
|
|
67
|
+
// has it (an alias within one collection), else the default.
|
|
68
|
+
const modeIn = (
|
|
69
|
+
modeId: string | null,
|
|
70
|
+
collection: VariableCollection | null | undefined,
|
|
71
|
+
): string | null =>
|
|
72
|
+
modeId !== null && collection?.modes.some((m) => m.modeId === modeId)
|
|
73
|
+
? modeId
|
|
74
|
+
: null;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A variable's value at a mode (null for its collection's default), following
|
|
78
|
+
* aliases to a value that isn't one. Each variable down the chain is read at
|
|
79
|
+
* the same mode where its collection has it, else at its default mode. Given
|
|
80
|
+
* an alias, starts at its target. Local variables only, from `lookup`; null
|
|
81
|
+
* where the chain leaves the file or runs past `maxDepth`.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveVariableValue(
|
|
84
|
+
start: Variable | VariableAlias,
|
|
85
|
+
modeId: string | null,
|
|
86
|
+
lookup: VariableLookup,
|
|
87
|
+
maxDepth = 10,
|
|
88
|
+
): VariableValue | null {
|
|
89
|
+
let variable: Variable | undefined;
|
|
90
|
+
let mode = modeId;
|
|
91
|
+
if (isVariableAlias(start)) {
|
|
92
|
+
variable = lookup.variables.get(start.id);
|
|
93
|
+
if (!variable) return null;
|
|
94
|
+
mode = modeIn(mode, lookup.collections.get(variable.variableCollectionId));
|
|
95
|
+
} else {
|
|
96
|
+
variable = start;
|
|
97
|
+
}
|
|
98
|
+
for (let depth = 0; depth <= maxDepth; depth++) {
|
|
99
|
+
const raw = valueAt(
|
|
100
|
+
variable,
|
|
101
|
+
mode,
|
|
102
|
+
lookup.collections.get(variable.variableCollectionId),
|
|
103
|
+
);
|
|
104
|
+
if (!isVariableAlias(raw)) return raw ?? null;
|
|
105
|
+
const target: Variable | undefined = lookup.variables.get(raw.id);
|
|
106
|
+
if (!target) return null;
|
|
107
|
+
mode = modeIn(mode, lookup.collections.get(target.variableCollectionId));
|
|
108
|
+
variable = target;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* As `resolveVariableValue`, but follows aliases into enabled libraries too,
|
|
115
|
+
* fetching any variable or collection `lookup` doesn't have (or every one,
|
|
116
|
+
* without a lookup).
|
|
117
|
+
*/
|
|
118
|
+
export async function resolveVariableValueAsync(
|
|
119
|
+
start: Variable | VariableAlias,
|
|
120
|
+
modeId: string | null,
|
|
121
|
+
lookup?: VariableLookup,
|
|
122
|
+
maxDepth = 10,
|
|
123
|
+
): Promise<VariableValue | null> {
|
|
124
|
+
const variableById = async (id: string) =>
|
|
125
|
+
lookup?.variables.get(id) ??
|
|
126
|
+
(await figma.variables.getVariableByIdAsync(id));
|
|
127
|
+
const collectionOf = async (v: Variable) =>
|
|
128
|
+
lookup?.collections.get(v.variableCollectionId) ??
|
|
129
|
+
(await figma.variables.getVariableCollectionByIdAsync(
|
|
130
|
+
v.variableCollectionId,
|
|
131
|
+
));
|
|
132
|
+
|
|
133
|
+
let variable: Variable | null;
|
|
134
|
+
let mode = modeId;
|
|
135
|
+
if (isVariableAlias(start)) {
|
|
136
|
+
variable = await variableById(start.id);
|
|
137
|
+
if (!variable) return null;
|
|
138
|
+
mode = modeIn(mode, await collectionOf(variable));
|
|
139
|
+
} else {
|
|
140
|
+
variable = start;
|
|
141
|
+
}
|
|
142
|
+
for (let depth = 0; depth <= maxDepth; depth++) {
|
|
143
|
+
const raw = valueAt(variable, mode, await collectionOf(variable));
|
|
144
|
+
if (!isVariableAlias(raw)) return raw ?? null;
|
|
145
|
+
const target: Variable | null = await variableById(raw.id);
|
|
146
|
+
if (!target) return null;
|
|
147
|
+
mode = modeIn(mode, await collectionOf(target));
|
|
148
|
+
variable = target;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy helpers with no Figma API, for either thread.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** "1 layer", "3 layers"; `pluralForm` for the irregular ones: "2 entries" */
|
|
6
|
+
export const plural = (
|
|
7
|
+
count: number,
|
|
8
|
+
singular: string,
|
|
9
|
+
pluralForm = `${singular}s`,
|
|
10
|
+
): string => `${count} ${count === 1 ? singular : pluralForm}`;
|
|
11
|
+
|
|
12
|
+
/** "a", "a and b", "a, b and c"; `conjunction` replaces "and" */
|
|
13
|
+
export const joinList = (items: string[], conjunction = "and"): string =>
|
|
14
|
+
items.length <= 1
|
|
15
|
+
? (items[0] ?? "")
|
|
16
|
+
: `${items.slice(0, -1).join(", ")} ${conjunction} ${items[items.length - 1]}`;
|
|
17
|
+
|
|
18
|
+
/** The last sentence of a success notification for a change to the file. */
|
|
19
|
+
export const UNDO = "Press Ctrl/Cmd+Z to undo.";
|
package/src/lib/index.js
CHANGED
|
@@ -5,10 +5,14 @@ export { sendToPlugin, createMessageHandler } from "./messages.js";
|
|
|
5
5
|
export {
|
|
6
6
|
rgbToHex,
|
|
7
7
|
hexToRgb,
|
|
8
|
+
isValidHex,
|
|
8
9
|
getLuminance,
|
|
9
10
|
getContrastRatio,
|
|
10
11
|
meetsContrastLevel,
|
|
11
|
-
} from "./colors.
|
|
12
|
+
} from "./colors.ts";
|
|
13
|
+
|
|
14
|
+
// Copy helpers
|
|
15
|
+
export { plural, joinList, UNDO } from "./format.ts";
|
|
12
16
|
|
|
13
17
|
// Validation utilities
|
|
14
18
|
export {
|
|
@@ -30,9 +34,6 @@ export {
|
|
|
30
34
|
withErrorHandling,
|
|
31
35
|
safeAsync,
|
|
32
36
|
parseJsonSafe,
|
|
33
|
-
notifyError,
|
|
34
|
-
notifySuccess,
|
|
35
|
-
notifyWarning,
|
|
36
37
|
} from "./errorHandling.js";
|
|
37
38
|
|
|
38
39
|
// Resize utilities
|
|
@@ -42,3 +43,11 @@ export {
|
|
|
42
43
|
resizeToFit,
|
|
43
44
|
autoResize,
|
|
44
45
|
} from "./resize.js";
|
|
46
|
+
|
|
47
|
+
// Confirmation dialogs, shown by ConfirmModal
|
|
48
|
+
export {
|
|
49
|
+
confirmAction,
|
|
50
|
+
confirmDiscardChanges,
|
|
51
|
+
answerConfirm,
|
|
52
|
+
confirmRequest,
|
|
53
|
+
} from "./confirm.ts";
|
package/src/lib/scale.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Responsive scale math shared by plugins that shape a scale across
|
|
2
|
+
// breakpoints, such as Vitrine Tools' Type and Spacing tabs: the ramp's quadratic
|
|
3
|
+
// Bézier, blending by viewport width, values between breakpoints, fallback
|
|
4
|
+
// widths and fluid CSS. Pure — no Figma API — so both threads may import it.
|
|
5
|
+
//
|
|
6
|
+
// Units are the caller's: the Type tab works in px along its ladder, the
|
|
7
|
+
// Spacing tab in ladder rungs. Nothing here maps a value onto a ladder.
|
|
8
|
+
|
|
9
|
+
/** The quadratic Bézier through p0 and p2, pulled toward p1, at t in 0…1. */
|
|
10
|
+
export const bezier = (t: number, p0: number, p1: number, p2: number) =>
|
|
11
|
+
(1 - t) ** 2 * p0 + 2 * (1 - t) * t * p1 + t * t * p2;
|
|
12
|
+
|
|
13
|
+
/** How far the bend may move toward either end; at 0 or 1 the curve folds. */
|
|
14
|
+
export const clampPosition = (c: number) => Math.min(0.98, Math.max(0.02, c));
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The curve's parameter t for a point at x, 0…1, when the bend sits at `c`
|
|
18
|
+
* along x: the curve runs through x(t) = 2(1−t)t·c + t², solved for t in the
|
|
19
|
+
* conjugate form, which has no division by zero at c = 0.5 (where t = x).
|
|
20
|
+
*/
|
|
21
|
+
export function levelT(x: number, c = 0.5): number {
|
|
22
|
+
const pos = clampPosition(c);
|
|
23
|
+
return x / (Math.sqrt(pos * pos + (1 - 2 * pos) * x) + pos);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rounds halves down, so a growing value holds its smaller size a breakpoint
|
|
30
|
+
* longer — 16 16 18 18 20 rather than 16 18 18 20 20.
|
|
31
|
+
*/
|
|
32
|
+
export const roundHalfDown = (x: number) => Math.ceil(x - 0.5);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How far breakpoint `index` sits from the smallest toward the largest, 0…1:
|
|
36
|
+
* by viewport width when every width is known and they widen, so the blend
|
|
37
|
+
* matches CSS vw, and evenly by position otherwise.
|
|
38
|
+
*/
|
|
39
|
+
export function blendAt(
|
|
40
|
+
index: number,
|
|
41
|
+
count: number,
|
|
42
|
+
widths?: number[],
|
|
43
|
+
): number {
|
|
44
|
+
if (count < 2) return 0;
|
|
45
|
+
const w0 = widths?.[0];
|
|
46
|
+
const w1 = widths?.[count - 1];
|
|
47
|
+
if (
|
|
48
|
+
widths?.length === count &&
|
|
49
|
+
w0 !== undefined &&
|
|
50
|
+
w1 !== undefined &&
|
|
51
|
+
w1 > w0
|
|
52
|
+
) {
|
|
53
|
+
return Math.min(1, Math.max(0, (widths[index] - w0) / (w1 - w0)));
|
|
54
|
+
}
|
|
55
|
+
return index / (count - 1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A value at viewport width `width`, from its value at each breakpoint:
|
|
60
|
+
* straight between the breakpoints either side, as fluid CSS has it, and
|
|
61
|
+
* held beyond the smallest and largest. Stepped, it is the value of the last
|
|
62
|
+
* breakpoint at or below the width, as a media query holds it.
|
|
63
|
+
*/
|
|
64
|
+
export function valueAtWidth(
|
|
65
|
+
widths: number[],
|
|
66
|
+
values: number[],
|
|
67
|
+
width: number,
|
|
68
|
+
stepped = false,
|
|
69
|
+
): number {
|
|
70
|
+
const last = widths.length - 1;
|
|
71
|
+
if (last < 0) return 0;
|
|
72
|
+
if (width <= widths[0]) return values[0];
|
|
73
|
+
if (width >= widths[last]) return values[last];
|
|
74
|
+
let b = 0;
|
|
75
|
+
while (b < last - 1 && widths[b + 1] <= width) b++;
|
|
76
|
+
if (stepped) return values[b];
|
|
77
|
+
const t = (width - widths[b]) / (widths[b + 1] - widths[b]);
|
|
78
|
+
return values[b] + (values[b + 1] - values[b]) * t;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Breakpoint widths by name, for a file that doesn't give them. */
|
|
82
|
+
export const FALLBACK_WIDTHS: Record<string, number> = {
|
|
83
|
+
xs: 320,
|
|
84
|
+
sm: 400,
|
|
85
|
+
md: 768,
|
|
86
|
+
lg: 1024,
|
|
87
|
+
xl: 1440,
|
|
88
|
+
xlg: 1440,
|
|
89
|
+
"2xl": 1920,
|
|
90
|
+
xxl: 1920,
|
|
91
|
+
"3xl": 2560,
|
|
92
|
+
xxxl: 2560,
|
|
93
|
+
max: 2560,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Whether a name is one the fallback widths know, in any case. */
|
|
97
|
+
export const isBreakpointName = (name: string) =>
|
|
98
|
+
Object.prototype.hasOwnProperty.call(FALLBACK_WIDTHS, name.toLowerCase());
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Each breakpoint's width: the file's (`known`), else the usual one for its
|
|
102
|
+
* name, else 400px past the one before. Out of order, a width is taken as
|
|
103
|
+
* unknown — a breakpoint can't sit narrower than the one before it.
|
|
104
|
+
*/
|
|
105
|
+
export function widthsFor(
|
|
106
|
+
names: string[],
|
|
107
|
+
known: Record<string, number> = {},
|
|
108
|
+
): number[] {
|
|
109
|
+
const out: number[] = [];
|
|
110
|
+
names.forEach((name, i) => {
|
|
111
|
+
const previous = out[i - 1] ?? 0;
|
|
112
|
+
const given = known[name] ?? FALLBACK_WIDTHS[name.toLowerCase()];
|
|
113
|
+
out.push(
|
|
114
|
+
given !== undefined && given > previous
|
|
115
|
+
? given
|
|
116
|
+
: previous
|
|
117
|
+
? previous + 400
|
|
118
|
+
: 400,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A number as CSS writes it: no trailing zeros, at most `places` decimals. */
|
|
125
|
+
export const trimNumber = (n: number, places = 4) =>
|
|
126
|
+
String(Number(n.toFixed(places)));
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A length fluid between two viewport widths, as CSS: `from` at `w0`, `to`
|
|
130
|
+
* at `w1`, straight between and held beyond — a clamp() over vw, or the
|
|
131
|
+
* length alone when it doesn't change. `length` writes a px value in the
|
|
132
|
+
* caller's unit, such as `(px) => `${px / 16}rem``.
|
|
133
|
+
*/
|
|
134
|
+
export function fluidClamp(
|
|
135
|
+
from: number,
|
|
136
|
+
to: number,
|
|
137
|
+
w0: number,
|
|
138
|
+
w1: number,
|
|
139
|
+
length: (px: number) => string,
|
|
140
|
+
): string {
|
|
141
|
+
if (w1 === w0 || Math.abs(from - to) < 1e-9) return length(from);
|
|
142
|
+
const slope = (to - from) / (w1 - w0);
|
|
143
|
+
const intercept = from - slope * w0;
|
|
144
|
+
const vw = slope * 100;
|
|
145
|
+
const sign = vw < 0 ? "-" : "+";
|
|
146
|
+
return `clamp(${length(Math.min(from, to))}, ${length(intercept)} ${sign} ${trimNumber(Math.abs(vw))}vw, ${length(Math.max(from, to))})`;
|
|
147
|
+
}
|
package/src/lib/colors.js
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Color utilities for Figma plugins
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Convert RGB color (0-1 range) to HEX string
|
|
7
|
-
* @param {{r: number, g: number, b: number}} color - RGB color with values 0-1
|
|
8
|
-
* @returns {string} HEX color string (e.g., "#FF0000")
|
|
9
|
-
*/
|
|
10
|
-
export function rgbToHex({ r, g, b }) {
|
|
11
|
-
const toHex = (value) => {
|
|
12
|
-
const hex = Math.round(value * 255)
|
|
13
|
-
.toString(16)
|
|
14
|
-
.padStart(2, "0");
|
|
15
|
-
return hex;
|
|
16
|
-
};
|
|
17
|
-
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Convert HEX string to RGB color (0-1 range)
|
|
22
|
-
* @param {string} hex - HEX color string (e.g., "#FF0000" or "FF0000")
|
|
23
|
-
* @returns {{r: number, g: number, b: number} | null} RGB color with values 0-1, or null if invalid
|
|
24
|
-
*/
|
|
25
|
-
export function hexToRgb(hex) {
|
|
26
|
-
const cleanHex = hex.replace(/^#/, "");
|
|
27
|
-
if (!/^[0-9A-Fa-f]{6}$/.test(cleanHex)) {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
const r = parseInt(cleanHex.substring(0, 2), 16) / 255;
|
|
31
|
-
const g = parseInt(cleanHex.substring(2, 4), 16) / 255;
|
|
32
|
-
const b = parseInt(cleanHex.substring(4, 6), 16) / 255;
|
|
33
|
-
return { r, g, b };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Get relative luminance of a color (for contrast calculations)
|
|
38
|
-
* @param {{r: number, g: number, b: number}} color - RGB color with values 0-1
|
|
39
|
-
* @returns {number} Relative luminance (0-1)
|
|
40
|
-
*/
|
|
41
|
-
export function getLuminance({ r, g, b }) {
|
|
42
|
-
const adjust = (c) =>
|
|
43
|
-
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
44
|
-
return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Calculate contrast ratio between two colors (WCAG formula)
|
|
49
|
-
* @param {{r: number, g: number, b: number}} color1 - First RGB color (0-1 range)
|
|
50
|
-
* @param {{r: number, g: number, b: number}} color2 - Second RGB color (0-1 range)
|
|
51
|
-
* @returns {number} Contrast ratio (1-21)
|
|
52
|
-
*/
|
|
53
|
-
export function getContrastRatio(color1, color2) {
|
|
54
|
-
const l1 = getLuminance(color1);
|
|
55
|
-
const l2 = getLuminance(color2);
|
|
56
|
-
const lighter = Math.max(l1, l2);
|
|
57
|
-
const darker = Math.min(l1, l2);
|
|
58
|
-
return (lighter + 0.05) / (darker + 0.05);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Check if contrast ratio meets WCAG level
|
|
63
|
-
* @param {number} ratio - Contrast ratio
|
|
64
|
-
* @param {"AA" | "AAA" | "AA-large" | "AAA-large"} level - WCAG level to check
|
|
65
|
-
* @returns {boolean} Whether the ratio meets the level
|
|
66
|
-
*/
|
|
67
|
-
export function meetsContrastLevel(ratio, level) {
|
|
68
|
-
switch (level) {
|
|
69
|
-
case "AAA":
|
|
70
|
-
return ratio >= 7;
|
|
71
|
-
case "AAA-large":
|
|
72
|
-
return ratio >= 4.5;
|
|
73
|
-
case "AA":
|
|
74
|
-
return ratio >= 4.5;
|
|
75
|
-
case "AA-large":
|
|
76
|
-
return ratio >= 3;
|
|
77
|
-
default:
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
}
|