fix(theme): fall back to default color for invalid stored theme color

Closes #12287
This commit is contained in:
Andras Bacsai
2026-10-23 20:05:08 +02:00
parent 16222f3588
commit 30d840f1c6
2 changed files with 53 additions and 4 deletions
+13 -4
View File
@@ -6,8 +6,16 @@
// The OS color picker only speaks hex. Convert it once so the whole theme
// cascade is authored in OKLCH (sRGB -> linear -> OKLab -> OKLCH).
const srgbToLinear = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
// localStorage can hold values from older versions or manual edits, so accept
// only #rgb / #rrggbb and fall back to the default color for anything else.
window.normalizeThemeColor = (color) => {
const hex = String(color ?? '').trim().replace(/^#/, '').toLowerCase();
if (/^[a-f\d]{6}$/.test(hex)) return `#${hex}`;
if (/^[a-f\d]{3}$/.test(hex)) return `#${hex.replace(/./g, '$&$&')}`;
return '#6b16ed';
};
window.hexToOklch = (hex) => {
const [r, g, b] = hex.match(/[a-f\d]{2}/gi).map((channel) => srgbToLinear(parseInt(channel, 16) / 255));
const [r, g, b] = window.normalizeThemeColor(hex).match(/[a-f\d]{2}/gi).map((channel) => srgbToLinear(parseInt(channel, 16) / 255));
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
@@ -20,7 +28,7 @@
return `oklch(${(okL * 100).toFixed(2)}% ${chroma.toFixed(4)} ${hue.toFixed(2)})`;
};
window.themeAccentForeground = (color) => {
const channels = color.match(/[a-f\d]{2}/gi).map(channel => parseInt(channel, 16) * 0.85 + 255 * 0.15);
const channels = window.normalizeThemeColor(color).match(/[a-f\d]{2}/gi).map(channel => parseInt(channel, 16) * 0.85 + 255 * 0.15);
const luminance = channels
.map(channel => channel / 255)
.map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)
@@ -30,7 +38,7 @@
};
window.applyStoredTheme = () => {
const theme = localStorage.theme === 'purple' ? 'custom' : (localStorage.theme || 'dark');
const themeColor = localStorage.themeColor || '#6b16ed';
const themeColor = window.normalizeThemeColor(localStorage.themeColor);
const customMode = localStorage.customMode || 'dark';
document.documentElement.dataset.navStyle = localStorage.navStyle === 'floating' ? 'floating' : 'default';
document.documentElement.dataset.navItems = localStorage.navItems === 'flat' ? 'flat' : '3d';
@@ -39,6 +47,7 @@
|| (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
localStorage.theme = theme;
if (localStorage.themeColor) localStorage.themeColor = themeColor;
document.documentElement.classList.toggle('dark', isDark);
document.documentElement.dataset.theme = theme === 'custom' ? 'custom' : (isDark ? 'dark' : 'light');
document.documentElement.style.setProperty('--theme-base-color', window.hexToOklch(themeColor));
@@ -49,7 +58,7 @@
// Appearance page and the profile dropdown via x-data="themeControls()".
window.themeControls = () => ({
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
themeColor: localStorage.getItem('themeColor') || '#6b16ed',
themeColor: window.normalizeThemeColor(localStorage.getItem('themeColor')),
customMode: localStorage.getItem('customMode') || 'dark',
pageWidth: localStorage.getItem('pageWidth') || 'centered',
navStyle: localStorage.getItem('navStyle') === 'floating' ? 'floating' : 'default',
@@ -0,0 +1,40 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
seedBrowserResourceStack();
});
it('falls back to the default theme color when the stored color is not hex', function () {
loginAndSkipBoarding();
$page = visit('/')->assertSee('Dashboard');
$page->script("localStorage.setItem('theme', 'custom'); localStorage.setItem('themeColor', 'purple');");
$page = $page->navigate('/')->assertSee('Dashboard')->assertNoJavaScriptErrors();
$result = $page->script(<<<'JS'
() => ({
base: document.documentElement.style.getPropertyValue('--theme-base-color'),
foreground: document.documentElement.style.getPropertyValue('--theme-accent-foreground'),
stored: localStorage.getItem('themeColor'),
shorthand: window.hexToOklch('#fff'),
rgb: window.hexToOklch('rgb(107,22,237)'),
oklch: window.themeAccentForeground('oklch(50% 0.2 290)'),
})
JS);
$page->screenshot(filename: 'theme-color-fallback');
expect($result)->toBe([
'base' => 'oklch(49.65% 0.2709 289.33)',
'foreground' => 'oklch(100% 0 0)',
'stored' => '#6b16ed',
'shorthand' => 'oklch(100.00% 0.0000 89.88)',
'rgb' => 'oklch(49.65% 0.2709 289.33)',
'oklch' => 'oklch(100% 0 0)',
]);
});