@taprootio/site-authoring 0.10.2 → 0.10.3
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 +4 -0
- package/examples/riverbend-wellness/settings/taproot-styles.json +25 -31
- package/package.json +2 -2
- package/src/cli.js +2 -0
- package/src/constants.js +1 -1
- package/src/presentation-reference.js +65 -18
- package/src/theme-projection.js +22 -38
- package/src/theme-validation.js +165 -40
- package/src/verbs/theme-push.js +6 -2
- package/src/verbs/validate.js +4 -0
package/README.md
CHANGED
|
@@ -351,6 +351,10 @@ complete effective theme — the stored theme resolved over the same defaults
|
|
|
351
351
|
every consumer renders — so a fresh workspace validates as pulled, and it
|
|
352
352
|
keeps only authored `semanticMappings` pins (listed in
|
|
353
353
|
`explicitMappingTokens`) because a pinned token shadows its role.
|
|
354
|
+
`theme push` — and `theme push --dry-run`, which writes nothing — also runs
|
|
355
|
+
Espalier's fit report over the root and every context in both schemes and
|
|
356
|
+
prints each lint as a warning, such as a filled action that renders the
|
|
357
|
+
opposite way round from its swatches; a lint never stops the push.
|
|
354
358
|
`redirects pull` writes the site's
|
|
355
359
|
redirect map to `redirects.json` with a baseline, and `redirects push`
|
|
356
360
|
validates the file offline and replaces the whole map; `help redirects`
|
|
@@ -137,10 +137,6 @@
|
|
|
137
137
|
"text": "anchor:reed.text",
|
|
138
138
|
"hover": "anchor:reed.hover"
|
|
139
139
|
},
|
|
140
|
-
"action": {
|
|
141
|
-
"color": "anchor:ember",
|
|
142
|
-
"ink": "anchor:night"
|
|
143
|
-
},
|
|
144
140
|
"structure": "anchor:reed"
|
|
145
141
|
},
|
|
146
142
|
"raised": {
|
|
@@ -154,10 +150,6 @@
|
|
|
154
150
|
"text": "anchor:reed.text",
|
|
155
151
|
"hover": "anchor:reed.hover"
|
|
156
152
|
},
|
|
157
|
-
"action": {
|
|
158
|
-
"color": "anchor:ember",
|
|
159
|
-
"ink": "anchor:night"
|
|
160
|
-
},
|
|
161
153
|
"structure": "anchor:root"
|
|
162
154
|
},
|
|
163
155
|
"inverted": {
|
|
@@ -171,10 +163,6 @@
|
|
|
171
163
|
"text": "anchor:ember",
|
|
172
164
|
"hover": "anchor:ember.hover"
|
|
173
165
|
},
|
|
174
|
-
"action": {
|
|
175
|
-
"color": "anchor:ember",
|
|
176
|
-
"ink": "anchor:night"
|
|
177
|
-
},
|
|
178
166
|
"structure": "anchor:mist",
|
|
179
167
|
"lightness": {
|
|
180
168
|
"surface": 0.14,
|
|
@@ -199,7 +187,9 @@
|
|
|
199
187
|
"series8": "#CC79A7"
|
|
200
188
|
},
|
|
201
189
|
"dataRamps": {},
|
|
202
|
-
"explicitMappingTokens": [
|
|
190
|
+
"explicitMappingTokens": [
|
|
191
|
+
"actionBackground"
|
|
192
|
+
],
|
|
203
193
|
"fontBody": "\"Noto Sans\", sans-serif",
|
|
204
194
|
"fontBrand": "\"Inika\", serif",
|
|
205
195
|
"fontHeadings": "\"Inika\", serif",
|
|
@@ -244,7 +234,7 @@
|
|
|
244
234
|
},
|
|
245
235
|
"action": {
|
|
246
236
|
"color": "anchor:ember",
|
|
247
|
-
"ink": "anchor:
|
|
237
|
+
"ink": "anchor:paper"
|
|
248
238
|
},
|
|
249
239
|
"structure": "anchor:root"
|
|
250
240
|
},
|
|
@@ -256,10 +246,17 @@
|
|
|
256
246
|
"success": 150,
|
|
257
247
|
"warning": 90
|
|
258
248
|
},
|
|
259
|
-
"semanticMappings": {
|
|
249
|
+
"semanticMappings": {
|
|
250
|
+
"actionBackground": {
|
|
251
|
+
"source": "anchor:ember",
|
|
252
|
+
"lightness": "tone:ember-deep"
|
|
253
|
+
}
|
|
254
|
+
},
|
|
260
255
|
"spaceRatio": 1.618,
|
|
261
256
|
"stylesheets": [],
|
|
262
|
-
"tones": {
|
|
257
|
+
"tones": {
|
|
258
|
+
"ember-deep": 0.5
|
|
259
|
+
},
|
|
263
260
|
"typeRatio": 1.25,
|
|
264
261
|
"variantChroma": {},
|
|
265
262
|
"viewportMax": 1200,
|
|
@@ -401,10 +398,6 @@
|
|
|
401
398
|
"text": "anchor:ember.text",
|
|
402
399
|
"hover": "anchor:ember.hover"
|
|
403
400
|
},
|
|
404
|
-
"action": {
|
|
405
|
-
"color": "anchor:ember",
|
|
406
|
-
"ink": "anchor:night"
|
|
407
|
-
},
|
|
408
401
|
"structure": "anchor:reed"
|
|
409
402
|
},
|
|
410
403
|
"raised": {
|
|
@@ -418,10 +411,6 @@
|
|
|
418
411
|
"text": "anchor:ember.text",
|
|
419
412
|
"hover": "anchor:ember.hover"
|
|
420
413
|
},
|
|
421
|
-
"action": {
|
|
422
|
-
"color": "anchor:ember",
|
|
423
|
-
"ink": "anchor:night"
|
|
424
|
-
},
|
|
425
414
|
"structure": "anchor:reed"
|
|
426
415
|
},
|
|
427
416
|
"inverted": {
|
|
@@ -435,10 +424,6 @@
|
|
|
435
424
|
"text": "anchor:reed.text",
|
|
436
425
|
"hover": "anchor:reed.hover"
|
|
437
426
|
},
|
|
438
|
-
"action": {
|
|
439
|
-
"color": "anchor:ember",
|
|
440
|
-
"ink": "anchor:night"
|
|
441
|
-
},
|
|
442
427
|
"structure": "anchor:root",
|
|
443
428
|
"lightness": {
|
|
444
429
|
"surface": 0.97,
|
|
@@ -463,7 +448,9 @@
|
|
|
463
448
|
"series8": "#CC79A7"
|
|
464
449
|
},
|
|
465
450
|
"dataRamps": {},
|
|
466
|
-
"explicitMappingTokens": [
|
|
451
|
+
"explicitMappingTokens": [
|
|
452
|
+
"actionBackground"
|
|
453
|
+
],
|
|
467
454
|
"fontBody": "\"Noto Sans\", sans-serif",
|
|
468
455
|
"fontBrand": "\"Inika\", serif",
|
|
469
456
|
"fontHeadings": "\"Inika\", serif",
|
|
@@ -520,10 +507,17 @@
|
|
|
520
507
|
"success": 150,
|
|
521
508
|
"warning": 90
|
|
522
509
|
},
|
|
523
|
-
"semanticMappings": {
|
|
510
|
+
"semanticMappings": {
|
|
511
|
+
"actionBackground": {
|
|
512
|
+
"source": "anchor:ember",
|
|
513
|
+
"lightness": "tone:ember-light"
|
|
514
|
+
}
|
|
515
|
+
},
|
|
524
516
|
"spaceRatio": 1.618,
|
|
525
517
|
"stylesheets": [],
|
|
526
|
-
"tones": {
|
|
518
|
+
"tones": {
|
|
519
|
+
"ember-light": 0.86
|
|
520
|
+
},
|
|
527
521
|
"typeRatio": 1.25,
|
|
528
522
|
"variantChroma": {},
|
|
529
523
|
"viewportMax": 1200,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@taprootio/site-authoring",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@taprootio/espalier": "4.
|
|
31
|
+
"@taprootio/espalier": "4.19.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"test": "node --test",
|
package/src/cli.js
CHANGED
|
@@ -300,6 +300,8 @@ const VERBS = Object.freeze([
|
|
|
300
300
|
+ "was lost is replayed safely; the site answers applied=false when it already holds the change set. "
|
|
301
301
|
+ "--dry-run reads the site, reports the JSON paths at which each settings file differs from it and whether "
|
|
302
302
|
+ "the recorded baseline is still current, and writes nothing. "
|
|
303
|
+
+ "Validation includes Espalier's fit report over the root and every context in both schemes; each lint prints "
|
|
304
|
+
+ "as a warning naming 'fit lint <id>' and does not stop the push, so read them in a --dry-run first. "
|
|
303
305
|
+ "pull writes each scheme's complete effective theme — the stored theme resolved over the same defaults every "
|
|
304
306
|
+ "consumer renders — so a fresh workspace validates as pulled. semanticMappings holds only authored pins, listed "
|
|
305
307
|
+ "in explicitMappingTokens; every other token compiles from roles at render time, so never copy default "
|
package/src/constants.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const CLI_NAME = "@taprootio/site-authoring";
|
|
2
|
-
export const CLI_VERSION = "0.10.
|
|
2
|
+
export const CLI_VERSION = "0.10.3";
|
|
3
3
|
export const CLI_BINARY_NAME = "taproot-site";
|
|
4
4
|
export const RESULT_SCHEMA_VERSION = 1;
|
|
5
5
|
export const CONFIG_FILE_NAME = "taproot-site.json";
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
MAXIMUM_THEME_OPEN_MAP_ENTRIES,
|
|
43
43
|
MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS,
|
|
44
44
|
REQUIRED_THEME_PROPERTIES,
|
|
45
|
-
|
|
45
|
+
validateAndLintThemePair,
|
|
46
46
|
} from "./theme-validation.js";
|
|
47
47
|
import {
|
|
48
48
|
SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
|
|
@@ -90,6 +90,12 @@ function exampleTheme(defaults, scheme) {
|
|
|
90
90
|
seedColor: scheme === "light" ? "oklch(0.58 0.14 305)" : "oklch(0.72 0.11 305)",
|
|
91
91
|
pageBackgroundImage: "",
|
|
92
92
|
pageBackgroundImageOpacity: 0,
|
|
93
|
+
// The shape pull writes for a theme authored through its roles: no cached
|
|
94
|
+
// mappings and a marker that claims none. The defaults' complete
|
|
95
|
+
// semanticMappings would hand every agent who copies this twenty entries
|
|
96
|
+
// the resolver never applies, each one a warning.
|
|
97
|
+
semanticMappings: {},
|
|
98
|
+
explicitMappingTokens: [],
|
|
93
99
|
anchors: {
|
|
94
100
|
plum: { color: "#6f2a78", text: "#4b1553", hover: "#8a3d92" },
|
|
95
101
|
paper: "#fffaf4",
|
|
@@ -120,6 +126,13 @@ function exampleTheme(defaults, scheme) {
|
|
|
120
126
|
accent: { color: "anchor:plum", text: "anchor:paper", hover: "anchor:plum.hover" },
|
|
121
127
|
action: { color: "anchor:paper", ink: "anchor:midnight" },
|
|
122
128
|
structure: "anchor:paper",
|
|
129
|
+
// Left to the engine, the paper action compiles to a grey button with
|
|
130
|
+
// a pale label — the wrong way round for a swatch that wants dark ink.
|
|
131
|
+
// A context declares its own action, so it needs its own pin; 0.88
|
|
132
|
+
// carries the midnight label at Lc 83 without crowding the status
|
|
133
|
+
// colours, which a paler stop does.
|
|
134
|
+
tones: { "paper-band": 0.88 },
|
|
135
|
+
semanticMappings: { actionBackground: { source: "anchor:paper", lightness: "tone:paper-band" } },
|
|
123
136
|
lightness: scheme === "light"
|
|
124
137
|
? { surface: 0.16, raised1: 0.2, raised2: 0.25, raised3: 0.3, raised4: 0.36, text: 0.96, ink: 0.99 }
|
|
125
138
|
: { surface: 0.12, raised1: 0.16, raised2: 0.21, raised3: 0.27, raised4: 0.34, text: 0.96, ink: 0.99 },
|
|
@@ -184,6 +197,7 @@ const THEME_GROUPS = Object.freeze([
|
|
|
184
197
|
themeField("tones", "Open map of named 0–1 lightness values for explicit mappings.", {
|
|
185
198
|
additionalProperties: true,
|
|
186
199
|
maximumEntries: MAXIMUM_THEME_OPEN_MAP_ENTRIES,
|
|
200
|
+
namePattern: "lowercase slug beginning with a letter",
|
|
187
201
|
maximumNameScalars: MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS,
|
|
188
202
|
minimum: 0,
|
|
189
203
|
maximum: 1,
|
|
@@ -288,14 +302,28 @@ export const ROLE_RESOLUTION = Object.freeze({
|
|
|
288
302
|
+ "lightness stop (with APCA enforcement) still sets its lightness. anchor:<name>.<slot> selects one of the "
|
|
289
303
|
+ "anchor's declared slots (text, hover, ...) as that hue-and-chroma source and resolves the same way; neither "
|
|
290
304
|
+ "form renders the declared colour verbatim.",
|
|
305
|
+
"Because an anchor supplies the chroma, a near-white anchor makes a near-grey ramp. A canvas anchor around "
|
|
306
|
+
+ "#FFFDF4 carries about 1% chroma, so background, every layer, and every border resolve at that 1% however "
|
|
307
|
+
+ "vivid the rest of the palette is: the page reads grey and no role, lightness stop or seedColor can add the "
|
|
308
|
+
+ "colour back. Give the canvas anchor the brand's own colour at full strength and let the lightness stops do "
|
|
309
|
+
+ "the lightening — a violet #6136C9 canvas resolves to a pale violet background and carries about 11% chroma "
|
|
310
|
+
+ "by the third layer, where a pale lavender picked to 'look like a light background' caps the whole ramp at "
|
|
311
|
+
+ "its own 6%. Choose the hue before the chroma: saturating toward whichever hue holds the most chroma near "
|
|
312
|
+
+ "white gives a tan page whatever the brand is, because pale yellows survive there and pale violets and blues "
|
|
313
|
+
+ "do not.",
|
|
314
|
+
"A canvas anchor needs to be its own anchor. The light ink on a dark band is usually the same near-white the "
|
|
315
|
+
+ "light scheme wanted for its page, so one anchor ends up serving both, and then it cannot be saturated "
|
|
316
|
+
+ "without putting brand-coloured text on a brand-coloured band. Declare a second anchor for the canvas and "
|
|
317
|
+
+ "leave the pale one to its ink duty.",
|
|
291
318
|
"Anchors resolve per scheme: the same anchor:<name> in lightTheme and darkTheme reads each scheme's own anchors "
|
|
292
319
|
+ "table, so a dark scheme usually declares darker or lighter anchors under the same names.",
|
|
293
320
|
]),
|
|
294
321
|
precedence: Object.freeze([
|
|
295
|
-
"
|
|
296
|
-
+ "
|
|
297
|
-
+ "
|
|
298
|
-
+ "
|
|
322
|
+
"A mapping in semanticMappings wins for its token only when explicitMappingTokens names it. The marker is what "
|
|
323
|
+
+ "makes a mapping a pin: the resolver recompiles every token the marker leaves out, so a mapping it does not "
|
|
324
|
+
+ "name is a cached value that is never applied. To pin a token, set the mapping and add the token to the "
|
|
325
|
+
+ "marker; to let a role reach it, drop the token from the marker or delete the mapping. A document that "
|
|
326
|
+
+ "carries no explicitMappingTokens at all predates the marker, and every mapping in it is still a pin.",
|
|
299
327
|
"Otherwise the token is compiled from the roles, and an APCA contrast check may nudge an action or ink stop so "
|
|
300
328
|
+ "the pair stays readable.",
|
|
301
329
|
"Otherwise the Espalier default mapping applies.",
|
|
@@ -309,21 +337,31 @@ function themeLightness(value) {
|
|
|
309
337
|
}
|
|
310
338
|
|
|
311
339
|
/**
|
|
312
|
-
*
|
|
340
|
+
* Each ink is checked against the surface it is actually painted on. A dark
|
|
313
341
|
* example whose headings resolve to a dark anchor on a dark canvas validated
|
|
314
|
-
* fine and read as a defect
|
|
315
|
-
*
|
|
342
|
+
* fine and read as a defect, so the examples must render as contrasting
|
|
343
|
+
* pairs rather than merely validate. Action ink is the one that does not sit
|
|
344
|
+
* on the canvas: it is painted on the action background, and pairing it with
|
|
345
|
+
* the canvas asserts the wrong thing — an action ink chosen to read on a
|
|
346
|
+
* dark button looked like a failure once the roles reached it.
|
|
316
347
|
*/
|
|
348
|
+
const EXAMPLE_CONTRAST_PAIRS = Object.freeze([
|
|
349
|
+
["--esp-color-text", "--esp-color-background"],
|
|
350
|
+
["--esp-color-headings", "--esp-color-background"],
|
|
351
|
+
["--esp-color-link", "--esp-color-background"],
|
|
352
|
+
["--esp-color-action-text", "--esp-color-action-background"],
|
|
353
|
+
]);
|
|
354
|
+
|
|
317
355
|
function assertExampleContrast(theme, scheme) {
|
|
318
356
|
const properties = computeThemeProperties(
|
|
319
357
|
mergeTheme(scheme === "light" ? DEFAULT_LIGHT_THEME : DEFAULT_DARK_THEME, theme),
|
|
320
358
|
scheme,
|
|
321
359
|
);
|
|
322
|
-
const
|
|
323
|
-
|
|
360
|
+
for (const [token, surfaceToken] of EXAMPLE_CONTRAST_PAIRS) {
|
|
361
|
+
const surface = themeLightness(properties[surfaceToken]);
|
|
324
362
|
const lightness = themeLightness(properties[token]);
|
|
325
|
-
if (
|
|
326
|
-
throw new Error(`${scheme} theme example: ${token} (${properties[token]}) does not contrast with
|
|
363
|
+
if (surface === undefined || lightness === undefined || Math.abs(lightness - surface) < 0.4) {
|
|
364
|
+
throw new Error(`${scheme} theme example: ${token} (${properties[token]}) does not contrast with ${surfaceToken} (${properties[surfaceToken]}).`);
|
|
327
365
|
}
|
|
328
366
|
}
|
|
329
367
|
}
|
|
@@ -350,13 +388,17 @@ export function getThemeReference() {
|
|
|
350
388
|
roleResolution: ROLE_RESOLUTION,
|
|
351
389
|
workflow: [
|
|
352
390
|
"Run taproot-site pull and edit the complete light/dark pair it writes; never build a sparse theme from memory. pull resolves the stored theme over the same defaults every consumer renders, so a fresh workspace validates and every group carries its effective value.",
|
|
353
|
-
"Define brand anchors first, then assign canvas, ink, accent, action, and structure roles in both schemes.",
|
|
354
|
-
"
|
|
391
|
+
"Define brand anchors first, then assign canvas, ink, accent, action, and structure roles in both schemes. Give the canvas anchor as much chroma as the design's saturation calls for: it sets the chroma of the background, every layer and every border, so a near-white paper anchor produces a grey page that no other setting can rescue.",
|
|
392
|
+
"Check the filled action before anything else looks finished. Its ramp stop is chosen by the engine near a mid-band target rather than named by the theme, so seating the ramp — the remedy for every other anchored token — cannot move it, and declaring an action role does not either. The only lever is pinning semanticMappings.actionBackground.",
|
|
393
|
+
"A brand colour between roughly L 0.55 and L 0.83 cannot be a button surface at its own lightness: across that band no ink, not even pure black, reaches the Lc 75 APCA asks of a button label. Espalier then walks the label to the far end of the ramp and the button renders inverted — a dark surface carrying a pale label where the swatch wanted the opposite. Pin the stop just past the band instead. A gold at L 0.81 pinned to a tone at L 0.85 carries a dark label at Lc 76; the same gold left to the engine compiles to a brown one.",
|
|
394
|
+
"Name a tone as a lowercase slug — gold-band, not goldBand. theme push refuses any other name, and refuses a mapping that names a tone its theme or context never declares.",
|
|
395
|
+
"theme push runs Espalier's fit report over the root and every context in both schemes and lists each finding as a warning reading 'fit lint <id>'; theme push --dry-run does the same and writes nothing, and validate does it for an offline fixture. Read the lints before calling a theme done: action-anchor-inversion names a filled action that rendered the opposite way round from the swatches it declares, apca-target-unmet names a pair enforcement could not rescue, and action-canvas-separation and link-hover-ordering cover an action lost in its canvas and a hover weaker than its link. A lint is a warning, not a refusal, so a push still goes through with one — the lints are what tell an agent the design did not survive the compile.",
|
|
396
|
+
"Add named contexts for whole zones, including inverted zones, and rebind lightness when the zone changes brightness. A context that declares its own action needs its own actionBackground pin: without one it compiles at the engine's mid-band stop, and where the root is pinned it carries the root's pin instead, whatever action colour the context declares.",
|
|
355
397
|
"Tune typography, type/space ratios, radii, and viewport interpolation as one layout system.",
|
|
356
398
|
"Typography is per scheme on purpose: light and dark set their own moods, so a heading, body, or brand face that differs between them is a design choice, and theme push never warns about it.",
|
|
357
399
|
"Retune danger/success/warning/info through intents without changing their meanings; design data palettes separately.",
|
|
358
|
-
"Use semanticMappings only for meanings roles and contexts cannot express; scattered pins shadow the coherent model. pull keeps only authored pins there and lists them in explicitMappingTokens; a
|
|
359
|
-
"Run theme push
|
|
400
|
+
"Use semanticMappings only for meanings roles and contexts cannot express; scattered pins shadow the coherent model. pull keeps only authored pins there and lists them in explicitMappingTokens; a mapping the marker does not name is never applied, so a hand-added pin needs its token added to the marker or theme push reports it as inert.",
|
|
401
|
+
"Run theme push --dry-run and clear its warnings, fit lints included; then run theme push and verify text, actions, focus states, and both schemes in authoring previews.",
|
|
360
402
|
],
|
|
361
403
|
example: THEME_EXAMPLE,
|
|
362
404
|
};
|
|
@@ -639,8 +681,13 @@ export function getFooterReference() {
|
|
|
639
681
|
};
|
|
640
682
|
}
|
|
641
683
|
|
|
642
|
-
export function assertPresentationExamples() {
|
|
643
|
-
|
|
684
|
+
export async function assertPresentationExamples() {
|
|
685
|
+
const themes = await validateAndLintThemePair(THEME_EXAMPLE.lightTheme, THEME_EXAMPLE.darkTheme);
|
|
686
|
+
// The example is what an agent copies, so it has to look like a finished
|
|
687
|
+
// theme: nothing for validate to warn about, fit lints included.
|
|
688
|
+
if (themes.warningCount > 0) {
|
|
689
|
+
throw new Error(`The theme example raises ${themes.warningCount} warning(s): ${themes.warnings.join(" | ")}`);
|
|
690
|
+
}
|
|
644
691
|
assertExampleContrast(THEME_EXAMPLE.lightTheme, "light");
|
|
645
692
|
assertExampleContrast(THEME_EXAMPLE.darkTheme, "dark");
|
|
646
693
|
validateFooterDocument(FOOTER_EXAMPLE);
|
package/src/theme-projection.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "@taprootio/espalier/shared/theme";
|
|
12
12
|
|
|
13
13
|
import { SiteAuthoringError } from "./errors.js";
|
|
14
|
-
import { missingThemeFields } from "./theme-validation.js";
|
|
14
|
+
import { authoredMappingTokens, missingThemeFields } from "./theme-validation.js";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Project one stored scheme theme to the complete theme a consumer actually
|
|
@@ -28,16 +28,16 @@ import { missingThemeFields } from "./theme-validation.js";
|
|
|
28
28
|
* every group with its authoritative value instead of reconstructing it.
|
|
29
29
|
*
|
|
30
30
|
* Semantic mappings are the one group that is *not* filled from defaults.
|
|
31
|
-
* Espalier compiles them from `roles` at merge time
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
31
|
+
* Espalier compiles them from `roles` at merge time, and since 4.18.0 the
|
|
32
|
+
* serialized `explicitMappingTokens` marker decides which stored mappings
|
|
33
|
+
* pin their token: an entry the marker does not claim is a cached value the
|
|
34
|
+
* resolver recompiles. So the projection keeps exactly the claimed mappings
|
|
35
|
+
* and drops the rest. Writing an unclaimed one back would pin a role-derived
|
|
36
|
+
* token and make the roles inert for it from then on — the failure a complete
|
|
37
|
+
* seeded theme used to exhibit for every token at once. A document carrying no
|
|
38
|
+
* usable marker predates that contract, and every mapping in it is still a
|
|
39
|
+
* pin, so all of them survive. `theme push` then stores pins only, and
|
|
40
|
+
* `theme push`'s own warnings report any mapping the marker leaves inert.
|
|
41
41
|
*
|
|
42
42
|
* Layering alone is not rendering parity: `mergeTheme` also lets an
|
|
43
43
|
* unpinned type-role ink inherit a customized `text` mapping, and lets a
|
|
@@ -61,12 +61,6 @@ function isPlainObject(value) {
|
|
|
61
61
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
function sameMapping(left, right) {
|
|
65
|
-
return isPlainObject(left) && isPlainObject(right)
|
|
66
|
-
&& left.source === right.source && left.lightness === right.lightness
|
|
67
|
-
&& Object.keys(left).length === Object.keys(right).length;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
64
|
/**
|
|
71
65
|
* The complete theme a consumer layers a stored theme onto: Espalier's
|
|
72
66
|
* scheme defaults, and — unless the theme is managed externally — the
|
|
@@ -97,14 +91,11 @@ export function projectPulledTheme(stored, scheme, { managedExternally = false }
|
|
|
97
91
|
// mapping as authored; the pin filter below then decides which survive.
|
|
98
92
|
const merged = mergePartials({ ...layerable, semanticMappings: {}, explicitMappingTokens: [] }, source);
|
|
99
93
|
|
|
100
|
-
const
|
|
101
|
-
? new Set(source.explicitMappingTokens.filter((token) => typeof token === "string"))
|
|
102
|
-
: undefined;
|
|
94
|
+
const authored = authoredMappingTokens(source);
|
|
103
95
|
const pins = {};
|
|
104
96
|
if (isPlainObject(source.semanticMappings)) {
|
|
105
97
|
for (const [token, mapping] of Object.entries(source.semanticMappings)) {
|
|
106
|
-
|
|
107
|
-
if (marker?.has(token) || !cachedDefault) pins[token] = mapping;
|
|
98
|
+
if (authored.has(token)) pins[token] = mapping;
|
|
108
99
|
}
|
|
109
100
|
}
|
|
110
101
|
const projected = reconcileWithRendering(
|
|
@@ -112,6 +103,7 @@ export function projectPulledTheme(stored, scheme, { managedExternally = false }
|
|
|
112
103
|
source,
|
|
113
104
|
scheme,
|
|
114
105
|
managedExternally,
|
|
106
|
+
authored,
|
|
115
107
|
);
|
|
116
108
|
|
|
117
109
|
requireProjectedThemeComplete(projected, scheme);
|
|
@@ -138,22 +130,18 @@ function canonicalJson(value) {
|
|
|
138
130
|
* Two passes suffice: a mapping pin can change what a companion token
|
|
139
131
|
* inherits, so the comparison runs again after the first corrections.
|
|
140
132
|
*
|
|
141
|
-
* One deliberate exception
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
133
|
+
* One deliberate exception, and it is the whole point of the pins-only
|
|
134
|
+
* document: a token its author never pinned is role-derived, so writing the
|
|
135
|
+
* value it happens to resolve to today would pin it and make the roles inert
|
|
136
|
+
* for it from then on — the defect Espalier 4.18.0 fixed. Those tokens stay
|
|
137
|
+
* out of the projection and keep recompiling. Every other difference between
|
|
138
|
+
* the projection and the stored theme's rendering is written back.
|
|
146
139
|
*/
|
|
147
|
-
function reconcileWithRendering(projected, stored, scheme, managedExternally) {
|
|
140
|
+
function reconcileWithRendering(projected, stored, scheme, managedExternally, authored) {
|
|
148
141
|
const { defaults } = SCHEME_DEFAULTS[scheme];
|
|
149
142
|
let rendered;
|
|
150
|
-
let roleCompiled;
|
|
151
143
|
try {
|
|
152
144
|
rendered = renderedTheme(stored, scheme, managedExternally);
|
|
153
|
-
const declaresRoles = isPlainObject(stored.roles) && Object.keys(stored.roles).length > 0;
|
|
154
|
-
roleCompiled = declaresRoles
|
|
155
|
-
? mergeTheme(defaults, { ...stored, semanticMappings: {}, explicitMappingTokens: [] }).semanticMappings
|
|
156
|
-
: undefined;
|
|
157
145
|
} catch {
|
|
158
146
|
// An invalid stored theme cannot be rendered for comparison; validation
|
|
159
147
|
// reports it, and the layered projection is the best available document.
|
|
@@ -176,11 +164,7 @@ function reconcileWithRendering(projected, stored, scheme, managedExternally) {
|
|
|
176
164
|
const pins = { ...current.semanticMappings };
|
|
177
165
|
for (const [token, mapping] of Object.entries(rendered.semanticMappings)) {
|
|
178
166
|
if (canonicalJson(mapping) === canonicalJson(candidate.semanticMappings[token])) continue;
|
|
179
|
-
|
|
180
|
-
const cachedDefaultTheRolesMove = roleCompiled !== undefined
|
|
181
|
-
&& sameMapping(storedMapping, defaults.semanticMappings[token])
|
|
182
|
-
&& canonicalJson(candidate.semanticMappings[token]) === canonicalJson(roleCompiled[token]);
|
|
183
|
-
if (cachedDefaultTheRolesMove) continue;
|
|
167
|
+
if (!authored.has(token)) continue;
|
|
184
168
|
pins[token] = structuredClone(mapping);
|
|
185
169
|
}
|
|
186
170
|
current = { ...current, semanticMappings: pins, explicitMappingTokens: Object.keys(pins) };
|
package/src/theme-validation.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
encodeTheme,
|
|
5
5
|
LIGHTNESS_KEYS,
|
|
6
6
|
mergeTheme,
|
|
7
|
+
parseTheme,
|
|
7
8
|
SEMANTIC_COLOR_NAMES,
|
|
8
9
|
validateThemePair,
|
|
9
10
|
} from "@taprootio/espalier/shared/theme";
|
|
@@ -11,7 +12,10 @@ import {
|
|
|
11
12
|
import { hasControlCharacter, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
|
|
12
13
|
|
|
13
14
|
export const MAXIMUM_THEME_WARNINGS = 32;
|
|
14
|
-
|
|
15
|
+
// Large enough for a whole fit lint: apca-target-unmet lists every pair that
|
|
16
|
+
// missed its floor before the remedy, and a surface that fails across the
|
|
17
|
+
// board runs past 900 scalars. Truncating it would cut the remedy off.
|
|
18
|
+
export const MAXIMUM_THEME_WARNING_SCALARS = 2048;
|
|
15
19
|
export const MAXIMUM_THEME_OPEN_MAP_ENTRIES = 128;
|
|
16
20
|
export const MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS = 64;
|
|
17
21
|
|
|
@@ -302,46 +306,180 @@ function sameMapping(left, right) {
|
|
|
302
306
|
}
|
|
303
307
|
|
|
304
308
|
/**
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
* owns the invalid cases.
|
|
309
|
+
* The tokens a stored theme's `explicitMappingTokens` marker claims, or
|
|
310
|
+
* undefined when the document has no usable marker at all.
|
|
311
|
+
*
|
|
312
|
+
* Espalier accepts a marker all-or-nothing: one entry that is not a semantic
|
|
313
|
+
* token name, one duplicate, or one name with no matching `semanticMappings`
|
|
314
|
+
* entry and it ignores the whole marker, falling back to treating every stored
|
|
315
|
+
* mapping as a pin. Reading a malformed marker leniently here — keeping the
|
|
316
|
+
* entries that happen to parse — would make this CLI describe and project a
|
|
317
|
+
* theme that renders differently from what Espalier actually resolves, which
|
|
318
|
+
* is the one thing the projection must never do.
|
|
316
319
|
*/
|
|
317
|
-
export function
|
|
318
|
-
|
|
319
|
-
if (!
|
|
320
|
+
export function serializedMarkerTokens(theme) {
|
|
321
|
+
const tokens = theme?.explicitMappingTokens;
|
|
322
|
+
if (!Array.isArray(tokens)) return undefined;
|
|
323
|
+
if (tokens.some((token) => typeof token !== "string" || !SEMANTIC_COLOR_NAMES.includes(token))) return undefined;
|
|
324
|
+
const unique = new Set(tokens);
|
|
325
|
+
if (unique.size !== tokens.length) return undefined;
|
|
326
|
+
if (unique.size > 0) {
|
|
327
|
+
if (!isPlainObject(theme.semanticMappings)) return undefined;
|
|
328
|
+
for (const token of unique) {
|
|
329
|
+
if (!Object.hasOwn(theme.semanticMappings, token)) return undefined;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return unique;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Which of a stored theme's `semanticMappings` its author actually pinned: the
|
|
337
|
+
* marker's tokens, or — for a document that carries no usable marker — all of
|
|
338
|
+
* them, since a marker-less document predates the contract and every mapping
|
|
339
|
+
* in it is still a pin.
|
|
340
|
+
*/
|
|
341
|
+
export function authoredMappingTokens(theme) {
|
|
342
|
+
if (!isPlainObject(theme) || !isPlainObject(theme.semanticMappings)) return new Set();
|
|
343
|
+
return serializedMarkerTokens(theme) ?? new Set(Object.keys(theme.semanticMappings));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* A mapping the document carries but never claims is inert. Since Espalier
|
|
348
|
+
* 4.18.0 the serialized marker decides which `semanticMappings` entries are
|
|
349
|
+
* pins: the resolver recompiles every other token from the roles and the
|
|
350
|
+
* stored value is not applied. A cached entry that already matches what the
|
|
351
|
+
* resolver produces is invisible — that is the seeded shape every site has —
|
|
352
|
+
* so only a value the resolver will actually discard is reported. That covers
|
|
353
|
+
* both the pin that merely repeats Espalier's default on a token the roles
|
|
354
|
+
* move (the accepted-but-gray link WTFM saw, TR00801) and a mapping edited by
|
|
355
|
+
* hand without adding its token to the marker, which would otherwise vanish
|
|
356
|
+
* with no sign it was ignored. A document with no marker at all predates the
|
|
357
|
+
* contract and keeps every mapping as a pin, so it has nothing to report. It
|
|
358
|
+
* is a warning rather than a refusal because the theme still renders a valid,
|
|
359
|
+
* coherent result; Espalier validation owns the invalid cases.
|
|
360
|
+
*/
|
|
361
|
+
export function inertMappingWarnings(theme, scheme) {
|
|
362
|
+
if (!isPlainObject(theme) || !isPlainObject(theme.semanticMappings)) return [];
|
|
363
|
+
// No usable marker means Espalier keeps every mapping as a pin, so nothing
|
|
364
|
+
// the document carries is inert and there is nothing to report.
|
|
365
|
+
const deliberate = serializedMarkerTokens(theme);
|
|
366
|
+
if (deliberate === undefined) return [];
|
|
320
367
|
const defaults = SCHEME_DEFAULTS[scheme];
|
|
321
|
-
let
|
|
368
|
+
let resolved;
|
|
322
369
|
try {
|
|
323
|
-
|
|
370
|
+
resolved = mergeTheme(defaults, theme).semanticMappings;
|
|
324
371
|
} catch {
|
|
325
372
|
// An invalid theme is Espalier validation's finding, not this one's.
|
|
326
373
|
return [];
|
|
327
374
|
}
|
|
328
|
-
const deliberate = new Set(Array.isArray(theme.explicitMappingTokens) ? theme.explicitMappingTokens : []);
|
|
329
375
|
const warnings = [];
|
|
330
376
|
for (const [token, mapping] of Object.entries(theme.semanticMappings)) {
|
|
331
377
|
if (deliberate.has(token)) continue;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
378
|
+
if (sameMapping(mapping, resolved[token])) continue;
|
|
379
|
+
const applied = isPlainObject(resolved[token])
|
|
380
|
+
? `${resolved[token].source}/${resolved[token].lightness}`
|
|
381
|
+
: "the compiled value";
|
|
382
|
+
warnings.push(
|
|
383
|
+
`${scheme}: semanticMappings.${token} is not named in explicitMappingTokens, so the resolver recompiles the `
|
|
384
|
+
+ `token and this value is never applied — ${token} renders as ${applied}; list ${token} in `
|
|
385
|
+
+ `explicitMappingTokens to pin the value, or remove the mapping to author the intent through the roles.`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
return warnings;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let fitReport;
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Espalier exports its fit report only from the package root, and the root
|
|
395
|
+
* re-exports every component module, which registers custom elements as it
|
|
396
|
+
* loads. This package must never do that: a process that already holds
|
|
397
|
+
* another Espalier copy — the generator's browser harness loads this package
|
|
398
|
+
* beside its own — throws on a second esp-root
|
|
399
|
+
* (generator/src/site-authoring-content-isolation.test.ts). The report itself
|
|
400
|
+
* is side-effect free and sits beside the exported theme module, so it is
|
|
401
|
+
* loaded from there, sharing that module's instance, and only when a theme is
|
|
402
|
+
* actually checked. The exact Espalier pin and this package's tests catch a
|
|
403
|
+
* release that moves it.
|
|
404
|
+
*/
|
|
405
|
+
function loadFitReport() {
|
|
406
|
+
fitReport ??= import(new URL("./theme-fit-report.js", import.meta.resolve("@taprootio/espalier/shared/theme")).href);
|
|
407
|
+
return fitReport;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Validation says whether Espalier accepts a theme; the fit report says how
|
|
412
|
+
* the accepted theme renders. Its lints are the findings no validator can
|
|
413
|
+
* make, because each is about the compiled result: a filled action that came
|
|
414
|
+
* out the opposite way round from its swatches (action-anchor-inversion), a
|
|
415
|
+
* text pair enforcement could not rescue (apca-target-unmet), an action lost
|
|
416
|
+
* in its canvas, or a hover weaker than its resting link. Every surface is
|
|
417
|
+
* checked — the root and each context, in both schemes — because a context
|
|
418
|
+
* declares its own action and fails on its own; a root-only check misses it.
|
|
419
|
+
* The suite's data-palette lints are left out: validateThemePair already warns
|
|
420
|
+
* about the same collisions, in the same words.
|
|
421
|
+
*
|
|
422
|
+
* The pair passed in is the encoded one that is stored, parsed back, so the
|
|
423
|
+
* report describes exactly what the published page resolves. Lints are
|
|
424
|
+
* warnings rather than refusals, matching how Espalier classifies them.
|
|
425
|
+
*/
|
|
426
|
+
export async function fitLintWarnings(light, dark) {
|
|
427
|
+
const { ROOT_SURFACE, themeFitReportSuite } = await loadFitReport();
|
|
428
|
+
let suite;
|
|
429
|
+
try {
|
|
430
|
+
suite = themeFitReportSuite(parseTheme(light) ?? {}, parseTheme(dark) ?? {});
|
|
431
|
+
} catch (error) {
|
|
432
|
+
// A pair that passed validation resolves, so this should not happen; if
|
|
433
|
+
// it does, say the lints were not checked rather than implying none fired.
|
|
434
|
+
return [`Espalier could not build the fit report, so no fit lints were checked: ${error?.message ?? error}`];
|
|
435
|
+
}
|
|
436
|
+
const warnings = [];
|
|
437
|
+
for (const scheme of ["light", "dark"]) {
|
|
438
|
+
for (const report of suite[scheme]) {
|
|
439
|
+
const surface = report.surface === ROOT_SURFACE ? "" : `contexts.${report.surface} `;
|
|
440
|
+
for (const lint of report.lints) {
|
|
441
|
+
warnings.push(`${scheme}: ${surface}fit lint ${lint.id} — ${lint.message}`);
|
|
442
|
+
}
|
|
339
443
|
}
|
|
340
444
|
}
|
|
341
445
|
return warnings;
|
|
342
446
|
}
|
|
343
447
|
|
|
448
|
+
function boundedThemeWarnings(allWarnings) {
|
|
449
|
+
const warnings = allWarnings
|
|
450
|
+
.slice(0, MAXIMUM_THEME_WARNINGS)
|
|
451
|
+
.map((warning) =>
|
|
452
|
+
[...sanitizeDiagnostic(warning, "Theme validation warning.")]
|
|
453
|
+
.slice(0, MAXIMUM_THEME_WARNING_SCALARS)
|
|
454
|
+
.join("")
|
|
455
|
+
);
|
|
456
|
+
return {
|
|
457
|
+
warnings,
|
|
458
|
+
warningCount: allWarnings.length,
|
|
459
|
+
warningsTruncated: allWarnings.length > warnings.length,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
344
463
|
export function validateAndEncodeThemePair(lightTheme, darkTheme) {
|
|
464
|
+
const { light, dark, warnings, inert } = checkThemePair(lightTheme, darkTheme);
|
|
465
|
+
return { light, dark, ...boundedThemeWarnings([...warnings, ...inert]) };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The validation `validate` and `theme push` run: everything
|
|
470
|
+
* validateAndEncodeThemePair checks, plus the fit lints over the accepted
|
|
471
|
+
* pair, bounded together so the cap and the count cover both. The lints come
|
|
472
|
+
* before the inert-mapping warnings because the seeded shape alone produces
|
|
473
|
+
* one of those per cached token and scheme — enough to fill the cap — and a
|
|
474
|
+
* lint is the finding an author can least afford to have cut off.
|
|
475
|
+
*/
|
|
476
|
+
export async function validateAndLintThemePair(lightTheme, darkTheme) {
|
|
477
|
+
const { light, dark, warnings, inert } = checkThemePair(lightTheme, darkTheme);
|
|
478
|
+
const lints = await fitLintWarnings(light, dark);
|
|
479
|
+
return { light, dark, ...boundedThemeWarnings([...warnings, ...lints, ...inert]) };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function checkThemePair(lightTheme, darkTheme) {
|
|
345
483
|
requireCompleteTheme(lightTheme, "light");
|
|
346
484
|
requireCompleteTheme(darkTheme, "dark");
|
|
347
485
|
|
|
@@ -372,24 +510,11 @@ export function validateAndEncodeThemePair(lightTheme, darkTheme) {
|
|
|
372
510
|
}
|
|
373
511
|
refuseAgentStylesheets(lightTheme, "light");
|
|
374
512
|
refuseAgentStylesheets(darkTheme, "dark");
|
|
375
|
-
const allWarnings = [
|
|
376
|
-
...result.warnings,
|
|
377
|
-
...shadowedRoleMappingWarnings(lightTheme, "light"),
|
|
378
|
-
...shadowedRoleMappingWarnings(darkTheme, "dark"),
|
|
379
|
-
];
|
|
380
|
-
const warnings = allWarnings
|
|
381
|
-
.slice(0, MAXIMUM_THEME_WARNINGS)
|
|
382
|
-
.map((warning) =>
|
|
383
|
-
[...sanitizeDiagnostic(warning, "Theme validation warning.")]
|
|
384
|
-
.slice(0, MAXIMUM_THEME_WARNING_SCALARS)
|
|
385
|
-
.join("")
|
|
386
|
-
);
|
|
387
513
|
return {
|
|
388
514
|
light,
|
|
389
515
|
dark,
|
|
390
|
-
warnings,
|
|
391
|
-
|
|
392
|
-
warningsTruncated: allWarnings.length > warnings.length,
|
|
516
|
+
warnings: result.warnings,
|
|
517
|
+
inert: [...inertMappingWarnings(lightTheme, "light"), ...inertMappingWarnings(darkTheme, "dark")],
|
|
393
518
|
};
|
|
394
519
|
}
|
|
395
520
|
|
package/src/verbs/theme-push.js
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
|
|
30
30
|
SETTINGS_TYPE_TAPROOT_STYLES,
|
|
31
31
|
} from "../settings-catalog.js";
|
|
32
|
-
import { missingThemeFields,
|
|
32
|
+
import { missingThemeFields, validateAndLintThemePair } from "../theme-validation.js";
|
|
33
33
|
import { ApiError } from "../transport.js";
|
|
34
34
|
import {
|
|
35
35
|
readWorkspaceJson,
|
|
@@ -151,7 +151,7 @@ export async function validateThemeWorkspace(workspaceDir, siteId, knownImageIds
|
|
|
151
151
|
},
|
|
152
152
|
);
|
|
153
153
|
}
|
|
154
|
-
const themes =
|
|
154
|
+
const themes = await validateAndLintThemePair(style.lightTheme, style.darkTheme);
|
|
155
155
|
const scalarOperations = buildAppearanceScalarOperations(
|
|
156
156
|
documents,
|
|
157
157
|
knownImageIds,
|
|
@@ -316,6 +316,10 @@ export async function themePush(invocation) {
|
|
|
316
316
|
}
|
|
317
317
|
onProgress(`Validated the complete light/dark theme pair and ${scalarOperations.length} appearance settings.`);
|
|
318
318
|
for (const warning of themes.warnings) onProgress(`Espalier warning: ${warning}`);
|
|
319
|
+
if (themes.warningsTruncated) {
|
|
320
|
+
const hidden = themes.warningCount - themes.warnings.length;
|
|
321
|
+
onProgress(`${hidden} more Espalier warning(s) not shown; resolve the ones above and run theme push --dry-run to see them.`);
|
|
322
|
+
}
|
|
319
323
|
const warnings = {
|
|
320
324
|
items: themes.warnings,
|
|
321
325
|
count: themes.warningCount,
|
package/src/verbs/validate.js
CHANGED
|
@@ -448,6 +448,10 @@ export async function validateFixture(invocation = {}) {
|
|
|
448
448
|
}
|
|
449
449
|
}
|
|
450
450
|
for (const warning of presentation.themes.warnings) onProgress(`Espalier warning: ${warning}`);
|
|
451
|
+
if (presentation.themes.warningsTruncated) {
|
|
452
|
+
const hidden = presentation.themes.warningCount - presentation.themes.warnings.length;
|
|
453
|
+
onProgress(`${hidden} more Espalier warning(s) not shown; resolve the ones above and run validate again to see them.`);
|
|
454
|
+
}
|
|
451
455
|
const sharedContexts = sharedThemeContextNames(
|
|
452
456
|
presentation.style.lightTheme,
|
|
453
457
|
presentation.style.darkTheme,
|