diff --git a/backend/__pycache__/seed.cpython-312.pyc b/backend/__pycache__/seed.cpython-312.pyc index f8ae05c..a3245f2 100644 Binary files a/backend/__pycache__/seed.cpython-312.pyc and b/backend/__pycache__/seed.cpython-312.pyc differ diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc index 951db82..f2d8ded 100644 Binary files a/backend/app/__pycache__/__init__.cpython-312.pyc and b/backend/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/__pycache__/auth.cpython-312.pyc b/backend/app/__pycache__/auth.cpython-312.pyc index d1adf3b..7c01d5e 100644 Binary files a/backend/app/__pycache__/auth.cpython-312.pyc and b/backend/app/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc index c33ab09..c96e43d 100644 Binary files a/backend/app/__pycache__/config.cpython-312.pyc and b/backend/app/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/__pycache__/database.cpython-312.pyc b/backend/app/__pycache__/database.cpython-312.pyc index 7e7f255..9a04cdf 100644 Binary files a/backend/app/__pycache__/database.cpython-312.pyc and b/backend/app/__pycache__/database.cpython-312.pyc differ diff --git a/backend/app/__pycache__/log_parser.cpython-312.pyc b/backend/app/__pycache__/log_parser.cpython-312.pyc index 91114cc..c66f89b 100644 Binary files a/backend/app/__pycache__/log_parser.cpython-312.pyc and b/backend/app/__pycache__/log_parser.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index f639758..93a916a 100644 Binary files a/backend/app/__pycache__/main.cpython-312.pyc and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc index 2706e0b..49a7560 100644 Binary files a/backend/app/__pycache__/models.cpython-312.pyc and b/backend/app/__pycache__/models.cpython-312.pyc differ diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc index 4fa7891..44bf447 100644 Binary files a/backend/app/__pycache__/schemas.cpython-312.pyc and b/backend/app/__pycache__/schemas.cpython-312.pyc differ diff --git a/backend/app/__pycache__/user_models.cpython-312.pyc b/backend/app/__pycache__/user_models.cpython-312.pyc index 7746fc5..ed7c844 100644 Binary files a/backend/app/__pycache__/user_models.cpython-312.pyc and b/backend/app/__pycache__/user_models.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/__init__.cpython-312.pyc b/backend/app/api/__pycache__/__init__.cpython-312.pyc index a634744..6a243b5 100644 Binary files a/backend/app/api/__pycache__/__init__.cpython-312.pyc and b/backend/app/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/admin.cpython-312.pyc b/backend/app/api/__pycache__/admin.cpython-312.pyc index 347410a..4dd1bcd 100644 Binary files a/backend/app/api/__pycache__/admin.cpython-312.pyc and b/backend/app/api/__pycache__/admin.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/auth.cpython-312.pyc b/backend/app/api/__pycache__/auth.cpython-312.pyc index a8b7cce..e51fb93 100644 Binary files a/backend/app/api/__pycache__/auth.cpython-312.pyc and b/backend/app/api/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/community.cpython-312.pyc b/backend/app/api/__pycache__/community.cpython-312.pyc index bd3c7d0..07b797d 100644 Binary files a/backend/app/api/__pycache__/community.cpython-312.pyc and b/backend/app/api/__pycache__/community.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/events.cpython-312.pyc b/backend/app/api/__pycache__/events.cpython-312.pyc index 6ecf4ed..bc50720 100644 Binary files a/backend/app/api/__pycache__/events.cpython-312.pyc and b/backend/app/api/__pycache__/events.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/history.cpython-312.pyc b/backend/app/api/__pycache__/history.cpython-312.pyc index d6255b0..101cf15 100644 Binary files a/backend/app/api/__pycache__/history.cpython-312.pyc and b/backend/app/api/__pycache__/history.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/shows.cpython-312.pyc b/backend/app/api/__pycache__/shows.cpython-312.pyc index ee5d992..a79caf2 100644 Binary files a/backend/app/api/__pycache__/shows.cpython-312.pyc and b/backend/app/api/__pycache__/shows.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/station_config.cpython-312.pyc b/backend/app/api/__pycache__/station_config.cpython-312.pyc index bd3b7d9..c66d790 100644 Binary files a/backend/app/api/__pycache__/station_config.cpython-312.pyc and b/backend/app/api/__pycache__/station_config.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/stats.cpython-312.pyc b/backend/app/api/__pycache__/stats.cpython-312.pyc index 482bca4..f2f4fea 100644 Binary files a/backend/app/api/__pycache__/stats.cpython-312.pyc and b/backend/app/api/__pycache__/stats.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/storage.cpython-312.pyc b/backend/app/api/__pycache__/storage.cpython-312.pyc index 7165171..ca80508 100644 Binary files a/backend/app/api/__pycache__/storage.cpython-312.pyc and b/backend/app/api/__pycache__/storage.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/team.cpython-312.pyc b/backend/app/api/__pycache__/team.cpython-312.pyc index d832322..b1b33b5 100644 Binary files a/backend/app/api/__pycache__/team.cpython-312.pyc and b/backend/app/api/__pycache__/team.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/underwriters.cpython-312.pyc b/backend/app/api/__pycache__/underwriters.cpython-312.pyc index 578dc1e..ca25f62 100644 Binary files a/backend/app/api/__pycache__/underwriters.cpython-312.pyc and b/backend/app/api/__pycache__/underwriters.cpython-312.pyc differ diff --git a/backend/app/main.py b/backend/app/main.py index 81f0db4..51756fa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models.py b/backend/app/models.py index 50d24f8..6c5efe1 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6c80ce7..01b3c6e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 ────────────────────────────────────────── diff --git a/backend/seed.py b/backend/seed.py index 1b29a51..61e9513 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -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] = [ diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index ba51624..b0b89c8 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -37,6 +37,11 @@ [class.active]="activeTab() === 'station'" (click)="activeTab.set('station')" >Station + + + + + + } + @if (activeTab() === 'underwriters') { @if (underwriters$ | async; as underwriters) { diff --git a/src/app/admin/admin.component.scss b/src/app/admin/admin.component.scss index 3219673..8318f82 100644 --- a/src/app/admin/admin.component.scss +++ b/src/app/admin/admin.component.scss @@ -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; + } +} diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 3319372..68fc97a 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -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([]); readonly underwriters$ = new BehaviorSubject([]); + // ── 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 { + 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; + } } diff --git a/src/app/contact/contact.component.scss b/src/app/contact/contact.component.scss index 1ab3d3a..1cc4dfa 100644 --- a/src/app/contact/contact.component.scss +++ b/src/app/contact/contact.component.scss @@ -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 diff --git a/src/app/hero/hero.component.scss b/src/app/hero/hero.component.scss index 2885556..b374e0c 100644 --- a/src/app/hero/hero.component.scss +++ b/src/app/hero/hero.component.scss @@ -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; } diff --git a/src/app/interfaces/station-config.ts b/src/app/interfaces/station-config.ts index 19413ee..240b9b1 100644 --- a/src/app/interfaces/station-config.ts +++ b/src/app/interfaces/station-config.ts @@ -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; } diff --git a/src/app/navbar/navbar.component.scss b/src/app/navbar/navbar.component.scss index 1ee2dc0..30a778b 100644 --- a/src/app/navbar/navbar.component.scss +++ b/src/app/navbar/navbar.component.scss @@ -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 diff --git a/src/app/services/station-config.service.ts b/src/app/services/station-config.service.ts index 11b19da..efdb972 100644 --- a/src/app/services/station-config.service.ts +++ b/src/app/services/station-config.service.ts @@ -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(DEFAULT_CONFIG); @@ -47,6 +63,7 @@ export class StationConfigService { try { const data = await firstValueFrom(this.http.get(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(this.baseUrl, payload), ); this.config.set(data); + this.themeService.apply(data); } /** Upload an image file for station branding (admin only). Returns the relative URL. */ diff --git a/src/app/services/theme.service.ts b/src/app/services/theme.service.ts new file mode 100644 index 0000000..fdb0a04 --- /dev/null +++ b/src/app/services/theme.service.ts @@ -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%)`, + ); + } +} diff --git a/src/app/utils/color-harmony.ts b/src/app/utils/color-harmony.ts new file mode 100644 index 0000000..626c21b --- /dev/null +++ b/src/app/utils/color-harmony.ts @@ -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 = { + complementary: 'Complementary (opposite, ~180°)', + analogous: 'Analogous (neighbors, ±30°)', + triadic: 'Triadic (three-way, ±120°)', + 'split-complementary': 'Split-Complementary (offset, ±150°)', + none: 'No harmony detected', +}; diff --git a/src/styles.scss b/src/styles.scss index fcaa721..a451582 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -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); } }