Skip to content

feat(app): glassmorphism — frosted chrome over a see-through window - #49

Merged
simota merged 9 commits into
mainfrom
feat/glassmorphism
Jul 26, 2026
Merged

feat(app): glassmorphism — frosted chrome over a see-through window#49
simota merged 9 commits into
mainfrom
feat/glassmorphism

Conversation

@simota

@simota simota commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Summary

Adds glassmorphism: an opt-in config key (default false) 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-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.

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

  • Config (ff47979): the key, the transparency takeover, and configured_background_* so turning it back off returns to what the user asked for rather than the values the toggle derived
  • Render (1c45f2c, 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 padding
  • App (13bfd6b, 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 erased
  • Legacy Japanese UI strings translated to English (a71f480, separate leading commit — unrelated to the rest, safe to review first and forget)

Test plan

  • cargo clippy --workspace --tests --offline: clean, zero warnings
  • cargo 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)
  • 44 new tests, 31% of the diff. Notable: a headless GPU regression asserting a card's glow leaves the backdrop alpha intact under alpha-replace, one asserting the overlay surface alpha is uniform across a cell-covered pixel and a padding pixel, and one pinning the selected row's colour against the sRGB-vs-linear blend shift
  • Manual GUI pass still pending — the entire visual outcome is unverified on a real display. Worth checking with glassmorphism = true and background-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 preview

Notes

Intermediate commits do not build standalone: the noa-app changes are a single compile unit (select_palette / needs_macos_titlebar_backdrop signatures, OverviewChromeCardPipeline.glass and OverviewPillKey.chrome_pill fields 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.

simota added 9 commits July 26, 2026 09:39
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread crates/noa-app/src/app/config_reload.rs Outdated
false,
noa_config::ConfigOverrides::default(),
);
assert!(!base.glassmorphism, "test assumes the documented default-off start");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@simota
simota force-pushed the feat/glassmorphism branch from 41486d0 to 3b71be9 Compare July 26, 2026 01:03
@simota
simota merged commit d8ff615 into main Jul 26, 2026
1 check passed
@simota
simota deleted the feat/glassmorphism branch July 26, 2026 01:06
@simota simota mentioned this pull request Jul 27, 2026
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