From 46545d8ba4e2684740cf48f75bb2d418d92b8cce Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:30:19 -0400 Subject: [PATCH 1/5] feat(editor): make Source the hero with Author/Review density and a Problems hierarchy A cold open now lands in a calm Author view (Source + Project; review panes unmounted, authoring hints folded, and section-nav links to Review panes switching modes before they land) while Review keeps the full chrome. Source gains a measured line gutter, current-line band, and presentation-only frontmatter seam over the native textarea, and Problems leads with primary Build & validate actions, carries per-command aria-busy progress, and collapses large reports. Refs #988 --- content/guides/editor.md | 27 ++ .../changelog.d/990-editor-writing-surface.md | 3 + editor/README.md | 40 ++ editor/ui/src/App.svelte | 54 ++- editor/ui/src/components/ActionGroup.svelte | 21 + .../ui/src/components/AuthoringTools.svelte | 22 +- editor/ui/src/components/Header.svelte | 34 +- editor/ui/src/components/ProblemsPane.svelte | 63 ++- .../ui/src/components/PublicationPane.svelte | 19 +- editor/ui/src/components/ReportBlock.svelte | 27 ++ editor/ui/src/components/SectionNav.svelte | 50 ++- editor/ui/src/components/SourcePane.svelte | 377 ++++++++++++++++-- editor/ui/src/lib/state/density.svelte.ts | 39 ++ editor/ui/src/lib/state/problems.svelte.ts | 5 +- editor/ui/src/styles.css | 197 ++++++++- editor/ui/tests/density-modes.spec.ts | 357 +++++++++++++++++ editor/ui/tests/graph-map.spec.ts | 3 + editor/ui/tests/reading-hierarchy.spec.ts | 4 + editor/ui/tests/safe-editing.spec.ts | 4 + editor/ui/tests/section-nav.spec.ts | 4 + editor/ui/tests/watch-admin.spec.ts | 3 + 21 files changed, 1288 insertions(+), 65 deletions(-) create mode 100644 docs/changelog.d/990-editor-writing-surface.md create mode 100644 editor/ui/src/components/ActionGroup.svelte create mode 100644 editor/ui/src/components/ReportBlock.svelte create mode 100644 editor/ui/src/lib/state/density.svelte.ts create mode 100644 editor/ui/tests/density-modes.spec.ts diff --git a/content/guides/editor.md b/content/guides/editor.md index 54ed9046..bf09822a 100644 --- a/content/guides/editor.md +++ b/content/guides/editor.md @@ -226,6 +226,25 @@ does not store secrets or run a deployer. --- +## Density modes and the writing surface + +The shell opens in **Author** mode: Source and Project only, with the +authoring hints folded into one disclosure under the editor. **Review** mode +restores the full chrome — Problems, Preview, Watch, Graph, and Publication. +The choice is a per-browser preference, not project state; switching modes +never touches your buffer or unsaved changes. + +Source is the hero in both modes. Around the native textarea the editor draws +presentation chrome: a line gutter measured against the real text geometry +(numbers for the visible window sit where their lines actually are, so +wrapped lines cannot drift them, and the current line is highlighted), a +current-line band, and a seam under a recognized leading frontmatter fence. The seam reads fence *shape* only — it +does not parse YAML or keys, and **Build diagnostics** and Boris remain the +authority on frontmatter. + +A section-nav link for a pane that lives in Review switches modes and then +jumps to that pane, so no link pretends a hidden pane is on screen. + ## Compiler-backed commands and problems The Problems pane runs a fixed allowlist of Boris invocations against saved @@ -256,6 +275,14 @@ Boris exit codes stay distinct: **1** content/graph failure, **2** usage/configuration failure, **3** I/O/system failure. The editor surfaces the class plus the raw exit code. +Commands are laid out by how often an author runs them: **Validate project** +and **Build diagnostics** lead in a primary group, **Build HTML** sits with +them, and **Check graph**, **Verify proof**, and **Run impact** recede into +the analysis row — all still named, visible, and reachable from the command +palette. The command in flight shows an in-button progress affordance and +`aria-busy`, not only the status sentence, and a proof report longer than a +screenful starts collapsed behind **Show proof verify report**. + Diagnostics are grouped by content-relative source, severity, and Boris code. Each problem card offers: diff --git a/docs/changelog.d/990-editor-writing-surface.md b/docs/changelog.d/990-editor-writing-surface.md new file mode 100644 index 00000000..b18c4a0c --- /dev/null +++ b/docs/changelog.d/990-editor-writing-surface.md @@ -0,0 +1,3 @@ +### Changed + +- The editor's everyday surface now reads as a writing tool first: a default **Author** density mode (Source + Project; review panes unmounted, authoring hints folded, and section-nav links to Review panes switching modes before they land) beside the full **Review** chrome, Source as a measured hero surface with a measured line gutter, current-line band, and presentation-only frontmatter seam, and a primary/secondary action hierarchy in Problems with per-command `aria-busy` progress and large reports collapsed behind an explicit disclosure. Links: [the editor guide](/content/guides/editor.md#density-modes-and-the-writing-surface), [#988](https://github.com/drawmeanelephant/boris/issues/988), [#989](https://github.com/drawmeanelephant/boris/issues/989), [#990](https://github.com/drawmeanelephant/boris/issues/990), [#991](https://github.com/drawmeanelephant/boris/issues/991). diff --git a/editor/README.md b/editor/README.md index 217d13c6..e499c0b7 100644 --- a/editor/README.md +++ b/editor/README.md @@ -744,6 +744,46 @@ labels. Presentation only: no endpoint, no Boris surface, and no pipeline change. +## Density modes and the writing surface + +The shell has two density modes (#990), persisted per browser under +`boris-editor-density` and validated on load like the other editor +preferences. The mode is disposable UI state, never project truth: + +- **Author** (the cold-open default): Source + Project. The review panes + (Problems, Preview, Watch, Graph, Publication) are not mounted, and the + authoring hints fold into a single disclosure under the writing surface + instead of a second card. A section-nav link whose pane lives in Review + switches modes and then lands on the real pane — it stays enabled, muted, + and titled, so the nav never claims a hidden pane is present. +- **Review**: the full diagnostics chrome, unchanged. + +Source is the hero in both modes (#989): the writing column outweighs the +file and rail columns, and the editing surface is a bordered, elevated shell +whose chrome is presentation only: + +- a measured **line gutter** — numbers for the visible window are placed at + each line's measured position and the current line is highlighted, so + wrapped lines cannot drift them (a fixed-rhythm number column would lie on + wrapped prose); +- a **current-line band** behind the text; +- a **frontmatter/body seam** drawn when the buffer opens with a recognized + `---` … `---` fence pair. It reads fence shape only and validates nothing; + Boris remains the frontmatter authority. + +The geometry comes from a hidden mirror that holds the buffer verbatim with +the textarea's exact font, padding, and wrapping metrics, the same technique +Focus writing mode uses for its paragraph bands. The native textarea remains +the editing authority; typing, undo/redo, save, and recovery are the shell's +existing machinery. + +Problems now carries an action hierarchy (#991): a primary **Build and +validate** group (Validate project, Build diagnostics, Build HTML) and a +secondary **Analysis** group (Check graph, Verify proof), with Run impact in +its named row. The in-flight command carries `aria-busy` plus an in-button +progress affordance, and a proof report over 24 lines starts collapsed behind +a `Show …` disclosure. The allowlist and exit-class reporting are unchanged. + ## Project file tree The Project pane renders an indented directory tree over the host's file list. diff --git a/editor/ui/src/App.svelte b/editor/ui/src/App.svelte index c1788129..bb8d32d8 100644 --- a/editor/ui/src/App.svelte +++ b/editor/ui/src/App.svelte @@ -1,5 +1,7 @@ @@ -890,11 +917,16 @@
- + -
+
-
- - rebuildPreview('manual')} /> - -
+ {#if density.mode === 'review'} +
+ + rebuildPreview('manual')} /> + +
+ {/if}
+ import type { Snippet } from 'svelte'; + + // One action row of the editor's shared hierarchy language (#991): a + // labelled group whose tone carries primary vs secondary weight. Buttons + // keep their own accessible names; the group label is only a landmark for + // assistive tech and pointer grouping. + let { + label, + tone = 'plain', + children + }: { + label: string; + tone?: 'plain' | 'primary' | 'secondary'; + children: Snippet; + } = $props(); + + +
+ {@render children()} +
diff --git a/editor/ui/src/components/AuthoringTools.svelte b/editor/ui/src/components/AuthoringTools.svelte index fa1c1c1a..8549b8d1 100644 --- a/editor/ui/src/components/AuthoringTools.svelte +++ b/editor/ui/src/components/AuthoringTools.svelte @@ -4,6 +4,11 @@ import { authoring, suggestions, changeCompletionKind, refreshAuthoring } from '../lib/state/authoring.svelte'; import { buffer, insertSuggestion } from '../lib/state/buffer.svelte'; + // Author mode (#990) keeps the hints present but folded away: the combobox + // and schema bounds are one disclosure, not a permanent slab under the + // writing surface. Review mode renders the same content expanded. + let { collapsed = false }: { collapsed?: boolean } = $props(); + // The completion combobox owns its keyboard behavior: Esc closes the list, // the arrows move the active suggestion, Enter inserts it. Focus and input // always reopen the list after an Esc close. @@ -28,7 +33,7 @@ } - +{/snippet} + +{#if collapsed} +
+ Boris authoring hints +
+ {@render tools()} +
+
+{:else} + +{/if} diff --git a/editor/ui/src/components/Header.svelte b/editor/ui/src/components/Header.svelte index 9c3cde7a..84d52df8 100644 --- a/editor/ui/src/components/Header.svelte +++ b/editor/ui/src/components/Header.svelte @@ -1,5 +1,6 @@ @@ -18,10 +19,31 @@

Boris Editor

{connection}

- +
+ +
+ + +
+ +
diff --git a/editor/ui/src/components/ProblemsPane.svelte b/editor/ui/src/components/ProblemsPane.svelte index 6dd15ca4..7dd533de 100644 --- a/editor/ui/src/components/ProblemsPane.svelte +++ b/editor/ui/src/components/ProblemsPane.svelte @@ -13,6 +13,8 @@ import { problems, problemGroups, problemsNotice, staleProblems, copyDiagnosticPacket } from '../lib/state/problems.svelte'; import { connection } from '../lib/state/connection.svelte'; import { dirty } from '../lib/state/buffer.svelte'; + import ActionGroup from './ActionGroup.svelte'; + import ReportBlock from './ReportBlock.svelte'; let { onRunCommand, @@ -21,6 +23,12 @@ onRunCommand: (mode: CommandMode) => void; onNavigate: (problem: Problem) => void; } = $props(); + + // The busy affordance lands on the button that was pressed, not on every + // command in the row (#991). + function running(mode: CommandMode): boolean { + return problems.running && problems.runningMode === mode; + }
@@ -47,18 +55,57 @@ {#if dirty() && ((problems.result?.problems.length ?? 0) > 0 || problemsNotice().clean)}

Problems reflect saved files; the open buffer has unsaved changes.

{/if} -
- - - - - +
+ + + + + + + + + +
(problems.impactId = (e.currentTarget as HTMLInputElement).value)} /> - +
{#if dirty()} @@ -107,7 +154,7 @@ {#if problems.result?.proof_report}

Proof verify report

-
{problems.result.proof_report}
+
{/if} {#if problems.result && problems.result.findings.length > 0} diff --git a/editor/ui/src/components/PublicationPane.svelte b/editor/ui/src/components/PublicationPane.svelte index dd0eb5de..ef377216 100644 --- a/editor/ui/src/components/PublicationPane.svelte +++ b/editor/ui/src/components/PublicationPane.svelte @@ -1,6 +1,7 @@ + +{#if large} +
+ Show {summary} ({lines} lines) +
{report}
+
+{:else} +
{report}
+{/if} diff --git a/editor/ui/src/components/SectionNav.svelte b/editor/ui/src/components/SectionNav.svelte index ea1366d2..29afee72 100644 --- a/editor/ui/src/components/SectionNav.svelte +++ b/editor/ui/src/components/SectionNav.svelte @@ -17,6 +17,8 @@ // presented disabled and explains itself on activation instead of // silently doing nothing. Graph stays available with no file open // (#970); the map is a project-level artifact. + import { tick } from 'svelte'; + type SectionLink = { id: string; label: string }; type Props = { @@ -24,12 +26,19 @@ // human reason. Absent targets render aria-disabled with the reason as // their title; activation is a no-op that reports the reason. unavailable?: Record; + // Section ids that live in another density mode (#990), mapped to that + // mode's name. The link stays fully enabled: activation asks the owner + // to reveal the mode first, then performs the ordinary jump. + modeGated?: Record; // Receives the reason for a click on an unavailable target; App routes // it to the editing-status live region. onBlockedNav?: (reason: string) => void; + // Switches to the mode that owns a gated section and resolves once its + // pane is mounted. + onReveal?: (id: string) => Promise | void; }; - let { unavailable = {}, onBlockedNav }: Props = $props(); + let { unavailable = {}, modeGated = {}, onBlockedNav, onReveal }: Props = $props(); const links: SectionLink[] = [ { id: 'project', label: 'Project' }, @@ -66,6 +75,12 @@ return document.getElementById(id); } + // A section hidden by the current density mode renders no boxes (unmounted + // by the mode switch), so the spy must not let it claim the reading line. + function sectionVisible(section: HTMLElement | null): section is HTMLElement { + return section !== null && section.getClientRects().length > 0; + } + function clearArrival() { arrivedElement?.classList.remove('arrived'); arrivedElement = undefined; @@ -110,7 +125,7 @@ // through to the browser (new tab/window), keyboard activation goes // through the same click event. preventDefault plus the manual jump keeps // URL and behavior in one place; href remains the no-JS fallback. - function handleNav(event: MouseEvent, id: string) { + async function handleNav(event: MouseEvent, id: string) { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; // An absent target never jumps and never touches the URL (#944); the // reason goes to the App's live region so the no-op says why. Enter on @@ -121,6 +136,14 @@ onBlockedNav?.(reason); return; } + // A mode-gated target (#990) is shown in the other density mode: switch + // first, then take the ordinary jump so focus, URL, and currency land on + // the real pane instead of a no-op. + if (modeGated[id]) { + event.preventDefault(); + await onReveal?.(id); + await tick(); + } const section = sectionFor(id); if (!section) return; event.preventDefault(); @@ -165,7 +188,7 @@ const maxScroll = document.documentElement.scrollHeight - window.innerHeight; if (maxScroll > 0 && window.scrollY >= maxScroll - 1) { for (let i = links.length - 1; i >= 0; i--) { - if (sectionFor(links[i].id)) { + if (sectionVisible(sectionFor(links[i].id))) { current = links[i].id; return; } @@ -188,7 +211,7 @@ let bestTop = -Infinity; for (const { id } of links) { const section = sectionFor(id); - if (!section) continue; + if (!sectionVisible(section)) continue; const margin = parseFloat(getComputedStyle(section).scrollMarginTop) || 0; const top = section.getBoundingClientRect().top; // The reading line every section is measured against is its own @@ -210,7 +233,7 @@ // again, so this self-releases and never pins currency to a stale jump. if (jumpTarget && best !== jumpTarget) { const target = sectionFor(jumpTarget); - if (target) { + if (sectionVisible(target)) { const margin = parseFloat(getComputedStyle(target).scrollMarginTop) || 0; const top = target.getBoundingClientRect().top; if (top <= margin + LINE_TOLERANCE_PX && top >= bestTop) best = jumpTarget; @@ -274,6 +297,17 @@ updateEdges(); }); + // A density-mode switch mounts or unmounts whole panes (#990): re-run the + // spy after the DOM settles so aria-current cannot point at a pane that is + // no longer on screen, or miss one that just appeared. + $effect(() => { + void modeGated; + void (async () => { + await tick(); + syncCurrent(); + })(); + }); + $effect(() => () => { clearTimeout(arrivedTimer); clearTimeout(resyncTimer); @@ -292,10 +326,12 @@ {#each links as { id, label } (id)} handleNav(event, id)} + onclick={(event) => void handleNav(event, id)} aria-current={current === id ? 'true' : undefined} aria-disabled={unavailable[id] ? 'true' : undefined} - title={unavailable[id]} + class:mode-gated={Boolean(modeGated[id])} + title={unavailable[id] + ?? (modeGated[id] ? `Shown in ${modeGated[id]} mode — activating switches modes` : undefined)} >{label} {/each}
diff --git a/editor/ui/src/components/SourcePane.svelte b/editor/ui/src/components/SourcePane.svelte index 284dd982..1749ccb4 100644 --- a/editor/ui/src/components/SourcePane.svelte +++ b/editor/ui/src/components/SourcePane.svelte @@ -1,6 +1,7 @@
@@ -47,37 +340,76 @@
- - + +
{#if buffer.activePath} - - +
+ +
+ + + +
+
+
+ Line {buffer.cursor.line}, column {buffer.cursor.column} + + {buffer.readOnly ? 'Read-only file' : dirty() ? 'Unsaved changes' : 'Saved on disk'} + +
+ {:else}

Choose a file from Project files. Generated output and editor state are intentionally excluded.

{/if} - + {#if density.mode === 'review'} + + {/if} {#if buffer.activePath} -

- {buffer.readOnly ? 'Read-only file' : dirty() ? 'Unsaved changes' : 'Saved on disk'} -

{#if activeProblems().length > 0}
diff --git a/editor/ui/src/lib/state/density.svelte.ts b/editor/ui/src/lib/state/density.svelte.ts new file mode 100644 index 00000000..607fcd88 --- /dev/null +++ b/editor/ui/src/lib/state/density.svelte.ts @@ -0,0 +1,39 @@ +// editor/ui/src/lib/state/density.svelte.ts +// Editor density modes (#990): the calm Author writing view or the full +// Review chrome. This is disposable UI state — a per-browser preference, not +// project truth — so it persists beside the other editor prefs and is +// validated on load. Focus writing mode stays a separate overlay. + +export type DensityMode = 'author' | 'review'; + +const STORAGE_KEY = 'boris-editor-density'; + +function storedMode(): DensityMode | null { + try { + const value = localStorage.getItem(STORAGE_KEY); + return value === 'author' || value === 'review' ? value : null; + } catch { + return null; + } +} + +export const density = $state({ + mode: 'author' as DensityMode +}); + +export function initDensity() { + density.mode = storedMode() ?? 'author'; +} + +export function setDensity(mode: DensityMode) { + density.mode = mode; + try { + localStorage.setItem(STORAGE_KEY, mode); + } catch { + // Persistence is best-effort; the in-session choice still applies. + } +} + +export function toggleDensity() { + setDensity(density.mode === 'author' ? 'review' : 'author'); +} diff --git a/editor/ui/src/lib/state/problems.svelte.ts b/editor/ui/src/lib/state/problems.svelte.ts index 229115b8..69d8b6fb 100644 --- a/editor/ui/src/lib/state/problems.svelte.ts +++ b/editor/ui/src/lib/state/problems.svelte.ts @@ -7,7 +7,7 @@ import { api, elapsedLabel } from '../api'; import { failureLabel, groupProblems, packetCopyKey, projectPathForProblem } from '../utils'; -import type { CommandResult, Problem, ProblemGroup, RecipeScaleView, ValidateState } from '../types'; +import type { CommandMode, CommandResult, Problem, ProblemGroup, RecipeScaleView, ValidateState } from '../types'; import { buffer, dirty } from './buffer.svelte'; import { connection } from './connection.svelte'; import { graph, activeNode } from './graph.svelte'; @@ -16,6 +16,9 @@ export const problems = $state({ result: null as CommandResult | null, status: 'No Boris command has run yet.', running: false, + // Which allowlisted command is in flight, so the action hierarchy can put + // its busy affordance on the button that was actually pressed (#991). + runningMode: '' as CommandMode | '', impactId: '', copiedPacketKey: '', validateState: null as ValidateState | null, diff --git a/editor/ui/src/styles.css b/editor/ui/src/styles.css index 512e9663..82452568 100644 --- a/editor/ui/src/styles.css +++ b/editor/ui/src/styles.css @@ -96,6 +96,34 @@ p { margin-top: 0; } .theme-toggle:hover:not(:disabled) { background: var(--header-chip-bg-hover); border-color: var(--header-chip-border); } .theme-toggle:focus-visible { outline-color: var(--color-focus); } +/* Density modes (#990): one segmented control in the header. It is a + disposable per-browser preference, so it sits beside the theme toggle + rather than inside any pane. */ +.header-preferences { display: flex; align-items: center; gap: 0.7rem; flex-wrap: wrap; margin-bottom: 0.35rem; } +.header-preferences .theme-toggle { margin-bottom: 0; } +.density-toggle { + display: inline-flex; + padding: 0.15rem; + border: 1px solid var(--header-chip-border); + border-radius: var(--radius-pill); + background: var(--header-chip-bg); +} +.density-option { + min-height: 1.8rem; + padding: 0.15rem 0.7rem; + border: 0; + border-radius: var(--radius-pill); + color: var(--color-header-text); + background: transparent; + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.02em; +} +.density-option:hover:not(:disabled) { background: var(--header-chip-bg-hover); border-color: transparent; } +.density-option:focus-visible { outline-color: var(--color-focus); } +.density-option[aria-pressed='true'] { color: var(--color-on-accent); background: var(--color-accent); } +.density-option[aria-pressed='true']:hover:not(:disabled) { background: var(--color-accent-strong); } + .section-nav { display: flex; align-items: center; @@ -120,6 +148,10 @@ p { margin-top: 0; } title and activation feedback remain discoverable. */ .section-nav a[aria-disabled='true'] { color: var(--color-text-faint); background: none; } .section-nav a[aria-disabled='true']:hover { background: none; text-decoration: none; } +/* #990: a link whose pane lives in another density mode still works — it is + muted, not disabled, and activating it switches modes and lands. */ +.section-nav a.mode-gated { color: var(--color-text-muted); } +.section-nav a.mode-gated:hover { background: var(--color-accent-soft); color: var(--color-link); } /* #941 E: edge affordance for the scrollable mobile pill row — the edge hint is a visible bar, not a silent clip. Bars are positioned against the non-scrolling nav (the row inside is the scroller), come up only on the @@ -160,7 +192,11 @@ section.arrived { .recovery-banner ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; } .recovery-banner li { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; } -main { display: grid; grid-template-columns: minmax(16rem, 0.7fr) minmax(24rem, 1.3fr); gap: var(--space-4); padding: var(--space-5) var(--content-pad) var(--space-6); } +/* Source is the hero (#989): the writing column outweighs the file and rail + columns at every breakpoint, and Author mode (#990) drops the rail entirely + so the same column carries the page. */ +main { display: grid; grid-template-columns: minmax(15rem, 0.55fr) minmax(26rem, 2.1fr); gap: var(--space-4); padding: var(--space-5) var(--content-pad) var(--space-6); } +main.author-mode { grid-template-columns: minmax(15rem, 0.5fr) minmax(26rem, 2.2fr); } section { min-height: 11rem; padding: var(--space-6); @@ -292,6 +328,121 @@ input:disabled, select:disabled, textarea:disabled { background: var(--color-sun .authoring-tools dd { margin: 0; } .buffer-state { margin-top: 0.65rem; color: var(--color-ok); font-weight: 700; font-size: var(--text-sm); } .buffer-state.warning { color: var(--color-danger); } + +/* --- Source writing chrome (#989) ---------------------------------------- + The textarea stays the editing authority; the shell around it is + presentation. A mirror (below the text) measures line boxes for the + caret-ruler gutter, the current-line band, and the frontmatter seam; the + gutter and band layers share one translate(-scrollTop) so scrolling never + re-renders a number. */ +#source { + border-color: var(--color-border-strong); + box-shadow: var(--shadow-2); +} +.source-editor-shell { + --source-pad-y: 1rem; + --source-pad-x: 1.1rem; + position: relative; + display: flex; + margin-top: 0.15rem; + min-height: 24rem; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-lg); + background: var(--color-surface); + box-shadow: var(--shadow-2); + overflow: hidden; +} +/* Author mode trades chrome for writing height (#990). */ +.author-mode .source-editor-shell { min-height: clamp(28rem, 66vh, 46rem); } +.source-editor-shell.readonly { background: var(--color-sunken); } +.source-gutter { + position: relative; + flex: 0 0 auto; + width: 3.4rem; + overflow: hidden; + background: var(--color-sunken); + border-right: 1px solid var(--color-border-soft); +} +.source-editor-shell.readonly .source-gutter { background: transparent; } +.source-gutter-inner { position: absolute; inset: 0 0 auto 0; will-change: transform; } +/* The current line's number is measured in content coordinates; the rail + shows only what was measured, so a wrapped line can never drift it. */ +.source-gutter-line { + position: absolute; + right: 0.6rem; + color: var(--color-text-faint); + font: var(--text-code)/var(--leading-loose) var(--font-mono); + font-variant-numeric: tabular-nums; +} +.source-gutter-line.current { color: var(--color-accent); font-weight: 700; } +.source-surface { position: relative; flex: 1 1 auto; min-width: 0; } +/* Identical wrapping metrics to the textarea, including the scrollbar + gutter, so mirror rects map 1:1 to buffer coordinates. */ +.source-mirror { + position: absolute; + inset: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + user-select: none; + white-space: pre-wrap; + overflow-wrap: break-word; + scrollbar-gutter: stable; + padding: var(--source-pad-y) var(--source-pad-x); + font: var(--text-code)/var(--leading-loose) var(--font-mono); +} +.source-surface textarea { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + min-height: 0; + resize: none; + padding: var(--source-pad-y) var(--source-pad-x); + border: 0; + border-radius: 0; + background: transparent; + color: var(--color-text); + font: var(--text-code)/var(--leading-loose) var(--font-mono); + scrollbar-gutter: stable; + overflow-wrap: break-word; +} +.source-surface textarea[readonly] { background: transparent; color: var(--color-text-muted); } +/* The rust focus ring is inset so the shell's rounded clip never hides it. */ +.source-surface textarea:focus-visible { outline-offset: -0.2rem; } +.source-lines { position: absolute; inset: 0; overflow: hidden; pointer-events: none; } +.source-lines-inner { position: absolute; inset: 0; will-change: transform; } +.source-current-line { + position: absolute; + left: 0; + right: 0; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-accent-soft) 65%, transparent); +} +/* Presentation-only separator: a recognized leading fence shape, drawn where + the body starts. It reads no keys and validates nothing. */ +.source-frontmatter-seam { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--color-accent-border); } +.source-seam-label { + position: absolute; + right: 0.45rem; + top: 0.1rem; + padding: 0 0.35rem; + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text-faint); + font-size: var(--text-2xs); + letter-spacing: 0.06em; + text-transform: uppercase; +} +.source-status-line { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; margin-top: 0.4rem; } +.source-caret { color: var(--color-text-faint); font: var(--text-sm)/var(--leading-normal) var(--font-mono); font-variant-numeric: tabular-nums; } +.source-status-line .buffer-state { margin-top: 0; } + +/* Author-mode authoring hints (#990): present, folded, one disclosure. */ +.authoring-tools > summary { cursor: pointer; font-size: var(--text-sm); font-weight: 700; color: var(--color-text-muted); } +.authoring-tools > summary:hover { color: var(--color-text); } +.authoring-tools[open] > summary { margin-bottom: var(--space-3); } +.authoring-collapse-body .pane-heading h3 { margin-bottom: 0.2rem; } .inline-problems { margin-top: var(--space-4); padding: 0.85rem; border: 1px solid var(--color-danger-border); border-radius: var(--radius-md); background: var(--color-danger-soft); } .inline-problems h3 { margin: 0 0 0.5rem; } .inline-problems ul, .problem-group ul, .analysis-results ul { margin: 0; padding: 0; list-style: none; } @@ -379,7 +530,34 @@ input:disabled, select:disabled, textarea:disabled { background: var(--color-sun .command-result { flex: 0 0 auto; padding: 0.3rem 0.7rem; border: 1px solid var(--color-ok-border); border-radius: var(--radius-pill); color: var(--color-ok); background: var(--color-ok-soft); font-size: var(--text-sm); font-weight: 700; white-space: nowrap; } .command-result.failure { color: var(--color-danger-strong); background: var(--color-danger-soft-strong); border-color: var(--color-danger-border); } .problems-notice { margin: 0.75rem 0 0; padding: 0.6rem 0.75rem; border-left: 0.3rem solid var(--color-border-strong); border-radius: var(--radius-sm); background: var(--color-sunken); font-size: var(--text-sm); } -.command-bar { display: flex; gap: 0.55rem; flex-wrap: wrap; margin: var(--space-4) 0; } +/* Action hierarchy (#991): the commands an author runs constantly sit in a + tinted primary group; the rarer analysis commands recede in a secondary + group, still visible and named. The language is shared with Source and + Publication toolbars through .action-group / button.quiet. */ +.command-bar { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: start; margin: var(--space-4) 0; } +.action-group { display: flex; gap: 0.55rem; flex-wrap: wrap; } +.action-group-primary { padding: 0.4rem; border: 1px solid var(--color-accent-border); border-radius: var(--radius-md); background: var(--color-surface-tint); } +.action-group-secondary button { border-color: var(--color-border); color: var(--color-text-muted); } +.action-group-secondary button:hover:not(:disabled) { color: var(--color-text); } +button.quiet { border-color: var(--color-border); color: var(--color-text-muted); } +button.quiet:hover:not(:disabled) { color: var(--color-text); } +.impact-command button { border-color: var(--color-border); color: var(--color-text-muted); } +.impact-command button:hover:not(:disabled) { color: var(--color-text); } +/* The busy affordance is on the button that was pressed, in addition to the + status sentence; the pseudo-element keeps the accessible name untouched. */ +button.is-running::after { + content: ''; + display: inline-block; + width: 0.62em; + height: 0.62em; + margin-left: 0.45em; + vertical-align: -0.04em; + border: 0.14em solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: command-spin 0.8s linear infinite; +} +@keyframes command-spin { to { transform: rotate(1turn); } } .impact-command { max-width: 38rem; margin: var(--space-4) 0; } .impact-command > div { display: grid; grid-template-columns: minmax(12rem, 1fr) auto; gap: 0.55rem; } .warning-text, .fallback-notice { padding: 0.75rem; border-left: 0.3rem solid var(--color-warn-accent); border-radius: var(--radius-sm); background: var(--color-warn-soft); color: var(--color-warn); font-size: var(--text-sm); } @@ -426,6 +604,12 @@ 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; } +/* Large Boris reports start collapsed (#991); short ones stay inline. */ +.report-details { margin-top: 0.75rem; } +.report-details > summary { cursor: pointer; font-size: var(--text-sm); font-weight: 600; color: var(--color-link); } +.report-details > summary:hover { text-decoration: underline; } +.report-details > summary:focus-visible { outline: var(--focus-ring-width) solid var(--color-focus); outline-offset: var(--focus-ring-offset); } +.report-details .proof-report { margin-top: 0.5rem; } .proof-report { margin: 0.75rem 0 0; padding: var(--space-3); @@ -804,7 +988,7 @@ footer .key-hint { margin: 0 0 0.35rem; } .connection, .recovery-banner ul { margin-top: var(--space-4); } .section-nav-row { overflow-x: auto; } .section-nav-fade { display: block; } - main, .comparison { grid-template-columns: 1fr; } + main, main.author-mode, .comparison { grid-template-columns: 1fr; } .project-pane, .source-pane, .problems-pane { min-height: auto; } .analysis-results li { display: block; } /* .pane-heading already wraps, so the text block takes the full width and @@ -819,8 +1003,11 @@ footer .key-hint { margin: 0 0 0.35rem; } /* Three columns, sized by what has to fit rather than by equal thirds: the Project column needs enough width for a full content path to sit on one line (a broken path is the one place where wrapping costs meaning), the - Source column is the working surface, and the rail carries prose. */ - main { grid-template-columns: minmax(16rem, 0.68fr) minmax(24rem, 1.28fr) minmax(20rem, 1.04fr); } + Source column is the working surface and takes the weight (#989), and the + rail carries prose. Author mode (#990) drops the rail: two columns, with + the writing surface taking the freed width. */ + main { grid-template-columns: minmax(15rem, 0.5fr) minmax(28rem, 1.9fr) minmax(20rem, 1.05fr); } + main.author-mode { grid-template-columns: minmax(15rem, 0.5fr) minmax(28rem, 2.2fr); } .workspace-rail { grid-column: auto; max-height: calc(100vh - 14rem); overflow-y: auto; } /* Keep rail panes from shrinking below their content: a shrunken box spills its controls under the later-painted sibling pane, which covers clicks on diff --git a/editor/ui/tests/density-modes.spec.ts b/editor/ui/tests/density-modes.spec.ts new file mode 100644 index 00000000..16e4e921 --- /dev/null +++ b/editor/ui/tests/density-modes.spec.ts @@ -0,0 +1,357 @@ +import { expect, test, type Page } from '@playwright/test'; + +// Writing-surface e2e (#988, #989, #990, #991). Uses the same mocked-host +// approach as safe-editing.spec.ts: every endpoint is routed in-page, no Zig +// host is spawned. +// +// The suite pins three contracts: +// 1. Density modes: a cold open is the calm Author view (Source + Project); +// Review restores the full chrome; the choice is disposable, persisted +// per browser, and never touches the buffer. +// 2. Section navigation stays honest across modes: a target that lives in +// the other mode switches modes and then lands; it is never disabled. +// 3. Writing chrome: the measured line gutter, the current-line band, and +// the presentation-only frontmatter seam all track the buffer. +// 4. Problems action hierarchy: primary vs secondary groups, the busy +// affordance, and collapsed large reports. + +const FILES = [ + { path: 'boris.json' }, + { path: 'content/index.md' }, + { path: 'content/guides/frontmatter.md' } +]; + +const HOME_CONTENT = '---\nid: index\ntitle: Home\nparent: null\nstatus: published\n---\n\n# Home\n\nBody.\n'; +const PLAIN_CONTENT = '# Home\n\nNo frontmatter here.\n'; + +type MockOptions = { + mode?: 'author' | 'review'; + content?: string; + commandDelayMs?: number; + proofReport?: string | null; +}; + +type CommandResultShape = { + mode: string; + exit_code: number; + failure_class: string; + compiler_id: string; + report_version: string | null; + used_stderr_fallback: boolean; + problems: Array>; + findings: Array>; + 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 = {}): CommandResultShape { + return { + mode, + exit_code: 0, + failure_class: 'success', + compiler_id: 'boris/0.8.2', + report_version: null, + used_stderr_fallback: false, + problems: [], + findings: [], + impact: [], + publication_plan: null, + recipe_scale_view: null, + graph_document: null, + proof_report: null, + ...overrides + }; +} + +async function installApi(page: Page, options: MockOptions = {}) { + await page.route('**/api/health', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + status: 'ok', + editor_id: 'boris-editor/0.1.0', + project: { content: true, default_layout: true, publication_profile: true, input_mode: 'markdown' } + }) + })); + await page.route('**/api/version', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ compiler_id: 'boris/0.8.2' }) + })); + await page.route('**/api/files', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ files: FILES }) + })); + await page.route('**/api/recovery', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ snapshots: [], skipped: 0 }) + })); + await page.route('**/api/recovery/snapshot', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ status: 'snapshotted' }) + })); + await page.route('**/api/recovery/clear', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ status: 'cleared' }) + })); + await page.route('**/api/files/probe', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ status: 'unchanged', fingerprint: 'a'.repeat(64), read_only: false }) + })); + await page.route('**/api/files/open', async route => { + const { path } = route.request().postDataJSON() as { path: string }; + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + status: 'opened', + path, + content: options.content ?? HOME_CONTENT, + fingerprint: 'a'.repeat(64), + read_only: false + }) + }); + }); + await page.route('**/api/commands/run', async route => { + const { mode } = route.request().postDataJSON() as { mode: string }; + if (options.commandDelayMs) await new Promise(resolve => setTimeout(resolve, options.commandDelayMs)); + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(commandResult(mode, { proof_report: options.proofReport ?? null })) + }); + }); + await page.route('**/api/authoring', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + frontmatter_schema: { title: 'Boris frontmatter grammar (schema v1)', properties: { id: { type: 'string' } } }, + completion: null, + completion_status: 'build_required' + }) + })); + await page.route('**/api/graph', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ graph: null, graph_status: 'build_required' }) + })); + await page.route('**/api/publication', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ profiles: [{ path: 'boris.json' }], proof: null }) + })); + await page.route('**/api/preview/state', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + phase: 'idle', generation: 0, exit_code: null, used_stderr_fallback: false, + message: 'Preview has not been built yet.', preview_url: 'https://preview.invalid/?token=test' + }) + })); + await page.route('**/api/watch/state', route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + supported: false, state: 'idle', seq: 0, cycle: 0, events_count: 0, oldest_seq: null, + dropped_lines: 0, last_event: null, compiler_id: null, hello_schema: null, last_error: null + }) + })); + for (const endpoint of ['start', 'stop']) { + await page.route(`**/api/watch/${endpoint}`, route => route.fulfill({ + status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'not_found' }) + })); + } + await page.route(/\/api\/watch\/events/, route => route.fulfill({ + status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'not_found' }) + })); + if (options.mode) { + await page.addInitScript((mode: string) => localStorage.setItem('boris-editor-density', mode), options.mode); + } + await page.goto('/#token=test-session-token'); +} + +function densityToggle(page: Page) { + return page.getByRole('group', { name: 'Editor density' }); +} + +async function openHome(page: Page) { + await page.getByRole('button', { name: 'content/index.md', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'Source for content/index.md' })).toBeVisible(); + return page.getByRole('textbox', { name: 'Source for content/index.md' }); +} + +async function switchToReview(page: Page) { + await densityToggle(page).getByRole('button', { name: 'Review', exact: true }).click(); + await expect(page.locator('#problems')).toBeVisible(); +} + +test('a cold open is the calm Author view with Source as the writing surface', async ({ page }) => { + await installApi(page); + const editor = await openHome(page); + + // Author is selected and the review panes are not mounted at all — they + // cannot pretend to be present. + await expect(densityToggle(page).getByRole('button', { name: 'Author', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await expect(densityToggle(page).getByRole('button', { name: 'Review', exact: true })).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('.workspace-rail')).toHaveCount(0); + await expect(page.locator('#problems')).toHaveCount(0); + await expect(page.locator('#preview')).toHaveCount(0); + await expect(page.locator('#watch')).toHaveCount(0); + await expect(page.locator('#publication')).toHaveCount(0); + + // The writing surface keeps its accessible name and the authoring hints are + // folded to one disclosure instead of a second card under the editor. + await expect(editor).toBeVisible(); + const hints = page.locator('details.authoring-collapse'); + await expect(hints).toBeVisible(); + await expect(hints).not.toHaveAttribute('open', ''); + await expect(hints.locator(':scope > summary')).toHaveText('Boris authoring hints'); +}); + +test('Author gives the writing surface more height, and the buffer survives both toggles', async ({ page }) => { + await installApi(page); + const editor = await openHome(page); + await editor.fill('# Draft\n\nUnsaved line.\n'); + const authorHeight = (await editor.boundingBox())!.height; + + await switchToReview(page); + const reviewHeight = (await editor.boundingBox())!.height; + expect(authorHeight).toBeGreaterThan(reviewHeight + 60); + await expect(editor).toHaveValue('# Draft\n\nUnsaved line.\n'); + await expect(page.getByText('Unsaved changes', { exact: true })).toBeVisible(); + + await densityToggle(page).getByRole('button', { name: 'Author', exact: true }).click(); + await expect(page.locator('#problems')).toHaveCount(0); + await expect(editor).toHaveValue('# Draft\n\nUnsaved line.\n'); + await expect(page.getByText('Unsaved changes', { exact: true })).toBeVisible(); +}); + +test('the density choice is disposable editor state that survives a reload', async ({ page }) => { + await installApi(page); + await switchToReview(page); + await page.reload(); + await expect(page.locator('#problems')).toBeVisible(); + await expect(densityToggle(page).getByRole('button', { name: 'Review', exact: true })).toHaveAttribute('aria-pressed', 'true'); + + await densityToggle(page).getByRole('button', { name: 'Author', exact: true }).click(); + await expect(page.locator('#problems')).toHaveCount(0); + await page.reload(); + await expect(page.locator('#problems')).toHaveCount(0); + await expect(densityToggle(page).getByRole('button', { name: 'Author', exact: true })).toHaveAttribute('aria-pressed', 'true'); +}); + +test('a nav link to a pane in the other mode switches modes and lands there', async ({ page }) => { + await installApi(page); + const problems = page.getByRole('navigation', { name: 'Editor sections' }).getByRole('link', { name: 'Problems', exact: true }); + // Honest, not disabled: the target is available, just in Review. + await expect(problems).not.toHaveAttribute('aria-disabled'); + await expect(problems).toHaveClass(/mode-gated/); + await problems.click(); + + await expect(densityToggle(page).getByRole('button', { name: 'Review', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#problems')).toBeVisible(); + await expect(page.locator('#problems')).toBeFocused(); + await expect(page.locator('#problems')).toHaveClass(/arrived/, { timeout: 2_000 }); + await expect(page.getByRole('status', { name: 'Editing status' })).toContainText('Switched to Review mode'); +}); + +test('the measured line gutter and current-line band track the caret', async ({ page }) => { + await installApi(page); + const editor = await openHome(page); + + // Line 1 is current on open; the band and the rail number agree. + await expect(page.locator('.source-gutter-line.current')).toHaveText('1'); + const band = page.locator('.source-current-line'); + await expect(band).toHaveCount(1); + const firstTop = await band.evaluate(el => Number.parseFloat((el as HTMLElement).style.top)); + + // Move the caret to a later line and let the select event update state. + await editor.evaluate((el: HTMLTextAreaElement) => { + const offset = el.value.indexOf('# Home'); + el.focus(); + el.setSelectionRange(offset, offset); + el.dispatchEvent(new Event('select')); + }); + await expect(page.locator('.source-gutter-line.current')).toHaveText('8'); + await expect(page.locator('.source-caret')).toContainText('Line 8'); + const laterTop = await page.locator('.source-current-line').evaluate(el => Number.parseFloat((el as HTMLElement).style.top)); + expect(laterTop).toBeGreaterThan(firstTop); + + // The frontmatter seam is drawn once, for the recognized leading fence + // shape; it is presentation, so it lives outside the accessibility tree. + const seam = page.locator('.source-frontmatter-seam'); + await expect(seam).toHaveCount(1); + await expect(seam.locator('.source-seam-label')).toHaveText('frontmatter ends'); + await expect(page.locator('.source-gutter')).toHaveAttribute('aria-hidden', 'true'); + await expect(page.locator('.source-mirror')).toHaveAttribute('aria-hidden', 'true'); +}); + +test('a buffer without a frontmatter fence gets no seam', async ({ page }) => { + await installApi(page, { content: PLAIN_CONTENT }); + await openHome(page); + await expect(page.locator('.source-frontmatter-seam')).toHaveCount(0); + await expect(page.locator('.source-current-line')).toHaveCount(1); +}); + +test('the measured gutter follows a long buffer while scrolling', async ({ page }) => { + const long = Array.from({ length: 200 }, (_, index) => `line ${index + 1} of a long document`).join('\n'); + await installApi(page, { content: long }); + const editor = await openHome(page); + + const scrollTop = await editor.evaluate((el: HTMLTextAreaElement) => { + el.scrollTop = el.scrollHeight; + return el.scrollTop; + }); + await expect(page.locator('.source-gutter-inner')).toContainText('200'); + // The gutter layer shares the textarea's scroll offset, so the measured + // numbers stay glued to their lines. + await expect(page.locator('.source-gutter-inner')).toHaveCSS('transform', `matrix(1, 0, 0, 1, 0, ${-scrollTop})`); +}); + +test('Problems leads with primary actions and keeps analysis in a secondary group', async ({ page }) => { + await installApi(page, { mode: 'review' }); + const problems = page.locator('#problems'); + await openHome(page); + + const buildGroup = problems.getByRole('group', { name: 'Build and validate' }); + const analysisGroup = problems.getByRole('group', { name: 'Analysis' }); + await expect(buildGroup.getByRole('button', { name: 'Validate project', exact: true })).toHaveClass(/primary/); + await expect(buildGroup.getByRole('button', { name: 'Build diagnostics', exact: true })).toHaveClass(/primary/); + await expect(buildGroup.getByRole('button', { name: 'Build HTML', exact: true })).toBeVisible(); + await expect(analysisGroup.getByRole('button', { name: 'Check graph', exact: true })).toBeVisible(); + await expect(analysisGroup.getByRole('button', { name: 'Verify proof', exact: true })).toBeVisible(); + await expect(analysisGroup.getByRole('button', { name: 'Check graph', exact: true })).not.toHaveClass(/primary/); +}); + +test('the running command carries aria-busy and an in-button progress affordance', async ({ page }) => { + await installApi(page, { mode: 'review', commandDelayMs: 150 }); + const problems = page.locator('#problems'); + await openHome(page); + + const diagnostics = problems.getByRole('button', { name: 'Build diagnostics', exact: true }); + await diagnostics.click(); + await expect(diagnostics).toHaveAttribute('aria-busy', 'true'); + await expect(diagnostics).toHaveClass(/is-running/); + await expect(page.getByRole('status', { name: 'Boris command status' })).toContainText('Running Build diagnostics'); + await expect(diagnostics).not.toHaveAttribute('aria-busy', 'true', { timeout: 5_000 }); + await expect(page.getByRole('status', { name: 'Boris command status' })).toContainText('finished'); +}); + +test('the command palette still lists the secondary commands', async ({ page }) => { + await installApi(page, { mode: 'review' }); + await openHome(page); + await page.keyboard.press('Control+k'); + const palette = page.getByRole('dialog', { name: 'Commands' }); + await expect(palette.getByRole('option', { name: /^Check graph/ })).toBeVisible(); + await expect(palette.getByRole('option', { name: /^Verify proof/ })).toBeVisible(); + await expect(palette.getByRole('option', { name: /^Run impact;/ })).toBeVisible(); +}); + +test('a large proof report starts collapsed behind an explicit disclosure', async ({ page }) => { + const report = Array.from({ length: 40 }, (_, index) => `verify line ${index + 1}`).join('\n'); + await installApi(page, { mode: 'review', proofReport: report }); + const problems = page.locator('#problems'); + await openHome(page); + await problems.getByRole('button', { name: 'Verify proof', exact: true }).click(); + + const details = problems.locator('details.report-details'); + await expect(details).toBeVisible(); + await expect(details.locator('summary')).toHaveText('Show proof verify report (40 lines)'); + await expect(details.locator('pre')).toBeHidden(); + await details.locator('summary').click(); + await expect(details.locator('pre')).toContainText('verify line 40'); +}); diff --git a/editor/ui/tests/graph-map.spec.ts b/editor/ui/tests/graph-map.spec.ts index b012bf19..a80ca6c9 100644 --- a/editor/ui/tests/graph-map.spec.ts +++ b/editor/ui/tests/graph-map.spec.ts @@ -204,6 +204,9 @@ async function installApi(page: Page, options: InstallOptions = {}) { }) })); await page.route('https://preview.invalid/**', route => route.fulfill({ contentType: 'text/html', body: '

Compiler output

' })); + // Graph lives in Review density (#990); the default Author view is + // covered by density-modes.spec.ts. + await page.addInitScript(() => localStorage.setItem('boris-editor-density', 'review')); await page.goto(options.hash ?? '/#token=test-session-token'); } diff --git a/editor/ui/tests/reading-hierarchy.spec.ts b/editor/ui/tests/reading-hierarchy.spec.ts index dc6ed330..de0442e0 100644 --- a/editor/ui/tests/reading-hierarchy.spec.ts +++ b/editor/ui/tests/reading-hierarchy.spec.ts @@ -106,6 +106,10 @@ async function installApi(page: Page) { contentType: 'application/json', body: JSON.stringify({ status: endpoint === 'start' ? 'started' : 'stopped' }) })); } + // These assertions read the full pane chrome, which lives in Review + // density (#990); the default Author view is covered by + // density-modes.spec.ts. + await page.addInitScript(() => localStorage.setItem('boris-editor-density', 'review')); await page.goto('/#token=test-session-token'); } diff --git a/editor/ui/tests/safe-editing.spec.ts b/editor/ui/tests/safe-editing.spec.ts index ab520c28..25a51305 100644 --- a/editor/ui/tests/safe-editing.spec.ts +++ b/editor/ui/tests/safe-editing.spec.ts @@ -338,6 +338,10 @@ async function installApi(page: Page, options: MockOptions = {}) { return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }); }); await page.route('https://preview.invalid/**', route => route.fulfill({ contentType: 'text/html', body: '

Compiler output

' })); + // This suite exercises the full pane chrome (Problems, Preview, Watch, + // Graph, Publication), which lives in Review density (#990); the default + // Author view is covered by density-modes.spec.ts. + await page.addInitScript(() => localStorage.setItem('boris-editor-density', 'review')); await page.goto(options.hash ?? '/#token=test-session-token'); } diff --git a/editor/ui/tests/section-nav.spec.ts b/editor/ui/tests/section-nav.spec.ts index e7c8472d..422b0296 100644 --- a/editor/ui/tests/section-nav.spec.ts +++ b/editor/ui/tests/section-nav.spec.ts @@ -110,6 +110,10 @@ async function installApi(page: Page, options: InstallOptions = {}) { // launch-open path (#943). const launchParams = new URLSearchParams({ token: 'test-session-token' }); if (options.open) launchParams.set('open', options.open); + // Nav currency and layout here assume every pane is mounted, i.e. Review + // density (#990); the Author/Review switch itself is covered by + // density-modes.spec.ts. + await page.addInitScript(() => localStorage.setItem('boris-editor-density', 'review')); await page.goto(`/#${launchParams.toString()}`); } diff --git a/editor/ui/tests/watch-admin.spec.ts b/editor/ui/tests/watch-admin.spec.ts index 88d1c2fc..1ff33b73 100644 --- a/editor/ui/tests/watch-admin.spec.ts +++ b/editor/ui/tests/watch-admin.spec.ts @@ -230,6 +230,9 @@ async function installWatchApi(page: Page, options: WatchApiOptions = {}): Promi })); } + // Watch lives in Review density (#990); the default Author view is + // covered by density-modes.spec.ts. + await page.addInitScript(() => localStorage.setItem('boris-editor-density', 'review')); await page.goto('/#token=test-session-token'); return daemon; } From 486bf41098564171eae5bcfbce6cfdb6ad05df99 Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:30:52 -0400 Subject: [PATCH 2/5] docs(changelog): name the fragment for PR 992 --- ...90-editor-writing-surface.md => 992-editor-writing-surface.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changelog.d/{990-editor-writing-surface.md => 992-editor-writing-surface.md} (100%) diff --git a/docs/changelog.d/990-editor-writing-surface.md b/docs/changelog.d/992-editor-writing-surface.md similarity index 100% rename from docs/changelog.d/990-editor-writing-surface.md rename to docs/changelog.d/992-editor-writing-surface.md From 53273e4e3c7b21de1c83db1647fa415b37e5fd85 Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:41:22 -0400 Subject: [PATCH 3/5] fix(editor): widen the review rail so the Problems lede keeps its measure The Linux editor gate rendered the result chip at the rail's full content width, so the reading-hierarchy assertion that the lede outgrows the chip landed on an exact tie. Give the rail column more weight and the source column the same hero share. --- editor/ui/src/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editor/ui/src/styles.css b/editor/ui/src/styles.css index 82452568..306e530f 100644 --- a/editor/ui/src/styles.css +++ b/editor/ui/src/styles.css @@ -1006,7 +1006,7 @@ footer .key-hint { margin: 0 0 0.35rem; } Source column is the working surface and takes the weight (#989), and the rail carries prose. Author mode (#990) drops the rail: two columns, with the writing surface taking the freed width. */ - main { grid-template-columns: minmax(15rem, 0.5fr) minmax(28rem, 1.9fr) minmax(20rem, 1.05fr); } + main { grid-template-columns: minmax(15rem, 0.5fr) minmax(28rem, 1.8fr) minmax(22rem, 1.15fr); } main.author-mode { grid-template-columns: minmax(15rem, 0.5fr) minmax(28rem, 2.2fr); } .workspace-rail { grid-column: auto; max-height: calc(100vh - 14rem); overflow-y: auto; } /* Keep rail panes from shrinking below their content: a shrunken box spills From e2a413d3c365996b71bacfe62c5bcc04062c1593 Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:48:15 -0400 Subject: [PATCH 4/5] test(editor): switch to Review density in the live preview browser gate The Preview pane is mounted in Review density (#990); the real-host frame check now switches modes before clicking Rebuild preview instead of assuming the full chrome is the cold-open state. --- editor/scripts/preview-frame-check.cjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/editor/scripts/preview-frame-check.cjs b/editor/scripts/preview-frame-check.cjs index 65aa5b3e..8f19c6c0 100644 --- a/editor/scripts/preview-frame-check.cjs +++ b/editor/scripts/preview-frame-check.cjs @@ -21,6 +21,12 @@ const { chromium } = require('playwright'); }); try { await page.goto(appUrl, { waitUntil: 'domcontentloaded' }); + // The Preview pane lives in Review density (#990); a cold open is the + // calm Author view. Switch modes the way an author would, then rebuild. + await page.getByRole('group', { name: 'Editor density' }) + .getByRole('button', { name: 'Review', exact: true }) + .click(); + await page.locator('#preview').waitFor({ timeout: 30000 }); await page.getByRole('button', { name: 'Rebuild preview' }).click(); await page.locator('p.preview-state').filter({ hasText: 'success' }).waitFor({ timeout: 120000 }); const frameBody = page.frameLocator('iframe[title="Boris site preview"]').locator('body'); From 9940750f1fb504d56763d2f2fb52b2848bbc80eb Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:56:51 -0400 Subject: [PATCH 5/5] fix(editor): keep the landed nav target current while it covers the reading line QA found that a reduced-motion, mode-gated Graph reveal lost aria-current: Review mounts the Graph pane, the jump clamps at max scroll, and the spy's bottom rule handed currency to the last present pane even though Graph's box spans the reading line. Prefer the most recent jump target while it still covers the line; scrolling the target off the line releases it, so a stale jump never pins wayfinding. Adds the reduced-motion regression test. --- .../changelog.d/992-editor-writing-surface.md | 2 +- editor/README.md | 5 ++- editor/ui/src/components/SectionNav.svelte | 33 ++++++++++++------- editor/ui/tests/density-modes.spec.ts | 23 +++++++++++++ 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/docs/changelog.d/992-editor-writing-surface.md b/docs/changelog.d/992-editor-writing-surface.md index b18c4a0c..5e2e6f78 100644 --- a/docs/changelog.d/992-editor-writing-surface.md +++ b/docs/changelog.d/992-editor-writing-surface.md @@ -1,3 +1,3 @@ ### Changed -- The editor's everyday surface now reads as a writing tool first: a default **Author** density mode (Source + Project; review panes unmounted, authoring hints folded, and section-nav links to Review panes switching modes before they land) beside the full **Review** chrome, Source as a measured hero surface with a measured line gutter, current-line band, and presentation-only frontmatter seam, and a primary/secondary action hierarchy in Problems with per-command `aria-busy` progress and large reports collapsed behind an explicit disclosure. Links: [the editor guide](/content/guides/editor.md#density-modes-and-the-writing-surface), [#988](https://github.com/drawmeanelephant/boris/issues/988), [#989](https://github.com/drawmeanelephant/boris/issues/989), [#990](https://github.com/drawmeanelephant/boris/issues/990), [#991](https://github.com/drawmeanelephant/boris/issues/991). +- The editor's everyday surface now reads as a writing tool first: a default **Author** density mode (Source + Project; review panes unmounted, authoring hints folded, and section-nav links to Review panes switching modes before they land) beside the full **Review** chrome, Source as a measured hero surface with a measured line gutter, current-line band, and presentation-only frontmatter seam, and a primary/secondary action hierarchy in Problems with per-command `aria-busy` progress and large reports collapsed behind an explicit disclosure; a mode-gated landing that clamps at max scroll keeps its active nav marker while it covers the reading line. Links: [the editor guide](/content/guides/editor.md#density-modes-and-the-writing-surface), [#988](https://github.com/drawmeanelephant/boris/issues/988), [#989](https://github.com/drawmeanelephant/boris/issues/989), [#990](https://github.com/drawmeanelephant/boris/issues/990), [#991](https://github.com/drawmeanelephant/boris/issues/991). diff --git a/editor/README.md b/editor/README.md index e499c0b7..b1d9448c 100644 --- a/editor/README.md +++ b/editor/README.md @@ -755,7 +755,10 @@ preferences. The mode is disposable UI state, never project truth: authoring hints fold into a single disclosure under the writing surface instead of a second card. A section-nav link whose pane lives in Review switches modes and then lands on the real pane — it stays enabled, muted, - and titled, so the nav never claims a hidden pane is present. + and titled, so the nav never claims a hidden pane is present. A landing + that clamps at max scroll keeps the target's `aria-current` while the + target's box still covers the reading line; scrolling off it releases the + marker. - **Review**: the full diagnostics chrome, unchanged. Source is the hero in both modes (#989): the writing column outweighs the diff --git a/editor/ui/src/components/SectionNav.svelte b/editor/ui/src/components/SectionNav.svelte index 29afee72..c9d6e32c 100644 --- a/editor/ui/src/components/SectionNav.svelte +++ b/editor/ui/src/components/SectionNav.svelte @@ -81,6 +81,17 @@ return section !== null && section.getClientRects().length > 0; } + // The section whose box spans the reading line — the pane the author is + // actually looking at. A mode-gated reveal can leave the page clamped at + // max scroll, where geometry-only rules hand currency to another pane even + // though the landed target covers the line. + function sectionCoversReadingLine(section: HTMLElement | null): boolean { + if (!sectionVisible(section)) return false; + const margin = parseFloat(getComputedStyle(section).scrollMarginTop) || 0; + const rect = section.getBoundingClientRect(); + return rect.top <= margin + LINE_TOLERANCE_PX && rect.bottom > margin; + } + function clearArrival() { arrivedElement?.classList.remove('arrived'); arrivedElement = undefined; @@ -182,6 +193,16 @@ // Above every section (page top), the journey starts at the first link so // wayfinding never reads as "nowhere". function syncCurrent() { + // The most recent jump target stays current while it still covers the + // reading line. A mode-gated reveal can leave the page clamped at max + // scroll, where the bottom rule and the nearest-above heuristic would + // hand currency to another pane even though the author's target is the + // visible destination (#992 review). Scrolling the target off the line + // releases it, so a stale jump never pins wayfinding. + if (jumpTarget && sectionCoversReadingLine(sectionFor(jumpTarget))) { + current = jumpTarget; + return; + } // Bottom rule (standard scrollspy behavior): at max scroll the last // present section is current, because a short page or the footer clamp // can keep it from ever reaching the reading line. @@ -227,18 +248,6 @@ bestTop = top; } } - // A jump destination that is still at the reading line wins a tie against - // a pane that shares its offset in another column. Once scrolling moves - // the destination out of contention the reading-line answer takes over - // again, so this self-releases and never pins currency to a stale jump. - if (jumpTarget && best !== jumpTarget) { - const target = sectionFor(jumpTarget); - if (sectionVisible(target)) { - const margin = parseFloat(getComputedStyle(target).scrollMarginTop) || 0; - const top = target.getBoundingClientRect().top; - if (top <= margin + LINE_TOLERANCE_PX && top >= bestTop) best = jumpTarget; - } - } current = best ?? links[0].id; } diff --git a/editor/ui/tests/density-modes.spec.ts b/editor/ui/tests/density-modes.spec.ts index 16e4e921..bc999b21 100644 --- a/editor/ui/tests/density-modes.spec.ts +++ b/editor/ui/tests/density-modes.spec.ts @@ -249,6 +249,29 @@ test('a nav link to a pane in the other mode switches modes and lands there', as await expect(page.getByRole('status', { name: 'Editing status' })).toContainText('Switched to Review mode'); }); +test('a reduced-motion reveal keeps aria-current on the landed target at max scroll', async ({ page }) => { + // QA repro: a mode-gated reveal grows the page (Review mounts the Graph + // pane), the jump clamps at max scroll, and the reading line falls inside + // the Graph pane. The landed target must keep its active marker instead of + // the spy's bottom rule handing it to the last present pane. + await page.setViewportSize({ width: 1280, height: 720 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await installApi(page); + await openHome(page); + + const graph = page.getByRole('navigation', { name: 'Editor sections' }).getByRole('link', { name: 'Graph', exact: true }); + await graph.click(); + await expect(page.locator('#graph')).toBeFocused(); + await expect(graph).toHaveAttribute('aria-current', 'true'); + + // Settled state, after the spy's 600 ms resync: the target still owns + // wayfinding and no other link claims it. + await page.waitForTimeout(800); + await expect(graph).toHaveAttribute('aria-current', 'true'); + await expect(page.getByRole('navigation', { name: 'Editor sections' }).getByRole('link', { name: 'Watch', exact: true })) + .not.toHaveAttribute('aria-current'); +}); + test('the measured line gutter and current-line band track the caret', async ({ page }) => { await installApi(page); const editor = await openHome(page);