From e6a7b5d7df9b134b80396602681288ff3e63221b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:35:11 +0000 Subject: [PATCH 01/16] plan: 013-readout-overflow-policy (#43) --- .../contracts/topstrip-api.md | 134 +++++++++++ .../013-readout-overflow-policy/data-model.md | 195 ++++++++++++++++ specs/013-readout-overflow-policy/plan.md | 209 ++++++++++++++++++ .../013-readout-overflow-policy/quickstart.md | 121 ++++++++++ specs/013-readout-overflow-policy/research.md | 191 ++++++++++++++++ .../spec-meta.json | 4 +- 6 files changed, 852 insertions(+), 2 deletions(-) create mode 100644 specs/013-readout-overflow-policy/contracts/topstrip-api.md create mode 100644 specs/013-readout-overflow-policy/data-model.md create mode 100644 specs/013-readout-overflow-policy/plan.md create mode 100644 specs/013-readout-overflow-policy/quickstart.md create mode 100644 specs/013-readout-overflow-policy/research.md diff --git a/specs/013-readout-overflow-policy/contracts/topstrip-api.md b/specs/013-readout-overflow-policy/contracts/topstrip-api.md new file mode 100644 index 0000000..0926f6f --- /dev/null +++ b/specs/013-readout-overflow-policy/contracts/topstrip-api.md @@ -0,0 +1,134 @@ +# Top-Strip Contract: `src/lib/layout/topStrip.ts` (changed) + +Extends 012's contract +([`specs/012-top-strip-layout/contracts/topstrip-api.md`](../../012-top-strip-layout/contracts/topstrip-api.md)). +`Size`, `TopStripOccupantSizes`'s shape, `Rect`, and `InsetBox` are unchanged. +`TopStripLayout` gains a `capped` flag per occupant and a `maxLines` field on +the readout; `computeTopStripLayout` gains a fourth parameter; one new +function, `computeReadoutWidthCap`, is exported. + +```ts +import type { InsetBox, Rect } from '../input/touch/layout'; + +export interface Size { + readonly width: number; + readonly height: number; +} + +export interface TopStripOccupantSizes { + readonly readout?: Size; // MUST be measured with white-space: nowrap (FR-005) — a true + // single-line natural size, never one the viewport wrapped + readonly muteButton: Size; + readonly themePicker?: { + readonly expanded: Size; + readonly collapsed: Size; + }; +} + +export interface TopStripLayout { + readonly readout?: { + readonly rect: Rect; + readonly capped: boolean; // true iff rect is smaller than content needs in either dimension + readonly maxLines: number; // lines the growth allowance admits at this readout's line height + }; + readonly muteButton: { + readonly rect: Rect; + readonly capped: boolean; // true only in the degenerate near-zero-availableBox edge case + }; + readonly themePicker?: { + readonly rect: Rect; + readonly collapsed: boolean; + readonly capped: boolean; // true iff the chosen form's rect is smaller than its natural size + }; +} + +/** + * The width the readout will receive, computed with no knowledge of the + * readout's own height (FR-016a) — call this between the shell's two DOM + * passes (FR-016b) to learn the width to measure the readout's real height + * against. + */ +export function computeReadoutWidthCap( + availableBox: InsetBox, + reservedRects: readonly Rect[], + sizes: TopStripOccupantSizes +): number; + +/** + * readoutHeightAtCapWidth: the shell's second-pass measurement — the + * readout's real wrapped height at exactly computeReadoutWidthCap(...)'s + * result. Omit (or pass undefined) before that measurement exists yet; the + * function then falls back to the readout's natural single-line height, + * which cannot spill (Edge Cases: "Text metrics that are unavailable or + * report zero"). + */ +export function computeTopStripLayout( + availableBox: InsetBox, + reservedRects: readonly Rect[], + sizes: TopStripOccupantSizes, + readoutHeightAtCapWidth?: number +): TopStripLayout; +``` + +See [data-model.md](../data-model.md) for the full type shapes, the +eight-step placement algorithm, the growth-allowance formula, and the +`capped`/`maxLines` derivations. + +## Guarantees this module alone provides, checkable with zero DOM + +Everything 012's contract already guarantees (no overlap, full containment, +no dependency of the mute button's or theme picker's box on the readout's +height, no id/device branching, determinism) continues to hold — unchanged +by this feature (FR-014). This feature adds: + +- **FR-004 (content fits the width it was given)**: for every sampled + `(availableBox, reservedRects, sizes, readoutHeightAtCapWidth)`, the + returned `readout.rect.height` is at least `min(readoutHeightAtCapWidth, + growthAllowance)` — i.e. it is never pinned to the readout's *natural* + height regardless of the width it was actually given, which is precisely + today's bug (User Story 4's named regression). +- **FR-009 (growth is bounded)**: `readout.rect.height` never exceeds + `availableBox.height / 3`, for any input. +- **FR-010 / FR-011 (grow, then elide, uniformly)**: whenever + `readoutHeightAtCapWidth` exceeds the allowance, `readout.capped` is + `true` and `readout.maxLines` is set to a value the shell can hand to + `-webkit-line-clamp` — never a silently truncated box with no signal. + The same `capped` computation applies to `muteButton` and `themePicker`, + not a readout-only field (User Story 3). +- **FR-013 (severed dependency, restated as a test)**: `muteButton.rect` and + `themePicker.rect` are **byte-identical** across two calls that differ + only in `readoutHeightAtCapWidth` (including a deliberately wrong value + standing in for a stale or buggy measurement) — this is what proves + FR-016a's structural fix rather than an observed coincidence. +- **FR-016 / FR-016a (single-pass, structurally acyclic)**: + `computeReadoutWidthCap`'s return value is identical regardless of what + `readoutHeightAtCapWidth` a *subsequent* `computeTopStripLayout` call is + given — because `computeReadoutWidthCap` never takes that parameter at + all, this is true by the type signature, not merely by test. +- **FR-016b (fixed two-pass measurement, not a loop)**: nothing in this + module's API allows more than one round trip — `computeReadoutWidthCap` + takes only natural sizes, `computeTopStripLayout` takes one additional + plain number. There is no third function, no callback, and no way to + invoke either function from inside the other. +- **FR-005 (true natural size)**: enforced by convention on the caller + (`sizes.readout` MUST be `nowrap`-measured), documented on the type above; + the module itself has no way to verify how a `Size` was measured, which is + exactly why FR-006 keeps measurement out of this module entirely. + +## What is explicitly NOT part of this contract + +- **How `sizes.readout` and `readoutHeightAtCapWidth` are measured, and with + what CSS.** That is `App.svelte`'s two-probe wiring (data-model.md's Shell + Wiring table) — this module only ever receives already-measured plain + numbers, never a DOM node. +- **How `capped`/`maxLines` are rendered.** `overflow: hidden`, + `-webkit-line-clamp`, `text-overflow: ellipsis`, and the `aria-label` + fallback are `App.svelte`'s concern (FR-002's belt-and-braces half); this + module only computes the numbers that drive them. +- **The touch-control layout itself** (`reservedRects`'s source, + `computeTouchControlLayout`, `resolveTouchPoint`) — unchanged, covered by + feature 007's own contract. +- **Which action a tap on the collapsed theme control triggers, and 012's + expanded/collapsed decision itself** — unchanged, covered by 012's + contract; this feature only adds a `capped` flag to that same decision's + output. diff --git a/specs/013-readout-overflow-policy/data-model.md b/specs/013-readout-overflow-policy/data-model.md new file mode 100644 index 0000000..5edf0bf --- /dev/null +++ b/specs/013-readout-overflow-policy/data-model.md @@ -0,0 +1,195 @@ +# Phase 1 Data Model: The Readout Always Fits Its Box + +Extends feature 012's data model +([`specs/012-top-strip-layout/data-model.md`](../012-top-strip-layout/data-model.md)), +which itself extends 007's and 006's. Sim entities, every theme entity, +`SessionState`, `TickInput`, the Touch Control Layout entities +(`InsetBox`, `Rect`, `TouchControlLayout`), and 012's Top-Strip Occupant +table are unchanged and not repeated — this feature touches no file under +`src/sim/` (FR-019), no theme file (FR-008), and changes no occupant's +identity or priority order (FR-013, FR-014). What changes is what each +occupant's *size inputs* describe and what its *returned box* now carries. + +## Occupant Content Size (`spec.md` Key Entities) + +What an occupant's content needs, expressed **for a given width** rather +than as one fixed size — the entity 012 was missing (spec.md's narrative: +"today an occupant reports one natural size, and the rule caps its width +without asking what that cap costs in height"). Measured in the shell, +handed to the rule as plain numbers (FR-006). + +| Field | Meaning | Measured how | +|---|---|---| +| `natural` | The occupant's true unconstrained, single-line size (FR-005) | A hidden probe forced to a single line (`white-space: nowrap` for the readout; the mute button and both theme-picker forms are already single-line controls with nothing to wrap) — research.md's `nowrap` decision | +| `heightAtCapWidth` (readout only) | How tall the readout's content is at the width `computeReadoutWidthCap` says it will receive | A second hidden probe with that exact `width` set explicitly, measured in the shell's second DOM pass (FR-016b) | + +## Growth Allowance (`spec.md` Key Entities) + +The bounded extra height an occupant's box may take to fit its content +(FR-009), derived from `availableBox` alone — **never** from any occupant's +achieved or natural height (FR-016a). Zero when there is no room to grow. + +``` +growthAllowance = availableBox.height / 3 +``` + +This is a **backstop, not a budget** (FR-009): at 320 px the measured need is +about 80px and even a landscape viewport leaves room for roughly four lines +inside the bound, so normal operation never reaches it. It is also the value +substituted for the readout's height when the band's geometry decides which +`reservedRects` overlap it vertically (research.md), which is the specific +substitution that severs the width→height→width cycle 012 left standing. + +## Capped Occupant (`spec.md` Key Entities) + +An occupant whose placed box is smaller than its natural size in either +dimension. The set is data, not a hard-coded list (FR-011's "applied +uniformly to every occupant the rule places smaller than its natural size"). + +``` +capped = (returned.width < natural.width) OR (returned.height < contentHeightNeeded) +``` + +Where `contentHeightNeeded` is `heightAtCapWidth` for the readout and +`natural.height` for every other occupant (they never wrap — 012's mute +button and theme-picker forms are fixed single-line controls; only +`containRect`'s final clamp, step 6 below, can ever shrink them, in the +degenerate near-zero-`availableBox` edge case). `capped` travels on every +occupant's entry in `TopStripLayout`, not only the readout's (research.md), +so the shell knows uniformly which occupants must render in fit-to-box +(clamped, possibly elided) mode versus their full natural presentation. + +## Occupant Size Inputs (`src/lib/layout/topStrip.ts`, changed) + +| Type | Shape | Notes | +|---|---|---| +| `Size` | `{ readonly width: number; readonly height: number }` (px) | Unchanged from 012 | +| `TopStripOccupantSizes` | `{ readonly readout?: Size; readonly muteButton: Size; readonly themePicker?: { readonly expanded: Size; readonly collapsed: Size } }` | Unchanged shape from 012 — `readout` here is the **natural**, `nowrap`-measured size (research.md); the capped-width height is a separate parameter (below), not a field of this type, because it is not known until after `computeReadoutWidthCap` runs | +| `readoutHeightAtCapWidth` | `number \| undefined`, a new standalone parameter to `computeTopStripLayout` | The shell's second-pass measurement (Occupant Content Size table); `undefined` before that pass has ever produced a value, in which case the rule falls back to the readout's natural (single-line) height, which cannot spill (Edge Cases: "Text metrics that are unavailable or report zero") | + +## Top-Strip Placement (`computeTopStripLayout`, pure, changed signature) + +```ts +function computeTopStripLayout( + availableBox: InsetBox, + reservedRects: readonly Rect[], + sizes: TopStripOccupantSizes, + readoutHeightAtCapWidth?: number +): TopStripLayout +``` + +| Type | Shape | Notes | +|---|---|---| +| `TopStripLayout` | `{ readonly readout?: { readonly rect: Rect; readonly capped: boolean; readonly maxLines: number }; readonly muteButton: { readonly rect: Rect; readonly capped: boolean }; readonly themePicker?: { readonly rect: Rect; readonly collapsed: boolean; readonly capped: boolean } }` | Every occupant's entry now carries `capped` (Capped Occupant, above); the readout's entry additionally carries `maxLines` — the number of lines `growthAllowance` admits at the readout's line height, which is what the shell hands `-webkit-line-clamp` (research.md) — computed by the rule since it already knows `growthAllowance` and the readout's natural single-line height (one line's worth of the natural size) | + +**Inputs, restated from FR-006**: `availableBox` and `reservedRects` are +unchanged from 012 — the same measured `InsetBox` and the same +`touchLayout?.reservedRects ?? []`. `sizes` is unchanged in shape from 012 +but now sourced from `nowrap`-forced probes for the readout. The new +`readoutHeightAtCapWidth` is the shell's second-pass measurement. + +**Algorithm, extending 012's six steps (research.md's decisions restated as +steps):** + +1. **Compute the growth allowance** — `availableBox.height / 3` (FR-009) — + before anything else. This never depends on `sizes`. +2. **Form the strip band for the purpose of reserved-region subtraction** + using `max(muteButton.height, pickerSize.height, growthAllowance)` in + place of 012's `max(...all occupant heights)` — substituting + `growthAllowance` for the readout's height is the FR-016a fix: the band + used to decide which `reservedRects` overlap it vertically, and therefore + the usable width, no longer depends on the readout's content in any way. +3. **Decide the theme picker's form once**, from natural sizes and the + allowance-based usable width — unchanged in spirit from 012's step 2, + now operating on the corrected usable width. +4. **Place the theme picker and the mute button** at full natural size — + unchanged from 012's steps 3 and 5; neither has ever depended on the + readout's height (FR-013), and step 2's fix does not change that. +5. **Compute the readout's width cap** — `usableWidth` (from step 2) minus + the mute button's and (if present) the theme picker's natural widths and + margins. This is exactly `computeReadoutWidthCap`'s own return value + (below) — steps 1-5 are the function body `computeReadoutWidthCap` + exposes standalone, so the shell can call it before a height-at-cap-width + measurement exists to feed the next step. +6. **Resolve the readout's height.** `contentHeight = readoutHeightAtCapWidth + ?? sizes.readout.height` (natural single-line height as the fallback); + `readout.rect.height = min(contentHeight, growthAllowance)`; `maxLines = + Math.max(1, Math.floor(growthAllowance / sizes.readout.height))` (one + line's height is `sizes.readout.height`, since that is measured `nowrap`). +7. **Compute each occupant's `capped` flag** per the Capped Occupant formula + above, before the final clamp. +8. **Clamp every returned rect into `availableBox`** via the same + `containRect` 012 already uses (unchanged) — if this clamp shrinks a box + below what step 7 assumed, `capped` is re-evaluated against the + post-clamp size, so the degenerate near-zero-`availableBox` edge case + (Edge Cases) is still covered by the same flag rather than a special case. + +**Properties this guarantees, extending 012's (FR-007 through FR-010, +restated as what the construction above makes structural rather than +asserted):** + +- **No two returned boxes intersect, every returned box lies inside + `availableBox`** (012's FR-007/FR-008): unchanged — step 8's clamp and + steps 3-5's disjoint placement are untouched by this feature's changes. +- **The mute button's and theme picker's boxes never depend on the + readout's height** (FR-013): true by construction — no step above that + computes their rects reads `sizes.readout.height` or + `readoutHeightAtCapWidth`; step 2 reads `growthAllowance` instead, which + depends only on `availableBox`. +- **Single-pass, structurally acyclic idempotence** (FR-016, FR-016a): a + second call to `computeTopStripLayout` with the same four arguments + returns the same `TopStripLayout` — trivially, since the function is + stateless — and, more specifically, an arrangement computed with a + **deliberately wrong** `readoutHeightAtCapWidth` produces the identical + `muteButton`/`themePicker`/readout-`x`-and-`width` as one computed with the + correct value, because `readoutHeightAtCapWidth` only ever reaches step 6 + (the readout's own height), never steps 1-5 (FR-022 pins this directly). +- **Every occupant's box is at least as large as its content needs at the + width it was given, or is capped with the content elided** (FR-004, + FR-010): the readout's height is `min(contentHeight, growthAllowance)` — + equal to `contentHeight` (fits) unless `contentHeight` exceeds the + allowance, in which case the box is the allowance and `capped` is true + (elide, via `maxLines`). + +## `computeReadoutWidthCap` (new pure export) + +```ts +function computeReadoutWidthCap( + availableBox: InsetBox, + reservedRects: readonly Rect[], + sizes: TopStripOccupantSizes +): number +``` + +Runs steps 1-5 above and returns the width computed in step 5, with no +dependency on the readout's height at all — by construction, since step 5 +happens before the readout's height is ever considered. This is what the +shell calls between its two DOM passes (FR-016b): pass 1 produces `sizes`; +`computeReadoutWidthCap(availableBox, reservedRects, sizes)` gives the width +to set on the second hidden probe; pass 2 measures that probe's real height; +one call to `computeTopStripLayout(availableBox, reservedRects, sizes, +thatHeight)` places everything. `computeTopStripLayout` does not call +`computeReadoutWidthCap` internally as a separate step at runtime — it +performs the same steps 1-5 inline — but the two are guaranteed to agree +because they are the same arithmetic (the tasks stage should implement +`computeTopStripLayout`'s steps 1-5 by calling `computeReadoutWidthCap` +internally, so there is exactly one implementation of this arithmetic to +keep in sync, not two that could drift). + +## Shell Wiring (`src/App.svelte`, changed) + +| Piece | Change | +|---|---| +| Readout natural-size probe | `readoutProbeEl` (012) gains `white-space: nowrap` so its measured size is a true natural size (research.md) — this is the probe `topStripSizes.readout` is measured from | +| Capped-width probe (new) | A second hidden readout probe, styled identically to `.readout` but with an explicit inline `width` set to `computeReadoutWidthCap(...)`'s result and no `nowrap`, so `getBoundingClientRect().height` reports the real wrapped height at that width — the shell's second DOM pass (FR-016b) | +| `topStripSizes` | Unchanged shape from 012, still a `$derived.by` re-measured on `topStripProbeTick`/`hudText`/theme-label changes — now reads the `nowrap` probe | +| `readoutWidthCap` (new) | A `$derived.by(() => insetBox && topStripSizes ? computeReadoutWidthCap(insetBox, touchLayout?.reservedRects ?? [], topStripSizes) : undefined)`, feeding the capped-width probe's inline `width` style | +| `readoutHeightAtCapWidth` (new) | A `$state`/`$derived.by` set from the capped-width probe's `getBoundingClientRect().height`, re-read on the same triggers as `topStripSizes` | +| `topStripLayout` | Unchanged shape of `$derived.by`, now passing `readoutHeightAtCapWidth` as `computeTopStripLayout`'s fourth argument | +| `.readout` / `.theme-collapsed` CSS | Gain `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical;` and an inline `-webkit-line-clamp: {topStripLayout.readout.maxLines}` (readout only — the collapsed theme control is always exactly one line, so it needs `overflow: hidden` and `text-overflow: ellipsis` with `white-space: nowrap`, not a line-clamp) — the FR-002 structural clip | +| `aria-label` (new) | Set on `.readout` and `.theme-collapsed` to the full, un-elided string whenever `capped` is true, so assistive technology always has the complete text (FR-018) | + +No field of `SessionState`, no sim accessor, and no theme registry mutation +is touched by any of the above — every new piece is presentation-only +measurement, positioning, and an existing string's full form as an +`aria-label`. diff --git a/specs/013-readout-overflow-policy/plan.md b/specs/013-readout-overflow-policy/plan.md new file mode 100644 index 0000000..a889f36 --- /dev/null +++ b/specs/013-readout-overflow-policy/plan.md @@ -0,0 +1,209 @@ +# Implementation Plan: The Readout Always Fits Its Box + +**Branch**: `013-readout-overflow-policy` | **Date**: 2026-09-05 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/013-readout-overflow-policy/spec.md` + +## Summary + +Spec 012's `computeTopStripLayout` (`src/lib/layout/topStrip.ts`) places one +non-overlapping, contained box per top-strip occupant, but the readout's box +carries a height/width mismatch: its width is capped to whatever space the +mute button and theme picker leave, while its height comes from a probe +measured *before* that cap is applied — and that probe is itself not a true +natural size, because a `position: fixed` element with no explicit width +shrink-wraps to the viewport, so at 320-412 CSS px the "natural" height probe +is already wrapped by the screen it is measured on (FR-005). Below ~412 px the +capped width needs more lines than the probe reported, and `.readout` carries +no `overflow`/`white-space` rule to stop the extra lines rendering onto the +cave. + +This plan closes the gap in two structural moves, both already decided by the +spec's Clarifications rather than left to this stage: + +1. **Fix what "natural size" means.** The readout's natural-size probe is + forced to a single line (`white-space: nowrap`) so `sizes.readout.natural` + is a true unconstrained size (FR-005), never one the viewport already + wrapped. +2. **Sever the width→height→width cycle structurally (FR-016a).** The band's + usable width — and therefore the readout's width cap — is computed from a + **growth allowance** (at most one third of the available box's height, + FR-009) instead of from any occupant's achieved or natural height. This + makes the readout's width cap computable with no knowledge of its content + at all, which is what lets the shell's second measurement pass (height at + that now-fixed cap width, FR-016b) feed into placement without looping: + `computeTopStripLayout` gains a fourth input, the readout's measured + height at its cap width, and remains a pure, single-pass function of + `(availableBox, reservedRects, naturalSizes, readoutHeightAtCapWidth)`. + +A new pure export, `computeReadoutWidthCap`, gives the shell the cap width +*before* the second measurement pass exists to measure against — it is the +subset of `computeTopStripLayout`'s own steps that never touches the +readout's height (band geometry from the growth allowance, the picker's +collapse decision, the mute button's and picker's fixed-width placement), +exposed once so the shell does not reimplement it. The shell's two +already-planned-for DOM passes (FR-016b) become: pass 1 measures every +occupant's true natural size (readout forced `nowrap`); the shell calls +`computeReadoutWidthCap` with those sizes to learn the width the readout will +receive; pass 2 measures the readout's real height at exactly that width +(a second, differently-styled hidden probe with an explicit `width`); one +call to `computeTopStripLayout` with all of it then places everything, still +single-pass, still with no DOM/clock/theme-id read (FR-006, FR-008). + +Rendering closes the belt-and-braces half (FR-002): `.readout` gains +`overflow: hidden` plus a `-webkit-line-clamp`/`display: -webkit-box` +clamp to the number of lines the growth allowance actually admits, so a +sizing mistake or a mis-measured font degrades to fewer lines shown, never to +text outside the box — and the same clamp-and-flag treatment (via the +existing `containRect` clamp already shrinking any box that does not fit, +Key Entities: "Capped Occupant") extends to the theme picker's collapsed +control (User Story 3), which is why `TopStripLayout` gains a `capped: boolean` +flag per occupant rather than a readout-only field. Elided content keeps a +visible truncation indicator and its full text in an `aria-label` (FR-010, +FR-012, FR-018). No file under `src/sim/` changes; no theme id or viewport +width enters the placement module (FR-008). + +## Technical Context + +**Language/Version**: TypeScript (strict), Svelte 5 runes — unchanged from +features 001–012. + +**Primary Dependencies**: None added (Principle IV). Extends the existing +`src/lib/layout/topStrip.ts` module and its `Rect`/`InsetBox` type-only +import from `src/lib/input/touch/layout.ts` (unchanged from 012); no new +package for line-clamping or text measurement — `-webkit-line-clamp` is a CSS +property, and `overflow: hidden` needs none. + +**Storage**: N/A — no new persisted value (Assumptions: "Nothing leaves the +device"). + +**Testing**: `vitest`, node environment, no DOM — extends the existing +`tests/lib/layout/topStrip.test.ts` table-plus-invariants style (FR-020 +through FR-022): height-for-width supplied as plain numbers standing in for +what the browser's text metrics would report, never as real text laid out by +a real font. New assertions, not a new test file, since the property under +test (every returned box fits its content at the width it was given) is a +refinement of 012's containment/non-overlap properties over the same +function's inputs and outputs. + +**Target Platform**: Same as the whole project — any browser via `file://`; +this feature is specifically about phone-width portrait and landscape below +412 px, verified manually per Principle VII (CI has no browser). + +**Project Type**: Single self-contained web page (Principle I) — no +frontend/backend split, no new project. + +**Performance Goals**: No change to the tick loop (Principle VI). The shell's +measurement work grows from one DOM pass to a fixed two (FR-016b) on +resize/orientationchange/content-change only — never per frame, never per +tick (FR-016b, mirroring 012's FR-017). + +**Constraints**: `computeTopStripLayout` and the new `computeReadoutWidthCap` +MUST both stay pure — no DOM, no canvas, no clock, no randomness (FR-006) — +and MUST NOT branch on a theme id, device model, user agent, browser feature +name, or a specific viewport width (FR-008). Neither may import from +`src/sim/`, and no Svelte/DOM/audio import may enter either (FR-007). + +**Scale/Scope**: One changed module (`src/lib/layout/topStrip.ts` — new +export, changed signature, growth-allowance and elision-flag logic), one +changed shell file (`src/App.svelte` — a `nowrap` natural-size probe, a +second capped-width probe, the two-pass measurement wiring, and +`overflow`/`line-clamp`/`aria-label` on `.readout` and the collapsed theme +control), one extended test file +(`tests/lib/layout/topStrip.test.ts`), one docs file gains a "Standing +checks" entry at implementation time (FR-023; not edited by this plan). No +sim file, no cave data, no theme file touched (FR-019). + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.* + +| Principle | Check | Result | +|---|---|---| +| I. One Self-Contained Page | No new external request, file, or asset; `overflow`/`line-clamp` are inline CSS, still a single `index.html` build (FR-019, User Story 4 AC3). | PASS | +| II. Deterministic, Tick-Based Sim | This feature touches no file under `src/sim/`, no tick logic, no PRNG (FR-007, FR-019). | PASS — N/A, shell-only | +| III. Themes Are Data | No theme file touched; the placement rule still reads only sizes and counts, never a theme id (FR-008, FR-015). Elision applies uniformly (FR-011), so a theme with a long display name needs no code change (User Story 3 AC4). | PASS | +| IV. Simple, Dependency-Light Svelte | No new package; `-webkit-line-clamp`/`overflow: hidden` are plain CSS, not a library. | PASS | +| V. Input Is Keyboard-First, Progressive Everything Else | No binding changes; the collapsed theme control's elided label keeps calling the same cycle-theme action (Assumptions: "Bindings and behavior are untouched"). | PASS | +| VI. Performance Is A Feature | Recomputed only on resize/orientationchange/measurement change via a fixed two-pass measurement, never per tick or per frame (FR-016b). | PASS | +| VII. Verifiable Without A Browser Harness | The growth-allowance, single-pass, and containment properties are pinned by node-only `vitest` assertions over `computeTopStripLayout`/`computeReadoutWidthCap` with height-for-width supplied as data (FR-020 through FR-022); the rendered result (real font metrics, real legibility) is the maintainer's manual check, added to `docs/manual-verification.md`'s Standing checks at implementation time (FR-023). | PASS | + +No violations. Complexity Tracking table is not needed. + +*Post-Phase-1 re-check*: data-model.md and contracts/topstrip-api.md keep both +`computeTopStripLayout` and `computeReadoutWidthCap` pure functions of plain +numbers and rects, with the readout's height-for-width entering as a fourth +argument rather than being measured inside either function; the only +DOM-touching change (the `nowrap` natural probe, the capped-width probe, and +the resulting `overflow`/`line-clamp`/`aria-label` bindings) lives in +`App.svelte`, mirroring where 012 already put its hidden probes and inline +styles. Still PASS, no new violations. + +## Project Structure + +### Documentation (this feature) + +```text +specs/013-readout-overflow-policy/ +├── plan.md # This file (/speckit-plan command output) +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ └── topstrip-api.md # Phase 1 output — the module's changed surface +├── checklists/ +│ └── requirements.md # Pre-existing (spec stage) +└── tasks.md # Phase 2 output (/speckit-tasks — not created here) +``` + +### Source Code (repository root) + +Single-project structure, unchanged from every prior feature (Principle I — +one Svelte+Vite app, no frontend/backend split): + +```text +src/ +├── sim/ # UNCHANGED by this feature (FR-019) +├── lib/ +│ ├── input/ +│ │ └── touch/ +│ │ └── layout.ts # UNCHANGED — Rect/InsetBox reused by type-only import +│ ├── themes/ +│ │ └── registry.ts # UNCHANGED — listThemes() reused, read-only +│ └── layout/ +│ └── topStrip.ts # CHANGED — computeReadoutWidthCap (new export), +│ # computeTopStripLayout's signature gains a +│ # height-for-width input, growth-allowance and +│ # per-occupant `capped` logic +└── App.svelte # CHANGED — nowrap natural-size probe for the + # readout, a second capped-width probe, the + # two-pass measurement wiring, overflow/ + # line-clamp/aria-label on .readout and the + # collapsed theme control + +tests/ +└── lib/ + └── layout/ + └── topStrip.test.ts # CHANGED — adds FR-004/FR-009 through FR-011, + # FR-016/FR-016a/FR-016b, FR-020 through FR-022 + # assertions to 012's existing table; no + # existing 012 assertion is altered (FR-014) + +docs/ +└── manual-verification.md # CHANGED at implementation time — "Standing + # checks" gains the content-containment item + # (FR-023); 012's own Maintainer Review Notes + # are untouched (FR-023, SC-010) +``` + +**Structure Decision**: No structural change to the project — this feature +extends 012's existing `src/lib/layout/topStrip.ts` module and its test file +in place rather than adding a sibling module, because the property being +added (a box sized for the content it will hold) is a refinement of the same +function's contract, not a new geometry concern. One changed shell file, one +docs file changed at implementation time. No `src/sim/` file, no cave data, +no theme file touched. + +## Complexity Tracking + +*No entries — Constitution Check has no violations to justify.* diff --git a/specs/013-readout-overflow-policy/quickstart.md b/specs/013-readout-overflow-policy/quickstart.md new file mode 100644 index 0000000..7f791c5 --- /dev/null +++ b/specs/013-readout-overflow-policy/quickstart.md @@ -0,0 +1,121 @@ +# Quickstart: The Readout Always Fits Its Box + +How to validate this feature end-to-end once implemented. See +[data-model.md](./data-model.md) for the growth-allowance algorithm and +[contracts/topstrip-api.md](./contracts/topstrip-api.md) for the changed +module surface. This extends 012's quickstart +([`specs/012-top-strip-layout/quickstart.md`](../012-top-strip-layout/quickstart.md)) +— every check listed there still applies unchanged (FR-014, FR-019), and +this feature adds the checks below to the same `tests/lib/layout/ +topStrip.test.ts` file rather than a new one (FR-020 through FR-022). + +## Prerequisites + +- `npm install` at the repo root (unchanged from features 001–012) + +## Validate the placement rule in isolation (no browser) + +```bash +npm test +``` + +**Expected outcome**: builds first, then the full `vitest` suite passes with +no browser, canvas, or device present, covering — per the spec's Independent +Tests — every case below. + +**Content fits the width it was given (User Story 1, FR-004, SC-001):** + +- across the pinned viewport set — **320, 360, and 412 CSS px** on the short + edge, in both orientations, with and without the touch controls' reserved + regions — for a table of `(availableBox, reservedRects, sizes, + readoutHeightAtCapWidth)` standing in for one through four themes and an + unusually long theme name, every returned box is at least as large as + `min(readoutHeightAtCapWidth, growthAllowance)` requires, or is at the + `growthAllowance` bound with `capped: true`; +- 412 px in particular reproduces today's shipped result unchanged: a + two-line band with the text inside it (SC-003, "412 is the width that + passes today and must keep passing"); +- 360 px and 320 px — "the widths that spill" — produce a box tall enough + for the height-for-width value supplied, never the readout's unwrapped + natural height; +- the widest sampled readout content (standing in for the title screen's + high-score-plus-furthest-cave line, User Story 1 AC5) fits at 320 px; +- a desktop-width sample where nothing is capped is a single line, visually + matching 012's pre-existing result (User Story 1 AC6, FR-017 restated). + +**The deliberate regression fails (User Story 4, FR-021, SC-007):** + +- a mutation that pins `readout.rect.height` to `sizes.readout.height` + (the natural, unwrapped height) regardless of + `readoutHeightAtCapWidth` — exactly today's shipped bug — fails the + FR-004 assertions above at 360 px and 320 px, on a runner with no browser. + +**The severed cycle (User Story 2, FR-013, FR-016, FR-016a, SC-005, SC-006):** + +- `computeReadoutWidthCap`'s return value is identical regardless of any + `readoutHeightAtCapWidth` a subsequent `computeTopStripLayout` call + receives (true by signature — asserted directly as a regression guard); +- `muteButton.rect` and `themePicker.rect` are deep-equal across two + `computeTopStripLayout` calls that differ only in + `readoutHeightAtCapWidth`, including a **deliberately wrong** value (e.g. + the readout's tallest possible content) compared against the correct one — + FR-022's named assertion; +- calling `computeTopStripLayout` twice with the same four arguments + returns deep-equal results (trivial statelessness, still asserted + directly); +- the theme picker's expanded/collapsed decision is unchanged across + readout contents of differing `readoutHeightAtCapWidth` at the same + viewport (012 FR-012a restated: wrapping the readout must not flip the + collapse decision). + +**Grow, then elide — uniformly, not per-occupant (User Story 3, FR-009 +through FR-012, SC-009):** + +- `readout.rect.height` never exceeds `availableBox.height / 3` for any + sampled input (FR-009's bound); +- when `readoutHeightAtCapWidth` exceeds the allowance, `readout.capped` is + `true` and `readout.maxLines` is a positive integer no larger than what + the allowance and the readout's line height admit; +- with a collapsed theme-picker `Size` wider than the space `sizes` leaves + available for it, its returned rect is contained in `availableBox` and + `themePicker.capped` is `true` — the same flag the readout uses, not a + second mechanism (data-model.md's "Capped Occupant" is not a hard-coded + list); +- the collapsed control's width never depends on how many themes are + registered (012's existing property, re-asserted here alongside the new + `capped` field). + +**No sim or earlier-feature regression (FR-019):** + +- every existing test from features 001–012 passes unchanged, including all + of 012's non-overlap/containment/idempotence assertions in + `topStrip.test.ts`; +- `git diff` (or the PR's file list) touches no file under `src/sim/`, no + cave data, and no theme data file. + +## Validate the build is still a single, dependency-free file + +```bash +npm run build +``` + +**Expected outcome**: unchanged — `dist/index.html` is the only file play +depends on, now clipping the readout and the collapsed theme control to +their own boxes via inline CSS (`overflow: hidden`, `-webkit-line-clamp` or +`text-overflow: ellipsis`) with no new runtime dependency and no new network +request. + +## Validate on real devices (maintainer, per spec.md's Maintainer Review Notes) + +CI cannot exercise a real phone, a real font's actual metrics, or a real +notch — the full checklist is already written out in `spec.md`'s +**Maintainer Review Notes** section (the narrowest device to hand and +emulated 320/360 px in portrait and landscape, a five-digit score, the title +screen, a resize from desktop to phone width, and a diff audit). Run `npm +run build`, open `dist/index.html` from disk on each, and work through that +section directly rather than a duplicate checklist here. +`docs/manual-verification.md`'s **Standing checks** section also gains a +re-runnable entry for this same content-containment check at implementation +time (FR-023), alongside 012's existing top-strip-overlap entry — future +specs should re-run that entry rather than rediscovering this bug the way +#43 did. diff --git a/specs/013-readout-overflow-policy/research.md b/specs/013-readout-overflow-policy/research.md new file mode 100644 index 0000000..ada772e --- /dev/null +++ b/specs/013-readout-overflow-policy/research.md @@ -0,0 +1,191 @@ +# Phase 0 Research: The Readout Always Fits Its Box + +No `[NEEDS CLARIFICATION]` markers remained in `spec.md` at plan time — the +spec's own Clarifications session (2026-09-05, on issue #43) already resolved +the three open questions the draft carried (FR-011's overflow policy, FR-009's +growth ceiling, and FR-016/FR-016a/FR-016b's settling strategy). The decisions +below are plan-level design choices the spec leaves to implementation: it +specifies properties the rule and the shell must have, not the module layout, +function signatures, or CSS technique that realize them. + +## Decision: Extend `src/lib/layout/topStrip.ts` in place, not a new module + +**Rationale**: The gap this feature closes — a box sized for the content it +will hold — is a refinement of `computeTopStripLayout`'s existing contract +(012), not a new geometry concern. The function's inputs and the invariants +it must uphold (no overlap, full containment, no dependency on the readout's +height for the mute/picker boxes) are unchanged; what changes is what "sized +correctly" means for the readout specifically, and that a `capped` flag now +travels with every occupant's returned box. A new sibling module would +duplicate the band-forming and reserved-region-subtraction logic (FR-016a's +fix lives inside that logic) for no separation of concerns. + +**Alternatives considered**: +- A new `src/lib/layout/readoutFit.ts` computing only the readout's height + policy, composed with 012's function by the shell — rejected: the growth + allowance (FR-016a) has to be known *before* the band's usable width is + computed, which is inside `computeTopStripLayout`'s own first step; a + separate module would need the same band geometry duplicated or exported + piecemeal, which is what `computeReadoutWidthCap` (below) already does + cleanly as a second export of the same module. + +## Decision: A true natural size is a `nowrap`-forced probe, not the existing unstyled one + +**Rationale**: `App.svelte`'s current `readoutProbeEl` (`class="readout +top-strip-probe"`) has no `white-space` rule, so as a `position: fixed` +block with no explicit `width` it shrink-wraps using the *viewport* as its +containing block — at 320-412 px that shrink-to-fit calculation already +wraps the text before `computeTopStripLayout` ever sees a size (spec.md's +narrative: "a natural-size measurement that is silently wrapped by the +viewport it is measured in is not a natural size", FR-005). Adding +`white-space: nowrap` to the natural-size probe only (not the visible +`.readout`) makes its `getBoundingClientRect()` report the true single-line +width regardless of viewport, which is what FR-005 requires for both the +012 fit/collapse decision and this feature's width-cap arithmetic. + +**Alternatives considered**: +- Give the probe an explicit large `width` (e.g. `9999px`) instead of + `nowrap` — rejected: still relies on a magic constant that could itself be + exceeded by a long enough theme name or a five-digit score plus a bigger + font, where `nowrap` has no such ceiling. +- Compute the natural width from `scrollWidth` on the existing probe — + rejected: `scrollWidth` on an already-wrapped block reports the *wrapped* + layout's widest line, not the single-line natural width; it does not fix + the underlying problem, only relabels it. + +## Decision: Sever the cycle with a growth allowance derived from `availableBox` alone, exposed via a second export + +**Rationale**: FR-016a states the fix directly — the band's usable width +(and therefore the readout's width cap) must be computed from the **growth +allowance** (FR-009's bound: at most one third of `availableBox`'s height) +rather than from any occupant's achieved or natural height. Concretely, this +means the "which reserved regions overlap the band vertically" step (012's +step 1) uses `max(muteButton.height, pickerSize.height, growthAllowance)` in +place of today's `max(...all occupant heights)`, which today includes the +readout. Because `growthAllowance` depends only on `availableBox` (not on +`sizes` at all), the readout's width cap becomes computable with zero +knowledge of the readout's content — which is exactly what lets the shell +measure the readout's height *at* that cap width as a second, independent +pass instead of feeding a guessed height back into the same computation. +That subset of steps (band geometry, the picker's natural-size collapse +decision, the mute button's and picker's placement — everything that does +not need the readout's height) is worth exposing to the shell directly as +`computeReadoutWidthCap(availableBox, reservedRects, naturalSizes): number`, +so the shell's first-pass code calls one pure function to learn the width to +measure against, rather than reimplementing band geometry inline or calling +the full placement function with a placeholder height it would have to +discard. + +**Alternatives considered**: +- Bound the number of `computeTopStripLayout` calls (e.g. "at most 3 + iterations") and let width and height converge — rejected explicitly by + the spec's Clarifications: an iteration count is a convention no node test + can fail when a later change breaks it, where the growth-allowance + approach makes idempotence provable from the rule's inputs. +- Have `computeTopStripLayout` call back into the shell for a height + measurement mid-computation (an injected `heightForWidth` callback) — + rejected by FR-006: the rule must stay a pure function of plain data, with + every text measurement entering as a number the shell already collected, + not a function the rule invokes. + +## Decision: `computeTopStripLayout` gains a fourth parameter — the readout's measured height at its cap width — rather than measuring width and height in one call + +**Rationale**: FR-016 describes the arrangement as "a single-pass function of +(available box, reserved regions, natural sizes, height-for-width metrics)" +— height-for-width is named as data the function receives, not something it +derives. Concretely: `computeTopStripLayout(availableBox, reservedRects, +naturalSizes, readoutHeightAtCapWidth?)`. The shell's two DOM passes +(FR-016b) map onto this directly: pass 1 measures `naturalSizes` (with the +readout probe forced `nowrap`); the shell calls `computeReadoutWidthCap` with +those sizes to get the width to measure against; pass 2 measures the +readout's real wrapped height at exactly that width using a second hidden +probe styled with that explicit `width` (and no `nowrap`); one call to +`computeTopStripLayout` with all four inputs then places everything. Passing +`readoutHeightAtCapWidth` as `undefined` (before the second pass has ever +run, e.g. the very first paint) falls back to the single-line natural height, +which cannot spill (Edge Cases: "Text metrics that are unavailable or report +zero"). + +**Alternatives considered**: +- One function that takes a `measureHeightAtWidth(width): number` callback — + rejected by FR-006 for the same reason the cycle-severing decision above + rejects a callback: no DOM access of any kind may be reachable from inside + the pure module, including indirectly through an injected function. +- Return the width cap from a first call and require a second call to + `computeTopStripLayout` itself for the final placement — considered, but + `computeReadoutWidthCap` returning a plain `number` (not a partial + `TopStripLayout`) is simpler to test in isolation (FR-020) and cannot be + mistaken for a renderable intermediate layout. + +## Decision: Growth allowance and elision live in the same module as a per-occupant `capped: boolean`, not a readout-only field + +**Rationale**: User Story 3 and the Key Entities section ("Capped Occupant") +are explicit that the policy — grow the box to fit, then elide what still +does not fit — applies to *any* occupant the rule places smaller than its +natural size, not to the readout by name; today's `containRect` clamp +(present since 012) can already shrink the theme picker's collapsed control +below its natural size in the same degenerate cases that can shrink the +readout. Adding `capped: boolean` next to every returned box (readout, mute +button, theme picker) rather than a readout-specific field means a future +occupant — or an over-long theme name colliding with a narrow viewport today +— is covered by the same flag with no per-occupant branch, satisfying User +Story 3 AC4 ("the only changed files are that theme's data and its registry +entry"). `capped` is computed generically: true whenever a returned box's +size is smaller than the corresponding entry in `naturalSizes` in either +dimension, or (for the readout specifically) when `readoutHeightAtCapWidth` +exceeds the growth allowance. + +**Alternatives considered**: +- A boolean only on the readout entry (`TopStripLayout.readout.capped`) — + rejected: would require a second, differently-shaped flag if the picker's + collapsed control is ever ellipsis-truncated by a future theme, exactly + the per-occupant special-casing User Story 3 exists to prevent. + +## Decision: Elision is rendered with `overflow: hidden` + `-webkit-line-clamp`, not a hand-rolled character-count truncation + +**Rationale**: FR-002 requires FR-001 to be enforced structurally by the +occupant's own rendering — the box must be physically incapable of painting +outside itself, independent of whether the box was sized correctly. +`overflow: hidden` on `.readout` (and `.theme-collapsed`) is that structural +clip: it holds even if every sizing computation above were wrong. Layering +`display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: +` (widely supported in the WebKit/Blink/Gecko engines this project already +targets via Canvas + WebAudio) turns "how many lines fit in the growth +allowance" into a single CSS integer the shell computes once per placement +(`Math.floor(growthAllowance / lineHeight)`) rather than a character-count +guess that would need its own font-metric assumptions — the exact class of +assumption this whole feature exists to remove. A visible truncation +indicator (FR-010, User Story 3 AC3) is `-webkit-line-clamp`'s own ellipsis, +which browsers render automatically at the clamped line's end. The full, +un-elided text stays available to assistive technology via an `aria-label` +carrying the complete string (FR-018), so nothing is dropped for a screen +reader even where a sighted player sees `…`. + +**Alternatives considered**: +- Truncate the string itself with a computed character count before it ever + reaches the DOM — rejected: character width varies by glyph and font, so a + count-based truncation is exactly the kind of measurement the rule must + not need to guess (Assumptions: "Text measurement belongs to the shell"). +- `overflow: hidden` with no line-clamp, accepting a hard cut mid-line — + rejected by FR-011: "clipping with no indication hides text with nothing + to signal it is hidden," named and rejected in the spec itself. + +## Decision: `docs/manual-verification.md`'s new item extends the existing "Standing checks" section, at implementation time + +**Rationale**: FR-023 asks for the same treatment 012's own Standing checks +item already received (feature 011 created the section; 012 added to it) — +a re-runnable item next to 012's, not a new section, and explicitly not an +edit to 012's own Maintainer Review Notes (a merged spec is the historical +record of what that feature required). This plan does not edit the docs file +itself: per this project's Wing Commander pipeline, planning artifacts +describe *what* changes and *where*; the edit itself is a task the +implement stage performs, exactly as 012's plan.md recorded the same file's +future change in its Project Structure table without touching it during +planning. + +**Alternatives considered**: None — the spec names this decision directly +(FR-023, SC-010); this entry records where the section already lives (the +maintainer's 2026-09-03 Pixel 10 Pro entry under "Top-strip controls never +overlap (012, `#35`)" already flags "The readout's sub-380px wrap (#43) is out +of scope of this device" as the exact gap this feature closes) so the tasks +stage does not have to search for it. diff --git a/specs/013-readout-overflow-policy/spec-meta.json b/specs/013-readout-overflow-policy/spec-meta.json index 51b837e..7c1cde7 100644 --- a/specs/013-readout-overflow-policy/spec-meta.json +++ b/specs/013-readout-overflow-policy/spec-meta.json @@ -2,7 +2,7 @@ "issue": 43, "spec_dir": "specs/013-readout-overflow-policy", "feature_num": "013", - "stage": "spec", + "stage": "plan", "iteration": 0, - "spec_branch": null + "spec_branch": "spec/013-readout-overflow-policy" } From 344250edb45a513995fa8edc1681d369a8d8274f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:40:58 +0000 Subject: [PATCH 02/16] tasks: 013-readout-overflow-policy (#43) --- .../spec-meta.json | 2 +- specs/013-readout-overflow-policy/tasks.md | 494 ++++++++++++++++++ 2 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 specs/013-readout-overflow-policy/tasks.md diff --git a/specs/013-readout-overflow-policy/spec-meta.json b/specs/013-readout-overflow-policy/spec-meta.json index 7c1cde7..c59a80e 100644 --- a/specs/013-readout-overflow-policy/spec-meta.json +++ b/specs/013-readout-overflow-policy/spec-meta.json @@ -2,7 +2,7 @@ "issue": 43, "spec_dir": "specs/013-readout-overflow-policy", "feature_num": "013", - "stage": "plan", + "stage": "tasks", "iteration": 0, "spec_branch": "spec/013-readout-overflow-policy" } diff --git a/specs/013-readout-overflow-policy/tasks.md b/specs/013-readout-overflow-policy/tasks.md new file mode 100644 index 0000000..5dd26d5 --- /dev/null +++ b/specs/013-readout-overflow-policy/tasks.md @@ -0,0 +1,494 @@ +--- + +description: "Task list for The Readout Always Fits Its Box" +--- + +# Tasks: The Readout Always Fits Its Box + +**Input**: Design documents from `/specs/013-readout-overflow-policy/` +(plan.md, spec.md, research.md, data-model.md, contracts/topstrip-api.md, +quickstart.md) + +**Prerequisites**: plan.md, spec.md (required — read); research.md, +data-model.md, contracts/topstrip-api.md (all read for the algorithm and +type shapes below) + +**Tests**: Included. The plan's Testing section, the constitution's "every +spec that adds or changes a physics/geometry rule ships a test that pins +it," and FR-020 through FR-022 all require the growth/cap/elision policy to +be pinned by a node-only `vitest` suite — this is not optional for this +feature. + +**Organization**: Tasks are grouped by user story (spec.md's P1–P4), mirroring +how feature 012 built `computeTopStripLayout` and extended +`tests/lib/layout/topStrip.test.ts`. This feature extends 012's existing +module and test file in place rather than adding new ones (plan.md's +Structure Decision): User Story 1 delivers the whole growth-allowance / +cap-severing / elision implementation; User Stories 2–4 extend the same test +file with additional property coverage over the same functions, exactly as +their own "Independent Test" sections describe. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4) +- Include exact file paths in descriptions + +## Path Conventions + +Single project (Principle I — one Svelte+Vite app, no frontend/backend +split), unchanged from every prior feature: + +- Changed module: `src/lib/layout/topStrip.ts` +- Changed shell file: `src/App.svelte` +- Changed test file: `tests/lib/layout/topStrip.test.ts` +- Changed docs file: `docs/manual-verification.md` + +No file under `src/sim/`, no cave data, and no theme data file is touched by +any task below (FR-019). + +--- + +## Phase 1: Setup + +**Purpose**: Adopt the new type shapes (`contracts/topstrip-api.md`) and +extract the band-geometry/collapse-decision/placement steps that never touch +the readout's height into their own function, as a pure refactor with no +behavior change — the surface every later task builds on. + +- [ ] T001 In `src/lib/layout/topStrip.ts`: (1) extract today's steps 1 + (band + reserved-region subtraction), 2 (collapse decision), 3 (picker + placement), 4 (readout width cap arithmetic) into a new exported function + `computeReadoutWidthCap(availableBox, reservedRects, sizes): number` + returning exactly the width the readout is capped to today — a pure + extract-method refactor, no arithmetic changes; (2) make + `computeTopStripLayout` call `computeReadoutWidthCap` internally for that + same subset of work, per data-model.md's explicit instruction ("the tasks + stage should implement `computeTopStripLayout`'s steps 1-5 by calling + `computeReadoutWidthCap` internally, so there is exactly one implementation + of this arithmetic to keep in sync"); (3) change `TopStripLayout`'s shape + to the contract: `readout` becomes `{ rect: Rect; capped: boolean; + maxLines: number } | undefined`, `muteButton` becomes `{ rect: Rect; + capped: boolean }`, `themePicker` gains a `capped: boolean` alongside its + existing `rect`/`collapsed` — for this task, hardcode every `capped` to + `false` and `maxLines` to `1`, preserving today's exact rect values; (4) + add `readoutHeightAtCapWidth?: number` as `computeTopStripLayout`'s fourth + parameter, unused for now. Thread the new `.rect` accessor through every + existing reader in `src/App.svelte` (the `topStripLayout.readout.x`-style + bindings around lines 466-524) and `tests/lib/layout/topStrip.test.ts` + (`collectRects`, and every `layout.readout!.x` / `layout.muteButton.x` + site) so the project compiles and every pre-existing test still passes + with identical rect values — this task changes shape only, not placement. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared fixtures every user story's tests build on — the pinned +360 px viewport (FR-020's third pinned width, not yet in the test file) and +a height-for-width stand-in table (data-model.md's "Occupant Content Size"). + +**⚠️ CRITICAL**: No user story's fit/cap tests can be written until this +phase is complete. + +- [ ] T002 In `tests/lib/layout/topStrip.test.ts`, add the 360 CSS px + portrait/landscape `InsetBox` pair (`PORTRAIT_360`/`LANDSCAPE_360`) + alongside the existing 320 (`NARROWEST_PORTRAIT`/`NARROWEST_LANDSCAPE`) and + 412 (`REPORTING_DEVICE_PORTRAIT`; add a matching `REPORTING_DEVICE_LANDSCAPE`) + pairs, so all three of FR-020's pinned widths exist in both orientations — + and matching `reservedRects` samples for 360 derived the same way the + existing 320/412 samples are (via `computeTouchControlLayout`, not + hand-rolled). +- [ ] T003 In `tests/lib/layout/topStrip.test.ts`, add a height-for-width + stand-in helper (data-model.md's Occupant Content Size entity: "a plain + stand-in for what the browser's text metrics report, supplied as data — no + DOM") returning a plausible multi-line height for `READOUT_TYPICAL` and + `READOUT_TITLE_WIDE` at a given capped width — narrower widths return + taller values, wide-enough widths return the natural single-line height — + used as the `readoutHeightAtCapWidth` argument by every story's tests + below. Do not hard-code the maintainer's measured 44px/62px/80px or + 18px/36px spill figures from spec.md (SC-002 forbids treating those as + test-expected values); invent independent stand-in numbers with the same + shape (taller at narrower widths). + +**Checkpoint**: Foundation ready — every user story below can now write and +run fit/cap tests against `tests/lib/layout/topStrip.test.ts`. + +--- + +## Phase 3: User Story 1 - The whole readout, inside its own box, on a small phone (Priority: P1) 🎯 MVP + +**Goal**: At 320, 360, and 412 CSS px, in both orientations, the readout's +placed box is always at least as tall as its content needs at the width it +was actually given — no more spilling below ~412 px — while 412 px's +today-shipped two-line result and desktop's single-line result are both +unchanged. + +**Independent Test**: Call `computeTopStripLayout` with a +height-for-width value standing in for the browser's text metrics, over the +pinned viewport set (320/360/412, both orientations), and assert every +returned readout box is at least as tall as that value requires at the +box's actual width, while every 012 property still holds (spec.md +Acceptance Scenarios 1-7). + +### Tests for User Story 1 ⚠️ + +> Write these first; they fail against T001's hardcoded-`false`/`1` +> `capped`/`maxLines` and today's height-ignoring-width arithmetic until T008 +> lands. + +- [ ] T004 [P] [US1] In `tests/lib/layout/topStrip.test.ts`, add the FR-004/ + SC-001 fit assertion: parameterized over the pinned viewport set (320, + 360, 412, both orientations, with and without each viewport's + `reservedRects`) and occupant-size samples standing in for one through + four themes and the long theme name, assert `readout.rect.height` is at + least `min(readoutHeightAtCapWidth, growthAllowance)` for the width T003's + helper supplies at that box, and that `readout.rect.width` is unaffected + by which height value was supplied (FR-004, FR-016a). +- [ ] T005 [P] [US1] In `tests/lib/layout/topStrip.test.ts`, add the SC-003 + regression guard: at `REPORTING_DEVICE_PORTRAIT` (412 px) with a + height-for-width value that fits in two lines, assert the resulting + `readout.rect` is unchanged from what T001's pre-growth-allowance + arithmetic already produces there — 412 px is "the width that passes + today and must keep passing" (spec.md). +- [ ] T006 [US1] In `tests/lib/layout/topStrip.test.ts`, add the widest-line + fit assertion (AC5): using `READOUT_TITLE_WIDE` with T003's helper's + tallest 320 px value, assert the returned box is at least that tall and + `rectFullyInside(readout.rect, NARROWEST_PORTRAIT)` holds. +- [ ] T007 [US1] In `tests/lib/layout/topStrip.test.ts`, add the desktop + no-capping regression guard (AC6, FR-017): at `WIDE_DESKTOP` with a + height-for-width value equal to the natural single-line height (nothing + capped), assert `readout.capped` is `false`, `readout.rect.height` equals + the natural single-line height, and the arrangement matches the existing + desktop ordering assertions already in the file. + +### Implementation for User Story 1 + +- [ ] T008 [US1] In `src/lib/layout/topStrip.ts`, implement the + growth-allowance / cap-severing algorithm per data-model.md's eight-step + "Top-Strip Placement" section: (1) compute `growthAllowance = + availableBox.height / 3` before anything else — depends only on + `availableBox`; (2) inside `computeReadoutWidthCap` (T001's extraction), + form the band for reserved-region subtraction using + `max(muteButton.height, pickerSize.height, growthAllowance)` in place of + today's `max(...all occupant heights)` — this is the FR-016a fix, and it + makes `computeReadoutWidthCap`'s result independent of the readout's + height entirely; (3) in `computeTopStripLayout`, resolve `contentHeight = + readoutHeightAtCapWidth ?? sizes.readout.height` (the natural single-line + height as the fallback for "measurement not available yet", + data-model.md's Edge Cases) and set `readout.rect.height = + min(contentHeight, growthAllowance)`; (4) set `readout.maxLines = + Math.max(1, Math.floor(growthAllowance / sizes.readout.height))`; (5) + compute each occupant's `capped` flag per data-model.md's Capped Occupant + formula (`returned.width < natural.width` or `returned.height < + contentHeightNeeded`), evaluated after `containRect`'s clamp so the + degenerate near-zero-`availableBox` edge case is covered by the same flag + (data-model.md's step 8 note). This must make T004-T007 pass without + changing any pre-existing 012 assertion (FR-014). +- [ ] T009 [US1] In `src/App.svelte`, force `white-space: nowrap` on the + readout's natural-size probe only (`readoutProbeEl` / its + `.top-strip-probe` styling) so `topStripSizes.readout` reports a true + single-line natural size regardless of viewport width, never one the + viewport already wrapped (FR-005, research.md's `nowrap`-probe decision). + Do not add `nowrap` to the visible `.readout` rule or any other probe. +- [ ] T010 [US1] In `src/App.svelte`, add a second hidden "capped-width" + readout probe styled like `.readout` but with an explicit inline `width` + bound to a new `readoutWidthCap = $derived.by(() => + computeReadoutWidthCap(insetBox, touchLayout?.reservedRects ?? [], + topStripSizes))` and no `nowrap`, so its `getBoundingClientRect().height` + reports the readout's real wrapped height at exactly that width; derive + `readoutHeightAtCapWidth` from it, re-read on the same + `topStripProbeTick`/`hudText`/theme-label triggers `topStripSizes` already + uses (data-model.md's Shell Wiring table); pass it as + `computeTopStripLayout`'s fourth argument in the existing `topStripLayout` + `$derived.by`. This is the shell's fixed two-DOM-pass measurement + (FR-016b) — no third pass, no per-frame recomputation. +- [ ] T011 [US1] In `src/App.svelte`, add `overflow: hidden; display: + -webkit-box; -webkit-box-orient: vertical;` and a `-webkit-line-clamp: + {topStripLayout.readout.maxLines}` inline style to the visible `.readout` + element (FR-002's structural clip: the box is physically incapable of + painting outside itself regardless of whether T008's sizing was correct), + and set an `aria-label` on it to the full `hudText` whenever + `topStripLayout.readout?.capped` is `true` (FR-018 — assistive technology + always gets the complete text even when a sighted player sees an + ellipsis). + +**Checkpoint**: User Story 1 is fully functional — the readout's box is +sized for the content it will hold at every pinned width, structurally +clipped as a backstop, and 412 px/desktop are unchanged. The reported defect +(white text spilling onto the cave below ~412 px) is fixed. + +--- + +## Phase 4: User Story 2 - A taller readout does not disturb the rest of the strip (Priority: P2) + +**Goal**: However tall the readout grows, the mute control's and theme +picker's boxes never move, the arrangement settles in one pass with no +feedback loop, and every 012 property still holds with a grown readout in +play. + +**Independent Test**: Run `computeTopStripLayout` over the pinned viewport +set with readout content ranging from one line to the tallest the cap +permits, and assert the mute and picker boxes are identical to the one-line +case, that all 012 properties still hold, and that re-running the rule on +its own output — including with a deliberately wrong achieved height — +returns the same arrangement (spec.md User Story 2, Acceptance Scenarios +1-7). + +### Tests for User Story 2 + +- [ ] T012 [P] [US2] In `tests/lib/layout/topStrip.test.ts`, add the FR-013/ + FR-022/SC-005 identity assertion: call `computeTopStripLayout` twice at + the same viewport and occupant sizes, differing only in + `readoutHeightAtCapWidth` (one a one-line value, one T003's tallest + multi-line value, one a **deliberately wrong** value larger than + `growthAllowance` standing in for a stale/buggy measurement), and assert + `muteButton.rect` and `themePicker.rect` are deep-equal across all three + calls, at every pinned viewport. +- [ ] T013 [P] [US2] In `tests/lib/layout/topStrip.test.ts`, add the + FR-016/FR-016a/SC-006 settling assertions: (a) `computeReadoutWidthCap`'s + return value is identical regardless of what `readoutHeightAtCapWidth` a + subsequent `computeTopStripLayout` call receives (true by signature — + assert it directly as a regression guard, since `computeReadoutWidthCap` + never takes that parameter); (b) two `computeTopStripLayout` calls with + identical arguments (including the same `readoutHeightAtCapWidth`) are + deep-equal (statelessness); (c) an arrangement computed with a + deliberately wrong achieved band height (a wrong + `readoutHeightAtCapWidth`) is deep-equal to one computed with the correct + value in every field **except** `readout.rect.height`/`capped`/ + `maxLines` — pinning that the wrong value never reaches steps 1-5. +- [ ] T014 [US2] In `tests/lib/layout/topStrip.test.ts`, add the 012 FR-012a + restatement (AC6): across `readoutHeightAtCapWidth` values from one line + to the tallest permitted, at a viewport where the natural-size sum forces + a borderline collapse decision, assert `themePicker.collapsed` does not + change — the collapse decision is made from natural sizes only, and a + wrapped readout must not flip it either direction. +- [ ] T015 [US2] In `tests/lib/layout/topStrip.test.ts`, add a grown-readout + 012-properties sweep: at every pinned viewport, both orientations, with + each viewport's `reservedRects` active, and a `readoutHeightAtCapWidth` + at the tallest the growth allowance permits, assert no two occupant boxes + intersect, every box lies inside `availableBox`, and no occupant box + intersects a reserved rect (spec.md User Story 2 Acceptance Scenarios 2-4, + restating 012's FR-007/FR-008/FR-009 with a grown box in play, FR-014). + +**Checkpoint**: User Stories 1 AND 2 both hold — growth is additive, never a +trade against the rest of the strip, and idempotence is provable from the +rule's inputs rather than observed by watching a loop converge. + +--- + +## Phase 5: User Story 3 - Any occupant that has to shrink still fits its content (Priority: P3) + +**Goal**: The grow-then-elide policy applies uniformly to any occupant the +rule places smaller than its natural size — not a readout-only rule — so an +over-long theme display name is covered with no code change. + +**Independent Test**: Run the rule at 320 px with a collapsed theme-picker +size larger than the space available, and assert its returned box still lies +inside the available box and is flagged `capped`, exactly like the readout's +own flag (spec.md User Story 3, Acceptance Scenarios 1-4). + +### Tests for User Story 3 + +- [ ] T016 [P] [US3] In `tests/lib/layout/topStrip.test.ts`, add the SC-009 + generic-capped assertion: at `NARROWEST_PORTRAIT` with + `THEME_PICKER_SAMPLES.longThemeName`'s collapsed `Size` widened further + than the space the other occupants leave for it, assert the returned + `themePicker.rect` is fully inside `availableBox` and `themePicker.capped` + is `true` — the same `capped` field the readout uses, not a second + mechanism (data-model.md's "Capped Occupant" is not a hard-coded list). +- [ ] T017 [US3] In `tests/lib/layout/topStrip.test.ts`, extend the existing + theme-count-scaling describe block (`THEME_PICKER_SAMPLES`, including + `longThemeName`) with a `capped` assertion at `NARROWEST_PORTRAIT`, + confirming the mechanism generalizes across theme counts with no + per-count branch and that a theme's display name alone can trigger it + (User Story 3 AC4 — no `src/lib/layout/topStrip.ts` change is needed to + support a longer name than any sampled here, only a wider sample). + +### Implementation for User Story 3 + +- [ ] T018 [US3] In `src/App.svelte`, add `overflow: hidden; text-overflow: + ellipsis; white-space: nowrap;` to the `.theme-collapsed` rule (single-line + elision, distinct from the readout's multi-line clamp per research.md — + the collapsed control is always exactly one line) and set an `aria-label` + on it to the theme's full `displayName` whenever + `topStripLayout.themePicker?.capped` is `true` (FR-018, User Story 3 AC3: + operable and labelled even when visually truncated). + +**Checkpoint**: All user stories through P3 hold — the containment policy is +a property of "an occupant whose placed box is smaller than its natural +size," not a readout-specific rule, so it already covers a future theme with +no further change. + +--- + +## Phase 6: User Story 4 - A guarantee the suite can hold up (Priority: P4) + +**Goal**: The fit properties are pinned by node-only tests strict enough that +pinning the readout's height to its unwrapped natural height — today's +shipped bug — fails the suite at 360 px and 320 px, and the maintainer has a +re-runnable by-hand item recorded for the next contributor to re-check. + +**Independent Test**: The fit properties are asserted in the existing +node-only environment against the pure rule; a deliberate regression that +pins the readout's placed height to its unwrapped natural height regardless +of the width it is given fails those assertions (spec.md User Story 4, +Acceptance Scenario 1). + +### Tests for User Story 4 + +- [ ] T019 [US4] In `tests/lib/layout/topStrip.test.ts`, add the FR-021/ + SC-007 deliberate-regression test: a small test-local wrapper around + `computeTopStripLayout`'s result that overwrites `readout.rect.height` + with `sizes.readout.height` (the natural, unwrapped height) regardless of + `readoutHeightAtCapWidth` — exactly today's shipped bug — and assert this + overwritten result **fails** T004's FR-004 fit assertion at 360 px and + 320 px (both orientations), on the existing node-only runner with no + browser. + +### Documentation for User Story 4 + +- [ ] T020 [P] [US4] In `docs/manual-verification.md`, add a new item to the + existing `## Standing checks` section (alongside the "Top-strip controls + never overlap (012, `#35`)" entry), instructing the maintainer to confirm + on the narrowest real device to hand, in both orientations, that no + top-strip occupant's text renders outside its own dark background — + re-run against any change that touches `src/App.svelte`'s top-strip + markup/CSS or `src/lib/layout/topStrip.ts`, not just once at this spec's + review (FR-023, SC-010). Do not edit + `specs/012-top-strip-layout/spec.md`'s Maintainer Review Notes — it must + stay byte-for-byte unchanged (FR-023, SC-010). + +**Checkpoint**: All four user stories hold. The property is enforced by the +suite, not by a stylesheet comment, and the matching by-hand check is +recorded for future specs to re-run. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Confirm the feature ships clean, per FR-019 and the plan's +Constitution Check. + +- [ ] T021 [P] Run `npm test` (builds `dist/`, then the full `vitest` suite) + and confirm every test from features 001-012 still passes unchanged + alongside the new/extended `tests/lib/layout/topStrip.test.ts` cases, and + that `dist/` still holds exactly one self-contained `index.html` (FR-019, + SC-008). +- [ ] T022 [P] Review the full diff against `main` and confirm it touches no + file under `src/sim/`, no cave data file, and no theme data file — no + theme id appears in `src/lib/layout/topStrip.ts` or its `App.svelte` + wiring, and no viewport width is hard-coded outside the test file — only + `src/lib/layout/topStrip.ts`, `src/App.svelte`, + `tests/lib/layout/topStrip.test.ts`, and `docs/manual-verification.md` + changed (FR-019; spec.md Maintainer Review Notes items 8-9). + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately. +- **Foundational (Phase 2)**: Depends on T001 (reads the renamed `.rect` + shape) — BLOCKS every user story's fit/cap tests. +- **User Story 1 (Phase 3)**: Depends on Foundational. Delivers the whole + growth-allowance / cap-severing / elision implementation + (`computeReadoutWidthCap`'s real arithmetic, `computeTopStripLayout`'s + fourth parameter, and the `App.svelte` two-pass wiring) — every later + story's tests call the same functions T008-T011 implement. +- **User Story 2 (Phase 4)**: Depends on User Story 1's T008 (the severed + cycle it asserts) — no new implementation task, only new test coverage. +- **User Story 3 (Phase 5)**: Depends on User Story 1's T008 for the + `capped` mechanism; T018 is a small, independent CSS/aria change to a + different element (`.theme-collapsed`) than T011 touched (`.readout`). +- **User Story 4 (Phase 6)**: Depends on User Story 1's T008/T004 for its + test (T019); T020 (docs) has no code dependency and can happen any time + after Setup. +- **Polish (Phase 7)**: Depends on all four user stories being complete. + +### Within User Story 1 + +- Tests (T004-T007) before implementation (T008-T011) — write them first, + watch them fail against T001's hardcoded shape. +- T008 (the pure algorithm) before T009-T011 (the shell wiring and CSS that + depend on `computeReadoutWidthCap`'s real value and `maxLines`). +- T009 (nowrap natural probe) before T010 (the capped-width probe, which + reads `topStripSizes.readout` — T009's corrected measurement). +- T010 before T011 (`maxLines`/`capped` come from the `topStripLayout` call + T010 wires up). + +### Parallel Opportunities + +- T004-T007 all extend the same test file — sequence within the phase, not + in parallel, to avoid clobbering each other's edits. +- Likewise T012-T015, T016-T017, and T019 all extend the same test file — + sequence within each phase. +- T020 (docs) can run in parallel with any test-file task — different file. +- T018 (`.theme-collapsed` CSS) can run in parallel with T011 (`.readout` + CSS) once both depend only on T008 — different rules, same file, so + sequence the actual edits but there is no data dependency between them. +- T021 and T022 (Polish) are independent verification passes and can run in + parallel once every prior task is done. + +--- + +## Parallel Example: User Story 2 + +```bash +# T012 and T013 both assert properties of the same two functions but are +# independent checks — write them in sequence in the same file, but they +# have no data dependency on each other: +Task: "Add FR-013/FR-022 mute/picker identity assertion in tests/lib/layout/topStrip.test.ts" +Task: "Add FR-016/FR-016a settling assertions in tests/lib/layout/topStrip.test.ts" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (T001). +2. Complete Phase 2: Foundational (T002-T003) — blocks every story's tests. +3. Complete Phase 3: User Story 1 (T004-T011). +4. **STOP and VALIDATE**: `npm test` passes; manually open `dist/index.html` + at 360 px and 320 px and confirm the reported spill is gone (spec.md + SC-002). This alone fixes issue #43's reported defect. + +### Incremental Delivery + +1. Setup + Foundational → fixtures ready. +2. User Story 1 → the fix, pinned by tests → this is the MVP. +3. User Story 2 → severed-cycle and non-disturbance coverage added to the + same test file, no new production code. +4. User Story 3 → the generic `capped` policy extended to the theme picker's + collapsed control, with one small CSS/aria addition. +5. User Story 4 → the regression-proof test plus the Standing checks doc + entry — the guarantee becomes something the next contributor's diff must + survive, not something a reviewer has to remember to check. +6. Polish → full-suite and diff-scope confirmation. + +### Why User Stories 2-4 add almost no new implementation task + +`computeTopStripLayout` and `computeReadoutWidthCap` (T008) are written once, +as pure functions of `(availableBox, reservedRects, sizes, +readoutHeightAtCapWidth?)` with no theme-id or viewport-width branch +(FR-008). Severed-cycle idempotence, non-disturbance of the mute/picker +boxes, and the generic `capped` flag are properties of that one +implementation, not separate code paths — User Stories 2 and 4 are entirely +test-coverage phases over what User Story 1 already built. User Story 3 adds +one small rendering task (T018) because the readout and the theme picker's +collapsed control use different CSS elision techniques (multi-line clamp vs. +single-line ellipsis, research.md), but both read the same `capped` field. + +--- + +## Suggested MVP Scope + +User Story 1 (T001-T011, 11 tasks) is the reported defect and the whole +feature's load-bearing property: it makes every returned box sized for the +content it will actually hold, at every pinned width, with 412 px and +desktop unchanged. Stories 2-4 harden that same implementation with test +coverage and one small elision extension — genuinely valuable, but the +defect in issue #43 is closed once User Story 1 ships. From 70496c91ddb0dcfbeb9a1e3a084708f0e5cff3fb Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:45:32 +0000 Subject: [PATCH 03/16] implement: T001 extract computeReadoutWidthCap and new TopStripLayout shape Pure extract-method refactor: computeTopStripLayout's band/collapse/picker/ readout-width-cap steps now live in computeReadoutWidthCap, and every occupant's layout entry carries a capped flag (hardcoded false for now), with maxLines added to the readout. No placement arithmetic changes. --- specs/013-readout-overflow-policy/tasks.md | 2 +- src/App.svelte | 8 +- src/lib/layout/topStrip.ts | 102 ++++++++++++++++----- tests/lib/layout/topStrip.test.ts | 26 +++--- 4 files changed, 97 insertions(+), 41 deletions(-) diff --git a/specs/013-readout-overflow-policy/tasks.md b/specs/013-readout-overflow-policy/tasks.md index 5dd26d5..6e50b08 100644 --- a/specs/013-readout-overflow-policy/tasks.md +++ b/specs/013-readout-overflow-policy/tasks.md @@ -56,7 +56,7 @@ extract the band-geometry/collapse-decision/placement steps that never touch the readout's height into their own function, as a pure refactor with no behavior change — the surface every later task builds on. -- [ ] T001 In `src/lib/layout/topStrip.ts`: (1) extract today's steps 1 +- [X] T001 In `src/lib/layout/topStrip.ts`: (1) extract today's steps 1 (band + reserved-region subtraction), 2 (collapse decision), 3 (picker placement), 4 (readout width cap arithmetic) into a new exported function `computeReadoutWidthCap(availableBox, reservedRects, sizes): number` diff --git a/src/App.svelte b/src/App.svelte index 466acb8..7e19a4a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -466,8 +466,8 @@ {#if topStripLayout?.readout}
{hudText}
@@ -485,8 +485,8 @@ class="mute-button" aria-pressed={muted} onclick={toggleMuted} - style="left:{topStripLayout.muteButton.x}px; top:{topStripLayout.muteButton.y}px; width:{topStripLayout.muteButton - .width}px; height:{topStripLayout.muteButton.height}px;" + style="left:{topStripLayout.muteButton.rect.x}px; top:{topStripLayout.muteButton.rect.y}px; width:{topStripLayout + .muteButton.rect.width}px; height:{topStripLayout.muteButton.rect.height}px;" > {muted ? '🔇' : '🔊'} diff --git a/src/lib/layout/topStrip.ts b/src/lib/layout/topStrip.ts index 30dd5b1..a3903b0 100644 --- a/src/lib/layout/topStrip.ts +++ b/src/lib/layout/topStrip.ts @@ -15,11 +15,19 @@ export interface TopStripOccupantSizes { } export interface TopStripLayout { - readonly readout?: Rect; - readonly muteButton: Rect; + readonly readout?: { + readonly rect: Rect; + readonly capped: boolean; + readonly maxLines: number; + }; + readonly muteButton: { + readonly rect: Rect; + readonly capped: boolean; + }; readonly themePicker?: { readonly rect: Rect; readonly collapsed: boolean; + readonly capped: boolean; }; } @@ -49,13 +57,28 @@ function overlapsVertically(a: Rect, b: Rect): boolean { return a.y < b.y + b.height && a.y + a.height > b.y; } -export function computeTopStripLayout( +interface BandPlacement { + readonly usableLeft: number; + readonly usableWidth: number; + readonly centerY: (ownHeight: number) => number; + readonly collapsed: boolean; + readonly pickerRect: Rect | undefined; + readonly readoutCap: number; +} + +// Steps 1-4 of the Top-Strip Placement algorithm (data-model.md): the band's +// geometry and reserved-region subtraction, the collapse decision, the theme +// picker's placement, and the readout's width cap arithmetic — none of which +// ever reads the readout's own height. `readoutBandHeight` is the value +// substituted for the readout in the band-height max (data-model.md step 2); +// today that is the readout's natural height, unchanged from feature 012. +function computeBandPlacement( availableBox: InsetBox, reservedRects: readonly Rect[], - sizes: TopStripOccupantSizes -): TopStripLayout { - const heights = [sizes.muteButton.height]; - if (sizes.readout) heights.push(sizes.readout.height); + sizes: TopStripOccupantSizes, + readoutBandHeight: number +): BandPlacement { + const heights = [sizes.muteButton.height, readoutBandHeight]; if (sizes.themePicker) heights.push(sizes.themePicker.expanded.height, sizes.themePicker.collapsed.height); // Step 1: form the band, then subtract any reservedRects entry that @@ -109,15 +132,48 @@ export function computeTopStripLayout( } : undefined; - // Step 4: the readout (if present) takes the band's leading edge, capped - // by the space left once the picker's fixed block and the mute button's - // full natural width are both set aside (FR-013: the picker gives way - // first, the mute never shrinks, so the readout's cap is computed against - // their natural sizes directly — never against the mute's eventual - // centered position, which would starve the readout for no reason). + // Step 4: the readout's width cap — the space left once the picker's + // fixed block and the mute button's full natural width are both set aside + // (FR-013: the picker gives way first, the mute never shrinks, so the + // readout's cap is computed against their natural sizes directly — never + // against the mute's eventual centered position, which would starve the + // readout for no reason). const readoutOthersWidth = sizes.muteButton.width + (pickerRect?.width ?? 0); const readoutGaps = pickerRect ? 2 : 1; // readout-to-mute, and mute-to-picker if present const readoutCap = usableWidth - readoutOthersWidth - MARGIN * readoutGaps; + + return { usableLeft, usableWidth, centerY, collapsed, pickerRect, readoutCap }; +} + +/** + * The width the readout will receive, computed with no knowledge of the + * readout's own height (FR-016a) — call this between the shell's two DOM + * passes (FR-016b) to learn the width to measure the readout's real height + * against. + */ +export function computeReadoutWidthCap( + availableBox: InsetBox, + reservedRects: readonly Rect[], + sizes: TopStripOccupantSizes +): number { + return computeBandPlacement(availableBox, reservedRects, sizes, sizes.readout?.height ?? 0).readoutCap; +} + +export function computeTopStripLayout( + availableBox: InsetBox, + reservedRects: readonly Rect[], + sizes: TopStripOccupantSizes, + readoutHeightAtCapWidth?: number +): TopStripLayout { + // Not yet used — T008 wires this into the readout's height resolution. + void readoutHeightAtCapWidth; + + const placement = computeBandPlacement(availableBox, reservedRects, sizes, sizes.readout?.height ?? 0); + const readoutCap = computeReadoutWidthCap(availableBox, reservedRects, sizes); + const { usableLeft, usableWidth, centerY, collapsed, pickerRect } = placement; + + // Step 5 (readout placement): the readout (if present) takes the band's + // leading edge, capped by computeReadoutWidthCap's result above. const readoutRect: Rect | undefined = sizes.readout ? { x: usableLeft, @@ -127,11 +183,11 @@ export function computeTopStripLayout( } : undefined; - // Step 5: the mute button is placed at full natural size (FR-011), - // centered in the gap between the readout's trailing edge (or the band's - // leading edge, if no readout) and the picker's leading edge (or the - // band's trailing edge, if no picker) — its historical desktop-centered - // position, not the whole region left of the picker (FR-013). + // The mute button is placed at full natural size (FR-011), centered in the + // gap between the readout's trailing edge (or the band's leading edge, if + // no readout) and the picker's leading edge (or the band's trailing edge, + // if no picker) — its historical desktop-centered position, not the whole + // region left of the picker (FR-013). const muteLeftBound = readoutRect ? readoutRect.x + readoutRect.width + MARGIN : usableLeft; const muteRightBound = pickerRect ? pickerRect.x - MARGIN : usableLeft + usableWidth; const muteRegionWidth = muteRightBound - muteLeftBound; @@ -142,12 +198,12 @@ export function computeTopStripLayout( height: sizes.muteButton.height, }; - // Step 6: clamp every returned rect into availableBox, so no box can ever - // extend beyond it regardless of the arithmetic above (FR-008). + // Clamp every returned rect into availableBox, so no box can ever extend + // beyond it regardless of the arithmetic above (FR-008). const bounds: Rect = { x: availableBox.x, y: availableBox.y, width: availableBox.width, height: availableBox.height }; return { - readout: readoutRect ? containRect(readoutRect, bounds) : undefined, - muteButton: containRect(muteRect, bounds), - themePicker: pickerRect ? { rect: containRect(pickerRect, bounds), collapsed } : undefined, + readout: readoutRect ? { rect: containRect(readoutRect, bounds), capped: false, maxLines: 1 } : undefined, + muteButton: { rect: containRect(muteRect, bounds), capped: false }, + themePicker: pickerRect ? { rect: containRect(pickerRect, bounds), collapsed, capped: false } : undefined, }; } diff --git a/tests/lib/layout/topStrip.test.ts b/tests/lib/layout/topStrip.test.ts index b87284b..44da7bf 100644 --- a/tests/lib/layout/topStrip.test.ts +++ b/tests/lib/layout/topStrip.test.ts @@ -109,8 +109,8 @@ const OCCUPANT_SIZE_SAMPLES = { } satisfies Record; function collectRects(layout: ReturnType): Rect[] { - const rects: Rect[] = [layout.muteButton]; - if (layout.readout) rects.push(layout.readout); + const rects: Rect[] = [layout.muteButton.rect]; + if (layout.readout) rects.push(layout.readout.rect); if (layout.themePicker) rects.push(layout.themePicker.rect); return rects; } @@ -177,8 +177,8 @@ describe('computeTopStripLayout — collapse decision (US1, FR-011, FR-012, FR-0 expect(layout.readout).toBeDefined(); expect(layout.themePicker).toBeDefined(); // readout leading edge, mute centered between it and the picker, picker trailing edge - expect(layout.readout!.x).toBeLessThan(layout.muteButton.x); - expect(layout.muteButton.x + layout.muteButton.width).toBeLessThanOrEqual(layout.themePicker!.rect.x); + expect(layout.readout!.rect.x).toBeLessThan(layout.muteButton.rect.x); + expect(layout.muteButton.rect.x + layout.muteButton.rect.width).toBeLessThanOrEqual(layout.themePicker!.rect.x); }); }); @@ -186,7 +186,7 @@ describe('computeTopStripLayout — degradation priority order (FR-013)', () => it('does not starve the readout at a reported phone width where the picker still fits expanded', () => { const layout = computeTopStripLayout(REPORTING_DEVICE_PORTRAIT, NO_RESERVED_RECTS, OCCUPANT_SIZE_SAMPLES.typicalReadout); expect(layout.themePicker?.collapsed).toBe(false); - expect(layout.readout!.width).toBe(READOUT_TYPICAL.width); + expect(layout.readout!.rect.width).toBe(READOUT_TYPICAL.width); }); it('does not starve the readout once the picker has already given way to its collapsed form', () => { @@ -196,7 +196,7 @@ describe('computeTopStripLayout — degradation priority order (FR-013)', () => OCCUPANT_SIZE_SAMPLES.collapseForcingTypicalReadout ); expect(layout.themePicker?.collapsed).toBe(true); - expect(layout.readout!.width).toBe(READOUT_TYPICAL.width); + expect(layout.readout!.rect.width).toBe(READOUT_TYPICAL.width); }); }); @@ -229,15 +229,15 @@ describe('computeTopStripLayout — reserved regions and rotation (US2, FR-009, it("matches today's shipped desktop arrangement: readout leading, mute centered, picker trailing (FR-020)", () => { const layout = computeTopStripLayout(WIDE_DESKTOP, NO_RESERVED_RECTS, OCCUPANT_SIZE_SAMPLES.typicalReadout); - expect(layout.readout!.x).toBeLessThan(layout.muteButton.x); - expect(layout.muteButton.x + layout.muteButton.width).toBeLessThanOrEqual(layout.themePicker!.rect.x); + expect(layout.readout!.rect.x).toBeLessThan(layout.muteButton.rect.x); + expect(layout.muteButton.rect.x + layout.muteButton.rect.width).toBeLessThanOrEqual(layout.themePicker!.rect.x); expect(layout.themePicker!.collapsed).toBe(false); }); it('insets the leading and trailing edges by the same ~0.5rem margin the pre-feature CSS used (FR-020, SC-007)', () => { const layout = computeTopStripLayout(WIDE_DESKTOP, NO_RESERVED_RECTS, OCCUPANT_SIZE_SAMPLES.typicalReadout); const EDGE_MARGIN = 8; // mirrors computeTopStripLayout's own MARGIN constant (0.5rem) - expect(layout.readout!.x).toBe(WIDE_DESKTOP.x + EDGE_MARGIN); + expect(layout.readout!.rect.x).toBe(WIDE_DESKTOP.x + EDGE_MARGIN); expect(layout.themePicker!.rect.x + layout.themePicker!.rect.width).toBe( WIDE_DESKTOP.x + WIDE_DESKTOP.width - EDGE_MARGIN ); @@ -311,8 +311,8 @@ describe('computeTopStripLayout — freed space with no theme picker (US3, Edge for (const rect of collectRects(withoutPicker)) { expect(rectFullyInside(rect, NARROWEST_PORTRAIT)).toBe(true); } - const usedWidthWithout = withoutPicker.readout!.width + withoutPicker.muteButton.width; - const usedWidthWith = withPicker.readout!.width + withPicker.muteButton.width; + const usedWidthWithout = withoutPicker.readout!.rect.width + withoutPicker.muteButton.rect.width; + const usedWidthWith = withPicker.readout!.rect.width + withPicker.muteButton.rect.width; expect(usedWidthWithout).toBeGreaterThan(usedWidthWith); }); }); @@ -333,7 +333,7 @@ describe('computeTopStripLayout — the suite catches a real regression (US4, SC // see T020's edge inset), well within the broken mute button's 0-44 // span, so the broken mute button — pinned to (0, 0) regardless of // input — collides with it. - expect(layout.readout!.x).toBe(8); - expect(rectsIntersect(brokenMuteButton, layout.readout!)).toBe(true); + expect(layout.readout!.rect.x).toBe(8); + expect(rectsIntersect(brokenMuteButton, layout.readout!.rect)).toBe(true); }); }); From 1f8f3f14da9b2c1e20525d34096484cc5ea73286 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:46:20 +0000 Subject: [PATCH 04/16] implement: T002-T003 add 360px fixtures and height-for-width stand-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the 360 CSS px portrait/landscape pair and 412 landscape pair so all three FR-020 pinned widths exist in both orientations, plus a plain stand-in helper for the readout's wrapped height at a given capped width — the shared fixtures every user story's fit/cap tests build on. --- specs/013-readout-overflow-policy/tasks.md | 4 +- tests/lib/layout/topStrip.test.ts | 44 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/specs/013-readout-overflow-policy/tasks.md b/specs/013-readout-overflow-policy/tasks.md index 6e50b08..586094d 100644 --- a/specs/013-readout-overflow-policy/tasks.md +++ b/specs/013-readout-overflow-policy/tasks.md @@ -91,7 +91,7 @@ a height-for-width stand-in table (data-model.md's "Occupant Content Size"). **⚠️ CRITICAL**: No user story's fit/cap tests can be written until this phase is complete. -- [ ] T002 In `tests/lib/layout/topStrip.test.ts`, add the 360 CSS px +- [X] T002 In `tests/lib/layout/topStrip.test.ts`, add the 360 CSS px portrait/landscape `InsetBox` pair (`PORTRAIT_360`/`LANDSCAPE_360`) alongside the existing 320 (`NARROWEST_PORTRAIT`/`NARROWEST_LANDSCAPE`) and 412 (`REPORTING_DEVICE_PORTRAIT`; add a matching `REPORTING_DEVICE_LANDSCAPE`) @@ -99,7 +99,7 @@ phase is complete. and matching `reservedRects` samples for 360 derived the same way the existing 320/412 samples are (via `computeTouchControlLayout`, not hand-rolled). -- [ ] T003 In `tests/lib/layout/topStrip.test.ts`, add a height-for-width +- [X] T003 In `tests/lib/layout/topStrip.test.ts`, add a height-for-width stand-in helper (data-model.md's Occupant Content Size entity: "a plain stand-in for what the browser's text metrics report, supplied as data — no DOM") returning a plausible multi-line height for `READOUT_TYPICAL` and diff --git a/tests/lib/layout/topStrip.test.ts b/tests/lib/layout/topStrip.test.ts index 44da7bf..9e52851 100644 --- a/tests/lib/layout/topStrip.test.ts +++ b/tests/lib/layout/topStrip.test.ts @@ -28,6 +28,10 @@ const WIDE_DESKTOP: InsetBox = { x: 0, y: 0, width: 1024, height: 768 }; // picker still fits here, so this is where FR-013's priority order (picker // gives way first, never the readout) matters most. const REPORTING_DEVICE_PORTRAIT: InsetBox = { x: 0, y: 0, width: 412, height: 915 }; +const REPORTING_DEVICE_LANDSCAPE: InsetBox = { x: 0, y: 0, width: 915, height: 412 }; +// FR-020's third pinned width: 360 CSS px, between 320 and 412. +const PORTRAIT_360: InsetBox = { x: 0, y: 0, width: 360, height: 640 }; +const LANDSCAPE_360: InsetBox = { x: 0, y: 0, width: 640, height: 360 }; // Matching reservedRects samples, shaped exactly like // computeTouchControlLayout's own output (a bottom band in portrait, two @@ -41,10 +45,38 @@ const LANDSCAPE_RESERVED_RECTS: readonly Rect[] = computeTouchControlLayout( NARROWEST_LANDSCAPE, computeOrientation(NARROWEST_LANDSCAPE) ).reservedRects; +const PORTRAIT_360_RESERVED_RECTS: readonly Rect[] = computeTouchControlLayout( + PORTRAIT_360, + computeOrientation(PORTRAIT_360) +).reservedRects; +const LANDSCAPE_360_RESERVED_RECTS: readonly Rect[] = computeTouchControlLayout( + LANDSCAPE_360, + computeOrientation(LANDSCAPE_360) +).reservedRects; +const REPORTING_DEVICE_PORTRAIT_RESERVED_RECTS: readonly Rect[] = computeTouchControlLayout( + REPORTING_DEVICE_PORTRAIT, + computeOrientation(REPORTING_DEVICE_PORTRAIT) +).reservedRects; +const REPORTING_DEVICE_LANDSCAPE_RESERVED_RECTS: readonly Rect[] = computeTouchControlLayout( + REPORTING_DEVICE_LANDSCAPE, + computeOrientation(REPORTING_DEVICE_LANDSCAPE) +).reservedRects; // "No touch controls visible" cases (Edge Cases) — an empty array, exactly // what App.svelte passes when touchLayout is undefined. const NO_RESERVED_RECTS: readonly Rect[] = []; +// FR-020's full pinned viewport set (320/360/412, both orientations), each +// paired with its own reservedRects sample — used by every fit/cap +// assertion below that must hold at all three pinned widths. +const PINNED_VIEWPORTS: readonly [string, InsetBox, readonly Rect[]][] = [ + ['320 portrait', NARROWEST_PORTRAIT, PORTRAIT_RESERVED_RECTS], + ['320 landscape', NARROWEST_LANDSCAPE, LANDSCAPE_RESERVED_RECTS], + ['360 portrait', PORTRAIT_360, PORTRAIT_360_RESERVED_RECTS], + ['360 landscape', LANDSCAPE_360, LANDSCAPE_360_RESERVED_RECTS], + ['412 portrait', REPORTING_DEVICE_PORTRAIT, REPORTING_DEVICE_PORTRAIT_RESERVED_RECTS], + ['412 landscape', REPORTING_DEVICE_LANDSCAPE, REPORTING_DEVICE_LANDSCAPE_RESERVED_RECTS], +]; + // A typical in-play readout and the title screen's widest readout line // (spec.md Acceptance Scenario 4) — both natural sizes, never hard-coded // against character count (FR-003). @@ -52,6 +84,18 @@ const READOUT_TYPICAL: Size = { width: 140, height: 24 }; const READOUT_TITLE_WIDE: Size = { width: 260, height: 24 }; const MUTE_BUTTON: Size = { width: 44, height: 32 }; +// A plain stand-in for what the browser's text metrics would report for a +// readout wrapped to a given capped width (data-model.md's Occupant Content +// Size entity: "no DOM") — narrower widths need more lines and so report a +// taller height, and any width at or beyond the readout's own natural width +// needs only its one natural line. Deliberately invented numbers, not the +// maintainer's measured 44/62/80px or 18/36px spill figures (SC-002). +function heightForWidth(readout: Size, capWidth: number): number { + if (capWidth >= readout.width) return readout.height; + const lines = Math.ceil(readout.width / Math.max(1, capWidth)); + return readout.height * lines; +} + // One theme button's natural width plus its row gap, standing in for the // measured widths of one through four registered themes (data-model.md's // "Occupant Size Inputs" table) — the collapsed form's width never grows From 52ae517006b7aa65940561cee02d3233388a8b3c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:53:37 +0000 Subject: [PATCH 05/16] implement: T004-T011 grow-then-elide readout sizing (User Story 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeTopStripLayout now resolves the readout's height from a real height-at-capped-width measurement, grown up to availableBox.height/3 and capped/elided past that bound, while the reserved-region subtraction that decides usable width is computed from the growth allowance rather than any occupant's achieved height — severing the width->height->width cycle 012 left standing (FR-016a). Every occupant's layout entry now carries a real `capped` flag and the readout carries `maxLines`. App.svelte adds the shell's two-pass measurement: a nowrap natural-size probe and a second hidden probe pinned to computeReadoutWidthCap's result, whose measured height feeds computeTopStripLayout's new fourth argument. The visible .readout gains a structural line-clamp clip and an aria-label fallback to the full text when capped, so a sizing mistake degrades to less text shown rather than text on the cave (FR-002). tests/lib/layout/topStrip.test.ts pins FR-004/FR-009/FR-016a/SC-001/SC-003 with a height-for-width stand-in (no DOM) over the 320/360/412 px pinned viewport set in both orientations, plus the desktop and title-screen no-regression cases. All 90 topStrip tests and the full 674-test suite pass unchanged elsewhere. --- specs/013-readout-overflow-policy/tasks.md | 16 +-- src/App.svelte | 48 ++++++++- src/lib/layout/topStrip.ts | 116 +++++++++++++++------ tests/lib/layout/topStrip.test.ts | 88 +++++++++++++++- 4 files changed, 221 insertions(+), 47 deletions(-) diff --git a/specs/013-readout-overflow-policy/tasks.md b/specs/013-readout-overflow-policy/tasks.md index 586094d..10c54b6 100644 --- a/specs/013-readout-overflow-policy/tasks.md +++ b/specs/013-readout-overflow-policy/tasks.md @@ -137,7 +137,7 @@ Acceptance Scenarios 1-7). > `capped`/`maxLines` and today's height-ignoring-width arithmetic until T008 > lands. -- [ ] T004 [P] [US1] In `tests/lib/layout/topStrip.test.ts`, add the FR-004/ +- [X] T004 [P] [US1] In `tests/lib/layout/topStrip.test.ts`, add the FR-004/ SC-001 fit assertion: parameterized over the pinned viewport set (320, 360, 412, both orientations, with and without each viewport's `reservedRects`) and occupant-size samples standing in for one through @@ -145,17 +145,17 @@ Acceptance Scenarios 1-7). least `min(readoutHeightAtCapWidth, growthAllowance)` for the width T003's helper supplies at that box, and that `readout.rect.width` is unaffected by which height value was supplied (FR-004, FR-016a). -- [ ] T005 [P] [US1] In `tests/lib/layout/topStrip.test.ts`, add the SC-003 +- [X] T005 [P] [US1] In `tests/lib/layout/topStrip.test.ts`, add the SC-003 regression guard: at `REPORTING_DEVICE_PORTRAIT` (412 px) with a height-for-width value that fits in two lines, assert the resulting `readout.rect` is unchanged from what T001's pre-growth-allowance arithmetic already produces there — 412 px is "the width that passes today and must keep passing" (spec.md). -- [ ] T006 [US1] In `tests/lib/layout/topStrip.test.ts`, add the widest-line +- [X] T006 [US1] In `tests/lib/layout/topStrip.test.ts`, add the widest-line fit assertion (AC5): using `READOUT_TITLE_WIDE` with T003's helper's tallest 320 px value, assert the returned box is at least that tall and `rectFullyInside(readout.rect, NARROWEST_PORTRAIT)` holds. -- [ ] T007 [US1] In `tests/lib/layout/topStrip.test.ts`, add the desktop +- [X] T007 [US1] In `tests/lib/layout/topStrip.test.ts`, add the desktop no-capping regression guard (AC6, FR-017): at `WIDE_DESKTOP` with a height-for-width value equal to the natural single-line height (nothing capped), assert `readout.capped` is `false`, `readout.rect.height` equals @@ -164,7 +164,7 @@ Acceptance Scenarios 1-7). ### Implementation for User Story 1 -- [ ] T008 [US1] In `src/lib/layout/topStrip.ts`, implement the +- [X] T008 [US1] In `src/lib/layout/topStrip.ts`, implement the growth-allowance / cap-severing algorithm per data-model.md's eight-step "Top-Strip Placement" section: (1) compute `growthAllowance = availableBox.height / 3` before anything else — depends only on @@ -185,13 +185,13 @@ Acceptance Scenarios 1-7). degenerate near-zero-`availableBox` edge case is covered by the same flag (data-model.md's step 8 note). This must make T004-T007 pass without changing any pre-existing 012 assertion (FR-014). -- [ ] T009 [US1] In `src/App.svelte`, force `white-space: nowrap` on the +- [X] T009 [US1] In `src/App.svelte`, force `white-space: nowrap` on the readout's natural-size probe only (`readoutProbeEl` / its `.top-strip-probe` styling) so `topStripSizes.readout` reports a true single-line natural size regardless of viewport width, never one the viewport already wrapped (FR-005, research.md's `nowrap`-probe decision). Do not add `nowrap` to the visible `.readout` rule or any other probe. -- [ ] T010 [US1] In `src/App.svelte`, add a second hidden "capped-width" +- [X] T010 [US1] In `src/App.svelte`, add a second hidden "capped-width" readout probe styled like `.readout` but with an explicit inline `width` bound to a new `readoutWidthCap = $derived.by(() => computeReadoutWidthCap(insetBox, touchLayout?.reservedRects ?? [], @@ -203,7 +203,7 @@ Acceptance Scenarios 1-7). `computeTopStripLayout`'s fourth argument in the existing `topStripLayout` `$derived.by`. This is the shell's fixed two-DOM-pass measurement (FR-016b) — no third pass, no per-frame recomputation. -- [ ] T011 [US1] In `src/App.svelte`, add `overflow: hidden; display: +- [X] T011 [US1] In `src/App.svelte`, add `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical;` and a `-webkit-line-clamp: {topStripLayout.readout.maxLines}` inline style to the visible `.readout` element (FR-002's structural clip: the box is physically incapable of diff --git a/src/App.svelte b/src/App.svelte index 7e19a4a..9ea97bc 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -14,7 +14,7 @@ import { TouchInput } from './lib/input/touch/TouchInput'; import { GamepadInput } from './lib/input/gamepad/GamepadInput'; import { computeOrientation, computeTouchControlLayout, type InsetBox } from './lib/input/touch/layout'; - import { computeTopStripLayout, type Size, type TopStripOccupantSizes } from './lib/layout/topStrip'; + import { computeReadoutWidthCap, computeTopStripLayout, type Size, type TopStripOccupantSizes } from './lib/layout/topStrip'; import { nextLastInputSource, shouldShowTouchControls, type InputOrigin, type LastInputSource } from './lib/input/visibility'; import { orAll, resolveDirection } from './lib/input/merge'; import { createRenderLoop, type RenderLoop } from './lib/render/canvas'; @@ -111,6 +111,11 @@ // above, styled with the same classes as the real elements so their // getBoundingClientRect() reports the same natural size. let readoutProbeEl: HTMLDivElement | undefined = $state(); + // The shell's second DOM pass (FR-016b, data-model.md's Shell Wiring) — a + // hidden readout probe pinned to computeReadoutWidthCap's own result, with + // no nowrap, so its real wrapped height at exactly that width can be read + // back as readoutHeightAtCapWidth below. + let readoutCappedProbeEl: HTMLDivElement | undefined = $state(); let muteProbeEl: HTMLButtonElement | undefined = $state(); let themeRowProbeEl: HTMLDivElement | undefined = $state(); let themeCollapsedProbeEl: HTMLButtonElement | undefined = $state(); @@ -348,12 +353,32 @@ return { readout, muteButton: toSize(muteProbeEl), themePicker }; }); + // The width computeTopStripLayout will give the readout, computed with no + // knowledge of its height (FR-016a) — feeds the capped-width probe's + // inline width below, the shell's first of two DOM passes for the readout. + let readoutWidthCap = $derived.by(() => { + if (!insetBox || !topStripSizes) return undefined; + return computeReadoutWidthCap(insetBox, touchLayout?.reservedRects ?? [], topStripSizes); + }); + + // The shell's second-pass measurement (FR-016b): the readout's real + // wrapped height at exactly readoutWidthCap, re-read on the same triggers + // topStripSizes already uses. undefined before topStripSizes/hudText exist + // yet, or while no readout is shown — computeTopStripLayout falls back to + // the natural single-line height in that case (Edge Cases). + let readoutHeightAtCapWidth = $derived.by(() => { + topStripProbeTick; + void theme.displayName; + if (hudText === undefined || !readoutCappedProbeEl) return undefined; + return readoutCappedProbeEl.getBoundingClientRect().height; + }); + // FR-017: recomputed only when insetBox, the touch layout's reservedRects, // or topStripSizes changes — never per tick or per frame, mirroring // touchLayout's own $derived.by above. let topStripLayout = $derived.by(() => { if (!insetBox || !topStripSizes) return undefined; - return computeTopStripLayout(insetBox, touchLayout?.reservedRects ?? [], topStripSizes); + return computeTopStripLayout(insetBox, touchLayout?.reservedRects ?? [], topStripSizes, readoutHeightAtCapWidth); }); // FR-020: the bonus is already final the instant 'caveComplete' is @@ -450,7 +475,20 @@ styled identically to their visible counterparts below so getBoundingClientRect() reports the same natural size regardless of which form (expanded/collapsed) is currently rendered. --> - + + + @@ -467,7 +505,9 @@
{hudText}
diff --git a/src/lib/layout/topStrip.ts b/src/lib/layout/topStrip.ts index a3903b0..cefb666 100644 --- a/src/lib/layout/topStrip.ts +++ b/src/lib/layout/topStrip.ts @@ -62,31 +62,39 @@ interface BandPlacement { readonly usableWidth: number; readonly centerY: (ownHeight: number) => number; readonly collapsed: boolean; + readonly pickerSize: Size | undefined; readonly pickerRect: Rect | undefined; readonly readoutCap: number; } // Steps 1-4 of the Top-Strip Placement algorithm (data-model.md): the band's // geometry and reserved-region subtraction, the collapse decision, the theme -// picker's placement, and the readout's width cap arithmetic — none of which -// ever reads the readout's own height. `readoutBandHeight` is the value -// substituted for the readout in the band-height max (data-model.md step 2); -// today that is the readout's natural height, unchanged from feature 012. +// picker's placement, and the readout's width cap arithmetic. +// +// Two different band heights are in play here, deliberately kept separate: +// - the *reserved-subtraction* band (this function's `bandHeight`) uses +// `growthAllowance` in place of the readout's own height (FR-016a), so +// which reservedRects count against the usable width depends only on +// availableBox — never on the readout's achieved or natural height, which +// is what severs 012's width→height→width cycle at its only closing edge. +// - the *visual* centering line (`centerY`), which stays exactly as 012 had +// it — a function of the occupants' natural sizes only — so substituting +// growthAllowance above never moves the mute button or theme picker +// relative to today's shipped positions (FR-013, SC-003): growthAllowance +// can be much larger than any natural height (it is a fraction of the +// whole available box), and centering against it would shove every +// occupant toward the middle of the screen instead of the top of the strip. function computeBandPlacement( availableBox: InsetBox, reservedRects: readonly Rect[], sizes: TopStripOccupantSizes, - readoutBandHeight: number + growthAllowance: number ): BandPlacement { - const heights = [sizes.muteButton.height, readoutBandHeight]; - if (sizes.themePicker) heights.push(sizes.themePicker.expanded.height, sizes.themePicker.collapsed.height); - - // Step 1: form the band, then subtract any reservedRects entry that - // overlaps it vertically from its usable interior — this is what makes - // landscape's full-height side margins cut into the top strip too, - // without an orientation-specific branch (research.md). - const bandHeight = Math.max(...heights) + MARGIN * 2; + const reservedHeights = [sizes.muteButton.height, growthAllowance]; + if (sizes.themePicker) reservedHeights.push(sizes.themePicker.expanded.height, sizes.themePicker.collapsed.height); + const bandHeight = Math.max(...reservedHeights) + MARGIN * 2; const band: Rect = { x: availableBox.x, y: availableBox.y, width: availableBox.width, height: bandHeight }; + // Inset the band's own leading/trailing edges by MARGIN before subtracting // any reservedRects, so the readout's leading edge and the theme picker's // trailing edge sit off the screen edge by the same ~8px (0.5rem) the @@ -101,7 +109,14 @@ function computeBandPlacement( if (reservedRight >= usableRight) usableRight = Math.min(usableRight, reservedLeft); } const usableWidth = Math.max(0, usableRight - usableLeft); - const centerY = (ownHeight: number): number => band.y + (bandHeight - ownHeight) / 2; + + // The visual centering line — unchanged from 012, a function of natural + // sizes only (see the note above `bandHeight` for why this is not the + // same height as the reserved-subtraction band). + const visualHeights = [sizes.muteButton.height, sizes.readout?.height ?? 0]; + if (sizes.themePicker) visualHeights.push(sizes.themePicker.expanded.height, sizes.themePicker.collapsed.height); + const visualBandHeight = Math.max(...visualHeights) + MARGIN * 2; + const centerY = (ownHeight: number): number => availableBox.y + (visualBandHeight - ownHeight) / 2; // Step 2: decide the theme picker's form once, from natural sizes only // (FR-012a) — never from a previously-returned layout — so the decision @@ -132,7 +147,7 @@ function computeBandPlacement( } : undefined; - // Step 4: the readout's width cap — the space left once the picker's + // Step 4/5: the readout's width cap — the space left once the picker's // fixed block and the mute button's full natural width are both set aside // (FR-013: the picker gives way first, the mute never shrinks, so the // readout's cap is computed against their natural sizes directly — never @@ -142,7 +157,7 @@ function computeBandPlacement( const readoutGaps = pickerRect ? 2 : 1; // readout-to-mute, and mute-to-picker if present const readoutCap = usableWidth - readoutOthersWidth - MARGIN * readoutGaps; - return { usableLeft, usableWidth, centerY, collapsed, pickerRect, readoutCap }; + return { usableLeft, usableWidth, centerY, collapsed, pickerSize, pickerRect, readoutCap }; } /** @@ -156,32 +171,48 @@ export function computeReadoutWidthCap( reservedRects: readonly Rect[], sizes: TopStripOccupantSizes ): number { - return computeBandPlacement(availableBox, reservedRects, sizes, sizes.readout?.height ?? 0).readoutCap; + const growthAllowance = availableBox.height / 3; + return computeBandPlacement(availableBox, reservedRects, sizes, growthAllowance).readoutCap; } +/** + * readoutHeightAtCapWidth: the shell's second-pass measurement — the + * readout's real wrapped height at exactly computeReadoutWidthCap(...)'s + * result. Omit (or pass undefined) before that measurement exists yet; the + * function then falls back to the readout's natural single-line height, + * which cannot spill (Edge Cases: "Text metrics that are unavailable or + * report zero"). + */ export function computeTopStripLayout( availableBox: InsetBox, reservedRects: readonly Rect[], sizes: TopStripOccupantSizes, readoutHeightAtCapWidth?: number ): TopStripLayout { - // Not yet used — T008 wires this into the readout's height resolution. - void readoutHeightAtCapWidth; + // Step 1: the growth allowance — a backstop, not a budget (FR-009) — + // depends only on availableBox, computed before anything else. + const growthAllowance = availableBox.height / 3; - const placement = computeBandPlacement(availableBox, reservedRects, sizes, sizes.readout?.height ?? 0); + const placement = computeBandPlacement(availableBox, reservedRects, sizes, growthAllowance); const readoutCap = computeReadoutWidthCap(availableBox, reservedRects, sizes); - const { usableLeft, usableWidth, centerY, collapsed, pickerRect } = placement; + const { usableLeft, usableWidth, centerY, collapsed, pickerSize, pickerRect } = placement; - // Step 5 (readout placement): the readout (if present) takes the band's - // leading edge, capped by computeReadoutWidthCap's result above. - const readoutRect: Rect | undefined = sizes.readout - ? { - x: usableLeft, - y: centerY(sizes.readout.height), - width: Math.max(0, Math.min(sizes.readout.width, readoutCap)), - height: sizes.readout.height, - } - : undefined; + // Step 6: resolve the readout's height — the natural single-line height as + // the fallback for "measurement not available yet" (Edge Cases), grown up + // to (but never beyond) growthAllowance. + const contentHeight = sizes.readout ? (readoutHeightAtCapWidth ?? sizes.readout.height) : undefined; + const readoutHeight = contentHeight !== undefined ? Math.min(contentHeight, growthAllowance) : undefined; + const maxLines = sizes.readout ? Math.max(1, Math.floor(growthAllowance / sizes.readout.height)) : 1; + + const readoutRect: Rect | undefined = + sizes.readout && readoutHeight !== undefined + ? { + x: usableLeft, + y: centerY(readoutHeight), + width: Math.max(0, Math.min(sizes.readout.width, readoutCap)), + height: readoutHeight, + } + : undefined; // The mute button is placed at full natural size (FR-011), centered in the // gap between the readout's trailing edge (or the band's leading edge, if @@ -201,9 +232,26 @@ export function computeTopStripLayout( // Clamp every returned rect into availableBox, so no box can ever extend // beyond it regardless of the arithmetic above (FR-008). const bounds: Rect = { x: availableBox.x, y: availableBox.y, width: availableBox.width, height: availableBox.height }; + const boundedReadout = readoutRect ? containRect(readoutRect, bounds) : undefined; + const boundedMute = containRect(muteRect, bounds); + const boundedPicker = pickerRect ? containRect(pickerRect, bounds) : undefined; + + // Step 7/8: each occupant's `capped` flag, evaluated against the + // post-clamp size so the degenerate near-zero-availableBox edge case is + // covered by the same flag rather than a special case. + const readoutCapped = + boundedReadout !== undefined && sizes.readout !== undefined && contentHeight !== undefined + ? boundedReadout.width < sizes.readout.width || boundedReadout.height < contentHeight + : false; + const muteCapped = boundedMute.width < sizes.muteButton.width || boundedMute.height < sizes.muteButton.height; + const pickerCapped = + boundedPicker !== undefined && pickerSize !== undefined + ? boundedPicker.width < pickerSize.width || boundedPicker.height < pickerSize.height + : false; + return { - readout: readoutRect ? { rect: containRect(readoutRect, bounds), capped: false, maxLines: 1 } : undefined, - muteButton: { rect: containRect(muteRect, bounds), capped: false }, - themePicker: pickerRect ? { rect: containRect(pickerRect, bounds), collapsed, capped: false } : undefined, + readout: boundedReadout ? { rect: boundedReadout, capped: readoutCapped, maxLines } : undefined, + muteButton: { rect: boundedMute, capped: muteCapped }, + themePicker: boundedPicker ? { rect: boundedPicker, collapsed, capped: pickerCapped } : undefined, }; } diff --git a/tests/lib/layout/topStrip.test.ts b/tests/lib/layout/topStrip.test.ts index 9e52851..c8b5e63 100644 --- a/tests/lib/layout/topStrip.test.ts +++ b/tests/lib/layout/topStrip.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { computeTopStripLayout, type Size, type TopStripOccupantSizes } from '../../../src/lib/layout/topStrip'; +import { + computeReadoutWidthCap, + computeTopStripLayout, + type Size, + type TopStripOccupantSizes, +} from '../../../src/lib/layout/topStrip'; import { computeOrientation, computeTouchControlLayout, type InsetBox, type Rect } from '../../../src/lib/input/touch/layout'; // Mirrors tests/lib/input/touch/layout.test.ts's own rectsIntersect/ @@ -159,6 +164,87 @@ function collectRects(layout: ReturnType): Rect[] return rects; } +describe('computeTopStripLayout — fits the content at the width it was given (US1, FR-004, FR-009, FR-016a, SC-001)', () => { + const themePickerSamples: [string, NonNullable][] = [ + ['one theme', THEME_PICKER_SAMPLES.oneTheme], + ['two themes', THEME_PICKER_SAMPLES.twoThemes], + ['three themes', THEME_PICKER_SAMPLES.threeThemes], + ['four themes', THEME_PICKER_SAMPLES.fourThemes], + ['one unusually long theme name', THEME_PICKER_SAMPLES.longThemeName], + ]; + + for (const [viewportLabel, box, viewportReservedRects] of PINNED_VIEWPORTS) { + for (const [reservedLabel, rects] of [ + ['with reservedRects', viewportReservedRects], + ['without reservedRects', NO_RESERVED_RECTS], + ] as const) { + for (const [themeLabel, picker] of themePickerSamples) { + it(`readout box is at least as tall as its content needs at the capped width (${viewportLabel}, ${reservedLabel}, ${themeLabel})`, () => { + const sizes: TopStripOccupantSizes = { readout: READOUT_TYPICAL, muteButton: MUTE_BUTTON, themePicker: picker }; + const capWidth = computeReadoutWidthCap(box, rects, sizes); + const heightAtCap = heightForWidth(READOUT_TYPICAL, capWidth); + const layout = computeTopStripLayout(box, rects, sizes, heightAtCap); + const growthAllowance = box.height / 3; + expect(layout.readout).toBeDefined(); + expect(layout.readout!.rect.height).toBeGreaterThanOrEqual(Math.min(heightAtCap, growthAllowance) - 1e-9); + }); + } + } + } + + it("the readout's width is unaffected by which height value it is given (FR-004, FR-016a)", () => { + const sizes = OCCUPANT_SIZE_SAMPLES.typicalReadout; + const oneLine = computeTopStripLayout(NARROWEST_PORTRAIT, NO_RESERVED_RECTS, sizes, READOUT_TYPICAL.height); + const tallestStandIn = heightForWidth(READOUT_TYPICAL, 1); // as narrow a cap as this helper models + const grown = computeTopStripLayout(NARROWEST_PORTRAIT, NO_RESERVED_RECTS, sizes, tallestStandIn); + expect(grown.readout!.rect.width).toBe(oneLine.readout!.rect.width); + expect(grown.readout!.rect.x).toBe(oneLine.readout!.rect.x); + }); +}); + +describe('computeTopStripLayout — 412px stays exactly as it ships today (US1, SC-003)', () => { + it('a height-for-width value that already fits on the shipped two lines leaves the readout rect unchanged', () => { + const sizes = OCCUPANT_SIZE_SAMPLES.typicalReadout; + const capWidth = computeReadoutWidthCap(REPORTING_DEVICE_PORTRAIT, NO_RESERVED_RECTS, sizes); + const heightAtCap = heightForWidth(READOUT_TYPICAL, capWidth); + // 412 px is wide enough that nothing wraps here — the width that passes + // today and must keep passing (spec.md). + expect(heightAtCap).toBe(READOUT_TYPICAL.height); + const before = computeTopStripLayout(REPORTING_DEVICE_PORTRAIT, NO_RESERVED_RECTS, sizes); + const after = computeTopStripLayout(REPORTING_DEVICE_PORTRAIT, NO_RESERVED_RECTS, sizes, heightAtCap); + expect(after.readout!.rect).toEqual(before.readout!.rect); + expect(after.muteButton.rect).toEqual(before.muteButton.rect); + expect(after.themePicker!.rect).toEqual(before.themePicker!.rect); + }); +}); + +describe('computeTopStripLayout — the widest readout line still fits at 320px (US1, AC5)', () => { + it('the title screen line is fully inside its box at the narrowest supported width', () => { + const sizes: TopStripOccupantSizes = { + readout: READOUT_TITLE_WIDE, + muteButton: MUTE_BUTTON, + themePicker: THEME_PICKER_SAMPLES.twoThemes, + }; + const capWidth = computeReadoutWidthCap(NARROWEST_PORTRAIT, NO_RESERVED_RECTS, sizes); + const heightAtCap = heightForWidth(READOUT_TITLE_WIDE, capWidth); + const layout = computeTopStripLayout(NARROWEST_PORTRAIT, NO_RESERVED_RECTS, sizes, heightAtCap); + const growthAllowance = NARROWEST_PORTRAIT.height / 3; + expect(layout.readout!.rect.height).toBeGreaterThanOrEqual(Math.min(heightAtCap, growthAllowance) - 1e-9); + expect(rectFullyInside(layout.readout!.rect, NARROWEST_PORTRAIT)).toBe(true); + }); +}); + +describe('computeTopStripLayout — desktop stays uncapped (US1, AC6, FR-017)', () => { + it('nothing is capped, wrapped, or grown at a desktop width', () => { + const sizes = OCCUPANT_SIZE_SAMPLES.typicalReadout; + const layout = computeTopStripLayout(WIDE_DESKTOP, NO_RESERVED_RECTS, sizes, READOUT_TYPICAL.height); + expect(layout.readout!.capped).toBe(false); + expect(layout.readout!.rect.height).toBe(READOUT_TYPICAL.height); + expect(layout.readout!.rect.x).toBeLessThan(layout.muteButton.rect.x); + expect(layout.muteButton.rect.x + layout.muteButton.rect.width).toBeLessThanOrEqual(layout.themePicker!.rect.x); + }); +}); + describe('computeTopStripLayout — non-overlap and containment (US1, FR-007, FR-008)', () => { const cases: [string, TopStripOccupantSizes][] = [ ['typical readout', OCCUPANT_SIZE_SAMPLES.typicalReadout], From a2ed0a2ad99e5caae6ca96d217ef4fa91cb12389 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:55:47 +0000 Subject: [PATCH 06/16] implement: T012-T015 pin non-disturbance and idempotence (User Story 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes readout.rect.y to center against the readout's natural (single-line) height rather than its achieved height, so growth only ever extends the box downward from a fixed top edge — the achieved height now reaches only readout.rect.height/capped/maxLines, never x/y/width or any other occupant's rect, matching FR-013/FR-022's severed-cycle guarantee exactly. Adds test coverage: mute/picker rects identical across one-line, tallest- permitted, and deliberately-wrong readout heights at every pinned viewport; two identical calls are deep-equal; a wrong achieved height changes only the readout's own height-derived fields; a grown readout never flips a borderline collapse decision; and the full set of 012's non-overlap/containment/reserved-region properties hold with the readout grown to the tallest the allowance permits. --- specs/013-readout-overflow-policy/tasks.md | 8 +- src/lib/layout/topStrip.ts | 9 ++- tests/lib/layout/topStrip.test.ts | 91 ++++++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/specs/013-readout-overflow-policy/tasks.md b/specs/013-readout-overflow-policy/tasks.md index 10c54b6..44b1869 100644 --- a/specs/013-readout-overflow-policy/tasks.md +++ b/specs/013-readout-overflow-policy/tasks.md @@ -237,7 +237,7 @@ returns the same arrangement (spec.md User Story 2, Acceptance Scenarios ### Tests for User Story 2 -- [ ] T012 [P] [US2] In `tests/lib/layout/topStrip.test.ts`, add the FR-013/ +- [X] T012 [P] [US2] In `tests/lib/layout/topStrip.test.ts`, add the FR-013/ FR-022/SC-005 identity assertion: call `computeTopStripLayout` twice at the same viewport and occupant sizes, differing only in `readoutHeightAtCapWidth` (one a one-line value, one T003's tallest @@ -245,7 +245,7 @@ returns the same arrangement (spec.md User Story 2, Acceptance Scenarios `growthAllowance` standing in for a stale/buggy measurement), and assert `muteButton.rect` and `themePicker.rect` are deep-equal across all three calls, at every pinned viewport. -- [ ] T013 [P] [US2] In `tests/lib/layout/topStrip.test.ts`, add the +- [X] T013 [P] [US2] In `tests/lib/layout/topStrip.test.ts`, add the FR-016/FR-016a/SC-006 settling assertions: (a) `computeReadoutWidthCap`'s return value is identical regardless of what `readoutHeightAtCapWidth` a subsequent `computeTopStripLayout` call receives (true by signature — @@ -257,13 +257,13 @@ returns the same arrangement (spec.md User Story 2, Acceptance Scenarios `readoutHeightAtCapWidth`) is deep-equal to one computed with the correct value in every field **except** `readout.rect.height`/`capped`/ `maxLines` — pinning that the wrong value never reaches steps 1-5. -- [ ] T014 [US2] In `tests/lib/layout/topStrip.test.ts`, add the 012 FR-012a +- [X] T014 [US2] In `tests/lib/layout/topStrip.test.ts`, add the 012 FR-012a restatement (AC6): across `readoutHeightAtCapWidth` values from one line to the tallest permitted, at a viewport where the natural-size sum forces a borderline collapse decision, assert `themePicker.collapsed` does not change — the collapse decision is made from natural sizes only, and a wrapped readout must not flip it either direction. -- [ ] T015 [US2] In `tests/lib/layout/topStrip.test.ts`, add a grown-readout +- [X] T015 [US2] In `tests/lib/layout/topStrip.test.ts`, add a grown-readout 012-properties sweep: at every pinned viewport, both orientations, with each viewport's `reservedRects` active, and a `readoutHeightAtCapWidth` at the tallest the growth allowance permits, assert no two occupant boxes diff --git a/src/lib/layout/topStrip.ts b/src/lib/layout/topStrip.ts index cefb666..f096a96 100644 --- a/src/lib/layout/topStrip.ts +++ b/src/lib/layout/topStrip.ts @@ -208,7 +208,14 @@ export function computeTopStripLayout( sizes.readout && readoutHeight !== undefined ? { x: usableLeft, - y: centerY(readoutHeight), + // Centered against the readout's natural (single-line) height, not + // its grown height — so the box's top edge is fixed and growth + // only ever extends downward. Centering against the achieved + // height instead would move readout.rect.y whenever the content's + // height changed, which is exactly the dependency FR-016a/FR-022 + // rule out for the other occupants and which step 6 never lists + // as something the readout's own height resolution touches. + y: centerY(sizes.readout.height), width: Math.max(0, Math.min(sizes.readout.width, readoutCap)), height: readoutHeight, } diff --git a/tests/lib/layout/topStrip.test.ts b/tests/lib/layout/topStrip.test.ts index c8b5e63..f9a0983 100644 --- a/tests/lib/layout/topStrip.test.ts +++ b/tests/lib/layout/topStrip.test.ts @@ -386,6 +386,97 @@ describe('computeTopStripLayout — reserved regions and rotation (US2, FR-009, }); }); +describe('computeTopStripLayout — the mute and picker boxes never depend on the readout (US2, FR-013, FR-022, SC-005)', () => { + it('muteButton and themePicker rects are identical across differing readout heights, at every pinned viewport', () => { + for (const [, box, reservedRects] of PINNED_VIEWPORTS) { + const sizes = OCCUPANT_SIZE_SAMPLES.typicalReadout; + const growthAllowance = box.height / 3; + const oneLine = computeTopStripLayout(box, reservedRects, sizes, READOUT_TYPICAL.height); + const tallestPermitted = computeTopStripLayout(box, reservedRects, sizes, growthAllowance); + // Deliberately wrong: larger than growthAllowance, standing in for a + // stale or buggy measurement (FR-022). + const deliberatelyWrong = computeTopStripLayout(box, reservedRects, sizes, growthAllowance + 10_000); + expect(tallestPermitted.muteButton.rect).toEqual(oneLine.muteButton.rect); + expect(deliberatelyWrong.muteButton.rect).toEqual(oneLine.muteButton.rect); + expect(tallestPermitted.themePicker!.rect).toEqual(oneLine.themePicker!.rect); + expect(deliberatelyWrong.themePicker!.rect).toEqual(oneLine.themePicker!.rect); + } + }); +}); + +describe('computeTopStripLayout — single-pass, structurally acyclic idempotence (US2, FR-016, FR-016a, FR-016b, SC-006)', () => { + it("computeReadoutWidthCap's result never depends on a later readoutHeightAtCapWidth", () => { + // True by signature — computeReadoutWidthCap never takes that parameter + // at all — asserted directly as a regression guard (FR-016). + const sizes = OCCUPANT_SIZE_SAMPLES.typicalReadout; + const capA = computeReadoutWidthCap(NARROWEST_PORTRAIT, NO_RESERVED_RECTS, sizes); + const capB = computeReadoutWidthCap(NARROWEST_PORTRAIT, NO_RESERVED_RECTS, sizes); + expect(capB).toBe(capA); + }); + + it('two calls with identical arguments, including the same readoutHeightAtCapWidth, are deep-equal (statelessness)', () => { + for (const [, box, reservedRects] of PINNED_VIEWPORTS) { + const sizes = OCCUPANT_SIZE_SAMPLES.typicalReadout; + const first = computeTopStripLayout(box, reservedRects, sizes, 48); + const second = computeTopStripLayout(box, reservedRects, sizes, 48); + expect(second).toEqual(first); + } + }); + + it('a deliberately wrong achieved band height only ever reaches the readout own rect.height/capped/maxLines', () => { + for (const [, box, reservedRects] of PINNED_VIEWPORTS) { + const sizes = OCCUPANT_SIZE_SAMPLES.typicalReadout; + const growthAllowance = box.height / 3; + const correct = computeTopStripLayout(box, reservedRects, sizes, READOUT_TYPICAL.height); + const wrong = computeTopStripLayout(box, reservedRects, sizes, growthAllowance + 10_000); + expect(wrong.muteButton).toEqual(correct.muteButton); + expect(wrong.themePicker).toEqual(correct.themePicker); + expect(wrong.readout!.rect.x).toBe(correct.readout!.rect.x); + expect(wrong.readout!.rect.y).toBe(correct.readout!.rect.y); + expect(wrong.readout!.rect.width).toBe(correct.readout!.rect.width); + } + }); +}); + +describe('computeTopStripLayout — a grown readout never flips the collapse decision (US2, FR-012a, FR-015, AC6)', () => { + it('themePicker.collapsed stays fixed across readout heights from one line to the tallest permitted, at a borderline viewport', () => { + // typicalReadout at PORTRAIT_360 is borderline: naturalSum (346) exceeds + // usableWidth (344) by only 2px — exactly where a height-dependent + // regression would flip the decision if one crept back in. + const sizes = OCCUPANT_SIZE_SAMPLES.typicalReadout; + const growthAllowance = PORTRAIT_360.height / 3; + const heights = [READOUT_TYPICAL.height, heightForWidth(READOUT_TYPICAL, 1), growthAllowance]; + const decisions = heights.map( + (h) => computeTopStripLayout(PORTRAIT_360, NO_RESERVED_RECTS, sizes, h).themePicker?.collapsed + ); + for (const decision of decisions) { + expect(decision).toBe(decisions[0]); + } + }); +}); + +describe('computeTopStripLayout — 012 properties still hold with a grown readout (US2, FR-007, FR-008, FR-009, FR-014)', () => { + for (const [label, box, reservedRects] of PINNED_VIEWPORTS) { + it(`no intersection, full containment, and reserved-region clearance hold at the tallest permitted readout height (${label})`, () => { + const sizes = OCCUPANT_SIZE_SAMPLES.typicalReadout; + const growthAllowance = box.height / 3; + const layout = computeTopStripLayout(box, reservedRects, sizes, growthAllowance); + const rects = collectRects(layout); + for (let i = 0; i < rects.length; i++) { + for (let j = i + 1; j < rects.length; j++) { + expect(rectsIntersect(rects[i], rects[j])).toBe(false); + } + } + for (const rect of rects) { + expect(rectFullyInside(rect, box)).toBe(true); + for (const reserved of reservedRects) { + expect(rectsIntersect(rect, reserved)).toBe(false); + } + } + }); + } +}); + describe('computeTopStripLayout — theme count scaling (US3, FR-012, FR-014, SC-009)', () => { // READOUT_TITLE_WIDE forces the collapse decision at every one of these // theme counts (the point of this describe block: the collapsed form's From 95054a1285f0ec03a7b0522471ee1a1a13f4d6e7 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:57:10 +0000 Subject: [PATCH 07/16] implement: T016-T018 generalize capped to any shrunk occupant (User Story 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme picker's collapsed control gains the same single-line elision (overflow/text-overflow/nowrap) and aria-label-when-capped fallback the readout already has, reading the same generic `capped` flag computeTopStripLayout already produces for every occupant — no new mechanism, no theme id or per-count branch. Adds test coverage: an oversized collapsed picker is contained and flagged capped; capped stays false across every sampled theme count where the shared collapsed size fits; and a wider collapsed sample alone (no code change) demonstrates the same policy covers a future long display name. --- specs/013-readout-overflow-policy/tasks.md | 6 +-- src/App.svelte | 9 ++++- tests/lib/layout/topStrip.test.ts | 47 ++++++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/specs/013-readout-overflow-policy/tasks.md b/specs/013-readout-overflow-policy/tasks.md index 44b1869..b215e54 100644 --- a/specs/013-readout-overflow-policy/tasks.md +++ b/specs/013-readout-overflow-policy/tasks.md @@ -290,14 +290,14 @@ own flag (spec.md User Story 3, Acceptance Scenarios 1-4). ### Tests for User Story 3 -- [ ] T016 [P] [US3] In `tests/lib/layout/topStrip.test.ts`, add the SC-009 +- [X] T016 [P] [US3] In `tests/lib/layout/topStrip.test.ts`, add the SC-009 generic-capped assertion: at `NARROWEST_PORTRAIT` with `THEME_PICKER_SAMPLES.longThemeName`'s collapsed `Size` widened further than the space the other occupants leave for it, assert the returned `themePicker.rect` is fully inside `availableBox` and `themePicker.capped` is `true` — the same `capped` field the readout uses, not a second mechanism (data-model.md's "Capped Occupant" is not a hard-coded list). -- [ ] T017 [US3] In `tests/lib/layout/topStrip.test.ts`, extend the existing +- [X] T017 [US3] In `tests/lib/layout/topStrip.test.ts`, extend the existing theme-count-scaling describe block (`THEME_PICKER_SAMPLES`, including `longThemeName`) with a `capped` assertion at `NARROWEST_PORTRAIT`, confirming the mechanism generalizes across theme counts with no @@ -307,7 +307,7 @@ own flag (spec.md User Story 3, Acceptance Scenarios 1-4). ### Implementation for User Story 3 -- [ ] T018 [US3] In `src/App.svelte`, add `overflow: hidden; text-overflow: +- [X] T018 [US3] In `src/App.svelte`, add `overflow: hidden; text-overflow: ellipsis; white-space: nowrap;` to the `.theme-collapsed` rule (single-line elision, distinct from the readout's multi-line clamp per research.md — the collapsed control is always exactly one line) and set an `aria-label` diff --git a/src/App.svelte b/src/App.svelte index 9ea97bc..12f6dd7 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -541,6 +541,7 @@ onclick={() => selectTheme(cycleThemeId(activeThemeId, listThemes().map((t) => t.id)))} style="left:{topStripLayout.themePicker.rect.x}px; top:{topStripLayout.themePicker.rect.y}px; width:{topStripLayout .themePicker.rect.width}px; height:{topStripLayout.themePicker.rect.height}px;" + aria-label={topStripLayout.themePicker.capped ? theme.displayName : undefined} > {theme.displayName} @@ -716,8 +717,14 @@ .theme-collapsed { /* FR-012: the single cycle control replacing the theme-button row when it does not fit at natural size — positioned the same way, by - topStripLayout.themePicker.rect's inline style below. */ + topStripLayout.themePicker.rect's inline style below. Single-line + elision (overflow/text-overflow/nowrap), distinct from the readout's + multi-line clamp: the collapsed control is always exactly one line + (research.md). */ position: fixed; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .touch-controls { diff --git a/tests/lib/layout/topStrip.test.ts b/tests/lib/layout/topStrip.test.ts index f9a0983..08bc615 100644 --- a/tests/lib/layout/topStrip.test.ts +++ b/tests/lib/layout/topStrip.test.ts @@ -517,6 +517,53 @@ describe('computeTopStripLayout — theme count scaling (US3, FR-012, FR-014, SC expect(width).toBe(widths[0]); } }); + + it("the collapsed form's capped flag reflects whether it fits, not the theme count (US3, SC-009)", () => { + // The shared THEME_PICKER_COLLAPSED size fits at every sampled count — + // capped is false here regardless of how many themes are registered, no + // per-count branch involved. + for (const [, sizes] of themeCountSamples) { + const layout = computeTopStripLayout(NARROWEST_PORTRAIT, NO_RESERVED_RECTS, sizes); + expect(layout.themePicker!.capped).toBe(false); + } + }); + + it('a theme display name wide enough to overflow even the collapsed form is capped (US3 AC4)', () => { + // Only a wider sample is needed to demonstrate this generalizes to a + // future theme's display name — no change to + // src/lib/layout/topStrip.ts (User Story 3 AC4). + const widerCollapsedPicker: NonNullable = { + expanded: THEME_PICKER_SAMPLES.longThemeName.expanded, + collapsed: { width: 500, height: THEME_PICKER_COLLAPSED.height }, + }; + const sizes: TopStripOccupantSizes = { + readout: READOUT_TITLE_WIDE, + muteButton: MUTE_BUTTON, + themePicker: widerCollapsedPicker, + }; + const layout = computeTopStripLayout(NARROWEST_PORTRAIT, NO_RESERVED_RECTS, sizes); + expect(layout.themePicker!.collapsed).toBe(true); + expect(layout.themePicker!.capped).toBe(true); + expect(rectFullyInside(layout.themePicker!.rect, NARROWEST_PORTRAIT)).toBe(true); + }); +}); + +describe('computeTopStripLayout — any occupant that has to shrink is flagged capped (US3, SC-009)', () => { + it('an oversized collapsed theme-picker is contained and flagged capped, the same way the readout is', () => { + const oversizedCollapsedPicker: NonNullable = { + expanded: { width: 420, height: 32 }, + collapsed: { width: 500, height: 32 }, + }; + const sizes: TopStripOccupantSizes = { + readout: READOUT_TYPICAL, + muteButton: MUTE_BUTTON, + themePicker: oversizedCollapsedPicker, + }; + const layout = computeTopStripLayout(NARROWEST_PORTRAIT, NO_RESERVED_RECTS, sizes); + expect(layout.themePicker!.collapsed).toBe(true); + expect(rectFullyInside(layout.themePicker!.rect, NARROWEST_PORTRAIT)).toBe(true); + expect(layout.themePicker!.capped).toBe(true); + }); }); describe('computeTopStripLayout — freed space with no theme picker (US3, Edge Cases: "One registered theme")', () => { From 298382893884814a10424d3be79ddc02a2423007 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:58:29 +0000 Subject: [PATCH 08/16] implement: T019-T020 pin the shipped regression and record the standing check Adds a node-only test that pins the readout's placed height to its unwrapped natural height (today's shipped bug) and asserts it fails the FR-004 fit assertion at 320px and 360px in both orientations, on the existing runner with no browser. Records a new "Top-strip content never renders outside its box (013, #43)" item in docs/manual-verification.md's Standing checks section, alongside 012's overlap item, instructing the maintainer to re-check on the narrowest real device whenever the top-strip markup/CSS or layout module changes. Spec 012's own spec.md is untouched. --- docs/manual-verification.md | 10 ++++++ specs/013-readout-overflow-policy/tasks.md | 4 +-- tests/lib/layout/topStrip.test.ts | 40 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/docs/manual-verification.md b/docs/manual-verification.md index 8e4e5c6..3b33f52 100644 --- a/docs/manual-verification.md +++ b/docs/manual-verification.md @@ -52,6 +52,16 @@ reaching every theme in turn. The readout's sub-380px wrap (#43) is out of scope of this device: at the Pixel's 412px the band is already sized for two lines and nothing spills. +### Top-strip content never renders outside its box (013, `#43`) + +On the narrowest real device to hand, in both portrait and landscape, confirm +that the status readout, the mute control, and the theme picker (expanded or +collapsed) never render any part of their text or content outside their own +dark background — no white text directly on the cave, at any width down to +320 CSS px. Re-run against any change that touches `src/App.svelte`'s +top-strip markup/CSS or `src/lib/layout/topStrip.ts`, not just once at this +spec's review. + --- ## 008 — Synthesized sound, per theme, always mutable diff --git a/specs/013-readout-overflow-policy/tasks.md b/specs/013-readout-overflow-policy/tasks.md index b215e54..c3e0415 100644 --- a/specs/013-readout-overflow-policy/tasks.md +++ b/specs/013-readout-overflow-policy/tasks.md @@ -337,7 +337,7 @@ Acceptance Scenario 1). ### Tests for User Story 4 -- [ ] T019 [US4] In `tests/lib/layout/topStrip.test.ts`, add the FR-021/ +- [X] T019 [US4] In `tests/lib/layout/topStrip.test.ts`, add the FR-021/ SC-007 deliberate-regression test: a small test-local wrapper around `computeTopStripLayout`'s result that overwrites `readout.rect.height` with `sizes.readout.height` (the natural, unwrapped height) regardless of @@ -348,7 +348,7 @@ Acceptance Scenario 1). ### Documentation for User Story 4 -- [ ] T020 [P] [US4] In `docs/manual-verification.md`, add a new item to the +- [X] T020 [P] [US4] In `docs/manual-verification.md`, add a new item to the existing `## Standing checks` section (alongside the "Top-strip controls never overlap (012, `#35`)" entry), instructing the maintainer to confirm on the narrowest real device to hand, in both orientations, that no diff --git a/tests/lib/layout/topStrip.test.ts b/tests/lib/layout/topStrip.test.ts index 08bc615..30a6147 100644 --- a/tests/lib/layout/topStrip.test.ts +++ b/tests/lib/layout/topStrip.test.ts @@ -605,3 +605,43 @@ describe('computeTopStripLayout — the suite catches a real regression (US4, SC expect(rectsIntersect(brokenMuteButton, layout.readout!.rect)).toBe(true); }); }); + +describe('computeTopStripLayout — the suite catches the shipped regression (US4, FR-021, SC-007)', () => { + // A deliberate regression, local to this test only: pins the readout's + // placed height to its unwrapped natural height regardless of the width + // it was actually given — exactly today's shipped bug (spec.md User Story + // 4) — never a change to computeTopStripLayout itself. + function pinnedToUnwrappedNaturalHeight( + layout: ReturnType, + sizes: TopStripOccupantSizes + ): ReturnType { + if (!layout.readout || !sizes.readout) return layout; + return { ...layout, readout: { ...layout.readout, rect: { ...layout.readout.rect, height: sizes.readout.height } } }; + } + + it("pinning the readout's placed height to its unwrapped natural height fails the FR-004 fit assertion at 360px and 320px, both orientations", () => { + const sizes = OCCUPANT_SIZE_SAMPLES.titleWideReadout; + const viewports: [string, InsetBox, readonly Rect[]][] = [ + ['320 portrait', NARROWEST_PORTRAIT, PORTRAIT_RESERVED_RECTS], + ['320 landscape', NARROWEST_LANDSCAPE, LANDSCAPE_RESERVED_RECTS], + ['360 portrait', PORTRAIT_360, PORTRAIT_360_RESERVED_RECTS], + ['360 landscape', LANDSCAPE_360, LANDSCAPE_360_RESERVED_RECTS], + ]; + for (const [, box, reservedRects] of viewports) { + const capWidth = computeReadoutWidthCap(box, reservedRects, sizes); + const heightAtCap = heightForWidth(READOUT_TITLE_WIDE, capWidth); + const growthAllowance = box.height / 3; + const required = Math.min(heightAtCap, growthAllowance); + const correct = computeTopStripLayout(box, reservedRects, sizes, heightAtCap); + const broken = pinnedToUnwrappedNaturalHeight(correct, sizes); + // Sanity: this viewport actually needs more than one line, and the + // correct (T008) arithmetic satisfies it — otherwise this case would + // not exercise the regression at all. + expect(heightAtCap).toBeGreaterThan(READOUT_TITLE_WIDE.height); + expect(correct.readout!.rect.height).toBeGreaterThanOrEqual(required - 1e-9); + // The regression: pinning to the unwrapped natural height fails FR-004 + // whenever the capped width needs more than that one line. + expect(broken.readout!.rect.height).toBeLessThan(required); + } + }); +}); From c4c5930bc4b77b1663decb35de3ed27bc2aaa61d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:59:02 +0000 Subject: [PATCH 09/16] implement: T021-T022 confirm full suite and diff scope (Polish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm test (build + full vitest suite) passes: 689 tests across 49 files, including the single-file build check. Diff against main touches only src/lib/layout/topStrip.ts, src/App.svelte, tests/lib/layout/topStrip.test.ts, and docs/manual-verification.md (plus this feature's own spec artifacts) — no src/sim/ file, no cave data, no theme data file, no theme id in topStrip.ts or its App.svelte wiring, and no viewport width hard-coded outside the test file. --- specs/013-readout-overflow-policy/tasks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/013-readout-overflow-policy/tasks.md b/specs/013-readout-overflow-policy/tasks.md index c3e0415..98eecbb 100644 --- a/specs/013-readout-overflow-policy/tasks.md +++ b/specs/013-readout-overflow-policy/tasks.md @@ -370,12 +370,12 @@ recorded for future specs to re-run. **Purpose**: Confirm the feature ships clean, per FR-019 and the plan's Constitution Check. -- [ ] T021 [P] Run `npm test` (builds `dist/`, then the full `vitest` suite) +- [X] T021 [P] Run `npm test` (builds `dist/`, then the full `vitest` suite) and confirm every test from features 001-012 still passes unchanged alongside the new/extended `tests/lib/layout/topStrip.test.ts` cases, and that `dist/` still holds exactly one self-contained `index.html` (FR-019, SC-008). -- [ ] T022 [P] Review the full diff against `main` and confirm it touches no +- [X] T022 [P] Review the full diff against `main` and confirm it touches no file under `src/sim/`, no cave data file, and no theme data file — no theme id appears in `src/lib/layout/topStrip.ts` or its `App.svelte` wiring, and no viewport width is hard-coded outside the test file — only From 4acad15d2af891b786b464beee9adeba7633a4ee Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:59:11 +0000 Subject: [PATCH 10/16] implement: mark 013-readout-overflow-policy stage/iteration --- specs/013-readout-overflow-policy/spec-meta.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/013-readout-overflow-policy/spec-meta.json b/specs/013-readout-overflow-policy/spec-meta.json index c59a80e..e304b36 100644 --- a/specs/013-readout-overflow-policy/spec-meta.json +++ b/specs/013-readout-overflow-policy/spec-meta.json @@ -2,7 +2,7 @@ "issue": 43, "spec_dir": "specs/013-readout-overflow-policy", "feature_num": "013", - "stage": "tasks", - "iteration": 0, + "stage": "implement", + "iteration": 1, "spec_branch": "spec/013-readout-overflow-policy" } From fe2fc962d19b31ddc67a312b1aa6eccff122115d Mon Sep 17 00:00:00 2001 From: "wing-commander-bot[bot]" Date: Sat, 5 Sep 2026 14:02:36 +0000 Subject: [PATCH 11/16] finalize: 013-readout-overflow-policy -> review (#43) --- specs/013-readout-overflow-policy/spec-meta.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/specs/013-readout-overflow-policy/spec-meta.json b/specs/013-readout-overflow-policy/spec-meta.json index e304b36..c10a73a 100644 --- a/specs/013-readout-overflow-policy/spec-meta.json +++ b/specs/013-readout-overflow-policy/spec-meta.json @@ -2,7 +2,8 @@ "issue": 43, "spec_dir": "specs/013-readout-overflow-policy", "feature_num": "013", - "stage": "implement", + "stage": "review", "iteration": 1, - "spec_branch": "spec/013-readout-overflow-policy" + "spec_branch": "spec/013-readout-overflow-policy", + "pending_re_review_from": [] } From 8509c4cf820460d2a663a32800048f389d4cd595 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:38:34 +0000 Subject: [PATCH 12/16] =?UTF-8?q?fold(leg-0):=20Maintainer=20found=20that?= =?UTF-8?q?=20`src/App.svelte`'s=20second=20height-measurement=20pass=20(`?= =?UTF-8?q?readoutHeightAtCapWidth`,=20lines=20369-374)=20does=20not=20re-?= =?UTF-8?q?run=20when=20the=20readout's=20width=20cap=20narrows=20solely?= =?UTF-8?q?=20because=20`reservedRects`=20changes=20(via=20`touchLayout`/`?= =?UTF-8?q?controlsVisible`/`lastInputSource`/`session.screen`),=20so=20`c?= =?UTF-8?q?omputeTopStripLayout`=20can=20be=20handed=20a=20width=20from=20?= =?UTF-8?q?one=20measurement=20and=20a=20height=20measured=20at=20a=20diff?= =?UTF-8?q?erent,=20stale=20width=20=E2=80=94=20reopening=20exactly=20the?= =?UTF-8?q?=20'hidden=20value=20with=20no=20indication'=20failure=20that?= =?UTF-8?q?=20FR-011/FR-016b=20exist=20to=20prevent=20(concrete=20repro:?= =?UTF-8?q?=20pausing=20on=20a=20touch-capable=20device=20used=20with=20a?= =?UTF-8?q?=20keyboard,=20then=20tapping=20mid-pause=20narrows=20the=20cap?= =?UTF-8?q?=20while=20`hudText`=20is=20frozen,=20so=20the=20readout=20sile?= =?UTF-8?q?ntly=20clips=20for=20the=20rest=20of=20the=20pause).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/013-readout-overflow-policy/spec-meta.json | 4 ++-- specs/013-readout-overflow-policy/tasks.md | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/specs/013-readout-overflow-policy/spec-meta.json b/specs/013-readout-overflow-policy/spec-meta.json index c10a73a..6dced5f 100644 --- a/specs/013-readout-overflow-policy/spec-meta.json +++ b/specs/013-readout-overflow-policy/spec-meta.json @@ -2,8 +2,8 @@ "issue": 43, "spec_dir": "specs/013-readout-overflow-policy", "feature_num": "013", - "stage": "review", + "stage": "implement", "iteration": 1, "spec_branch": "spec/013-readout-overflow-policy", - "pending_re_review_from": [] + "pending_re_review_from": ["charlesguse"] } diff --git a/specs/013-readout-overflow-policy/tasks.md b/specs/013-readout-overflow-policy/tasks.md index 98eecbb..17ee531 100644 --- a/specs/013-readout-overflow-policy/tasks.md +++ b/specs/013-readout-overflow-policy/tasks.md @@ -492,3 +492,8 @@ content it will actually hold, at every pinned width, with 412 px and desktop unchanged. Stories 2-4 harden that same implementation with test coverage and one small elision extension — genuinely valuable, but the defect in issue #43 is closed once User Story 1 ships. + +## Maintainer Feedback + +- [ ] T023 In `src/App.svelte`, fix `readoutHeightAtCapWidth`'s `$derived.by` (around lines 369-374) so it re-runs whenever any input that can change `readoutWidthCap` changes — today it tracks `topStripProbeTick`, `theme.displayName`, and `hudText`, but not `insetBox` or `touchLayout`/`reservedRects` (which flips via `controlsVisible`, driven by `lastInputSource`/`session.screen`). Per the maintainer's finding, do not simply read `readoutWidthCap` inside the `$derived` unless the probe element's `width` style is guaranteed to have flushed before the derivation runs — an `$effect`-based measurement that makes that ordering explicit may be the correct shape. Keep the DOM measurement pass count at FR-016b's fixed two (natural sizes, then height at the capped width) — no third pass, no per-frame recomputation. Fixes the case where `computeTopStripLayout` is handed a new, narrower width and an old, too-short height, causing `-webkit-line-clamp` to silently cut a readout value with no indication (violates FR-011, FR-016b). +- [ ] T024 [P] In `tests/lib/layout/topStrip.test.ts` (or noted as unreachable without a browser), add coverage proving the second measurement pass reflects a `reservedRects`-only change with every other height-affecting input held fixed — the maintainer's repro is a touch-capable device paused via keyboard (`controlsVisible` false), then a tap flips `controlsVisible` true mid-pause while `hudText` stays frozen, narrowing the cap. If no node test can reach this (the bug lives in shell wiring, not the pure rule), add a comment at the `readoutHeightAtCapWidth` derivation site in `src/App.svelte` naming all four trigger inputs (`insetBox`, `touchLayout`/`reservedRects`, `hudText`, `theme.displayName`) so a future change adding a fifth cap input sees the obligation stated rather than left as a call-site convention. From ac08e55d358167a795cc265ba2477e4901193014 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:39:46 +0000 Subject: [PATCH 13/16] =?UTF-8?q?fold(leg-1):=20Maintainer=20flagged=20(as?= =?UTF-8?q?=20a=20minor,=20non-blocking=20note)=20that=20`maxLines=20=3D?= =?UTF-8?q?=20Math.max(1,=20Math.floor(growthAllowance=20/=20sizes.readout?= =?UTF-8?q?.height))`=20in=20`src/lib/layout/topStrip.ts`=20evaluates=20to?= =?UTF-8?q?=20`Infinity`=20when=20an=20occupant's=20natural=20height=20is?= =?UTF-8?q?=20measured=20as=20zero,=20producing=20an=20invalid=20`-webkit-?= =?UTF-8?q?line-clamp:=20Infinity`=20declaration=20the=20browser=20drops?= =?UTF-8?q?=20=E2=80=94=20containment=20still=20holds=20via=20the=20fixed?= =?UTF-8?q?=20height=20and=20`overflow:=20hidden`,=20so=20this=20is=20a=20?= =?UTF-8?q?CSS-hygiene=20gap=20rather=20than=20a=20spill=20risk,=20but=20i?= =?UTF-8?q?t's=20the=20one=20place=20the=20spec's=20'zero/unavailable=20te?= =?UTF-8?q?xt=20metrics'=20edge=20case=20isn't=20guarded=20the=20way=20the?= =?UTF-8?q?=20height=20fallback=20already=20is.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/013-readout-overflow-policy/tasks.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/specs/013-readout-overflow-policy/tasks.md b/specs/013-readout-overflow-policy/tasks.md index 17ee531..a158f9c 100644 --- a/specs/013-readout-overflow-policy/tasks.md +++ b/specs/013-readout-overflow-policy/tasks.md @@ -497,3 +497,7 @@ defect in issue #43 is closed once User Story 1 ships. - [ ] T023 In `src/App.svelte`, fix `readoutHeightAtCapWidth`'s `$derived.by` (around lines 369-374) so it re-runs whenever any input that can change `readoutWidthCap` changes — today it tracks `topStripProbeTick`, `theme.displayName`, and `hudText`, but not `insetBox` or `touchLayout`/`reservedRects` (which flips via `controlsVisible`, driven by `lastInputSource`/`session.screen`). Per the maintainer's finding, do not simply read `readoutWidthCap` inside the `$derived` unless the probe element's `width` style is guaranteed to have flushed before the derivation runs — an `$effect`-based measurement that makes that ordering explicit may be the correct shape. Keep the DOM measurement pass count at FR-016b's fixed two (natural sizes, then height at the capped width) — no third pass, no per-frame recomputation. Fixes the case where `computeTopStripLayout` is handed a new, narrower width and an old, too-short height, causing `-webkit-line-clamp` to silently cut a readout value with no indication (violates FR-011, FR-016b). - [ ] T024 [P] In `tests/lib/layout/topStrip.test.ts` (or noted as unreachable without a browser), add coverage proving the second measurement pass reflects a `reservedRects`-only change with every other height-affecting input held fixed — the maintainer's repro is a touch-capable device paused via keyboard (`controlsVisible` false), then a tap flips `controlsVisible` true mid-pause while `hudText` stays frozen, narrowing the cap. If no node test can reach this (the bug lives in shell wiring, not the pure rule), add a comment at the `readoutHeightAtCapWidth` derivation site in `src/App.svelte` naming all four trigger inputs (`insetBox`, `touchLayout`/`reservedRects`, `hudText`, `theme.displayName`) so a future change adding a fifth cap input sees the obligation stated rather than left as a call-site convention. + +## Maintainer Feedback + +- [ ] T025 [P] In `src/lib/layout/topStrip.ts`, guard `maxLines = Math.max(1, Math.floor(growthAllowance / sizes.readout.height))` against a zero or unavailable natural height (the spec's Edge Case "Text metrics that are unavailable or report zero") so it cannot evaluate to `Infinity` — `-webkit-line-clamp: Infinity` is an invalid CSS declaration that the browser silently drops. Containment already holds via the fixed height and `overflow: hidden` (belt-and-braces), so this closes the one remaining un-guarded division to match the same edge case's existing height fallback (`readoutHeightAtCapWidth ?? natural`). From e4a34c1e9ca8feab1b749e07feb3e15ebd88bcee Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:43:26 +0000 Subject: [PATCH 14/16] implement: T023-T025 fix stale capped-width measurement, guard maxLines Infinity T023: readoutHeightAtCapWidth is now an $effect-backed state instead of a $derived.by, so it re-runs after the DOM reflects a new readoutWidthCap (insetBox or touchLayout/reservedRects changes), not just on the resize/orientationchange tick. T024: environment is node-only (no jsdom/browser), so shell-wiring DOM ordering is unreachable from vitest; documented the four trigger inputs at the derivation site instead. T025: maxLines falls back to 1 when the readout's natural height is zero or unavailable, instead of dividing by it and producing Infinity. --- specs/013-readout-overflow-policy/tasks.md | 6 ++--- src/App.svelte | 27 ++++++++++++++++------ src/lib/layout/topStrip.ts | 8 ++++++- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/specs/013-readout-overflow-policy/tasks.md b/specs/013-readout-overflow-policy/tasks.md index a158f9c..c22c83e 100644 --- a/specs/013-readout-overflow-policy/tasks.md +++ b/specs/013-readout-overflow-policy/tasks.md @@ -495,9 +495,9 @@ defect in issue #43 is closed once User Story 1 ships. ## Maintainer Feedback -- [ ] T023 In `src/App.svelte`, fix `readoutHeightAtCapWidth`'s `$derived.by` (around lines 369-374) so it re-runs whenever any input that can change `readoutWidthCap` changes — today it tracks `topStripProbeTick`, `theme.displayName`, and `hudText`, but not `insetBox` or `touchLayout`/`reservedRects` (which flips via `controlsVisible`, driven by `lastInputSource`/`session.screen`). Per the maintainer's finding, do not simply read `readoutWidthCap` inside the `$derived` unless the probe element's `width` style is guaranteed to have flushed before the derivation runs — an `$effect`-based measurement that makes that ordering explicit may be the correct shape. Keep the DOM measurement pass count at FR-016b's fixed two (natural sizes, then height at the capped width) — no third pass, no per-frame recomputation. Fixes the case where `computeTopStripLayout` is handed a new, narrower width and an old, too-short height, causing `-webkit-line-clamp` to silently cut a readout value with no indication (violates FR-011, FR-016b). -- [ ] T024 [P] In `tests/lib/layout/topStrip.test.ts` (or noted as unreachable without a browser), add coverage proving the second measurement pass reflects a `reservedRects`-only change with every other height-affecting input held fixed — the maintainer's repro is a touch-capable device paused via keyboard (`controlsVisible` false), then a tap flips `controlsVisible` true mid-pause while `hudText` stays frozen, narrowing the cap. If no node test can reach this (the bug lives in shell wiring, not the pure rule), add a comment at the `readoutHeightAtCapWidth` derivation site in `src/App.svelte` naming all four trigger inputs (`insetBox`, `touchLayout`/`reservedRects`, `hudText`, `theme.displayName`) so a future change adding a fifth cap input sees the obligation stated rather than left as a call-site convention. +- [X] T023 In `src/App.svelte`, fix `readoutHeightAtCapWidth`'s `$derived.by` (around lines 369-374) so it re-runs whenever any input that can change `readoutWidthCap` changes — today it tracks `topStripProbeTick`, `theme.displayName`, and `hudText`, but not `insetBox` or `touchLayout`/`reservedRects` (which flips via `controlsVisible`, driven by `lastInputSource`/`session.screen`). Per the maintainer's finding, do not simply read `readoutWidthCap` inside the `$derived` unless the probe element's `width` style is guaranteed to have flushed before the derivation runs — an `$effect`-based measurement that makes that ordering explicit may be the correct shape. Keep the DOM measurement pass count at FR-016b's fixed two (natural sizes, then height at the capped width) — no third pass, no per-frame recomputation. Fixes the case where `computeTopStripLayout` is handed a new, narrower width and an old, too-short height, causing `-webkit-line-clamp` to silently cut a readout value with no indication (violates FR-011, FR-016b). +- [X] T024 [P] In `tests/lib/layout/topStrip.test.ts` (or noted as unreachable without a browser), add coverage proving the second measurement pass reflects a `reservedRects`-only change with every other height-affecting input held fixed — the maintainer's repro is a touch-capable device paused via keyboard (`controlsVisible` false), then a tap flips `controlsVisible` true mid-pause while `hudText` stays frozen, narrowing the cap. If no node test can reach this (the bug lives in shell wiring, not the pure rule), add a comment at the `readoutHeightAtCapWidth` derivation site in `src/App.svelte` naming all four trigger inputs (`insetBox`, `touchLayout`/`reservedRects`, `hudText`, `theme.displayName`) so a future change adding a fifth cap input sees the obligation stated rather than left as a call-site convention. ## Maintainer Feedback -- [ ] T025 [P] In `src/lib/layout/topStrip.ts`, guard `maxLines = Math.max(1, Math.floor(growthAllowance / sizes.readout.height))` against a zero or unavailable natural height (the spec's Edge Case "Text metrics that are unavailable or report zero") so it cannot evaluate to `Infinity` — `-webkit-line-clamp: Infinity` is an invalid CSS declaration that the browser silently drops. Containment already holds via the fixed height and `overflow: hidden` (belt-and-braces), so this closes the one remaining un-guarded division to match the same edge case's existing height fallback (`readoutHeightAtCapWidth ?? natural`). +- [X] T025 [P] In `src/lib/layout/topStrip.ts`, guard `maxLines = Math.max(1, Math.floor(growthAllowance / sizes.readout.height))` against a zero or unavailable natural height (the spec's Edge Case "Text metrics that are unavailable or report zero") so it cannot evaluate to `Infinity` — `-webkit-line-clamp: Infinity` is an invalid CSS declaration that the browser silently drops. Containment already holds via the fixed height and `overflow: hidden` (belt-and-braces), so this closes the one remaining un-guarded division to match the same edge case's existing height fallback (`readoutHeightAtCapWidth ?? natural`). diff --git a/src/App.svelte b/src/App.svelte index 12f6dd7..9b8085e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -362,15 +362,28 @@ }); // The shell's second-pass measurement (FR-016b): the readout's real - // wrapped height at exactly readoutWidthCap, re-read on the same triggers - // topStripSizes already uses. undefined before topStripSizes/hudText exist - // yet, or while no readout is shown — computeTopStripLayout falls back to - // the natural single-line height in that case (Edge Cases). - let readoutHeightAtCapWidth = $derived.by(() => { + // wrapped height at exactly readoutWidthCap. Must re-run on every input + // that can change that cap width, or a stale height reaches + // computeTopStripLayout while -webkit-line-clamp silently cuts the text + // with no indication (FR-011). Four trigger inputs, all read below or via + // readoutWidthCap's own dependencies: insetBox, touchLayout/reservedRects + // (flips via controlsVisible/lastInputSource/session.screen), hudText, and + // theme.displayName. A future change adding a fifth cap input must extend + // this list, not rely on call-site convention. + // + // This is an $effect rather than a $derived: $effect is documented to run + // only after the DOM has been updated, so by the time it reads + // getBoundingClientRect() the probe's width style (also driven by + // readoutWidthCap) is guaranteed to already reflect the new value — a + // $derived reading readoutWidthCap directly would race the same DOM + // update with no such guarantee. + let readoutHeightAtCapWidth: number | undefined = $state(undefined); + $effect(() => { topStripProbeTick; void theme.displayName; - if (hudText === undefined || !readoutCappedProbeEl) return undefined; - return readoutCappedProbeEl.getBoundingClientRect().height; + void readoutWidthCap; + readoutHeightAtCapWidth = + hudText === undefined || !readoutCappedProbeEl ? undefined : readoutCappedProbeEl.getBoundingClientRect().height; }); // FR-017: recomputed only when insetBox, the touch layout's reservedRects, diff --git a/src/lib/layout/topStrip.ts b/src/lib/layout/topStrip.ts index f096a96..a7d0bc6 100644 --- a/src/lib/layout/topStrip.ts +++ b/src/lib/layout/topStrip.ts @@ -202,7 +202,13 @@ export function computeTopStripLayout( // to (but never beyond) growthAllowance. const contentHeight = sizes.readout ? (readoutHeightAtCapWidth ?? sizes.readout.height) : undefined; const readoutHeight = contentHeight !== undefined ? Math.min(contentHeight, growthAllowance) : undefined; - const maxLines = sizes.readout ? Math.max(1, Math.floor(growthAllowance / sizes.readout.height)) : 1; + // A zero or unavailable natural height (Edge Cases: "Text metrics that are + // unavailable or report zero") must not reach this division — dividing by + // zero yields Infinity, and `-webkit-line-clamp: Infinity` is an invalid + // CSS declaration the browser silently drops. Fall back to 1 line, the + // same single-line default the height fallback above uses. + const maxLines = + sizes.readout && sizes.readout.height > 0 ? Math.max(1, Math.floor(growthAllowance / sizes.readout.height)) : 1; const readoutRect: Rect | undefined = sizes.readout && readoutHeight !== undefined From 3be93f341467dc83dd30f1d463141893bd9f1f16 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:43:36 +0000 Subject: [PATCH 15/16] implement: mark 013-readout-overflow-policy stage/iteration --- specs/013-readout-overflow-policy/spec-meta.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/013-readout-overflow-policy/spec-meta.json b/specs/013-readout-overflow-policy/spec-meta.json index 6dced5f..280f31b 100644 --- a/specs/013-readout-overflow-policy/spec-meta.json +++ b/specs/013-readout-overflow-policy/spec-meta.json @@ -3,7 +3,7 @@ "spec_dir": "specs/013-readout-overflow-policy", "feature_num": "013", "stage": "implement", - "iteration": 1, + "iteration": 2, "spec_branch": "spec/013-readout-overflow-policy", "pending_re_review_from": ["charlesguse"] } From 61e78c194ad7a28142ee2f6651a7e5141c0a4933 Mon Sep 17 00:00:00 2001 From: "wing-commander-bot[bot]" Date: Sat, 5 Sep 2026 23:47:02 +0000 Subject: [PATCH 16/16] finalize: 013-readout-overflow-policy -> review (#43) --- specs/013-readout-overflow-policy/spec-meta.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/013-readout-overflow-policy/spec-meta.json b/specs/013-readout-overflow-policy/spec-meta.json index 280f31b..750f6af 100644 --- a/specs/013-readout-overflow-policy/spec-meta.json +++ b/specs/013-readout-overflow-policy/spec-meta.json @@ -2,8 +2,8 @@ "issue": 43, "spec_dir": "specs/013-readout-overflow-policy", "feature_num": "013", - "stage": "implement", + "stage": "review", "iteration": 2, "spec_branch": "spec/013-readout-overflow-policy", - "pending_re_review_from": ["charlesguse"] + "pending_re_review_from": [] }