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.
@@ -18,56 +18,43 @@ export function sendToUI<T extends Record<string, unknown>>(
18
18
  }
19
19
  }
20
20
 
21
- /**
22
- * Get all local variable collections
23
- * @returns Promise resolving to array of variable collections
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 to the user
42
- * @param message - Error message to display
43
- * @param timeout - How long to show the notification (ms)
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 = 5000): void {
46
- figma.notify(message, { error: true, timeout });
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 user
51
- * @param message - Success message to display
52
- * @param timeout - How long to show the notification (ms)
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 = 3000): void {
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
- * Get the current selection, optionally filtered by type
60
- * @param nodeType - Optional node type to filter by
61
- * @returns Array of selected nodes
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 getSelection<T extends SceneNode>(
64
- nodeType?: NodeType,
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 async function loadFont(family: string, style: string): Promise<void> {
89
- await figma.loadFontAsync({ family, style });
75
+ export function loadFont(family: string, style: string): Promise<void> {
76
+ return loadFontOnce({ family, style });
90
77
  }
91
78
 
92
79
  /**
93
- * Save data to client storage (persists across sessions)
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 async function saveToStorage<T>(key: string, value: T): Promise<void> {
98
- await figma.clientStorage.setAsync(key, value);
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 data from client storage
103
- * @param key - Storage key
104
- * @param defaultValue - Default value if key doesn't exist
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 async function loadFromStorage<T>(
108
- key: string,
109
- defaultValue?: T,
110
- ): Promise<T | undefined> {
111
- try {
112
- const value = await figma.clientStorage.getAsync(key);
113
- return value !== undefined ? value : defaultValue;
114
- } catch {
115
- return defaultValue;
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.js";
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";
@@ -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
- }