Shade the window frame, panel headers and file rows apart

Every surface was one flat shade. The window now reads in layers: a darker
frame (tab, transfer and status bars), a lighter header strip per file panel
(session strip, path bar, column headers), the file list, and popovers above
them all. The active panel's header carries a tint of the accent, and odd file
rows get a faint zebra stripe (per row, so it stays aligned at any interface
scale; the cursor and selection replace it).

Hover and selected states become a translucent overlay: an opaque hover
vanished on a surface of its own lightness (light's header strip). Dark cards
and badges step up so popovers never read darker than the strip they cover.
AMOLED is unchanged: every new layer collapses to black there.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-10-05 09:47:32 +02:00
co-authored by Claude
parent 99bfd3abc2
commit f3bec7aeaf
6 changed files with 85 additions and 29 deletions
+17 -10
View File
@@ -289,16 +289,23 @@ component; tokens → `ui/src/styles.css`.
mode) — every dark token change goes in BOTH. Light is the zinc family; the mode) — every dark token change goes in BOTH. Light is the zinc family; the
dark neutrals are a **warm gray family (hue 107)** anchored on `#1a1a19` dark neutrals are a **warm gray family (hue 107)** anchored on `#1a1a19`
(`--background`) — retoning one dark neutral means retoning them all, or warm (`--background`) — retoning one dark neutral means retoning them all, or warm
and cool surfaces clash. Surface elevation ramp, both themes: `--chrome` and cool surfaces clash. **Surface layers**, each its own shade: `--chrome`
(frame strips) sits *below* `--background` (content panes) < `--card` (frame — tab/transfer/status bars, editor/manager header+footer strips, the
(popovers/dialogs) < `--accent` (hover). Dark ramp `#151514` → `#1a1a19` → manager's connection sidebar), `--toolbar` (each file panel's header strip —
`#20201f` → `#2e2e2d`. The "darker" tone is true-black AMOLED: `--chrome` AND session strip, path bar, column headers — and the editor's search bar; the
`--background` are both `#000` (the frame/content step collapses on purpose), ACTIVE panel's strip is `--toolbar-active`, a `color-mix` of `--primary` into
elevation survives only via `--card`/`--accent`, and `--border`/`--input` go to `--toolbar`, so it follows the accent), `--background` (file lists, editor
16%/18% white because those lines then carry the whole layout. text, forms; `--row-alt` stripes odd file rows, skipped on the cursor/selected
`bg-chrome` is on the tab bar, status bar, transfer bar, the editor/manager row so two `bg-*` utilities never fight) and `--card` (popovers, dialogs,
header+footer strips and the manager's connection sidebar; panel-internal toasts). Dark climbs frame 0.165 < list 0.218 < header 0.265 < card 0.285;
toolbars deliberately stay on `--background`. Accent tones: one light steps greys out from white content: frame 0.935 < header 0.965 < list =
card 1. **`--accent` is translucent** (6% zinc-900 light, 9% white dark):
hover/selected states sit on every layer, and an opaque hover vanishes on a
surface of its own lightness. The "darker" tone is true-black AMOLED: chrome,
background, toolbar (+active) and row-alt are all `#000` (the layers collapse
on purpose), elevation survives only via `--card`/`--accent` (opaque there),
and `--border`/`--input` go to 16%/18% white because those lines then carry the
whole layout. Accent tones: one
`--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`, `--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`,
`--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc. `--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc.
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`, `@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
+15
View File
@@ -56,6 +56,21 @@ touching the adjacent code.
anything poking out of a horizontally scrolling strip (a raised tab, an anything poking out of a horizontally scrolling strip (a raised tab, an
underline at `-bottom-px`) is silently clipped. Reserve padding instead. underline at `-bottom-px`) is silently clipped. Reserve padding instead.
## Theming (`ui/src/styles.css`)
- **A new surface token needs an AMOLED line, or AMOLED stops being
all-black.** The two `[data-dark-tone='darker']` blocks re-declare every
surface (`--chrome`, `--toolbar`, `--toolbar-active`, `--row-alt`, …) as
`#000`; a token added only to the dark blocks shows through in AMOLED.
- **Never declare one token in both the AMOLED blocks and the accent dark
formula blocks.** Both are (0,3,0), so source order decides and the later
accent block wins. That is why `--toolbar-active` lives in the base theme
blocks — its `var(--primary)` resolves per accent on `:root` anyway.
- **Hover/selected color is a translucent overlay (`--accent`).** An opaque
hover vanishes on a surface of its own lightness — light's old 0.967 hover
on the 0.965 header strip did exactly that. Keep `--accent` alpha-based when
retoning surfaces.
## The interface scale (root font-size lever) ## The interface scale (root font-size lever)
- **electrobun's native page zoom is a silent no-op on Linux — do not reach for - **electrobun's native page zoom is a silent no-op on Linux — do not reach for
@@ -46,7 +46,7 @@
</header> </header>
@if (findOpen()) { @if (findOpen()) {
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5"> <div class="flex shrink-0 items-center gap-2 border-b border-border bg-toolbar px-3 py-1.5">
<ng-icon name="tdesignSearch" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<input <input
#findBox #findBox
@@ -1,5 +1,5 @@
<!-- session strip --> <!-- session strip -->
<div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1"> <div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1" [class]="headerSurface()">
<button <button
(click)="toggleMenu()" (click)="toggleMenu()"
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent" class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
@@ -158,7 +158,7 @@
</div> </div>
<!-- path bar --> <!-- path bar -->
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5"> <div class="flex items-center gap-1 border-b border-border px-2 py-1.5" [class]="headerSurface()">
<button <button
(click)="panel().up()" (click)="panel().up()"
[disabled]="blocked()" [disabled]="blocked()"
@@ -196,6 +196,7 @@
(contextmenu)="headerContextMenu($event)" (contextmenu)="headerContextMenu($event)"
[style.grid-template-columns]="gridTemplate()" [style.grid-template-columns]="gridTemplate()"
class="grid border-b border-border text-[0.6875rem] font-medium text-muted-foreground" class="grid border-b border-border text-[0.6875rem] font-medium text-muted-foreground"
[class]="headerSurface()"
> >
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')"> <button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }} {{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
@@ -264,6 +265,7 @@
(contextmenu)="rowContextMenu($event, i)" (contextmenu)="rowContextMenu($event, i)"
[style.grid-template-columns]="gridTemplate()" [style.grid-template-columns]="gridTemplate()"
class="grid h-(--row-h) items-center text-(length:--list-font)" class="grid h-(--row-h) items-center text-(length:--list-font)"
[class.bg-row-alt]="i % 2 === 1 && i !== panel().cursor() && !isSelected(entry)"
[class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)" [class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)"
[class.bg-primary/15]="isSelected(entry)" [class.bg-primary/15]="isSelected(entry)"
[class.ring-1]="i === panel().cursor() && isActive()" [class.ring-1]="i === panel().cursor() && isActive()"
@@ -55,6 +55,9 @@ export class FilePanel {
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
readonly panel = computed(() => this.panels.get(this.side())); readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side()); readonly isActive = computed(() => this.panels.activeSide() === this.side());
/** The header strip's surface (session strip, path bar, column headers):
* the active panel's carries the accent tint. */
readonly headerSurface = computed(() => (this.isActive() ? 'bg-toolbar-active' : 'bg-toolbar'));
readonly isRemote = computed(() => this.panel().sessionId() !== 'local'); readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
/** Input is ignored while the panel is connecting or a listing is in flight. */ /** Input is ignored while the panel is connecting or a listing is in flight. */
readonly blocked = computed(() => this.panel().busy()); readonly blocked = computed(() => this.panel().busy());
+45 -16
View File
@@ -4,14 +4,24 @@
warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below. warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below.
The app follows the OS light/dark preference automatically. The app follows the OS light/dark preference automatically.
Surface elevation ramp (both themes): --chrome (frame strips: tab/transfer/status Surface layers, each its own shade so the window reads as parts: --chrome
bars, window toolbars) sits BELOW --background (content panes), which sits below (frame strips: tab/transfer/status bars, window header/footer strips),
--card (popovers, dialogs), which sits below --accent (hover). */ --toolbar (the file-panel header strip — toolbar, path bar, column header —
and secondary toolbars), --background (content: file lists, editor text,
forms; --row-alt stripes every other file row) and --card (popovers,
dialogs). Dark climbs frame < list < header < card; light steps greys out
from white content: frame < header < list = card. --accent is a translucent
overlay, not a surface: hover/selected states must read on every layer, and
an opaque hover vanishes on a surface of its own lightness. */
:root { :root {
/* Native UI (scrollbars, form controls) follows the OS unless a theme is forced. */ /* Native UI (scrollbars, form controls) follows the OS unless a theme is forced. */
color-scheme: light dark; color-scheme: light dark;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--chrome: oklch(0.977 0.001 286.375); --chrome: oklch(0.935 0.002 286.375);
--toolbar: oklch(0.965 0.001 286.375);
/* the active panel's header strip: a whisper of the accent (graphite: neutral) */
--toolbar-active: color-mix(in oklab, var(--primary) 8%, var(--toolbar));
--row-alt: oklch(0.978 0.001 286.375);
--foreground: oklch(0.141 0.005 285.823); --foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823); --card-foreground: oklch(0.141 0.005 285.823);
@@ -21,7 +31,7 @@
--secondary-foreground: oklch(0.21 0.006 285.885); --secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375); --muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938); --muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375); --accent: oklch(0.21 0.006 285.885 / 6%);
--accent-foreground: oklch(0.21 0.006 285.885); --accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
@@ -48,17 +58,21 @@
:root[data-theme='dark'] { :root[data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
--background: oklch(0.2178 0.002 107); --background: oklch(0.2178 0.002 107);
--chrome: oklch(0.1953 0.002 107); --chrome: oklch(0.165 0.002 107);
--toolbar: oklch(0.265 0.002 107);
--toolbar-active: color-mix(in oklab, var(--primary) 10%, var(--toolbar));
--row-alt: oklch(0.232 0.002 107);
--foreground: oklch(0.982 0.003 107); --foreground: oklch(0.982 0.003 107);
--card: oklch(0.245 0.002 107); /* above --toolbar, so a popover never reads darker than the strip it covers */
--card: oklch(0.285 0.002 107);
--card-foreground: oklch(0.982 0.003 107); --card-foreground: oklch(0.982 0.003 107);
--primary: oklch(0.92 0.004 107); --primary: oklch(0.92 0.004 107);
--primary-foreground: oklch(0.21 0.002 107); --primary-foreground: oklch(0.21 0.002 107);
--secondary: oklch(0.302 0.003 107); --secondary: oklch(0.302 0.003 107);
--secondary-foreground: oklch(0.982 0.003 107); --secondary-foreground: oklch(0.982 0.003 107);
--muted: oklch(0.302 0.003 107); --muted: oklch(0.335 0.003 107);
--muted-foreground: oklch(0.715 0.012 107); --muted-foreground: oklch(0.715 0.012 107);
--accent: oklch(0.302 0.003 107); --accent: oklch(1 0 0 / 9%);
--accent-foreground: oklch(0.982 0.003 107); --accent-foreground: oklch(0.982 0.003 107);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.982 0.003 107); --destructive-foreground: oklch(0.982 0.003 107);
@@ -77,17 +91,20 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) { :root:not([data-theme='light']) {
--background: oklch(0.2178 0.002 107); --background: oklch(0.2178 0.002 107);
--chrome: oklch(0.1953 0.002 107); --chrome: oklch(0.165 0.002 107);
--toolbar: oklch(0.265 0.002 107);
--toolbar-active: color-mix(in oklab, var(--primary) 10%, var(--toolbar));
--row-alt: oklch(0.232 0.002 107);
--foreground: oklch(0.982 0.003 107); --foreground: oklch(0.982 0.003 107);
--card: oklch(0.245 0.002 107); --card: oklch(0.285 0.002 107);
--card-foreground: oklch(0.982 0.003 107); --card-foreground: oklch(0.982 0.003 107);
--primary: oklch(0.92 0.004 107); --primary: oklch(0.92 0.004 107);
--primary-foreground: oklch(0.21 0.002 107); --primary-foreground: oklch(0.21 0.002 107);
--secondary: oklch(0.302 0.003 107); --secondary: oklch(0.302 0.003 107);
--secondary-foreground: oklch(0.982 0.003 107); --secondary-foreground: oklch(0.982 0.003 107);
--muted: oklch(0.302 0.003 107); --muted: oklch(0.335 0.003 107);
--muted-foreground: oklch(0.715 0.012 107); --muted-foreground: oklch(0.715 0.012 107);
--accent: oklch(0.302 0.003 107); --accent: oklch(1 0 0 / 9%);
--accent-foreground: oklch(0.982 0.003 107); --accent-foreground: oklch(0.982 0.003 107);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.982 0.003 107); --destructive-foreground: oklch(0.982 0.003 107);
@@ -109,12 +126,18 @@
the frame/content step of the default tone disappears and every large surface is the frame/content step of the default tone disappears and every large surface is
#000 (OLED pixels actually off). Elevation survives only through --card and #000 (OLED pixels actually off). Elevation survives only through --card and
--accent; --border/--input are raised to 16%/18% because on pure black those --accent; --border/--input are raised to 16%/18% because on pure black those
lines are the only thing defining panes, bars and rows. Duplicated for lines are the only thing defining panes, bars and rows. The surface layers
forced-dark and system-dark like the token blocks above — the extra attribute (--toolbar, its active tint, --row-alt) collapse to black too: AMOLED stays
outranks them. */ one flat black on purpose, so every NEW surface token needs a line here.
Duplicated for forced-dark and system-dark like the token blocks above — the
extra attribute outranks them. Never declare a token both here and in the
accent dark formula blocks below: same (0,3,0) specificity, the later wins. */
:root[data-theme='dark'][data-dark-tone='darker'] { :root[data-theme='dark'][data-dark-tone='darker'] {
--background: oklch(0 0 0); --background: oklch(0 0 0);
--chrome: oklch(0 0 0); --chrome: oklch(0 0 0);
--toolbar: oklch(0 0 0);
--toolbar-active: oklch(0 0 0);
--row-alt: oklch(0 0 0);
--card: oklch(0.21 0.002 107); --card: oklch(0.21 0.002 107);
--secondary: oklch(0.274 0.003 107); --secondary: oklch(0.274 0.003 107);
--muted: oklch(0.274 0.003 107); --muted: oklch(0.274 0.003 107);
@@ -127,6 +150,9 @@
:root:not([data-theme='light'])[data-dark-tone='darker'] { :root:not([data-theme='light'])[data-dark-tone='darker'] {
--background: oklch(0 0 0); --background: oklch(0 0 0);
--chrome: oklch(0 0 0); --chrome: oklch(0 0 0);
--toolbar: oklch(0 0 0);
--toolbar-active: oklch(0 0 0);
--row-alt: oklch(0 0 0);
--card: oklch(0.21 0.002 107); --card: oklch(0.21 0.002 107);
--secondary: oklch(0.274 0.003 107); --secondary: oklch(0.274 0.003 107);
--muted: oklch(0.274 0.003 107); --muted: oklch(0.274 0.003 107);
@@ -140,6 +166,9 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-chrome: var(--chrome); --color-chrome: var(--chrome);
--color-toolbar: var(--toolbar);
--color-toolbar-active: var(--toolbar-active);
--color-row-alt: var(--row-alt);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);