@scattered-light/loomiere 0.2.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.
@@ -19,8 +19,8 @@ const CHAKRA_SLOTS = { contrast: 'on-solid', subtle: 'soft', muted: 'soft-hover'
19
19
  const STATUS = ['info', 'warning', 'success', 'error'];
20
20
  /** Text styles a Chakra user can name as textStyle="…". */
21
21
  const TEXT_STYLES = [
22
- 'minimize', 'condense', 'base', 'magnify', 'base-label', 'base-selected', 'condense-subtitle-label',
23
- 'condense-trigger', 'base-trigger', 'magnify-trigger', 'maximize-md', 'maximize-lg', 'maximize-xl', 'maximize-2xl', 'maximize-3xl', 'maximize-4xl',
22
+ 'body-minimize', 'body-condense', 'body-base', 'body-magnify', 'label-base', 'selected-base', 'eyebrow-condense',
23
+ 'trigger-condense', 'trigger-base', 'trigger-magnify', 'title-condense', 'title-base', 'title-magnify', 'title-amplify', 'title-maximize',
24
24
  ];
25
25
 
26
26
  /** Merges our recipe pieces into Chakra's own, in the generated file (see RESTYLE below). */
@@ -127,7 +127,7 @@ export default {
127
127
 
128
128
  const field = {
129
129
  ...k.field,
130
- ...text('base'),
130
+ ...text('body-base'),
131
131
  focusVisibleRing: null,
132
132
  focusRingColor: null,
133
133
  _hover: k.fieldStates.hover,
@@ -137,7 +137,7 @@ export default {
137
137
  _placeholder: k.fieldStates.placeholder,
138
138
  };
139
139
  const fieldSize = (tier) => ({
140
- ...text(tier === 'xs' || tier === 'sm' ? 'condense' : tier === 'lg' ? 'magnify' : 'base'),
140
+ ...text(tier === 'xs' || tier === 'sm' ? 'body-condense' : tier === 'lg' ? 'body-magnify' : 'body-base'),
141
141
  textStyle: null,
142
142
  '--input-height': v(`control-height-${tier}`),
143
143
  height: v(`control-height-${tier}`),
@@ -196,7 +196,7 @@ export default {
196
196
 
197
197
  const checkControl = (tier) => ({ boxSize: k.iconSize(tier), '& svg': { width: v(`icon-size-${{ xs: 'minimize', sm: 'minimize', md: 'condense', lg: 'base' }[tier]}`), height: v(`icon-size-${{ xs: 'minimize', sm: 'minimize', md: 'condense', lg: 'base' }[tier]}`) } });
198
198
  const checkRow = { gap: v('gap-control'), color: v('text-primary') };
199
- const label = { ...text('base'), textStyle: null, _disabled: { color: v('text-disabled'), opacity: null } };
199
+ const label = { ...text('body-base'), textStyle: null, _disabled: { color: v('text-disabled'), opacity: null } };
200
200
  const checked = '&:is([data-state=checked], [data-state=indeterminate])';
201
201
 
202
202
  const slotRecipes = {
@@ -204,9 +204,9 @@ export default {
204
204
  base: {
205
205
  root: { gap: v('field-gap') },
206
206
  // A disabled field fades as a whole, as a disabled Base UI Field.Root does.
207
- label: { ...text('base-label'), textStyle: null, gap: v('space-1'), marginBottom: v('field-label-spacing'), color: v('text-primary'), _disabled: { opacity: v('field-opacity-disabled') } },
208
- helperText: { ...text('condense'), textStyle: null, margin: `${v('field-message-spacing')} 0 0`, color: v('text-secondary'), _disabled: { opacity: v('field-opacity-disabled') } },
209
- errorText: { ...text('base-label'), textStyle: null, margin: `${v('field-message-spacing')} 0 0`, color: v('status-error') },
207
+ label: { ...text('label-base'), textStyle: null, gap: v('space-1'), marginBottom: v('field-label-spacing'), color: v('text-primary'), _disabled: { opacity: v('field-opacity-disabled') } },
208
+ helperText: { ...text('body-condense'), textStyle: null, margin: `${v('field-message-spacing')} 0 0`, color: v('text-secondary'), _disabled: { opacity: v('field-opacity-disabled') } },
209
+ errorText: { ...text('label-base'), textStyle: null, margin: `${v('field-message-spacing')} 0 0`, color: v('status-error') },
210
210
  requiredIndicator: { color: v('status-error') },
211
211
  },
212
212
  },
@@ -260,7 +260,7 @@ export default {
260
260
  switch: {
261
261
  base: {
262
262
  root: { gap: v('gap-control'), '--switch-diff': 'calc(var(--switch-width) - var(--switch-height))' },
263
- label: { ...text('base-label'), fontSize: v('font-base-label-font-size'), color: v('text-primary'), _disabled: { color: v('text-disabled'), opacity: null } },
263
+ label: { ...text('label-base'), fontSize: v('font-label-base-font-size'), color: v('text-primary'), _disabled: { color: v('text-disabled'), opacity: null } },
264
264
  control: {
265
265
  boxSizing: 'border-box',
266
266
  alignItems: 'center',
@@ -271,7 +271,7 @@ export default {
271
271
  transition: `background-color ${v('duration-normal')} ${v('easing-in-out')}`,
272
272
  focusVisibleRing: null,
273
273
  _before: {
274
- ...text('condense-subtitle-label'),
274
+ ...text('eyebrow-condense'),
275
275
  content: '"off"',
276
276
  position: 'absolute',
277
277
  insetBlock: 0,
@@ -318,9 +318,9 @@ export default {
318
318
  },
319
319
  slider: {
320
320
  base: {
321
- root: { ...text('base'), textStyle: null, gap: v('gauge-gap'), _disabled: { opacity: v('check-opacity-disabled') } },
322
- label: { ...text('base-label'), textStyle: null, color: v('gauge-label-color') },
323
- valueText: { ...text('base'), color: v('gauge-value-color') },
321
+ root: { ...text('body-base'), textStyle: null, gap: v('gauge-gap'), _disabled: { opacity: v('check-opacity-disabled') } },
322
+ label: { ...text('label-base'), textStyle: null, color: v('gauge-label-color') },
323
+ valueText: { ...text('body-base'), color: v('gauge-value-color') },
324
324
  track: { bg: v('gauge-track-color'), borderRadius: v('gauge-radius'), boxShadow: 'none' },
325
325
  range: { bg: 'colorPalette.solid', borderRadius: v('gauge-radius') },
326
326
  thumb: {
@@ -343,7 +343,7 @@ export default {
343
343
  base: {
344
344
  list: { borderColor: v('disclosure-divider-color') },
345
345
  trigger: {
346
- ...text('base'),
346
+ ...text('body-base'),
347
347
  height: 'auto',
348
348
  minW: 0,
349
349
  padding: `${v('space-2')} ${v('space-4')}`,
@@ -361,7 +361,7 @@ export default {
361
361
  list: { display: 'flex', borderColor: v('disclosure-divider-color'), _horizontal: { borderBottomWidth: v('stroke-default') }, _vertical: { borderEndWidth: v('stroke-default') } },
362
362
  trigger: {
363
363
  _selected: {
364
- ...text('base-selected'),
364
+ ...text('selected-base'),
365
365
  color: v('disclosure-selected-color'),
366
366
  _horizontal: { layerStyle: 'indicator.bottom', '--indicator-offset-y': `calc(${v('stroke-default')} * -1)`, '--indicator-color': v('disclosure-indicator-color'), '--indicator-thickness': v('disclosure-indicator-width') },
367
367
  _vertical: { layerStyle: 'indicator.end', '--indicator-offset-x': `calc(${v('stroke-default')} * -1)`, '--indicator-color': v('disclosure-indicator-color'), '--indicator-thickness': v('disclosure-indicator-width') },
@@ -376,7 +376,7 @@ export default {
376
376
  root: { color: v('text-primary') },
377
377
  item: { borderBottom: `${v('stroke-default')} solid ${v('disclosure-divider-color')}`, _last: { borderBottom: 'none' } },
378
378
  itemTrigger: {
379
- ...text('maximize-md'),
379
+ ...text('title-base'),
380
380
  justifyContent: 'space-between',
381
381
  gap: v('space-4'),
382
382
  padding: `${v('space-4')} ${v('space-6')}`,
@@ -388,7 +388,7 @@ export default {
388
388
  _disabled: { opacity: v('disclosure-opacity-disabled'), layerStyle: null },
389
389
  },
390
390
  itemIndicator: { color: v('disclosure-icon-color') },
391
- itemBody: { ...text('base'), padding: `0 ${v('space-6')} ${v('space-4')}`, color: v('disclosure-content-color') },
391
+ itemBody: { ...text('body-base'), padding: `0 ${v('space-6')} ${v('space-4')}`, color: v('disclosure-content-color') },
392
392
  },
393
393
  variants: {
394
394
  variant: { outline: { item: {} } },
@@ -401,7 +401,7 @@ export default {
401
401
  content: { ...popup, minW: v('popup-min-width'), padding: v('popup-padding'), overflow: null },
402
402
  item: { ...row, paddingBlock: v('item-padding-block-dense'), '&[data-type]': { ps: null } },
403
403
  itemGroupLabel: groupLabel,
404
- itemCommand: { ...text('condense'), color: v('text-tertiary'), opacity: null, letterSpacing: null },
404
+ itemCommand: { ...text('body-condense'), color: v('text-tertiary'), opacity: null, letterSpacing: null },
405
405
  itemIndicator: { color: v('item-indicator-color') },
406
406
  separator: { ...k.separator, mx: 0, my: null },
407
407
  arrow: { '--arrow-background': v('popup-background') },
@@ -424,7 +424,7 @@ export default {
424
424
  _placeholderShown: { color: v('field-placeholder-color') },
425
425
  },
426
426
  indicator: { color: v('text-tertiary') },
427
- label: { ...text('base-label'), textStyle: null, color: v('text-primary') },
427
+ label: { ...text('label-base'), textStyle: null, color: v('text-primary') },
428
428
  ...listParts,
429
429
  },
430
430
  variants: {
@@ -438,9 +438,9 @@ export default {
438
438
  combobox: {
439
439
  base: {
440
440
  input: { ...field, focusVisibleRing: null },
441
- label: { ...text('base-label'), textStyle: null, color: v('text-primary') },
441
+ label: { ...text('label-base'), textStyle: null, color: v('text-primary') },
442
442
  trigger: { color: v('text-tertiary') },
443
- empty: { ...text('condense'), color: v('text-tertiary'), justifyContent: 'center', paddingBlock: v('popup-empty-padding-block') },
443
+ empty: { ...text('body-condense'), color: v('text-tertiary'), justifyContent: 'center', paddingBlock: v('popup-empty-padding-block') },
444
444
  ...listParts,
445
445
  },
446
446
  variants: {
@@ -450,16 +450,16 @@ export default {
450
450
  },
451
451
  popover: {
452
452
  base: {
453
- content: { ...popup, '--popover-bg': v('popup-background'), textStyle: null, ...text('base') },
454
- title: { ...text('maximize-md') },
455
- description: { ...text('base'), color: v('text-secondary') },
453
+ content: { ...popup, '--popover-bg': v('popup-background'), textStyle: null, ...text('body-base') },
454
+ title: { ...text('title-base') },
455
+ description: { ...text('body-base'), color: v('text-secondary') },
456
456
  arrow: { '--arrow-background': v('popup-background') },
457
457
  },
458
458
  },
459
459
  tooltip: {
460
460
  base: {
461
461
  content: {
462
- ...text('condense'),
462
+ ...text('body-condense'),
463
463
  textStyle: null,
464
464
  maxW: v('tooltip-max-width'),
465
465
  padding: `${v('space-1-5')} ${v('space-2-5')}`,
@@ -476,12 +476,12 @@ export default {
476
476
  dialog: {
477
477
  base: {
478
478
  backdrop: { bg: v('modal-backdrop') },
479
- content: { ...k.modalSurface, bg: null, background: v('modal-background'), textStyle: null, ...text('base'), width: v('modal-width'), maxH: v('modal-max-height') },
479
+ content: { ...k.modalSurface, bg: null, background: v('modal-background'), textStyle: null, ...text('body-base'), width: v('modal-width'), maxH: v('modal-max-height') },
480
480
  header: { padding: 0, gap: v('space-2') },
481
481
  body: { padding: 0 },
482
482
  footer: { padding: 0, gap: v('modal-actions-gap'), marginTop: v('modal-actions-spacing') },
483
- title: { ...text('maximize-xl'), textStyle: null, color: v('modal-title-color') },
484
- description: { ...text('base'), color: v('modal-description-color') },
483
+ title: { ...text('title-amplify'), textStyle: null, color: v('modal-title-color') },
484
+ description: { ...text('body-base'), color: v('modal-description-color') },
485
485
  closeTrigger: { top: v('space-3'), insetEnd: v('space-3') },
486
486
  },
487
487
  variants: { size: { md: { content: { maxW: v('modal-max-width') } } } },
@@ -489,9 +489,9 @@ export default {
489
489
  drawer: {
490
490
  base: {
491
491
  backdrop: { bg: v('modal-backdrop') },
492
- content: { bg: v('modal-background'), color: v('text-primary'), boxShadow: v('modal-shadow'), textStyle: null, ...text('base') },
493
- title: { ...text('maximize-xl'), textStyle: null, color: v('modal-title-color') },
494
- description: { ...text('base'), color: v('modal-description-color') },
492
+ content: { bg: v('modal-background'), color: v('text-primary'), boxShadow: v('modal-shadow'), textStyle: null, ...text('body-base') },
493
+ title: { ...text('title-amplify'), textStyle: null, color: v('modal-title-color') },
494
+ description: { ...text('body-base'), color: v('modal-description-color') },
495
495
  footer: { gap: v('modal-actions-gap') },
496
496
  },
497
497
  },
@@ -500,7 +500,7 @@ export default {
500
500
  base: {
501
501
  root: {
502
502
  ...k.popupSurface,
503
- ...text('base'),
503
+ ...text('body-base'),
504
504
  padding: v('notice-padding'),
505
505
  gap: v('notice-gap'),
506
506
  '--toast-trigger-bg': v('surface-raised'),
@@ -508,16 +508,16 @@ export default {
508
508
  STATUS.filter((s) => scopes[s]).map((s) => [`&[data-type=${s}]`, { colorPalette: s, bg: 'colorPalette.soft', borderColor: 'colorPalette.border', color: 'colorPalette.on-soft' }]),
509
509
  ),
510
510
  },
511
- title: { ...text('maximize-md'), textStyle: null },
512
- description: { ...text('base'), textStyle: null, marginTop: v('notice-text-gap'), color: v('notice-description-color'), '[data-type] &': { color: 'inherit' } },
511
+ title: { ...text('title-base'), textStyle: null },
512
+ description: { ...text('body-base'), textStyle: null, marginTop: v('notice-text-gap'), color: v('notice-description-color'), '[data-type] &': { color: 'inherit' } },
513
513
  closeTrigger: k.iconButton,
514
514
  },
515
515
  },
516
516
  alert: {
517
517
  base: {
518
- root: { ...text('base'), textStyle: null, padding: v('notice-padding'), gap: v('notice-gap'), borderRadius: v('popup-radius'), borderWidth: v('popup-border-width'), borderStyle: 'solid', borderColor: 'transparent' },
519
- title: { ...text('maximize-md') },
520
- description: { ...text('base'), marginTop: v('notice-text-gap') },
518
+ root: { ...text('body-base'), textStyle: null, padding: v('notice-padding'), gap: v('notice-gap'), borderRadius: v('popup-radius'), borderWidth: v('popup-border-width'), borderStyle: 'solid', borderColor: 'transparent' },
519
+ title: { ...text('title-base') },
520
+ description: { ...text('body-base'), marginTop: v('notice-text-gap') },
521
521
  indicator: { color: 'inherit' },
522
522
  },
523
523
  variants: {
@@ -534,8 +534,8 @@ export default {
534
534
  progress: {
535
535
  base: {
536
536
  root: { gap: v('gauge-gap') },
537
- label: { ...text('base-label'), textStyle: null, color: v('gauge-label-color') },
538
- valueText: { ...text('base'), textStyle: null, color: v('gauge-value-color') },
537
+ label: { ...text('label-base'), textStyle: null, color: v('gauge-label-color') },
538
+ valueText: { ...text('body-base'), textStyle: null, color: v('gauge-value-color') },
539
539
  track: { bg: v('gauge-track-color'), borderRadius: v('gauge-radius') },
540
540
  range: { bg: 'colorPalette.solid', borderRadius: v('gauge-radius'), transition: v('gauge-transition') },
541
541
  },
@@ -546,7 +546,7 @@ export default {
546
546
  },
547
547
  avatar: {
548
548
  base: {
549
- root: { ...text('maximize-md'), boxSizing: 'border-box', border: `${v('stroke-emphasis')} solid ${v('surface-canvas')}`, boxShadow: v('elevation-sm'), textTransform: 'uppercase' },
549
+ root: { ...text('title-base'), boxSizing: 'border-box', border: `${v('stroke-emphasis')} solid ${v('surface-canvas')}`, boxShadow: v('elevation-sm'), textTransform: 'uppercase' },
550
550
  },
551
551
  variants: {
552
552
  variant: Object.fromEntries(['solid', 'subtle', 'outline'].map((s) => [s, { root: { bg: 'colorPalette.solid', color: 'colorPalette.on-solid' } }])),
@@ -555,8 +555,8 @@ export default {
555
555
  },
556
556
  table: {
557
557
  base: {
558
- root: { ...text('condense'), textStyle: null, color: v('table-color') },
559
- columnHeader: { ...text('base-label'), bg: v('table-header-background'), color: v('table-header-color'), borderColor: v('table-row-border-color') },
558
+ root: { ...text('body-condense'), textStyle: null, color: v('table-color') },
559
+ columnHeader: { ...text('label-base'), bg: v('table-header-background'), color: v('table-header-color'), borderColor: v('table-row-border-color') },
560
560
  cell: { borderColor: v('table-row-border-color'), paddingInline: v('table-cell-padding-inline') },
561
561
  row: { '&[data-selected]': { bg: v('table-row-selected') } },
562
562
  },
@@ -567,7 +567,7 @@ export default {
567
567
 
568
568
  const config = {
569
569
  // Dark follows data-theme, the attribute tokens.css (and Base UI's stories) use.
570
- conditions: { light: ':root &, .light &, [data-theme=light] &', dark: '.dark &, [data-theme=dark] &' },
570
+ conditions: { light: ':root &, .light &, [data-theme=light] &', dark: '.dark &, [data-theme=dark] &, [data-theme=dark-high-contrast] &' },
571
571
  globalCss: { html: { colorPalette: 'accent' } },
572
572
  theme: {
573
573
  tokens: {
@@ -69,7 +69,7 @@ export function styleKit(prefix, roles) {
69
69
  const control = (size, { trigger = false } = {}) => {
70
70
  const s = SIZES[size];
71
71
  return {
72
- ...text(trigger ? `${s.text}-trigger` : s.text),
72
+ ...text(trigger ? `trigger-${s.text}` : `body-${s.text}`),
73
73
  height: v(`control-height${s.suffix || '-md'}`),
74
74
  paddingInline: v(`control-padding-inline${s.suffix}`),
75
75
  gap: v(size === 'xs' ? 'gap-control-xs' : size === 'sm' ? 'gap-control-sm' : 'gap-control'),
@@ -107,7 +107,7 @@ export function styleKit(prefix, roles) {
107
107
  };
108
108
 
109
109
  const itemRow = {
110
- ...text('base'),
110
+ ...text('body-base'),
111
111
  display: 'flex',
112
112
  alignItems: 'center',
113
113
  gap: v('item-gap'),
@@ -120,11 +120,11 @@ export function styleKit(prefix, roles) {
120
120
  };
121
121
  const itemStates = {
122
122
  hover: { background: v('item-background-hover'), color: v('item-color-hover') },
123
- selected: { ...text('base-selected'), background: v('item-background-selected'), color: v('item-color-selected') },
123
+ selected: { ...text('selected-base'), background: v('item-background-selected'), color: v('item-color-selected') },
124
124
  disabled: { color: v('item-color-disabled'), opacity: v('item-opacity-disabled') },
125
125
  };
126
126
  const groupLabel = {
127
- ...text('condense-subtitle-label'),
127
+ ...text('eyebrow-condense'),
128
128
  paddingBlock: `${v('popup-group-label-padding-top')} ${v('popup-group-label-padding-bottom')}`,
129
129
  paddingInline: v('item-padding-inline'),
130
130
  color: v('text-secondary'),
@@ -97,8 +97,8 @@ export default {
97
97
  // ------------------------------------------------------------------------------- type
98
98
  const styleNames = [...new Set(theme.tokens.filter((t) => /^font\.[^.]+\.font$/.test(t.path)).map((t) => t.path.split('.')[1]))];
99
99
  const familyKey = (style) => byPath.get(`font.${style}.font-family`)?.references?.match(/^\{font-family\.([^}]+)\}$/)?.[1] ?? 'body';
100
- const tierOf = (style) => (style === 'code' ? 'code' : style.split('-')[0]);
101
- const TIERS = ['minimize', 'condense', 'base', 'magnify', 'maximize', 'code'];
100
+ const tierOf = (style) => style.split('-').pop();
101
+ const TIERS = ['minimize', 'condense', 'base', 'magnify', 'amplify', 'maximize'];
102
102
  const tiers = [...new Set(styleNames.map(tierOf))].sort((a, b) => (TIERS.indexOf(a) + 1 || 99) - (TIERS.indexOf(b) + 1 || 99));
103
103
  const styleOf = (style) => {
104
104
  const v = (prop) => byPath.get(`font.${style}.${prop}`)?.value;
@@ -256,6 +256,12 @@ function brandBook({ theme, brand, byPath, cssName, usageOf, tokens, tiers, styl
256
256
  '',
257
257
  `\`data-color="<palette>"\` on any element repaints everything inside it in that palette (\`primary\`, \`secondary\`, \`error\`, \`accent\`, \`chrome\`...): a destructive button is \`<Button data-emphasis="bold" data-color="error">\`.`,
258
258
  '',
259
+ '### Statuses',
260
+ '',
261
+ `\`data-status="info|success|warning|error"\` on any element gives every emphasis level inside it that status's version, so a subtle warning banner is \`<div data-status="warning" data-emphasis="subtle">\`. A level is the level's style in the status's palette unless the brand picked a pair for it:`,
262
+ '',
263
+ ...Object.entries(roles.status ?? {}).flatMap(([status, picks]) => Object.entries(picks).map(([level, pair]) => `- ${level} in ${status}: the \`${pair}\` pair`)),
264
+ ...(Object.keys(roles.status ?? {}).length ? [''] : ['(This brand picks none, so every level is automatic.)', '']),
259
265
  );
260
266
  const pairs = Object.entries(brand?.color?.pairs ?? {});
261
267
  if (pairs.length) {
@@ -284,16 +290,16 @@ function brandBook({ theme, brand, byPath, cssName, usageOf, tokens, tiers, styl
284
290
  condense: 'compact UI: small controls, table cells, secondary labels.',
285
291
  base: 'the default UI and body text.',
286
292
  magnify: 'emphasized body text: lead paragraphs, larger controls.',
287
- maximize: 'display text for headings and hero copy, in steps xs to 4xl.',
288
- code: 'code, in the mono family.',
293
+ amplify: 'titles of dialogs, drawers and page sections.',
294
+ maximize: 'the page head.',
289
295
  };
290
296
  for (const tier of tiers) {
291
- const variants = styleNames.filter((s) => s.startsWith(`${tier}-`)).map((s) => s.slice(tier.length + 1));
292
- out.push(`- **${tier}**: ${tierWhy[tier] ?? ''}${variants.length ? ` Variants: ${variants.map((x) => `\`${x}\``).join(', ')}.` : ''}`);
297
+ const variants = styleNames.filter((s) => s.endsWith(`-${tier}`)).map((s) => s.slice(0, -tier.length - 1));
298
+ out.push(`- **${tier}**: ${tierWhy[tier] ?? ''}${variants.length ? ` Roles: ${variants.map((x) => `\`${x}\``).join(', ')}.` : ''}`);
293
299
  }
294
300
  out.push(
295
301
  '',
296
- 'Inside a tier, `-trigger` is for buttons and triggers, `-selected` for the current tab, toggle or nav item, `-link` for links, `-subtitle` for subheads and `-label` for form labels. Sizes below the base are fixed; the base and up grow with the viewport.',
302
+ 'Names are role first, then tier: `body-` for running text, `trigger-` for buttons and triggers, `selected-` for the current tab, toggle or nav item, `link-` for links, `subtitle-` for subheads, `label-` for form labels, `eyebrow-` for small uppercase labels, `title-` for titles in the display family, `code-` for code in the mono family. Sizes below the base are fixed; the base and up grow with the viewport.',
297
303
  '',
298
304
  '## Space, shape and depth',
299
305
  '',
@@ -309,17 +315,75 @@ function brandBook({ theme, brand, byPath, cssName, usageOf, tokens, tiers, styl
309
315
  '',
310
316
  '## Modes',
311
317
  '',
312
- `Light and dark: \`data-theme="light|dark"\` on the root, or the system setting when neither is set. High contrast follows \`prefers-contrast: more\`. Color vision themes: \`data-vision="deuteranopia|protanopia|tritanopia|achromatopsia"\`, each designed from this brand. Never hand-pick dark colors; the tokens already flip.`,
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.`,
313
319
  '',
314
320
  );
315
321
  if (componentNames.length) {
322
+ const has = (n) => componentNames.includes(n);
323
+ const pick = (list) => list.filter(([n]) => has(n)).map(([n, use]) => `| \`${n}\` | ${use} |`);
316
324
  out.push(
317
325
  '## Components',
318
326
  '',
319
- `The components are [Base UI](https://base-ui.com) parts styled by class name: ${componentNames.map((n) => `\`${n}\``).join(', ')}. Each component's README lists the classes its parts take and the data attributes they answer to (\`data-size="sm|lg"\`, \`data-emphasis\`, \`data-color\`). In previews and designs they are \`window.Loomiere.<Name>\`; Button, Input, Field, Fieldset, Form and NavigationMenu are wrappers that put their classes on themselves.`,
327
+ `The components are [Base UI](https://base-ui.com) parts styled by class name: ${componentNames.map((n) => `\`${n}\``).join(', ')}. Each component's README lists the classes its parts take and the data attributes they answer to (\`data-size="sm|lg"\`, \`data-emphasis\`, \`data-color\`).`,
320
328
  '',
321
329
  'Icons are Material Symbols Outlined by ligature name (`<span class="material-symbols-outlined">search</span>`, or `Loomiere.MaterialSymbol name="search"`), sized by the `.icon-<tier>` utilities to match the text beside them.',
322
330
  '',
331
+ '### Using the components in a page or design',
332
+ '',
333
+ 'Load three things, in this order:',
334
+ '',
335
+ '1. `components/bundle.css`: the whole theme in one sheet. It holds every token (light, dark, high contrast and color vision modes), the text style, icon and focus utilities, the brand pairs, and every component\'s styles, and it imports the brand fonts and Material Symbols from Google Fonts itself. Nothing else is needed for styling; `tokens.json` is the same tokens as data for the Design System page.',
336
+ '2. React 18 and ReactDOM 18 as the `window.React` and `window.ReactDOM` globals. A design canvas or the preview frame already has them.',
337
+ '3. `components/bundle.js`: one classic script that sets `window.Loomiere`.',
338
+ '',
339
+ '`window.Loomiere` holds two kinds of component:',
340
+ '',
341
+ `- **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.`,
342
+ '- **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.',
343
+ '',
344
+ '`Loomiere.Base` holds the bare Base UI parts for the wrappers too (`Loomiere.Base.Button`), for when you need the part without its classes.',
345
+ '',
346
+ '### Choosing a component',
347
+ '',
348
+ 'Pick by what the person is doing, not by look:',
349
+ '',
350
+ '| Component | Use it to |',
351
+ '|---|---|',
352
+ ...pick([
353
+ ['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.'],
354
+ ['Switch', 'Turn one setting on or off, taking effect at once.'],
355
+ ['Checkbox', 'Turn one option on or off inside a form that is submitted later.'],
356
+ ['Toggle', 'A tool that stays pressed, such as Bold in a toolbar.'],
357
+ ['ToggleGroup', 'Switch between 2 to 5 short modes or views that apply at once (Single date / Date range, List / Grid).'],
358
+ ['Tabs', 'Switch between panels of content in the same place.'],
359
+ ['Radio', 'Choose one of 2 to 6 options in a form, where each option can carry a label and description.'],
360
+ ['CheckboxGroup', 'Choose any number of a few visible options.'],
361
+ ['Select', 'Choose one of a fixed list too long to show (about 6 to 15 items).'],
362
+ ['Combobox', 'Choose one or more from a long list by typing to filter it.'],
363
+ ['Autocomplete', 'Type free text, with suggestions to speed it up.'],
364
+ ['NumberField', 'Enter an exact number, with steppers.'],
365
+ ['Slider', 'Set a value in a range where roughly is good enough.'],
366
+ ['OTPField', 'Enter a one-time code.'],
367
+ ['Menu', 'Offer commands from a button. Menus run actions; they don\'t hold a value.'],
368
+ ['ContextMenu', 'Offer commands on right-click or long press.'],
369
+ ['Menubar', 'An app\'s top row of menus.'],
370
+ ['Toolbar', 'A row of buttons, toggles and fields that act on one thing.'],
371
+ ['NavigationMenu', 'Site navigation, with panels of links.'],
372
+ ['Tooltip', 'Name an icon-only control. Never put anything you can click inside one.'],
373
+ ['PreviewCard', 'Preview what a link leads to on hover.'],
374
+ ['Popover', 'Show interactive content beside its trigger: a picker, a short form, filters.'],
375
+ ['Dialog', 'A task that needs full attention before the page goes on.'],
376
+ ['AlertDialog', 'Confirm a destructive or hard-to-undo action.'],
377
+ ['Drawer', 'A side task or detail panel that keeps the page in view.'],
378
+ ['Accordion', 'Several sections where only some need to be open.'],
379
+ ['Collapsible', 'One section to show or hide.'],
380
+ ['Toast', 'Report the result of an action, briefly.'],
381
+ ['Progress', 'Show how far a task has got.'],
382
+ ['Meter', 'Show a measured amount within known limits, such as storage used.'],
383
+ ]),
384
+ '',
385
+ `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.`,
386
+ '',
323
387
  );
324
388
  }
325
389
  return `${out.join('\n')}\n`;
@@ -384,7 +448,7 @@ function cover({ theme, brand, title }) {
384
448
  function agentsGuide({ theme, readme, componentNames }) {
385
449
  return `# ${cap(theme.brand)} theme: guide for agents
386
450
 
387
- 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-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.
451
+ 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.
388
452
  ${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` : ''}
389
453
  Change the brand only in the brand file and rebuild (\`loomiere build\`); never edit generated files.
390
454