feat(app): glassmorphism — frosted chrome over a see-through window - #49
Conversation
The sidebar's relative-time column and attention label were the only Japanese strings left in the UI, so a card read `3分前` next to an English process name and branch. Every other noa string is fixed English; these predate that settling. Translate them, and keep the result inside the column they have to fit in. `CARD_UPDATED_W` affords about eleven cells, which is why the forms are abbreviated — `Yday 23:47` rather than the spelled-out `Yesterday`, `Nm ago` rather than `N minutes ago`. An overlong form is drawn last and silently eats into the card's name, so pin the widest value each branch can produce with a test rather than leaving the fit to inspection. - Add a fixed month-abbreviation table for the older-than-yesterday form; a locale lookup would be the odd one out here - Update the two specs that quote the attention label verbatim
`glassmorphism` asks noa to render its own chrome — the session sidebar, the tab overview, the modal cards — as frosted glass rather than opaque panels. Default off, so nothing changes for anyone who does not ask. The toggle takes `background-opacity` and `background-blur-radius` over outright rather than composing with them. Frosted chrome over an opaque window is a no-op: there is nothing behind it to show through, which is the whole of the "glassmorphism does nothing" report this resolves. So the two keys are not really independent of the toggle, and honoring a configured `1.0` alongside it would only reproduce that. Applied once at the end of resolution, so every consumer — window transparency at creation, the renderer, the macOS blur, the Settings panel, the `config` dump — sees the same values on startup and on every live reload alike. - Record the pre-takeover pair as `configured_background_*` so turning the toggle back off returns to what the user actually asked for, not to the values the toggle derived - Expose `resolved_background_opacity` / `resolved_background_blur_radius` so a caller needing the effective value ahead of the next resolution pass cannot drift from the rule - Name the silenced keys in a diagnostic, but only when they were explicitly set — an untouched key is a default, not a conflict
The overlay style hands out fixed opaque colors, which is the right answer for every caller today and the wrong one the moment the chrome is meant to be frosted. Rather than thread a "glass?" flag through every overlay draw, put the alpha in the palette: the surface fills carry `overlay_surface_alpha()`, and everything else — text, borders, accents — stays opaque. Set to `1.0` by default, so the value is a no-op until something installs a frosted palette: same colors, same draw calls, byte-identical output on the opaque path. Deliberately scoped to the surface fills. A frosted panel whose text and rim also faded would not read as glass, it would read as a panel someone turned the opacity down on.
The chrome palette gains the three alphas the frosted variant needs — backdrop, surface, pill — and a `glassify()` that derives them, plus a frosted rim mixed toward the foreground. The opaque palettes hold all three at `1.0`, so with the toggle off no draw call, texture or pass is added anywhere; the values simply are what they always were. The blur itself is the window's, not ours: the macOS backdrop already exists for `background-blur-radius`, and glass reuses it. What is new is the titlebar, which needs its own backdrop view once the window is see-through under a transparent titlebar style, and needs it removed again when it stops being needed. - Select the palette from the theme's polarity so the chrome follows a light or dark theme without a second knob - Route a reloaded toggle through the theme apply: it is the one path that re-selects the palette, drops the textures painted with the old one, and refreshes the native window backgrounds together - Hint at startup when the toggle is on but `background-opacity` leaves nothing behind the chrome to show through - Balance the backdrop helper's NSView reference count; the earlier shape leaked a retain on every reinstall
The card composite assumed the surface underneath it was opaque, which holds right up until the chrome is frosted. Three things break at once there, and they are the same shape: a blend that was a no-op against `alpha = 1` stops being one. Alpha-replace, for the face. Ordinary blending *adds* alpha at every layer, so a card drawn over a translucent backdrop lands denser than either, and each extra pass a hover ring or a zoom draws over the same tile pushes it further — the glass would visibly thicken with whatever the pointer is doing. Writing each surface's own alpha keeps the density a property of the palette instead. Destination alpha, for the glow. The focus ring's own falloff must not be *written* into the destination the way the face's is: under alpha-replace that punches a fading transparent halo into the backdrop around every selected, attention-flagged or zoomed tile. Split it into its own fragment entry point so it can blend with the alpha channel held, and draw the two with separate pipelines. Border opacity, for the rim. The stroke keeps its own alpha rather than inheriting the sampled surface's — over a frosted card the rim is exactly the part that has to stay solid to hold the card's edge against whatever shows through it. - Thread the blend through the Overview's thumbnail resources and card pipeline, and key both on it: a pipeline's blend state is fixed at creation, so a toggle has to rebuild rather than reuse the cached one - Cover all three headlessly, including a card drawn over a known translucent backdrop whose alpha must survive the glow beside it
The command palette and the confirm dialog emitted a background quad for every cell of every row, colored with the palette's surface color — and that color now carries the surface alpha. Drawn over a scratch texture already cleared to the same alpha, ordinary blending applies it a second time, so the card interior lands far denser than the palette asked for, and denser than its own padding rim, which no cell covers. Make the clear the sole carrier: a row whose background *is* the block's surface color draws no quad at all. Only the palette's selected row still fills, so it is the only row that can differ from the clear. That row cannot land on the face alpha exactly — over-blending strictly raises alpha for any non-zero source, so hitting it would need a destination-alpha-preserving pass ordered between a row's background and its glyphs, and background-before-glyph is a single-pass, whole-pane invariant of the shared renderer. Give the wash a fixed alpha independent of the surface's instead, which bounds the overshoot and is exactly opaque when the frosted palette is not installed. - Pre-compensate the wash color: the palette lerps 8-bit sRGB channels, but the blend hardware mixes in linear light on an sRGB target, which would shift the selected row visibly brighter on a dark theme. Solve for the quad value that reproduces the intended color post-blend; a light theme clamps, and keeps the smaller error - Retarget the unit tests that counted background quads as a proxy for "row rendered"; glyphs are the proxy now, and the quad counts are asserted exactly
Point the sidebar and the tab overview at the palette's alphas — panel backdrop, card faces, title bands, pills — so the chrome picks up the frosted variant wherever it paints a surface, and stays byte-identical under the opaque one. Two ordering details this depends on. The overlay scratch textures set their clear color *after* rebuilding cells, because the rebuild resets it from the snapshot's opaque background and would otherwise discard the alpha entirely. And the modal card composites its scratch once: the shadow and border passes used to each blend the real surface, which is invisible while it is opaque and turns a frosted card nearly solid.
Expose the toggle in the Settings panel, and make the panel honest about what turning it on does to the two rows it takes over. While glass is on, `background-opacity` and `background-blur-radius` display the values it resolves to rather than the user's, so the panel never shows an opacity the running config will not have. Those two rows stay untouched while glass owns them. The toggle is what gets written and the resolver derives the pair from it; writing them too would bake a redundant pair of keys into the config file that goes stale the moment glass is turned back off. Stash what they held so switching back within the same session hands them straight back. Turning it on in a session that started opaque carries a restart note: a window created opaque cannot become see-through in place. - Cover the undo path and the tab carryover, where the configured pair has to survive a toggle that never wrote it
A live reload re-selects the chrome palette, drops the textures painted with the old one, and refreshes the native window backgrounds — the whole apply for a toggle edited in the config file. A toggle committed from the Settings panel does not get that: the commit mirrors the new value into the running config, which the palette selection right after it reads, and that leaves the reload's own diff comparing the value against itself. So the panel has to drive the rest itself. The palette and textures were already handled; the native window background and titlebar backdrop were not, and nothing else removes a backdrop that has stopped being needed — the render path installs one but never takes one away. A transparent titlebar over a background image would keep its frosted strip after the toggle went back off. Derive the opacity to refresh at rather than reading it back: the toggle also decides `background-opacity`, but that happens during resolution, so at commit time the running value is still the pre-toggle one. - Keep `configured_background_*` in step when those rows are committed directly; the derivation reads them, and a stale twin refreshes the native background at the previous opacity, undoing on save what the live preview had just got right - Restore both twins on undo, symmetrically
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 41486d03a7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| false, | ||
| noa_config::ConfigOverrides::default(), | ||
| ); | ||
| assert!(!base.glassmorphism, "test assumes the documented default-off start"); |
There was a problem hiding this comment.
Format the changed Rust sources
cargo fmt --all -- --check exits with status 1 on this commit, including this assertion and changes in noa-config and noa-render, so the patch fails the repository's required formatting validation. Run cargo fmt --all and commit the resulting formatting changes.
AGENTS.md reference: AGENTS.md:L32-L36
Useful? React with 👍 / 👎.
| gpu.theme = new_theme; | ||
| gpu.preview_theme = None; | ||
| crate::chrome::select_palette(gpu.theme.is_light()); | ||
| crate::chrome::select_palette(gpu.theme.is_light(), glassmorphism); |
There was a problem hiding this comment.
Defer glass changes for opaque-created windows
When the settings panel belongs to a window whose created_transparent flag is false, the UI classifies enabling Glassmorphism as an OnLaunch change because that AppKit window cannot become translucent in place. Nevertheless, the commit path mirrors the toggle and this call immediately installs the translucent chrome palette; the expedited reload then also lowers the renderer opacity and enables blur. The result is a partially applied, darkened glass appearance that cannot reveal the desktop until restart, contradicting the restart-only behavior shown to the user. Persist the setting without changing the runtime palette/transparency state for opaque-created windows.
Useful? React with 👍 / 👎.
41486d0 to
3b71be9
Compare
Summary
Adds
glassmorphism: an opt-in config key (defaultfalse) that renders noa's own chrome — session sidebar, tab overview, modal cards — as frosted glass over a see-through window, instead of opaque panels. The blur is the window's existing macOS backdrop, reused; what is new is that every chrome surface now carries an alpha from the palette rather than being opaque by construction.The toggle takes
background-opacityandbackground-blur-radiusover outright rather than composing with them. Frosted chrome over an opaque window is a no-op — there is nothing behind it to show through — which is the whole of the "glassmorphism does nothing" report this resolves.With the toggle off, the palette's three alphas are all
1.0: no draw call, texture, or pass is added anywhere, and the opaque path is byte-identical to today.Changes
ff47979): the key, the transparency takeover, andconfigured_background_*so turning it back off returns to what the user asked for rather than the values the toggle derived1c45f2c,9c0566a,9a123a5): a configurable surface alpha on the overlay palette; card compositing that survives a translucent surface — alpha-replace for the face so the glass does not thicken with hover/attention/zoom state, destination-alpha preservation for the focus glow so it does not punch a transparent halo, and border opacity so the rim still holds the card's edge; and a fix for the overlay surface alpha being applied twice (clear + per-cell quad), which left the palette and confirm dialog near-solid and denser than their own padding13bfd6b,d8ae71d,42ea0b0,41486d0): the chrome palette and its frosted variant, the macOS titlebar backdrop (install and remove — nothing removed one that had stopped being needed), sidebar/overview surfaces, the Settings row, and applying a panel-committed toggle without waiting on a reload whose diff the commit has already eraseda71f480, separate leading commit — unrelated to the rest, safe to review first and forget)Test plan
cargo clippy --workspace --tests --offline: clean, zero warningscargo test --workspace --offline: 34 suites green, 0 failures (run with the sandbox disabled — the headless GPU tests silently report a false "ok" when no adapter is available)glassmorphism = trueandbackground-opacity < 1.0: the five overlays read as frosted and uniform, no transparent halo around a selected/attention/zoomed tile, the selected palette row keeps its usual colour, the titlebar's frosted strip disappears when the toggle goes off, and saving an opacity change with the toggle off does not revert the live previewNotes
Intermediate commits do not build standalone: the noa-app changes are a single compile unit (
select_palette/needs_macos_titlebar_backdropsignatures,OverviewChromeCardPipeline.glassandOverviewPillKey.chrome_pillfields are referenced across 15+ files), so splitting per concern and keeping each commit buildable are mutually exclusive here. Split per concern, since main is squash-merged and the intermediates never land.