@taprootio/site-authoring 0.22.4 → 0.23.1

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 = 40;
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 "
@@ -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
@@ -51,6 +51,10 @@ const IDEMPOTENT_METHODS = new Set(["GET"]);
51
51
 
52
52
  /** An exchanged credential is renewed once it is this close to expiring. */
53
53
  const CREDENTIAL_RENEWAL_MARGIN_MILLISECONDS = 5 * 60_000;
54
+ const GATEWAY_UPLOAD_ATTEMPTS = 4;
55
+ const GATEWAY_CAPACITY_WINDOW_MILLISECONDS = 15_000;
56
+ const UPLOAD_CAPACITY_HEADER = "x-taproot-upload-admission";
57
+ const UPLOAD_CAPACITY_REFUSAL = "capacity-before-claim";
54
58
 
55
59
  const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
56
60
  const RETRYABLE_RESPONSE_ERRORS = new Set([
@@ -965,15 +969,16 @@ export class SiteApiClient {
965
969
  }
966
970
 
967
971
  /**
968
- * Performs one presigned whole-object PUT. The signed headers are echoed
972
+ * Performs a whole-object PUT. Signed S3 headers are echoed
969
973
  * exactly as the server returned them and are never recomputed: the values
970
974
  * are covered by the SigV4 signature, so a "helpfully" regenerated
971
975
  * Content-Type or an added header turns into an opaque S3 403. The bearer is
972
976
  * never forwarded — the signature is the authorization, and sending the
973
977
  * site key to an object store would leak it outside the reviewed origin.
974
978
  *
975
- * The PUT of an immutable object is idempotent, so this is the one mutation
976
- * shape that may be replayed.
979
+ * S3 create-only uploads retain their existing retry contract. A gateway
980
+ * capability is consumed once before the body is read. Only an explicit
981
+ * capacity refusal before that claim permits a bounded retry.
977
982
  */
978
983
  async upload({ url: uploadUrl, requiredHeaders } = {}, bodyBytes) {
979
984
  // A video original is streamed from disk: `{ byteLength, stream, timeoutMilliseconds }`,
@@ -1040,7 +1045,8 @@ export class SiteApiClient {
1040
1045
  names.add(normalizedName);
1041
1046
  headers.set(name, value);
1042
1047
  }
1043
- if (!headers.has("content-type") || !headers.has("content-length")) {
1048
+ const gateway = headers.has("x-taproot-upload-capability");
1049
+ if (!headers.has("content-type") || (!gateway && !headers.has("content-length"))) {
1044
1050
  throw new SiteAuthoringError(
1045
1051
  "upload.headers_invalid",
1046
1052
  "The signed upload capability is missing required content headers.",
@@ -1049,31 +1055,48 @@ export class SiteApiClient {
1049
1055
  // The declared length is bound into the signature, so a body of any other
1050
1056
  // size cannot succeed. Failing here names the real problem instead of
1051
1057
  // surfacing an S3 signature error.
1052
- if (headers.get("content-length") !== String(bodyBytes.byteLength)) {
1058
+ if (gateway && (
1059
+ url.origin !== this.apiOrigin
1060
+ || !url.pathname.startsWith(this.apiPathPrefix)
1061
+ || !/^\/api\/v1\/images\/uploads\/[0-9a-f-]{36}\/content$/u.test(url.pathname)
1062
+ || url.search !== ""
1063
+ )) {
1064
+ throw new SiteAuthoringError("upload.contract_invalid", "Taproot returned an invalid gateway URL.");
1065
+ }
1066
+ if (!gateway && headers.get("content-length") !== String(bodyBytes.byteLength)) {
1053
1067
  throw new SiteAuthoringError(
1054
1068
  "upload.content_length_invalid",
1055
1069
  "The signed upload content length does not match the bytes being uploaded.",
1056
1070
  );
1057
1071
  }
1058
1072
 
1059
- for (let attempt = 0; attempt < LIMITS.uploadAttempts; attempt += 1) {
1073
+ const maximumAttempts = gateway ? GATEWAY_UPLOAD_ATTEMPTS : LIMITS.uploadAttempts;
1074
+ const startedAt = this.now();
1075
+ const uploadDeadline = startedAt + ((streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds);
1076
+ for (let attempt = 0; attempt < maximumAttempts; attempt += 1) {
1077
+ if (this.signal?.aborted) throw cancelledError(false);
1078
+ const remaining = uploadDeadline - this.now();
1079
+ if (gateway && remaining <= 0) {
1080
+ throw new SiteAuthoringError("upload.capacity_exhausted", "The image upload exhausted its attempt window.");
1081
+ }
1060
1082
  let response;
1083
+ const attemptSignal = operationSignal(
1084
+ this.signal,
1085
+ gateway ? remaining : (streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds,
1086
+ this.timeoutSignal,
1087
+ );
1061
1088
  try {
1062
- response = await this.fetch(url, {
1089
+ response = await awaitWithSignal(this.fetch(url, {
1063
1090
  method: "PUT",
1064
1091
  redirect: "error",
1065
- signal: operationSignal(
1066
- this.signal,
1067
- (streamed ? bodyBytes.timeoutMilliseconds : undefined) ?? LIMITS.uploadMilliseconds,
1068
- this.timeoutSignal,
1069
- ),
1092
+ signal: attemptSignal,
1070
1093
  headers,
1071
1094
  ...(streamed
1072
1095
  ? { body: Readable.toWeb(bodyBytes.stream()), duplex: "half" }
1073
1096
  : { body: bodyBytes }),
1074
- });
1097
+ }), attemptSignal);
1075
1098
  } catch {
1076
- if (attempt + 1 < LIMITS.uploadAttempts && !this.signal?.aborted) {
1099
+ if (!gateway && attempt + 1 < maximumAttempts && !this.signal?.aborted) {
1077
1100
  await this.sleep(delayForAttempt(attempt), this.signal);
1078
1101
  continue;
1079
1102
  }
@@ -1082,14 +1105,29 @@ export class SiteApiClient {
1082
1105
  "The whole-object upload ended without an authoritative response.",
1083
1106
  );
1084
1107
  }
1085
- await cancelBody(response);
1108
+ await cancelBody(response, attemptSignal);
1086
1109
  if (response.status >= 200 && response.status < 300 && !response.redirected) return;
1087
- if (RETRYABLE_STATUS.has(response.status) && attempt + 1 < LIMITS.uploadAttempts) {
1110
+ const capacityRefused = gateway && !response.redirected && response.status === HTTP_TOO_MANY_REQUESTS
1111
+ && response.headers.get(UPLOAD_CAPACITY_HEADER) === UPLOAD_CAPACITY_REFUSAL;
1112
+ const retryAfter = response.headers.get("retry-after") ?? "";
1113
+ if (capacityRefused && /^[1-5]$/u.test(retryAfter) && attempt + 1 < maximumAttempts) {
1114
+ const delay = Number(retryAfter) * 1000;
1115
+ const capacityRemaining = Math.min(uploadDeadline, startedAt + GATEWAY_CAPACITY_WINDOW_MILLISECONDS) - this.now();
1116
+ if (delay < capacityRemaining) {
1117
+ // TR01295: the server guarantees that this rejected PUT did not
1118
+ // claim the capability. Network failures and 5xx never retry it.
1119
+ await awaitWithSignal(this.sleep(delay, this.signal), operationSignal(
1120
+ this.signal, capacityRemaining, this.timeoutSignal,
1121
+ ));
1122
+ if (this.now() - startedAt < GATEWAY_CAPACITY_WINDOW_MILLISECONDS) continue;
1123
+ }
1124
+ }
1125
+ if (!gateway && RETRYABLE_STATUS.has(response.status) && attempt + 1 < maximumAttempts) {
1088
1126
  await this.sleep(delayForAttempt(attempt), this.signal);
1089
1127
  continue;
1090
1128
  }
1091
1129
  throw new SiteAuthoringError(
1092
- RETRYABLE_STATUS.has(response.status) ? "upload.ambiguous" : "upload.rejected",
1130
+ RETRYABLE_STATUS.has(response.status) && !capacityRefused ? "upload.ambiguous" : "upload.rejected",
1093
1131
  `The whole-object upload was rejected with HTTP ${response.status}.`,
1094
1132
  { status: `http:${response.status}` },
1095
1133
  );
@@ -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);