@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.
- package/dist/gallery/{chunk-48emrxg6.js → chunk-0by8qn4x.js} +15 -0
- package/dist/gallery/frame/artifact.css +3 -0
- package/dist/gallery/frame/chunks/{markdown-zazx9h4n.js → markdown-31ee7zgx.js} +1 -1
- package/dist/gallery/frame/chunks/{markdown-fvfncadf.js → markdown-7scxp44j.js} +9 -1
- package/dist/gallery/frame/chunks/{markdown-z1wc0v0d.js → markdown-mksxbxx8.js} +2 -2
- package/dist/gallery/frame/chunks/{markdown-d1wfbs70.js → markdown-n9xcxy80.js} +14 -9
- package/dist/gallery/frame/chunks/{markdown-rd8w0ng9.js → markdown-rkt612xp.js} +1 -1
- package/dist/gallery/frame/chunks/{mermaid-de7p4v0x.js → mermaid-b8fxhsgg.js} +3 -3
- package/dist/gallery/frame/frame.css +4 -24
- package/dist/gallery/frame/runtime/chart.js +3 -3
- package/dist/gallery/frame/runtime/html.js +3 -3
- package/dist/gallery/frame/runtime/markdown.js +8 -4
- package/dist/gallery/frame/runtime/mermaid.js +4 -4
- package/dist/gallery/frame/runtime/svg.js +3 -3
- package/dist/gallery/frame/runtime/tsx.js +3 -3
- package/dist/gallery/index.html +1 -1
- package/docs/reference/cli.md +1 -1
- package/docs/reference/html.md +3 -3
- package/docs/reference/mcp.md +15 -2
- package/docs/reference/tsx.md +3 -1
- package/docs/reference/validation.md +12 -4
- package/package.json +1 -1
- package/skills/facet/SKILL.md +3 -4
- package/src/gallery-web/app.ts +20 -1
- package/src/gallery-web/frame/renderers/markdown.ts +5 -1
- package/src/gallery-web/frame/renderers/registry.ts +17 -10
- package/src/gallery-web/frame/runtime.ts +7 -1
- package/src/gallery-web/frame/styles/artifact.css +3 -0
- package/src/gallery-web/frame/styles/frame.css +4 -24
- package/src/gallery-web/frame-html.ts +5 -1
- package/src/harness-adapters/mcp/server.ts +2 -2
- package/src/service/dispatcher.ts +12 -0
- package/src/shared/html/policy.ts +9 -0
- package/src/shared/html/style-vocabulary.ts +1 -0
- package/src/validation/tier0/html.ts +6 -9
- package/src/validation/tier0/markdown.ts +2 -12
- package/src/validation/tier1/isolated-probe.ts +20 -11
- package/src/validation/tier1/limits.ts +11 -6
- package/src/validation/tier1/protocol-probe.ts +60 -26
- package/src/validation/tier1/runner.ts +173 -10
- package/src/validation/tier1/verdict.ts +9 -0
- package/templates/capacity-report.tsx +1 -1
- package/templates/fleet-dashboard.html +1 -1
- package/templates/html-release-ledger.html +1 -1
- package/templates/html-status-report.html +1 -1
- package/templates/incident-console.tsx +1 -1
- package/templates/tsx-interactive-counter.tsx +1 -1
- 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
|
-
|
|
1096
|
-
|
|
1097
|
-
const
|
|
1098
|
-
const
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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">
|