@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.
Files changed (59) hide show
  1. package/README.md +50 -21
  2. package/dist/gallery/{chunk-4g8rem85.css → chunk-2dge4gwb.css} +22 -1
  3. package/dist/gallery/{chunk-c6s9k9ty.js → chunk-48emrxg6.js} +71 -3
  4. package/dist/gallery/frame/frame.css +8 -1
  5. package/dist/gallery/frame/runtime/chart.js +23 -1
  6. package/dist/gallery/index.html +4 -2
  7. package/docs/reference/cli.md +29 -1
  8. package/docs/reference/export.md +4 -0
  9. package/docs/reference/mcp.md +16 -8
  10. package/docs/reference/security.md +7 -0
  11. package/docs/reference/storage.md +6 -1
  12. package/docs/reference/tsx.md +14 -0
  13. package/docs/reference/validation.md +34 -6
  14. package/package.json +4 -4
  15. package/skills/facet/SKILL.md +24 -1
  16. package/src/cli/commands/promote.ts +1 -0
  17. package/src/cli/commands/templates.ts +25 -0
  18. package/src/cli/main.ts +4 -0
  19. package/src/cli/parser.ts +4 -0
  20. package/src/cli/presenter.ts +48 -0
  21. package/src/gallery-web/app.ts +45 -1
  22. package/src/gallery-web/favicon.ts +1 -0
  23. package/src/gallery-web/frame/renderers/chart.ts +31 -1
  24. package/src/gallery-web/frame/styles/frame.css +8 -1
  25. package/src/gallery-web/frame-html.ts +2 -1
  26. package/src/gallery-web/index.html +3 -1
  27. package/src/gallery-web/styles/verdict.css +19 -4
  28. package/src/harness-adapters/mcp/cli-bridge.ts +11 -1
  29. package/src/harness-adapters/mcp/main.ts +2 -0
  30. package/src/harness-adapters/mcp/server.ts +92 -69
  31. package/src/harness-adapters/mcp/tool-schemas.ts +7 -0
  32. package/src/service/dispatcher.ts +10 -0
  33. package/src/service/router-guards.ts +2 -0
  34. package/src/service/store/migrations.ts +12 -2
  35. package/src/service/store/repository-lifecycle.ts +91 -19
  36. package/src/service/store/repository.ts +60 -2
  37. package/src/service/store/schema.ts +4 -0
  38. package/src/service/stored-verdict.ts +3 -0
  39. package/src/shared/contracts/artifact.ts +1 -0
  40. package/src/shared/contracts/commands/index.ts +10 -0
  41. package/src/shared/contracts/commands/names.ts +2 -0
  42. package/src/shared/contracts/commands/requests.ts +7 -0
  43. package/src/shared/contracts/commands/results.ts +20 -1
  44. package/src/shared/contracts/promotion.ts +16 -0
  45. package/src/shared/contracts/validation.ts +7 -0
  46. package/src/shared/errors/facet-error.ts +2 -0
  47. package/src/shared/errors/store-error.ts +4 -0
  48. package/src/shared/html/artifact-main.ts +3 -0
  49. package/src/shared/storage-version.ts +1 -1
  50. package/src/validation/tier0/dom-shim.ts +3 -18
  51. package/src/validation/tier0/markdown.ts +28 -5
  52. package/src/validation/tier0/mermaid.ts +17 -8
  53. package/src/validation/tier0/worker-dispatch.ts +1 -1
  54. package/src/validation/tier1/entries/tsx.ts +1 -0
  55. package/src/validation/tier1/harness.ts +2 -1
  56. package/src/validation/tier1/isolated-probe.ts +4 -0
  57. package/src/validation/tier1/protocol-probe.ts +37 -0
  58. package/src/validation/tier1/runner.ts +21 -4
  59. 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. `error` (declared-opaque, observed-zero) — the artifact declared opaque regions and none were seen. This sits BELOW `partial:unstable` deliberately: it compares the expectation against a SINGLE observation, so a structure change makes it unsafe to assert. A canvas that painted after the barrier would otherwise be reported as an artifact that never painted at all — a false accusation, which is the failure class the whole partial taxonomy exists to avoid.
47
- 7. `partial:opaque_content` — single-snapshot claim: structure has opaque regions.
48
- 8. `partial:external_resources` — single-snapshot claim: structure has external HTTP references.
49
- 9. `partial:layout_unverified` — single-snapshot claim: visible SVG with zeroed viewBoxes.
50
- 10. `ok`.
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:unstable` screenshot mandates are honored. The runner
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.9.0",
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": "./src/cli/main.ts",
24
- "facet-mcp": "./src/harness-adapters/mcp/main.ts"
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": "oxfmt",
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",
@@ -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",
@@ -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[] = [];
@@ -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
- : null;
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
  };
@@ -10,6 +10,7 @@ export const FAVICON_TINT_BY_STATUS = {
10
10
  "partial:opaque_content": "amber",
11
11
  "partial:external_resources": "amber",
12
12
  "partial:unstable": "amber",
13
+ "partial:empty_render": "amber",
13
14
  tampered: "red",
14
15
  timeout: "red",
15
16
  shim_only: "grey",
@@ -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
- min-height: 100%;
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 id="artifact" data-facet-artifact-type="${artifactType}"></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">idle</span>
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",
@@ -1,3 +1,5 @@
1
+ #!/usr/bin/env bun
2
+
1
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
4
 
3
5
  import { createFacetMcpServer } from "./server";