@scattered-light/loomiere 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/exporters/mui.mjs CHANGED
@@ -13,19 +13,19 @@ import { raw, styleKit, toSource } from './component-styles.mjs';
13
13
  const MUI_GREYS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
14
14
  /** MUI typography variant → text style. */
15
15
  const TYPE = {
16
- h1: 'maximize-4xl',
17
- h2: 'maximize-3xl',
18
- h3: 'maximize-2xl',
19
- h4: 'maximize-xl',
20
- h5: 'maximize-lg',
21
- h6: 'maximize-md',
22
- subtitle1: 'magnify',
23
- subtitle2: 'base-label',
24
- body1: 'base',
25
- body2: 'condense',
26
- caption: 'minimize',
27
- button: 'base-trigger',
28
- overline: 'condense-subtitle-label',
16
+ h1: 'title-maximize',
17
+ h2: 'title-amplify',
18
+ h3: 'title-magnify',
19
+ h4: 'title-base',
20
+ h5: 'title-condense',
21
+ h6: 'title-condense',
22
+ subtitle1: 'body-magnify',
23
+ subtitle2: 'label-base',
24
+ body1: 'body-base',
25
+ body2: 'body-condense',
26
+ caption: 'body-minimize',
27
+ button: 'trigger-base',
28
+ overline: 'eyebrow-condense',
29
29
  };
30
30
  /** MUI size → control tier. */
31
31
  const SIZE = { small: 'sm', medium: 'md', large: 'lg' };
@@ -105,7 +105,7 @@ export default {
105
105
  paddingBlock: 0,
106
106
  paddingInline: v(`control-padding-inline${tier === 'md' ? '' : `-${tier}`}`),
107
107
  },
108
- ...text(tier === 'sm' ? 'condense' : tier === 'lg' ? 'magnify' : 'base'),
108
+ ...text(tier === 'sm' ? 'body-condense' : tier === 'lg' ? 'body-magnify' : 'body-base'),
109
109
  });
110
110
 
111
111
  const components = {
@@ -183,7 +183,7 @@ export default {
183
183
  defaultProps: { shrink: true },
184
184
  styleOverrides: {
185
185
  root: {
186
- ...text('base-label'),
186
+ ...text('label-base'),
187
187
  position: 'relative',
188
188
  transform: 'none',
189
189
  maxWidth: 'none',
@@ -199,7 +199,7 @@ export default {
199
199
  MuiFormLabel: {
200
200
  styleOverrides: {
201
201
  root: {
202
- ...text('base-label'),
202
+ ...text('label-base'),
203
203
  color: v('text-primary'),
204
204
  '&.Mui-focused, &.Mui-error': { color: v('text-primary') },
205
205
  '&.Mui-disabled': { color: v('text-disabled') },
@@ -209,16 +209,16 @@ export default {
209
209
  MuiFormHelperText: {
210
210
  styleOverrides: {
211
211
  root: {
212
- ...text('condense'),
212
+ ...text('body-condense'),
213
213
  margin: `calc(${v('field-gap')} + ${v('field-message-spacing')}) 0 0`,
214
214
  color: v('text-secondary'),
215
- '&.Mui-error': { ...text('base-label'), color: v('status-error') },
215
+ '&.Mui-error': { ...text('label-base'), color: v('status-error') },
216
216
  },
217
217
  },
218
218
  },
219
219
  MuiInputBase: {
220
220
  styleOverrides: {
221
- root: { ...text('base'), color: v('field-color') },
221
+ root: { ...text('body-base'), color: v('field-color') },
222
222
  input: { '&::placeholder': k.fieldStates.placeholder, '&.Mui-disabled': { WebkitTextFillColor: v('field-color-disabled') } },
223
223
  },
224
224
  },
@@ -280,7 +280,7 @@ export default {
280
280
  },
281
281
  },
282
282
  },
283
- MuiListSubheader: { styleOverrides: { root: { ...k.groupLabel, margin: 0, lineHeight: v('font-condense-subtitle-label-line-height') } } },
283
+ MuiListSubheader: { styleOverrides: { root: { ...k.groupLabel, margin: 0, lineHeight: v('font-eyebrow-condense-line-height') } } },
284
284
  MuiDivider: {
285
285
  styleOverrides: {
286
286
  root: {
@@ -300,8 +300,8 @@ export default {
300
300
  '&[aria-selected="true"], &[aria-selected="true"].Mui-focused': k.itemStates.selected,
301
301
  '&[aria-disabled="true"]': k.itemStates.disabled,
302
302
  },
303
- groupLabel: { ...k.groupLabel, lineHeight: v('font-condense-subtitle-label-line-height') },
304
- noOptions: { ...text('condense'), color: v('text-tertiary'), textAlign: 'center', paddingBlock: v('popup-empty-padding-block') },
303
+ groupLabel: { ...k.groupLabel, lineHeight: v('font-eyebrow-condense-line-height') },
304
+ noOptions: { ...text('body-condense'), color: v('text-tertiary'), textAlign: 'center', paddingBlock: v('popup-empty-padding-block') },
305
305
  clearIndicator: { ...k.iconButton, '&:hover': k.iconButtonHover },
306
306
  popupIndicator: { color: v('text-tertiary') },
307
307
  tag: { margin: v('space-0-5') },
@@ -318,9 +318,9 @@ export default {
318
318
  color: v('text-primary'),
319
319
  '&.Mui-disabled': { cursor: 'not-allowed' },
320
320
  // A switch's label uses the label style, as SwitchLabel does.
321
- '&:has(.MuiSwitch-root) .MuiFormControlLabel-label': text('base-label'),
321
+ '&:has(.MuiSwitch-root) .MuiFormControlLabel-label': text('label-base'),
322
322
  },
323
- label: { ...text('base'), '&.Mui-disabled': { color: v('text-disabled') } },
323
+ label: { ...text('body-base'), '&.Mui-disabled': { color: v('text-disabled') } },
324
324
  // A switch's label is the label style, as SwitchLabel is.
325
325
 
326
326
  },
@@ -416,7 +416,7 @@ export default {
416
416
  border: `${v('check-border-width')} solid ${v('check-border-color')}`,
417
417
  transition: `background-color ${v('duration-normal')} ${v('easing-in-out')}`,
418
418
  '&::before': {
419
- ...text('condense-subtitle-label'),
419
+ ...text('eyebrow-condense'),
420
420
  content: '"off"',
421
421
  position: 'absolute',
422
422
  insetBlock: 0,
@@ -464,7 +464,7 @@ export default {
464
464
  },
465
465
  mark: { width: v('stroke-emphasis'), height: `calc(${v('gauge-height-sm')} * 2)`, background: v('fill-strong'), borderRadius: v('radius-pill') },
466
466
  markActive: { background: v('fill-strong'), opacity: 1 },
467
- valueLabel: { ...text('condense'), background: v('surface-inverse'), color: v('text-on-inverse-surface'), borderRadius: v('radius-chrome') },
467
+ valueLabel: { ...text('body-condense'), background: v('surface-inverse'), color: v('text-on-inverse-surface'), borderRadius: v('radius-chrome') },
468
468
  },
469
469
  },
470
470
 
@@ -478,7 +478,7 @@ export default {
478
478
  MuiTab: {
479
479
  styleOverrides: {
480
480
  root: {
481
- ...text('base'),
481
+ ...text('body-base'),
482
482
  textTransform: 'none',
483
483
  minHeight: 0,
484
484
  minWidth: 0,
@@ -487,7 +487,7 @@ export default {
487
487
  opacity: 1,
488
488
  transition: v('transition-button'),
489
489
  '&:hover:not(.Mui-disabled)': { color: v('text-primary'), background: v('disclosure-trigger-background-hover') },
490
- '&.Mui-selected': { ...text('base-selected'), color: v('disclosure-selected-color') },
490
+ '&.Mui-selected': { ...text('selected-base'), color: v('disclosure-selected-color') },
491
491
  '&.Mui-focusVisible': k.focusRingInset,
492
492
  '&.Mui-disabled': { color: v('text-disabled'), opacity: v('disclosure-opacity-disabled') },
493
493
  },
@@ -509,7 +509,7 @@ export default {
509
509
  MuiAccordionSummary: {
510
510
  styleOverrides: {
511
511
  root: {
512
- ...text('maximize-md'),
512
+ ...text('title-base'),
513
513
  gap: v('space-4'),
514
514
  minHeight: 0,
515
515
  padding: `${v('space-4')} ${v('space-6')}`,
@@ -523,13 +523,13 @@ export default {
523
523
  expandIconWrapper: { color: v('disclosure-icon-color') },
524
524
  },
525
525
  },
526
- MuiAccordionDetails: { styleOverrides: { root: { ...text('base'), padding: `0 ${v('space-6')} ${v('space-4')}`, color: v('disclosure-content-color') } } },
526
+ MuiAccordionDetails: { styleOverrides: { root: { ...text('body-base'), padding: `0 ${v('space-6')} ${v('space-4')}`, color: v('disclosure-content-color') } } },
527
527
 
528
528
  // Overlays.
529
529
  MuiTooltip: {
530
530
  styleOverrides: {
531
531
  tooltip: {
532
- ...text('condense'),
532
+ ...text('body-condense'),
533
533
  maxWidth: v('tooltip-max-width'),
534
534
  padding: `${v('space-1-5')} ${v('space-2-5')}`,
535
535
  borderRadius: v('radius-chrome'),
@@ -546,9 +546,9 @@ export default {
546
546
  paper: { ...k.modalSurface, width: v('modal-width'), maxWidth: v('modal-max-width'), maxHeight: v('modal-max-height'), margin: v('space-4') },
547
547
  },
548
548
  },
549
- MuiDialogTitle: { styleOverrides: { root: { ...text('maximize-xl'), padding: 0, color: v('modal-title-color') } } },
549
+ MuiDialogTitle: { styleOverrides: { root: { ...text('title-amplify'), padding: 0, color: v('modal-title-color') } } },
550
550
  MuiDialogContent: { styleOverrides: { root: { padding: 0, '.MuiDialogTitle-root + &': { paddingTop: 0 } } } },
551
- MuiDialogContentText: { styleOverrides: { root: { ...text('base'), color: v('modal-description-color') } } },
551
+ MuiDialogContentText: { styleOverrides: { root: { ...text('body-base'), color: v('modal-description-color') } } },
552
552
  MuiDialogActions: {
553
553
  styleOverrides: { root: { padding: 0, gap: v('modal-actions-gap'), marginTop: v('modal-actions-spacing'), '& > :not(style) ~ :not(style)': { marginLeft: 0 } } },
554
554
  },
@@ -557,11 +557,11 @@ export default {
557
557
  },
558
558
 
559
559
  // Feedback.
560
- MuiSnackbarContent: { styleOverrides: { root: { ...k.popupSurface, ...text('base'), padding: v('notice-padding') } } },
560
+ MuiSnackbarContent: { styleOverrides: { root: { ...k.popupSurface, ...text('body-base'), padding: v('notice-padding') } } },
561
561
  MuiAlert: {
562
562
  styleOverrides: {
563
563
  root: {
564
- ...text('base'),
564
+ ...text('body-base'),
565
565
  padding: v('notice-padding'),
566
566
  gap: v('notice-gap'),
567
567
  borderRadius: v('popup-radius'),
@@ -577,7 +577,7 @@ export default {
577
577
  },
578
578
  },
579
579
  },
580
- MuiAlertTitle: { styleOverrides: { root: { ...text('maximize-md'), margin: `0 0 ${v('notice-text-gap')}` } } },
580
+ MuiAlertTitle: { styleOverrides: { root: { ...text('title-base'), margin: `0 0 ${v('notice-text-gap')}` } } },
581
581
  MuiLinearProgress: {
582
582
  styleOverrides: {
583
583
  root: {
@@ -599,7 +599,7 @@ export default {
599
599
  MuiAvatar: {
600
600
  styleOverrides: {
601
601
  root: {
602
- ...text('maximize-md'),
602
+ ...text('title-base'),
603
603
  width: v('avatar-size-md'),
604
604
  height: v('avatar-size-md'),
605
605
  boxSizing: 'border-box',
@@ -613,8 +613,8 @@ export default {
613
613
  MuiAvatarGroup: { styleOverrides: { avatar: { border: `${v('stroke-emphasis')} solid ${v('surface-canvas')}`, boxSizing: 'border-box' } } },
614
614
  MuiTableCell: {
615
615
  styleOverrides: {
616
- root: { ...text('condense'), color: v('table-color'), borderBottom: `${v('stroke-default')} solid ${v('table-row-border-color')}`, paddingInline: v('table-cell-padding-inline') },
617
- head: { ...text('base-label'), background: v('table-header-background'), color: v('table-header-color') },
616
+ root: { ...text('body-condense'), color: v('table-color'), borderBottom: `${v('stroke-default')} solid ${v('table-row-border-color')}`, paddingInline: v('table-cell-padding-inline') },
617
+ head: { ...text('label-base'), background: v('table-header-background'), color: v('table-header-color') },
618
618
  },
619
619
  },
620
620
  MuiTableRow: { styleOverrides: { root: { '&.MuiTableRow-hover:hover': { background: v('table-row-hover') }, '&.Mui-selected, &.Mui-selected:hover': { background: v('table-row-selected') } } } },
@@ -628,7 +628,7 @@ export default {
628
628
  typography: {
629
629
  fontFamily: theme.fonts.body,
630
630
  ...Object.fromEntries(Object.entries(TYPE).map(([variant, style]) => [variant, text(style)])),
631
- button: { ...text('base-trigger'), textTransform: 'none' },
631
+ button: { ...text('trigger-base'), textTransform: 'none' },
632
632
  },
633
633
  components,
634
634
  };
@@ -7,6 +7,7 @@
7
7
  * It replaces everything after the imports in shadcn's globals.css. Only colors,
8
8
  * the radius, fonts and text sizes follow the brand; shadcn's own spacing and shapes stay.
9
9
  */
10
+ import { darkVariant } from './tailwind.mjs';
10
11
 
11
12
  /** shadcn variable → Loomiere token path. */
12
13
  const VARIABLES = {
@@ -48,7 +49,7 @@ const VARIABLES = {
48
49
  };
49
50
 
50
51
  /** shadcn's text sizes → the text tier whose size they take. */
51
- const SIZES = { xs: 'minimize', sm: 'condense', base: 'base', lg: 'magnify' };
52
+ const SIZES = { xs: 'body-minimize', sm: 'body-condense', base: 'body-base', lg: 'body-magnify' };
52
53
 
53
54
  export default {
54
55
  id: 'shadcn',
@@ -72,8 +73,8 @@ export default {
72
73
  @import "<theme>/exports/shadcn.css";
73
74
  Replaces everything after the imports in shadcn's globals.css. Every variable
74
75
  points at a --${theme.prefix}- concept, so dark mode, contrast modes and color vision themes come
75
- along. Dark mode is data-theme="dark" on <html> (next-themes: attribute="data-theme"), or the
76
- system setting. With the Loomiere Tailwind theme as well, import this after it: text-xs to
76
+ along. Dark mode is data-theme="dark" or "dark-high-contrast" on <html> (next-themes:
77
+ attribute="data-theme"), or the system setting. With the Loomiere Tailwind theme as well, import this after it: text-xs to
77
78
  text-lg come back for shadcn's components, sized by the text tiers. */
78
79
  @import "../tokens.css";
79
80
 
@@ -81,16 +82,7 @@ export default {
81
82
  ${root.join('\n')}
82
83
  }
83
84
 
84
- @custom-variant dark {
85
- &:where([data-theme='dark'], [data-theme='dark'] *) {
86
- @slot;
87
- }
88
- @media (prefers-color-scheme: dark) {
89
- &:where(:root:not([data-theme='light']), :root:not([data-theme='light']) *) {
90
- @slot;
91
- }
92
- }
93
- }
85
+ ${darkVariant(theme)}
94
86
 
95
87
  @theme inline {
96
88
  ${[...colors, ...fonts, ...sizes].join('\n')}
@@ -60,7 +60,7 @@ export default {
60
60
  id: 'tailwind',
61
61
  name: 'Tailwind CSS v4',
62
62
  file: 'tailwind.css',
63
- for: 'Tailwind v4 projects: import after tailwindcss (it brings tokens.css along). Utilities like bg-accent-solid, text-fg, bg-emphasis-bg, and text styles by tier: text-base, text-magnify-trigger.',
63
+ for: 'Tailwind v4 projects: import after tailwindcss (it brings tokens.css along). Utilities like bg-accent-solid, text-fg, bg-emphasis-bg, and text styles by tier: text-body-base, text-trigger-magnify.',
64
64
  build(theme) {
65
65
  const name = new Map(theme.tokens.map((t) => [t.path, t.name]));
66
66
  const line = (twVar, path) => (name.has(path) ? ` ${twVar}: var(${name.get(path)});` : null);
@@ -102,7 +102,7 @@ export default {
102
102
  for (const g of ['popup', 'modal']) lines.push(line(`--shadow-${g}`, `${g}.shadow`));
103
103
  // Media queries can't read custom properties, so breakpoints are written as values.
104
104
  const breakpoints = theme.tokens.filter((t) => /^breakpoint\.[\w-]+$/.test(t.path)).map((t) => ` --breakpoint-${t.path.slice(11)}: ${t.value};`);
105
- // Each text style is one utility named by its tier (text-base, text-magnify-trigger), so a
105
+ // Each text style is one utility named by its tier (text-body-base, text-trigger-magnify), so a
106
106
  // class sets family, size, weight, line height and letter spacing together.
107
107
  const styles = new Map();
108
108
  for (const t of theme.tokens) {
@@ -113,9 +113,9 @@ export default {
113
113
  // z-index layers by name (z-modal, z-toast); Tailwind's z-10 and friends still work.
114
114
  const z = theme.tokens.filter((t) => /^z-index\.[\w-]+$/.test(t.path)).map((t) => `@utility z-${t.path.slice(8)} {\n z-index: var(${t.name});\n}`);
115
115
  const durations = theme.tokens.filter((t) => /^duration\.[\w-]+$/.test(t.path)).map((t) => `@utility duration-${t.path.slice(9)} {\n --tw-duration: var(${t.name});\n transition-duration: var(${t.name});\n}`);
116
- // dark: follows Loomiere's mode: data-theme="dark" on <html>, or the system setting
117
- // unless data-theme="light" says otherwise.
118
- const dark = `@custom-variant dark {\n &:where([data-theme='dark'], [data-theme='dark'] *) {\n @slot;\n }\n @media (prefers-color-scheme: dark) {\n &:where(:root:not([data-theme='light']), :root:not([data-theme='light']) *) {\n @slot;\n }\n }\n}`;
116
+ // dark: follows Loomiere's theme: data-theme="dark" or "dark-high-contrast" on <html>, or
117
+ // the system setting unless data-theme names a light theme (color vision themes are light).
118
+ const dark = darkVariant(theme);
119
119
  // Brand pairs and color scopes as utilities (pair-stone-soft, scope-error), the same
120
120
  // declarations as .${theme.prefix}-pair-<name> and [data-color='<name>'] in tokens.css, so
121
121
  // variants work on them: md:pair-stone-soft, hover:scope-error.
@@ -137,3 +137,12 @@ export default {
137
137
  @theme inline {\n${lines.filter(Boolean).join('\n')}\n}\n\n@theme {\n --breakpoint-*: initial;\n${breakpoints.join('\n')}\n}\n\n${[dark, ...utilities, ...z, ...durations, ...pairs, ...scopeUtilities].filter(Boolean).join('\n\n')}\n`;
138
138
  },
139
139
  };
140
+
141
+ /** Tailwind's dark variant on Loomiere's themes; shadcn.mjs uses it too. */
142
+ export function darkVariant(theme) {
143
+ const visions = [...new Set(theme.tokens.filter((t) => t.path.startsWith('vision.')).map((t) => t.path.split('.')[1]))];
144
+ const list = (names) => names.map((n) => `[data-theme='${n}']`).join(', ');
145
+ const dark = list(['dark', 'dark-high-contrast']);
146
+ const notLight = `:root:not(${list(['light', 'light-high-contrast', ...visions])})`;
147
+ return `@custom-variant dark {\n &:where(${dark}, :is(${dark}) *) {\n @slot;\n }\n @media (prefers-color-scheme: dark) {\n &:where(${notLight}, ${notLight} *) {\n @slot;\n }\n }\n}`;
148
+ }
@@ -0,0 +1,135 @@
1
+ # Figma
2
+
3
+ Loomiere sends a built theme into a Figma file as variables, text styles and effect styles. The repo stays the
4
+ source of truth: Figma gets a copy that updates in place, so layers bound to a variable or text
5
+ style stay bound when the theme changes.
6
+
7
+ There are three ways to send it. All three run the same engine (`engine.js`), so the file ends
8
+ up the same whichever you use.
9
+
10
+ | Who | Way | What it needs |
11
+ | --------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------- |
12
+ | You, in a Claude chat about the project | Say "sync Figma" | Claude with the Figma connector |
13
+ | You, in a terminal | `npx loomiere figma push` | Claude Code with a Figma connector, or an Enterprise `FIGMA_TOKEN`, or the plugin |
14
+ | A designer without the repo | The Loomiere plugin: import `figma.json` or pull it from a URL | Figma desktop |
15
+
16
+ Nothing watches for changes. A push happens when someone asks for one.
17
+
18
+ ## What goes into Figma
19
+
20
+ `loomiere build` writes `exports/figma.json`. It holds the variables in six collections and
21
+ one text style per text style token.
22
+
23
+ Hidden collections hold the raw values, with a group for each variant. Published collections are
24
+ what designers pick from and switch. Figma picks each collection's mode on its own, and one
25
+ collection's mode can't choose another's, so a variant that a single switch has to change is a
26
+ group in a hidden collection, and the published collection's modes point at the right group.
27
+
28
+ | Collection | Modes | Holds |
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. |
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
+ | 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
+ | 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. |
34
+ | Status | Default, Info, Success, Warning, Error, Neutral | What each emphasis level is in each status (`bold/bg`, `subtle/fg`…): a brand pair the brand file picked (`roles.status`), or the level's style in the status's palette. As `data-status` in CSS; Neutral is the chrome palette, as `data-color="neutral"` (`palette="neutral"`). No scopes; Emphasis reads it. |
35
+ | Emphasis | Bold, Standard, Subtle, Quiet | The emphasis parts components paint with (`emphasis/bg`, `emphasis/fg`…), as `data-emphasis`, each pointing at its level in Status. |
36
+
37
+ So one frame can be Dark, Warning and Subtle at once, the same as the CSS, and an emphasized
38
+ component follows the theme set on the page around it.
39
+
40
+ Files synced before these names are renamed in place on the next sync (Scheme, Theme for the
41
+ pairs, Color, Pairs, Size base), and Brand's colors move into their theme groups, so layers bound
42
+ to them stay bound. Color Pair and Color Role are retired: a sync with "remove" deletes them.
43
+
44
+ Every variable has its CSS variable as code syntax (`var(--sl-text-primary)`), the token's
45
+ description, and scopes that keep it to the right pickers (text colors only show for text, radii
46
+ only for corners). Text styles follow the Size and Brand variables for their size and family, and
47
+ their description names the CSS class that matches.
48
+
49
+ Effect styles hold what a variable can't: elevation/xs to elevation/2xl from the shadow tokens,
50
+ focus/ring (gap, ring and halo), and focus/field and focus/field-error for fields. Shadow colors that are
51
+ Theme tokens stay bound, so the focus styles follow light, dark and the other themes.
52
+
53
+ ### Plans
54
+
55
+ Modes per collection depend on the Figma plan: Professional allows 10, Organization 20. The sync
56
+ checks the file's limit each time.
57
+
58
+ - **Professional and up** get the layout above. If a collection has more modes than the plan
59
+ allows, the extra modes are left out and the sync says which.
60
+ - **Starter** allows one mode per collection, so it gets a simpler layout instead: Brand, Color Pair,
61
+ Theme and "Theme dark", with sizes at their desktop value and no color vision modes. Moving
62
+ the file to a paid plan and syncing again switches it to the full layout; sync with "remove"
63
+ to delete the old collections.
64
+
65
+ ## 1. In a Claude chat
66
+
67
+ Ask Claude to sync Figma. Claude runs the same steps as `--via scripts` below with the Figma
68
+ connector:
69
+
70
+ 1. `loomiere build`, then `loomiere figma push --via scripts`, which writes a snapshot script.
71
+ 2. Runs the snapshot in the Figma file. It returns the plan's mode limit and a short hash for
72
+ each group of variables (`Brand:default/color/primary`, `Theme:text`…).
73
+ 3. `loomiere figma push --via scripts --since <snapshot>` writes numbered scripts holding only
74
+ the groups that differ, plus a last one with the text and effect styles and anything to remove.
75
+ 4. Runs them in order and reports what changed.
76
+
77
+ After the first push, a change to one palette sends one or two groups, not the whole theme.
78
+
79
+ ## 2. In a terminal: `loomiere figma push`
80
+
81
+ ```bash
82
+ npx loomiere figma push # build, then push by the first way that works here
83
+ npx loomiere figma push --dry-run # report what would change
84
+ npx loomiere figma push --remove # also delete what the build no longer has
85
+ ```
86
+
87
+ Say which file in `loomiere.config.json`:
88
+
89
+ ```json
90
+ { "figma": { "file": "https://www.figma.com/design/<key>/<name>" } }
91
+ ```
92
+
93
+ or with `--file <url>`. Then it picks a way, or you pick with `--via`:
94
+
95
+ | `--via` | When it's picked | How it works |
96
+ | --------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
97
+ | `api` | `FIGMA_TOKEN` is set | Writes variables through Figma's REST API. Only Enterprise plans can write variables this way, and the API can't write text or effect styles, so those still need one of the other ways. The token needs the `file_variables:write` scope. |
98
+ | `claude` | Claude Code is installed | Hands the push to Claude Code (`claude -p`), which runs the steps from the chat way with its Figma connector. Claude Code needs a Figma connector (`claude mcp add` or the claude.ai Figma connector). |
99
+ | `plugin` | Neither of the above | Serves the build at `http://localhost:4322/figma.json` and asks you to click **Pull from project** in the plugin. |
100
+ | `scripts` | Only when asked | Writes the push scripts to `<out>/figma-push/` and stops. What the chat way and `--via claude` run. |
101
+
102
+ `npx loomiere figma serve` serves the build for the plugin without pushing. `loomiere studio`
103
+ serves it too, at the same address, so a designer next to you can pull while you edit.
104
+
105
+ ## 3. The Loomiere plugin
106
+
107
+ For designers who don't run the repo. Until it's published to the Figma Community:
108
+
109
+ 1. Get this package (`npm pack @scattered-light/loomiere`, or a clone of the repo).
110
+ 2. In the Figma desktop app: **Plugins › Development › Import plugin from manifest…** and pick
111
+ `figma/plugin/manifest.json`.
112
+ 3. Run **Plugins › Development › Loomiere**.
113
+
114
+ Then bring a theme in one of three ways:
115
+
116
+ - **Theme file:** drop or choose a `figma.json`. Whoever builds the theme can send it over; it's
117
+ in `exports/` of every build.
118
+ - **Pull from project:** reads `http://localhost:4322/figma.json` from `loomiere studio` or
119
+ `loomiere figma serve` on the same computer.
120
+ - **Pull from URL:** a published theme package, for example
121
+ `https://unpkg.com/@scattered-light/loomiere-mojave/dist/exports/figma.json`.
122
+
123
+ The plugin checks the file first and lists what it will create, change and remove. Nothing
124
+ changes until you click **Apply**. Leave "Also delete" off to keep anything the theme no longer
125
+ has.
126
+
127
+ ## Files
128
+
129
+ | File | What it is |
130
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
+ | `../exporters/figma.mjs` | Writes `figma.json` from the token catalog. |
132
+ | `engine.js` | The sync engine: plain JavaScript for Figma's plugin API, with no imports, so the plugin, the push scripts and Node all run the same text. |
133
+ | `../lib/figma.mjs` | `loomiere figma`: the push scripts, the four ways, `serve`. |
134
+ | `plugin/` | The plugin. `code.js` is generated from `engine.js` and `plugin-main.js` by `npm run build -w @scattered-light/loomiere`. |
135
+ | `../../../scripts/check-figma.mjs` | `npm run check:figma`: runs the engine against a stand-in for Figma's API in both layouts, whole and in parts, and checks a second run changes nothing. |
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Writes plugin/code.js: the sync engine plus the plugin's own few lines, as the one file a
3
+ * Figma plugin runs. `node figma/build-plugin.mjs --check` fails if code.js is out of date.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const here = path.dirname(fileURLToPath(import.meta.url));
10
+ const out = path.join(here, 'plugin', 'code.js');
11
+ const code = [
12
+ '// Generated by figma/build-plugin.mjs from engine.js and plugin-main.js. Do not edit.',
13
+ fs.readFileSync(path.join(here, 'engine.js'), 'utf8'),
14
+ fs.readFileSync(path.join(here, 'plugin-main.js'), 'utf8'),
15
+ ].join('\n');
16
+
17
+ if (process.argv.includes('--check')) {
18
+ if (!fs.existsSync(out) || fs.readFileSync(out, 'utf8') !== code) {
19
+ console.error('figma/plugin/code.js is out of date: run npm run build -w @scattered-light/loomiere');
20
+ process.exitCode = 1;
21
+ }
22
+ } else {
23
+ fs.writeFileSync(out, code);
24
+ console.log(`Wrote ${path.relative(process.cwd(), out)}.`);
25
+ }