diff --git a/src/shared/types.ts b/src/shared/types.ts index 42286f7..9d3a423 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -64,6 +64,8 @@ export type AccentTone = export interface AppSettings { theme: "system" | "light" | "dark"; + /** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */ + darkTone: "dark" | "darker"; /** Accent tone for chrome and selection tint; graphite = neutral zinc. */ accent: AccentTone; /** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */ @@ -85,6 +87,7 @@ export interface AppSettings { export const DEFAULT_SETTINGS: AppSettings = { theme: "system", + darkTone: "dark", accent: "blue", iconColors: "multi", speedUnit: "MBps", diff --git a/ui/src/app/settings.service.ts b/ui/src/app/settings.service.ts index 6e9f0b9..86eafc7 100644 --- a/ui/src/app/settings.service.ts +++ b/ui/src/app/settings.service.ts @@ -1,10 +1,11 @@ -import { Injectable, inject, signal } from '@angular/core'; +import { Injectable, computed, inject, signal } from '@angular/core'; import { DEFAULT_SETTINGS, type AppSettings } from '@shared/types'; import { RpcService } from './rpc.service'; @Injectable({ providedIn: 'root' }) export class SettingsService { readonly theme = signal(DEFAULT_SETTINGS.theme); + readonly darkTone = signal(DEFAULT_SETTINGS.darkTone); readonly accent = signal(DEFAULT_SETTINGS.accent); readonly iconColors = signal(DEFAULT_SETTINGS.iconColors); readonly speedUnit = signal(DEFAULT_SETTINGS.speedUnit); @@ -17,13 +18,23 @@ export class SettingsService { private readonly rpc = inject(RpcService); + /** Whether the app is actually rendering dark right now (drives the Tone row). */ + readonly effectiveDark = computed( + () => this.theme() === 'dark' || (this.theme() === 'system' && this.systemDark()), + ); + private readonly systemDark = signal(window.matchMedia('(prefers-color-scheme: dark)').matches); + constructor() { + window + .matchMedia('(prefers-color-scheme: dark)') + .addEventListener('change', (e) => this.systemDark.set(e.matches)); // the default accent is not the CSS default — apply before first paint this.applyAccent(); if (!this.rpc.available) return; void this.rpc.request.getSettings({}).then((res) => { if (!res.ok) return; this.theme.set(res.settings.theme); + this.darkTone.set(res.settings.darkTone); this.accent.set(res.settings.accent); this.iconColors.set(res.settings.iconColors); this.speedUnit.set(res.settings.speedUnit); @@ -34,6 +45,7 @@ export class SettingsService { this.editor.set(res.settings.editor); this.editorApp.set(res.settings.editorApp); this.applyTheme(); + this.applyDarkTone(); this.applyAccent(); this.applyTextSize(); }); @@ -45,6 +57,12 @@ export class SettingsService { void this.persist(); } + setDarkTone(tone: AppSettings['darkTone']): void { + this.darkTone.set(tone); + this.applyDarkTone(); + void this.persist(); + } + setAccent(tone: AppSettings['accent']): void { this.accent.set(tone); this.applyAccent(); @@ -102,6 +120,15 @@ export class SettingsService { } } + private applyDarkTone(): void { + const tone = this.darkTone(); + if (tone === 'dark') { + delete document.documentElement.dataset['darkTone']; + } else { + document.documentElement.dataset['darkTone'] = tone; + } + } + private applyAccent(): void { const tone = this.accent(); if (tone === 'graphite') { @@ -123,6 +150,7 @@ export class SettingsService { protected settings(): AppSettings { return { theme: this.theme(), + darkTone: this.darkTone(), accent: this.accent(), iconColors: this.iconColors(), speedUnit: this.speedUnit(), diff --git a/ui/src/app/tab-bar/tab-bar.ts b/ui/src/app/tab-bar/tab-bar.ts index d0b77ba..b772ffd 100644 --- a/ui/src/app/tab-bar/tab-bar.ts +++ b/ui/src/app/tab-bar/tab-bar.ts @@ -7,6 +7,7 @@ import { RpcService } from '../rpc.service'; import { SettingsService } from '../settings.service'; 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 // cards (the live --primary values would vanish against one of them). const ACCENTS: readonly { value: AccentTone; swatch: string; label: string }[] = [ @@ -133,6 +134,25 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; + @if (settings.effectiveDark()) { +
+ Tone +
+ @for (tone of darkTones; track tone) { + + } +
+
+ } +
Accent
@@ -322,6 +342,7 @@ export class TabBar { readonly settingsOpen = signal(false); readonly isMacChrome = IS_MAC_CHROME; readonly themes = THEMES; + readonly darkTones = DARK_TONES; readonly accents = ACCENTS; readonly iconColorModes = ICON_COLOR_MODES; readonly speedUnits = SPEED_UNITS; diff --git a/ui/src/styles.css b/ui/src/styles.css index 541eb66..5723f10 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -41,23 +41,23 @@ :root[data-theme='dark'] { color-scheme: dark; - --background: oklch(0.141 0.005 285.823); + --background: oklch(0.27 0.006 286.03); --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); + --card: oklch(0.315 0.007 286.05); --card-foreground: oklch(0.985 0 0); --primary: oklch(0.92 0.004 286.32); --primary-foreground: oklch(0.21 0.006 285.885); - --secondary: oklch(0.274 0.006 286.033); + --secondary: oklch(0.37 0.013 285.805); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); + --muted: oklch(0.37 0.013 285.805); + --muted-foreground: oklch(0.74 0.015 286.07); + --accent: oklch(0.37 0.013 285.805); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(0.985 0 0); --success: oklch(0.696 0.17 149.2); - --border: oklch(1 0 0 / 12%); - --input: oklch(1 0 0 / 15%); + --border: oklch(1 0 0 / 14%); + --input: oklch(1 0 0 / 18%); --ring: oklch(0.552 0.016 285.938); --icon-folder: oklch(0.75 0.11 250); --icon-link: oklch(0.74 0.09 190); @@ -69,23 +69,23 @@ @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { - --background: oklch(0.141 0.005 285.823); + --background: oklch(0.27 0.006 286.03); --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0.006 285.885); + --card: oklch(0.315 0.007 286.05); --card-foreground: oklch(0.985 0 0); --primary: oklch(0.92 0.004 286.32); --primary-foreground: oklch(0.21 0.006 285.885); - --secondary: oklch(0.274 0.006 286.033); + --secondary: oklch(0.37 0.013 285.805); --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); + --muted: oklch(0.37 0.013 285.805); + --muted-foreground: oklch(0.74 0.015 286.07); + --accent: oklch(0.37 0.013 285.805); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(0.985 0 0); --success: oklch(0.696 0.17 149.2); - --border: oklch(1 0 0 / 12%); - --input: oklch(1 0 0 / 15%); + --border: oklch(1 0 0 / 14%); + --input: oklch(1 0 0 / 18%); --ring: oklch(0.552 0.016 285.938); --icon-folder: oklch(0.75 0.11 250); --icon-link: oklch(0.74 0.09 190); @@ -96,6 +96,32 @@ } } +/* Dark tone "darker" (data-dark-tone set by SettingsService; absent = grey "dark"). + Near-black AMOLED palette. Duplicated for forced-dark and system-dark like the + token blocks above — the extra attribute outranks them. */ +:root[data-theme='dark'][data-dark-tone='darker'] { + --background: oklch(0.141 0.005 285.823); + --card: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --border: oklch(1 0 0 / 12%); + --input: oklch(1 0 0 / 15%); +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light'])[data-dark-tone='darker'] { + --background: oklch(0.141 0.005 285.823); + --card: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --border: oklch(1 0 0 / 12%); + --input: oklch(1 0 0 / 15%); + } +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground);