From ee79662e5d1d3842e010b1f33abdbb5220c294b2 Mon Sep 17 00:00:00 2001 From: kyle Date: Mon, 29 Jun 2026 21:23:51 +0000 Subject: [PATCH] adds color themeing support to the site --- backend/__pycache__/seed.cpython-312.pyc | Bin 21174 -> 21816 bytes .../app/__pycache__/__init__.cpython-312.pyc | Bin 140 -> 143 bytes backend/app/__pycache__/auth.cpython-312.pyc | Bin 5685 -> 5688 bytes .../app/__pycache__/config.cpython-312.pyc | Bin 1195 -> 1198 bytes .../app/__pycache__/database.cpython-312.pyc | Bin 841 -> 844 bytes .../__pycache__/log_parser.cpython-312.pyc | Bin 12292 -> 12295 bytes backend/app/__pycache__/main.cpython-312.pyc | Bin 10234 -> 13083 bytes .../app/__pycache__/models.cpython-312.pyc | Bin 9506 -> 10537 bytes .../app/__pycache__/schemas.cpython-312.pyc | Bin 11469 -> 12480 bytes .../__pycache__/user_models.cpython-312.pyc | Bin 1142 -> 1145 bytes .../api/__pycache__/__init__.cpython-312.pyc | Bin 144 -> 147 bytes .../app/api/__pycache__/admin.cpython-312.pyc | Bin 2046 -> 2049 bytes .../app/api/__pycache__/auth.cpython-312.pyc | Bin 5637 -> 5640 bytes .../api/__pycache__/community.cpython-312.pyc | Bin 5017 -> 5020 bytes .../api/__pycache__/events.cpython-312.pyc | Bin 4817 -> 4820 bytes .../api/__pycache__/history.cpython-312.pyc | Bin 4921 -> 4924 bytes .../app/api/__pycache__/shows.cpython-312.pyc | Bin 6913 -> 6916 bytes .../station_config.cpython-312.pyc | Bin 2876 -> 2879 bytes .../app/api/__pycache__/stats.cpython-312.pyc | Bin 8862 -> 8865 bytes .../api/__pycache__/storage.cpython-312.pyc | Bin 3549 -> 3552 bytes .../app/api/__pycache__/team.cpython-312.pyc | Bin 4895 -> 4898 bytes .../__pycache__/underwriters.cpython-312.pyc | Bin 4917 -> 4920 bytes backend/app/main.py | 14 + backend/app/models.py | 15 + backend/app/schemas.py | 28 ++ backend/seed.py | 14 + src/app/admin/admin.component.html | 82 +++++ src/app/admin/admin.component.scss | 194 +++++++++++ src/app/admin/admin.component.ts | 178 +++++++++- src/app/contact/contact.component.scss | 21 +- src/app/hero/hero.component.scss | 23 +- src/app/interfaces/station-config.ts | 14 + src/app/navbar/navbar.component.scss | 17 +- src/app/services/station-config.service.ts | 18 + src/app/services/theme.service.ts | 67 ++++ src/app/utils/color-harmony.ts | 324 ++++++++++++++++++ src/styles.scss | 52 ++- 37 files changed, 1046 insertions(+), 15 deletions(-) create mode 100644 src/app/services/theme.service.ts create mode 100644 src/app/utils/color-harmony.ts diff --git a/backend/__pycache__/seed.cpython-312.pyc b/backend/__pycache__/seed.cpython-312.pyc index f8ae05cb404819e18618949d134a88e3abd87e3b..a3245f2e61364995569992e43decfefbdcd72738 100644 GIT binary patch delta 2717 zcma)6S#%Rs7@kQo=|Z~DbV(^ofzmc?E$KokD65FzK_Bpl<;a|A{z(Z!WaP$Ne zwTfcufK)6fVnI<+j36!*H{5V3ih!dc>WhyTdGZ_{{QnyYDF-~{;hXRK@4ess{(EO~ zs82q;PtHGcy~YrX(dK2*Qi78yxUbuMPTO5A20f!C!_~U>_6+{wll%uY-$tyREfMFVdam*bJKN zM-vI&fH&a)SkUFIUIkL{HXKA%D%2r(2M(hu5XuAZ!h5J(q}pwXR#%4E`@(Dqd;tAu z^&tjY21no{G$Q^nd;*`M4e`(5D1444$T3iPhQ}Ae<4gDoH~AV}j$xp0;9FGRp%1C= zVF1-}bUN0XP~Zpn5#HaNY})>;9V_0OnBW@r=js0{rJ z>d_hcSJY!N^l!CTsLUvwK|L1!gx~Qw4xeZ7IbN@VKa4`9MgA+bNEwq~;DB&Qiv_|V zHO_*jWUO=ElgRUhgJGsdSfEp5Yu9CspQbfvO+M2(q^<@7?JHwA>d~4sug@$RItm)= zz`=Um)7V&l<eswS6J*R2V&j%Y+f+q#ah6S$FEl z6vLXxIF$m^8VN@-j)o34eKd4}SMELnYo;CapdQo3U9l{xSOQ?$wMaw{fjc+j@@RVc z25r%m;WZcI8ONtha;>Iy4mBQ$>MW*;(Jn0QOci^Ih9u0qHwXENcV`l@t0lbp3zV}G zKX>uTU*m`S6kBOp)H<_kyGn%SW15n`;CM(9f1){Gl9un@H(;J3%b9onM04s0uyPEf2G zizNLmrLty^&Mp-ny?8-#R5hlx1$9+r8;ltXYwD6XpLHX+W#JyhwbDgLM~uZoJ}suJ zS}@3ZaNt6!dttjp_8I>!+MBrCm1s!xBHEyT9VQwV?TSvRfpn+Tq=+dT&1_W-X`L9z zwBl~!FXBATkkU&MKx@MTV**l=C+B}M77Iu+LqJR!mY^8+VYcj zt{~%fNpU6@^DN@ol2Vck@f_m0l9E4AFqID@;gFQVWI4|xo=*$r^8(_9w4j3zCtf5e zxm`24lX$VD6buwr^AZxJk}_f-Q8AsDk#b2&@j%IBUQS|!q?8S~JiLO$NU@`rk0LQz zQXB($qxcvSl|t0=u_VTcX;tUOlc=IFbNK|~)siwiIgZy5uf;ukJlsuuBJqTWPa-jy z61VXw#HUJ1MbBJbN8E$4l6Uh4;*GSRg*OpzmXz`yH=j=2o36JIzkxQsmCqnPGd<@< z;x|c(tEZgLB0gJA56qFPT?;IHF3Pf=96k@Fh0jMdJn7~)3$*|h?tktUp%%(g$>`HX d75^+IxjsK>J1#ny+{`yXPu|Kb1u delta 2071 zcma)6>rWh26rb7M*|K!m1@^(h@`li^#conQPzbSUVq+~r(xz=Hww*4V0ao^vyR#Bw z;)-C4qIGfXOHu0s(F(P7YFpoL6T_GNq+~zoKVT;I572Y&0-e-`u)Cc3{qCImyT5zR zz1+Sc-kcX*%VlMy0)GDeqvy$`zFAj5tiQf(n>7m)rtkA}_IYu@uXq$MO!ivIyE zgY$3!nV+-lMYsfSAa!%P3*Lk|WC3C*7BGpu#l`yJZFmQzE~BAi@GiWELWJLk4`3cu z2!99*@DXYtMvZEd#>ZUa3VecVETYO%79CxMPmx|jp(SM3;WK19N)fvOpCh}8V#rXe z#w2!&i``Z{@C8_gZSdukZ6FAD`B%tmP5f)*bte7|@_qHDfp2l3!Nk8q-e}_ABX5GA z;Rk#+B_<4HD_PDO^4ba-4Dlq0E>cDAiiQm}VNb-nGRCHpwSkb@22gV`uJC_F(S zOyM|%6BJHTkTHbp>=fZ66e1W3_Mv2AI66|CF@z&&JBCL7VL%yvIg?5&OqJ7W(e>&K zDT;50^>d-LCEJUTc`)>|V;B!J?yc11jgEKG+j{Qi3`$BuWlwM5&nIWJGwog}hAX7C z%k7Keu-^CJoS5yu*N?eYTi5+i+UBg-<4Sx`VHgr@&CQT@ywf8+ zi%TqMYUfqnxP++9c7-=?UfH_6%{R}ISt|=p{@)>AZ(Jq;3L9tIo^DB+z|33Pqum$8 zl>S%uKKmnd`RK;P<59IBrC2nf7OXKP!RRWm0~Gow&{1RuDNyU#Aquohc9_D17T;UF zlbR5+ny%w%97tO+;N2_UJX=~(fKB7julkL>+e?D{s&7GFaLBS68H_2i%v9veuDSY} z!;=K3FoZ-doE8zUiaAx5Neo3)MUKQ`YzANat?xfDViAvO*B)EQ-ZjsCbIaTSpLMEm>#4h0&c}t(iLvL6` zQUAT~0c$o8u&me|(_MxY(VsR~&TdGE47(tCa=peD!X<*_%Owm4;Zi|z<=s1ttwfxH zRGzCg$_Tq?!o!A}a5+sFGb#vI3R3BGkKrNg6(o1Qyv^_t@e5K-J{#;bs)z*y$(#4J z8`VT=1gRU?g#}TXY&CAQh00h~;9Vc=di|gr^=NDxc7bGU97VBr{mgJ@7%wO$@RE056^n A*#H0l delta 43 xcmeBY>|x|S&CAQh00h6|wI^~Li)ibY=NDxc7bGU97VDR%CdDTf6if`Y0s#Lu4M_k1 diff --git a/backend/app/__pycache__/auth.cpython-312.pyc b/backend/app/__pycache__/auth.cpython-312.pyc index d1adf3b8946c75bebde0417fe08da52861031a68..7c01d5e8813adeeec6bf54f55c01e7e40ec7da0c 100644 GIT binary patch delta 49 zcmdn0vqOjbG%qg~0}$l=cHGFlo>^Q=zdXMvySN}RIki|nJGUe+EhoP`wP^D>=5!$d DhYt|_ delta 46 zcmdm?vsH)tG%qg~0}u%Ln{4D>&n%*@U!Gr-U0jfuoLa13o|+V&SWvL}B6GSB05Fvf Ad;kCd diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc index c33ab09e4df24800fccb17237f1ec27048cc6eca..c96e43d110ebf0f922d5f457c9de61efd650fe41 100644 GIT binary patch delta 49 zcmZ3@xsH?jG%qg~0}$l=cHGDv#VoF)U!Gr-U0jfuoLa1(om-NZmXlweTC};6*@zJU DYLgGO delta 46 zcmZ3-xtf#vG%qg~0}vSfv)jlW#Vn$!U!Gr-U0jfuoLa13o|+V&SWvLJhS`V_05Mh$ A4FCWD diff --git a/backend/app/__pycache__/database.cpython-312.pyc b/backend/app/__pycache__/database.cpython-312.pyc index 7e7f255d1bbfd79a2273294104505f9023172762..9a04cdf14b2fb28dfe987fb89cf4cbc2dd8a6d18 100644 GIT binary patch delta 49 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 delta 3094 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 diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc index 2706e0b0068f59d565e506c930ff5a458426d786..49a7560be9c72a0159d62b4b6c218f0cda7255c2 100644 GIT binary patch delta 1461 zcmZwGOH30%7y#f-tstvBN(o^WxapyXCZ@f3(!|8Xm~i1D?(BkD>NeTGGynh1KQnzi?|gMF z=TmkzA>&?s#D&|Vx_3EGY9k3$FTZ}f}>yslB-G|jLvdC3|1X9bF=?QHJZ zq&z5>(F*R#be3-c+(RaFo_1>A5L>5&*-fpa(20ajQdEdUg{07PB6z%wUXmC@O$?Hv zQY0!RMU_ZYrHdec-)a%5PA9ym5s4Z}VH63Yq^K2%+9T0+@`JAvkvhpH^&(L}(_lQ$ z{>*+1fQ zaGY_EL1reUIx!s_bq|jOQu!y7PRMxD;n-w=cBak=5IP*R_un#McGOBK7K6?K-0G

IAo3J!uIH7%$-nQnd?awX*UX^V3XSE+wDjc^~M#NBvrnV5t-P`+G*dl zC#asdqEJeys8Cfz88$}gSbsuwd9GV(9a$Edioo#9POxRym>|33iUKL7DGj+ISHtd| z%3Ug<>541VNu?%~@pK}b6>X2%6Uz3v&ZN31{p|lAA#K&YPKM-uYD||54Ur`KJ-->C zUsM*ji*#xSm_?AJ*mboB*#=*0gGr^N3lyq%YazI6&zk8eaQq^;`?ov+ycZZuHf(ismgAE zRlf3|ECs+bw317rDJ=;$&_iuEv4$lMd+3|?C0JvZbq!cWG^L;qB`YeyBXn3%PDHW8 z;R~wK_k$?9uU|0C;v|1P{sR0-`9tyh^R1((!B7geQI`Scw1P`u<>I~!zwZaoN5jZp D(?y^j delta 471 zcmZ1(w8)F^G%qg~0}wpCYjLa8VBsQNF3uNTV0Vx9!xsx>{k{NR*7b;0^UMi8z z$W;iE0TC6GrKHLk3&Aq0rMwxr%0V(9qGggyI%7FlqEW`0kui4iURle@auS@3{gdP> zCwEH-PCm!Qxp}f&FC$kSNE3*dJy}bkkg*P|YpX&uBUdL#21KlwETt62*txk-=`$l^ z&*X*d5|e9HBp4@8o~Gi%)elkyBDPL`qq3Z_fAT&q$<1q3MPMrTOS3YDOn#uI%GfrU zOIXqf&`Q{O?b3*NM}fdzH0m9x zyamfww}B^)`{RLL)?P9el%xf7*2so56Q=d<=%m)Jhr4=H9Xee+uf5|(yS)q zQMqrea9|{rh%Z&-gwa?mF<5ZRjq;(Yl!{~VZVZ(VpV#FgsdKtFqp`kjd1z!Xz9iT&Veeqqki3y^`f#)K0(@a`!j$M?k6>QBuup5q2!^cL zmJw`OlQkn)^R`Bx5%hV+XvqkcylpUN1anrcJ=>Dax#omOz_#*G@z=%BVmbi#bvhXH z=@uT9H?fI0Yquwa#CkIxcuCxZN5MIVf}>{OoKwM3TX4>$;HW`3=T>mkDxC8uIBFWs zRda4)B{B66C%pbheTh50?C6Ay!$- zCNjdNAFBgrnx?m>chA&kgg{n`EDFZ7X|8(Ko)IG1;KoJ4kS?9m&Z;uP#klmox`yeVbaG}OBZRUp@0q>RrD^R9&*ROm zUeoF{&y)`ZuUw7wX@h%iGxQj(pbnPt2d5l<+aQZaa0=lf^s9aB2#K0ZVR#}0;V*Tq z`X~yF2<-?R@YrPW5-H#~g5W|RjT^1Ju*Pf`J8_HvFkjWiIw`zEllo`a_cV>-aa1F` z3%_e#f~_VC%m%}d)OMD1a8@dVddZ?rpo)@rp%sChm0H+uE@K~pLs!RQlq02^+Z@Mf zS{AvG8so?T4pOCTL)(tvK!`(y*;KBOLdfjd(1TM5cljsT1|4P(e5$W!J(Q-(Fsc97 zG%#;y7Ehor!Y5E-Y=C#{7GD1@)&bucUla#$65&3mOnx?yul)Ob`mt#{N1k0AMiIgT zUcUg$`zv9}>}JFHIKY5oMuXap7>S zH*Q@wiZckm@K3Uh!}%g;DX~Mly`GKI67JdG_Wx)4l|uAUA;vHw!ZM6I8rj&weaAM& z#^I@RXK^WmU9L`c9&Wo#Fy$^QE@tqR>j2z!TcE~WUonopr#UpsuVXO(BoYW}#+Rq! z#`&Ubl`bRbvZCr-WA4sP@?|dbqx`_LkDbEMg-N`P{MlMy)`lfK^B8O)A z5}KD0iV^4}u5e4Lzzg7{X(}OUatDRNX@p7I>s2%n{BYa*8oNqqG`^pmg*1vR6akM* zz5+)7rmfG_L{LM*AgK`)Nw47;VGCUMUuV}S>7Kx-`vy)Ryo|*v1V2IuVKYJ-0zIAdz#YdS z{$$WYK{uXmHr-shdUSiJo2dciQz)huqK2T;qUuqlXzRC%_-^Doup?|2KjLy;g`#8^ hM8mIIf-I75TrT2Y%Wcfdd}(R9h<_~~Wbh#T^lv!tlyCq5 delta 1600 zcmZ{kU1*zS6vy*qF*a|Lrd^XXecyaHA5E5z!REw%$zT`8ZkcskYaFeuO;)X?&E8Dv z*hlBKZdO;QJ(JZ7*P$TDZl*yHy_pE}LWUO>EGmc>_R8YGfEViO^FJw491Fa^_j%6w zpL6n@=XsyoBX{!+-`H%5LH?TW_l*DH`?#Ugk9Z_kr z-4UjfBJQT2?H7%4FMZg!NWW@!dsc?&z!CYBJ1f_kI)$=gO6zUxpySC_%4-_kZN8-R z!UU9P&=C>6?CkOEpbb+y{qE>&+z&wuZ~(|rhf@u+(S&&l2mq`JVzem?c)dy==D;*9 zJNrc+r?+JazznUqo)CwmdLI>9hRiu>i(4q2QtO|ChD+yZy~VBcV-8#) zySq#DbA_V&hI0T8{!<*1$23Q3t11TQACJ41Xw#~BL-wN(09WNx?x+3U5Z!F;7DqXw zQGMef8JE?ZGKg5A9@U^9TEL7{h~E1MOc8(Y0B3j ziX8iuPnRp+;}4n;Mah(>u>(5p;Y%3hxRGNR0$6?Da(riUtSYvP*~LhJzKHxNcj;QR*UWiqCC(rY_{%`%*pN8GCC%@s7C6MIYNS9h`#$XcMwqb#frPrTJD&zS`|F zx7p}8*^}+m=W|5o5dp*yVXH+bwhP%+r+dZZu7%CZuomLLIk)4{YyEYU=|YvxrLT|g8_06PJ`Ao=Fw`-!g| zTp|4V^LMNdW6Iwoe^g_bjstuS`55rQ;Zwj{#4Y3Y@QN1r?B+Ckl!dgZzdmCHAj h3-oN}h+7q@rEJw8U)2tm$SlRH2KlP)p)WHV{{jvObJPF; diff --git a/backend/app/__pycache__/user_models.cpython-312.pyc b/backend/app/__pycache__/user_models.cpython-312.pyc index 7746fc5206d75589836927957e4ba17d1d1151af..ed7c844a473ee7d58489a01510d55439f0c0269f 100644 GIT binary patch delta 49 zcmeyy@sor5G%qg~0}$l=cHGF##4K*8U!Gr-U0jfuoLa1(om-NZmXlweTC`b_Ig}9q Ddmj(M delta 46 zcmey#@r{G~G%qg~0}x#GGug<^#4MtxU!Gr-U0jfuoLa13o|+V&SWvK8g*lWF06y#v A0ssI2 diff --git a/backend/app/api/__pycache__/__init__.cpython-312.pyc b/backend/app/api/__pycache__/__init__.cpython-312.pyc index a634744617125b39a2e8a8daa90dccd60ca5c6d8..6a243b514c578aac2f2c3c8273b1322dc58450bf 100644 GIT binary patch delta 46 zcmbQhIGK_AG%qg~0}$l=cAUsHq)$ delta 43 xcmbQtIDwJ-G%qg~0}wCoXm&CAQh00cR|9XE1kGmGo#m**E{7Z)TZrxxpH=a%H9<>Z&A7H#fgt`-6S DY+Dd5 delta 46 zcmeCsY1QFA&CAQh00h4NCL6i4nMJhq%kzt}iwhEyQ;YS>Q0U!Gr-U0jfuoLa1(om-NZmXlweTC_QowT2e} De}NEB delta 46 zcmcbjdQp}8G%qg~0}wCZ&A7HwY58YT(= Db0H93 delta 46 zcmZoMYc%6N&CAQh00f#VEH-lYu!`vDm**E{7Z)TZrxxp%rzXWG78Gn=#~LOI04$0Q AQ2+n{ diff --git a/backend/app/api/__pycache__/station_config.cpython-312.pyc b/backend/app/api/__pycache__/station_config.cpython-312.pyc index bd3b7d95c6f80b59302b7564b99b828cab65661a..c66d790dbf53ea58723544a4b640f69d03a86db3 100644 GIT binary patch delta 49 zcmdlZwqK02a A0ssI2 diff --git a/backend/app/api/__pycache__/stats.cpython-312.pyc b/backend/app/api/__pycache__/stats.cpython-312.pyc index 482bca4b2b0ed8f477010b18e0891e58657ec98f..f2f4fea7707aeec2fccb6f947f0f07264955da8d 100644 GIT binary patch delta 49 zcmbQ|y3m#TG%qg~0}$l=cHGDv%POv~U!Gr-U0jfuoLa1(om-NZmXlweTC}-_RY?{A De6|oQ delta 46 zcmZ4JI?t8+G%qg~0}x~yIBevOWfjrUFV8Q^E-pw+PA%3iPfdzXEGXDq&#EK~059AP A?*IS* diff --git a/backend/app/api/__pycache__/storage.cpython-312.pyc b/backend/app/api/__pycache__/storage.cpython-312.pyc index 7165171a0c936aa6e4a29b12ecdb5a7627da8bc1..ca80508311f086bd2a941e296ddd61dd76e95de0 100644 GIT binary patch delta 49 zcmcaB{Xm-gG%qg~0}$l=cHGD<$1ZNDU!Gr-U0jfuoLa1(om-NZmXlweTC~}geE}N) DeXI~g delta 46 zcmaDLeOH?MG%qg~0}!0}WwDW4j$K4gzdXMvySN}RIki~7JT)mkv7lhH1N#Ct09A|+ ADF6Tf diff --git a/backend/app/api/__pycache__/team.cpython-312.pyc b/backend/app/api/__pycache__/team.cpython-312.pyc index d8323220e0aa2356732ec5a6dd80ee7189b388dc..b1b33b57b3ff88cb6b2dd8fc6f1bb19a365f38e2 100644 GIT binary patch delta 49 zcmbQQwn&ZpG%qg~0}$l=cHGDv%qp&@U!Gr-U0jfuoLa1(om-NZmXlweTC}-{buBLd DaX=7O delta 46 zcmZ3aHeZeVG%qg~0}z}FHQ&e`%qpU-U!Gr-U0jfuoLa13o|+V&SWvLJlyxmH05wGp AmH+?% diff --git a/backend/app/api/__pycache__/underwriters.cpython-312.pyc b/backend/app/api/__pycache__/underwriters.cpython-312.pyc index 578dc1ee11f44a7ae559c8889f4d43024254517f..ca25f621dbfd981d19e6c2b27d59541e933e5b52 100644 GIT binary patch delta 49 zcmdn0wnL5kG%qg~0}$l=cHGDv&MI!EU!Gr-U0jfuoLa1(om-NZmXlweTC};0bw4iv Ddk_$$ delta 46 zcmdm?wpES$G%qg~0}!lav)afV&MIQ8U!Gr-U0jfuoLa13o|+V&SWvLJl65~X05GWz AcK`qY 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); } }