@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.
@@ -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 | 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. |
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": {
@@ -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.1",
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: