Skip to content

Commit 76be4f8

Browse files
committed
UI: liquid-glass translucency on the main window
- Enable `tauri/macos-private-api` + window `transparent: true` so the WKWebView's `drawsBackground` can be turned off and the macOS `NSVisualEffectView` (Sidebar material) renders behind it. `tauri.conf.json` declares the `windowEffects` and `backgroundColor: [0,0,0,0]` - Three new translucent bg tokens (light + dark): `--color-bg-primary` (0.85 light / 0.93 dark), `--color-bg-secondary` (0.5 light / 0.7 dark), `--color-bg-info-bar` (0.5 / 0.7). Light-mode `--color-selection-fg-cursor` darkened from `#b80808` to `#a30000` so the brown-tint + cursor-active + purple-accent corner of `row_state_matrix.go` stays at WCAG AA when `bg-primary` drops to 0.85 - `app.html` and `app.css` no longer paint an opaque `--bg` on `html` / `body` / `#loading-screen` — those were covering the visual-effect view on every paint - One single pane base layer painted on `.file-pane > .content`. Removed the per-row `bg-primary`, the `row-filler` / `column-filler` / horizontal-filler structural attempts in `FullList` / `BriefList`, and the `min-height` calc on `.listbox-region` — `.content` is the only ancestor mounted continuously across every dynamic state (loading, error, MTP, file list, etc.), so it's guaranteed exactly one base layer on every pane pixel, never zero (no transition flicker), never two (no double-paint) - `--color-bg-stripe` switched to an opaque `#ffffff` / `#1e1e1e` base so striped rows replace the pane bg as a single opaque layer instead of stacking a second translucent layer - Dev-mode / E2E-mode title-bar tint alpha dropped from 60% to 25% so the title bar reads as glass like the rest of the chrome while the hue still signals DEV / E2E - New `appearance.translucency` setting (Settings > Appearance > Colors and formats, on by default, label "Translucency", desc "Liquid glass-y look if also enabled in your System Settings"). Wired through `settings-applier.ts` to a `data-translucency` attribute on `<html>`; `:root[data-translucency='off']` flips the three bg tokens back to fully opaque so the webview covers the visual-effect view
1 parent 79ed3b6 commit 76be4f8

12 files changed

Lines changed: 167 additions & 36 deletions

File tree

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
3333
tauri-build = { version = "2", features = [] }
3434

3535
[dependencies]
36-
tauri = { version = "2", features = [] }
36+
tauri = { version = "2", features = ["macos-private-api"] }
3737
tauri-plugin-opener = "2"
3838
tauri-plugin-mcp-bridge = "0.11"
3939
tauri-plugin-store = "2"

apps/desktop/src-tauri/tauri.conf.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"app": {
1313
"withGlobalTauri": false,
14+
"macOSPrivateApi": true,
1415
"windows": [
1516
{
1617
"title": "Cmdr",
@@ -25,7 +26,13 @@
2526
"y": 17
2627
},
2728
"hiddenTitle": true,
28-
"acceptFirstMouse": true
29+
"acceptFirstMouse": true,
30+
"windowEffects": {
31+
"effects": ["sidebar"],
32+
"state": "followsWindowActiveState"
33+
},
34+
"backgroundColor": [0, 0, 0, 0],
35+
"transparent": true
2936
}
3037
],
3138
"security": {

apps/desktop/src/app.css

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,20 @@
1212
color-scheme: light dark;
1313

1414
/* === Backgrounds === */
15-
--color-bg-primary: #ffffff;
16-
--color-bg-secondary: #f5f5f5;
15+
/* Background tokens are intentionally semi-transparent so the macOS
16+
`NSVisualEffectView` (Sidebar material, applied via
17+
`tauri.conf.json` › `windowEffects`) shows through with a subtle
18+
wallpaper-blur — Finder-style "liquid glass". Tuned at ~88% so text
19+
contrast against the underlying material still clears WCAG AA in both
20+
light and dark wallpapers; verified by `a11y-contrast`. */
21+
--color-bg-primary: rgba(255, 255, 255, 0.85);
22+
--color-bg-secondary: rgba(245, 245, 245, 0.5);
1723
--color-bg-tertiary: #e8e8e8;
1824
/* Status-bar surface that contrasts gently with `--color-bg-secondary`.
1925
Light: a touch darker than secondary. Dark: a touch lighter. Used by
2026
`SelectionInfo` so the status band reads as distinct from the
2127
function-key bar below (which keeps `--color-bg-secondary`). */
22-
--color-bg-info-bar: #ececec;
28+
--color-bg-info-bar: rgba(236, 236, 236, 0.5);
2329

2430
/* === Borders === */
2531
--color-border-strong: #bbb;
@@ -111,8 +117,14 @@
111117
--color-toast-success-stripe: #16a34a;
112118
--color-toast-success-bg: #f0fdf4;
113119

114-
/* === Striped rows (zebra striping for file lists) === */
115-
--color-bg-stripe: color-mix(in oklch, var(--color-bg-primary), var(--color-text-primary) 4%);
120+
/* === Striped rows (zebra striping for file lists) ===
121+
Uses an OPAQUE `#ffffff` base (not `var(--color-bg-primary)`, which is
122+
now translucent for the macOS vibrancy backdrop). Striped rows are a
123+
visual replacement of the base pane bg, so they should be a single
124+
opaque layer rather than a second translucent layer stacked on top of
125+
`.content`'s `bg-primary` — that would double the alpha (~99.5%) and
126+
produce a visible darker stripe vs non-stripe rows. */
127+
--color-bg-stripe: color-mix(in oklch, #ffffff, var(--color-text-primary) 4%);
116128

117129
/* === Volume tint palette (12 hues + none) ===
118130
The 100% saturated swatches shown in the Settings color picker for
@@ -154,7 +166,7 @@
154166
* them lives further down (search for "selection-fg fallback"). The
155167
* contrast checker's `row_state_matrix.go` synthesizer mirrors it. */
156168
--color-selection-fg-primary: #cc0000;
157-
--color-selection-fg-cursor: #b80808;
169+
--color-selection-fg-cursor: #a30000;
158170
--color-selection-fg-fallback: var(--color-text-primary);
159171
--color-selection-fg: var(--color-selection-fg-primary);
160172
/* Selection background and hairline border applied to selected rows in
@@ -358,6 +370,19 @@
358370
--color-age-old: var(--color-text-secondary);
359371
}
360372

373+
/* === Translucency: Off ===
374+
When the user disables `appearance.translucency` (Settings > Appearance >
375+
Colors and formats), the three translucent bg tokens snap to fully opaque
376+
values so the webview covers the macOS `NSVisualEffectView` Sidebar
377+
material that sits behind it. The visual-effect view itself is set up
378+
statically in `tauri.conf.json` and stays alive — disabling it at runtime
379+
would need a Tauri command — but the opaque webview hides it. */
380+
:root[data-translucency='off'] {
381+
--color-bg-primary: #ffffff;
382+
--color-bg-secondary: #f5f5f5;
383+
--color-bg-info-bar: #ececec;
384+
}
385+
361386
/* selection-fg fallback: dark mode + pane tint + focused cursor on a selected
362387
row. In this state the row bg becomes the translucent yellow accent
363388
composited over a tinted dark surface — Y around 0.09. Even the brightest
@@ -459,12 +484,15 @@
459484
@media (prefers-color-scheme: dark) {
460485
:root {
461486
/* === Backgrounds === */
462-
--color-bg-primary: #1e1e1e;
463-
--color-bg-secondary: #2a2a2a;
487+
/* Semi-transparent for the same Sidebar-material reason as light mode.
488+
Slightly heavier alpha than light mode because dark backgrounds need
489+
more body to keep text contrast against bright wallpapers. */
490+
--color-bg-primary: rgba(30, 30, 30, 0.93);
491+
--color-bg-secondary: rgba(42, 42, 42, 0.7);
464492
--color-bg-tertiary: #333333;
465493
/* Dark mode: lighter than secondary (opposite of light mode) so the
466494
status band lifts away from the function-key bar below. */
467-
--color-bg-info-bar: #323232;
495+
--color-bg-info-bar: rgba(50, 50, 50, 0.7);
468496

469497
/* === Borders === */
470498
--color-border-strong: #555555;
@@ -547,6 +575,12 @@
547575
--color-git-portal-text: #80deea;
548576
--color-git-portal-subtle: color-mix(in oklch, var(--color-git-portal), transparent 85%);
549577

578+
/* === Striped rows (dark) ===
579+
Same logic as light mode: opaque base so striped rows are a single
580+
layer that replaces the pane's translucent backdrop, not a second
581+
translucent layer that doubles its alpha. */
582+
--color-bg-stripe: color-mix(in oklch, #1e1e1e, var(--color-text-primary) 4%);
583+
550584
/* === Search Highlight === */
551585
--color-highlight: rgba(255, 213, 100, 0.9);
552586
--color-highlight-active: rgba(255, 150, 100, 0.9);
@@ -583,6 +617,15 @@
583617
--color-age-old: #d49858;
584618
}
585619

620+
/* === Translucency: Off (dark) ===
621+
Same logic as light mode (see the light-mode block for the full
622+
rationale): opaque values so the webview covers the visual-effect view. */
623+
:root[data-translucency='off'] {
624+
--color-bg-primary: #1e1e1e;
625+
--color-bg-secondary: #2a2a2a;
626+
--color-bg-info-bar: #323232;
627+
}
628+
586629
/* Old-WebKit fallback (dark mode). See the light-mode counterpart above for
587630
the full rationale. The rainbow size palette is the most visible case: it
588631
reaches *all* file lists once the user is in dark mode, so leaving it
@@ -912,7 +955,12 @@ body {
912955
width: 100%;
913956
height: 100%;
914957
overflow: hidden;
915-
background-color: var(--color-bg-primary);
958+
/* `html` must stay transparent so the macOS `NSVisualEffectView`
959+
(Sidebar material, set in `tauri.conf.json`) shows through. The
960+
opaque `--bg` in `app.html`'s inline critical CSS was the original
961+
blocker; this rule is the belt-and-braces override in case the
962+
inline style ever drifts. */
963+
background-color: transparent;
916964
color: var(--color-text-primary);
917965
/* Disable text selection globally */
918966
user-select: none;
@@ -921,6 +969,14 @@ body {
921969
cursor: default;
922970
}
923971

972+
/* `body` is intentionally transparent so the chrome bars (`.tab-bar`,
973+
`.title-bar`, `.header`, `.selection-info`, etc., each painted with
974+
`--color-bg-secondary` / `--color-bg-info-bar`) are the only layer between
975+
the user and the `NSVisualEffectView` (Sidebar material, set in
976+
`tauri.conf.json`). Without this, body's `--color-bg-primary` would paint
977+
underneath every bar, compounding alpha (1 − (1 − α_body) × (1 − α_bar))
978+
and pushing the chrome to ~99 % opaque. File rows have no bg of their own
979+
either, so the file list area shows pure vibrancy. */
924980
body {
925981
display: flex;
926982
flex-direction: column;

apps/desktop/src/app.html

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,19 @@
2828
}
2929
}
3030

31+
/* Background is intentionally NOT set here. The window uses an
32+
`NSVisualEffectView` (Sidebar material, configured via
33+
`tauri.conf.json` › `windowEffects` + `transparent: true`); the
34+
WKWebView is transparent (`drawsBackground: NO`), so any opaque
35+
color painted on `html` / `body` / `#loading-screen` would cover the
36+
vibrancy and break the liquid-glass effect. `app.css` paints the
37+
translucent `--color-bg-primary` on `body` once it loads. */
3138
html,
3239
body {
3340
margin: 0;
3441
padding: 0;
3542
width: 100%;
3643
height: 100%;
37-
background-color: var(--bg);
3844
color: var(--text);
3945
font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
4046
}
@@ -47,7 +53,6 @@
4753
align-items: center;
4854
justify-content: center;
4955
z-index: 9999;
50-
background-color: var(--bg);
5156
}
5257

5358
.app-loader {

apps/desktop/src/lib/file-explorer/pane/FilePane.svelte

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2624,6 +2624,16 @@
26242624
flex-direction: column;
26252625
/* Anchor for the type-to-jump indicator (absolutely positioned, bottom-right). */
26262626
position: relative;
2627+
/* The pane's single translucent base layer. `.content` is the only
2628+
ancestor that's mounted continuously across every dynamic state
2629+
(loading, error, MTP, file list, etc.), so painting it once here
2630+
guarantees a stable backdrop with no transition frame where the
2631+
vibrancy bleeds through. Downstream views (FullList / BriefList /
2632+
ErrorPane / …) keep their interior elements transparent so this
2633+
stays the single base layer — no doubling. Highlights (selection,
2634+
cursor) sit on top intentionally; their alpha is the tint, not a
2635+
second base layer. */
2636+
background-color: var(--color-bg-primary);
26272637
}
26282638
26292639
.error-message {

apps/desktop/src/lib/file-explorer/views/BriefList.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,8 @@
974974
padding: var(--spacing-xxs) var(--spacing-sm);
975975
gap: var(--spacing-sm);
976976
align-items: center;
977+
/* Rows are transparent; pane bg lives on `.file-pane > .content`.
978+
See FullList.svelte / FilePane.svelte for the rationale. */
977979
white-space: nowrap;
978980
overflow: hidden;
979981
}

apps/desktop/src/lib/file-explorer/views/CLAUDE.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,16 @@ compact/comfortable/spacious). The virtual scroll uses an `itemSize` parameter f
6969

7070
**Decision**: `FullList`'s column header lives **inside** the scroll container as a `position: sticky; top: 0;` child,
7171
not as a sibling above. **Why**: when the user has "Always show scrollbars" set (System Settings → Appearance),
72-
non-overlay scrollbars steal a ~15 px gutter from the scroll container. A sibling header rendering at the wrapper's
73-
full width then misaligned with the data rows below. Moving the header inside makes it share the row content width
72+
non-overlay scrollbars steal a ~15 px gutter from the scroll container. A sibling header rendering at the wrapper's full
73+
width then misaligned with the data rows below. Moving the header inside makes it share the row content width
7474
automatically (and therefore the scrollbar gutter), so columns line up at every scrollbar mode without JS measurement.
7575
The trade-off is virtual-scroll math: row positions are now `headerHeight` pixels into the scrollable content, so
76-
`FullList` derives `spacerScrollTop = max(0, scrollTop - headerHeight)` and `rowAreaHeight = containerHeight -
77-
headerHeight` and feeds those into `calculateVirtualWindow` / `getScrollToPosition` / `firstVisibleGlobalIndex` /
78-
`lastVisibleGlobalIndex` / `getVisibleItemsCountUtil`. `scrollToIndex` adds `headerHeight` back when writing to
79-
`scrollContainer.scrollTop`. A11y: the listbox role moves off `.full-list` (now a generic scroll container) onto a
80-
`.listbox-region` inner wrapper around `.virtual-spacer` so the sticky header isn't a direct child of the listbox
81-
(would violate `aria-required-children`).
76+
`FullList` derives `spacerScrollTop = max(0, scrollTop - headerHeight)` and
77+
`rowAreaHeight = containerHeight - headerHeight` and feeds those into `calculateVirtualWindow` / `getScrollToPosition` /
78+
`firstVisibleGlobalIndex` / `lastVisibleGlobalIndex` / `getVisibleItemsCountUtil`. `scrollToIndex` adds `headerHeight`
79+
back when writing to `scrollContainer.scrollTop`. A11y: the listbox role moves off `.full-list` (now a generic scroll
80+
container) onto a `.listbox-region` inner wrapper around `.virtual-spacer` so the sticky header isn't a direct child of
81+
the listbox (would violate `aria-required-children`).
8282

8383
**Decision**: Virtual scroll in frontend, data in backend **Why**: Sending 50k entries over IPC = 17.4MB, ~4s transfer.
8484
Virtual scroll fetches only visible ~50 items on demand. Backend-driven caching eliminates serialization overhead.

apps/desktop/src/lib/file-explorer/views/FullList.svelte

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -896,9 +896,7 @@
896896
</div>
897897
</div>
898898
{#if (hasParent ? totalCount - 1 : totalCount) === 0}
899-
<div class="empty-folder-message" style="height: {rowAreaHeight - (hasParent ? rowHeight : 0)}px;">
900-
Empty folder
901-
</div>
899+
<div class="empty-folder-message">Empty folder</div>
902900
{/if}
903901
</div>
904902
</div>
@@ -952,11 +950,30 @@
952950
/* Semantic wrapper for the listbox role; no visual styling. The class
953951
exists so the role + aria-activedescendant can sit on a child of the
954952
scroll container without violating aria-required-children (the sticky
955-
header is a sibling, not a child of this region). */
953+
header is a sibling, not a child of this region).
954+
`min-height: calc(100% - <header height>)` makes the listbox always
955+
span the rest of the pane below the sticky header, even when there
956+
are fewer rows than fit on screen — so the empty area is still part
957+
of the listbox (for hit testing / focus) while staying transparent
958+
(file rows paint the bg, not this wrapper). The header's own height
959+
is `calc(22px * var(--font-scale))`, mirrored here so subtracting it
960+
lands the listbox exactly flush with the scroll container's bottom
961+
at every text scale, with no spurious scrollbar. */
962+
/* Semantic listbox wrapper — no background, no stacking context. The
963+
pane bg lives on `.file-pane > .content` (see FilePane.svelte). */
956964
.listbox-region {
957965
outline: none;
958966
}
959967
968+
.empty-folder-message {
969+
display: flex;
970+
align-items: center;
971+
justify-content: center;
972+
flex: 1;
973+
color: var(--color-text-tertiary);
974+
font-size: var(--font-size-sm);
975+
}
976+
960977
.virtual-window {
961978
will-change: transform;
962979
}
@@ -967,6 +984,12 @@
967984
padding: var(--spacing-xxs) var(--spacing-sm);
968985
gap: var(--spacing-sm);
969986
align-items: center;
987+
/* Rows are transparent. The pane's base translucent layer lives on
988+
`.file-pane > .content` (see FilePane.svelte) — painting it once
989+
there is the single-source-of-truth approach: every pane pixel
990+
gets exactly one base layer, never zero (no flicker on state
991+
swap) and never two (no double-paint). Highlights (selection,
992+
cursor) keep their own bgs and sit on top as intentional tints. */
970993
/* Guarantee one visual line per row regardless of cell content length */
971994
white-space: nowrap;
972995
transition: grid-template-columns 300ms ease;
@@ -1292,12 +1315,4 @@
12921315
color: var(--color-text-primary);
12931316
}
12941317
1295-
.empty-folder-message {
1296-
display: flex;
1297-
align-items: center;
1298-
justify-content: center;
1299-
flex: 1;
1300-
color: var(--color-text-tertiary);
1301-
font-size: var(--font-size-sm);
1302-
}
13031318
</style>

apps/desktop/src/lib/settings/settings-applier.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ function applyDateColors(palette: DateColorsPalette): void {
5959
log.debug('Applied date colors palette: {palette}', { palette })
6060
}
6161

62+
/**
63+
* Toggles the window's translucent (macOS `NSVisualEffectView` Sidebar
64+
* material) backdrop by setting `data-translucency` on the root element.
65+
* `app.css` scopes the opaque-bg fallback to `[data-translucency='off']`.
66+
* The vibrancy material is configured statically in `tauri.conf.json` and
67+
* stays alive behind the webview either way; when this is off, the CSS
68+
* paints the webview's bg fully opaque so the material isn't visible.
69+
*/
70+
function applyTranslucency(enabled: boolean): void {
71+
document.documentElement.dataset.translucency = enabled ? 'on' : 'off'
72+
log.debug('Applied translucency: {enabled}', { enabled })
73+
}
74+
6275
/**
6376
* Density currently has no CSS-side effect: `--spacing-icon-size` is owned
6477
* by `app.css` as `calc(16px * var(--font-scale))`, and row height /
@@ -131,6 +144,9 @@ function applyAllSettings(): void {
131144
// Date age color palette
132145
applyDateColors(getSetting('appearance.dateColors'))
133146

147+
// Translucency (macOS vibrancy backdrop)
148+
applyTranslucency(getSetting('appearance.translucency'))
149+
134150
// Theme (light / dark / system). Must run at startup or windows that open
135151
// before the user touches Settings will flash the wrong theme.
136152
void applyTheme(getSetting('theme.mode'))
@@ -185,6 +201,10 @@ function handleSettingChange(id: string, value: unknown): void {
185201
applyDateColors(value as DateColorsPalette)
186202
return
187203
}
204+
if (id === 'appearance.translucency') {
205+
applyTranslucency(value as boolean)
206+
return
207+
}
188208
if (id === 'theme.mode') {
189209
void applyTheme(value as ThemeMode)
190210
return

apps/desktop/src/lib/settings/settings-registry.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ export const settingsRegistry: SettingDefinition[] = [
6161
],
6262
},
6363
},
64+
{
65+
id: 'appearance.translucency',
66+
section: ['Appearance', 'Colors and formats'],
67+
label: 'Translucency',
68+
description: 'Liquid glass-y look if also enabled in your System Settings.',
69+
keywords: ['translucency', 'translucent', 'vibrancy', 'glass', 'blur', 'transparency', 'liquid'],
70+
type: 'boolean',
71+
default: true,
72+
component: 'switch',
73+
},
6474
{
6575
id: 'appearance.sizeColors',
6676
section: ['Appearance', 'Colors and formats'],

0 commit comments

Comments
 (0)