+
+
Color Theme
+
+
+
+
+ Harmony:
+ {{ getHarmonyLabel() }}
+
+
+
+
+ @if (themeSaveSuccess) {
+
{{ themeSaveSuccess }}
+ }
+ @if (themeSaveError) {
+
{{ themeSaveError }}
+ }
+
+
+
+ @for (role of colorRoles; track role.field) {
+
+
+
+
+
+ {{ role.label }}
+ {{ themeColors[role.field] }}
+
+
+ @if (hasHarmonyIssue(role.field)) {
+ ⚠
+ } @else if (role.group !== 'neutral') {
+ ✓
+ }
+
+
+ }
+
+
+
+
+
+
+
+
+ }
+
@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