@greese/ui 1.1.0 → 1.2.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/README.md +19 -14
- package/dist/_maps.scss +1 -1
- package/dist/_tokens-base.scss +30 -6
- package/dist/_tokens.scss +31 -2
- package/dist/_utilities-table.scss +1615 -0
- package/dist/clipboard.d.ts +18 -0
- package/dist/clipboard.js +62 -0
- package/dist/css/Button.css +2 -2
- package/dist/css/Carousel.css +145 -47
- package/dist/css/Checkbox.css +2 -2
- package/dist/css/Chip.css +38 -8
- package/dist/css/ChoiceGroup.css +42 -1
- package/dist/css/Collapsible.css +1 -0
- package/dist/css/ColorPicker.css +84 -0
- package/dist/css/Combobox.css +2 -2
- package/dist/css/Dialog.css +24 -22
- package/dist/css/EditableField.css +219 -0
- package/dist/css/Input.css +11 -2
- package/dist/css/Menu.css +9 -0
- package/dist/css/NumberField.css +53 -8
- package/dist/css/NumberStepper.css +9 -0
- package/dist/css/OtpField.css +1 -1
- package/dist/css/Radio.css +2 -2
- package/dist/css/ScrollCarousel.css +225 -0
- package/dist/css/SegmentedControl.css +2 -2
- package/dist/css/Select.css +3 -2
- package/dist/css/Slider.css +45 -5
- package/dist/css/Switch.css +6 -6
- package/dist/css/Text.css +14 -14
- package/dist/css/Textarea.css +1 -1
- package/dist/css/core.css +36 -6
- package/dist/css/resets.css +162 -0
- package/dist/css.d.ts +2 -0
- package/dist/css.js +2 -0
- package/dist/greese.css +978 -138
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/scss/components/_Accordion.scss +2 -0
- package/dist/scss/components/_ActionSelect.scss +2 -0
- package/dist/scss/components/_Alert.scss +2 -0
- package/dist/scss/components/_AppBar.scss +2 -0
- package/dist/scss/components/_AspectRatio.scss +2 -0
- package/dist/scss/components/_AutoLayout.scss +2 -0
- package/dist/scss/components/_Avatar.scss +2 -0
- package/dist/scss/components/_Badge.scss +2 -0
- package/dist/scss/components/_Box.scss +2 -0
- package/dist/scss/components/_Breadcrumbs.scss +2 -0
- package/dist/scss/components/_Button.scss +2 -0
- package/dist/scss/components/_ButtonGroup.scss +2 -0
- package/dist/scss/components/_Card.scss +2 -0
- package/dist/scss/components/_Carousel.scss +2 -0
- package/dist/scss/components/_Center.scss +2 -0
- package/dist/scss/components/_ChatBubble.scss +2 -0
- package/dist/scss/components/_Checkbox.scss +2 -0
- package/dist/scss/components/_Chip.scss +2 -0
- package/dist/scss/components/_ChoiceGroup.scss +2 -0
- package/dist/scss/components/_Code.scss +2 -0
- package/dist/scss/components/_Collapsible.scss +2 -0
- package/dist/scss/components/_ColorPicker.scss +2 -0
- package/dist/scss/components/_Combobox.scss +2 -0
- package/dist/scss/components/_Container.scss +2 -0
- package/dist/scss/components/_Countdown.scss +2 -0
- package/dist/scss/components/_DateField.scss +2 -0
- package/dist/scss/components/_Dialog.scss +2 -0
- package/dist/scss/components/_Diff.scss +2 -0
- package/dist/scss/components/_Divider.scss +2 -0
- package/dist/scss/components/_Dock.scss +2 -0
- package/dist/scss/components/_EditableField.scss +2 -0
- package/dist/scss/components/_Field.scss +2 -0
- package/dist/scss/components/_Fieldset.scss +2 -0
- package/dist/scss/components/_FileInput.scss +2 -0
- package/dist/scss/components/_Footer.scss +2 -0
- package/dist/scss/components/_Grid.scss +2 -0
- package/dist/scss/components/_Header.scss +2 -0
- package/dist/scss/components/_Heading.scss +2 -0
- package/dist/scss/components/_Icon.scss +2 -0
- package/dist/scss/components/_IconButton.scss +2 -0
- package/dist/scss/components/_Image.scss +2 -0
- package/dist/scss/components/_Indicator.scss +2 -0
- package/dist/scss/components/_Input.scss +2 -0
- package/dist/scss/components/_Key.scss +2 -0
- package/dist/scss/components/_Link.scss +2 -0
- package/dist/scss/components/_List.scss +2 -0
- package/dist/scss/components/_Media.scss +2 -0
- package/dist/scss/components/_Menu.scss +2 -0
- package/dist/scss/components/_Motion.scss +2 -0
- package/dist/scss/components/_NumberField.scss +2 -0
- package/dist/scss/components/_NumberStepper.scss +2 -0
- package/dist/scss/components/_OtpField.scss +2 -0
- package/dist/scss/components/_PageTransition.scss +2 -0
- package/dist/scss/components/_Pagination.scss +2 -0
- package/dist/scss/components/_Popover.scss +2 -0
- package/dist/scss/components/_Progress.scss +2 -0
- package/dist/scss/components/_RadialProgress.scss +2 -0
- package/dist/scss/components/_Radio.scss +2 -0
- package/dist/scss/components/_Rating.scss +2 -0
- package/dist/scss/components/_ScrollCarousel.scss +2 -0
- package/dist/scss/components/_ScrollSpy.scss +2 -0
- package/dist/scss/components/_SegmentedControl.scss +2 -0
- package/dist/scss/components/_Select.scss +2 -0
- package/dist/scss/components/_Sidebar.scss +2 -0
- package/dist/scss/components/_Skeleton.scss +2 -0
- package/dist/scss/components/_Slider.scss +2 -0
- package/dist/scss/components/_Spacer.scss +2 -0
- package/dist/scss/components/_SpeedDial.scss +2 -0
- package/dist/scss/components/_Spinner.scss +2 -0
- package/dist/scss/components/_Stack.scss +2 -0
- package/dist/scss/components/_Stat.scss +2 -0
- package/dist/scss/components/_Status.scss +2 -0
- package/dist/scss/components/_Steps.scss +2 -0
- package/dist/scss/components/_Swap.scss +2 -0
- package/dist/scss/components/_Switch.scss +2 -0
- package/dist/scss/components/_Table.scss +2 -0
- package/dist/scss/components/_Tabs.scss +2 -0
- package/dist/scss/components/_Text.scss +2 -0
- package/dist/scss/components/_TextRotate.scss +2 -0
- package/dist/scss/components/_Textarea.scss +2 -0
- package/dist/scss/components/_ThemeProvider.scss +2 -0
- package/dist/scss/components/_Tile.scss +2 -0
- package/dist/scss/components/_Toast.scss +2 -0
- package/dist/scss/components/_ToggleButton.scss +2 -0
- package/dist/scss/components/_Tooltip.scss +2 -0
- package/dist/scss/components/_Type.scss +2 -0
- package/dist/scss/components/_Visibility.scss +2 -0
- package/dist/scss/components/_VisuallyHidden.scss +2 -0
- package/dist/shapes.d.ts +12 -1
- package/dist/shapes.js +11 -10
- package/dist/style-manifest.json +18830 -132
- package/dist/theme.js +6 -1
- package/dist/themeFiles/index.d.ts +9 -0
- package/dist/themeFiles/index.js +9 -0
- package/dist/themeFiles/scssModule.d.ts +100 -0
- package/dist/themeFiles/scssModule.js +384 -0
- package/dist/themeFiles/spec.d.ts +198 -0
- package/dist/themeFiles/spec.js +877 -0
- package/dist/tokens.css +30 -1
- package/dist/typography.js +1 -1
- package/dist/utilities.css +435 -0
- package/dist/utilities.d.ts +8 -0
- package/dist/utilities.js +75 -0
- package/package.json +20 -1
- package/src/components/AutoLayout/AutoLayout.scss +2 -26
- package/src/components/Carousel/Carousel.scss +122 -126
- package/src/components/Checkbox/Checkbox.scss +2 -2
- package/src/components/Chip/Chip.scss +63 -8
- package/src/components/ChoiceGroup/ChoiceGroup.scss +19 -1
- package/src/components/Collapsible/Collapsible.scss +3 -0
- package/src/components/ColorPicker/ColorPicker.scss +95 -0
- package/src/components/Combobox/Combobox.scss +4 -2
- package/src/components/Dialog/Dialog.scss +24 -7
- package/src/components/EditableField/EditableField.scss +275 -0
- package/src/components/Grid/Grid.scss +2 -70
- package/src/components/Input/Input.scss +29 -0
- package/src/components/Menu/Menu.scss +18 -0
- package/src/components/NumberField/NumberField.scss +56 -9
- package/src/components/NumberStepper/NumberStepper.scss +13 -0
- package/src/components/Radio/Radio.scss +2 -2
- package/src/components/ScrollCarousel/ScrollCarousel.scss +142 -0
- package/src/components/Select/Select.scss +4 -0
- package/src/components/Slider/Slider.scss +51 -6
- package/src/components/Stack/Stack.scss +2 -48
- package/src/components/Switch/Switch.scss +9 -6
- package/src/components/Text/Text.scss +5 -2
- package/src/styles/_breakpoint-vars.scss +17 -11
- package/src/styles/_controls.scss +2 -2
- package/src/styles/_core.scss +1 -0
- package/src/styles/_kit.scss +483 -56
- package/src/styles/_layout-props.scss +197 -0
- package/src/styles/_manifest.scss +53 -23
- package/src/styles/_mixins.scss +53 -15
- package/src/styles/_slides.scss +112 -0
- package/src/styles/_utilities.scss +104 -0
- package/src/styles/breakpoints.test.ts +21 -1
- package/src/styles/entryPoints.test.ts +25 -2
- package/src/styles/index.scss +3 -1
- package/src/styles/kit.test.ts +171 -1
- package/src/styles/public/_breakpoints.scss +43 -0
- package/src/styles/public/_slides.scss +3 -0
- package/src/styles/public/_utilities.scss +3 -0
- package/src/styles/resets.scss +7 -0
- package/src/styles/utilities.test.ts +39 -0
- package/themes/bevel.css +187 -187
- package/themes/bevel.scss +960 -958
- package/themes/extrude.css +42 -42
- package/themes/folio.css +200 -200
- package/themes/folio.scss +880 -878
- package/themes/outline.css +18 -18
- package/themes/pebble.css +8 -8
- package/themes/ridge.css +188 -188
- package/themes/ridge.scss +820 -818
- package/themes/soft.css +11 -11
- package/themes/ticket.css +180 -180
- package/themes/ticket.scss +881 -879
- package/themes/tilt.css +25 -25
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A theme as data: plain JSON, the file Theme Studio (or any other tool)
|
|
3
|
+
* reads and writes. `specToLayout()` turns it into a folder of SCSS modules
|
|
4
|
+
* that use the SCSS kit (`pkg:@greese/ui/scss/kit`), to keep in your source
|
|
5
|
+
* and edit a part at a time; `specToScss()` into one file. Either compiles
|
|
6
|
+
* exactly like a theme written by hand. Pure: Node and the browser both use
|
|
7
|
+
* it (`@greese/ui/theme-files`).
|
|
8
|
+
*/
|
|
9
|
+
import { breakpoints as defaultBreakpoints, color, cssProperty, effectClass, cssPlan, entranceNames, exitNames, isBuiltInEffect, scalarTokens, typography as typeDefaults, LAYER_ORDER, } from "../index.js";
|
|
10
|
+
import { shapeClass, shapeParams, shapeProperties, shapeSelector, shapesCss, shapeTarget, } from "../shapes.js";
|
|
11
|
+
import { declarationProblems, mergeUses, scssProblems, splitScss, } from "./scssModule.js";
|
|
12
|
+
import { kebab } from "../kebab.js";
|
|
13
|
+
/** What a page rule can set. */
|
|
14
|
+
export const PAGE_RULE_KEYS = ['enter', 'exit', 'duration', 'delay', 'ease'];
|
|
15
|
+
export const emptySpec = () => ({ tokens: {}, components: {} });
|
|
16
|
+
/** The breakpoints in effect, smallest first: the theme's, else the defaults. */
|
|
17
|
+
export const breakpointsOf = (spec) => Object.entries(spec.breakpoints ?? defaultBreakpoints).sort(([, a], [, b]) => a - b);
|
|
18
|
+
/** The theme's own breakpoints, if they differ from the defaults. */
|
|
19
|
+
export const customBreakpoints = (spec) => {
|
|
20
|
+
const own = breakpointsOf(spec);
|
|
21
|
+
const standard = Object.entries(defaultBreakpoints);
|
|
22
|
+
const same = own.length === standard.length &&
|
|
23
|
+
own.every(([key, width], i) => key === standard[i][0] && width === standard[i][1]);
|
|
24
|
+
return spec.breakpoints && !same ? own : undefined;
|
|
25
|
+
};
|
|
26
|
+
/** A Sass map of breakpoints: `(xs: 0px, tablet: 700px, '2xl': 1800px)`. */
|
|
27
|
+
const breakpointMap = (list) => `(${list.map(([key, width]) => `${/^\d/.test(key) ? `'${key}'` : key}: ${width}px`).join(', ')})`;
|
|
28
|
+
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
29
|
+
const COMPONENT = /^[A-Z][A-Za-z]*$/;
|
|
30
|
+
const EFFECT_NAME = /^[a-z][A-Za-z0-9]*$/;
|
|
31
|
+
const PROPERTY = /^(--)?[a-z][a-z0-9-]*$/;
|
|
32
|
+
const KEY = /^[a-z0-9][a-z0-9-]*$/;
|
|
33
|
+
/** A type property, as the CSS property in its JavaScript form: `fontSize`. */
|
|
34
|
+
const TYPE_PROP = /^[a-z][a-zA-Z]*$/;
|
|
35
|
+
// Values become CSS text: nothing that could end a declaration or a rule.
|
|
36
|
+
const UNSAFE = /[;{}]|<\//;
|
|
37
|
+
const SHAPE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
38
|
+
const PARAM = /^[a-z][a-z0-9]*$/;
|
|
39
|
+
/** Problems that would make the generated SCSS unsafe or invalid. */
|
|
40
|
+
export const validateSpec = (spec) => {
|
|
41
|
+
const errors = [];
|
|
42
|
+
const value = (where, v) => {
|
|
43
|
+
const values = typeof v === 'object' && v !== null ? Object.values(v) : [v];
|
|
44
|
+
for (const s of values)
|
|
45
|
+
if (typeof s !== 'string' || UNSAFE.test(s))
|
|
46
|
+
errors.push(`${where}: ${JSON.stringify(s)} is not a CSS value`);
|
|
47
|
+
};
|
|
48
|
+
const key = (where, k, pattern = KEY) => {
|
|
49
|
+
if (!pattern.test(k))
|
|
50
|
+
errors.push(`${where}: ${JSON.stringify(k)} is not a valid name`);
|
|
51
|
+
};
|
|
52
|
+
const decls = (where, d) => {
|
|
53
|
+
for (const [p, v] of Object.entries(d ?? {})) {
|
|
54
|
+
key(where, p, PROPERTY);
|
|
55
|
+
value(`${where} ${p}`, v);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
if (spec.name && !NAME.test(spec.name))
|
|
59
|
+
errors.push(`name: ${JSON.stringify(spec.name)} must be lower-case letters, digits and -`);
|
|
60
|
+
for (const [group, values] of Object.entries(spec.tokens ?? {})) {
|
|
61
|
+
key('tokens', group);
|
|
62
|
+
for (const [k, v] of Object.entries(values)) {
|
|
63
|
+
key(`tokens.${group}`, k);
|
|
64
|
+
value(`tokens.${group}.${k}`, v);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (spec.breakpoints) {
|
|
68
|
+
const keys = Object.keys(spec.breakpoints);
|
|
69
|
+
for (const built of Object.keys(defaultBreakpoints))
|
|
70
|
+
if (!keys.includes(built))
|
|
71
|
+
errors.push(`breakpoints: ${built} is needed, components use it`);
|
|
72
|
+
for (const [k, width] of Object.entries(spec.breakpoints)) {
|
|
73
|
+
if (!/^[a-z0-9]+$/i.test(k))
|
|
74
|
+
errors.push(`breakpoints.${k}: a key is one word, letters and digits`);
|
|
75
|
+
if (!Number.isInteger(width) || width < 0)
|
|
76
|
+
errors.push(`breakpoints.${k}: ${JSON.stringify(width)} is not a width in px`);
|
|
77
|
+
}
|
|
78
|
+
if (spec.breakpoints.xs !== 0)
|
|
79
|
+
errors.push('breakpoints.xs: must be 0');
|
|
80
|
+
const widths = Object.values(spec.breakpoints);
|
|
81
|
+
if (new Set(widths).size !== widths.length)
|
|
82
|
+
errors.push('breakpoints: every key needs its own width');
|
|
83
|
+
}
|
|
84
|
+
for (const [variant, points] of Object.entries(spec.typography ?? {})) {
|
|
85
|
+
key('typography', variant);
|
|
86
|
+
for (const [point, style] of Object.entries(points)) {
|
|
87
|
+
key(`typography.${variant}`, point);
|
|
88
|
+
for (const [prop, v] of Object.entries(style)) {
|
|
89
|
+
key(`typography.${variant}.${point}`, prop, TYPE_PROP);
|
|
90
|
+
value(`typography.${variant}.${point}.${prop}`, v);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const [k, v] of Object.entries(spec.motionDefaults ?? {})) {
|
|
95
|
+
key('motionDefaults', k);
|
|
96
|
+
value(`motionDefaults.${k}`, v);
|
|
97
|
+
}
|
|
98
|
+
const entrances = new Set(entranceNames().map(effectClass));
|
|
99
|
+
const exits = new Set(exitNames().map(effectClass));
|
|
100
|
+
for (const [selector, rule] of Object.entries(spec.pageRules ?? {})) {
|
|
101
|
+
const where = `pageRules[${JSON.stringify(selector)}]`;
|
|
102
|
+
// It's written into a stylesheet: nothing that could end a rule.
|
|
103
|
+
if (!selector.trim() || /[<>{};]/.test(selector)) {
|
|
104
|
+
errors.push(`${where}: not a selector`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (rule === 'still')
|
|
108
|
+
continue;
|
|
109
|
+
if (typeof rule !== 'object' || rule === null) {
|
|
110
|
+
errors.push(`${where}: \`still\`, or ${PAGE_RULE_KEYS.join(', ')}`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
for (const [k, v] of Object.entries(rule)) {
|
|
114
|
+
if (!PAGE_RULE_KEYS.includes(k))
|
|
115
|
+
errors.push(`${where}.${k}: a rule takes ${PAGE_RULE_KEYS.join(', ')}`);
|
|
116
|
+
value(`${where}.${k}`, v);
|
|
117
|
+
const names = k === 'enter' ? entrances : k === 'exit' ? exits : undefined;
|
|
118
|
+
if (names && v && v !== 'none' && !names.has(v))
|
|
119
|
+
errors.push(`${where}.${k}: ${JSON.stringify(v)} is not ${k === 'enter' ? 'an entrance' : 'an exit'}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const [name, effect] of Object.entries(spec.effects ?? {})) {
|
|
123
|
+
if (!EFFECT_NAME.test(name))
|
|
124
|
+
errors.push(`effects: ${JSON.stringify(name)} must be camelCase letters and digits`);
|
|
125
|
+
else if (isBuiltInEffect(name))
|
|
126
|
+
errors.push(`effects: ${name} is a built-in effect, which keeps its own keyframes; name yours differently and assign it`);
|
|
127
|
+
const where = `effects.${name}`;
|
|
128
|
+
const frames = effect?.keyframes;
|
|
129
|
+
const list = Array.isArray(frames)
|
|
130
|
+
? frames
|
|
131
|
+
: frames && typeof frames === 'object'
|
|
132
|
+
? [frames]
|
|
133
|
+
: [];
|
|
134
|
+
if (!list.length ||
|
|
135
|
+
!list.every((f) => f && typeof f === 'object' && !Array.isArray(f)))
|
|
136
|
+
errors.push(`${where}: keyframes are an object, or a list of objects`);
|
|
137
|
+
for (const frame of list) {
|
|
138
|
+
for (const [prop, v] of Object.entries(frame)) {
|
|
139
|
+
if (prop === 'offset') {
|
|
140
|
+
if (!(typeof v === 'number' && v >= 0 && v <= 1))
|
|
141
|
+
errors.push(`${where}: offset ${JSON.stringify(v)} is not a number from 0 to 1`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (prop === 'ease') {
|
|
145
|
+
// An ease's name, with numbers if it takes them (`steps(4)`,
|
|
146
|
+
// `elastic.out(1, 0.3)`), or a cubic Bézier's four numbers.
|
|
147
|
+
const named = typeof v === 'string' && /^[A-Za-z][\w.-]*(\([\d., ]*\))?$/.test(v);
|
|
148
|
+
const bezier = Array.isArray(v) &&
|
|
149
|
+
v.length === 4 &&
|
|
150
|
+
v.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
151
|
+
if (!named && !bezier)
|
|
152
|
+
errors.push(`${where}: ease ${JSON.stringify(v)} is not an ease name or four numbers`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!/^(--)?[A-Za-z][A-Za-z0-9-]*$/.test(prop))
|
|
156
|
+
errors.push(`${where}: ${JSON.stringify(prop)} is not a property name`);
|
|
157
|
+
for (const x of Array.isArray(v) ? v : [v])
|
|
158
|
+
if (!(typeof x === 'number' && Number.isFinite(x)) &&
|
|
159
|
+
(typeof x !== 'string' || UNSAFE.test(x)))
|
|
160
|
+
errors.push(`${where} ${prop}: ${JSON.stringify(x)} is not a value`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (effect?.duration !== undefined && !(effect.duration > 0))
|
|
164
|
+
errors.push(`${where}: duration is a number of seconds above 0`);
|
|
165
|
+
}
|
|
166
|
+
for (const [name, c] of Object.entries(spec.components ?? {})) {
|
|
167
|
+
key('components', name, COMPONENT);
|
|
168
|
+
decls(`${name} styles`, c.styles);
|
|
169
|
+
for (const [k, v] of Object.entries(c.vars ?? {})) {
|
|
170
|
+
key(`${name} vars`, k);
|
|
171
|
+
value(`${name} vars.${k}`, v);
|
|
172
|
+
}
|
|
173
|
+
for (const [m, d] of Object.entries(c.modifiers ?? {})) {
|
|
174
|
+
key(`${name} modifiers`, m);
|
|
175
|
+
decls(`${name}--${m}`, d);
|
|
176
|
+
}
|
|
177
|
+
for (const [p, d] of Object.entries(c.parts ?? {})) {
|
|
178
|
+
key(`${name} parts`, p);
|
|
179
|
+
decls(`${name}-${p}`, d);
|
|
180
|
+
}
|
|
181
|
+
if (c.scss)
|
|
182
|
+
errors.push(...scssProblems(c.scss).map((p) => `${name} scss: ${p}`));
|
|
183
|
+
const { durations, easings, ...motion } = c.motion ?? {};
|
|
184
|
+
for (const [k, v] of Object.entries({ ...motion, ...durations, ...easings }))
|
|
185
|
+
value(`${name} motion.${k}`, v);
|
|
186
|
+
}
|
|
187
|
+
for (const [name, shape] of Object.entries(spec.shapes ?? {})) {
|
|
188
|
+
const where = `shapes.${name}`;
|
|
189
|
+
if (!SHAPE_NAME.test(name))
|
|
190
|
+
errors.push(`shapes: ${JSON.stringify(name)} is not a kebab-case name`);
|
|
191
|
+
if (!Array.isArray(shape.points) || shape.points.length < 3)
|
|
192
|
+
errors.push(`${where}: a shape needs at least 3 points`);
|
|
193
|
+
for (const [i, p] of (shape.points ?? []).entries())
|
|
194
|
+
for (const [axis, l] of [
|
|
195
|
+
['x', p?.x],
|
|
196
|
+
['y', p?.y],
|
|
197
|
+
]) {
|
|
198
|
+
if (!l || typeof l !== 'object') {
|
|
199
|
+
errors.push(`${where} point ${i + 1}: no ${axis}`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
for (const k of ['pct', 'px', 'k'])
|
|
203
|
+
if (l[k] !== undefined && !Number.isFinite(l[k]))
|
|
204
|
+
errors.push(`${where} point ${i + 1} ${axis}.${k}: not a number`);
|
|
205
|
+
if (l.param !== undefined && !PARAM.test(l.param))
|
|
206
|
+
errors.push(`${where} point ${i + 1} ${axis}.param: ${JSON.stringify(l.param)} is not a parameter name`);
|
|
207
|
+
}
|
|
208
|
+
for (const state of ['params', 'hover', 'active'])
|
|
209
|
+
for (const [k, v] of Object.entries(shape[state] ?? {})) {
|
|
210
|
+
if (!PARAM.test(k))
|
|
211
|
+
errors.push(`${where}.${state}: ${JSON.stringify(k)} is not a parameter name`);
|
|
212
|
+
if (!Number.isFinite(v))
|
|
213
|
+
errors.push(`${where}.${state}.${k}: not a number of px`);
|
|
214
|
+
}
|
|
215
|
+
if (shape.border?.color && UNSAFE.test(shape.border.color))
|
|
216
|
+
errors.push(`${where}.border.color: ${JSON.stringify(shape.border.color)} is not a value`);
|
|
217
|
+
}
|
|
218
|
+
for (const [name, c] of Object.entries(spec.components ?? {}))
|
|
219
|
+
if (c.shape && !spec.shapes?.[c.shape])
|
|
220
|
+
errors.push(`${name} shape: the theme has no shape named ${JSON.stringify(c.shape)}`);
|
|
221
|
+
errors.push(...mergedUses(spec, 'stylesheet').problems);
|
|
222
|
+
errors.push(...declarationProblems(componentSplits(spec).map(([name, split]) => [name, split.declarations])));
|
|
223
|
+
return errors;
|
|
224
|
+
};
|
|
225
|
+
/** The imports the theme file writes itself, which a component's own may repeat but not contradict. */
|
|
226
|
+
const reservedUses = (spec, entry) => [
|
|
227
|
+
{ url: 'pkg:@greese/ui/scss/kit', namespace: 'greese' },
|
|
228
|
+
...(customBreakpoints(spec)
|
|
229
|
+
? [
|
|
230
|
+
entry === 'config'
|
|
231
|
+
? { url: 'pkg:@greese/ui/scss/config', namespace: 'config' }
|
|
232
|
+
: { url: 'pkg:@greese/ui/scss', namespace: 'scss' },
|
|
233
|
+
]
|
|
234
|
+
: []),
|
|
235
|
+
];
|
|
236
|
+
/** Every component with SCSS, split into imports, declarations and body, by name. */
|
|
237
|
+
const componentSplits = (spec) => Object.keys(spec.components ?? {})
|
|
238
|
+
.sort()
|
|
239
|
+
.filter((name) => spec.components[name].scss?.trim())
|
|
240
|
+
.map((name) => [name, splitScss(spec.components[name].scss)]);
|
|
241
|
+
/** Every component's SCSS imports, merged, for the top of the theme file. */
|
|
242
|
+
const mergedUses = (spec, entry) => mergeUses(componentSplits(spec).map(([name, split]) => [name, split.uses]), reservedUses(spec, entry));
|
|
243
|
+
/** The token names the system has, by kebab-case group: `color`, `radius`, `line-height`… */
|
|
244
|
+
const KNOWN = {
|
|
245
|
+
color: new Set(Object.keys(color).map(kebab)),
|
|
246
|
+
...Object.fromEntries(Object.entries(scalarTokens).map(([group, values]) => [
|
|
247
|
+
kebab(group),
|
|
248
|
+
new Set(Object.keys(values).map(kebab)),
|
|
249
|
+
])),
|
|
250
|
+
};
|
|
251
|
+
/**
|
|
252
|
+
* A token the theme adds: its group exists but the system has no token of
|
|
253
|
+
* that name. (A group that doesn't exist stays an override, so the kit
|
|
254
|
+
* reports it.)
|
|
255
|
+
*/
|
|
256
|
+
export const isCustomToken = (group, key) => group in KNOWN && !KNOWN[group].has(key);
|
|
257
|
+
const isEmpty = (o) => !o || Object.values(o).every((v) => v === '' || v === undefined);
|
|
258
|
+
/** A Sass string: the kit unquotes it when it writes CSS. */
|
|
259
|
+
const str = (value) => JSON.stringify(value);
|
|
260
|
+
const schemeValue = (v) => typeof v === 'string' ? str(v) : `(light: ${str(v.light)}, dark: ${str(v.dark)})`;
|
|
261
|
+
/** A Sass map literal, one entry per line. */
|
|
262
|
+
const map = (entries, indent) => `(\n${entries.map(([k, v]) => `${indent} ${k}: ${v},`).join('\n')}\n${indent})`;
|
|
263
|
+
const filled = (record) => Object.entries(record ?? {}).filter(([, v]) => v !== '' && v !== undefined && !(typeof v === 'object' && isEmpty(v)));
|
|
264
|
+
const declarations = (d, indent) => filled(d).map(([p, v]) => `${indent}${p}: ${v};`);
|
|
265
|
+
const componentScss = (name, c, indent, mark) => {
|
|
266
|
+
const inner = `${indent} `;
|
|
267
|
+
const lines = declarations(c.styles, inner);
|
|
268
|
+
const vars = filled(c.vars);
|
|
269
|
+
if (vars.length)
|
|
270
|
+
lines.push(`${inner}@include greese.vars(${map(vars.map(([k, v]) => [k, schemeValue(v)]), inner)});`);
|
|
271
|
+
for (const [kind, targets] of [
|
|
272
|
+
['modifier', c.modifiers],
|
|
273
|
+
['part', c.parts],
|
|
274
|
+
]) {
|
|
275
|
+
for (const [target, d] of filled(targets)) {
|
|
276
|
+
const body = declarations(d, `${inner} `);
|
|
277
|
+
if (body.length)
|
|
278
|
+
lines.push(`${inner}@include greese.${kind}(${target}) {\n${body.join('\n')}\n${inner}}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const { durations, easings, ...motion } = c.motion ?? {};
|
|
282
|
+
const options = filled(motion).map(([k, v]) => [k, str(v)]);
|
|
283
|
+
for (const [k, group] of [
|
|
284
|
+
['durations', durations],
|
|
285
|
+
['easings', easings],
|
|
286
|
+
]) {
|
|
287
|
+
const entries = filled(group);
|
|
288
|
+
if (entries.length)
|
|
289
|
+
options.push([k, `(${entries.map(([key, v]) => `${key}: ${str(v)}`).join(', ')})`]);
|
|
290
|
+
}
|
|
291
|
+
if (options.length)
|
|
292
|
+
lines.push(`${inner}@include greese.motion(${map(options, inner)});`);
|
|
293
|
+
if (c.scss?.trim()) {
|
|
294
|
+
// Only the body: the imports go at the top of the file. Its first line
|
|
295
|
+
// carries a marker, so an error can be traced back to the box and line.
|
|
296
|
+
const { body, bodyLine } = splitScss(c.scss);
|
|
297
|
+
const bodyLines = body.trimEnd().split('\n');
|
|
298
|
+
if (body.trim())
|
|
299
|
+
lines.push(`${inner}${mark({ component: name, line: bodyLine, span: bodyLines.length })}${bodyLines
|
|
300
|
+
.map((line, i) => (i === 0 || !line ? line : `${inner}${line}`))
|
|
301
|
+
.join('\n')}`);
|
|
302
|
+
}
|
|
303
|
+
if (!lines.length)
|
|
304
|
+
return '';
|
|
305
|
+
return `${indent}@include greese.component(${name}) {\n${lines.join('\n')}\n${indent}}`;
|
|
306
|
+
};
|
|
307
|
+
/** Tokens as a Sass map, by group then key. */
|
|
308
|
+
const tokenMap = (list, indent) => map(list.map(([group, values]) => [
|
|
309
|
+
group,
|
|
310
|
+
map(values.map(([k, v]) => [/^\d/.test(k) ? `'${k}'` : k, schemeValue(v)]), `${indent} `),
|
|
311
|
+
]), indent);
|
|
312
|
+
/**
|
|
313
|
+
* The theme's tokens, by group: those the system has (`own`, overrides) and
|
|
314
|
+
* the rest (`added`, new ones, declared with `$extend`: the kit never
|
|
315
|
+
* guesses at names).
|
|
316
|
+
*/
|
|
317
|
+
const tokenLists = (spec) => {
|
|
318
|
+
const split = (custom) => filled(spec.tokens)
|
|
319
|
+
.map(([group, values]) => [
|
|
320
|
+
group,
|
|
321
|
+
filled(values).filter(([k]) => isCustomToken(group, k) === custom),
|
|
322
|
+
])
|
|
323
|
+
.filter(([, values]) => values.length);
|
|
324
|
+
return { own: split(false), added: split(true) };
|
|
325
|
+
};
|
|
326
|
+
/** One `tokens()` call: these overrides, and these new tokens as `$extend`. */
|
|
327
|
+
const tokensCall = (own, added, indent) => `${indent}@include greese.tokens(${own.length ? tokenMap(own, indent) : '()'}${added.length ? `, $extend: ${tokenMap(added, indent)}` : ''});`;
|
|
328
|
+
/** The type system's changes as a Sass map for `typography()`, if there are any. */
|
|
329
|
+
const typographyMap = (spec, indent) => {
|
|
330
|
+
const types = filled(spec.typography)
|
|
331
|
+
.map(([variant, points]) => [
|
|
332
|
+
variant,
|
|
333
|
+
filled(points)
|
|
334
|
+
.map(([point, style]) => [point, filled(style)])
|
|
335
|
+
.filter(([, values]) => values.length),
|
|
336
|
+
])
|
|
337
|
+
.filter(([, points]) => points.length);
|
|
338
|
+
if (!types.length)
|
|
339
|
+
return undefined;
|
|
340
|
+
return map(types.map(([variant, points]) => [
|
|
341
|
+
variant,
|
|
342
|
+
map(points.map(([point, values]) => [
|
|
343
|
+
point,
|
|
344
|
+
map(values.map(([prop, v]) => [cssProperty(prop), str(v)]), `${indent} `),
|
|
345
|
+
]), `${indent} `),
|
|
346
|
+
]), indent);
|
|
347
|
+
};
|
|
348
|
+
/** `motion-defaults()`'s options as a Sass map, if there are any. */
|
|
349
|
+
const motionDefaultsMap = (spec, indent) => {
|
|
350
|
+
const defaults = filled(spec.motionDefaults);
|
|
351
|
+
return defaults.length
|
|
352
|
+
? map(defaults.map(([k, v]) => [k, str(v)]), indent)
|
|
353
|
+
: undefined;
|
|
354
|
+
};
|
|
355
|
+
/** Selector rules for page transitions as a Sass map; a rule with nothing set says nothing. */
|
|
356
|
+
const pageRulesMap = (spec, indent) => {
|
|
357
|
+
const rules = Object.entries(spec.pageRules ?? {}).flatMap(([selector, rule]) => {
|
|
358
|
+
if (rule === 'still')
|
|
359
|
+
return [[str(selector), 'still']];
|
|
360
|
+
const set = filled(rule);
|
|
361
|
+
return set.length
|
|
362
|
+
? [
|
|
363
|
+
[
|
|
364
|
+
str(selector),
|
|
365
|
+
map(set.map(([k, v]) => [k, str(v)]), `${indent} `),
|
|
366
|
+
],
|
|
367
|
+
]
|
|
368
|
+
: [];
|
|
369
|
+
});
|
|
370
|
+
return rules.length ? map(rules, indent) : undefined;
|
|
371
|
+
};
|
|
372
|
+
/**
|
|
373
|
+
* What a theme file starts with. With its own breakpoints it builds the whole
|
|
374
|
+
* stylesheet with them (`pkg:@greese/ui/scss`, which the kit then shares), so
|
|
375
|
+
* the compiled CSS is the complete stylesheet. `config` brings in only the
|
|
376
|
+
* settings, no CSS, so the theme part can be compiled alone.
|
|
377
|
+
*/
|
|
378
|
+
const scssUseLines = (spec, entry) => {
|
|
379
|
+
const own = customBreakpoints(spec);
|
|
380
|
+
const kit = "@use 'pkg:@greese/ui/scss/kit' as greese;";
|
|
381
|
+
return own ? `${stylesheetScss(own, entry)}\n${kit}` : kit;
|
|
382
|
+
};
|
|
383
|
+
/** The line that builds the stylesheet (or just its settings) with these breakpoints. */
|
|
384
|
+
export const stylesheetScss = (breakpoints, entry = 'stylesheet') => `@use 'pkg:@greese/ui/${entry === 'config' ? 'scss/config' : 'scss'}' with ($breakpoints: ${breakpointMap(breakpoints)});`;
|
|
385
|
+
/**
|
|
386
|
+
* The theme as SCSS for the kit. Output is stable (sorted components) so the
|
|
387
|
+
* file diffs cleanly in version control.
|
|
388
|
+
*/
|
|
389
|
+
export const specToScss = (spec, source = 'greese-theme.json',
|
|
390
|
+
/** How the stylesheet is brought in: whole (the output), or just its settings. */
|
|
391
|
+
entry = 'stylesheet') => specToScssWithLines(spec, source, entry).scss;
|
|
392
|
+
const MARK = /\/\*§(\d+)§\*\//;
|
|
393
|
+
/**
|
|
394
|
+
* `specToScss`, and which component's SCSS each line of it came from, so a
|
|
395
|
+
* Sass error in the generated file points back at the box and line you wrote.
|
|
396
|
+
*/
|
|
397
|
+
export const specToScssWithLines = (spec, source = 'greese-theme.json', entry = 'stylesheet') => {
|
|
398
|
+
const marks = [];
|
|
399
|
+
const mark = (origin) => `/*§${marks.push(origin) - 1}§*/`;
|
|
400
|
+
const indent = spec.name ? ' ' : '';
|
|
401
|
+
const blocks = [];
|
|
402
|
+
const { own, added } = tokenLists(spec);
|
|
403
|
+
if (own.length || added.length)
|
|
404
|
+
blocks.push(tokensCall(own, added, indent));
|
|
405
|
+
const types = typographyMap(spec, indent);
|
|
406
|
+
if (types)
|
|
407
|
+
blocks.push(`${indent}@include greese.typography(${types});`);
|
|
408
|
+
const defaults = motionDefaultsMap(spec, indent);
|
|
409
|
+
if (defaults)
|
|
410
|
+
blocks.push(`${indent}@include greese.motion-defaults(${defaults});`);
|
|
411
|
+
const pageRules = pageRulesMap(spec, indent);
|
|
412
|
+
if (pageRules)
|
|
413
|
+
blocks.push(`${indent}@include greese.page-transitions(${pageRules});`);
|
|
414
|
+
for (const name of Object.keys(spec.components ?? {}).sort()) {
|
|
415
|
+
const block = componentScss(name, spec.components[name], indent, mark);
|
|
416
|
+
if (block)
|
|
417
|
+
blocks.push(block);
|
|
418
|
+
}
|
|
419
|
+
const body = spec.name
|
|
420
|
+
? `@include greese.theme(${spec.name}) {\n${blocks.join('\n\n')}\n}`
|
|
421
|
+
: blocks.join('\n\n');
|
|
422
|
+
// Effects first, outside the theme's scope (@keyframes are global): the kit
|
|
423
|
+
// learns their names here, so a component's `motion()` below can use them.
|
|
424
|
+
const effects = Object.entries(spec.effects ?? {})
|
|
425
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
426
|
+
.map(([name, effect]) => effectScss(name, effect));
|
|
427
|
+
// Components' own imports, once each, after the kit and any breakpoints
|
|
428
|
+
// (the partials load the configured settings).
|
|
429
|
+
const imports = mergedUses(spec, entry).lines.map(({ text, component, line }) => `${mark({ component, line, span: 1 })}${text}`);
|
|
430
|
+
// Then their top-level variables, mixins and functions, which Sass allows
|
|
431
|
+
// only at a file's top level, before any component uses them.
|
|
432
|
+
const declared = componentSplits(spec).flatMap(([component, { declarations }]) => declarations.map(({ text, line }) => `${mark({ component, line, span: text.split('\n').length })}${text.trim()}`));
|
|
433
|
+
const shapes = shapesScss(spec);
|
|
434
|
+
const marked = `// Generated by greese ui Theme Studio from ${source}. Edit it there, or in
|
|
435
|
+
// the studio (npm run studio); changes made here are overwritten.
|
|
436
|
+
${scssUseLines(spec, entry)}${imports.length ? `\n${imports.join('\n')}` : ''}${declared.length ? `\n\n${declared.join('\n\n')}` : ''}
|
|
437
|
+
${effects.length ? `\n${effects.join('\n\n')}\n` : ''}${body ? `\n${body}\n` : ''}${shapes ? `\n${shapes}\n` : ''}`;
|
|
438
|
+
// Strip the markers, keeping where each body and import landed.
|
|
439
|
+
const starts = [];
|
|
440
|
+
const scss = marked
|
|
441
|
+
.split('\n')
|
|
442
|
+
.map((text, i) => {
|
|
443
|
+
const found = MARK.exec(text);
|
|
444
|
+
if (!found)
|
|
445
|
+
return text;
|
|
446
|
+
starts.push([i + 1, marks[Number(found[1])]]);
|
|
447
|
+
return text.replace(MARK, '');
|
|
448
|
+
})
|
|
449
|
+
.join('\n');
|
|
450
|
+
const origin = (line) => {
|
|
451
|
+
const before = starts.filter(([at]) => at <= line).at(-1);
|
|
452
|
+
if (!before)
|
|
453
|
+
return undefined;
|
|
454
|
+
const [at, { span, ...from }] = before;
|
|
455
|
+
return line - at < span ? { ...from, line: from.line + (line - at) } : undefined;
|
|
456
|
+
};
|
|
457
|
+
return { scss, origin };
|
|
458
|
+
};
|
|
459
|
+
/**
|
|
460
|
+
* The theme's shapes (ADR 0024): their parameters registered, a
|
|
461
|
+
* `greese-shape-<name>` class for each, and the components that take one.
|
|
462
|
+
* Written by @greese/ui/shapes, the same code the Studio's editor previews
|
|
463
|
+
* with; each selector goes through the kit's `themed()`, which scopes it to
|
|
464
|
+
* the theme (`scope`, a Sass expression for its name) and, for a component,
|
|
465
|
+
* guards it for the shape layer, so `.greese-reset-shape` and `reset(shape)`
|
|
466
|
+
* reach shapes too.
|
|
467
|
+
*/
|
|
468
|
+
export const shapesScss = (spec, scope = spec.name ?? 'null', size) => {
|
|
469
|
+
const shapes = Object.fromEntries(filled(spec.shapes));
|
|
470
|
+
if (!Object.keys(shapes).length)
|
|
471
|
+
return '';
|
|
472
|
+
const themed = (selector, component) => `#{greese.themed(${str(selector)}${component ? `, ${component}` : ''})}`;
|
|
473
|
+
const uses = Object.keys(shapes).map((name) => ({
|
|
474
|
+
shape: name,
|
|
475
|
+
selector: themed(`.${shapeClass(name)}`),
|
|
476
|
+
}));
|
|
477
|
+
for (const name of Object.keys(spec.components ?? {}).sort()) {
|
|
478
|
+
const shape = spec.components[name].shape;
|
|
479
|
+
if (shape && shapes[shape])
|
|
480
|
+
uses.push({
|
|
481
|
+
...shapeTarget(name),
|
|
482
|
+
shape,
|
|
483
|
+
selector: themed(shapeSelector(name), name),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
const params = new Set(Object.values(shapes).flatMap((def) => shapeParams(def)));
|
|
487
|
+
const css = shapesCss(shapes, uses, size)
|
|
488
|
+
.split('\n')
|
|
489
|
+
.map((l) => (l ? ` ${l}` : l))
|
|
490
|
+
.join('\n');
|
|
491
|
+
return `// Shapes (ADR 0024)\n${shapeProperties(params)}\n${LAYER_ORDER}\n@layer greese.overrides {\n @include greese.scope(${scope}) {\n${css}\n }\n}`;
|
|
492
|
+
};
|
|
493
|
+
/**
|
|
494
|
+
* One effect of the theme as the kit's `effect()`: the same CSS the built-in
|
|
495
|
+
* effects have, planned by `cssPlan()` in @greese/ui from the keyframes alone.
|
|
496
|
+
* A built-in's name retunes it; any other adds one that `motion()` accepts.
|
|
497
|
+
*/
|
|
498
|
+
const effectScss = (name, { keyframes }) => {
|
|
499
|
+
const plan = cssPlan(keyframes);
|
|
500
|
+
const declarations = (values) => map(Object.entries(values).map(([p, v]) => [p, str(v)]), ' ');
|
|
501
|
+
const parts = [];
|
|
502
|
+
if (plan.enter)
|
|
503
|
+
parts.push([
|
|
504
|
+
'enter',
|
|
505
|
+
map(plan.enter.map(([at, values]) => [
|
|
506
|
+
/%$/.test(at) ? `'${at}'` : at,
|
|
507
|
+
declarations(values),
|
|
508
|
+
]), ' '),
|
|
509
|
+
]);
|
|
510
|
+
else
|
|
511
|
+
parts.push(['exit', Object.keys(plan.exit).length ? declarations(plan.exit) : '()']);
|
|
512
|
+
return `@include greese.effect(${effectClass(name)}, ${map(parts, '')});`;
|
|
513
|
+
};
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// The layout: a theme as a folder of SCSS modules (ADR 0034).
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
/**
|
|
518
|
+
* The first line of every file `specToLayout()` writes. A tool that rewrites
|
|
519
|
+
* the layout leaves a file alone once this line is gone: whoever deleted it
|
|
520
|
+
* maintains that file by hand now.
|
|
521
|
+
*/
|
|
522
|
+
export const LAYOUT_MARK = '// greese-theme: generated';
|
|
523
|
+
const header = (source, about) => `${LAYOUT_MARK} from ${source}. Delete this line to keep the file as yours:
|
|
524
|
+
// tools that regenerate the layout leave it alone from then on.
|
|
525
|
+
${about
|
|
526
|
+
.trim()
|
|
527
|
+
.split('\n')
|
|
528
|
+
.map((line) => (line ? `// ${line}` : '//'))
|
|
529
|
+
.join('\n')}
|
|
530
|
+
`;
|
|
531
|
+
const KIT = "@use 'pkg:@greese/ui/scss/kit' as greese;";
|
|
532
|
+
/** A file name for a name: `AccordionItem` → `accordion-item`. */
|
|
533
|
+
const fileName = (name) => kebab(name).replace(/^-/, '');
|
|
534
|
+
/** A Sass map's entries, live then commented out: what a file sets, then the defaults it leaves alone. */
|
|
535
|
+
const mapLines = (live, commented, indent) => [
|
|
536
|
+
...live.map(([k, v]) => `${indent}${k}: ${v},`),
|
|
537
|
+
...commented.map(([k, v]) => `${indent}// ${k}: ${v},`),
|
|
538
|
+
].join('\n');
|
|
539
|
+
/** A Sass map key: one that starts with a digit is quoted (`'4'`). */
|
|
540
|
+
const key = (k) => (/^\d/.test(k) ? `'${k}'` : k);
|
|
541
|
+
/** Every token group, kebab-case, with its keys' defaults: color first, as the docs list them. */
|
|
542
|
+
const TOKEN_DEFAULTS = [
|
|
543
|
+
['color', Object.entries(color).map(([k, v]) => [kebab(k), v])],
|
|
544
|
+
...Object.entries(scalarTokens).map(([group, values]) => [
|
|
545
|
+
kebab(group),
|
|
546
|
+
Object.entries(values).map(([k, v]) => [kebab(k), v]),
|
|
547
|
+
]),
|
|
548
|
+
];
|
|
549
|
+
/** One token group's file: what the theme sets, then every default it leaves, commented. */
|
|
550
|
+
const tokenGroupScss = (group, set, extra, defaults) => {
|
|
551
|
+
const taken = new Set(set.map(([k]) => k));
|
|
552
|
+
const live = set.map(([k, v]) => [key(k), schemeValue(v)]);
|
|
553
|
+
const rest = defaults
|
|
554
|
+
.filter(([k]) => !taken.has(k))
|
|
555
|
+
.map(([k, v]) => [key(k), schemeValue(v)]);
|
|
556
|
+
return `$tokens: (
|
|
557
|
+
${group}: (
|
|
558
|
+
${mapLines(live, rest, ' ')}
|
|
559
|
+
),
|
|
560
|
+
);
|
|
561
|
+
$extend: ${extra.length ? tokenMap([[group, extra]], '') : '()'};
|
|
562
|
+
`;
|
|
563
|
+
};
|
|
564
|
+
/** The type system's changes, then every default left alone, commented, as `$changes`. */
|
|
565
|
+
const typographyScss = (spec) => {
|
|
566
|
+
const points = ['base', ...breakpointsOf(spec).map(([k]) => k)];
|
|
567
|
+
const byPoint = (a, b) => points.indexOf(a) - points.indexOf(b);
|
|
568
|
+
const props = (style) => Object.entries(style).map(([p, v]) => [cssProperty(p), str(v)]);
|
|
569
|
+
const oneLine = (style) => `(${props(style)
|
|
570
|
+
.map(([p, v]) => `${p}: ${v}`)
|
|
571
|
+
.join(', ')})`;
|
|
572
|
+
const set = Object.fromEntries(filled(spec.typography).map(([variant, at]) => [
|
|
573
|
+
variant,
|
|
574
|
+
Object.fromEntries(filled(at)
|
|
575
|
+
.map(([point, style]) => [point, Object.fromEntries(filled(style))])
|
|
576
|
+
.filter(([, style]) => Object.keys(style).length)),
|
|
577
|
+
]));
|
|
578
|
+
const lines = [];
|
|
579
|
+
const variants = [
|
|
580
|
+
...Object.keys(set).filter((v) => Object.keys(set[v]).length),
|
|
581
|
+
...Object.keys(typeDefaults).filter((v) => !set[v] || !Object.keys(set[v]).length),
|
|
582
|
+
];
|
|
583
|
+
for (const variant of variants) {
|
|
584
|
+
const own = set[variant] ?? {};
|
|
585
|
+
const base = typeDefaults[variant] ??
|
|
586
|
+
{};
|
|
587
|
+
if (!Object.keys(own).length) {
|
|
588
|
+
// Untouched: the variant's defaults, commented out whole.
|
|
589
|
+
lines.push(` // ${variant}: (`, ...Object.keys(base)
|
|
590
|
+
.sort(byPoint)
|
|
591
|
+
.map((point) => ` // ${point}: ${oneLine(base[point])},`), ' // ),');
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
lines.push(` ${variant}: (`);
|
|
595
|
+
for (const point of Object.keys(own))
|
|
596
|
+
lines.push(` ${point}: (`, mapLines(props(own[point]), props(Object.fromEntries(Object.entries(base[point] ?? {}).filter(([p]) => !(p in own[point])))), ' '), ' ),');
|
|
597
|
+
for (const point of Object.keys(base)
|
|
598
|
+
.filter((p) => !own[p])
|
|
599
|
+
.sort(byPoint))
|
|
600
|
+
lines.push(` // ${point}: ${oneLine(base[point])},`);
|
|
601
|
+
lines.push(' ),');
|
|
602
|
+
}
|
|
603
|
+
return `$changes: (\n${lines.join('\n')}\n);\n`;
|
|
604
|
+
};
|
|
605
|
+
/**
|
|
606
|
+
* `motion-defaults()`'s options a theme leaves alone, with the library's own
|
|
607
|
+
* value where it has one (the rest are listed in the file's comment).
|
|
608
|
+
*/
|
|
609
|
+
const MOTION_BASES = [
|
|
610
|
+
['duration', '0.5s'],
|
|
611
|
+
['delay', '0s'],
|
|
612
|
+
['ease', 'out'],
|
|
613
|
+
['stagger', '0s'],
|
|
614
|
+
['theme', 'fade-up'],
|
|
615
|
+
['page', 'fade-in'],
|
|
616
|
+
['page-exit', 'fade-out'],
|
|
617
|
+
['page-exit-order', 'reverse'],
|
|
618
|
+
['page-mode', 'auto'],
|
|
619
|
+
];
|
|
620
|
+
/** The breakpoints, one per line, for `_settings.scss`. */
|
|
621
|
+
const breakpointLines = (list) => list.map(([k, width]) => ` ${/^\d/.test(k) ? `'${k}'` : k}: ${width}px,`).join('\n');
|
|
622
|
+
/**
|
|
623
|
+
* The theme as a folder of SCSS modules, by path inside the folder. Each file
|
|
624
|
+
* is a Sass module that writes its own CSS when it is loaded, so a component,
|
|
625
|
+
* a token group or the motion can be edited, reviewed and replaced alone;
|
|
626
|
+
* `main.scss` loads them and is the file to compile. The theme's CSS is the
|
|
627
|
+
* same as `specToScss()`'s.
|
|
628
|
+
*
|
|
629
|
+
* Every setting has its place, written out even when the theme leaves it
|
|
630
|
+
* alone: each token group's file and the type system's list every default,
|
|
631
|
+
* commented, so a change is an uncomment; `_settings.scss` holds the
|
|
632
|
+
* breakpoints, and builds greese ui's stylesheet and utility classes with them
|
|
633
|
+
* whenever they differ from the library's.
|
|
634
|
+
*
|
|
635
|
+
* main.scss the entry: only `@use` lines
|
|
636
|
+
* _settings.scss breakpoints, `$library`, `$utilities` and `$theme`
|
|
637
|
+
* _library.scss greese ui's stylesheet, built here when `$library`
|
|
638
|
+
* _resets.scss theme layers taken back from components, first
|
|
639
|
+
* tokens/_index.scss one `tokens()` call per group, then the type system
|
|
640
|
+
* tokens/_<group>.scss a group's values (`$tokens`) and new tokens (`$extend`)
|
|
641
|
+
* tokens/_typography.scss
|
|
642
|
+
* motion/_index.scss effects, motion roles and page transitions
|
|
643
|
+
* components/_<name>.scss one component each, its own imports and helpers
|
|
644
|
+
* shapes/_index.scss the shapes, their sizes and what takes them
|
|
645
|
+
* utilities/_index.scss greese ui's utility classes when `$utilities`, and yours
|
|
646
|
+
*
|
|
647
|
+
* `options.components` names component modules of your own
|
|
648
|
+
* (`components/_hero.scss`) for `main.scss` to load too, so a tool that
|
|
649
|
+
* rewrites the layout keeps loading the files it no longer writes.
|
|
650
|
+
*/
|
|
651
|
+
export const specToLayout = (spec, source = 'greese-theme.json', options = {}) => {
|
|
652
|
+
const files = {};
|
|
653
|
+
const scope = 'settings.$theme';
|
|
654
|
+
const entry = ['settings', 'library', 'resets', 'tokens', 'motion'];
|
|
655
|
+
files['_settings.scss'] = `${header(source, `What every file of the theme reads. main.scss loads it first: Sass
|
|
656
|
+
configures a module once, when it is first loaded.`)}
|
|
657
|
+
/// The breakpoints, smallest first, in px: change one here and every media
|
|
658
|
+
/// query follows, the components' and the utility classes' alike. Keep xs
|
|
659
|
+
/// (at 0), sm, md, lg and xl; add your own (one word each). The type system's
|
|
660
|
+
/// values can be set here too: \`$typography\` (see tokens/_typography.scss).
|
|
661
|
+
@use 'pkg:@greese/ui/scss/config' with (
|
|
662
|
+
$breakpoints: (
|
|
663
|
+
${breakpointLines(breakpointsOf(spec))}
|
|
664
|
+
)
|
|
665
|
+
);
|
|
666
|
+
@use 'pkg:@greese/ui/scss/maps';
|
|
667
|
+
|
|
668
|
+
/// Build greese ui's whole stylesheet here, with these breakpoints: import
|
|
669
|
+
/// main.scss in place of \`styles.css\`. Its media queries are fixed when it's
|
|
670
|
+
/// built, so it's on whenever the breakpoints differ from the library's. Set
|
|
671
|
+
/// \`true\` to always ship one stylesheet; with \`false\`, import \`styles.css\`
|
|
672
|
+
/// before main.scss, and keep the library's breakpoints.
|
|
673
|
+
$library: config.$breakpoints != maps.$breakpoints;
|
|
674
|
+
|
|
675
|
+
/// Build greese ui's utility classes here too (utilities/_index.scss), with
|
|
676
|
+
/// these breakpoints, in place of \`utilities.css\`. Set \`false\` if the app uses
|
|
677
|
+
/// none, or set it \`true\` to purge them with \`$only\` there.
|
|
678
|
+
$utilities: $library;
|
|
679
|
+
|
|
680
|
+
/// The theme's name: its rules apply inside \`data-greese-theme="<name>"\`
|
|
681
|
+
/// (ThemeProvider's \`theme="<name>"\`). \`null\` makes them the page's own
|
|
682
|
+
/// defaults. Set it here rather than with \`@use … with ($theme: …)\`, where
|
|
683
|
+
/// Sass reads \`null\` as "not configured" and keeps this value.
|
|
684
|
+
$theme: ${spec.name || 'null'};
|
|
685
|
+
`;
|
|
686
|
+
files['_library.scss'] = `${header(source, `greese ui's whole stylesheet, built with the theme's breakpoints when
|
|
687
|
+
_settings.scss says so ($library). It comes before the theme, which the
|
|
688
|
+
cascade layers put above it anyway.`)}@use 'sass:meta';
|
|
689
|
+
@use 'settings';
|
|
690
|
+
|
|
691
|
+
@if settings.$library {
|
|
692
|
+
@include meta.load-css('pkg:@greese/ui/scss');
|
|
693
|
+
}
|
|
694
|
+
`;
|
|
695
|
+
files['_resets.scss'] = `${header(source, `Theme layers (color, shape, motion) taken back from components. It loads
|
|
696
|
+
before the theme's rules, which a reset leaves out from then on:
|
|
697
|
+
|
|
698
|
+
@include greese.scope(settings.$theme) {
|
|
699
|
+
@include greese.component(Card) {
|
|
700
|
+
@include greese.reset(shape);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
Part of a page takes them back with a class instead: .greese-reset-color,
|
|
705
|
+
-shape, -motion, or .greese-reset for all three (@greese/ui/css/resets.css).`)}@use 'settings';
|
|
706
|
+
${KIT}
|
|
707
|
+
`;
|
|
708
|
+
// Tokens: a file per group, every group, then one call each, which add up
|
|
709
|
+
// (one that sets nothing writes nothing).
|
|
710
|
+
const { own: overrides, added } = tokenLists(spec);
|
|
711
|
+
const setGroups = [...new Set([...overrides, ...added].map(([group]) => group))];
|
|
712
|
+
const groups = [
|
|
713
|
+
...setGroups,
|
|
714
|
+
...TOKEN_DEFAULTS.map(([g]) => g).filter((g) => !setGroups.includes(g)),
|
|
715
|
+
];
|
|
716
|
+
for (const group of groups) {
|
|
717
|
+
const set = overrides.find(([g]) => g === group)?.[1] ?? [];
|
|
718
|
+
const extra = added.find(([g]) => g === group)?.[1] ?? [];
|
|
719
|
+
files[`tokens/_${group}.scss`] = `${header(source, `The theme's ${group} tokens: those it sets ($tokens), then the defaults it
|
|
720
|
+
leaves alone, commented (uncomment one to change it), and those it adds
|
|
721
|
+
($extend), which become --greese-${group}-<key>.`)}${tokenGroupScss(group, set, extra, TOKEN_DEFAULTS.find(([g]) => g === group)?.[1] ?? [])}`;
|
|
722
|
+
}
|
|
723
|
+
files['tokens/_typography.scss'] = `${header(source, `The type system: by variant, then \`base\` or a breakpoint, then property.
|
|
724
|
+
What the theme changes, then the defaults it leaves alone, commented.`)}${typographyScss(spec)}`;
|
|
725
|
+
files['tokens/_index.scss'] = `${header(source, `The theme's tokens. Each tokens() call adds to the ones before it (in a
|
|
726
|
+
named theme, the first that sets anything declares the theme complete), so a
|
|
727
|
+
group's file can change alone.`)}@use '../settings';
|
|
728
|
+
${KIT}
|
|
729
|
+
${[...groups, 'typography'].map((g) => `@use '${g}';`).join('\n')}
|
|
730
|
+
|
|
731
|
+
@include greese.scope(${scope}) {
|
|
732
|
+
${[
|
|
733
|
+
...groups.map((g) => ` @include greese.tokens(${g}.$tokens, $extend: ${g}.$extend);`),
|
|
734
|
+
' @include greese.typography(typography.$changes);',
|
|
735
|
+
].join('\n')}
|
|
736
|
+
}
|
|
737
|
+
`;
|
|
738
|
+
// Motion: the theme's effects (global, as @keyframes are), then its roles.
|
|
739
|
+
const effects = Object.entries(spec.effects ?? {})
|
|
740
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
741
|
+
.map(([name, effect]) => effectScss(name, effect));
|
|
742
|
+
const setMotion = filled(spec.motionDefaults);
|
|
743
|
+
const pageRules = pageRulesMap(spec, ' ');
|
|
744
|
+
files['motion/_index.scss'] = `${header(source, `The theme's motion: its own effects (before any component's motion() uses
|
|
745
|
+
them), entrance pacing and motion roles, and page transitions.
|
|
746
|
+
|
|
747
|
+
motion-defaults() takes duration, delay, ease, distance and stagger (every
|
|
748
|
+
\`greese\` entrance), and the motion roles: theme, content, rows, toast,
|
|
749
|
+
backdrop, overlay, dialog, popover, tooltip, drawer, carousel, carousel-back
|
|
750
|
+
(entrances); overlay-exit, dialog-exit, popover-exit, tooltip-exit,
|
|
751
|
+
drawer-exit, carousel-exit, carousel-back-exit (exits by name); their
|
|
752
|
+
-duration, enter-ease and exit-ease; page, page-back, page-exit,
|
|
753
|
+
page-back-exit, page-duration, page-ease, page-stagger, page-exit-order,
|
|
754
|
+
page-mode, share-duration, share-ease (Guides/Theming, "Motion roles").`)}@use '../settings';
|
|
755
|
+
${KIT}
|
|
756
|
+
|
|
757
|
+
${effects.length
|
|
758
|
+
? `${effects.join('\n\n')}\n`
|
|
759
|
+
: `// An effect of the theme's own: a name, and the frame it starts from.
|
|
760
|
+
// @include greese.effect(float-up, (enter: (from: (translate: 0 1.5rem, opacity: 0))));
|
|
761
|
+
`}
|
|
762
|
+
@include greese.scope(${scope}) {
|
|
763
|
+
@include greese.motion-defaults((
|
|
764
|
+
${mapLines(setMotion.map(([k, v]) => [k, str(v)]), MOTION_BASES.filter(([k]) => !setMotion.some(([s]) => s === k)), ' ')}
|
|
765
|
+
));
|
|
766
|
+
|
|
767
|
+
// Parts of a page with page-transition motion of their own, by selector:
|
|
768
|
+
// \`still\`, or (enter, exit, duration, delay, ease).
|
|
769
|
+
@include greese.page-transitions(${pageRules ??
|
|
770
|
+
`(
|
|
771
|
+
// "header": still,
|
|
772
|
+
)`});
|
|
773
|
+
}
|
|
774
|
+
`;
|
|
775
|
+
// Components: a module each, with its own imports and helpers.
|
|
776
|
+
// `../settings` first in every file: it configures the breakpoints, which
|
|
777
|
+
// the kit and the library's partials load.
|
|
778
|
+
const reserved = [
|
|
779
|
+
{ url: 'pkg:@greese/ui/scss/kit', namespace: 'greese' },
|
|
780
|
+
{ url: '../settings', namespace: 'settings' },
|
|
781
|
+
];
|
|
782
|
+
const moduleOf = (path) => path.replace(/\/_/, '/').replace(/\.scss$/, '');
|
|
783
|
+
for (const name of Object.keys(spec.components ?? {}).sort()) {
|
|
784
|
+
const c = spec.components[name];
|
|
785
|
+
const block = componentScss(name, c, ' ', () => '');
|
|
786
|
+
if (!block)
|
|
787
|
+
continue;
|
|
788
|
+
const split = c.scss?.trim() ? splitScss(c.scss) : undefined;
|
|
789
|
+
const uses = mergeUses(split ? [[name, split.uses]] : [], reserved).lines.map((l) => l.text);
|
|
790
|
+
const helpers = (split?.declarations ?? []).map(({ text }) => text.trim());
|
|
791
|
+
const path = `components/_${fileName(name)}.scss`;
|
|
792
|
+
files[path] =
|
|
793
|
+
`${header(source, `${name}: its overrides, in @layer greese.overrides.`)}@use '../settings';
|
|
794
|
+
${KIT}${uses.length ? `\n${uses.join('\n')}` : ''}
|
|
795
|
+
${helpers.length ? `\n${helpers.join('\n\n')}\n` : ''}
|
|
796
|
+
@include greese.scope(${scope}) {
|
|
797
|
+
${block}
|
|
798
|
+
}
|
|
799
|
+
`;
|
|
800
|
+
entry.push(moduleOf(path));
|
|
801
|
+
}
|
|
802
|
+
// Component files of your own (or ones the theme stopped writing), after.
|
|
803
|
+
for (const path of options.components ?? []) {
|
|
804
|
+
const module = moduleOf(path.replace(/^components\/(?!_)/, 'components/_'));
|
|
805
|
+
if (!entry.includes(module))
|
|
806
|
+
entry.push(module);
|
|
807
|
+
}
|
|
808
|
+
const shapes = shapesScss(spec, scope, shapeSize);
|
|
809
|
+
if (shapes) {
|
|
810
|
+
files['shapes/_index.scss'] = `${header(source, `The theme's shapes (ADR 0024), written by @greese/ui/shapes. Their sizes
|
|
811
|
+
come first: change one there. The outlines are generated geometry, best
|
|
812
|
+
changed in Theme Studio. Shape a component differently with a component
|
|
813
|
+
file, or take its shape back in _resets.scss.`)}@use '../settings';
|
|
814
|
+
${KIT}
|
|
815
|
+
@use 'sass:map';
|
|
816
|
+
|
|
817
|
+
${shapeSizes(spec)}
|
|
818
|
+
${shapes}
|
|
819
|
+
`;
|
|
820
|
+
entry.push('shapes');
|
|
821
|
+
}
|
|
822
|
+
files['utilities/_index.scss'] = `${header(source, `Utility classes, in @layer greese.utilities. greese ui's own are built
|
|
823
|
+
here when _settings.scss says so ($utilities), with the theme's breakpoints:
|
|
824
|
+
choose groups (spacing, sizing, layout, flex, grid, typography, border,
|
|
825
|
+
color, effects), purge with $only (class names to keep), or add a $prefix.
|
|
826
|
+
Your own go after them, in the same layer.`)}@use '../settings';
|
|
827
|
+
@use 'pkg:@greese/ui/scss/mixins';
|
|
828
|
+
@use 'pkg:@greese/ui/scss/utilities';
|
|
829
|
+
|
|
830
|
+
@if settings.$utilities {
|
|
831
|
+
@include utilities.classes($groups: null, $only: null, $prefix: '');
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Your own:
|
|
835
|
+
// @layer greese.utilities {
|
|
836
|
+
// .text-balance { text-wrap: balance; }
|
|
837
|
+
// @include mixins.up(md) { .md-text-balance { text-wrap: balance; } }
|
|
838
|
+
// }
|
|
839
|
+
`;
|
|
840
|
+
entry.push('utilities');
|
|
841
|
+
files['main.scss'] = `${header(source, `The theme's entry: compile this file. With greese ui built in
|
|
842
|
+
(_settings.scss, $library), it's the whole stylesheet; otherwise import it
|
|
843
|
+
after greese ui's styles.
|
|
844
|
+
Each module writes its CSS when it is first loaded, in this order. The cascade
|
|
845
|
+
layers decide what wins, so order matters only within a layer. Sass wants
|
|
846
|
+
every @use before any rule, so this file holds nothing else. Add a line for
|
|
847
|
+
each file you add.`)}${entry.map((url) => `@use '${url}';`).join('\n')}
|
|
848
|
+
`;
|
|
849
|
+
return files;
|
|
850
|
+
};
|
|
851
|
+
/** A shape's size in the layout: read from the `$shapes` map at the file's top. */
|
|
852
|
+
const shapeSize = (shape, state, param, _value, factor) => {
|
|
853
|
+
const at = state === 'border'
|
|
854
|
+
? `map.get($shapes, ${str(shape)}, border)`
|
|
855
|
+
: `map.get($shapes, ${str(shape)}, ${state}, ${str(param)})`;
|
|
856
|
+
return `#{${factor === 1 ? at : `${at} * ${Math.round(factor * 1000) / 1000}`}}`;
|
|
857
|
+
};
|
|
858
|
+
/** Each shape's sizes as a Sass map: its border, its parameters at rest, hovered and pressed. */
|
|
859
|
+
const shapeSizes = (spec) => {
|
|
860
|
+
const px = (n) => `${Math.round(n * 1000) / 1000}px`;
|
|
861
|
+
const shapes = filled(spec.shapes);
|
|
862
|
+
return `/// Each shape's sizes, in px: its border's width, and its parameters at
|
|
863
|
+
/// rest, hovered and pressed (a smaller variant of an element scales them).
|
|
864
|
+
$shapes: (
|
|
865
|
+
${shapes
|
|
866
|
+
.map(([name, def]) => {
|
|
867
|
+
const states = ['params', 'hover', 'active']
|
|
868
|
+
.filter((state) => def[state] && Object.keys(def[state]).length)
|
|
869
|
+
.map((state) => ` ${state}: (${Object.entries(def[state])
|
|
870
|
+
.map(([k, v]) => `${str(k)}: ${px(v)}`)
|
|
871
|
+
.join(', ')}),`);
|
|
872
|
+
return ` ${str(name)}: (\n border: ${px(def.border?.width ?? 1)},\n${states.join('\n')}\n ),`;
|
|
873
|
+
})
|
|
874
|
+
.join('\n')}
|
|
875
|
+
);
|
|
876
|
+
`;
|
|
877
|
+
};
|