@taprootio/site-authoring 0.8.3 → 0.8.5

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.
@@ -252,6 +252,8 @@ const IMAGE_BANNER = Object.freeze({
252
252
  // the site header; `viewport` fills exactly that space (TR00413).
253
253
  heightMode: enumOf("ratio", "viewport"),
254
254
  contentPosition: enumOf("bottom-start", "bottom", "bottom-end", "center", "top-start", "top", "top-end"),
255
+ contentGutter: enumOf("frame", "page"),
256
+ followingSpacing: enumOf("standard", "compact"),
255
257
  scrim: enumOf("auto", "none", "flat", "top", "bottom", "left", "right", "radial"),
256
258
  scrimStrength: enumOf("soft", "medium", "strong"),
257
259
  bannerScheme: enumOf("auto", "light", "dark"),
@@ -260,7 +262,7 @@ const IMAGE_BANNER = Object.freeze({
260
262
  // A built-in or application-registered Espalier texture name.
261
263
  texture: textureName(),
262
264
  textureScale: enumOf("fine", "medium", "coarse"),
263
- imageMotion: enumOf("none", "slow-zoom", "drift-start", "drift-end"),
265
+ imageMotion: enumOf("none", "slow-zoom", "drift-start", "drift-end", "parallax-subtle", "parallax"),
264
266
  });
265
267
 
266
268
  const SPACER = Object.freeze({
@@ -371,6 +373,8 @@ const COMPONENT_DEFINITIONS = Object.freeze({
371
373
  "Identify the speaker with authorName; use authorTitle when it gives useful context.",
372
374
  "Author images need descriptive alt text unless they are purely decorative.",
373
375
  "Keep carousel intervals long enough for the full quotation to be read.",
376
+ "Set carousel:true for directional previous/next arrows, dot navigation and a play/pause control. Navigation or keyboard focus pauses rotation; reduced-motion users get manual navigation without sliding or automatic rotation.",
377
+ "Use real attributed reviews, or clearly label fictional demo reviews. Never imply invented testimonials are genuine customer endorsements.",
374
378
  ],
375
379
  {
376
380
  items: [{
@@ -494,6 +498,8 @@ const COMPONENT_DEFINITIONS = Object.freeze({
494
498
  compactRatio: "3/2",
495
499
  heightMode: "ratio",
496
500
  contentPosition: "center",
501
+ contentGutter: "frame",
502
+ followingSpacing: "standard",
497
503
  scrim: "auto",
498
504
  scrimStrength: "medium",
499
505
  bannerScheme: "auto",
@@ -505,6 +511,16 @@ const COMPONENT_DEFINITIONS = Object.freeze({
505
511
  [
506
512
  "Set altText to describe meaningful banner imagery; leave it empty only when the image is decorative.",
507
513
  "Choose a scrim and scheme that keep overlay text readable across the image.",
514
+ "For a full-bleed photograph with text aligned to the surrounding page well, set contentGutter to page. followingSpacing: compact reduces the top padding of an immediately following section so a bottom banner heading reads with its supporting paragraph. Use a banner-only contentPadding: none section followed by a standard text section; keep their theme contexts consistent. Defaults preserve frame-edge text and standard section spacing.",
515
+ "Compose around the actual photograph: identify the meaningful subject and a quiet area for copy. Do not cover faces, product details, or high-detail edges. A focal point controls cropping, not text placement; inspect wide and compact crops separately. Keep the subject's face, ears, and other identifying details inside the compact frame, including at both ends of parallax travel. Adjust focus toward the subject rather than reusing a generic center; reduce motion or choose a roomier crop if preserving the subject requires it.",
516
+ "Use contentPosition to place copy in that quiet area, not automatically in the center. Keep one dominant message and nearby supporting copy/actions; if the photo has no safe text area, use a split hero or a separate text section instead of forcing an overlay.",
517
+ "Use the rule of thirds as a composition guide: consider the subject near one third and a compact text group in quiet space on the opposite side. Golden-ratio proportions are optional alternatives, not a proven engagement formula. Neither guide overrides legibility, the actual subject, or the compact crop. Current contentPosition values are edge/center anchors, not exact third or golden-ratio coordinates; do not invent unsupported positioning properties.",
518
+ "Check contrast behind the actual letters at both motion endpoints and both themes: WCAG AA requires 4.5:1 for ordinary text and 3:1 for qualifying large text. A text shadow alone is not a contrast guarantee. Keep text and actions stationary; recheck at mobile widths and enlarged text.",
519
+ "Prefer parallax-subtle or parallax when motion is useful: motion follows the reader's scrolling and stops when they stop. Infinite ambient loops are an explicit art-direction choice, not the default recommendation.",
520
+ "imageMotion is optional and defaults to none. slow-zoom is a cinematic 18% zoom over 6 seconds, alternating; drift-start and drift-end travel horizontally over 8 seconds with a 14% safety crop.",
521
+ "These retuned ambient presets affect existing published banners when their shared runtime updates, without a per-site republish. Preview existing sites before rolling out the runtime.",
522
+ "parallax-subtle and parallax are scroll-linked image motion, not autoplay: scroll to see them. Published pages prefer CSS view timelines and use a platform-owned JavaScript fallback in unsupported browsers, including Firefox; authors need no custom code. Both paths respect reduced motion. They travel vertically by +/-4% and +/-8% of image height with 12% and 22% overscan. Keep focal subjects away from edges; overlay text remains stationary in its frame.",
523
+ "All image motion is disabled for prefers-reduced-motion. Without native view timelines and JavaScript, parallax remains static. Choose one motion per banner; never put essential text in the raster image.",
508
524
  ],
509
525
  {
510
526
  image: null,
@@ -516,7 +532,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
516
532
  scrim: "bottom",
517
533
  scrimStrength: "medium",
518
534
  texture: "paper",
519
- imageMotion: "slow-zoom",
535
+ imageMotion: "parallax-subtle",
520
536
  },
521
537
  ),
522
538
  });
@@ -183,16 +183,19 @@ function renderFreeFormRootNodes(
183
183
  ): string {
184
184
  const rendered: string[] = [];
185
185
  let flow: ProseMirrorNode[] = [];
186
+ let compactFollowing = false;
186
187
  const flushFlow = () => {
187
188
  if (flow.length === 0) return;
188
- rendered.push(renderSection(flow, {}, "implicit", options, "root"));
189
+ rendered.push(renderSection(flow, {}, "implicit", options, "root", compactFollowing));
190
+ compactFollowing = false;
189
191
  flow = [];
190
192
  };
191
193
 
192
194
  for (const node of nodes) {
193
195
  if (node.type === freeFormSectionRegistry.section.nodeType || isRootBandComponent(node)) {
194
196
  flushFlow();
195
- rendered.push(renderNode(node, options, "root"));
197
+ rendered.push(renderNode(node, options, "root", compactFollowing));
198
+ compactFollowing = joinsFollowingSection(node);
196
199
  continue;
197
200
  }
198
201
  flow.push(node);
@@ -201,6 +204,20 @@ function renderFreeFormRootNodes(
201
204
  return rendered.join("");
202
205
  }
203
206
 
207
+ function joinsFollowingSection(node: ProseMirrorNode): boolean {
208
+ if (node.type === "section") {
209
+ return node.attrs?.contentPadding === "none" && node.content?.length === 1
210
+ && joinsFollowingSection(node.content[0]);
211
+ }
212
+ if (node.type !== "componentBlock" || node.attrs?.componentType !== "image-banner") return false;
213
+ try {
214
+ const data = JSON.parse(stringAttr(node.attrs?.componentData) ?? "{}");
215
+ return data?.followingSpacing === "compact";
216
+ } catch {
217
+ return false;
218
+ }
219
+ }
220
+
204
221
  function isRootBandComponent(node: ProseMirrorNode): boolean {
205
222
  if (node.type !== "componentBlock") return false;
206
223
  const componentType = stringAttr(node.attrs?.componentType);
@@ -215,6 +232,7 @@ function renderNode(
215
232
  node: ProseMirrorNode,
216
233
  options: RenderProseMirrorOptions,
217
234
  placement: RenderPlacement,
235
+ compactFollowing = false,
218
236
  ): string {
219
237
  switch (node.type) {
220
238
  case "doc":
@@ -272,6 +290,7 @@ function renderNode(
272
290
  "explicit",
273
291
  options,
274
292
  placement === "root" ? "section" : "nested",
293
+ compactFollowing,
275
294
  );
276
295
  case "rawHtml": {
277
296
  const html = stringAttr(node.attrs?.html) ?? "";
@@ -685,6 +704,7 @@ function renderSection(
685
704
  kind: "explicit" | "implicit",
686
705
  options: RenderProseMirrorOptions,
687
706
  contentPlacement: RenderPlacement,
707
+ compactFollowing = false,
688
708
  ): string {
689
709
  const definitions = freeFormSectionRegistry.section.attrs;
690
710
  const contentPadding = attrs.contentPadding === "none" ? "none" : definitions.contentPadding.default;
@@ -748,6 +768,7 @@ function renderSection(
748
768
  {
749
769
  "data-taproot-section": kind,
750
770
  "data-content-padding": contentPadding,
771
+ "data-following-spacing": compactFollowing && contentPadding === "standard" ? "compact" : undefined,
751
772
  "data-surface": surface,
752
773
  "data-entrance": entrance,
753
774
  "data-entrance-stagger": entranceStagger,
@@ -0,0 +1,276 @@
1
+ import { sanitizeDiagnostic } from "./errors.js";
2
+ import { withholdCredentialLocation } from "./staging-check.js";
3
+
4
+ /**
5
+ * The optional browser dimension of delivery verification (TR00824).
6
+ *
7
+ * HTTP evidence cannot say what a visitor's browser loaded: the shared runtime
8
+ * comes from a mutable major pointer that a returning browser may hold in its
9
+ * cache. This probe drives a real browser at the target twice — a fresh
10
+ * context, then a second navigation in the same context so the browser's own
11
+ * cache participates — and reports which runtime entry each load evaluated,
12
+ * whether the declared capability elements were defined, and whether the
13
+ * second load actually served the runtime entry from cache.
14
+ *
15
+ * Playwright is deliberately not a dependency of this package: basic CLI use
16
+ * must not carry a browser. The probe imports it only if the operator has
17
+ * installed it where this package's imports resolve (a global install of both
18
+ * packages, run through the installed command), and reports the dimension as
19
+ * unchecked with that recovery otherwise.
20
+ *
21
+ * Playwright's own request routing is not used: it disables the HTTP cache,
22
+ * which is the very thing the returning load measures. Instead a DevTools
23
+ * session on the browser itself — not on the page, because a page session
24
+ * sees only its own target and authored script can open a popup — intercepts
25
+ * every document request in the browser (the navigation, each redirect hop,
26
+ * a script-driven navigation, a frame, a popup) at the request stage, before
27
+ * it is transmitted, and fails any that would leave the target origin; the
28
+ * subresource cache is untouched. A page session reports whether the runtime
29
+ * entry came from the memory or disk cache. Without the browser session the
30
+ * boundary cannot be enforced, so the dimension is unchecked rather than
31
+ * probed without it. Every URL the probe reports passes through the
32
+ * credential withholding the staging check uses, so a handoff-bearing
33
+ * redirect never reaches the result. A staging cookie is attached only to the
34
+ * staging origin it was minted for.
35
+ */
36
+
37
+ // Node resolves `playwright` from the CLI's own dependency chain: a copy run
38
+ // through `npx` lives in npm's cache and cannot see a global Playwright, so
39
+ // the recovery installs the CLI and Playwright side by side and reruns
40
+ // through the installed `taproot-site` command rather than `npx`.
41
+ export const BROWSER_INSTALL_HINT = "npm install --global @taprootio/site-authoring@latest playwright && npx playwright install chromium, then rerun with the installed taproot-site command (not npx)";
42
+ const SETTLE_MILLISECONDS = 20_000;
43
+ const EVIDENCE_CHARACTERS = 512;
44
+ const BLOCKED_NAVIGATION = /ERR_BLOCKED_BY_CLIENT|ERR_ABORTED/u;
45
+
46
+ async function defaultImportPlaywright() {
47
+ return await import("playwright");
48
+ }
49
+
50
+ /** A URL or entry name as evidence: credentials withheld, controls removed, bounded. */
51
+ function evidence(value) {
52
+ return [...withholdCredentialLocation(typeof value === "string" ? value : "")].slice(0, EVIDENCE_CHARACTERS).join("");
53
+ }
54
+
55
+ async function observe(page, capabilities) {
56
+ return await page.evaluate((declared) => {
57
+ const root = document.querySelector("esp-root");
58
+ const entries = performance.getEntriesByType("resource")
59
+ .map((entry) => entry.name)
60
+ .filter((name) => /taproot-shared-runtime[^/]*\.js(?:$|\?)/u.test(name));
61
+ return {
62
+ rootDefined: customElements.get("esp-root") !== undefined,
63
+ themeReady: root?.hasAttribute("data-theme-ready") ?? false,
64
+ loadingHeld: root?.hasAttribute("data-taproot-loading") ?? false,
65
+ runtimeEntries: entries.slice(0, 4),
66
+ capabilitiesDefined: declared.filter((tag) => customElements.get(tag) !== undefined).length,
67
+ title: document.title,
68
+ };
69
+ }, capabilities);
70
+ }
71
+
72
+ const NOTHING_OBSERVED = Object.freeze({ rootDefined: false, themeReady: false, loadingHeld: false, runtimeEntries: [], capabilitiesDefined: 0 });
73
+
74
+ /** Every URL the navigation passed through, from the first request to the final document. */
75
+ function redirectChain(response) {
76
+ const hops = [];
77
+ let request = response?.request?.();
78
+ while (request && hops.length < 16) {
79
+ hops.unshift(request.url());
80
+ request = request.redirectedFrom?.();
81
+ }
82
+ return hops;
83
+ }
84
+
85
+ function sameOrigin(url, origin) {
86
+ try {
87
+ return new URL(url).origin === origin;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The DevTools sessions that enforce the navigation boundary (on the browser,
95
+ * so every target is covered) and observe cache reuse (on the page). Undefined
96
+ * when the browser offers no such sessions.
97
+ */
98
+ async function startDevTools(browser, context, page, { origin, expectedEntryUrl }) {
99
+ if (typeof browser.newBrowserCDPSession !== "function" || typeof context.newCDPSession !== "function") return undefined;
100
+ let boundary;
101
+ let session;
102
+ try {
103
+ boundary = await browser.newBrowserCDPSession();
104
+ session = await context.newCDPSession(page);
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ const state = { blocked: [], requests: new Map(), fromCache: undefined };
109
+ boundary.on("Fetch.requestPaused", (event) => {
110
+ const url = event?.request?.url ?? "";
111
+ if (sameOrigin(url, origin)) {
112
+ boundary.send("Fetch.continueRequest", { requestId: event.requestId }).catch(() => undefined);
113
+ } else {
114
+ if (state.blocked.length < 8) state.blocked.push(url);
115
+ boundary.send("Fetch.failRequest", { requestId: event.requestId, errorReason: "BlockedByClient" }).catch(() => undefined);
116
+ }
117
+ });
118
+ session.on("Network.requestWillBeSent", (event) => {
119
+ if (event?.request?.url === expectedEntryUrl) state.requests.set(event.requestId, event.request.url);
120
+ });
121
+ // A memory-cache hit is announced by requestServedFromCache; only a disk
122
+ // (or prefetch) cache hit is visible on the response itself.
123
+ session.on("Network.requestServedFromCache", (event) => {
124
+ if (state.requests.has(event?.requestId)) state.fromCache = true;
125
+ });
126
+ session.on("Network.responseReceived", (event) => {
127
+ if (event?.response?.url !== expectedEntryUrl || state.fromCache === true) return;
128
+ state.fromCache = Boolean(event.response.fromDiskCache || event.response.fromPrefetchCache);
129
+ });
130
+ await session.send("Network.enable");
131
+ await boundary.send("Fetch.enable", { patterns: [{ urlPattern: "*", resourceType: "Document", requestStage: "Request" }] });
132
+ return {
133
+ state,
134
+ reset() {
135
+ state.blocked = [];
136
+ state.requests.clear();
137
+ state.fromCache = undefined;
138
+ },
139
+ };
140
+ }
141
+
142
+ async function load(page, url, capabilities, origin, devtools) {
143
+ devtools.reset();
144
+ let response;
145
+ let blockedNavigation = false;
146
+ try {
147
+ response = await page.goto(url, { waitUntil: "domcontentloaded" });
148
+ } catch (error) {
149
+ // The boundary failed a document request before it was sent; the
150
+ // navigation error is the expected outcome, not a probe failure.
151
+ if (devtools.state.blocked.length === 0 && !BLOCKED_NAVIGATION.test(String(error?.message ?? ""))) throw error;
152
+ blockedNavigation = true;
153
+ }
154
+ if (!blockedNavigation) {
155
+ await page.waitForFunction(
156
+ () => {
157
+ const root = document.querySelector("esp-root");
158
+ return root !== null && !root.hasAttribute("data-taproot-loading");
159
+ },
160
+ undefined,
161
+ { timeout: SETTLE_MILLISECONDS },
162
+ ).catch(() => undefined);
163
+ }
164
+ const hops = blockedNavigation ? [] : [...new Set([...redirectChain(response), page.url()])];
165
+ const strayed = [...new Set([...devtools.state.blocked, ...hops.filter((hop) => !sameOrigin(hop, origin))])];
166
+ const observed = blockedNavigation ? NOTHING_OBSERVED : await observe(page, capabilities);
167
+ return { observed, strayed, finalUrl: blockedNavigation ? "" : page.url() };
168
+ }
169
+
170
+ function judge({ observed, strayed, finalUrl }, expectedEntryUrl, capabilityCount) {
171
+ const loadedEntry = observed.runtimeEntries.find((name) => name === expectedEntryUrl)
172
+ ?? observed.runtimeEntries[0]
173
+ ?? "";
174
+ return {
175
+ loadedEntry: evidence(loadedEntry),
176
+ expectedEntry: observed.runtimeEntries.includes(expectedEntryUrl),
177
+ rootDefined: observed.rootDefined,
178
+ themeReady: observed.themeReady,
179
+ capabilitiesDefined: observed.capabilitiesDefined,
180
+ capabilitiesDeclared: capabilityCount,
181
+ finalUrl: evidence(finalUrl),
182
+ ...(strayed.length > 0 ? { offOriginNavigation: strayed.slice(0, 4).map(evidence) } : {}),
183
+ ok: strayed.length === 0 && observed.rootDefined && observed.themeReady
184
+ && observed.runtimeEntries.includes(expectedEntryUrl)
185
+ && observed.capabilitiesDefined === capabilityCount,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * @param options.baseUrl The delivery origin.
191
+ * @param options.expectedEntryUrl The runtime entry the major pointer names right now.
192
+ * @param options.capabilities Custom-element tags the home page declares.
193
+ * @param options.cookie Optional staging cookie, sent only to `baseUrl`'s host.
194
+ * @param options.importPlaywright Test seam; defaults to a dynamic import.
195
+ */
196
+ export async function probeDeliveryWithBrowser({
197
+ baseUrl,
198
+ expectedEntryUrl,
199
+ capabilities = [],
200
+ cookie,
201
+ importPlaywright = defaultImportPlaywright,
202
+ }) {
203
+ let playwright;
204
+ try {
205
+ playwright = await importPlaywright();
206
+ } catch {
207
+ return {
208
+ status: "unchecked",
209
+ reason: "playwright_unavailable",
210
+ install: BROWSER_INSTALL_HINT,
211
+ note: "Fresh and returning browser behaviour was not verified; install the CLI and Playwright together and rerun through the installed taproot-site command to check it.",
212
+ };
213
+ }
214
+ const origin = new URL(baseUrl).origin;
215
+ let browser;
216
+ try {
217
+ browser = await playwright.chromium.launch({ headless: true });
218
+ const context = await browser.newContext();
219
+ if (cookie) {
220
+ const [name, value] = cookie.split("=", 2);
221
+ // A __Host- cookie carries no Domain attribute by definition; the URL
222
+ // form binds it to the staging origin without one.
223
+ await context.addCookies([{ name, value, url: baseUrl, secure: true, httpOnly: true, sameSite: "Lax" }]);
224
+ }
225
+ const page = await context.newPage();
226
+ const devtools = await startDevTools(browser, context, page, { origin, expectedEntryUrl });
227
+ if (!devtools) {
228
+ await context.close();
229
+ return {
230
+ status: "unchecked",
231
+ reason: "navigation_boundary_unavailable",
232
+ note: "The browser offers no DevTools sessions, so document requests could not be held to the site origin; nothing was loaded.",
233
+ };
234
+ }
235
+ const fresh = judge(await load(page, baseUrl, capabilities, origin, devtools), expectedEntryUrl, capabilities.length);
236
+ if (fresh.offOriginNavigation) {
237
+ // A target that leaves its origin is not probed again.
238
+ await context.close();
239
+ return {
240
+ status: "checked",
241
+ fresh,
242
+ returning: { skipped: true, reason: "fresh_load_left_origin", ok: false },
243
+ staleRuntimeObserved: false,
244
+ returningCache: { status: "unchecked", reason: "fresh_load_left_origin" },
245
+ ok: false,
246
+ };
247
+ }
248
+ // The same context navigates again: its HTTP cache now holds whatever the
249
+ // first load fetched, which is exactly what a returning visitor has.
250
+ const returning = judge(await load(page, baseUrl, capabilities, origin, devtools), expectedEntryUrl, capabilities.length);
251
+ const cacheObserved = devtools.state.fromCache;
252
+ await context.close();
253
+ return {
254
+ status: "checked",
255
+ fresh,
256
+ returning: {
257
+ ...returning,
258
+ runtimeFromCache: cacheObserved === undefined ? "unchecked" : cacheObserved,
259
+ },
260
+ staleRuntimeObserved: fresh.ok && !returning.expectedEntry,
261
+ returningCache: cacheObserved === undefined
262
+ ? { status: "unchecked", reason: "cache_observation_unavailable" }
263
+ : { status: "checked", servedFromCache: cacheObserved },
264
+ ok: fresh.ok && returning.ok,
265
+ };
266
+ } catch (error) {
267
+ return {
268
+ status: "failed",
269
+ reason: "browser_probe_failed",
270
+ error: sanitizeDiagnostic(error instanceof Error ? error.name : "Error", "Error"),
271
+ note: "The browser dimension could not be completed and is not verified.",
272
+ };
273
+ } finally {
274
+ await browser?.close().catch(() => undefined);
275
+ }
276
+ }