Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e6a7b5d
plan: 013-readout-overflow-policy (#43)
github-actions[bot] Sep 5, 2026
344250e
tasks: 013-readout-overflow-policy (#43)
github-actions[bot] Sep 5, 2026
70496c9
implement: T001 extract computeReadoutWidthCap and new TopStripLayout…
github-actions[bot] Sep 5, 2026
1f8f3f1
implement: T002-T003 add 360px fixtures and height-for-width stand-in
github-actions[bot] Sep 5, 2026
52ae517
implement: T004-T011 grow-then-elide readout sizing (User Story 1)
github-actions[bot] Sep 5, 2026
a2ed0a2
implement: T012-T015 pin non-disturbance and idempotence (User Story 2)
github-actions[bot] Sep 5, 2026
95054a1
implement: T016-T018 generalize capped to any shrunk occupant (User S…
github-actions[bot] Sep 5, 2026
2983828
implement: T019-T020 pin the shipped regression and record the standi…
github-actions[bot] Sep 5, 2026
c4c5930
implement: T021-T022 confirm full suite and diff scope (Polish)
github-actions[bot] Sep 5, 2026
4acad15
implement: mark 013-readout-overflow-policy stage/iteration
github-actions[bot] Sep 5, 2026
fe2fc96
finalize: 013-readout-overflow-policy -> review (#43)
wing-commander-bot[bot] Sep 5, 2026
8509c4c
fold(leg-0): Maintainer found that `src/App.svelte`'s second height-m…
github-actions[bot] Sep 5, 2026
ac08e55
fold(leg-1): Maintainer flagged (as a minor, non-blocking note) that …
github-actions[bot] Sep 5, 2026
e4a34c1
implement: T023-T025 fix stale capped-width measurement, guard maxLin…
github-actions[bot] Sep 5, 2026
3be93f3
implement: mark 013-readout-overflow-policy stage/iteration
github-actions[bot] Sep 5, 2026
61e78c1
finalize: 013-readout-overflow-policy -> review (#43)
wing-commander-bot[bot] Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/manual-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions specs/013-readout-overflow-policy/contracts/topstrip-api.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading