@taprootio/site-authoring 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@taprootio/espalier": "4.13.0"
31
+ "@taprootio/espalier": "4.14.1"
32
32
  },
33
33
  "scripts": {
34
34
  "test": "node --test",
package/src/constants.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const CLI_NAME = "@taprootio/site-authoring";
2
- export const CLI_VERSION = "0.8.2";
2
+ export const CLI_VERSION = "0.8.4";
3
3
  export const CLI_BINARY_NAME = "taproot-site";
4
4
  export const RESULT_SCHEMA_VERSION = 1;
5
5
  export const CONFIG_FILE_NAME = "taproot-site.json";
@@ -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
  });
@@ -68,6 +68,16 @@ export interface RenderProseMirrorOptions {
68
68
  /** The page shell emits this default once so rich-text formats inherit it. */
69
69
  maxHeightVh?: TiptapImageMaxHeightVh | number | null;
70
70
  };
71
+ /** Generator-only render hooks for structured nodes that need page context. */
72
+ nodeRenderers?: {
73
+ componentBlock?: (node: ProseMirrorNode) => string | undefined;
74
+ taprootImage?: (node: ProseMirrorNode) => string | undefined;
75
+ rawHtml?: (html: string, node: ProseMirrorNode) => string | undefined;
76
+ };
77
+ /** Records visible document-order content after each node is rendered. */
78
+ onRenderedNode?: (node: ProseMirrorNode, html: string) => void;
79
+ /** Claims the page's one LCP slot before rendering a section photo. */
80
+ claimSectionBackgroundImage?: () => boolean;
71
81
  }
72
82
 
73
83
  export function createEmptyProseMirrorDocument(): ProseMirrorDocument {
@@ -97,11 +107,62 @@ export function renderRichTextBodyToHtml(
97
107
  body: unknown,
98
108
  options: RenderProseMirrorOptions = {},
99
109
  ): string {
100
- if (typeof body === "string") return body;
101
110
  if (!isProseMirrorDocument(body)) return "";
102
111
  return renderProseMirrorDocumentToHtml(body, options);
103
112
  }
104
113
 
114
+ /**
115
+ * Extracts the reader-visible text from a ProseMirror document for metadata
116
+ * that only accepts plain text, such as Schema.org recipe instructions.
117
+ */
118
+ export function extractRichTextBodyText(body: unknown): string {
119
+ if (!isProseMirrorDocument(body)) return "";
120
+ const parts: string[] = [];
121
+ collectText(body, parts);
122
+ return parts.join("").replace(/\s+/gu, " ").trim();
123
+ }
124
+
125
+ function collectText(node: ProseMirrorNode, parts: string[]): void {
126
+ if (node.type === "text") {
127
+ if (node.text) parts.push(node.text);
128
+ return;
129
+ }
130
+
131
+ // rawHtml is an intentional, already-sanitized node in the JSON document
132
+ // contract. It is not the retired outer HTML-body transport. Preserve its
133
+ // reader-visible text when a consumer (for example Recipe JSON-LD) needs a
134
+ // plain-text representation of the document.
135
+ if (node.type === "rawHtml") {
136
+ const html = stringAttr(node.attrs?.html);
137
+ if (html) parts.push(plainTextFromRawHtml(html));
138
+ return;
139
+ }
140
+
141
+ for (const child of node.content ?? []) {
142
+ collectText(child, parts);
143
+ }
144
+
145
+ if (node.type === "hardBreak" || isTextBlock(node.type)) {
146
+ parts.push(" ");
147
+ }
148
+ }
149
+
150
+ function isTextBlock(type: string): boolean {
151
+ return type === "paragraph"
152
+ || type === "heading"
153
+ || type === "listItem"
154
+ || type === "blockquote"
155
+ || type === "codeBlock"
156
+ || type === "tableCell"
157
+ || type === "tableHeader";
158
+ }
159
+
160
+ function plainTextFromRawHtml(html: string): string {
161
+ return html
162
+ .replace(/<(?:br\s*\/?|\/(?:p|div|li|h[1-6]|blockquote|pre|tr|table|section|article))\s*[^>]*>/giu, " ")
163
+ .replace(/<[^>]*>/gu, "");
164
+ }
165
+
105
166
  type RenderPlacement = "root" | "section" | "nested";
106
167
 
107
168
  function renderNodes(
@@ -109,7 +170,11 @@ function renderNodes(
109
170
  options: RenderProseMirrorOptions,
110
171
  placement: RenderPlacement,
111
172
  ): string {
112
- return nodes.map((node) => renderNode(node, options, placement)).join("");
173
+ return nodes.map((node) => {
174
+ const html = renderNode(node, options, placement);
175
+ options.onRenderedNode?.(node, html);
176
+ return html;
177
+ }).join("");
113
178
  }
114
179
 
115
180
  function renderFreeFormRootNodes(
@@ -118,16 +183,19 @@ function renderFreeFormRootNodes(
118
183
  ): string {
119
184
  const rendered: string[] = [];
120
185
  let flow: ProseMirrorNode[] = [];
186
+ let compactFollowing = false;
121
187
  const flushFlow = () => {
122
188
  if (flow.length === 0) return;
123
- rendered.push(renderSection(flow, {}, "implicit", options, "root"));
189
+ rendered.push(renderSection(flow, {}, "implicit", options, "root", compactFollowing));
190
+ compactFollowing = false;
124
191
  flow = [];
125
192
  };
126
193
 
127
194
  for (const node of nodes) {
128
195
  if (node.type === freeFormSectionRegistry.section.nodeType || isRootBandComponent(node)) {
129
196
  flushFlow();
130
- rendered.push(renderNode(node, options, "root"));
197
+ rendered.push(renderNode(node, options, "root", compactFollowing));
198
+ compactFollowing = joinsFollowingSection(node);
131
199
  continue;
132
200
  }
133
201
  flow.push(node);
@@ -136,6 +204,20 @@ function renderFreeFormRootNodes(
136
204
  return rendered.join("");
137
205
  }
138
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
+
139
221
  function isRootBandComponent(node: ProseMirrorNode): boolean {
140
222
  if (node.type !== "componentBlock") return false;
141
223
  const componentType = stringAttr(node.attrs?.componentType);
@@ -150,6 +232,7 @@ function renderNode(
150
232
  node: ProseMirrorNode,
151
233
  options: RenderProseMirrorOptions,
152
234
  placement: RenderPlacement,
235
+ compactFollowing = false,
153
236
  ): string {
154
237
  switch (node.type) {
155
238
  case "doc":
@@ -193,11 +276,11 @@ function renderNode(
193
276
  case "table":
194
277
  return placement === "root" || placement === "section" ? renderTable(node, options) : "";
195
278
  case "taprootImage":
196
- return renderTaprootImage(node, options);
279
+ return options.nodeRenderers?.taprootImage?.(node) ?? renderTaprootImage(node, options);
197
280
  case "inlineFacts":
198
281
  return renderInlineFacts(node, options, placement);
199
282
  case "componentBlock":
200
- return renderComponentBlock(node);
283
+ return options.nodeRenderers?.componentBlock?.(node) ?? renderComponentBlock(node);
201
284
  case "integrationPlacement":
202
285
  return renderIntegrationPlacement(node, options);
203
286
  case "section":
@@ -207,9 +290,12 @@ function renderNode(
207
290
  "explicit",
208
291
  options,
209
292
  placement === "root" ? "section" : "nested",
293
+ compactFollowing,
210
294
  );
211
- case "rawHtml":
212
- return stringAttr(node.attrs?.html) ?? "";
295
+ case "rawHtml": {
296
+ const html = stringAttr(node.attrs?.html) ?? "";
297
+ return options.nodeRenderers?.rawHtml?.(html, node) ?? html;
298
+ }
213
299
  default:
214
300
  return renderNodes(node.content ?? [], options, "nested");
215
301
  }
@@ -618,6 +704,7 @@ function renderSection(
618
704
  kind: "explicit" | "implicit",
619
705
  options: RenderProseMirrorOptions,
620
706
  contentPlacement: RenderPlacement,
707
+ compactFollowing = false,
621
708
  ): string {
622
709
  const definitions = freeFormSectionRegistry.section.attrs;
623
710
  const contentPadding = attrs.contentPadding === "none" ? "none" : definitions.contentPadding.default;
@@ -650,6 +737,9 @@ function renderSection(
650
737
  if (decorationStyle) sectionStyleParts.push(...decorationStyle);
651
738
  }
652
739
  const background = kind === "explicit" ? normalizeSectionBackground(attrs.background) : undefined;
740
+ const backgroundPicture = background
741
+ ? renderSectionBackgroundPicture(background, options.claimSectionBackgroundImage?.() === true)
742
+ : "";
653
743
  if (background) {
654
744
  sectionStyleParts.push(
655
745
  `--esp-section-background: color-mix(in oklab, var(--esp-color-background) ${background.scrimOpacityPercent}%, transparent)`,
@@ -678,6 +768,7 @@ function renderSection(
678
768
  {
679
769
  "data-taproot-section": kind,
680
770
  "data-content-padding": contentPadding,
771
+ "data-following-spacing": compactFollowing && contentPadding === "standard" ? "compact" : undefined,
681
772
  "data-surface": surface,
682
773
  "data-entrance": entrance,
683
774
  "data-entrance-stagger": entranceStagger,
@@ -695,7 +786,7 @@ function renderSection(
695
786
  style:
696
787
  `--taproot-section-focus: ${background.focus}; --taproot-section-portrait-focus: ${background.portraitFocus}`,
697
788
  },
698
- `${renderSectionBackgroundPicture(background)}${section}`,
789
+ `${backgroundPicture}${section}`,
699
790
  );
700
791
  }
701
792
 
@@ -845,7 +936,7 @@ function normalizeSectionFocus(
845
936
  return `${x * 100}% ${y * 100}%`;
846
937
  }
847
938
 
848
- function renderSectionBackgroundPicture(background: NormalizedSectionBackground): string {
939
+ function renderSectionBackgroundPicture(background: NormalizedSectionBackground, hero: boolean): string {
849
940
  const portraitSource = background.portraitImage
850
941
  ? renderVoidElement("source", {
851
942
  media: "(max-width: 48rem)",
@@ -862,7 +953,8 @@ function renderSectionBackgroundPicture(background: NormalizedSectionBackground)
862
953
  height: background.image.height,
863
954
  alt: "",
864
955
  decoding: "async",
865
- loading: "lazy",
956
+ loading: hero ? undefined : "lazy",
957
+ fetchpriority: hero ? "high" : undefined,
866
958
  });
867
959
  return renderElement(
868
960
  "picture",
@@ -1032,19 +1124,50 @@ function renderComponentBlock(node: ProseMirrorNode): string {
1032
1124
  );
1033
1125
  }
1034
1126
 
1035
- function renderTaprootImage(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
1127
+ export interface ResolvedTiptapImage {
1128
+ src: string;
1129
+ urls: Array<{ minWidth: number; url: string; type?: string }>;
1130
+ width: number;
1131
+ height: number;
1132
+ alt: string;
1133
+ imageId: string;
1134
+ preserveStoredSrc: boolean;
1135
+ extraAttributes: Record<string, string | number | undefined>;
1136
+ }
1137
+
1138
+ export function resolveTiptapImage(
1139
+ node: ProseMirrorNode,
1140
+ imageDefaults: RenderProseMirrorOptions["imageDefaults"],
1141
+ ): ResolvedTiptapImage {
1036
1142
  const attrs = node.attrs ?? {};
1037
- const resolved = resolveImagePresentation(attrs, options.imageDefaults);
1143
+ const crop = jsonAttr(attrs.crop);
1144
+ const resolved = resolveImagePresentation(attrs, imageDefaults);
1145
+ return {
1146
+ src: stringAttr(attrs.src) ?? "",
1147
+ urls: validResponsiveImageUrls(attrs.urls),
1148
+ width: numberAttr(attrs.width) ?? 0,
1149
+ height: numberAttr(attrs.height) ?? 0,
1150
+ alt: stringAttr(attrs.alt) ?? "",
1151
+ imageId: stringAttr(attrs.imageId) ?? "",
1152
+ preserveStoredSrc: Boolean(crop && crop !== "null"),
1153
+ extraAttributes: {
1154
+ ...(crop ? { "data-crop": crop } : {}),
1155
+ ...resolved,
1156
+ },
1157
+ };
1158
+ }
1159
+
1160
+ function renderTaprootImage(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
1161
+ const image = resolveTiptapImage(node, options.imageDefaults);
1038
1162
  const imageAttrs: Record<string, string | number | boolean | null | undefined> = {
1039
- "low-res": stringAttr(attrs.src),
1040
- "original-width": numberAttr(attrs.width),
1041
- "original-height": numberAttr(attrs.height),
1042
- "data-image-id": stringAttr(attrs.imageId),
1043
- "data-crop": jsonAttr(attrs.crop),
1044
- caption: stringAttr(attrs.alt),
1045
- ...resolved,
1163
+ "low-res": image.src,
1164
+ "original-width": image.width,
1165
+ "original-height": image.height,
1166
+ "data-image-id": image.imageId,
1167
+ caption: image.alt,
1168
+ ...image.extraAttributes,
1046
1169
  };
1047
- const optionsHtml = validResponsiveImageUrls(attrs.urls)
1170
+ const optionsHtml = image.urls
1048
1171
  .map((url) =>
1049
1172
  renderVoidElement("esp-image-option", {
1050
1173
  width: url.minWidth,
@@ -333,7 +333,17 @@ export function getAppearanceReference() {
333
333
  imageReferences:
334
334
  "Use site-owned image ids retained from pull or returned by media upload. URLs are server projections, never authored inputs.",
335
335
  logoContract:
336
- "lightLogoId and darkLogoId are the current scheme-specific logo fields; there is no separate compact-logo setting.",
336
+ "lightLogoId and darkLogoId are the current scheme-specific logo fields; there is no separate compact-logo setting. "
337
+ + "Prefer transparent PNG or WebP artwork with genuine alpha so the header background shows through, including "
338
+ + "inside letter counters. Avoid baked-in background rectangles unless the brand intentionally uses a badge. "
339
+ + "Keep padding tight and verify the delivered image preserves transparency. Logo style and coloration are "
340
+ + "project-specific: choose monochrome, an accent, or multiple colors to suit the brand, not a universal recipe. "
341
+ + "Keep the lettering readable and test the visible artwork against each header scheme.",
342
+ faviconContract:
343
+ "Include a favicon when designing a site: upload a square, simplified brand mark and set brand.faviconId "
344
+ + "in settings/brand.json, then run theme push; faviconUrl is a read-only projection. Do not shrink a full wordmark into a favicon. "
345
+ + "Inspect at 16 and 32 pixels in light and dark browser tabs; a deliberate contrasting badge can help a favicon "
346
+ + "remain recognizable independently of the header theme. Verify the published icon loads, not just the setting.",
337
347
  logoContrastContract:
338
348
  "Evaluate lightLogoId against the rendered light-scheme header and darkLogoId against the rendered dark-scheme "
339
349
  + "header at desktop and compact/mobile widths. Transparent pixels provide no contrast: inspect the visible mark, "
@@ -602,6 +612,7 @@ export function formatPresentationReference(reference) {
602
612
  + reference.readOnlyProjections.map((field) => " " + field.file + " :: " + field.path).join("\n")
603
613
  + "\n\nImages: " + reference.imageReferences + "\nLogos: " + reference.logoContract
604
614
  + "\nLogo contrast: " + reference.logoContrastContract
615
+ + "\nFavicon: " + reference.faviconContract
605
616
  + "\nHeader width: " + reference.headerWidthContract
606
617
  + "\nMobile menu and menu font: " + reference.menuContract
607
618
  + "\nFooter: " + reference.footerBoundary
@@ -51,7 +51,7 @@ import {
51
51
 
52
52
  export { getAppearanceReference, getFooterReference, getThemeReference };
53
53
 
54
- export const REFERENCE_VERSION = 23;
54
+ export const REFERENCE_VERSION = 24;
55
55
  export const PAGE_TYPES = Object.freeze(["free-form"]);
56
56
 
57
57
  function deepFreeze(value) {
@@ -134,7 +134,7 @@ const DESIGN_BLUEPRINTS = deepFreeze([
134
134
  purpose: "Make a single product story legible before offering supporting choices.",
135
135
  section: { entrance: "scale", entranceStagger: "none" },
136
136
  components: [
137
- { type: "image-banner", properties: { ratio: "3/1", heightMode: "viewport", contentPosition: "bottom-start", scrim: "bottom", imageMotion: "slow-zoom" } },
137
+ { type: "image-banner", properties: { ratio: "3/1", heightMode: "viewport", contentPosition: "bottom-start", scrim: "bottom", imageMotion: "parallax" } },
138
138
  { type: "cta", properties: { variant: "primary", imagePosition: "right", borderWidth: 0 } },
139
139
  ],
140
140
  },
@@ -148,7 +148,7 @@ const DESIGN_BLUEPRINTS = deepFreeze([
148
148
  ],
149
149
  },
150
150
  ],
151
- motionCeiling: "Use one slow image movement or short hover response per view; never conceal names, price context, or actions behind motion.",
151
+ motionCeiling: "Prefer one optional image parallax per view (imageMotion: parallax-subtle or parallax). Published pages use CSS view timelines where supported and a platform-owned scroll-linked JavaScript fallback otherwise; no custom code is needed. Movement stops when scrolling stops, pauses offscreen, and is disabled for reduced motion. Without JavaScript or native timelines the image stays static. Cinematic zoom is an explicit ambient-loop alternative. Never conceal names, price context, or actions behind motion.",
152
152
  mediaBrief: "Commission or license product, material, and in-use photography; document crop-safe banner areas and supply alt text for informative images.",
153
153
  integrationBoundaries: "Use outbound links for retail, commerce, search, or booking providers. This blueprint includes none of those systems and no custom code.",
154
154
  accessibilityCautions: ["Preserve text contrast over imagery with a tested scrim and do not make text depend on hover.", "Respect reduced-motion preferences and give every campaign action a visible text label."],
@@ -511,6 +511,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
511
511
  details: Object.freeze([
512
512
  "Paths are relative to the configured workspace root, not the shell's current directory.",
513
513
  "PNG, JPEG, GIF, and WebP are accepted; retina names such as logo@2x.png and logo@3x.png are supported.",
514
+ "For header logos prefer genuine transparent PNG/WebP, check contrast in both themes, and include a simplified square favicon. See help appearance for branding guidance.",
514
515
  "Each result item includes media: { imageId, src, urls, width, height, alt }.",
515
516
  "The same src/urls delivery fields are saved in .taproot-site-media.json for page and component authoring.",
516
517
  ]),