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`,
+ )
+ }
+ })
+})