@taprootio/site-authoring 0.10.2 → 0.10.7

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 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:night"
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.2",
3
+ "version": "0.10.7",
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.17.0"
31
+ "@taprootio/espalier": "4.20.1"
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";
2
+ export const CLI_VERSION = "0.10.7";
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";
@@ -203,14 +203,25 @@ const TESTIMONIAL = Object.freeze({
203
203
 
204
204
  /** `LatestPostsData` — resolved at generation time. */
205
205
  const LATEST_POSTS = Object.freeze({
206
- count: num({ values: Object.freeze([3, 6, 9, 12]) }),
207
- columns: num({ values: Object.freeze([2, 3, 4]) }),
206
+ count: num({
207
+ integer: true,
208
+ minimum: 1,
209
+ maximum: 12,
210
+ description: "How many posts to show, 1 to 12; a single post spans the full width.",
211
+ }),
212
+ columns: num({ values: Object.freeze([1, 2, 3, 4]) }),
208
213
  pageTypes: list(enumOf(...LATEST_POST_PAGE_TYPES)),
209
214
  showPinned: bool(),
210
215
  highlightFirst: bool(),
211
216
  showDescription: bool(),
212
217
  showDate: bool(),
213
218
  showAuthor: bool(),
219
+ borderWidth: num({
220
+ integer: true,
221
+ minimum: 0,
222
+ description: "Pixel border around each card, as on a card grid; the grid itself is never framed. 0 boxes nothing; omitted publishes 1.",
223
+ }),
224
+ bannerScheme: enumOf("auto", "light", "dark"),
214
225
  });
215
226
 
216
227
  /** `CardGridData`. `cropState` is the one genuinely optional field in types.ts. */
@@ -435,6 +446,8 @@ const COMPONENT_DEFINITIONS = Object.freeze({
435
446
  showDescription: true,
436
447
  showDate: true,
437
448
  showAuthor: false,
449
+ borderWidth: 1,
450
+ bannerScheme: "auto",
438
451
  },
439
452
  ["Keep descriptions enabled when titles alone do not make each destination clear."],
440
453
  {
@@ -446,6 +459,8 @@ const COMPONENT_DEFINITIONS = Object.freeze({
446
459
  showDescription: true,
447
460
  showDate: true,
448
461
  showAuthor: false,
462
+ borderWidth: 1,
463
+ bannerScheme: "dark",
449
464
  },
450
465
  ),
451
466
  "card-grid": component(
@@ -732,10 +732,10 @@ function renderSection(
732
732
  "--esp-section-padding-block: 0",
733
733
  );
734
734
  }
735
- if (kind === "explicit") {
736
- const decorationStyle = normalizedSectionDecorationStyle(attrs.decoration);
737
- if (decorationStyle) sectionStyleParts.push(...decorationStyle);
738
- }
735
+ // Espalier paints the decoration plane only on a section carrying the
736
+ // `decoration` attribute, so the hooks and the attribute travel together.
737
+ const decorationStyle = kind === "explicit" ? normalizedSectionDecorationStyle(attrs.decoration) : undefined;
738
+ if (decorationStyle) sectionStyleParts.push(...decorationStyle);
739
739
  const background = kind === "explicit" ? normalizeSectionBackground(attrs.background) : undefined;
740
740
  const backgroundPicture = background
741
741
  ? renderSectionBackgroundPicture(background, options.claimSectionBackgroundImage?.() === true)
@@ -773,6 +773,7 @@ function renderSection(
773
773
  "data-entrance": entrance,
774
774
  "data-entrance-stagger": entranceStagger,
775
775
  context,
776
+ decoration: decorationStyle ? true : undefined,
776
777
  style: sectionStyle,
777
778
  },
778
779
  surfaced,
@@ -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
- validateAndEncodeThemePair,
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
- "Any mapping present in semanticMappings wins for its token, whether or not explicitMappingTokens names it: "
296
- + "the shipped resolver overlays the stored mappings after compiling the roles. To let a role reach a token, "
297
- + "delete the mapping; editing the marker alone does not unpin it. explicitMappingTokens records which mappings "
298
- + "are authored pins and is what pull writes.",
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
- * The examples must render as contrasting pairs, not merely validate: a dark
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. Resolve both through Espalier and require the
315
- * text, heading and link tokens to sit well away from the canvas lightness.
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 canvas = themeLightness(properties["--esp-color-background"]);
323
- for (const token of ["--esp-color-text", "--esp-color-headings", "--esp-color-link", "--esp-color-action-text"]) {
360
+ for (const [token, surfaceToken] of EXAMPLE_CONTRAST_PAIRS) {
361
+ const surface = themeLightness(properties[surfaceToken]);
324
362
  const lightness = themeLightness(properties[token]);
325
- if (canvas === undefined || lightness === undefined || Math.abs(lightness - canvas) < 0.4) {
326
- throw new Error(`${scheme} theme example: ${token} (${properties[token]}) does not contrast with the canvas (${properties["--esp-color-background"]}).`);
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
- "Add named contexts for whole zones, including inverted zones, and rebind lightness when the zone changes brightness.",
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 default mapping copied into the document becomes a pin and makes the role for that token inert.",
359
- "Run theme push, inspect warnings, then verify text, actions, focus states, and both schemes in authoring previews.",
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
- validateAndEncodeThemePair(THEME_EXAMPLE.lightTheme, THEME_EXAMPLE.darkTheme);
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);
@@ -51,7 +51,7 @@ import {
51
51
 
52
52
  export { getAppearanceReference, getFooterReference, getThemeReference };
53
53
 
54
- export const REFERENCE_VERSION = 24;
54
+ export const REFERENCE_VERSION = 26;
55
55
  export const PAGE_TYPES = Object.freeze(["free-form"]);
56
56
 
57
57
  function deepFreeze(value) {
@@ -177,7 +177,7 @@ const DESIGN_BLUEPRINTS = deepFreeze([
177
177
  section: { entrance: "fade", entranceStagger: "tight" },
178
178
  components: [
179
179
  { type: "image-banner", properties: { ratio: "2/1", heightMode: "ratio", contentPosition: "bottom-start", scrim: "bottom", imageMotion: "none" } },
180
- { type: "latest-posts", properties: { count: 6, columns: 3, highlightFirst: false, showDescription: true, showDate: true } },
180
+ { type: "latest-posts", properties: { count: 5, columns: 3, highlightFirst: false, showDescription: true, showDate: true, borderWidth: 1, bannerScheme: "auto" } },
181
181
  ],
182
182
  },
183
183
  {
@@ -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; a mapping stored in
32
- * `semanticMappings` pins that token and shadows whatever the roles would
33
- * have produced. Copying the defaults' twenty-three mappings into the
34
- * document would therefore freeze them and make later role changes inert
35
- * — the failure a complete seeded theme already exhibits. The projection
36
- * keeps only the mappings that are authored pins: those the stored marker
37
- * names, plus any mapping whose value differs from the effective default
38
- * (a pin added by hand without updating the marker). It writes the marker
39
- * for exactly that set, so `theme push` stores pins only and roles compile
40
- * for everything else.
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 marker = Array.isArray(source.explicitMappingTokens)
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
- const cachedDefault = sameMapping(mapping, base.semanticMappings?.[token]);
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: a cached default mapping on a token the stored
142
- * theme's own roles would move is the shadowing pin TR00801 documents and
143
- * warns about. Restoring it would keep the roles inert, so it stays dropped
144
- * and the role-compiled value is accepted as the rendering the author asked
145
- * for. Every other difference is written back.
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
- const storedMapping = isPlainObject(stored.semanticMappings) ? stored.semanticMappings[token] : undefined;
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) };
@@ -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
- export const MAXIMUM_THEME_WARNING_SCALARS = 512;
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
- * Roles compile the semantic mappings at render time, and any mapping present
306
- * in `semanticMappings` pins its token over that compilation (TR00801). A pin
307
- * that merely repeats Espalier's default on a token the declared roles would
308
- * have moved is the one an author never meant: it came from a copied example
309
- * or a pre-projection pull, and it silently keeps the role from reaching the
310
- * token — the accepted-but-gray link WTFM saw. Which tokens the roles move is
311
- * asked of Espalier itself, by compiling the same roles with no pins at all;
312
- * a token the roles leave at its default, or one the marker names as a
313
- * deliberate pin, is never reported. It is a warning rather than a refusal
314
- * because the pair still renders exactly as declared; Espalier validation
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 shadowedRoleMappingWarnings(theme, scheme) {
318
- if (!isPlainObject(theme) || !isPlainObject(theme.roles) || Object.keys(theme.roles).length === 0) return [];
319
- if (!isPlainObject(theme.semanticMappings)) return [];
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 compiled;
368
+ let resolved;
322
369
  try {
323
- compiled = mergeTheme(defaults, { ...theme, semanticMappings: {}, explicitMappingTokens: [] }).semanticMappings;
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
- const fallback = defaults.semanticMappings[token];
333
- if (sameMapping(mapping, fallback) && !sameMapping(compiled[token], fallback)) {
334
- warnings.push(
335
- `${scheme}: semanticMappings.${token} repeats the Espalier default and pins the token, so the declared roles `
336
- + `never reach it; remove the pin to let the roles compile ${token}, or list it in explicitMappingTokens `
337
- + `if the default is the intent.`,
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
- warningCount: allWarnings.length,
392
- warningsTruncated: allWarnings.length > warnings.length,
516
+ warnings: result.warnings,
517
+ inert: [...inertMappingWarnings(lightTheme, "light"), ...inertMappingWarnings(darkTheme, "dark")],
393
518
  };
394
519
  }
395
520
 
@@ -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, validateAndEncodeThemePair } from "../theme-validation.js";
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 = validateAndEncodeThemePair(style.lightTheme, style.darkTheme);
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,
@@ -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,