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 @@ -
+
-
-

Local authoring environment

+ +

Boris Editor

+

+ + {connection.summary} + + {#if connectionDetailOpen} + {connection.status} + {/if} +

-

{connection}

{theme.current === 'dark' ? 'Dark' : 'Light'}
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} + + + + {/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 }) => {