Add customizable theme color (#582)

This commit is contained in:
Flaminel
2026-04-26 01:28:07 +03:00
committed by GitHub
parent db2e3e71db
commit 9f48d3565a
57 changed files with 774 additions and 135 deletions

View File

@@ -52,6 +52,7 @@ import {
tablerHistory,
tablerGripVertical,
tablerFilter,
tablerPalette,
} from '@ng-icons/tabler-icons';
import { routes } from './app.routes';
@@ -114,6 +115,7 @@ export const appConfig: ApplicationConfig = {
tablerHistory,
tablerGripVertical,
tablerFilter,
tablerPalette,
}),
],
};

View File

@@ -128,6 +128,13 @@ export const routes: Routes = [
'@features/settings/account/account-settings.component'
).then((m) => m.AccountSettingsComponent),
},
{
path: 'appearance',
loadComponent: () =>
import(
'@features/settings/appearance/appearance-settings.component'
).then((m) => m.AppearanceSettingsComponent),
},
],
},
],

View File

@@ -2,19 +2,68 @@ import { Injectable, signal, effect } from '@angular/core';
export type Theme = 'dark' | 'light';
export const ACCENT_PRESETS = [
'default',
'blue',
'green',
'rose',
'amber',
'teal',
] as const;
export type AccentPreset = (typeof ACCENT_PRESETS)[number];
export type Accent = AccentPreset | 'custom';
// Preview swatch colors for each preset. These mirror the --brand-500 stop
// declared in styles/_accents.scss (and styles/_tokens.scss for 'default').
// Keep the two in sync — there is no SCSS-from-TS import path.
export const ACCENT_PRESET_HEX: Record<AccentPreset, string> = {
default: '#8b5cf6',
blue: '#3b82f6',
green: '#10b981',
rose: '#f43f5e',
amber: '#f59e0b',
teal: '#14b8a6',
};
const THEME_KEY = 'cleanuparr-theme';
const PERFORMANCE_MODE_KEY = 'cleanuparr-performance-mode';
const FULL_WIDTH_KEY = 'cleanuparr-full-width';
const ACCENT_KEY = 'cleanuparr-accent';
const CUSTOM_ACCENT_KEY = 'cleanuparr-custom-accent';
const DEFAULT_CUSTOM_ACCENT = '#8b5cf6';
const HEX_COLOR_REGEX = /^#[0-9a-f]{6}$/i;
const BRAND_SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
// Lightness stops per shade, tuned to match the visual weight of the default purple scale.
const LIGHTNESS_STOPS: Record<(typeof BRAND_SHADES)[number], number> = {
50: 97,
100: 93,
200: 86,
300: 75,
400: 62,
500: 50,
600: 42,
700: 34,
800: 27,
900: 20,
950: 12,
};
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly root = document.documentElement;
private readonly _theme = signal<Theme>('dark');
private readonly _performanceMode = signal(false);
private readonly _fullWidth = signal(false);
private readonly _accent = signal<Accent>('default');
private readonly _customAccent = signal<string>(DEFAULT_CUSTOM_ACCENT);
readonly theme = this._theme.asReadonly();
readonly performanceMode = this._performanceMode.asReadonly();
readonly fullWidth = this._fullWidth.asReadonly();
readonly accent = this._accent.asReadonly();
readonly customAccent = this._customAccent.asReadonly();
constructor() {
this.restoreFromStorage();
@@ -55,6 +104,38 @@ export class ThemeService {
localStorage.setItem(FULL_WIDTH_KEY, String(value));
}
setAccent(accent: Accent): void {
this._accent.set(accent);
localStorage.setItem(ACCENT_KEY, accent);
}
/**
* Picks the right icon variant for the active theme. Asset filenames must
* follow the `*-light.svg` (designed for dark backgrounds) /
* `*-dark.svg` (designed for light backgrounds) convention.
*/
themedIconSrc(src: string): string {
if (this._theme() === 'dark')
{
return src;
}
return src.replace('-light.svg', '-dark.svg');
}
setCustomAccent(hex: string): void {
const normalized = hex.trim().toLowerCase();
if (!HEX_COLOR_REGEX.test(normalized))
{
return;
}
this._customAccent.set(normalized);
localStorage.setItem(CUSTOM_ACCENT_KEY, normalized);
if (this._accent() !== 'custom')
{
this.setAccent('custom');
}
}
private restoreFromStorage(): void {
const savedTheme = localStorage.getItem(THEME_KEY);
if (savedTheme === 'light' || savedTheme === 'dark') {
@@ -70,6 +151,18 @@ export class ThemeService {
if (savedFullWidth === 'true') {
this._fullWidth.set(true);
}
const savedAccent = localStorage.getItem(ACCENT_KEY);
const migratedAccent = savedAccent === 'purple' ? 'default' : savedAccent;
if (migratedAccent && this.isAccent(migratedAccent)) {
this._accent.set(migratedAccent);
}
const savedCustom = localStorage.getItem(CUSTOM_ACCENT_KEY);
if (savedCustom && HEX_COLOR_REGEX.test(savedCustom))
{
this._customAccent.set(savedCustom);
}
}
private detectSystemPreferences(): void {
@@ -81,15 +174,111 @@ export class ThemeService {
private bindToDom(): void {
effect(() => {
document.documentElement.setAttribute('data-theme', this._theme());
this.root.setAttribute('data-theme', this._theme());
});
effect(() => {
document.documentElement.setAttribute('data-performance-mode', String(this._performanceMode()));
this.root.setAttribute('data-performance-mode', String(this._performanceMode()));
});
effect(() => {
document.documentElement.setAttribute('data-full-width', String(this._fullWidth()));
this.root.setAttribute('data-full-width', String(this._fullWidth()));
});
effect(() => {
const accent = this._accent();
this.root.setAttribute('data-accent', accent);
if (accent === 'custom')
{
this.applyCustomAccent(this._customAccent());
}
else
{
this.clearInlineAccent();
}
});
}
private isAccent(value: string): value is Accent {
return value === 'custom' || (ACCENT_PRESETS as readonly string[]).includes(value);
}
private applyCustomAccent(hex: string): void {
const rgb = hexToRgb(hex);
if (!rgb)
{
return;
}
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
// Keep some chroma even when the user picks a near-gray, otherwise the whole
// brand scale collapses to shades of gray and active states become invisible.
const s = Math.max(hsl.s, 15);
for (const shade of BRAND_SHADES)
{
const l = shade === 500 ? hsl.l : LIGHTNESS_STOPS[shade];
const { r, g, b } = hslToRgb(hsl.h, s, l);
this.root.style.setProperty(`--brand-${shade}`, rgbToHex(r, g, b));
}
this.root.style.setProperty('--accent-rgb', `${rgb.r}, ${rgb.g}, ${rgb.b}`);
}
private clearInlineAccent(): void {
for (const shade of BRAND_SHADES)
{
this.root.style.removeProperty(`--brand-${shade}`);
}
this.root.style.removeProperty('--accent-rgb');
}
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!match) return null;
const n = parseInt(match[1], 16);
return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff };
}
function rgbToHex(r: number, g: number, b: number): string {
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
const hex = (v: number) => clamp(v).toString(16).padStart(2, '0');
return `#${hex(r)}${hex(g)}${hex(b)}`;
}
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
let h = 0, s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break;
case gn: h = (bn - rn) / d + 2; break;
case bn: h = (rn - gn) / d + 4; break;
}
h *= 60;
}
return { h, s: s * 100, l: l * 100 };
}
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
const sn = s / 100, ln = l / 100;
const c = (1 - Math.abs(2 * ln - 1)) * sn;
const hp = h / 60;
const x = c * (1 - Math.abs((hp % 2) - 1));
let r1 = 0, g1 = 0, b1 = 0;
if (hp >= 0 && hp < 1) [r1, g1, b1] = [c, x, 0];
else if (hp < 2) [r1, g1, b1] = [x, c, 0];
else if (hp < 3) [r1, g1, b1] = [0, c, x];
else if (hp < 4) [r1, g1, b1] = [0, x, c];
else if (hp < 5) [r1, g1, b1] = [x, 0, c];
else [r1, g1, b1] = [c, 0, x];
const m = ln - c / 2;
return { r: (r1 + m) * 255, g: (g1 + m) * 255, b: (b1 + m) * 255 };
}

View File

@@ -130,16 +130,16 @@
font-family: var(--font-family);
font-size: var(--font-size-sm);
font-weight: 500;
color: #ffffff;
background: #7E57C2;
color: var(--color-primary-text);
background: var(--color-primary);
border: 1px solid transparent;
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-default);
&:hover:not(:disabled) {
background: #6D28D9;
box-shadow: 0 0 20px rgba(126, 87, 194, 0.4);
background: var(--color-primary-hover);
box-shadow: 0 0 20px rgba(var(--accent-rgb), 0.4);
}
&:active:not(:disabled) {

View File

@@ -47,15 +47,15 @@
transition: all var(--duration-normal) var(--ease-default);
.step-group.active & {
background: rgba(126, 87, 194, 0.15);
background: rgba(var(--accent-rgb), 0.15);
color: var(--color-primary);
box-shadow: 0 0 8px rgba(126, 87, 194, 0.25);
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.25);
}
.step-group.completed & {
background: var(--color-primary);
color: #ffffff;
box-shadow: 0 0 12px rgba(126, 87, 194, 0.35);
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.35);
}
}

View File

@@ -30,7 +30,7 @@
&:hover {
background: var(--glass-bg-hover);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(126, 87, 194, 0.12);
box-shadow: 0 8px 24px rgba(var(--accent-rgb), 0.12);
}
// GitHub — monochrome grey/white
@@ -278,8 +278,8 @@
}
&--important {
background: rgba(126, 87, 194, 0.08);
border-color: rgba(126, 87, 194, 0.25);
background: rgba(var(--accent-rgb), 0.08);
border-color: rgba(var(--accent-rgb), 0.25);
animation: slide-up var(--duration-normal) var(--ease-default), glow-pulse-important 3s ease-in-out infinite;
animation-delay: 0s, var(--duration-normal);
}
@@ -533,9 +533,9 @@
box-shadow: 0 0 8px rgba(34, 197, 94, 0.25);
}
&--primary {
background: rgba(126, 87, 194, 0.15);
background: rgba(var(--accent-rgb), 0.15);
color: var(--color-primary);
box-shadow: 0 0 8px rgba(126, 87, 194, 0.25);
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.25);
}
&--default {
background: rgba(148, 163, 184, 0.15);
@@ -726,10 +726,10 @@
@keyframes glow-pulse-important {
0%, 100% {
box-shadow: 0 0 12px rgba(126, 87, 194, 0.1), 0 0 24px rgba(126, 87, 194, 0.05);
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.1), 0 0 24px rgba(var(--accent-rgb), 0.05);
}
50% {
box-shadow: 0 0 20px rgba(126, 87, 194, 0.25), 0 0 40px rgba(126, 87, 194, 0.1);
box-shadow: 0 0 20px rgba(var(--accent-rgb), 0.25), 0 0 40px rgba(var(--accent-rgb), 0.1);
}
}

View File

@@ -42,7 +42,7 @@
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent);
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.06), transparent);
transform: translateX(-100%);
transition: transform var(--duration-normal) var(--ease-default);
pointer-events: none;
@@ -233,8 +233,8 @@
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-3);
background: rgba(126, 87, 194, 0.1);
border: 1px solid rgba(126, 87, 194, 0.2);
background: rgba(var(--accent-rgb), 0.1);
border: 1px solid rgba(var(--accent-rgb), 0.2);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
color: var(--color-primary);

View File

@@ -44,7 +44,7 @@
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent);
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.06), transparent);
transform: translateX(-100%);
transition: transform var(--duration-normal) var(--ease-default);
pointer-events: none;
@@ -202,8 +202,8 @@
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-3);
background: rgba(126, 87, 194, 0.1);
border: 1px solid rgba(126, 87, 194, 0.2);
background: rgba(var(--accent-rgb), 0.1);
border: 1px solid rgba(var(--accent-rgb), 0.2);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
color: var(--color-primary);

View File

@@ -58,7 +58,7 @@
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent);
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.06), transparent);
transform: translateX(-100%);
transition: transform var(--duration-normal) var(--ease-default);
pointer-events: none;

View File

@@ -135,7 +135,7 @@
&__progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 75%, #c084fc));
background: linear-gradient(90deg, var(--color-primary), var(--brand-300));
border-radius: var(--radius-full);
transition: width var(--duration-normal) var(--ease-default);
min-width: 0;
@@ -307,7 +307,7 @@
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent);
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.06), transparent);
transform: translateX(-100%);
transition: transform var(--duration-normal) var(--ease-default);
pointer-events: none;

View File

@@ -49,7 +49,7 @@
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent);
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.06), transparent);
transform: translateX(-100%);
transition: transform var(--duration-normal) var(--ease-default);
pointer-events: none;

View File

@@ -0,0 +1,65 @@
<app-page-header
title="Appearance"
subtitle="Customize the look and feel of the interface"
/>
<div class="settings-form">
<app-card header="Theme">
<p class="section-hint">Choose between dark and light color modes.</p>
<div class="theme-options">
<button
type="button"
class="theme-option"
[class.theme-option--active]="theme() === 'dark'"
(click)="selectTheme('dark')"
>
<span class="theme-option__preview theme-option__preview--dark"></span>
<span class="theme-option__label">Dark</span>
</button>
<button
type="button"
class="theme-option"
[class.theme-option--active]="theme() === 'light'"
(click)="selectTheme('light')"
>
<span class="theme-option__preview theme-option__preview--light"></span>
<span class="theme-option__label">Light</span>
</button>
</div>
</app-card>
<app-card header="Accent color">
<p class="section-hint">Pick a preset or choose a custom color. Changes apply instantly and persist across sessions.</p>
<div class="swatch-grid">
@for (swatch of presetSwatches; track swatch.value) {
<button
type="button"
class="swatch"
[class.swatch--active]="accent() === swatch.value"
[attr.aria-label]="swatch.label"
[attr.title]="swatch.label"
(click)="selectAccent(swatch.value)"
>
<span class="swatch__dot" [style.background]="swatch.color"></span>
<span class="swatch__label">{{ swatch.label }}</span>
</button>
}
<label
class="swatch swatch--custom"
[class.swatch--active]="accent() === 'custom'"
title="Custom color"
>
<span class="swatch__dot swatch__dot--custom"></span>
<span class="swatch__label">Custom</span>
<input
type="color"
class="swatch__input"
[value]="customAccent()"
(input)="onCustomColorChange($event)"
aria-label="Pick a custom accent color"
/>
</label>
</div>
</app-card>
</div>

View File

@@ -0,0 +1,132 @@
@use 'settings-layout' as *;
:host { @include settings-page; }
.settings-form { @include settings-form; }
.section-hint {
font-size: var(--font-size-sm);
color: var(--text-secondary);
line-height: 1.5;
margin: 0 0 var(--space-3);
}
.theme-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
margin-top: var(--space-2);
@media (max-width: 480px) {
grid-template-columns: 1fr;
}
}
.theme-option {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border-radius: var(--radius-lg);
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: border-color var(--duration-fast) var(--ease-default),
background var(--duration-fast) var(--ease-default),
box-shadow var(--duration-fast) var(--ease-default);
&:hover {
border-color: var(--glass-border-hover);
background: var(--glass-bg-hover);
}
&--active {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-subtle);
}
&__preview {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
border: 1px solid var(--glass-border);
flex-shrink: 0;
&--dark {
background: linear-gradient(135deg, var(--brand-950) 0%, #0c0614 100%);
}
&--light {
background: linear-gradient(135deg, #ffffff 0%, var(--brand-100) 100%);
}
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
}
.swatch-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: var(--space-2);
margin-top: var(--space-2);
}
.swatch {
position: relative;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
cursor: pointer;
text-align: left;
transition: border-color var(--duration-fast) var(--ease-default),
background var(--duration-fast) var(--ease-default),
box-shadow var(--duration-fast) var(--ease-default);
&:hover {
border-color: var(--glass-border-hover);
background: var(--glass-bg-hover);
}
&--active {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-subtle);
}
&__dot {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.15);
flex-shrink: 0;
&--custom {
background-image: conic-gradient(from 180deg, #f43f5e, #f59e0b, #10b981, #3b82f6, #7e57c2, #f43f5e);
}
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
&__input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
border: 0;
padding: 0;
}
}

View File

@@ -0,0 +1,51 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import { CardComponent } from '@ui';
import {
ACCENT_PRESETS,
ACCENT_PRESET_HEX,
Accent,
Theme,
ThemeService,
} from '@core/services/theme.service';
interface AccentSwatch {
readonly value: Accent;
readonly label: string;
readonly color: string;
}
@Component({
selector: 'app-appearance-settings',
standalone: true,
imports: [PageHeaderComponent, CardComponent],
templateUrl: './appearance-settings.component.html',
styleUrl: './appearance-settings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppearanceSettingsComponent {
private readonly themeService = inject(ThemeService);
readonly theme = this.themeService.theme;
readonly accent = this.themeService.accent;
readonly customAccent = this.themeService.customAccent;
readonly presetSwatches: AccentSwatch[] = ACCENT_PRESETS.map((value) => ({
value,
label: value.charAt(0).toUpperCase() + value.slice(1),
color: ACCENT_PRESET_HEX[value],
}));
selectTheme(theme: Theme): void {
this.themeService.setTheme(theme);
}
selectAccent(accent: Accent): void {
this.themeService.setAccent(accent);
}
onCustomColorChange(event: Event): void {
const { value } = event.target as HTMLInputElement;
this.themeService.setCustomAccent(value);
}
}

View File

@@ -84,8 +84,8 @@
@for (provider of availableProviders; track provider.type) {
<button class="provider-card" (click)="onProviderTypeSelected(provider.type)">
<span class="provider-card__icon-wrapper">
<img [src]="provider.iconLightUrl" [alt]="provider.name" class="provider-card__icon provider-card__icon--light" />
<img [src]="provider.iconUrl" [alt]="provider.name" class="provider-card__icon provider-card__icon--normal" />
<img [src]="themeService.themedIconSrc(provider.iconLightUrl)" alt="" aria-hidden="true" class="provider-card__icon provider-card__icon--light" />
<img [src]="provider.iconUrl" alt="" aria-hidden="true" class="provider-card__icon provider-card__icon--normal" />
</span>
<span class="provider-card__name">{{ provider.name }}</span>
<span class="provider-card__description">{{ provider.description }}</span>

View File

@@ -9,6 +9,7 @@ import {
import { NotificationApi } from '@core/api/notification.api';
import { ToastService } from '@core/services/toast.service';
import { ConfirmService } from '@core/services/confirm.service';
import { ThemeService } from '@core/services/theme.service';
import {
NotificationProviderDto,
CreateDiscordProviderRequest,
@@ -114,6 +115,9 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
private readonly api = inject(NotificationApi);
private readonly toast = inject(ToastService);
private readonly confirmService = inject(ConfirmService);
protected readonly themeService = inject(ThemeService);
readonly theme = this.themeService.theme;
readonly loader = new DeferredLoader();
readonly loadError = signal(false);

View File

@@ -41,7 +41,7 @@
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent);
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.06), transparent);
transform: translateX(-100%);
transition: transform var(--duration-normal) var(--ease-default);
pointer-events: none;

View File

@@ -1,7 +1,7 @@
<div class="auth-layout">
<div class="auth-layout__card">
<div class="auth-layout__brand">
<img src="icons/128.png" alt="Cleanuparr" class="auth-layout__logo" />
<app-logo class="auth-layout__logo" />
<h1>Cleanuparr</h1>
</div>
<router-outlet />

View File

@@ -68,7 +68,7 @@
&__logo {
width: 64px;
height: 64px;
filter: drop-shadow(0 0 8px rgba(126, 87, 194, 0.4));
filter: drop-shadow(0 0 8px rgba(var(--accent-rgb), 0.4));
margin-bottom: var(--space-3);
animation: float-gentle 6s ease-in-out infinite;
}

View File

@@ -1,10 +1,11 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { LogoComponent } from '@ui';
@Component({
selector: 'app-auth-layout',
standalone: true,
imports: [RouterOutlet],
imports: [RouterOutlet, LogoComponent],
templateUrl: './auth-layout.component.html',
styleUrl: './auth-layout.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -5,7 +5,7 @@
[class.sidebar--mobile-open]="mobileOpen()"
>
<div class="sidebar__brand">
<img src="icons/128.png" alt="Cleanuparr" class="sidebar__logo-img" />
<app-logo class="sidebar__logo-img" />
<span class="sidebar__logo-text">Cleanuparr</span>
</div>
@@ -69,7 +69,7 @@
(click)="onNavItemClick()"
>
@if (item.iconSrc) {
<img [src]="item.iconSrc" [alt]="item.label" class="sidebar__item-img" />
<img [src]="themeService.themedIconSrc(item.iconSrc)" alt="" aria-hidden="true" class="sidebar__item-img" />
} @else if (item.icon) {
<ng-icon [name]="item.icon" class="sidebar__item-icon" />
}

View File

@@ -28,21 +28,21 @@
}
&__logo-img {
width: 32px;
height: 32px;
width: 40px;
height: 40px;
flex-shrink: 0;
filter: drop-shadow(0 0 8px rgba(126, 87, 194, 0.4));
filter: drop-shadow(0 0 8px rgba(var(--accent-rgb), 0.4));
transition: filter var(--duration-fast) var(--ease-default);
.sidebar__brand:hover & {
filter: drop-shadow(0 0 12px rgba(126, 87, 194, 0.6));
filter: drop-shadow(0 0 12px rgba(var(--accent-rgb), 0.6));
}
}
&__logo-text {
font-size: var(--font-size-xl);
font-weight: 700;
color: #ffffff;
color: var(--sidebar-item-active-text);
letter-spacing: -0.02em;
white-space: nowrap;
overflow: hidden;
@@ -73,7 +73,7 @@
left: 0;
right: 0;
height: 60px;
background: linear-gradient(to bottom, transparent, rgba(12, 6, 20, 0.9));
background: linear-gradient(to bottom, transparent, var(--sidebar-fade));
pointer-events: none;
z-index: 1;
}
@@ -95,14 +95,14 @@
&:hover {
background: var(--sidebar-item-hover);
color: #ffffff;
color: var(--sidebar-item-active-text);
}
&--active {
background: linear-gradient(90deg, rgba(126, 87, 194, 0.3), rgba(126, 87, 194, 0.08));
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.3), rgba(var(--accent-rgb), 0.08));
color: var(--sidebar-item-active-text);
font-weight: 500;
box-shadow: 0 0 20px rgba(126, 87, 194, 0.15);
box-shadow: 0 0 20px rgba(var(--accent-rgb), 0.15);
position: relative;
// Active indicator bar
@@ -206,7 +206,7 @@
&:hover {
background: var(--sidebar-item-hover);
color: #ffffff;
color: var(--sidebar-item-active-text);
}
}
@@ -229,7 +229,7 @@
&:hover {
background: rgba(239, 68, 68, 0.08);
color: #ffffff;
color: var(--color-error);
}
}
@@ -260,7 +260,7 @@
font-family: var(--font-mono);
&:hover {
color: #ffffff;
color: var(--sidebar-section-label-hover);
}
}

View File

@@ -1,8 +1,10 @@
import { Component, ChangeDetectionStrategy, input, output, signal, inject, computed } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { NgIcon } from '@ng-icons/core';
import { LogoComponent } from '@ui';
import { AppHubService } from '@core/realtime/app-hub.service';
import { AuthService } from '@core/auth/auth.service';
import { ThemeService } from '@core/services/theme.service';
interface NavItem {
label: string;
@@ -20,7 +22,7 @@ interface ExternalLink {
@Component({
selector: 'app-nav-sidebar',
standalone: true,
imports: [RouterLink, RouterLinkActive, NgIcon],
imports: [RouterLink, RouterLinkActive, NgIcon, LogoComponent],
templateUrl: './nav-sidebar.component.html',
styleUrl: './nav-sidebar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -28,6 +30,9 @@ interface ExternalLink {
export class NavSidebarComponent {
private readonly hub = inject(AppHubService);
private readonly auth = inject(AuthService);
protected readonly themeService = inject(ThemeService);
readonly theme = this.themeService.theme;
collapsed = input(false);
mobileOpen = input(false);
@@ -74,6 +79,7 @@ export class NavSidebarComponent {
otherSettingsItems: NavItem[] = [
{ label: 'Notifications', icon: 'tablerBellRinging', route: '/settings/notifications' },
{ label: 'Account', icon: 'tablerUser', route: '/settings/account' },
{ label: 'Appearance', icon: 'tablerPalette', route: '/settings/appearance' },
];
onNavItemClick(): void {

View File

@@ -21,7 +21,7 @@
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.3), transparent);
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.3), transparent);
pointer-events: none;
}
@@ -54,7 +54,7 @@
&:hover {
background: var(--glass-bg);
color: var(--text-primary);
box-shadow: 0 0 12px rgba(126, 87, 194, 0.15);
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.15);
}
&:focus-visible {

View File

@@ -30,9 +30,9 @@
}
&--primary {
background: linear-gradient(135deg, rgba(126, 87, 194, 0.2), rgba(126, 87, 194, 0.1));
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.2), rgba(var(--accent-rgb), 0.1));
color: var(--color-primary);
border: 1px solid rgba(126, 87, 194, 0.2);
border: 1px solid rgba(var(--accent-rgb), 0.2);
}
&--success {

View File

@@ -70,13 +70,13 @@
background: var(--color-primary);
color: var(--color-primary-text);
border: 1px solid transparent;
box-shadow: 0 0 20px rgba(126, 87, 194, 0.3),
0 4px 12px rgba(126, 87, 194, 0.2);
box-shadow: 0 0 20px rgba(var(--accent-rgb), 0.3),
0 4px 12px rgba(var(--accent-rgb), 0.2);
&:hover:not(:disabled) {
background: var(--color-primary-hover);
box-shadow: 0 0 30px rgba(126, 87, 194, 0.4),
0 6px 16px rgba(126, 87, 194, 0.3);
box-shadow: 0 0 30px rgba(var(--accent-rgb), 0.4),
0 6px 16px rgba(var(--accent-rgb), 0.3);
}
&:active:not(:disabled) {

View File

@@ -15,8 +15,8 @@
// Hover lift effect with inner glow
&:hover {
transform: translateY(-2px);
box-shadow: var(--glass-shadow), 0 8px 24px rgba(126, 87, 194, 0.08),
inset 0 0 30px rgba(126, 87, 194, 0.03);
box-shadow: var(--glass-shadow), 0 8px 24px rgba(var(--accent-rgb), 0.08),
inset 0 0 30px rgba(var(--accent-rgb), 0.03);
&::before {
opacity: 0.7;

View File

@@ -48,7 +48,7 @@
min-height: 26px;
padding: var(--space-1) var(--space-2);
background: var(--color-primary-subtle);
border: 1px solid rgba(126, 87, 194, 0.2);
border: 1px solid rgba(var(--accent-rgb), 0.2);
border-radius: var(--radius-lg);
font-size: var(--font-size-xs);
color: var(--text-primary);
@@ -83,7 +83,7 @@
height: 26px;
padding: 0;
background: var(--color-primary-subtle);
border: 1px solid rgba(126, 87, 194, 0.2);
border: 1px solid rgba(var(--accent-rgb), 0.2);
border-radius: var(--radius-full);
color: var(--text-primary);
font-size: var(--font-size-sm);

View File

@@ -27,3 +27,4 @@ export { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.componen
export { SizeInputComponent } from './size-input/size-input.component';
export type { SizeUnit } from './size-input/size-input.component';
export { TooltipComponent } from './tooltip/tooltip.component';
export { LogoComponent } from './logo/logo.component';

View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -0,0 +1,11 @@
:host {
display: inline-flex;
color: var(--logo-fg);
line-height: 0;
}
svg {
width: 100%;
height: 100%;
display: block;
}

View File

@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'app-logo',
standalone: true,
templateUrl: './logo.component.html',
styleUrl: './logo.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LogoComponent {
ariaLabel = input<string>('Cleanuparr');
}

View File

@@ -28,7 +28,7 @@
position: absolute;
inset: -1px;
border-radius: inherit;
background: linear-gradient(135deg, rgba(126, 87, 194, 0.3), transparent 50%, rgba(59, 130, 246, 0.2));
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.3), transparent 50%, rgba(var(--accent-rgb), 0.2));
z-index: -1;
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;

View File

@@ -23,7 +23,7 @@
&:focus-within {
border-color: var(--input-border-focus);
box-shadow: 0 0 0 3px var(--color-primary-subtle),
0 0 12px rgba(126, 87, 194, 0.15);
0 0 12px rgba(var(--accent-rgb), 0.15);
}
}

View File

@@ -23,7 +23,7 @@
&:focus-within {
border-color: var(--input-border-focus);
box-shadow: 0 0 0 3px var(--color-primary-subtle),
0 0 12px rgba(126, 87, 194, 0.15);
0 0 12px rgba(var(--accent-rgb), 0.15);
}
}

View File

@@ -2,6 +2,7 @@
@use 'styles/tokens';
@use 'styles/themes';
@use 'styles/accents';
@use 'styles/reset';
@use 'styles/typography';
@use 'styles/scrollbar';
@@ -17,8 +18,8 @@
background: linear-gradient(
90deg,
var(--glass-bg) 0px,
rgba(126, 87, 194, 0.08) 30px,
rgba(59, 130, 246, 0.06) 50px,
rgba(var(--accent-rgb), 0.08) 30px,
rgba(var(--accent-rgb), 0.06) 50px,
var(--glass-bg-hover) 70px,
var(--glass-bg) 100px
);

View File

@@ -0,0 +1,94 @@
// =============================================================================
// Accent Presets
// Each preset overrides the --brand-* scale and --accent-rgb triple.
// The 'default' preset keeps the UI brand palette (from _tokens.scss) unchanged
// and restores the logo's original hexes. Scales follow the same perceptual ramp
// (Tailwind-style 50..950 lightness).
//
// NOTE: The --brand-500 stop of each preset is mirrored as a TS constant in
// app/core/services/theme.service.ts (`ACCENT_PRESET_HEX`) so the appearance
// settings page can render preview swatches without inspecting the DOM. Keep
// the two in sync when changing a preset's mid-tone.
// =============================================================================
// Default: UI stays on brand-purple defaults; logo uses the original darker
// purple for accent parts, and the body flips with the theme (black on dark,
// white on light) so it blends into the sidebar.
[data-accent='default'] {
--logo-accent: #420077;
}
[data-accent='blue'] {
--brand-50: #eff6ff;
--brand-100: #dbeafe;
--brand-200: #bfdbfe;
--brand-300: #93c5fd;
--brand-400: #60a5fa;
--brand-500: #3b82f6;
--brand-600: #2563eb;
--brand-700: #1d4ed8;
--brand-800: #1e40af;
--brand-900: #1e3a8a;
--brand-950: #172554;
--accent-rgb: 59, 130, 246;
}
[data-accent='green'] {
--brand-50: #ecfdf5;
--brand-100: #d1fae5;
--brand-200: #a7f3d0;
--brand-300: #6ee7b7;
--brand-400: #34d399;
--brand-500: #10b981;
--brand-600: #059669;
--brand-700: #047857;
--brand-800: #065f46;
--brand-900: #064e3b;
--brand-950: #022c22;
--accent-rgb: 16, 185, 129;
}
[data-accent='rose'] {
--brand-50: #fff1f2;
--brand-100: #ffe4e6;
--brand-200: #fecdd3;
--brand-300: #fda4af;
--brand-400: #fb7185;
--brand-500: #f43f5e;
--brand-600: #e11d48;
--brand-700: #be123c;
--brand-800: #9f1239;
--brand-900: #881337;
--brand-950: #4c0519;
--accent-rgb: 244, 63, 94;
}
[data-accent='amber'] {
--brand-50: #fffbeb;
--brand-100: #fef3c7;
--brand-200: #fde68a;
--brand-300: #fcd34d;
--brand-400: #fbbf24;
--brand-500: #f59e0b;
--brand-600: #d97706;
--brand-700: #b45309;
--brand-800: #92400e;
--brand-900: #78350f;
--brand-950: #451a03;
--accent-rgb: 245, 158, 11;
}
[data-accent='teal'] {
--brand-50: #f0fdfa;
--brand-100: #ccfbf1;
--brand-200: #99f6e4;
--brand-300: #5eead4;
--brand-400: #2dd4bf;
--brand-500: #14b8a6;
--brand-600: #0d9488;
--brand-700: #0f766e;
--brand-800: #115e59;
--brand-900: #134e4a;
--brand-950: #042f2e;
--accent-rgb: 20, 184, 166;
}

View File

@@ -73,8 +73,8 @@
// Gentle glow breathe for focused inputs — very slow, very subtle
@keyframes glow-breathe {
0%, 100% { box-shadow: 0 0 0 3px var(--color-primary-subtle), 0 0 12px rgba(126, 87, 194, 0.12); }
50% { box-shadow: 0 0 0 3px var(--color-primary-subtle), 0 0 16px rgba(126, 87, 194, 0.22); }
0%, 100% { box-shadow: 0 0 0 3px var(--color-primary-subtle), 0 0 12px rgba(var(--accent-rgb), 0.12); }
50% { box-shadow: 0 0 0 3px var(--color-primary-subtle), 0 0 16px rgba(var(--accent-rgb), 0.22); }
}
// Delayed content materialise — used inside expanding containers
@@ -106,7 +106,7 @@
// Toast entrance glow
@keyframes toast-glow {
from { box-shadow: 0 0 20px var(--toast-glow-color, rgba(126, 87, 194, 0.3)); }
from { box-shadow: 0 0 20px var(--toast-glow-color, rgba(var(--accent-rgb), 0.3)); }
to { box-shadow: none; }
}

View File

@@ -35,7 +35,7 @@ $_glass-noise: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http:
-webkit-backdrop-filter: blur(var(--glass-blur-lg));
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
box-shadow: var(--glass-shadow), 0 0 80px rgba(126, 87, 194, 0.04);
box-shadow: var(--glass-shadow), 0 0 80px rgba(var(--accent-rgb), 0.04);
// Frost noise overlay for realism
background-image: #{$_glass-noise};
@@ -100,7 +100,7 @@ $_glass-noise: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http:
outline: none;
border-color: var(--input-border-focus);
box-shadow: 0 0 0 3px var(--color-primary-subtle),
0 0 12px rgba(126, 87, 194, 0.15);
0 0 12px rgba(var(--accent-rgb), 0.15);
animation: glow-breathe 3s ease-in-out infinite;
}

View File

@@ -31,7 +31,7 @@
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent);
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.06), transparent);
transform: translateX(-100%);
transition: transform var(--duration-normal) var(--ease-default);
pointer-events: none;

View File

@@ -7,9 +7,11 @@
// Dark theme (default)
:root,
[data-theme='dark'] {
// Surfaces
// Surfaces with fallbacks for browsers that can't parse color-mix()
--surface-ground: #0c0614;
--surface-section: #140b22;
--surface-ground: color-mix(in srgb, var(--brand-500) 8%, #000000);
--surface-section: #14091e;
--surface-section: color-mix(in srgb, var(--brand-500) 14%, #000000);
--surface-card: rgba(20, 11, 34, 0.75);
--surface-overlay: rgba(12, 6, 20, 0.92);
--surface-elevated: rgba(30, 18, 50, 0.65);
@@ -34,14 +36,14 @@
--color-primary-hover: var(--brand-400);
--color-primary-active: var(--brand-300);
--color-primary-text: #ffffff;
--color-primary-subtle: rgba(126, 87, 194, 0.15);
--color-primary-subtle: rgba(var(--accent-rgb), 0.15);
// Sidebar (always dark in both themes)
--sidebar-bg: linear-gradient(180deg, #1a0e2e 0%, #0c0614 100%);
// Sidebar
--sidebar-bg: linear-gradient(180deg, var(--brand-950) 0%, var(--surface-ground) 100%);
--sidebar-border: rgba(255, 255, 255, 0.06);
--sidebar-item-text: rgba(255, 255, 255, 0.65);
--sidebar-item-hover: rgba(126, 87, 194, 0.12);
--sidebar-item-active: rgba(126, 87, 194, 0.22);
--sidebar-item-hover: rgba(var(--accent-rgb), 0.12);
--sidebar-item-active: rgba(var(--accent-rgb), 0.22);
--sidebar-item-active-text: #ffffff;
--sidebar-section-label: rgba(255, 255, 255, 0.45);
--sidebar-section-label-hover: rgba(255, 255, 255, 0.65);
@@ -61,8 +63,8 @@
// Scrollbar
--scrollbar-track: transparent;
--scrollbar-thumb: rgba(126, 87, 194, 0.45);
--scrollbar-thumb-hover: rgba(126, 87, 194, 0.70);
--scrollbar-thumb: rgba(var(--accent-rgb), 0.45);
--scrollbar-thumb-hover: rgba(var(--accent-rgb), 0.70);
// Dropdown
--dropdown-bg: rgba(20, 11, 34, 0.97);
@@ -74,6 +76,18 @@
--focus-ring: var(--brand-500);
--focus-ring-offset: var(--surface-ground);
// Logo body color: black on dark theme so it blends into the dark sidebar —
// only the accent-colored parts of the logo read against the background.
--logo-fg: #000000;
// Logo accent color (follows --color-primary; overridden for 'default' preset to
// restore the original darker purple).
--logo-accent: var(--color-primary);
// Sidebar fade gradient (bottom of nav scroll area)
--sidebar-fade: rgba(12, 6, 20, 0.90);
--sidebar-fade: color-mix(in srgb, var(--surface-ground) 90%, transparent);
// Ambient orbs
--orb-opacity: 0.15;
--orb-blur: 120px;
@@ -84,9 +98,11 @@
// Light theme
[data-theme='light'] {
// Surfaces
--surface-ground: #f5f0fa;
--surface-section: #ede5f7;
// Surfaces with fallbacks for browsers that can't parse color-mix()
--surface-ground: #faf7fd;
--surface-ground: color-mix(in srgb, var(--brand-500) 3%, #ffffff);
--surface-section: #f3edf9;
--surface-section: color-mix(in srgb, var(--brand-500) 6%, #ffffff);
--surface-card: rgba(255, 255, 255, 0.70);
--surface-overlay: rgba(245, 240, 250, 0.94);
--surface-elevated: rgba(255, 255, 255, 0.80);
@@ -95,9 +111,9 @@
--glass-bg: rgba(255, 255, 255, 0.50);
--glass-bg-hover: rgba(255, 255, 255, 0.65);
--glass-bg-active: rgba(255, 255, 255, 0.75);
--glass-border: rgba(126, 87, 194, 0.10);
--glass-border-hover: rgba(126, 87, 194, 0.20);
--glass-shadow: 0 8px 32px rgba(126, 87, 194, 0.06);
--glass-border: rgba(var(--accent-rgb), 0.10);
--glass-border-hover: rgba(var(--accent-rgb), 0.20);
--glass-shadow: 0 8px 32px rgba(var(--accent-rgb), 0.06);
// Text
--text-primary: rgba(12, 6, 20, 0.90);
@@ -111,50 +127,62 @@
--color-primary-hover: var(--brand-700);
--color-primary-active: var(--brand-800);
--color-primary-text: #ffffff;
--color-primary-subtle: rgba(126, 87, 194, 0.08);
--color-primary-subtle: rgba(var(--accent-rgb), 0.08);
// Sidebar (stays dark purple in light theme for brand identity)
--sidebar-bg: linear-gradient(180deg, #2d1a4e 0%, #1a0e2e 100%);
--sidebar-border: rgba(255, 255, 255, 0.08);
--sidebar-item-text: rgba(255, 255, 255, 0.65);
--sidebar-item-hover: rgba(255, 255, 255, 0.10);
--sidebar-item-active: rgba(255, 255, 255, 0.18);
--sidebar-item-active-text: #ffffff;
--sidebar-section-label: rgba(255, 255, 255, 0.45);
--sidebar-section-label-hover: rgba(255, 255, 255, 0.65);
// Sidebar (light theme): light accent-tinted background with dark text
--sidebar-bg: linear-gradient(180deg, var(--brand-50) 0%, var(--brand-100) 100%);
--sidebar-border: var(--divider);
--sidebar-item-text: rgba(12, 6, 20, 0.70);
--sidebar-item-hover: rgba(var(--accent-rgb), 0.10);
--sidebar-item-active: rgba(var(--accent-rgb), 0.18);
--sidebar-item-active-text: rgba(12, 6, 20, 0.95);
--sidebar-section-label: rgba(12, 6, 20, 0.50);
--sidebar-section-label-hover: rgba(12, 6, 20, 0.80);
// Toolbar
--toolbar-bg: rgba(255, 255, 255, 0.65);
--toolbar-border: rgba(126, 87, 194, 0.10);
--toolbar-border: rgba(var(--accent-rgb), 0.10);
// Input
--input-bg: rgba(255, 255, 255, 0.60);
--input-bg-hover: rgba(255, 255, 255, 0.75);
--input-border: rgba(126, 87, 194, 0.15);
--input-border-hover: rgba(126, 87, 194, 0.25);
--input-border: rgba(var(--accent-rgb), 0.15);
--input-border-hover: rgba(var(--accent-rgb), 0.25);
--input-border-focus: var(--brand-600);
--input-placeholder: rgba(12, 6, 20, 0.45);
--input-text: var(--text-primary);
// Scrollbar
--scrollbar-track: transparent;
--scrollbar-thumb: rgba(126, 87, 194, 0.50);
--scrollbar-thumb-hover: rgba(126, 87, 194, 0.75);
--scrollbar-thumb: rgba(var(--accent-rgb), 0.50);
--scrollbar-thumb-hover: rgba(var(--accent-rgb), 0.75);
// Dropdown
--dropdown-bg: rgba(255, 255, 255, 0.97);
// Divider
--divider: rgba(126, 87, 194, 0.10);
--divider: rgba(var(--accent-rgb), 0.10);
// Focus ring
--focus-ring: var(--brand-600);
--focus-ring-offset: var(--surface-ground);
// Ambient orbs (stronger for light backgrounds)
// Logo body color: white on light theme so it blends into the light sidebar —
// only the accent-colored parts of the logo read against the background.
--logo-fg: #ffffff;
// Logo accent color (follows --color-primary; overridden for 'default' preset to
// restore the original darker purple).
--logo-accent: var(--color-primary);
// Sidebar fade gradient (bottom of nav scroll area)
--sidebar-fade: rgba(237, 233, 254, 0.90);
--sidebar-fade: color-mix(in srgb, var(--brand-100) 90%, transparent);
// Ambient orbs (stronger for light backgrounds; retint with accent)
--orb-opacity: 0.35;
--orb-blur: 150px;
--orb-primary: radial-gradient(circle, #9333ea, #6d28d9);
--orb-secondary: radial-gradient(circle, #7c3aed, #2563eb);
--orb-primary: radial-gradient(circle, var(--brand-500), var(--brand-700));
--orb-secondary: radial-gradient(circle, var(--brand-400), var(--color-info));
--orb-tertiary: radial-gradient(circle, #0891b2, #6366f1);
}

View File

@@ -20,6 +20,11 @@
--brand-900: #{$brand-900};
--brand-950: #{$brand-950};
// Accent RGB triple - used by translucent surfaces (rgba(var(--accent-rgb), X)).
// Must match $brand-500 in _variables.scss.
// Overridden per preset in _accents.scss and at runtime by ThemeService for custom colors.
--accent-rgb: 139, 92, 246;
// Semantic colors
--color-success: #{$color-success};
--color-success-dim: #{$color-success-dim};

View File

@@ -4,18 +4,18 @@
// These feed into _tokens.scss (CSS custom properties) and _themes.scss.
// =============================================================================
// Brand colors (Cleanuparr purple palette)
$brand-50: #f3e8ff;
$brand-100: #e9d5ff;
$brand-200: #d8b4fe;
$brand-300: #c084fc;
$brand-400: #a855f7;
$brand-500: #7E57C2;
$brand-600: #6D28D9;
$brand-700: #5B21B6;
$brand-800: #4C1D95;
$brand-900: #3B0764;
$brand-950: #1e0038;
// Brand colors (Cleanuparr violet palette — cooler purple, less pink-lavender)
$brand-50: #f5f3ff;
$brand-100: #ede9fe;
$brand-200: #ddd6fe;
$brand-300: #c4b5fd;
$brand-400: #a78bfa;
$brand-500: #8b5cf6;
$brand-600: #7c3aed;
$brand-700: #6d28d9;
$brand-800: #5b21b6;
$brand-900: #4c1d95;
$brand-950: #2e1065;
// Semantic colors
$color-success: #22c55e;