@legionworks/facet 1.10.1 → 1.10.2

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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  FacetRenderError,
3
3
  decodeArtifactBytes
4
- } from "./markdown-d1wfbs70.js";
4
+ } from "./markdown-n9xcxy80.js";
5
5
 
6
6
  // src/gallery-web/frame/renderers/svg.ts
7
7
  var STRIPPED_TAGS = new Set([
@@ -5,7 +5,7 @@ import {
5
5
  dispatchRender,
6
6
  isRenderer,
7
7
  isTsxExecutionMode
8
- } from "./markdown-d1wfbs70.js";
8
+ } from "./markdown-n9xcxy80.js";
9
9
 
10
10
  // src/gallery-web/theme.ts
11
11
  function isResolvedGalleryTheme(value) {
@@ -10,11 +10,11 @@ import {
10
10
  } from "./markdown-h7pzv34e.js";
11
11
  import {
12
12
  importSanitizedSvgText
13
- } from "./markdown-zazx9h4n.js";
13
+ } from "./markdown-31ee7zgx.js";
14
14
  import {
15
15
  FacetRenderError,
16
16
  decodeArtifactBytes
17
- } from "./markdown-d1wfbs70.js";
17
+ } from "./markdown-n9xcxy80.js";
18
18
  import {
19
19
  dedent
20
20
  } from "./markdown-qx3cngz2.js";
@@ -51,6 +51,15 @@ function isHtmlEventHandlerAttribute(name) {
51
51
  function isHtmlInlineStyleAttribute(name) {
52
52
  return name.toLowerCase() === "style";
53
53
  }
54
+ function isExternalHttpsImageSource(src) {
55
+ if (typeof src !== "string")
56
+ return false;
57
+ try {
58
+ return new URL(src).protocol === "https:";
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
54
63
  function isAllowedHtmlUrl(elementName, attributeName, value) {
55
64
  const element = htmlUrlBearingElement(elementName);
56
65
  if (element === null || !isHtmlUrlBearingAttribute(element, attributeName))
@@ -177,7 +186,8 @@ function countPageShim() {
177
186
  const graphRoots = roots.filter((root) => root.getAttribute(RENDERER_GRAPH_ATTRIBUTE) === "true");
178
187
  const allMarkedCandidates = safeSelectorElements(MARKED_ROOT_SELECTOR);
179
188
  const markedSet = new Set(allMarkedCandidates);
180
- const htmlRoots = allMarkedCandidates.filter((root) => root.nodeName.toLowerCase() !== "svg" && !hasMarkedRootAncestor(root, markedSet));
189
+ const contentRoots = allMarkedCandidates.filter((root) => root.nodeName.toLowerCase() !== "svg" && !hasMarkedRootAncestor(root, markedSet));
190
+ const htmlRoots = contentRoots.filter((root) => root.getAttribute("data-facet-renderer-kind") !== "markdown");
181
191
  const html = htmlRoots.length === 0 ? undefined : {
182
192
  rendererRootCount: htmlRoots.length,
183
193
  headingCount: 0,
@@ -193,24 +203,19 @@ function countPageShim() {
193
203
  html.listCount += safeSelectorElementsWithin(root, HTML_STRUCTURAL_GROUPS.lists.join(",")).length;
194
204
  const images = safeSelectorElementsWithin(root, HTML_STRUCTURAL_GROUPS.images.join(","));
195
205
  html.imageCount += images.length;
196
- html.externalImageCount += images.filter((image) => {
197
- try {
198
- return new URL(image.getAttribute("src") ?? "").protocol === "https:";
199
- } catch {
200
- return false;
201
- }
202
- }).length;
206
+ html.externalImageCount += images.filter((image) => isExternalHttpsImageSource(image.getAttribute("src"))).length;
203
207
  html.canvasCount += safeSelectorElementsWithin(root, HTML_STRUCTURAL_GROUPS.canvases.join(",")).length;
204
208
  }
205
209
  const mermaidNodeCount = graphRoots.reduce((count, root) => count + safeSelectorElementsWithin(root, "g.node").length, 0);
206
210
  const opaqueRegionCount = safeSelectorElements("*").filter((element) => element.nodeName.toLowerCase() === "canvas").length;
211
+ const externalImageCount = contentRoots.reduce((count, root) => count + safeSelectorElementsWithin(root, HTML_STRUCTURAL_GROUPS.images.join(",")).filter((image) => isExternalHttpsImageSource(image.getAttribute("src"))).length, 0);
207
212
  return {
208
213
  rendererRootSvgCount: roots.length,
209
214
  graphCount: graphRoots.length,
210
215
  mermaidNodeCount,
211
216
  visibleSvgCount: roots.filter(nonDegenerateViewBox).length,
212
217
  opaqueRegionCount,
213
- externalImageCount: html?.externalImageCount ?? 0,
218
+ externalImageCount,
214
219
  errorCount: safeSelectorElements("[data-facet-error]").length,
215
220
  ...html === undefined ? {} : { html }
216
221
  };
@@ -6,7 +6,7 @@ import {
6
6
  isHtmlInlineStyleAttribute,
7
7
  isHtmlUrlAttributeName,
8
8
  isHtmlUrlBearingAttribute
9
- } from "./markdown-d1wfbs70.js";
9
+ } from "./markdown-n9xcxy80.js";
10
10
 
11
11
  // src/gallery-web/frame/renderers/html.ts
12
12
  function shouldKeepAttribute(elementName, name, value) {
@@ -2,12 +2,12 @@ import {
2
2
  mermaidInitializeConfig,
3
3
  renderMermaidDocument,
4
4
  renderMermaidInto
5
- } from "./markdown-z1wc0v0d.js";
5
+ } from "./markdown-mksxbxx8.js";
6
6
  import"./markdown-6zz8efx2.js";
7
7
  import"./markdown-rm2yxfjj.js";
8
8
  import"./markdown-h7pzv34e.js";
9
- import"./markdown-zazx9h4n.js";
10
- import"./markdown-d1wfbs70.js";
9
+ import"./markdown-31ee7zgx.js";
10
+ import"./markdown-n9xcxy80.js";
11
11
  import"./markdown-s1jatrnk.js";
12
12
  import"./markdown-m7frq8x8.js";
13
13
  import"./markdown-tvdpppw8.js";
@@ -1,14 +1,14 @@
1
1
  import {
2
2
  importSanitizedSvgText
3
- } from "../chunks/markdown-zazx9h4n.js";
3
+ } from "../chunks/markdown-31ee7zgx.js";
4
4
  import {
5
5
  installGalleryFrameApi
6
- } from "../chunks/markdown-g3asgaw3.js";
6
+ } from "../chunks/markdown-7scxp44j.js";
7
7
  import {
8
8
  FacetRenderError,
9
9
  createRendererRegistry,
10
10
  decodeArtifactBytes
11
- } from "../chunks/markdown-d1wfbs70.js";
11
+ } from "../chunks/markdown-n9xcxy80.js";
12
12
  import {
13
13
  Accent_default,
14
14
  Dark2_default,
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  renderHtml
3
- } from "../chunks/markdown-rd8w0ng9.js";
3
+ } from "../chunks/markdown-rkt612xp.js";
4
4
  import {
5
5
  installGalleryFrameApi
6
- } from "../chunks/markdown-g3asgaw3.js";
6
+ } from "../chunks/markdown-7scxp44j.js";
7
7
  import {
8
8
  createRendererRegistry
9
- } from "../chunks/markdown-d1wfbs70.js";
9
+ } from "../chunks/markdown-n9xcxy80.js";
10
10
  import"../chunks/markdown-809k7q41.js";
11
11
 
12
12
  // src/gallery-web/frame/entries/html.ts
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  installGalleryFrameApi
3
- } from "../chunks/markdown-g3asgaw3.js";
3
+ } from "../chunks/markdown-7scxp44j.js";
4
4
  import {
5
5
  createRendererRegistry,
6
6
  decodeArtifactBytes
7
- } from "../chunks/markdown-d1wfbs70.js";
7
+ } from "../chunks/markdown-n9xcxy80.js";
8
8
  import {
9
9
  __require
10
10
  } from "../chunks/markdown-809k7q41.js";
@@ -1380,7 +1380,7 @@ async function renderMarkdown(ctx, bytes) {
1380
1380
  const template = document.createElement("template");
1381
1381
  template.innerHTML = html;
1382
1382
  const mermaidBlocks = Array.from(template.content.querySelectorAll("pre > code.language-mermaid"));
1383
- const renderMermaidInto = mermaidBlocks.length === 0 ? null : (await import("../chunks/mermaid-de7p4v0x.js")).renderMermaidInto;
1383
+ const renderMermaidInto = mermaidBlocks.length === 0 ? null : (await import("../chunks/mermaid-b8fxhsgg.js")).renderMermaidInto;
1384
1384
  for (const code of mermaidBlocks) {
1385
1385
  const source = code.textContent ?? "";
1386
1386
  const pre = code.parentElement;
@@ -1395,7 +1395,11 @@ async function renderMarkdown(ctx, bytes) {
1395
1395
  else
1396
1396
  pre.remove();
1397
1397
  }
1398
- ctx.container.appendChild(template.content);
1398
+ const root = document.createElement("div");
1399
+ root.setAttribute("data-facet-renderer-root", "true");
1400
+ root.setAttribute("data-facet-renderer-kind", "markdown");
1401
+ root.appendChild(template.content);
1402
+ ctx.container.appendChild(root);
1399
1403
  }
1400
1404
 
1401
1405
  // src/gallery-web/frame/entries/markdown.ts
@@ -1,16 +1,16 @@
1
1
  import {
2
2
  renderMermaidDocument
3
- } from "../chunks/markdown-z1wc0v0d.js";
3
+ } from "../chunks/markdown-mksxbxx8.js";
4
4
  import"../chunks/markdown-6zz8efx2.js";
5
5
  import"../chunks/markdown-rm2yxfjj.js";
6
6
  import"../chunks/markdown-h7pzv34e.js";
7
- import"../chunks/markdown-zazx9h4n.js";
7
+ import"../chunks/markdown-31ee7zgx.js";
8
8
  import {
9
9
  installGalleryFrameApi
10
- } from "../chunks/markdown-g3asgaw3.js";
10
+ } from "../chunks/markdown-7scxp44j.js";
11
11
  import {
12
12
  createRendererRegistry
13
- } from "../chunks/markdown-d1wfbs70.js";
13
+ } from "../chunks/markdown-n9xcxy80.js";
14
14
  import"../chunks/markdown-s1jatrnk.js";
15
15
  import"../chunks/markdown-m7frq8x8.js";
16
16
  import"../chunks/markdown-tvdpppw8.js";
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  renderSvgDocument
3
- } from "../chunks/markdown-zazx9h4n.js";
3
+ } from "../chunks/markdown-31ee7zgx.js";
4
4
  import {
5
5
  installGalleryFrameApi
6
- } from "../chunks/markdown-g3asgaw3.js";
6
+ } from "../chunks/markdown-7scxp44j.js";
7
7
  import {
8
8
  createRendererRegistry
9
- } from "../chunks/markdown-d1wfbs70.js";
9
+ } from "../chunks/markdown-n9xcxy80.js";
10
10
  import"../chunks/markdown-809k7q41.js";
11
11
 
12
12
  // src/gallery-web/frame/entries/svg.ts
@@ -1,14 +1,14 @@
1
1
  import {
2
2
  renderHtml
3
- } from "../chunks/markdown-rd8w0ng9.js";
3
+ } from "../chunks/markdown-rkt612xp.js";
4
4
  import {
5
5
  installGalleryFrameApi
6
- } from "../chunks/markdown-g3asgaw3.js";
6
+ } from "../chunks/markdown-7scxp44j.js";
7
7
  import {
8
8
  RENDER_ERROR_ATTRIBUTE,
9
9
  appendRenderError,
10
10
  createRendererRegistry
11
- } from "../chunks/markdown-d1wfbs70.js";
11
+ } from "../chunks/markdown-n9xcxy80.js";
12
12
  import"../chunks/markdown-809k7q41.js";
13
13
 
14
14
  // src/gallery-web/frame/renderers/tsx.ts
@@ -14,7 +14,7 @@ Every other layer is bound to it through `VerdictSchema.status`.
14
14
  | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
15
15
  | `ok` | Counts agree across protocol + shim + isolated worlds, layout observable, no discriminative errors. |
16
16
  | `error` | Counts disagree with the lexical expectation OR the protocol surfaced a discriminative error. |
17
- | `partial:layout_unverified` | Counts agree but the layout pass is unverified (no SVG rendered with a non-degenerate viewBox). MUST carry a screenshot path on the wire. |
17
+ | `partial:layout_unverified` | Counts agree but the layout pass is unverified (no SVG rendered with a non-degenerate viewBox). Markdown with zero expected renderer roots and opaque regions instead reaches `ok` when protocol and isolated observations agree that its renderer root is non-empty. Empty Markdown remains unverified. MUST carry a screenshot path on the wire. |
18
18
  | `partial:opaque_content` | An opaque DOM region was observed, so structural contents were not verified. MUST carry a screenshot path, or a typed `screenshotError` marker when capture fails transiently. |
19
19
  | `partial:external_resources` | The artifact references external HTTPS images the no-egress verifier could not observe. MUST carry a screenshot path, or a typed `screenshotError` marker when capture fails transiently. |
20
20
  | `partial:unstable` | TSX interactive mode: the structure observed at the render barrier differed from the structure observed after a bounded stability window. Deliberately NOT `tampered` — a legitimately animated or async-loading component also changes structure between observations, and branding that a forgery would manufacture the false-verdict class this project has spent three arcs eliminating. `tampered` stays reserved for channel divergence. MUST carry a screenshot path, or a typed `screenshotError` marker when capture fails transiently. |
@@ -48,7 +48,7 @@ ordering, top to bottom:
48
48
  7. `error` (declared-opaque, observed-zero) — the artifact declared opaque regions and none were seen.
49
49
  8. `partial:opaque_content` — single-snapshot claim: structure has opaque regions.
50
50
  9. `partial:external_resources` — single-snapshot claim: structure has external HTTP references.
51
- 10. `partial:layout_unverified` — single-snapshot claim: visible SVG with zeroed viewBoxes.
51
+ 10. `partial:layout_unverified` — single-snapshot claim: visible SVG with zeroed viewBoxes; it also applies to empty Markdown, while diagram-free Markdown with matching non-empty observations has no layout to verify.
52
52
  11. `ok`.
53
53
 
54
54
  The rule generalizing rows 4 and 6: a claim that depends on ONE observation
@@ -77,6 +77,14 @@ WebP; legacy PNG evidence remains readable and exportable. Before static
77
77
  capture it emulates `prefers-reduced-motion: reduce` and awaits
78
78
  `document.fonts.ready`; these pre-flights keep repeated captures byte-identical.
79
79
 
80
+ If resizing the capture viewport makes the artifact grow, Tier 1 restores the
81
+ 1280×800 evidence viewport and captures the artifact's scroll area in tiles.
82
+ The tiles form one still WebP image, including for interactive or animated
83
+ artifacts. This avoids changing viewport-sized elements such as `100vh` while
84
+ retaining content below and to the right. Fixed and sticky elements repeat in
85
+ each tile. Content that grows while the tiles are captured gets
86
+ `screenshot_unavailable` instead of an incomplete image.
87
+
80
88
  Tier 1 uses the same artifact-type layout rules as the gallery frame: Mermaid,
81
89
  SVG, and chart roots are safely centered on both axes, with oversized content
82
90
  remaining reachable by scrolling; Markdown is top-aligned in a horizontally
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@legionworks/facet",
3
- "version": "1.10.1",
3
+ "version": "1.10.2",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "artifacts",
@@ -77,5 +77,9 @@ export async function renderMarkdown(ctx: RenderContext, bytes: Uint8Array): Pro
77
77
  if (region.firstElementChild !== null) pre.replaceWith(region);
78
78
  else pre.remove();
79
79
  }
80
- ctx.container.appendChild(template.content);
80
+ const root = document.createElement("div");
81
+ root.setAttribute("data-facet-renderer-root", "true");
82
+ root.setAttribute("data-facet-renderer-kind", "markdown");
83
+ root.appendChild(template.content);
84
+ ctx.container.appendChild(root);
81
85
  }
@@ -1,6 +1,6 @@
1
1
  import type { ArtifactType } from "../../../shared/contracts/artifact-types";
2
2
  import { isRenderer, type Renderer as RendererKind } from "../../../shared/contracts/renderers";
3
- import { HTML_STRUCTURAL_GROUPS } from "../../../shared/html/policy";
3
+ import { HTML_STRUCTURAL_GROUPS, isExternalHttpsImageSource } from "../../../shared/html/policy";
4
4
  import type { VerdictObserved } from "../../../shared/contracts/validation";
5
5
  import type { ObservedCountKey } from "../../../shared/contracts/observed-counts";
6
6
  import { isTsxExecutionMode, type TsxExecutionMode } from "../../../shared/tsx/execution";
@@ -140,9 +140,12 @@ export function countPageShim(): PageShimCounts {
140
140
  const graphRoots = roots.filter((root) => root.getAttribute(RENDERER_GRAPH_ATTRIBUTE) === "true");
141
141
  const allMarkedCandidates = safeSelectorElements(MARKED_ROOT_SELECTOR);
142
142
  const markedSet = new Set(allMarkedCandidates);
143
- const htmlRoots = allMarkedCandidates.filter(
143
+ const contentRoots = allMarkedCandidates.filter(
144
144
  (root) => root.nodeName.toLowerCase() !== "svg" && !hasMarkedRootAncestor(root, markedSet),
145
145
  );
146
+ const htmlRoots = contentRoots.filter(
147
+ (root) => root.getAttribute("data-facet-renderer-kind") !== "markdown",
148
+ );
146
149
  const html =
147
150
  htmlRoots.length === 0
148
151
  ? undefined
@@ -170,13 +173,9 @@ export function countPageShim(): PageShimCounts {
170
173
  ).length;
171
174
  const images = safeSelectorElementsWithin(root, HTML_STRUCTURAL_GROUPS.images.join(","));
172
175
  html!.imageCount += images.length;
173
- html!.externalImageCount += images.filter((image) => {
174
- try {
175
- return new URL(image.getAttribute("src") ?? "").protocol === "https:";
176
- } catch {
177
- return false;
178
- }
179
- }).length;
176
+ html!.externalImageCount += images.filter((image) =>
177
+ isExternalHttpsImageSource(image.getAttribute("src")),
178
+ ).length;
180
179
  html!.canvasCount += safeSelectorElementsWithin(
181
180
  root,
182
181
  HTML_STRUCTURAL_GROUPS.canvases.join(","),
@@ -192,13 +191,21 @@ export function countPageShim(): PageShimCounts {
192
191
  const opaqueRegionCount = safeSelectorElements("*").filter(
193
192
  (element) => element.nodeName.toLowerCase() === "canvas",
194
193
  ).length;
194
+ const externalImageCount = contentRoots.reduce(
195
+ (count, root) =>
196
+ count +
197
+ safeSelectorElementsWithin(root, HTML_STRUCTURAL_GROUPS.images.join(",")).filter((image) =>
198
+ isExternalHttpsImageSource(image.getAttribute("src")),
199
+ ).length,
200
+ 0,
201
+ );
195
202
  return {
196
203
  rendererRootSvgCount: roots.length,
197
204
  graphCount: graphRoots.length,
198
205
  mermaidNodeCount,
199
206
  visibleSvgCount: roots.filter(nonDegenerateViewBox).length,
200
207
  opaqueRegionCount,
201
- externalImageCount: html?.externalImageCount ?? 0,
208
+ externalImageCount,
202
209
  errorCount: safeSelectorElements("[data-facet-error]").length,
203
210
  ...(html === undefined ? {} : { html }),
204
211
  };
@@ -61,6 +61,15 @@ export function isHtmlInlineStyleAttribute(name: string): boolean {
61
61
  return name.toLowerCase() === "style";
62
62
  }
63
63
 
64
+ export function isExternalHttpsImageSource(src: string | null | undefined): boolean {
65
+ if (typeof src !== "string") return false;
66
+ try {
67
+ return new URL(src).protocol === "https:";
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
64
73
  export function isAllowedHtmlUrl(
65
74
  elementName: string,
66
75
  attributeName: string,
@@ -10,6 +10,7 @@ import {
10
10
  isHtmlInlineStyleAttribute,
11
11
  isHtmlUrlAttributeName,
12
12
  isHtmlUrlBearingAttribute,
13
+ isExternalHttpsImageSource,
13
14
  } from "../../shared/html/policy";
14
15
 
15
16
  export interface HtmlParseOk {
@@ -131,14 +132,6 @@ function templateContent(node: unknown): unknown | null {
131
132
  return isRecord(node) && isRecord(node.content) ? node.content : null;
132
133
  }
133
134
 
134
- function isHttpsUrl(value: string): boolean {
135
- try {
136
- return new URL(value.trim()).protocol.toLowerCase() === "https:";
137
- } catch {
138
- return false;
139
- }
140
- }
141
-
142
135
  function isWhitespace(character: string): boolean {
143
136
  return (
144
137
  character === " " ||
@@ -190,7 +183,11 @@ function validateUrl(
190
183
  );
191
184
  continue;
192
185
  }
193
- if (countStructure && (tagName === "img" || tagName === "source") && isHttpsUrl(candidate)) {
186
+ if (
187
+ countStructure &&
188
+ (tagName === "img" || tagName === "source") &&
189
+ isExternalHttpsImageSource(candidate)
190
+ ) {
194
191
  counts.externalImageCount += 1;
195
192
  }
196
193
  }
@@ -29,6 +29,7 @@
29
29
  import { Lexer, type Token, type Tokens } from "marked";
30
30
 
31
31
  import type { DiscriminativeError, VerdictObserved } from "../../shared/contracts/validation";
32
+ import { isExternalHttpsImageSource } from "../../shared/html/policy";
32
33
  import { countMermaidNodeDeclarations } from "../../shared/util/mermaid-nodes";
33
34
  import { parseMermaidText } from "./mermaid";
34
35
 
@@ -58,17 +59,6 @@ interface MarkdownCounts {
58
59
  mermaidBodies: string[];
59
60
  }
60
61
 
61
- function isExternalHttpsUrl(value: string): boolean {
62
- // The URL constructor canonicalizes the protocol; malformed URLs
63
- // (e.g. `https://[`) return false here rather than throwing, so the
64
- // token walk surfaces a typed zero instead of crashing Tier 0.
65
- try {
66
- return new URL(value.trim()).protocol.toLowerCase() === "https:";
67
- } catch {
68
- return false;
69
- }
70
- }
71
-
72
62
  /**
73
63
  * Walk the marked token tree and tally the surfaces that matter for
74
64
  * the Tier 0 verdict: fenced blocks, mermaid blocks, raw HTML blocks,
@@ -122,7 +112,7 @@ function walkTokens(tokens: Token[], counts: MarkdownCounts): void {
122
112
  if (/\b(?:href|src)\s*=\s*["']https?:/i.test(raw)) counts.hasExternalRef = true;
123
113
  } else if (token.type === "image") {
124
114
  const image = token as Tokens.Image;
125
- if (isExternalHttpsUrl(image.href ?? "")) counts.externalImageCount += 1;
115
+ if (isExternalHttpsImageSource(image.href)) counts.externalImageCount += 1;
126
116
  }
127
117
  // Recurse every container Marked actually emits with children.
128
118
  const recurseTokens = (sub: Token[] | undefined): void => {
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import type { ProtocolObservation } from "../../shared/contracts/validation";
22
- import { HTML_STRUCTURAL_GROUPS } from "../../shared/html/policy";
22
+ import { HTML_STRUCTURAL_GROUPS, isExternalHttpsImageSource } from "../../shared/html/policy";
23
23
 
24
24
  import type { VerifierCdpSession } from "./browser-process";
25
25
 
@@ -32,27 +32,37 @@ export async function probeIsolatedCounts(
32
32
  const selectors = Object.fromEntries(
33
33
  Object.entries(HTML_STRUCTURAL_GROUPS).map(([group, names]) => [group, names.join(",")]),
34
34
  );
35
+ const externalHttpsImageSource = isExternalHttpsImageSource.toString();
35
36
  const result = (await session.send("Runtime.evaluate", {
36
37
  contextId: executionContextId,
37
38
  returnByValue: true,
38
39
  expression: [
39
40
  "(function(){",
40
41
  " var candidates = Array.prototype.slice.call(document.querySelectorAll('[data-facet-renderer-root=\"true\"]'));",
41
- " var candidateSet = new Set(candidates);",
42
- " var roots = candidates.filter(function(root){",
43
- " for (var parent = root.parentElement; parent; parent = parent.parentElement) {",
44
- " if (candidateSet.has(parent)) return false;",
45
- " }",
42
+ " var svgCandidates = candidates.filter(function(root){ return String(root.nodeName).toLowerCase() === 'svg'; });",
43
+ " var svgCandidateSet = new Set(svgCandidates);",
44
+ " var svgRoots = svgCandidates.filter(function(root){",
45
+ " for (var parent = root.parentElement; parent; parent = parent.parentElement) if (svgCandidateSet.has(parent)) return false;",
46
46
  " return true;",
47
47
  " });",
48
- " var svgRoots = roots.filter(function(root){ return String(root.nodeName).toLowerCase() === 'svg'; });",
48
+ " var contentCandidates = candidates.filter(function(root){ return String(root.nodeName).toLowerCase() !== 'svg'; });",
49
+ " var contentCandidateSet = new Set(contentCandidates);",
50
+ " var contentRoots = contentCandidates.filter(function(root){",
51
+ " for (var parent = root.parentElement; parent; parent = parent.parentElement) if (contentCandidateSet.has(parent)) return false;",
52
+ " return true;",
53
+ " });",
54
+ " var htmlRoots = contentRoots.filter(function(root){ return root.getAttribute('data-facet-renderer-kind') !== 'markdown'; });",
49
55
  " var graphRoots = svgRoots.filter(function(root){ return root.getAttribute('data-facet-renderer-graph') === 'true'; });",
50
- " var htmlRoots = roots.filter(function(root){ return String(root.nodeName).toLowerCase() !== 'svg'; });",
51
56
  ` var observeContent = ${JSON.stringify(observeContent)};`,
52
- " var emptyRendererRoot = observeContent && htmlRoots.length === 1 ? Array.prototype.every.call(htmlRoots[0].childNodes, function(node){ return node.nodeType !== 1 && (node.nodeType !== 3 || !node.textContent.trim()); }) : undefined;",
57
+ ` var isExternalHttpsImageSource = (${externalHttpsImageSource});`,
58
+ " var emptyRendererRoot = observeContent && contentRoots.length === 1 ? Array.prototype.every.call(contentRoots[0].childNodes, function(node){ return node.nodeType !== 1 && (node.nodeType !== 3 || !node.textContent.trim()); }) : undefined;",
53
59
  ` var selectors = ${JSON.stringify(selectors)};`,
54
60
  " var html = htmlRoots.length === 0 ? null : {rendererRootCount:htmlRoots.length,headingCount:0,tableCount:0,listCount:0,imageCount:0,canvasCount:0,externalImageCount:0};",
55
61
  " var externalImageCount = 0;",
62
+ " for (var c = 0; c < contentRoots.length; c++) {",
63
+ " var contentImages = Array.prototype.slice.call(contentRoots[c].querySelectorAll(selectors.images));",
64
+ " externalImageCount += contentImages.filter(function(image){ return isExternalHttpsImageSource(image.getAttribute('src')); }).length;",
65
+ " }",
56
66
  " for (var h = 0; h < htmlRoots.length; h++) {",
57
67
  " var htmlRoot = htmlRoots[h];",
58
68
  " html.headingCount += htmlRoot.querySelectorAll(selectors.headings).length;",
@@ -60,9 +70,8 @@ export async function probeIsolatedCounts(
60
70
  " html.listCount += htmlRoot.querySelectorAll(selectors.lists).length;",
61
71
  " var images = Array.prototype.slice.call(htmlRoot.querySelectorAll(selectors.images));",
62
72
  " html.imageCount += images.length;",
63
- " var rootExternal = images.filter(function(image){ try { return new URL(image.getAttribute('src') || '').protocol === 'https:'; } catch (_) { return false; } }).length;",
73
+ " var rootExternal = images.filter(function(image){ return isExternalHttpsImageSource(image.getAttribute('src')); }).length;",
64
74
  " html.externalImageCount += rootExternal;",
65
- " externalImageCount += rootExternal;",
66
75
  " html.canvasCount += htmlRoot.querySelectorAll(selectors.canvases).length;",
67
76
  " }",
68
77
  " var nodeCount = 0;",
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Resource caps for the Tier 1 verifier.
3
3
  *
4
- * Each cap is named and exposed so the runner, the harness builder,
5
- * and the penetration test agree on a single value. A failing cap
6
- * produces a typed `FacetError` with the matching `tier1_*` code.
4
+ * Named limits keep the runner, harness builder, and penetration test
5
+ * aligned. Enforced caps produce typed `FacetError`s with matching
6
+ * `tier1_*` codes; TIER1_TIMEOUT_MS remains an intended total-budget
7
+ * target until outer enforcement lands.
7
8
  */
8
9
 
9
10
  /**
10
- * Wall-clock budget for one Tier 1 verifier invocation. The harness
11
+ * Intended total budget target for one Tier 1 verifier invocation; it
12
+ * is not enforced yet. The harness
11
13
  * bundles the REAL renderers (mermaid + marked + vega, ~8 MB inline);
12
14
  * parse + first render of a 40-node fixture needs real headroom.
13
15
  */
@@ -36,10 +38,13 @@ export const TIER1_USER_DATA_DIR_MODE = 0o700;
36
38
  */
37
39
  export const TIER1_RENDER_BARRIER_MS = 30_000;
38
40
 
41
+ /** Keep tiled evidence within the intended total-budget target after render and stability windows. */
42
+ export const TIER1_TILED_CAPTURE_DEADLINE_MS = 15_000;
43
+
39
44
  /**
40
45
  * Time between the first interactive TSX observation and its bounded
41
- * stability re-check. It stays below the headroom after the render barrier so
42
- * a typed `partial:unstable` verdict remains observable before Tier 1 times out.
46
+ * stability re-check. It stays below the intended total-budget target after
47
+ * the render barrier; that outer target is not enforced yet.
43
48
  */
44
49
  export const TSX_STABILITY_WINDOW_MS = 1_000;
45
50
 
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import type { HtmlStructureCounts, ProtocolObservation } from "../../shared/contracts/validation";
23
- import { HTML_STRUCTURAL_GROUPS } from "../../shared/html/policy";
23
+ import { HTML_STRUCTURAL_GROUPS, isExternalHttpsImageSource } from "../../shared/html/policy";
24
24
 
25
25
  import type { VerifierCdpSession } from "./browser-process";
26
26
  import type { ResolvedChildFrame } from "./frame-target";
@@ -49,7 +49,7 @@ export interface SnapshotDocument {
49
49
  function snapshotRootEmpty(snapshot: SnapshotResponse, documentIndex: number): boolean | undefined {
50
50
  const document = snapshot.documents[documentIndex];
51
51
  if (document === undefined) return undefined;
52
- const roots = htmlRootIndexes(snapshot, documentIndex);
52
+ const roots = contentRootIndexes(snapshot, documentIndex);
53
53
  if (roots.length !== 1) return undefined;
54
54
  const root = roots[0];
55
55
  if (root === undefined) return undefined;
@@ -166,6 +166,18 @@ function hasAncestorIn(
166
166
  }
167
167
 
168
168
  function htmlRootIndexes(snapshot: SnapshotResponse, documentIndex: number): number[] {
169
+ return contentRootIndexes(snapshot, documentIndex).filter(
170
+ (nodeIndex) =>
171
+ attributeValue(
172
+ snapshot,
173
+ snapshot.documents[documentIndex]!,
174
+ nodeIndex,
175
+ "data-facet-renderer-kind",
176
+ ) !== "markdown",
177
+ );
178
+ }
179
+
180
+ function contentRootIndexes(snapshot: SnapshotResponse, documentIndex: number): number[] {
169
181
  const document = snapshot.documents[documentIndex];
170
182
  if (document === undefined) return [];
171
183
  const candidates = new Set<number>();
@@ -181,6 +193,27 @@ function htmlRootIndexes(snapshot: SnapshotResponse, documentIndex: number): num
181
193
  });
182
194
  }
183
195
 
196
+ function countSnapshotExternalImages(snapshot: SnapshotResponse, documentIndex: number): number {
197
+ const document = snapshot.documents[documentIndex];
198
+ if (document === undefined) return 0;
199
+ const roots = new Set(contentRootIndexes(snapshot, documentIndex));
200
+ let count = 0;
201
+ for (let nodeIndex = 0; nodeIndex < document.nodes.nodeName.length; nodeIndex += 1) {
202
+ if (!isDescendantOf(document, nodeIndex, roots)) continue;
203
+ const name = readString(
204
+ snapshot.strings,
205
+ document.nodes.nodeName[nodeIndex] ?? 0,
206
+ ).toLowerCase();
207
+ if (
208
+ (HTML_STRUCTURAL_GROUPS.images as readonly string[]).includes(name) &&
209
+ isExternalHttpsImageSource(attributeValue(snapshot, document, nodeIndex, "src"))
210
+ ) {
211
+ count += 1;
212
+ }
213
+ }
214
+ return count;
215
+ }
216
+
184
217
  function isDescendantOf(
185
218
  document: SnapshotDocument,
186
219
  nodeIndex: number,
@@ -194,15 +227,6 @@ function isDescendantOf(
194
227
  return false;
195
228
  }
196
229
 
197
- function isExternalHttps(value: string | undefined): boolean {
198
- if (value === undefined) return false;
199
- try {
200
- return new URL(value).protocol === "https:";
201
- } catch {
202
- return false;
203
- }
204
- }
205
-
206
230
  export function countSnapshotHtml(
207
231
  snapshot: SnapshotResponse,
208
232
  documentIndex: number,
@@ -233,7 +257,7 @@ export function countSnapshotHtml(
233
257
  if ((HTML_STRUCTURAL_GROUPS.lists as readonly string[]).includes(name)) counts.listCount += 1;
234
258
  if ((HTML_STRUCTURAL_GROUPS.images as readonly string[]).includes(name)) {
235
259
  counts.imageCount += 1;
236
- if (isExternalHttps(attributeValue(snapshot, document, nodeIndex, "src"))) {
260
+ if (isExternalHttpsImageSource(attributeValue(snapshot, document, nodeIndex, "src"))) {
237
261
  counts.externalImageCount += 1;
238
262
  }
239
263
  }
@@ -372,6 +396,7 @@ export async function probeProtocolSnapshot(
372
396
  const discriminativeErrors = collectDiscriminativeErrors(snapshot, documentIndex);
373
397
  const errorCount = discriminativeErrors.length;
374
398
  const htmlCounts = countSnapshotHtml(snapshot, documentIndex);
399
+ const externalImageCount = countSnapshotExternalImages(snapshot, documentIndex);
375
400
  const emptyRendererRoot = observeContent ? snapshotRootEmpty(snapshot, documentIndex) : undefined;
376
401
  return {
377
402
  rendererRootSvgCount: rendererRoots.length,
@@ -379,7 +404,7 @@ export async function probeProtocolSnapshot(
379
404
  mermaidNodeCount: countGNode(snapshot, documentIndex, graphRoots),
380
405
  visibleSvgCount: viewBoxes.filter(isNonDegenerateViewBox).length,
381
406
  opaqueRegionCount: countByName(snapshot, documentIndex, "canvas"),
382
- externalImageCount: htmlCounts?.externalImageCount ?? 0,
407
+ externalImageCount,
383
408
  ...(htmlCounts === undefined ? {} : { html: htmlCounts }),
384
409
  ...(emptyRendererRoot === undefined ? {} : { emptyRendererRoot }),
385
410
  viewBoxes,
@@ -424,6 +449,7 @@ export async function probeProtocolGetDocument(
424
449
  let gNodeCount = 0;
425
450
  let opaqueRegionCount = 0;
426
451
  let visibleSvgCount = 0;
452
+ let externalImageCount = 0;
427
453
  let html: HtmlStructureCounts | undefined;
428
454
  let emptyRendererRoot: boolean | undefined;
429
455
  const viewBoxes: string[] = [];
@@ -458,19 +484,21 @@ export async function probeProtocolGetDocument(
458
484
  const name = (record.nodeName ?? "").toLowerCase();
459
485
  const markedRoot = findAttr("data-facet-renderer-root") === "true";
460
486
  const rendererRoot = name === "svg" && markedRoot && !withinRendererRoot;
461
- const htmlRoot = name !== "svg" && markedRoot && !withinMarkedRoot;
487
+ const contentRoot = name !== "svg" && markedRoot && !withinMarkedRoot;
488
+ const markdownRoot = contentRoot && findAttr("data-facet-renderer-kind") === "markdown";
489
+ const htmlRoot = contentRoot && !markdownRoot;
462
490
  const graphRoot = rendererRoot && findAttr("data-facet-renderer-graph") === "true";
491
+ if (contentRoot && observeContent) {
492
+ const empty = !(record.children ?? []).some((child) => {
493
+ const direct = child as ProtocolDomNode & { nodeType?: number; nodeValue?: string };
494
+ return (
495
+ direct.nodeType === 1 ||
496
+ (direct.nodeType === 3 && (direct.nodeValue ?? "").trim().length > 0)
497
+ );
498
+ });
499
+ emptyRendererRoot = emptyRendererRoot === undefined ? empty : false;
500
+ }
463
501
  if (htmlRoot) {
464
- if (observeContent) {
465
- const empty = !(record.children ?? []).some((child) => {
466
- const direct = child as ProtocolDomNode & { nodeType?: number; nodeValue?: string };
467
- return (
468
- direct.nodeType === 1 ||
469
- (direct.nodeType === 3 && (direct.nodeValue ?? "").trim().length > 0)
470
- );
471
- });
472
- emptyRendererRoot = emptyRendererRoot === undefined ? empty : false;
473
- }
474
502
  html ??= {
475
503
  rendererRootCount: 0,
476
504
  headingCount: 0,
@@ -498,6 +526,12 @@ export async function probeProtocolGetDocument(
498
526
  // covers the entire child-frame document so smuggled canvases stay visible.
499
527
  // getContext() would create a context and make the observation self-fulfilling.
500
528
  if (name === "canvas") opaqueRegionCount += 1;
529
+ if (
530
+ (withinMarkedRoot || contentRoot) &&
531
+ (HTML_STRUCTURAL_GROUPS.images as readonly string[]).includes(name)
532
+ ) {
533
+ if (isExternalHttpsImageSource(findAttr("src"))) externalImageCount += 1;
534
+ }
501
535
  if (withinHtmlRoot && html !== undefined) {
502
536
  if ((HTML_STRUCTURAL_GROUPS.headings as readonly string[]).includes(name))
503
537
  html.headingCount += 1;
@@ -505,7 +539,7 @@ export async function probeProtocolGetDocument(
505
539
  if ((HTML_STRUCTURAL_GROUPS.lists as readonly string[]).includes(name)) html.listCount += 1;
506
540
  if ((HTML_STRUCTURAL_GROUPS.images as readonly string[]).includes(name)) {
507
541
  html.imageCount += 1;
508
- if (isExternalHttps(findAttr("src"))) html.externalImageCount += 1;
542
+ if (isExternalHttpsImageSource(findAttr("src"))) html.externalImageCount += 1;
509
543
  }
510
544
  if ((HTML_STRUCTURAL_GROUPS.canvases as readonly string[]).includes(name))
511
545
  html.canvasCount += 1;
@@ -545,7 +579,7 @@ export async function probeProtocolGetDocument(
545
579
  mermaidNodeCount: gNodeCount,
546
580
  visibleSvgCount,
547
581
  opaqueRegionCount,
548
- externalImageCount: html?.externalImageCount ?? 0,
582
+ externalImageCount,
549
583
  ...(html === undefined ? {} : { html }),
550
584
  ...(emptyRendererRoot === undefined ? {} : { emptyRendererRoot }),
551
585
  viewBoxes,
@@ -31,6 +31,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
31
31
  import { tmpdir } from "node:os";
32
32
  import { join } from "node:path";
33
33
  import { Buffer } from "node:buffer";
34
+ import sharp from "sharp";
34
35
 
35
36
  import {
36
37
  Tier1ResultSchema,
@@ -73,6 +74,7 @@ import {
73
74
  TIER1_SCREENSHOT_MAX_AXIS_PX,
74
75
  TIER1_SCREENSHOT_MAX_PIXELS,
75
76
  TIER1_SCREENSHOT_WEBP_QUALITY,
77
+ TIER1_TILED_CAPTURE_DEADLINE_MS,
76
78
  TIER1_VIEWPORT_HEIGHT,
77
79
  TIER1_VIEWPORT_WIDTH,
78
80
  TSX_STABILITY_WINDOW_MS,
@@ -327,7 +329,7 @@ async function runTier1Attempt(
327
329
  artifactFrame,
328
330
  isolated.executionContextId,
329
331
  interactiveTsx ? runtimeExceptions!.errorsForFrame(artifactFrame.frameId) : [],
330
- input.artifactType === "tsx",
332
+ input.artifactType === "tsx" || input.artifactType === "markdown",
331
333
  );
332
334
  const secondObservation = interactiveTsx
333
335
  ? await waitForStabilityObservation(
@@ -353,6 +355,7 @@ async function runTier1Attempt(
353
355
  renderComplete: shim.renderComplete,
354
356
  interactive: interactiveTsx,
355
357
  tsx: input.artifactType === "tsx",
358
+ markdown: input.artifactType === "markdown",
356
359
  channelDivergence,
357
360
  structureChanged:
358
361
  interactiveTsx && countsDiffer(firstObservation.protocol, secondObservation.protocol),
@@ -976,6 +979,165 @@ export async function captureEvidenceScreenshot(
976
979
  return captureScreenshotWithRetry(session, { bounds: options.bounds });
977
980
  }
978
981
 
982
+ async function captureTiledScreenshot(
983
+ session: VerifierCdpSession,
984
+ executionContextId: number,
985
+ options: {
986
+ readonly tileTimeoutMs?: number;
987
+ readonly tileAttempts?: number;
988
+ readonly tiledDeadlineMs?: number;
989
+ } = {},
990
+ ): Promise<CapturedEvidenceImage> {
991
+ const deadlineAt =
992
+ performance.now() + (options.tiledDeadlineMs ?? TIER1_TILED_CAPTURE_DEADLINE_MS);
993
+ const assertDeadline = () => {
994
+ if (performance.now() >= deadlineAt) throw new Error("tiled-capture deadline exceeded");
995
+ };
996
+ await configureTier1Viewport(session);
997
+ await session.send("Runtime.evaluate", {
998
+ contextId: executionContextId,
999
+ expression: "new Promise(requestAnimationFrame)",
1000
+ awaitPromise: true,
1001
+ });
1002
+ const source = await measureArtifactCaptureSize(session, executionContextId);
1003
+ const bounds = boundCaptureSize(source);
1004
+ const viewport = (await session.send("Runtime.evaluate", {
1005
+ contextId: executionContextId,
1006
+ returnByValue: true,
1007
+ expression:
1008
+ "(function(){var element=document.getElementById('artifact');" +
1009
+ "if(!element)return null;return {width:element.clientWidth,height:element.clientHeight}})()",
1010
+ })) as { result: { value?: { width: number; height: number } | null } };
1011
+ const visible = viewport.result.value;
1012
+ if (!visible || visible.width <= 0 || visible.height <= 0)
1013
+ throw new Error("artifact tile viewport unavailable");
1014
+ const owner = (await session.send("Runtime.evaluate", {
1015
+ returnByValue: true,
1016
+ expression:
1017
+ "(function(){var frame=document.querySelector('#host-root iframe');" +
1018
+ "if(!frame)return null;var rect=frame.getBoundingClientRect();" +
1019
+ "return {x:rect.left,y:rect.top,width:rect.width,height:rect.height}})()",
1020
+ })) as { result: { value?: { x: number; y: number; width: number; height: number } | null } };
1021
+ const frame = owner.result.value;
1022
+ if (!frame || frame.x < 0 || frame.y < 0)
1023
+ throw new Error("artifact frame capture box unavailable");
1024
+ const tileWidth = Math.min(Math.max(1, Math.floor(visible.width)), Math.floor(frame.width));
1025
+ const tileHeight = Math.min(Math.max(1, Math.floor(visible.height)), Math.floor(frame.height));
1026
+ if (tileWidth <= 0 || tileHeight <= 0) throw new Error("artifact tile bounds unavailable");
1027
+ const columns = Math.ceil(source.width / tileWidth);
1028
+ const rows = Math.ceil(source.height / tileHeight);
1029
+ if (columns * rows > 256) throw new Error("artifact requires too many screenshot tiles");
1030
+ const layers: { input: Buffer; left: number; top: number }[] = [];
1031
+ for (let row = 0; row < rows; row += 1) {
1032
+ for (let column = 0; column < columns; column += 1) {
1033
+ assertDeadline();
1034
+ const x = column * tileWidth;
1035
+ const y = row * tileHeight;
1036
+ const scrolled = (await session.send("Runtime.evaluate", {
1037
+ contextId: executionContextId,
1038
+ returnByValue: true,
1039
+ expression:
1040
+ "(function(){var element=document.getElementById('artifact');" +
1041
+ "element.style.scrollBehavior='auto';element.scrollLeft=" +
1042
+ x +
1043
+ ";element.scrollTop=" +
1044
+ y +
1045
+ ";return new Promise(function(resolve){requestAnimationFrame(function(){resolve({left:element.scrollLeft,top:element.scrollTop})})})})()",
1046
+ awaitPromise: true,
1047
+ })) as { result: { value?: { left: number; top: number } } };
1048
+ const offset = scrolled.result.value;
1049
+ if (!offset || offset.left > x || offset.top > y)
1050
+ throw new Error("artifact tile scroll position unavailable");
1051
+ const tile = await captureScreenshotWithRetry(session, {
1052
+ attempts: options.tileAttempts ?? TIER1_SCREENSHOT_CAPTURE_ATTEMPTS,
1053
+ timeoutMs: options.tileTimeoutMs ?? TIER1_SCREENSHOT_CAPTURE_TIMEOUT_MS,
1054
+ capture: async (cdpSession) => {
1055
+ const shot = (await cdpSession.send("Page.captureScreenshot", {
1056
+ format: "png",
1057
+ captureBeyondViewport: false,
1058
+ clip: {
1059
+ x: Math.floor(frame.x),
1060
+ y: Math.floor(frame.y),
1061
+ width: tileWidth,
1062
+ height: tileHeight,
1063
+ scale: 1,
1064
+ },
1065
+ })) as { data?: string };
1066
+ return shot.data ? { bytes: Buffer.from(shot.data, "base64"), format: "png" } : null;
1067
+ },
1068
+ });
1069
+ if (tile.screenshot === null)
1070
+ throw new Error(`tiled screenshot capture failed: ${tile.screenshotError?.message}`);
1071
+ const outputLeft = Math.floor(x * bounds.scale);
1072
+ const outputTop = Math.floor(y * bounds.scale);
1073
+ const outputWidth =
1074
+ Math.min(bounds.width, Math.floor(Math.min(x + tileWidth, source.width) * bounds.scale)) -
1075
+ outputLeft;
1076
+ const outputHeight =
1077
+ Math.min(
1078
+ bounds.height,
1079
+ Math.floor(Math.min(y + tileHeight, source.height) * bounds.scale),
1080
+ ) - outputTop;
1081
+ if (outputWidth <= 0 || outputHeight <= 0) continue;
1082
+ const input = await sharp(tile.screenshot.bytes)
1083
+ .extract({
1084
+ left: x - offset.left,
1085
+ top: y - offset.top,
1086
+ width: Math.min(tileWidth, source.width - x),
1087
+ height: Math.min(tileHeight, source.height - y),
1088
+ })
1089
+ .resize(outputWidth, outputHeight, { fit: "fill" })
1090
+ .png()
1091
+ .toBuffer();
1092
+ layers.push({ input, left: outputLeft, top: outputTop });
1093
+ }
1094
+ }
1095
+ assertDeadline();
1096
+ const finalSize = await measureArtifactCaptureSize(session, executionContextId);
1097
+ await session.send("Runtime.evaluate", {
1098
+ contextId: executionContextId,
1099
+ expression:
1100
+ "(function(){var element=document.getElementById('artifact');element.scrollLeft=0;element.scrollTop=0})()",
1101
+ });
1102
+ if (finalSize.width > source.width || finalSize.height > source.height)
1103
+ throw new Error("artifact grew during tiled screenshot capture");
1104
+ const bytes = await sharp({
1105
+ create: { width: bounds.width, height: bounds.height, channels: 3, background: "#151823" },
1106
+ })
1107
+ .composite(layers)
1108
+ .webp({ quality: TIER1_SCREENSHOT_WEBP_QUALITY })
1109
+ .toBuffer();
1110
+ if (bytes.byteLength > TIER1_SCREENSHOT_CAP_BYTES)
1111
+ throw new Error("tiled screenshot exceeds encoded-size cap");
1112
+ assertDeadline();
1113
+ return { bytes, format: "webp" };
1114
+ }
1115
+
1116
+ export async function captureTiledEvidenceScreenshot(
1117
+ session: VerifierCdpSession,
1118
+ executionContextId: number,
1119
+ options: {
1120
+ readonly tileTimeoutMs?: number;
1121
+ readonly tileAttempts?: number;
1122
+ readonly tiledDeadlineMs?: number;
1123
+ } = {},
1124
+ ): Promise<{
1125
+ readonly screenshot: CapturedEvidenceImage | null;
1126
+ readonly screenshotError: ScreenshotError | null;
1127
+ }> {
1128
+ try {
1129
+ return {
1130
+ screenshot: await captureTiledScreenshot(session, executionContextId, options),
1131
+ screenshotError: null,
1132
+ };
1133
+ } catch (error) {
1134
+ return {
1135
+ screenshot: null,
1136
+ screenshotError: { code: "screenshot_unavailable", message: screenshotFailureMessage(error) },
1137
+ };
1138
+ }
1139
+ }
1140
+
979
1141
  interface ScreenshotRetryOptions {
980
1142
  readonly attempts?: number;
981
1143
  readonly timeoutMs?: number;
@@ -1092,15 +1254,16 @@ async function captureEvidence(
1092
1254
  returnByValue: true,
1093
1255
  });
1094
1256
  const finalSize = await measureArtifactCaptureSize(target.session, options.executionContextId);
1095
- if (finalSize.width > initialSize.width || finalSize.height > initialSize.height)
1096
- throw new Error("artifact grew after viewport resize");
1097
- const bounds = boundCaptureSize(finalSize);
1098
- const captureOptions = { bounds: { bounds, source: finalSize } };
1099
- const capture = await captureEvidenceScreenshot(target.session, {
1100
- animated,
1101
- bounds: captureOptions.bounds,
1102
- ...(captureScreenshot === undefined ? {} : { captureStatic: captureScreenshot }),
1103
- });
1257
+ // The host iframe clips beyond its own 800px viewport; a larger iframe would
1258
+ // resize 100vh descendants and never converge. Tiles preserve that viewport.
1259
+ const grew = finalSize.width > initialSize.width || finalSize.height > initialSize.height;
1260
+ const capture = grew
1261
+ ? await captureTiledEvidenceScreenshot(target.session, options.executionContextId)
1262
+ : await captureEvidenceScreenshot(target.session, {
1263
+ animated,
1264
+ bounds: { bounds: boundCaptureSize(finalSize), source: finalSize },
1265
+ ...(captureScreenshot === undefined ? {} : { captureStatic: captureScreenshot }),
1266
+ });
1104
1267
  screenshotError = capture.screenshotError;
1105
1268
  const screenshot = capture.screenshot;
1106
1269
  if (screenshot !== null) {
@@ -88,6 +88,8 @@ export interface LifecycleSummary {
88
88
  readonly channelDivergence?: boolean;
89
89
  /** Interactive TSX has no lexical HTML prediction and no trusted outer shim. */
90
90
  readonly interactive?: boolean;
91
+ /** Markdown can have no layout-bearing roots while still containing readable prose. */
92
+ readonly markdown?: boolean;
91
93
  /** Only TSX has a renderer-root emptiness claim from Tier 1. */
92
94
  readonly tsx?: boolean;
93
95
  }
@@ -166,6 +168,13 @@ export function deriveVerdict(
166
168
  if (
167
169
  !lifecycle.interactive &&
168
170
  expected.html === undefined &&
171
+ !(
172
+ lifecycle.markdown === true &&
173
+ expected.rendererRootSvgCount === 0 &&
174
+ expected.opaqueRegionCount === 0 &&
175
+ protocolObservation.emptyRendererRoot === false &&
176
+ isolatedObservation?.emptyRendererRoot === false
177
+ ) &&
169
178
  !layoutObservable(protocolObservation)
170
179
  ) {
171
180
  return "partial:layout_unverified";