adds color themeing support to the site
This commit is contained in:
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -51,6 +51,20 @@ def _migrate_add_missing_columns(sync_conn):
|
|||||||
("station_config", "play_store_icon_url", sa.String(500), ""),
|
("station_config", "play_store_icon_url", sa.String(500), ""),
|
||||||
("station_config", "play_store_url", sa.String(500), ""),
|
("station_config", "play_store_url", sa.String(500), ""),
|
||||||
("station_config", "app_store_embed_html", sa.Text, ""),
|
("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
|
# Determine dialect
|
||||||
|
|||||||
@@ -105,6 +105,21 @@ class StationConfig(Base):
|
|||||||
play_store_url = Column(String(500), nullable=False, default="")
|
play_store_url = Column(String(500), nullable=False, default="")
|
||||||
app_store_embed_html = Column(Text, 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):
|
class HistoryEntry(Base):
|
||||||
__tablename__ = "history_entries"
|
__tablename__ = "history_entries"
|
||||||
|
|||||||
@@ -78,6 +78,20 @@ class StationConfigResponse(BaseModel):
|
|||||||
play_store_icon_url: str
|
play_store_icon_url: str
|
||||||
play_store_url: str
|
play_store_url: str
|
||||||
app_store_embed_html: 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}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -105,6 +119,20 @@ class StationConfigUpdate(BaseModel):
|
|||||||
play_store_icon_url: Optional[str] = None
|
play_store_icon_url: Optional[str] = None
|
||||||
play_store_url: Optional[str] = None
|
play_store_url: Optional[str] = None
|
||||||
app_store_embed_html: 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 ──────────────────────────────────────────
|
# ── HistoryEntry ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -217,6 +217,20 @@ STATION_CONFIG: dict = {
|
|||||||
"play_store_icon_url": "",
|
"play_store_icon_url": "",
|
||||||
"play_store_url": "",
|
"play_store_url": "",
|
||||||
"app_store_embed_html": "",
|
"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] = [
|
HISTORY_ENTRIES: list[dict] = [
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
[class.active]="activeTab() === 'station'"
|
[class.active]="activeTab() === 'station'"
|
||||||
(click)="activeTab.set('station')"
|
(click)="activeTab.set('station')"
|
||||||
>Station</button>
|
>Station</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
[class.active]="activeTab() === 'theme'"
|
||||||
|
(click)="activeTab.set('theme'); onTabChanged()"
|
||||||
|
>Theme</button>
|
||||||
<button
|
<button
|
||||||
class="tab-btn"
|
class="tab-btn"
|
||||||
[class.active]="activeTab() === 'underwriters'"
|
[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 -->
|
<!-- Underwriters Tab -->
|
||||||
@if (activeTab() === 'underwriters') {
|
@if (activeTab() === 'underwriters') {
|
||||||
@if (underwriters$ | async; as underwriters) {
|
@if (underwriters$ | async; as underwriters) {
|
||||||
|
|||||||
@@ -214,3 +214,197 @@
|
|||||||
gap: $spacing-sm;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Underwriter } from '../interfaces/underwriter';
|
|||||||
import { ShowService } from '../services/show.service';
|
import { ShowService } from '../services/show.service';
|
||||||
import { EventService } from '../services/event.service';
|
import { EventService } from '../services/event.service';
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
import { ThemeService } from '../services/theme.service';
|
||||||
import { HistoryEntryService } from '../services/history-entry.service';
|
import { HistoryEntryService } from '../services/history-entry.service';
|
||||||
import { TeamMemberService } from '../services/team-member.service';
|
import { TeamMemberService } from '../services/team-member.service';
|
||||||
import { CommunityHighlightService } from '../services/community-highlight.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 { AdminCommunityFormComponent } from './admin-community-form.component';
|
||||||
import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
|
import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
|
||||||
import { AdminStatsDashboardComponent } from './admin-stats-dashboard.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({
|
@Component({
|
||||||
selector: 'app-admin',
|
selector: 'app-admin',
|
||||||
@@ -51,6 +81,7 @@ export class AdminComponent implements OnInit {
|
|||||||
private showService = inject(ShowService);
|
private showService = inject(ShowService);
|
||||||
private eventService = inject(EventService);
|
private eventService = inject(EventService);
|
||||||
private stationConfigService = inject(StationConfigService);
|
private stationConfigService = inject(StationConfigService);
|
||||||
|
private themeService = inject(ThemeService);
|
||||||
private historyEntryService = inject(HistoryEntryService);
|
private historyEntryService = inject(HistoryEntryService);
|
||||||
private teamMemberService = inject(TeamMemberService);
|
private teamMemberService = inject(TeamMemberService);
|
||||||
private communityHighlightService = inject(CommunityHighlightService);
|
private communityHighlightService = inject(CommunityHighlightService);
|
||||||
@@ -75,6 +106,45 @@ export class AdminComponent implements OnInit {
|
|||||||
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
|
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
|
||||||
readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]);
|
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;
|
showShowForm = false;
|
||||||
showEventForm = false;
|
showEventForm = false;
|
||||||
showStationForm = false;
|
showStationForm = false;
|
||||||
@@ -322,4 +392,110 @@ export class AdminComponent implements OnInit {
|
|||||||
await firstValueFrom(this.underwriterService.deleteUnderwriter(id));
|
await firstValueFrom(this.underwriterService.deleteUnderwriter(id));
|
||||||
await this.reloadUnderwriters();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,10 +80,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-form {
|
.btn-form {
|
||||||
@include button-style($accent-orange, $neutral-white, $accent-orange-dark);
|
background: var(--color-accent);
|
||||||
align-self: flex-start;
|
color: var(--color-white);
|
||||||
font-size: 1rem;
|
border: none;
|
||||||
|
border-radius: $radius-md;
|
||||||
padding: $spacing-sm $spacing-xl;
|
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
|
// Info
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: $sky-gradient;
|
background: var(--sky-gradient);
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +105,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-hero-primary {
|
.btn-hero-primary {
|
||||||
@include button-style($accent-orange, $neutral-white, $accent-orange-dark);
|
background: var(--color-accent);
|
||||||
font-size: 1.1rem;
|
color: var(--color-white);
|
||||||
|
border: none;
|
||||||
|
border-radius: $radius-md;
|
||||||
padding: $spacing-sm $spacing-xl;
|
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;
|
letter-spacing: 0.03em;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-accent-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-hero-secondary {
|
.btn-hero-secondary {
|
||||||
@@ -139,7 +154,7 @@
|
|||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: $radius-full;
|
border-radius: $radius-full;
|
||||||
background: $success-green;
|
background: var(--color-success);
|
||||||
animation: pulse 2s ease-in-out infinite;
|
animation: pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,4 +23,18 @@ export interface StationConfig {
|
|||||||
play_store_icon_url: string;
|
play_store_icon_url: string;
|
||||||
play_store_url: string;
|
play_store_url: string;
|
||||||
app_store_embed_html: 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,9 +158,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-donate-nav {
|
.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;
|
padding: $spacing-xs $spacing-md;
|
||||||
font-size: 0.9rem;
|
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
|
// Responsive
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
|
|
||||||
import { StationConfig } from '../interfaces/station-config';
|
import { StationConfig } from '../interfaces/station-config';
|
||||||
import { getAppConfig } from './app-config.service';
|
import { getAppConfig } from './app-config.service';
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
/** Hardcoded defaults — used when the API is unavailable (dev fallback). */
|
/** Hardcoded defaults — used when the API is unavailable (dev fallback). */
|
||||||
const DEFAULT_CONFIG: StationConfig = {
|
const DEFAULT_CONFIG: StationConfig = {
|
||||||
@@ -31,6 +32,20 @@ const DEFAULT_CONFIG: StationConfig = {
|
|||||||
play_store_icon_url: '',
|
play_store_icon_url: '',
|
||||||
play_store_url: '',
|
play_store_url: '',
|
||||||
app_store_embed_html: '',
|
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({
|
@Injectable({
|
||||||
@@ -38,6 +53,7 @@ const DEFAULT_CONFIG: StationConfig = {
|
|||||||
})
|
})
|
||||||
export class StationConfigService {
|
export class StationConfigService {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
private themeService = inject(ThemeService);
|
||||||
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
|
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
|
||||||
|
|
||||||
readonly config = signal<StationConfig>(DEFAULT_CONFIG);
|
readonly config = signal<StationConfig>(DEFAULT_CONFIG);
|
||||||
@@ -47,6 +63,7 @@ export class StationConfigService {
|
|||||||
try {
|
try {
|
||||||
const data = await firstValueFrom(this.http.get<StationConfig>(this.baseUrl));
|
const data = await firstValueFrom(this.http.get<StationConfig>(this.baseUrl));
|
||||||
this.config.set(data);
|
this.config.set(data);
|
||||||
|
this.themeService.apply(data);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the default config if the API is unavailable.
|
// Keep the default config if the API is unavailable.
|
||||||
console.warn('StationConfigService: Failed to load config, using defaults.');
|
console.warn('StationConfigService: Failed to load config, using defaults.');
|
||||||
@@ -59,6 +76,7 @@ export class StationConfigService {
|
|||||||
this.http.put<StationConfig>(this.baseUrl, payload),
|
this.http.put<StationConfig>(this.baseUrl, payload),
|
||||||
);
|
);
|
||||||
this.config.set(data);
|
this.config.set(data);
|
||||||
|
this.themeService.apply(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upload an image file for station branding (admin only). Returns the relative URL. */
|
/** Upload an image file for station branding (admin only). Returns the relative URL. */
|
||||||
|
|||||||
@@ -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%)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -80,26 +80,66 @@ img, svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $spacing-xs;
|
gap: $spacing-xs;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-family: var(--font-primary);
|
font-family: var(--font-primary);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-accent-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.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 {
|
.btn-outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 2px solid $accent-orange;
|
border: 2px solid var(--color-accent);
|
||||||
color: $accent-orange;
|
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 {
|
&:hover {
|
||||||
background: $accent-orange;
|
background: var(--color-accent);
|
||||||
color: $neutral-white;
|
color: var(--color-white);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user