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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14
View File
@@ -51,6 +51,20 @@ def _migrate_add_missing_columns(sync_conn):
("station_config", "play_store_icon_url", sa.String(500), ""),
("station_config", "play_store_url", sa.String(500), ""),
("station_config", "app_store_embed_html", sa.Text, ""),
# Color theme columns
("station_config", "color_primary", sa.String(7), "#1a3a5c"),
("station_config", "color_primary_light", sa.String(7), "#2a5a8c"),
("station_config", "color_primary_dark", sa.String(7), "#0e2440"),
("station_config", "color_primary_muted", sa.String(7), "#3a6a9c"),
("station_config", "color_accent", sa.String(7), "#e87a2e"),
("station_config", "color_accent_light", sa.String(7), "#f09a4e"),
("station_config", "color_accent_dark", sa.String(7), "#c85a1e"),
("station_config", "color_accent_muted", sa.String(7), "#f0a86a"),
("station_config", "color_background", sa.String(7), "#faf6f0"),
("station_config", "color_text", sa.String(7), "#3a3632"),
("station_config", "color_success", sa.String(7), "#4a9e4f"),
("station_config", "color_danger", sa.String(7), "#c83030"),
("station_config", "color_info", sa.String(7), "#3a8abf"),
]
# Determine dialect
+15
View File
@@ -105,6 +105,21 @@ class StationConfig(Base):
play_store_url = Column(String(500), nullable=False, default="")
app_store_embed_html = Column(Text, nullable=False, default="")
# Color theme — hex strings, e.g. "#1a3a5c"
color_primary = Column(String(7), nullable=False, default="#1a3a5c")
color_primary_light = Column(String(7), nullable=False, default="#2a5a8c")
color_primary_dark = Column(String(7), nullable=False, default="#0e2440")
color_primary_muted = Column(String(7), nullable=False, default="#3a6a9c")
color_accent = Column(String(7), nullable=False, default="#e87a2e")
color_accent_light = Column(String(7), nullable=False, default="#f09a4e")
color_accent_dark = Column(String(7), nullable=False, default="#c85a1e")
color_accent_muted = Column(String(7), nullable=False, default="#f0a86a")
color_background = Column(String(7), nullable=False, default="#faf6f0")
color_text = Column(String(7), nullable=False, default="#3a3632")
color_success = Column(String(7), nullable=False, default="#4a9e4f")
color_danger = Column(String(7), nullable=False, default="#c83030")
color_info = Column(String(7), nullable=False, default="#3a8abf")
class HistoryEntry(Base):
__tablename__ = "history_entries"
+28
View File
@@ -78,6 +78,20 @@ class StationConfigResponse(BaseModel):
play_store_icon_url: str
play_store_url: str
app_store_embed_html: str
# Color theme
color_primary: str
color_primary_light: str
color_primary_dark: str
color_primary_muted: str
color_accent: str
color_accent_light: str
color_accent_dark: str
color_accent_muted: str
color_background: str
color_text: str
color_success: str
color_danger: str
color_info: str
model_config = {"from_attributes": True}
@@ -105,6 +119,20 @@ class StationConfigUpdate(BaseModel):
play_store_icon_url: Optional[str] = None
play_store_url: Optional[str] = None
app_store_embed_html: Optional[str] = None
# Color theme
color_primary: Optional[str] = None
color_primary_light: Optional[str] = None
color_primary_dark: Optional[str] = None
color_primary_muted: Optional[str] = None
color_accent: Optional[str] = None
color_accent_light: Optional[str] = None
color_accent_dark: Optional[str] = None
color_accent_muted: Optional[str] = None
color_background: Optional[str] = None
color_text: Optional[str] = None
color_success: Optional[str] = None
color_danger: Optional[str] = None
color_info: Optional[str] = None
# ── HistoryEntry ──────────────────────────────────────────
+14
View File
@@ -217,6 +217,20 @@ STATION_CONFIG: dict = {
"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",
}
HISTORY_ENTRIES: list[dict] = [
+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',
};
+46 -6
View File
@@ -80,26 +80,66 @@ img, svg {
}
.btn {
@include button-style($accent-orange, $neutral-white);
background: var(--color-accent);
color: var(--color-white);
border: none;
border-radius: $radius-md;
padding: $spacing-sm $spacing-lg;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background $transition-fast, transform $transition-fast;
display: inline-flex;
align-items: center;
gap: $spacing-xs;
text-decoration: none;
font-family: var(--font-primary);
letter-spacing: 0.02em;
&:hover {
background: var(--color-accent-dark);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
.btn-secondary {
@include button-style($primary-blue, $neutral-white, $primary-blue-dark);
background: var(--color-primary);
color: var(--color-white);
border: none;
border-radius: $radius-md;
padding: $spacing-sm $spacing-lg;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background $transition-fast, transform $transition-fast;
&:hover {
background: var(--color-primary-dark);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
.btn-outline {
background: transparent;
border: 2px solid $accent-orange;
color: $accent-orange;
border: 2px solid var(--color-accent);
color: var(--color-accent);
border-radius: $radius-md;
padding: $spacing-sm $spacing-lg;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background $transition-fast, color $transition-fast;
&:hover {
background: $accent-orange;
color: $neutral-white;
background: var(--color-accent);
color: var(--color-white);
}
}