diff --git a/content/guides/editor.md b/content/guides/editor.md
index bf09822a..a328e4ce 100644
--- a/content/guides/editor.md
+++ b/content/guides/editor.md
@@ -245,6 +245,20 @@ 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.
+Author mode carries that promise through the chrome as well. Project recedes
+to a file drawer under the writing page instead of a second equal card, the
+section nav leads with Project and Source while the Review destinations recede
+into a captioned, faint cluster — still live links, so activating one still
+switches modes and lands — and Source is drawn as the page itself: the card
+edge and drop shadow give way to one focus ring, worn by the page rather than
+doubled inside it, and on a wide window the page is capped at a comfortable
+reading measure and centered, so you get a column to write in rather than a
+wider dashboard tile. The top band quiets as well: the
+title and the connection status share one row under a smaller product mark, the
+connection readout is a compact state chip whose full sentence is one
+activation away, and the theme control shows just the current theme. Review
+keeps the wide working surface beside its rail.
+
## Compiler-backed commands and problems
The Problems pane runs a fixed allowlist of Boris invocations against saved
diff --git a/docs/changelog.d/994-editor-flat-chrome.md b/docs/changelog.d/994-editor-flat-chrome.md
new file mode 100644
index 00000000..3cdb1462
--- /dev/null
+++ b/docs/changelog.d/994-editor-flat-chrome.md
@@ -0,0 +1,62 @@
+### Changed
+
+- Author mode stops reading as an equal-card dashboard: Project is demoted to a
+ file drawer under the writing page (no shared height, elevation, or heavy
+ edge) instead of a peer card, the section nav leads with Project and Source
+ while the Review destinations recede into a captioned, faint cluster that
+ still switches modes and lands on activation, and Source takes page material
+ rather than a wider dashboard tile — no card elevation, a focus-within edge,
+ and a readable-measure cap centered in its column on wide windows, so the
+ pane edge is the page edge and heading, editor, gutter, and status line share
+ one column. The top band quiets too: the header drops its decorative eyebrow
+ so the product mark and the live connection status share one row, the
+ connection readout collapses to a compact state chip whose honest sentence is
+ one activation away (the live region announces the short label rather than
+ re-reading a sentence), the theme control states only its state, and Author
+ takes a smaller product mark and tighter header/nav bands than Review. Links:
+ [the editor
+ guide](/content/guides/editor.md#density-modes-and-the-writing-surface),
+ [#993](https://github.com/drawmeanelephant/boris/issues/993).
+
+### Fixed
+
+- Review corrections to the Author surface above. The Author app title step is
+ its own link in the type chain (`--text-2xl-compact`) rather than the
+ pane-title size, which had made `h1` exactly as large as the pane titles it
+ outranks. The theme control drops `aria-pressed`: a pressed state presumes a
+ name that does not change with it, and here the changing label *is* the
+ state. The Author Source textarea keeps no focus ring of its own now that the
+ shell owns one, so the page shows a single edge instead of a frame within a
+ frame. The connection readout's live region is text-only, matching every other
+ status region in the editor, with the disclosure chip as a sibling rather than
+ a button nested inside an atomic region.
+
+### Added
+
+- Every ordered scale the editor declares — the type chain, the spacing rhythm,
+ the corner steps, and the stacking ladder — now writes its order down exactly
+ once, in a `--scale-*` list in `editor/ui/src/lib/tokens.css`, and nothing
+ else keeps a copy. `--scale-space-base` declares the spacing rhythm's base
+ instead of describing it in prose, and the six bare `z-index` values in
+ `styles.css` became `--layer-*` names, so the ladder is the only authority on
+ stacking order.
+- `editor/ui/scripts/check-scales.mjs`, run by `npm run check`, holds the static
+ half: every declared token of each family is classified, every listed name
+ exists, each list ascends (numerically where a value is resolvable without a
+ viewport, and reported as deferred where it is a `clamp`), spacing steps are
+ whole multiples of the declared base, the layer ladder holds bare numbers,
+ every scale token is declared once and only in `tokens.css`, every `var()`
+ reference lands on a declared step, no `z-index` is a bare number, and each
+ documented section names its scale's list without naming tokens that do not
+ exist. It runs its own self-tests first, so the checker is checked.
+- `editor/ui/tests/scales.spec.ts` holds the half that needs a viewport. It
+ resolves the same lists out of the *applied* stylesheet at four widths, which
+ is the only way to check a `clamp` step, a value overridden further down the
+ cascade, or the rhythm's base — and it asserts that the sticky nav and the
+ skip link carry the declared layer rather than a loose number. The rendered
+ heading levels stay in `editor/ui/tests/reading-hierarchy.spec.ts`, now
+ measured in both density modes.
+- That last point is why any of this exists: the prior hierarchy suite pinned
+ Review density only, so an Author-only override could flatten the app title
+ onto the pane-title size with every suite green. Which is exactly what
+ happened, and what these scales are now walked against.
diff --git a/editor/README.md b/editor/README.md
index b1d9448c..1880f1e0 100644
--- a/editor/README.md
+++ b/editor/README.md
@@ -710,17 +710,34 @@ The editor's chrome is one type scale, defined in
size, not only by weight and color, or every label competes with the prose it
labels.
-- **Scale.** Two meta steps sit below the 16px body (`--text-2xs`,
- `--text-xs`), then one step per heading level above it: `--text-lg` for
- sub-pane titles (h3), `--text-xl` for pane titles (h2), `--text-2xl` for the
- app title (h1). The ratio is ~1.2 pairwise, which is what makes a pane title
- read as a heading beside body copy. `--text-code` is the monospace working
- size for the source surface.
+- **Scale.** The order is written down once, in `--scale-type` in
+ [`ui/src/lib/tokens.css`](ui/src/lib/tokens.css), and nothing else keeps a
+ copy of it. Two meta steps sit below body copy (`--text-2xs` for kbd chips and
+ micro labels, `--text-xs` for eyebrows and status meta), then `--text-sm` for
+ ledes and field labels and `--text-md` for the 16px body itself, and one step
+ per heading level above it: `--text-lg` for sub-pane titles (h3), `--text-xl`
+ for pane titles (h2), `--text-2xl-compact` for the app title (h1) in Author's
+ calmer band, and `--text-2xl` for that title in Review. The ratio is ~1.2
+ pairwise, which is what makes a pane title read as a heading beside body copy.
+ `--text-code` is the monospace working size for the source surface, and it is
+ listed as off-chain because it is an orthographic axis: it may interleave with
+ the steps but never coincide with one. Every step stays ordered at every
+ viewport, so a mode may choose a different step but two levels never collapse
+ onto one size.
- **No off-scale sizes.** Component rules must consume a token rather than a
raw `rem`. An off-scale size is exactly how the surface drifted into
everything-is-16px, and it is how a sub-pane heading ended up rendering
*larger* than the pane title above it (unstyled `h3` inherits the user
agent's `1.17em`). Extend the chain in `tokens.css` instead.
+- **Enforced, not checked by eye.** A new `--text-*` token must be classified
+ in the type scale, and this section must name `--scale-type` —
+ [`ui/scripts/check-scales.mjs`](ui/scripts/check-scales.mjs) fails
+ `npm run check` otherwise, with no browser needed.
+ `ui/tests/scales.spec.ts` then resolves the chain out of the *applied*
+ stylesheet at four widths, asserting strict ascent with no collisions, and
+ `ui/tests/reading-hierarchy.spec.ts` measures the rendered heading levels in
+ *both* density modes. The prose, the stylesheet, and the browser are all held
+ to the one list.
- **Reading rhythm.** `:root` sets `--leading-normal` (1.55) so prose gets a
real line-height by default; headings take `--leading-tight` and controls
opt in explicitly. `--measure-prose` caps a lede's line length.
@@ -744,6 +761,43 @@ labels.
Presentation only: no endpoint, no Boris surface, and no pipeline change.
+## Spacing, radius, and stacking
+
+Three more ordered scales sit beside the type chain, declared the same way and
+in the same block of [`ui/src/lib/tokens.css`](ui/src/lib/tokens.css): the order
+lives in one `--scale-*` list, and nothing else keeps a copy.
+
+- **Spacing (`--scale-space`).** Eight steps on a 0.25rem base, `--space-1`
+ through `--space-8` (0.25rem to 3rem). Padding and gaps come from these, and
+ every step is a whole multiple of the base — that is what keeps a surface on
+ one rhythm instead of drifting into one-off values. The base is declared too,
+ as `--scale-space-base`, so the checks read it rather than trusting this
+ sentence.
+- **Radius (`--scale-radius`).** `--radius-sm`, `--radius-md`, `--radius-lg`,
+ and `--radius-pill`: three corner steps plus the pill, ascending.
+- **Stacking (`--scale-layer`).** One ladder for the whole editor,
+ `--layer-focus-mirror` < `--layer-focus-editor` < `--layer-focus-zen` <
+ `--layer-nav` < `--layer-skip-link` < `--layer-overlay`. The first three order
+ elements inside the focus editor's own stacking context — the measuring
+ mirror, the text above it, the dimming veil over both; the last three are
+ global chrome: the sticky section nav, the skip link, and the full-viewport
+ focus overlay. `z-index` in `styles.css` must name a layer, because a bare
+ number is how two layers silently swap places.
+
+Elevation tiers (`--shadow-1`, `--shadow-2`, `--shadow-3`) are deliberately not
+a declared scale: a shadow list has no scalar order for a list to protect.
+
+[`ui/scripts/check-scales.mjs`](ui/scripts/check-scales.mjs) enforces all of this
+from `npm run check` — every declared token of each family is classified, every
+listed name exists, each list ascends (numerically where a value is resolvable
+without a viewport, and reported as deferred where it is a `clamp`), spacing
+steps are whole multiples of the base, the layer ladder holds bare numbers, and
+this section names each scale's list. Then
+[`ui/tests/scales.spec.ts`](ui/tests/scales.spec.ts) re-reads the same lists out
+of the *applied* stylesheet: that is the half which catches what no file parse
+can, namely a value overridden in a later block, a media query, or the dark
+theme.
+
## Density modes and the writing surface
The shell has two density modes (#990), persisted per browser under
@@ -759,11 +813,36 @@ preferences. The mode is disposable UI state, never project truth:
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.
+ - The residual-flat polish (#993) carries the same intent through the
+ chrome: Project recedes to a file drawer under the writing page (it no
+ longer stretches to the page's height, and drops the card elevation and
+ heavy edge for a recessed neutral surface with tighter padding), the nav
+ leads with Project and Source while the Review destinations recede into a
+ captioned, faint cluster (emphasis only — those links stay live, so
+ activating one still switches modes and lands), and Source takes page
+ material rather than a wider dashboard tile. The top band quiets too: the
+ header drops its decorative eyebrow so the product mark and the live
+ connection status share one baseline row, the connection readout collapses
+ to a compact state chip whose honest sentence is one activation away (the
+ region is text-only and announces the short label, not a sentence re-read
+ on every host blip), the theme control states only its state
+ ("Dark"/"Light", matching its accessible name) and carries no
+ `aria-pressed` — a pressed state presumes a name that does not change with
+ it — and Author takes a smaller product mark and tighter header/nav bands
+ than Review.
- **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:
+file and rail columns, and the editing surface is a bordered shell whose
+chrome is presentation only. It keeps its elevation in Review; in Author the
+card edge and drop shadow give way to a quiet hairline and a `:focus-within`
+ring the shell owns alone (the focused textarea keeps no second ring inside
+it), and the pane itself is capped at a readable measure (~80 monospace
+columns) and centered in its grid column — the pane edge is the page edge, so
+heading, editor, gutter, and status line share one column instead of a narrow
+measure stretched across a wide tile. Below the cap the pane just fills its
+column, and the cap is Author-only: Review keeps the wide working surface
+beside its rail. The 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
diff --git a/editor/scripts/test-editor-gate.sh b/editor/scripts/test-editor-gate.sh
index d3cabb84..a03cbb93 100755
--- a/editor/scripts/test-editor-gate.sh
+++ b/editor/scripts/test-editor-gate.sh
@@ -110,7 +110,7 @@ fi
run_stage "editor format" zig fmt --check editor/build.zig editor/build.zig.zon editor/src
run_stage "editor host unit tests" zig build --build-file editor/build.zig test
-run_stage "editor UI static checks (svelte-check + key hints)" npm --prefix editor/ui run check
+run_stage "editor UI static checks (svelte-check + key hints + scales)" npm --prefix editor/ui run check
run_stage "editor UI build" npm --prefix editor/ui run build
run_stage "editor host build (boris-editor + contract probe)" build_host_binaries
run_stage "editor UI end-to-end suite (Playwright, mocked host)" npm --prefix editor/ui run test:e2e
diff --git a/editor/ui/package.json b/editor/ui/package.json
index 07f581ff..2c17df75 100644
--- a/editor/ui/package.json
+++ b/editor/ui/package.json
@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "check": "svelte-check --tsconfig ./tsconfig.json && node scripts/check-key-hints.mjs",
+ "check": "svelte-check --tsconfig ./tsconfig.json && node scripts/check-key-hints.mjs && node scripts/check-scales.mjs",
"test:e2e": "playwright test"
},
"devDependencies": {
diff --git a/editor/ui/scripts/check-scales.mjs b/editor/ui/scripts/check-scales.mjs
new file mode 100644
index 00000000..bc67eda8
--- /dev/null
+++ b/editor/ui/scripts/check-scales.mjs
@@ -0,0 +1,462 @@
+#!/usr/bin/env node
+// Static conformance lint for the editor's declared scales.
+//
+// Each ordered scale in the editor — the type chain, the spacing rhythm, the
+// corner steps, the stacking ladder — has its order written down in exactly one
+// place: a `--scale-*` list in src/lib/tokens.css. Nothing else keeps a copy.
+// The browser walk in `ui/tests/scales.spec.ts` resolves those lists out of the
+// *applied* stylesheet; this script checks the same lists statically, before a
+// browser is needed. That division is the point: a duplicated order is how the
+// app title ended up rendering at exactly the pane-title size with every suite
+// green, because the tests and the stylesheet each had their own idea of what
+// the chain was.
+//
+// Per scale, against tokens.css:
+// 1. the scale's list exists and is non-empty;
+// 2. every declared token of the family is classified in exactly one of the
+// scale's lists, so nothing enters the surface unclassified;
+// 3. every listed name is a declared token, so no entry survives a rename;
+// 4. no name is listed twice, and none is in both lists;
+// 5. the list ascends — numerically wherever a value is resolvable without a
+// viewport, and reported as deferred where it is not (a `clamp` in `vw`);
+// 6. a scale with a stated base is a whole multiple of it;
+// 7. an integer scale (the layer ladder) holds bare numbers, not lengths.
+// Per scale, against editor/README.md:
+// 8. the documented section names the scale's list, so the prose points at
+// the one source instead of restating the order;
+// 9. it names no token of that family that tokens.css does not declare.
+// Globally, against styles.css:
+// 10. no `z-index` is a bare number — the ladder is the only authority on
+// stacking order — and every `--layer-*` name it uses is declared.
+//
+// Zero dependencies: plain Node, regex over the three files. Runs built-in
+// self-tests first, then checks the real source.
+
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const TOKENS_CSS = join(HERE, '..', 'src', 'lib', 'tokens.css');
+const STYLES_CSS = join(HERE, '..', 'src', 'styles.css');
+const README = join(HERE, '..', '..', 'README.md');
+
+/** The declared scales. `prefix` is what a token of this scale looks like, and
+ * `readme` is the section that documents it. */
+const define = (id, prefix, rest) => ({ id, prefix, family: new RegExp(`^${prefix}`), ...rest });
+
+export const SCALES = [
+ define('type', '--text-', {
+ list: '--scale-type',
+ offChain: '--scale-type-offchain',
+ readme: 'Reading hierarchy',
+ kind: 'length'
+ }),
+ define('space', '--space-', {
+ list: '--scale-space',
+ readme: 'Spacing, radius, and stacking',
+ kind: 'length',
+ // The base is a declared token, not a literal here: a second copy of it
+ // would be exactly the drift this script exists to prevent.
+ base: '--scale-space-base'
+ }),
+ define('radius', '--radius-', {
+ list: '--scale-radius',
+ readme: 'Spacing, radius, and stacking',
+ kind: 'length'
+ }),
+ define('layer', '--layer-', {
+ list: '--scale-layer',
+ readme: 'Spacing, radius, and stacking',
+ kind: 'integer'
+ })
+];
+
+/** Comments carry prose that names tokens; strip them so prose can never be
+ * mistaken for a declaration. */
+export function stripComments(cssText) {
+ return cssText.replace(/\/\*[\s\S]*?\*\//g, '');
+}
+
+/** Declared custom properties of one family. The lookahead requires a `:`, so a
+ * name merely *referenced* inside a list value is not a declaration. */
+export function declaredTokens(cssText, family) {
+ const names = [...new Set(cssText.match(/--[a-z0-9-]+(?=\s*:)/g) ?? [])];
+ return names.filter(name => family.test(name)).sort();
+}
+
+/** The declaration text of one token, or null when it is not declared. */
+export function declaredValue(cssText, name) {
+ const match = cssText.match(new RegExp(`${name}\\s*:\\s*([^;]*);`));
+ return match ? match[1].trim() : null;
+}
+
+/** The whitespace-separated value of a list declaration, or null when absent. */
+export function listedValues(cssText, name) {
+ const value = declaredValue(cssText, name);
+ return value === null ? null : value.split(/\s+/).filter(Boolean);
+}
+
+/** A value as a comparable number, or null when only a viewport can resolve it.
+ * `rem` assumes the 16px root: the editor sets no root font-size, and the
+ * browser walk asserts the effective values, so a future root change shows up
+ * there rather than silently skewing this comparison. */
+export function scalarValue(value, kind = 'length') {
+ const text = String(value).trim();
+ if (kind === 'integer') return /^\d+$/.test(text) ? Number(text) : null;
+ if (/^-?\d*\.?\d+px$/i.test(text)) return Number.parseFloat(text);
+ if (/^-?\d*\.?\d+rem$/i.test(text)) return Number.parseFloat(text) * 16;
+ return null;
+}
+
+/** Classification problems for one scale. */
+export function classificationProblems(scale, { declared, chain, offChain }) {
+ if (!chain) return [`tokens.css declares no ${scale.list}`];
+ if (scale.offChain && !offChain) return [`tokens.css declares no ${scale.offChain}`];
+
+ const problems = [];
+ if (chain.length === 0) problems.push(`${scale.list} is empty`);
+ for (const name of chain) {
+ if (offChain.includes(name)) problems.push(`${name} is listed in both ${scale.list} and ${scale.offChain}`);
+ }
+ const lists = scale.offChain ? [[scale.list, chain], [scale.offChain, offChain]] : [[scale.list, chain]];
+ for (const [label, names] of lists) {
+ for (const name of names) {
+ if (names.indexOf(name) !== names.lastIndexOf(name)) problems.push(`${name} is listed twice in ${label}`);
+ if (!declared.includes(name)) problems.push(`${label} lists ${name}, which tokens.css does not declare`);
+ }
+ }
+ for (const name of declared) {
+ if (!chain.includes(name) && !offChain.includes(name)) {
+ problems.push(`${name} is declared but classified by no ${scale.id} list — add it to ${scale.list}`);
+ }
+ }
+ return problems;
+}
+
+/** Ordering, base-multiple, and kind problems for one scale. Returns the
+ * problems plus how many adjacent pairs a viewport had to resolve. */
+export function scaleOrderProblems(scale, chain, values, base = null) {
+ const problems = [];
+ const deferred = [];
+ let previous = null;
+
+ for (const name of chain) {
+ const value = values.get(name);
+ if (value === null || value === undefined) {
+ deferred.push(name);
+ continue;
+ }
+ if (scale.kind === 'integer' && !/^\d+$/.test(String(value))) {
+ problems.push(`${name} must be a bare number, not ${value}`);
+ }
+ if (previous && value <= previous.value) {
+ problems.push(`${name} must outrank ${previous.name} in ${scale.list}`);
+ }
+ previous = { name, value };
+ }
+
+ if (scale.base && base !== null) {
+ for (const name of chain) {
+ const value = values.get(name);
+ if (value === null || value === undefined) continue;
+ if (!Number.isInteger(value / base.px)) {
+ problems.push(`${name} is ${value}px, not a whole multiple of the ${base.token} base (${base.px}px)`);
+ }
+ }
+ } else if (scale.base) {
+ problems.push(`tokens.css declares no usable ${scale.base}`);
+ }
+ return { problems, deferred };
+}
+
+/** Whether `text` names `name` as a whole token rather than as the prefix of a
+ * longer one: `--scale-space-base` must not pass for `--scale-space`. */
+export function mentions(text, name) {
+ return new RegExp(`${name}(?![a-z0-9-])`).test(text);
+}
+
+/** README problems for one scale: the section must point at the scale's list and
+ * must not name tokens that do not exist. */
+export function readmeProblems(scale, readmeText, declared) {
+ const problems = [];
+ const section = readmeText.split(/^## /m).find(text => text.startsWith(scale.readme));
+ if (!section) {
+ problems.push(`editor/README.md has no "${scale.readme}" section to check ${scale.id} against`);
+ return problems;
+ }
+ if (!mentions(section, scale.list)) {
+ problems.push(`editor/README.md's "${scale.readme}" section does not name ${scale.list}`);
+ }
+ // A token name is hyphen-separated segments, each non-empty: that keeps the
+ // *prefix* of a glob like `--text-*` from reading as a token named `--text-`.
+ for (const name of new Set(section.match(/--[a-z0-9]+(?:-[a-z0-9]+)*/g) ?? [])) {
+ if (scale.family.test(name) && !declared.includes(name)) {
+ problems.push(`editor/README.md names ${name}, which tokens.css does not declare`);
+ }
+ }
+ return problems;
+}
+
+/** No bare-number `z-index`: the ladder is the only authority on stacking. */
+export function stackingProblems(stylesCss) {
+ const problems = [];
+ for (const match of stripComments(stylesCss).matchAll(/z-index:\s*([^;}]+)/g)) {
+ const value = match[1].trim();
+ if (!/^var\(--layer-[a-z0-9-]+\)$/.test(value)) {
+ problems.push(`styles.css sets z-index: ${value} — name a layer token instead`);
+ }
+ }
+ return problems;
+}
+
+/** Where a scale token may be declared, and that every reference resolves. */
+export function placementProblems(scales, tokensCss, stylesCss) {
+ const problems = [];
+
+ // Declared once: a repeated declaration is an override in disguise, because
+ // the last one wins in the cascade and nothing else says so.
+ for (const scale of scales) {
+ // Deliberately unanchored: a re-declaration is just as real inside a
+ // one-line or nested block, and the colon is what marks a declaration (a
+ // list value or a `var()` reference is never followed by one).
+ const found = (tokensCss.match(new RegExp(`${scale.prefix}[a-z0-9-]+\\s*:`, 'g')) ?? [])
+ .map(match => match.replace(/\s*:$/, ''));
+ for (const name of new Set(found)) {
+ const count = found.filter(candidate => candidate === name).length;
+ if (count > 1) problems.push(`${name} is declared ${count} times in tokens.css — a scale has one value per step`);
+ }
+ }
+
+ // And declared only there: a scale token in styles.css would be a second
+ // source of truth wearing a different file name.
+ for (const match of stripComments(stylesCss).matchAll(/(--[a-z0-9-]+)\s*:/g)) {
+ const name = match[1];
+ const owner = scales.find(candidate => candidate.family.test(name));
+ if (owner) problems.push(`styles.css declares ${name}; the ${owner.id} scale belongs in tokens.css`);
+ }
+
+ // Every reference must land on a declared step of the same scale — a typo in
+ // a `var()` is otherwise an invalid property at runtime and nothing else.
+ const known = new Map(scales.map(scale => [scale, declaredTokens(tokensCss, scale.family)]));
+ for (const match of stripComments(stylesCss).matchAll(/var\(\s*(--[a-z0-9-]+)/g)) {
+ const name = match[1];
+ const owner = scales.find(candidate => candidate.family.test(name));
+ if (owner && !known.get(owner).includes(name)) {
+ problems.push(`styles.css uses var(${name}), which tokens.css does not declare`);
+ }
+ }
+ return problems;
+}
+
+function runSelfTests() {
+ const fixtures = [
+ {
+ name: 'a fully classified scale reports nothing',
+ actual: classificationProblems(
+ { id: 'x', list: '--scale-x', offChain: '--scale-x-offchain' },
+ { declared: ['--x-a', '--x-b', '--x-c'], chain: ['--x-a', '--x-b'], offChain: ['--x-c'] }
+ ),
+ expected: []
+ },
+ {
+ name: 'an unclassified token is reported',
+ actual: classificationProblems(
+ { id: 'x', list: '--scale-x' },
+ { declared: ['--x-a', '--x-b'], chain: ['--x-a'], offChain: [] }
+ ),
+ expected: ['--x-b is declared but classified by no x list — add it to --scale-x']
+ },
+ {
+ name: 'a stale entry is reported',
+ actual: classificationProblems(
+ { id: 'x', list: '--scale-x' },
+ { declared: ['--x-a'], chain: ['--x-a', '--x-gone'], offChain: [] }
+ ),
+ expected: ['--scale-x lists --x-gone, which tokens.css does not declare']
+ },
+ {
+ name: 'a missing list is reported rather than passing vacuously',
+ actual: classificationProblems({ id: 'x', list: '--scale-x' }, { declared: [], chain: null, offChain: [] }),
+ expected: ['tokens.css declares no --scale-x']
+ },
+ {
+ name: 'an empty off-chain axis is legitimate',
+ actual: classificationProblems(
+ { id: 'x', list: '--scale-x', offChain: '--scale-x-offchain' },
+ { declared: ['--x-a'], chain: ['--x-a'], offChain: [] }
+ ),
+ expected: []
+ },
+ {
+ name: 'a collapsed step is reported',
+ actual: scaleOrderProblems(
+ { id: 'x', list: '--scale-x', kind: 'length' },
+ ['--x-a', '--x-b'],
+ new Map([['--x-a', 16], ['--x-b', 16]])
+ ).problems,
+ expected: ['--x-b must outrank --x-a in --scale-x']
+ },
+ {
+ name: 'a viewport-dependent step is deferred, not failed',
+ actual: scaleOrderProblems(
+ { id: 'x', list: '--scale-x', kind: 'length' },
+ ['--x-a', '--x-b', '--x-c'],
+ new Map([['--x-a', 16], ['--x-b', null], ['--x-c', 32]])
+ ),
+ expected: { problems: [], deferred: ['--x-b'] }
+ },
+ {
+ name: 'a step off the declared base is reported',
+ actual: scaleOrderProblems(
+ { id: 'x', list: '--scale-x', kind: 'length', base: '--scale-x-base' },
+ ['--x-a', '--x-b'],
+ new Map([['--x-a', 4], ['--x-b', 6]]),
+ { token: '--scale-x-base', px: 4 }
+ ).problems,
+ expected: ['--x-b is 6px, not a whole multiple of the --scale-x-base base (4px)']
+ },
+ {
+ name: 'a scale whose base went missing is reported, not skipped',
+ actual: scaleOrderProblems(
+ { id: 'x', list: '--scale-x', kind: 'length', base: '--scale-x-base' },
+ ['--x-a'],
+ new Map([['--x-a', 4]]),
+ null
+ ).problems,
+ expected: ['tokens.css declares no usable --scale-x-base']
+ },
+ {
+ name: 'a bare-number z-index is reported',
+ actual: stackingProblems('.f { z-index: 5; }', []),
+ expected: ['styles.css sets z-index: 5 — name a layer token instead']
+ },
+ {
+ name: 'a layered z-index passes',
+ actual: stackingProblems('.f { z-index: var(--layer-nav); }'),
+ expected: []
+ },
+ {
+ name: 'an undeclared reference is caught',
+ actual: placementProblems(
+ [define('layer', '--layer-', { id: 'layer' })],
+ ':root { --layer-nav: 5; }',
+ '.f { z-index: var(--layer-nav); } .g { z-index: var(--layer-nope); }'
+ ),
+ expected: ['styles.css uses var(--layer-nope), which tokens.css does not declare']
+ },
+ {
+ name: 'a scale token re-declared in tokens.css is caught',
+ actual: placementProblems(
+ [define('layer', '--layer-', { id: 'layer' })],
+ ':root { --layer-nav: 5; }\n:root { --layer-nav: 9; }',
+ ''
+ ),
+ expected: ['--layer-nav is declared 2 times in tokens.css — a scale has one value per step']
+ },
+ {
+ name: 'a scale token declared in styles.css is caught',
+ actual: placementProblems(
+ [define('layer', '--layer-', { id: 'layer' })],
+ ':root { --layer-nav: 5; }',
+ '.f { --layer-local: 3; }'
+ ),
+ expected: ['styles.css declares --layer-local; the layer scale belongs in tokens.css']
+ },
+ {
+ name: 'comments are not declarations',
+ actual: declaredTokens(stripComments(':root {\n /* --text-fake: 1rem; */\n --text-real: 1rem;\n}'), /^--text-/),
+ expected: ['--text-real']
+ },
+ {
+ name: 'units normalize for comparison',
+ actual: [scalarValue('0.25rem'), scalarValue('999px'), scalarValue('clamp(1rem, 2vw, 2rem)'), scalarValue('5', 'integer')],
+ expected: [4, 999, null, 5]
+ },
+ {
+ name: 'a glob is not a token name',
+ actual: ('## Reading hierarchy\n\nsee `--text-*` and --text-xl').match(/--[a-z0-9]+(?:-[a-z0-9]+)*/g),
+ expected: ['--text', '--text-xl']
+ },
+ {
+ name: 'a longer name does not satisfy the list pointer',
+ actual: readmeProblems(
+ { id: 'space', list: '--scale-space', family: /^--space-/, readme: 'Spacing', kind: 'length' },
+ '## Spacing\n\nThe base is --scale-space-base.\n',
+ []
+ ),
+ expected: ['editor/README.md\'s "Spacing" section does not name --scale-space']
+ },
+ {
+ name: 'the list name satisfies its own pointer',
+ actual: readmeProblems(
+ { id: 'space', list: '--scale-space', family: /^--space-/, readme: 'Spacing', kind: 'length' },
+ '## Spacing\n\nEight steps, declared as --scale-space.\n',
+ []
+ ),
+ expected: []
+ }
+ ];
+
+ const show = value => (value && !Array.isArray(value) && typeof value === 'object'
+ ? JSON.stringify({ problems: value.problems, deferred: value.deferred })
+ : JSON.stringify(value));
+
+ let failures = 0;
+ for (const fixture of fixtures) {
+ if (show(fixture.actual) !== show(fixture.expected)) {
+ console.error(` - self-test "${fixture.name}" expected ${show(fixture.expected)}, got ${show(fixture.actual)}`);
+ failures += 1;
+ }
+ }
+ return failures;
+}
+
+// --- main -------------------------------------------------------------------
+
+const selfTestFailures = runSelfTests();
+
+const tokensCss = readFileSync(TOKENS_CSS, 'utf8');
+const stylesCss = readFileSync(STYLES_CSS, 'utf8');
+const readme = readFileSync(README, 'utf8');
+const tokens = stripComments(tokensCss);
+
+const problems = [];
+const summary = [];
+
+for (const scale of SCALES) {
+ const declared = declaredTokens(tokens, scale.family);
+ const chain = listedValues(tokens, scale.list);
+ const offChain = scale.offChain ? listedValues(tokens, scale.offChain) : [];
+ problems.push(...classificationProblems(scale, { declared, chain, offChain }));
+
+ const values = new Map();
+ for (const name of chain ?? []) {
+ const raw = declaredValue(tokens, name);
+ values.set(name, raw === null ? null : scalarValue(raw, scale.kind));
+ }
+ let base = null;
+ if (scale.base) {
+ const baseText = declaredValue(tokens, scale.base);
+ const px = baseText === null ? null : scalarValue(baseText);
+ base = px === null ? null : { token: scale.base, px };
+ }
+ const order = scaleOrderProblems(scale, chain ?? [], values, base);
+ problems.push(...order.problems);
+ problems.push(...readmeProblems(scale, readme, declared));
+
+ summary.push(`${scale.id} ${(chain ?? []).length}${order.deferred.length > 0 ? ` (${order.deferred.length} deferred to the browser walk)` : ''}`);
+}
+
+problems.push(...stackingProblems(stylesCss));
+problems.push(...placementProblems(SCALES, tokens, stripComments(stylesCss)));
+
+for (const problem of problems) console.error(` - ${problem}`);
+if (problems.length > 0) {
+ console.error(`\nscale conformance: ${problems.length} problem(s)`);
+ process.exitCode = 1;
+} else {
+ console.log(`scale conformance: OK (${summary.join(', ')}; no bare z-index)`);
+}
+
+if (selfTestFailures > 0) process.exitCode = 1;
diff --git a/editor/ui/src/App.svelte b/editor/ui/src/App.svelte
index bb8d32d8..07170d61 100644
--- a/editor/ui/src/App.svelte
+++ b/editor/ui/src/App.svelte
@@ -33,7 +33,7 @@
sourceOffset,
defaultCreatePath
} from './lib/utils';
- import { connection, applyHealth, applyVersion, markConnected, markTokenMissing, markConnectFailed, markHostUnavailable } from './lib/state/connection.svelte';
+ import { connection, applyHealth, applyVersion, markConnected, markTokenMissing, markConnectFailed, markHostUnavailable, HOST_UNAVAILABLE_DETAIL } from './lib/state/connection.svelte';
import { project, refreshFiles, rememberFile, rememberRenamedFile, forgetFile, initProjectTree } from './lib/state/project.svelte';
import { buffer, dirty, loadBuffer, resetBuffer, undo, redo, trackCursor, discardBuffer, clearRecovery, stopRecoveryTimer, flushRecovery, loadRecovery, markBufferHostUnavailable } from './lib/state/buffer.svelte';
import { authoring, suggestions, refreshAuthoring, setAuthoring } from './lib/state/authoring.svelte';
@@ -341,8 +341,9 @@
}
function noteHostUnavailable() {
- const next = 'Local host unavailable. Restart boris-editor.';
- if (connection.status === next) return;
+ // The sentence doubles as the "already reported" guard, so the copy and the
+ // comparison both come from the connection state module (#993).
+ if (connection.status === HOST_UNAVAILABLE_DETAIL) return;
markHostUnavailable();
markBufferHostUnavailable();
}
@@ -915,11 +916,12 @@
onfocus={() => void probeDisk()}
/>
-
+
diff --git a/editor/ui/src/components/Header.svelte b/editor/ui/src/components/Header.svelte
index 84d52df8..3ae789ef 100644
--- a/editor/ui/src/components/Header.svelte
+++ b/editor/ui/src/components/Header.svelte
@@ -1,11 +1,16 @@
-
+Skip to workspace
-
diff --git a/editor/ui/src/components/SectionNav.svelte b/editor/ui/src/components/SectionNav.svelte
index c9d6e32c..7c4138ad 100644
--- a/editor/ui/src/components/SectionNav.svelte
+++ b/editor/ui/src/components/SectionNav.svelte
@@ -30,6 +30,10 @@
// mode's name. The link stays fully enabled: activation asks the owner
// to reveal the mode first, then performs the ordinary jump.
modeGated?: Record;
+ // The active density mode (#993). Author leads with the writing
+ // destinations and lets the Review destinations recede; Review shows the
+ // full row at one weight. Emphasis only — every link stays live.
+ mode?: 'author' | 'review';
// Receives the reason for a click on an unavailable target; App routes
// it to the editing-status live region.
onBlockedNav?: (reason: string) => void;
@@ -38,7 +42,7 @@
onReveal?: (id: string) => Promise | void;
};
- let { unavailable = {}, modeGated = {}, onBlockedNav, onReveal }: Props = $props();
+ let { unavailable = {}, modeGated = {}, mode = 'review', onBlockedNav, onReveal }: Props = $props();
const links: SectionLink[] = [
{ id: 'project', label: 'Project' },
@@ -50,6 +54,12 @@
{ id: 'watch', label: 'Watch' }
];
+ // The review-side destinations, in link order (#993). Author mode keeps
+ // them reachable but stops the strip from advertising the whole product:
+ // the boundary gets a caption and the group recedes to faint ink.
+ const REVIEW_IDS = new Set(['graph', 'publication', 'problems', 'preview', 'watch']);
+ const reviewBoundary = links.find(({ id }) => REVIEW_IDS.has(id))?.id ?? null;
+
const ARRIVAL_MS = 1600;
let nav = $state() as HTMLElement | undefined;
@@ -327,18 +337,26 @@
class="section-nav"
class:scroll-start={canScrollStart}
class:scroll-end={canScrollEnd}
+ class:author-quiet={mode === 'author'}
data-arrived={arrived ?? ''}
aria-label="Editor sections"
bind:this={nav}
>
{#each links as { id, label } (id)}
+ {#if mode === 'author' && id === reviewBoundary}
+
+
+ Review
+ {/if}
void handleNav(event, id)}
aria-current={current === id ? 'true' : undefined}
aria-disabled={unavailable[id] ? 'true' : undefined}
class:mode-gated={Boolean(modeGated[id])}
+ class:review-link={REVIEW_IDS.has(id)}
title={unavailable[id]
?? (modeGated[id] ? `Shown in ${modeGated[id]} mode — activating switches modes` : undefined)}
>{label}
diff --git a/editor/ui/src/lib/state/buffer.svelte.ts b/editor/ui/src/lib/state/buffer.svelte.ts
index 7ed3af76..840a291b 100644
--- a/editor/ui/src/lib/state/buffer.svelte.ts
+++ b/editor/ui/src/lib/state/buffer.svelte.ts
@@ -6,7 +6,7 @@
import { tick } from 'svelte';
import { api } from '../api';
-import { connection } from './connection.svelte';
+import { connection, markHostUnavailable, HOST_UNAVAILABLE_DETAIL } from './connection.svelte';
import type {
BufferResponse,
ErrorResponse,
@@ -214,11 +214,13 @@ export async function snapshotBuffer(options: RequestInit = {}) {
...options
});
if ((result.data as ErrorResponse).error === 'host_unavailable') {
- // Mirrors the host-watch path: the connection line flips once, and the
- // editing-status message is rewritten only on that transition.
- const next = 'Local host unavailable. Restart boris-editor.';
- if (connection.status !== next) {
- connection.status = next;
+ // Mirrors the host-watch path: the connection readout flips once, and the
+ // editing-status message is rewritten only on that transition. The write
+ // goes through markHostUnavailable rather than assigning connection.status
+ // here, so the chip's short label can never disagree with the sentence it
+ // expands (#993).
+ if (connection.status !== HOST_UNAVAILABLE_DETAIL) {
+ markHostUnavailable();
markBufferHostUnavailable();
}
return;
diff --git a/editor/ui/src/lib/state/connection.svelte.ts b/editor/ui/src/lib/state/connection.svelte.ts
index b49bc562..40862c01 100644
--- a/editor/ui/src/lib/state/connection.svelte.ts
+++ b/editor/ui/src/lib/state/connection.svelte.ts
@@ -9,6 +9,10 @@ import { versionLabel } from '../utils';
import type { Health, Version } from '../types';
export const connection = $state({
+ // The compact label the header chip shows (#993). It is written together
+ // with the honest sentence below — never derived by parsing it — so a short
+ // label can never quietly disagree with the detail it summarizes.
+ summary: 'Connecting…',
status: 'Connecting to the local host…',
compiler: 'Checking Boris version…',
project: 'Checking project conventions…',
@@ -16,14 +20,22 @@ export const connection = $state({
validateDaemon: false
});
+// The single writer for the status pair: the chip's short label and the full
+// sentence the author expands on demand. Both are honest restatements of what
+// a host payload said, so they change in the same place and stay in step.
+function setStatus(summary: string, detail: string) {
+ connection.summary = summary;
+ connection.status = detail;
+}
+
export function markTokenMissing() {
- connection.status = 'Session token missing. Launch the editor from boris-editor.';
+ setStatus('Token missing', 'Session token missing. Launch the editor from boris-editor.');
connection.compiler = 'Boris version unavailable.';
connection.project = 'Project status unavailable.';
}
export function markConnected(editorId: string, started: number) {
- connection.status = `Connected to ${editorId}. Opened project in ${elapsedLabel(started)}.`;
+ setStatus('Connected', `Connected to ${editorId}. Opened project in ${elapsedLabel(started)}.`);
}
export function applyHealth(health: Health) {
@@ -49,6 +61,12 @@ export function markConnectFailed() {
connection.project = 'Project status unavailable.';
}
+// The host-unavailable sentence is also the guard other host-watch paths use
+// to tell "already reported" from "just failed", so it lives here with the
+// writer instead of being re-typed at each site — one place to keep the short
+// label and the sentence in step with each other.
+export const HOST_UNAVAILABLE_DETAIL = 'Local host unavailable. Restart boris-editor.';
+
export function markHostUnavailable() {
- connection.status = 'Local host unavailable. Restart boris-editor.';
+ setStatus('Host unavailable', HOST_UNAVAILABLE_DETAIL);
}
diff --git a/editor/ui/src/lib/tokens.css b/editor/ui/src/lib/tokens.css
index 324fae0b..e576d207 100644
--- a/editor/ui/src/lib/tokens.css
+++ b/editor/ui/src/lib/tokens.css
@@ -66,6 +66,31 @@
--header-chip-bg: transparent;
--header-chip-bg-hover: rgba(249, 255, 248, 0.14);
+ /* --- declared scales ---
+ These lists are the contract for every ordered scale here, and they live
+ in one place so each order is written down exactly once: nothing else —
+ not the tests, not the README, not a component — keeps a copy.
+
+ `--scale-type` is the prose chain, ascending. `--scale-type-offchain` is
+ the sizes that are real tokens but not steps of it, because they are an
+ orthographic axis: those may interleave with the chain but never coincide
+ with a step. `--scale-space` and `--scale-radius` are the spacing rhythm
+ and the corner steps, and `--scale-layer` is the stacking ladder.
+
+ A new `--text-*`, `--space-*`, `--radius-*`, or `--layer-*` token must be
+ listed in its scale, and `scripts/check-scales.mjs` fails otherwise — so
+ no size, gap, corner, or layer enters the surface unclassified. The
+ elevation tiers are deliberately absent: a shadow list has no scalar
+ order for a list to protect. */
+ --scale-type: --text-2xs --text-xs --text-sm --text-md --text-lg --text-xl --text-2xl-compact --text-2xl;
+ --scale-type-offchain: --text-code;
+ --scale-space: --space-1 --space-2 --space-3 --space-4 --space-5 --space-6 --space-7 --space-8;
+ /* The spacing rhythm's base, declared rather than described: the lint and
+ the browser walk both check that every step is a whole multiple of it. */
+ --scale-space-base: 0.25rem;
+ --scale-radius: --radius-sm --radius-md --radius-lg --radius-pill;
+ --scale-layer: --layer-focus-mirror --layer-focus-editor --layer-focus-zen --layer-nav --layer-skip-link --layer-overlay;
+
/* --- spacing scale (0.25rem base) --- */
--space-1: 0.25rem;
--space-2: 0.5rem;
@@ -82,6 +107,20 @@
--radius-lg: 0.75rem;
--radius-pill: 999px;
+ /* --- stacking layers ---
+ One ladder for the whole editor. The first three order elements *inside*
+ the focus editor's own stacking context — the measuring mirror, the text
+ above it, and the dimming veil over both; the last three are global
+ chrome: the sticky section nav, the skip link, and the full-viewport
+ focus overlay. `z-index` in styles.css must name one of these, because a
+ bare number is how two layers silently swap places. */
+ --layer-focus-mirror: 0;
+ --layer-focus-editor: 1;
+ --layer-focus-zen: 2;
+ --layer-nav: 5;
+ --layer-skip-link: 10;
+ --layer-overlay: 50;
+
/* --- elevation tiers --- */
--shadow-1: 0 1px 2px rgba(23, 32, 29, 0.05), 0 2px 4px rgba(23, 32, 29, 0.06);
--shadow-2: 0 2px 4px rgba(23, 32, 29, 0.08), 0 6px 18px rgba(23, 32, 29, 0.1);
@@ -97,7 +136,9 @@
Component styles must consume these tokens rather than raw rems: an
off-scale size is how the hierarchy drifted into everything-is-16px in
- the first place. Extend the chain here instead of adding a local size. */
+ the first place. Extend the chain here instead of adding a local size.
+ Its order is declared in the `--- declared scales ---` block above, with
+ every other ordered scale — that list is the only copy. */
--text-2xs: 0.7rem; /* 11.2px kbd chips, micro labels */
--text-xs: 0.78rem; /* 12.5px eyebrows, status meta, captions */
--text-sm: 0.875rem; /* 14px ledes, field labels, dense rows */
@@ -105,6 +146,7 @@
--text-lg: 1.125rem; /* 18px sub-pane titles (h3) */
--text-xl: 1.35rem; /* 21.6px pane titles (h2) */
--text-2xl: clamp(1.6rem, 2.4vw, 2rem); /* app title (h1) */
+ --text-2xl-compact: clamp(1.5rem, 2vw, 1.75rem); /* app title in the Author band: one step down, still above pane titles (h2) */
--text-code: 0.95rem; /* 15.2px monospace working surfaces */
/* --- reading rhythm ---
diff --git a/editor/ui/src/styles.css b/editor/ui/src/styles.css
index 306e530f..8a3f2b85 100644
--- a/editor/ui/src/styles.css
+++ b/editor/ui/src/styles.css
@@ -47,19 +47,26 @@ button.danger:hover:not(:disabled) { background: var(--color-danger-soft); borde
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [role="option"]:focus-visible, summary:focus-visible { outline: var(--focus-ring-width) solid var(--color-focus); outline-offset: var(--focus-ring-offset); }
a { color: var(--color-link); text-underline-offset: 0.2em; }
-.skip-link { position: absolute; left: var(--space-4); top: -4rem; padding: var(--space-3) var(--space-4); background: var(--color-surface); color: var(--color-text); border-radius: var(--radius-md); box-shadow: var(--shadow-2); z-index: 10; }
+.skip-link { position: absolute; left: var(--space-4); top: -4rem; padding: var(--space-3) var(--space-4); background: var(--color-surface); color: var(--color-text); border-radius: var(--radius-md); box-shadow: var(--shadow-2); z-index: var(--layer-skip-link); }
.skip-link:focus { top: var(--space-4); }
header {
display: flex;
- align-items: end;
+ align-items: center;
justify-content: space-between;
- gap: var(--space-7);
- padding: var(--space-5) var(--content-pad) var(--space-4);
+ gap: var(--space-5) var(--space-7);
+ padding: var(--space-4) var(--content-pad);
background: var(--color-header);
color: var(--color-header-text);
border-bottom: 0.2rem solid var(--color-accent);
}
+/* #993: the identity row. The product mark and the live connection status sit
+ on one baseline row instead of stacking (eyebrow + title + a full-width
+ sentence), which is what took the band from three text lines to one. The
+ status keeps the ordinary prose rhythm so a two-line status stays readable,
+ and drops to the muted header ink: it is secondary to the title and updates
+ on its own, so it should not read as a headline. */
+.header-identity { display: flex; align-items: baseline; gap: var(--space-3) var(--space-4); flex-wrap: wrap; min-width: 0; }
/* Heading roles. Size steps come from lib/tokens.css and are never raw rems,
so the order holds everywhere: h1 (app) > h2 (pane) > h3 (sub-pane) > body.
h4 is deliberately not a fifth size: it is a group label inside a sub-pane
@@ -78,13 +85,42 @@ h4 {
color: var(--color-text-muted);
}
p { margin-top: 0; }
-.eyebrow { margin-bottom: 0.35rem; text-transform: uppercase; letter-spacing: 0.12em; font-size: var(--text-xs); color: var(--header-chip-border); }
-/* Connection status is secondary to the app title: one step down, and it gets
- the ordinary prose rhythm so a two-line status stays readable. */
-.connection { max-width: 24rem; margin-bottom: 0.35rem; font-size: var(--text-sm); }
+/* #993: the connection readout is a chip. The honest sentence is one
+ activation away instead of occupying the band on every load, and the live
+ region announces the short label — a host blip re-reads "Host unavailable",
+ not a full sentence. The chip is styled as a control because it is one, and
+ the detail it reveals stays plainly readable beside it. */
+.connection { display: inline-flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; max-width: min(30rem, 100%); margin: 0; font-size: var(--text-sm); color: color-mix(in srgb, var(--color-header-text) 74%, transparent); }
+.connection-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ min-height: 1.7rem;
+ padding: 0.05rem 0.55rem;
+ border: 1px solid var(--header-chip-border);
+ border-radius: var(--radius-pill);
+ background: var(--header-chip-bg);
+ color: var(--color-header-text);
+ font-size: var(--text-xs);
+ font-weight: 600;
+}
+.connection-chip:hover:not(:disabled) { background: var(--header-chip-bg-hover); border-color: var(--header-chip-border); }
+.connection-chip:focus-visible { outline-color: var(--color-focus); }
+/* The disclosure caret is a pseudo-element, so it never joins the accessible
+ name: the button is announced as exactly the state it names. */
+.connection-chip::after {
+ content: '';
+ width: 0.28rem;
+ height: 0.28rem;
+ border-right: 0.09rem solid currentColor;
+ border-bottom: 0.09rem solid currentColor;
+ transform: rotate(45deg) translate(-0.02rem, -0.04rem);
+ transition: transform var(--motion-fast) var(--ease-out);
+}
+.connection-chip[aria-expanded='true']::after { transform: rotate(-135deg) translate(-0.02rem, -0.04rem); }
+.connection-detail { font-size: var(--text-xs); }
.theme-toggle {
min-height: 2.1rem;
- margin-bottom: 0.35rem;
padding: 0.3rem 0.75rem;
color: var(--color-header-text);
background: var(--header-chip-bg);
@@ -99,8 +135,7 @@ p { margin-top: 0; }
/* 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; }
+.header-preferences { display: flex; align-items: center; gap: 0.7rem; flex-wrap: wrap; }
.density-toggle {
display: inline-flex;
padding: 0.15rem;
@@ -124,6 +159,16 @@ p { margin-top: 0; }
.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); }
+/* #993: Author mode gets a calmer top band. The writing surface is the
+ subject, so the chrome above it takes less height and less ink — a smaller
+ product mark and tighter bands. Review restores the full rhythm, where the
+ header is an entry point into the whole tool rather than a cover page.
+ The smaller mark is its own step in the chain (lib/tokens.css), not the
+ pane-title size: dropping h1 to --text-xl would have made the app title
+ exactly as large as the pane titles it outranks. */
+header[data-density='author'] { padding-block: var(--space-3); }
+header[data-density='author'] h1 { font-size: var(--text-2xl-compact); }
+
.section-nav {
display: flex;
align-items: center;
@@ -133,7 +178,7 @@ p { margin-top: 0; }
}
/* #941 C: the nav stays reachable after any jump. The header is not sticky,
so this sticks cleanly below the viewport top. */
-.section-nav { position: sticky; top: 0; z-index: 5; }
+.section-nav { position: sticky; top: 0; z-index: var(--layer-nav); }
/* The inner row is the scroller: flex 1 + min-width 0 let it shrink below
its content so overflow-x can engage on narrow viewports; the fade bars
stay anchored to the non-scrolling nav. */
@@ -152,6 +197,34 @@ p { margin-top: 0; }
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); }
+/* #993: the band itself tightens in Author mode, matching the header above it. */
+.section-nav.author-quiet { padding-block: 0.5rem; }
+/* #993: Author mode leads with the writing destinations. Project and Source
+ take the weight and the Review destinations recede into a captioned, faint
+ cluster, so the strip stops reading as a full IDE dashboard at first
+ glance. This is emphasis, not gating: every link stays a live link (and
+ mode-gated activation still switches modes and lands), and the group
+ caption is decorative — each link keeps its own name. */
+.section-nav.author-quiet a:not(.review-link) { font-weight: 700; }
+.section-nav.author-quiet a.review-link {
+ padding: 0.24rem 0.55rem;
+ color: var(--color-text-faint);
+ font-size: var(--text-xs);
+ font-weight: 500;
+}
+.section-nav.author-quiet a.review-link:hover { color: var(--color-link); }
+.section-nav.author-quiet a.review-link[aria-current='true'] { color: var(--color-on-accent); background: var(--color-accent); }
+.section-nav-divider { flex: 0 0 auto; align-self: center; width: 1px; height: 1rem; margin: 0 0.3rem; background: var(--color-border); }
+.section-nav-group {
+ flex: 0 0 auto;
+ align-self: center;
+ margin-right: 0.15rem;
+ color: var(--color-text-faint);
+ font-size: var(--text-2xs);
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
/* #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
@@ -197,6 +270,21 @@ section.arrived {
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); }
+/* #993: Author mode demotes Project from a peer card to a file drawer. Source
+ is a page the author sits down at; the tree is a side tool, so it stops
+ matching the page's height (align-self defeats the grid stretch), drops the
+ elevation and the heavy edge, and takes the recessed neutral surface with
+ tighter padding. Same pane, subordinate weight. */
+main.author-mode .project-pane {
+ align-self: start;
+ min-height: 0;
+ padding: var(--space-4) var(--space-5);
+ background: var(--color-surface-tint-neutral);
+ border-color: var(--color-border-soft);
+ border-radius: var(--radius-md);
+ box-shadow: none;
+}
+main.author-mode .project-pane .file-tree { max-height: 30rem; }
section {
min-height: 11rem;
padding: var(--space-6);
@@ -354,6 +442,48 @@ input:disabled, select:disabled, textarea:disabled { background: var(--color-sun
}
/* Author mode trades chrome for writing height (#990). */
.author-mode .source-editor-shell { min-height: clamp(28rem, 66vh, 46rem); }
+/* #993: Author mode gives Source page material rather than a wider dashboard
+ tile. The pane chrome goes quiet (the paper surface is the surface, so the
+ card edge and the drop shadow come off) and the editing shell loses its
+ second heavy edge in favor of a real focus-within state.
+
+ The measure is the pane itself: Source is capped at a comfortable
+ monospace line length (~80ch of --text-code inside the gutter and padding)
+ and centered in its grid column, so the pane edge IS the page edge. That is
+ deliberately not a padding inset on a full-width pane — insetting the text
+ while the box stays wide just re-draws the admin textarea with dead side
+ margins, and it rents the line-number gutter away from the lines it
+ numbers. Capping the pane keeps heading, editor, gutter, status line, and
+ the nested Graph/Publication sub-panes on one shared column. Below the cap
+ the pane simply fills its column, so narrow layouts are unchanged, and the
+ cap is Author-only: Review keeps the wide working surface beside its rail. */
+main.author-mode #source {
+ /* width:100% is load-bearing: a grid item with any justify-self other than
+ `stretch` sizes to its content, so without it the pane would shrink to
+ ~520px instead of taking the cap. Percentages resolve against the grid
+ area, so this reads as min(column, cap). */
+ width: 100%;
+ max-width: 54rem;
+ justify-self: center;
+ padding: var(--space-5) var(--space-5) var(--space-4);
+ background: var(--color-surface);
+ border-color: var(--color-border-soft);
+ border-radius: var(--radius-md);
+ box-shadow: none;
+}
+main.author-mode .source-editor-shell {
+ border-color: var(--color-border-soft);
+ box-shadow: none;
+}
+main.author-mode .source-editor-shell:focus-within {
+ border-color: var(--color-focus);
+ box-shadow: 0 0 0 0.18rem var(--color-accent-soft);
+}
+/* #993: the shell is the page in Author, so it owns the focus ring. The
+ textarea keeps the global ring everywhere else, but inside this shell it
+ would draw a second rust frame just inside the page edge — a frame within
+ a frame, which is the boxed-admin look the page treatment replaced. */
+main.author-mode .source-surface textarea:focus-visible { outline: none; }
.source-editor-shell.readonly { background: var(--color-sunken); }
.source-gutter {
position: relative;
@@ -682,7 +812,7 @@ footer .key-hint { margin: 0 0 0.35rem; }
.focus-overlay {
position: fixed;
inset: 0;
- z-index: 50;
+ z-index: var(--layer-overlay);
display: flex;
flex-direction: column;
background: var(--color-canvas);
@@ -787,11 +917,11 @@ footer .key-hint { margin: 0 0 0.35rem; }
flex: 1 1 0;
min-width: 0;
}
-.focus-editor-shell .focus-editor { position: relative; z-index: 1; }
+.focus-editor-shell .focus-editor { position: relative; z-index: var(--layer-focus-editor); }
.zen-underlay {
position: absolute;
inset: 0;
- z-index: 2;
+ z-index: var(--layer-focus-zen);
opacity: var(--zen-underlay);
pointer-events: none;
border-radius: var(--radius-lg);
@@ -801,7 +931,7 @@ footer .key-hint { margin: 0 0 0.35rem; }
.focus-mirror {
position: absolute;
inset: 0;
- z-index: 0;
+ z-index: var(--layer-focus-mirror);
/* opacity instead of visibility: Chrome returns empty client rects for
ranges inside visibility:hidden subtrees, which would blind both aids. */
opacity: 0;
@@ -985,7 +1115,9 @@ footer .key-hint { margin: 0 0 0.35rem; }
@media (max-width: 52rem) {
header, .recovery-banner { display: block; }
- .connection, .recovery-banner ul { margin-top: var(--space-4); }
+ /* Stacked below its title, the status keeps a real gap; the header is a
+ block container here so the flex gap no longer applies. */
+ .header-identity, .recovery-banner ul { margin-bottom: var(--space-4); }
.section-nav-row { overflow-x: auto; }
.section-nav-fade { display: block; }
main, main.author-mode, .comparison { grid-template-columns: 1fr; }
diff --git a/editor/ui/tests/density-modes.spec.ts b/editor/ui/tests/density-modes.spec.ts
index bc999b21..c05d6d12 100644
--- a/editor/ui/tests/density-modes.spec.ts
+++ b/editor/ui/tests/density-modes.spec.ts
@@ -272,6 +272,256 @@ test('a reduced-motion reveal keeps aria-current on the landed target at max scr
.not.toHaveAttribute('aria-current');
});
+// #993 residual polish: after the #989–#991 slices Author still read as an
+// equal-card dashboard with a full IDE strip. These pin the three material
+// contracts so a regression is a failing test, not a vibe.
+test('Author demotes Project to a drawer instead of a twin card', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await installApi(page);
+ await openHome(page);
+
+ const project = page.locator('#project');
+ const source = page.locator('#source');
+
+ // Material: the page keeps no elevation and the drawer sits on a different
+ // (recessed) surface, so the two stop sharing one card language.
+ await expect(project).toHaveCSS('box-shadow', 'none');
+ await expect(source).toHaveCSS('box-shadow', 'none');
+ expect(await project.evaluate(el => getComputedStyle(el).backgroundColor))
+ .not.toBe(await source.evaluate(el => getComputedStyle(el).backgroundColor));
+
+ // Geometry: the drawer no longer stretches to the page's height.
+ const projectBox = (await project.boundingBox())!;
+ const sourceBox = (await source.boundingBox())!;
+ expect(sourceBox.height).toBeGreaterThan(projectBox.height);
+
+ // Review restores the peer panel: one row, equal height, card elevation.
+ await switchToReview(page);
+ await expect(project).not.toHaveCSS('box-shadow', 'none');
+ const reviewProject = (await project.boundingBox())!;
+ const reviewSource = (await source.boundingBox())!;
+ expect(Math.abs(reviewProject.height - reviewSource.height)).toBeLessThanOrEqual(1);
+});
+
+test('Author quiets the Review destinations in the section nav', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await installApi(page);
+ await openHome(page);
+ const nav = page.getByRole('navigation', { name: 'Editor sections' });
+
+ // Emphasis, not gating: every destination is still a live, named link.
+ await expect(nav.getByRole('link')).toHaveCount(7);
+ await expect(nav.locator('.section-nav-group')).toHaveText('Review');
+
+ const writingSize = await nav.getByRole('link', { name: 'Source', exact: true })
+ .evaluate(el => Number.parseFloat(getComputedStyle(el).fontSize));
+ const reviewSize = await nav.getByRole('link', { name: 'Problems', exact: true })
+ .evaluate(el => Number.parseFloat(getComputedStyle(el).fontSize));
+ expect(reviewSize).toBeLessThan(writingSize);
+
+ // The quieted link still switches modes and lands (the #990 contract), so
+ // quieting never turned a working destination into a dead one.
+ await nav.getByRole('link', { name: 'Problems', exact: true }).click();
+ await expect(page.locator('#problems')).toBeFocused();
+
+ // Review shows the whole row at one weight, with no caption.
+ await expect(nav.locator('.section-nav-group')).toHaveCount(0);
+ const reviewProblemsSize = await nav.getByRole('link', { name: 'Problems', exact: true })
+ .evaluate(el => Number.parseFloat(getComputedStyle(el).fontSize));
+ await expect(nav.getByRole('link', { name: 'Source', exact: true })).toHaveCSS('font-size', `${reviewProblemsSize}px`);
+});
+
+test('Author gives Source page material and keeps the mirror 1:1', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await installApi(page);
+ const editor = await openHome(page);
+ const shell = page.locator('.source-editor-shell');
+
+ // The page carries no card elevation; focus is what gives it an edge.
+ await expect(shell).toHaveCSS('box-shadow', 'none');
+ const unfocusedBorder = await shell.evaluate(el => getComputedStyle(el).borderTopColor);
+ await editor.focus();
+ await expect.poll(() => shell.evaluate(el => getComputedStyle(el).borderTopColor)).not.toBe(unfocusedBorder);
+
+ // The shell owns the ring, so the focused textarea must not draw a second
+ // rust frame just inside the page edge — one edge, not a frame in a frame.
+ expect(await editor.evaluate(el => getComputedStyle(el).outlineStyle)).toBe('none');
+
+ // The wider measure rides --source-pad-x, which the textarea and its
+ // measuring mirror both read: if they ever diverge, mirror rects stop
+ // mapping 1:1 to buffer coordinates and the gutter/current-line drift.
+ const [mirror, textarea] = await Promise.all([
+ page.locator('.source-mirror').evaluate(el => {
+ const style = getComputedStyle(el);
+ return { paddingLeft: style.paddingLeft, fontSize: style.fontSize };
+ }),
+ editor.evaluate((el: HTMLTextAreaElement) => {
+ const style = getComputedStyle(el);
+ return { paddingLeft: style.paddingLeft, fontSize: style.fontSize };
+ })
+ ]);
+ expect(mirror).toEqual(textarea);
+});
+
+test('Author caps Source at a readable measure and keeps one column', async ({ page }) => {
+ // Wide enough that the Author column exceeds the measure cap, and the Review
+ // column (which shares the row with the rail) exceeds it too — so this test
+ // can tell "capped in Author" apart from "capped everywhere".
+ await page.setViewportSize({ width: 1920, height: 1000 });
+ await installApi(page);
+ const editor = await openHome(page);
+ const source = page.locator('#source');
+
+ // The pane's grid track, measured without parsing grid-template-columns: the
+ // sibling column's trailing edge plus the gap starts the track, and main's
+ // content box ends it. Comparing against main's border box would be wrong —
+ // that box includes main's own padding.
+ const measure = await source.evaluate(el => {
+ const main = document.querySelector('main') as HTMLElement;
+ const project = document.querySelector('#project') as HTMLElement;
+ const style = getComputedStyle(main);
+ const gap = Number.parseFloat(style.columnGap || '0');
+ const trackLeft = project.getBoundingClientRect().right + gap;
+ const trackRight = main.getBoundingClientRect().right - Number.parseFloat(style.paddingRight);
+ const rect = el.getBoundingClientRect();
+ return {
+ width: rect.width,
+ trackWidth: trackRight - trackLeft,
+ leftInset: rect.left - trackLeft,
+ rightInset: trackRight - rect.right
+ };
+ });
+
+ // The measure is the pane: it stops short of its grid column and the slack is
+ // shared — a one-sided gap would be a layout accident, not a centered page.
+ expect(measure.width).toBeLessThan(measure.trackWidth - 200);
+ expect(Math.abs(measure.leftInset - measure.rightInset)).toBeLessThanOrEqual(2);
+
+ // The measure is a line length, not just a pixel count: 80-ish monospace
+ // columns is the point of the cap, so assert it in characters. The advance
+ // is measured from the live textarea font rather than assumed.
+ const columns = await editor.evaluate((el: HTMLTextAreaElement) => {
+ const style = getComputedStyle(el);
+ const probe = document.createElement('span');
+ probe.style.fontFamily = style.fontFamily;
+ probe.style.fontSize = style.fontSize;
+ probe.style.letterSpacing = style.letterSpacing;
+ probe.style.whiteSpace = 'pre';
+ probe.style.position = 'absolute';
+ probe.textContent = '0'.repeat(100);
+ document.body.appendChild(probe);
+ const advance = probe.getBoundingClientRect().width / 100;
+ probe.remove();
+ const gutters = Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.paddingRight);
+ return (el.clientWidth - gutters) / advance;
+ });
+ expect(columns).toBeGreaterThan(60);
+ expect(columns).toBeLessThan(95);
+
+ // Agreement: the heading and the editing shell share the page's column, and
+ // the line-number gutter stays bolted to the lines it numbers (no inset ever
+ // rents it away from the text it measures).
+ const [headingLeft, shellLeft, gutterRight, surfaceLeft] = await Promise.all([
+ page.locator('#source .pane-heading h2').evaluate(el => el.getBoundingClientRect().left),
+ page.locator('#source .source-editor-shell').evaluate(el => el.getBoundingClientRect().left),
+ page.locator('.source-gutter').evaluate(el => el.getBoundingClientRect().right),
+ page.locator('.source-surface').evaluate(el => el.getBoundingClientRect().left)
+ ]);
+ expect(Math.abs(headingLeft - shellLeft)).toBeLessThanOrEqual(1);
+ expect(Math.abs(gutterRight - surfaceLeft)).toBeLessThanOrEqual(1);
+
+ // Author-only: Review keeps the wide working surface, filling its track.
+ await switchToReview(page);
+ const review = await source.evaluate(el => {
+ const main = el.parentElement as HTMLElement;
+ const project = document.querySelector('#project') as HTMLElement;
+ const rail = document.querySelector('.workspace-rail') as HTMLElement;
+ const gap = Number.parseFloat(getComputedStyle(main).columnGap || '0');
+ const trackLeft = project.getBoundingClientRect().right + gap;
+ const trackRight = rail.getBoundingClientRect().left - gap;
+ return { width: el.getBoundingClientRect().width, trackWidth: trackRight - trackLeft };
+ });
+ expect(review.width).toBeGreaterThan(measure.width);
+ expect(Math.abs(review.width - review.trackWidth)).toBeLessThanOrEqual(1);
+});
+
+test('Author gives the top band a calmer rhythm than Review', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ // Pin the theme so the compact control's label is deterministic.
+ await page.addInitScript(() => localStorage.setItem('boris-editor-theme', 'dark'));
+ await installApi(page);
+ await openHome(page);
+
+ // The decorative eyebrow band is gone, and the product mark and the live
+ // status share one row instead of stacking as three separate text lines.
+ await expect(page.locator('header .eyebrow')).toHaveCount(0);
+ const [title, status] = await Promise.all([
+ page.locator('header h1').boundingBox(),
+ page.locator('header .connection').boundingBox()
+ ]);
+ expect(status!.y).toBeGreaterThanOrEqual(title!.y);
+ expect(status!.y).toBeLessThan(title!.y + title!.height);
+
+ // The theme control states the state, and its visible label is its
+ // accessible name — the editor's standing rule for visible labels. It is
+ // deliberately not a pressed toggle: a pressed state presumes a name that
+ // does not change with it, so "Light, not pressed" would contradict the
+ // label. Here the changing name *is* the state.
+ const themeButton = page.getByRole('button', { name: 'Dark', exact: true });
+ await expect(themeButton).toHaveText('Dark');
+ expect(await themeButton.getAttribute('aria-pressed')).toBeNull();
+
+ // Author tightens the band and drops the product mark a step; Review
+ // restores the full rhythm. The smaller mark is still an app title: it
+ // outranks the pane titles below it (dropping h1 to --text-xl had made the
+ // two identical, and nothing caught it).
+ const author = {
+ header: (await page.locator('header').boundingBox())!.height,
+ title: await page.locator('header h1').evaluate(el => Number.parseFloat(getComputedStyle(el).fontSize)),
+ paneTitle: await page
+ .locator('#source .pane-heading h2')
+ .evaluate(el => Number.parseFloat(getComputedStyle(el).fontSize))
+ };
+ expect(author.title).toBeGreaterThan(author.paneTitle);
+ await switchToReview(page);
+ const review = {
+ header: (await page.locator('header').boundingBox())!.height,
+ title: await page.locator('header h1').evaluate(el => Number.parseFloat(getComputedStyle(el).fontSize))
+ };
+ expect(author.header).toBeLessThan(review.header);
+ expect(author.title).toBeLessThan(review.title);
+});
+
+test('the connection readout is a compact chip whose detail is on demand', async ({ page }) => {
+ await installApi(page);
+ await openHome(page);
+
+ const region = page.getByRole('status', { name: 'Connection status' });
+ const chip = page.locator('.connection-chip');
+
+ // The live region is text-only, as every other status region in the editor
+ // is: a button inside an atomic region would be re-announced with the state
+ // it sits in. The chip is a real button beside it, not inside it.
+ await expect(region.getByRole('button')).toHaveCount(0);
+
+ // The live text is the short state, not the sentence that used to occupy the
+ // band on every load, and the honest detail is not in the DOM until asked for.
+ await expect(region).toHaveText('Connected');
+ await expect(chip).toHaveText('Connected');
+ await expect(region).not.toContainText('Opened project in');
+ await expect(page.locator('.connection-detail')).toHaveCount(0);
+
+ // On demand, by keyboard: the disclosure is a real button, not a hover-only
+ // tooltip, and activating it reveals the sentence the state summarizes.
+ await expect(chip).toHaveAttribute('aria-expanded', 'false');
+ await chip.press('Enter');
+ await expect(chip).toHaveAttribute('aria-expanded', 'true');
+ await expect(page.locator('.connection-detail')).toContainText('Connected to boris-editor/0.1.0.');
+
+ await chip.press('Enter');
+ await expect(page.locator('.connection-detail')).toHaveCount(0);
+});
+
test('the measured line gutter and current-line band track the caret', async ({ page }) => {
await installApi(page);
const editor = await openHome(page);
diff --git a/editor/ui/tests/reading-hierarchy.spec.ts b/editor/ui/tests/reading-hierarchy.spec.ts
index de0442e0..4a08a267 100644
--- a/editor/ui/tests/reading-hierarchy.spec.ts
+++ b/editor/ui/tests/reading-hierarchy.spec.ts
@@ -14,10 +14,16 @@ import { expect, test, type Page } from '@playwright/test';
// 5. a pane's action cluster never overflows its own section box — the
// overflow painted the cluster over the neighbouring pane, which is how
// it surfaced: a non-shrinking cluster made the Project pane's Delete
-// file button land under #source and fail pointer interception.
+// file button land under #source and fail pointer interception;
+// 6. the rendered heading levels stay ordered in *both* density modes.
//
// Sizes are read from computed styles, not from the stylesheet, so a token
// edit that breaks the chain fails here rather than in a screenshot review.
+// Invariant 6 exists because the loop below pins Review density to reach the
+// full pane chrome, and density-modes.spec.ts only ever compares the pair it
+// was told about — so an Author-only override had flattened the app title onto
+// the pane-title size with every suite green. The declared chains themselves —
+// type, space, radius, and layer — are walked in scales.spec.ts.
const BODY_REM = 16;
@@ -42,7 +48,7 @@ const PROBLEM = {
packet: '{"code":"EFRONTMATTER"}'
};
-async function installApi(page: Page) {
+async function installApi(page: Page, mode: 'author' | 'review' = 'review') {
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' } })
@@ -106,10 +112,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'));
+ // Most assertions here read the full pane chrome, which lives in Review
+ // density (#990), so Review is the default. The mode-sensitive checks pass
+ // their own mode and run in both.
+ await page.addInitScript(m => localStorage.setItem('boris-editor-density', m), mode);
await page.goto('/#token=test-session-token');
}
@@ -118,6 +124,11 @@ async function fontSize(page: Page, selector: string): Promise {
return page.locator(selector).first().evaluate(el => Number.parseFloat(getComputedStyle(el).fontSize));
}
+/** Distinct sizes, so several panes sharing one token count once. */
+function distinct(sizes: number[]): number[] {
+ return [...new Set(sizes)];
+}
+
/** Box and line metrics for a locator, so line counting stays honest. */
async function chipGeometry(locator: ReturnType) {
return locator.evaluate(el => ({
@@ -173,6 +184,70 @@ for (const width of [1440, 1024]) {
});
}
+// The level map, in order. Querying one level rather than one element is what
+// makes this a walk of the scale: every pane title is checked, not just the
+// first one, so a single pane overriding h2 cannot hide behind a sibling.
+const HEADING_LEVELS = [
+ { level: 'app title (h1)', selector: 'header h1' },
+ { level: 'pane title (h2)', selector: '.pane-heading h2' },
+ { level: 'sub-pane title (h3)', selector: '.pane-heading h3' }
+] as const;
+
+for (const mode of ['author', 'review'] as const) {
+ test(`heading levels never invert in ${mode} density`, async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await installApi(page, mode);
+ await openHome(page);
+
+ // Only what is rendered counts, so a title inside a collapsed disclosure
+ // cannot satisfy a level the mode does not actually show.
+ const measured = await Promise.all(
+ HEADING_LEVELS.map(async ({ level, selector }) => {
+ const visible = page.locator(`${selector}:visible`);
+ const count = await visible.count();
+ const sizes: number[] = [];
+ for (let i = 0; i < count; i += 1) {
+ sizes.push(await visible.nth(i).evaluate(el => Number.parseFloat(getComputedStyle(el).fontSize)));
+ }
+ return { level, sizes };
+ })
+ );
+
+ const [app, pane, subPane] = measured;
+ expect(app.sizes.length, 'the app title must be rendered in every density').toBeGreaterThan(0);
+ expect(pane.sizes.length, 'at least one pane title must be rendered').toBeGreaterThan(0);
+
+ const appSize = app.sizes[0];
+ const paneSizes = distinct(pane.sizes);
+ const subPaneSizes = distinct(subPane.sizes);
+
+ // Strict descent, and `toBeLessThan` is strict on purpose: an app title
+ // rendering at exactly the pane-title size is the regression, not a tie.
+ for (const size of paneSizes) {
+ expect(size, `a pane title must stay below the app title in ${mode} density`).toBeLessThan(appSize);
+ expect(size, `a pane title must outrank body copy in ${mode} density`).toBeGreaterThan(BODY_REM);
+ }
+ for (const size of subPaneSizes) {
+ expect(size, `a sub-pane title must stay below every pane title in ${mode} density`)
+ .toBeLessThan(Math.min(...paneSizes));
+ expect(size, `a sub-pane title must outrank body copy in ${mode} density`).toBeGreaterThan(BODY_REM);
+ }
+
+ // No two rendered levels may share a size, stated directly so the failure
+ // names the collision instead of only reporting an ordering miss.
+ const levels: Array<[string, number]> = [
+ ['app title (h1)', appSize],
+ ...paneSizes.map((size): [string, number] => ['pane title (h2)', size]),
+ ...subPaneSizes.map((size): [string, number] => ['sub-pane title (h3)', size]),
+ ['body copy', BODY_REM]
+ ];
+ const bySize = new Map();
+ for (const [level, size] of levels) bySize.set(size, [...(bySize.get(size) ?? []), level]);
+ const collisions = [...bySize.values()].filter(names => names.length > 1);
+ expect(collisions, `two levels render at one size in ${mode} density: ${JSON.stringify(collisions)}`).toEqual([]);
+ });
+}
+
test('the Problems result chip wraps instead of squeezing the lede', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await installApi(page);
diff --git a/editor/ui/tests/safe-editing.spec.ts b/editor/ui/tests/safe-editing.spec.ts
index 25a51305..744068b7 100644
--- a/editor/ui/tests/safe-editing.spec.ts
+++ b/editor/ui/tests/safe-editing.spec.ts
@@ -345,6 +345,16 @@ async function installApi(page: Page, options: MockOptions = {}) {
await page.goto(options.hash ?? '/#token=test-session-token');
}
+// The connection readout is a chip (#993): the live region carries the short
+// label, and the honest sentence is one activation away. Assertions that are
+// about the sentence expand the chip first instead of weakening to the label.
+async function expandConnection(page: Page) {
+ // The chip is a sibling of the text-only live region, not a descendant (#993).
+ const toggle = page.locator('.connection-chip');
+ if ((await toggle.getAttribute('aria-expanded')) !== 'true') await toggle.click();
+ return page.locator('.connection-detail');
+}
+
test('semantic shell and file tree expose stable keyboard and accessible names', async ({ page }) => {
await installApi(page);
await expect(page.getByRole('heading', { name: 'Boris Editor', level: 1 })).toBeVisible();
@@ -359,7 +369,7 @@ test('semantic shell and file tree expose stable keyboard and accessible names',
const button = page.getByRole('button', { name, exact: true });
await expect(button).toHaveText(name);
}
- await expect(page.getByRole('status', { name: 'Connection status' })).toContainText('Connected to boris-editor/0.1.0.');
+ await expect(await expandConnection(page)).toContainText('Connected to boris-editor/0.1.0.');
const tree = await page.getByRole('navigation', { name: 'Project files' }).ariaSnapshot();
expect(tree).toContain('button "content/index.md"');
});
@@ -3202,12 +3212,14 @@ test('hiding the tab flushes the latest unsaved buffer (#418 M11)', async ({ pag
test('a dead editor host is named and tells you to restart (#418 M11)', async ({ page }) => {
await installApi(page);
- await expect(page.getByRole('status', { name: 'Connection status' })).toContainText('Connected to boris-editor/0.1.0.');
+ await expect(await expandConnection(page)).toContainText('Connected to boris-editor/0.1.0.');
await page.getByRole('button', { name: 'content/index.md', exact: true }).click();
await page.route('**/api/recovery/snapshot', route => route.abort());
await page.getByRole('textbox', { name: 'Source for content/index.md' }).fill('# After crash\n');
- await expect(page.getByRole('status', { name: 'Connection status' }))
- .toContainText('Local host unavailable. Restart boris-editor.');
+ // The chip names the failure in the live region; the rest of the sentence is
+ // behind the same disclosure.
+ await expect(page.getByRole('status', { name: 'Connection status' })).toContainText('Host unavailable');
+ await expect(await expandConnection(page)).toContainText('Local host unavailable. Restart boris-editor.');
await expect(page.getByRole('status', { name: 'Editing status' }))
.toContainText('The editor host stopped');
});
@@ -3263,7 +3275,7 @@ test('a clean buffer reloads when disk changes outside the editor (#418 M11)', a
test('opening the project names how long connect took (#418 M11)', async ({ page }) => {
await installApi(page);
- await expect(page.getByRole('status', { name: 'Connection status' }))
+ await expect(await expandConnection(page))
.toContainText('Opened project in');
});
@@ -3273,7 +3285,7 @@ test('opening and saving a file name the wait and elapsed time (#418 M11)', asyn
await installApi(page, {
files: [{ path: 'boris.json' }, { path: 'content/guides/start.md' }]
});
- await expect(page.getByRole('status', { name: 'Connection status' })).toContainText('Connected to boris-editor');
+ await expect(await expandConnection(page)).toContainText('Connected to boris-editor');
const holdOpenMs = 300;
await page.route('**/api/files/open', async route => {
const { path } = route.request().postDataJSON() as { path: string };
diff --git a/editor/ui/tests/scales.spec.ts b/editor/ui/tests/scales.spec.ts
new file mode 100644
index 00000000..442bfe30
--- /dev/null
+++ b/editor/ui/tests/scales.spec.ts
@@ -0,0 +1,177 @@
+import { expect, test, type Page } from '@playwright/test';
+
+// Every ordered scale the editor declares (#993 follow-up). The order itself
+// lives in exactly one place — the `--scale-*` lists in src/lib/tokens.css —
+// and this walks them out of the *applied* stylesheet rather than the source
+// file. That is the half no static parse can do: it sees a value overridden in
+// a later block, inside a media query, or by the dark theme, and it resolves
+// the `clamp` steps whose whole point is to change with the viewport.
+//
+// scripts/check-scales.mjs holds the static half — classification, names, the
+// prose in the README, and the ban on bare-number z-indexes. Together they keep
+// every size, gap, corner, and layer on one declared list.
+
+const FILES = [
+ { path: 'boris.json' },
+ { path: 'content/index.md' }
+];
+
+/** Only what a clean Author boot needs: the review panes are unmounted, so the
+ * graph/publication/preview/watch endpoints are never asked for. */
+async function installApi(page: Page) {
+ 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.goto('/#token=test-session-token');
+}
+
+const REQUIRED_LISTS = ['--scale-type', '--scale-space', '--scale-radius', '--scale-layer'];
+
+/** The scale lists as the cascade holds them, not as the file spells them. */
+async function scaleLists(page: Page): Promise> {
+ return page.evaluate(names => {
+ const style = getComputedStyle(document.documentElement);
+ const lists: Record = {};
+ for (const name of names) {
+ lists[name] = style.getPropertyValue(name).trim().split(/\s+/).filter(Boolean);
+ }
+ return lists;
+ }, [...REQUIRED_LISTS, '--scale-type-offchain']);
+}
+
+/** Resolve length tokens to the pixels a `font-size` takes. A custom property
+ * computes to its token stream (the `clamp(...)` itself), so a probe element
+ * is what turns it into the value the viewport actually produced. */
+async function resolvedLengths(page: Page, names: string[]): Promise> {
+ return page.evaluate(tokenNames => {
+ const probe = document.createElement('span');
+ probe.style.position = 'absolute';
+ probe.style.visibility = 'hidden';
+ document.body.appendChild(probe);
+ const resolved: Record = {};
+ for (const name of tokenNames) {
+ probe.style.fontSize = `var(${name})`;
+ resolved[name] = Number.parseFloat(getComputedStyle(probe).fontSize);
+ }
+ probe.remove();
+ return resolved;
+ }, names);
+}
+
+/** Resolve unitless tokens (the layer ladder) straight from the cascade. */
+async function resolvedIntegers(page: Page, names: string[]): Promise> {
+ return page.evaluate(tokenNames => {
+ const style = getComputedStyle(document.documentElement);
+ const resolved: Record = {};
+ for (const name of tokenNames) {
+ resolved[name] = Number.parseInt(style.getPropertyValue(name).trim(), 10);
+ }
+ return resolved;
+ }, names);
+}
+
+function ascending(values: Record, names: string[], label: string, where: string) {
+ for (let i = 1; i < names.length; i += 1) {
+ const lower = names[i - 1];
+ const higher = names[i];
+ expect(
+ values[higher],
+ `${label} scale: ${higher} must outrank ${lower} ${where}`
+ ).toBeGreaterThan(values[lower]);
+ }
+}
+
+// The widths matter here, not just as repetition: two steps are clamps in `vw`,
+// so a chain can ascend at 1440px and collapse at 480px.
+for (const width of [1920, 1440, 1024, 480]) {
+ test(`every declared scale ascends in the applied stylesheet at ${width}px`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await installApi(page);
+
+ const lists = await scaleLists(page);
+ // An empty list means the cascade lost the contract — fail rather than let
+ // the loops below pass vacuously on nothing.
+ for (const name of REQUIRED_LISTS) {
+ expect(lists[name].length, `${name} must be declared and non-empty in the cascade`).toBeGreaterThan(0);
+ }
+
+ const lengths = await resolvedLengths(page, [
+ ...lists['--scale-type'],
+ ...lists['--scale-space'],
+ ...lists['--scale-radius']
+ ]);
+ for (const [name, value] of Object.entries(lengths)) {
+ expect(value, `${name} must resolve to a pixel size`).toBeGreaterThan(0);
+ }
+
+ ascending(lengths, lists['--scale-type'], 'type', `at ${width}px`);
+ ascending(lengths, lists['--scale-space'], 'space', `at ${width}px`);
+ ascending(lengths, lists['--scale-radius'], 'radius', `at ${width}px`);
+
+ // The rhythm is a rhythm: every step a whole multiple of the declared base.
+ // Checking it here as well as statically is what catches a base or a step
+ // overridden further down the cascade.
+ const base = (await resolvedLengths(page, ['--scale-space-base']))['--scale-space-base'];
+ expect(base, '--scale-space-base must resolve to a pixel size').toBeGreaterThan(0);
+ for (const name of lists['--scale-space']) {
+ const remainder = lengths[name] % base;
+ expect(
+ Math.min(remainder, base - remainder),
+ `${name} (${lengths[name]}px) must be a whole multiple of the ${base}px base`
+ ).toBeLessThan(1e-6);
+ }
+ });
+}
+
+test('the layer ladder is the only authority on z-index', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await installApi(page);
+
+ const lists = await scaleLists(page);
+ const layers = await resolvedIntegers(page, lists['--scale-layer']);
+ for (const [name, value] of Object.entries(layers)) {
+ expect(Number.isInteger(value), `${name} must resolve to an integer layer`).toBe(true);
+ }
+ ascending(layers, lists['--scale-layer'], 'layer', 'in the cascade');
+
+ // The ladder has to reach the chrome, not just the token block: a bare number
+ // in a rule would leave these two at whatever it said.
+ await expect(page.locator('.section-nav')).toHaveCSS('z-index', String(layers['--layer-nav']));
+ await expect(page.locator('.skip-link')).toHaveCSS('z-index', String(layers['--layer-skip-link']));
+ // The skip link must stay above the nav it jumps past, or it is unreachable.
+ expect(layers['--layer-skip-link']).toBeGreaterThan(layers['--layer-nav']);
+});
+
+test('the off-chain size interleaves with the chain but never coincides', async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await installApi(page);
+
+ const lists = await scaleLists(page);
+ const [offChain] = lists['--scale-type-offchain'];
+ expect(offChain, 'tokens.css must declare --scale-type-offchain').toBeTruthy();
+
+ const lengths = await resolvedLengths(page, [offChain, ...lists['--scale-type']]);
+ for (const step of lists['--scale-type']) {
+ expect(lengths[offChain], `${offChain} must not coincide with ${step}`).not.toBe(lengths[step]);
+ }
+});
diff --git a/editor/ui/tests/section-nav.spec.ts b/editor/ui/tests/section-nav.spec.ts
index 422b0296..b9576f17 100644
--- a/editor/ui/tests/section-nav.spec.ts
+++ b/editor/ui/tests/section-nav.spec.ts
@@ -117,6 +117,16 @@ async function installApi(page: Page, options: InstallOptions = {}) {
await page.goto(`/#${launchParams.toString()}`);
}
+// The connection readout is a chip (#993): the live region carries the short
+// label, and the honest sentence is one activation away. These continuity
+// checks are about the sentence, so they expand the chip first.
+async function expandConnection(page: Page) {
+ // The chip is a sibling of the text-only live region, not a descendant (#993).
+ const toggle = page.locator('.connection-chip');
+ if ((await toggle.getAttribute('aria-expanded')) !== 'true') await toggle.click();
+ return page.locator('.connection-detail');
+}
+
function navLink(page: Page, name: string) {
return page.getByRole('navigation', { name: 'Editor sections' }).getByRole('link', { name, exact: true });
}
@@ -326,7 +336,7 @@ test('nav click preserves the token and drops the consumed open param (#943)', a
// token-wiping URL write), and the section jump is NOT reapplied — the
// component's spy handles positioning, not launch parsing.
await page.reload();
- await expect(page.getByRole('status', { name: 'Connection status' })).toContainText('Connected to boris-editor');
+ await expect(await expandConnection(page)).toContainText('Connected to boris-editor');
});
test('the open= launch param is consumed and not resurrected by nav clicks', async ({ page }) => {
@@ -345,7 +355,7 @@ test('the open= launch param is consumed and not resurrected by nav clicks', asy
await expect(page.getByRole('textbox', { name: 'Source for content/index.md' })).toBeVisible();
const reloaded = new URLSearchParams(new URL(page.url()).hash.slice(1));
expect(reloaded.get('open')).toBeNull();
- await expect(page.getByRole('status', { name: 'Connection status' })).toContainText('Connected to boris-editor');
+ await expect(await expandConnection(page)).toContainText('Connected to boris-editor');
});
test('the Graph nav link is available before a file is open (#970)', async ({ page }) => {