Skip to content

feat(client): in-app acrylic material for overlay panels (UI/UX v3 P2b) - #74

Merged
mizu-jun merged 17 commits into
masterfrom
worktree-p2b-in-app-acrylic
Aug 27, 2026
Merged

feat(client): in-app acrylic material for overlay panels (UI/UX v3 P2b)#74
mizu-jun merged 17 commits into
masterfrom
worktree-p2b-in-app-acrylic

Conversation

@mizu-jun

Copy link
Copy Markdown
Owner

Implements P2b in-app acrylic from the UI/UX v3 plan: overlay panels — dialogs, pickers, the settings panel and the tooltip — stop being flat opaque fills and instead sample a blurred, tinted copy of the terminal content behind them, Fluent-style.

Design spec: docs/superpowers/specs/2026-08-22-p2b-in-app-acrylic-design.md
Plan: docs/superpowers/plans/2026-08-22-p2b-in-app-acrylic.md

Ships opt-in and off by default (window.in_app_blur_enabled = false), because none of it has been seen on real hardware — see "What is not verified" below.

What is in here

Area Change
Config window.in_app_blur_enabled (bool, default false) and window.in_app_blur_strength (f32 0.0..=1.0, default 0.6), documented in docs/CONFIGURATION.md
Capture AcrylicCaptureState — a pure, unit-tested invalidation state machine keyed on an overlay-open count and a generation counter bumped on resize and DPI change
Blur Dual-Kawase chain (2 downsamples, 2 upsamples) in a new WGSL shader, with the CPU-side tap-offset math kept as pure functions so the kernel's geometry is testable without a GPU
Vertex path BgVertex gains an 8th attribute acrylic_mix; BG_SHADER samples the blurred texture only when it is non-zero
Render loop render_frame captures the pre-overlay background range to an offscreen target, runs the blur chain, updates the composite uniform, then draws overlays — all in one encoder, ordered so nothing samples a texture before it is written
Panel fills draw_overlay_panel takes an explicit acrylic_mix, threaded to all ten call sites plus the tooltip's inlined chrome. Shadows and border rings stay opaque; only the fill is translucent
Settings UI A toggle and a strength slider on the Window tab, wired through SettingsPanel to the toml_edit write-back, with keyboard arrow support matching every sibling row
i18n Four keys × eight locales

A spec violation the tests caught

The contrast test in this PR is what surfaced it. The shader's tint uniform was being fed the user's in_app_blur_strength at the same time as the vertex's acrylic_mix, which makes the composite

final = S + m(1 - m)(B - S) + m·grain

— a curve whose deviation coefficient is zero at both ends. Turning the strength slider to maximum therefore produced a fully opaque panel again, the exact opposite of what the spec requires ("0.0 = fully the existing opaque token fill, 1.0 = fully blur+tint"), and it would have shipped as a user-facing slider whose maximum setting means "no effect".

Fixed by decoupling: the tint amount is now a fixed ACRYLIC_TINT_OPACITY = 0.85 (Fluent's in-app-acrylic tint opacity), leaving final = S + m(1 - T)(B - S) — monotonic in the slider, with at most 15% of the backdrop showing through at maximum.

Readability

panel_body_text_clears_contrast_floor_across_acrylic_strengths asserts the project's 4.5:1 floor for text_primary and text_secondary across all nine built-in schemes at five strengths, modelling the shader's arithmetic rather than an approximation of it.

Two deliberate scope limits, both documented in the test's own doc comment so they cannot be quietly "fixed" later:

  • The backdrop is modelled as surface_0/surface_1 — what the grid layer actually paints — not as pure black/white. No non-zero blur can satisfy the pure-extreme bound on this palette set, because Nord's text_secondary has 0.02:1 of baseline headroom; asserting it would measure the themes' lack of headroom rather than acrylic's readability. A program painting a full-screen high-luminance background behind a panel can push body text below the floor at mid-to-high strengths. Measured, accepted for an opt-in feature, logged to the backlog.
  • The ±1.5% procedural grain is excluded, because it is a zero-mean per-pixel dither and WCAG contrast is a property of the mean background.

Unrelated pre-existing finding: Solarized and OneDark already fail that 4.5:1 floor for panel body text over surface_2 with no acrylic involved (text_primary 3.33 / 4.48, text_secondary 2.61 / 3.38). They are pinned in an explicit allow-list carrying those measured baselines, so the test fires if the tokens are ever fixed without updating it. Worth its own issue.

What is not verified

There is no GPU in the environment this was built in. Nothing here has rendered a frame. Every wgpu resource, bind-group/pipeline-layout pairing, uniform byte layout and render-pass ordering decision was checked by reading the code, never by executing it. The on-device verification backlog in docs/plans/ui-ux-modernization-v3.md carries the specifics: blur quality and tap radius, the carried-over P2a focus-ring double-blend risk, frame-time cost of the extra offscreen pass on integrated GPUs, DPI/multi-monitor recapture, whether the noise reads as grain or banding, whether ACRYLIC_TINT_OPACITY = 0.85 is the right recipe, and whether the French label overflows its column.

The default-off path was specifically checked: panel_acrylic_mix is exactly 0.0 when disabled (unit-tested), the capture and blur block never executes, and the resize handler no longer allocates acrylic textures for users who never enable the feature.

Test plan

  • cargo test --workspace — 1737 passed, 0 failed, 6 ignored
  • cargo clippy -- -D warnings — clean workspace-wide
  • cargo fmt --check — clean
  • cargo test -p nexterm-config --test doc_matches_schema — the new config keys are documented
  • cargo test -p nexterm-i18n — locale key parity across all 8 files
  • Cargo.lock unchanged, so pkg/flatpak/cargo-sources.json needs no regeneration
  • On-device visual verification — not done, see above

Notes for review

  • Two (PR pending) placeholders in docs/plans/ui-ux-modernization-v3.md need this PR's number filled in.
  • The plan intended two PRs, split at d86d450 (engine, then settings UI). The whole-branch review's fix commit touches engine-side files but lands after the settings commits, so the split is no longer a clean prefix; separating them now would mean reordering history. Landing as one PR instead.
  • ja uses 「ブラー」; 「ぼかし」 may read better — a one-line change in ja.json if preferred.

🤖 Generated with Claude Code

mizu-jun and others added 17 commits August 22, 2026 00:44
Covers the offscreen scene_color capture + Kawase blur chain, the
P2b-scoped config surface (in_app_blur_enabled/strength, deferring the
window.backdrop enum to P2c), settings panel UI, and the machine-
verifiable test strategy given no GPU is available in this environment.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
14 tasks across two PRs: PR1 ships the offscreen capture + Kawase blur
engine, panel-fill sampling across all three elevation tiers, and the
config-only opt-in toggle (default off, unverified on real GPU); PR2
exposes it on the Window settings tab plus the 8-locale strings.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
…DER (P2b)

Adds the 8th BgVertex attribute (acrylic_mix) and the matching WGSL
group(0) bindings (acrylic_tex/acrylic_sampler/AcrylicUniform) so
BG_SHADER can mix in a blurred/tinted scene sample when acrylic_mix > 0.

The acrylic bind group layout is created once in WgpuState::new and
stored as a WgpuState field (acrylic_bind_group_layout) rather than
being rebuilt independently in shader_reload.rs's hot-reload path, so
the pipeline layout and the AcrylicState bind group a later task builds
are guaranteed to share the same layout object.

Also updates the two BgVertex literal construction sites in
vertex_util.rs (plain rect and gradient rect) to set acrylic_mix: 0.0 --
neither is wired to acrylic yet, that lands in a later task.
…P2b)

Adds count_open_overlays (pure, unit-tested) and uses it to drive
AcrylicCaptureState per frame; inserts the offscreen scene-color
capture + 4-pass Kawase blur chain before main_render_pass so
blurred_result is ready by the time overlay panels could sample it;
binds AcrylicState's composite bind group before every bg_pipeline
draw call now that the pipeline layout requires it.

Also closes a gap left by Task 6: AcrylicState had no bind
groups/uniform buffers for run_blur_chain's 4 render passes to read
from, only the final composite bind group. Adds BlurReadResources (3
instances covering all 4 passes, half_res's shared by two of them) and
threads a wgpu::Queue through AcrylicState::new/ensure_size to write
their texel_size uniforms. Removes the now-provably-unnecessary
blur_pipeline_layout field (never read after pipeline construction,
matching how bg/text/image pipeline layouts are already local-only)
and removes the module-level dead_code allow now that AcrylicState and
AcrylicCaptureState are both live.
…s (P2b)

Thread a panel_acrylic_mix scalar (derived from window.in_app_blur_enabled /
in_app_blur_strength) from render() down to every overlay panel's background
fill and the settings-panel tooltip, so panels sample the blurred scene
behind them instead of always painting an opaque surface_2 fill. The shadow
and border ring stay opaque; only the fill layer opts into acrylic.

draw_overlay_panel gains an acrylic_mix parameter, used only by its panel
background pass via the new add_px_rounded_rect_sdf_with_acrylic. All 10
draw_overlay_panel call sites (dialog.rs x5, picker.rs x4, settings/mod.rs
x1) and the tooltip's inlined chrome thread the value through explicitly,
since none of these builder methods have access to Config.
…ath (P2b review fix)

Review round 1 findings on e0e76b1, both adjudicated by the coordinator:

- add_px_rounded_rect_sdf now delegates to add_px_rounded_rect_sdf_with_acrylic
  with acrylic_mix=0.0 instead of duplicating its coordinate-transform body.
  Signature and all ~15 call sites are unchanged.
- Extracted the in_app_blur_enabled ? strength : 0.0 decision into a pure
  acrylic::panel_acrylic_mix(enabled, strength) function, unit-tested for
  both branches (including a non-zero strength while disabled still yielding
  exactly 0.0), since render() itself is not testable without a GPU.
…9 schemes (P2b)

Also decouples the acrylic uniform's tint opacity from the user's blur
strength (ACRYLIC_TINT_OPACITY, fixed at 0.85) so the blend is monotonic
in in_app_blur_strength as the design spec requires, instead of folding
back to the opaque fallback at maximum strength.
…gs tab (P2b)

Adds two new Window-tab rows (toggle + slider) backed by the P2b acrylic
engine's existing config keys (window.in_app_blur_enabled /
in_app_blur_strength). Wires them through SettingsPanel state, the
widget-desc layer (label/kind/apply_window_action), TOML write-back, and
the physical arrow-key handler (window_field_increase/decrease), so the
new rows are reachable by mouse, keyboard and AccessKit exactly like
every sibling row.

Also updates settings/row_filter.rs::window_row_labels (not listed in
the task brief) since WINDOW_ROW_COUNT now disagrees with it otherwise;
see the task report for details. Locale keys referenced here are added
by Task 13.
- Stop reallocating the 4 acrylic offscreen textures and 3 BlurReadResources
  on every WindowEvent::Resized regardless of whether the feature is used.
  render_frame's own dirty-gated ensure_size call already sizes them to the
  current surface_config dimensions before anything samples blurred_result,
  on both the enable-at-runtime and resize-while-enabled paths, and
  ensure_size early-returns when the size already matches — so the call in
  WgpuState::resize was pure waste on the default-off path. resize() still
  invalidates the capture unconditionally via note_resize().
- Invalidate the acrylic capture on WindowEvent::ScaleFactorChanged too
  (AcrylicCaptureState::note_resize's own doc contract already covered DPI
  changes; the call site just never existed).
- Remove the now-stale #[allow(dead_code)] and "Task 6"/"a later task"
  comments on KAWASE_BLUR_SHADER and acrylic_bind_group_layout — both were
  wired in within this same branch.
- Correct comments/docs that described the acrylic capture as "the grid
  layer's cell backgrounds only": it is bg_pipeline's full pre-overlay
  range (cell backgrounds, gradient, chrome bars, pane/copy-mode overlays),
  excluding the overlay layer, background image, and text glyphs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
@github-actions

Copy link
Copy Markdown

Coverage report


Generated by cargo llvm-cov (workspace minus nexterm-client-gpu and nexterm-i18n).

1 similar comment
@github-actions

Copy link
Copy Markdown

Coverage report


Generated by cargo llvm-cov (workspace minus nexterm-client-gpu and nexterm-i18n).

@mizu-jun
mizu-jun merged commit b9893c4 into master Aug 27, 2026
12 checks passed
@mizu-jun
mizu-jun deleted the worktree-p2b-in-app-acrylic branch August 27, 2026 22:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant