@scattered-light/loomiere 0.5.3 → 0.7.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 (39) hide show
  1. package/build/build-tokens.mjs +33 -6
  2. package/exporters/chakra.mjs +74 -0
  3. package/exporters/component-styles.mjs +64 -0
  4. package/exporters/design-system.mjs +23 -7
  5. package/exporters/mui.mjs +60 -0
  6. package/figma/library/pages/18-custom/01-custom.js +57 -0
  7. package/figma/library/pages/18-custom/02-labels.js +7 -0
  8. package/figma/library/pages/18-custom/03-doc.js +28 -0
  9. package/figma/library/pages/18-custom/04-date-picker.js +327 -0
  10. package/figma/library/pages/18-custom/05-date-picker-labels.js +9 -0
  11. package/figma/library/pages/18-custom/06-date-picker-doc.js +25 -0
  12. package/figma/library/pages/18-custom/07-money-field.js +107 -0
  13. package/figma/library/pages/18-custom/08-money-field-labels.js +8 -0
  14. package/figma/library/pages/18-custom/09-money-field-doc.js +101 -0
  15. package/figma/library/pages/18-custom/10-splitter.js +100 -0
  16. package/figma/library/pages/18-custom/11-splitter-labels.js +8 -0
  17. package/figma/library/pages/18-custom/12-splitter-doc.js +92 -0
  18. package/figma/library/pages/18-custom/13-empty-state.js +114 -0
  19. package/figma/library/pages/18-custom/14-empty-state-labels.js +8 -0
  20. package/figma/library/pages/18-custom/15-empty-state-doc.js +58 -0
  21. package/figma/library/pages/18-custom/16-collection.js +255 -0
  22. package/figma/library/pages/18-custom/17-collection-doc.js +43 -0
  23. package/figma/library/run-order.json +1 -0
  24. package/lib/contrast.mjs +17 -0
  25. package/package.json +1 -1
  26. package/tokens/README.md +10 -3
  27. package/tokens/component/badge.json +53 -0
  28. package/tokens/component/collection.json +49 -0
  29. package/tokens/component/list-detail.json +29 -0
  30. package/tokens/component/money-field.json +9 -0
  31. package/tokens/component/splitter.json +25 -0
  32. package/tokens/semantic/accessibility.json +4 -0
  33. package/tokens/semantic/calendar.json +75 -0
  34. package/tokens/semantic/empty.json +79 -0
  35. package/tokens/semantic/field.json +4 -0
  36. package/tokens/semantic/popup.json +2 -2
  37. package/tokens/semantic/table.json +8 -0
  38. package/tools/brand-builder/builder.js +45 -0
  39. package/tools/brand-builder/index.html +6 -0
@@ -121,12 +121,30 @@ function visionBlocks(dictionary) {
121
121
  // Each component that paints with emphasis starts at a level of its own; data-emphasis on it, or
122
122
  // on a group around a toggle, picks another. Button's data-variant names are aliases for levels,
123
123
  // so the brand's roles drive every button; a button with neither is bold.
124
+ //
125
+ // Pinned entries keep that level's own color. A parent data-color does not recolor them; the
126
+ // element has to set data-color itself. followsScope entries inherit a parent data-color, so a
127
+ // section can tint them. Badge stays pinned (chrome subtle). EmptyState media follows the scope.
124
128
  const DEFAULT_LEVELS = {
125
- bold: [".Button[data-variant='solid']:not([data-emphasis])", '.Button:not([data-variant]):not([data-emphasis])', '.AvatarRoot:not([data-emphasis])'],
126
- standard: [".Button[data-variant='outline']:not([data-emphasis])", '.Toggle:not([data-emphasis], [data-emphasis] *)'],
127
- quiet: [".Button[data-variant='ghost']:not([data-emphasis])"],
129
+ bold: [
130
+ { selector: ".Button[data-variant='solid']:not([data-emphasis])" },
131
+ { selector: '.Button:not([data-variant]):not([data-emphasis])' },
132
+ { selector: '.AvatarRoot:not([data-emphasis])' },
133
+ { selector: '.CalendarDay[data-selected]:not([data-emphasis])' },
134
+ ],
135
+ standard: [
136
+ { selector: ".Button[data-variant='outline']:not([data-emphasis])" },
137
+ { selector: '.Toggle:not([data-emphasis], [data-emphasis] *)' },
138
+ ],
139
+ subtle: [
140
+ { selector: '.Badge:not([data-emphasis])' },
141
+ { selector: '.EmptyStateMedia:not([data-emphasis])', followsScope: true },
142
+ ],
143
+ quiet: [{ selector: ".Button[data-variant='ghost']:not([data-emphasis])" }],
128
144
  };
129
145
 
146
+ const levelSelectors = (level) => [`[data-emphasis='${level}']`, ...(DEFAULT_LEVELS[level] ?? []).map((entry) => entry.selector)];
147
+
130
148
  // A toast's type (toastManager.add({ type: 'success' })) is its status, so success, warning,
131
149
  // error and info toasts tint themselves. data-color or data-status wins when one is set.
132
150
  const TOAST_TYPES = ['success', 'warning', 'error', 'info'];
@@ -167,7 +185,7 @@ function roleBlocks(dictionary) {
167
185
 
168
186
  // Every level's style, read from whatever scope is in effect: a status's palette, data-color's, or the accent.
169
187
  for (const [level, parts] of statuses.get('default') ?? []) {
170
- const selectors = [`[data-emphasis='${level}']`, ...(DEFAULT_LEVELS[level] ?? [])];
188
+ const selectors = levelSelectors(level);
171
189
  const style = STYLES[roles.emphasis[level].style];
172
190
  blocks.push(rule(selectors, paint(parts.map(([part]) => [part, style[part] === 'transparent' ? 'transparent' : `{scope.any.${style[part]}}`]))));
173
191
  // Default status: the pair the brand picked, or the level's own color. Accent levels follow
@@ -178,14 +196,23 @@ function roleBlocks(dictionary) {
178
196
  continue;
179
197
  }
180
198
  const color = parts.map(([, v]) => v.match(/^\{scope\.([^.]+)\./)?.[1]).find(Boolean);
181
- if (color && color !== 'accent') blocks.push(rule(selectors.map((sel) => `${sel}:not([data-color], ${IN_STATUS})`), point(color)));
199
+ if (color && color !== 'accent') {
200
+ blocks.push(rule(selectors.map((sel) => {
201
+ // The pin sits on the element, so a parent data-color would not reach it.
202
+ // followsScope also skips the pin when an ancestor has data-color, so the
203
+ // section's scope recolors that element. Badge stays pinned.
204
+ const follows = (DEFAULT_LEVELS[level] ?? []).some((entry) => entry.followsScope && entry.selector === sel);
205
+ const except = follows ? `[data-color], [data-color] *, ${IN_STATUS}` : `[data-color], ${IN_STATUS}`;
206
+ return `${sel}:not(${except})`;
207
+ }), point(color)));
208
+ }
182
209
  }
183
210
  // Other statuses: the scope already holds the status's palette; only a picked pair needs its own rule.
184
211
  for (const [name, levels] of statuses) {
185
212
  if (name === 'default') continue;
186
213
  for (const [level, parts] of levels) {
187
214
  if (!pairOf(parts)) continue;
188
- const selectors = `:is(${[`[data-emphasis='${level}']`, ...(DEFAULT_LEVELS[level] ?? [])].join(', ')})`;
215
+ const selectors = `:is(${levelSelectors(level).join(', ')})`;
189
216
  blocks.push(rule(statusSelectors(name).flatMap((s) => [`${s} ${selectors}`, `${s}${selectors}`]), paint(parts)));
190
217
  }
191
218
  }
@@ -14,6 +14,9 @@ import { styleKit, toSource } from './component-styles.mjs';
14
14
  const TIER = { '2xs': 'xs', xs: 'xs', sm: 'sm', md: 'md', lg: 'lg', xl: 'lg', '2xl': 'lg' };
15
15
  /** Button variant → emphasis level, as data-variant does for the Base UI Button. */
16
16
  const VARIANT_LEVEL = { solid: 'bold', outline: 'standard', subtle: 'subtle', surface: 'subtle', ghost: 'quiet', plain: 'quiet' };
17
+ /** Chakra badge variants. Filled-looking ones are subtle, the Badge default; there is no hover. */
18
+ const BADGE_LEVEL = { solid: 'bold', subtle: 'subtle', surface: 'subtle', outline: 'standard', plain: 'quiet' };
19
+ const BADGE_SIZE = { xs: 'minimize', sm: 'condense', md: 'base', lg: 'base' };
17
20
  /** Chakra's own color-palette keys → scope slots, so Chakra components we leave alone still fit. */
18
21
  const CHAKRA_SLOTS = { contrast: 'on-solid', subtle: 'soft', muted: 'soft-hover', emphasized: 'soft-active', focusRing: 'focus' };
19
22
  const STATUS = ['info', 'warning', 'success', 'error'];
@@ -163,6 +166,36 @@ export default {
163
166
 
164
167
  const statusPalette = (s) => (scopes[s] ? s : 'chrome');
165
168
 
169
+ // Colors stay on colorPalette so colorPalette= recolors a badge. Sizes and type come from badge().
170
+ const chakraBadge = (level, tier = 'condense') => {
171
+ const b = k.badge(level, undefined, tier);
172
+ const { style, color } = roles.emphasis[level];
173
+ const slots = STYLES[style];
174
+ const slot = (part) => (slots[part] === 'transparent' ? 'transparent' : `colorPalette.${slots[part]}`);
175
+ return {
176
+ colorPalette: color,
177
+ display: 'inline-flex',
178
+ alignItems: 'center',
179
+ whiteSpace: 'nowrap',
180
+ bg: slot('bg'),
181
+ color: slot('fg'),
182
+ borderColor: slot('border'),
183
+ borderWidth: b.borderWidth,
184
+ borderStyle: 'solid',
185
+ borderRadius: b.borderRadius,
186
+ height: b.height,
187
+ minH: b.height,
188
+ minW: b.minWidth,
189
+ px: b.paddingInline,
190
+ gap: b.gap,
191
+ fontFamily: b.fontFamily,
192
+ fontSize: b.fontSize,
193
+ fontWeight: b.fontWeight,
194
+ lineHeight: b.lineHeight,
195
+ letterSpacing: b.letterSpacing,
196
+ };
197
+ };
198
+
166
199
  const recipes = {
167
200
  button: {
168
201
  base: {
@@ -184,6 +217,28 @@ export default {
184
217
  size: sizes(['2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl'], (tier) => ({ ...control(tier, { trigger: true }), _icon: { width: k.iconSize(tier), height: k.iconSize(tier) } })),
185
218
  },
186
219
  },
220
+ badge: {
221
+ base: chakraBadge('subtle', 'condense'),
222
+ variants: {
223
+ variant: Object.fromEntries(Object.entries(BADGE_LEVEL).map(([variant, level]) => [variant, chakraBadge(level)])),
224
+ // Size only: a full badge() here would paint over the variant's colors.
225
+ size: Object.fromEntries(Object.entries(BADGE_SIZE).map(([size, tier]) => {
226
+ const b = chakraBadge('subtle', tier);
227
+ return [size, {
228
+ height: b.height,
229
+ minH: b.minH,
230
+ minW: b.minW,
231
+ px: b.px,
232
+ gap: b.gap,
233
+ fontFamily: b.fontFamily,
234
+ fontSize: b.fontSize,
235
+ fontWeight: b.fontWeight,
236
+ lineHeight: b.lineHeight,
237
+ letterSpacing: b.letterSpacing,
238
+ }];
239
+ })),
240
+ },
241
+ },
187
242
  input: {
188
243
  base: { ...field, height: v('control-height-md'), minW: 0 },
189
244
  variants: { size: sizes(['2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl'], fieldSize), variant: { outline: {} } },
@@ -200,6 +255,23 @@ export default {
200
255
  const checked = '&:is([data-state=checked], [data-state=indeterminate])';
201
256
 
202
257
  const slotRecipes = {
258
+ emptyState: {
259
+ base: {
260
+ root: { width: '100%', color: v('text-primary'), textAlign: 'center' },
261
+ content: { display: 'flex', alignItems: 'center', justifyContent: 'center' },
262
+ indicator: { display: 'flex', alignItems: 'center', justifyContent: 'center' },
263
+ title: { margin: 0 },
264
+ description: { margin: 0 },
265
+ },
266
+ variants: {
267
+ size: {
268
+ sm: k.emptyStateSize('sm'),
269
+ md: k.emptyStateSize('md'),
270
+ lg: k.emptyStateSize('lg'),
271
+ },
272
+ },
273
+ },
274
+
203
275
  field: {
204
276
  base: {
205
277
  root: { gap: v('field-gap') },
@@ -593,6 +665,8 @@ export default {
593
665
  * Every palette, plus "accent" and "chrome", works as a colorPalette. Button variants are
594
666
  * the brand's emphasis levels (solid = bold, outline = standard, subtle = subtle,
595
667
  * ghost = quiet); each keeps its own color unless you pass colorPalette.
668
+ * Badge uses the same levels (solid = bold, outline = standard, subtle and surface = subtle,
669
+ * plain = quiet) and does not change on hover.
596
670
  * To add your own config on top: createSystem(config, yours).
597
671
  */
598
672
  import { createSystem, defaultConfig, mergeConfigs } from '@chakra-ui/react';
@@ -149,6 +149,68 @@ export function styleKit(prefix, roles) {
149
149
  disabled: { opacity: v('check-opacity-disabled') },
150
150
  };
151
151
 
152
+ /**
153
+ * A badge at one emphasis level. Colors are the level's rest state.
154
+ * Min width equals the height, so a single digit is a circle and a wider label is unchanged.
155
+ * `tier` is minimize, condense (the default) or base.
156
+ */
157
+ const BADGE_TIER = {
158
+ minimize: { height: 'badge-height-minimize', pad: 'badge-padding-inline-minimize', gap: 'badge-gap-minimize', text: 'trigger-minimize' },
159
+ condense: { height: 'badge-height-condense', pad: 'badge-padding-inline-condense', gap: 'badge-gap-condense', text: 'trigger-condense' },
160
+ base: { height: 'badge-height-base', pad: 'badge-padding-inline-base', gap: 'badge-gap-base', text: 'trigger-base' },
161
+ };
162
+ const badge = (level, palette, tier = 'condense') => {
163
+ const e = emphasis(level, palette);
164
+ const s = BADGE_TIER[tier];
165
+ return {
166
+ ...text(s.text),
167
+ display: 'inline-flex',
168
+ alignItems: 'center',
169
+ justifyContent: 'center',
170
+ gap: v(s.gap),
171
+ height: v(s.height),
172
+ minWidth: v(s.height),
173
+ paddingInline: v(s.pad),
174
+ borderRadius: v('radius-pill'),
175
+ borderWidth: v('stroke-default'),
176
+ borderStyle: 'solid',
177
+ background: e.bg,
178
+ color: e.fg,
179
+ borderColor: e.border,
180
+ whiteSpace: 'nowrap',
181
+ };
182
+ };
183
+
184
+ /**
185
+ * Chakra's emptyState slots, in Loomiere sizes. sm is compact (icon, no circle),
186
+ * md is standard, lg is spacious. The circle is the subtle emphasis level, the
187
+ * same helper Badge uses, so a brand that points subtle at the accent is followed.
188
+ * text-gap has no slot of its own: title and description share the content gap.
189
+ * textStyle: null clears Chakra's own textStyle (description base is "sm", and each
190
+ * size variant sets one on the title). The variant slot is replaced wholesale, so
191
+ * the null is what drops that textStyle. fontWeight stays: subtitle and title styles are bold.
192
+ */
193
+ const EMPTY_SIZE = {
194
+ sm: { block: 'empty-padding-block-compact', inline: 'empty-padding-inline-compact', gap: 'empty-gap-compact', title: 'subtitle-condense', description: 'body-condense', icon: 'icon-size-condense', circle: null },
195
+ md: { block: 'empty-padding-block-standard', inline: 'empty-padding-inline-standard', gap: 'empty-gap-standard', title: 'title-condense', description: 'body-base', icon: 'icon-size-magnify', circle: 'empty-media-size-standard' },
196
+ lg: { block: 'empty-padding-block-spacious', inline: 'empty-padding-inline-spacious', gap: 'empty-gap-spacious', title: 'title-magnify', description: 'body-base', icon: 'icon-size-maximize', circle: 'empty-media-size-spacious' },
197
+ };
198
+ const emptyStateSize = (size) => {
199
+ const s = EMPTY_SIZE[size];
200
+ const tone = emphasis('subtle');
201
+ const circle = s.circle
202
+ ? { width: v(s.circle), height: v(s.circle), background: tone.bg, color: tone.fg, borderRadius: v('radius-pill') }
203
+ : { width: 'auto', height: 'auto', background: 'transparent', color: tone.fg, borderRadius: 0 };
204
+ return {
205
+ root: { px: v(s.inline), py: v(s.block) },
206
+ content: { gap: v(s.gap), flexDirection: s.circle ? 'column' : 'row' },
207
+ // Circle size is width and height. The icon is fontSize, which Chakra's indicator textStyle would otherwise set.
208
+ title: { ...text(s.title), textStyle: null, color: v('text-primary') },
209
+ description: { ...text(s.description), textStyle: null, color: v('text-secondary'), maxWidth: v('empty-description-max-width') },
210
+ indicator: { ...circle, fontSize: v(s.icon), textStyle: null },
211
+ };
212
+ };
213
+
152
214
  const iconButton = {
153
215
  width: v('icon-button-size'),
154
216
  height: v('icon-button-size'),
@@ -164,6 +226,8 @@ export function styleKit(prefix, roles) {
164
226
  text,
165
227
  scope,
166
228
  emphasis,
229
+ badge,
230
+ emptyStateSize,
167
231
  levels: LEVELS,
168
232
  focusRing,
169
233
  focusRingInset,
@@ -345,8 +345,8 @@ function brandBook({ theme, brand, byPath, cssName, usageOf, tokens, tiers, styl
345
345
  '',
346
346
  '`window.Loomiere` holds two kinds of component:',
347
347
  '',
348
- `- **Wrappers** put their own classes on, so use them bare: ${['Button', 'Input', 'Field', 'Fieldset', 'Form', 'NavigationMenu'].filter(has).map((n) => `\`Loomiere.${n}\``).join(', ')}. \`Loomiere.Button\` also takes \`emphasis\` (\`bold|standard|subtle|quiet\`) and \`palette\` props, and a Button holding only an icon draws itself square (it sets \`data-icon-only\`). A plain \`.Button\`, such as a Popover or Toolbar trigger, is drawn square when it has an \`aria-label\` and a single icon inside, or when you set \`data-icon-only\` on it.`,
349
- '- **Everything else is the bare Base UI part** under its usual name (`Loomiere.Popover.Popup`, `Loomiere.ToggleGroup`). It draws nothing until you give each part its class, which is the component and part names joined: `<Loomiere.Popover.Popup className="PopoverPopup">`, `<Loomiere.Toggle className="Toggle">`. The component READMEs list every class.',
348
+ `- **Wrappers** put their own classes on, so use them bare: ${['Button', 'Input', 'Field', 'Fieldset', 'Form', 'NavigationMenu', 'Select', 'NumberField', 'OTPField', 'Autocomplete', 'Combobox', 'Checkbox', 'Radio', 'CheckboxGroup', 'Toggle', 'ToggleGroup', 'Dialog', 'AlertDialog', 'Meter', 'Progress', 'Slider', 'Menu', 'ContextMenu', 'Menubar', 'Popover', 'PreviewCard', 'Tooltip', 'Drawer', 'Accordion', 'Collapsible', 'Tabs', 'Switch', 'Toast', 'Avatar', 'Toolbar', 'ScrollArea', 'Separator', 'Badge', 'DatePicker', 'MoneyField', 'Splitter', 'ListDetail', 'EmptyState', 'DataTable', 'DataList', 'CollectionToolbar', 'CollectionPagination', 'EditableList'].filter(has).map((n) => `\`Loomiere.${n}\``).join(', ')}. \`Loomiere.Button\` also takes \`emphasis\` (\`bold|standard|subtle|quiet\`) and \`palette\` props, and a Button holding only an icon draws itself square (it sets \`data-icon-only\`). Wrapped parts that are buttons (\`Dialog.Trigger\`, \`Menu.Trigger\`, \`Toolbar.Button\`, \`Drawer.Close\`...) take the same \`emphasis\` and \`palette\` props and draw square the same way. \`Popover.Trigger\`, \`Tooltip.Trigger\`, \`Select.Trigger\` and \`Dialog.Close\` become Buttons only when they set \`emphasis\`, \`palette\` or \`className="Button"\`. A plain \`.Button\` on a bare part is drawn square when it has an \`aria-label\` and a single icon inside, or when you set \`data-icon-only\` on it. \`Loomiere.Badge\` takes the same \`emphasis\` and \`palette\` props, plus \`status\`, \`size\` and \`dot\`.`,
349
+ '- **Table, Badge, DatePicker, MoneyField, Splitter, ListDetail, EmptyState, DataTable, DataList, CollectionToolbar, CollectionPagination and EditableList are Loomiere components**, not Base UI parts. Table is plain HTML for a static table or a report: use the classes in its stylesheet. Badge is `Loomiere.Badge`. DatePicker is `Loomiere.DatePicker`; `Loomiere.Calendar` is the month grid on its own and `Loomiere.DatePresets` is the quick-selection list. MoneyField is `Loomiere.MoneyField`: an amount of money, stored as minor units. Splitter is `Loomiere.Splitter`. ListDetail is `Loomiere.ListDetail`. EmptyState is `Loomiere.EmptyState`. DataTable, DataList, CollectionToolbar and CollectionPagination come from `@scattered-light/loomiere-base-ui/custom/collection` and need `@tanstack/react-table` installed. EditableList comes from the package root or `./custom`.',
350
350
  '',
351
351
  '`Loomiere.Base` holds the bare Base UI parts for the wrappers too (`Loomiere.Base.Button`), for when you need the part without its classes.',
352
352
  '',
@@ -357,18 +357,19 @@ function brandBook({ theme, brand, byPath, cssName, usageOf, tokens, tiers, styl
357
357
  '| Component | Use it to |',
358
358
  '|---|---|',
359
359
  ...pick([
360
- ['Button', 'Run one action. Set its emphasis by how much the action matters. For a small, frequent action shown as an icon alone (calendar arrows, row actions), use a quiet neutral icon-only Button: `<Loomiere.Button emphasis="quiet" palette="neutral" data-size="xs" aria-label="Next month">` with one icon inside.'],
360
+ ['Button', 'Run one action. Set its emphasis by how much the action matters. For a small, frequent action shown as an icon alone (the month arrows inside DatePicker, row actions), use a quiet neutral icon-only Button: `<Loomiere.Button emphasis="quiet" palette="neutral" data-size="xs" aria-label="Next month">` with one icon inside.'],
361
361
  ['Switch', 'Turn one setting on or off, taking effect at once.'],
362
362
  ['Checkbox', 'Turn one option on or off inside a form that is submitted later.'],
363
363
  ['Toggle', 'A tool that stays pressed, such as Bold in a toolbar.'],
364
- ['ToggleGroup', 'Switch between 2 to 5 short modes or views that apply at once (Single date / Date range, List / Grid).'],
364
+ ['ToggleGroup', 'Switch between 2 to 5 short modes or views that apply at once (List / Grid). Picking a date or a period is DatePicker, not a toggle.'],
365
365
  ['Tabs', 'Switch between panels of content in the same place.'],
366
366
  ['Radio', 'Choose one of 2 to 6 options in a form, where each option can carry a label and description.'],
367
367
  ['CheckboxGroup', 'Choose any number of a few visible options.'],
368
368
  ['Select', 'Choose one of a fixed list too long to show (about 6 to 15 items).'],
369
369
  ['Combobox', 'Choose one or more from a long list by typing to filter it.'],
370
370
  ['Autocomplete', 'Type free text, with suggestions to speed it up.'],
371
- ['NumberField', 'Enter an exact number, with steppers.'],
371
+ ['NumberField', 'Enter an exact number, with steppers. For an amount of money, use MoneyField.'],
372
+ ['MoneyField', 'Enter an amount of money in one or more currencies. Use NumberField for plain quantities and percentages.'],
372
373
  ['Slider', 'Set a value in a range where roughly is good enough.'],
373
374
  ['OTPField', 'Enter a one-time code.'],
374
375
  ['Menu', 'Offer commands from a button. Menus run actions; they don\'t hold a value.'],
@@ -382,14 +383,29 @@ function brandBook({ theme, brand, byPath, cssName, usageOf, tokens, tiers, styl
382
383
  ['Dialog', 'A task that needs full attention before the page goes on.'],
383
384
  ['AlertDialog', 'Confirm a destructive or hard-to-undo action.'],
384
385
  ['Drawer', 'A side task or detail panel that keeps the page in view.'],
386
+ ['Splitter', 'Let people resize side-by-side or stacked areas, like a sidebar or a documents column.'],
387
+ ['ListDetail', 'Show the details of one item next to its list, falling back to a drawer or a page on narrow screens. Use Dialog when the detail must block the list.'],
385
388
  ['Accordion', 'Several sections where only some need to be open.'],
386
389
  ['Collapsible', 'One section to show or hide.'],
387
390
  ['Toast', 'Report the result of an action, briefly.'],
391
+ ['Badge', 'Label a status or a count next to something else. Not clickable; use Button or Toggle for that.'],
392
+ ['DatePicker', 'Pick a date or a period, with quick selections. Use Input type=date only when you can\'t load React. Calendar is the grid on its own; DatePresets is the list of quick selections.'],
393
+ ['EmptyState', 'Show that a list, table or page has nothing in it yet, nothing matches, failed to load, or is all done. Pick the kind; write your own copy.'],
394
+ ['DataList', 'A list people work through: a review queue, inbox, search results. Default for narrow panes.'],
395
+ ['DataTable', 'Rows people compare across several columns. Use the plain Table for static tables and reports.'],
396
+ ['EditableList', 'Repeating form rows people add, edit and remove: line items, allocations.'],
397
+ ['CollectionToolbar', 'Search, tabs, filters and the selection bar for a DataTable or a DataList.'],
398
+ ['CollectionPagination', 'Which page of a long list you are on. It also works without a collection.'],
399
+ ['EmptyState', 'Don\'t use it for a message about something that just happened (use Toast) or a warning above content that still shows (use a notice or an inline alert).'],
388
400
  ['Progress', 'Show how far a task has got.'],
389
401
  ['Meter', 'Show a measured amount within known limits, such as storage used.'],
390
402
  ]),
391
403
  '',
392
- `A list of choices that stays on screen inside a panel, one of them selected (quick picks, saved filters, presets), has no Base UI part. Make each row a \`<button aria-pressed>\` drawn from the item concept: \`--${theme.prefix}-item-padding-block\`, \`-padding-inline\`, \`-radius\` and \`-gap\`, \`--${theme.prefix}-item-background-hover\` and \`-color-hover\` on hover, \`--${theme.prefix}-item-background-selected\` and \`-color-selected\` with the \`-selected\` text style when chosen. These are the same rows menus and selects use. When the choice is a form value, use Radio instead.`,
404
+ `A date or a period, including the quick selections beside the calendar, is DatePicker. Any other list of choices that stays on screen inside a panel, one of them selected (saved filters, views), has no Base UI part. Make each row a \`<button aria-pressed>\` drawn from the item concept: \`--${theme.prefix}-item-padding-block\`, \`-padding-inline\`, \`-radius\` and \`-gap\`, \`--${theme.prefix}-item-background-hover\` and \`-color-hover\` on hover, \`--${theme.prefix}-item-background-selected\` and \`-color-selected\` with the \`-selected\` text style when chosen. These are the same rows menus and selects use. When the choice is a form value, use Radio instead.`,
405
+ '',
406
+ 'An empty state\'s title says what is empty in plain words ("No invoices yet"). The description says why, or what to do next, in one sentence. The action is a verb ("Create invoice", "Clear filters"). The first action is the main one: bold when the person is creating the first thing, standard everywhere else, and a secondary action quiet. Error and no-access stay straight: no jokes.',
407
+ '',
408
+ 'Use AG Grid only for spreadsheet work (cell-by-cell editing, ranges, tens of thousands of rows). A screen that is a list people work through uses DataList or DataTable, and the code should say why if it reaches for the grid.',
393
409
  '',
394
410
  );
395
411
  }
@@ -456,7 +472,7 @@ function agentsGuide({ theme, readme, componentNames }) {
456
472
  return `# ${cap(theme.brand)} theme: guide for agents
457
473
 
458
474
  This folder is a Loomiere theme. Load \`tokens.css\` (every token as a \`--${theme.prefix}-\` variable, light and dark) and \`utilities.css\` (text styles, focus ring, brand pairs). Framework adapters sit in \`exports/\`: \`tailwind.css\` (Tailwind v4: text styles by tier such as \`text-body-base\`, concept classes such as \`bg-field\` and \`text-link\`, no bare sizes like \`text-sm\`), \`shadcn.css\` (shadcn/ui), \`mui.js\`, \`chakra.js\`, AG Grid themes, \`tokens.dtcg.json\`. \`tokens.json\` lists every token with its path, value, modes and the reason it exists; read a token's \`description\` before using it.
459
- ${componentNames ? `\nComponents: \`@scattered-light/loomiere-base-ui\` (Base UI parts styled by class name; import \`@scattered-light/loomiere-base-ui/styles.css\` after the theme). \`exports/design-system/components/<Name>/README.md\` lists each component's classes and an example.\n` : ''}
475
+ ${componentNames ? `\nComponents: \`@scattered-light/loomiere-base-ui\` (Base UI parts styled by class name; import \`@scattered-light/loomiere-base-ui/styles.css\` after the theme). Collection (\`DataTable\`, \`DataList\`, \`CollectionToolbar\`, \`CollectionPagination\`) is imported from \`@scattered-light/loomiere-base-ui/custom/collection\`, and the app installs \`@tanstack/react-table\`. \`EditableList\` comes from \`@scattered-light/loomiere-base-ui/custom\`. Use AG Grid only for spreadsheet work (cell-by-cell editing, ranges, tens of thousands of rows); say why in the code. \`exports/design-system/components/<Name>/README.md\` lists each component's classes and an example.\n` : ''}
460
476
  Change the brand only in the brand file and rebuild (\`loomiere build\`); never edit generated files.
461
477
 
462
478
  ${readme.replace(/^#+ /gm, (m) => `#${m}`)}`;
package/exporters/mui.mjs CHANGED
@@ -135,6 +135,66 @@ export default {
135
135
  },
136
136
  },
137
137
 
138
+ MuiChip: {
139
+ styleOverrides: {
140
+ root: {
141
+ // Filled is subtle, Badge's default (a soft label). Outlined is the standard level.
142
+ // Chip has no minimize size: small is condense, medium is base.
143
+ // A clickable or deletable Chip keeps the level's hover and active colors, and the shared focus ring.
144
+ ...k.badge('subtle'),
145
+ '& .MuiChip-icon': { color: 'inherit', margin: 0 },
146
+ '& .MuiChip-label': { padding: 0 },
147
+ '&.MuiChip-clickable, &.MuiChip-deletable': {
148
+ '&:hover': { background: v('e-bg-hover') },
149
+ '&:active': { background: v('e-bg-active') },
150
+ '&.Mui-focusVisible': k.focusRing,
151
+ },
152
+ variants: [
153
+ ...['filled', 'outlined'].flatMap((variant) =>
154
+ Object.entries({ default: undefined, ...COLOR_SCOPE }).map(([c, palette]) => {
155
+ const level = variant === 'outlined' ? 'standard' : 'subtle';
156
+ const e = k.emphasis(level, palette);
157
+ return {
158
+ props: { variant, color: c },
159
+ style: {
160
+ ...k.badge(level, palette),
161
+ // Same slots as --sl-e-bg-hover and --sl-e-bg-active, in this chip's palette.
162
+ '&.MuiChip-clickable, &.MuiChip-deletable': {
163
+ '&:hover': { background: e['bg-hover'] },
164
+ '&:active': { background: e['bg-active'] },
165
+ '&.Mui-focusVisible': k.focusRing,
166
+ },
167
+ },
168
+ };
169
+ }),
170
+ ),
171
+ ...[
172
+ ['small', 'condense'],
173
+ ['medium', 'base'],
174
+ ].map(([size, tier]) => {
175
+ const b = k.badge('subtle', undefined, tier);
176
+ const icon = v(`icon-size-${tier === 'base' ? 'base' : 'condense'}`);
177
+ return {
178
+ props: { size },
179
+ style: {
180
+ height: b.height,
181
+ minWidth: b.height,
182
+ paddingInline: b.paddingInline,
183
+ gap: b.gap,
184
+ fontFamily: b.fontFamily,
185
+ fontSize: b.fontSize,
186
+ fontWeight: b.fontWeight,
187
+ lineHeight: b.lineHeight,
188
+ letterSpacing: b.letterSpacing,
189
+ '& .MuiChip-icon': { width: icon, height: icon },
190
+ },
191
+ };
192
+ }),
193
+ ],
194
+ },
195
+ },
196
+ },
197
+
138
198
  MuiIconButton: {
139
199
  styleOverrides: {
140
200
  root: {
@@ -0,0 +1,57 @@
1
+ // Custom: Badge. Color comes from the Emphasis and Status modes on an instance, the way Button does, not from variants.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var C = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'compHelpers'))(figma);
4
+ var V = C.V, COL = C.COL, v = C.v, fill = C.fill, stroke = C.stroke, bind = C.bind, radius = C.radius, add = C.add, T = C.T, mode = C.mode, page = C.page;
5
+ var icon = C.icon, comp = C.comp, textProp = C.textProp, place = C.place, removeOld = C.removeOld;
6
+ await page('Custom');
7
+ var PLUS = 'M 8 3 L 8 13 M 3 8 L 13 8';
8
+ function num(k) { var x = v(k); var m = COL[k.split(':')[0]].modes[0].modeId, val = x.valuesByMode[m]; while (val && val.type === 'VARIABLE_ALIAS') { var t = Object.values(V).find(function (y) { return y.id === val.id; }); val = t.valuesByMode[Object.keys(t.valuesByMode)[0]]; } return val; }
9
+ function frameSet(set) { fill(set, 'Theme:surface/canvas'); set.strokes = [{ type: 'SOLID', color: { r: 0.592, g: 0.278, b: 1 } }]; set.dashPattern = [6, 4]; set.strokeWeight = 1; set.cornerRadius = 8; set.clipsContent = false; }
10
+ function grid(set, cols, colW, rowH) {
11
+ set.layoutMode = 'NONE';
12
+ set.children.forEach(function (c, i) { c.x = 24 + (i % cols) * colW; c.y = 24 + Math.floor(i / cols) * rowH; });
13
+ frameSet(set);
14
+ set.resize(24 + cols * colW, 24 + Math.ceil(set.children.length / cols) * rowH);
15
+ }
16
+
17
+ // Rows are sizes; columns are none, a dot, and an icon. Every one carries a label.
18
+ var SIZES = [['Minimize', 'minimize'], ['Condense', 'condense'], ['Base', 'base']];
19
+ var MARKS = ['None', 'Dot', 'Icon'];
20
+ removeOld('Badge');
21
+ var comps = [];
22
+ for (var s = 0; s < SIZES.length; s++) for (var m = 0; m < MARKS.length; m++) {
23
+ var sizeName = SIZES[s][0], tier = SIZES[s][1], mark = MARKS[m];
24
+ var c = comp('Size=' + sizeName + ', Mark=' + mark);
25
+ c.primaryAxisAlignItems = 'CENTER'; c.counterAxisAlignItems = 'CENTER';
26
+ mode(c, 'Emphasis', 'Subtle');
27
+ fill(c, 'Emphasis:emphasis/bg');
28
+ stroke(c, 'Emphasis:emphasis/border', 'Size:stroke/default'); c.strokeAlign = 'INSIDE';
29
+ radius(c, 'Size:radius/pill');
30
+ bind(c, ['paddingLeft', 'paddingRight'], 'Size:badge/padding-inline-' + tier);
31
+ bind(c, 'itemSpacing', 'Size:badge/gap-' + tier);
32
+ if (mark === 'Dot') {
33
+ var d = figma.createEllipse(); d.name = 'Dot';
34
+ var dot = num('Size:badge/dot-' + tier); d.resize(dot, dot);
35
+ bind(d, ['width', 'height'], 'Size:badge/dot-' + tier);
36
+ fill(d, 'Emphasis:emphasis/fg');
37
+ add(c, [d]);
38
+ }
39
+ if (mark === 'Icon') {
40
+ var ic = icon('Icon', PLUS, num('Size:icon-size/' + tier), 'Emphasis:emphasis/fg');
41
+ bind(ic, ['width', 'height'], 'Size:icon-size/' + tier);
42
+ add(c, [ic]);
43
+ }
44
+ var lab = await T('Label', 'trigger-' + tier, 'Emphasis:emphasis/fg'); lab.name = 'Label';
45
+ add(c, [lab]);
46
+ c.counterAxisSizingMode = 'FIXED';
47
+ c.resize(c.width, num('Size:badge/height-' + tier));
48
+ bind(c, 'height', 'Size:badge/height-' + tier);
49
+ bind(c, 'minWidth', 'Size:badge/height-' + tier);
50
+ c.clipsContent = true;
51
+ comps.push(c);
52
+ }
53
+ var set = figma.combineAsVariants(comps, figma.currentPage); set.name = 'Badge';
54
+ grid(set, MARKS.length, 160, 64);
55
+ textProp(set, 'Label', 'Label', 'Label');
56
+ set.description = 'A status or count label. Not clickable. Size and Mark (none, a dot, or an icon) are the variants. Color comes from the Emphasis mode (Subtle unless you change it) and the Status mode on the instance. Min width equals the height, so a single digit is a circle.';
57
+ place(set);
@@ -0,0 +1,7 @@
1
+ // Custom: a header for the Badge set. Run after 01.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var C = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'compHelpers'))(figma);
4
+ await C.page('Custom');
5
+ return await C.labelSets({
6
+ Badge: 'A status or count label. Not clickable. Subtle emphasis unless the Emphasis mode says otherwise; Status recolors it. Min width equals the height, so a single digit is a circle.',
7
+ });
@@ -0,0 +1,28 @@
1
+ // Custom doc frame. Run after 01 and 02.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var K = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'docHelpers'))(figma);
4
+ await K.page('Custom');
5
+ var L = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'docKit'))(figma);
6
+ var AL = L.AL, add = L.add, T = L.T, mode = L.mode, docFrame = L.docFrame, section = L.section, hrow = L.hrow, divider = L.divider;
7
+ var inst = L.inst, notes = L.notes, captioned = L.captioned, usage = L.usage, stage = L.stage;
8
+ function B(size, mark) { return inst('Badge', 'Size=' + size + ', Mark=' + (mark || 'None')); }
9
+
10
+ var doc = await docFrame('Custom', 'COMPONENTS', 'Custom', 'Components Base UI does not have, built from the same concepts as the rest of the library. Badge labels a status or a count beside something else. It is not clickable: use Button or Toggle for that. Components sit to the right.');
11
+ doc.x = 0; doc.y = 400;
12
+
13
+ var pick = await section(doc, 'Choosing', 'Start from whether the person can press it.');
14
+ var picks = [['Name a status beside something else', 'Badge', 'Saved, Pending, 3. Subtle by default.'], ['Run an action', 'Button', 'A badge never takes a click.'], ['Stay pressed', 'Toggle', 'A badge does not hold a state.']];
15
+ var pt = AL('VERTICAL', { name: 'Picker' }); add(pick, [pt]); pt.layoutSizingHorizontal = 'FILL';
16
+ for (var i = 0; i < picks.length; i++) { var r = hrow(pt); divider(r); add(r, [await T(picks[i][0], 'body-base', 'Theme:text/primary', 380), await T(picks[i][1], 'subtitle-base', 'Theme:text/accent', 200), await T(picks[i][2], 'body-condense', 'Theme:text/secondary', 520)]); }
17
+
18
+ var look = await section(doc, 'Badge', 'Three sizes and an optional dot or icon. Color is the Emphasis and Status modes on the instance, not a variant. Subtle is the default.');
19
+ var s1 = stage(look, 'Sizes and marks');
20
+ for (var z of ['Minimize', 'Condense', 'Base']) add(s1, [await captioned(B(z, 'None'), z)]);
21
+ add(s1, [await captioned(B('Condense', 'Dot'), 'Dot'), await captioned(B('Condense', 'Icon'), 'Icon')]);
22
+ var s2 = stage(look, 'Emphasis');
23
+ for (var em of ['Bold', 'Standard', 'Subtle', 'Quiet']) { var a = B('Condense', 'None'); mode(a, 'Emphasis', em); add(s2, [await captioned(a, em)]); }
24
+ var s3 = stage(look, 'Status');
25
+ for (var st of ['Default', 'Info', 'Success', 'Warning', 'Error']) { var b = B('Condense', 'Dot'); mode(b, 'Status', st); add(s3, [await captioned(b, st)]); }
26
+ await usage(look, ['A status next to a title, a row, or a count of unread items.', 'Error and Warning when the word says what is wrong, not color alone.', 'A number on its own when the count is the whole message.'], ['Anything the person clicks. Use Button or Toggle.', 'A long sentence. A badge is a label.', 'Color as the only signal. The word or the number has to say it too.']);
27
+ await notes(look, [['Height', 'badge/height-minimize · condense · base', 'Shorter than a control, so it sits in a line of text.'], ['Padding', 'badge/padding-inline-*', 'Tighter than a button.'], ['Gap', 'badge/gap-*', 'Between the dot or icon and the label.'], ['Dot', 'badge/dot-* · current color', 'A mark, smaller than the icon.'], ['Icon', 'icon-size/minimize · condense · base', 'The same tier as the text.'], ['Text', 'trigger-minimize · trigger-condense · trigger-base', 'Medium, one whole style per size.'], ['Shape', 'radius/pill · min width = height', 'A single digit is a circle. A wider label keeps its padding.'], ['Colors', 'emphasis/bg · fg · border', 'Subtle unless the Emphasis mode says otherwise. Status recolors it.']]);
28
+ return { id: doc.id, h: Math.round(doc.height), overflow: L.overflow(doc) };