@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.
Files changed (58) hide show
  1. package/README.md +3 -1
  2. package/bin/loomiere.mjs +3 -1
  3. package/build/build-tokens.mjs +180 -35
  4. package/exporters/design-system.mjs +7 -0
  5. package/exporters/figma.mjs +545 -478
  6. package/exporters/index.mjs +4 -0
  7. package/figma/README.md +24 -8
  8. package/figma/library/README.md +92 -0
  9. package/figma/library/check/known-differences.md +20 -0
  10. package/figma/library/check/mojave.json +246 -0
  11. package/figma/library/kit/base.js +89 -0
  12. package/figma/library/kit/components.js +95 -0
  13. package/figma/library/kit/docs.js +37 -0
  14. package/figma/library/kit/fingerprint.js +143 -0
  15. package/figma/library/pages/02-palettes.js +40 -0
  16. package/figma/library/pages/03-color.js +126 -0
  17. package/figma/library/pages/04-type.js +56 -0
  18. package/figma/library/pages/05-scale.js +58 -0
  19. package/figma/library/pages/06-size.js +111 -0
  20. package/figma/library/pages/07-elevation-focus-motion.js +108 -0
  21. package/figma/library/pages/08-themes.js +36 -0
  22. package/figma/library/pages/09-fields/01-input.js +55 -0
  23. package/figma/library/pages/09-fields/02-checks.js +82 -0
  24. package/figma/library/pages/09-fields/03-field.js +32 -0
  25. package/figma/library/pages/09-fields/04-more-fields.js +144 -0
  26. package/figma/library/pages/09-fields/05-labels.js +14 -0
  27. package/figma/library/pages/09-fields/06-doc.js +75 -0
  28. package/figma/library/pages/10-buttons/01-buttons.js +97 -0
  29. package/figma/library/pages/10-buttons/02-doc.js +55 -0
  30. package/figma/library/pages/11-popups/01-popups.js +166 -0
  31. package/figma/library/pages/11-popups/02-more-popups.js +90 -0
  32. package/figma/library/pages/11-popups/03-labels.js +14 -0
  33. package/figma/library/pages/11-popups/04-doc.js +89 -0
  34. package/figma/library/pages/12-overlays/01-overlays.js +135 -0
  35. package/figma/library/pages/12-overlays/02-labels.js +10 -0
  36. package/figma/library/pages/12-overlays/03-doc.js +80 -0
  37. package/figma/library/pages/13-disclosure/01-disclosure.js +205 -0
  38. package/figma/library/pages/13-disclosure/02-collapsible.js +39 -0
  39. package/figma/library/pages/13-disclosure/03-labels.js +16 -0
  40. package/figma/library/pages/13-disclosure/04-doc.js +91 -0
  41. package/figma/library/pages/14-display/01-display.js +165 -0
  42. package/figma/library/pages/14-display/02-more-display.js +55 -0
  43. package/figma/library/pages/14-display/03-labels.js +14 -0
  44. package/figma/library/pages/14-display/04-doc.js +96 -0
  45. package/figma/library/pages/15-cover.js +87 -0
  46. package/figma/library/run-order.json +21 -0
  47. package/figma/library/tools/compare.mjs +11 -0
  48. package/figma/library/tools/diff-snippet.js +15 -0
  49. package/figma/library/tools/kit-script.mjs +21 -0
  50. package/figma/library/tools/run.mjs +27 -0
  51. package/figma/library/tools/runs.mjs +34 -0
  52. package/generator/brand.schema.json +83 -0
  53. package/generator/generate.mjs +26 -6
  54. package/generator/looks.mjs +62 -0
  55. package/lib/contrast.mjs +24 -6
  56. package/lib/figma.mjs +58 -23
  57. package/package.json +2 -1
  58. 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": {
@@ -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/. Those files are generated: edit the brand
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 { files, applied, pairs, problems, roleTokens } = buildPrimitives(brand, overrides);
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(generatedDir, 'tokens', 'primitive');
31
- const semanticDir = path.join(generatedDir, 'tokens', 'semantic');
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 { brand, applied, files };
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
- /** Checks a built theme: { rows, failures }, one row per pair and mode. */
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 { tokens } = JSON.parse(fs.readFileSync(path.join(outDir, 'tokens.json'), 'utf8'));
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
- console.log(`Contrast check passed: ${rows.length} pairs across ${new Set(rows.map((r) => r.mode)).size} modes, brand pairs included.`);
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), --remove (delete what the build no
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
- export function pushScripts(data, snapshot, { remove = false, dryRun = false } = {}) {
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 head = engineCompact();
85
- const room = PART_LIMIT - head.length - 400;
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
- const parts = [];
107
- let current = [];
108
- let size = 0;
109
- const flush = (extra = {}) => {
110
- const collections = headers.map((h) => ({ ...h, variables: current.filter(([, g]) => g.collection === h.name).flatMap(([, g]) => g.variables) }));
111
- parts.push(head + call({ ...base, collections, ...extra.data }, { ...options, complete: current.map(([key]) => key), textStyles: false, ...extra.options }));
112
- current = [];
113
- size = 0;
114
- };
115
- for (const entry of changed) {
116
- const n = JSON.stringify(entry[1].variables).length + entry[0].length + 4;
117
- if (size && size + n > room) flush();
118
- current.push(entry);
119
- size += n;
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.4.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: