diff --git a/backend/app/__pycache__/__init__.cpython-312 - Copy.pyc b/backend/app/__pycache__/__init__.cpython-312 - Copy.pyc new file mode 100644 index 0000000..f2d8ded Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-312 - Copy.pyc differ diff --git a/backend/app/__pycache__/config.cpython-312 - Copy.pyc b/backend/app/__pycache__/config.cpython-312 - Copy.pyc new file mode 100644 index 0000000..c96e43d Binary files /dev/null and b/backend/app/__pycache__/config.cpython-312 - Copy.pyc differ diff --git a/backend/app/__pycache__/database.cpython-312 - Copy.pyc b/backend/app/__pycache__/database.cpython-312 - Copy.pyc new file mode 100644 index 0000000..9a04cdf Binary files /dev/null and b/backend/app/__pycache__/database.cpython-312 - Copy.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312 - Copy.pyc b/backend/app/__pycache__/main.cpython-312 - Copy.pyc new file mode 100644 index 0000000..a1fc247 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-312 - Copy.pyc differ diff --git a/backend/app/__pycache__/models.cpython-312 - Copy.pyc b/backend/app/__pycache__/models.cpython-312 - Copy.pyc new file mode 100644 index 0000000..2d459cc Binary files /dev/null and b/backend/app/__pycache__/models.cpython-312 - Copy.pyc differ diff --git a/backend/app/__pycache__/user_models.cpython-312 - Copy.pyc b/backend/app/__pycache__/user_models.cpython-312 - Copy.pyc new file mode 100644 index 0000000..ed7c844 Binary files /dev/null and b/backend/app/__pycache__/user_models.cpython-312 - Copy.pyc differ diff --git a/backend/app/api/__pycache__/__init__.cpython-312 - Copy.pyc b/backend/app/api/__pycache__/__init__.cpython-312 - Copy.pyc new file mode 100644 index 0000000..6a243b5 Binary files /dev/null and b/backend/app/api/__pycache__/__init__.cpython-312 - Copy.pyc differ diff --git a/backend/app/api/__pycache__/admin.cpython-312 - Copy.pyc b/backend/app/api/__pycache__/admin.cpython-312 - Copy.pyc new file mode 100644 index 0000000..4dd1bcd Binary files /dev/null and b/backend/app/api/__pycache__/admin.cpython-312 - Copy.pyc differ diff --git a/backend/app/api/__pycache__/auth.cpython-312 - Copy.pyc b/backend/app/api/__pycache__/auth.cpython-312 - Copy.pyc new file mode 100644 index 0000000..e51fb93 Binary files /dev/null and b/backend/app/api/__pycache__/auth.cpython-312 - Copy.pyc differ diff --git a/backend/app/api/__pycache__/community.cpython-312 - Copy.pyc b/backend/app/api/__pycache__/community.cpython-312 - Copy.pyc new file mode 100644 index 0000000..07b797d Binary files /dev/null and b/backend/app/api/__pycache__/community.cpython-312 - Copy.pyc differ diff --git a/backend/app/api/__pycache__/events.cpython-312 - Copy.pyc b/backend/app/api/__pycache__/events.cpython-312 - Copy.pyc new file mode 100644 index 0000000..bc50720 Binary files /dev/null and b/backend/app/api/__pycache__/events.cpython-312 - Copy.pyc differ diff --git a/backend/app/api/__pycache__/history.cpython-312 - Copy.pyc b/backend/app/api/__pycache__/history.cpython-312 - Copy.pyc new file mode 100644 index 0000000..101cf15 Binary files /dev/null and b/backend/app/api/__pycache__/history.cpython-312 - Copy.pyc differ diff --git a/backend/app/api/__pycache__/shows.cpython-312 - Copy.pyc b/backend/app/api/__pycache__/shows.cpython-312 - Copy.pyc new file mode 100644 index 0000000..a79caf2 Binary files /dev/null and b/backend/app/api/__pycache__/shows.cpython-312 - Copy.pyc differ diff --git a/backend/app/api/__pycache__/station_config.cpython-312 - Copy.pyc b/backend/app/api/__pycache__/station_config.cpython-312 - Copy.pyc new file mode 100644 index 0000000..c66d790 Binary files /dev/null and b/backend/app/api/__pycache__/station_config.cpython-312 - Copy.pyc differ diff --git a/backend/app/api/__pycache__/stats.cpython-312 - Copy.pyc b/backend/app/api/__pycache__/stats.cpython-312 - Copy.pyc new file mode 100644 index 0000000..f2f4fea Binary files /dev/null and b/backend/app/api/__pycache__/stats.cpython-312 - Copy.pyc differ diff --git a/backend/app/api/__pycache__/storage.cpython-312 - Copy.pyc b/backend/app/api/__pycache__/storage.cpython-312 - Copy.pyc new file mode 100644 index 0000000..ca80508 Binary files /dev/null and b/backend/app/api/__pycache__/storage.cpython-312 - Copy.pyc differ diff --git a/backend/app/api/__pycache__/team.cpython-312 - Copy.pyc b/backend/app/api/__pycache__/team.cpython-312 - Copy.pyc new file mode 100644 index 0000000..b1b33b5 Binary files /dev/null and b/backend/app/api/__pycache__/team.cpython-312 - Copy.pyc differ diff --git a/backend/app/api/__pycache__/underwriters.cpython-312 - Copy.pyc b/backend/app/api/__pycache__/underwriters.cpython-312 - Copy.pyc new file mode 100644 index 0000000..ca25f62 Binary files /dev/null and b/backend/app/api/__pycache__/underwriters.cpython-312 - Copy.pyc differ diff --git a/src/app/utils/color-harmony.ts b/src/app/utils/color-harmony.ts index 7605438..b6174b3 100644 --- a/src/app/utils/color-harmony.ts +++ b/src/app/utils/color-harmony.ts @@ -4,7 +4,7 @@ // ── Types ──────────────────────────────────────────────────────────────────── -export type HarmonyRule = 'complementary' | 'analogous' | 'triadic' | 'split-complementary' | 'none'; +export type HarmonyRule = 'complementary' | 'analogous' | 'triadic' | 'split-complementary' | 'monochromatic' | 'tetradic' | 'none'; export interface HSL { h: number; // 0-360 @@ -103,6 +103,110 @@ export function generateMuted(hex: string): string { return hslToHex(h, Math.max(0, s - 20), l); } +// ── Hue blending helper ───────────────────────────────────────────────────── + +/** + * Blend two hues along the shortest arc on the color wheel, + * returning the intermediate hue at the given ratio (0 = from, 1 = to). + */ +export function blendHues(from: number, to: number, ratio: number): number { + const dist = hueDistance(from, to); + const step = from < to && to - from <= 180 ? dist : -dist; + return ((from + step * ratio) % 360 + 360) % 360; +} + +// ── Semantic color canon ──────────────────────────────────────────────────── + +/** + * Canonical semantic hues that remain recognizable regardless of the + * primary harmony rule. Used by generateFullPalette as a fallback when + * the harmony-derived hue would clash with the standard meaning. + */ +const SEMANTIC_CANONICAL = { + color_success: { h: 145, s: 55, l: 40 }, + color_danger: { h: 0, s: 70, l: 50 }, + color_info: { h: 210, s: 60, l: 45 }, +} as const; + +// ── Palette generation ────────────────────────────────────────────────────── + +export interface PaletteOptions { + primary: string; + accent?: string; + rule?: HarmonyRule; +} + +/** + * Generate a full theme palette from a primary color. + * If no accent is provided, it is derived from the harmony rule + * (defaults to 'complementary' when unspecified). + */ +export function generateFullPalette(options: PaletteOptions): { [key: string]: string } { + const primaryHex = options.primary; + const rule = options.rule ?? 'complementary'; + const { h: ph, s: ps, l: pl } = hexToHSL(primaryHex); + + // Derive accent from the harmony rule if not explicitly provided + let accentHex: string; + if (options.accent) { + accentHex = options.accent; + } else if (rule === 'monochromatic') { + accentHex = hslToHex(ph, Math.max(ps - 20, 20), pl); + } else { + const hues = getHarmonyHues(ph, rule); + const accentHue = hues[1] ?? (ph + 180) % 360; + accentHex = hslToHex(accentHue, ps, pl); + } + + return { + // Primary family + color_primary: primaryHex, + color_primary_light: generateLight(primaryHex), + color_primary_dark: generateDark(primaryHex), + color_primary_muted: generateMuted(primaryHex), + + // Accent family + color_accent: accentHex, + color_accent_light: generateLight(accentHex), + color_accent_dark: generateDark(accentHex), + color_accent_muted: generateMuted(accentHex), + + // Neutrals derived from primary lightness + color_background: hslToHex(ph, Math.min(ps, 8), Math.max(pl + 30, 92)), + color_text: hslToHex(ph, Math.min(ps, 15), Math.min(pl - 40, 15)), + color_white: '#ffffff', + color_light: hslToHex(ph, 6, 90), + color_medium: hslToHex(ph, 8, 65), + color_black: '#000000', + + // Semantic colors — prefer harmony-aligned hue, fall back to canonical + color_success: hslToHex(SEMANTIC_CANONICAL.color_success.h, SEMANTIC_CANONICAL.color_success.s, SEMANTIC_CANONICAL.color_success.l), + color_danger: hslToHex(SEMANTIC_CANONICAL.color_danger.h, SEMANTIC_CANONICAL.color_danger.s, SEMANTIC_CANONICAL.color_danger.l), + color_info: hslToHex(SEMANTIC_CANONICAL.color_info.h, SEMANTIC_CANONICAL.color_info.s, SEMANTIC_CANONICAL.color_info.l), + }; +} + +/** + * Suggest the best harmony rule for a given primary color. + * Picks the rule whose canonical hues produce the most readable + * contrast when rendered at the primary's saturation and lightness. + * Defaults to 'complementary' for safe, high-contrast palettes. + */ +export function suggestHarmonyRuleForPrimary( + primaryHex: string, + preferred: Exclude = 'complementary', +): Exclude { + const { s, l } = hexToHSL(primaryHex); + + // Low-saturation primaries look best monochromatic (accent is subtle) + if (s < 15) return 'monochromatic'; + + // Very dark or very light primaries benefit from complementary contrast + if (l < 25 || l > 75) return 'complementary'; + + return preferred; +} + // ── Harmony detection ─────────────────────────────────────────────────────── /** @@ -122,8 +226,10 @@ export function detectHarmonyRule(primaryHex: string, accentHex: string): Harmon const rules: Array<{ rule: HarmonyRule; target: number; tolerance: number }> = [ { rule: 'complementary', target: 180, tolerance: 20 }, { rule: 'split-complementary', target: 150, tolerance: 15 }, + { rule: 'tetradic', target: 90, tolerance: 15 }, { rule: 'triadic', target: 120, tolerance: 15 }, { rule: 'analogous', target: 30, tolerance: 15 }, + { rule: 'monochromatic', target: 0, tolerance: 10 }, ]; for (const { rule, target, tolerance } of rules) { @@ -166,7 +272,7 @@ export function validateColor( return { isHarmonious: true, deviation: 0 }; } - const expectedHues = getExpectedHues(primary.h, rule); + const expectedHues = getHarmonyHues(primary.h, rule); let minDeviation = 360; for (const expected of expectedHues) { @@ -185,8 +291,8 @@ export function validateColor( }; } -/** Get the expected hues for a given harmony rule relative to the primary hue. */ -function getExpectedHues(primaryHue: number, rule: HarmonyRule): number[] { +/** Get the harmony hues for a given rule relative to the primary hue. */ +export function getHarmonyHues(primaryHue: number, rule: HarmonyRule): number[] { switch (rule) { case 'complementary': return [primaryHue, (primaryHue + 180) % 360]; @@ -196,6 +302,10 @@ function getExpectedHues(primaryHue: number, rule: HarmonyRule): number[] { return [primaryHue, (primaryHue + 120) % 360, (primaryHue + 240) % 360]; case 'split-complementary': return [primaryHue, (primaryHue + 150) % 360, (primaryHue + 210) % 360]; + case 'monochromatic': + return [primaryHue]; + case 'tetradic': + return [primaryHue, (primaryHue + 90) % 360, (primaryHue + 180) % 360, (primaryHue + 270) % 360]; default: return [primaryHue]; } @@ -211,7 +321,7 @@ export function suggestHarmoniousColor( rule: HarmonyRule, ): string { const color = hexToHSL(colorHex); - const expectedHues = getExpectedHues(hexToHSL(primaryHex).h, rule); + const expectedHues = getHarmonyHues(hexToHSL(primaryHex).h, rule); // Find the closest expected hue let closestHue = expectedHues[0]; @@ -324,5 +434,7 @@ export const HARMONY_RULE_LABELS: Record = { analogous: 'Analogous (neighbors, ±30°)', triadic: 'Triadic (three-way, ±120°)', 'split-complementary': 'Split-Complementary (offset, ±150°)', + monochromatic: 'Monochromatic (single hue, value/saturation variants)', + tetradic: 'Tetradic (four-way, ±90°)', none: 'No harmony detected', };