@scattered-light/loomiere 0.4.1 → 0.5.1
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/README.md +3 -1
- package/bin/loomiere.mjs +3 -1
- package/build/build-tokens.mjs +180 -35
- package/exporters/design-system.mjs +7 -0
- package/exporters/figma.mjs +545 -478
- package/exporters/index.mjs +4 -0
- package/figma/README.md +16 -1
- package/figma/library/pages/03-color.js +1 -1
- package/generator/brand.schema.json +83 -0
- package/generator/generate.mjs +26 -6
- package/generator/looks.mjs +62 -0
- package/lib/contrast.mjs +24 -6
- package/lib/figma.mjs +58 -23
- package/package.json +2 -1
- package/tokens/README.md +26 -0
package/exporters/index.mjs
CHANGED
|
@@ -30,6 +30,10 @@ const wanted = (id, only) => only === 'all' || only.some((pick) => id === pick |
|
|
|
30
30
|
*/
|
|
31
31
|
export function exportAll(catalog, roles, outDir, only = 'all', context = {}) {
|
|
32
32
|
const theme = readTheme(catalog, roles);
|
|
33
|
+
// A brand with several looks: each look's own theme, the brand's own look first.
|
|
34
|
+
if (context.looks?.length > 1) {
|
|
35
|
+
theme.looks = context.looks.map(({ name, label, description, base, catalog: c, roles: r }) => ({ name, label, description, base, theme: base ? theme : readTheme(c, r) }));
|
|
36
|
+
}
|
|
33
37
|
// tokens.css sits beside exports/; adapters that re-use its rules (Tailwind's pair and scope
|
|
34
38
|
// utilities) read it from here.
|
|
35
39
|
const tokensCss = path.join(outDir, '..', 'tokens.css');
|
package/figma/README.md
CHANGED
|
@@ -27,7 +27,7 @@ group in a hidden collection, and the published collection's modes point at the
|
|
|
27
27
|
|
|
28
28
|
| Collection | Modes | Holds |
|
|
29
29
|
| -------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
30
|
-
| Brand (hidden) | Default
|
|
30
|
+
| Brand (hidden; published with looks) | Default, or one per look | Font families, and a complete group of the brand's colors per theme: `default/color/…`, then `deuteranopia/color/…` and the other color vision themes. Data colors are here by name (`color/data/violet`). A color a vision theme doesn't change points at default's. |
|
|
31
31
|
| Scale (hidden) | Default | The raw steps: space, radius, stroke and icon size scales, and the font sizes as `desktop/`, `tablet/` and `mobile/font-size/…`, each `clamp()` read at that width (tablet at 768px). |
|
|
32
32
|
| Size | Desktop, Tablet, Mobile | The sizes designers pick: font sizes, and the named semantic and component sizes (control heights, gaps, padding), each pointing at a Scale step. |
|
|
33
33
|
| Theme | Light, Dark, Light high contrast, Dark high contrast, Deuteranopia, Protanopia, Tritanopia, Achromatopsia | The themes, as `data-theme` in CSS. Each color vision theme is a light theme of its own with the brand's colors redesigned for that kind of color vision. Holds every semantic color designers pick, the data series slots (`data/1`…, each pointing at the named data color that theme puts there), and, with no scopes, the palette scopes and each brand pair's parts (`pair/<name>/bg`…) that Status points at. |
|
|
@@ -50,6 +50,21 @@ Effect styles hold what a variable can't: elevation/xs to elevation/2xl from the
|
|
|
50
50
|
focus/ring (gap, ring and halo), and focus/field and focus/field-error for fields. Shadow colors that are
|
|
51
51
|
Theme tokens stay bound, so the focus styles follow light, dark and the other themes.
|
|
52
52
|
|
|
53
|
+
### Looks
|
|
54
|
+
|
|
55
|
+
A brand with [looks](../tokens/README.md#looks) gets one Brand mode per look, the brand's own
|
|
56
|
+
first (Mojave, Classic). Everything else stays as it is: a value in Scale, Size or Theme that
|
|
57
|
+
differs between looks points at a Brand variable named `look/<collection>/<mode>/<name>`
|
|
58
|
+
(`look/theme/light/scope/accent/solid`), which holds each look's value. Set the Brand mode on a
|
|
59
|
+
frame to switch its look; Theme, Size, Status and Emphasis still switch on their own.
|
|
60
|
+
|
|
61
|
+
With looks, Brand is published, so a file that uses the library can set the look too. Its
|
|
62
|
+
variables still have no scopes, so they stay out of the color and number pickers.
|
|
63
|
+
|
|
64
|
+
Limits: text styles and effect styles come from the brand's own look. A text style's size and
|
|
65
|
+
family are variables, so they follow the look, but its line height and letter spacing are fixed
|
|
66
|
+
numbers. Shadow colors that aren't Theme tokens are the brand look's.
|
|
67
|
+
|
|
53
68
|
### Plans
|
|
54
69
|
|
|
55
70
|
Modes per collection depend on the Figma plan: Professional allows 10, Organization 20. The sync
|
|
@@ -84,7 +84,7 @@ async function lane(label, items, note) {
|
|
|
84
84
|
add(how, [l]);
|
|
85
85
|
}
|
|
86
86
|
await lane('Color', [
|
|
87
|
-
['Brand', 'Hidden', 'One mode. Groups: default, deuteranopia, protanopia, tritanopia, achromatopsia', 'The ramps and 20 named data colors, a full set per group.'],
|
|
87
|
+
['Brand', 'Hidden', 'One mode per look. Groups: default, deuteranopia, protanopia, tritanopia, achromatopsia, look', 'The ramps and 20 named data colors, a full set per group. look/ holds what changes between looks.'],
|
|
88
88
|
['Theme', 'Published', 'Light, Dark, Light/Dark high contrast, 4 vision themes', 'The jobs: surface, text, border, fill, interactive, status, pairs, data/1-20.'],
|
|
89
89
|
['Status', 'Published', 'Default, Info, Success, Warning, Error', 'What a control is about. Default is the brand accent; the rest are status palettes.'],
|
|
90
90
|
['Emphasis', 'Published', 'Bold, Standard, Subtle, Quiet', 'How loud a control is. Components bind emphasis/bg, fg and border.']
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
"type": "string",
|
|
22
22
|
"description": "Brand id, used in file names and build output."
|
|
23
23
|
},
|
|
24
|
+
"label": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "The brand's display name, and its own look's name in Figma and docs. Defaults to the name, capitalized."
|
|
27
|
+
},
|
|
24
28
|
"description": {
|
|
25
29
|
"type": "string"
|
|
26
30
|
},
|
|
@@ -677,6 +681,85 @@
|
|
|
677
681
|
}
|
|
678
682
|
}
|
|
679
683
|
}
|
|
684
|
+
},
|
|
685
|
+
"tokens": {
|
|
686
|
+
"type": "object",
|
|
687
|
+
"description": "Values for semantic or component tokens that the brand inputs can't express, by token path: { \"button.radius\": \"{radius.control}\" }. A value is a literal or a {reference}.",
|
|
688
|
+
"additionalProperties": {
|
|
689
|
+
"type": [
|
|
690
|
+
"string",
|
|
691
|
+
"number"
|
|
692
|
+
]
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
"looks": {
|
|
696
|
+
"type": "object",
|
|
697
|
+
"description": "Other looks of the same brand, by name (data-look=\"<name>\" in CSS, a Brand mode in Figma). The brand file's own values are the first look. Each look is merged over the brand: objects merge key by key, lists replace, and a palette, pair, emphasis level, concept or font metrics entry replaces the brand's whole entry. Every look keeps the same tokens, palettes, pairs and roles structure; only values change.",
|
|
698
|
+
"additionalProperties": {
|
|
699
|
+
"type": "object",
|
|
700
|
+
"additionalProperties": false,
|
|
701
|
+
"properties": {
|
|
702
|
+
"label": {
|
|
703
|
+
"type": "string",
|
|
704
|
+
"description": "The look's display name. Defaults to the name, capitalized."
|
|
705
|
+
},
|
|
706
|
+
"description": {
|
|
707
|
+
"type": "string"
|
|
708
|
+
},
|
|
709
|
+
"overrides": {
|
|
710
|
+
"type": "object",
|
|
711
|
+
"description": "Exact values pinned on top of this look's math, as in <brand>.overrides.json.",
|
|
712
|
+
"additionalProperties": {
|
|
713
|
+
"type": [
|
|
714
|
+
"string",
|
|
715
|
+
"number"
|
|
716
|
+
]
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
"tokens": {
|
|
720
|
+
"type": "object",
|
|
721
|
+
"description": "Values for semantic or component tokens that the brand inputs can't express, by token path: { \"button.radius\": \"{radius.control}\" }. A value is a literal or a {reference}.",
|
|
722
|
+
"additionalProperties": {
|
|
723
|
+
"type": [
|
|
724
|
+
"string",
|
|
725
|
+
"number"
|
|
726
|
+
]
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
"space": {
|
|
730
|
+
"type": "object",
|
|
731
|
+
"description": "Changes to the brand's space, merged over it."
|
|
732
|
+
},
|
|
733
|
+
"color": {
|
|
734
|
+
"type": "object",
|
|
735
|
+
"description": "Changes to the brand's color, merged over it."
|
|
736
|
+
},
|
|
737
|
+
"type": {
|
|
738
|
+
"type": "object",
|
|
739
|
+
"description": "Changes to the brand's type, merged over it."
|
|
740
|
+
},
|
|
741
|
+
"radius": {
|
|
742
|
+
"type": "object",
|
|
743
|
+
"description": "Changes to the brand's radius, merged over it."
|
|
744
|
+
},
|
|
745
|
+
"stroke": {
|
|
746
|
+
"type": "object",
|
|
747
|
+
"description": "Changes to the brand's stroke, merged over it."
|
|
748
|
+
},
|
|
749
|
+
"elevation": {
|
|
750
|
+
"type": "object",
|
|
751
|
+
"description": "Changes to the brand's elevation, merged over it."
|
|
752
|
+
},
|
|
753
|
+
"motion": {
|
|
754
|
+
"type": "object",
|
|
755
|
+
"description": "Changes to the brand's motion, merged over it."
|
|
756
|
+
},
|
|
757
|
+
"roles": {
|
|
758
|
+
"type": "object",
|
|
759
|
+
"description": "Changes to the brand's roles, merged over it."
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
680
763
|
}
|
|
681
764
|
},
|
|
682
765
|
"$defs": {
|
package/generator/generate.mjs
CHANGED
|
@@ -4,31 +4,51 @@
|
|
|
4
4
|
* Reads a brand file (the inputs) and the optional <brand>.overrides.json beside it (exact
|
|
5
5
|
* values pinned on top of the math), and writes DTCG token files to
|
|
6
6
|
* <generatedDir>/tokens/primitive/ plus the brand-specific semantic files (pairs, role scopes,
|
|
7
|
-
* statuses, emphasis) to <generatedDir>/tokens/semantic
|
|
7
|
+
* statuses, emphasis) to <generatedDir>/tokens/semantic/, and the same for each other look
|
|
8
|
+
* (looks.mjs) under <generatedDir>/looks/<name>/. Those files are generated: edit the brand
|
|
8
9
|
* file, not them.
|
|
9
10
|
*/
|
|
10
11
|
import fs from 'node:fs';
|
|
11
12
|
import path from 'node:path';
|
|
12
13
|
|
|
13
14
|
import { buildPrimitives } from './brand.mjs';
|
|
15
|
+
import { looksOf } from './looks.mjs';
|
|
14
16
|
|
|
15
17
|
const ROLE_FILE_NOTE = 'Generated from roles in the brand file. Edit the brand file (or the Roles step of loomiere studio), not this file.';
|
|
16
18
|
const PAIR_FILE_NOTE = 'Generated from color.pairs in the brand file. Edit the brand file (or use loomiere lab), not this file.';
|
|
17
19
|
|
|
18
20
|
export const overridesFileFor = (brandFile) => brandFile.replace(/\.json$/, '.overrides.json');
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Writes every look's generated tokens: the brand's own look to <generatedDir>/tokens/, each
|
|
24
|
+
* other look to <generatedDir>/looks/<name>/tokens/. Returns the brand's own look's results
|
|
25
|
+
* plus `looks`: [{ name, label, description, brand, base, dir }].
|
|
26
|
+
*/
|
|
20
27
|
export function generate(brandFile, generatedDir) {
|
|
21
28
|
const overridesFile = overridesFileFor(brandFile);
|
|
22
29
|
const brand = JSON.parse(fs.readFileSync(brandFile, 'utf8'));
|
|
23
30
|
const overrides = fs.existsSync(overridesFile) ? JSON.parse(fs.readFileSync(overridesFile, 'utf8')) : {};
|
|
24
31
|
|
|
25
|
-
const
|
|
32
|
+
const looks = looksOf(brand, overrides);
|
|
33
|
+
fs.rmSync(path.join(generatedDir, 'looks'), { recursive: true, force: true });
|
|
34
|
+
let own;
|
|
35
|
+
for (const look of looks) {
|
|
36
|
+
look.dir = look.base ? generatedDir : path.join(generatedDir, 'looks', look.name);
|
|
37
|
+
const result = writeLook(look, look.dir);
|
|
38
|
+
if (look.base) own = result;
|
|
39
|
+
}
|
|
40
|
+
return { brand, ...own, looks };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeLook(look, dir) {
|
|
44
|
+
const { files, applied, pairs, problems, roleTokens } = buildPrimitives(look.brand, look.overrides);
|
|
45
|
+
const where = look.base ? '' : ` (look "${look.name}")`;
|
|
26
46
|
if (problems.length) {
|
|
27
|
-
throw new Error(`Brand pairs need AA (4.5:1) text:\n ${problems.join('\n ')}\nPick a passing pair in \`loomiere lab\` or fix color.pairs in the brand file.`);
|
|
47
|
+
throw new Error(`Brand pairs need AA (4.5:1) text${where}:\n ${problems.join('\n ')}\nPick a passing pair in \`loomiere lab\` or fix color.pairs in the brand file.`);
|
|
28
48
|
}
|
|
29
49
|
|
|
30
|
-
const primitiveDir = path.join(
|
|
31
|
-
const semanticDir = path.join(
|
|
50
|
+
const primitiveDir = path.join(dir, 'tokens', 'primitive');
|
|
51
|
+
const semanticDir = path.join(dir, 'tokens', 'semantic');
|
|
32
52
|
fs.mkdirSync(primitiveDir, { recursive: true });
|
|
33
53
|
fs.mkdirSync(semanticDir, { recursive: true });
|
|
34
54
|
for (const [name, content] of Object.entries(files)) {
|
|
@@ -39,5 +59,5 @@ export function generate(brandFile, generatedDir) {
|
|
|
39
59
|
for (const name of ['scope', 'status', 'emphasis']) {
|
|
40
60
|
fs.writeFileSync(path.join(semanticDir, `${name}.json`), `${JSON.stringify({ $description: ROLE_FILE_NOTE, ...roleTokens[name] }, null, 2)}\n`);
|
|
41
61
|
}
|
|
42
|
-
return {
|
|
62
|
+
return { applied, files };
|
|
43
63
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Looks: one brand, several ways it can look. The brand file's own values are the first look;
|
|
3
|
+
* each entry in `looks` changes some of them:
|
|
4
|
+
*
|
|
5
|
+
* "looks": {
|
|
6
|
+
* "classic": {
|
|
7
|
+
* "label": "Classic",
|
|
8
|
+
* "description": "The look before the rebrand.",
|
|
9
|
+
* "color": { "palettes": { "primary": { "keys": ["#e5927d"] } } },
|
|
10
|
+
* "type": { "families": { "body": ["Roboto", "sans-serif"] } },
|
|
11
|
+
* "radius": { "base": 3 },
|
|
12
|
+
* "tokens": { "button.radius": "{radius.control}" }
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* A look is merged over the brand: objects merge key by key, lists replace, and a palette, a
|
|
17
|
+
* pair, an emphasis level, a concept or a font's metrics replaces the brand's whole entry, so a
|
|
18
|
+
* look's palette never keeps keys from the brand's. Every look has the same tokens; only their
|
|
19
|
+
* values change, so components, Figma bindings and code written against one look work in all.
|
|
20
|
+
* In CSS a look is data-look="<name>" on the root element; with no data-look the brand's own
|
|
21
|
+
* look applies. `tokens` picks a value for any semantic or component token (also allowed at the
|
|
22
|
+
* top of the brand file), for what the brand inputs can't say, such as square buttons.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Entries a look replaces whole instead of merging into. */
|
|
26
|
+
const WHOLE = /^(color\.palettes|color\.pairs|roles\.concepts|roles\.emphasis|roles\.status|type\.metrics)\.[^.]+$/;
|
|
27
|
+
/** What a look may change. */
|
|
28
|
+
const LOOK_KEYS = ['label', 'description', 'overrides', 'tokens', 'space', 'color', 'type', 'radius', 'stroke', 'elevation', 'motion', 'roles'];
|
|
29
|
+
|
|
30
|
+
const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
31
|
+
|
|
32
|
+
function merge(base, over, at = '') {
|
|
33
|
+
if (!isObject(base) || !isObject(over) || WHOLE.test(at)) return structuredClone(over);
|
|
34
|
+
const out = structuredClone(base);
|
|
35
|
+
for (const [key, value] of Object.entries(over)) {
|
|
36
|
+
const p = at ? `${at}.${key}` : key;
|
|
37
|
+
out[key] = key in base ? merge(base[key], value, p) : structuredClone(value);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const labelOf = (name) => name.replace(/(^|-)(\w)/g, (_, dash, c) => (dash ? ' ' : '') + c.toUpperCase());
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The brand's looks, its own first: [{ name, label, description, brand, overrides, base }].
|
|
46
|
+
* `brand` is the whole brand file as that look sees it (without `looks`); `overrides` are the
|
|
47
|
+
* exact values pinned for that look: the brand's .overrides.json for its own look, a look's
|
|
48
|
+
* `overrides` for the others.
|
|
49
|
+
*/
|
|
50
|
+
export function looksOf(brand, overrides = {}) {
|
|
51
|
+
const { looks = {}, ...own } = brand;
|
|
52
|
+
const list = [{ name: brand.name, label: brand.label ?? labelOf(brand.name), description: brand.description, brand: own, overrides, base: true }];
|
|
53
|
+
for (const [name, look] of Object.entries(looks)) {
|
|
54
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error(`Look "${name}": names use a-z, 0-9 and dashes, starting with a letter`);
|
|
55
|
+
if (name === brand.name) throw new Error(`Look "${name}" has the brand's own name; the brand file's values are already the look "${name}".`);
|
|
56
|
+
const unknown = Object.keys(look).filter((k) => !LOOK_KEYS.includes(k));
|
|
57
|
+
if (unknown.length) throw new Error(`Look "${name}" sets ${unknown.join(', ')}; a look can set ${LOOK_KEYS.join(', ')}.`);
|
|
58
|
+
const { label, description, overrides: pinned = {}, ...changes } = look;
|
|
59
|
+
list.push({ name, label: label ?? labelOf(name), description, brand: { ...merge(own, changes), name: brand.name }, overrides: pinned, base: false });
|
|
60
|
+
}
|
|
61
|
+
return list;
|
|
62
|
+
}
|
package/lib/contrast.mjs
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
* Brand pairs (color.pairs in the brand file) are checked too, in light and dark: the
|
|
7
7
|
* text, the accent, the button label on hover and press, and text on the tinted parts.
|
|
8
8
|
* loomiere build runs it after every build and fails if anything falls short; loomiere check
|
|
9
|
-
* runs it on its own.
|
|
9
|
+
* runs it on its own. A brand with several looks is checked once per look.
|
|
10
10
|
*/
|
|
11
11
|
import fs from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { wcagContrast } from 'culori';
|
|
14
14
|
|
|
15
|
+
import { looksOf } from '../generator/looks.mjs';
|
|
15
16
|
import { resolvePair } from '../generator/pairs.mjs';
|
|
16
17
|
|
|
17
18
|
export const MODES = ['light', 'dark', 'contrast-more', 'dark-contrast-more'];
|
|
@@ -79,9 +80,26 @@ const PAIRS = [
|
|
|
79
80
|
['scope.accent.focus', 'surface.canvas', UI, 'accent focus ring'],
|
|
80
81
|
];
|
|
81
82
|
|
|
82
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Checks a built theme: { rows, failures }, one row per pair and mode, in every look. Rows of a
|
|
85
|
+
* look other than the brand's own carry its name in `look`.
|
|
86
|
+
*/
|
|
83
87
|
export function checkContrast(outDir, brandFile) {
|
|
84
|
-
const
|
|
88
|
+
const looks = looksOf(JSON.parse(fs.readFileSync(brandFile, 'utf8')));
|
|
89
|
+
const rows = [];
|
|
90
|
+
const failures = [];
|
|
91
|
+
for (const look of looks) {
|
|
92
|
+
const file = look.base ? path.join(outDir, 'tokens.json') : path.join(outDir, 'generated', 'looks', look.name, 'tokens.json');
|
|
93
|
+
const { tokens } = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
94
|
+
const result = checkLook(tokens, look.brand.color.pairs ?? {});
|
|
95
|
+
const tag = (r) => (look.base ? r : { ...r, look: look.name });
|
|
96
|
+
rows.push(...result.rows.map(tag));
|
|
97
|
+
failures.push(...result.failures.map(tag));
|
|
98
|
+
}
|
|
99
|
+
return { rows, failures };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkLook(tokens, brandPairs) {
|
|
85
103
|
const byPath = new Map(tokens.map((t) => [t.path, t]));
|
|
86
104
|
const failures = [];
|
|
87
105
|
const rows = [];
|
|
@@ -99,7 +117,6 @@ for (const mode of modes) {
|
|
|
99
117
|
}
|
|
100
118
|
|
|
101
119
|
// Palettes are the same in every mode, so a pair resolves from the light palette values.
|
|
102
|
-
const brandPairs = JSON.parse(fs.readFileSync(brandFile, 'utf8')).color.pairs ?? {};
|
|
103
120
|
const hexOf = (tokenPath) => byPath.get(tokenPath)?.value;
|
|
104
121
|
for (const [name, { dark, ...spec }] of Object.entries(brandPairs)) {
|
|
105
122
|
for (const [mode, modeSpec] of [['light', spec], ...(dark ? [['dark', { ...spec, ...dark }]] : [])]) {
|
|
@@ -130,10 +147,11 @@ export function reportContrast({ rows, failures }) {
|
|
|
130
147
|
if (failures.length) {
|
|
131
148
|
console.error(`Contrast check: ${failures.length} of ${rows.length} pairs fall short.`);
|
|
132
149
|
for (const f of failures) {
|
|
133
|
-
console.error(` ${f.mode.padEnd(18)} ${f.fg} on ${f.bg}: ${f.ratio}:1, needs ${f.min}:1 (${f.kind})`);
|
|
150
|
+
console.error(` ${(f.look ? `${f.look} ${f.mode}` : f.mode).padEnd(18)} ${f.fg} on ${f.bg}: ${f.ratio}:1, needs ${f.min}:1 (${f.kind})`);
|
|
134
151
|
}
|
|
135
152
|
return false;
|
|
136
153
|
}
|
|
137
|
-
|
|
154
|
+
const looks = new Set(rows.map((r) => r.look ?? '')).size;
|
|
155
|
+
console.log(`Contrast check passed: ${rows.length} pairs across ${new Set(rows.map((r) => r.mode)).size} modes${looks > 1 ? ` in ${looks} looks` : ''}, brand pairs included.`);
|
|
138
156
|
return true;
|
|
139
157
|
}
|
package/lib/figma.mjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* loomiere figma serve serve the build for the plugin, without pushing
|
|
11
11
|
*
|
|
12
12
|
* Options: --file <Figma URL or key> (or "figma": { "file": … } in loomiere.config.json),
|
|
13
|
-
* --since <snapshot.json> (scripts: only what changed
|
|
13
|
+
* --since <snapshot.json> (scripts: only what changed; the data goes in compressed unless
|
|
14
|
+
* --plain), --remove (delete what the build no
|
|
14
15
|
* longer has), --dry-run (report only), --port <n>.
|
|
15
16
|
*
|
|
16
17
|
* Every way runs the same engine (figma/engine.js), so they leave the file the same.
|
|
@@ -19,8 +20,11 @@ import { execFileSync, spawn } from 'node:child_process';
|
|
|
19
20
|
import fs from 'node:fs';
|
|
20
21
|
import http from 'node:http';
|
|
21
22
|
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
22
24
|
import vm from 'node:vm';
|
|
23
25
|
|
|
26
|
+
import LZString from 'lz-string';
|
|
27
|
+
|
|
24
28
|
import { engineRoot } from './tool-server.mjs';
|
|
25
29
|
|
|
26
30
|
const ENGINE_FILE = path.join(engineRoot, 'figma', 'engine.js');
|
|
@@ -76,13 +80,37 @@ export function snapshotScript() {
|
|
|
76
80
|
* snapshotScript() returned: the plan's mode limit and a hash per group of variables, so only
|
|
77
81
|
* groups that differ are sent, each whole in one part.
|
|
78
82
|
*/
|
|
79
|
-
|
|
83
|
+
/**
|
|
84
|
+
* lz-string's decompressor, for compressed pushes: use_figma has no network and no zlib, and
|
|
85
|
+
* the code it takes is what costs time in a chat, so the data goes in compressed.
|
|
86
|
+
*/
|
|
87
|
+
const lzDecompressor = () => fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.resolve('lz-string'))), 'lz-string.min.js'), 'utf8');
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The head of a compressed part: the decompressor, then the engine, itself compressed. The first
|
|
91
|
+
* part also keeps both in the file (shared plugin data "loomiere"/"push"), so the later parts
|
|
92
|
+
* load them from there instead of carrying them again.
|
|
93
|
+
*/
|
|
94
|
+
const PUSH_KEY = ['loomiere', 'push'];
|
|
95
|
+
const compressedHeads = () => {
|
|
96
|
+
const kept = JSON.stringify({ lz: lzDecompressor(), engine: LZString.compressToBase64(`${engineCompact()}\nreturn loomiereSync;`) });
|
|
97
|
+
const load = 'var LZString = new Function(HEAD.lz + "\\nreturn LZString;")();\nvar loomiereSync = new Function(LZString.decompressFromBase64(HEAD.engine))();\n';
|
|
98
|
+
return {
|
|
99
|
+
first: `var HEAD = ${kept};\nfigma.root.setSharedPluginData(${PUSH_KEY.map((k) => JSON.stringify(k)).join(', ')}, JSON.stringify(HEAD));\n${load}`,
|
|
100
|
+
later: `var HEAD = JSON.parse(figma.root.getSharedPluginData(${PUSH_KEY.map((k) => JSON.stringify(k)).join(', ')}) || "null");\nif (!HEAD) throw new Error("Run the first push script first: it keeps the engine in the file for the others.");\n${load}`,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
const compressedCall = (data, options) => `\nreturn await loomiereSync(JSON.parse(LZString.decompressFromBase64(${JSON.stringify(LZString.compressToBase64(JSON.stringify(data)))})), ${JSON.stringify(options)});\n`;
|
|
104
|
+
|
|
105
|
+
export function pushScripts(data, snapshot, { remove = false, dryRun = false, compress = false } = {}) {
|
|
80
106
|
const { loomiereFingerprint, loomiereLayout, loomiereGroupOf, loomiereGroupHash } = engine();
|
|
81
107
|
const limit = snapshot.modeLimit ?? 1;
|
|
82
108
|
const warnings = [];
|
|
83
109
|
const layout = loomiereLayout(data, limit, 'auto', warnings);
|
|
84
|
-
const
|
|
85
|
-
const room = PART_LIMIT -
|
|
110
|
+
const heads = compress ? compressedHeads() : { first: engineCompact(), later: engineCompact() };
|
|
111
|
+
const room = PART_LIMIT - heads.first.length - 400;
|
|
112
|
+
const roomLater = PART_LIMIT - heads.later.length - 400;
|
|
113
|
+
const callFor = compress ? compressedCall : call;
|
|
86
114
|
const base = { format: data.format, version: data.version, brand: data.brand };
|
|
87
115
|
const options = { layout: 'modes', modeLimit: limit, dryRun, partial: true, remove };
|
|
88
116
|
const headers = layout.collections.map((c) => ({ name: c.name, modes: c.modes, ...(c.hidden && { hidden: true }) }));
|
|
@@ -103,24 +131,31 @@ export function pushScripts(data, snapshot, { remove = false, dryRun = false } =
|
|
|
103
131
|
const removeGroups = Object.keys(have).filter((g) => ours.includes(g.slice(0, g.indexOf(':'))) && !groups.has(g));
|
|
104
132
|
const removeCollections = (snapshot.collections ?? []).filter((n) => !ours.includes(n));
|
|
105
133
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
let
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
// Compressed, a part holds about `ratio` times its size in data; start there and shrink until every part fits.
|
|
135
|
+
const ratio = compress ? JSON.stringify(changed.map(([, g]) => g.variables)).length / LZString.compressToBase64(JSON.stringify(changed.map(([, g]) => g.variables))).length : 1;
|
|
136
|
+
let parts;
|
|
137
|
+
for (let scale = 0.9; ; scale *= 0.85) {
|
|
138
|
+
parts = [];
|
|
139
|
+
let current = [];
|
|
140
|
+
let size = 0;
|
|
141
|
+
const flush = (extra = {}) => {
|
|
142
|
+
const collections = headers.map((h) => ({ ...h, variables: current.filter(([, g]) => g.collection === h.name).flatMap(([, g]) => g.variables) }));
|
|
143
|
+
parts.push((parts.length ? heads.later : heads.first) + callFor({ ...base, collections, ...extra.data }, { ...options, complete: current.map(([key]) => key), textStyles: false, ...extra.options }));
|
|
144
|
+
current = [];
|
|
145
|
+
size = 0;
|
|
146
|
+
};
|
|
147
|
+
for (const entry of changed) {
|
|
148
|
+
const n = JSON.stringify(entry[1].variables).length + entry[0].length + 4;
|
|
149
|
+
const budget = (parts.length ? roomLater : room) * ratio * scale;
|
|
150
|
+
if (size && size + n > budget) flush();
|
|
151
|
+
current.push(entry);
|
|
152
|
+
size += n;
|
|
153
|
+
}
|
|
154
|
+
if (current.length) flush();
|
|
155
|
+
// Last: text and effect styles, and the groups and collections the build no longer has.
|
|
156
|
+
flush({ data: { textStyles: data.textStyles, effectStyles: data.effectStyles }, options: { textStyles: true, removeGroups, removeCollections } });
|
|
157
|
+
if (!compress || parts.every((p) => p.length <= PART_LIMIT) || ratio * scale < 1) break;
|
|
120
158
|
}
|
|
121
|
-
if (current.length) flush();
|
|
122
|
-
// Last: text and effect styles, and the groups and collections the build no longer has.
|
|
123
|
-
flush({ data: { textStyles: data.textStyles, effectStyles: data.effectStyles }, options: { textStyles: true, removeGroups, removeCollections } });
|
|
124
159
|
const tooBig = parts.findIndex((p) => p.length > 50000);
|
|
125
160
|
if (tooBig !== -1) throw new Error(`Push part ${tooBig + 1} is over use_figma's 50,000 characters; a variable group is too large to send in one part.`);
|
|
126
161
|
const variables = changed.reduce((n, [, g]) => n + g.variables.length, 0);
|
|
@@ -304,7 +339,7 @@ async function viaApi(config, fileKey, token, { remove, dryRun }) {
|
|
|
304
339
|
|
|
305
340
|
// --- the command -------------------------------------------------------------
|
|
306
341
|
|
|
307
|
-
export async function figmaCommand(config, sub, { via, file, since, remove = false, dryRun = false, port } = {}) {
|
|
342
|
+
export async function figmaCommand(config, sub, { via, file, since, remove = false, dryRun = false, plain = false, port } = {}) {
|
|
308
343
|
if (sub === 'serve') return serve(config, { port });
|
|
309
344
|
if (sub !== 'push') throw new Error('Use loomiere figma push or loomiere figma serve.');
|
|
310
345
|
const fileKey = fileKeyOf(file ?? config.figma?.file ?? config.figma);
|
|
@@ -316,7 +351,7 @@ export async function figmaCommand(config, sub, { via, file, since, remove = fal
|
|
|
316
351
|
return true;
|
|
317
352
|
}
|
|
318
353
|
const snapshot = JSON.parse(fs.readFileSync(path.resolve(config.cwd, since), 'utf8'));
|
|
319
|
-
const { layout, limit, changed, groups, parts, warnings } = pushScripts(readFigmaJson(config), snapshot, { remove, dryRun });
|
|
354
|
+
const { layout, limit, changed, groups, parts, warnings } = pushScripts(readFigmaJson(config), snapshot, { remove, dryRun, compress: !plain });
|
|
320
355
|
const files = writeScripts(config, parts);
|
|
321
356
|
for (const w of warnings) console.log(`Note: ${w}`);
|
|
322
357
|
console.log(`${changed} variables in ${groups} groups differ from the file (${layout} layout, ${limit} modes per collection allowed).`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scattered-light/loomiere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Turns a small brand file into a full design token theme: OKLCH color ramps, a type scale, light, dark and high-contrast modes, plus Tailwind, MUI, Chakra, AG Grid and Figma exports.",
|
|
5
5
|
"keywords": ["design-tokens", "theme", "style-dictionary", "dtcg", "oklch", "base-ui", "ag-grid", "tailwind"],
|
|
6
6
|
"repository": {
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"culori": "^4.0.2",
|
|
47
|
+
"lz-string": "^1.5.0",
|
|
47
48
|
"style-dictionary": "^5.7.0"
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
package/tokens/README.md
CHANGED
|
@@ -45,6 +45,32 @@ The generator (`generator/vision.mjs`) designs each theme from the brand's own a
|
|
|
45
45
|
|
|
46
46
|
For low vision, the `contrast-more` modes above already raise contrast when the system asks for it.
|
|
47
47
|
|
|
48
|
+
## Looks
|
|
49
|
+
|
|
50
|
+
A look is another way the same brand can look: an old and a new brand, a quiet and a bold version, a white-label client. The brand file's own values are the first look, named after the brand. Each entry in `looks` changes only what differs:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
"looks": {
|
|
54
|
+
"classic": {
|
|
55
|
+
"label": "Classic",
|
|
56
|
+
"description": "The look before the rebrand.",
|
|
57
|
+
"color": { "palettes": { "primary": { "name": "coral", "keys": ["#fedfd7", "#e5927d", "#c37c6a"] } } },
|
|
58
|
+
"type": { "families": { "display": ["Roboto", "sans-serif"], "body": ["Roboto", "sans-serif"] } },
|
|
59
|
+
"radius": { "base": 3 },
|
|
60
|
+
"tokens": { "button.radius": "{radius.control}" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- A look can set `label`, `description`, `overrides` (exact values, like `.overrides.json`), `tokens`, `space`, `color`, `type`, `radius`, `stroke`, `elevation`, `motion` and `roles`.
|
|
66
|
+
- It is merged over the brand: objects merge key by key and lists replace. A palette, a pair, a concept, an emphasis or status entry, or a font's metrics replaces the brand's whole entry, so a look's palette never keeps keys from the brand's.
|
|
67
|
+
- `tokens` picks a value for any semantic or component token, for what the brand inputs can't say (square buttons, a thicker focus ring). It works at the top of the brand file too.
|
|
68
|
+
- Every look has the same tokens; only their values change. Components, Figma bindings and code written against one look work in all of them. Pair names and role rules must match across looks; the build says so if they don't.
|
|
69
|
+
|
|
70
|
+
In CSS a look is `data-look="<name>"` on the root element, next to `data-theme`. With no `data-look` the brand's own look applies. `tokens.css` holds one `[data-look]` block with only the variables that change, plus that look's dark, high contrast and color vision blocks. The contrast check runs on every look, and `tokens.json` lists the looks under `looks` with what each one changes. In Figma each look is a mode of the Brand collection (see [figma/README.md](../figma/README.md)), and Storybook has a Look toolbar.
|
|
71
|
+
|
|
72
|
+
Mojave's brand file has one: `classic`, the look the brand had before.
|
|
73
|
+
|
|
48
74
|
## Roles: chrome, accent and the emphasis ladder
|
|
49
75
|
|
|
50
76
|
Semantic and component tokens never name a brand color. They say `color.chrome.*` (surfaces, text, borders) or `color.accent.*` (links, focus, the loudest actions), and the brand file's `roles` decides which palettes those are:
|