From 8ea63b5c38b39f00c9a32ce7169969633952a123 Mon Sep 17 00:00:00 2001 From: Tim Disney Date: Sat, 25 Jul 2026 23:01:12 -0700 Subject: [PATCH] =?UTF-8?q?Make=20the=20rail=20resizable=20=E2=80=94=20no?= =?UTF-8?q?=20focus=20ring=20for=20a=20press=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make the rail resizable The rail was 246px and that was that — a width chosen in the comp and true for every reader, every space and every window. It is a list of project and goal *titles*, so the width that works is a property of what is in it: a space whose goals are one-line labels wants less, a space whose goals read like sentences wants more, and neither can say so. So the rail's own edge becomes a grip. It is a focusable `separator` — ARIA's window splitter, which is exactly what this is — so it works as a drag, as the arrow keys (Home and End for the limits, Enter or a double-click for the 246px it was drawn at), and it announces the width it is at. The width is furniture rather than protocol: it belongs to the browser profile, like the theme, and is remembered beside it — including in `app.html`, before first paint, because a rail that starts at 246 and jumps to what you chose is a flicker the theme already taught us to avoid. `rail.svelte.ts` owns the rules and is tested without a browser: a floor where labels stop being readable, a ceiling where a rail stops being a rail, and a pane that keeps 560px whatever was dragged or remembered. What is stored is the reader's decision and never the window's — a session spent in a narrow window draws the rail narrower without rewriting the choice, and widening the window gives it straight back. Below 860px nothing changes: a drawer over the pane is not a column beside it, so there is no edge between two things to drag and the grip is absent. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) * Rail grip: fix the seven review findings Seven notes on the grip, from the review of the rail-resize work. Two of them were about the gesture and are the reason a control like this wants to be seen moving rather than reasoned about. **The drag snapped to the cursor.** It set the width from `clientX - railLeft`, so pressing near either end of the 9px target and moving one pixel jumped the edge by up to 5px, and from then on the edge sat under the pointer rather than where it was picked up. It is a delta now — the X and the drawn width, both taken at `pointerdown` — which also takes the rail's own left edge out of the arithmetic, so `?frame` no longer figures in it at all. **Clicking the grip never focused it.** `preventDefault()` on `pointerdown` suppresses the compatibility `mousedown`, and focus is one of that event's default actions — so the reader most likely to want the arrow keys, the one who has just dragged the edge, was the one who could not use them. The only other way in was Tab, past every smart list and every goal row. It takes focus explicitly now and keeps the `preventDefault`, which is still what stops the gesture from beginning by selecting the label beside it. **A held arrow key wrote `localStorage` on every autorepeat.** The module argues the case against that for the drag — none of the widths in between is a decision anybody made — and thirty repeats a second is the same gesture. It settles on `keyup`, or on `blur` if focus leaves mid-repeat. **Any button and any pointer started a resize.** A right-click whose context menu swallowed the matching `pointerup` left `resizing` set, and with it a `col-resize` cursor and dead selection across the whole shell. Left button, primary pointer, or nothing happens. **The separator did not say what it separates.** ARIA's window-splitter pattern pairs the role with `aria-controls`; the pane has an id and the grip points at it. **`PANE_MIN` was enforced against the window.** It is a rule about the space the two columns share, and under `?frame` the shell is `min(1220px, 100%)` inside a 26px-padded body — up to 52px narrower, which at a 900px framed window left the pane 508px against a promised 560. The grip observes `.app` now rather than `innerWidth`, so the promise is exact whatever the frame is doing; the pre-paint script, which has no layout to measure this early, computes the same geometry from the same numbers. **The duplicated bounds had a comment and no guard.** `test/rail-bounds.test.mjs` reads `RAIL_MIN`, `RAIL_MAX` and `PANE_MIN` back out of `rail.svelte.ts`, and the frame geometry out of `app.css`, and holds `app.html` to both — so moving the ceiling in one place fails a test instead of quietly bringing back the hydration jump the script exists to prevent, for the readers who had resized. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) * Rail grip: no focus ring for a press The grip takes focus when it is grabbed, so that a reader who has just dragged the edge can carry on with the arrow keys instead of tabbing the length of the rail. But a scripted `focus()` is exactly the case a UA is entitled to read as keyboard-driven, so `:focus-visible` matched and the drag ended with a ring drawn down the edge of a rail somebody had just moved by pointing at it. The ring is the component's decision now. `RailResizer` keeps a `ring` flag — set from the UA's own heuristic when focus arrives (right about Tab, right about a press, wrong only about the focus the component takes itself), cleared directly after `grab` focuses, set again when an arrow, Home, End or Enter actually drives the edge. `app.css` declines the UA ring for the grip and draws it, and the accent hairline, from the `data-ring` attribute instead. `test/rail-focus-ring.test.mjs` keeps the two halves honest: the ring rule has to be keyed on the attribute the component sets, and no rule may paint the grip from `:focus-visible` alone. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) --------- Co-authored-by: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) --- DESIGN.md | 14 +- packages/ui/src/app.css | 42 +++- packages/ui/src/app.html | 24 ++- packages/ui/src/lib/components/Rail.svelte | 5 + .../ui/src/lib/components/RailResizer.svelte | 183 ++++++++++++++++++ packages/ui/src/lib/rail.svelte.ts | 107 ++++++++++ packages/ui/src/lib/rail.test.ts | 150 ++++++++++++++ packages/ui/src/routes/+layout.svelte | 7 +- packages/ui/test/rail-bounds.test.mjs | 57 ++++++ packages/ui/test/rail-focus-ring.test.mjs | 39 ++++ 10 files changed, 618 insertions(+), 10 deletions(-) create mode 100644 packages/ui/src/lib/components/RailResizer.svelte create mode 100644 packages/ui/src/lib/rail.svelte.ts create mode 100644 packages/ui/src/lib/rail.test.ts create mode 100644 packages/ui/test/rail-bounds.test.mjs create mode 100644 packages/ui/test/rail-focus-ring.test.mjs diff --git a/DESIGN.md b/DESIGN.md index 6f8c3d4..c1cd4e1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -191,7 +191,7 @@ The system rejects the generic AI-SaaS look outright: no gradients on chrome, no metric tiles, no eyebrow above every section, no accent used decoratively. **Key Characteristics:** -- Two-column app shell: a 246px rail beside a paper pane, full-bleed in the tab (the comp's desk-and-window framing is opt-in via `?frame`). +- Two-column app shell: a 246px rail beside a paper pane, full-bleed in the tab (the comp's desk-and-window framing is opt-in via `?frame`). The rail's edge is a grip: 190–460px, remembered per browser profile, double-click back to 246. - One accent (ink indigo), on actions and selection only; verdict colours are rationed to verdicts. - System UI sans + system mono. Mono is semantic: it marks machine identifiers (`at://` URIs, CIDs, paths, commit hashes) and never English words. - Flat at rest. Elevation is a response to state, and a hairline plus a tone step does most of the separating. @@ -277,8 +277,11 @@ longer than the block under it is a ragged edge. ## Layout -A two-column shell: a **246px rail** and a pane that takes the rest (`grid-template-columns: 246px -minmax(0, 1fr)`). The app fills the tab; the comp's desk-and-window framing (1220×860, centred, on a +A two-column shell: a **246px rail** and a pane that takes the rest (`grid-template-columns: var(--rail-w) +minmax(0, 1fr)`, `--rail-w` defaulting to 246px). The rail is **resizable** between 190 and 460px by the +grip on its own edge, never past the point where the pane is left under 560px; the width is a property of +the browser profile rather than of the space, so it is remembered in `localStorage` and applied before +first paint, next to the theme. The app fills the tab; the comp's desk-and-window framing (1220×860, centred, on a radial-gradient desk) is opt-in with `?frame` and exists for screenshots. The sign-in picker is the exception — with no rail to sit beside, it stays a `min(620px)` card centred on the desk. @@ -292,7 +295,9 @@ margins, so a hover fill bleeds past the text without moving it. Section heads t below. **Responsive.** At **860px** the rail becomes an overlay drawer over a full-bleed pane (a hamburger in the -bar, a scrim behind, a 300ms slide), `?frame` gives up, and gutters tighten to 16px. At **560px** the +bar, a scrim behind, a 300ms slide) at its own fixed width with no grip — a drawer over the pane is not a +column beside it, so there is no edge between two things to drag — `?frame` gives up, and gutters tighten +to 16px. At **560px** the sheet sheds what it can afford to lose, in a fixed order: a unit row's summary, the breadcrumb's tail, the fixture chip, an actor's disc on a goal row, the second badge in a tail, and every badge's long form for its short one. A System row is the exception — its title is the record's identity, so the summary stays @@ -362,6 +367,7 @@ fill *is* the state (a pressed version button, an "on" auto-review switch). - **Items:** 13.5px, 5px/8px, 6px radius, a 17px coloured glyph, a label and a right-aligned count. Hover fills with `hover`; the current page fills with `sel` and goes 600. - **Glyph colour is the list's meaning:** accent for "For me" and goals, amber for "Awaiting input", sage for the Logbook, teal for System, `ink-2` for the space's own page — which sits last, after a rule, because it is where the space's shape is authored rather than where its work is read. - **Groups** collapse by disclosure triangle; projects start open, and only what the reader collapsed is remembered. +- **The grip** is the rail's own edge: a 9px target straddling the hairline, invisible until pointed at, when the hairline thickens into the accent. It is a focusable `separator` — arrows move it 8px (32 with Shift), Home and End take it to its limits, Enter and a double-click restore 246px — and the width is written back only when the gesture ends. A drag takes focus, so the arrows are live straight afterwards, but does not draw the focus ring: the ring is for a reader who arrived by keyboard and needs telling where they are, not for one who has the pointer on the edge already. ### The ⊕ (signature component) A 46px accent disc pinned to the pane's bottom-right with an accent-tinted shadow; it lifts 1.5px on hover diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index 7abfa74..5803894 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -47,6 +47,12 @@ --teal: oklch(0.560 0.100 200); --plum: oklch(0.520 0.130 330); + /* The rail's width, and the only token here that a reader edits: the grip on the rail's edge writes + it back (`rail.svelte.ts`), and `app.html` applies the remembered one before first paint. It is a + variable rather than a number in `.app` because the grid needs it where both columns are laid + out — the rail cannot widen itself without the pane knowing. */ + --rail-w: 246px; + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "Helvetica Neue", sans-serif; --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, "Cascadia Mono", monospace; @@ -100,7 +106,10 @@ body { } button, input, textarea, select { font: inherit; color: inherit; } a { color: var(--accent); text-underline-offset: 2px; } -:where(button, a, [tabindex]):focus-visible { +/* The rail's grip is the one control that says for itself whether focus is worth a ring: it takes + focus when it is dragged, which `:focus-visible` may read as keyboard-driven. See `.rail-resize`. */ +:where(button, a, [tabindex]):focus-visible, +.rail-resize[data-ring] { outline: 2px solid var(--accent-mark); outline-offset: 2px; border-radius: 5px; @@ -116,10 +125,13 @@ a { color: var(--accent); text-underline-offset: 2px; } width: 100%; height: 100dvh; display: grid; - grid-template-columns: 246px minmax(0, 1fr); + grid-template-columns: var(--rail-w) minmax(0, 1fr); background: var(--paper); overflow: hidden; } +/* While the edge is being dragged the whole window is the drag: the cursor does not change under the + pointer as it crosses into the pane, and nothing selects the text it passes over. */ +.app.resizing { cursor: col-resize; user-select: none; } :root[data-frame] body { padding: 26px; } :root[data-frame] .app { width: min(1220px, 100%); @@ -141,12 +153,35 @@ a { color: var(--accent); text-underline-offset: 2px; } /* ─── rail ───────────────────────────────────────────────────────────────── */ .rail { + position: relative; background: var(--rail); border-right: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; } +/* The grip. A 9px target straddling the hairline — wide enough to hit without aiming, and invisible + until it is pointed at, because the edge is already drawn and a second line there would be the app + advertising a control instead of having one. Under the pointer (or the focus ring) the hairline + thickens into the accent, which is the whole animation. */ +.rail-resize { + position: absolute; top: 0; right: -4px; bottom: 0; width: 9px; z-index: 20; + cursor: col-resize; touch-action: none; +} +.rail-resize::after { + content: ""; position: absolute; inset: 0 4px; + background: var(--accent-mark); opacity: 0; + transition: opacity 120ms ease; +} +/* Dragging the edge focuses the grip, so that the arrow keys are reachable afterwards without tabbing + the length of the rail — but that focus is the tail of a press, not a reader asking where they are, + and a ring left down the edge of a rail somebody has just dragged is noise. So the UA's ring is + declined here and `data-ring` (set by `RailResizer` when focus came from the keyboard) is what draws + it, in the rule above. The hairline follows the same signal. */ +.rail-resize:not([data-ring]):focus-visible { outline: 0; } +.rail-resize:hover::after, +.rail-resize[data-ring]::after, +.app.resizing .rail-resize::after { opacity: 1; } .rail-top { padding: 16px 12px 10px; display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: none; text-align: left; cursor: pointer; color: inherit; @@ -877,6 +912,9 @@ select.sl { resize: none; cursor: pointer; padding-right: 8px; } transform: translateX(-100%); transition: transform 300ms var(--ease-out); box-shadow: 0 0 60px oklch(0.2 0.03 262 / 0.25); } + /* A drawer over the pane is not a column beside it, so there is no edge between two things to drag: + the rail takes its own width here and the grip goes away rather than resizing an overlay. */ + .rail-resize { display: none; } .app.rail-open .rail { transform: none; } .app.rail-open::after { content: ""; position: absolute; inset: 0; z-index: 29; diff --git a/packages/ui/src/app.html b/packages/ui/src/app.html index 23845cd..5c54b32 100644 --- a/packages/ui/src/app.html +++ b/packages/ui/src/app.html @@ -15,9 +15,29 @@ // and framed for the pictures of it. Set here rather than in the app so an automated capture // cannot photograph the unframed layout on its way to being framed. It survives client-side // navigation because it is on the document, so one param frames a whole screenshot run. + let framed = false try { - if (new URLSearchParams(location.search).has('frame')) { - document.documentElement.dataset.frame = 'on' + framed = new URLSearchParams(location.search).has('frame') + if (framed) document.documentElement.dataset.frame = 'on' + } catch {} + // Same reason, same moment: a rail the reader widened to 320px must not be drawn at 246px and + // then jump. The bounds are repeated here because nothing is bundled yet — they are `RAIL_MIN`, + // `RAIL_MAX` and `PANE_MIN` in `lib/rail.svelte.ts`, which re-reads the same key and owns the + // rules; this has to reach the same answer or the jump comes back at hydration, which is what + // `test/rail-bounds.test.mjs` holds the two copies to. + // + // `PANE_MIN` is a rule about the shell rather than the window, and the shell under `?frame` is + // `min(1220px, 100%)` inside a 26px-padded body (`app.css`) — up to 52px narrower. There is no + // layout to measure this early, so it is computed; after hydration `RailResizer` observes the + // real `.app`. (Below the 860px breakpoint the frame is dropped and `--rail-w` is not read at + // all, so the adjustment there is moot rather than wrong.) + try { + const width = Number(localStorage.getItem('radial:rail-width')) + if (Number.isFinite(width) && width > 0) { + const shell = framed ? Math.min(1220, innerWidth - 52) : innerWidth + const ceiling = Math.max(190, Math.min(460, shell - 560)) + const px = Math.min(ceiling, Math.max(190, Math.round(width))) + document.documentElement.style.setProperty('--rail-w', px + 'px') } } catch {} diff --git a/packages/ui/src/lib/components/Rail.svelte b/packages/ui/src/lib/components/Rail.svelte index f7528ff..71d288a 100644 --- a/packages/ui/src/lib/components/Rail.svelte +++ b/packages/ui/src/lib/components/Rail.svelte @@ -20,6 +20,7 @@ import { ui } from '$lib/ui.svelte.js' import Glyph from './Glyph.svelte' import Pie from './Pie.svelte' + import RailResizer from './RailResizer.svelte' // Things 3's rail: coloured smart-list glyphs above the user's own structure. The smart lists are // folds over the whole space; the projects below them are the space's own shape. @@ -188,4 +189,8 @@ + + + diff --git a/packages/ui/src/lib/components/RailResizer.svelte b/packages/ui/src/lib/components/RailResizer.svelte new file mode 100644 index 0000000..baba196 --- /dev/null +++ b/packages/ui/src/lib/components/RailResizer.svelte @@ -0,0 +1,183 @@ + + + + + + diff --git a/packages/ui/src/lib/rail.svelte.ts b/packages/ui/src/lib/rail.svelte.ts new file mode 100644 index 0000000..674fdd8 --- /dev/null +++ b/packages/ui/src/lib/rail.svelte.ts @@ -0,0 +1,107 @@ +// How wide the rail is. +// +// The width is furniture rather than protocol: it belongs to the browser profile, like the theme, and +// is remembered there (`radial:rail-width`). It lives here rather than in `ui.svelte.ts` because it is +// the one piece of window state with rules — a floor, a ceiling, and a pane that must survive the +// ceiling — and rules are worth testing without a browser. +// +// 246px is the width the design was drawn at (DESIGN.md, Layout), so that is where a profile that has +// never touched the grip starts and where a double-click puts it back. The floor is where a nav item's +// label stops being readable before its count; the ceiling is where the rail stops being a rail. + +/** The narrowest rail that still reads as a list of labels rather than a column of glyphs. */ +export const RAIL_MIN = 190 +/** The widest the rail is ever allowed to get, however much window there is. */ +export const RAIL_MAX = 460 +/** The comp's width: the default, and what a double-click on the grip restores. */ +export const RAIL_DEFAULT = 246 +/** + * What the pane keeps, whatever was dragged or remembered. Roughly the width at which a goal row + * still has somewhere to put its tail — below it the sheet stops being a sheet, and a rail that wide + * has taken the thing the reader came for. + */ +export const PANE_MIN = 560 + +const KEY = 'radial:rail-width' + +/** + * A width the layout will accept: inside the fixed range, and — when the space the two columns share + * is known — never so wide that the pane is left with less than `PANE_MIN`. A shell too narrow to + * honour both loses to the floor, because a rail below `RAIL_MIN` is unreadable while a squeezed pane + * is merely tight. + * + * `viewport` is the width of `.app` rather than of the window: under `?frame` the shell is + * `min(1220px, 100%)` inside a padded body, so the window is up to 52px wider than the thing this is + * dividing. `RailResizer` measures the element; the pre-paint script in `app.html`, which has no + * layout to measure, computes the same number from the stylesheet's own — `test/rail-bounds.test.mjs` + * keeps the two copies honest. + */ +export function clampRailWidth(px: number, viewport?: number): number { + const ceiling = + viewport === undefined ? RAIL_MAX : Math.max(RAIL_MIN, Math.min(RAIL_MAX, viewport - PANE_MIN)) + if (!Number.isFinite(px)) return RAIL_DEFAULT + return Math.min(ceiling, Math.max(RAIL_MIN, Math.round(px))) +} + +/** The rail's geometry, and whether a drag is in progress (the app shell dims its own cursor for it). */ +export const rail = $state({ width: RAIL_DEFAULT, resizing: false }) + +/** A browser that refuses storage still resizes; it just forgets. Same bargain as the theme toggle. */ +function storage(): Storage | undefined { + try { + return globalThis.localStorage ?? undefined + } catch { + return undefined + } +} + +/** + * What this profile last chose. Not narrowed to the window it is being read into: what is stored is a + * decision, and a session spent in a small window must not quietly rewrite it. Fitting the width to + * the window that is actually there is the drawing's job, one `clampRailWidth` later. + */ +export function savedRailWidth(): number | undefined { + const raw = storage()?.getItem(KEY) + if (raw === null || raw === undefined) return undefined + const px = Number(raw) + return Number.isFinite(px) && px > 0 ? clampRailWidth(px) : undefined +} + +/** Adopt the remembered width. Called at module load so the first paint is already the chosen one. */ +export function restoreRailWidth(): void { + const saved = savedRailWidth() + if (saved !== undefined) rail.width = saved +} + +/** Move the edge. Clamped here rather than at each call site, so no caller can set an illegal width. */ +export function setRailWidth(px: number, viewport?: number): void { + rail.width = clampRailWidth(px, viewport) +} + +/** The keyboard's version of a drag: the arrow keys move the edge by a step. */ +export function nudgeRailWidth(delta: number, viewport?: number): void { + setRailWidth(rail.width + delta, viewport) +} + +/** Back to the width the app was designed at — the double-click, and Enter on the grip. */ +export function resetRailWidth(): void { + rail.width = RAIL_DEFAULT + saveRailWidth() +} + +/** + * Remember the width. Only at the end of a gesture: a drag is a few hundred `pointermove`s, and none + * of the intermediate widths is a decision anybody made. + */ +export function saveRailWidth(): void { + try { + storage()?.setItem(KEY, String(rail.width)) + } catch { + // Quota, private mode, a profile with storage off — the rail is still the width they dragged it to. + } +} + +// The pre-paint script in `app.html` applies the same value to `--rail-w` before this module exists, +// which is what keeps a remembered 320px rail from being drawn at 246px first. Reading it again here +// means the state the grip edits and the variable the grid reads start out agreeing. +restoreRailWidth() diff --git a/packages/ui/src/lib/rail.test.ts b/packages/ui/src/lib/rail.test.ts new file mode 100644 index 0000000..0bfd807 --- /dev/null +++ b/packages/ui/src/lib/rail.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + clampRailWidth, + nudgeRailWidth, + PANE_MIN, + rail, + RAIL_DEFAULT, + RAIL_MAX, + RAIL_MIN, + resetRailWidth, + restoreRailWidth, + saveRailWidth, + savedRailWidth, + setRailWidth, +} from './rail.svelte.js' + +// The width is the one piece of window state with rules, and the rules are the reason the module +// exists: nothing that edits the rail — a drag, an arrow key, a remembered value from a window that +// was a different size — is allowed to produce a width the layout cannot draw. + +/** `localStorage`, as much of it as this needs. The node test environment has none. */ +function fakeStorage(initial: Record = {}) { + const map = new Map(Object.entries(initial)) + return { + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => void map.set(key, value), + removeItem: (key: string) => void map.delete(key), + clear: () => map.clear(), + key: (index: number) => [...map.keys()][index] ?? null, + get length() { + return map.size + }, + } as Storage +} + +const store = (initial?: Record): Storage => { + const fake = fakeStorage(initial) + Object.defineProperty(globalThis, 'localStorage', { value: fake, configurable: true }) + return fake +} + +beforeEach(() => { + store() + rail.width = RAIL_DEFAULT +}) + +afterEach(() => { + Reflect.deleteProperty(globalThis, 'localStorage') +}) + +describe('clampRailWidth', () => { + it('keeps the edge inside the range the design allows', () => { + expect(clampRailWidth(300)).toBe(300) + expect(clampRailWidth(40)).toBe(RAIL_MIN) + expect(clampRailWidth(9000)).toBe(RAIL_MAX) + }) + + it('rounds, because a grid column is drawn in whole pixels', () => { + expect(clampRailWidth(273.6)).toBe(274) + }) + + it('never lets the rail take the pane below its minimum', () => { + // A 900px window: the ceiling is what is left after the pane's share, not the fixed 460. + expect(clampRailWidth(RAIL_MAX, 900)).toBe(900 - PANE_MIN) + // A window with room for both gets the fixed ceiling back. + expect(clampRailWidth(RAIL_MAX, 2000)).toBe(RAIL_MAX) + }) + + it('gives the rail its floor when the window is too narrow to satisfy both', () => { + // Below the responsive breakpoint the rail is a drawer and this does not arise, but a window + // resized to something absurd must still produce a readable rail rather than a 40px one. + expect(clampRailWidth(300, 500)).toBe(RAIL_MIN) + }) + + it('falls back to the width the app was drawn at rather than NaN', () => { + expect(clampRailWidth(Number.NaN)).toBe(RAIL_DEFAULT) + }) +}) + +describe('remembering', () => { + it('reads back what was saved', () => { + setRailWidth(318) + saveRailWidth() + rail.width = RAIL_DEFAULT + restoreRailWidth() + expect(rail.width).toBe(318) + }) + + it('ignores a value that is not a width', () => { + store({ 'radial:rail-width': 'wide' }) + expect(savedRailWidth()).toBeUndefined() + restoreRailWidth() + expect(rail.width).toBe(RAIL_DEFAULT) + }) + + it('keeps a remembered width a window is currently too narrow to draw', () => { + // The stored value is a decision, not a measurement: a session in a small window draws the rail + // narrower (`clampRailWidth` with a viewport) without rewriting what the reader chose. + store({ 'radial:rail-width': '440' }) + restoreRailWidth() + expect(rail.width).toBe(440) + expect(clampRailWidth(rail.width, 900)).toBe(900 - PANE_MIN) + }) + + it('refuses a stored width outside the range, however it got there', () => { + store({ 'radial:rail-width': '2400' }) + expect(savedRailWidth()).toBe(RAIL_MAX) + }) + + it('resizes for a profile whose browser refuses storage', () => { + Object.defineProperty(globalThis, 'localStorage', { + get() { + throw new Error('storage is disabled') + }, + configurable: true, + }) + expect(() => saveRailWidth()).not.toThrow() + expect(savedRailWidth()).toBeUndefined() + setRailWidth(280) + expect(rail.width).toBe(280) + }) + + it('does not write a width mid-gesture', () => { + // `saveRailWidth` is the end of a drag; `setRailWidth` is a frame of one. + setRailWidth(300) + expect(savedRailWidth()).toBeUndefined() + }) +}) + +describe('moving the edge', () => { + it('clamps whatever a caller asks for', () => { + setRailWidth(10_000) + expect(rail.width).toBe(RAIL_MAX) + }) + + it('steps by the arrow keys and stops at the ends', () => { + setRailWidth(RAIL_MIN + 4) + nudgeRailWidth(-8) + expect(rail.width).toBe(RAIL_MIN) + nudgeRailWidth(32) + expect(rail.width).toBe(RAIL_MIN + 32) + }) + + it('puts the rail back at the width the app was drawn at, and remembers that too', () => { + setRailWidth(RAIL_MAX) + resetRailWidth() + expect(rail.width).toBe(RAIL_DEFAULT) + expect(savedRailWidth()).toBe(RAIL_DEFAULT) + }) +}) diff --git a/packages/ui/src/routes/+layout.svelte b/packages/ui/src/routes/+layout.svelte index 62a91af..892cfed 100644 --- a/packages/ui/src/routes/+layout.svelte +++ b/packages/ui/src/routes/+layout.svelte @@ -8,6 +8,7 @@ import { keepFocus } from '$lib/focus.js' import { watchJoin } from '$lib/join.svelte.js' import { onKeydown } from '$lib/keys.js' + import { rail } from '$lib/rail.svelte.js' import Diagnostics from '$lib/components/Diagnostics.svelte' import PaneBar from '$lib/components/PaneBar.svelte' import PlusMenu from '$lib/components/PlusMenu.svelte' @@ -146,9 +147,11 @@ {#if space} -
+
-
+ +
diff --git a/packages/ui/test/rail-bounds.test.mjs b/packages/ui/test/rail-bounds.test.mjs new file mode 100644 index 0000000..7b49071 --- /dev/null +++ b/packages/ui/test/rail-bounds.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, it } from 'node:test' + +// `app.html` re-implements the rail's clamp. It has to: it runs before first paint, before anything +// is bundled, and the whole point of it is that a rail the reader widened to 320px is never drawn at +// 246 and then jumped. The duplication is only safe while the copies agree — move `RAIL_MAX` to 520 +// in `rail.svelte.ts` alone and the jump comes back at hydration, silently, and only for the readers +// who had resized. So the numbers are read back out of the three files that hold them and compared. +// +// This lives in the node tests rather than beside `rail.test.ts` because `src/` is a browser project +// with no `node:*` types; the values are parsed from the source rather than imported for the same +// reason — `rail.svelte.ts` is a runes module and needs the Svelte compiler to be run at all. + +const src = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src') +const read = (name) => readFileSync(resolve(src, name), 'utf8') + +/** A `export const NAME = 123` from the module that owns the rules. */ +function constant(source, name) { + const found = new RegExp(`export const ${name} = (\\d+)`).exec(source) + assert.ok(found, `rail.svelte.ts no longer exports ${name}`) + return Number(found[1]) +} + +describe('the pre-paint rail width', () => { + const html = read('app.html') + const rules = read('lib/rail.svelte.ts') + const css = read('app.css') + const script = html.slice(html.indexOf('radial:rail-width')) + + it('reads the key the module writes', () => { + assert.match(rules, /const KEY = 'radial:rail-width'/) + assert.match(html, /localStorage\.getItem\('radial:rail-width'\)/) + }) + + it('clamps with the bounds the module owns', () => { + const min = constant(rules, 'RAIL_MIN') + const max = constant(rules, 'RAIL_MAX') + const pane = constant(rules, 'PANE_MIN') + assert.ok(script.includes(`Math.max(${min}, Math.min(${max}, shell - ${pane}))`)) + assert.ok(script.includes(`Math.max(${min}, Math.round(width))`)) + }) + + it('measures the shell the way the stylesheet draws it', () => { + // `PANE_MIN` is a rule about `.app`, not about the window: under `?frame` the shell is narrower + // than the window by the desk's padding on both sides. After hydration `RailResizer` observes the + // real element; this early there is no layout, so the same geometry is computed from the numbers + // in the stylesheet — and must keep being the numbers in the stylesheet. + const padding = Number(/:root\[data-frame\] body \{ padding: (\d+)px/.exec(css)?.[1]) + const width = Number(/:root\[data-frame\] \.app \{\s*width: min\((\d+)px/.exec(css)?.[1]) + assert.ok(padding > 0, 'the framed desk no longer pads the body') + assert.ok(width > 0, 'the framed shell no longer has a fixed maximum') + assert.ok(script.includes(`framed ? Math.min(${width}, innerWidth - ${padding * 2}) : innerWidth`)) + }) +}) diff --git a/packages/ui/test/rail-focus-ring.test.mjs b/packages/ui/test/rail-focus-ring.test.mjs new file mode 100644 index 0000000..257d0d8 --- /dev/null +++ b/packages/ui/test/rail-focus-ring.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, it } from 'node:test' + +// Two files hold one decision between them. The grip takes focus when it is dragged — otherwise the +// arrow keys it advertises are only reachable by tabbing the length of the rail — and a scripted +// `focus()` is precisely the case a UA may read as keyboard-driven, so `:focus-visible` can leave a +// ring down the edge of a rail somebody has just dragged. `RailResizer` therefore says whether the +// focus came from the keyboard and `app.css` draws the ring only when it did. Remove either half and +// the ring is back on every drag, quietly and only under the pointer. So they are checked against +// each other. + +const src = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src') +const read = (name) => readFileSync(resolve(src, name), 'utf8') + +describe('the grip focus ring', () => { + const css = read('app.css') + const component = read('lib/components/RailResizer.svelte') + + it('is drawn from the attribute the component sets', () => { + assert.match(component, /data-ring=\{/) + assert.match(css, /\.rail-resize\[data-ring\][\s,][^{}]*\{[^}]*outline: 2px/) + }) + + it('is the only thing that draws it', () => { + assert.ok(css.includes('.rail-resize:not([data-ring]):focus-visible { outline: 0; }')) + // The opt-out above is the whole point; a rule that paints the grip from `:focus-visible` alone + // would put the drag's ring back beside it. (Selectors spanning lines — the shared ring rule the + // first test reads — do not match, which is right: that one is keyed on the attribute.) + for (const selector of css.match(/[^\n{}]*\.rail-resize[^\n{}]*:focus-visible[^\n{}]*/g) ?? []) { + assert.ok( + selector.includes(':not([data-ring])'), + `\`${selector.trim()}\` gives the grip a ring the pointer can trip`, + ) + } + }) +}) -- 2.51.2