@taprootio/site-authoring 0.8.4 → 0.8.6

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/errors.js CHANGED
@@ -91,6 +91,17 @@ export function sanitizeDiagnostic(value, fallback = "The operation failed.") {
91
91
  return [...clean].slice(0, LIMITS.diagnosticScalars).join("") || fallback;
92
92
  }
93
93
 
94
+ /**
95
+ * The fixed wording every `theme.setting_missing` detail carries. A required
96
+ * key that is absent is exactly that: absence alone says nothing about when
97
+ * the key entered the contract, so the text never claims a later contract
98
+ * introduced it. The remedy names both truthful recoveries — restore the key
99
+ * locally, or refresh a workspace that is stale — without promising that a
100
+ * repeated pull repairs a server projection that already omits the key.
101
+ */
102
+ export const MISSING_SETTING_MESSAGE =
103
+ "is required and absent (restore it locally, or run 'taproot-site pull' if this workspace is stale)";
104
+
94
105
  /**
95
106
  * The one error type this package throws. Every failure carries a stable
96
107
  * dotted code and an exit code: 2 for a usage fault the caller fixes by
@@ -111,7 +122,7 @@ export class SiteAuthoringError extends Error {
111
122
  .map((item) => ({
112
123
  code: item.code,
113
124
  field: sanitizeDiagnostic(item.field, "").slice(0, 200),
114
- message: "is missing (added in a later contract; run taproot-site pull)",
125
+ message: MISSING_SETTING_MESSAGE,
115
126
  }))
116
127
  : undefined;
117
128
  this.previewDiagnostic = normalizePreviewDiagnostic(options.previewDiagnostic);
@@ -1,5 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
 
3
+ import { computeThemeProperties } from "@taprootio/espalier/shared/theme-properties";
4
+
3
5
  import {
4
6
  DATA_SERIES_KEYS,
5
7
  DEFAULT_DATA_RAMP_STEPS,
@@ -11,6 +13,7 @@ import {
11
13
  DEFAULT_DARK_THEME,
12
14
  DEFAULT_LIGHT_THEME,
13
15
  LIGHTNESS_KEYS,
16
+ mergeTheme,
14
17
  NESTED_THEME_KEYS,
15
18
  ROLE_NAMES,
16
19
  SEMANTIC_COLOR_NAMES,
@@ -92,13 +95,24 @@ function exampleTheme(defaults, scheme) {
92
95
  paper: "#fffaf4",
93
96
  midnight: "#21172b",
94
97
  },
95
- roles: {
96
- canvas: "primary",
97
- ink: { color: "primary", heading: "anchor:midnight" },
98
- accent: { color: "anchor:plum", text: "anchor:plum.text", hover: "anchor:plum.hover" },
99
- action: { color: "anchor:plum", ink: "anchor:paper" },
100
- structure: "primary",
101
- },
98
+ // Contrasting on purpose: light headings sit in the dark anchor on the
99
+ // light canvas, dark headings in the paper anchor on the dark canvas. The
100
+ // pair is resolved and contrast-checked by assertPresentationExamples.
101
+ roles: scheme === "light"
102
+ ? {
103
+ canvas: "primary",
104
+ ink: { color: "primary", heading: "anchor:midnight" },
105
+ accent: { color: "anchor:plum", text: "anchor:plum.text", hover: "anchor:plum.hover" },
106
+ action: { color: "anchor:plum", ink: "anchor:paper" },
107
+ structure: "primary",
108
+ }
109
+ : {
110
+ canvas: "primary",
111
+ ink: { color: "primary", heading: "anchor:paper" },
112
+ accent: { color: "anchor:plum", text: "anchor:plum.hover", hover: "anchor:paper" },
113
+ action: { color: "anchor:plum", ink: "anchor:paper" },
114
+ structure: "primary",
115
+ },
102
116
  contexts: {
103
117
  inverted: {
104
118
  canvas: "anchor:midnight",
@@ -251,6 +265,69 @@ const THEME_GROUPS = Object.freeze([
251
265
  }),
252
266
  ]);
253
267
 
268
+ /**
269
+ * How a role binding becomes a rendered token, in the order the resolver
270
+ * applies it. Stated once here so help theme, the walkthrough and the
271
+ * validation warnings describe the same model (TR00801, TR00806).
272
+ */
273
+ export const ROLE_RESOLUTION = Object.freeze({
274
+ slots: Object.freeze({
275
+ canvas: Object.freeze([]),
276
+ ink: Object.freeze(["heading"]),
277
+ accent: Object.freeze(["text", "hover"]),
278
+ action: Object.freeze(["ink"]),
279
+ structure: Object.freeze([]),
280
+ }),
281
+ bindingForms: Object.freeze([
282
+ "A role takes one mapping source, or { color, ...slots } to bind its slots separately.",
283
+ "A geometric source (" + COLOR_SOURCES.filter((source) => !STATUS_COLOR_SOURCES.includes(source)).join(", ")
284
+ + ") is relative: it takes the seed hue through that family's angle and the token's lightness stop, so it moves "
285
+ + "with seedColor and the lightness ramp. A status family (" + STATUS_COLOR_SOURCES.join(", ")
286
+ + ") keeps its fixed hue from semanticHues (or its intents override) and does not move with seedColor.",
287
+ "anchor:<name> is absolute in hue and chroma: the anchor's declared colour supplies both, and the token's own "
288
+ + "lightness stop (with APCA enforcement) still sets its lightness. anchor:<name>.<slot> selects one of the "
289
+ + "anchor's declared slots (text, hover, ...) as that hue-and-chroma source and resolves the same way; neither "
290
+ + "form renders the declared colour verbatim.",
291
+ "Anchors resolve per scheme: the same anchor:<name> in lightTheme and darkTheme reads each scheme's own anchors "
292
+ + "table, so a dark scheme usually declares darker or lighter anchors under the same names.",
293
+ ]),
294
+ 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.",
299
+ "Otherwise the token is compiled from the roles, and an APCA contrast check may nudge an action or ink stop so "
300
+ + "the pair stays readable.",
301
+ "Otherwise the Espalier default mapping applies.",
302
+ "A context rebinds roles, lightness, tones and pins for one zone with the same precedence inside that zone.",
303
+ ]),
304
+ });
305
+
306
+ function themeLightness(value) {
307
+ const match = /^oklch\(\s*([0-9.]+)/u.exec(value ?? "");
308
+ return match ? Number(match[1]) : undefined;
309
+ }
310
+
311
+ /**
312
+ * The examples must render as contrasting pairs, not merely validate: a dark
313
+ * 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.
316
+ */
317
+ function assertExampleContrast(theme, scheme) {
318
+ const properties = computeThemeProperties(
319
+ mergeTheme(scheme === "light" ? DEFAULT_LIGHT_THEME : DEFAULT_DARK_THEME, theme),
320
+ scheme,
321
+ );
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"]) {
324
+ 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"]}).`);
327
+ }
328
+ }
329
+ }
330
+
254
331
  export function getThemeReference() {
255
332
  const version = getEspalierVersion();
256
333
  return {
@@ -270,14 +347,15 @@ export function getThemeReference() {
270
347
  semanticColors: SEMANTIC_COLOR_NAMES,
271
348
  dataSeries: DATA_SERIES_KEYS,
272
349
  },
350
+ roleResolution: ROLE_RESOLUTION,
273
351
  workflow: [
274
- "Run taproot-site pull and edit the complete light/dark pair it writes; never build a sparse theme from memory.",
352
+ "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.",
275
353
  "Define brand anchors first, then assign canvas, ink, accent, action, and structure roles in both schemes.",
276
354
  "Add named contexts for whole zones, including inverted zones, and rebind lightness when the zone changes brightness.",
277
355
  "Tune typography, type/space ratios, radii, and viewport interpolation as one layout system.",
278
356
  "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.",
279
357
  "Retune danger/success/warning/info through intents without changing their meanings; design data palettes separately.",
280
- "Use semanticMappings only for meanings roles and contexts cannot express; scattered pins shadow the coherent model.",
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.",
281
359
  "Run theme push, inspect warnings, then verify text, actions, focus states, and both schemes in authoring previews.",
282
360
  ],
283
361
  example: THEME_EXAMPLE,
@@ -541,6 +619,8 @@ export function getFooterReference() {
541
619
 
542
620
  export function assertPresentationExamples() {
543
621
  validateAndEncodeThemePair(THEME_EXAMPLE.lightTheme, THEME_EXAMPLE.darkTheme);
622
+ assertExampleContrast(THEME_EXAMPLE.lightTheme, "light");
623
+ assertExampleContrast(THEME_EXAMPLE.darkTheme, "dark");
544
624
  validateFooterDocument(FOOTER_EXAMPLE);
545
625
  }
546
626
 
@@ -600,7 +680,15 @@ export function formatPresentationReference(reference) {
600
680
  + reference.themePaths.join(", ") + ")\nComplete pulled baseline required: yes\nNested merge groups: "
601
681
  + reference.nestedMergeGroups.join(", ") + "\n\nDesign workflow:\n"
602
682
  + reference.workflow.map((step, index) => " " + (index + 1) + ". " + step).join("\n")
603
- + "\n\nTheme groups:\n" + groups + "\n\nValid complete pair:\n"
683
+ + "\n\nRole slots:\n"
684
+ + Object.entries(reference.roleResolution.slots).map(([role, slots]) =>
685
+ " " + role.padEnd(10) + (slots.length === 0 ? "color only" : "color, " + slots.join(", "))
686
+ ).join("\n")
687
+ + "\n\nHow a binding resolves:\n"
688
+ + reference.roleResolution.bindingForms.map((line) => " - " + line).join("\n")
689
+ + "\n\nPrecedence:\n"
690
+ + reference.roleResolution.precedence.map((line, index) => " " + (index + 1) + ". " + line).join("\n")
691
+ + "\n\nTheme groups:\n" + groups + "\n\nValid complete pair (both schemes resolved and contrast-checked):\n"
604
692
  + JSON.stringify(reference.example, null, 2) + "\n";
605
693
  }
606
694
  if (reference.referenceKind === "appearance") {
@@ -374,6 +374,16 @@ export const REFERENCE_TOPICS = Object.freeze([
374
374
  usage: `${CLI_BINARY_NAME} help preview`,
375
375
  summary: "Describe authoring preview creation, waiting, and recovery.",
376
376
  }),
377
+ Object.freeze({
378
+ name: "delivery",
379
+ usage: `${CLI_BINARY_NAME} help delivery`,
380
+ summary: "Verify visitor-facing delivery after a deployment: routes, assets, runtime, browser.",
381
+ }),
382
+ Object.freeze({
383
+ name: "walkthrough",
384
+ usage: `${CLI_BINARY_NAME} help walkthrough`,
385
+ summary: "Design a site end to end: select, pull, edit both schemes, validate, push, review staging, promote.",
386
+ }),
377
387
  Object.freeze({
378
388
  name: "theme",
379
389
  usage: `${CLI_BINARY_NAME} help theme`,
@@ -397,6 +407,105 @@ export const REFERENCE_TOPICS = Object.freeze([
397
407
  ]);
398
408
 
399
409
  const WORKFLOW_REFERENCES = Object.freeze({
410
+ delivery: Object.freeze({
411
+ title: "Delivery verification",
412
+ summary: "delivery check reads a completed deployment's target the way a visitor does and reports HTTP delivery, runtime compatibility and (optionally) browser behaviour as separate dimensions.",
413
+ usage: `${CLI_BINARY_NAME} delivery check (--staging | --production [--url <origin>]) [--wait <seconds>] [--no-browser]`,
414
+ details: Object.freeze([
415
+ "A completed deployment job proves Taproot wrote what it meant to write, not that a visitor receives it. The "
416
+ + "check verifies the latest completed deployment for the target, and notes when the workspace recorded a "
417
+ + "different one.",
418
+ "Routes come from the workspace manifest's pages plus '/', bounded to 20; each must answer 200 text/html "
419
+ + "without redirecting. Internal link targets found in those pages are checked too (bounded to 40).",
420
+ "Assets are what the home page declares: favicon links, module preloads, the site bundle, and up to six "
421
+ + "images. A 200 is not enough: the content type must match the kind, so an HTML error page served as an "
422
+ + "image fails.",
423
+ "Runtime: the page's bootstrap declares the major stream, the mutable major pointer, the immutable fallback "
424
+ + "copy and the capability modules. The pointer's major must match, its entry must load as JavaScript, every "
425
+ + "declared capability must be resolvable, and a pointer behind the fallback's version is reported as a "
426
+ + "stale runtime pointer that returning browsers may still be on.",
427
+ "Browser: a fresh and a returning (same-context, cached) load are compared against the pointer's entry when "
428
+ + "Playwright resolves from the CLI's own install: npm install --global @taprootio/site-authoring@latest "
429
+ + "playwright && npx playwright install chromium, then rerun through the installed taproot-site command (a "
430
+ + "copy run through npx lives in npm's cache and cannot see a global Playwright). A DevTools "
431
+ + "session on the browser fails any document request that would leave the site origin before it is sent "
432
+ + "(redirect hops, frames, script-driven navigations and popups included), so a target that strays is "
433
+ + "reported with offOriginNavigation and is not loaded again; without that session nothing is loaded and "
434
+ + "the dimension is unchecked. Whether "
435
+ + "the returning load really reused the cached runtime entry (memory or disk) is observed through the same "
436
+ + "session and reported as browser.returningCache, unchecked where it cannot be observed. Every URL the "
437
+ + "probe reports passes the same credential withholding as redirect Locations. Without Playwright the "
438
+ + "dimension is reported as unchecked, never as passed; a cache-busted HTTP fetch cannot stand in for it.",
439
+ "Bounds: four concurrent requests, two attempts per request, 15 seconds each; --wait re-checks failures once "
440
+ + "after at most 120 seconds. The report stays truthful after retry exhaustion: attempts and failures are "
441
+ + "listed with observed and expected values and the final URL. Twenty local-only references and forty "
442
+ + "failure lines are kept across the whole run, and the whole report is trimmed (successful items first, "
443
+ + "then failed items, failure lines, local-only references, capability detail, and finally the runtime "
444
+ + "detail down to its verdict counts, each with totals and truncation flags) so it always fits the output bound.",
445
+ "Read-only and bounded: the staging cookie goes only to the staging origin, only same-origin assets and the "
446
+ + "runtime the page's bootstrap declares are fetched (an authored reference to any other host is content, "
447
+ + "not delivery), local-only references (localhost, *.test, *.local) are reported rather than fetched, a "
448
+ + "redirect Location that could reflect a credential is withheld, bodies are read up to a fixed bound, "
449
+ + "member-only content is not exercised, and nothing is purged, republished or rolled back.",
450
+ "Verdicts: delivered (no findings), degraded (every route served and the runtime is compatible, but an asset, "
451
+ + "link, pointer or browser load did not match), failed (a route or the runtime did not deliver).",
452
+ ]),
453
+ example: Object.freeze({
454
+ command: `${CLI_BINARY_NAME} delivery check --production --url https://www.example.com/ --wait 30`,
455
+ }),
456
+ }),
457
+ walkthrough: Object.freeze({
458
+ title: "Design walkthrough",
459
+ summary: "One pass from selecting a site to promoting a reviewed presentation, with the reference each step relies on.",
460
+ usage: `${CLI_BINARY_NAME} help walkthrough`,
461
+ details: Object.freeze([
462
+ `Run the current release: npm install --global @taprootio/site-authoring, or npx --yes @taprootio/site-authoring@latest <verb>. `
463
+ + "Always name @latest with npx: an unversioned npx reuses whatever it cached, and Taproot accepts only the latest "
464
+ + "release. --version prints what is running; status reports the latest known release as cliRelease.",
465
+ `Select the site: ${CLI_BINARY_NAME} login, then ${CLI_BINARY_NAME} sites and ${CLI_BINARY_NAME} use <site-name-or-id>. `
466
+ + "sites reports each site's kind and authoring surface. A standard site takes every verb. A managed Docs site "
467
+ + "takes presentation only — pull, theme push, footer push, media upload, deploy, status — because its pages, "
468
+ + "navigation and redirects come from the Docs artifact; a prebuilt Docs site takes no verb at all.",
469
+ `Pull the baseline: ${CLI_BINARY_NAME} pull writes settings/taproot-styles.json with both schemes' complete effective `
470
+ + "themes (the stored theme resolved over the same defaults every consumer renders) beside brand.json, "
471
+ + "site-header.json and site-publishing-preferences.json. Keep that pull as the baseline you edit; never build "
472
+ + "a theme from memory or from an example. semanticMappings holds only authored pins, listed in explicitMappingTokens.",
473
+ "Edit both schemes: settings.lightTheme and settings.darkTheme are separate documents. Declare anchors, then roles "
474
+ + "(help theme lists the role slots and how anchor references resolve), tune typography per scheme, and keep "
475
+ + "contexts and intents paired across schemes. Set appearance scalars (default scheme, header, logos, favicon) "
476
+ + "in the appearance files: help appearance.",
477
+ "Prepare media locally: PNG, JPEG, GIF and WebP raster files only, addressed relative to the workspace root. Make "
478
+ + "the variants yourself with any image tool (a transparent logo, a @2x copy, a square favicon); there is no "
479
+ + "recolor, crop or SVG upload command. Upload each with media upload, then assign lightLogoId and darkLogoId "
480
+ + "(and brand.faviconId) from the returned image ids so each scheme carries the artwork that reads on its header.",
481
+ `Validate offline: ${CLI_BINARY_NAME} validate checks the complete pair, the appearance files and the footer against `
482
+ + "the contracts the site enforces, and warns when a semanticMappings pin repeats the default on a token your "
483
+ + "roles would have moved (the pin would keep the role from rendering).",
484
+ `Push: ${CLI_BINARY_NAME} theme push writes the footer scheme colours, the appearance scalars, then the light and dark `
485
+ + "themes in that order, not atomically. A failure reports completedWrites: pull, compare the remote result "
486
+ + "with the workspace, reconcile, and push again. footer push owns footer prose, links and imagery.",
487
+ `Review on staging: ${CLI_BINARY_NAME} deploy --staging stages the presentation and returns a single-use review handoff `
488
+ + "in stagingPreview.url (on a managed Docs site too). Open it once, then use the site's theme toggle to check "
489
+ + "both schemes: text, headings, links and hover, actions and focus, the header logos, the favicon. When no "
490
+ + "handoff could be minted, stagingPreview.reason says why and staging review mints another. A page draft is "
491
+ + "not needed to review a presentation change; preview page renders one persisted draft and is a different tool.",
492
+ `Promote only after review: ${CLI_BINARY_NAME} deploy --production promotes the completed staging deployment. `
493
+ + "Never use production to discover what staging should have shown.",
494
+ ]),
495
+ example: Object.freeze({
496
+ commands: Object.freeze([
497
+ `npx --yes @taprootio/site-authoring@latest login`,
498
+ `${CLI_BINARY_NAME} sites`,
499
+ `${CLI_BINARY_NAME} use "Riverbend Wellness"`,
500
+ `${CLI_BINARY_NAME} pull`,
501
+ `${CLI_BINARY_NAME} media upload media/logo.png media/logo-dark.png media/favicon.png`,
502
+ `${CLI_BINARY_NAME} validate`,
503
+ `${CLI_BINARY_NAME} theme push`,
504
+ `${CLI_BINARY_NAME} deploy --staging`,
505
+ `${CLI_BINARY_NAME} deploy --production`,
506
+ ]),
507
+ }),
508
+ }),
400
509
  nav: Object.freeze({
401
510
  title: "Navigation workspace contract",
402
511
  summary: "nav push replaces the complete tree in nav.json; item IDs are author-minted canonical lowercase UUIDs.",
@@ -512,6 +621,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
512
621
  "Paths are relative to the configured workspace root, not the shell's current directory.",
513
622
  "PNG, JPEG, GIF, and WebP are accepted; retina names such as logo@2x.png and logo@3x.png are supported.",
514
623
  "For header logos prefer genuine transparent PNG/WebP, check contrast in both themes, and include a simplified square favicon. See help appearance for branding guidance.",
624
+ "Prepare variants locally with any image tool before uploading: a transparent logo, its @2x copy, a scheme-specific dark logo when one asset does not read on both headers, and a square favicon. The CLI uploads what you give it; it has no recolor, crop, or SVG upload command.",
625
+ "Assign both schemes after uploading: lightLogoId and darkLogoId in settings/taproot-styles.json and brand.faviconId in settings/brand.json take the returned image ids; theme push writes them. See help walkthrough for the full design pass.",
515
626
  "Each result item includes media: { imageId, src, urls, width, height, alt }.",
516
627
  "The same src/urls delivery fields are saved in .taproot-site-media.json for page and component authoring.",
517
628
  ]),
@@ -539,6 +650,12 @@ const WORKFLOW_REFERENCES = Object.freeze({
539
650
  + "remain optional and old snapshots without content identity do not block deployment.",
540
651
  "deploy --staging and redirects check return a separate fresh single-use staging handoff in "
541
652
  + "stagingPreview.url, after authenticating headless redirect checks. Handoffs never go to GITHUB_OUTPUT.",
653
+ "On a managed Docs site deploy --staging stages settings only and still returns stagingPreview.url: open it "
654
+ + "once, then use the site's theme toggle to review both schemes; no page draft is needed to preview a "
655
+ + "presentation change. When no handoff can be minted the result carries stagingPreview.reason "
656
+ + "(staging.host_unavailable, staging.authority_denied, staging.surface_refused, or staging.handoff_unavailable) "
657
+ + "and a recovery step; run staging review to mint another (it works on every surface). Never verify a theme "
658
+ + "on production instead.",
542
659
  "A page path resolves through .taproot-site-manifest.json; a canonical page UUID works directly.",
543
660
  "The homepage's manifest path is empty; address it as '/', which resolves to that empty root path.",
544
661
  "Preview before approving: approve consumes the draft, so an approved page has no draft left to render and answers preview.no_draft. Review it on staging after a deploy instead.",
@@ -547,7 +664,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
547
664
  "Success returns READY with url, snapshotId, expiresAt, storedPreviewCap, storedPreviewCount, and evictedPreviews.",
548
665
  "The url is a single-use handoff that expires two minutes after it is minted: opening it consumes it, and a reused, shared, or bookmarked URL answers Not found. Run preview page again for another.",
549
666
  "At the configured cap, creation revokes the oldest snapshot for the same page first, then the oldest snapshot held by the same key.",
550
- `A stalled preview can be released with ${CLI_BINARY_NAME} preview revoke <page-id> <snapshot-id>.`,
667
+ `A stalled preview can be released with ${CLI_BINARY_NAME} preview revoke <page-id> <snapshot-id>. Revocation addresses the snapshot, not the draft: it works after approve has consumed the draft, revoking an already revoked snapshot succeeds again with status REVOKED, and an unknown, expired-and-cleaned-up, or other-page snapshot answers preview.not_found without revealing whether it exists elsewhere.`,
551
668
  ]),
552
669
  example: Object.freeze({ command: `${CLI_BINARY_NAME} preview page classes` }),
553
670
  }),
@@ -2,6 +2,7 @@ import { parseTheme } from "@taprootio/espalier/shared/theme";
2
2
 
3
3
  import { SiteAuthoringError } from "./errors.js";
4
4
  import { projectFooterSettingsForWorkspace } from "./footer-contract.js";
5
+ import { projectPulledTheme } from "./theme-projection.js";
5
6
 
6
7
  /**
7
8
  * The settings surface the authoring CLI covers.
@@ -19,7 +20,8 @@ import { projectFooterSettingsForWorkspace } from "./footer-contract.js";
19
20
  * is not: it is gated on `site.settings.manage` rather than theme management,
20
21
  * promotes immediately rather than through staging, and is site administration
21
22
  * rather than authored presentation.
22
- * - `lightTheme` and `darkTheme` are decoded into readable JSON objects. Theme
23
+ * - `lightTheme` and `darkTheme` are decoded into readable JSON objects and
24
+ * projected to the complete effective theme (`theme-projection.js`). Theme
23
25
  * push validates and re-encodes them only at the wire boundary.
24
26
  * - `footerSettings` is snapshotted as its structured document. Theme push
25
27
  * overlays only its scheme colors onto a fresh server read before using the
@@ -186,6 +188,17 @@ export function projectSettingsGroup(group, response) {
186
188
  const projected = {};
187
189
  for (const field of group.fields) {
188
190
  projected[field.name] = normalizeField(field, source[field.name]);
191
+ if (field.wireType === "theme") {
192
+ // A stored theme is what was written, possibly before a required key
193
+ // existed; the workspace gets the complete theme consumers actually
194
+ // render, resolved the way they resolve it (TR00775). The read-only
195
+ // provenance flag says whether Taproot's house look still layers under it.
196
+ projected[field.name] = projectPulledTheme(
197
+ projected[field.name],
198
+ field.name === "lightTheme" ? "light" : "dark",
199
+ { managedExternally: source[`${field.name}ManagedExternally`] === true },
200
+ );
201
+ }
189
202
  }
190
203
  return projected;
191
204
  }
@@ -30,6 +30,10 @@ function unavailable() {
30
30
  }
31
31
 
32
32
  /** Consume only the checker's handoff; never forward a platform bearer or follow a redirect. */
33
+ export async function authorizeStagingHandoff(client, handoff, signal) {
34
+ return await authorize(client, handoff, signal);
35
+ }
36
+
33
37
  async function authorize(client, handoff, signal) {
34
38
  const response = await request(client, handoff.url, undefined, signal);
35
39
  const origin = new URL(handoff.stagingUrl).origin;
@@ -49,6 +53,11 @@ async function authorize(client, handoff, signal) {
49
53
  return cookie;
50
54
  }
51
55
 
56
+ /** Withhold a Location that could reflect a staging credential; exported for the delivery check. */
57
+ export function withholdCredentialLocation(value) {
58
+ return safeLocation(value);
59
+ }
60
+
52
61
  function safeLocation(value) {
53
62
  if (value === null) return "";
54
63
  // The edge could be misconfigured. Never reflect handoff credentials from a
@@ -0,0 +1,213 @@
1
+ import {
2
+ buildTaprootDarkTheme,
3
+ buildTaprootLightTheme,
4
+ DEFAULT_DARK_THEME,
5
+ DEFAULT_LIGHT_THEME,
6
+ encodeTheme,
7
+ layerThemes,
8
+ mergePartials,
9
+ mergeTheme,
10
+ parseTheme,
11
+ } from "@taprootio/espalier/shared/theme";
12
+
13
+ import { SiteAuthoringError } from "./errors.js";
14
+ import { missingThemeFields } from "./theme-validation.js";
15
+
16
+ /**
17
+ * Project one stored scheme theme to the complete theme a consumer actually
18
+ * renders (TR00775).
19
+ *
20
+ * Taproot stores what was written. A site whose theme was seeded or written
21
+ * before a key existed keeps a sparse document, and every consumer — the
22
+ * app's preview, the generator, the published runtime — resolves it by
23
+ * layering the same defaults underneath: Espalier's scheme defaults plus,
24
+ * for a theme Taproot still manages, the Taproot house theme with an empty
25
+ * page background. A theme the CLI manages (`*ThemeManagedExternally`) is
26
+ * rendered as stored over Espalier's defaults alone. `pull` writes exactly
27
+ * that effective theme, so a fresh workspace validates and a designer edits
28
+ * every group with its authoritative value instead of reconstructing it.
29
+ *
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.
41
+ *
42
+ * Layering alone is not rendering parity: `mergeTheme` also lets an
43
+ * unpinned type-role ink inherit a customized `text` mapping, and lets a
44
+ * `chroma.text` override flow into the type-role chroma bands the document
45
+ * did not set. Filling those groups from the defaults, or dropping a
46
+ * cached mapping that was holding a token at its default, would change
47
+ * what the site renders. So the projection is reconciled against Espalier
48
+ * itself: the stored theme is resolved exactly the way its consumers resolve
49
+ * it, the projected document is resolved the same way, and every difference
50
+ * is written back as the rendered value (a mapping pin, a chroma band). The
51
+ * result validates, keeps the author's pins, and renders what the site
52
+ * rendered before the pull.
53
+ */
54
+
55
+ const SCHEME_DEFAULTS = Object.freeze({
56
+ light: { defaults: DEFAULT_LIGHT_THEME, house: buildTaprootLightTheme },
57
+ dark: { defaults: DEFAULT_DARK_THEME, house: buildTaprootDarkTheme },
58
+ });
59
+
60
+ function isPlainObject(value) {
61
+ return value !== null && typeof value === "object" && !Array.isArray(value);
62
+ }
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
+ /**
71
+ * The complete theme a consumer layers a stored theme onto: Espalier's
72
+ * scheme defaults, and — unless the theme is managed externally — the
73
+ * Taproot house theme with the empty page background both the BFF and the
74
+ * generator clear before rendering.
75
+ */
76
+ export function effectiveThemeBase(scheme, { managedExternally = false } = {}) {
77
+ const { defaults, house } = SCHEME_DEFAULTS[scheme];
78
+ const houseTheme = parseTheme(house("")) ?? {};
79
+ if (managedExternally) {
80
+ // Espalier's defaults carry no page background at all; the stored
81
+ // contract requires both keys. No image is the rendered truth, and the
82
+ // opacity of an absent image is neutral, so the house value fills it.
83
+ return {
84
+ ...defaults,
85
+ pageBackgroundImage: "",
86
+ pageBackgroundImageOpacity: houseTheme.pageBackgroundImageOpacity,
87
+ };
88
+ }
89
+ return { ...mergePartials(defaults, houseTheme), pageBackgroundImage: "" };
90
+ }
91
+
92
+ export function projectPulledTheme(stored, scheme, { managedExternally = false } = {}) {
93
+ const source = isPlainObject(stored) ? stored : {};
94
+ const base = effectiveThemeBase(scheme, { managedExternally });
95
+ const { semanticMappings: _baseMappings, ...layerable } = base;
96
+ // The marker is set on the base so mergePartials treats every stored
97
+ // mapping as authored; the pin filter below then decides which survive.
98
+ const merged = mergePartials({ ...layerable, semanticMappings: {}, explicitMappingTokens: [] }, source);
99
+
100
+ const marker = Array.isArray(source.explicitMappingTokens)
101
+ ? new Set(source.explicitMappingTokens.filter((token) => typeof token === "string"))
102
+ : undefined;
103
+ const pins = {};
104
+ if (isPlainObject(source.semanticMappings)) {
105
+ 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;
108
+ }
109
+ }
110
+ const projected = reconcileWithRendering(
111
+ { ...merged, semanticMappings: pins, explicitMappingTokens: Object.keys(pins) },
112
+ source,
113
+ scheme,
114
+ managedExternally,
115
+ );
116
+
117
+ requireProjectedThemeComplete(projected, scheme);
118
+ return projected;
119
+ }
120
+
121
+ /** What a consumer renders for a stored theme: the partial it layers, resolved over Espalier's defaults. */
122
+ function renderedTheme(stored, scheme, managedExternally) {
123
+ const { defaults, house } = SCHEME_DEFAULTS[scheme];
124
+ const layered = managedExternally
125
+ ? layerThemes(encodeTheme(stored))
126
+ : layerThemes(house(""), encodeTheme({ pageBackgroundImage: "" }), encodeTheme(stored));
127
+ return mergeTheme(defaults, parseTheme(layered) ?? {});
128
+ }
129
+
130
+ function canonicalJson(value) {
131
+ if (Array.isArray(value)) return JSON.stringify(value.map((entry) => JSON.parse(canonicalJson(entry))));
132
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
133
+ return JSON.stringify(Object.fromEntries(Object.keys(value).sort().map((key) => [key, JSON.parse(canonicalJson(value[key]))])));
134
+ }
135
+
136
+ /**
137
+ * Bring the projected document to rendering parity with the stored theme.
138
+ * Two passes suffice: a mapping pin can change what a companion token
139
+ * inherits, so the comparison runs again after the first corrections.
140
+ *
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.
146
+ */
147
+ function reconcileWithRendering(projected, stored, scheme, managedExternally) {
148
+ const { defaults } = SCHEME_DEFAULTS[scheme];
149
+ let rendered;
150
+ let roleCompiled;
151
+ try {
152
+ 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
+ } catch {
158
+ // An invalid stored theme cannot be rendered for comparison; validation
159
+ // reports it, and the layered projection is the best available document.
160
+ return projected;
161
+ }
162
+ let current = projected;
163
+ for (let pass = 0; pass < 2; pass += 1) {
164
+ let candidate;
165
+ try {
166
+ candidate = mergeTheme(defaults, current);
167
+ } catch {
168
+ return current;
169
+ }
170
+ let changed = false;
171
+ for (const key of Object.keys(rendered)) {
172
+ if (key === "explicitMappingTokens") continue;
173
+ if (canonicalJson(rendered[key]) === canonicalJson(candidate[key])) continue;
174
+ changed = true;
175
+ if (key === "semanticMappings") {
176
+ const pins = { ...current.semanticMappings };
177
+ for (const [token, mapping] of Object.entries(rendered.semanticMappings)) {
178
+ 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;
184
+ pins[token] = structuredClone(mapping);
185
+ }
186
+ current = { ...current, semanticMappings: pins, explicitMappingTokens: Object.keys(pins) };
187
+ } else {
188
+ current = { ...current, [key]: structuredClone(rendered[key]) };
189
+ }
190
+ }
191
+ if (!changed) break;
192
+ }
193
+ return current;
194
+ }
195
+
196
+ /**
197
+ * The refusal for a projection that still lacks required fields. Reachable
198
+ * only when Espalier's defaults and the server response together omit a key
199
+ * this CLI requires — a contract drift between releases, never a stale
200
+ * workspace — so the remedy is a report, not another pull. Exported so the
201
+ * refusal itself is testable without an incomplete default to hand.
202
+ */
203
+ export function requireProjectedThemeComplete(projected, scheme) {
204
+ const missing = missingThemeFields(projected, scheme);
205
+ if (missing.length === 0) return;
206
+ throw new SiteAuthoringError(
207
+ "theme.projection_incomplete",
208
+ "Taproot's settings response and the Espalier defaults this CLI ships still omit required theme fields. "
209
+ + "This is a contract problem between the server projection and this CLI release, not a stale workspace: "
210
+ + "repeating taproot-site pull cannot supply them. Report the listed paths with the CLI and server versions.",
211
+ { field: missing[0], alternatives: missing },
212
+ );
213
+ }