From f3893d98d44ddea99334d132d0532cc20c381c7e Mon Sep 17 00:00:00 2001 From: Cho-P4 Date: Thu, 23 Jul 2026 00:56:10 +0800 Subject: [PATCH] feat(theme): add Material You tonal palette --- package.json | 3 +- .../theme/services/MaterialYouPalette.test.ts | 45 ++++++++ .../theme/services/MaterialYouPalette.ts | 102 ++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/features/theme/services/MaterialYouPalette.test.ts create mode 100644 src/features/theme/services/MaterialYouPalette.ts diff --git a/package.json b/package.json index d08b52b1..e807f074 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "dev": "pnpm setup && vite", "preview": "pnpm setup && vite preview", "build": "pnpm setup && vite build", - "test": "node -e \"console.log('imagine')\"", + "test": "pnpm test:material-you", + "test:material-you": "tsx --test src/features/theme/services/*.test.ts", "build-md5": "find build -type f | sort | xargs md5sum | awk '{ print $1 }' | tr -d '\n' | md5sum| awk '{ print $1 }' > buildZip/md5sum ", "build-zip": "cd build && rev=$(git rev-list HEAD --count) && echo r$rev > revision && zip -9 -r ../buildZip/Suwayomi-WebUI-r$rev *", "lint": "oxlint src tools", diff --git a/src/features/theme/services/MaterialYouPalette.test.ts b/src/features/theme/services/MaterialYouPalette.test.ts new file mode 100644 index 00000000..9632c8e8 --- /dev/null +++ b/src/features/theme/services/MaterialYouPalette.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createMaterialYouScheme, MATERIAL_YOU_STABLE_SEED } from './MaterialYouPalette.ts'; + +describe('createMaterialYouScheme', () => { + it('creates a deterministic stable blue light scheme', () => { + const first = createMaterialYouScheme(MATERIAL_YOU_STABLE_SEED, 'light'); + const second = createMaterialYouScheme(MATERIAL_YOU_STABLE_SEED, 'light'); + + assert.deepEqual(first, second); + assert.equal(first.seed, '#5B74EF'); + assert.equal(first.onSurface, '#171B2C'); + assert.notEqual(first.surface, first.surfaceContainer); + }); + + it('orders dark tonal surfaces from darkest to lightest', () => { + const scheme = createMaterialYouScheme('#8F7CF7', 'dark'); + + assert.deepEqual( + [scheme.surface, scheme.surfaceContainer, scheme.surfaceContainerHigh], + ['#11131A', '#191B23', '#24262F'], + ); + assert.equal(scheme.onSurface, '#E4E6F0'); + }); + + it('uses true black only for the base pure-black surface', () => { + const scheme = createMaterialYouScheme('#5B74EF', 'dark', true); + + assert.equal(scheme.surface, '#000000'); + assert.notEqual(scheme.surfaceContainer, '#000000'); + assert.notEqual(scheme.primary, scheme.onPrimary); + }); + + it('normalizes invalid seeds to the stable seed', () => { + assert.deepEqual(createMaterialYouScheme('not-a-color', 'dark'), createMaterialYouScheme('#5B74EF', 'dark')); + }); +}); diff --git a/src/features/theme/services/MaterialYouPalette.ts b/src/features/theme/services/MaterialYouPalette.ts new file mode 100644 index 00000000..08a5fdab --- /dev/null +++ b/src/features/theme/services/MaterialYouPalette.ts @@ -0,0 +1,102 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { hsl, parseToHsl } from 'polished'; +import { coerceIn } from '@/lib/HelperFunctions.ts'; + +export const MATERIAL_YOU_STABLE_SEED = '#5B74EF'; + +export type MaterialYouScheme = { + seed: string; + primary: string; + onPrimary: string; + primaryContainer: string; + onPrimaryContainer: string; + secondary: string; + onSecondary: string; + tertiary: string; + surface: string; + surfaceContainer: string; + surfaceContainerHigh: string; + surfaceVariant: string; + onSurface: string; + onSurfaceVariant: string; + outline: string; + outlineVariant: string; +}; + +const toUpperHex = (color: string): string => color.toUpperCase(); + +const normalizeSeed = (seed: string): string => { + try { + const parsed = parseToHsl(seed); + return toUpperHex(hsl(parsed.hue, parsed.saturation, parsed.lightness)); + } catch { + return MATERIAL_YOU_STABLE_SEED; + } +}; + +const tone = (seed: string, lightness: number, saturationDelta: number = 0): string => { + const parsed = parseToHsl(seed); + + return toUpperHex(hsl(parsed.hue, coerceIn(parsed.saturation + saturationDelta, 0.25, 0.92), lightness)); +}; + +const shiftedTone = (seed: string, hueDelta: number, lightness: number): string => { + const parsed = parseToHsl(seed); + + return toUpperHex(hsl((parsed.hue + hueDelta + 360) % 360, coerceIn(parsed.saturation * 0.58, 0.28, 0.62), lightness)); +}; + +export const createMaterialYouScheme = ( + requestedSeed: string, + mode: 'light' | 'dark', + pureBlack: boolean = false, +): MaterialYouScheme => { + const seed = normalizeSeed(requestedSeed); + + if (mode === 'dark') { + return { + seed, + primary: tone(seed, 0.78, -0.08), + onPrimary: tone(seed, 0.2, -0.12), + primaryContainer: tone(seed, 0.3, -0.1), + onPrimaryContainer: tone(seed, 0.9, -0.14), + secondary: shiftedTone(seed, 24, 0.76), + onSecondary: shiftedTone(seed, 24, 0.2), + tertiary: shiftedTone(seed, 62, 0.78), + surface: pureBlack ? '#000000' : '#11131A', + surfaceContainer: '#191B23', + surfaceContainerHigh: '#24262F', + surfaceVariant: '#2D303A', + onSurface: '#E4E6F0', + onSurfaceVariant: '#C4C6D0', + outline: '#8E909A', + outlineVariant: '#454751', + }; + } + + return { + seed, + primary: tone(seed, 0.43, -0.03), + onPrimary: '#FFFFFF', + primaryContainer: tone(seed, 0.9, -0.13), + onPrimaryContainer: tone(seed, 0.2, -0.1), + secondary: shiftedTone(seed, 24, 0.42), + onSecondary: '#FFFFFF', + tertiary: shiftedTone(seed, 62, 0.43), + surface: '#F9F9FF', + surfaceContainer: '#F0F1FA', + surfaceContainerHigh: '#E8E9F2', + surfaceVariant: '#E1E2EC', + onSurface: '#171B2C', + onSurfaceVariant: '#444752', + outline: '#757781', + outlineVariant: '#C5C6D0', + }; +};