@brydio/manifest 0.1.0-alpha.59 → 0.1.0-alpha.61

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/src/look.js ADDED
@@ -0,0 +1,744 @@
1
+ /**
2
+ * How an app may look: its theme (`tasks/app-publishing` PB21) and the
3
+ * `style` any element takes (PB22).
4
+ *
5
+ * A copy of Brydio's rule set, `apps/api/src/apps/manifest/app-look.ts`, which
6
+ * is the one source: a publish and the registry read it there, the host
7
+ * checks every node it draws with its own copy, and `brydio validate`, the
8
+ * catalogue's checks (`@brydio/ui/validate`) and the fake host read this
9
+ * one. `test/look.test.ts` holds
10
+ * this copy to Brydio's answers, sentence for sentence, whenever a Brydio
11
+ * checkout sits beside this repository, and to the recorded answers in
12
+ * `contracts/brydio.json` otherwise. It imports nothing.
13
+ */
14
+ /* ------------------------------------------------------------------ colours */
15
+ /** A name an app gives one of its colours: `ocean`, read as `app.ocean`. */
16
+ export const APP_COLOUR_NAME = /^[a-z][a-z0-9-]{0,31}$/;
17
+ /** The most named colours a theme may hold. */
18
+ export const MAX_APP_COLOURS = 24;
19
+ /** A plain colour: `#rgb` or `#rrggbb`. */
20
+ export const HEX_COLOUR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
21
+ /** WCAG AA for body text. */
22
+ export const TEXT_CONTRAST = 4.5;
23
+ /**
24
+ * Brydio's own colours an app may name in a style, each to the token it
25
+ * reads. They follow light and dark by themselves.
26
+ */
27
+ export const BRYDIO_COLOURS = {
28
+ text: '--fg',
29
+ 'text-strong': '--fg-strong',
30
+ 'text-soft': '--fg-soft',
31
+ 'text-muted': '--fg-muted',
32
+ 'text-faint': '--fg-faint',
33
+ surface: '--bg-surface',
34
+ 'surface-soft': '--bg-surface-soft',
35
+ sunken: '--bg-sunken',
36
+ elevated: '--bg-elevated',
37
+ fill: '--bg-fill',
38
+ selected: '--bg-selected',
39
+ page: '--bg-page',
40
+ line: '--line',
41
+ 'line-strong': '--line-strong',
42
+ 'line-faint': '--line-faint',
43
+ brand: '--brand',
44
+ 'brand-text': '--brand-fg',
45
+ 'brand-on': '--brand-on',
46
+ 'brand-soft': '--brand-soft',
47
+ 'brand-line': '--brand-line',
48
+ success: '--success',
49
+ 'success-text': '--success-fg',
50
+ 'success-soft': '--success-soft',
51
+ warn: '--warn',
52
+ 'warn-text': '--warn-fg',
53
+ 'warn-soft': '--warn-soft',
54
+ danger: '--danger',
55
+ 'danger-text': '--danger-fg',
56
+ 'danger-soft': '--danger-soft',
57
+ };
58
+ /**
59
+ * What a check reads a Brydio colour as, to judge contrast on a screenshot.
60
+ * Near enough to the tokens to judge by; never drawn.
61
+ */
62
+ export const BRYDIO_COLOUR_CHECK_VALUES = {
63
+ light: {
64
+ text: '#1f1f1f', 'text-strong': '#0f0f0f', 'text-soft': '#3d3d3d', 'text-muted': '#666666', 'text-faint': '#8a8a8a',
65
+ surface: '#ffffff', 'surface-soft': '#fafafa', sunken: '#f5f5f5', elevated: '#ffffff', fill: '#f0f0f0', selected: '#ebebeb', page: '#ffffff',
66
+ line: '#e5e5e5', 'line-strong': '#d4d4d4', 'line-faint': '#f0f0f0',
67
+ brand: '#00746a', 'brand-text': '#00746a', 'brand-on': '#ffffff', 'brand-soft': '#eaf5f3', 'brand-line': '#b3ddd6',
68
+ success: '#15803d', 'success-text': '#15803d', 'success-soft': '#ecfdf3',
69
+ warn: '#b45309', 'warn-text': '#b45309', 'warn-soft': '#fffbeb',
70
+ danger: '#c62828', 'danger-text': '#c62828', 'danger-soft': '#fef2f2',
71
+ },
72
+ dark: {
73
+ text: '#ededed', 'text-strong': '#fafafa', 'text-soft': '#d4d4d4', 'text-muted': '#a3a3a3', 'text-faint': '#7a7a7a',
74
+ surface: '#212121', 'surface-soft': '#262626', sunken: '#1a1a1a', elevated: '#2a2a2a', fill: '#2e2e2e', selected: '#333333', page: '#1c1c1c',
75
+ line: '#333333', 'line-strong': '#404040', 'line-faint': '#2a2a2a',
76
+ brand: '#00746a', 'brand-text': '#79c2b8', 'brand-on': '#ffffff', 'brand-soft': '#003e38', 'brand-line': '#00574f',
77
+ success: '#22c55e', 'success-text': '#4ade80', 'success-soft': '#052e16',
78
+ warn: '#f59e0b', 'warn-text': '#fbbf24', 'warn-soft': '#2b1d05',
79
+ danger: '#ef4444', 'danger-text': '#f87171', 'danger-soft': '#2c0b0b',
80
+ },
81
+ };
82
+ /** Where Brydio draws an app: the ground a screenshot check reads text against. */
83
+ export const GROUND = { light: '#ffffff', dark: '#212121' };
84
+ /* -------------------------------------------------------------------- theme */
85
+ export const THEME_DENSITIES = ['compact', 'normal', 'roomy'];
86
+ export const THEME_ROUNDINGS = ['none', 'small', 'normal', 'large', 'round'];
87
+ /** How much each density scales Brydio's spacing and control heights. */
88
+ export const DENSITY_SCALE = { compact: 0.85, normal: 1, roomy: 1.15 };
89
+ /** How much each rounding scales Brydio's corner radii. */
90
+ export const ROUNDING_SCALE = { none: 0, small: 0.6, normal: 1, large: 1.4, round: 2 };
91
+ const THEME_KEYS = ['accent', 'colors', 'rounding', 'density'];
92
+ /** Words people reach for that the theme refuses on purpose, with why. */
93
+ const THEME_REFUSED = {
94
+ font: 'There is no font setting: every app uses Brydio’s own font.',
95
+ fontFamily: 'There is no font setting: every app uses Brydio’s own font.',
96
+ fonts: 'There is no font setting: every app uses Brydio’s own font.',
97
+ colours: 'Named colours go in "colors".',
98
+ };
99
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
100
+ /** Everything a publish refuses about a theme. Empty when it is fine. */
101
+ export function themeProblems(theme) {
102
+ if (theme === undefined)
103
+ return [];
104
+ if (!isRecord(theme))
105
+ return [{ code: 'theme_invalid', path: 'theme', message: 'The theme is a record: accent, colors, rounding, density.' }];
106
+ const problems = [];
107
+ for (const key of Object.keys(theme)) {
108
+ if (THEME_KEYS.includes(key))
109
+ continue;
110
+ const why = THEME_REFUSED[key];
111
+ problems.push({
112
+ code: why && key !== 'colours' ? 'theme_font' : 'theme_invalid',
113
+ path: `theme.${key}`,
114
+ message: why ?? `The theme has no setting called "${key}". It takes ${THEME_KEYS.join(', ')}.`,
115
+ });
116
+ }
117
+ if (theme.accent !== undefined)
118
+ problems.push(...pairProblems(theme.accent, 'theme.accent', 'The accent'));
119
+ if (theme.colors !== undefined) {
120
+ if (!isRecord(theme.colors)) {
121
+ problems.push({ code: 'theme_invalid', path: 'theme.colors', message: 'Named colours are a record: { "ocean": { "light": "#0e7490", "dark": "#67e8f9" } }.' });
122
+ }
123
+ else {
124
+ const names = Object.keys(theme.colors);
125
+ if (names.length > MAX_APP_COLOURS) {
126
+ problems.push({ code: 'theme_colours_too_many', path: 'theme.colors', message: `A theme holds at most ${MAX_APP_COLOURS} named colours.` });
127
+ }
128
+ for (const name of names) {
129
+ if (!APP_COLOUR_NAME.test(name)) {
130
+ problems.push({
131
+ code: 'theme_colour_name',
132
+ path: `theme.colors.${name}`,
133
+ message: `"${name}" is not a colour name: lower-case letters, digits and dashes, starting with a letter, at most 32.`,
134
+ });
135
+ continue;
136
+ }
137
+ problems.push(...pairProblems(theme.colors[name], `theme.colors.${name}`, `The colour "${name}"`));
138
+ }
139
+ }
140
+ }
141
+ if (theme.density !== undefined && !THEME_DENSITIES.includes(theme.density)) {
142
+ problems.push({ code: 'theme_density', path: 'theme.density', message: `The density is one of ${THEME_DENSITIES.join(', ')}.` });
143
+ }
144
+ if (theme.rounding !== undefined && !THEME_ROUNDINGS.includes(theme.rounding)) {
145
+ problems.push({ code: 'theme_rounding', path: 'theme.rounding', message: `The rounding is one of ${THEME_ROUNDINGS.join(', ')}.` });
146
+ }
147
+ return problems;
148
+ }
149
+ function pairProblems(value, path, said) {
150
+ if (!isRecord(value) || value.light === undefined || value.dark === undefined) {
151
+ return [{ code: 'theme_colour_pair', path, message: `${said} needs both a light and a dark value: { "light": "#…", "dark": "#…" }.` }];
152
+ }
153
+ const problems = [];
154
+ for (const key of Object.keys(value)) {
155
+ if (key !== 'light' && key !== 'dark') {
156
+ problems.push({ code: 'theme_colour_pair', path: `${path}.${key}`, message: `${said} has only a light and a dark value.` });
157
+ }
158
+ }
159
+ for (const mode of ['light', 'dark']) {
160
+ if (typeof value[mode] !== 'string' || !HEX_COLOUR.test(value[mode])) {
161
+ problems.push({ code: 'theme_colour_format', path: `${path}.${mode}`, message: `${said}'s ${mode} value is a colour like #0e7490.` });
162
+ }
163
+ }
164
+ return problems;
165
+ }
166
+ /**
167
+ * What a publish warns about a theme, and never refuses: a colour whose best
168
+ * text, white or near-black, falls below WCAG AA on it.
169
+ */
170
+ export function themeWarnings(theme) {
171
+ if (!isRecord(theme) || themeProblems(theme).length)
172
+ return [];
173
+ const warnings = [];
174
+ const check = (pair, path, said) => {
175
+ for (const mode of ['light', 'dark']) {
176
+ const ratio = contrastOf(onColour(pair[mode]), pair[mode]);
177
+ if (ratio < TEXT_CONTRAST) {
178
+ warnings.push({
179
+ code: 'theme_contrast_low',
180
+ path: `${path}.${mode}`,
181
+ message: `Text on ${said} in ${mode} mode reads at ${ratio.toFixed(2)}:1, below the ${TEXT_CONTRAST}:1 WCAG AA asks for.`,
182
+ });
183
+ }
184
+ }
185
+ };
186
+ const accent = theme.accent;
187
+ if (accent)
188
+ check(accent, 'theme.accent', 'the accent');
189
+ for (const [name, pair] of Object.entries((theme.colors ?? {}))) {
190
+ check(pair, `theme.colors.${name}`, `"${name}"`);
191
+ }
192
+ return warnings;
193
+ }
194
+ /** The theme as code reads it after `themeProblems` found nothing. */
195
+ export function themeOf(manifest) {
196
+ const theme = isRecord(manifest) ? manifest.theme : undefined;
197
+ return isRecord(theme) && themeProblems(theme).length === 0 ? theme : null;
198
+ }
199
+ /* ------------------------------------------------------------ colour maths */
200
+ /** `#abc` → `[170, 187, 204]`, or null. */
201
+ export function rgbOf(hex) {
202
+ if (!HEX_COLOUR.test(hex))
203
+ return null;
204
+ const digits = hex.length === 4 ? [...hex.slice(1)].map(digit => digit + digit).join('') : hex.slice(1);
205
+ return [0, 2, 4].map(at => parseInt(digits.slice(at, at + 2), 16));
206
+ }
207
+ function luminance([r, g, b]) {
208
+ const channel = (value) => {
209
+ const c = value / 255;
210
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
211
+ };
212
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
213
+ }
214
+ /** WCAG contrast between two plain colours; 1 when either isn't one. */
215
+ export function contrastOf(a, b) {
216
+ const first = rgbOf(a);
217
+ const second = rgbOf(b);
218
+ if (!first || !second)
219
+ return 1;
220
+ const [light, dark] = [luminance(first), luminance(second)].sort((x, y) => y - x);
221
+ return (light + 0.05) / (dark + 0.05);
222
+ }
223
+ /** The text colour that reads best on a fill: white or near-black. */
224
+ export function onColour(fill) {
225
+ return contrastOf('#ffffff', fill) >= contrastOf('#111111', fill) ? '#ffffff' : '#111111';
226
+ }
227
+ /* -------------------------------------------------------------------- style */
228
+ /** How long a style may be, so a node can't carry a stylesheet. */
229
+ export const STYLE_MAX_KEYS = 48;
230
+ export const STYLE_MAX_BYTES = 4 * 1024;
231
+ /** The furthest a length may reach, in pixels. */
232
+ export const LENGTH_MAX = 10_000;
233
+ /** Brydio's spacing steps an app may name: `space.4`. */
234
+ export const SPACE_MAX = 16;
235
+ /** The highest layer inside an app's area. The area starts its own, so this is all an app needs. */
236
+ export const Z_INDEX_MAX = 10;
237
+ export const BRYDIO_RADII = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'];
238
+ export const BRYDIO_SHADOWS = ['xs', 'sm', 'md', 'lg'];
239
+ export const TRANSITIONS = ['none', 'fast', 'base', 'slow'];
240
+ const ALIGN = ['start', 'end', 'center', 'stretch', 'baseline', 'flex-start', 'flex-end', 'normal'];
241
+ const JUSTIFY = [...ALIGN, 'space-between', 'space-around', 'space-evenly'];
242
+ const OVERFLOW = ['visible', 'hidden', 'clip', 'auto', 'scroll'];
243
+ /** Every property a style may set (PB22), and what it takes. Nothing else is drawn. */
244
+ export const STYLE_PROPERTIES = {
245
+ width: { kind: 'size' },
246
+ height: { kind: 'size' },
247
+ minWidth: { kind: 'size' },
248
+ maxWidth: { kind: 'size' },
249
+ minHeight: { kind: 'size' },
250
+ maxHeight: { kind: 'size' },
251
+ padding: { kind: 'space', negative: false, sides: true },
252
+ paddingTop: { kind: 'space', negative: false, sides: false },
253
+ paddingRight: { kind: 'space', negative: false, sides: false },
254
+ paddingBottom: { kind: 'space', negative: false, sides: false },
255
+ paddingLeft: { kind: 'space', negative: false, sides: false },
256
+ paddingInline: { kind: 'space', negative: false, sides: false },
257
+ paddingBlock: { kind: 'space', negative: false, sides: false },
258
+ margin: { kind: 'space', negative: true, sides: true },
259
+ marginTop: { kind: 'space', negative: true, sides: false },
260
+ marginRight: { kind: 'space', negative: true, sides: false },
261
+ marginBottom: { kind: 'space', negative: true, sides: false },
262
+ marginLeft: { kind: 'space', negative: true, sides: false },
263
+ marginInline: { kind: 'space', negative: true, sides: false },
264
+ marginBlock: { kind: 'space', negative: true, sides: false },
265
+ gap: { kind: 'space', negative: false, sides: false },
266
+ rowGap: { kind: 'space', negative: false, sides: false },
267
+ columnGap: { kind: 'space', negative: false, sides: false },
268
+ background: { kind: 'colour' },
269
+ color: { kind: 'colour' },
270
+ borderColor: { kind: 'colour' },
271
+ borderWidth: { kind: 'border-width' },
272
+ borderStyle: { kind: 'enum', values: ['solid', 'dashed', 'dotted', 'none'] },
273
+ borderRadius: { kind: 'radius' },
274
+ boxShadow: { kind: 'shadow' },
275
+ backgroundImage: { kind: 'image' },
276
+ backgroundSize: { kind: 'enum', values: ['auto', 'cover', 'contain'] },
277
+ backgroundPosition: { kind: 'enum', values: ['center', 'top', 'bottom', 'left', 'right'] },
278
+ backgroundRepeat: { kind: 'enum', values: ['no-repeat', 'repeat', 'repeat-x', 'repeat-y'] },
279
+ fontSize: { kind: 'font-size' },
280
+ fontWeight: { kind: 'font-weight' },
281
+ lineHeight: { kind: 'line-height' },
282
+ textAlign: { kind: 'enum', values: ['start', 'center', 'end', 'left', 'right', 'justify'] },
283
+ opacity: { kind: 'number', min: 0, max: 1 },
284
+ display: { kind: 'enum', values: ['flex', 'inline-flex', 'grid', 'inline-grid', 'block', 'inline-block', 'inline', 'none'] },
285
+ flexDirection: { kind: 'enum', values: ['row', 'column', 'row-reverse', 'column-reverse'] },
286
+ flexWrap: { kind: 'enum', values: ['nowrap', 'wrap', 'wrap-reverse'] },
287
+ flex: { kind: 'flex' },
288
+ flexGrow: { kind: 'number', min: 0, max: 12 },
289
+ flexShrink: { kind: 'number', min: 0, max: 12 },
290
+ flexBasis: { kind: 'size' },
291
+ order: { kind: 'number', min: -100, max: 100, integer: true },
292
+ alignItems: { kind: 'enum', values: ALIGN },
293
+ alignSelf: { kind: 'enum', values: [...ALIGN, 'auto'] },
294
+ alignContent: { kind: 'enum', values: JUSTIFY },
295
+ justifyContent: { kind: 'enum', values: JUSTIFY },
296
+ justifyItems: { kind: 'enum', values: ALIGN },
297
+ justifySelf: { kind: 'enum', values: [...ALIGN, 'auto'] },
298
+ gridTemplateColumns: { kind: 'tracks' },
299
+ gridTemplateRows: { kind: 'tracks' },
300
+ gridColumn: { kind: 'grid-line' },
301
+ gridRow: { kind: 'grid-line' },
302
+ position: { kind: 'position' },
303
+ top: { kind: 'offset' },
304
+ right: { kind: 'offset' },
305
+ bottom: { kind: 'offset' },
306
+ left: { kind: 'offset' },
307
+ inset: { kind: 'offset' },
308
+ zIndex: { kind: 'z-index' },
309
+ overflow: { kind: 'enum', values: OVERFLOW },
310
+ overflowX: { kind: 'enum', values: OVERFLOW },
311
+ overflowY: { kind: 'enum', values: OVERFLOW },
312
+ transition: { kind: 'transition' },
313
+ };
314
+ /** Properties people reach for that are refused on purpose, with the rule that refuses them. */
315
+ export const STYLE_REFUSED = {
316
+ fontFamily: 'every app uses Brydio’s own font, so there is no font setting',
317
+ font: 'every app uses Brydio’s own font, so there is no font setting',
318
+ animation: 'animations that never stop aren’t allowed, and a style has no animation; use transition for a change of state',
319
+ animationName: 'animations that never stop aren’t allowed, and a style has no animation; use transition for a change of state',
320
+ animationIterationCount: 'animations that never stop aren’t allowed, and a style has no animation; use transition for a change of state',
321
+ backgroundColor: 'the colour of a background is "background"',
322
+ border: 'a border is borderWidth, borderStyle and borderColor, each on its own',
323
+ transform: 'a transform could move an element out of where Brydio laid it; use margin or position inside the app’s area',
324
+ filter: 'a filter is not on the list of properties a style may set',
325
+ backdropFilter: 'a filter is not on the list of properties a style may set',
326
+ clipPath: 'a clip path is not on the list of properties a style may set',
327
+ mask: 'a mask could load an image from outside the app',
328
+ content: 'there is no generated content in an app',
329
+ cursor: 'Brydio chooses the pointer from what an element does',
330
+ pointerEvents: 'Brydio decides what can be pressed',
331
+ outline: 'Brydio draws the focus ring, so the outline is its own',
332
+ };
333
+ /** An address the app's file store gives a file: `…/files/apps/<instance>/<file>`. */
334
+ const APP_FILE_ADDRESS = /^(?:https?:\/\/[^/\s]+)?(?:\/[\w.-]+)*\/files\/apps\/[\w-]{1,128}\/[\w-]{1,128}$/;
335
+ const VIEWPORT_UNIT = /^-?\d+(\.\d+)?(vw|vh|vmin|vmax|dvh|dvw|svh|svw|lvh|lvw)$/;
336
+ const LENGTH = /^(-?\d+(?:\.\d+)?)(px|rem|em|%)$/;
337
+ const SPACE = /^(-?)space\.(\d{1,2})$/;
338
+ const SIZE_WORDS = ['auto', 'fit-content', 'min-content', 'max-content'];
339
+ /** What a style looks like when it is fine, for the sentences that refuse one. */
340
+ const LENGTH_SAID = 'a length: a number of pixels, "12px", "1.5rem", "50%" or a spacing step like "space.4"';
341
+ function lengthRefusal(value, options) {
342
+ if (typeof value === 'number') {
343
+ if (!Number.isFinite(value) || Math.abs(value) > LENGTH_MAX)
344
+ return `must be at most ${LENGTH_MAX} pixels`;
345
+ if (value < 0 && !options.negative)
346
+ return 'can’t be less than 0';
347
+ return null;
348
+ }
349
+ if (typeof value !== 'string')
350
+ return `must be ${LENGTH_SAID}`;
351
+ if (options.words?.includes(value))
352
+ return null;
353
+ if (VIEWPORT_UNIT.test(value))
354
+ return 'can’t use viewport units, which measure Brydio’s window, not the app’s area; use % or pixels';
355
+ if (/url\(/i.test(value))
356
+ return 'can’t hold url()';
357
+ const space = SPACE.exec(value);
358
+ if (space) {
359
+ if (Number(space[2]) > SPACE_MAX)
360
+ return `names a spacing step from space.0 to space.${SPACE_MAX}`;
361
+ if (space[1] && !options.negative)
362
+ return 'can’t be less than 0';
363
+ return null;
364
+ }
365
+ const length = LENGTH.exec(value);
366
+ if (!length)
367
+ return `must be ${LENGTH_SAID}`;
368
+ const amount = Number(length[1]);
369
+ const unit = length[2];
370
+ if (amount < 0 && !options.negative)
371
+ return 'can’t be less than 0';
372
+ if (unit === '%' && Math.abs(amount) > 100)
373
+ return 'can be at most 100%';
374
+ if ((unit === 'rem' || unit === 'em') && Math.abs(amount) > 500)
375
+ return 'can be at most 500rem';
376
+ if (unit === 'px' && Math.abs(amount) > LENGTH_MAX)
377
+ return `must be at most ${LENGTH_MAX} pixels`;
378
+ return null;
379
+ }
380
+ /**
381
+ * Why a colour can't be drawn, or null. A colour is one of Brydio's
382
+ * (`brydio.text`), one of the app's (`app.ocean`), `transparent`,
383
+ * `currentColor`, or a plain pair `{ light, dark }`. `colours`, when given,
384
+ * are the app's own names, so a name it never declared is refused too.
385
+ */
386
+ export function colourRefusal(value, colours) {
387
+ if (value === 'transparent' || value === 'currentColor')
388
+ return null;
389
+ if (typeof value === 'string') {
390
+ if (value.startsWith('brydio.')) {
391
+ return Object.prototype.hasOwnProperty.call(BRYDIO_COLOURS, value.slice(7))
392
+ ? null
393
+ : `names a Brydio colour that doesn’t exist; Brydio’s colours are ${Object.keys(BRYDIO_COLOURS).map(name => `brydio.${name}`).join(', ')}`;
394
+ }
395
+ if (value.startsWith('app.')) {
396
+ const name = value.slice(4);
397
+ if (!APP_COLOUR_NAME.test(name))
398
+ return 'names an app colour, like app.ocean';
399
+ if (colours && !colours.has(name))
400
+ return `names "${name}", which the app’s theme doesn’t declare`;
401
+ return null;
402
+ }
403
+ if (HEX_COLOUR.test(value))
404
+ return 'is a plain colour with no dark-mode value; give both: { "light": "…", "dark": "…" }';
405
+ if (/url\(/i.test(value))
406
+ return 'can’t hold url()';
407
+ return 'must be a Brydio colour (brydio.text), one of the app’s (app.ocean), or { "light": "#…", "dark": "#…" }';
408
+ }
409
+ if (isRecord(value)) {
410
+ const keys = Object.keys(value);
411
+ if (keys.some(key => key !== 'light' && key !== 'dark') || value.light === undefined || value.dark === undefined) {
412
+ return 'a plain colour needs both a light and a dark value, and nothing else';
413
+ }
414
+ for (const mode of ['light', 'dark']) {
415
+ if (typeof value[mode] !== 'string' || !HEX_COLOUR.test(value[mode]))
416
+ return `${mode} value must be a colour like #0e7490`;
417
+ }
418
+ return null;
419
+ }
420
+ return 'must be a Brydio colour (brydio.text), one of the app’s (app.ocean), or { "light": "#…", "dark": "#…" }';
421
+ }
422
+ const TRACK = String.raw `(?:-?\d+(?:\.\d+)?(?:px|rem|em|%)|\d+(?:\.\d+)?fr|auto|min-content|max-content|space\.\d{1,2})`;
423
+ const MINMAX = String.raw `minmax\(\s*${TRACK}\s*,\s*${TRACK}\s*\)`;
424
+ const ONE = `(?:${TRACK}|${MINMAX})`;
425
+ const REPEAT = String.raw `repeat\(\s*(?:\d{1,2}|auto-fill|auto-fit)\s*,\s*${ONE}(?:\s+${ONE}){0,3}\s*\)`;
426
+ const TRACKS = new RegExp(`^(?:${ONE}|${REPEAT})(?:\\s+(?:${ONE}|${REPEAT})){0,23}$`);
427
+ const GRID_LINE = /^(?:auto|span \d{1,2}|-?\d{1,2})(?:\s*\/\s*(?:auto|span \d{1,2}|-?\d{1,2}))?$/;
428
+ function ruleRefusal(rule, value, colours) {
429
+ switch (rule.kind) {
430
+ case 'size':
431
+ return lengthRefusal(value, { negative: false, words: SIZE_WORDS });
432
+ case 'space': {
433
+ if (rule.sides && typeof value === 'string' && value.trim().includes(' ')) {
434
+ const parts = value.trim().split(/\s+/);
435
+ if (parts.length > 4)
436
+ return 'takes at most four lengths, one for each side';
437
+ for (const part of parts) {
438
+ const refused = lengthRefusal(part, { negative: rule.negative, words: rule.negative ? ['auto'] : [] });
439
+ if (refused)
440
+ return refused;
441
+ }
442
+ return null;
443
+ }
444
+ return lengthRefusal(value, { negative: rule.negative, words: rule.negative ? ['auto'] : [] });
445
+ }
446
+ case 'offset':
447
+ return lengthRefusal(value, { negative: true, words: ['auto'] });
448
+ case 'colour':
449
+ return colourRefusal(value, colours);
450
+ case 'enum':
451
+ return typeof value === 'string' && rule.values.includes(value) ? null : `must be one of ${rule.values.join(', ')}`;
452
+ case 'number':
453
+ return typeof value === 'number' && Number.isFinite(value) && value >= rule.min && value <= rule.max && (!rule.integer || Number.isInteger(value))
454
+ ? null
455
+ : `must be a ${rule.integer ? 'whole ' : ''}number from ${rule.min} to ${rule.max}`;
456
+ case 'border-width':
457
+ if (typeof value === 'number')
458
+ return value >= 0 && value <= 16 ? null : 'must be from 0 to 16 pixels';
459
+ return typeof value === 'string' && /^(?:\d{1,2}(?:\.\d+)?)px$/.test(value) && Number.parseFloat(value) <= 16 ? null : 'must be from 0 to 16 pixels';
460
+ case 'radius':
461
+ if (value === 'full')
462
+ return null;
463
+ if (typeof value === 'string' && value.startsWith('radius.')) {
464
+ return BRYDIO_RADII.includes(value.slice(7)) ? null : `names one of Brydio’s radii: ${BRYDIO_RADII.map(name => `radius.${name}`).join(', ')}`;
465
+ }
466
+ return lengthRefusal(value, { negative: false });
467
+ case 'shadow':
468
+ if (value === 'none')
469
+ return null;
470
+ return typeof value === 'string' && value.startsWith('shadow.') && BRYDIO_SHADOWS.includes(value.slice(7))
471
+ ? null
472
+ : `must be none or one of Brydio’s shadows: ${BRYDIO_SHADOWS.map(name => `shadow.${name}`).join(', ')}`;
473
+ case 'font-size': {
474
+ const pixels = typeof value === 'number' ? value : typeof value === 'string' ? pixelsOf(value) : null;
475
+ return pixels !== null && pixels >= 10 && pixels <= 64 ? null : 'must be from 10 to 64 pixels ("14px" or "0.875rem")';
476
+ }
477
+ case 'font-weight':
478
+ return [400, 500, 600, 700, '400', '500', '600', '700'].includes(value) ? null : 'must be 400, 500, 600 or 700';
479
+ case 'line-height':
480
+ if (typeof value === 'number')
481
+ return value >= 0.8 && value <= 3 ? null : 'as a plain number is from 0.8 to 3';
482
+ return lengthRefusal(value, { negative: false });
483
+ case 'flex':
484
+ if (value === 'none' || value === 'auto')
485
+ return null;
486
+ return typeof value === 'number' && value >= 0 && value <= 12 ? null : 'must be none, auto, or a number from 0 to 12';
487
+ case 'tracks':
488
+ return typeof value === 'string' && value.length <= 400 && TRACKS.test(value.trim())
489
+ ? null
490
+ : 'must be a list of tracks: lengths, fr, auto, minmax() or repeat(), like "repeat(3, 1fr)"';
491
+ case 'grid-line':
492
+ return typeof value === 'string' && GRID_LINE.test(value.trim()) ? null : 'must be like "span 2" or "1 / 3"';
493
+ case 'position':
494
+ if (value === 'fixed' || value === 'sticky') {
495
+ return 'can’t be fixed or sticky: an app draws only inside its own area, so position is relative to that area (static, relative or absolute)';
496
+ }
497
+ return value === 'static' || value === 'relative' || value === 'absolute' ? null : 'must be static, relative or absolute';
498
+ case 'z-index':
499
+ if (typeof value === 'number' && Number.isInteger(value) && value > Z_INDEX_MAX) {
500
+ return `can be at most ${Z_INDEX_MAX}: the app’s area is its own layer, and nothing inside it can reach above it`;
501
+ }
502
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? null : `must be a whole number from 0 to ${Z_INDEX_MAX}`;
503
+ case 'image':
504
+ if (isRecord(value) &&
505
+ Object.keys(value).length === 1 &&
506
+ typeof value.file === 'string' &&
507
+ value.file.length <= 512) {
508
+ // Only an address the app's file store gave it; the host draws it
509
+ // only when that store is Brydio's own and the file is this
510
+ // instance's, so nothing is ever loaded from outside.
511
+ return APP_FILE_ADDRESS.test(value.file)
512
+ ? null
513
+ : 'names a file by the address the app’s file store gave it';
514
+ }
515
+ return 'can only be one of the app’s own uploaded files: { "file": "<its address>" }; url() and outside images aren’t allowed';
516
+ case 'transition':
517
+ return typeof value === 'string' && TRANSITIONS.includes(value) ? null : `must be one of ${TRANSITIONS.join(', ')}`;
518
+ }
519
+ }
520
+ /** `14px` → 14, `0.875rem` → 14; anything else null. */
521
+ function pixelsOf(value) {
522
+ const length = LENGTH.exec(value);
523
+ if (!length)
524
+ return null;
525
+ if (length[2] === 'px')
526
+ return Number(length[1]);
527
+ if (length[2] === 'rem' || length[2] === 'em')
528
+ return Number(length[1]) * 16;
529
+ return null;
530
+ }
531
+ /**
532
+ * Why a node's `style` can't be drawn, or null when it can (PB22). The
533
+ * sentence names the property and the rule: "style position can’t be fixed
534
+ * or sticky: …".
535
+ */
536
+ export function styleRefusal(style, colours) {
537
+ if (!isRecord(style))
538
+ return 'style must be a record of properties, like { "padding": "space.4" }.';
539
+ const keys = Object.keys(style);
540
+ if (keys.length > STYLE_MAX_KEYS)
541
+ return `style can set at most ${STYLE_MAX_KEYS} properties.`;
542
+ if (JSON.stringify(style).length > STYLE_MAX_BYTES)
543
+ return 'style must be at most 4 KB.';
544
+ for (const key of keys) {
545
+ const value = style[key];
546
+ if (value === undefined)
547
+ continue;
548
+ const refused = STYLE_REFUSED[key];
549
+ if (refused)
550
+ return `style ${key} isn’t allowed: ${refused}.`;
551
+ const rule = STYLE_PROPERTIES[key];
552
+ if (!rule)
553
+ return `style ${key} isn’t allowed: it is not on the list of properties a style may set.`;
554
+ const why = ruleRefusal(rule, value, colours);
555
+ if (why)
556
+ return `style ${key} ${why}.`;
557
+ }
558
+ return null;
559
+ }
560
+ /** The app colour names a theme declares, for `colourRefusal`. */
561
+ export function colourNamesOf(theme) {
562
+ return new Set(Object.keys(theme?.colors ?? {}));
563
+ }
564
+ /** The app colour a tone names (`app.ocean` → `ocean`), or null when it names a Brydio tone. */
565
+ export const appColourOfTone = (tone) => typeof tone === 'string' && tone.startsWith('app.') && APP_COLOUR_NAME.test(tone.slice(4)) ? tone.slice(4) : null;
566
+ /**
567
+ * The plain colour a value is in this mode, for a check: an app colour or a
568
+ * pair to its hex, a Brydio colour to the value checks read it as, otherwise
569
+ * null.
570
+ */
571
+ export function checkColourOf(value, mode, theme) {
572
+ if (isRecord(value) && typeof value[mode] === 'string')
573
+ return value[mode];
574
+ if (typeof value !== 'string')
575
+ return null;
576
+ if (value.startsWith('app.'))
577
+ return theme?.colors?.[value.slice(4)]?.[mode] ?? null;
578
+ if (value.startsWith('brydio.'))
579
+ return BRYDIO_COLOUR_CHECK_VALUES[mode][value.slice(7)] ?? null;
580
+ return null;
581
+ }
582
+ /** The CSS a colour is drawn with in this mode. */
583
+ export function cssColourOf(value, mode) {
584
+ if (value === 'transparent' || value === 'currentColor')
585
+ return value;
586
+ if (isRecord(value) && typeof value[mode] === 'string' && HEX_COLOUR.test(value[mode]))
587
+ return value[mode];
588
+ if (typeof value !== 'string')
589
+ return null;
590
+ if (value.startsWith('app.') && APP_COLOUR_NAME.test(value.slice(4)))
591
+ return `var(--app-color-${value.slice(4)}, currentColor)`;
592
+ if (value.startsWith('brydio.')) {
593
+ const token = BRYDIO_COLOURS[value.slice(7)];
594
+ return token ? `var(${token})` : null;
595
+ }
596
+ return null;
597
+ }
598
+ function cssLength(value) {
599
+ if (typeof value === 'number')
600
+ return value === 0 ? '0' : `${value}px`;
601
+ const text = String(value);
602
+ const space = SPACE.exec(text);
603
+ if (space)
604
+ return `calc(var(--spacing, 0.25rem) * ${space[1] ? '-' : ''}${space[2]})`;
605
+ return text;
606
+ }
607
+ const kebab = (key) => key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
608
+ /**
609
+ * A checked style as CSS declarations, for this mode. Only what passed
610
+ * `styleRefusal` is ever given here; anything it can't read is left out.
611
+ * `backgroundImage` is the host's to draw, since only it can read the file.
612
+ */
613
+ export function styleToCss(style, mode) {
614
+ const css = {};
615
+ for (const [key, value] of Object.entries(style)) {
616
+ const rule = STYLE_PROPERTIES[key];
617
+ if (!rule || value === undefined)
618
+ continue;
619
+ const name = kebab(key);
620
+ switch (rule.kind) {
621
+ case 'size':
622
+ case 'offset':
623
+ css[name] = cssLength(value);
624
+ break;
625
+ case 'space':
626
+ css[name] = typeof value === 'string' && value.trim().includes(' ') ? value.trim().split(/\s+/).map(cssLength).join(' ') : cssLength(value);
627
+ break;
628
+ case 'colour': {
629
+ const colour = cssColourOf(value, mode);
630
+ if (colour)
631
+ css[key === 'background' ? 'background-color' : name] = colour;
632
+ break;
633
+ }
634
+ case 'border-width':
635
+ css[name] = cssLength(typeof value === 'string' ? value : Number(value));
636
+ break;
637
+ case 'radius':
638
+ css[name] = value === 'full' ? '9999px' : typeof value === 'string' && value.startsWith('radius.') ? `var(--radius-${value.slice(7)})` : cssLength(value);
639
+ break;
640
+ case 'shadow':
641
+ css[name] = value === 'none' ? 'none' : `var(--shadow-${String(value).slice(7)})`;
642
+ break;
643
+ case 'font-size':
644
+ case 'line-height':
645
+ css[name] = typeof value === 'number' && rule.kind === 'line-height' ? String(value) : cssLength(value);
646
+ break;
647
+ case 'transition':
648
+ css[name] =
649
+ value === 'none'
650
+ ? 'none'
651
+ : `background-color var(--dur-${value}) var(--ease-out), color var(--dur-${value}) var(--ease-out), border-color var(--dur-${value}) var(--ease-out), opacity var(--dur-${value}) var(--ease-out), box-shadow var(--dur-${value}) var(--ease-out)`;
652
+ break;
653
+ case 'image':
654
+ break;
655
+ default:
656
+ css[name] = String(value);
657
+ }
658
+ }
659
+ return css;
660
+ }
661
+ /**
662
+ * The CSS variables an app's area carries for its theme in this mode: its
663
+ * colours as `--app-color-<name>`, the accent as Brydio's brand tokens (so
664
+ * the `brand` tone is the app's), and the rounding and density as scales of
665
+ * Brydio's own radii and spacing.
666
+ */
667
+ export function themeVariables(theme, mode) {
668
+ const vars = {};
669
+ if (!theme)
670
+ return vars;
671
+ for (const [name, pair] of Object.entries(theme.colors ?? {}))
672
+ vars[`--app-color-${name}`] = pair[mode];
673
+ if (theme.accent) {
674
+ const accent = theme.accent[mode];
675
+ const on = onColour(accent);
676
+ Object.assign(vars, {
677
+ '--brand': accent,
678
+ '--brand-hover': `color-mix(in oklab, ${accent} 88%, ${mode === 'dark' ? 'white' : 'black'})`,
679
+ '--brand-active': `color-mix(in oklab, ${accent} 78%, ${mode === 'dark' ? 'white' : 'black'})`,
680
+ '--brand-fg': accent,
681
+ '--brand-on': on,
682
+ '--brand-soft': `color-mix(in oklab, ${accent} ${mode === 'dark' ? 22 : 12}%, transparent)`,
683
+ '--brand-soft-hover': `color-mix(in oklab, ${accent} ${mode === 'dark' ? 30 : 18}%, transparent)`,
684
+ '--brand-line': `color-mix(in oklab, ${accent} 45%, transparent)`,
685
+ '--brand-ring': `color-mix(in oklab, ${accent} 24%, transparent)`,
686
+ '--primary': accent,
687
+ '--primary-foreground': on,
688
+ '--ring': accent,
689
+ '--focus-ring': `color-mix(in oklab, ${accent} 62%, transparent)`,
690
+ '--app-color-accent': accent,
691
+ });
692
+ }
693
+ if (theme.rounding && theme.rounding !== 'normal') {
694
+ const scale = ROUNDING_SCALE[theme.rounding];
695
+ const base = { '': 0.625, xs: 0.375, sm: 0.5, md: 0.625, lg: 0.75, xl: 1, '2xl': 1.25, '3xl': 1.75 };
696
+ for (const [step, rem] of Object.entries(base))
697
+ vars[step ? `--radius-${step}` : '--radius'] = `${+(rem * scale).toFixed(4)}rem`;
698
+ }
699
+ if (theme.density && theme.density !== 'normal') {
700
+ const scale = DENSITY_SCALE[theme.density];
701
+ Object.assign(vars, {
702
+ '--spacing': `${+(0.25 * scale).toFixed(4)}rem`,
703
+ '--control-h-sm': `${Math.round(28 * scale)}px`,
704
+ '--control-h': `${Math.round(32 * scale)}px`,
705
+ '--control-h-lg': `${Math.round(36 * scale)}px`,
706
+ '--list-row-h': `${Math.round(48 * scale)}px`,
707
+ });
708
+ }
709
+ return vars;
710
+ }
711
+ /* ------------------------------------------------------------- catalogue */
712
+ /**
713
+ * Whether a setting may name one of the app's colours as well as its own
714
+ * values (PB21): a `tone` that offers Brydio's `brand`.
715
+ */
716
+ export const takesAppColour = (name, values) => name === 'tone' && values.includes('brand');
717
+ /** What a tone that names an app colour is told when it isn't one. */
718
+ export const appColourSaid = 'or one of the app’s colours (app.<name>)';
719
+ /**
720
+ * Why a `bry-canvas`'s shapes can't be drawn, or null (PB24). Their shape is
721
+ * the catalogue's to check; this is their colours, which are names only —
722
+ * Brydio's or the app's — so a canvas follows light and dark.
723
+ */
724
+ export function canvasRefusal(ops, colours) {
725
+ if (!Array.isArray(ops))
726
+ return null;
727
+ for (const [index, op] of ops.entries()) {
728
+ if (!isRecord(op))
729
+ continue;
730
+ for (const field of ['fill', 'stroke']) {
731
+ const value = op[field];
732
+ if (value === undefined)
733
+ continue;
734
+ if (typeof value === 'string' && (value.startsWith('brydio.') || value.startsWith('app.') || value === 'transparent')) {
735
+ const why = colourRefusal(value, colours);
736
+ if (!why)
737
+ continue;
738
+ return `bry-canvas ops[${index}].${field} ${why}.`;
739
+ }
740
+ return `bry-canvas ops[${index}].${field} must name a colour, Brydio’s (brydio.text) or the app’s (app.ocean); give a plain colour a name in the theme.`;
741
+ }
742
+ }
743
+ return null;
744
+ }