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
+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 { RpcService } from './rpc.service';
@Injectable({ providedIn: 'root' })
export class SettingsService {
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 iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
readonly speedUnit = signal<AppSettings['speedUnit']>(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(),
+21
View File
@@ -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;
</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">
<span class="text-xs text-muted-foreground">Accent</span>
<div class="flex items-center gap-1.5">
@@ -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;
+42 -16
View File
@@ -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);