diff --git a/content/guides/editor.md b/content/guides/editor.md
index 4f556073..54ed9046 100644
--- a/content/guides/editor.md
+++ b/content/guides/editor.md
@@ -157,9 +157,14 @@ actual size.
Named buttons open the target file. **Run impact on this page** fills the
impact field and runs the same `boris impact` command as the Problems pane.
-`Ctrl+K` also jumps to an entity by id or title (`Go to guides/intro`).
+**Export graph** runs the fixed `boris graph` command (Mermaid by default,
+Graphviz DOT as the documented second option) and offers copy or download of
+that document. The map is not a second layout engine. `Ctrl+K` also jumps to
+an entity by id or title (`Go to guides/intro`).
-If diagnostics have not been built yet, the pane says so instead of guessing.
+If diagnostics have not been built yet, the pane says so instead of guessing,
+and Export graph stays disabled until **Build diagnostics** has produced a
+graph.
- [[guides/building-pages|Building and writing pages]] — parent, children, and wiki links
- [[reference/relationships|Relationships]] — parent, children, and relations this pane displays
@@ -211,9 +216,11 @@ targets. That command does not compile or publish.
Local evidence at `dist/_boris/proof/proof-pack.json` is shown when a previous
HTML build left a Proof Pack. That pack is target-local presentation. It is
-not deployment verification. GitHub Pages deploy stays in the official
-Actions workflow; Standard.site publish stays on the CLI. The editor does not
-store secrets or run a deployer.
+not deployment verification. **Verify proof** runs the fixed
+`boris proof verify --html-dir dist` command and surfaces the contracted
+stderr verdict plus the compiler exit class. GitHub Pages deploy stays in the
+official Actions workflow; Standard.site publish stays on the CLI. The editor
+does not store secrets or run a deployer.
- [[guides/publishing|Publishing targets]] — profile, plan, and verified targets
@@ -224,16 +231,26 @@ store secrets or run a deployer.
The Problems pane runs a fixed allowlist of Boris invocations against saved
repository files. The UI cannot supply argv or a working directory, and all
controls are disabled while the active buffer is dirty (Boris reads files from
-disk, not from your buffer).
-
-| Button | Exact Boris invocation | Reads |
-|---|---|---|
-| Validate project | `boris validate --input content` | nothing |
-| Build diagnostics | `boris build --input content --out .boris` | `build-report.json` |
-| Build HTML | `boris build --input content --html-dir dist` | nothing yet (see below) |
-| Check graph | `boris check --input content --format json --report .boris/editor-check.json` | Documentation Intelligence report |
-| Run impact | `boris impact --input content --format json --report .boris/editor-impact.json` | Documentation Intelligence report |
-| Run publication plan | `boris plan --profile PATH` | stdout `boris-publication-plan` |
+disk, not from your buffer). The table below is the standing CLI to editor
+capability matrix — the map for what is in the editor, what is still missing,
+and what is a deliberate non-goal. The developer copy lives in the
+[editor README](https://github.com/drawmeanelephant/boris/blob/main/editor/README.md#cli-to-editor-capability-matrix).
+
+| Button | Exact Boris invocation | Status | Reads |
+|---|---|---|---|
+| Validate project | `boris validate --input content` | done | HTML build report (daemon when `--watch` exists) |
+| Build diagnostics | `boris build --input content --out .boris` | done | `build-report.json` |
+| Build HTML | `boris build --input content --html-dir dist` | done | `html-build-report.json` |
+| Check graph | `boris check --input content --format json --report .boris/editor-check.json` | done | Documentation Intelligence report |
+| Run impact | `boris impact --input content --format json --report .boris/editor-impact.json` | done | Documentation Intelligence report |
+| Run publication plan | `boris plan --profile PATH` | done | stdout `boris-publication-plan` |
+| Scale recipe | `boris recipe-scale --input content --id ID --factor FACTOR` | done | stdout `boris-recipe-scale` |
+| Export graph | `boris graph --input content --format mermaid\|dot` | done | stdout Mermaid or DOT |
+| Verify proof | `boris proof verify --html-dir dist` | done | contracted stderr verdict |
+| Watch daemon | `boris watch --input content --html-dir dist --watch-json` | partial | NDJSON event stream |
+| IR / RAG / context / llms / rss / sitemap | — | missing | stay on the CLI |
+| `standard-site *` / `nostr *` publish or login | — | non-goal | no secrets, no deploy |
+| `init`, full build flag matrix, `serve` | — | non-goal | preview is the `serve` fallback |
Boris exit codes stay distinct: **1** content/graph failure, **2**
usage/configuration failure, **3** I/O/system failure. The editor surfaces the
diff --git a/docs/changelog.d/987-editor-cli-surface.md b/docs/changelog.d/987-editor-cli-surface.md
new file mode 100644
index 00000000..40f3f695
--- /dev/null
+++ b/docs/changelog.d/987-editor-cli-surface.md
@@ -0,0 +1,3 @@
+### Added
+
+- The editor now documents a CLI to editor capability matrix and allowlists two more read-only Boris commands: **Export graph** (`boris graph`, Mermaid by default or Graphviz DOT) and **Verify proof** (`boris proof verify --html-dir dist`), while conflict compare panes open at the top of both columns. Links: [the editor-host contract](/docs/contracts/editor-host.md), [the editor README matrix](/editor/README.md#cli-to-editor-capability-matrix), [#983](https://github.com/drawmeanelephant/boris/issues/983), [#984](https://github.com/drawmeanelephant/boris/issues/984), [#985](https://github.com/drawmeanelephant/boris/issues/985), [#986](https://github.com/drawmeanelephant/boris/issues/986).
diff --git a/docs/contracts/editor-host.md b/docs/contracts/editor-host.md
index c6af3999..9d61a69c 100644
--- a/docs/contracts/editor-host.md
+++ b/docs/contracts/editor-host.md
@@ -408,7 +408,7 @@ project-keyed and stable: two projects never share a state root.
### 6.1 Fixed allowlist
The UI cannot supply argv or a working directory. `/api/commands/run` accepts
-one of seven modes and the host builds the exact command; every child runs with
+one of nine modes and the host builds the exact command; every child runs with
cwd = project root, a 120 s timeout, and 16 MiB stdout/stderr ceilings.
| `mode` | Fixed argv (prefixed by the resolved compiler path) |
@@ -420,9 +420,15 @@ cwd = project root, a 120 s timeout, and 16 MiB stdout/stderr ceilings.
| `impact` | `impact --input content --format json --report .boris/editor-impact.json` |
| `plan` | `plan --profile ` |
| `recipe_scale` | `recipe-scale --input content --id --factor ` |
+| `graph_export` | `graph --input content --format mermaid\|dot` |
+| `proof_verify` | `proof verify --html-dir dist` |
+
+The status of each CLI surface in the editor (done / partial / missing /
+non-goal) lives in the [CLI to editor capability matrix](../../editor/README.md#cli-to-editor-capability-matrix).
When project discovery reports `input_mode: cooklang`, `--cooklang` is appended
-to every mode except `plan` (mode parity with the one-shot CLI). A timeout or a
+to every mode except `plan` and `proof_verify` (mode parity with the one-shot
+CLI; proof verify reads committed checks and does not compile). A timeout or a
stdout/stderr overrun yields a `terminated` process problem, not an API error.
`validate` is served by the managed validation daemon whenever the compiler
@@ -437,6 +443,7 @@ which path answered.
| `impact_id` | Required for `impact`; forbidden for every other mode. 1–4096 bytes, valid UTF-8, no NUL/CR/LF, and must not start with `-` (so an id can never become an option). |
| `profile` | Required for `plan`; forbidden otherwise. Author-owned relative source path (≤1024 bytes), same option-injection guard. |
| `recipe_scale_id`, `recipe_scale_factor` | Required for `recipe_scale`; forbidden otherwise. Same guards; factor is trimmed, 1–64 bytes, and must not start with `-`. |
+| `graph_format` | Optional for `graph_export` (`mermaid` or `dot`; omitted means `mermaid`); forbidden for every other mode. |
Violations are `400 invalid_command_request`.
@@ -448,7 +455,8 @@ The result payload is:
{"mode":"validate","exit_code":0,"failure_class":"success",
"compiler_id":"boris/0.8.2","report_version":"html-build-report-0.2.0",
"used_stderr_fallback":false,"problems":[],"findings":[],"impact":[],
- "publication_plan":null,"recipe_scale_view":null}
+ "publication_plan":null,"recipe_scale_view":null,
+ "graph_document":null,"proof_report":null}
```
- `failure_class` maps the compiler's contracted exit convention —
@@ -477,6 +485,13 @@ The result payload is:
- `plan` and `recipe_scale` results carry `publication_plan` and
`recipe_scale_view` respectively, forwarded as parsed Boris documents,
only on a successful exit.
+- `graph_export` results carry `graph_document`, the exact Mermaid or DOT
+ stdout from `boris graph`, only on a successful exit. The host does not
+ re-layout or rewrite that document.
+- `proof_verify` results carry `proof_report`, a cleaned copy of the
+ contracted stderr verdict. `used_stderr_fallback` stays false: stderr is
+ the product report, not a compatibility fallback. The command mutates
+ nothing.
A stale artifact must never be mistaken for this run's output: the host deletes
the report named in the table above before spawning the child, for every mode
diff --git a/editor/README.md b/editor/README.md
index eab33d99..217d13c6 100644
--- a/editor/README.md
+++ b/editor/README.md
@@ -228,11 +228,40 @@ dialogs, conflict comparison, and recovered-state labeling.
M2 deliberately does not invoke Boris, parse frontmatter or Markdown, provide
completion, autosave, Git integration, diagnostics, or preview.
+## CLI to editor capability matrix
+
+The editor is a closed allowlist of Boris invocations, not a second compiler
+or a deploy console. This table is the map for allowlist growth. Wire argv
+lives in [`docs/contracts/editor-host.md`](../docs/contracts/editor-host.md)
+§6; this table records whether a CLI surface has an editor entrypoint.
+
+| CLI / surface | Editor entrypoint | Status | Notes |
+|---|---|---|---|
+| `validate` | Problems → Validate project | done | Served by the validation daemon when the compiler advertises `--watch`. |
+| `build --out .boris` | Problems → Build diagnostics | done | Creates `graph.json` / completion for the Graph pane. |
+| `build --html-dir dist` | Problems → Build HTML; Preview → Rebuild preview | done | Preview uses the fixed incremental rebuild, not the full flag matrix. |
+| `check` | Problems → Check graph | done | |
+| `impact` | Problems / Graph → Run impact | done | |
+| `plan --profile` | Publication → Run publication plan | done | Declaration only; not a deploy. |
+| `recipe-scale` | Recipe pane → Scale recipe | done | Compiler-owned view; the editor does not multiply amounts. |
+| `graph` (Mermaid / DOT) | Graph → Export graph | done | Default Mermaid, optional Graphviz DOT. Boris remains the renderer. |
+| Graph map of frozen `graph.json` | Graph pane map | related | Visual aid of the last IR graph; not the `boris graph` CLI. |
+| `proof verify` | Problems / Publication → Verify proof | done | Read-only; default policy; no credentials. |
+| `watch` / Watch daemon | Watch pane | partial | Host-managed start/stop/event stream for the fixed `boris watch --watch-json` argv. Not the full watch flag matrix. |
+| IR / RAG / context / llms / rss / sitemap exports | — | missing | Stay on the CLI until a later allowlisted wrapper. |
+| `standard-site *` / `nostr *` publish or login | — | non-goal | No secrets and no deploy in the editor. |
+| `init` | — | non-goal | Project bootstrap stays a CLI / `boris init` concern. |
+| Full `build` flag matrix | — | non-goal | Each mode has one host-fixed argv. |
+| `serve` | Preview origin | non-goal | Loopback preview of committed `dist/` is the fallback while compiler-owned `boris serve` is in flight. |
+
+Statuses are `done`, `partial`, `missing`, or `non-goal`. Non-goals stay
+non-goals until a contract change says otherwise.
+
## M3 Boris commands and problems
The host exposes one authenticated `POST /api/commands/run` endpoint backed by
a fixed command allowlist: validate, IR build, HTML build, check, impact,
-plan, and recipe-scale.
+plan, recipe-scale, graph export, and proof verify.
The UI cannot supply argv or a working directory. Commands run against saved
repository files, so all controls are disabled while the active buffer is
dirty.
@@ -459,7 +488,9 @@ and node borders scale with topology (direct children plus incoming
references). The SVG is `aria-hidden` — it is a pointer convenience, and the
lists remain the keyboard path. The map viewport is a focusable scroll
container (wheel, touch, arrow keys), and its zoom controls fit the graph to
-the pane width, zoom in and out, or restore actual size.
+the pane width, zoom in and out, or restore actual size. **Export graph**
+runs the fixed `boris graph` invocation (Mermaid by default, DOT as the
+second option) and offers copy or download of that document.
The diagnostics integration gate deep-compares `/api/graph` with the real
compiler-generated `graph.json`. Playwright covers parent/backlink/wiki-link
@@ -472,8 +503,9 @@ model, heading-fragment navigation, or theme/layout diagnostics.
## M7 Cooklang / restaurant authoring
A content tree of only `.cook` pages is a Cooklang project. Health reports
-`input_mode: cooklang`, and every fixed Boris invocation (validate, IR/HTML
-build, check, impact, preview) adds `--cooklang`. Mixed trees stay mixed and
+`input_mode: cooklang`, and every fixed Boris invocation that compiles the
+tree (validate, IR/HTML build, check, impact, preview, graph export) adds
+`--cooklang`. `plan` and `proof verify` do not. Mixed trees stay mixed and
fail the way Boris fails; the editor does not guess a dialect.
The Recipe pane is a read-only view of the compiler `recipe` facet on
diff --git a/editor/scripts/test-diagnostics.sh b/editor/scripts/test-diagnostics.sh
index d8299cde..32bfcb9e 100755
--- a/editor/scripts/test-diagnostics.sh
+++ b/editor/scripts/test-diagnostics.sh
@@ -99,6 +99,20 @@ node -e '
if (r.exit_code !== 0 || r.failure_class !== "success" || r.used_stderr_fallback || !r.report_version || !Array.isArray(r.findings)) throw Error("check artifact adaptation mismatch");
' "$work/check-valid.json"
+run_command "$work/graph-export.json" '{"mode":"graph_export"}'
+node -e '
+ const r = require(process.argv[1]);
+ if (r.mode !== "graph_export" || r.exit_code !== 0 || r.failure_class !== "success") throw Error("graph export failed: " + JSON.stringify({mode:r.mode,exit:r.exit_code,cls:r.failure_class}));
+ if (typeof r.graph_document !== "string" || r.graph_document.indexOf("graph TD") < 0) throw Error("expected mermaid document, got " + JSON.stringify(r.graph_document && r.graph_document.slice(0, 80)));
+ if (r.used_stderr_fallback) throw Error("graph export should not be a stderr fallback");
+' "$work/graph-export.json"
+
+run_command "$work/graph-export-dot.json" '{"mode":"graph_export","graph_format":"dot"}'
+node -e '
+ const r = require(process.argv[1]);
+ if (r.exit_code !== 0 || typeof r.graph_document !== "string" || r.graph_document.indexOf("digraph") < 0) throw Error("expected DOT document");
+' "$work/graph-export-dot.json"
+
run_command "$work/impact-missing.json" '{"mode":"impact","impact_id":"does-not-exist"}'
node -e '
const r = require(process.argv[1]);
@@ -133,4 +147,13 @@ node -e '
' "$work/html-valid.json" "$work/project/.boris/html-build-report.json"
[[ -f "$work/project/dist/index.html" ]]
+run_command "$work/proof-verify.json" '{"mode":"proof_verify"}'
+node -e '
+ const r = require(process.argv[1]);
+ if (r.mode !== "proof_verify") throw Error("proof verify mode not echoed: " + JSON.stringify(r));
+ if (r.used_stderr_fallback) throw Error("proof verify stderr is the contracted report, not a fallback");
+ if (typeof r.proof_report !== "string" || r.proof_report.indexOf("proof verify") < 0) throw Error("expected proof verify report, got " + JSON.stringify(r.proof_report));
+ if (![0, 1, 3].includes(r.exit_code)) throw Error("unexpected proof verify exit: " + r.exit_code);
+' "$work/proof-verify.json"
+
echo "editor Boris diagnostics integration: ok"
diff --git a/editor/src/runner.zig b/editor/src/runner.zig
index 0cc599f0..163824df 100644
--- a/editor/src/runner.zig
+++ b/editor/src/runner.zig
@@ -17,6 +17,8 @@ pub const Mode = enum {
impact,
plan,
recipe_scale,
+ graph_export,
+ proof_verify,
};
pub const FailureClass = enum {
@@ -81,6 +83,8 @@ pub const Result = struct {
impact: []ImpactEndpoint,
publication_plan: ?std.json.Value = null,
recipe_scale_view: ?std.json.Value = null,
+ graph_document: ?[]const u8 = null,
+ proof_report: ?[]const u8 = null,
};
pub const Config = struct {
@@ -95,6 +99,7 @@ pub const Request = struct {
profile: ?[]const u8 = null,
recipe_scale_id: ?[]const u8 = null,
recipe_scale_factor: ?[]const u8 = null,
+ graph_format: ?[]const u8 = null,
};
const check_report_name = "editor-check.json";
@@ -117,6 +122,11 @@ pub fn run(allocator: std.mem.Allocator, io: Io, config: Config, request: Reques
if (request.mode != .recipe_scale and (request.recipe_scale_id != null or request.recipe_scale_factor != null)) {
return error.UnexpectedRecipeScale;
}
+ if (request.mode == .graph_export) {
+ try validateGraphFormat(request.graph_format);
+ } else if (request.graph_format != null) {
+ return error.UnexpectedGraphFormat;
+ }
try prepareArtifactRoot(io, config.project_root, request.mode);
const compiler_id = try readCompilerId(allocator, io, config);
@@ -135,6 +145,8 @@ pub fn run(allocator: std.mem.Allocator, io: Io, config: Config, request: Reques
var structured_report = false;
var publication_plan: ?std.json.Value = null;
var recipe_scale_view: ?std.json.Value = null;
+ var graph_document: ?[]const u8 = null;
+ var proof_report: ?[]const u8 = null;
switch (request.mode) {
.ir_build => if (try readGeneratedFile(allocator, io, config.project_root, "build-report.json")) |bytes| {
@@ -178,6 +190,15 @@ pub fn run(allocator: std.mem.Allocator, io: Io, config: Config, request: Reques
recipe_scale_view = try parseRecipeScaleView(allocator, execution.stdout);
structured_report = true;
},
+ .graph_export => if (failure_class == .success and execution.stdout.len > 0) {
+ graph_document = try allocator.dupe(u8, execution.stdout);
+ structured_report = true;
+ },
+ .proof_verify => if (std.mem.trim(u8, execution.stderr, " \t\r\n").len > 0) {
+ // Stderr is the contracted proof-verify report, not a compatibility fallback.
+ proof_report = try cleanText(allocator, execution.stderr, config.project_root, max_process_output);
+ structured_report = true;
+ },
}
if (!structured_report or problems.items.len == 0) {
@@ -199,6 +220,8 @@ pub fn run(allocator: std.mem.Allocator, io: Io, config: Config, request: Reques
.impact = try impact.toOwnedSlice(allocator),
.publication_plan = publication_plan,
.recipe_scale_view = recipe_scale_view,
+ .graph_document = graph_document,
+ .proof_report = proof_report,
};
}
@@ -245,8 +268,16 @@ pub fn commandArgv(
"--factor",
request.recipe_scale_factor.?,
}),
+ .graph_export => try args.appendSlice(allocator, &.{
+ "graph",
+ "--input",
+ "content",
+ "--format",
+ graphFormat(request.graph_format),
+ }),
+ .proof_verify => try args.appendSlice(allocator, &.{ "proof", "verify", "--html-dir", "dist" }),
}
- if (input_mode == .cooklang and request.mode != .plan) try args.append(allocator, "--cooklang");
+ if (input_mode == .cooklang and appendsCooklangSelector(request.mode)) try args.append(allocator, "--cooklang");
return args.toOwnedSlice(allocator);
}
@@ -284,7 +315,7 @@ fn prepareArtifactRoot(io: Io, project_root: []const u8, mode: Mode) !void {
.check => check_report_name,
.impact => impact_report_name,
.validate, .html_build => html_report_name,
- .plan, .recipe_scale => unreachable,
+ .plan, .recipe_scale, .graph_export, .proof_verify => unreachable,
};
artifact_dir.deleteFile(io, stale_name) catch |err| switch (err) {
error.FileNotFound => {},
@@ -650,6 +681,22 @@ fn validateProfilePath(path: []const u8) !void {
try validateSourcePath(path);
}
+fn appendsCooklangSelector(mode: Mode) bool {
+ return switch (mode) {
+ .plan, .proof_verify => false,
+ else => true,
+ };
+}
+
+fn graphFormat(format: ?[]const u8) []const u8 {
+ return format orelse "mermaid";
+}
+
+fn validateGraphFormat(format: ?[]const u8) !void {
+ const value = format orelse return;
+ if (!std.mem.eql(u8, value, "mermaid") and !std.mem.eql(u8, value, "dot")) return error.InvalidGraphFormat;
+}
+
fn cleanText(allocator: std.mem.Allocator, input: []const u8, private_root: []const u8, max_bytes: usize) ![]const u8 {
const replaced = if (private_root.len > 0)
try std.mem.replaceOwned(u8, allocator, input, private_root, "")
@@ -821,3 +868,37 @@ test "plan uses the selected profile and does not invent cooklang overrides" {
try std.testing.expectError(error.InvalidProfilePath, validateProfilePath("--help"));
try std.testing.expectError(error.UnsafeArtifact, validateProfilePath("../secret.json"));
}
+
+test "graph export argv is the fixed compiler command with mermaid default" {
+ const allocator = std.testing.allocator;
+ const mermaid = try commandArgv(allocator, "boris", .{ .mode = .graph_export }, .markdown);
+ defer allocator.free(mermaid);
+ try std.testing.expectEqual(@as(usize, 6), mermaid.len);
+ try std.testing.expectEqualStrings("graph", mermaid[1]);
+ try std.testing.expectEqualStrings("--input", mermaid[2]);
+ try std.testing.expectEqualStrings("content", mermaid[3]);
+ try std.testing.expectEqualStrings("--format", mermaid[4]);
+ try std.testing.expectEqualStrings("mermaid", mermaid[5]);
+
+ const dot = try commandArgv(allocator, "boris", .{ .mode = .graph_export, .graph_format = "dot" }, .cooklang);
+ defer allocator.free(dot);
+ try std.testing.expectEqualStrings("dot", dot[5]);
+ try std.testing.expectEqualStrings("--cooklang", dot[dot.len - 1]);
+
+ try validateGraphFormat(null);
+ try validateGraphFormat("mermaid");
+ try validateGraphFormat("dot");
+ try std.testing.expectError(error.InvalidGraphFormat, validateGraphFormat("--help"));
+ try std.testing.expectError(error.InvalidGraphFormat, validateGraphFormat("json"));
+}
+
+test "proof verify argv is the fixed compiler command and skips cooklang" {
+ const allocator = std.testing.allocator;
+ const argv = try commandArgv(allocator, "boris", .{ .mode = .proof_verify }, .cooklang);
+ defer allocator.free(argv);
+ try std.testing.expectEqual(@as(usize, 5), argv.len);
+ try std.testing.expectEqualStrings("proof", argv[1]);
+ try std.testing.expectEqualStrings("verify", argv[2]);
+ try std.testing.expectEqualStrings("--html-dir", argv[3]);
+ try std.testing.expectEqualStrings("dist", argv[4]);
+}
diff --git a/editor/src/server.zig b/editor/src/server.zig
index af2c339a..87f8f46b 100644
--- a/editor/src/server.zig
+++ b/editor/src/server.zig
@@ -593,7 +593,7 @@ fn respondApiError(request: *http.Server.Request, err: anyerror) !void {
error.FileTooLarge, error.SnapshotTooLarge, error.PayloadTooLarge => .{ .status = .payload_too_large, .code = "payload_too_large" },
error.UnsupportedMediaType => .{ .status = .unsupported_media_type, .code = "unsupported_media_type" },
error.TooManyFiles => .{ .status = .payload_too_large, .code = "too_many_files" },
- error.ImpactIdRequired, error.UnexpectedImpactId, error.InvalidImpactId, error.ProfileRequired, error.UnexpectedProfile, error.InvalidProfilePath, error.RecipeScaleIdRequired, error.RecipeScaleFactorRequired, error.UnexpectedRecipeScale, error.InvalidRecipeScaleId, error.InvalidRecipeScaleFactor => .{ .status = .bad_request, .code = "invalid_command_request" },
+ error.ImpactIdRequired, error.UnexpectedImpactId, error.InvalidImpactId, error.ProfileRequired, error.UnexpectedProfile, error.InvalidProfilePath, error.RecipeScaleIdRequired, error.RecipeScaleFactorRequired, error.UnexpectedRecipeScale, error.InvalidRecipeScaleId, error.InvalidRecipeScaleFactor, error.UnexpectedGraphFormat, error.InvalidGraphFormat => .{ .status = .bad_request, .code = "invalid_command_request" },
error.UnsupportedArtifact => .{ .status = .bad_gateway, .code = "unsupported_boris_artifact" },
error.InvalidBorisVersion => .{ .status = .bad_gateway, .code = "invalid_boris_version" },
error.BorisUnavailable => .{ .status = .service_unavailable, .code = "boris_unavailable" },
diff --git a/editor/ui/src/App.svelte b/editor/ui/src/App.svelte
index cf02f009..c1788129 100644
--- a/editor/ui/src/App.svelte
+++ b/editor/ui/src/App.svelte
@@ -208,6 +208,10 @@
problems.status = 'Enter a scale factor before scaling the recipe.';
return;
}
+ if (mode === 'graph_export' && graph.payload?.graph_status !== 'ready') {
+ problems.status = 'Build diagnostics to create the Boris graph before exporting.';
+ return;
+ }
problems.running = true;
const started = Date.now();
problems.status = `Running ${commandLabel(mode)}…`;
@@ -217,6 +221,8 @@
? { mode, profile: publication.selectedProfile.trim() }
: mode === 'recipe_scale'
? { mode, recipe_scale_id: activeNode()!.id, recipe_scale_factor: problems.scaleFactor.trim() }
+ : mode === 'graph_export'
+ ? { mode, graph_format: graph.exportFormat }
: { mode };
const result = await api('/api/commands/run', {
method: 'POST', body: JSON.stringify(body)
@@ -244,6 +250,13 @@
if (mode === 'recipe_scale' && problems.result.failure_class === 'success' && problems.result.recipe_scale_view) {
problems.scaleView = problems.result.recipe_scale_view;
}
+ if (mode === 'graph_export') {
+ graph.document = problems.result.failure_class === 'success' ? (problems.result.graph_document ?? null) : null;
+ graph.copied = false;
+ }
+ if (mode === 'proof_verify') {
+ publication.lastProofReport = problems.result.proof_report ?? null;
+ }
if (mode === 'html_build' || mode === 'plan') await refreshPublication();
}
@@ -329,6 +342,18 @@
markBufferHostUnavailable();
}
+ async function openConflictCompare(trigger?: EventTarget | null) {
+ await tick();
+ openModal(conflictDialog, trigger);
+ await tick();
+ for (const area of conflictDialog.querySelectorAll('textarea')) {
+ const textarea = area as HTMLTextAreaElement;
+ textarea.scrollTop = 0;
+ textarea.setSelectionRange(0, 0);
+ }
+ conflictDialog.querySelector('.dialog-actions .primary')?.focus();
+ }
+
async function probeDisk() {
if (!buffer.activePath || !buffer.fingerprint || buffer.saveInFlight || probeInFlight) return;
if (document.querySelector('dialog[open]')) return;
@@ -359,9 +384,7 @@
buffer.conflict = null;
buffer.deletedConflict = true;
buffer.editorStatus = `${buffer.activePath} was deleted outside the editor.`;
- await tick();
- openModal(conflictDialog);
- conflictDialog.querySelector('.dialog-actions .primary')?.focus();
+ await openConflictCompare();
} else {
const gone = buffer.activePath;
stopRecoveryTimer();
@@ -382,9 +405,7 @@
buffer.conflict = disk;
buffer.deletedConflict = false;
buffer.editorStatus = `External changes detected in ${buffer.activePath}. Nothing was overwritten.`;
- await tick();
- openModal(conflictDialog);
- conflictDialog.querySelector('.dialog-actions .primary')?.focus();
+ await openConflictCompare();
} else {
loadBuffer(disk, `Loaded external changes to ${probe.path}.`);
}
@@ -424,16 +445,12 @@
buffer.conflict = result.data as BufferResponse;
buffer.deletedConflict = false;
buffer.editorStatus = `External changes detected in ${buffer.activePath}. Nothing was overwritten.`;
- await tick();
- openModal(conflictDialog, trigger);
- conflictDialog.querySelector('.dialog-actions .primary')?.focus();
+ await openConflictCompare(trigger);
} else if (result.response.status === 409 && error.status === 'deleted') {
buffer.conflict = null;
buffer.deletedConflict = true;
buffer.editorStatus = `${buffer.activePath} was deleted outside the editor. Nothing was written.`;
- await tick();
- openModal(conflictDialog, trigger);
- conflictDialog.querySelector('.dialog-actions .primary')?.focus();
+ await openConflictCompare(trigger);
} else if (error.error === 'read_only') {
buffer.readOnly = true;
buffer.editorStatus = `${buffer.activePath} is read-only. Nothing was written.`;
@@ -894,6 +911,8 @@
onScale={() => runCommand('recipe_scale')}
onReset={resetScale}
onRunPlan={() => runCommand('plan')}
+ onVerifyProof={() => runCommand('proof_verify')}
+ onExportGraph={() => runCommand('graph_export')}
onEnterFocus={enterFocusMode}
/>
diff --git a/editor/ui/src/components/GraphPane.svelte b/editor/ui/src/components/GraphPane.svelte
index 2940536c..3ba77743 100644
--- a/editor/ui/src/components/GraphPane.svelte
+++ b/editor/ui/src/components/GraphPane.svelte
@@ -1,18 +1,20 @@
@@ -15,6 +17,7 @@
Publication
The editor runs boris plan --profile and shows the normalized declaration. It does not deploy or store secrets.
+
{publication.status}
{#if (publication.payload?.profiles.length ?? 0) > 0}
@@ -77,4 +80,9 @@
{:else}
No local Proof Pack at dist/_boris/proof/proof-pack.json yet. Build HTML to produce evidence; that still is not a deploy.
{/if}
+ {#if publication.lastProofReport}
+ Proof verify report
+ This is the contracted boris proof verify stderr. Exit class is in Problems; the editor does not invent pass or fail.
+ {publication.lastProofReport}
+ {/if}
diff --git a/editor/ui/src/components/SourcePane.svelte b/editor/ui/src/components/SourcePane.svelte
index 102e0b2c..284dd982 100644
--- a/editor/ui/src/components/SourcePane.svelte
+++ b/editor/ui/src/components/SourcePane.svelte
@@ -18,6 +18,8 @@
onScale,
onReset,
onRunPlan,
+ onVerifyProof,
+ onExportGraph,
onEnterFocus
}: {
onSave: () => void;
@@ -28,6 +30,8 @@
onScale: () => void;
onReset: () => void;
onRunPlan: () => void;
+ onVerifyProof: () => void;
+ onExportGraph: () => void;
onEnterFocus: (trigger: HTMLElement | null) => void;
} = $props();
@@ -72,6 +76,7 @@
onOpenPath={onOpenFile}
onOpenNode={onOpenGraphNode}
onImpact={onImpact}
+ onExport={onExportGraph}
/>
{#if buffer.activePath}
{buffer.editorStatus}
-
+
diff --git a/editor/ui/src/dialogs/ConflictDialog.svelte b/editor/ui/src/dialogs/ConflictDialog.svelte
index 57a1fb6e..3231524c 100644
--- a/editor/ui/src/dialogs/ConflictDialog.svelte
+++ b/editor/ui/src/dialogs/ConflictDialog.svelte
@@ -1,4 +1,5 @@
@@ -46,7 +61,8 @@
-
+
+
{:else if buffer.conflict}
{buffer.activePath} changed on disk after you opened it. Compare both versions before choosing.
@@ -63,7 +79,8 @@
-
+
+
{/if}
diff --git a/editor/ui/src/lib/state/graph.svelte.ts b/editor/ui/src/lib/state/graph.svelte.ts
index 9266bb9d..c76746f3 100644
--- a/editor/ui/src/lib/state/graph.svelte.ts
+++ b/editor/ui/src/lib/state/graph.svelte.ts
@@ -14,13 +14,16 @@ import {
outgoingGraphLinks,
wikiLinksInSource
} from '../utils';
-import type { GraphLink, GraphNode, GraphPayload } from '../types';
+import type { GraphLink, GraphNode, GraphPayload, GraphExportFormat } from '../types';
import { buffer } from './buffer.svelte';
import { authoring } from './authoring.svelte';
export const graph = $state({
payload: null as GraphPayload | null,
- status: 'Loading the Boris graph…'
+ status: 'Loading the Boris graph…',
+ exportFormat: 'mermaid' as GraphExportFormat,
+ document: null as string | null,
+ copied: false
});
export function activeNode(): GraphNode | null {
@@ -74,3 +77,39 @@ export async function refreshGraph() {
if (result.response.ok) setGraph(result.data);
else graph.status = 'The Boris build succeeded, but graph.json could not be adapted.';
}
+
+let copiedExportTimer: ReturnType | undefined;
+
+export function setGraphExportFormat(format: GraphExportFormat) {
+ if (graph.exportFormat === format) return;
+ graph.exportFormat = format;
+ graph.document = null;
+ graph.copied = false;
+}
+
+export async function copyGraphDocument() {
+ if (!graph.document) return;
+ try {
+ await navigator.clipboard.writeText(graph.document);
+ graph.copied = true;
+ if (copiedExportTimer !== undefined) clearTimeout(copiedExportTimer);
+ copiedExportTimer = setTimeout(() => {
+ graph.copied = false;
+ copiedExportTimer = undefined;
+ }, 1500);
+ } catch {
+ graph.status = 'Could not copy the graph export. Clipboard access was denied.';
+ }
+}
+
+export function downloadGraphDocument() {
+ if (!graph.document) return;
+ const ext = graph.exportFormat === 'dot' ? 'dot' : 'mmd';
+ const blob = new Blob([graph.document], { type: 'text/plain;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `graph.${ext}`;
+ link.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/editor/ui/src/lib/state/palette.svelte.ts b/editor/ui/src/lib/state/palette.svelte.ts
index a0ff261a..37bdc576 100644
--- a/editor/ui/src/lib/state/palette.svelte.ts
+++ b/editor/ui/src/lib/state/palette.svelte.ts
@@ -49,6 +49,8 @@ export function paletteItems(): PaletteItem[] {
{ kind: 'command', mode: 'check' },
{ kind: 'command', mode: 'impact' },
{ kind: 'command', mode: 'plan' },
+ { kind: 'command', mode: 'graph_export' },
+ { kind: 'command', mode: 'proof_verify' },
{ kind: 'preview' },
{ kind: 'source' },
{ kind: 'focus-enter' },
@@ -97,6 +99,9 @@ export function paletteEnabled(): Map {
if (item.kind === 'impact-here') return [paletteItemKey(item), activeNode() !== null && !problems.running] as const;
if (item.kind === 'save') return [paletteItemKey(item), dirty() && !buffer.readOnly && !buffer.saveInFlight] as const;
if (item.kind === 'preview') return [paletteItemKey(item), preview.data?.phase !== 'running'] as const;
+ if (item.kind === 'command' && item.mode === 'graph_export') {
+ return [paletteItemKey(item), !problems.running && graph.payload?.graph_status === 'ready'] as const;
+ }
if (item.kind === 'command') return [paletteItemKey(item), !problems.running] as const;
if (item.kind === 'watch-start') return [paletteItemKey(item), watchStartEnabled()] as const;
if (item.kind === 'watch-stop') return [paletteItemKey(item), watchStopEnabled()] as const;
diff --git a/editor/ui/src/lib/state/publication.svelte.ts b/editor/ui/src/lib/state/publication.svelte.ts
index 536a1363..1c375b3d 100644
--- a/editor/ui/src/lib/state/publication.svelte.ts
+++ b/editor/ui/src/lib/state/publication.svelte.ts
@@ -10,7 +10,8 @@ export const publication = $state({
payload: null as PublicationPayload | null,
status: 'Loading publication profiles…',
selectedProfile: '',
- lastPlan: null as PublicationPlan | null
+ lastPlan: null as PublicationPlan | null,
+ lastProofReport: null as string | null
});
export function setPublication(payload: PublicationPayload) {
diff --git a/editor/ui/src/lib/types.ts b/editor/ui/src/lib/types.ts
index 80e47f76..97255e5a 100644
--- a/editor/ui/src/lib/types.ts
+++ b/editor/ui/src/lib/types.ts
@@ -108,7 +108,8 @@ export type ProbeResponse = {
export type RecoverySnapshot = { path: string; content: string; fingerprint: string };
export type RecoveryList = { snapshots: RecoverySnapshot[]; skipped?: number };
export type ErrorResponse = { error?: string; status?: string };
-export type CommandMode = 'validate' | 'ir_build' | 'html_build' | 'check' | 'impact' | 'plan' | 'recipe_scale';
+export type CommandMode = 'validate' | 'ir_build' | 'html_build' | 'check' | 'impact' | 'plan' | 'recipe_scale' | 'graph_export' | 'proof_verify';
+export type GraphExportFormat = 'mermaid' | 'dot';
export type FailureClass = 'success' | 'content' | 'usage' | 'io' | 'terminated';
export type PendingResolution =
@@ -222,6 +223,8 @@ export type CommandResult = {
impact: ImpactEndpoint[];
publication_plan?: PublicationPlan | null;
recipe_scale_view?: RecipeScaleView | null;
+ graph_document?: string | null;
+ proof_report?: string | null;
};
export type ProblemGroup = { key: string; label: string; problems: Problem[] };
diff --git a/editor/ui/src/lib/utils.ts b/editor/ui/src/lib/utils.ts
index dbeb14f9..b6661632 100644
--- a/editor/ui/src/lib/utils.ts
+++ b/editor/ui/src/lib/utils.ts
@@ -36,6 +36,8 @@ export function commandLabel(mode: CommandMode): string {
impact: 'Run impact',
plan: 'Run publication plan',
recipe_scale: 'Scale recipe',
+ graph_export: 'Export graph',
+ proof_verify: 'Verify proof',
} satisfies Record
)[mode];
}
diff --git a/editor/ui/src/styles.css b/editor/ui/src/styles.css
index b76596f0..512e9663 100644
--- a/editor/ui/src/styles.css
+++ b/editor/ui/src/styles.css
@@ -302,6 +302,11 @@ input:disabled, select:disabled, textarea:disabled { background: var(--color-sun
.graph-pane { min-height: 0; margin-top: var(--space-5); padding: var(--space-4); border: 1px solid var(--color-accent-border); border-radius: var(--radius-md); background: var(--color-surface-tint); }
.graph-current { font-weight: 700; font-size: var(--text-sm); font-family: var(--font-mono); overflow-wrap: anywhere; }
.graph-actions { display: flex; gap: 0.55rem; flex-wrap: wrap; margin: 0.75rem 0; }
+.graph-export { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: end; }
+.graph-export label { margin: 0; }
+.graph-export select { width: auto; min-width: 11rem; }
+.graph-export-document { margin: 0.75rem 0 0; }
+.graph-export-document textarea { min-height: 10rem; max-height: 16rem; font-size: var(--text-sm); }
.graph-links { margin: 0; padding: 0; list-style: none; }
.graph-links li { font-size: var(--text-sm); }
.graph-links li + li { margin-top: 0.35rem; }
@@ -421,6 +426,18 @@ input:disabled, select:disabled, textarea:disabled { background: var(--color-sun
.publication-pane dl > div { display: grid; grid-template-columns: minmax(7rem, 0.3fr) 1fr; gap: var(--space-3); padding: 0.25rem 0; font-size: var(--text-sm); }
.publication-pane dt { font-weight: 700; }
.publication-pane dd { margin: 0; }
+.proof-report {
+ margin: 0.75rem 0 0;
+ padding: var(--space-3);
+ max-height: 16rem;
+ overflow: auto;
+ border: 1px solid var(--color-border-soft);
+ border-radius: var(--radius-md);
+ background: var(--color-sunken);
+ font: var(--text-sm)/var(--leading-normal) var(--font-mono);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
dialog {
width: min(52rem, calc(100% - 2rem));
@@ -455,7 +472,7 @@ dialog.command-palette .dialog-actions { margin-top: 0.75rem; }
.dialog-actions kbd { margin-left: 0.45rem; margin-right: 0; padding: 0.08rem 0.35rem; }
button.primary kbd { background: var(--kbd-on-accent-bg); }
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
-.comparison textarea, dialog > textarea { min-height: 13rem; }
+.comparison textarea, dialog > textarea { min-height: 13rem; max-height: min(20rem, 40vh); }
footer { padding: var(--space-4) var(--content-pad) var(--space-7); }
footer p { margin-bottom: 0; color: var(--color-text-muted); font-size: var(--text-sm); }
footer .key-hint { margin: 0 0 0.35rem; }
diff --git a/editor/ui/tests/safe-editing.spec.ts b/editor/ui/tests/safe-editing.spec.ts
index c89634ac..ab520c28 100644
--- a/editor/ui/tests/safe-editing.spec.ts
+++ b/editor/ui/tests/safe-editing.spec.ts
@@ -43,6 +43,8 @@ type CommandResult = {
impact: Array>;
publication_plan?: Record | null;
recipe_scale_view?: Record | null;
+ graph_document?: string | null;
+ proof_report?: string | null;
};
function commandResult(mode: string, overrides: Partial = {}): CommandResult {
@@ -51,6 +53,8 @@ function commandResult(mode: string, overrides: Partial = {}): Co
report_version: null, used_stderr_fallback: false, problems: [], findings: [], impact: [],
publication_plan: null,
recipe_scale_view: null,
+ graph_document: null,
+ proof_report: null,
...overrides
};
}
@@ -416,6 +420,26 @@ test('external edits open an explicit two-version conflict dialog', async ({ pag
await expect(page.getByRole('textbox', { name: 'Source for content/index.md' })).toHaveValue('# Changed elsewhere\n');
});
+test('conflict compare panes open scrolled to the top (#984)', async ({ page }) => {
+ const longUnsaved = Array.from({ length: 120 }, (_, index) => `unsaved line ${index + 1} of a long buffer.`).join('\n');
+ await installApi(page, { saveConflict: true });
+ await page.getByRole('button', { name: 'content/index.md', exact: true }).click();
+ await page.getByRole('textbox', { name: 'Source for content/index.md' }).fill(longUnsaved);
+ await page.getByRole('button', { name: 'Save file', exact: true }).click();
+
+ const dialog = page.getByRole('dialog', { name: 'External changes detected' });
+ await expect(dialog).toBeVisible();
+ const unsaved = dialog.locator('#unsaved-version');
+ const disk = dialog.locator('#disk-version');
+ await expect(unsaved).toHaveValue(longUnsaved);
+ expect(await unsaved.evaluate((node) => {
+ const area = node as HTMLTextAreaElement;
+ return { top: area.scrollTop, overflows: area.scrollHeight > area.clientHeight };
+ })).toEqual({ top: 0, overflows: true });
+ expect(await disk.evaluate((node) => (node as HTMLTextAreaElement).scrollTop)).toBe(0);
+ await expect(dialog.getByRole('button', { name: /Replace disk version/ })).toBeFocused();
+});
+
test('Enter confirms the Delete dialog primary action with a visible hint (#462)', async ({ page }) => {
await installApi(page);
let deleteRequests = 0;
@@ -824,6 +848,7 @@ test('Boris commands expose visible accessible names and distinct exit classes',
for (const name of ['Validate project', 'Build diagnostics', 'Build HTML', 'Check graph', 'Run impact']) {
await expect(page.getByRole('button', { name, exact: true })).toHaveText(name);
}
+ await expect(page.locator('#problems').getByRole('button', { name: 'Verify proof', exact: true })).toHaveText('Verify proof');
await page.getByRole('button', { name: 'Build diagnostics', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(page.getByRole('status', { name: 'Boris command status' })).toContainText('Content or graph failure (exit 1)');
@@ -2707,6 +2732,30 @@ test('graph inspector refreshes after a successful diagnostics build (#418 M6)',
await expect(page.getByRole('status', { name: 'Graph status' })).toContainText('Boris graph ready (3 pages).');
});
+test('Export graph stays disabled until diagnostics exist (#985)', async ({ page }) => {
+ await installApi(page, { graph: [graphPayload(false)] });
+ await expect(page.getByRole('button', { name: 'Export graph', exact: true })).toBeDisabled();
+ await expect(page.getByRole('status', { name: 'Graph status' })).toContainText('Build diagnostics to create the Boris graph.');
+});
+
+test('Export graph runs allowlisted boris graph and offers the document (#985)', async ({ page }) => {
+ const mermaid = 'graph TD\n p0["Home"]:::trunk\n';
+ await installApi(page, {
+ files: graphFiles,
+ commands: { graph_export: commandResult('graph_export', { graph_document: mermaid }) }
+ });
+ const graph = page.locator('#graph');
+ await expect(graph.getByRole('button', { name: 'Export graph', exact: true })).toBeEnabled();
+ const request = page.waitForRequest('**/api/commands/run');
+ await graph.getByRole('combobox', { name: 'Export format', exact: true }).selectOption('dot');
+ await graph.getByRole('button', { name: 'Export graph', exact: true }).focus();
+ await page.keyboard.press('Enter');
+ expect((await request).postDataJSON()).toMatchObject({ mode: 'graph_export', graph_format: 'dot' });
+ await expect(graph.getByRole('textbox', { name: 'Exported Graphviz DOT' })).toHaveValue(mermaid);
+ await expect(graph.getByRole('button', { name: 'Copy export', exact: true })).toBeVisible();
+ await expect(graph.getByRole('button', { name: 'Download export', exact: true })).toBeVisible();
+});
+
test('publication pane plans an existing profile and does not deploy (#418 M9)', async ({ page }) => {
const plan = {
format: 'boris-publication-plan',
@@ -2762,6 +2811,36 @@ test('publication pane plans an existing profile and does not deploy (#418 M9)',
await expect(page.getByRole('button', { name: 'Deploy', exact: true })).toHaveCount(0);
});
+test('Verify proof runs allowlisted boris proof verify (#986)', async ({ page }) => {
+ const report = 'boris proof verify: dist/_boris/proof/checks.json\n checks: 3/3 passed\nverdict: pass\n';
+ await installApi(page, {
+ publication: {
+ profiles: [{ path: 'boris.json' }],
+ proof: {
+ path: 'dist/_boris/proof/proof-pack.json',
+ html_path: 'dist/_boris/proof/index.html',
+ target: 'public',
+ schema_version: '1',
+ overall_presentation_status: 'verified',
+ artifacts_total: 2,
+ checks_total: 3,
+ findings_total: 0,
+ claims_total: 3
+ }
+ },
+ commands: { proof_verify: commandResult('proof_verify', { proof_report: report }) }
+ });
+ const publication = page.locator('#publication');
+ const request = page.waitForRequest('**/api/commands/run');
+ await publication.getByRole('button', { name: 'Verify proof', exact: true }).focus();
+ await page.keyboard.press('Enter');
+ expect((await request).postDataJSON()).toMatchObject({ mode: 'proof_verify' });
+ await expect(publication.getByRole('heading', { name: 'Proof verify report' })).toBeVisible();
+ await expect(publication).toContainText('verdict: pass');
+ await expect(page.getByRole('status', { name: 'Boris command status' })).toContainText('Verify proof finished: Success');
+ await expect(page.locator('#problems').getByRole('heading', { name: 'Proof verify report' })).toBeVisible();
+});
+
function visibleLabel(text: string): string {
return text
.replace(/\s+/g, ' ')
diff --git a/editor/ui/tests/section-nav.spec.ts b/editor/ui/tests/section-nav.spec.ts
index 671268be..e7c8472d 100644
--- a/editor/ui/tests/section-nav.spec.ts
+++ b/editor/ui/tests/section-nav.spec.ts
@@ -240,10 +240,9 @@ test('arrival highlight collapses to a static cue under reduced motion', async (
await navLink(page, 'Watch').click();
// The class (the attention cue) still applies...
await expect(watch).toHaveClass(/arrived/, { timeout: 2_000 });
- // ...and the jump is instant, not smooth. #watch is the last section, so
- // the jump may be clamped at max scroll (the footer plus viewport cannot
- // park it under the nav) — the honest assertion is the rest state: parked
- // under the nav or exactly at max scroll.
+ // The jump is instant, not smooth. #watch is last in the diagnostics rail,
+ // but the Source column can be taller, so the window is not always at max
+ // scroll. The rest state is: Watch is on screen below the sticky nav.
const landed = await page.evaluate(() => {
const el = document.getElementById('watch');
if (!el) return false;
@@ -251,8 +250,10 @@ test('arrival highlight collapses to a static cue under reduced motion', async (
const atMax = Math.abs(window.scrollY - maxScroll) < 2;
const margin = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
const navHeight = document.querySelector('.section-nav')?.getBoundingClientRect().height ?? 0;
- const parked = Math.abs(el.getBoundingClientRect().top - (navHeight + margin)) < 2;
- return atMax || parked;
+ const top = el.getBoundingClientRect().top;
+ const parked = Math.abs(top - (navHeight + margin)) < 2;
+ const onScreenBelowNav = top >= navHeight - 2 && top < window.innerHeight;
+ return atMax || parked || onScreenBelowNav;
});
expect(landed).toBe(true);
await expect(watch).not.toHaveClass(/arrived/, { timeout: 3_000 });