@legionworks/facet 1.10.0 → 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.
Files changed (48) hide show
  1. package/dist/gallery/{chunk-48emrxg6.js → chunk-0by8qn4x.js} +15 -0
  2. package/dist/gallery/frame/artifact.css +3 -0
  3. package/dist/gallery/frame/chunks/{markdown-zazx9h4n.js → markdown-31ee7zgx.js} +1 -1
  4. package/dist/gallery/frame/chunks/{markdown-fvfncadf.js → markdown-7scxp44j.js} +9 -1
  5. package/dist/gallery/frame/chunks/{markdown-z1wc0v0d.js → markdown-mksxbxx8.js} +2 -2
  6. package/dist/gallery/frame/chunks/{markdown-d1wfbs70.js → markdown-n9xcxy80.js} +14 -9
  7. package/dist/gallery/frame/chunks/{markdown-rd8w0ng9.js → markdown-rkt612xp.js} +1 -1
  8. package/dist/gallery/frame/chunks/{mermaid-de7p4v0x.js → mermaid-b8fxhsgg.js} +3 -3
  9. package/dist/gallery/frame/frame.css +4 -24
  10. package/dist/gallery/frame/runtime/chart.js +3 -3
  11. package/dist/gallery/frame/runtime/html.js +3 -3
  12. package/dist/gallery/frame/runtime/markdown.js +8 -4
  13. package/dist/gallery/frame/runtime/mermaid.js +4 -4
  14. package/dist/gallery/frame/runtime/svg.js +3 -3
  15. package/dist/gallery/frame/runtime/tsx.js +3 -3
  16. package/dist/gallery/index.html +1 -1
  17. package/docs/reference/cli.md +1 -1
  18. package/docs/reference/html.md +3 -3
  19. package/docs/reference/mcp.md +15 -2
  20. package/docs/reference/tsx.md +3 -1
  21. package/docs/reference/validation.md +12 -4
  22. package/package.json +1 -1
  23. package/skills/facet/SKILL.md +3 -4
  24. package/src/gallery-web/app.ts +20 -1
  25. package/src/gallery-web/frame/renderers/markdown.ts +5 -1
  26. package/src/gallery-web/frame/renderers/registry.ts +17 -10
  27. package/src/gallery-web/frame/runtime.ts +7 -1
  28. package/src/gallery-web/frame/styles/artifact.css +3 -0
  29. package/src/gallery-web/frame/styles/frame.css +4 -24
  30. package/src/gallery-web/frame-html.ts +5 -1
  31. package/src/harness-adapters/mcp/server.ts +2 -2
  32. package/src/service/dispatcher.ts +12 -0
  33. package/src/shared/html/policy.ts +9 -0
  34. package/src/shared/html/style-vocabulary.ts +1 -0
  35. package/src/validation/tier0/html.ts +6 -9
  36. package/src/validation/tier0/markdown.ts +2 -12
  37. package/src/validation/tier1/isolated-probe.ts +20 -11
  38. package/src/validation/tier1/limits.ts +11 -6
  39. package/src/validation/tier1/protocol-probe.ts +60 -26
  40. package/src/validation/tier1/runner.ts +173 -10
  41. package/src/validation/tier1/verdict.ts +9 -0
  42. package/templates/capacity-report.tsx +1 -1
  43. package/templates/fleet-dashboard.html +1 -1
  44. package/templates/html-release-ledger.html +1 -1
  45. package/templates/html-status-report.html +1 -1
  46. package/templates/incident-console.tsx +1 -1
  47. package/templates/tsx-interactive-counter.tsx +1 -1
  48. package/templates/tsx-status-report.tsx +1 -1
@@ -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
  };
@@ -1,5 +1,5 @@
1
1
  import { isTsxExecutionMode, type TsxExecutionMode } from "../../shared/tsx/execution";
2
- import { isResolvedGalleryTheme } from "../theme";
2
+ import { galleryDataTheme, isResolvedGalleryTheme, type ResolvedGalleryTheme } from "../theme";
3
3
 
4
4
  import { validateRenderer } from "./renderer-validation";
5
5
  import {
@@ -80,6 +80,7 @@ interface DiagramRegionGestures {
80
80
 
81
81
  export interface GalleryFrameApi {
82
82
  render(payload: FrameRenderPayload): Promise<RenderResult>;
83
+ setTheme(theme: ResolvedGalleryTheme): void;
83
84
  }
84
85
 
85
86
  declare global {
@@ -410,6 +411,11 @@ export function installGalleryFrameApi(registry: RendererRegistry): void {
410
411
  };
411
412
 
412
413
  const api: GalleryFrameApi = {
414
+ setTheme(theme) {
415
+ if (!isResolvedGalleryTheme(theme))
416
+ throw new FacetRenderError("frame theme is invalid", "invalid_request");
417
+ document.documentElement.dataset.theme = galleryDataTheme(theme);
418
+ },
413
419
  async render(payload: FrameRenderPayload): Promise<RenderResult> {
414
420
  if (rendered) throw new FacetRenderError("frame already rendered", "invalid_request");
415
421
  const validated = validatePayload(payload);
@@ -5256,6 +5256,9 @@
5256
5256
  .mx-16 {
5257
5257
  margin-inline: calc(var(--spacing) * 16);
5258
5258
  }
5259
+ .mx-auto {
5260
+ margin-inline: auto;
5261
+ }
5259
5262
  .my-0 {
5260
5263
  margin-block: 0px;
5261
5264
  }
@@ -78,26 +78,7 @@ body {
78
78
  padding-inline: max(16px, calc((100% - 92ch) / 2));
79
79
  }
80
80
 
81
- /*
82
- * Native dashboard artifacts (html, interactive tsx) read top-down
83
- * like markdown — vertical stays start-aligned — but unlike markdown
84
- * their width is whatever the fixture authored (often a fixed-width
85
- * `max-w-*` container), not a fluid readable column. Left unstyled,
86
- * that narrow content sits pinned to the container's left edge,
87
- * stranding most of a wide desktop stage as dead space.
88
- *
89
- * The centering has to land on the renderer-ROOT element
90
- * (`.facet-html-root` / `#facet-tsx-mount`), not `#artifact` itself:
91
- * `renderHtml`/`renderTsx` always mount a single full-width wrapper
92
- * as `#artifact`'s only child, and the fixture's own narrow content
93
- * (e.g. `<main class="max-w-2xl">`) is a GRANDCHILD of `#artifact`
94
- * inside that wrapper — centering `#artifact`'s single full-width
95
- * child does nothing. `safe center` on the horizontal axis only —
96
- * the same fallback-to-start mechanism the standalone-diagram rule
97
- * above uses — centers content that fits and never clips content
98
- * wider than the stage (it start-aligns and stays reachable by
99
- * scroll instead).
100
- */
81
+ /* These selectors match usesArtifactStylesheet in frame-html.ts; CSS cannot import it. */
101
82
  #artifact[data-facet-artifact-type="html"],
102
83
  #artifact[data-facet-artifact-type="tsx"] {
103
84
  display: flex;
@@ -106,10 +87,9 @@ body {
106
87
 
107
88
  #artifact[data-facet-artifact-type="html"] > [data-facet-renderer-root],
108
89
  #artifact[data-facet-artifact-type="tsx"] > [data-facet-renderer-root] {
109
- display: flex;
110
- justify-content: safe center;
111
- align-items: flex-start;
112
- /* A full-height root plus a sibling runtime error makes capture bounds grow on every viewport resize. */
90
+ /* Block flow preserves authored sibling order while keeping the renderer root a flex item of the stage. */
91
+ display: block;
92
+ /* Filling the stage keeps a sibling runtime-error line outside capture bounds as the viewport resizes. */
113
93
  flex: 1 0 auto;
114
94
  }
115
95
 
@@ -31,6 +31,10 @@ export function buildFrameAttributes(src = "/gallery/frame"): FrameAttributes {
31
31
  };
32
32
  }
33
33
 
34
+ export function usesArtifactStylesheet(artifactType: string): boolean {
35
+ return artifactType === "html" || artifactType === "tsx";
36
+ }
37
+
34
38
  export function buildFrameDocument(options: {
35
39
  artifactType: string;
36
40
  runtimeUrl: string;
@@ -45,7 +49,7 @@ export function buildFrameDocument(options: {
45
49
  return (
46
50
  `<!doctype html><html data-theme="${galleryDataTheme(theme)}"><head>` +
47
51
  `<meta charset="utf-8"><link rel="stylesheet" href="/gallery/frame/frame.css">` +
48
- (artifactType === "html" || artifactType === "tsx"
52
+ (usesArtifactStylesheet(artifactType)
49
53
  ? `<link rel="stylesheet" href="/gallery/frame/artifact.css">`
50
54
  : "") +
51
55
  "</head><body>" +
@@ -145,7 +145,7 @@ export function createFacetMcpServer(): Server {
145
145
  ),
146
146
  facet_publish: defineTool(
147
147
  PublishToolSchema,
148
- "Publish exactly one sourceText or local file. Check envelope.ok separately from data.verdict.status: stored verdict status error is not a transport failure.",
148
+ 'Publish exactly one sourceText or local file. Check envelope.ok separately from data.verdict.status: a stored verdict status of error is not a transport failure. Tier 0 parses and compiles structure but is not rendered; call facet_read_back with tier: "visual" before reporting a render.',
149
149
  async (input) => {
150
150
  const source = publishSource(input);
151
151
  return invoke(buildFacetArgs("publish", { ...input, ...source }), source);
@@ -153,7 +153,7 @@ export function createFacetMcpServer(): Server {
153
153
  ),
154
154
  facet_read_back: defineTool(
155
155
  ReadBackToolSchema,
156
- "Read back the latest or revision-bound stored verdict at Tier 0, Tier 1, or visual. Tier 1 and visual need browser evidence; inspect envelope.ok before verdict status.",
156
+ "Read back a stored verdict at Tier 0, Tier 1, or visual. Tier 1 and visual need browser evidence from the pinned browser; inspect envelope.ok before verdict status. Visual covers the first rendered state; later interaction is not validated and does not change the stored verdict.",
157
157
  async (input) => invoke(buildFacetArgs("read_back", input)),
158
158
  ),
159
159
  facet_status: defineTool(
@@ -526,6 +526,12 @@ export async function dispatch(
526
526
  ? deps.repository.getLatestRevision(command.artifactId)
527
527
  : deps.repository.getRevisionBySha(command.artifactId, command.revisionSha);
528
528
  if (revision === null) {
529
+ if (deps.repository.getArtifactById(command.artifactId) === null) {
530
+ throw new FacetError("artifact_not_found", "Artifact not found", {
531
+ retryable: false,
532
+ details: { artifactId: command.artifactId },
533
+ });
534
+ }
529
535
  throw new FacetError("revision_not_found", "Revision not found", {
530
536
  retryable: false,
531
537
  details: {
@@ -614,6 +620,12 @@ export async function dispatch(
614
620
  ? deps.repository.getLatestRevision(command.artifactId)
615
621
  : deps.repository.getRevisionBySha(command.artifactId, command.revisionSha);
616
622
  if (revision === null) {
623
+ if (deps.repository.getArtifactById(command.artifactId) === null) {
624
+ throw new FacetError("artifact_not_found", "Artifact not found", {
625
+ retryable: false,
626
+ details: { artifactId: command.artifactId },
627
+ });
628
+ }
617
629
  throw new FacetError("revision_not_found", "Revision not found", {
618
630
  retryable: false,
619
631
  details: {
@@ -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,
@@ -28,6 +28,7 @@ export const HTML_TAILWIND_CLASSES = [
28
28
  "mt-4",
29
29
  "mb-2",
30
30
  "mb-4",
31
+ "mx-auto",
31
32
  "w-full",
32
33
  "max-w-prose",
33
34
  "max-w-2xl",
@@ -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,