@legionworks/facet 1.9.0 → 1.10.0
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/README.md +50 -21
- package/dist/gallery/{chunk-4g8rem85.css → chunk-2dge4gwb.css} +22 -1
- package/dist/gallery/{chunk-c6s9k9ty.js → chunk-48emrxg6.js} +71 -3
- package/dist/gallery/frame/frame.css +8 -1
- package/dist/gallery/frame/runtime/chart.js +23 -1
- package/dist/gallery/index.html +4 -2
- package/docs/reference/cli.md +29 -1
- package/docs/reference/export.md +4 -0
- package/docs/reference/mcp.md +16 -8
- package/docs/reference/security.md +7 -0
- package/docs/reference/storage.md +6 -1
- package/docs/reference/tsx.md +14 -0
- package/docs/reference/validation.md +34 -6
- package/package.json +4 -4
- package/skills/facet/SKILL.md +24 -1
- package/src/cli/commands/promote.ts +1 -0
- package/src/cli/commands/templates.ts +25 -0
- package/src/cli/main.ts +4 -0
- package/src/cli/parser.ts +4 -0
- package/src/cli/presenter.ts +48 -0
- package/src/gallery-web/app.ts +45 -1
- package/src/gallery-web/favicon.ts +1 -0
- package/src/gallery-web/frame/renderers/chart.ts +31 -1
- package/src/gallery-web/frame/styles/frame.css +8 -1
- package/src/gallery-web/frame-html.ts +2 -1
- package/src/gallery-web/index.html +3 -1
- package/src/gallery-web/styles/verdict.css +19 -4
- package/src/harness-adapters/mcp/cli-bridge.ts +11 -1
- package/src/harness-adapters/mcp/main.ts +2 -0
- package/src/harness-adapters/mcp/server.ts +92 -69
- package/src/harness-adapters/mcp/tool-schemas.ts +7 -0
- package/src/service/dispatcher.ts +10 -0
- package/src/service/router-guards.ts +2 -0
- package/src/service/store/migrations.ts +12 -2
- package/src/service/store/repository-lifecycle.ts +91 -19
- package/src/service/store/repository.ts +60 -2
- package/src/service/store/schema.ts +4 -0
- package/src/service/stored-verdict.ts +3 -0
- package/src/shared/contracts/artifact.ts +1 -0
- package/src/shared/contracts/commands/index.ts +10 -0
- package/src/shared/contracts/commands/names.ts +2 -0
- package/src/shared/contracts/commands/requests.ts +7 -0
- package/src/shared/contracts/commands/results.ts +20 -1
- package/src/shared/contracts/promotion.ts +16 -0
- package/src/shared/contracts/validation.ts +7 -0
- package/src/shared/errors/facet-error.ts +2 -0
- package/src/shared/errors/store-error.ts +4 -0
- package/src/shared/html/artifact-main.ts +3 -0
- package/src/shared/storage-version.ts +1 -1
- package/src/validation/tier0/dom-shim.ts +3 -18
- package/src/validation/tier0/markdown.ts +28 -5
- package/src/validation/tier0/mermaid.ts +17 -8
- package/src/validation/tier0/worker-dispatch.ts +1 -1
- package/src/validation/tier1/entries/tsx.ts +1 -0
- package/src/validation/tier1/harness.ts +2 -1
- package/src/validation/tier1/isolated-probe.ts +4 -0
- package/src/validation/tier1/protocol-probe.ts +37 -0
- package/src/validation/tier1/runner.ts +21 -4
- package/src/validation/tier1/verdict.ts +24 -39
|
@@ -18,6 +18,7 @@ Every other layer is bound to it through `VerdictSchema.status`.
|
|
|
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. |
|
|
21
|
+
| `partial:empty_render` | TSX Tier 1: all authoritative observations agree that the renderer root has no element children and no non-whitespace text. MUST carry a screenshot path, or a typed `screenshotError` marker when capture fails transiently. |
|
|
21
22
|
| `tampered` | Page-shim or isolated-world observation diverges from protocol authority. |
|
|
22
23
|
| `timeout` | The harness did not emit `render-complete` within `TIER1_RENDER_BARRIER_MS`. |
|
|
23
24
|
| `shim_only` | Isolated-world channel missing; only the untrusted page-shim produced usable counts. |
|
|
@@ -43,11 +44,12 @@ ordering, top to bottom:
|
|
|
43
44
|
3. `probe_only` / `shim_only` — channel availability (meta-claim about the channels, not the page).
|
|
44
45
|
4. `error` — discriminative errors are non-empty, or observed counts disagree with the lexical expectation. Both survive a structure change: a parse error is a parse error whenever it was observed, and a count that never matched the source's expectation never matches.
|
|
45
46
|
5. **`partial:unstable`** — TSX interactive mode; structure changed between the render barrier and the stability window.
|
|
46
|
-
6. `
|
|
47
|
-
7. `
|
|
48
|
-
8. `partial:
|
|
49
|
-
9. `partial:
|
|
50
|
-
10. `
|
|
47
|
+
6. `partial:empty_render` — TSX root emptiness, after instability and before other single-snapshot partials.
|
|
48
|
+
7. `error` (declared-opaque, observed-zero) — the artifact declared opaque regions and none were seen.
|
|
49
|
+
8. `partial:opaque_content` — single-snapshot claim: structure has opaque regions.
|
|
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.
|
|
52
|
+
11. `ok`.
|
|
51
53
|
|
|
52
54
|
The rule generalizing rows 4 and 6: a claim that depends on ONE observation
|
|
53
55
|
loses to `partial:unstable`; a claim that holds regardless of when it was
|
|
@@ -68,13 +70,21 @@ mode 0700 — the canonical secret-bearing layout matches the DB file
|
|
|
68
70
|
permissions.
|
|
69
71
|
|
|
70
72
|
Tier 1 capture happens AFTER the verdict is derived so the
|
|
71
|
-
`partial:layout_unverified`, `partial:opaque_content`, `partial:external_resources`, and `partial:
|
|
73
|
+
`partial:layout_unverified`, `partial:opaque_content`, `partial:external_resources`, `partial:unstable`, and `partial:empty_render` screenshot mandates are honored. The runner
|
|
72
74
|
measures the whole artifact, bounds each axis at 4096 pixels and the total at
|
|
73
75
|
8,388,608 pixels, and encodes evidence under the 8 MiB cap. New captures are
|
|
74
76
|
WebP; legacy PNG evidence remains readable and exportable. Before static
|
|
75
77
|
capture it emulates `prefers-reduced-motion: reduce` and awaits
|
|
76
78
|
`document.fonts.ready`; these pre-flights keep repeated captures byte-identical.
|
|
77
79
|
|
|
80
|
+
Tier 1 uses the same artifact-type layout rules as the gallery frame: Mermaid,
|
|
81
|
+
SVG, and chart roots are safely centered on both axes, with oversized content
|
|
82
|
+
remaining reachable by scrolling; Markdown is top-aligned in a horizontally
|
|
83
|
+
centered column capped at about 92ch; HTML and TSX are top-aligned while their
|
|
84
|
+
renderer roots are safely centered across the stage. The renderer-parity gate
|
|
85
|
+
compares both the gallery and verifier renderer-module sets and CSS/stylesheet
|
|
86
|
+
sets.
|
|
87
|
+
|
|
78
88
|
Interactive TSX declares animated-capture eligibility; it does not imply that
|
|
79
89
|
the component is always visibly changing. CSS/Web Animations are probed, and
|
|
80
90
|
eligible captures may contain multiple WebP frames. Static artifacts retain a
|
|
@@ -90,8 +100,26 @@ browser or network-namespace failure records a Tier 1 `error` verdict with its
|
|
|
90
100
|
typed `tier1_*` code; it does not erase the Tier 0 verdict or turn visual
|
|
91
101
|
read-back into `revision_not_found`.
|
|
92
102
|
|
|
103
|
+
Tier 0 `ok` means the source passed structural checks; rendering is not
|
|
104
|
+
verified. The TTY presenter says `structure checked · rendering not verified`
|
|
105
|
+
and includes a visual read-back command. TSX Tier 0 reports `ok · compiled`.
|
|
106
|
+
The JSON envelope is unchanged. A malformed Mermaid fence returns `error` with
|
|
107
|
+
`mermaid_parse_error`, the parser message, and a zero-based `mermaid fence <i>`
|
|
108
|
+
location.
|
|
109
|
+
|
|
110
|
+
Tier 0 grammar-checks Mermaid fences but does not sanitize Mermaid label HTML;
|
|
111
|
+
it never renders a diagram. Sanitization happens in Tier 1, when Mermaid runs
|
|
112
|
+
in the real browser.
|
|
113
|
+
|
|
93
114
|
## Observed fields and renderer expectations
|
|
94
115
|
|
|
116
|
+
For Vega-Lite charts, Facet supplies 640×360 view defaults through
|
|
117
|
+
`config.view` only when the spec omits `autosize`, `config.view`, and both
|
|
118
|
+
top-level `width` and `height`. A supplied width or height is preserved while
|
|
119
|
+
the other dimension receives its default. An authored `config.view` or
|
|
120
|
+
`autosize` prevents defaulting. The composite-view defaults reach facet,
|
|
121
|
+
concat, and repeat children; author sizing is not replaced.
|
|
122
|
+
|
|
95
123
|
The canonical observed fields include `rendererRootSvgCount`, `graphCount`,
|
|
96
124
|
`mermaidNodeCount`, `visibleSvgCount`, `opaqueRegionCount`, `viewBoxes`,
|
|
97
125
|
`errorCount`, and `discriminativeErrors`. `opaqueRegionCount` counts DOM
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@legionworks/facet",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"keywords": [
|
|
6
6
|
"artifacts",
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"url": "git+https://github.com/legion-works/facet.git"
|
|
21
21
|
},
|
|
22
22
|
"bin": {
|
|
23
|
-
"facet": "
|
|
24
|
-
"facet-mcp": "
|
|
23
|
+
"facet": "src/cli/main.ts",
|
|
24
|
+
"facet-mcp": "src/harness-adapters/mcp/main.ts"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"src/**",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"build": "bun scripts/build-gallery.ts",
|
|
43
43
|
"prepack": "bun scripts/build-gallery.ts --if-stale",
|
|
44
44
|
"lint": "oxlint --deny-warnings",
|
|
45
|
-
"format": "
|
|
45
|
+
"format": "bun scripts/check-format.ts --write",
|
|
46
46
|
"format:check": "bun scripts/check-format.ts",
|
|
47
47
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.runtime-egress-fixture.json",
|
|
48
48
|
"test": "bun test",
|
package/skills/facet/SKILL.md
CHANGED
|
@@ -67,10 +67,28 @@ the detected evidence format:
|
|
|
67
67
|
|
|
68
68
|
Promotion is operator-only. The CLI discovers the token from
|
|
69
69
|
`FACET_PROMOTE_TOKEN`, then `FACET_HOME/secrets/promote.token`; never put a
|
|
70
|
-
token on argv.
|
|
70
|
+
token on argv. First request visual read-back for the exact revision, then
|
|
71
|
+
promote it. Tier 0 or publication success is not visual verification. The
|
|
72
|
+
`templates` command lists promoted templates and their source verdicts.
|
|
73
|
+
`--allow-unverified` records the refusal reason as `promotionOverride` for
|
|
74
|
+
audit; it does not create verification or turn a refusal into a passing verdict.
|
|
75
|
+
A Tier 1 verdict carrying `insecure.level === 3` is refused as
|
|
76
|
+
`insecure:unvalidated` even when its status would otherwise be allow-listed —
|
|
77
|
+
levels 1 and 2 are real validator runs and keep status-based treatment.
|
|
71
78
|
|
|
72
79
|
## Error quick reference
|
|
73
80
|
|
|
81
|
+
Render statuses are a closed set: `ok`, `error`, `partial:layout_unverified`,
|
|
82
|
+
`partial:opaque_content`, `partial:external_resources`, `partial:unstable`,
|
|
83
|
+
`partial:empty_render`, `tampered`, `timeout`, `shim_only`, `probe_only`, and
|
|
84
|
+
`insecure:unvalidated`.
|
|
85
|
+
|
|
86
|
+
Tier 0 `ok` means structure checked, not rendered. For visual evidence, run
|
|
87
|
+
`facet read-back --artifact-id <id> --tier visual`. TSX Tier 0 `ok` means
|
|
88
|
+
`ok · compiled`. Tier 1 TSX can return `partial:empty_render` when all
|
|
89
|
+
authoritative channels agree that the renderer root has no elements or
|
|
90
|
+
non-whitespace text; the verdict requires a screenshot.
|
|
91
|
+
|
|
74
92
|
| Error | Do next |
|
|
75
93
|
| ------------------------ | ---------------------------------------------------------------------------------------------------------------- |
|
|
76
94
|
| `invalid_request` | Correct the command flags, artifact type, or source bytes, then retry. |
|
|
@@ -94,6 +112,11 @@ token on argv.
|
|
|
94
112
|
or ask a human to inspect.
|
|
95
113
|
- Preserve stdout exactly so callers can parse the versioned envelope.
|
|
96
114
|
- With shell access, the CLI is the integration — MCP is for structured-tool-only hosts, even when the host owns an MCP client. On that surface, `facet_open_url` is safe because it always uses `--no-launch`.
|
|
115
|
+
- For MCP registration, use `bun add -g @legionworks/facet` and the bare
|
|
116
|
+
`facet-mcp` command, or the no-install fallback `npx -p @legionworks/facet
|
|
117
|
+
facet-mcp`. The measured `bunx -p @legionworks/facet facet-mcp` form exited
|
|
118
|
+
with status 1 under Bun `1.3.14` and the available scratch Bun `1.4.0`; treat
|
|
119
|
+
that as an environment-specific caveat, not a universal Bun claim.
|
|
97
120
|
|
|
98
121
|
## Deeper reference
|
|
99
122
|
|
|
@@ -43,6 +43,7 @@ export function buildPromoteRequest(
|
|
|
43
43
|
revisionId,
|
|
44
44
|
name,
|
|
45
45
|
promotedBy,
|
|
46
|
+
...(args["allow-unverified"] === true ? { allowUnverified: true } : {}),
|
|
46
47
|
...(typeof artifactId === "string" ? { artifactId } : {}),
|
|
47
48
|
...(typeof description === "string" ? { description } : {}),
|
|
48
49
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { generateRequestId } from "../../shared/util/time";
|
|
2
|
+
import { FacetError } from "../../shared/errors/facet-error";
|
|
3
|
+
import { MAX_LIST_LIMIT } from "../../shared/config/limits";
|
|
4
|
+
import type { TemplatesRequest } from "../../shared/contracts/commands/requests";
|
|
5
|
+
|
|
6
|
+
export function buildTemplatesRequest(
|
|
7
|
+
args: Readonly<Record<string, string | boolean>>,
|
|
8
|
+
): TemplatesRequest {
|
|
9
|
+
const limit = args["limit"];
|
|
10
|
+
const limitNumber = typeof limit === "string" ? Number(limit) : undefined;
|
|
11
|
+
if (
|
|
12
|
+
limitNumber !== undefined &&
|
|
13
|
+
(!Number.isSafeInteger(limitNumber) || limitNumber < 1 || limitNumber > MAX_LIST_LIMIT)
|
|
14
|
+
) {
|
|
15
|
+
throw new FacetError(
|
|
16
|
+
"invalid_request",
|
|
17
|
+
`--limit must be an integer between 1 and ${MAX_LIST_LIMIT}`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
command: "templates",
|
|
22
|
+
requestId: generateRequestId(),
|
|
23
|
+
...(limitNumber === undefined ? {} : { limit: limitNumber }),
|
|
24
|
+
};
|
|
25
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { FacetClient } from "./client";
|
|
|
37
37
|
|
|
38
38
|
import { buildCreateRequest } from "./commands/create";
|
|
39
39
|
import { buildListRequest } from "./commands/list";
|
|
40
|
+
import { buildTemplatesRequest } from "./commands/templates";
|
|
40
41
|
import { buildOpenRequest, launchDisplay } from "./commands/open";
|
|
41
42
|
import { buildPinRequest } from "./commands/pin";
|
|
42
43
|
import { buildPromoteRequest } from "./commands/promote";
|
|
@@ -168,6 +169,9 @@ async function executeVerb(
|
|
|
168
169
|
case "list":
|
|
169
170
|
request = buildListRequest(args);
|
|
170
171
|
break;
|
|
172
|
+
case "templates":
|
|
173
|
+
request = buildTemplatesRequest(args);
|
|
174
|
+
break;
|
|
171
175
|
case "readBack":
|
|
172
176
|
request = buildReadBackRequest(args);
|
|
173
177
|
break;
|
package/src/cli/parser.ts
CHANGED
|
@@ -77,6 +77,7 @@ const VERB_FLAGS: Readonly<Record<CommandName, readonly FlagDefinition[]>> = {
|
|
|
77
77
|
{ flag: "--slug-prefix", takesValue: true },
|
|
78
78
|
{ flag: "--limit", takesValue: true },
|
|
79
79
|
],
|
|
80
|
+
templates: [{ flag: "--limit", takesValue: true }],
|
|
80
81
|
readBack: [
|
|
81
82
|
{ flag: "--artifact-id", takesValue: true, required: true },
|
|
82
83
|
{ flag: "--revision-sha", takesValue: true },
|
|
@@ -97,6 +98,7 @@ const VERB_FLAGS: Readonly<Record<CommandName, readonly FlagDefinition[]>> = {
|
|
|
97
98
|
{ flag: "--name", takesValue: true, required: true },
|
|
98
99
|
{ flag: "--description", takesValue: true },
|
|
99
100
|
{ flag: "--promoted-by", takesValue: true, required: true },
|
|
101
|
+
{ flag: "--allow-unverified", takesValue: false },
|
|
100
102
|
],
|
|
101
103
|
instantiate: [
|
|
102
104
|
{ flag: "--name", takesValue: true, required: true },
|
|
@@ -121,6 +123,7 @@ const VERB_TO_COMMAND: Readonly<Record<string, CommandName>> = {
|
|
|
121
123
|
create: "create",
|
|
122
124
|
publish: "publish",
|
|
123
125
|
list: "list",
|
|
126
|
+
templates: "templates",
|
|
124
127
|
"read-back": "readBack",
|
|
125
128
|
status: "status",
|
|
126
129
|
open: "open",
|
|
@@ -134,6 +137,7 @@ const COMMAND_TO_VERB: Readonly<Record<CommandName, string>> = {
|
|
|
134
137
|
create: "create",
|
|
135
138
|
publish: "publish",
|
|
136
139
|
list: "list",
|
|
140
|
+
templates: "templates",
|
|
137
141
|
readBack: "read-back",
|
|
138
142
|
status: "status",
|
|
139
143
|
open: "open",
|
package/src/cli/presenter.ts
CHANGED
|
@@ -55,6 +55,7 @@ export const VERDICT_GLYPH: Record<RenderStatus, string> = {
|
|
|
55
55
|
"partial:opaque_content": "◐",
|
|
56
56
|
"partial:external_resources": "◐",
|
|
57
57
|
"partial:unstable": "◐",
|
|
58
|
+
"partial:empty_render": "◐",
|
|
58
59
|
tampered: "⊘",
|
|
59
60
|
timeout: "◌",
|
|
60
61
|
shim_only: "◇",
|
|
@@ -82,6 +83,7 @@ const STATUS_TONE: Record<RenderStatus, Tone> = {
|
|
|
82
83
|
"partial:opaque_content": "yellow",
|
|
83
84
|
"partial:external_resources": "yellow",
|
|
84
85
|
"partial:unstable": "yellow",
|
|
86
|
+
"partial:empty_render": "yellow",
|
|
85
87
|
tampered: "redInverse",
|
|
86
88
|
timeout: "dim",
|
|
87
89
|
shim_only: "dim",
|
|
@@ -101,6 +103,10 @@ function row(label: string, value: string): string {
|
|
|
101
103
|
return ` ${label.padEnd(10, " ")}${value}`;
|
|
102
104
|
}
|
|
103
105
|
|
|
106
|
+
function shortCell(value: string, width: number): string {
|
|
107
|
+
return value.length <= width ? value : `${value.slice(0, width - 1)}…`;
|
|
108
|
+
}
|
|
109
|
+
|
|
104
110
|
function verdictLines(verdict: Verdict, caps: PresenterCaps): string[] {
|
|
105
111
|
const paint = makePaint(caps);
|
|
106
112
|
const tone = STATUS_TONE[verdict.status];
|
|
@@ -108,6 +114,9 @@ function verdictLines(verdict: Verdict, caps: PresenterCaps): string[] {
|
|
|
108
114
|
const head = [
|
|
109
115
|
paint(tone, `${VERDICT_GLYPH[verdict.status]} ${word}`),
|
|
110
116
|
...(detail === undefined ? [] : [paint("dim", detail)]),
|
|
117
|
+
...(verdict.tier === 0 && verdict.status === "ok" && verdict.execution !== undefined
|
|
118
|
+
? [paint("dim", "compiled")]
|
|
119
|
+
: []),
|
|
111
120
|
paint("dim", `tier ${verdict.tier}`),
|
|
112
121
|
`${verdict.artifactId} ${paint("dim", "@")} ${paint("cyan", sha8(verdict.revisionSha))}`,
|
|
113
122
|
].join(paint("dim", " · "));
|
|
@@ -121,6 +130,12 @@ function verdictLines(verdict: Verdict, caps: PresenterCaps): string[] {
|
|
|
121
130
|
"observed",
|
|
122
131
|
`svg ${o.rendererRootSvgCount} · graphs ${o.graphCount} · nodes ${o.mermaidNodeCount} · errors ${o.errorCount}`,
|
|
123
132
|
),
|
|
133
|
+
...(verdict.tier === 0 && verdict.status === "ok"
|
|
134
|
+
? [
|
|
135
|
+
" structure checked · rendering not verified",
|
|
136
|
+
row("verify", `facet read-back --artifact-id ${verdict.artifactId} --tier visual`),
|
|
137
|
+
]
|
|
138
|
+
: []),
|
|
124
139
|
];
|
|
125
140
|
const first = o.discriminativeErrors?.[0];
|
|
126
141
|
if (first !== undefined)
|
|
@@ -163,6 +178,15 @@ export function presentEnvelope(envelope: FacetEnvelope<unknown>, caps: Presente
|
|
|
163
178
|
if (revision?.sha256 !== undefined) {
|
|
164
179
|
lines.push(row("read-back", `facet read-back --revision-sha ${revision.sha256}`));
|
|
165
180
|
}
|
|
181
|
+
const tier0 = data["verdict"];
|
|
182
|
+
if (
|
|
183
|
+
typeof tier0 === "object" &&
|
|
184
|
+
tier0 !== null &&
|
|
185
|
+
(tier0 as Verdict).tier === 0 &&
|
|
186
|
+
(tier0 as Verdict).status === "ok"
|
|
187
|
+
) {
|
|
188
|
+
lines.push(...verdictLines(tier0 as Verdict, caps));
|
|
189
|
+
}
|
|
166
190
|
const tier1 = data["tier1Verdict"];
|
|
167
191
|
if (typeof tier1 === "object" && tier1 !== null) {
|
|
168
192
|
lines.push(...verdictLines(tier1 as Verdict, caps));
|
|
@@ -188,6 +212,30 @@ export function presentEnvelope(envelope: FacetEnvelope<unknown>, caps: Presente
|
|
|
188
212
|
}
|
|
189
213
|
}
|
|
190
214
|
|
|
215
|
+
if (command === "templates") {
|
|
216
|
+
const templates = Array.isArray(data["templates"]) ? data["templates"] : [];
|
|
217
|
+
const lines = ["NAME · SHA · VERDICT · OVERRIDE · BY · DATE"];
|
|
218
|
+
for (const entry of templates) {
|
|
219
|
+
const item = entry as Record<string, unknown>;
|
|
220
|
+
const verdict = item["sourceVerdict"] as { status: RenderStatus; tier: number } | null;
|
|
221
|
+
lines.push(
|
|
222
|
+
[
|
|
223
|
+
shortCell(String(item["name"]), 12),
|
|
224
|
+
String(item["revisionSha"]).slice(0, 12),
|
|
225
|
+
verdict === null
|
|
226
|
+
? "—"
|
|
227
|
+
: `${VERDICT_GLYPH[verdict.status]} ${verdict.status} t${verdict.tier}`,
|
|
228
|
+
item["promotionOverride"] === null
|
|
229
|
+
? ""
|
|
230
|
+
: shortCell(String(item["promotionOverride"]), 12),
|
|
231
|
+
shortCell(String(item["promotedBy"]), 8),
|
|
232
|
+
String(item["promotedAt"]).slice(0, 10),
|
|
233
|
+
].join(" · "),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return lines;
|
|
237
|
+
}
|
|
238
|
+
|
|
191
239
|
if (command === "doctor") {
|
|
192
240
|
const probes = Array.isArray(data["probes"]) ? data["probes"] : [];
|
|
193
241
|
const lines: string[] = [];
|
package/src/gallery-web/app.ts
CHANGED
|
@@ -888,7 +888,9 @@ function setGalleryVerdict(document: Document, verdict: Verdict | null): void {
|
|
|
888
888
|
? "external"
|
|
889
889
|
: verdict.status === "partial:unstable"
|
|
890
890
|
? "unstable"
|
|
891
|
-
:
|
|
891
|
+
: verdict.status === "partial:empty_render"
|
|
892
|
+
? "empty"
|
|
893
|
+
: null;
|
|
892
894
|
const insecure = verdict.insecure === undefined ? null : `INSECURE L${verdict.insecure.level}`;
|
|
893
895
|
const suffix = insecure === null ? `T${verdict.tier}` : `${insecure} · T${verdict.tier}`;
|
|
894
896
|
tier.textContent = detail === null ? `· ${suffix}` : `· ${detail} · ${suffix}`;
|
|
@@ -1091,6 +1093,42 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
|
|
|
1091
1093
|
let removeThemePreferenceListener: () => void = noOp;
|
|
1092
1094
|
let generation = 0;
|
|
1093
1095
|
let activeFrame: CreatedArtifactFrame | null = null;
|
|
1096
|
+
let removeInteractionListener: () => void = noOp;
|
|
1097
|
+
// Every path that leaves the displayed frame ends the signal: a stale flag would paint the
|
|
1098
|
+
// previous artifact's runtime error onto whatever the shell shows next.
|
|
1099
|
+
const endInteractionSignal = (): void => {
|
|
1100
|
+
removeInteractionListener();
|
|
1101
|
+
const badge = document.getElementById("facet-verdict");
|
|
1102
|
+
if (badge !== null) delete badge.dataset.interactionError;
|
|
1103
|
+
};
|
|
1104
|
+
const activateInteractionSignal = (frame: CreatedArtifactFrame): void => {
|
|
1105
|
+
endInteractionSignal();
|
|
1106
|
+
const badge = document.getElementById("facet-verdict");
|
|
1107
|
+
updateGalleryStatus("displayed");
|
|
1108
|
+
const frameWindow = (frame.element.raw as HTMLIFrameElement).contentWindow;
|
|
1109
|
+
if (frameWindow === null || typeof frameWindow.addEventListener !== "function") return;
|
|
1110
|
+
const markFailure = (): void => {
|
|
1111
|
+
if (expired || activeFrame !== frame || frame.renderResult === null) return;
|
|
1112
|
+
if (badge !== null) badge.dataset.interactionError = "true";
|
|
1113
|
+
updateGalleryStatus("displayed · runtime error during interaction");
|
|
1114
|
+
};
|
|
1115
|
+
frameWindow.addEventListener("error", markFailure, true);
|
|
1116
|
+
frameWindow.addEventListener("unhandledrejection", markFailure, true);
|
|
1117
|
+
const frameDocument = (frame.element.raw as HTMLIFrameElement).contentDocument;
|
|
1118
|
+
const observer =
|
|
1119
|
+
frameDocument == null || typeof MutationObserver !== "function"
|
|
1120
|
+
? null
|
|
1121
|
+
: new MutationObserver(() => {
|
|
1122
|
+
if (frameDocument.querySelector("[data-facet-error]")) markFailure();
|
|
1123
|
+
});
|
|
1124
|
+
if (frameDocument !== null)
|
|
1125
|
+
observer?.observe(frameDocument, { childList: true, subtree: true });
|
|
1126
|
+
removeInteractionListener = (): void => {
|
|
1127
|
+
frameWindow.removeEventListener("error", markFailure, true);
|
|
1128
|
+
frameWindow.removeEventListener("unhandledrejection", markFailure, true);
|
|
1129
|
+
observer?.disconnect();
|
|
1130
|
+
};
|
|
1131
|
+
};
|
|
1094
1132
|
const expireSession = (): void => {
|
|
1095
1133
|
if (expired) return;
|
|
1096
1134
|
generation += 1;
|
|
@@ -1100,6 +1138,7 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
|
|
|
1100
1138
|
exportMenu?.sync();
|
|
1101
1139
|
activeFrame?.renderResult?.setGestureMode("native");
|
|
1102
1140
|
swaps?.close();
|
|
1141
|
+
endInteractionSignal();
|
|
1103
1142
|
removeThemePreferenceListener();
|
|
1104
1143
|
clearSession(window.sessionStorage);
|
|
1105
1144
|
renderSessionExpired(
|
|
@@ -1237,6 +1276,7 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
|
|
|
1237
1276
|
updateGalleryFavicon(source.verdict?.status ?? "unverified");
|
|
1238
1277
|
updateGalleryVerdict(source.verdict ?? null);
|
|
1239
1278
|
updateGalleryStatus("displayed");
|
|
1279
|
+
activateInteractionSignal(current);
|
|
1240
1280
|
updateLiveState("live");
|
|
1241
1281
|
const themeToggle = document.getElementById("facet-theme-toggle");
|
|
1242
1282
|
const updateThemeToggle = (mode: GalleryThemeMode): void => {
|
|
@@ -1295,6 +1335,7 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
|
|
|
1295
1335
|
if (!canCommit() || result.cancelled || result.failedNewFrameReady) return;
|
|
1296
1336
|
current = next;
|
|
1297
1337
|
activeFrame = next;
|
|
1338
|
+
activateInteractionSignal(next);
|
|
1298
1339
|
commitTheme(nextMode, nextResolvedTheme);
|
|
1299
1340
|
syncPanZoomToggle();
|
|
1300
1341
|
syncZoomButtons();
|
|
@@ -1373,6 +1414,7 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
|
|
|
1373
1414
|
if (!result.failedNewFrameReady) {
|
|
1374
1415
|
current = frame;
|
|
1375
1416
|
activeFrame = frame;
|
|
1417
|
+
activateInteractionSignal(frame);
|
|
1376
1418
|
source = revision;
|
|
1377
1419
|
commitTheme(revisionThemeMode, revisionTheme);
|
|
1378
1420
|
syncPanZoomToggle();
|
|
@@ -1439,6 +1481,7 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
|
|
|
1439
1481
|
updateSwapBar("start");
|
|
1440
1482
|
updateGalleryFavicon("unverified");
|
|
1441
1483
|
updateGalleryVerdict(null);
|
|
1484
|
+
endInteractionSignal();
|
|
1442
1485
|
swaps?.enqueue({ kind: "revision", ...event });
|
|
1443
1486
|
},
|
|
1444
1487
|
onClose: (event) => {
|
|
@@ -1472,6 +1515,7 @@ export async function startGallery(runtime = browserGalleryRuntime()): Promise<v
|
|
|
1472
1515
|
// releases the lease when the TTL fires, which is the only path
|
|
1473
1516
|
// that lets "refresh the tab" reach the same displayed canvas.
|
|
1474
1517
|
stream.close();
|
|
1518
|
+
removeInteractionListener();
|
|
1475
1519
|
removeThemePreferenceListener();
|
|
1476
1520
|
if (zoomButtonPoll !== undefined) clearInterval(zoomButtonPoll);
|
|
1477
1521
|
};
|
|
@@ -128,6 +128,36 @@ export function withFacetChartTheme(spec: unknown, theme: ResolvedGalleryTheme):
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
export function withFacetChartDimensions(spec: unknown): unknown {
|
|
132
|
+
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) return spec;
|
|
133
|
+
const source = spec as Readonly<Record<string, unknown>>;
|
|
134
|
+
const config = source.config;
|
|
135
|
+
if (
|
|
136
|
+
"autosize" in source ||
|
|
137
|
+
(typeof config === "object" && config !== null && !Array.isArray(config) && "view" in config)
|
|
138
|
+
)
|
|
139
|
+
return spec;
|
|
140
|
+
if ("width" in source && "height" in source) return spec;
|
|
141
|
+
const authoredConfig =
|
|
142
|
+
typeof config === "object" && config !== null && !Array.isArray(config)
|
|
143
|
+
? (config as Readonly<Record<string, unknown>>)
|
|
144
|
+
: {};
|
|
145
|
+
const viewDefaults = {
|
|
146
|
+
...(!("width" in source) ? { continuousWidth: 640, discreteWidth: 640 } : {}),
|
|
147
|
+
...(!("height" in source) ? { continuousHeight: 360, discreteHeight: 360 } : {}),
|
|
148
|
+
};
|
|
149
|
+
// Composite views ignore top-level dimensions; config.view reaches each child view. An
|
|
150
|
+
// authored config.view wins because its sizing is an explicit author choice. The 640×360
|
|
151
|
+
// plot defaults leave charts readable in the 1280×800 verifier viewport.
|
|
152
|
+
return {
|
|
153
|
+
...source,
|
|
154
|
+
config: {
|
|
155
|
+
...authoredConfig,
|
|
156
|
+
view: viewDefaults,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
131
161
|
/** Render a chart artifact (artifactType "chart"): Vega-Lite JSON bytes. */
|
|
132
162
|
export async function renderChart(
|
|
133
163
|
ctx: RenderContext,
|
|
@@ -144,7 +174,7 @@ export async function renderChart(
|
|
|
144
174
|
}
|
|
145
175
|
let compiled: { spec: unknown };
|
|
146
176
|
try {
|
|
147
|
-
compiled = compile(withFacetChartTheme(spec, ctx.theme) as never);
|
|
177
|
+
compiled = compile(withFacetChartTheme(withFacetChartDimensions(spec), ctx.theme) as never);
|
|
148
178
|
} catch (error) {
|
|
149
179
|
const message = error instanceof Error ? error.message : String(error);
|
|
150
180
|
throw new FacetRenderError(`vega-lite compile failed: ${message}`, "chart_compile_error");
|
|
@@ -98,12 +98,19 @@ body {
|
|
|
98
98
|
* wider than the stage (it start-aligns and stays reachable by
|
|
99
99
|
* scroll instead).
|
|
100
100
|
*/
|
|
101
|
+
#artifact[data-facet-artifact-type="html"],
|
|
102
|
+
#artifact[data-facet-artifact-type="tsx"] {
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-direction: column;
|
|
105
|
+
}
|
|
106
|
+
|
|
101
107
|
#artifact[data-facet-artifact-type="html"] > [data-facet-renderer-root],
|
|
102
108
|
#artifact[data-facet-artifact-type="tsx"] > [data-facet-renderer-root] {
|
|
103
109
|
display: flex;
|
|
104
110
|
justify-content: safe center;
|
|
105
111
|
align-items: flex-start;
|
|
106
|
-
|
|
112
|
+
/* A full-height root plus a sibling runtime error makes capture bounds grow on every viewport resize. */
|
|
113
|
+
flex: 1 0 auto;
|
|
107
114
|
}
|
|
108
115
|
|
|
109
116
|
#artifact > svg {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { galleryDataTheme, type ResolvedGalleryTheme } from "./theme";
|
|
12
|
+
import { artifactMainAttributes } from "../shared/html/artifact-main";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Frame element attributes. The shell applies these to every iframe it
|
|
@@ -48,7 +49,7 @@ export function buildFrameDocument(options: {
|
|
|
48
49
|
? `<link rel="stylesheet" href="/gallery/frame/artifact.css">`
|
|
49
50
|
: "") +
|
|
50
51
|
"</head><body>" +
|
|
51
|
-
`<main
|
|
52
|
+
`<main ${artifactMainAttributes(artifactType)}></main>` +
|
|
52
53
|
`<script type="module" src="${escapedRuntimeUrl}"></script>` +
|
|
53
54
|
"</body></html>"
|
|
54
55
|
);
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
<span class="sep" aria-hidden="true"></span>
|
|
18
18
|
<span id="facet-artifact-title"></span>
|
|
19
19
|
<span class="revision" id="facet-revision">—</span>
|
|
20
|
-
<span class="status-line" id="facet-status-line"
|
|
20
|
+
<span class="status-line" id="facet-status-line" role="status" aria-live="polite"
|
|
21
|
+
>idle</span
|
|
22
|
+
>
|
|
21
23
|
<!--
|
|
22
24
|
Verdict badge. JS wiring: set data-status to the FULL wire
|
|
23
25
|
enum, text to status.split(":")[0], and the tier span to
|
|
@@ -57,6 +57,16 @@
|
|
|
57
57
|
content: "✓";
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
.facet-verdict[data-interaction-error="true"] {
|
|
61
|
+
border-color: var(--facet-fail);
|
|
62
|
+
box-shadow: var(--facet-glow-danger);
|
|
63
|
+
}
|
|
64
|
+
.facet-verdict[data-interaction-error="true"]::after {
|
|
65
|
+
content: "✗ runtime";
|
|
66
|
+
color: var(--facet-fail);
|
|
67
|
+
font-weight: 700;
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
.facet-verdict[data-status="error"] {
|
|
61
71
|
border-color: var(--facet-fail-border);
|
|
62
72
|
background: var(--facet-fail-fill);
|
|
@@ -100,6 +110,15 @@
|
|
|
100
110
|
content: "◐";
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
.facet-verdict[data-status="partial:empty_render"] {
|
|
114
|
+
border-color: var(--facet-partial-border);
|
|
115
|
+
background: var(--facet-partial-fill);
|
|
116
|
+
color: var(--facet-partial);
|
|
117
|
+
}
|
|
118
|
+
.facet-verdict[data-status="partial:empty_render"]::before {
|
|
119
|
+
content: "◐";
|
|
120
|
+
}
|
|
121
|
+
|
|
103
122
|
/* The alarm. The only fill, the only glow. Never animated. */
|
|
104
123
|
.facet-verdict[data-status="tampered"],
|
|
105
124
|
.facet-verdict[data-status="insecure:unvalidated"] {
|
|
@@ -109,10 +128,6 @@
|
|
|
109
128
|
font-weight: 700;
|
|
110
129
|
box-shadow: var(--facet-glow-danger);
|
|
111
130
|
}
|
|
112
|
-
.facet-verdict[data-status="tampered"]::before {
|
|
113
|
-
content: "⊘";
|
|
114
|
-
}
|
|
115
|
-
|
|
116
131
|
.facet-verdict[data-status="tampered"]::before,
|
|
117
132
|
.facet-verdict[data-status="insecure:unvalidated"]::before {
|
|
118
133
|
content: "⊘";
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
|
|
10
10
|
const STDERR_DETAIL_LIMIT = 4_096;
|
|
11
11
|
|
|
12
|
-
export type FacetToolName = "publish" | "read_back" | "status" | "export" | "open";
|
|
12
|
+
export type FacetToolName = "create" | "publish" | "read_back" | "status" | "export" | "open";
|
|
13
13
|
|
|
14
14
|
export interface FacetCliInvocation {
|
|
15
15
|
readonly command: string;
|
|
@@ -75,6 +75,16 @@ export function buildFacetArgs(
|
|
|
75
75
|
input: Readonly<Record<string, unknown>>,
|
|
76
76
|
): string[] {
|
|
77
77
|
switch (tool) {
|
|
78
|
+
case "create":
|
|
79
|
+
return [
|
|
80
|
+
"create",
|
|
81
|
+
"--project-id",
|
|
82
|
+
requiredString(input, "projectId"),
|
|
83
|
+
"--slug",
|
|
84
|
+
requiredString(input, "slug"),
|
|
85
|
+
"--title",
|
|
86
|
+
requiredString(input, "title"),
|
|
87
|
+
];
|
|
78
88
|
case "publish": {
|
|
79
89
|
const args = [
|
|
80
90
|
"publish",
|