From 3320884d2896c3bca9aa5b8f51496f12464a4cd8 Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:57:08 -0400 Subject: [PATCH 1/4] feat(editor): finish the Author writing surface (#993) The #989-#991 slices made Source the widest pane, but Author still read as an equal-card dashboard with a full IDE strip above it. This carries the writing-tool intent through the material rather than the proportions: - Project is demoted to a file drawer: it stops matching the page height (align-self defeats the grid stretch) and drops the card elevation for a recessed surface with tighter padding. - The section nav leads with the writing destinations and lets the Review destinations recede into a captioned, faint cluster; they stay live links that still switch modes and land on the real pane. - Source becomes a page: no card elevation, a focus-within edge instead, and a readable-measure cap (~80 monospace columns) centered in its grid column, so the pane edge is the page edge and heading, editor, gutter, and status line share one column. width:100% is load-bearing there - a grid item with any justify-self other than stretch sizes to its content, so without it the cap never bounds the page. - The top band quiets: the decorative eyebrow is gone, the product mark and the live connection status share one baseline row, the theme control states only its state, and Author takes a tighter header/nav rhythm than Review. - The long connection sentence collapses into a chip whose honest detail is one activation away. The chip is a real button, so the detail is reachable by keyboard and assistive tech rather than hover only, and connection.status now has a single writer so the short label cannot drift from the sentence it summarizes. Generated with Codebuff Co-Authored-By: Codebuff --- content/guides/editor.md | 13 ++ docs/changelog.d/993-editor-flat-chrome.md | 19 ++ editor/README.md | 26 ++- editor/ui/src/App.svelte | 10 +- editor/ui/src/components/Header.svelte | 38 ++- editor/ui/src/components/SectionNav.svelte | 20 +- editor/ui/src/lib/state/buffer.svelte.ts | 14 +- editor/ui/src/lib/state/connection.svelte.ts | 24 +- editor/ui/src/styles.css | 146 +++++++++++- editor/ui/tests/density-modes.spec.ts | 231 +++++++++++++++++++ editor/ui/tests/safe-editing.spec.ts | 23 +- editor/ui/tests/section-nav.spec.ts | 13 +- 12 files changed, 536 insertions(+), 41 deletions(-) create mode 100644 docs/changelog.d/993-editor-flat-chrome.md diff --git a/content/guides/editor.md b/content/guides/editor.md index bf09822a..0ef2a3bb 100644 --- a/content/guides/editor.md +++ b/content/guides/editor.md @@ -245,6 +245,19 @@ 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 a focus ring, 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/993-editor-flat-chrome.md b/docs/changelog.d/993-editor-flat-chrome.md new file mode 100644 index 00000000..fe886018 --- /dev/null +++ b/docs/changelog.d/993-editor-flat-chrome.md @@ -0,0 +1,19 @@ +### 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). diff --git a/editor/README.md b/editor/README.md index b1d9448c..eb097fa1 100644 --- a/editor/README.md +++ b/editor/README.md @@ -759,11 +759,33 @@ 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 + live region 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 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, 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/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..efba0c0e 100644 --- a/editor/ui/src/components/Header.svelte +++ b/editor/ui/src/components/Header.svelte @@ -1,11 +1,16 @@ -
+
-
-

Local authoring environment

+ +

Boris Editor

+

+ + {#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/styles.css b/editor/ui/src/styles.css index 306e530f..527d46f2 100644 --- a/editor/ui/src/styles.css +++ b/editor/ui/src/styles.css @@ -52,14 +52,21 @@ a { color: var(--color-link); text-underline-offset: 0.2em; } 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,13 @@ 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. */ +header[data-density='author'] { padding-block: var(--space-3); } +header[data-density='author'] h1 { font-size: var(--text-xl); } + .section-nav { display: flex; align-items: center; @@ -152,6 +194,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 +267,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 +439,43 @@ 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); +} .source-editor-shell.readonly { background: var(--color-sunken); } .source-gutter { position: relative; @@ -985,7 +1107,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..38bd1268 100644 --- a/editor/ui/tests/density-modes.spec.ts +++ b/editor/ui/tests/density-modes.spec.ts @@ -272,6 +272,237 @@ 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 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. + const themeButton = page.getByRole('button', { name: 'Dark', exact: true }); + await expect(themeButton).toHaveAttribute('aria-pressed', 'true'); + await expect(themeButton).toHaveText('Dark'); + + // Author tightens the band and drops the product mark a step; Review + // restores the full rhythm. + const author = { + header: (await page.locator('header').boundingBox())!.height, + title: await page.locator('header h1').evaluate(el => Number.parseFloat(getComputedStyle(el).fontSize)) + }; + 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 = region.getByRole('button'); + + // 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(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/safe-editing.spec.ts b/editor/ui/tests/safe-editing.spec.ts index 25a51305..51f1db34 100644 --- a/editor/ui/tests/safe-editing.spec.ts +++ b/editor/ui/tests/safe-editing.spec.ts @@ -345,6 +345,15 @@ 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) { + const toggle = page.getByRole('status', { name: 'Connection status' }).getByRole('button'); + 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 +368,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 +3211,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 +3274,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 +3284,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/section-nav.spec.ts b/editor/ui/tests/section-nav.spec.ts index 422b0296..29e04404 100644 --- a/editor/ui/tests/section-nav.spec.ts +++ b/editor/ui/tests/section-nav.spec.ts @@ -117,6 +117,15 @@ 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) { + const toggle = page.getByRole('status', { name: 'Connection status' }).getByRole('button'); + 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 +335,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 +354,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 }) => { From 9e19150eeb4e5afa01ab08b1049f66342b85095e Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:57:47 -0400 Subject: [PATCH 2/4] docs(changelog): name the fragment for PR 994 --- .../{993-editor-flat-chrome.md => 994-editor-flat-chrome.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changelog.d/{993-editor-flat-chrome.md => 994-editor-flat-chrome.md} (100%) diff --git a/docs/changelog.d/993-editor-flat-chrome.md b/docs/changelog.d/994-editor-flat-chrome.md similarity index 100% rename from docs/changelog.d/993-editor-flat-chrome.md rename to docs/changelog.d/994-editor-flat-chrome.md From 65247bcaa85a3b58fdc0518eab1d2b437ccb01e5 Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:25:26 -0400 Subject: [PATCH 3/4] fix(editor): settle the Author surface and give the scales one source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review corrections to the Author writing surface, plus the structural fix behind them: the scales wrote their order down twice, so the tests and the stylesheet could disagree without anything failing. The app title takes its own step in the type chain instead of the pane-title size it had collapsed onto, the theme control drops an aria-pressed that contradicted its own state label, the page shows one focus ring rather than two, the status chip's live region is text-only like every other one, and the hierarchy suite measures both density modes instead of pinning Review. Type, spacing, radius, and layer each declare their order once in tokens.css. A static lint holds the classification, the names, and the README to those lists; a cascade walk resolves the same lists out of the applied stylesheet, which is the only place a clamp step can be checked at all. The six bare z-index values are now named layers, so the ladder is the only authority on stacking order. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- content/guides/editor.md | 7 +- docs/changelog.d/994-editor-flat-chrome.md | 43 ++ editor/README.md | 79 +++- editor/scripts/test-editor-gate.sh | 2 +- editor/ui/package.json | 2 +- editor/ui/scripts/check-scales.mjs | 462 +++++++++++++++++++++ editor/ui/src/components/Header.svelte | 18 +- editor/ui/src/lib/tokens.css | 46 +- editor/ui/src/styles.css | 24 +- editor/ui/tests/density-modes.spec.ts | 29 +- editor/ui/tests/reading-hierarchy.spec.ts | 87 +++- editor/ui/tests/safe-editing.spec.ts | 3 +- editor/ui/tests/scales.spec.ts | 177 ++++++++ editor/ui/tests/section-nav.spec.ts | 3 +- 14 files changed, 941 insertions(+), 41 deletions(-) create mode 100644 editor/ui/scripts/check-scales.mjs create mode 100644 editor/ui/tests/scales.spec.ts diff --git a/content/guides/editor.md b/content/guides/editor.md index 0ef2a3bb..a328e4ce 100644 --- a/content/guides/editor.md +++ b/content/guides/editor.md @@ -250,9 +250,10 @@ 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 a focus ring, 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 +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 diff --git a/docs/changelog.d/994-editor-flat-chrome.md b/docs/changelog.d/994-editor-flat-chrome.md index fe886018..3cdb1462 100644 --- a/docs/changelog.d/994-editor-flat-chrome.md +++ b/docs/changelog.d/994-editor-flat-chrome.md @@ -17,3 +17,46 @@ [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 eb097fa1..3ffd49c1 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 five 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 @@ -770,17 +824,20 @@ preferences. The mode is disposable UI state, never project truth: 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 - live region 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 Author takes a smaller product mark and - tighter header/nav bands than Review. + 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 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, and the pane itself is capped at a readable measure (~80 monospace +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 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/components/Header.svelte b/editor/ui/src/components/Header.svelte index efba0c0e..3ae789ef 100644 --- a/editor/ui/src/components/Header.svelte +++ b/editor/ui/src/components/Header.svelte @@ -28,7 +28,17 @@ full sentence behind the disclosure. -->

Boris Editor

-

+

+ + {connection.summary} diff --git a/editor/ui/src/lib/tokens.css b/editor/ui/src/lib/tokens.css index 324fae0b..25189e03 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,11 @@ 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. */ + --type-scale-chain: --text-2xs --text-xs --text-sm --text-md --text-lg --text-xl --text-2xl-compact --text-2xl; + --type-scale-off-chain: --text-code; --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 +148,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 527d46f2..8a3f2b85 100644 --- a/editor/ui/src/styles.css +++ b/editor/ui/src/styles.css @@ -47,7 +47,7 @@ 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 { @@ -162,9 +162,12 @@ p { margin-top: 0; } /* #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. */ + 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-xl); } +header[data-density='author'] h1 { font-size: var(--text-2xl-compact); } .section-nav { display: flex; @@ -175,7 +178,7 @@ header[data-density='author'] h1 { font-size: var(--text-xl); } } /* #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. */ @@ -476,6 +479,11 @@ 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; @@ -804,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); @@ -909,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); @@ -923,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; diff --git a/editor/ui/tests/density-modes.spec.ts b/editor/ui/tests/density-modes.spec.ts index 38bd1268..c05d6d12 100644 --- a/editor/ui/tests/density-modes.spec.ts +++ b/editor/ui/tests/density-modes.spec.ts @@ -343,6 +343,10 @@ test('Author gives Source page material and keeps the mirror 1:1', async ({ page 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. @@ -459,17 +463,26 @@ test('Author gives the top band a calmer rhythm than Review', async ({ page }) = 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. + // 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).toHaveAttribute('aria-pressed', '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. + // 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)) + 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, @@ -484,10 +497,16 @@ test('the connection readout is a compact chip whose detail is on demand', async await openHome(page); const region = page.getByRole('status', { name: 'Connection status' }); - const chip = region.getByRole('button'); + 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); 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 51f1db34..744068b7 100644 --- a/editor/ui/tests/safe-editing.spec.ts +++ b/editor/ui/tests/safe-editing.spec.ts @@ -349,7 +349,8 @@ async function installApi(page: Page, options: MockOptions = {}) { // 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) { - const toggle = page.getByRole('status', { name: 'Connection status' }).getByRole('button'); + // 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'); } 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 29e04404..b9576f17 100644 --- a/editor/ui/tests/section-nav.spec.ts +++ b/editor/ui/tests/section-nav.spec.ts @@ -121,7 +121,8 @@ async function installApi(page: Page, options: InstallOptions = {}) { // 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) { - const toggle = page.getByRole('status', { name: 'Connection status' }).getByRole('button'); + // 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'); } From bb0b3b95c9049da84c49b64c37958c22825c9e82 Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:50:58 -0400 Subject: [PATCH 4/4] fix(editor): remove the duplicate type-scale aliases The declared-scales block is the only copy of each order, but the type scale also carried --type-scale-chain/--type-scale-off-chain with the same lists and no consumer. Drop them, and correct the README's count of the widths scales.spec.ts walks (four, matching the test and fragment). --- editor/README.md | 2 +- editor/ui/src/lib/tokens.css | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/editor/README.md b/editor/README.md index 3ffd49c1..1880f1e0 100644 --- a/editor/README.md +++ b/editor/README.md @@ -734,7 +734,7 @@ labels. [`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 five widths, asserting strict ascent with no collisions, and + 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. diff --git a/editor/ui/src/lib/tokens.css b/editor/ui/src/lib/tokens.css index 25189e03..e576d207 100644 --- a/editor/ui/src/lib/tokens.css +++ b/editor/ui/src/lib/tokens.css @@ -138,9 +138,7 @@ 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. Its order is declared in the `--- declared scales ---` block above, with - every other ordered scale. */ - --type-scale-chain: --text-2xs --text-xs --text-sm --text-md --text-lg --text-xl --text-2xl-compact --text-2xl; - --type-scale-off-chain: --text-code; + 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 */