@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
@@ -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";
@@ -16,7 +16,7 @@ export default function CapacityReport() {
16
16
  const capacityPercent = Math.round((readyWorkers / workerCapacity) * 100);
17
17
 
18
18
  return (
19
- <main className="max-w-2xl p-6 gap-6 flex flex-col bg-legion-paper text-legion-ink">
19
+ <main className="max-w-2xl mx-auto p-6 gap-6 flex flex-col bg-legion-paper text-legion-ink">
20
20
  <header className="flex flex-col gap-2">
21
21
  <div className="flex flex-wrap gap-2">
22
22
  <span className="badge">capacity report</span>
@@ -4,7 +4,7 @@
4
4
  <title>Fleet dashboard</title>
5
5
  </head>
6
6
  <body class="bg-legion-paper text-legion-ink leading-relaxed">
7
- <main class="max-w-2xl p-6 gap-6 flex flex-col">
7
+ <main class="max-w-2xl mx-auto p-6 gap-6 flex flex-col">
8
8
  <header class="flex flex-col gap-2">
9
9
  <div class="flex flex-wrap gap-2 items-center">
10
10
  <span class="badge">operations</span>
@@ -4,7 +4,7 @@
4
4
  <title>Release ledger</title>
5
5
  </head>
6
6
  <body class="bg-legion-paper text-legion-ink leading-relaxed">
7
- <main class="max-w-2xl p-6 gap-6 flex flex-col">
7
+ <main class="max-w-2xl mx-auto p-6 gap-6 flex flex-col">
8
8
  <header class="flex flex-col gap-2">
9
9
  <div class="flex flex-wrap items-center justify-between gap-2">
10
10
  <span class="badge">release ledger</span>
@@ -20,7 +20,7 @@
20
20
  <title>Status report</title>
21
21
  </head>
22
22
  <body class="bg-legion-paper text-legion-ink leading-relaxed">
23
- <main class="max-w-2xl p-6 gap-4 flex flex-col">
23
+ <main class="max-w-2xl mx-auto p-6 gap-4 flex flex-col">
24
24
  <header class="flex flex-col gap-2">
25
25
  <h1 class="text-2xl font-bold">Status report</h1>
26
26
  <p class="text-legion-muted text-sm">
@@ -44,7 +44,7 @@ export default function IncidentConsole() {
44
44
  const criticalCount = incidents.filter((incident) => incident.severity === "critical").length;
45
45
 
46
46
  return (
47
- <main className="max-w-2xl p-6 gap-6 flex flex-col bg-legion-paper text-legion-ink">
47
+ <main className="max-w-2xl mx-auto p-6 gap-6 flex flex-col bg-legion-paper text-legion-ink">
48
48
  <header className="flex flex-col gap-2">
49
49
  <div className="flex flex-wrap gap-2 items-center">
50
50
  <span className="badge">incident console</span>
@@ -4,7 +4,7 @@ export default function InteractiveCounter() {
4
4
  const [count, setCount] = useState(0);
5
5
 
6
6
  return (
7
- <section className="card bg-legion-paper p-6 w-full max-w-prose">
7
+ <section className="card bg-legion-paper p-6 w-full max-w-prose mx-auto">
8
8
  <h1 className="text-2xl font-semibold text-legion-ink">Interactive counter</h1>
9
9
  <p className="text-legion-muted mt-2">Button presses: {count}</p>
10
10
  <button
@@ -2,7 +2,7 @@ import React from "react";
2
2
 
3
3
  export default function StatusReport() {
4
4
  return (
5
- <section className="card bg-legion-paper p-6 w-full max-w-prose">
5
+ <section className="card bg-legion-paper p-6 w-full max-w-prose mx-auto">
6
6
  <h1 className="text-2xl font-semibold text-legion-ink">Status report</h1>
7
7
  <p className="text-legion-muted mt-2">Verification state for the current release.</p>
8
8
  <section className="mt-4">