@taprootio/site-authoring 0.22.3 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ import { HEX_DIGEST } from "./constants.js";
2
+ import { SiteAuthoringError } from "./errors.js";
3
+ import { NAVIGATION_FILE_NAME, readManifest, writeManifest } from "./workspace.js";
4
+
5
+ /**
6
+ * The navigation tree's workspace baseline (TR01231).
7
+ *
8
+ * `nav push` replaces the whole tree, so a push built on a stale read would
9
+ * silently delete an item added in the app since. The manifest therefore
10
+ * records the revision the tree was read at, exactly as it does for the
11
+ * redirect map, and a push that cannot produce one refuses toward pull rather
12
+ * than guessing.
13
+ */
14
+
15
+ export function isNavigationRevision(value) {
16
+ return typeof value === "string" && HEX_DIGEST.test(value);
17
+ }
18
+
19
+ function pullRequired(message, field) {
20
+ return new SiteAuthoringError("nav.pull_required", message, { field });
21
+ }
22
+
23
+ /** The manifest entry `pull` writes for a freshly read tree. */
24
+ export function navigationManifestEntry({ navItems, revision }) {
25
+ return { file: NAVIGATION_FILE_NAME, items: navItems.length, ...(isNavigationRevision(revision) ? { revision } : {}) };
26
+ }
27
+
28
+ /** The recorded revision, or a refusal that says to pull. */
29
+ export function requireNavigationBaseline(manifest) {
30
+ const navigation = manifest.navigation;
31
+ if (navigation === null || typeof navigation !== "object" || Array.isArray(navigation)) {
32
+ throw pullRequired(
33
+ "The pull manifest does not record the navigation tree. Run 'taproot-site pull' again.",
34
+ "navigation",
35
+ );
36
+ }
37
+ if (!isNavigationRevision(navigation.revision)) {
38
+ throw pullRequired(
39
+ "The pull manifest records no navigation revision, so a push could not tell whether the tree changed on the "
40
+ + "site since. Run 'taproot-site pull' again.",
41
+ "navigation.revision",
42
+ );
43
+ }
44
+ return navigation.revision;
45
+ }
46
+
47
+ /** The refusal for a tree that moved since the pull; nothing was written. */
48
+ export function navigationMovedRefusal() {
49
+ return new SiteAuthoringError(
50
+ "nav.concurrent_modification",
51
+ "The navigation changed on the site after this workspace read it, so nothing was written. Keep a copy of the "
52
+ + `edited ${NAVIGATION_FILE_NAME}, run 'taproot-site pull' to refresh it, re-apply the edits, and push again.`,
53
+ { field: "revision" },
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Records the revision the site reported after a successful save, so the next
59
+ * push is fenced against that state rather than the one before it.
60
+ */
61
+ export async function advanceNavigationManifest(workspaceDir, siteId, { navItems, revision }) {
62
+ const manifest = await readManifest(workspaceDir, siteId);
63
+ requireNavigationBaseline(manifest);
64
+ if (!isNavigationRevision(revision)) {
65
+ throw new SiteAuthoringError(
66
+ "api.navigation_contract",
67
+ "Taproot returned a navigation tree whose revision could not be recorded.",
68
+ { field: "revision" },
69
+ );
70
+ }
71
+ manifest.navigation = navigationManifestEntry({ navItems, revision });
72
+ await writeManifest(workspaceDir, manifest);
73
+ }
@@ -421,6 +421,7 @@ export function getThemeReference() {
421
421
  "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.",
422
422
  "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.",
423
423
  "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.",
424
+ "Run taproot-site theme resolve (offline; add --scheme, --context, or --json) after every edit to see what the theme compiles to: for the root page and each context in both schemes, every semantic color as oklch and hex, whether it is compiled from the roles or pinned (origin compiled, pinned, inherited_pin), and the WCAG contrast of each ink-on-surface pair Espalier defines against AA (4.5:1 text, 3:1 large text and UI). A pair under AA is listed in flags as theme.contrast_text_below_aa with the field to edit; it never fails the run, so decide whether it is a deliberate choice. theme resolve --site shows the site's stored theme for comparison.",
424
425
  "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. Contrast and status-color warnings end with 'Nearest passing value: …' when the CLI finds one: it applied that value to a copy of the theme and checked that the warning clears without raising another, so start there and check again. validate and theme push refuse a font weight a catalog family has no face for (theme.font_weight_unavailable), because the publish would fail, and name the nearest weight it has.",
425
426
  "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.",
426
427
  "Tune typography, type/space ratios, radii, and viewport interpolation as one layout system.",
@@ -1,3 +1,4 @@
1
+ import { HEX_DIGEST } from "./constants.js";
1
2
  import { SiteAuthoringError } from "./errors.js";
2
3
  import { RUNTIME_MIRROR_PATH_REASON } from "./reserved-paths.js";
3
4
  import {
@@ -77,7 +78,7 @@ export const REDIRECT_LIMITS = Object.freeze({
77
78
  // hide the mistake.
78
79
  const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/u;
79
80
  const ENTRY_KEYS = new Set(["path", "kind", "target", "status", "origin"]);
80
- const SHA256 = /^[0-9a-f]{64}$/u;
81
+ const SHA256 = HEX_DIGEST;
81
82
 
82
83
 
83
84
  /** Whether the value is a redirect-map revision as the server mints one. */
@@ -5,8 +5,8 @@ import { readManifest, writeManifest } from "./workspace.js";
5
5
  /**
6
6
  * The redirect map's workspace baseline (TR00702).
7
7
  *
8
- * `nav.json` needs no baseline because navigation is last-write-wins; the
9
- * redirect map cannot be, because it holds entries nobody typed. Every page and
8
+ * Navigation records a revision the same way (`navigation-workspace.js`). The
9
+ * redirect map in particular cannot be last-write-wins, because it holds entries nobody typed. Every page and
10
10
  * tag rename records one, so a whole-map push built on a stale read would
11
11
  * delete the entry that keeps the renamed page's old URL alive. The manifest
12
12
  * therefore records the revision the map was read at, exactly as it records the
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
 
3
- import { CLI_BINARY_NAME, LIMITS } from "./constants.js";
3
+ import { CLI_BINARY_NAME, LIMITS, STAGING_REVIEW_SESSION_NOTE } from "./constants.js";
4
4
  import {
5
5
  canonicalizeComponentData,
6
6
  COMPONENT_TYPES,
@@ -88,7 +88,7 @@ import {
88
88
 
89
89
  export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
90
90
 
91
- export const REFERENCE_VERSION = 39;
91
+ export const REFERENCE_VERSION = 41;
92
92
 
93
93
  // The date-limit examples `help forms` prints; tests run them through the form validator.
94
94
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -578,7 +578,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
578
578
  + "runtime the page's bootstrap declares are fetched (an authored reference to any other host is content, "
579
579
  + "not delivery), local-only references (localhost, *.test, *.local) are reported rather than fetched, a "
580
580
  + "redirect Location that could reflect a credential is withheld, bodies are read up to a fixed bound, "
581
- + "member-only content is not exercised, and nothing is purged, republished or rolled back.",
581
+ + "member-only content is not exercised, and nothing is purged, republished or rolled back. A staging check "
582
+ + "mints its own handoff, which ends an earlier review link minted with the same key (see 'help preview').",
582
583
  "Verdicts: delivered (no findings), degraded (every route served and the runtime is compatible, "
583
584
  + "but an asset, link or browser load did not match), failed (a route or the runtime did not deliver).",
584
585
  ]),
@@ -614,6 +615,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
614
615
  + "the variants yourself with any image tool (a transparent logo at the highest resolution you have, a square favicon); there is no "
615
616
  + "recolor, crop or SVG upload command. Upload each with media upload, then assign lightLogoId and darkLogoId "
616
617
  + "(and brand.faviconId) from the image ids .taproot-site-media.json records for those files, so each scheme carries the artwork that reads on its header.",
618
+ `See the compiled colors: ${CLI_BINARY_NAME} theme resolve prints, offline, every semantic color Espalier compiles `
619
+ + "from the pair for each scheme and context, whether each is compiled from the roles or pinned, and the contrast "
620
+ + "of every ink-on-surface pair, with a flag for each one under WCAG AA. Run it after each theme edit, in both "
621
+ + "schemes, instead of discovering the result on staging; add --json to branch on it.",
617
622
  `Validate offline: ${CLI_BINARY_NAME} validate checks the whole workspace at once — the complete pair, the `
618
623
  + "appearance files, the footer, every page, navigation and redirects — against the contracts the site enforces, "
619
624
  + "lists every problem in error.problems, and warns when a semanticMappings pin repeats the default on a token "
@@ -635,7 +640,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
635
640
  + "a single-use handoff in stagingPreview.url (on a managed Docs site too). Open it once, then use the site's "
636
641
  + "theme toggle to check both schemes: text, headings, links and hover, actions and focus, the header logos, the "
637
642
  + "favicon. When a requested handoff could not be minted, stagingPreview.reason says why and staging review mints "
638
- + "another (add --path <page> to open it on the page under review). A page draft is "
643
+ + "another (add --path <page> to open it on the page under review). The review session lasts five minutes: "
644
+ + "see 'help preview' for what ends it. A page draft is "
639
645
  + "not needed to review a presentation change; preview page renders one persisted draft and is a different tool.",
640
646
  `Promote only after review: ${CLI_BINARY_NAME} deploy --production promotes the completed staging deployment. `
641
647
  + "Never use production to discover what staging should have shown.",
@@ -656,13 +662,18 @@ const WORKFLOW_REFERENCES = Object.freeze({
656
662
  }),
657
663
  nav: Object.freeze({
658
664
  title: "Navigation workspace contract",
659
- summary: "nav push replaces the complete tree in nav.json; item IDs are author-minted canonical lowercase UUIDs.",
665
+ summary: "nav push replaces the complete tree in nav.json, fenced by the revision pull recorded; item IDs are author-minted canonical lowercase UUIDs.",
660
666
  usage: `${CLI_BINARY_NAME} nav push`,
661
667
  details: Object.freeze([
662
668
  "nav.json is { siteId, navItems }, not a bare array. Defaults are left out: an item has no resourceId or "
663
669
  + "externalUrl unless its kind uses one, and no children when it has none. pull and nav push both write that "
664
670
  + "form (nav push rewrites the file after it saves), and either form is accepted on the way in, so a pull after "
665
671
  + "a push shows no change you did not make.",
672
+ "nav push is fenced by the navigation revision that pull recorded in the manifest (navigation.revision), as "
673
+ + "theme push, footer push and redirects push are fenced by theirs. A tree edited in the app since is refused "
674
+ + "with nav.concurrent_modification and nothing is written: keep a copy of nav.json, run pull, re-apply the "
675
+ + "edits, and push again. A manifest with no navigation revision is refused with nav.pull_required; run pull. "
676
+ + "A successful push records the revision it saved, so the next push is fenced against that state.",
666
677
  "kind is NAV_ITEM_KIND_PAGE, NAV_ITEM_KIND_EXTERNAL_URL, or NAV_ITEM_KIND_GROUP_HEADER.",
667
678
  "PAGE uses resourceId from .taproot-site-manifest.json, never pageId or path.",
668
679
  "EXTERNAL_URL uses externalUrl; GROUP_HEADER has no target; every kind may carry children.",
@@ -704,7 +715,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
704
715
  + "two-minute handoff, minted after checks. It is emitted only in final JSON, excluded from progress and "
705
716
  + "GITHUB_OUTPUT; keep it private. Without it, stagingPreview.reason is staging.handoff_not_requested and "
706
717
  + "'staging review' mints one. The staging cookie lasts five minutes and remains subject to current "
707
- + "site/host/credential permission.",
718
+ + "site/host/credential permission. Each run mints its own handoff, which ends an earlier review link "
719
+ + "minted with the same key (see 'help preview').",
708
720
  `${REDIRECTS_FILE_NAME} is { siteId, revision, entries }, not a bare array. 'redirects pull' writes it and `
709
721
  + "records the revision in .taproot-site-manifest.json; 'pull' writes it too.",
710
722
  "An entry is { path, kind, target, status }. kind is 'redirect' (the default) or 'gone'; a gone entry "
@@ -934,6 +946,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
934
946
  + "staging review for one, or pass --handoff to get a fresh single-use handoff in stagingPreview.url, minted "
935
947
  + "after the headless redirect checks. staging review --path <page> (and --review-path <page> beside --handoff) "
936
948
  + "opens the review on that page instead of the home page. Handoffs never go to GITHUB_OUTPUT.",
949
+ `Review session: ${STAGING_REVIEW_SESSION_NOTE} Once the session has ended, run staging review again for a `
950
+ + "fresh link, or stay signed in to the Taproot app in a browser whose account can preview the site, to review longer.",
937
951
  "On a managed Docs site deploy --staging stages settings only: run staging review (or deploy --staging "
938
952
  + "--handoff) and open the URL once, then use the site's theme toggle to review both schemes; no page draft is "
939
953
  + "needed to preview a presentation change. When a requested handoff can't be minted the result carries "
@@ -263,6 +263,15 @@ const PREFERENCES_ENTRIES = {
263
263
  "The image lightbox.",
264
264
  false,
265
265
  ),
266
+ analyticsConsentPrompt: entry(
267
+ `${FEATURES} > Ask readers before counting visits`,
268
+ "Whether published pages ask readers to accept one short-lived cookie before Taproot counts their visits. Off, "
269
+ + "the cookie is set without asking; on, readers who decline or ignore the prompt are counted by the estimate "
270
+ + "only.",
271
+ "Every page of a published Standard site, as a small prompt at the bottom of the screen. A change applies after "
272
+ + "a deployment.",
273
+ false,
274
+ ),
266
275
  tiptapImageMaxHeightVh: entry(
267
276
  `${FEATURES} > Image height`,
268
277
  "The tallest an image in a page body may be, as a percent of the screen height (45, 60, 75 or 90).",
@@ -127,6 +127,9 @@ export const SETTINGS_GROUPS = Object.freeze([
127
127
  // zero and the default agree and a snapshot of an untouched site reads
128
128
  // the same as the site behaves.
129
129
  { name: "hideLightboxComments", wireType: "boolean" },
130
+ // Asks readers before the visit cookie is set (TR01263). Off is what an omitted value means, so a snapshot of
131
+ // an untouched site reads the same as the site behaves.
132
+ { name: "analyticsConsentPrompt", wireType: "boolean" },
130
133
  { name: "tiptapImageMaxHeightVh", wireType: "int" },
131
134
  { name: "tiptapImageMaxImagesPerRow", wireType: "int" },
132
135
  { name: "tiptapImageBorderWidth", wireType: "int" },
@@ -0,0 +1,252 @@
1
+ import { parseTheme, SEMANTIC_COLOR_NAMES, TOKEN_PAIRINGS } from "@taprootio/espalier/shared/theme";
2
+
3
+ import { SiteAuthoringError } from "./errors.js";
4
+ import { loadEspalierShared } from "./espalier-shared.js";
5
+ import { validateAndEncodeThemePair } from "./theme-validation.js";
6
+
7
+ /**
8
+ * `theme resolve` (TR01228): the colors Espalier compiles from a stored theme,
9
+ * with the contrast of every ink-on-surface pairing it defines.
10
+ *
11
+ * Nothing here compiles a color. The fit report suite is the same compile
12
+ * `theme push` lints and the page renders through (`computeThemeProperties`,
13
+ * and `resolveContextTheme` for each context), so a resolved value cannot
14
+ * disagree with the render. This module only reads that result and measures
15
+ * it.
16
+ */
17
+
18
+ export const THEME_STYLES_FILE = "settings/taproot-styles.json";
19
+
20
+ // WCAG 2.x AA. Espalier sizes its pairings by APCA target: Lc 90 and 75 are
21
+ // body and 16px+ text, Lc 60 is the 24px+ and UI tier, which is what AA calls
22
+ // large text and non-text contrast.
23
+ export const AA_TEXT_RATIO = 4.5;
24
+ export const AA_LARGE_RATIO = 3;
25
+ const LARGE_TEXT_LC = 60;
26
+
27
+ export const CONTRAST_TEXT_BELOW_AA = "theme.contrast_text_below_aa";
28
+
29
+ const THEME_SCHEMES = Object.freeze(["light", "dark"]);
30
+
31
+ let engines;
32
+
33
+ /** The fit report and color engine are Espalier modules beside the exported theme module (see espalier-shared.js). */
34
+ function loadEngines() {
35
+ engines ??= Promise.all([
36
+ loadEspalierShared("theme-fit-report.js"),
37
+ loadEspalierShared("color-engine.js"),
38
+ ]).then(([fit, color]) => ({ fit, color }));
39
+ return engines;
40
+ }
41
+
42
+ function channelByte(color, value) {
43
+ return Math.round(color.clampChannel(value) * 255);
44
+ }
45
+
46
+ /** The sRGB the engine gamut-maps a compiled color to, as channels in 0..1 and as #rrggbb. */
47
+ function toSrgb(color, value) {
48
+ const parsed = color.parseCssColor(value);
49
+ if (parsed === null) return undefined;
50
+ const srgb = color.oklchToSRGB(color.gamutMapToSRGB(parsed));
51
+ const hex = `#${[srgb.r, srgb.g, srgb.b].map((channel) => channelByte(color, channel).toString(16).padStart(2, "0")).join("")}`;
52
+ return { parsed, srgb, hex };
53
+ }
54
+
55
+ function relativeLuminance(color, srgb) {
56
+ const linear = (channel) => color.gammaToLinear(color.clampChannel(channel));
57
+ return 0.2126 * linear(srgb.r) + 0.7152 * linear(srgb.g) + 0.0722 * linear(srgb.b);
58
+ }
59
+
60
+ /** WCAG contrast ratio of two compiled colors, unrounded. */
61
+ function wcagRatio(color, first, second) {
62
+ const [lighter, darker] = [relativeLuminance(color, first.srgb), relativeLuminance(color, second.srgb)]
63
+ .sort((left, right) => right - left);
64
+ return (lighter + 0.05) / (darker + 0.05);
65
+ }
66
+
67
+ // Shown ratios round down, so a pair 0.004 short of AA never displays as 4.50.
68
+ function floorTo(value, places) {
69
+ const scale = 10 ** places;
70
+ return Math.floor(value * scale + 1e-9) / scale;
71
+ }
72
+
73
+ function origin(token) {
74
+ if (token.explicit === "declared") return "pinned";
75
+ if (token.explicit === "inherited") return "inherited_pin";
76
+ return "compiled";
77
+ }
78
+
79
+ function surfaceField(scheme, context) {
80
+ return context === null ? `${scheme}Theme` : `${scheme}Theme.contexts.${context}`;
81
+ }
82
+
83
+ /**
84
+ * The finding for one pair under AA: a stable code, and the theme field whose zone the author edits.
85
+ *
86
+ * There is one code. Espalier's enforcer walks an ink to the extreme that contrasts with its surface, so a
87
+ * large-text or UI pair never lands under 3:1 in a real theme and a second code could not be reached. `required`
88
+ * still says which minimum the pair missed.
89
+ */
90
+ function contrastFlag(pair, scheme, context) {
91
+ return {
92
+ code: CONTRAST_TEXT_BELOW_AA,
93
+ area: "presentation",
94
+ file: THEME_STYLES_FILE,
95
+ field: surfaceField(scheme, context),
96
+ token: pair.token,
97
+ background: pair.background,
98
+ scheme,
99
+ context,
100
+ ratio: pair.ratio,
101
+ required: pair.required,
102
+ message: `${pair.token} on ${pair.background} is ${pair.ratio}:1; needs ${pair.required}:1.`,
103
+ };
104
+ }
105
+
106
+ function describeSurface(color, report, scheme, rootSurface) {
107
+ const context = report.surface === rootSurface ? null : report.surface;
108
+ const resolved = new Map();
109
+ const tokens = report.tokens.map((token) => {
110
+ const measured = toSrgb(color, token.resolved);
111
+ resolved.set(token.token, measured);
112
+ return {
113
+ token: token.token,
114
+ value: token.resolved,
115
+ hex: measured?.hex ?? null,
116
+ origin: origin(token),
117
+ source: token.source,
118
+ lightness: token.lightness,
119
+ // Only when APCA enforcement moved it off the requested value; absent is the usual case.
120
+ ...(token.adjusted ? { adjusted: true } : {}),
121
+ };
122
+ });
123
+ const pairs = [];
124
+ for (const [ink, pairing] of Object.entries(TOKEN_PAIRINGS)) {
125
+ const foreground = resolved.get(ink);
126
+ const background = resolved.get(pairing.bg);
127
+ if (foreground === undefined || background === undefined) continue;
128
+ const required = pairing.targetLc <= LARGE_TEXT_LC ? AA_LARGE_RATIO : AA_TEXT_RATIO;
129
+ const ratio = wcagRatio(color, foreground, background);
130
+ const lc = Math.abs(color.apcaContrast(foreground.parsed, background.parsed));
131
+ pairs.push({
132
+ token: ink,
133
+ background: pairing.bg,
134
+ ratio: floorTo(ratio, 2),
135
+ required,
136
+ aa: ratio >= required,
137
+ apcaLc: floorTo(lc, 1),
138
+ apcaTarget: pairing.targetLc,
139
+ apcaMet: lc >= pairing.targetLc,
140
+ });
141
+ }
142
+ const flags = pairs.filter((pair) => !pair.aa).map((pair) => contrastFlag(pair, scheme, context));
143
+ return {
144
+ surface: { context, tokens, pairs, pinned: tokens.filter((token) => token.origin !== "compiled").map((token) => token.token) },
145
+ flags,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * The compiled colors of a stored light/dark pair. `scheme` and `context`
151
+ * narrow what is described, never what is validated or compiled: the pair is
152
+ * checked as a whole, as `theme push` checks it.
153
+ *
154
+ * Throws what `theme push` would throw for an incomplete or invalid theme, and
155
+ * a `theme.context_unknown` refusal for a context the theme does not declare.
156
+ */
157
+ export async function resolveThemePair(lightTheme, darkTheme, { scheme, context } = {}) {
158
+ const { light, dark } = validateAndEncodeThemePair(lightTheme, darkTheme);
159
+ const { fit, color } = await loadEngines();
160
+ // The stored pair is encoded and parsed back, as theme push's fit lints do, so this is what the page resolves.
161
+ const suite = fit.themeFitReportSuite(parseTheme(light) ?? {}, parseTheme(dark) ?? {});
162
+ const contexts = suite.surfaces.filter((surface) => surface !== fit.ROOT_SURFACE);
163
+ if (context !== undefined && !contexts.includes(context)) {
164
+ throw new SiteAuthoringError(
165
+ "theme.context_unknown",
166
+ contexts.length === 0
167
+ ? `The theme declares no context named '${context}'; it declares none.`
168
+ : `The theme declares no context named '${context}'. Declared: ${contexts.join(", ")}.`,
169
+ { field: "context", alternatives: contexts },
170
+ );
171
+ }
172
+
173
+ const schemes = [];
174
+ const flags = [];
175
+ for (const name of THEME_SCHEMES) {
176
+ if (scheme !== undefined && scheme !== name) continue;
177
+ const surfaces = [];
178
+ for (const report of suite[name]) {
179
+ if (context !== undefined && report.surface !== context) continue;
180
+ const described = describeSurface(color, report, name, fit.ROOT_SURFACE);
181
+ surfaces.push(described.surface);
182
+ flags.push(...described.flags);
183
+ }
184
+ schemes.push({ scheme: name, surfaces });
185
+ }
186
+ const pairs = schemes.flatMap((entry) => entry.surfaces).flatMap((surface) => surface.pairs);
187
+ return {
188
+ contexts,
189
+ schemes,
190
+ flags,
191
+ contrast: {
192
+ pairs: pairs.length,
193
+ belowAa: pairs.filter((pair) => !pair.aa).length,
194
+ belowApca: pairs.filter((pair) => !pair.apcaMet).length,
195
+ },
196
+ };
197
+ }
198
+
199
+ const TOKEN_ORDER = new Map(SEMANTIC_COLOR_NAMES.map((name, index) => [name, index]));
200
+
201
+ function pad(value, width) {
202
+ return String(value).padEnd(width);
203
+ }
204
+
205
+ function table(rows) {
206
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => String(row[column]).length)));
207
+ return rows.map((row) => ` ${row.map((cell, column) => pad(cell, widths[column])).join(" ").trimEnd()}`).join("\n");
208
+ }
209
+
210
+ /**
211
+ * The human table for a result. Tokens keep Espalier's own order, so a
212
+ * background and its layers read together; a pinned or inherited value is
213
+ * marked, and a pair under AA says so on its line.
214
+ */
215
+ export function formatThemeResolve(result) {
216
+ const lines = [`Compiled colors from the ${result.source === "site" ? "site's" : "workspace"} theme.`];
217
+ for (const { scheme, surfaces } of result.schemes) {
218
+ for (const surface of surfaces) {
219
+ lines.push("", `${scheme} · ${surface.context === null ? "page (root)" : `context ${surface.context}`}`);
220
+ const tokens = [...surface.tokens].sort((left, right) => TOKEN_ORDER.get(left.token) - TOKEN_ORDER.get(right.token));
221
+ lines.push(table([
222
+ ["token", "value", "hex", "from"],
223
+ ...tokens.map((token) => [
224
+ token.token,
225
+ token.value,
226
+ token.hex ?? "-",
227
+ `${token.origin === "compiled" ? "" : `${token.origin.replace("_", " ")}: `}${token.source} @ ${token.lightness}${token.adjusted ? " (adjusted for contrast)" : ""}`,
228
+ ]),
229
+ ]));
230
+ lines.push("", table([
231
+ ["ink", "on", "ratio", "needs", "apca", ""],
232
+ ...surface.pairs.map((pair) => [
233
+ pair.token,
234
+ pair.background,
235
+ `${pair.ratio}:1`,
236
+ `${pair.required}:1`,
237
+ `Lc ${pair.apcaLc}/${pair.apcaTarget}`,
238
+ pair.aa ? "ok" : "BELOW AA",
239
+ ]),
240
+ ]));
241
+ }
242
+ }
243
+ const { belowAa, pairs } = result.contrast;
244
+ lines.push("", belowAa === 0
245
+ ? `All ${pairs} pairs meet WCAG AA.`
246
+ : `${belowAa} of ${pairs} pairs are below WCAG AA (flags: ${[...new Set(result.flags.items.map((flag) => flag.code))].join(", ")}).`);
247
+ if (result.omittedSurfaces !== undefined) {
248
+ const names = result.omittedSurfaces.items.map((entry) => `${entry.scheme}/${entry.context ?? "root"}`);
249
+ lines.push(`Not shown (result size): ${names.join(", ")}${result.omittedSurfaces.truncated ? ", and more" : ""}. Narrow with --scheme and --context.`);
250
+ }
251
+ return `${lines.join("\n")}\n`;
252
+ }
package/src/transport.js CHANGED
@@ -39,8 +39,8 @@ const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
39
39
  // authoring mutations carry an idempotency key — TR00602 defined none — so a
40
40
  // replayed POST duplicates a page, a replayed PATCH re-applies an update over an edit
41
41
  // that landed in between, a replayed DELETE removes a resource the first
42
- // attempt already removed, and a replayed navigation PUT re-asserts a tree
43
- // after someone else changed it. An ambiguous mutation is reported as
42
+ // attempt already removed, and a replayed navigation PUT, fenced by the revision it
43
+ // presented, reports a false conflict once the first attempt has committed. An ambiguous mutation is reported as
44
44
  // ambiguous, never guessed at. The one PUT that *is* replayed is the
45
45
  // presigned whole-object upload in `upload()`, which writes immutable bytes
46
46
  // to one key and is therefore genuinely idempotent. Two POSTs have their own
@@ -1,7 +1,7 @@
1
1
  import {
2
+ isGonePage,
2
3
  listSitePages,
3
4
  PAGE_STATUS_APPROVED,
4
- PAGE_STATUS_DELETED,
5
5
  publishDrafts,
6
6
  withRefusalGuidance,
7
7
  } from "../api.js";
@@ -81,7 +81,7 @@ export async function approve(invocation) {
81
81
  }
82
82
  const pathOf = (summary) => normalizePagePath(summary.path) ?? summary.path;
83
83
  const candidates = pages.filter((summary) =>
84
- summary.status !== PAGE_STATUS_DELETED
84
+ !isGonePage(summary)
85
85
  && summary.hasDraft
86
86
  && manifestByPageId.has(summary.pageId)
87
87
  && (requestedPaths === undefined || requestedPaths.has(pathOf(summary))));
@@ -94,7 +94,7 @@ export async function approve(invocation) {
94
94
  const matched = new Set(candidates.map(pathOf));
95
95
  const unknown = [];
96
96
  for (const requested of [...requestedPaths].filter((value) => !matched.has(value)).sort()) {
97
- const summary = pages.find((page) => page.status !== PAGE_STATUS_DELETED && pathOf(page) === requested);
97
+ const summary = pages.find((page) => !isGonePage(page) && pathOf(page) === requested);
98
98
  if (summary === undefined || !manifestByPageId.has(summary.pageId)) {
99
99
  unknown.push(displayPagePath(requested));
100
100
  continue;
@@ -28,6 +28,7 @@ import { HANDOFF_NOT_REQUESTED_PROGRESS, handoffNotRequested, inspectStagingPrev
28
28
  import { confirmStagingServed, readStagingReleaseBefore, unverifiedStagingServed } from "../staging-served.js";
29
29
  import { ApiError } from "../transport.js";
30
30
  import { readManifest, writeManifest } from "../workspace.js";
31
+ import { STAGING_REVIEW_GUIDANCE } from "./staging-review.js";
31
32
 
32
33
  /**
33
34
  * When a redirect goes live (TR00702, TR00968, TR01236).
@@ -168,9 +169,6 @@ function reportReadiness(readiness) {
168
169
  };
169
170
  }
170
171
 
171
- const PRESENTATION_REVIEW_GUIDANCE = "Open the URL once in a browser, then switch the site's theme toggle between "
172
- + "light and dark to review both schemes; no page draft is needed to preview a presentation change.";
173
-
174
172
  /**
175
173
  * Why a managed Docs staging handoff could not be minted, as a stable reason
176
174
  * the caller can act on. The recovery never points at production or at a host
@@ -217,8 +215,8 @@ function presentationHandoffUnavailable(error) {
217
215
  async function mintPresentationStagingHandoff(client, siteId, onProgress, now, returnPath) {
218
216
  try {
219
217
  const handoff = await mintStagingPreviewHandoff(client, siteId, { now, returnPath });
220
- onProgress(`Staging review handoff minted for ${handoff.stagingUrl}. ${PRESENTATION_REVIEW_GUIDANCE}`);
221
- return { ...handoff, review: PRESENTATION_REVIEW_GUIDANCE };
218
+ onProgress(`Staging review handoff minted for ${handoff.stagingUrl}. ${STAGING_REVIEW_GUIDANCE}`);
219
+ return { ...handoff, review: STAGING_REVIEW_GUIDANCE };
222
220
  } catch (error) {
223
221
  if (error instanceof SiteAuthoringError && !(error instanceof ApiError)) throw error;
224
222
  const unavailable = presentationHandoffUnavailable(error);
@@ -15,6 +15,7 @@ import {
15
15
  VERB_LOGOUT,
16
16
  VERB_MEDIA_UPLOAD,
17
17
  VERB_NAV_PUSH,
18
+ VERB_PAGES_DELETE,
18
19
  VERB_PAGES_META_SET,
19
20
  VERB_PAGES_PUSH,
20
21
  VERB_PLACES_CATEGORY_CLEAR,
@@ -33,6 +34,7 @@ import {
33
34
  VERB_STAGING_REVIEW,
34
35
  VERB_STATUS,
35
36
  VERB_THEME_PUSH,
37
+ VERB_THEME_RESOLVE,
36
38
  VERB_USE,
37
39
  VERB_VALIDATE,
38
40
  VERB_WHOAMI,
@@ -51,6 +53,7 @@ import { login } from "./login.js";
51
53
  import { logout } from "./logout.js";
52
54
  import { mediaUpload } from "./media-upload.js";
53
55
  import { navPush } from "./nav-push.js";
56
+ import { pagesDelete } from "./pages-delete.js";
54
57
  import { pagesMetaSet } from "./pages-meta-set.js";
55
58
  import { pagesPush } from "./pages-push.js";
56
59
  import { placesCategoryClear, placesCategoryList, placesCategorySet, placesSearch, placesSelect } from "./places.js";
@@ -65,6 +68,7 @@ import { sites } from "./sites.js";
65
68
  import { stagingReview } from "./staging-review.js";
66
69
  import { status } from "./status.js";
67
70
  import { themePush } from "./theme-push.js";
71
+ import { themeResolve } from "./theme-resolve.js";
68
72
  import { use } from "./use.js";
69
73
  import { validateFixture } from "./validate.js";
70
74
  import { whoami } from "./whoami.js";
@@ -116,6 +120,7 @@ import { whoami } from "./whoami.js";
116
120
  * pagePaths, // approve: narrow to these page paths; with none, every
117
121
  * // draft the workspace manifest tracks is staged
118
122
  * authorHandle, // authors add: the handle of the site author to create
123
+ * deletePagePath, // pages delete: the one tracked page to delete
119
124
  * pageId, // preview page: the one persisted draft UUID to render
120
125
  * previewIds, // preview revoke: [page UUID, snapshot UUID]
121
126
  * }
@@ -149,6 +154,7 @@ export const VERB_HANDLERS = Object.freeze({
149
154
  [VERB_PULL]: pull,
150
155
  [VERB_PAGES_PUSH]: pagesPush,
151
156
  [VERB_PAGES_META_SET]: pagesMetaSet,
157
+ [VERB_PAGES_DELETE]: pagesDelete,
152
158
  [VERB_AUTHORS_LIST]: authorsList,
153
159
  [VERB_AUTHORS_ADD]: authorsAdd,
154
160
  [VERB_NAV_PUSH]: navPush,
@@ -163,6 +169,7 @@ export const VERB_HANDLERS = Object.freeze({
163
169
  [VERB_REDIRECTS_PULL]: redirectsPull,
164
170
  [VERB_REDIRECTS_PUSH]: redirectsPush,
165
171
  [VERB_THEME_PUSH]: themePush,
172
+ [VERB_THEME_RESOLVE]: themeResolve,
166
173
  [VERB_FOOTER_PUSH]: footerPush,
167
174
  [VERB_FORMS_PULL]: formsPull,
168
175
  [VERB_FORMS_PUSH]: formsPush,