Glass contrast: text on glass reaches 4.5:1 over any backdrop (static floor + adaptive density) (D365) #448

Open
opened 2026-11-09 06:53:11 +00:00 by kayg · 7 comments
Owner

Owner report 2026-11-09 (screenshot): in the #146 app-window mockup, the Send keys popover's glass over a light app window leaves its section labels ("Mac shortcuts", "Keys", "Function keys") and key hints ("⌘ Tab", "esc") faint grey on light grey. Owner asked whether contrast can be fixed dynamically. Related: D365 (4.5:1 for all text), D322/D338 (presets), D372, D375.

Orchestrator's approach, built in the shared glass material (packages/ui: glass-bed, overlay tokens, themes.ts):

  1. Static guarantee: every text role on every glass level reaches 4.5:1 against the glass over pure white and pure black plus the theme's extremes; audit test cases so a regression fails the gate.
  2. Adaptive density where the backdrop is known (terminal theme bg; app-window video sampled at low resolution, off the input path); cost measured; if measurable per frame, app windows keep only the static guarantee.

Job J414, branch glass-contrast-d365, base origin/dev 4754b14a7. Coordination: J304c extends the audit on themes-sync-j304; this job keeps its glass audit in its own module so the two merge cleanly.

Owner report 2026-11-09 (screenshot): in the #146 app-window mockup, the Send keys popover's glass over a light app window leaves its section labels ("Mac shortcuts", "Keys", "Function keys") and key hints ("⌘ Tab", "esc") faint grey on light grey. Owner asked whether contrast can be fixed dynamically. Related: D365 (4.5:1 for all text), D322/D338 (presets), D372, D375. Orchestrator's approach, built in the shared glass material (packages/ui: glass-bed, overlay tokens, themes.ts): 1. Static guarantee: every text role on every glass level reaches 4.5:1 against the glass over pure white and pure black plus the theme's extremes; audit test cases so a regression fails the gate. 2. Adaptive density where the backdrop is known (terminal theme bg; app-window video sampled at low resolution, off the input path); cost measured; if measurable per frame, app windows keep only the static guarantee. Job J414, branch `glass-contrast-d365`, base origin/dev 4754b14a7. Coordination: J304c extends the audit on `themes-sync-j304`; this job keeps its glass audit in its own module so the two merge cleanly.
Author
Owner

Start. Branch glass-contrast-d365, base origin/dev 4754b14a7. Plan: (1) packages/ui/src/glass-contrast.ts, an exact worst-case solver; (2) themes.ts writes per-theme glass rules; (3) red specs, then fix; (4) low-res backdrop sampler and its measured cost; (5) screenshots; (6) verify quick and the transfer budget. Estimate 4 h.

Findings so far (evidence: ~/.cache/codex-scratch/j414/evidence/table-*.txt on the control host, numbers below from dev-02 runs of the solver):

  • The two things this job depends on are not on origin/dev: theme-audit.ts / theme-audit.test.ts (only on themes-sync-j304) and the ?appmock=desktop&pop=keys mockup (only on appwin-mock-j209). So the glass audit lives in its own file (glass-contrast.test.ts) using its own luminance helpers and re-uses nothing from the J304 audit; they merge without touching each other. The mockup repro spec goes on a verify-only branch that merges appwin-mock-j209; the shared-material fix has no dependency on it.
  • A text colour's worst case over a black-to-white backdrop is not always an end: if its luminance falls inside the glass's own luminance range, some backdrop makes it vanish (1:1). Light text on thin dark glass is that case. So the solver is exact over the whole range, not just the two ends.
  • D160/D375: an overlay is never opaque unless the person chose Solid. The static floor therefore stops at 0.92 opacity and the text colours do the rest.
  • Today's tokens at the static (any backdrop) worst case, Tinted, Paper: fg 9.6, fg2 3.3, fg3 1.9, accent 3.0, ok 2.0; Ink: fg 5.3, fg2 1.9, fg3 1.0, ok 3.1. Frosted is below 2:1 for everything. The owner's screenshot is this: fg3/fg2 on a light backdrop.
  • Static floor needed: opacity 0.74 to 0.90 per theme (vs 0.2 to 0.78 today) at every thin level. So the any-backdrop guarantee flattens Tinted/Clear/Frosted into one density. That is why the adaptive layer exists: design choice (reversible, flag if you disagree): the themed set (terminal/canvas, known from the theme) keeps each level's approved opacity with D365-repaired text colours; any element with data-glass-under (a remote window's picture) takes the static set; a sampler can refine it.
**Start.** Branch `glass-contrast-d365`, base origin/dev 4754b14a7. Plan: (1) `packages/ui/src/glass-contrast.ts`, an exact worst-case solver; (2) `themes.ts` writes per-theme glass rules; (3) red specs, then fix; (4) low-res backdrop sampler and its measured cost; (5) screenshots; (6) `verify quick` and the transfer budget. Estimate 4 h. **Findings so far (evidence: `~/.cache/codex-scratch/j414/evidence/table-*.txt` on the control host, numbers below from dev-02 runs of the solver):** - The two things this job depends on are **not on origin/dev**: `theme-audit.ts` / `theme-audit.test.ts` (only on `themes-sync-j304`) and the `?appmock=desktop&pop=keys` mockup (only on `appwin-mock-j209`). So the glass audit lives in its own file (`glass-contrast.test.ts`) using its own luminance helpers and re-uses nothing from the J304 audit; they merge without touching each other. The mockup repro spec goes on a verify-only branch that merges `appwin-mock-j209`; the shared-material fix has no dependency on it. - A text colour's worst case over a black-to-white backdrop is not always an end: if its luminance falls inside the glass's own luminance range, some backdrop makes it vanish (1:1). Light text on thin dark glass is that case. So the solver is exact over the whole range, not just the two ends. - D160/D375: an overlay is never opaque unless the person chose Solid. The static floor therefore stops at 0.92 opacity and the text colours do the rest. - Today's tokens at the **static** (any backdrop) worst case, Tinted, Paper: fg 9.6, fg2 3.3, fg3 1.9, accent 3.0, ok 2.0; Ink: fg 5.3, fg2 1.9, fg3 1.0, ok 3.1. Frosted is below 2:1 for everything. The owner's screenshot is this: fg3/fg2 on a light backdrop. - Static floor needed: opacity 0.74 to 0.90 per theme (vs 0.2 to 0.78 today) at every thin level. So the any-backdrop guarantee flattens Tinted/Clear/Frosted into one density. That is why the adaptive layer exists: **design choice** (reversible, flag if you disagree): the themed set (terminal/canvas, known from the theme) keeps each level's approved opacity with D365-repaired text colours; any element with `data-glass-under` (a remote window's picture) takes the static set; a sampler can refine it.
Author
Owner

Progress 1. Pushed glass-contrast-d365 (head 239d182e9): solver + audit (122 unit cases pass on dev-02), themes.ts glass rules, sampler + cost spec. Browser before/after run is queued on dev-02 (position 8 of 8; compute01 is as long). Contrast table (exact worst case over black..white; evidence table-before-after.txt):

INK
level     opacity before->after |        fg        fg2        fg3  secondary     accent         ok     danger       warn
Tinted    0.70 -> 0.82         before |      5.31       1.90       1.00       3.44       2.40       3.11       2.49       4.05
                               after  |      8.13       4.55       4.60       5.27       4.56       4.76       4.53       6.20
Clear     0.40 -> 0.82         before |      2.11       1.00       1.00       1.36       1.00       1.23       1.00       1.61
                               after  |      8.13       4.55       4.60       5.27       4.56       4.76       4.53       6.20
Frosted   0.20 -> 0.82         before |      1.29       1.00       1.00       1.00       1.00       1.00       1.00       1.00
                               after  |      8.13       4.55       4.60       5.27       4.56       4.76       4.53       6.20
themed set (terminal/canvas backdrop of the theme), after:
Tinted    0.70 -> 0.70         after  |     11.30       4.53       4.53       7.32       5.10       6.62       5.29       8.62
Clear     0.40 -> 0.40         after  |      8.46       4.57       4.53       5.48       4.55       4.95       4.56       6.46
Frosted   0.20 -> 0.35         after  |      8.05       4.60       4.55       5.22       4.59       4.71       4.58       6.14

PAPER
level     opacity before->after |        fg        fg2        fg3  secondary     accent         ok     danger       warn
Tinted    0.78 -> 0.78         before |      9.63       3.32       1.89       5.22       2.98       2.00       3.24       3.26
                               after  |      9.63       4.59       4.59       5.22       4.63       4.53       4.59       4.59
Clear     0.50 -> 0.75         before |      4.12       1.42       1.00       2.23       1.28       1.00       1.39       1.40
                               after  |      8.89       4.58       4.58       4.82       4.56       4.55       4.59       4.58
Frosted   0.30 -> 0.75         before |      1.94       1.00       1.00       1.05       1.00       1.00       1.00       1.00
                               after  |      8.89       4.58       4.58       4.82       4.56       4.55       4.59       4.58
themed set (terminal/canvas backdrop of the theme), after:
Tinted    0.78 -> 0.78         after  |     13.88       4.78       4.57       7.53       4.60       4.52       4.68       4.71
Clear     0.50 -> 0.50         after  |     11.18       4.56       4.56       6.06       4.61       4.58       4.58       4.60
Frosted   0.30 -> 0.30         after  |      9.47       4.58       4.58       5.14       4.55       4.52       4.62       4.61

solve cost per theme apply (8 solves): 2.32 ms

Cost of applying a theme: 8 solves, 2.3 ms in bun on dev-02 (once per theme change, not per frame). Note the trade: under the static floor fg2 and fg3 land within 0.05:1 of each other, so the secondary/tertiary difference is carried by size and weight, not colour, on those surfaces.

**Progress 1.** Pushed `glass-contrast-d365` (head 239d182e9): solver + audit (122 unit cases pass on dev-02), themes.ts glass rules, sampler + cost spec. Browser before/after run is queued on dev-02 (position 8 of 8; compute01 is as long). Contrast table (exact worst case over black..white; evidence `table-before-after.txt`): ``` INK level opacity before->after | fg fg2 fg3 secondary accent ok danger warn Tinted 0.70 -> 0.82 before | 5.31 1.90 1.00 3.44 2.40 3.11 2.49 4.05 after | 8.13 4.55 4.60 5.27 4.56 4.76 4.53 6.20 Clear 0.40 -> 0.82 before | 2.11 1.00 1.00 1.36 1.00 1.23 1.00 1.61 after | 8.13 4.55 4.60 5.27 4.56 4.76 4.53 6.20 Frosted 0.20 -> 0.82 before | 1.29 1.00 1.00 1.00 1.00 1.00 1.00 1.00 after | 8.13 4.55 4.60 5.27 4.56 4.76 4.53 6.20 themed set (terminal/canvas backdrop of the theme), after: Tinted 0.70 -> 0.70 after | 11.30 4.53 4.53 7.32 5.10 6.62 5.29 8.62 Clear 0.40 -> 0.40 after | 8.46 4.57 4.53 5.48 4.55 4.95 4.56 6.46 Frosted 0.20 -> 0.35 after | 8.05 4.60 4.55 5.22 4.59 4.71 4.58 6.14 PAPER level opacity before->after | fg fg2 fg3 secondary accent ok danger warn Tinted 0.78 -> 0.78 before | 9.63 3.32 1.89 5.22 2.98 2.00 3.24 3.26 after | 9.63 4.59 4.59 5.22 4.63 4.53 4.59 4.59 Clear 0.50 -> 0.75 before | 4.12 1.42 1.00 2.23 1.28 1.00 1.39 1.40 after | 8.89 4.58 4.58 4.82 4.56 4.55 4.59 4.58 Frosted 0.30 -> 0.75 before | 1.94 1.00 1.00 1.05 1.00 1.00 1.00 1.00 after | 8.89 4.58 4.58 4.82 4.56 4.55 4.59 4.58 themed set (terminal/canvas backdrop of the theme), after: Tinted 0.78 -> 0.78 after | 13.88 4.78 4.57 7.53 4.60 4.52 4.68 4.71 Clear 0.50 -> 0.50 after | 11.18 4.56 4.56 6.06 4.61 4.58 4.58 4.60 Frosted 0.30 -> 0.30 after | 9.47 4.58 4.58 5.14 4.55 4.52 4.62 4.61 solve cost per theme apply (8 solves): 2.32 ms ``` Cost of applying a theme: 8 solves, 2.3 ms in bun on dev-02 (once per theme change, not per frame). Note the trade: under the static floor fg2 and fg3 land within 0.05:1 of each other, so the secondary/tertiary difference is carried by size and weight, not colour, on those surfaces.
Author
Owner

Finish. Branch glass-contrast-d365, head 56e5683545e0e1a81eb441ad9271c48454ab979f (base origin/dev 4754b14a7). Verify-only branch glass-contrast-d365-mock = this + the #146 mockup (appwin-mock-j209) + the marker; that is where the pixel spec runs.

Gates (evidence: gate4/quick.log, gate/budget-*.log):

  • verify quick: PASS on all 10 rows (scope, migrations, vendoring, fmt, doc ratchet, shape lints, doc comments on diff, test waits, ui literals, tracer typecheck).
  • bun run transfer-budget.ts, no cap changed: exit 0. Critical gzip 318,994 -> 320,090 B (+1,096; cap 327,680), brotli 266,497 -> 267,548 (+1,051); deferred and on-demand unchanged.
  • Unit audit: 122 cases pass (every theme x level, static and themed sets).
  • Pixel spec on the mockup, 64 cases: before (dev + mockup) 64 failed; after 64 passed (Ink/Paper, card 1-4, light/dark/white/black pictures, 1440x900 and 393x852).
  • verify fast NOT run (task asked for quick + budget; TS-only change).

Pixel table (lowest contrast in the popover, before -> after) is attached as pixel-contrast-table.txt; before/after montages attached.

**Finish.** Branch `glass-contrast-d365`, head `56e5683545e0e1a81eb441ad9271c48454ab979f` (base origin/dev 4754b14a7). Verify-only branch `glass-contrast-d365-mock` = this + the #146 mockup (`appwin-mock-j209`) + the marker; that is where the pixel spec runs. **Gates (evidence: gate4/quick.log, gate/budget-*.log):** - `verify quick`: PASS on all 10 rows (scope, migrations, vendoring, fmt, doc ratchet, shape lints, doc comments on diff, test waits, ui literals, tracer typecheck). - `bun run transfer-budget.ts`, no cap changed: exit 0. Critical gzip 318,994 -> 320,090 B (+1,096; cap 327,680), brotli 266,497 -> 267,548 (+1,051); deferred and on-demand unchanged. - Unit audit: 122 cases pass (every theme x level, static and themed sets). - Pixel spec on the mockup, 64 cases: before (dev + mockup) **64 failed**; after **64 passed** (Ink/Paper, card 1-4, light/dark/white/black pictures, 1440x900 and 393x852). - `verify fast` NOT run (task asked for quick + budget; TS-only change). Pixel table (lowest contrast in the popover, before -> after) is attached as pixel-contrast-table.txt; before/after montages attached.
Author
Owner

Review of #448: glass contrast (D365)

Reviewed SHA: 56e5683545e0e1a81eb441ad9271c48454ab979f (glass-contrast-d365); mockup branch glass-contrast-d365-mock e96b1bed3 has the same packages/ui code (empty diff there).
Verdict: REQUEST CHANGES

The pixel claim and the static maths hold. But (a) the owner's approved opacities are not kept, in the default themed set as well as over pictures, and (b) the contract that switches on the guarantee is fragile. Neither is a bug in the arithmetic.

What I verified

Claim Result Evidence (dev-02)
122-case audit 122 pass, 0 fail, 883 expects (bun test packages/ui/src/glass-contrast.test.ts on a checkout of 56e5683) run in ~/worktrees/j422
Pixel spec 64/64 64 passed against the fixed mockup :47662. Lowest ratio over all 64 cases: 4.60 (393×852 Paper, "Save data"). ~/.cache/codex-scratch/j422/spec-fixed.log, evidence/fixed/glass-contrast/ratios.jsonl
The spec bites The same spec against pre-fix :47661: 64 failed, lowest ratios 1.0 to 1.01. spec-prefix.log, evidence/prefix/glass-contrast/ratios.jsonl
Closed form is a true bound on a backdrop box Yes for the static set. Glass luminance is monotone in each backdrop channel, so the corners decide. My brute force of 300 random plus 10 saturated and mid-grey backdrops, all 10 themes, 4 levels, 8 roles gave no failure under the audited 8% fill. evidence/break.ts. Worst under fg-8% fill: none below 4.5.
Hot-path cost 8 solves per theme: median 4.5 ms, max 6.9 ms (bun). Solved only when the Theme object changes; the single caller (core-screen.ts:441) passes table themes, so no churn. A level change does not re-solve. evidence/cost.ts
Transfer glass-contrast.ts minified is about 2.4 KB, about 1.2 KB brotli, plus about 1 KB in themes.ts. glass-adapt.ts is imported by nothing in the app, so it is tree-shaken and costs 0 today. I did not run the size gate (reviewers don't run verify). local bun build

P1: the approved opacities are not kept (D160 / owner-approved levels)

  1. The themed set, which applies to every glass card in the app, changes approved opacities on 13 of 40 theme/level pairs. It is not only over pictures. From evidence/themed.ts:

    • ink Frosted 0.20 → 0.42 and Clear 0.40 → 0.42
    • mocha Frosted 0.20 → 0.37
    • rosepine 0.40/0.20 → 0.52/0.52
    • nord → 0.54/0.54
    • tokyo → 0.46/0.46
    • gruvbox → 0.53/0.53
    • dracula → 0.60/0.60

    On seven dark themes, Clear and Frosted become the same opacity, and Frosted is up to 3× its approved value. The TASTE row and the PR text say the level keeps its approved opacity over the theme's own backdrop. That is true only for the four light themes and for Ink/Mocha Tinted. Dark Frosted and Clear were not kept.

  2. Over data-glass-under, the levels collapse. The static alpha for levels 2–4 differs by ≤0.03 on 8 of 10 themes. Ink: 0.84 for Tinted, Clear and Frosted (nominal 0.70/0.40/0.20). Paper: 0.78/0.75/0.75 (nominal 0.78/0.50/0.30). In the 1440×900 and 393×852 contact sheets the four columns (Solid, Tinted, Clear, Frosted) look the same on the Ink rows. On Paper, the light, white and black rows are also near-identical. Tinted, Clear and Frosted do not look different over a picture. That is the worst case for the owner's purpose of seeing Frosted blur.

  3. The ceiling is 0.92 and the static set sits at 0.75–0.84, which is near-opaque. D375 says "an overlay is never opaque unless Solid". I could not find the text of D365 or D375 in DECISIONS.md on origin/dev or on this branch (grep returns nothing), so I compared against the TASTE row and D160 only. Please confirm D365/D375 are in the decisions file.

Only the owner can approve this trade-off. Recommendation: keep the approved opacity and move the work to the text. Over Frosted/Clear on a picture, adjust the label colours or give text a solid pill (the existing text-shadow halo, or a per-row backing), and only raise tint when text alone cannot reach 4.5:1. If the owner prefers more tint, they should say so with numbers on screen. Either way, get the owner to see the dark-theme Frosted change before merge.

P2

  • "Any backdrop" holds only over data-glass-under. The themed set assumes the terminal behind is between bg and bg lifted 35% to fg. Brute force with a block of an ANSI colour covering a fraction of the glass:

    • at 35% coverage: 0/2880 fail, worst 4.72;
    • at 50% coverage: 233/2880 fail, worst 3.85 (mocha Frosted fg3 over #f9e2af);
    • at 70% coverage: 1012 fail, worst 2.68;
    • at 100% (a solid white or yellow block, such as inverse video or a diff hunk): 1714 fail.

    Real terminals do this (a vim status line, htop bars, a git diff). Say in the TASTE row and the module doc that the themed set is an assumption, not a bound. The pixel spec and unit audit never exercise it.

  • Pixel spec margin and coverage. The 4.60 floor is within quantisation of 4.5. The spec does not hover, so the 8% fill is never measured on pixels. I also found --hover backgrounds widely used on glass (rows, kbd chips, tabs, settings rows). A 15% fill on top (selected row or a pressed state) drops ink Solid fg2 to 3.65 in my brute force. No such fill is used in the checked popovers, but nothing stops one. Make the audit fill the maximum fill the stylesheet really uses, or derive glassFill from --hover.

  • Muddy look over a dark picture on Paper (taste). Cards 2–4 over black or a dark picture render as a flat mid-grey slab (0.75 white tint over black → about rgb 191) with grey text: it passes 4.6:1 but reads grey-on-grey, and the blurred bottom edge shows as a smear (see fixed-1440x900-paper.png and fixed-393x852-paper.png, rows 2 and 4). The pre-fix version was unreadable, so this is better, but it is not "native".

P3

  • Startup cost. The solves are pure functions of the table, so they can be computed at build time (10 themes × 4 levels × 2 sets) and written out as constants; the 4.5–6.9 ms then goes away and the first-paint path stays free of OKLab search loops. It is under the 16 ms rule today, so optional.
  • Eager bundle. The added ~2–3 KB brotli is on the eager path (themes.ts). The memory notes say the tracer budget is nearly full on dev: run the size gate before merge and report the delta.
  • ok:false returns are possible at the ceiling; the audit asserts ok for the shipped themes, but a user-added theme has no such check.

Is data-glass-under a sound contract? No: opt-in, with a silent failure mode

  • Nothing in app code sets it today. Only the mockup does, mount.on-demand.ts:159-161. The mockup already needed it on the card root and on the phone stage, which shows that the card root alone was not enough: the phone popover is not a descendant of the card. The selector is [data-glass-under] :where(...), so any popover portalled outside the marked element gets the themed set and the faint text returns, with no error.
  • The real app-window card in #146 will have to remember this, and so will every future surface with a video or canvas behind it.

Safer default (my proposal): invert it. Make the static floor the default for glass surfaces, and opt out (data-glass-backdrop="theme") on the few places where the backdrop is the theme's own terminal or canvas. Forgetting then costs a slightly denser card, never unreadable text. Or, better, make it automatic: in the shared overlay/popover component, on open, call elementsFromPoint at the surface's box and treat any video, canvas or img below as "under a picture" (set the marker on the surface itself, not on an ancestor). That removes the portal problem and also lets watchBackdrop start without each caller wiring it. With P1 resolved this default costs much less.

TASTE check

Text ≥4.5:1 over real pixels: passes (4.60 floor). Levels still different, opacities kept: fails (P1). No fake glass: no new borders or bevels seen. Mockup chrome unchanged. Reduced to what I measured; I did not run verify or the size gate.

Evidence

Screenshots attached below: fixed-1440x900-ink.png, fixed-1440x900-paper.png, fixed-393x852-paper.png (4×4 sheets: rows light / dark / white / black picture, columns Solid / Tinted / Clear / Frosted) and before-after-1440-paper-light-card3.png. All raw files on infraternal-dev-02-std:~/.cache/codex-scratch/j422/evidence/{fixed,prefix,sheets} plus break.ts, break2.ts, cost.ts, themed.ts, spec-*.log. I ran the spec through the already-served mockups with nice -n10, 2 workers, not through build-slot: it builds nothing, and the slots were full. Pixel spec needs the mockup, so I did not run it on glass-contrast-d365 alone (it skips there by design).

## Review of #448: glass contrast (D365) **Reviewed SHA:** `56e5683545e0e1a81eb441ad9271c48454ab979f` (`glass-contrast-d365`); mockup branch `glass-contrast-d365-mock` `e96b1bed3` has the same `packages/ui` code (empty diff there). **Verdict: REQUEST CHANGES** The pixel claim and the static maths hold. But (a) the owner's approved opacities are not kept, in the default themed set as well as over pictures, and (b) the contract that switches on the guarantee is fragile. Neither is a bug in the arithmetic. ### What I verified | Claim | Result | Evidence (dev-02) | |---|---|---| | 122-case audit | **122 pass, 0 fail**, 883 expects (`bun test packages/ui/src/glass-contrast.test.ts` on a checkout of 56e5683) | run in `~/worktrees/j422` | | Pixel spec 64/64 | **64 passed** against the fixed mockup :47662. Lowest ratio over all 64 cases: **4.60** (393×852 Paper, "Save data"). | `~/.cache/codex-scratch/j422/spec-fixed.log`, `evidence/fixed/glass-contrast/ratios.jsonl` | | The spec bites | The same spec against pre-fix :47661: **64 failed**, lowest ratios 1.0 to 1.01. | `spec-prefix.log`, `evidence/prefix/glass-contrast/ratios.jsonl` | | Closed form is a true bound on a backdrop *box* | Yes for the **static** set. Glass luminance is monotone in each backdrop channel, so the corners decide. My brute force of 300 random plus 10 saturated and mid-grey backdrops, all 10 themes, 4 levels, 8 roles gave no failure under the audited 8% fill. | `evidence/break.ts`. Worst under `fg`-8% fill: none below 4.5. | | Hot-path cost | 8 solves per theme: median **4.5 ms**, max **6.9 ms** (bun). Solved only when the Theme object changes; the single caller (`core-screen.ts:441`) passes table themes, so no churn. A level change does not re-solve. | `evidence/cost.ts` | | Transfer | `glass-contrast.ts` minified is about 2.4 KB, about 1.2 KB brotli, plus about 1 KB in `themes.ts`. `glass-adapt.ts` is imported by nothing in the app, so it is tree-shaken and costs 0 today. I did **not** run the size gate (reviewers don't run `verify`). | local `bun build` | ### P1: the approved opacities are not kept (D160 / owner-approved levels) 1. **The themed set, which applies to every glass card in the app, changes approved opacities on 13 of 40 theme/level pairs.** It is not only over pictures. From `evidence/themed.ts`: - ink Frosted 0.20 → 0.42 and Clear 0.40 → 0.42 - mocha Frosted 0.20 → 0.37 - rosepine 0.40/0.20 → 0.52/0.52 - nord → 0.54/0.54 - tokyo → 0.46/0.46 - gruvbox → 0.53/0.53 - dracula → 0.60/0.60 On seven dark themes, **Clear and Frosted become the same opacity**, and Frosted is up to 3× its approved value. The TASTE row and the PR text say the level keeps its approved opacity over the theme's own backdrop. That is true only for the four light themes and for Ink/Mocha Tinted. Dark Frosted and Clear were not kept. 2. **Over `data-glass-under`, the levels collapse.** The static alpha for levels 2–4 differs by ≤0.03 on 8 of 10 themes. Ink: 0.84 for Tinted, Clear and Frosted (nominal 0.70/0.40/0.20). Paper: 0.78/0.75/0.75 (nominal 0.78/0.50/0.30). In the 1440×900 and 393×852 contact sheets the four columns (Solid, Tinted, Clear, Frosted) look the same on the Ink rows. On Paper, the light, white and black rows are also near-identical. Tinted, Clear and Frosted do not look different over a picture. That is the worst case for the owner's purpose of seeing Frosted blur. 3. The ceiling is 0.92 and the static set sits at 0.75–0.84, which is near-opaque. D375 says "an overlay is never opaque unless Solid". I could not find the text of D365 or D375 in `DECISIONS.md` on `origin/dev` or on this branch (grep returns nothing), so I compared against the TASTE row and D160 only. **Please confirm D365/D375 are in the decisions file.** Only the owner can approve this trade-off. Recommendation: keep the approved opacity and move the work to the text. Over Frosted/Clear on a picture, adjust the label colours or give text a solid pill (the existing text-shadow halo, or a per-row backing), and only raise tint when text alone cannot reach 4.5:1. If the owner prefers more tint, they should say so with numbers on screen. Either way, get the owner to see the dark-theme Frosted change before merge. ### P2 - **"Any backdrop" holds only over `data-glass-under`.** The themed set assumes the terminal behind is between `bg` and `bg` lifted 35% to `fg`. Brute force with a block of an ANSI colour covering a fraction of the glass: - at 35% coverage: 0/2880 fail, worst 4.72; - at 50% coverage: **233/2880 fail**, worst 3.85 (mocha Frosted `fg3` over `#f9e2af`); - at 70% coverage: 1012 fail, worst 2.68; - at 100% (a solid white or yellow block, such as inverse video or a diff hunk): 1714 fail. Real terminals do this (a vim status line, htop bars, a git diff). Say in the TASTE row and the module doc that the themed set is an assumption, not a bound. The pixel spec and unit audit never exercise it. - **Pixel spec margin and coverage.** The 4.60 floor is within quantisation of 4.5. The spec does not hover, so the 8% fill is never measured on pixels. I also found `--hover` backgrounds widely used on glass (rows, kbd chips, tabs, settings rows). A 15% fill on top (selected row or a pressed state) drops ink Solid `fg2` to 3.65 in my brute force. No such fill is used in the checked popovers, but nothing stops one. Make the audit fill the maximum fill the stylesheet really uses, or derive `glassFill` from `--hover`. - **Muddy look over a dark picture on Paper (taste).** Cards 2–4 over black or a dark picture render as a flat mid-grey slab (0.75 white tint over black → about rgb 191) with grey text: it passes 4.6:1 but reads grey-on-grey, and the blurred bottom edge shows as a smear (see `fixed-1440x900-paper.png` and `fixed-393x852-paper.png`, rows 2 and 4). The pre-fix version was unreadable, so this is better, but it is not "native". ### P3 - **Startup cost.** The solves are pure functions of the table, so they can be computed at build time (10 themes × 4 levels × 2 sets) and written out as constants; the 4.5–6.9 ms then goes away and the first-paint path stays free of OKLab search loops. It is under the 16 ms rule today, so optional. - **Eager bundle.** The added ~2–3 KB brotli is on the eager path (`themes.ts`). The memory notes say the tracer budget is nearly full on dev: run the size gate before merge and report the delta. - `ok:false` returns are possible at the ceiling; the audit asserts `ok` for the shipped themes, but a user-added theme has no such check. ### Is `data-glass-under` a sound contract? No: opt-in, with a silent failure mode - Nothing in app code sets it today. Only the mockup does, `mount.on-demand.ts:159-161`. The mockup already needed it on the card root **and** on the phone stage, which shows that the card root alone was not enough: the phone popover is not a descendant of the card. The selector is `[data-glass-under] :where(...)`, so any popover portalled outside the marked element gets the themed set and the faint text returns, with no error. - The real app-window card in #146 will have to remember this, and so will every future surface with a video or canvas behind it. **Safer default (my proposal):** invert it. Make the static floor the default for glass surfaces, and opt **out** (`data-glass-backdrop="theme"`) on the few places where the backdrop is the theme's own terminal or canvas. Forgetting then costs a slightly denser card, never unreadable text. Or, better, make it automatic: in the shared overlay/popover component, on open, call `elementsFromPoint` at the surface's box and treat any `video`, `canvas` or `img` below as "under a picture" (set the marker on the surface itself, not on an ancestor). That removes the portal problem and also lets `watchBackdrop` start without each caller wiring it. With P1 resolved this default costs much less. ### TASTE check Text ≥4.5:1 over real pixels: passes (4.60 floor). Levels still different, opacities kept: **fails** (P1). No fake glass: no new borders or bevels seen. Mockup chrome unchanged. Reduced to what I measured; I did not run `verify` or the size gate. ### Evidence Screenshots attached below: `fixed-1440x900-ink.png`, `fixed-1440x900-paper.png`, `fixed-393x852-paper.png` (4×4 sheets: rows light / dark / white / black picture, columns Solid / Tinted / Clear / Frosted) and `before-after-1440-paper-light-card3.png`. All raw files on `infraternal-dev-02-std:~/.cache/codex-scratch/j422/evidence/{fixed,prefix,sheets}` plus `break.ts`, `break2.ts`, `cost.ts`, `themed.ts`, `spec-*.log`. I ran the spec through the already-served mockups with `nice -n10`, 2 workers, not through `build-slot`: it builds nothing, and the slots were full. Pixel spec needs the mockup, so I did not run it on `glass-contrast-d365` alone (it skips there by design).
Author
Owner

Round 2 (answer to #448 comment 88650), head c5873a42110e4a2f0239d0868a0455f67a79f9c9. D365/D375 are in DECISIONS.md as numbered items; the review's grep missed them. All seven orchestrator decisions are in.

1. Approved opacities kept. The themed set no longer touches opacity. Each text role moves in lightness as far as it must; opacity rises only where no text colour can reach 4.5:1. Of 40 theme/level pairs, one is raised: dracula Frosted 0.20 → 0.37 (Clear stays 0.40, so the levels stay distinct). Full table in coverage-sweep.txt. Tinted/Clear/Frosted are three different opacities on every theme (asserted).

2. Terminal bound. The themed range is now the theme's own 18 colours (16 ANSI, text, background): the page colour up to a block of any of them covering at most 20% of the card, ordered by what the card shows. 20% is a chosen bound, said so in the code and in a test. Sweep (pairs raised of 30 thin ones): 10–15% → 0; 20% → 1; 25% → 4; 30% → 8; 35% → 9; 50% → 16 and Clear = Frosted opacity on ink, mocha, rosepine, nord, dracula; 100% → 23. At 100% the approved levels cannot hold the line over a full bright screen, and a test records that Ink Frosted fails there. The audit holds all 30 pairs over every colour at every coverage to 20% in every fill state.

3. No marker. glass-under.ts finds the picture itself (hit tests at the corners and centre, below the surface: video, img ≥ 64 px, canvas.aw-surface, [data-glass-picture]) and marks the surface; anchored-popover calls it on open and each placement, and the confirmation card through its glass bed. The mockup now marks only its picture; nothing marks the window or the stage. Not covered: glass built outside those two components (palette, sheets, toasts).
Red on today's head (56e568354's packages/ui with the new mockup and specs): 64 failed on data-glass-under missing on the popover (red2/run.log); the pre-fix tree failed 64/64 on contrast earlier.

4. Fills. Taken from the stylesheet: hover (text 6%), chip (9%), selected (accent-soft 16%), selected+hover, each +2 points. No control fills on press (the press is a scale). The pixel spec now measures rest, hover first/last and press first/last.
Green: 68 passed (64 pixel cases × 5 states + 4 captures), lowest ratio 5.82 (was 4.60). green3/run.log, pixel-contrast-table-r2.txt.

5. Paper over a dark picture. The static floor now also holds the tint within 0.1 OKLab lightness of itself over either extreme: Paper floor 0.87 (a pale card), not 0.75 (grey slab). Dark themes 0.87–0.91. Montage pixel-green-1440x900-paper.png.

6. Precompute: no. 10 themes × 4 levels × 2 sets is about 6 KB, larger than the solver (about 1.2 KB), and grows with every scheme added. The solve is now 2.2 ms per theme (was 4.5; bisection). The sampler is a lazy on-demand chunk.
Budget (no cap changed): critical gzip 322,512 (origin/dev cedeb4d26) → 324,709 (+2,197 B; cap 327,680, headroom now 2,971 B); brotli 269,238 → 271,399; deferred 58,492 → 58,489; on-demand largest chunk unchanged. This eats most of the critical headroom; the alternative (writing the rules from an on-demand chunk) shows unrepaired tokens for the first moments of a load. Orchestrator's call.

7. Motion. None added.

Sampling cost (unchanged, headless software rendering, r4/cost.log): 0.003–0.006 ms per sample from a live 1080p stream, a decoded VP8 video and a canvas; frame intervals p99 16.8 ms with and without the watcher. Not measured on a real GPU.

Screenshots (attached): levels2-red4-*.png (old head) and levels2-green4-*.png: Tinted, Clear, Frosted over the terminal at 1440×900 and 393×852, Ink and Paper; pixel-green-*.png: light, dark, white, black pictures, cards 2–4.
Gates: verify quick PASS on head c5873a421 (10 rows, r4/quick.log). verify fast on compute01 is running (first run on 685aed0e9, second queued on c5873a421); results follow.

**Round 2 (answer to #448 comment 88650), head `c5873a42110e4a2f0239d0868a0455f67a79f9c9`.** D365/D375 are in DECISIONS.md as numbered items; the review's grep missed them. All seven orchestrator decisions are in. **1. Approved opacities kept.** The themed set no longer touches opacity. Each text role moves in lightness as far as it must; opacity rises only where no text colour can reach 4.5:1. Of 40 theme/level pairs, **one** is raised: **dracula Frosted 0.20 → 0.37** (Clear stays 0.40, so the levels stay distinct). Full table in `coverage-sweep.txt`. Tinted/Clear/Frosted are three different opacities on every theme (asserted). **2. Terminal bound.** The themed range is now the theme's own 18 colours (16 ANSI, text, background): the page colour up to a block of any of them covering at most **20%** of the card, ordered by what the card shows. 20% is a chosen bound, said so in the code and in a test. Sweep (pairs raised of 30 thin ones): 10–15% → 0; 20% → 1; 25% → 4; 30% → 8; 35% → 9; 50% → 16 and Clear = Frosted opacity on ink, mocha, rosepine, nord, dracula; 100% → 23. At 100% the approved levels cannot hold the line over a full bright screen, and a test records that Ink Frosted fails there. The audit holds all 30 pairs over every colour at every coverage to 20% in every fill state. **3. No marker.** `glass-under.ts` finds the picture itself (hit tests at the corners and centre, below the surface: `video`, `img` ≥ 64 px, `canvas.aw-surface`, `[data-glass-picture]`) and marks the surface; `anchored-popover` calls it on open and each placement, and the confirmation card through its glass bed. The mockup now marks only its picture; nothing marks the window or the stage. Not covered: glass built outside those two components (palette, sheets, toasts). Red on today's head (56e568354's `packages/ui` with the new mockup and specs): **64 failed** on `data-glass-under` missing on the popover (`red2/run.log`); the pre-fix tree failed 64/64 on contrast earlier. **4. Fills.** Taken from the stylesheet: hover (text 6%), chip (9%), selected (accent-soft 16%), selected+hover, each +2 points. No control fills on press (the press is a scale). The pixel spec now measures rest, hover first/last and press first/last. Green: **68 passed** (64 pixel cases × 5 states + 4 captures), lowest ratio **5.82** (was 4.60). `green3/run.log`, `pixel-contrast-table-r2.txt`. **5. Paper over a dark picture.** The static floor now also holds the tint within 0.1 OKLab lightness of itself over either extreme: Paper floor 0.87 (a pale card), not 0.75 (grey slab). Dark themes 0.87–0.91. Montage `pixel-green-1440x900-paper.png`. **6. Precompute: no.** 10 themes × 4 levels × 2 sets is about 6 KB, larger than the solver (about 1.2 KB), and grows with every scheme added. The solve is now 2.2 ms per theme (was 4.5; bisection). The sampler is a lazy on-demand chunk. **Budget (no cap changed):** critical gzip 322,512 (origin/dev cedeb4d26) → 324,709 (+2,197 B; cap 327,680, headroom now 2,971 B); brotli 269,238 → 271,399; deferred 58,492 → 58,489; on-demand largest chunk unchanged. This eats most of the critical headroom; the alternative (writing the rules from an on-demand chunk) shows unrepaired tokens for the first moments of a load. Orchestrator's call. **7. Motion.** None added. **Sampling cost** (unchanged, headless software rendering, `r4/cost.log`): 0.003–0.006 ms per sample from a live 1080p stream, a decoded VP8 video and a canvas; frame intervals p99 16.8 ms with and without the watcher. Not measured on a real GPU. **Screenshots** (attached): `levels2-red4-*.png` (old head) and `levels2-green4-*.png`: Tinted, Clear, Frosted over the terminal at 1440×900 and 393×852, Ink and Paper; `pixel-green-*.png`: light, dark, white, black pictures, cards 2–4. **Gates:** `verify quick` PASS on head c5873a421 (10 rows, `r4/quick.log`). `verify fast` on compute01 is running (first run on 685aed0e9, second queued on c5873a421); results follow.
Author
Owner

Round 2 finish. Branch glass-contrast-d365, head 431e71f5065a3c872ad6ed2e0ac3645420f6a26b (merged with origin/dev 547a48885; git ls-remote confirms).

Gates

  • verify quick on this head: PASS, 10 of 10 rows (q6/quick.log).
  • verify fast on compute01, head 610525535 (before the last dev merge; fast2/sha): the whole Rust gate PASS (25 rows incl. cargo-deny, clippy, test, static builds; privileged integration SKIP by design), excerpt attached as fast2-excerpt.txt. It then stops at one tracer unit test, startup-toast.test.ts: a closing notice cannot hide its replacement (markMotion calls getAttribute on the toast's fake DOM element, #427). That test fails identically on unmodified origin/dev 547a48885 (bun test src/canvas/sessions/startup-toast.test.ts in a clean checkout: 0 pass, 1 fail), so it is not from this branch. Because of that stop, the browser rows of fast did not run; my own browser runs are below. An earlier fast run (685aed0e9) stopped at a cargo-deny advisory that origin/dev had since fixed in Cargo.lock; merging dev cleared it.
  • Pixel spec on the mockup (merged head 610525535, green5/run.log): 68 passed: 64 cases × 5 states (rest, hover first/last, press first/last) + 4 captures. Lowest contrast 5.82:1.
  • Transfer budget, no cap changed (r5/budget-*.log): critical gzip 322,924 (origin/dev c442500c2) → 325,117 (+2,193; cap 327,680), brotli 269,657 → 271,835 (+2,178); deferred 58,543 → 58,560; largest on-demand chunk 17,438 → 17,435.
  • Cost spec: 1 passed (r5/cost.log).
  • Mockup re-served on :47662 from the final mockup branch (glass-contrast-d365-mock, /health 200).

For the orchestrator: (a) the 20% terminal-coverage bound and its one raised pair (dracula Frosted 0.20 to 0.37) are choices you may want the owner to see; (b) critical-class headroom is 2,563 B now; (c) glass built outside anchored-popover and the confirmation card is not auto-detected over pictures.

**Round 2 finish. Branch `glass-contrast-d365`, head `431e71f5065a3c872ad6ed2e0ac3645420f6a26b` (merged with origin/dev 547a48885; `git ls-remote` confirms).** **Gates** - `verify quick` on this head: PASS, 10 of 10 rows (`q6/quick.log`). - `verify fast` on compute01, head `610525535` (before the last dev merge; `fast2/sha`): the whole Rust gate **PASS** (25 rows incl. cargo-deny, clippy, test, static builds; privileged integration SKIP by design), excerpt attached as `fast2-excerpt.txt`. It then stops at one tracer unit test, `startup-toast.test.ts: a closing notice cannot hide its replacement` (`markMotion` calls `getAttribute` on the toast's fake DOM element, #427). **That test fails identically on unmodified origin/dev 547a48885** (`bun test src/canvas/sessions/startup-toast.test.ts` in a clean checkout: 0 pass, 1 fail), so it is not from this branch. Because of that stop, the browser rows of `fast` did not run; my own browser runs are below. An earlier fast run (685aed0e9) stopped at a cargo-deny advisory that origin/dev had since fixed in Cargo.lock; merging dev cleared it. - Pixel spec on the mockup (merged head 610525535, `green5/run.log`): **68 passed**: 64 cases × 5 states (rest, hover first/last, press first/last) + 4 captures. Lowest contrast 5.82:1. - Transfer budget, no cap changed (`r5/budget-*.log`): critical gzip 322,924 (origin/dev c442500c2) → 325,117 (+2,193; cap 327,680), brotli 269,657 → 271,835 (+2,178); deferred 58,543 → 58,560; largest on-demand chunk 17,438 → 17,435. - Cost spec: 1 passed (`r5/cost.log`). - Mockup re-served on :47662 from the final mockup branch (`glass-contrast-d365-mock`, `/health` 200). **For the orchestrator:** (a) the 20% terminal-coverage bound and its one raised pair (dracula Frosted 0.20 to 0.37) are choices you may want the owner to see; (b) critical-class headroom is 2,563 B now; (c) glass built outside `anchored-popover` and the confirmation card is not auto-detected over pictures.
Author
Owner

Review of #448, round 2: glass contrast (D365 / D380)

Reviewed SHA: 431e71f5065a3c872ad6ed2e0ac3645420f6a26b (glass-contrast-d365). The served mockup :47662 has packages/ui byte-identical to it (diff -rq, empty).
Verdict: REQUEST CHANGES

Reviewer scope: I ran no verify. I measured pixels with Playwright on dev-02 against the served mockup, plus a small harness that bundles the shipped tooltip.ts, toast.ts and themes.ts over a full-screen picture. Scripts and raw output: infraternal-dev-02-std:~/.cache/codex-scratch/j448-rev2/ (term.cjs, gap3.cjs, h.cjs, h2.cjs, cap.cjs, sweep.ts, evidence/).

Summary

Ask Result
1. 20% coverage bound Not defensible. Two adjacent inverse rows (nano / vim status+command line / tmux), 7% by area, drop Ink to 3.6:1 at all three levels.
2. Hook coverage gaps Real. The palette fails over a picture (Ink 3.0 to 4.9, Paper 2.65 to 4.49). Toasts too (down to 1.28).
3. D380 confirm cards Does not get the guarantee on a normal open (P1 bug in pictureUnder). With a forced refresh the floor holds (≥6.57).
4. Critical JS Keep the solver eager; reasons below.
5. TASTE look Terminal row is good. Over pictures the three levels are indistinguishable.
6. startup-toast.test.ts Not this branch. Ignored.

P1

P1-a. The confirmation card never gets the picture floor on open (D380 not met). glass-under.ts:pictureUnder assumes the surface is in the hit-test stack: stack.slice(Math.max(0, findIndex(...)) + 1). The confirm card is pointer-events:none until data-open="true", and place() → bed.sync() → refreshGlassUnder runs before that. The card is then absent from elementsFromPoint, findIndex is -1, and slice(1) drops the first element, which is the picture. Result: data-glass-under is never set. It only appears after a later resize/scroll (I dispatched one to confirm).
Pixel evidence (shipped component over a full-screen <img>, h.cjs confirm, anchored and centred, identical):

  • Ink over a white picture: Tinted 3.22, Clear 1.43, Frosted 1.06.
  • Paper over a black picture: 4.38 / 2.07 / 1.06.
  • Over a dark picture on Ink it passes by luck (9.4 to 12.7).
    With a forced refresh (h2.cjs confirm resize): 12/12 anchored cases pass, lowest 6.57 (Ink 7.6 / 10.43, Paper 8.86 / 6.57). So the floor works once detection fires.
    Fix: when the surface is not in the stack, do not slice. Skip surface descendants and .ui-glass-bed and take everything else. Also refresh once more after the open animation (setOpen(true) plus a frame). Add a confirm-card case to glass-contrast.spec.ts: the spec covers only the Send keys and gear popovers, so D380 has no pixel test.

P1-b. The "20% of the card" terminal bound is wrong in the measure that matters (local, after blur). 20% by area is not 20% under a text line. Real screens, real popover (term.cjs, true Ink/Paper via &theme=; behind the popover: dense text plus the listed blocks; pixel contrast of every run vs the lightest/darkest pixel under it):

Screen (area covered) Ink T / C / F Paper T / C / F
plain text 7.7 / 8.1 / 8.4 6.7 / 6.7 / 6.9
one full-width inverse row (4%) 5.0 / 5.6 / 5.6 5.6 / 5.7 / 5.7
two adjacent inverse rows, mid-card (7%) 3.64 / 3.68 / 3.69 4.96 / 4.67 / 4.48
nano-style bars (11%) 3.64 / 2.77 / 2.35 4.96 / 4.01 / 3.44
htop header + selected row (7%) 4.86 / 5.75 / 5.43 5.9 / 6.0 / 5.9
ls in a world-writable dir, green name blocks (30%) 5.06 / 4.75 / 4.41 5.6 / 4.85 / 4.59
half-screen light pane (47%) 3.58 / 1.81 / 1.24 4.67 / 2.47 / 1.43
mc-style light dialog / pager (100%) 4.46 / 2.64 / 1.91 (dialog) 5.58 / 4.25 / 3.45

Tinted (0.70) fails too on Ink with two bright rows: a status line plus a prompt line under a popover is an everyday case. Cause: the model treats a block as an area average. A two-row (34 px) band under a 24 to 32 px blur reaches 40 to 60% locally. Anchored popovers inside a window also have no glass bed, so the card's own backdrop-filter fades at the box edge and lets sharp content through. I measured the lightest pixel behind "F9" on Ink Frosted at ~0.59 of the block colour, while a flat CSS blur of the same screen gives ~0.20.
What bound is needed? With the shipped solver (sweep.ts, themedBackdrop(t, c)):

Bound Pairs raised of 30 Clear = Frosted on Ink T / C / F (nominal 0.70 / 0.40 / 0.20)
20% 1 0 themes 0.70 / 0.40 / 0.20
40% 14 6 0.70 / 0.45 / 0.45
50% 16 7 0.70 / 0.58 / 0.58
80% 21 7 0.75 / 0.75 / 0.75

Roughly 40 to 50% is what a status+prompt pair needs; a light dialog or pager needs 80%+. That costs Clear and Frosted their distinction on 6 to 7 of 10 themes and brings dracula to 0.80. The approved levels and a real terminal guarantee cannot both hold. Recommendation: do not claim a guarantee over arbitrary terminal content. Rewrite the TASTE row, THEMED_COVERAGE and the module doc to say what is true: "holds for bright blocks up to about one row (≈4% of the card); two bright rows or more can drop text under 4.5:1 at all levels on dark themes." Keep the 20% constant as a documented, owner-visible assumption. The owner decides whether to buy more with opacity (table above). Add a pixel case for the themed set (see P2-b).

P2

P2-a. Auto-detect covers 2 of ~20 glass surfaces. Only anchored-popover and the confirm card's bed call refreshGlassUnder. Unhooked .ui-overlay-surface / .ui-panel users that can sit over a window or picture: palette (core-screen.ts:191), sidebar (:186), toast (toast.ts:284), regular tooltip (tooltip.ts:134, only the confirm tip has a bed), settings sheet, shortcuts view, probe panel, crumb menu, viewlist, view-pointer summary, cl-float / cl-badge, share dialog, details popover, add-host wizard.
Measured (mockup, gap3.cjs; harness h.cjs toast):

  • Palette over an app-window picture: Ink over white 4.88 / 3.60 / 3.01; Ink over the mockup's own light window 4.89 / 3.62 / 2.93; Paper over a black picture 4.49 / 3.34 / 2.65. data-glass-under is null.
  • Toast over a bright picture, Ink: 2.73 / 2.11 / 1.28; Paper over black: 6.57 / 4.15 / 1.96.
    Proposal: one hook in the shared material rather than per component. Put a watchGlassSurfaces() in glass-under.ts, started from applyTheme: one MutationObserver on body (childList, subtree, plus the hidden and data-open attributes) that calls refreshGlassUnder on each .ui-overlay-surface, .ui-panel when it appears or becomes visible, debounced to a frame. This also covers the sidebar and palette. For surfaces that stay while the canvas moves under them (sidebar), also re-check on the canvas camera moveend.
    Separate, found while measuring: the toast's filled "Undo" button is 2.73:1 on Ink regardless of backdrop (label rgb(149,186,255) on a rgb(107,107,107) fill). The solver's fill model has only hover, chip and selected, so filled action buttons are not modelled.

P2-b. The pixel spec's "Ink" and "Paper" are Mocha and Latte. The spec only sets colorScheme, and the mockup then falls back to mocha / latte (core-screen.ts:385; overlay token on the "Ink" case was rgb(44 44 59 / …), which is mocha's tint, not ink's 28 28 30). The 68/68 and the 5.82 floor therefore do not test Ink or Paper, and J414's screenshots were Mocha/Latte. Add &theme=ink / &theme=paper. Unit audit and my reruns (with theme=) cover the real themes, and the picture cases I reran at the real themes passed for popovers (the floor works there). The themed set has no pixel test at all; add the nano-bars case above.

P2-c. Over a picture the three levels collapse. The floor is ~0.87 (Paper) / 0.90 (Ink): in the screenshots below the Tinted, Clear and Frosted columns over a light or dark picture are indistinguishable, and the card reads near-opaque. Same as round-1 P1.2 (D160/D375: overlays are never opaque unless Solid). The "levels stay distinct" assertion holds only for the themed set. This is an owner call; please make the numbers visible to the owner (Ink 0.90/0.90/0.90, Paper 0.87/0.87/0.87).

Check 4: critical JS headroom (2,563 B)

Keep the solver eager. A first-paint surface is a glass card: the sign-in and setup cards (auth-card ui-overlay-surface) are the first thing a signed-out person sees, so an on-demand solver would flash unrepaired text there and then swap colours when the chunk lands. Moving only the floor (glassFloor + drift logic) saves little because the solver code is shared. If headroom is needed later, the cheap wins are elsewhere (e.g. solve three levels, since Solid keeps alpha 1). I did not rerun the size gate; I relied on the reported +2,193 B / 2,563 B left (cap 327,680; arithmetic consistent).

Check 5: TASTE look (Ink and Paper, real themes; 1440×900 and 393×852)

Sheets: rows = terminal / light picture / dark picture, columns = Tinted / Clear / Frosted. Terminal row = a dense synthetic terminal behind the real popover.

  • Terminal row: good. The levels are visibly different, text is crisp, with no ghost text. Paper Frosted over inverse rows is the weakest (the light bar shows through under the "F7 to F12" keys).
  • Picture rows: the three columns look the same (P2-c). On Paper over a dark picture the card is a pale grey slab with no see-through, flat and a little heavy but readable and tidy, not muddy as in round 1. Paper phone sheet shows the same.
  • No bevels, borders or alignment problems seen; the rows in the popover keep their left edge.

P3

  • MAX_SHIFT, MAX_DRIFT, the coverage sweep and the fill alphas live in code comments plus coverage-sweep.txt; put the sweep table in THEMED_COVERAGE's doc so the next reader sees the cost per bound.
  • pictureUnder samples only four corners and the centre: a picture strip crossing the card between those points (a thin video letterbox) is missed.

Evidence

Attached: sheet-1440x900-ink.png, sheet-1440x900-paper.png, sheet-393x852-ink.png, sheet-393x852-paper.png, ink-card4-nano-bars.png (the 2.35:1 case), palette-ink-white.png. Raw: ~/.cache/codex-scratch/j448-rev2/evidence/{term,gap,look} on dev-02 (term.jsonl has every row of the table).

## Review of #448, round 2: glass contrast (D365 / D380) **Reviewed SHA:** `431e71f5065a3c872ad6ed2e0ac3645420f6a26b` (`glass-contrast-d365`). The served mockup :47662 has `packages/ui` byte-identical to it (`diff -rq`, empty). **Verdict: REQUEST CHANGES** Reviewer scope: I ran no `verify`. I measured pixels with Playwright on dev-02 against the served mockup, plus a small harness that bundles the shipped `tooltip.ts`, `toast.ts` and `themes.ts` over a full-screen picture. Scripts and raw output: `infraternal-dev-02-std:~/.cache/codex-scratch/j448-rev2/` (`term.cjs`, `gap3.cjs`, `h.cjs`, `h2.cjs`, `cap.cjs`, `sweep.ts`, `evidence/`). ### Summary | Ask | Result | |---|---| | 1. 20% coverage bound | **Not defensible.** Two adjacent inverse rows (nano / vim status+command line / tmux), 7% by area, drop Ink to 3.6:1 at all three levels. | | 2. Hook coverage gaps | **Real.** The palette fails over a picture (Ink 3.0 to 4.9, Paper 2.65 to 4.49). Toasts too (down to 1.28). | | 3. D380 confirm cards | **Does not get the guarantee on a normal open** (P1 bug in `pictureUnder`). With a forced refresh the floor holds (≥6.57). | | 4. Critical JS | Keep the solver eager; reasons below. | | 5. TASTE look | Terminal row is good. Over pictures the three levels are indistinguishable. | | 6. `startup-toast.test.ts` | Not this branch. Ignored. | ### P1 **P1-a. The confirmation card never gets the picture floor on open (D380 not met).** `glass-under.ts:pictureUnder` assumes the surface is in the hit-test stack: `stack.slice(Math.max(0, findIndex(...)) + 1)`. The confirm card is `pointer-events:none` until `data-open="true"`, and `place()` → `bed.sync()` → `refreshGlassUnder` runs before that. The card is then absent from `elementsFromPoint`, `findIndex` is -1, and `slice(1)` drops the **first** element, which is the picture. Result: `data-glass-under` is never set. It only appears after a later `resize`/`scroll` (I dispatched one to confirm). Pixel evidence (shipped component over a full-screen `<img>`, `h.cjs confirm`, anchored and centred, identical): - Ink over a white picture: Tinted **3.22**, Clear **1.43**, Frosted **1.06**. - Paper over a black picture: **4.38 / 2.07 / 1.06**. - Over a dark picture on Ink it passes by luck (9.4 to 12.7). With a forced refresh (`h2.cjs confirm resize`): 12/12 anchored cases pass, lowest **6.57** (Ink 7.6 / 10.43, Paper 8.86 / 6.57). So the floor works once detection fires. Fix: when the surface is not in the stack, do not slice. Skip `surface` descendants and `.ui-glass-bed` and take everything else. Also refresh once more after the open animation (`setOpen(true)` plus a frame). Add a confirm-card case to `glass-contrast.spec.ts`: the spec covers only the Send keys and gear popovers, so D380 has no pixel test. **P1-b. The "20% of the card" terminal bound is wrong in the measure that matters (local, after blur).** 20% by area is not 20% under a text line. Real screens, real popover (`term.cjs`, true Ink/Paper via `&theme=`; behind the popover: dense text plus the listed blocks; pixel contrast of every run vs the lightest/darkest pixel under it): | Screen (area covered) | Ink T / C / F | Paper T / C / F | |---|---|---| | plain text | 7.7 / 8.1 / 8.4 | 6.7 / 6.7 / 6.9 | | one full-width inverse row (4%) | 5.0 / 5.6 / 5.6 | 5.6 / 5.7 / 5.7 | | **two adjacent inverse rows, mid-card (7%)** | **3.64 / 3.68 / 3.69** | 4.96 / 4.67 / **4.48** | | **nano-style bars (11%)** | **3.64 / 2.77 / 2.35** | 4.96 / 4.01 / 3.44 | | htop header + selected row (7%) | 4.86 / 5.75 / 5.43 | 5.9 / 6.0 / 5.9 | | `ls` in a world-writable dir, green name blocks (30%) | 5.06 / 4.75 / **4.41** | 5.6 / 4.85 / 4.59 | | half-screen light pane (47%) | 3.58 / 1.81 / 1.24 | 4.67 / 2.47 / 1.43 | | mc-style light dialog / pager (100%) | 4.46 / 2.64 / 1.91 (dialog) | 5.58 / 4.25 / 3.45 | Tinted (0.70) fails too on Ink with two bright rows: a status line plus a prompt line under a popover is an everyday case. Cause: the model treats a block as an area average. A two-row (34 px) band under a 24 to 32 px blur reaches 40 to 60% locally. Anchored popovers inside a window also have no glass bed, so the card's own backdrop-filter fades at the box edge and lets sharp content through. I measured the lightest pixel behind "F9" on Ink Frosted at ~0.59 of the block colour, while a flat CSS blur of the same screen gives ~0.20. What bound is needed? With the shipped solver (`sweep.ts`, `themedBackdrop(t, c)`): | Bound | Pairs raised of 30 | Clear = Frosted on | Ink T / C / F (nominal 0.70 / 0.40 / 0.20) | |---|---|---|---| | 20% | 1 | 0 themes | 0.70 / 0.40 / 0.20 | | 40% | 14 | 6 | 0.70 / 0.45 / 0.45 | | 50% | 16 | 7 | 0.70 / 0.58 / 0.58 | | 80% | 21 | 7 | 0.75 / 0.75 / 0.75 | Roughly **40 to 50%** is what a status+prompt pair needs; a light dialog or pager needs 80%+. That costs Clear and Frosted their distinction on 6 to 7 of 10 themes and brings dracula to 0.80. The approved levels and a real terminal guarantee cannot both hold. Recommendation: do not claim a guarantee over arbitrary terminal content. Rewrite the TASTE row, `THEMED_COVERAGE` and the module doc to say what is true: "holds for bright blocks up to about one row (≈4% of the card); two bright rows or more can drop text under 4.5:1 at all levels on dark themes." Keep the 20% constant as a documented, owner-visible assumption. The owner decides whether to buy more with opacity (table above). Add a pixel case for the themed set (see P2-b). ### P2 **P2-a. Auto-detect covers 2 of ~20 glass surfaces.** Only `anchored-popover` and the confirm card's bed call `refreshGlassUnder`. Unhooked `.ui-overlay-surface` / `.ui-panel` users that can sit over a window or picture: palette (`core-screen.ts:191`), sidebar (`:186`), toast (`toast.ts:284`), regular tooltip (`tooltip.ts:134`, only the confirm tip has a bed), settings sheet, shortcuts view, probe panel, crumb menu, viewlist, view-pointer summary, `cl-float` / `cl-badge`, share dialog, details popover, add-host wizard. Measured (mockup, `gap3.cjs`; harness `h.cjs toast`): - Palette over an app-window picture: Ink over white **4.88 / 3.60 / 3.01**; Ink over the mockup's own light window 4.89 / 3.62 / 2.93; Paper over a black picture **4.49 / 3.34 / 2.65**. `data-glass-under` is null. - Toast over a bright picture, Ink: **2.73 / 2.11 / 1.28**; Paper over black: 6.57 / 4.15 / **1.96**. Proposal: one hook in the shared material rather than per component. Put a `watchGlassSurfaces()` in `glass-under.ts`, started from `applyTheme`: one `MutationObserver` on `body` (childList, subtree, plus the `hidden` and `data-open` attributes) that calls `refreshGlassUnder` on each `.ui-overlay-surface, .ui-panel` when it appears or becomes visible, debounced to a frame. This also covers the sidebar and palette. For surfaces that stay while the canvas moves under them (sidebar), also re-check on the canvas camera `moveend`. Separate, found while measuring: the toast's filled "Undo" button is 2.73:1 on Ink regardless of backdrop (label `rgb(149,186,255)` on a `rgb(107,107,107)` fill). The solver's fill model has only hover, chip and selected, so filled action buttons are not modelled. **P2-b. The pixel spec's "Ink" and "Paper" are Mocha and Latte.** The spec only sets `colorScheme`, and the mockup then falls back to `mocha` / `latte` (`core-screen.ts:385`; overlay token on the "Ink" case was `rgb(44 44 59 / …)`, which is mocha's tint, not ink's `28 28 30`). The 68/68 and the 5.82 floor therefore do not test Ink or Paper, and J414's screenshots were Mocha/Latte. Add `&theme=ink` / `&theme=paper`. Unit audit and my reruns (with `theme=`) cover the real themes, and the picture cases I reran at the real themes passed for popovers (the floor works there). The themed set has no pixel test at all; add the nano-bars case above. **P2-c. Over a picture the three levels collapse.** The floor is ~0.87 (Paper) / 0.90 (Ink): in the screenshots below the Tinted, Clear and Frosted columns over a light or dark picture are indistinguishable, and the card reads near-opaque. Same as round-1 P1.2 (D160/D375: overlays are never opaque unless Solid). The "levels stay distinct" assertion holds only for the themed set. This is an owner call; please make the numbers visible to the owner (Ink 0.90/0.90/0.90, Paper 0.87/0.87/0.87). ### Check 4: critical JS headroom (2,563 B) Keep the solver eager. A first-paint surface is a glass card: the sign-in and setup cards (`auth-card ui-overlay-surface`) are the first thing a signed-out person sees, so an on-demand solver would flash unrepaired text there and then swap colours when the chunk lands. Moving only the floor (`glassFloor` + drift logic) saves little because the solver code is shared. If headroom is needed later, the cheap wins are elsewhere (e.g. solve three levels, since Solid keeps alpha 1). I did not rerun the size gate; I relied on the reported +2,193 B / 2,563 B left (cap 327,680; arithmetic consistent). ### Check 5: TASTE look (Ink and Paper, real themes; 1440×900 and 393×852) Sheets: rows = terminal / light picture / dark picture, columns = Tinted / Clear / Frosted. Terminal row = a dense synthetic terminal behind the real popover. - Terminal row: good. The levels are visibly different, text is crisp, with no ghost text. Paper Frosted over inverse rows is the weakest (the light bar shows through under the "F7 to F12" keys). - Picture rows: the three columns look the same (P2-c). On Paper over a dark picture the card is a pale grey slab with no see-through, flat and a little heavy but readable and tidy, not muddy as in round 1. Paper phone sheet shows the same. - No bevels, borders or alignment problems seen; the rows in the popover keep their left edge. ### P3 - `MAX_SHIFT`, `MAX_DRIFT`, the coverage sweep and the fill alphas live in code comments plus `coverage-sweep.txt`; put the sweep table in `THEMED_COVERAGE`'s doc so the next reader sees the cost per bound. - `pictureUnder` samples only four corners and the centre: a picture strip crossing the card between those points (a thin video letterbox) is missed. ### Evidence Attached: `sheet-1440x900-ink.png`, `sheet-1440x900-paper.png`, `sheet-393x852-ink.png`, `sheet-393x852-paper.png`, `ink-card4-nano-bars.png` (the 2.35:1 case), `palette-ink-white.png`. Raw: `~/.cache/codex-scratch/j448-rev2/evidence/{term,gap,look}` on dev-02 (`term.jsonl` has every row of the table).
Sign in to join this conversation.
No labels
launch-qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/infraternal#448
No description provided.