From bfc2147c7774b286b01c3c1427be475f217ba2c3 Mon Sep 17 00:00:00 2001 From: Jokin Date: Mon, 5 Oct 2026 10:21:50 +0200 Subject: [PATCH] Adopt Oxide's color scheme and a square design The palette now follows Oxide's design system (docs.oxide.computer): a cool neutral scale at hue 260 with almost no chroma, opaque strokes, and Oxide's green as the only accent. The accent-color option is gone: selection, the cursor row, focus rings and the active-panel line all read green. The file panel's header strip now steps toward the list, one shade per row (session strip, path bar, column headers), derived by color-mix so every theme, AMOLED included, follows on its own. Everything is square: every rounded utility and the radius tokens are gone, and checkboxes, radios, selects, number spinners and scrollbars are restyled to match (square scrollbars are always visible on macOS, the cost of styling them). Co-Authored-By: Claude --- README.md | 2 +- docs/architecture.md | 70 ++-- docs/gotchas.md | 21 +- docs/platform-notes.md | 1 + src/bun/windows.ts | 1 - src/shared/i18n/de.ts | 11 - src/shared/i18n/en.ts | 11 - src/shared/i18n/es.ts | 11 - src/shared/i18n/eu.ts | 11 - src/shared/i18n/fr.ts | 11 - src/shared/i18n/ja.ts | 11 - src/shared/i18n/zh.ts | 11 - src/shared/types.ts | 13 - ui/src/app/core/settings.service.ts | 21 - ui/src/app/skins/shadlike/app.html | 6 +- .../connection-manager.html | 49 ++- ui/src/app/skins/shadlike/context-menu.ts | 2 +- .../skins/shadlike/dialogs/chmod-dialog.ts | 10 +- .../skins/shadlike/dialogs/confirm-dialog.ts | 8 +- .../shadlike/dialogs/overwrite-dialog.ts | 18 +- .../skins/shadlike/dialogs/prompt-dialog.ts | 8 +- .../shadlike/editor-window/editor-window.html | 30 +- .../skins/shadlike/file-panel/file-panel.html | 32 +- .../settings-window/settings-window.html | 69 ++-- .../settings-window/settings-window.ts | 15 - ui/src/app/skins/shadlike/tab-bar/tab-bar.ts | 14 +- ui/src/app/skins/shadlike/toasts/toasts.ts | 2 +- .../shadlike/transfer-bar/transfer-bar.html | 10 +- ui/src/index.html | 6 +- ui/src/styles.css | 362 ++++++++++-------- 30 files changed, 372 insertions(+), 475 deletions(-) diff --git a/README.md b/README.md index fa7ede5..aa449e8 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu | Runtime | Bun (main process, all SSH/SFTP + file IO) | | Desktop | Electrobun 1.18 (native webview, typed RPC bridge) | | Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` | -| Styling | Tailwind CSS v4, shadcn-style tokens (no component library) | +| Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) | | Icons | @ng-icons/tdesign-icons | | SSH/SFTP | `ssh2` (pure-JS under Bun) | diff --git a/docs/architecture.md b/docs/architecture.md index 5e0641f..2aa99e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,8 +49,7 @@ before any page script, plus the persisted theme attributes and interface scale so the first paint is already right. `ui/src/main.ts` picks the root component from `APP_MODE`; all four roots use selector `app-root` (index.html hosts only that tag). `chromeOptions()` is the ONLY place the native frame is -decided, spread by -the main window and `openSecondaryWindow`: macOS `hiddenInset` + +decided, spread by the main window and `openSecondaryWindow`: macOS `hiddenInset` + `trafficLightOffset {x:12,y:13}` (the UI renders an `h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the native titlebar and a compact `h-9` non-drag header. @@ -233,7 +232,7 @@ component; tokens → `ui/src/styles.css`. enqueue and runs the collision flow (same-path auto-skip, type-mismatch skip, overwrite dialog with all/none batch modes). - `settings.service.ts` — one signal per setting; **imperative apply pattern** - (`applyTheme/DarkTone/Accent` set `data-*` attributes on ``; + (`applyTheme/DarkTone` set `data-*` attributes on ``; `applyUiScale` sets its inline `font-size`); `applyAll` also serves the `settingsChanged` broadcast, so every window follows a save made in any other. Every window RPC serves `saveSettings` (editor and manager windows @@ -281,7 +280,7 @@ component; tokens → `ui/src/styles.css`. pointer; the keyboard path never consults it — so `pick()` and the arrow-key walker check the flag themselves. - `file-icons.ts` — extension → category map driving glyphs, colors - (fixed `--icon-*` tokens, never accent-tinted) and the Kind column. + (fixed `--icon-*` tokens from Oxide's hue scales) and the Kind column. - `icons.ts` — the one icon registry (TDesign, `@ng-icons/tdesign-icons`), passed to `provideIcons` in `app.config.ts`. Icon names are plain strings in templates and the build never checks them: an unregistered name renders nothing (console error @@ -301,35 +300,42 @@ component; tokens → `ui/src/styles.css`. ## Theming -`ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in -`:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system -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` -(`--background`) — retoning one dark neutral means retoning them all, or warm -and cool surfaces clash. **Surface layers**, each its own shade: `--chrome` -(frame — tab/transfer/status bars, editor/manager header+footer strips, the -manager's connection sidebar), `--toolbar` (each file panel's header strip — -session strip, path bar, column headers — and the editor's search bar; both -panels share one neutral strip, and the ACTIVE panel is marked by a 0.125rem -`--primary` line along its top edge, an inset box-shadow on the session strip so -switching panels never shifts layout), `--background` (file lists, editor -text, forms; `--row-alt` stripes odd file rows, skipped on the cursor/selected -row so two `bg-*` utilities never fight) and `--card` (popovers, dialogs, -toasts). Dark climbs frame 0.165 < list 0.218 < header 0.265 < card 0.285; -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 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`, -`--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc. +`ui/src/styles.css`: OKLCH tokens adapted from **Oxide's design system** +(docs.oxide.computer: a cool neutral scale at hue 260 with almost no chroma, +one green accent, opaque strokes; the comments carry Oxide's step names +n0…n1300 / green-800). Dark values are duplicated in `:root[data-theme='dark']` +AND a `prefers-color-scheme` media block (system mode) — every dark token +change goes in BOTH; retone neutrals along the one scale, or warm and cool +surfaces clash. **There is no accent setting**: `--primary` is Oxide green per +theme (green-800 dark, green-500 light), so selection (`bg-primary/15`), the +cursor row (`--cursor-tint`, a `color-mix` of `--primary`), focus rings +(green-600) and `--success` all read green. **Surface layers**, each its own +shade: `--chrome` (frame — tab/transfer/status bars, the editor/manager/settings +header+footer strips and sidebars), the file panel's header strip **stepping +toward the list** — session strip `--toolbar`, path bar `--toolbar-2`, column +headers `--toolbar-3` (`color-mix` steps declared once in `:root`, so every +theme follows) — then `--background` (file lists, editor text, forms; +`--row-alt` stripes odd file rows, skipped on the cursor/selected row so two +`bg-*` utilities never fight) and `--card` (popovers, dialogs, toasts). The +ACTIVE panel is marked by a 0.125rem `--primary` line along its top edge (an +inset box-shadow on the session strip, so switching panels never shifts +layout). Dark: frame 0.162 < list 0.195 < header 0.275 → 0.248 → 0.221 < card +0.29; light steps greys out from near-white content: frame 0.925 < header 0.95 +→ 0.965 → 0.98 < list = card 0.995. **`--accent` is translucent** (n0 at 5% +light, white 7% dark): hover/selected states sit on every layer, and an opaque +hover vanishes on a surface of its own lightness. Strokes are opaque +(`--border` n300 dark / n900 light, `--input` one step stronger). The +"darker" tone is true-black AMOLED: chrome, background, toolbar (+ its steps) +and row-alt are all `#000` (the layers collapse on purpose); elevation +survives only via `--card`/`--muted`/`--accent` (opaque there). **Square +design**: no `rounded-*` utilities and no radius tokens; an `@layer base` +block restyles native controls to match — checkboxes/radios (`appearance: +none`, a drawn check / square dot), selects (a `currentColor` caret; use +`pl-*`, never `px-*`, on a select), hidden number spinners, and square +`::-webkit-scrollbar`s (which make macOS scrollbars always visible). `@theme inline` maps tokens to Tailwind utilities (`bg-primary`, -`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15` -(follow the accent); the cursor row uses `--cursor-tint` so graphite stays -pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`, +`text-icon-folder`, `bg-toolbar-2`, `bg-cursor-tint`). File-type icon colors +come from Oxide's hue scales (800 steps dark, 600 light). **Interface scale** (`settings.uiScale`, 0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets `html { font-size: 16px * scale }`, and since EVERY size in the app is rem (panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the diff --git a/docs/gotchas.md b/docs/gotchas.md index f21a4c2..c40e1a9 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -60,17 +60,22 @@ touching the adjacent code. - **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`, `--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. A token that must follow the accent belongs in the base - theme blocks instead — a `var(--primary)` there still resolves per accent on - `:root`. + surface (`--chrome`, `--toolbar`, `--background`, `--row-alt`, …) as `#000`; + a token added only to the dark blocks shows through in AMOLED. Tokens derived + in `:root` by `color-mix` (`--toolbar-2/-3`) follow on their own. - **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. +- **A native control ignores `border-radius` and background until + `appearance: none`.** WebKit draws checkboxes, radios and selects itself; + the square restyle in the `@layer base` block depends on it. Put `pl-*`, + never `px-*`, on a ` @@ -231,7 +230,7 @@ [value]="d.username" (input)="patch('username', $any($event.target).value)" spellcheck="false" - class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" + class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" /> @@ -253,7 +252,6 @@ name="authKind" [checked]="d.authKind === 'password'" (change)="patch('authKind', 'password')" - class="accent-primary" /> {{ i18n.t('connMgr.authPassword') }} @@ -263,7 +261,6 @@ name="authKind" [checked]="d.authKind === 'key'" (change)="patch('authKind', 'key')" - class="accent-primary" /> {{ i18n.t('connMgr.authKey') }} @@ -276,7 +273,7 @@ type="password" [value]="d.password" (input)="patch('password', $any($event.target).value)" - class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" + class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" /> } @else { @@ -288,12 +285,12 @@ (input)="patch('privateKeyPath', $any($event.target).value)" placeholder="~/.ssh/id_ed25519" spellcheck="false" - class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" + class="h-7 min-w-0 flex-1 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" /> @@ -305,7 +302,7 @@ type="password" [value]="d.passphrase" (input)="patch('passphrase', $any($event.target).value)" - class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" + class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" /> } @@ -318,7 +315,7 @@ (input)="patch('localDir', $any($event.target).value)" [placeholder]="i18n.t('connMgr.localDirPlaceholder')" spellcheck="false" - class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" + class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" /> {{ i18n.t('connMgr.localDirHint') }} @@ -330,13 +327,13 @@
@@ -344,14 +341,14 @@ diff --git a/ui/src/app/skins/shadlike/dialogs/confirm-dialog.ts b/ui/src/app/skins/shadlike/dialogs/confirm-dialog.ts index 84a6f6f..bb3e389 100644 --- a/ui/src/app/skins/shadlike/dialogs/confirm-dialog.ts +++ b/ui/src/app/skins/shadlike/dialogs/confirm-dialog.ts @@ -8,7 +8,7 @@ import { FocusService } from '@core/focus.service'; template: `
@@ -17,7 +17,7 @@ import { FocusService } from '@core/focus.service';
@@ -26,8 +26,8 @@ import { FocusService } from '@core/focus.service'; (click)="submit()" [class]=" request().destructive - ? 'rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90' - : 'rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90' + ? 'bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90' + : 'bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90' " > {{ request().confirmLabel }} diff --git a/ui/src/app/skins/shadlike/dialogs/overwrite-dialog.ts b/ui/src/app/skins/shadlike/dialogs/overwrite-dialog.ts index df797bc..f6ee2cf 100644 --- a/ui/src/app/skins/shadlike/dialogs/overwrite-dialog.ts +++ b/ui/src/app/skins/shadlike/dialogs/overwrite-dialog.ts @@ -21,7 +21,7 @@ import { FocusService } from '@core/focus.service'; (keydown.escape)="pick('cancel')" >

@@ -30,7 +30,7 @@ import { FocusService } from '@core/focus.service';

@if (request().total > 1) { {{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }} @@ -47,13 +47,13 @@ import { FocusService } from '@core/focus.service';
@for (side of comparison(); track side.label) { -
+
{{ i18n.t('dialogs.' + side.label) }} @if (side.newer) { - + {{ i18n.t('dialogs.newer') }} } @@ -79,7 +79,7 @@ import { FocusService } from '@core/focus.service';
@@ -87,13 +87,13 @@ import { FocusService } from '@core/focus.service'; @if (request().total > 1) { @@ -101,14 +101,14 @@ import { FocusService } from '@core/focus.service'; } diff --git a/ui/src/app/skins/shadlike/dialogs/prompt-dialog.ts b/ui/src/app/skins/shadlike/dialogs/prompt-dialog.ts index 8f35683..5d27ee6 100644 --- a/ui/src/app/skins/shadlike/dialogs/prompt-dialog.ts +++ b/ui/src/app/skins/shadlike/dialogs/prompt-dialog.ts @@ -16,7 +16,7 @@ import { FocusService } from '@core/focus.service'; template: `

{{ request().title }}

@@ -28,19 +28,19 @@ import { FocusService } from '@core/focus.service'; (keydown.escape)="cancel()" [placeholder]="request().placeholder ?? ''" spellcheck="false" - class="mt-3 h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring" + class="mt-3 h-8 w-full border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring" />
diff --git a/ui/src/app/skins/shadlike/editor-window/editor-window.html b/ui/src/app/skins/shadlike/editor-window/editor-window.html index 80c794a..db440cb 100644 --- a/ui/src/app/skins/shadlike/editor-window/editor-window.html +++ b/ui/src/app/skins/shadlike/editor-window/editor-window.html @@ -1,7 +1,7 @@
@if (!ctx) {
-
+

{{ i18n.t('editor.noFileTitle') }}

@@ -29,7 +29,7 @@ @@ -55,7 +55,7 @@ (keydown)="onFindKeydown($event)" [placeholder]="i18n.t('editor.findPlaceholder')" spellcheck="false" - class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring" + class="h-6 w-56 border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring" /> @@ -82,7 +82,7 @@ @@ -104,13 +104,13 @@

} @else if (loadError(); as error) {
-
+

{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}

{{ error }}

@@ -149,14 +149,14 @@
diff --git a/ui/src/app/skins/shadlike/file-panel/file-panel.html b/ui/src/app/skins/shadlike/file-panel/file-panel.html index cb11a46..1507485 100644 --- a/ui/src/app/skins/shadlike/file-panel/file-panel.html +++ b/ui/src/app/skins/shadlike/file-panel/file-panel.html @@ -5,7 +5,7 @@ > @@ -70,7 +70,7 @@ @@ -78,7 +78,7 @@ @@ -178,13 +178,13 @@ (keydown)="$event.stopPropagation()" [disabled]="blocked()" spellcheck="false" - class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" + class="h-(--path-ctl-h) min-w-0 flex-1 border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" /> @@ -252,7 +252,7 @@ diff --git a/ui/src/app/skins/shadlike/settings-window/settings-window.html b/ui/src/app/skins/shadlike/settings-window/settings-window.html index 0fa6078..04be898 100644 --- a/ui/src/app/skins/shadlike/settings-window/settings-window.html +++ b/ui/src/app/skins/shadlike/settings-window/settings-window.html @@ -19,7 +19,7 @@ @for (s of sections; track s) { diff --git a/ui/src/app/skins/shadlike/settings-window/settings-window.ts b/ui/src/app/skins/shadlike/settings-window/settings-window.ts index a8c183f..cd34cb7 100644 --- a/ui/src/app/skins/shadlike/settings-window/settings-window.ts +++ b/ui/src/app/skins/shadlike/settings-window/settings-window.ts @@ -2,7 +2,6 @@ import { Component, computed, inject, signal } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { BRANDING } from '@shared/branding'; import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n'; -import type { AccentTone } from '@shared/types'; import { COLUMN_KEYS } from '@core/context-menu.service'; import { I18nService } from '@core/i18n.service'; import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform'; @@ -15,19 +14,6 @@ type Section = (typeof SECTIONS)[number]; const THEMES = ['system', 'light', 'dark'] as const; const DARK_TONES = ['dark', 'darker'] as const; -// Swatch colors are fixed mid-tones so every dot reads on both light and dark -// pages (the live --primary values would vanish against one of them). Names are -// translated via options.accentNames.. -const ACCENTS: readonly { value: AccentTone; swatch: string }[] = [ - { value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)' }, - { value: 'blue', swatch: 'oklch(0.65 0.17 255)' }, - { value: 'indigo', swatch: 'oklch(0.65 0.19 277)' }, - { value: 'purple', swatch: 'oklch(0.65 0.17 305)' }, - { value: 'rose', swatch: 'oklch(0.65 0.17 8)' }, - { value: 'orange', swatch: 'oklch(0.68 0.17 50)' }, - { value: 'green', swatch: 'oklch(0.65 0.15 150)' }, - { value: 'teal', swatch: 'oklch(0.65 0.12 185)' }, -]; const SPEED_UNITS = [ { value: 'MBps', label: 'MB/s' }, { value: 'Mbps', label: 'Mbps' }, @@ -62,7 +48,6 @@ export class SettingsWindow { readonly localeLabels = LOCALE_LABELS; readonly themes = THEMES; readonly darkTones = DARK_TONES; - readonly accents = ACCENTS; readonly speedUnits = SPEED_UNITS; readonly transferModes = TRANSFER_MODES; readonly concurrencyChoices = CONCURRENCY_CHOICES; diff --git a/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts b/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts index 4eacace..9d527d0 100644 --- a/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts +++ b/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts @@ -28,7 +28,7 @@ import { UpdaterService } from '@core/updater.service'; @for (ws of panels.workspaces(); track ws.id; let i = $index) { } @@ -80,7 +80,7 @@ import { UpdaterService } from '@core/updater.service'; @@ -94,7 +94,7 @@ import { UpdaterService } from '@core/updater.service'; (click)="onUpdateClick()" [disabled]="updater.state() === 'downloading'" [title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })" - class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default" + class="relative mr-1 grid h-6 place-items-center overflow-hidden px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default" > @if (updater.state() === 'downloading') { {{ i18n.t('tabBar.connections') }} @@ -132,7 +132,7 @@ import { UpdaterService } from '@core/updater.service'; diff --git a/ui/src/app/skins/shadlike/toasts/toasts.ts b/ui/src/app/skins/shadlike/toasts/toasts.ts index 6bfbab6..a87d6fb 100644 --- a/ui/src/app/skins/shadlike/toasts/toasts.ts +++ b/ui/src/app/skins/shadlike/toasts/toasts.ts @@ -12,7 +12,7 @@ import { ToastService } from '@core/toast.service';
{{ i18n.t('transfer.clearFinished') }} @@ -25,10 +25,10 @@ aria-valuemax="100" [attr.aria-valuenow]="jobPercent(job)" [attr.aria-label]="job.name" - class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted" + class="mt-0.5 h-1 overflow-hidden bg-muted" >
@@ -38,7 +38,7 @@ @@ -46,7 +46,7 @@ diff --git a/ui/src/index.html b/ui/src/index.html index d1b26f0..06a4408 100644 --- a/ui/src/index.html +++ b/ui/src/index.html @@ -13,11 +13,11 @@ attributes are baked in by the bun preload (buildPreload in src/bun/windows.ts) so forced themes are correct at first paint. --> diff --git a/ui/src/styles.css b/ui/src/styles.css index da585a2..13788a4 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -1,50 +1,54 @@ @import "tailwindcss"; -/* shadcn-style theme tokens. Light stays the zinc family; the dark neutrals are a - warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below. - The app follows the OS light/dark preference automatically. +/* Theme tokens, adapted from Oxide's design system (docs.oxide.computer): a cool + neutral scale at hue 260 with almost no chroma, one green accent, opaque + strokes. Step names in the comments (n0…n1300, green-800, …) are Oxide's. + The app follows the OS light/dark preference unless a theme is forced. Surface layers, each its own shade so the window reads as parts: --chrome - (frame strips: tab/transfer/status bars, window header/footer strips), - --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. */ + (frame strips: tab/transfer/status bars, window header/footer strips and + sidebars), --toolbar (the top row of a file panel's header strip, and + secondary toolbars) stepping through --toolbar-2/-3 toward --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 near-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. The design is square: there are no radius tokens. */ :root { - /* Native UI (scrollbars, form controls) follows the OS unless a theme is forced. */ + /* Native UI (form controls) follows the OS unless a theme is forced. */ color-scheme: light dark; - --background: oklch(1 0 0); - --chrome: oklch(0.935 0.002 286.375); - --toolbar: oklch(0.965 0.001 286.375); - --row-alt: oklch(0.978 0.001 286.375); - --foreground: oklch(0.141 0.005 285.823); - --card: oklch(1 0 0); - --card-foreground: oklch(0.141 0.005 285.823); - --primary: oklch(0.21 0.006 285.885); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.967 0.001 286.375); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.21 0.006 285.885 / 6%); - --accent-foreground: oklch(0.21 0.006 285.885); - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.985 0 0); - --success: oklch(0.627 0.17 149.2); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.705 0.015 286.067); - --radius: 0.5rem; - /* file-type icon colors — fixed category hues, muted chroma */ - --icon-folder: oklch(0.58 0.13 250); - --icon-link: oklch(0.56 0.1 190); - --icon-image: oklch(0.58 0.13 295); - --icon-media: oklch(0.6 0.13 10); - --icon-archive: oklch(0.62 0.13 60); - --icon-code: oklch(0.56 0.12 155); + --background: oklch(0.995 0.0001 260); /* n1300 */ + --chrome: oklch(0.925 0.0001 260); /* n1000 */ + --toolbar: oklch(0.95 0.0001 260); /* n1100 */ + --row-alt: oklch(0.975 0.0001 260); /* n1200 */ + --foreground: oklch(0.162 0.01 260); /* n0 */ + --card: oklch(0.995 0.0001 260); + --card-foreground: oklch(0.162 0.01 260); + --primary: oklch(0.474 0.1481 173.7); /* green-500 */ + --primary-foreground: oklch(0.995 0.0001 260); + --muted: oklch(0.95 0.0001 260); /* n1100 */ + --muted-foreground: oklch(0.483 0.0041 260); /* n500 */ + --accent: oklch(0.162 0.01 260 / 5%); + --destructive: oklch(0.474 0.1285 21.3); /* red-500 */ + --destructive-foreground: oklch(0.995 0.0001 260); + --success: oklch(0.474 0.1481 173.7); /* green-500 */ + --border: oklch(0.9 0.0001 260); /* n900 */ + --input: oklch(0.79 0.0011 260); /* n800 */ + --ring: oklch(0.563 0.1714 170.4); /* green-600 */ + --cursor-tint: color-mix(in oklab, var(--primary) 8%, transparent); + /* The file panel's header strip steps toward the list, a third per row. + Declared once: the var()s resolve on :root, so dark and AMOLED (where both + ends are black) follow without their own lines. */ + --toolbar-2: color-mix(in oklab, var(--toolbar) 66%, var(--background)); + --toolbar-3: color-mix(in oklab, var(--toolbar) 33%, var(--background)); + /* file-type icon colors — fixed category hues from Oxide's scales (600 steps) */ + --icon-folder: oklch(0.563 0.1139 278.7); /* blue-600 */ + --icon-link: oklch(0.67 0.1216 275.3); /* blue-700 */ + --icon-image: oklch(0.563 0.1328 312.1); /* purple-600 */ + --icon-media: oklch(0.563 0.1487 18); /* red-600 */ + --icon-archive: oklch(0.563 0.125 81.7); /* yellow-600 */ + --icon-code: oklch(0.563 0.1714 170.4); /* green-600 */ } /* Dark tokens apply when the user forces dark, or follows a dark system theme. @@ -55,91 +59,80 @@ :root[data-theme='dark'] { color-scheme: dark; - --background: oklch(0.2178 0.002 107); - --chrome: oklch(0.165 0.002 107); - --toolbar: oklch(0.265 0.002 107); - --row-alt: oklch(0.232 0.002 107); - --foreground: oklch(0.982 0.003 107); + --background: oklch(0.195 0.009 260); /* n50 */ + --chrome: oklch(0.162 0.01 260); /* n0 */ + --toolbar: oklch(0.275 0.0065 260); /* between n200 and n300 */ + --row-alt: oklch(0.205 0.0085 260); /* between n50 and n100 */ + --foreground: oklch(0.9 0.0001 260); /* n900 */ /* 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); - --primary: oklch(0.92 0.004 107); - --primary-foreground: oklch(0.21 0.002 107); - --secondary: oklch(0.302 0.003 107); - --secondary-foreground: oklch(0.982 0.003 107); - --muted: oklch(0.335 0.003 107); - --muted-foreground: oklch(0.715 0.012 107); - --accent: oklch(1 0 0 / 9%); - --accent-foreground: oklch(0.982 0.003 107); - --destructive: oklch(0.704 0.191 22.216); - --destructive-foreground: oklch(0.982 0.003 107); - --success: oklch(0.696 0.17 149.2); - --border: oklch(1 0 0 / 14%); - --input: oklch(1 0 0 / 18%); - --ring: oklch(0.552 0.01 107); - --icon-folder: oklch(0.75 0.11 250); - --icon-link: oklch(0.74 0.09 190); - --icon-image: oklch(0.76 0.11 295); - --icon-media: oklch(0.76 0.11 10); - --icon-archive: oklch(0.77 0.11 65); - --icon-code: oklch(0.75 0.1 155); + --card: oklch(0.29 0.0065 260); + --card-foreground: oklch(0.9 0.0001 260); + --primary: oklch(0.77 0.1919 163.7); /* green-800 */ + --primary-foreground: oklch(0.162 0.01 260); + --muted: oklch(0.316 0.006 260); /* n300 */ + --muted-foreground: oklch(0.717 0.0021 260); /* n700 */ + --accent: oklch(1 0 0 / 7%); + --destructive: oklch(0.712 0.185 11.3); /* red-800 */ + --destructive-foreground: oklch(0.162 0.01 260); + --success: oklch(0.77 0.1919 163.7); /* green-800 */ + --border: oklch(0.316 0.006 260); /* n300 */ + --input: oklch(0.389 0.005 260); /* n400 */ + --ring: oklch(0.563 0.1714 170.4); /* green-600 */ + --cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent); + --icon-folder: oklch(0.71 0.15 272); /* blue-800 */ + --icon-link: oklch(0.67 0.1216 275.3); /* blue-700 */ + --icon-image: oklch(0.74 0.175 305.4); /* purple-800 */ + --icon-media: oklch(0.712 0.185 11.3); /* red-800 */ + --icon-archive: oklch(0.837 0.14 75); /* yellow-800 */ + --icon-code: oklch(0.77 0.1919 163.7); /* green-800 */ } @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { - --background: oklch(0.2178 0.002 107); - --chrome: oklch(0.165 0.002 107); - --toolbar: oklch(0.265 0.002 107); - --row-alt: oklch(0.232 0.002 107); - --foreground: oklch(0.982 0.003 107); - --card: oklch(0.285 0.002 107); - --card-foreground: oklch(0.982 0.003 107); - --primary: oklch(0.92 0.004 107); - --primary-foreground: oklch(0.21 0.002 107); - --secondary: oklch(0.302 0.003 107); - --secondary-foreground: oklch(0.982 0.003 107); - --muted: oklch(0.335 0.003 107); - --muted-foreground: oklch(0.715 0.012 107); - --accent: oklch(1 0 0 / 9%); - --accent-foreground: oklch(0.982 0.003 107); - --destructive: oklch(0.704 0.191 22.216); - --destructive-foreground: oklch(0.982 0.003 107); - --success: oklch(0.696 0.17 149.2); - --border: oklch(1 0 0 / 14%); - --input: oklch(1 0 0 / 18%); - --ring: oklch(0.552 0.01 107); - --icon-folder: oklch(0.75 0.11 250); - --icon-link: oklch(0.74 0.09 190); - --icon-image: oklch(0.76 0.11 295); - --icon-media: oklch(0.76 0.11 10); - --icon-archive: oklch(0.77 0.11 65); - --icon-code: oklch(0.75 0.1 155); + --background: oklch(0.195 0.009 260); + --chrome: oklch(0.162 0.01 260); + --toolbar: oklch(0.275 0.0065 260); + --row-alt: oklch(0.205 0.0085 260); + --foreground: oklch(0.9 0.0001 260); + --card: oklch(0.29 0.0065 260); + --card-foreground: oklch(0.9 0.0001 260); + --primary: oklch(0.77 0.1919 163.7); + --primary-foreground: oklch(0.162 0.01 260); + --muted: oklch(0.316 0.006 260); + --muted-foreground: oklch(0.717 0.0021 260); + --accent: oklch(1 0 0 / 7%); + --destructive: oklch(0.712 0.185 11.3); + --destructive-foreground: oklch(0.162 0.01 260); + --success: oklch(0.77 0.1919 163.7); + --border: oklch(0.316 0.006 260); + --input: oklch(0.389 0.005 260); + --ring: oklch(0.563 0.1714 170.4); + --cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent); + --icon-folder: oklch(0.71 0.15 272); + --icon-link: oklch(0.67 0.1216 275.3); + --icon-image: oklch(0.74 0.175 305.4); + --icon-media: oklch(0.712 0.185 11.3); + --icon-archive: oklch(0.837 0.14 75); + --icon-code: oklch(0.77 0.1919 163.7); } } /* Dark tone "darker" (data-dark-tone set by SettingsService; absent = grey "dark"). - True-black AMOLED palette: --chrome collapses into --background on purpose, so - 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 - --accent; --border/--input are raised to 16%/18% because on pure black those - lines are the only thing defining panes, bars and rows. The surface layers - (--toolbar, --row-alt) collapse to black too: AMOLED stays one flat black on - purpose, so every NEW surface token needs a line here. + True-black AMOLED palette: every surface layer (--chrome, --toolbar and the + steps derived from it, --background, --row-alt) collapses to #000 on purpose + — OLED pixels actually off — so every NEW surface token needs a line here. + Elevation survives only through --card, --muted and --accent (opaque here: + an overlay on black is too faint); the opaque strokes already read on black. 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. */ + extra attribute outranks them. */ :root[data-theme='dark'][data-dark-tone='darker'] { --background: oklch(0 0 0); --chrome: oklch(0 0 0); --toolbar: oklch(0 0 0); --row-alt: oklch(0 0 0); - --card: oklch(0.21 0.002 107); - --secondary: oklch(0.274 0.003 107); - --muted: oklch(0.274 0.003 107); - --muted-foreground: oklch(0.705 0.012 107); - --accent: oklch(0.274 0.003 107); - --border: oklch(1 0 0 / 16%); - --input: oklch(1 0 0 / 18%); + --card: oklch(0.216 0.008 260); /* n100 */ + --muted: oklch(0.247 0.007 260); /* n200 */ + --accent: oklch(0.247 0.007 260); /* n200 */ } @media (prefers-color-scheme: dark) { :root:not([data-theme='light'])[data-dark-tone='darker'] { @@ -147,13 +140,9 @@ --chrome: oklch(0 0 0); --toolbar: oklch(0 0 0); --row-alt: oklch(0 0 0); - --card: oklch(0.21 0.002 107); - --secondary: oklch(0.274 0.003 107); - --muted: oklch(0.274 0.003 107); - --muted-foreground: oklch(0.705 0.012 107); - --accent: oklch(0.274 0.003 107); - --border: oklch(1 0 0 / 16%); - --input: oklch(1 0 0 / 18%); + --card: oklch(0.216 0.008 260); + --muted: oklch(0.247 0.007 260); + --accent: oklch(0.247 0.007 260); } } @@ -161,18 +150,17 @@ --color-background: var(--background); --color-chrome: var(--chrome); --color-toolbar: var(--toolbar); + --color-toolbar-2: var(--toolbar-2); + --color-toolbar-3: var(--toolbar-3); --color-row-alt: var(--row-alt); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-success: var(--success); @@ -186,12 +174,6 @@ --color-icon-media: var(--icon-media); --color-icon-archive: var(--icon-archive); --color-icon-code: var(--icon-code); - /* rem subtrahends (4px/2px at the 16px root), NOT px: --radius is rem, and a - mixed-unit calc would stop the derived radii from following the interface - scale — corners would sharpen as the UI grew. */ - --radius-sm: calc(var(--radius) - 0.25rem); - --radius-md: calc(var(--radius) - 0.125rem); - --radius-lg: var(--radius); } /* Panel-chrome metrics: file list, path bar, action buttons, source picker. @@ -238,43 +220,6 @@ animation-play-state: paused; } -/* Accent tone (data-accent set by SettingsService; absent = graphite/zinc). - Each tone is one hue+chroma line; the formula blocks below derive --primary, - --ring and the cursor-row tint for light and both dark states. The dark - formula blocks (0,3,0) outrank the base dark token blocks and the light - formula block (0,2,0) in every dark state; graphite matches no [data-accent] - selector and keeps the zinc tokens untouched. */ -:root { - /* graphite: cursor row identical to the old bg-accent highlight */ - --cursor-tint: var(--accent); -} -:root[data-accent='blue'] { --tone-h: 255; --tone-c: 0.17; } -:root[data-accent='indigo'] { --tone-h: 277; --tone-c: 0.19; } -:root[data-accent='purple'] { --tone-h: 305; --tone-c: 0.17; } -:root[data-accent='rose'] { --tone-h: 8; --tone-c: 0.17; } -/* darkest orange that still reads orange; white button text lands ≈ 4.3:1 */ -:root[data-accent='orange'] { --tone-h: 50; --tone-c: 0.18; --tone-l: 0.58; } -:root[data-accent='green'] { --tone-h: 150; --tone-c: 0.15; } -:root[data-accent='teal'] { --tone-h: 185; --tone-c: 0.12; } - -:root[data-accent] { - --primary: oklch(var(--tone-l, 0.55) var(--tone-c) var(--tone-h)); - --ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h)); - --cursor-tint: color-mix(in oklab, var(--primary) 8%, transparent); -} -:root[data-theme='dark'][data-accent] { - --primary: oklch(0.72 calc(var(--tone-c) * 0.85) var(--tone-h)); - --ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h)); - --cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent); -} -@media (prefers-color-scheme: dark) { - :root[data-accent]:not([data-theme='light']) { - --primary: oklch(0.72 calc(var(--tone-c) * 0.85) var(--tone-h)); - --ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h)); - --cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent); - } -} - html, body { height: 100%; @@ -286,3 +231,92 @@ body { cursor: default; } +/* Native controls, restyled square to match the rest. WebKit draws checkboxes, + radios and selects natively and ignores border-radius/background until + appearance: none. In @layer base so utilities (size-3.5, pl-1 …) still win — + which is also why the selects use pl-*, never px-*: a px utility would + override the padding-right the caret needs. */ +@layer base { + input[type='checkbox'], + input[type='radio'] { + appearance: none; + display: inline-grid; + place-content: center; + flex-shrink: 0; + width: 0.875rem; + height: 0.875rem; + margin: 0; + border: 1px solid var(--input); + background-color: var(--background); + } + input[type='checkbox']:checked, + input[type='radio']:checked { + border-color: var(--primary); + background-color: var(--primary); + } + /* the check: an L of two borders, rotated */ + input[type='checkbox']:checked::after { + content: ''; + width: 0.5rem; + height: 0.28rem; + border: solid var(--primary-foreground); + border-width: 0 0 0.125rem 0.125rem; + transform: translateY(-0.06rem) rotate(-45deg); + } + /* radios are square too; a square dot tells them apart from checkboxes */ + input[type='radio']:checked::after { + content: ''; + width: 0.375rem; + height: 0.375rem; + background-color: var(--primary-foreground); + } + input[type='checkbox']:focus-visible, + input[type='radio']:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 1px; + } + input[type='checkbox']:disabled, + input[type='radio']:disabled { + opacity: 0.5; + } + + /* No native arrow: a caret drawn from two currentColor triangles follows the + theme (a data-URI svg can't). The opened option list stays native — macOS + draws that popup itself. */ + select { + appearance: none; + padding-right: 1.5rem; + background-image: + linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: + calc(100% - 0.8rem) 55%, + calc(100% - 0.5rem) 55%; + background-size: 0.3rem 0.3rem; + background-repeat: no-repeat; + } + + /* number inputs (the manager's port field): no native spin buttons */ + input[type='number']::-webkit-inner-spin-button, + input[type='number']::-webkit-outer-spin-button { + appearance: none; + margin: 0; + } + + /* Square scrollbars. Styling them replaces macOS's auto-hiding overlay + scrollbars with always-visible ones — the inherent cost. */ + ::-webkit-scrollbar { + width: 0.625rem; + height: 0.625rem; + } + ::-webkit-scrollbar-track, + ::-webkit-scrollbar-corner { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background-color: var(--border); + } + ::-webkit-scrollbar-thumb:hover { + background-color: var(--input); + } +}