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/992-editor-writing-surface.md b/docs/changelog.d/992-editor-writing-surface.md new file mode 100644 index 00000000..5e2e6f78 --- /dev/null +++ b/docs/changelog.d/992-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; 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 217d13c6..b1d9448c 100644 --- a/editor/README.md +++ b/editor/README.md @@ -744,6 +744,49 @@ 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. 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 +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/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'); 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..c9d6e32c 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,23 @@ 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; + } + + // 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; @@ -110,7 +136,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 +147,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(); @@ -159,13 +193,23 @@ // 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. 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 +232,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 @@ -204,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 (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; } @@ -274,6 +306,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 +335,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..306e530f 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.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 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..bc999b21 --- /dev/null +++ b/editor/ui/tests/density-modes.spec.ts @@ -0,0 +1,380 @@ +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('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); + + // 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; }