adds color themeing support to the site

This commit is contained in:
2026-06-29 21:23:51 +00:00
parent bfd6c5bb9e
commit ee79662e5d
37 changed files with 1046 additions and 15 deletions
+82
View File
@@ -37,6 +37,11 @@
[class.active]="activeTab() === 'station'"
(click)="activeTab.set('station')"
>Station</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'theme'"
(click)="activeTab.set('theme'); onTabChanged()"
>Theme</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'underwriters'"
@@ -318,6 +323,83 @@
}
}
<!-- Theme Tab -->
@if (activeTab() === 'theme') {
<div class="tab-content theme-panel">
<div class="tab-toolbar">
<h2>Color Theme</h2>
</div>
<div class="theme-harmony-bar">
<div class="harmony-info">
<span class="harmony-label">Harmony:</span>
<span class="harmony-rule">{{ getHarmonyLabel() }}</span>
</div>
<label class="auto-variants-toggle">
<input
type="checkbox"
[(ngModel)]="themeAutoVariants"
>
Auto-generate variants
</label>
</div>
@if (themeSaveSuccess) {
<div class="theme-message theme-success">{{ themeSaveSuccess }}</div>
}
@if (themeSaveError) {
<div class="theme-message theme-error">{{ themeSaveError }}</div>
}
<!-- Color grid -->
<div class="color-grid">
@for (role of colorRoles; track role.field) {
<div class="color-card" [class.has-issue]="hasHarmonyIssue(role.field)">
<div class="color-swatch" [style.background]="themeColors[role.field]">
<input
type="color"
[value]="themeColors[role.field]"
(input)="onThemeColorChange(role.field, $event.target.value)"
>
</div>
<div class="color-info">
<span class="color-name">{{ role.label }}</span>
<span class="color-hex">{{ themeColors[role.field] }}</span>
</div>
<div class="color-status">
@if (hasHarmonyIssue(role.field)) {
<span class="harmony-warn" title="Not harmonious — click Auto-Fix to correct"></span>
} @else if (role.group !== 'neutral') {
<span class="harmony-ok"></span>
}
</div>
</div>
}
</div>
<div class="theme-actions">
<button
type="button"
class="btn btn-outline-theme"
(click)="resetTheme()"
>Reset to Defaults</button>
<button
type="button"
class="btn btn-outline-theme"
(click)="autoFixTheme()"
>Auto-Fix Harmony</button>
<button
type="button"
class="btn btn-save-theme"
(click)="saveTheme()"
[disabled]="themeSaving"
>
{{ themeSaving ? 'Saving…' : 'Save Theme' }}
</button>
</div>
</div>
}
<!-- Underwriters Tab -->
@if (activeTab() === 'underwriters') {
@if (underwriters$ | async; as underwriters) {
+194
View File
@@ -214,3 +214,197 @@
gap: $spacing-sm;
}
}
// ── Theme tab ────────────────────────────────────────────
.theme-panel {
.theme-harmony-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-md;
background: rgba($primary-blue, 0.05);
border-radius: $radius-md;
margin-bottom: $spacing-lg;
flex-wrap: wrap;
gap: $spacing-sm;
.harmony-info {
display: flex;
align-items: center;
gap: $spacing-sm;
.harmony-label {
font-weight: 600;
color: $neutral-medium;
font-size: 0.9rem;
}
.harmony-rule {
color: $primary-blue;
font-weight: 600;
font-size: 0.95rem;
}
}
.auto-variants-toggle {
display: flex;
align-items: center;
gap: $spacing-sm;
font-size: 0.875rem;
color: $neutral-dark;
cursor: pointer;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
}
}
.theme-message {
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
font-size: 0.875rem;
margin-bottom: $spacing-md;
text-align: center;
&.theme-success {
background: rgba($success-green, 0.1);
color: $success-green;
}
&.theme-error {
background: rgba($danger-red, 0.1);
color: $danger-red;
}
}
}
// Color grid
.color-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: $spacing-md;
margin-bottom: $spacing-xl;
}
.color-card {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-sm $spacing-md;
background: $neutral-white;
border: 2px solid $neutral-light;
border-radius: $radius-md;
transition: border-color $transition-fast, box-shadow $transition-fast;
&.has-issue {
border-color: #e6a817;
box-shadow: 0 0 0 1px rgba(230, 168, 23, 0.3);
}
&:hover {
border-color: $primary-blue-muted;
}
}
.color-swatch {
position: relative;
width: 40px;
height: 40px;
border-radius: $radius-sm;
flex-shrink: 0;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
transition: transform $transition-fast;
&:hover {
transform: scale(1.1);
}
input[type="color"] {
position: absolute;
inset: 0;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
border: none;
padding: 0;
background: none;
}
}
.color-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
.color-name {
font-size: 0.85rem;
font-weight: 600;
color: $neutral-dark;
white-space: nowrap;
}
.color-hex {
font-size: 0.75rem;
color: $neutral-medium;
font-family: var(--font-mono, monospace);
}
}
.color-status {
font-size: 1.1rem;
flex-shrink: 0;
width: 24px;
text-align: center;
.harmony-ok {
color: $success-green;
}
.harmony-warn {
color: #e6a817;
}
}
// Theme actions
.theme-actions {
display: flex;
justify-content: flex-end;
gap: $spacing-sm;
flex-wrap: wrap;
padding-top: $spacing-md;
border-top: 1px solid $neutral-light;
}
.btn-save-theme {
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btn-outline-theme {
background: transparent;
border: 2px solid $primary-blue;
color: $primary-blue;
border-radius: $radius-md;
padding: $spacing-sm $spacing-lg;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background $transition-fast, color $transition-fast;
&:hover {
background: $primary-blue;
color: $neutral-white;
}
}
+177 -1
View File
@@ -13,6 +13,7 @@ import { Underwriter } from '../interfaces/underwriter';
import { ShowService } from '../services/show.service';
import { EventService } from '../services/event.service';
import { StationConfigService } from '../services/station-config.service';
import { ThemeService } from '../services/theme.service';
import { HistoryEntryService } from '../services/history-entry.service';
import { TeamMemberService } from '../services/team-member.service';
import { CommunityHighlightService } from '../services/community-highlight.service';
@@ -25,8 +26,37 @@ import { AdminTeamFormComponent } from './admin-team-form.component';
import { AdminCommunityFormComponent } from './admin-community-form.component';
import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
import { AdminStatsDashboardComponent } from './admin-stats-dashboard.component';
import {
COLOR_ROLES,
HARMONY_RULE_LABELS,
detectHarmonyRule,
validatePalette,
autoFixPalette,
generateLight,
generateDark,
generateMuted,
HarmonyRule,
ColorValidation,
} from '../utils/color-harmony';
type TabKey = 'shows' | 'events' | 'station' | 'history' | 'team' | 'community' | 'underwriters' | 'stats';
type TabKey = 'shows' | 'events' | 'station' | 'theme' | 'history' | 'team' | 'community' | 'underwriters' | 'stats';
interface ThemeColorState {
color_primary: string;
color_primary_light: string;
color_primary_dark: string;
color_primary_muted: string;
color_accent: string;
color_accent_light: string;
color_accent_dark: string;
color_accent_muted: string;
color_background: string;
color_text: string;
color_success: string;
color_danger: string;
color_info: string;
[key: string]: string;
}
@Component({
selector: 'app-admin',
@@ -51,6 +81,7 @@ export class AdminComponent implements OnInit {
private showService = inject(ShowService);
private eventService = inject(EventService);
private stationConfigService = inject(StationConfigService);
private themeService = inject(ThemeService);
private historyEntryService = inject(HistoryEntryService);
private teamMemberService = inject(TeamMemberService);
private communityHighlightService = inject(CommunityHighlightService);
@@ -75,6 +106,45 @@ export class AdminComponent implements OnInit {
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]);
// ── Theme tab state ──────────────────────────────────────────
/** Local editable color palette (copied from StationConfig on tab open). */
themeColors: ThemeColorState = {} as ThemeColorState;
/** Current harmony rule detected from the palette. */
themeHarmonyRule: HarmonyRule = 'none';
/** Validation results per color field. */
themeValidation: { [key: string]: ColorValidation } = {};
/** Whether auto-generate variants is enabled. */
themeAutoVariants = true;
/** Saving state for theme tab. */
themeSaving = false;
themeSaveSuccess = '';
themeSaveError = '';
/** Color role definitions for the template. */
readonly colorRoles = COLOR_ROLES;
/** Default palette for reset. */
readonly themeDefaults: ThemeColorState = {
color_primary: '#1a3a5c',
color_primary_light: '#2a5a8c',
color_primary_dark: '#0e2440',
color_primary_muted: '#3a6a9c',
color_accent: '#e87a2e',
color_accent_light: '#f09a4e',
color_accent_dark: '#c85a1e',
color_accent_muted: '#f0a86a',
color_background: '#faf6f0',
color_text: '#3a3632',
color_success: '#4a9e4f',
color_danger: '#c83030',
color_info: '#3a8abf',
};
showShowForm = false;
showEventForm = false;
showStationForm = false;
@@ -322,4 +392,110 @@ export class AdminComponent implements OnInit {
await firstValueFrom(this.underwriterService.deleteUnderwriter(id));
await this.reloadUnderwriters();
}
// ── Theme tab ────────────────────────────────────────────
onTabChanged(): void {
if (this.activeTab() === 'theme') {
this.loadThemeColors();
}
}
loadThemeColors(): void {
const config = this.stationConfigService.config();
this.themeColors = {
color_primary: config.color_primary || this.themeDefaults.color_primary,
color_primary_light: config.color_primary_light || this.themeDefaults.color_primary_light,
color_primary_dark: config.color_primary_dark || this.themeDefaults.color_primary_dark,
color_primary_muted: config.color_primary_muted || this.themeDefaults.color_primary_muted,
color_accent: config.color_accent || this.themeDefaults.color_accent,
color_accent_light: config.color_accent_light || this.themeDefaults.color_accent_light,
color_accent_dark: config.color_accent_dark || this.themeDefaults.color_accent_dark,
color_accent_muted: config.color_accent_muted || this.themeDefaults.color_accent_muted,
color_background: config.color_background || this.themeDefaults.color_background,
color_text: config.color_text || this.themeDefaults.color_text,
color_success: config.color_success || this.themeDefaults.color_success,
color_danger: config.color_danger || this.themeDefaults.color_danger,
color_info: config.color_info || this.themeDefaults.color_info,
};
this.themeSaveSuccess = '';
this.themeSaveError = '';
this.revalidateTheme();
}
/** Re-run harmony detection + validation on the current palette. */
revalidateTheme(): void {
const primary = this.themeColors.color_primary;
const accent = this.themeColors.color_accent;
this.themeHarmonyRule = detectHarmonyRule(primary, accent);
this.themeValidation = validatePalette(this.themeColors, this.themeHarmonyRule);
}
/** Handle color change from the picker. */
onThemeColorChange(field: string, value: string): void {
this.themeColors[field] = value;
// Auto-generate variants for primary/accent bases
if (this.themeAutoVariants) {
if (field === 'color_primary') {
this.themeColors.color_primary_light = generateLight(value);
this.themeColors.color_primary_dark = generateDark(value);
this.themeColors.color_primary_muted = generateMuted(value);
} else if (field === 'color_accent') {
this.themeColors.color_accent_light = generateLight(value);
this.themeColors.color_accent_dark = generateDark(value);
this.themeColors.color_accent_muted = generateMuted(value);
}
}
// Live-apply to DOM for instant preview
this.themeService.apply(this.themeColors as any);
this.revalidateTheme();
}
/** Auto-fix all non-harmonious colors. */
autoFixTheme(): void {
const fixed = autoFixPalette(this.themeColors, this.themeHarmonyRule);
for (const key of Object.keys(fixed) as (keyof ThemeColorState)[]) {
(this.themeColors as any)[key] = fixed[key];
}
this.themeService.apply(this.themeColors as any);
this.revalidateTheme();
}
/** Reset to default palette. */
resetTheme(): void {
for (const key of Object.keys(this.themeDefaults) as (keyof ThemeColorState)[]) {
(this.themeColors as any)[key] = this.themeDefaults[key];
}
this.themeService.apply(this.themeColors as any);
this.revalidateTheme();
}
/** Save theme colors to the database. */
async saveTheme(): Promise<void> {
this.themeSaving = true;
this.themeSaveError = '';
this.themeSaveSuccess = '';
try {
await this.stationConfigService.updateConfig(this.themeColors);
this.themeSaveSuccess = 'Theme saved successfully!';
} catch (err: any) {
this.themeSaveError = err?.error?.detail || 'Failed to save theme.';
} finally {
this.themeSaving = false;
}
}
/** Get harmony rule label for display. */
getHarmonyLabel(): string {
return HARMONY_RULE_LABELS[this.themeHarmonyRule];
}
/** Check if a color field has a harmony issue. */
hasHarmonyIssue(field: string): boolean {
const v = this.themeValidation[field];
return v ? !v.isHarmonious : false;
}
}
+18 -3
View File
@@ -80,10 +80,25 @@
}
.btn-form {
@include button-style($accent-orange, $neutral-white, $accent-orange-dark);
align-self: flex-start;
font-size: 1rem;
background: var(--color-accent);
color: var(--color-white);
border: none;
border-radius: $radius-md;
padding: $spacing-sm $spacing-xl;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background $transition-fast, transform $transition-fast;
align-self: flex-start;
&:hover {
background: var(--color-accent-dark);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
// Info
+19 -4
View File
@@ -32,7 +32,7 @@
position: absolute;
inset: 0;
z-index: 1;
background: $sky-gradient;
background: var(--sky-gradient);
opacity: 0.85;
}
@@ -105,10 +105,25 @@
}
.btn-hero-primary {
@include button-style($accent-orange, $neutral-white, $accent-orange-dark);
font-size: 1.1rem;
background: var(--color-accent);
color: var(--color-white);
border: none;
border-radius: $radius-md;
padding: $spacing-sm $spacing-xl;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: background $transition-fast, transform $transition-fast;
letter-spacing: 0.03em;
&:hover {
background: var(--color-accent-dark);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
.btn-hero-secondary {
@@ -139,7 +154,7 @@
width: 10px;
height: 10px;
border-radius: $radius-full;
background: $success-green;
background: var(--color-success);
animation: pulse 2s ease-in-out infinite;
}
+14
View File
@@ -23,4 +23,18 @@ export interface StationConfig {
play_store_icon_url: string;
play_store_url: string;
app_store_embed_html: string;
// Color theme
color_primary: string;
color_primary_light: string;
color_primary_dark: string;
color_primary_muted: string;
color_accent: string;
color_accent_light: string;
color_accent_dark: string;
color_accent_muted: string;
color_background: string;
color_text: string;
color_success: string;
color_danger: string;
color_info: string;
}
+16 -1
View File
@@ -158,9 +158,24 @@
}
.btn-donate-nav {
@include button-style($accent-orange, $neutral-white, $accent-orange-dark);
background: var(--color-accent);
color: var(--color-white);
border: none;
border-radius: $radius-md;
padding: $spacing-xs $spacing-md;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background $transition-fast, transform $transition-fast;
&:hover {
background: var(--color-accent-dark);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
// Responsive
@@ -4,6 +4,7 @@ import { firstValueFrom } from 'rxjs';
import { StationConfig } from '../interfaces/station-config';
import { getAppConfig } from './app-config.service';
import { ThemeService } from './theme.service';
/** Hardcoded defaults — used when the API is unavailable (dev fallback). */
const DEFAULT_CONFIG: StationConfig = {
@@ -31,6 +32,20 @@ const DEFAULT_CONFIG: StationConfig = {
play_store_icon_url: '',
play_store_url: '',
app_store_embed_html: '',
// Color theme defaults (match _variables.scss)
color_primary: '#1a3a5c',
color_primary_light: '#2a5a8c',
color_primary_dark: '#0e2440',
color_primary_muted: '#3a6a9c',
color_accent: '#e87a2e',
color_accent_light: '#f09a4e',
color_accent_dark: '#c85a1e',
color_accent_muted: '#f0a86a',
color_background: '#faf6f0',
color_text: '#3a3632',
color_success: '#4a9e4f',
color_danger: '#c83030',
color_info: '#3a8abf',
};
@Injectable({
@@ -38,6 +53,7 @@ const DEFAULT_CONFIG: StationConfig = {
})
export class StationConfigService {
private http = inject(HttpClient);
private themeService = inject(ThemeService);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
readonly config = signal<StationConfig>(DEFAULT_CONFIG);
@@ -47,6 +63,7 @@ export class StationConfigService {
try {
const data = await firstValueFrom(this.http.get<StationConfig>(this.baseUrl));
this.config.set(data);
this.themeService.apply(data);
} catch {
// Keep the default config if the API is unavailable.
console.warn('StationConfigService: Failed to load config, using defaults.');
@@ -59,6 +76,7 @@ export class StationConfigService {
this.http.put<StationConfig>(this.baseUrl, payload),
);
this.config.set(data);
this.themeService.apply(data);
}
/** Upload an image file for station branding (admin only). Returns the relative URL. */
+67
View File
@@ -0,0 +1,67 @@
import { Injectable } from '@angular/core';
import { StationConfig } from '../interfaces/station-config';
/** CSS custom property names mapped from StationConfig color fields. */
const COLOR_MAP: ReadonlyArray<{ field: keyof StationConfig; prop: string }> = [
{ field: 'color_primary', prop: '--color-primary' },
{ field: 'color_primary_light', prop: '--color-primary-light' },
{ field: 'color_primary_dark', prop: '--color-primary-dark' },
{ field: 'color_primary_muted', prop: '--color-primary-muted' },
{ field: 'color_accent', prop: '--color-accent' },
{ field: 'color_accent_light', prop: '--color-accent-light' },
{ field: 'color_accent_dark', prop: '--color-accent-dark' },
{ field: 'color_accent_muted', prop: '--color-accent-muted' },
{ field: 'color_background', prop: '--color-cream' },
{ field: 'color_text', prop: '--color-dark' },
{ field: 'color_success', prop: '--color-success' },
{ field: 'color_danger', prop: '--color-danger' },
{ field: 'color_info', prop: '--color-info' },
];
/** Build a gradient CSS value from three color stops. */
function gradient(from: string, mid: string, to: string): string {
return `linear-gradient(135deg, ${from} 0%, ${mid} 40%, ${to} 100%)`;
}
@Injectable({
providedIn: 'root',
})
export class ThemeService {
/**
* Apply color theme from StationConfig as CSS custom properties on `:root`.
* Also generates derived gradient variables.
*/
apply(config: StationConfig): void {
const root = document.documentElement.style;
for (const { field, prop } of COLOR_MAP) {
const value = (config as any)[field];
if (value) {
root.setProperty(prop, value);
}
}
// Derived gradients
root.setProperty(
'--sky-gradient',
gradient(
config.color_primary_dark,
config.color_primary,
config.color_primary_light,
),
);
root.setProperty(
'--sunset-gradient',
gradient(
config.color_accent_dark,
config.color_accent,
config.color_accent_light,
),
);
root.setProperty(
'--mixed-gradient',
`linear-gradient(135deg, ${config.color_primary_dark} 0%, ${config.color_primary} 50%, ${config.color_accent} 100%)`,
);
}
}
+324
View File
@@ -0,0 +1,324 @@
// =============================================================================
// Color harmony utilities — pure functions, no Angular dependencies
// =============================================================================
// ── Types ────────────────────────────────────────────────────────────────────
export type HarmonyRule = 'complementary' | 'analogous' | 'triadic' | 'split-complementary' | 'none';
export interface HSL {
h: number; // 0-360
s: number; // 0-100
l: number; // 0-100
}
export interface ColorValidation {
isHarmonious: boolean;
suggestedHex?: string;
deviation: number; // degrees from expected hue
}
// ── Hex ↔ HSL conversion ────────────────────────────────────────────────────
export function hexToHSL(hex: string): HSL {
hex = hex.replace('#', '');
if (hex.length === 3) {
hex = hex.split('').map(c => c + c).join('');
}
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) {
return { h: 0, s: 0, l: Math.round(l * 100) };
}
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === r) {
h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
} else if (max === g) {
h = ((b - r) / d + 2) * 60;
} else {
h = ((r - g) / d + 4) * 60;
}
return {
h: Math.round(h),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
export function hslToHex(h: number, s: number, l: number): string {
h = ((h % 360) + 360) % 360;
s = Math.max(0, Math.min(100, s)) / 100;
l = Math.max(0, Math.min(100, l)) / 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
const toHex = (v: number) => {
const hex = Math.round((v + m) * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// ── Variant generation ──────────────────────────────────────────────────────
/** Generate a lighter variant (+20% lightness). */
export function generateLight(hex: string): string {
const { h, s, l } = hexToHSL(hex);
return hslToHex(h, s, Math.min(95, l + 20));
}
/** Generate a darker variant (-20% lightness). */
export function generateDark(hex: string): string {
const { h, s, l } = hexToHSL(hex);
return hslToHex(h, s, Math.max(5, l - 20));
}
/** Generate a muted variant (-20% saturation). */
export function generateMuted(hex: string): string {
const { h, s, l } = hexToHSL(hex);
return hslToHex(h, Math.max(0, s - 20), l);
}
// ── Harmony detection ───────────────────────────────────────────────────────
/**
* Detect the harmony rule between two hues.
* Returns the best-fit rule or 'none' if the colors are too close or unrelated.
*/
export function detectHarmonyRule(primaryHex: string, accentHex: string): HarmonyRule {
const p = hexToHSL(primaryHex);
const a = hexToHSL(accentHex);
// If either color is near-gray (low saturation), harmony doesn't apply
if (p.s < 10 || a.s < 10) return 'none';
const diff = hueDistance(p.h, a.h);
// Tolerance in degrees for each rule
const rules: Array<{ rule: HarmonyRule; target: number; tolerance: number }> = [
{ rule: 'complementary', target: 180, tolerance: 20 },
{ rule: 'split-complementary', target: 150, tolerance: 15 },
{ rule: 'triadic', target: 120, tolerance: 15 },
{ rule: 'analogous', target: 30, tolerance: 15 },
];
for (const { rule, target, tolerance } of rules) {
if (Math.abs(diff - target) <= tolerance) {
return rule;
}
}
return 'none';
}
/** Calculate the shortest distance between two hues (0-180). */
function hueDistance(h1: number, h2: number): number {
const diff = Math.abs(h1 - h2);
return Math.min(diff, 360 - diff);
}
// ── Validation ──────────────────────────────────────────────────────────────
/**
* Check if a color's hue is harmonious given the primary hue and harmony rule.
* Neutrals (saturation < 10) are always considered harmonious.
*/
export function validateColor(
colorHex: string,
primaryHex: string,
rule: HarmonyRule,
tolerance: number = 15,
): ColorValidation {
const color = hexToHSL(colorHex);
const primary = hexToHSL(primaryHex);
// Neutrals are always harmonious
if (color.s < 10) {
return { isHarmonious: true, deviation: 0 };
}
// No rule = can't validate
if (rule === 'none') {
return { isHarmonious: true, deviation: 0 };
}
const expectedHues = getExpectedHues(primary.h, rule);
let minDeviation = 360;
for (const expected of expectedHues) {
const dev = hueDistance(color.h, expected);
if (dev < minDeviation) minDeviation = dev;
}
const isHarmonious = minDeviation <= tolerance;
return {
isHarmonious,
deviation: minDeviation,
suggestedHex: isHarmonious ? undefined : suggestHarmoniousColor(
colorHex, primaryHex, rule,
),
};
}
/** Get the expected hues for a given harmony rule relative to the primary hue. */
function getExpectedHues(primaryHue: number, rule: HarmonyRule): number[] {
switch (rule) {
case 'complementary':
return [primaryHue, (primaryHue + 180) % 360];
case 'analogous':
return [primaryHue, (primaryHue + 30) % 360, (primaryHue - 30 + 360) % 360];
case 'triadic':
return [primaryHue, (primaryHue + 120) % 360, (primaryHue + 240) % 360];
case 'split-complementary':
return [primaryHue, (primaryHue + 150) % 360, (primaryHue + 210) % 360];
default:
return [primaryHue];
}
}
/**
* Suggest a harmonious color by adjusting the hue to the nearest expected hue,
* preserving the original saturation and lightness.
*/
export function suggestHarmoniousColor(
colorHex: string,
primaryHex: string,
rule: HarmonyRule,
): string {
const color = hexToHSL(colorHex);
const expectedHues = getExpectedHues(hexToHSL(primaryHex).h, rule);
// Find the closest expected hue
let closestHue = expectedHues[0];
let closestDist = 360;
for (const hue of expectedHues) {
const dist = hueDistance(color.h, hue);
if (dist < closestDist) {
closestDist = dist;
closestHue = hue;
}
}
return hslToHex(closestHue, color.s, color.l);
}
// ── Palette-level operations ────────────────────────────────────────────────
/**
* Color role definitions for the theme palette.
* Each role maps to a StationConfig color field.
*/
export interface ColorRole {
field: string;
label: string;
group: 'primary' | 'accent' | 'neutral' | 'semantic';
}
export const COLOR_ROLES: readonly ColorRole[] = [
{ field: 'color_primary', label: 'Primary', group: 'primary' },
{ field: 'color_primary_light', label: 'Primary Light', group: 'primary' },
{ field: 'color_primary_dark', label: 'Primary Dark', group: 'primary' },
{ field: 'color_primary_muted', label: 'Primary Muted', group: 'primary' },
{ field: 'color_accent', label: 'Accent', group: 'accent' },
{ field: 'color_accent_light', label: 'Accent Light', group: 'accent' },
{ field: 'color_accent_dark', label: 'Accent Dark', group: 'accent' },
{ field: 'color_accent_muted', label: 'Accent Muted', group: 'accent' },
{ field: 'color_background', label: 'Background', group: 'neutral' },
{ field: 'color_text', label: 'Text', group: 'neutral' },
{ field: 'color_success', label: 'Success', group: 'semantic' },
{ field: 'color_danger', label: 'Danger', group: 'semantic' },
{ field: 'color_info', label: 'Info', group: 'semantic' },
] as const;
/**
* Validate an entire color palette against the detected harmony rule.
* Returns a map of field → validation result.
*/
export function validatePalette(
colors: { [key: string]: string },
rule: HarmonyRule,
): { [key: string]: ColorValidation } {
const primary = colors['color_primary'] || '#1a3a5c';
const result: { [key: string]: ColorValidation } = {};
for (const role of COLOR_ROLES) {
const hex = colors[role.field];
if (!hex) {
result[role.field] = { isHarmonious: true, deviation: 0 };
continue;
}
// Neutrals are always harmonious
if (role.group === 'neutral') {
result[role.field] = { isHarmonious: true, deviation: 0 };
continue;
}
// Semantic colors get wider tolerance
const tolerance = role.group === 'semantic' ? 30 : 15;
result[role.field] = validateColor(hex, primary, rule, tolerance);
}
return result;
}
/**
* Auto-fix the entire palette by adjusting non-harmonious colors.
* Returns a new colors record with corrections applied.
*/
export function autoFixPalette(
colors: { [key: string]: string },
rule: HarmonyRule,
): { [key: string]: string } {
const fixed = { ...colors };
const primary = fixed['color_primary'] || '#1a3a5c';
for (const role of COLOR_ROLES) {
if (role.group === 'neutral') continue;
const hex = fixed[role.field];
if (!hex) continue;
const validation = validateColor(hex, primary, rule, role.group === 'semantic' ? 30 : 15);
if (!validation.isHarmonious && validation.suggestedHex) {
fixed[role.field] = validation.suggestedHex;
}
}
return fixed;
}
// ── Harmony rule labels ─────────────────────────────────────────────────────
export const HARMONY_RULE_LABELS: Record<HarmonyRule, string> = {
complementary: 'Complementary (opposite, ~180°)',
analogous: 'Analogous (neighbors, ±30°)',
triadic: 'Triadic (three-way, ±120°)',
'split-complementary': 'Split-Complementary (offset, ±150°)',
none: 'No harmony detected',
};