Dark tone setting: grey Dark (default) vs near-black Darker

Tone row appears under Theme when the effective theme is dark.
Grey palette uses macOS-like #26262a background.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 15:43:01 +02:00
co-authored by Claude Fable 5
parent eb86ca514d
commit bdae49ba24
4 changed files with 95 additions and 17 deletions
+3
View File
@@ -64,6 +64,8 @@ export type AccentTone =
export interface AppSettings { export interface AppSettings {
theme: "system" | "light" | "dark"; 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 tone for chrome and selection tint; graphite = neutral zinc. */
accent: AccentTone; accent: AccentTone;
/** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */ /** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */
@@ -85,6 +87,7 @@ export interface AppSettings {
export const DEFAULT_SETTINGS: AppSettings = { export const DEFAULT_SETTINGS: AppSettings = {
theme: "system", theme: "system",
darkTone: "dark",
accent: "blue", accent: "blue",
iconColors: "multi", iconColors: "multi",
speedUnit: "MBps", speedUnit: "MBps",
+29 -1
View File
@@ -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 { DEFAULT_SETTINGS, type AppSettings } from '@shared/types';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SettingsService { export class SettingsService {
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme); readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent); readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors); readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit); readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
@@ -17,13 +18,23 @@ export class SettingsService {
private readonly rpc = inject(RpcService); 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() { 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 // the default accent is not the CSS default — apply before first paint
this.applyAccent(); this.applyAccent();
if (!this.rpc.available) return; if (!this.rpc.available) return;
void this.rpc.request.getSettings({}).then((res) => { void this.rpc.request.getSettings({}).then((res) => {
if (!res.ok) return; if (!res.ok) return;
this.theme.set(res.settings.theme); this.theme.set(res.settings.theme);
this.darkTone.set(res.settings.darkTone);
this.accent.set(res.settings.accent); this.accent.set(res.settings.accent);
this.iconColors.set(res.settings.iconColors); this.iconColors.set(res.settings.iconColors);
this.speedUnit.set(res.settings.speedUnit); this.speedUnit.set(res.settings.speedUnit);
@@ -34,6 +45,7 @@ export class SettingsService {
this.editor.set(res.settings.editor); this.editor.set(res.settings.editor);
this.editorApp.set(res.settings.editorApp); this.editorApp.set(res.settings.editorApp);
this.applyTheme(); this.applyTheme();
this.applyDarkTone();
this.applyAccent(); this.applyAccent();
this.applyTextSize(); this.applyTextSize();
}); });
@@ -45,6 +57,12 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
setDarkTone(tone: AppSettings['darkTone']): void {
this.darkTone.set(tone);
this.applyDarkTone();
void this.persist();
}
setAccent(tone: AppSettings['accent']): void { setAccent(tone: AppSettings['accent']): void {
this.accent.set(tone); this.accent.set(tone);
this.applyAccent(); 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 { private applyAccent(): void {
const tone = this.accent(); const tone = this.accent();
if (tone === 'graphite') { if (tone === 'graphite') {
@@ -123,6 +150,7 @@ export class SettingsService {
protected settings(): AppSettings { protected settings(): AppSettings {
return { return {
theme: this.theme(), theme: this.theme(),
darkTone: this.darkTone(),
accent: this.accent(), accent: this.accent(),
iconColors: this.iconColors(), iconColors: this.iconColors(),
speedUnit: this.speedUnit(), speedUnit: this.speedUnit(),
+21
View File
@@ -7,6 +7,7 @@ import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service'; import { SettingsService } from '../settings.service';
const THEMES = ['system', 'light', 'dark'] as const; 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 // 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). // cards (the live --primary values would vanish against one of them).
const ACCENTS: readonly { value: AccentTone; swatch: string; label: string }[] = [ const ACCENTS: readonly { value: AccentTone; swatch: string; label: string }[] = [
@@ -133,6 +134,25 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</div> </div>
</div> </div>
@if (settings.effectiveDark()) {
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Tone</span>
<div class="flex rounded-md border border-border p-0.5">
@for (tone of darkTones; track tone) {
<button
(click)="settings.setDarkTone(tone)"
class="rounded px-2 py-0.5 text-[11px] capitalize"
[class.bg-accent]="settings.darkTone() === tone"
[class.text-foreground]="settings.darkTone() === tone"
[class.text-muted-foreground]="settings.darkTone() !== tone"
>
{{ tone }}
</button>
}
</div>
</div>
}
<div class="mt-2.5 flex items-center justify-between gap-2"> <div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Accent</span> <span class="text-xs text-muted-foreground">Accent</span>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
@@ -322,6 +342,7 @@ export class TabBar {
readonly settingsOpen = signal(false); readonly settingsOpen = signal(false);
readonly isMacChrome = IS_MAC_CHROME; readonly isMacChrome = IS_MAC_CHROME;
readonly themes = THEMES; readonly themes = THEMES;
readonly darkTones = DARK_TONES;
readonly accents = ACCENTS; readonly accents = ACCENTS;
readonly iconColorModes = ICON_COLOR_MODES; readonly iconColorModes = ICON_COLOR_MODES;
readonly speedUnits = SPEED_UNITS; readonly speedUnits = SPEED_UNITS;
+42 -16
View File
@@ -41,23 +41,23 @@
:root[data-theme='dark'] { :root[data-theme='dark'] {
color-scheme: 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); --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); --card-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32); --primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885); --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); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033); --muted: oklch(0.37 0.013 285.805);
--muted-foreground: oklch(0.705 0.015 286.067); --muted-foreground: oklch(0.74 0.015 286.07);
--accent: oklch(0.274 0.006 286.033); --accent: oklch(0.37 0.013 285.805);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.696 0.17 149.2); --success: oklch(0.696 0.17 149.2);
--border: oklch(1 0 0 / 12%); --border: oklch(1 0 0 / 14%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 18%);
--ring: oklch(0.552 0.016 285.938); --ring: oklch(0.552 0.016 285.938);
--icon-folder: oklch(0.75 0.11 250); --icon-folder: oklch(0.75 0.11 250);
--icon-link: oklch(0.74 0.09 190); --icon-link: oklch(0.74 0.09 190);
@@ -69,23 +69,23 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) { :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); --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); --card-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32); --primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885); --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); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033); --muted: oklch(0.37 0.013 285.805);
--muted-foreground: oklch(0.705 0.015 286.067); --muted-foreground: oklch(0.74 0.015 286.07);
--accent: oklch(0.274 0.006 286.033); --accent: oklch(0.37 0.013 285.805);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
--success: oklch(0.696 0.17 149.2); --success: oklch(0.696 0.17 149.2);
--border: oklch(1 0 0 / 12%); --border: oklch(1 0 0 / 14%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 18%);
--ring: oklch(0.552 0.016 285.938); --ring: oklch(0.552 0.016 285.938);
--icon-folder: oklch(0.75 0.11 250); --icon-folder: oklch(0.75 0.11 250);
--icon-link: oklch(0.74 0.09 190); --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 { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);