@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
package/README.md CHANGED
@@ -54,7 +54,7 @@ Every value is a `--sl-` variable, so your own CSS, and component packages such
54
54
 
55
55
  | File | For |
56
56
  | --- | --- |
57
- | `tokens.css` | Every token as a `--sl-` variable, inside `@layer sl.tokens` so your own styles win. Dark mode and high contrast follow the system, or `data-theme` on `<html>`: `light`, `dark`, `light-high-contrast`, `dark-high-contrast`, or a color vision theme (`deuteranopia`, `protanopia`, `tritanopia`, `achromatopsia`). Also holds the `.sl-pair-<name>` classes, `data-color` scopes, `data-status` (info, success, warning, error) and `data-emphasis` levels. |
57
+ | `tokens.css` | Every token as a `--sl-` variable, inside `@layer sl.tokens` so your own styles win. Dark mode and high contrast follow the system, or `data-theme` on `<html>`: `light`, `dark`, `light-high-contrast`, `dark-high-contrast`, or a color vision theme (`deuteranopia`, `protanopia`, `tritanopia`, `achromatopsia`). A brand with looks also has `data-look`. Also holds the `.sl-pair-<name>` classes, `data-color` scopes, `data-status` (info, success, warning, error) and `data-emphasis` levels. |
58
58
  | `utilities.css` | `.text-style-*`, `.icon-*`, `.focus-ring`, `.hide-on-*`. |
59
59
  | `tokens.js`, `tokens.d.ts` | Resolved values for JavaScript. |
60
60
  | `tokens.json`, `pairs.json` | The token catalog and every palette color against every other, for docs and agents. |
@@ -63,4 +63,6 @@ Every value is a `--sl-` variable, so your own CSS, and component packages such
63
63
 
64
64
  Figma: three ways to send the theme into a Figma file (a Claude chat, `loomiere figma push`, or the Loomiere plugin for designers without the repo) in [figma/README.md](figma/README.md).
65
65
 
66
+ Several looks for one brand (`data-look`, Brand modes in Figma): [tokens/README.md#looks](tokens/README.md#looks).
67
+
66
68
  How the layers work, the brand file's fields, roles, concepts and pairs: [tokens/README.md](tokens/README.md).
package/bin/loomiere.mjs CHANGED
@@ -40,6 +40,7 @@ Options
40
40
  --file <url> figma: the Figma file (or "figma": { "file" } in the config)
41
41
  --via <way> figma push: claude, api, plugin or scripts (default: the first that works)
42
42
  --since <file> figma push --via scripts: the snapshot, to send only what changed
43
+ --plain figma push --via scripts: write the data as plain JSON, not compressed
43
44
  --remove figma push: delete what the build no longer has
44
45
  --dry-run figma push: report what would change, change nothing
45
46
  --help Show this help
@@ -56,6 +57,7 @@ const { values: flags, positionals } = parseArgs({
56
57
  file: { type: 'string' },
57
58
  via: { type: 'string' },
58
59
  since: { type: 'string' },
60
+ plain: { type: 'boolean' },
59
61
  remove: { type: 'boolean' },
60
62
  'dry-run': { type: 'boolean' },
61
63
  help: { type: 'boolean', short: 'h' },
@@ -121,7 +123,7 @@ async function main() {
121
123
  const sub = rest[0] ?? 'push';
122
124
  // Push what the brand says now, the way a designer would expect.
123
125
  if (sub === 'push' && flags.via !== 'scripts' && !(await build(config))) return void (process.exitCode = 1);
124
- const ok = await figmaCommand(config, sub, { via: flags.via, file: flags.file, since: flags.since, remove: flags.remove, dryRun: flags['dry-run'], port });
126
+ const ok = await figmaCommand(config, sub, { via: flags.via, file: flags.file, since: flags.since, plain: flags.plain, remove: flags.remove, dryRun: flags['dry-run'], port });
125
127
  if (ok === false) process.exitCode = 1;
126
128
  return;
127
129
  }
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * Tokens can carry mode values in $extensions["sl.modes"] (dark, contrast-more,
11
11
  * dark-contrast-more, reduced-motion); the CSS output emits each mode as its own block.
12
- * Color vision themes (vision.* primitives) become [data-theme='<theme>'] blocks.
12
+ * Color vision themes (vision.* primitives) become [data-theme='<theme>'] blocks, and the
13
+ * brand's other looks (generator/looks.mjs) [data-look='<name>'] blocks.
13
14
  *
14
15
  * Run it through the command line (loomiere build), which reads loomiere.config.json.
15
16
  */
@@ -242,6 +243,81 @@ function pairBlocks(dictionary) {
242
243
  return `/* Brand pairs: generated from color.pairs in the brand file. */\n${blocks.join('\n\n')}`;
243
244
  }
244
245
 
246
+ /**
247
+ * Looks (generator/looks.mjs). Each look is built on its own, and what it changes becomes
248
+ * [data-look='<name>'] blocks: the variables whose value differs from the brand's own look, then
249
+ * each mode's values where they differ, so a look's dark mode is its own. Every look has the
250
+ * same tokens, so the class rules (roles, pairs, color vision) are the brand's, shared; a look
251
+ * whose rules come out different is an error.
252
+ */
253
+ let lookParts = [];
254
+
255
+ const declarationsIn = (text) =>
256
+ new Map(
257
+ text
258
+ .split('\n')
259
+ .map((line) => line.match(/^\s*(--[\w-]+):\s*(.*?);(?:\s*\/\*\*.*\*\/)?$/))
260
+ .filter(Boolean)
261
+ .map((m) => [m[1], m[2]]),
262
+ );
263
+
264
+ /** What a look's CSS is made of: its :root values, its mode values, and its class rules. */
265
+ function partsOf(dictionary, options) {
266
+ const root = declarationsIn(formattedVariables({ format: 'css', dictionary, outputReferences: options.outputReferences, usesDtcg: true }));
267
+ const modeValues = {};
268
+ for (const [mode] of modes(visionsIn(dictionary))) {
269
+ modeValues[mode] = Object.fromEntries(
270
+ dictionary.allTokens.filter((token) => modeValue(token, mode) !== undefined).map((token) => [varName(token.path), refsToVars(modeValue(token, mode))]),
271
+ );
272
+ }
273
+ return { root: Object.fromEntries(root), modes: modeValues, rules: { roles: roleBlocks(dictionary), pairs: pairBlocks(dictionary), vision: visionBlocks(dictionary) } };
274
+ }
275
+
276
+ StyleDictionary.registerFormat({
277
+ name: 'json/sl-look-parts',
278
+ format: ({ dictionary, options }) => `${JSON.stringify(partsOf(dictionary, options))}\n`,
279
+ });
280
+
281
+ /** Adds [data-look='<name>'] to each selector in a list (commas inside :not() stay). */
282
+ const inLook = (selectors, name) =>
283
+ selectors
284
+ .split(/,(?![^(]*\))/)
285
+ .map((s) => `${s.trim()}[data-look='${name}']`)
286
+ .join(', ');
287
+
288
+ /** [rootBlocks, modeBlocks] for every look against the brand's own parts. */
289
+ function lookBlocks(dictionary, base) {
290
+ const roots = [];
291
+ const modeRules = [];
292
+ for (const { name, label, parts } of lookParts) {
293
+ for (const [rule, text] of Object.entries(parts.rules)) {
294
+ // Color vision blocks list the data colors by name, so a look with its own names has its own.
295
+ if (rule === 'vision') continue;
296
+ if (text !== base.rules[rule]) throw new Error(`Look "${name}" changes the ${rule} rules, not only values. A look can change colors, fonts and sizes, but it has to keep the brand's palettes, pairs, statuses and emphasis levels.`);
297
+ }
298
+ const changed = Object.entries(parts.root).filter(([v, value]) => base.root[v] !== value);
299
+ if (changed.length) roots.push(`/* Look: ${label} (data-look='${name}'). */\n[data-look='${name}'] {\n${indent(changed.map(([v, value]) => `${v}: ${value};`).join('\n'), 2)}\n}`);
300
+ if (parts.rules.vision !== base.rules.vision) {
301
+ roots.push(parts.rules.vision.replace(/^(\[data-theme='[\w-]+'\]) \{/gm, (_, sel) => `${inLook(sel, name)} {`));
302
+ }
303
+ // A mode value the look's own root value or mode value would lose to: the brand's mode
304
+ // blocks come after the look's root block, so restate the look's value in each mode.
305
+ for (const [mode, targets] of modes(visionsIn(dictionary))) {
306
+ const lines = [];
307
+ for (const v of Object.keys(parts.root)) {
308
+ const own = parts.modes[mode][v] ?? (base.modes[mode][v] !== undefined ? parts.root[v] : undefined);
309
+ if (own !== undefined && own !== base.modes[mode][v]) lines.push(`${v}: ${own};`);
310
+ }
311
+ if (!lines.length) continue;
312
+ for (const [media, selector] of targets) {
313
+ const rule = `${inLook(selector, name)} {\n${indent(lines.join('\n'), 2)}\n}`;
314
+ modeRules.push(media ? `${media} {\n${indent(rule, 2)}\n}` : rule);
315
+ }
316
+ }
317
+ }
318
+ return [roots.join('\n\n'), modeRules.join('\n\n')];
319
+ }
320
+
245
321
  StyleDictionary.registerTransform({
246
322
  name: 'name/sl-path',
247
323
  type: 'name',
@@ -260,7 +336,9 @@ StyleDictionary.registerFormat({
260
336
  usesDtcg: true,
261
337
  });
262
338
  const root = `:root {\n${variables}\n}`;
263
- const body = [root, modeBlocks(dictionary), visionBlocks(dictionary), roleBlocks(dictionary), pairBlocks(dictionary)].filter(Boolean).join('\n\n');
339
+ // Look roots come before the mode and color vision blocks, so those still win in their themes.
340
+ const [lookRoots, lookModes] = lookParts.length ? lookBlocks(dictionary, partsOf(dictionary, options)) : ['', ''];
341
+ const body = [root, lookRoots, modeBlocks(dictionary), visionBlocks(dictionary), lookModes, roleBlocks(dictionary), pairBlocks(dictionary)].filter(Boolean).join('\n\n');
264
342
  return `${header}@layer ${prefix}.tokens {\n${indent(body, 2)}\n}\n`;
265
343
  },
266
344
  });
@@ -379,6 +457,24 @@ export function expandTextStyles(tokens) {
379
457
 
380
458
  StyleDictionary.registerPreprocessor({ name: 'sl/text-styles', preprocessor: (tokens) => expandTextStyles(tokens) });
381
459
 
460
+ /**
461
+ * `tokens` in the brand file or a look: { "button.radius": "{radius.control}" } replaces a
462
+ * semantic or component token's value, for what the brand inputs can't say. Set per build.
463
+ */
464
+ let picks = {};
465
+ StyleDictionary.registerPreprocessor({
466
+ name: 'sl/picks',
467
+ preprocessor: (tokens) => {
468
+ for (const [tokenPath, value] of Object.entries(picks)) {
469
+ const token = tokenPath.split('.').reduce((node, key) => node?.[key], tokens);
470
+ if (token?.$value === undefined) throw new Error(`tokens."${tokenPath}" in the brand file is not a token`);
471
+ token.$extensions = { ...token.$extensions, 'sl.pick': { was: token.$value } };
472
+ token.$value = value;
473
+ }
474
+ return tokens;
475
+ },
476
+ });
477
+
382
478
  /**
383
479
  * Classes made from tokens, so they can't drift from them. All go in <out>/utilities.css:
384
480
  * .text-style-<name> every text style in tokens/semantic/typography.json
@@ -457,51 +553,75 @@ function utilitiesCss() {
457
553
  return `/* Utility classes for "${brandName}". Generated by loomiere build; do not edit. */\n\n${body}\n`;
458
554
  }
459
555
 
556
+ /**
557
+ * Builds one theme. `config` comes from resolveConfig() (lib/config.mjs):
558
+ * { brandFile, outDir, generatedDir, prefix, exports }.
559
+ */
560
+ /** Style Dictionary over this package's tokens and one look's generated ones. */
561
+ const sourcesFor = (generatedDir) => [
562
+ // Layer order, so tokens.css reads foundation → primitive → semantic → component.
563
+ path.join(root, 'tokens', 'foundation', '**', '*.json'),
564
+ path.join(generatedDir, 'tokens', 'primitive', '**', '*.json'),
565
+ path.join(root, 'tokens', 'semantic', '**', '*.json'),
566
+ path.join(generatedDir, 'tokens', 'semantic', '**', '*.json'),
567
+ path.join(root, 'tokens', 'component', '**', '*.json'),
568
+ ];
569
+ const dictionary = (generatedDir, platforms) =>
570
+ new StyleDictionary({ usesDtcg: true, preprocessors: ['sl/picks', 'sl/text-styles'], log: { verbosity: 'default', warnings: 'warn' }, source: sourcesFor(generatedDir), platforms });
571
+ const CSS_TRANSFORMS = ['name/sl-path', 'fontFamily/css', 'cubicBezier/css'];
572
+
460
573
  /**
461
574
  * Builds one theme. `config` comes from resolveConfig() (lib/config.mjs):
462
575
  * { brandFile, outDir, generatedDir, prefix, exports }.
463
576
  */
464
577
  export async function buildTokens(config) {
465
578
  prefix = config.prefix;
466
- const { brand, applied, files } = generate(config.brandFile, config.generatedDir);
579
+ const { brand, applied, files, looks } = generate(config.brandFile, config.generatedDir);
467
580
  brandName = brand.name;
468
- roles = resolveRoles(brand);
469
581
  const out = config.outDir + path.sep;
470
582
 
471
- const sd = new StyleDictionary({
472
- usesDtcg: true,
473
- preprocessors: ['sl/text-styles'],
474
- log: { verbosity: 'default', warnings: 'warn' },
475
- source: [
476
- // Layer order, so tokens.css reads foundation → primitive → semantic → component.
477
- path.join(root, 'tokens', 'foundation', '**', '*.json'),
478
- path.join(config.generatedDir, 'tokens', 'primitive', '**', '*.json'),
479
- path.join(root, 'tokens', 'semantic', '**', '*.json'),
480
- path.join(config.generatedDir, 'tokens', 'semantic', '**', '*.json'),
481
- path.join(root, 'tokens', 'component', '**', '*.json'),
482
- ],
483
- platforms: {
583
+ // Every other look first: its parts for tokens.css, and its catalog for the exports.
584
+ lookParts = [];
585
+ for (const look of looks.filter((l) => !l.base)) {
586
+ roles = resolveRoles(look.brand);
587
+ picks = look.brand.tokens ?? {};
588
+ await dictionary(look.dir, {
484
589
  css: {
485
590
  prefix,
486
- transforms: ['name/sl-path', 'fontFamily/css', 'cubicBezier/css'],
487
- buildPath: out,
591
+ transforms: CSS_TRANSFORMS,
592
+ buildPath: look.dir + path.sep,
488
593
  files: [
489
- {
490
- destination: 'tokens.css',
491
- format: 'css/sl-layered',
492
- options: { outputReferences: true, showFileHeader: true },
493
- },
594
+ { destination: 'parts.json', format: 'json/sl-look-parts', options: { outputReferences: true } },
494
595
  { destination: 'tokens.json', format: 'json/sl-catalog' },
495
596
  ],
496
597
  },
497
- js: {
498
- transforms: ['name/camel', 'fontFamily/css', 'cubicBezier/css'],
499
- buildPath: out,
500
- files: [
501
- { destination: 'tokens.js', format: 'javascript/sl-values' },
502
- { destination: 'tokens.d.ts', format: 'typescript/sl-values' },
503
- ],
504
- },
598
+ }).buildAllPlatforms();
599
+ lookParts.push({ ...look, parts: JSON.parse(fs.readFileSync(path.join(look.dir, 'parts.json'), 'utf8')) });
600
+ }
601
+
602
+ roles = resolveRoles(brand);
603
+ picks = brand.tokens ?? {};
604
+ const sd = dictionary(config.generatedDir, {
605
+ css: {
606
+ prefix,
607
+ transforms: CSS_TRANSFORMS,
608
+ buildPath: out,
609
+ files: [
610
+ {
611
+ destination: 'tokens.css',
612
+ format: 'css/sl-layered',
613
+ options: { outputReferences: true, showFileHeader: true },
614
+ },
615
+ { destination: 'tokens.json', format: 'json/sl-catalog' },
616
+ ],
617
+ },
618
+ js: {
619
+ transforms: ['name/camel', 'fontFamily/css', 'cubicBezier/css'],
620
+ buildPath: out,
621
+ files: [
622
+ { destination: 'tokens.js', format: 'javascript/sl-values' },
623
+ { destination: 'tokens.d.ts', format: 'typescript/sl-values' },
624
+ ],
505
625
  },
506
626
  });
507
627
 
@@ -522,8 +642,33 @@ export async function buildTokens(config) {
522
642
  path.join(config.outDir, 'pairs.json'),
523
643
  `${JSON.stringify({ brand: brandName, levels: Object.fromEntries(LEVELS), colors: Object.fromEntries(colors.map((c) => [c.path, c.hex])), pairs: compact })}\n`,
524
644
  );
525
- // Other frameworks: DTCG JSON plus one adapter per framework (exporters/).
645
+ // The looks in tokens.json: each one's name and what it changes, by CSS variable.
526
646
  const catalog = JSON.parse(fs.readFileSync(path.join(config.outDir, 'tokens.json'), 'utf8'));
527
- const written = exportAll(catalog, resolveRoles(brand), path.join(config.outDir, 'exports'), config.exports, { themeDir: config.outDir, brand });
528
- return { brand, applied, exports: written };
647
+ const lookCatalogs = looks.map((look) => ({
648
+ ...look,
649
+ catalog: look.base ? catalog : JSON.parse(fs.readFileSync(path.join(look.dir, 'tokens.json'), 'utf8')),
650
+ roles: resolveRoles(look.brand),
651
+ }));
652
+ if (looks.length > 1) {
653
+ const own = new Map(catalog.tokens.map((t) => [t.name, t]));
654
+ catalog.looks = lookCatalogs.map(({ name, label, description, base, catalog: c }) => ({
655
+ name,
656
+ label,
657
+ ...(description && { description }),
658
+ ...(base
659
+ ? { base: true }
660
+ : {
661
+ css: `[data-look='${name}']`,
662
+ changes: Object.fromEntries(
663
+ c.tokens
664
+ .filter((t) => JSON.stringify(t.value) !== JSON.stringify(own.get(t.name)?.value) || JSON.stringify(t.modes) !== JSON.stringify(own.get(t.name)?.modes))
665
+ .map((t) => [t.name, t.modes ? { value: t.value, modes: t.modes } : t.value]),
666
+ ),
667
+ }),
668
+ }));
669
+ fs.writeFileSync(path.join(config.outDir, 'tokens.json'), `${JSON.stringify(catalog, null, 2)}\n`);
670
+ }
671
+ // Other frameworks: DTCG JSON plus one adapter per framework (exporters/).
672
+ const written = exportAll(catalog, resolveRoles(brand), path.join(config.outDir, 'exports'), config.exports, { themeDir: config.outDir, brand, looks: lookCatalogs });
673
+ return { brand, applied, looks, exports: written };
529
674
  }
@@ -318,6 +318,13 @@ function brandBook({ theme, brand, byPath, cssName, usageOf, tokens, tiers, styl
318
318
  `Themes: \`data-theme="light|dark|light-high-contrast|dark-high-contrast"\` on the root, or the system setting (including \`prefers-contrast: more\`) when none is set. Color vision themes are themes of their own, each a light theme designed from this brand: \`data-theme="deuteranopia|protanopia|tritanopia|achromatopsia"\`. Never hand-pick dark colors; the tokens already flip.`,
319
319
  '',
320
320
  );
321
+ if (theme.looks?.length > 1) {
322
+ const others = theme.looks.filter((l) => !l.base);
323
+ out.push(
324
+ `Looks: this brand can look ${theme.looks.length} ways. With no \`data-look\` it is ${theme.looks[0].label}; ${others.map((l) => `\`data-look="${l.name}"\` on the root is ${l.label}${l.description ? ` (${l.description.replace(/\.$/, '')})` : ''}`).join('; ')}. A look changes token values, never names, so design with the tokens and every look follows. The values listed here are ${theme.looks[0].label}'s; \`bundle.css\` holds every look.`,
325
+ '',
326
+ );
327
+ }
321
328
  if (componentNames.length) {
322
329
  const has = (n) => componentNames.includes(n);
323
330
  const pick = (list) => list.filter(([n]) => has(n)).map(([n, use]) => `| \`${n}\` | ${use} |`);