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:
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user