@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.
- package/build/build-tokens.mjs +33 -6
- package/exporters/chakra.mjs +74 -0
- package/exporters/component-styles.mjs +64 -0
- package/exporters/design-system.mjs +23 -7
- package/exporters/mui.mjs +60 -0
- package/figma/library/pages/18-custom/01-custom.js +57 -0
- package/figma/library/pages/18-custom/02-labels.js +7 -0
- package/figma/library/pages/18-custom/03-doc.js +28 -0
- package/figma/library/pages/18-custom/04-date-picker.js +327 -0
- package/figma/library/pages/18-custom/05-date-picker-labels.js +9 -0
- package/figma/library/pages/18-custom/06-date-picker-doc.js +25 -0
- package/figma/library/pages/18-custom/07-money-field.js +107 -0
- package/figma/library/pages/18-custom/08-money-field-labels.js +8 -0
- package/figma/library/pages/18-custom/09-money-field-doc.js +101 -0
- package/figma/library/pages/18-custom/10-splitter.js +100 -0
- package/figma/library/pages/18-custom/11-splitter-labels.js +8 -0
- package/figma/library/pages/18-custom/12-splitter-doc.js +92 -0
- package/figma/library/pages/18-custom/13-empty-state.js +114 -0
- package/figma/library/pages/18-custom/14-empty-state-labels.js +8 -0
- package/figma/library/pages/18-custom/15-empty-state-doc.js +58 -0
- package/figma/library/pages/18-custom/16-collection.js +255 -0
- package/figma/library/pages/18-custom/17-collection-doc.js +43 -0
- package/figma/library/run-order.json +1 -0
- package/lib/contrast.mjs +17 -0
- package/package.json +1 -1
- package/tokens/README.md +10 -3
- package/tokens/component/badge.json +53 -0
- package/tokens/component/collection.json +49 -0
- package/tokens/component/list-detail.json +29 -0
- package/tokens/component/money-field.json +9 -0
- package/tokens/component/splitter.json +25 -0
- package/tokens/semantic/accessibility.json +4 -0
- package/tokens/semantic/calendar.json +75 -0
- package/tokens/semantic/empty.json +79 -0
- package/tokens/semantic/field.json +4 -0
- package/tokens/semantic/popup.json +2 -2
- package/tokens/semantic/table.json +8 -0
- package/tools/brand-builder/builder.js +45 -0
- package/tools/brand-builder/index.html +6 -0
package/build/build-tokens.mjs
CHANGED
|
@@ -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: [
|
|
126
|
-
|
|
127
|
-
|
|
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 =
|
|
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')
|
|
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(${
|
|
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
|
}
|
package/exporters/chakra.mjs
CHANGED
|
@@ -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\`).
|
|
349
|
-
'- **
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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) };
|