@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
package/README.md CHANGED
@@ -41,10 +41,10 @@ Current gallery captures use the Mermaid fixture in resolved dark and the chart
41
41
 
42
42
  ## Install
43
43
 
44
- Facet is distributed as `@legionworks/facet`. **Bun is the required runtime**:
45
- the CLI runs on Bun `1.4.0` or newer. npm and pnpm are distribution channels,
46
- not Node runtime support; a machine without Bun cannot run the installed CLI and
47
- will receive a runtime failure.
44
+ Facet is distributed as `@legionworks/facet`. Bun is the required runtime
45
+ (version `1.4.0` or newer); install it first using the [official Bun
46
+ installation instructions](https://bun.sh/docs/installation). npm and pnpm
47
+ distribute Facet, but do not provide Bun. npm alone is not enough.
48
48
 
49
49
  Recommended:
50
50
 
@@ -65,27 +65,56 @@ For a zero-install invocation:
65
65
  bunx @legionworks/facet <verb>
66
66
  ```
67
67
 
68
- The pinned browser used for visual read-back downloads on the first visual
69
- read-back. See the [CLI reference](docs/reference/cli.md) for the command
70
- contract and [MCP reference](docs/reference/mcp.md) for structured-tool-only
71
- hosts.
68
+ The first visual read-back requires the pinned browser. If it is missing,
69
+ `facet doctor` prints the install command. See the [CLI reference](docs/reference/cli.md) for
70
+ the command contract and [MCP reference](docs/reference/mcp.md) for
71
+ structured-tool-only hosts.
72
+
73
+ ## Quickstart from an installed binary
74
+
75
+ This walkthrough uses the globally installed `facet` command, not a source
76
+ checkout. It creates disposable runtime and export directories.
77
+
78
+ ```sh
79
+ export FACET_HOME="$(mktemp -d)"
80
+ facet status --start
81
+ facet doctor
82
+ bunx --bun puppeteer browsers install chrome-headless-shell@151.0.7922.77
83
+ SOURCE='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 80"><rect width="240" height="80" rx="8" fill="#202638"/><text x="20" y="48" fill="white">Facet quickstart</text></svg>'
84
+ SOURCE_FILE="$(mktemp)"
85
+ printf '%s' "$SOURCE" > "$SOURCE_FILE"
86
+ ARTIFACT_ID="$(facet create --project-id demo --slug quickstart --title 'Quickstart' | bun -e 'const x=JSON.parse(await Bun.stdin.text()); if(!x.ok) throw new Error(x.error.code); console.log(x.data.artifact.id)')"
87
+ PUBLISH="$(facet publish --artifact-id "$ARTIFACT_ID" --type svg --file "$SOURCE_FILE")"
88
+ printf '%s\n' "$PUBLISH" | bun -e 'const x=JSON.parse(await Bun.stdin.text()); if(!x.ok || !x.data.verdict) throw new Error("publish failed"); console.log(JSON.stringify(x.data.verdict))'
89
+ REVISION_SHA="$(printf '%s\n' "$PUBLISH" | bun -e 'const x=JSON.parse(await Bun.stdin.text()); console.log(x.data.revision.sha256)')"
90
+ facet read-back --artifact-id "$ARTIFACT_ID" --revision-sha "$REVISION_SHA" --tier visual
91
+ EXPORT_DIR="$(mktemp -d)"
92
+ facet export "$ARTIFACT_ID" --format source --out "$EXPORT_DIR/artifact.svg"
93
+ ```
94
+
95
+ `doctor` checks the local runtime and browser setup and prints the browser
96
+ install command if the pinned shell is missing. Install it before visual
97
+ read-back. Inspect `data.verdict` in the publish envelope: an `ok` command
98
+ envelope only confirms transport, and the stored verdict can still have
99
+ `status: "error"`.
72
100
 
73
101
  ## Verdict language
74
102
 
75
103
  The gallery and CLI use the same wire enum, glyph, hue, and treatment.
76
104
 
77
- | enum (wire, verbatim) | glyph | hue | treatment |
78
- | ---------------------------- | ----- | --------- | -------------------------------- |
79
- | `ok` | `✓` | `#c3e88d` | outline — proof, not celebration |
80
- | `error` | `✗` | `#ff757f` | outline |
81
- | `partial:layout_unverified` | `◐` | `#ffc777` | outline; screenshot required |
82
- | `partial:opaque_content` | `◐` | `#ffc777` | outline; screenshot required |
83
- | `partial:external_resources` | `◐` | `#ffc777` | outline; screenshot required |
84
- | `partial:unstable` | `◐` | `#ffc777` | outline; screenshot required |
85
- | `tampered` | `⊘` | `#ff757f` | filled alarm badge |
86
- | `timeout` | `◌` | `#737aa2` | dim outline |
87
- | `shim_only` | `◇` | `#737aa2` | dim outline |
88
- | `probe_only` | `◈` | `#737aa2` | dim outline |
105
+ | enum (wire, verbatim) | glyph | hue | treatment |
106
+ | ---------------------------- | ----- | --------- | ----------------------------------- |
107
+ | `ok` | `✓` | `#c3e88d` | outline — proof, not celebration |
108
+ | `error` | `✗` | `#ff757f` | outline |
109
+ | `partial:layout_unverified` | `◐` | `#ffc777` | outline; screenshot required |
110
+ | `partial:opaque_content` | `◐` | `#ffc777` | outline; screenshot required |
111
+ | `partial:external_resources` | `◐` | `#ffc777` | outline; screenshot required |
112
+ | `partial:unstable` | `◐` | `#ffc777` | outline; screenshot required |
113
+ | `partial:empty_render` | `◐` | `#ffc777` | empty TSX root; screenshot required |
114
+ | `tampered` | `⊘` | `#ff757f` | filled alarm badge |
115
+ | `timeout` | `◌` | `#737aa2` | dim outline |
116
+ | `shim_only` | `◇` | `#737aa2` | dim outline |
117
+ | `probe_only` | `◈` | `#737aa2` | dim outline |
89
118
 
90
119
  `partial:external_resources` means an artifact references external HTTPS images the no-egress verifier could not load. An `ok` verdict can still carry `Verdict.screenshotError.code: "screenshot_unavailable"` when whole-artifact capture cannot be produced; it records the limit without changing the validation result. `insecure:unvalidated` means level 3 intentionally skipped validation. A missing verdict is `UNVERIFIED` with no tier.
91
120
 
@@ -133,7 +162,7 @@ Facet's insecure mode is an explicit, boot-only opt-in (`FACET_INSECURE=1|2|3`)
133
162
 
134
163
  ## MCP
135
164
 
136
- On harnesses with shell access, the CLI is the integration; the MCP adapter is for structured-tool-only environments. The npm package includes a stdio MCP adapter with five CLI-backed tools: publish, read-back, status, export, and a no-launch frame URL lookup. It requires Bun `1.4.0`. See the [MCP reference](docs/reference/mcp.md) for OpenCode, Claude Code, and Codex registration.
165
+ On harnesses with shell access, the CLI is the integration; the MCP adapter is for structured-tool-only environments. The npm package includes six CLI-backed tools: `facet_create`, `facet_publish`, `facet_read_back`, `facet_status`, `facet_export`, and `facet_open_url`. It requires Bun `1.4.0`. Register it with `bun add -g @legionworks/facet` followed by the bare `facet-mcp` command; `npx -p @legionworks/facet facet-mcp` is the no-install alternative. See the [MCP reference](docs/reference/mcp.md) for OpenCode, Claude Code, and Codex registration.
137
166
 
138
167
  ## Documentation
139
168
 
@@ -136,6 +136,17 @@
136
136
  content: "✓";
137
137
  }
138
138
 
139
+ .facet-verdict[data-interaction-error="true"] {
140
+ border-color: var(--facet-fail);
141
+ box-shadow: var(--facet-glow-danger);
142
+ }
143
+
144
+ .facet-verdict[data-interaction-error="true"]:after {
145
+ content: "✗ runtime";
146
+ color: var(--facet-fail);
147
+ font-weight: 700;
148
+ }
149
+
139
150
  .facet-verdict[data-status="error"] {
140
151
  border-color: var(--facet-fail-border);
141
152
  background: var(--facet-fail-fill);
@@ -186,6 +197,16 @@
186
197
  content: "◐";
187
198
  }
188
199
 
200
+ .facet-verdict[data-status="partial:empty_render"] {
201
+ border-color: var(--facet-partial-border);
202
+ background: var(--facet-partial-fill);
203
+ color: var(--facet-partial);
204
+ }
205
+
206
+ .facet-verdict[data-status="partial:empty_render"]:before {
207
+ content: "◐";
208
+ }
209
+
189
210
  .facet-verdict[data-status="tampered"], .facet-verdict[data-status="insecure:unvalidated"] {
190
211
  border-color: var(--facet-fail);
191
212
  background: var(--facet-fail);
@@ -194,7 +215,7 @@
194
215
  font-weight: 700;
195
216
  }
196
217
 
197
- .facet-verdict[data-status="tampered"]:before, .facet-verdict[data-status="tampered"]:before, .facet-verdict[data-status="insecure:unvalidated"]:before {
218
+ .facet-verdict[data-status="tampered"]:before, .facet-verdict[data-status="insecure:unvalidated"]:before {
198
219
  content: "⊘";
199
220
  }
200
221
 
@@ -4349,7 +4349,8 @@ var TemplateSchema = exports_external.object({
4349
4349
  name: exports_external.string().min(1),
4350
4350
  description: exports_external.string().nullable(),
4351
4351
  promotedBy: exports_external.string().min(1),
4352
- promotedAt: IsoTimestampSchema
4352
+ promotedAt: IsoTimestampSchema,
4353
+ promotionOverride: exports_external.string().nullable()
4353
4354
  });
4354
4355
 
4355
4356
  // src/shared/contracts/observed-counts.ts
@@ -4380,6 +4381,7 @@ var RenderStatusSchema = exports_external.enum([
4380
4381
  "partial:opaque_content",
4381
4382
  "partial:external_resources",
4382
4383
  "partial:unstable",
4384
+ "partial:empty_render",
4383
4385
  "tampered",
4384
4386
  "timeout",
4385
4387
  "shim_only",
@@ -4417,8 +4419,12 @@ var LexicalCountersSchema = exports_external.object({
4417
4419
  html: HtmlStructureCountsSchema.optional()
4418
4420
  });
4419
4421
  var ObservedCountShape = Object.fromEntries(OBSERVED_COUNT_KEYS.map((key) => [key, exports_external.number().int().nonnegative()]));
4422
+ var RenderedContentShape = {
4423
+ emptyRendererRoot: exports_external.boolean().optional()
4424
+ };
4420
4425
  var VerdictObservedSchema = exports_external.object({
4421
4426
  ...ObservedCountShape,
4427
+ ...RenderedContentShape,
4422
4428
  html: HtmlStructureCountsSchema.optional(),
4423
4429
  viewBoxes: exports_external.array(exports_external.string()).optional(),
4424
4430
  errorCount: exports_external.number().int().nonnegative(),
@@ -4472,6 +4478,7 @@ var Tier1ResultSchema = Tier0ResultSchema.extend({
4472
4478
  });
4473
4479
  var ProtocolObservationSchema = exports_external.object({
4474
4480
  ...ObservedCountShape,
4481
+ ...RenderedContentShape,
4475
4482
  html: HtmlStructureCountsSchema.optional(),
4476
4483
  viewBoxes: exports_external.array(exports_external.string()),
4477
4484
  errorCount: exports_external.number().int().nonnegative(),
@@ -4486,6 +4493,7 @@ var FAVICON_TINT_BY_STATUS = {
4486
4493
  "partial:opaque_content": "amber",
4487
4494
  "partial:external_resources": "amber",
4488
4495
  "partial:unstable": "amber",
4496
+ "partial:empty_render": "amber",
4489
4497
  tampered: "red",
4490
4498
  timeout: "red",
4491
4499
  shim_only: "grey",
@@ -4599,6 +4607,10 @@ var ListRequestSchema = BaseRequestSchema.extend({
4599
4607
  slugPrefix: exports_external.string().optional(),
4600
4608
  limit: exports_external.number().int().positive().max(MAX_LIST_LIMIT).optional()
4601
4609
  });
4610
+ var TemplatesRequestSchema = BaseRequestSchema.extend({
4611
+ command: exports_external.literal("templates"),
4612
+ limit: exports_external.number().int().positive().max(MAX_LIST_LIMIT).optional()
4613
+ });
4602
4614
  var ReadBackRequestSchema = BaseRequestSchema.extend({
4603
4615
  command: exports_external.literal("readBack"),
4604
4616
  artifactId: exports_external.string().min(1),
@@ -4620,7 +4632,8 @@ var PromoteRequestSchema = BaseRequestSchema.extend({
4620
4632
  revisionId: exports_external.string().min(1),
4621
4633
  name: exports_external.string().min(1),
4622
4634
  description: exports_external.string().nullable().optional(),
4623
- promotedBy: exports_external.string().min(1)
4635
+ promotedBy: exports_external.string().min(1),
4636
+ allowUnverified: exports_external.boolean().optional()
4624
4637
  });
4625
4638
  var InstantiateRequestSchema = BaseRequestSchema.extend({
4626
4639
  command: exports_external.literal("instantiate"),
@@ -4678,6 +4691,19 @@ var ListResultSchema = BaseResultSchema.extend({
4678
4691
  artifacts: exports_external.array(ArtifactEnvelopeSchema),
4679
4692
  nextCursor: exports_external.string().nullable().optional()
4680
4693
  });
4694
+ var TemplatesResultSchema = BaseResultSchema.extend({
4695
+ command: exports_external.literal("templates"),
4696
+ templates: exports_external.array(exports_external.object({
4697
+ name: exports_external.string().min(1),
4698
+ artifactId: exports_external.string().min(1),
4699
+ revisionId: exports_external.string().min(1),
4700
+ revisionSha: exports_external.string().regex(/^[a-f0-9]{64}$/),
4701
+ promotedBy: exports_external.string().min(1),
4702
+ promotedAt: exports_external.string().datetime({ offset: true }),
4703
+ promotionOverride: exports_external.string().nullable(),
4704
+ sourceVerdict: exports_external.object({ status: RenderStatusSchema, tier: exports_external.union([exports_external.literal(0), exports_external.literal(1)]) }).nullable()
4705
+ }))
4706
+ });
4681
4707
  var ReadBackResultSchema = BaseResultSchema.extend({
4682
4708
  command: exports_external.literal("readBack"),
4683
4709
  renderer: RendererSchema,
@@ -5402,7 +5428,7 @@ function setGalleryVerdict(document2, verdict) {
5402
5428
  badge.dataset.status = verdict.status;
5403
5429
  badge.childNodes[0].textContent = verdict.status.split(":")[0] ?? verdict.status;
5404
5430
  if (tier !== null) {
5405
- const detail = verdict.status === "partial:opaque_content" ? "opaque" : verdict.status === "partial:layout_unverified" ? "layout" : verdict.status === "partial:external_resources" ? "external" : verdict.status === "partial:unstable" ? "unstable" : null;
5431
+ const detail = verdict.status === "partial:opaque_content" ? "opaque" : verdict.status === "partial:layout_unverified" ? "layout" : verdict.status === "partial:external_resources" ? "external" : verdict.status === "partial:unstable" ? "unstable" : verdict.status === "partial:empty_render" ? "empty" : null;
5406
5432
  const insecure = verdict.insecure === undefined ? null : `INSECURE L${verdict.insecure.level}`;
5407
5433
  const suffix = insecure === null ? `T${verdict.tier}` : `${insecure} · T${verdict.tier}`;
5408
5434
  tier.textContent = detail === null ? `· ${suffix}` : `· ${detail} · ${suffix}`;
@@ -5546,6 +5572,42 @@ async function startGallery(runtime = browserGalleryRuntime()) {
5546
5572
  let removeThemePreferenceListener = noOp;
5547
5573
  let generation = 0;
5548
5574
  let activeFrame = null;
5575
+ let removeInteractionListener = noOp;
5576
+ const endInteractionSignal = () => {
5577
+ removeInteractionListener();
5578
+ const badge = document2.getElementById("facet-verdict");
5579
+ if (badge !== null)
5580
+ delete badge.dataset.interactionError;
5581
+ };
5582
+ const activateInteractionSignal = (frame) => {
5583
+ endInteractionSignal();
5584
+ const badge = document2.getElementById("facet-verdict");
5585
+ updateGalleryStatus("displayed");
5586
+ const frameWindow = frame.element.raw.contentWindow;
5587
+ if (frameWindow === null || typeof frameWindow.addEventListener !== "function")
5588
+ return;
5589
+ const markFailure = () => {
5590
+ if (expired || activeFrame !== frame || frame.renderResult === null)
5591
+ return;
5592
+ if (badge !== null)
5593
+ badge.dataset.interactionError = "true";
5594
+ updateGalleryStatus("displayed · runtime error during interaction");
5595
+ };
5596
+ frameWindow.addEventListener("error", markFailure, true);
5597
+ frameWindow.addEventListener("unhandledrejection", markFailure, true);
5598
+ const frameDocument = frame.element.raw.contentDocument;
5599
+ const observer = frameDocument == null || typeof MutationObserver !== "function" ? null : new MutationObserver(() => {
5600
+ if (frameDocument.querySelector("[data-facet-error]"))
5601
+ markFailure();
5602
+ });
5603
+ if (frameDocument !== null)
5604
+ observer?.observe(frameDocument, { childList: true, subtree: true });
5605
+ removeInteractionListener = () => {
5606
+ frameWindow.removeEventListener("error", markFailure, true);
5607
+ frameWindow.removeEventListener("unhandledrejection", markFailure, true);
5608
+ observer?.disconnect();
5609
+ };
5610
+ };
5549
5611
  const expireSession = () => {
5550
5612
  if (expired)
5551
5613
  return;
@@ -5556,6 +5618,7 @@ async function startGallery(runtime = browserGalleryRuntime()) {
5556
5618
  exportMenu?.sync();
5557
5619
  activeFrame?.renderResult?.setGestureMode("native");
5558
5620
  swaps?.close();
5621
+ endInteractionSignal();
5559
5622
  removeThemePreferenceListener();
5560
5623
  clearSession(window2.sessionStorage);
5561
5624
  renderSessionExpired(document2, (message) => setGalleryError(document2, message), (status) => setGalleryStatus(document2, status));
@@ -5687,6 +5750,7 @@ async function startGallery(runtime = browserGalleryRuntime()) {
5687
5750
  updateGalleryFavicon(source.verdict?.status ?? "unverified");
5688
5751
  updateGalleryVerdict(source.verdict ?? null);
5689
5752
  updateGalleryStatus("displayed");
5753
+ activateInteractionSignal(current);
5690
5754
  updateLiveState("live");
5691
5755
  const themeToggle = document2.getElementById("facet-theme-toggle");
5692
5756
  const updateThemeToggle = (mode) => {
@@ -5745,6 +5809,7 @@ async function startGallery(runtime = browserGalleryRuntime()) {
5745
5809
  return;
5746
5810
  current = next;
5747
5811
  activeFrame = next;
5812
+ activateInteractionSignal(next);
5748
5813
  commitTheme(nextMode, nextResolvedTheme);
5749
5814
  syncPanZoomToggle();
5750
5815
  syncZoomButtons();
@@ -5815,6 +5880,7 @@ async function startGallery(runtime = browserGalleryRuntime()) {
5815
5880
  if (!result.failedNewFrameReady) {
5816
5881
  current = frame;
5817
5882
  activeFrame = frame;
5883
+ activateInteractionSignal(frame);
5818
5884
  source = revision;
5819
5885
  commitTheme(revisionThemeMode, revisionTheme);
5820
5886
  syncPanZoomToggle();
@@ -5870,6 +5936,7 @@ async function startGallery(runtime = browserGalleryRuntime()) {
5870
5936
  updateSwapBar("start");
5871
5937
  updateGalleryFavicon("unverified");
5872
5938
  updateGalleryVerdict(null);
5939
+ endInteractionSignal();
5873
5940
  swaps?.enqueue({ kind: "revision", ...event });
5874
5941
  },
5875
5942
  onClose: (event) => {
@@ -5885,6 +5952,7 @@ async function startGallery(runtime = browserGalleryRuntime()) {
5885
5952
  let zoomButtonPoll;
5886
5953
  const shutdown = () => {
5887
5954
  stream.close();
5955
+ removeInteractionListener();
5888
5956
  removeThemePreferenceListener();
5889
5957
  if (zoomButtonPoll !== undefined)
5890
5958
  clearInterval(zoomButtonPoll);
@@ -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 {
@@ -42693,6 +42693,28 @@ function withFacetChartTheme(spec, theme) {
42693
42693
  }
42694
42694
  };
42695
42695
  }
42696
+ function withFacetChartDimensions(spec) {
42697
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec))
42698
+ return spec;
42699
+ const source4 = spec;
42700
+ const config = source4.config;
42701
+ if ("autosize" in source4 || typeof config === "object" && config !== null && !Array.isArray(config) && "view" in config)
42702
+ return spec;
42703
+ if ("width" in source4 && "height" in source4)
42704
+ return spec;
42705
+ const authoredConfig = typeof config === "object" && config !== null && !Array.isArray(config) ? config : {};
42706
+ const viewDefaults = {
42707
+ ...!("width" in source4) ? { continuousWidth: 640, discreteWidth: 640 } : {},
42708
+ ...!("height" in source4) ? { continuousHeight: 360, discreteHeight: 360 } : {}
42709
+ };
42710
+ return {
42711
+ ...source4,
42712
+ config: {
42713
+ ...authoredConfig,
42714
+ view: viewDefaults
42715
+ }
42716
+ };
42717
+ }
42696
42718
  async function renderChart(ctx, bytes, renderer = "svg") {
42697
42719
  const text3 = decodeArtifactBytes(bytes);
42698
42720
  let spec;
@@ -42704,7 +42726,7 @@ async function renderChart(ctx, bytes, renderer = "svg") {
42704
42726
  }
42705
42727
  let compiled;
42706
42728
  try {
42707
- compiled = compile(withFacetChartTheme(spec, ctx.theme));
42729
+ compiled = compile(withFacetChartTheme(withFacetChartDimensions(spec), ctx.theme));
42708
42730
  } catch (error3) {
42709
42731
  const message = error3 instanceof Error ? error3.message : String(error3);
42710
42732
  throw new FacetRenderError(`vega-lite compile failed: ${message}`, "chart_compile_error");
@@ -8,7 +8,7 @@
8
8
 
9
9
 
10
10
 
11
- <link rel="stylesheet" crossorigin href="./chunk-4g8rem85.css"><script type="module" crossorigin src="./chunk-c6s9k9ty.js"></script></head>
11
+ <link rel="stylesheet" crossorigin href="./chunk-2dge4gwb.css"><script type="module" crossorigin src="./chunk-48emrxg6.js"></script></head>
12
12
  <body>
13
13
  <div id="facet-shell">
14
14
  <header id="facet-status">
@@ -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
@@ -28,7 +28,8 @@ Errors use the same top level with `ok: false` and
28
28
  | `read-back` | `--artifact-id`, optional `--revision-sha` (latest when omitted), `--tier` (one of: `0` \| `1` \| `visual`) |
29
29
  | `status` | `--artifact-id`, `--start` (valueless start-then-inspect switch) |
30
30
  | `open` | `--artifact-id`, optional `--revision-sha` (latest when omitted), `--no-launch` |
31
- | `promote` | `--artifact-id`, `--revision-id`, `--name`, `--description`, `--promoted-by` |
31
+ | `promote` | `--artifact-id`, `--revision-id`, `--name`, `--description`, `--promoted-by`, `--allow-unverified` |
32
+ | `templates` | optional `--limit` |
32
33
  | `instantiate` | `--name`, `--new-slug`, `--project-id` |
33
34
  | `pin` | `--revision-id`, `--pinned` (`true` or `false`) |
34
35
  | `export` | `<artifactId>`, `--revision`, `--format source\|render`, `--out`, `--force`, `--include-bytes` |
@@ -96,6 +97,12 @@ available without invoking the desktop launcher, and a launcher failure reports
96
97
  defaults to `svg`; `canvas` is valid only for chart artifacts. An invalid
97
98
  renderer value is a usage error and exits 64.
98
99
 
100
+ For charts, Facet defaults unspecified views to 640×360 through
101
+ `config.view`. These defaults apply only when the spec has no `autosize`, no
102
+ `config.view`, and does not set both top-level `width` and `height`. An authored
103
+ dimension is preserved; composite facet, concat, and repeat views receive the
104
+ defaults through their child view config.
105
+
99
106
  `publish --type html` publishes a static, script-free HTML artifact.
100
107
  See [HTML reference](html.md) for the static / script-free contract,
101
108
  the denied element and attribute set, the vendored class vocabulary,
@@ -134,6 +141,27 @@ mode does not assert that its visuals always change.
134
141
  `FACET_HOME/secrets/promote.token`. Neither source is accepted as an argv flag
135
142
  or emitted in a CLI envelope.
136
143
 
144
+ Before promotion, request visual read-back for the exact revision. Promotion
145
+ requires Tier 1 visual verification. A refusal returns `promotion_refused`
146
+ (HTTP 409) with `error.details.reason`: `no_visual_verification` when no visual
147
+ run exists, `error` when Tier 0 failed, or the refused Tier 1 status. The gate refuses `error`,
148
+ `partial:empty_render`, `tampered`, `timeout`, `shim_only`, `probe_only`, and
149
+ `insecure:unvalidated`. Other Tier 1 statuses are allowed when Tier 0 is not an
150
+ error. `--allow-unverified` permits a refused promotion but stores the refusal
151
+ reason in the template's `promotionOverride`; this is an audit record, not
152
+ verification or a passing verdict. A duplicate template name returns
153
+ `template_name_taken` (HTTP 409).
154
+
155
+ `facet templates [--limit N]` lists promoted templates. It uses the install
156
+ token. Each row includes the source verdict, read from the stored revision when
157
+ the list is requested, and any `promotionOverride` reason.
158
+
159
+ A Tier 1 verdict whose `insecure.level === 3` marker is present is refused as
160
+ `insecure:unvalidated` even when the verdict's status would otherwise be
161
+ allow-listed (level 3 skips validation); `--allow-unverified` records the same
162
+ reason on `promotionOverride`. Levels 1 and 2 keep their status-based
163
+ treatment — they are real validator results under relaxed sandboxing.
164
+
137
165
  ## Insecure mode
138
166
 
139
167
  `FACET_INSECURE=1|2|3` sets a boot-only forced floor. `FACET_INSECURE_AUTO=1`
@@ -84,6 +84,10 @@ its sidecar is not a valid export.
84
84
  The sidecar describes the stored verdict. Export does not approve, promote,
85
85
  revalidate, or otherwise change the artifact.
86
86
 
87
+ Evidence consumers must inspect `verdict.insecure` before treating a result as
88
+ validated. In particular, an explicit Tier 1 run under insecure level 3 carries
89
+ the insecure marker even when its status is not `insecure:unvalidated`.
90
+
87
91
  ## `evidence_unavailable`
88
92
 
89
93
  Render export returns typed `evidence_unavailable` when the selected revision
@@ -4,12 +4,17 @@ On harnesses with shell access, the CLI is the integration; the MCP adapter is f
4
4
 
5
5
  Facet's npm package includes the `facet-mcp` bin. It needs Bun `1.4.0`; npm and pnpm install the package, but Bun remains the runtime.
6
6
 
7
- Run the adapter without a checkout:
7
+ Install globally, then run the bin with Bun `1.4.0` or newer:
8
8
 
9
9
  ```sh
10
- bunx -p @legionworks/facet facet-mcp
10
+ bun add -g @legionworks/facet
11
+ facet-mcp
11
12
  ```
12
13
 
14
+ Releases through `1.9.0` ship a `facet-mcp` bin that cannot launch directly. Use the next release or newer. A local package built with this fix answered JSON-RPC `initialize` after a global install. The `npx -p @legionworks/facet facet-mcp` path still needs verification against that published release before it is recommended.
15
+
16
+ Do not use `bunx -p @legionworks/facet facet-mcp` as the registration command without verifying the local Bun release. It exited immediately with status 1 on Bun `1.3.14` on the measured host; the same form also exited with status 1 under the available scratch Bun `1.4.0` runtime. These observations are environment-specific, so this caveat is not a universal claim about every Bun release or installation.
17
+
13
18
  The adapter resolves the CLI in this order: `FACET_CLI`, then `bun <adapter-relative-repository>/src/cli/main.ts`, then `facet` on `PATH`. Set `FACET_CLI` to an absolute CLI executable when the adapter should use another installation.
14
19
 
15
20
  ## Register the server
@@ -21,7 +26,7 @@ OpenCode config:
21
26
  "mcp": {
22
27
  "facet": {
23
28
  "type": "local",
24
- "command": ["bunx", "-p", "@legionworks/facet", "facet-mcp"],
29
+ "command": ["facet-mcp"],
25
30
  "enabled": true
26
31
  }
27
32
  }
@@ -34,8 +39,7 @@ Claude Code project config (`.mcp.json`):
34
39
  {
35
40
  "mcpServers": {
36
41
  "facet": {
37
- "command": "bunx",
38
- "args": ["-p", "@legionworks/facet", "facet-mcp"]
42
+ "command": "facet-mcp"
39
43
  }
40
44
  }
41
45
  }
@@ -45,8 +49,7 @@ Codex config (`~/.codex/config.toml`):
45
49
 
46
50
  ```toml
47
51
  [mcp_servers.facet]
48
- command = "bunx"
49
- args = ["-p", "@legionworks/facet", "facet-mcp"]
52
+ command = "facet-mcp"
50
53
  ```
51
54
 
52
55
  Set `FACET_HOME` in the host configuration when the adapter must use a non-default Facet runtime directory. Set `FACET_CLI` only for an alternate CLI executable.
@@ -55,6 +58,7 @@ Set `FACET_HOME` in the host configuration when the adapter must use a non-defau
55
58
 
56
59
  | Tool | Inputs | Effect |
57
60
  | ----------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
61
+ | `facet_create` | `projectId`, `slug`, `title` | Creates an artifact; pass its returned `data.artifact.id` to publish. |
58
62
  | `facet_publish` | `artifactId`, `type`, exactly one of `sourceText` or `file`; optional `execution`, `renderer`, `note`, `parentRevisionId` | Publishes inline source through CLI stdin or reads the named local file. |
59
63
  | `facet_read_back` | `artifactId`; optional `revisionSha`, `tier` (`0` \| `1` \| `visual`) | Reads the latest or named revision. Tier 1 and visual need browser evidence. |
60
64
  | `facet_status` | optional `artifactId`, `start` | Reads status. Set `start` only when activation is intended. |
@@ -65,7 +69,7 @@ Set `FACET_HOME` in the host configuration when the adapter must use a non-defau
65
69
 
66
70
  Exactly one of `sourceText` or `file` is required. The adapter returns `invalid_request` when both or neither are supplied.
67
71
 
68
- The MCP surface is the five artifact tools; run `facet doctor` through the CLI.
72
+ The MCP surface has six artifact tools. Start a cold-home flow with `facet_create`, then call `facet_publish`, `facet_read_back`, and `facet_export`. Each tool advertises its input schema in `tools/list`; run `facet doctor` through the CLI.
69
73
 
70
74
  ## Result and error handling
71
75
 
@@ -73,6 +77,10 @@ Every tool returns one text content item containing the complete versioned Facet
73
77
 
74
78
  Typed Facet failures return that same envelope with `isError: true`. The JSON body preserves `error.code`, `error.message`, `error.retryable`, and `error.details`. The adapter converts malformed CLI stdout and subprocess failures into typed `invalid_envelope` errors instead of throwing raw process text through MCP.
75
79
 
80
+ For `output_unwritable`, `error.details.out` is the absolute path whose write
81
+ failed. A malformed Mermaid fence is a verdict result, not a tool failure: the
82
+ call returns `isError: false` with `data.verdict.status: "error"`.
83
+
76
84
  ## Boundary
77
85
 
78
86
  The adapter only shells out to `facet` and parses the shared wire envelope. It does not import service, validation, or gallery code. The boundary checker permits only the MCP SDK, Zod, Node builtins, adapter-local modules, shared contracts, and the shared product version in `src/harness-adapters/mcp/`.
@@ -107,6 +107,13 @@ service.
107
107
  | `2` | Removes Tier 0 and Tier 1 network-namespace isolation. Real validators still run. |
108
108
  | `3` | Performs no validation and records `insecure:unvalidated`. |
109
109
 
110
+ Level 3 skips publish-time validation, not an explicit visual read-back.
111
+ `facet read-back --tier visual` still runs Tier 1, and that verdict carries the
112
+ insecure marker. Evidence consumers must check the marker, not infer trust from
113
+ `status` alone. A level-3 visual verdict does not count toward `facet promote`
114
+ either — the gate refuses it as `insecure:unvalidated` unless the operator
115
+ passes `--allow-unverified`.
116
+
110
117
  Levels compose as a forced floor: the effective level is never below the
111
118
  operator's `FACET_INSECURE` value. `FACET_INSECURE_AUTO=1` may raise a level when
112
119
  startup probes fail, but it never selects level 3. With auto mode off, hard
@@ -23,7 +23,7 @@ the evidence root and every per-run evidence directory.
23
23
  ## Schema migrations
24
24
 
25
25
  `runMigrations` records applied versions in `schema_migrations` and applies
26
- additive fragments in order. The current schema is v9:
26
+ additive fragments in order. The current schema is v10:
27
27
 
28
28
  | version | change |
29
29
  | ------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -36,6 +36,7 @@ additive fragments in order. The current schema is v9:
36
36
  | v7 | Backfills HTML observation defaults. |
37
37
  | v8 | Adds `tsx`, declared revision execution, and nullable `render_runs.compiled_path`. |
38
38
  | v9 | Adds `render_runs.screenshot_format`, recorded as `png` or `webp` for retained evidence. |
39
+ | v10 | Adds nullable `templates.promotion_override`; null for pre-v10 templates and for promotions that passed the verification gate. |
39
40
 
40
41
  Migrations are additive and transactional. Existing revisions are not rewritten
41
42
  when a later schema version is applied.
@@ -81,6 +82,10 @@ artifact and unlinks older screenshot and console files. Rows marked
81
82
  `retained = 1` are exempt. Cleanup is best-effort; the database row remains the
82
83
  authority and the orphan sweep can recover from stale files.
83
84
 
85
+ Facet targets one operator on a local machine. The evidence bound is the last
86
+ `EVIDENCE_LAST_N_PER_ARTIFACT` Tier 1 runs per artifact, plus any retained runs.
87
+ There is no quota; retained evidence can grow without a fixed limit.
88
+
84
89
  ## Templates
85
90
 
86
91
  A template records one immutable revision ID. Later publication to the source
@@ -29,6 +29,15 @@ and must not call `createRoot` or self-mount.
29
29
 
30
30
  ## Imports and styling
31
31
 
32
+ Artifacts cannot fetch data (`connect-src 'none'`). Generate source with the
33
+ data embedded, then publish it. For repeated updates, regenerate the file and
34
+ use `facet publish --watch` to publish each changed version:
35
+
36
+ ```sh
37
+ node -e 'const data = [{label:"A", value:3}]; require("node:fs").writeFileSync("report.tsx", `export default function Report(){ return <pre>{JSON.stringify(${JSON.stringify(data)})}</pre> }`)'
38
+ facet publish --artifact-id <id> --type tsx --file report.tsx
39
+ ```
40
+
32
41
  The compiler accepts only vendored modules: `react`, `react-dom`,
33
42
  `react-dom/client`, `react/jsx-runtime`, and `react/jsx-dev-runtime`.
34
43
  Pinned versions are React and React DOM `19.2.8`, TypeScript `5.7.3`.
@@ -62,6 +71,11 @@ has no host port or service capability.
62
71
 
63
72
  ## Storage and export
64
73
 
74
+ The TSX verifier loads the vendored `artifact.css` used by the gallery frame.
75
+ A runtime error after initial render, such as one thrown by a click handler,
76
+ appears as a gallery signal in an `aria-live="polite"` status region. It does
77
+ not rewrite the immutable verdict and clears on revision swap or theme change.
78
+
65
79
  Revision source remains immutable. Compilation creates derived bytes recorded at
66
80
  the run's `compiled_path`; TSX source export writes the original `.tsx` bytes.
67
81
  Render export remains the retained Tier 1 screenshot. Evidence retains the last