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