@scattered-light/loomiere 0.4.0 → 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/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 +24 -8
- package/figma/library/README.md +92 -0
- package/figma/library/check/known-differences.md +20 -0
- package/figma/library/check/mojave.json +246 -0
- package/figma/library/kit/base.js +89 -0
- package/figma/library/kit/components.js +95 -0
- package/figma/library/kit/docs.js +37 -0
- package/figma/library/kit/fingerprint.js +143 -0
- package/figma/library/pages/02-palettes.js +40 -0
- package/figma/library/pages/03-color.js +126 -0
- package/figma/library/pages/04-type.js +56 -0
- package/figma/library/pages/05-scale.js +58 -0
- package/figma/library/pages/06-size.js +111 -0
- package/figma/library/pages/07-elevation-focus-motion.js +108 -0
- package/figma/library/pages/08-themes.js +36 -0
- package/figma/library/pages/09-fields/01-input.js +55 -0
- package/figma/library/pages/09-fields/02-checks.js +82 -0
- package/figma/library/pages/09-fields/03-field.js +32 -0
- package/figma/library/pages/09-fields/04-more-fields.js +144 -0
- package/figma/library/pages/09-fields/05-labels.js +14 -0
- package/figma/library/pages/09-fields/06-doc.js +75 -0
- package/figma/library/pages/10-buttons/01-buttons.js +97 -0
- package/figma/library/pages/10-buttons/02-doc.js +55 -0
- package/figma/library/pages/11-popups/01-popups.js +166 -0
- package/figma/library/pages/11-popups/02-more-popups.js +90 -0
- package/figma/library/pages/11-popups/03-labels.js +14 -0
- package/figma/library/pages/11-popups/04-doc.js +89 -0
- package/figma/library/pages/12-overlays/01-overlays.js +135 -0
- package/figma/library/pages/12-overlays/02-labels.js +10 -0
- package/figma/library/pages/12-overlays/03-doc.js +80 -0
- package/figma/library/pages/13-disclosure/01-disclosure.js +205 -0
- package/figma/library/pages/13-disclosure/02-collapsible.js +39 -0
- package/figma/library/pages/13-disclosure/03-labels.js +16 -0
- package/figma/library/pages/13-disclosure/04-doc.js +91 -0
- package/figma/library/pages/14-display/01-display.js +165 -0
- package/figma/library/pages/14-display/02-more-display.js +55 -0
- package/figma/library/pages/14-display/03-labels.js +14 -0
- package/figma/library/pages/14-display/04-doc.js +96 -0
- package/figma/library/pages/15-cover.js +87 -0
- package/figma/library/run-order.json +21 -0
- package/figma/library/tools/compare.mjs +11 -0
- package/figma/library/tools/diff-snippet.js +15 -0
- package/figma/library/tools/kit-script.mjs +21 -0
- package/figma/library/tools/run.mjs +27 -0
- package/figma/library/tools/runs.mjs +34 -0
- 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
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Diffs units on a real page against the same units on its check page. tools/run.mjs --diff
|
|
2
|
+
// sets PAGE, UNITS (['doc:Color/Text', 'set:Input', 'wrap:Input', ...]) and MAX.
|
|
3
|
+
var F = await new (Object.getPrototypeOf(async function () {}).constructor)('figma', figma.root.getSharedPluginData('loomiere', 'fingerprint'))(figma);
|
|
4
|
+
var PREFIX = figma.root.getSharedPluginData('loomiere', 'pagePrefix');
|
|
5
|
+
async function unitNode(name, key) {
|
|
6
|
+
var pg = figma.root.children.filter(function (p) { return p.name === name; })[0]; await pg.loadAsync();
|
|
7
|
+
var kind = key.slice(0, key.indexOf(':')), rest = key.slice(key.indexOf(':') + 1).replace(/#\d+$/, '');
|
|
8
|
+
if (kind === 'set') return pg.findAll(function (n) { return (n.type === 'COMPONENT_SET' || (n.type === 'COMPONENT' && n.parent.type !== 'COMPONENT_SET')) && n.name === rest; })[0];
|
|
9
|
+
if (kind === 'doc') { var d = rest.split('/'), top = pg.children.filter(function (n) { return n.name === d[0].replace('(frame)', ''); })[0]; return d.length > 1 ? top.children.filter(function (n) { return n.name === d.slice(1).join('/'); })[0] : top; }
|
|
10
|
+
if (kind === 'wrap') return pg.children.filter(function (n) { return n.name === 'Components'; })[0].children.filter(function (n) { return n.name === rest; })[0];
|
|
11
|
+
return pg.children.filter(function (n) { return n.name === rest.replace('(shell)', ''); })[0];
|
|
12
|
+
}
|
|
13
|
+
var out = {};
|
|
14
|
+
for (var i = 0; i < UNITS.length; i++) out[UNITS[i]] = await F.diff(await unitNode(PAGE, UNITS[i]), await unitNode(PREFIX + PAGE, UNITS[i]), MAX || 12);
|
|
15
|
+
return out;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Prints the use_figma script that stores the library kit in a file's shared plugin data, and
|
|
2
|
+
// returns each stored part's length and hash, to compare with `node tools/kit-script.mjs --hashes`.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
const kit = new URL('../kit/', import.meta.url);
|
|
5
|
+
export const KEYS = { 'base.js': 'docHelpers', 'components.js': 'compHelpers', 'docs.js': 'docKit', 'fingerprint.js': 'fingerprint' };
|
|
6
|
+
const h32 = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); };
|
|
7
|
+
const read = (file) => fs.readFileSync(new URL(file, kit), 'utf8');
|
|
8
|
+
if (process.argv.includes('--hashes')) {
|
|
9
|
+
console.log(JSON.stringify(Object.fromEntries(Object.entries(KEYS).map(([f, k]) => [k, `${read(f).length} ${h32(read(f))}`]))));
|
|
10
|
+
} else {
|
|
11
|
+
// --only docHelpers,docKit stores just those parts.
|
|
12
|
+
const only = process.argv.includes('--only') ? process.argv[process.argv.indexOf('--only') + 1].split(',') : null;
|
|
13
|
+
let out = '';
|
|
14
|
+
for (const [file, key] of Object.entries(KEYS)) {
|
|
15
|
+
if (only && !only.includes(key)) continue;
|
|
16
|
+
out += `figma.root.setSharedPluginData('loomiere', '${key}', ${JSON.stringify(read(file))});\n`;
|
|
17
|
+
}
|
|
18
|
+
out += `function h32(s) { var h = 0x811c9dc5; for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return ('0000000' + h.toString(16)).slice(-8); }\n`;
|
|
19
|
+
out += `var out = {}; ${JSON.stringify(Object.values(KEYS))}.forEach(function (k) { var t = figma.root.getSharedPluginData('loomiere', k); out[k] = t.length + ' ' + h32(t); }); return out;\n`;
|
|
20
|
+
process.stdout.write(out);
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Bundles page scripts into one use_figma script, under its 50,000-character limit.
|
|
2
|
+
// node tools/run.mjs [--store all|docHelpers,...] [--check|--real] [--diff Page=unit,unit] [--units Page] pages/...js ...
|
|
3
|
+
// --store writes the kit first (needed once per file, and after a kit change).
|
|
4
|
+
// --check builds on "✱ check / " pages beside the real ones; --real builds the real pages.
|
|
5
|
+
// --diff compares units on the real page with the check page; --units returns a page's fingerprints
|
|
6
|
+
// for tools/compare.mjs.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
const lib = new URL('../', import.meta.url);
|
|
10
|
+
const args = process.argv.slice(2), files = [], opt = {};
|
|
11
|
+
for (let i = 0; i < args.length; i++) {
|
|
12
|
+
if (!args[i].startsWith('--')) files.push(args[i]);
|
|
13
|
+
else opt[args[i].slice(2)] = ['check', 'real'].includes(args[i].slice(2)) ? true : args[++i];
|
|
14
|
+
}
|
|
15
|
+
let out = 'var __out = {};\n';
|
|
16
|
+
if (opt.store) out += execFileSync('node', [new URL('tools/kit-script.mjs', lib).pathname, ...(opt.store === 'all' ? [] : ['--only', opt.store])], { encoding: 'utf8' }).replace(/^var out = .*$/m, '') + '\n';
|
|
17
|
+
if (opt.check) out += "figma.root.setSharedPluginData('loomiere', 'pagePrefix', '✱ check / ');\n";
|
|
18
|
+
if (opt.real) out += "figma.root.setSharedPluginData('loomiere', 'pagePrefix', '');\n";
|
|
19
|
+
for (const f of files) out += `__out[${JSON.stringify(f)}] = await (async function () {\n${fs.readFileSync(new URL(f, lib), 'utf8')}\n})();\n`;
|
|
20
|
+
if (opt.diff) {
|
|
21
|
+
const [page, units] = opt.diff.split('=');
|
|
22
|
+
out += `__out.diff = await (async function (PAGE, UNITS, MAX) {\n${fs.readFileSync(new URL('tools/diff-snippet.js', lib), 'utf8')}\n})(${JSON.stringify(page)}, ${JSON.stringify(units.split(','))}, ${Number(opt.max) || 12});\n`;
|
|
23
|
+
}
|
|
24
|
+
if (opt.units) out += `__out.units = await (async function () { var F = await new (Object.getPrototypeOf(async function () {}).constructor)('figma', figma.root.getSharedPluginData('loomiere', 'fingerprint'))(figma); var pg = figma.root.children.filter(function (p) { return p.name === (figma.root.getSharedPluginData('loomiere', 'pagePrefix') || '') + ${JSON.stringify(opt.units)}; })[0]; await figma.setCurrentPageAsync(pg); return await F.units(); })();\n`;
|
|
25
|
+
out += 'return __out;\n';
|
|
26
|
+
if (out.length > 50000) throw new Error(`Bundle is ${out.length} characters; use_figma takes 50,000. Split it.`);
|
|
27
|
+
process.stdout.write(out);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Packs run-order.json into as few use_figma scripts as fit under 50,000 characters each, and
|
|
2
|
+
// writes them to a folder, numbered in the order to run them.
|
|
3
|
+
// node tools/runs.mjs [--real|--check] [--store] [--pages Fields,Buttons] [--out dir]
|
|
4
|
+
// --real builds the real pages (the default); --check builds "✱ check / " pages to compare.
|
|
5
|
+
// --store puts the kit store first (a new file, or after a kit change).
|
|
6
|
+
// --pages keeps only the steps for those pages, in run order, for patching a few pages.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
const lib = new URL('../', import.meta.url);
|
|
10
|
+
const args = process.argv.slice(2), opt = {};
|
|
11
|
+
for (let i = 0; i < args.length; i++) opt[args[i].slice(2)] = ['real', 'check', 'store'].includes(args[i].slice(2)) ? true : args[++i];
|
|
12
|
+
const { steps } = JSON.parse(fs.readFileSync(new URL('run-order.json', lib), 'utf8'));
|
|
13
|
+
const want = opt.pages ? opt.pages.split(',') : null;
|
|
14
|
+
const files = steps.filter((s) => !want || want.includes(s.page)).flatMap((s) => s.files);
|
|
15
|
+
const run = (list, extra) => execFileSync('node', [new URL('tools/run.mjs', lib).pathname, opt.check ? '--check' : '--real', ...extra, ...list], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
16
|
+
const out = new URL((opt.out || 'runs').replace(/\/?$/, '/'), lib);
|
|
17
|
+
fs.rmSync(out, { recursive: true, force: true }); fs.mkdirSync(out, { recursive: true });
|
|
18
|
+
const bundles = [];
|
|
19
|
+
if (opt.store) bundles.push({ files: ['kit'], text: run([], ['--store', 'all']) });
|
|
20
|
+
let group = [];
|
|
21
|
+
for (const f of files) {
|
|
22
|
+
const next = [...group, f];
|
|
23
|
+
let ok = true;
|
|
24
|
+
try { run(next, []); } catch { ok = false; }
|
|
25
|
+
if (ok) { group = next; continue; }
|
|
26
|
+
if (!group.length) throw new Error(`${f} alone is over the limit; split it.`);
|
|
27
|
+
bundles.push({ files: group, text: run(group, []) }); group = [f];
|
|
28
|
+
}
|
|
29
|
+
if (group.length) bundles.push({ files: group, text: run(group, []) });
|
|
30
|
+
bundles.forEach((b, i) => {
|
|
31
|
+
const name = `run-${String(i + 1).padStart(2, '0')}.js`;
|
|
32
|
+
fs.writeFileSync(new URL(name, out), b.text);
|
|
33
|
+
console.log(`${name} ${String(b.text.length).padStart(6)} ${b.files.join(' ')}`);
|
|
34
|
+
});
|
|
@@ -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.0",
|
|
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:
|