Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions content/guides/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions docs/changelog.d/992-editor-writing-surface.md
Original file line number Diff line number Diff line change
@@ -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).
43 changes: 43 additions & 0 deletions editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions editor/scripts/preview-frame-check.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
54 changes: 44 additions & 10 deletions editor/ui/src/App.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script lang="ts">
import { tick } from 'svelte';
import { fade } from 'svelte/transition';
import { prefersReducedMotion } from 'svelte/motion';
import { token, launchOpenPath, api, elapsedLabel, hostErrorLabel, authorPathIssue, isLaunchOpenSafe, defaultLaunchPath } from './lib/api';
import type {
Health,
Expand Down Expand Up @@ -39,6 +41,7 @@
import { publication, refreshPublication, setPublication } from './lib/state/publication.svelte';
import { preview, setPreview, noteWatchRefusal, refreshPreviewState } from './lib/state/preview.svelte';
import { focusMode, openFocusMode, closeFocusMode, initFocusLayout, initFocusType, initFocusZen, focusReturnElement, setFocusReturn } from './lib/state/focus.svelte';
import { density, initDensity, setDensity } from './lib/state/density.svelte';
import { problems, copyDiagnosticPacket, scheduleValidateRefresh, startValidateWatch } from './lib/state/problems.svelte';
import {
startWatchStateWatch,
Expand Down Expand Up @@ -213,6 +216,7 @@
return;
}
problems.running = true;
problems.runningMode = mode;
const started = Date.now();
problems.status = `Running ${commandLabel(mode)}…`;
const body = mode === 'impact'
Expand All @@ -228,6 +232,7 @@
method: 'POST', body: JSON.stringify(body)
});
problems.running = false;
problems.runningMode = '';
if (!result.response.ok) {
problems.status = `Could not run ${commandLabel(mode)}: ${hostErrorLabel((result.data as ErrorResponse).error)}.`;
return;
Expand Down Expand Up @@ -698,6 +703,27 @@
return {};
}

// Nav targets that live in the other density mode (#990). Activating one
// switches modes first and then lands: the pane is never claimed to be
// present when its mode is not showing it. Focus mode stays a separate
// overlay, so its nav entry is not part of this map.
const REVIEW_SECTIONS = ['graph', 'publication', 'problems', 'preview', 'watch'];

function navModeGated(): Record<string, string> {
if (density.mode === 'review') return {};
return Object.fromEntries(REVIEW_SECTIONS.map(id => [id, 'Review']));
}

// Runs before the SectionNav jump: switch to Review and let the panes
// mount so the target section exists by the time the jump looks for it.
async function revealSection(id: string) {
const mode = navModeGated()[id];
if (!mode) return;
setDensity('review');
buffer.editorStatus = `Switched to ${mode} mode to open the ${id} pane.`;
await tick();
}

function reportBlockedNav(reason: string) {
buffer.editorStatus = reason;
}
Expand Down Expand Up @@ -872,6 +898,7 @@
initFocusLayout();
initFocusType();
initFocusZen();
initDensity();
initProjectTree();
connect();
</script>
Expand All @@ -890,11 +917,16 @@

<Header connection={connection.status} />

<SectionNav unavailable={navUnavailable()} onBlockedNav={reportBlockedNav} />
<SectionNav
unavailable={navUnavailable()}
modeGated={navModeGated()}
onBlockedNav={reportBlockedNav}
onReveal={revealSection}
/>

<RecoveryBanner onRestore={restoreSnapshot} onDiscard={clearRecovery} />

<main id="workspace" tabindex="-1">
<main id="workspace" tabindex="-1" class:author-mode={density.mode === 'author'}>
<ProjectPane
onOpen={openFile}
onCreate={openCreateDialog}
Expand All @@ -916,14 +948,16 @@
onEnterFocus={enterFocusMode}
/>

<div class="workspace-rail">
<ProblemsPane
onRunCommand={runCommand}
onNavigate={navigateToProblem}
/>
<PreviewPane onRebuild={() => rebuildPreview('manual')} />
<WatchPane />
</div>
{#if density.mode === 'review'}
<div class="workspace-rail" transition:fade={{ duration: prefersReducedMotion.current ? 0 : 120 }}>
<ProblemsPane
onRunCommand={runCommand}
onNavigate={navigateToProblem}
/>
<PreviewPane onRebuild={() => rebuildPreview('manual')} />
<WatchPane />
</div>
{/if}
</main>

<ConflictDialog
Expand Down
21 changes: 21 additions & 0 deletions editor/ui/src/components/ActionGroup.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script lang="ts">
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();
</script>

<div class="action-group action-group-{tone}" role="group" aria-label={label}>
{@render children()}
</div>
22 changes: 20 additions & 2 deletions editor/ui/src/components/AuthoringTools.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -28,7 +33,7 @@
}
</script>

<aside class="authoring-tools" aria-labelledby="authoring-heading">
{#snippet tools()}
<div class="pane-heading">
<div>
<h3 id="authoring-heading">Boris authoring hints</h3>
Expand Down Expand Up @@ -109,4 +114,17 @@
<p>The schema is a looser pre-check for multibyte lengths and dates. The Boris parser remains authoritative.</p>
</details>
{/if}
</aside>
{/snippet}

{#if collapsed}
<details class="authoring-tools authoring-collapse">
<summary>Boris authoring hints</summary>
<div class="authoring-collapse-body">
{@render tools()}
</div>
</details>
{:else}
<aside class="authoring-tools" aria-labelledby="authoring-heading">
{@render tools()}
</aside>
{/if}
34 changes: 28 additions & 6 deletions editor/ui/src/components/Header.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { theme, toggleTheme } from '../lib/theme.svelte';
import { density, setDensity } from '../lib/state/density.svelte';

let { connection }: { connection: string } = $props();
</script>
Expand All @@ -18,10 +19,31 @@
<h1>Boris Editor</h1>
</div>
<p class="connection" role="status" aria-label="Connection status" aria-live="polite">{connection}</p>
<button
type="button"
class="theme-toggle"
aria-pressed={theme.current === 'dark'}
onclick={toggleTheme}>Theme: {theme.current}</button
>
<div class="header-preferences">
<!-- Density mode (#990): a disposable per-browser preference, kept out of
project truth. Author is the calm writing view; Review restores the
full diagnostics chrome. -->
<div class="density-toggle" role="group" aria-label="Editor density">
<button
type="button"
class="density-option"
aria-pressed={density.mode === 'author'}
title="Calm writing view: Source and Project only"
onclick={() => setDensity('author')}>Author</button
>
<button
type="button"
class="density-option"
aria-pressed={density.mode === 'review'}
title="Full review chrome: Problems, Graph, Preview, Publication, and Watch"
onclick={() => setDensity('review')}>Review</button
>
</div>
<button
type="button"
class="theme-toggle"
aria-pressed={theme.current === 'dark'}
onclick={toggleTheme}>Theme: {theme.current}</button
>
</div>
</header>
Loading