From ab83ee3f20efcfa0da44b13032d72b04879715c0 Mon Sep 17 00:00:00 2001 From: kodi Date: Thu, 12 Mar 2026 18:26:29 +0100 Subject: [PATCH] feat: theme --- project_docs/THEME_SELECTION_V1_DESIGN.md | 177 ++++++++++++++++++ .../api/__pycache__/schemas.cpython-313.pyc | Bin 9014 -> 9157 bytes webui/backend/app/api/schemas.py | 4 + .../settings_service.cpython-313.pyc | Bin 3707 -> 6002 bytes .../backend/app/services/settings_service.py | 47 +++++ webui/backend/data/tasks.db | Bin 81920 -> 81920 bytes .../test_api_settings_golden.cpython-313.pyc | Bin 10981 -> 13945 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 15076 -> 16275 bytes .../tests/golden/test_api_settings_golden.py | 40 ++++ .../tests/golden/test_ui_smoke_golden.py | 20 +- webui/html/app.js | 102 +++++++--- webui/html/index.html | 14 ++ webui/html/style.css | 4 +- 13 files changed, 374 insertions(+), 34 deletions(-) create mode 100644 project_docs/THEME_SELECTION_V1_DESIGN.md diff --git a/project_docs/THEME_SELECTION_V1_DESIGN.md b/project_docs/THEME_SELECTION_V1_DESIGN.md new file mode 100644 index 0000000..be5635b --- /dev/null +++ b/project_docs/THEME_SELECTION_V1_DESIGN.md @@ -0,0 +1,177 @@ +# Theme Selection v1 + +## 1. Doel +Theme-selectie voegt nu waarde toe omdat de UI al een nette light/dark basis heeft, maar nog geen expliciet onderscheid maakt tussen: +- `theme`: de stijlset +- `mode`: light of dark binnen die stijlset + +Dat onderscheid maakt de UI uitbreidbaar zonder de dagelijkse snelle UX kwijt te raken. Dit past logisch in de bestaande Settings-structuur: +- `General` voor functionele voorkeuren +- `Interface` voor theme-keuze +- `Logs` voor recente acties + +## 2. Scope +Theme Selection v1 omvat: +- nieuw Settings-tabblad: `Interface` +- daarin alleen een pulldown/select: `Theme` +- bestaande snelle dark/light toggle blijft in de hoofdinterface bestaan +- beide keuzes worden opgeslagen in bestaande SQLite settings-opslag +- app leest beide waarden bij startup via backend en past die direct toe + +Niet in scope: +- vrije CSS-bestandskeuze +- padinvoer +- upload van themes +- custom theme editor +- theme packs van externe bron + +## 3. Theme-model +Aanbevolen model voor v1: +- werk met een whitelist van toegestane theme keys +- werk daarnaast met een aparte whitelist van toegestane color modes +- sla beide als strings op in settings + +Aanbevolen settings voor v1: +- `selected_theme: string | null` +- `selected_color_mode: string | null` + +Whitelist v1: +- `selected_theme` + - `default` +- `selected_color_mode` + - `dark` + - `light` + +Waarom dit veiliger en eenvoudiger is dan bestandsselectie: +- geen vrije filesystemtoegang nodig +- geen risico op ongeldige of kwaadaardige CSS-inhoud +- geen extra upload- of assetbeheer +- duidelijke validatie in backend mogelijk +- stabiel contract tussen backend setting en frontend rendering + +## 4. Settings-opslag +Nieuwe settings in bestaande settings-opslag: +- `selected_theme` +- `selected_color_mode` + +Semantiek: +- `selected_theme = null` betekent: fallback naar veilige default `default` +- `selected_color_mode = null` betekent: fallback naar veilige default `dark` +- onbekende opgeslagen waarden betekenen: negeren en fallback toepassen + +Aanbevolen effectieve defaults: +- theme -> `default` +- mode -> `dark` + +## 5. Settings UI +Tabs in Settings worden: +- `General` +- `Interface` +- `Logs` + +`Interface` bevat in v1 alleen: +- label: `Theme` +- een select/pulldown met toegestane themes + +Belangrijk: +- geen dark/light selector in `Settings > Interface` +- dark/light blijft een snelle hoofdinterface-actie + +Aanbevolen v1-UX: +- select toont huidige theme-keuze +- gebruiker kiest andere waarde +- opslaan gebeurt via bestaande settings-saveflow +- keuze wordt direct toegepast in de UI na succesvolle backend-save + +## 6. Frontend-impact +Frontend moet bij startup vroeg settings laden en daaruit beide waarden ophalen: +- `selected_theme` +- `selected_color_mode` + +Daarna bepaalt frontend het effectieve UI-theme. Aanbevolen intern model: +- `data-theme="default-dark"` +- `data-theme="default-light"` + +Aanbevolen volgorde: +1. `GET /api/settings` +2. bepaal effectief theme + mode +3. zet `document.documentElement.dataset.theme` +4. initialiseer de rest van de UI + +Relatie met bestaande light/dark toggle: +- toggle blijft bestaan in de hoofdinterface +- toggle wijzigt alleen `selected_color_mode` +- toggle schrijft dus naar backend, niet naar localStorage + +Reden: +- snelle dagelijkse UX blijft behouden +- `Settings > Interface` blijft schoon en beperkt tot theme-keuze +- theme en mode blijven conceptueel gescheiden + +## 7. Backend-impact +Bestaande settings-API wordt uitgebreid met: +- `selected_theme` +- `selected_color_mode` + +Benodigd: +- whitelistvalidatie op backend +- onbekende waarden blokkeren bij write +- bestaande settings repository/service/API uitbreiden + +Niet nodig: +- nieuwe dependency +- vrije filesystemtoegang +- nieuwe asset-uploadroute + +## 8. Regressierisico +Belangrijkste risico's: +- startup-volgorde: theme moet vroeg genoeg worden toegepast om flicker te beperken +- bestaande theme-toggle logica conflicteert nu nog met localStorage +- onbekende opgeslagen theme/mode-waarden moeten veilig terugvallen +- Settings-tabcomplexiteit mag niet onnodig toenemen + +Belangrijkste mitigaties: +- één centrale frontendfunctie die theme en mode uit backend toepast +- localStorage volledig verwijderen als leidende theme-bron +- backend whitelistvalidatie +- fallback naar `default-dark` + +## 9. Teststrategie +Backend golden tests: +- default `selected_theme` +- default `selected_color_mode` +- geldige theme save (`default`) +- geldige color mode save (`dark`, `light`) +- ongeldige theme key wordt geblokkeerd +- ongeldige color mode wordt geblokkeerd +- settings response bevat beide velden + +UI smoke/regressietests: +- `Settings` bevat tabs `General`, `Interface`, `Logs` +- `Interface` tab bevat alleen theme select +- hoofdinterface bevat nog steeds dark/light toggle +- app leest beide settings via backend +- fallback bij ontbrekende/ongeldige waarde breekt startup niet + +Handmatige validatie: +- theme wijzigen in `Settings > Interface` +- mode wisselen via toggle in de hoofdinterface +- app herladen en controleren dat beide keuzes behouden blijven +- controle dat light/dark correct doorwerken in modals, panelen en editor/viewers + +## 10. Aanbeveling +Aanbevolen v1-richting met laag regressierisico: +- voeg `Interface` tab toe +- voeg `selected_theme` en `selected_color_mode` toe aan bestaande settings-opslag +- werk alleen met veilige whitelists +- houd v1 beperkt tot: + - theme: `default` + - mode: `dark|light` +- laat startup en toggle beide backendpersistente settings gebruiken +- fallback altijd veilig naar `default-dark` + +Deze richting is: +- simpel +- veilig +- onderhoudbaar +- duidelijk uitbreidbaar naar extra themes, zonder de dagelijkse dark/light UX opnieuw te moeten ontwerpen diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index 5ee8970a0bc7f93708e4c82be615a55981623066..a3c9719aba91d950d9bae39ff412aab5612d1609 100644 GIT binary patch delta 1301 zcmZuw-%A`<6!ywk*BLiEvp?LO{WYu681*68Jd{K!eX7;kC@g)GZcJQs>zXBZw@Aq^ zsfi}36v=@!fu*5_N~jP?0tple1kw;f3W6Y8|A*3)*oP7*x!)PxB#?#s?YZ}SXU=!e zIrsjjzxR1xdcAs$`mOwXH}~p}*Jp}h`o~UEkC}*$Xz7M_u}o6H~UswdJ<-JXE&7#M(@TE&m(Lw}0{70qokM-~S*w6@B=&UGz(ExM_7$^esT zwb7<&|2N_!{pBxxFoi?+V_BGhlWG;a$d?S!PXR|{IplA>o&FE_#VN9ahnfrE0#2#* z62+RYsThe(E#amqaLoDOf6h5DL6GBs2>{f}uK|-(4o%1N%G}b6rBR7mkCr5>YE-8k zCJoymVbM@iyLko?X8~`iE@2NqqT+=e)doFmIw4A|;)UH!(_yc91x!}5le*hT>N7Y5 zT%omaOw3dxS|Y*Vk8mPltG+Z^oXq6LWR6r++bkRaFp3bWuJ1Kx2P=)fxzF7a%<`v} zBgd?=@*k03D%`!aZ4X9SgoVRNz$L(C0Cz*5hY2WC**+}hcZ7W=ZqlLXfwl#>0XNm! zRiJxk`zywtxdB(ff~z2!c5Z>)3g9Mi5t7*c0LbG``XWrgE&3$ZDi%2yUH>K4BvxrH z7EvRkh&F);ScM@0JX%zZ7>q}ZdpLJfA}VzidjK58dIhRv9%HW04Zh03PvXlTKLC*x zvepS~F);5`5Qlt$)3A6j^021p*F>x1_V+!UH*=$3ha2#K^yE=-y()es84{1_Q8Mga zS4a0G;4ulONtBt`?Tm`2L{6`M18%@m`qSwYH`x8ccBnViBGxIBimUIOPVLv1Km@GQ z?Nm%G(X-TaA3tDzc>JPxm}fxb2}Aq2$=nyNIhT-ivnI|gm*`@1LM+pA^B<8qky!k8 WyGH$On{}e=T0iWrZIixlS$Pe&20>r| delta 1122 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 HmP!3T%1+_% delta 1288 zcmZ8g-D@LN6u);q?o1|`Otx*(SX<1-cFpWcyH+CzQdbvzNTja4tOza+X)ll>GOFd(zEJ!0`Lb?gN zajagr)fo*EUxzt#1G5BG1Ydxw4$Q_h7`RLApc4n1Q8e&P@YHYS+PT<7d5s24(V=;D zO`I1l()c5_CwA0~b@7p3Eu-YzVMfk@GHSYK4MNHo`OVT;BFoxW zv+gCnsII!3Vw_>ttgqbd)`js+E4ZZQAleH7?|q!SJctleDJHdhh-|9=YyR@270}Y>Ku#y#o>7YUF-5QFP!z|g< zprD{O|8iZ@`A}Tn}m>PUX=bRt2`Y)_~ T%$_}F&ELfeuis?|+2MZyk|z SettingsResponse: @@ -31,6 +40,12 @@ class SettingsService: if request.preferred_startup_path_right is not None: self._set_directory_setting("preferred_startup_path_right", request.preferred_startup_path_right) + if request.selected_theme is not None: + self._repository.set_setting("selected_theme", self._validate_theme(request.selected_theme)) + + if request.selected_color_mode is not None: + self._repository.set_setting("selected_color_mode", self._validate_color_mode(request.selected_color_mode)) + return self.get_settings() def _set_directory_setting(self, key: str, value: str) -> None: @@ -53,3 +68,35 @@ class SettingsService: return None normalized = value.strip() return normalized or None + + @staticmethod + def _normalize_theme(value: str | None) -> str: + normalized = (value or "").strip() + return normalized if normalized in VALID_THEMES else "default" + + @staticmethod + def _normalize_color_mode(value: str | None) -> str: + normalized = (value or "").strip().lower() + return normalized if normalized in VALID_COLOR_MODES else "dark" + + @staticmethod + def _validate_theme(value: str) -> str: + normalized = value.strip() + if normalized not in VALID_THEMES: + raise AppError( + status_code=400, + code="invalid_request", + message="Theme must be one of: default", + ) + return normalized + + @staticmethod + def _validate_color_mode(value: str) -> str: + normalized = value.strip().lower() + if normalized not in VALID_COLOR_MODES: + raise AppError( + status_code=400, + code="invalid_request", + message="Color mode must be one of: dark, light", + ) + return normalized diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 5a841db9554b36a678af68772411355f9e7a6337..0828ee2382c37a136a848d107517fd919ddae3f0 100644 GIT binary patch delta 124 zcmZo@U~On%ogmHVFj2;t(P3l4lK*_H{Gtr}FZeg{m-8F)i*8mFVCI*RVr4cKEl$lz zO)g1IiBHbY$uEk}%}+^9Ni52qZphDg8K|q0f&UUv*F^qG{HHGn%*KpZG_h`< JD9@O$0092nCNTg2 delta 68 zcmZo@U~On%ogmF5g7Y~y8(jQHSOKj(Y52;I|hh! zNFoqJ5RV{8BFsOc(HN~i@C*0H#1DRK0m5=IQA~^>(TH)FVAOct({^o_L1t{zr_VX} z-19!q`=0x@Kla>>ntnGL^(ylEfExC9pXAcM4IUQ6dW!Z^x!!g$Jbgy|^L6Q-xkK$wBDLc$6uGZJR>^gtjJ zp(Z+6L|74J#e@}8W+u!`nT0S5Wmdwhl-UTgQDztH$kDIyyA?Zba@v!My1{^dR_mB| z@K{wjZZsXR30i@xW9!wB1C`3F>R0)BeA!fDR=BX(7aWPoiY6RHJKtjQEmK>SVv@wD z7?QM8!Hg4#lH!6c~z!MOIwz3r##Gbz7t7<0ho>KUWZJiMy1Mo^gVt!e z!|HT;v=o1&jej-UnJt*LTrjL9 z6VhQC2;bxu;~SREmignTeGEQW<-@zJhd55)1zjDB6OIa8YI7L$f;!_Lk2^}ADG)T| zonDo>LNjCNP&8egLO0q#Vs@h)1pNi5oqVAVfKGrGfQA>m%HUsqF*KCKiJ%mbc z;A;E!T)i`pm)kac%&T~`<5;qhz!NL0J9U)n=Lkl< z5Eb;MB!cFYL@KrmhE(h(U{rBh=UoTZ?k1DcIDXj~X}HRFPVB0JGXWx?QxN4PL^EIt~$^%4UPMTFu=(S%5AR*E9A-N9%i96;i(5mLQ$SDNee!F;Dk93)L& z3S@4c^_2P%V4A_{>ie&H6JkKh?^D=%P1`lTeI8q>Qc2=)B#1`H9tb)U8U@$`Fn_(4 zFVID{GRpewL5<)xr@Qi=rm{D^i*NeolrY~&YcFV=ZNh(=B{`0Q{o?XveUYeGC-0GO zogtOVno=yoL3tn`?;REcp?It>8VO}8g`0e9@h4?EysJ!Lk|eSx`CXZvsmq3--Es^f z9t6l{>UkKZySvXj&U^&AlQ+CB#?T9wQUdWm6&~97(I_tSm1@&BH2KakpC@nnmNDzH ziy_p$!t`ld7LJD`bQE@)jj32NM+U{?a)6w?pp=oYr83EUK_ygFxpH@<{jqp9M?(=w zBK;-uN`8t=G(|D8YhQdM7S8ONc;G$e8%PUosXoOx@%PnFtltWno(2d4gaP^i1_6cu zb^+`Kcn;tYzzYB`0vrdxcw5a1W)PpKajrD7`kAs~0+r`b0pYvHY)6SgUaD7qj)S}uM6&>E*&*HC_l>7^1 C>GFI4 delta 1384 zcmaKqU1%It6vyYz?CfWDHr>r;m)$j)w83_g?oMKo`cbWDX|0lOl1?OTY`a}HlbAH! zP3}%A2B`)?EB2)v;s?@}ph!z;i|%vYd=TG6Ld(ASppb_`N>@Qa^qiY)wkmW6e*EXo zz32ZwXXcODUtZT927`VHuODuIJAFF&jTVsK{R$tKrIQk8mc(U?am5N-azdJMaTh8@ zDAkHsEtX1phH*EpyT!T(l}9KqDz8vJR6e2nsQf|&Pz8i)Le(Tx5LGadCL;}{CKf}e zLP9m8Y8GmTwS$MJTt>7Sg_UHqA}jj?ehgO`B!gai)t?<@N7z?eGl+Izxl79J=Z`iqG#!_aGm6zN_zEPyYjtE2#aZ zdVGG~jjsv}QNOlL`zv2J8!Y}GM_-n=;MQs@&(}s1?0~h@U;8mGezrzjDsIS| zsf8PPEGoLLZ%*jZ{I}Xx-S6P*@WSS;5LpV#AC7dH{o&KnMg6-7KuY; zr^NG9cCVSqn6^_`2mFy6m8!A1yz^$x;ra76FXv}-6SiX)9DAZ({gQDI4rF{#OIz$y zkujHvvpo$L%DC9HP&`{Ky;U?PN`*3aDBWgQxMs%dZLXRXcBL}kW3qpZ=4NkF-|noc zi9E%Ulbb9p6t`pi+Ixq6N%nB-sn1yiUg<(Njz@My5RJ_i2<$?_U!BEXgA?OL%ThHi zRZbO6rKoA2DsiZ)N^MmRRhxD%MEyPV(nBjFtx9F76_t8yr9`PxsfVbuL+mJ0I*0Gg zoA1q=pYOdt*5NvH-)4Kpgh$7NFEU?te$G6!m@ijpOvPL!{KYu5SD#Ipfnwf=A8q|` zhpB-D$^pL~STqb{loy>RI0C;hUi1RNQG#Oz+Q+9XeP-hTlZiEToA?V5uHHlw+Bemk z=0 zWhJ;GrzB6l6+Wvy4BNGvjqDS_<?wfDB5 z(^0tVazfnI;tSnQ>GZ%abk&=-8@5|ZTdGdCV{eak?$XYJYj6kCr(xD5So?R{b^4_C zk?T9uve$d~Qb$D+;ryjR;fcj$LigXZ0aMYgee4ce;PqAq+^no2wyJgG?PiDeZtF2* z_}V@@Q~?j08n7fam;mm;j1q(5aIeV=?;NaQv5W|;6*gEZOkt^9zO(S*kqub&InX$W zJ}ssweL%F{cgXMzL@PD=Jq#`OfruE~#eZ z94n>MxCSyWNo4zobS*%Np_3(Y8=E+7DDVn$16rQ+(7>MQpn@03*uszoJ(di$*XLP4RCDigpr7U)Hj02ECxK~ zhk7_||7gq_W21^Jt8*z)6%U#wR{HKB`PhYg+y6&s=bsBBc&*3Tru5(%Emo~7*~F@- zE+^8mqJ;1gSE<;sQNnPi=WFZBc)IpIZ1moMf5X*qD^v|sXrq!ZOwH-^JlqRAjkWNR zZL3aqKWdypL&x^CPKU~!v(>WQpwq|7e!7=HIem2+l?{GSOrp!flB03${zCCV7XcBh&hpdCfb;qGO;zkn?iE#8~L>>RA%oZzF#7s_^FajG4s52JnL0kyyS?Pqb3=7eV_2(=ovPJJXxm_psx5@DCPFvxJ GsQW+T?z*P{ delta 691 zcmbPS|D=@fGcPX}0}xoe+>|M&xsmT5Gt(OG&3r7;?2}_zMJC6q2yE`<4q=+STV7!D zS>7#^YxzYc@8lBz(|4iteswTk&?}VLlnG=Q1A_vCK0~ng>nDhjbULbO^wnok5Xa$kUyES+x^Jpn@1_TEN2U#-Z zv4VK40l~q+L6c*&WEn#y7w{_xg|Y>QB0QbWpcyuKjh2P3PI7);afw2RhpVq^d~k?= zkfXb6ytiwlf~|s5d1_K_VqRi;YLRY9Mrv-V(q=d9YR1X`bj&7K>Av3lMbDZ|SHh|& zKffe8C9xzCqR3V$C9x=5DYi-yQ)Y9HsTm`q&g6M!GBUS#N{e#h3ld8*;?werij0B5 ztI1v@2nuD>$+yk+vK0fF#paXenRiZhwWyiA(wcwr0}KAiJ1zMq+gj>x?y)?}BxJOB?#gdztnOEckQYZ!@gh7HFHo5sJr8%i~Mah$S?86PJm>4Z*a9o$r nyeOghg#pC-P{qo?!qZW8nOX7%3s1XmlW(j42WAEqDX=L3ZbHqS diff --git a/webui/backend/tests/golden/test_api_settings_golden.py b/webui/backend/tests/golden/test_api_settings_golden.py index bec6dd7..b385d05 100644 --- a/webui/backend/tests/golden/test_api_settings_golden.py +++ b/webui/backend/tests/golden/test_api_settings_golden.py @@ -59,6 +59,8 @@ class SettingsApiGoldenTest(unittest.TestCase): "show_thumbnails": False, "preferred_startup_path_left": None, "preferred_startup_path_right": None, + "selected_theme": "default", + "selected_color_mode": "dark", }, ) @@ -75,6 +77,8 @@ class SettingsApiGoldenTest(unittest.TestCase): "show_thumbnails": False, "preferred_startup_path_left": "storage1/docs", "preferred_startup_path_right": None, + "selected_theme": "default", + "selected_color_mode": "dark", }, ) @@ -96,6 +100,8 @@ class SettingsApiGoldenTest(unittest.TestCase): "show_thumbnails": True, "preferred_startup_path_left": "storage1/docs", "preferred_startup_path_right": "storage1/docs", + "selected_theme": "default", + "selected_color_mode": "dark", }, ) self.assertEqual( @@ -104,6 +110,8 @@ class SettingsApiGoldenTest(unittest.TestCase): "show_thumbnails": True, "preferred_startup_path_left": "storage1/docs", "preferred_startup_path_right": "storage1/docs", + "selected_theme": "default", + "selected_color_mode": "dark", }, ) @@ -113,6 +121,8 @@ class SettingsApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["preferred_startup_path_left"], "storage1/docs") self.assertEqual(response.json()["preferred_startup_path_right"], None) + self.assertEqual(response.json()["selected_theme"], "default") + self.assertEqual(response.json()["selected_color_mode"], "dark") def test_settings_preferred_startup_path_right_persistence(self) -> None: response = self._request("POST", "/api/settings", {"preferred_startup_path_right": "storage1/docs"}) @@ -120,6 +130,8 @@ class SettingsApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["preferred_startup_path_left"], None) self.assertEqual(response.json()["preferred_startup_path_right"], "storage1/docs") + self.assertEqual(response.json()["selected_theme"], "default") + self.assertEqual(response.json()["selected_color_mode"], "dark") def test_settings_preferred_startup_path_empty_string_resets_only_left_to_null(self) -> None: self._request( @@ -135,6 +147,34 @@ class SettingsApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["preferred_startup_path_left"], None) self.assertEqual(response.json()["preferred_startup_path_right"], "storage1/docs") + self.assertEqual(response.json()["selected_theme"], "default") + self.assertEqual(response.json()["selected_color_mode"], "dark") + + def test_settings_selected_theme_persistence(self) -> None: + response = self._request("POST", "/api/settings", {"selected_theme": "default"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["selected_theme"], "default") + self.assertEqual(response.json()["selected_color_mode"], "dark") + + def test_settings_selected_color_mode_persistence(self) -> None: + response = self._request("POST", "/api/settings", {"selected_color_mode": "light"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["selected_theme"], "default") + self.assertEqual(response.json()["selected_color_mode"], "light") + + def test_settings_rejects_invalid_selected_theme(self) -> None: + response = self._request("POST", "/api/settings", {"selected_theme": "unknown"}) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + + def test_settings_rejects_invalid_selected_color_mode(self) -> None: + response = self._request("POST", "/api/settings", {"selected_color_mode": "sepia"}) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") def test_settings_preferred_startup_path_left_rejects_file_path(self) -> None: response = self._request("POST", "/api/settings", {"preferred_startup_path_left": "storage1/file.txt"}) diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index a4777d3..b437d51 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -65,14 +65,19 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="rename-input"', body) self.assertIn('id="rename-apply-btn"', body) self.assertIn('id="settings-general-tab"', body) + self.assertIn('id="settings-interface-tab"', body) self.assertIn('id="settings-logs-tab"', body) self.assertIn('id="settings-show-thumbnails"', body) self.assertIn("Show thumbnails", body) + self.assertIn('id="settings-selected-theme"', body) + self.assertIn("Theme", body) + self.assertNotIn('id="settings-selected-color-mode"', body) self.assertIn('id="settings-startup-path-left"', body) self.assertIn('id="settings-startup-path-right"', body) self.assertIn("Preferred startup path (left)", body) self.assertIn("Preferred startup path (right)", body) self.assertIn('id="settings-general-save-btn"', body) + self.assertIn('id="settings-interface-save-btn"', body) self.assertIn('id="settings-logs-list"', body) self.assertIn('id="viewer-content"', body) self.assertIn('id="editor-modal"', body) @@ -119,7 +124,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertTrue((static_root / "style.css").exists()) app_js = (static_root / "app.js").read_text(encoding="utf-8") self.assertIn('currentPath: "/Volumes"', app_js) - self.assertIn('const THEME_STORAGE_KEY = "webmanager-theme"', app_js) + self.assertIn('selectedTheme: "default"', app_js) + self.assertIn('selectedColorMode: "dark"', app_js) + self.assertIn('function effectiveThemeKey(theme, colorMode)', app_js) self.assertIn("document.documentElement.dataset.theme", app_js) self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js) self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) @@ -127,12 +134,19 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('await loadSettings();', app_js) self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js) self.assertIn('settings.generalSaveButton.onclick = handlePreferredStartupPathSave;', app_js) + self.assertIn('settings.interfaceSaveButton.onclick = handleInterfaceSave;', app_js) self.assertIn('preferredStartupPathLeft', app_js) self.assertIn('preferredStartupPathRight', app_js) + self.assertIn('selected_theme', app_js) + self.assertIn('selected_color_mode', app_js) + self.assertNotIn("localStorage", app_js) + self.assertNotIn("THEME_STORAGE_KEY", app_js) self.assertIn('preferred_startup_path_left', app_js) self.assertIn('preferred_startup_path_right', app_js) self.assertIn('paneState("left").currentPath = settingsState.preferredStartupPathLeft || "/Volumes";', app_js) self.assertIn('paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes";', app_js) + self.assertIn('applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);', app_js) + self.assertIn('settings.interfaceTab.onclick = () => setSettingsTab("interface");', app_js) self.assertIn('"/api/settings"', app_js) self.assertIn('`/api/files/thumbnail?', app_js) self.assertIn("function iconTypeForEntry(entry)", app_js) @@ -180,8 +194,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn("function rootKeyFromPath(path)", app_js) self.assertIn("function isNestedPath(sourcePath, destinationPath)", app_js) style_css = (static_root / "style.css").read_text(encoding="utf-8") - self.assertIn(':root[data-theme="dark"]', style_css) - self.assertIn(':root[data-theme="light"]', style_css) + self.assertIn(':root[data-theme="default-dark"]', style_css) + self.assertIn(':root[data-theme="default-light"]', style_css) self.assertIn('#theme-toggle', style_css) self.assertIn('.settings-card', style_css) self.assertIn('.settings-tabs', style_css) diff --git a/webui/html/app.js b/webui/html/app.js index 4156e31..6d53bcd 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -57,47 +57,51 @@ let settingsState = { showThumbnails: false, preferredStartupPathLeft: null, preferredStartupPathRight: null, + selectedTheme: "default", + selectedColorMode: "dark", }; let searchState = { pane: "left", path: "/Volumes", query: "", }; -const THEME_STORAGE_KEY = "webmanager-theme"; - -function preferredTheme() { - const stored = window.localStorage.getItem(THEME_STORAGE_KEY); - if (stored === "light" || stored === "dark") { - return stored; - } - if (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches) { - return "light"; - } - return "dark"; +function effectiveThemeKey(theme, colorMode) { + const family = theme === "default" ? "default" : "default"; + const mode = colorMode === "light" ? "light" : "dark"; + return `${family}-${mode}`; } -function applyTheme(theme) { - const nextTheme = theme === "light" ? "light" : "dark"; +function currentColorMode() { + return (document.documentElement.dataset.theme || "").endsWith("-light") ? "light" : "dark"; +} + +function applyTheme(theme, colorMode) { + const nextTheme = effectiveThemeKey(theme, colorMode); + const mode = colorMode === "light" ? "light" : "dark"; document.documentElement.dataset.theme = nextTheme; const icon = document.getElementById("theme-toggle-icon"); const button = document.getElementById("theme-toggle"); if (icon) { - icon.textContent = nextTheme === "dark" ? "☾" : "☀"; + icon.textContent = mode === "dark" ? "☾" : "☀"; } if (button) { - button.setAttribute("aria-label", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`); - button.setAttribute("title", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`); + button.setAttribute("aria-label", `Switch to ${mode === "dark" ? "light" : "dark"} mode`); + button.setAttribute("title", `Switch to ${mode === "dark" ? "light" : "dark"} mode`); } if (monacoState.module) { - monacoState.module.editor.setTheme(nextTheme === "light" ? "vs" : "vs-dark"); + monacoState.module.editor.setTheme(mode === "light" ? "vs" : "vs-dark"); } } -function toggleTheme() { - const current = document.documentElement.dataset.theme === "light" ? "light" : "dark"; +async function toggleTheme() { + const current = settingsState.selectedColorMode === "light" ? "light" : "dark"; const next = current === "dark" ? "light" : "dark"; - applyTheme(next); - window.localStorage.setItem(THEME_STORAGE_KEY, next); + try { + const data = await saveSettings({ selected_color_mode: next }); + applyTheme(data.selected_theme, data.selected_color_mode); + } catch (err) { + setError("actions-error", `Theme: ${err.message}`); + } } function paneState(pane) { @@ -223,13 +227,18 @@ function settingsElements() { overlay: document.getElementById("settings-modal"), closeButton: document.getElementById("settings-close-btn"), generalTab: document.getElementById("settings-general-tab"), + interfaceTab: document.getElementById("settings-interface-tab"), logsTab: document.getElementById("settings-logs-tab"), generalPanel: document.getElementById("settings-general-panel"), + interfacePanel: document.getElementById("settings-interface-panel"), showThumbnailsInput: document.getElementById("settings-show-thumbnails"), startupPathLeftInput: document.getElementById("settings-startup-path-left"), startupPathRightInput: document.getElementById("settings-startup-path-right"), generalError: document.getElementById("settings-general-error"), generalSaveButton: document.getElementById("settings-general-save-btn"), + selectedThemeInput: document.getElementById("settings-selected-theme"), + interfaceError: document.getElementById("settings-interface-error"), + interfaceSaveButton: document.getElementById("settings-interface-save-btn"), logsPanel: document.getElementById("settings-logs-panel"), logsList: document.getElementById("settings-logs-list"), logsError: document.getElementById("settings-logs-error"), @@ -507,6 +516,8 @@ async function loadSettings() { settingsState.showThumbnails = !!data.show_thumbnails; settingsState.preferredStartupPathLeft = data.preferred_startup_path_left || null; settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null; + settingsState.selectedTheme = data.selected_theme || "default"; + settingsState.selectedColorMode = data.selected_color_mode || "dark"; const elements = settingsElements(); if (elements.showThumbnailsInput) { elements.showThumbnailsInput.checked = settingsState.showThumbnails; @@ -517,6 +528,9 @@ async function loadSettings() { if (elements.startupPathRightInput) { elements.startupPathRightInput.value = settingsState.preferredStartupPathRight || ""; } + if (elements.selectedThemeInput) { + elements.selectedThemeInput.value = settingsState.selectedTheme; + } } async function saveSettings(update) { @@ -524,6 +538,8 @@ async function saveSettings(update) { settingsState.showThumbnails = !!data.show_thumbnails; settingsState.preferredStartupPathLeft = data.preferred_startup_path_left || null; settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null; + settingsState.selectedTheme = data.selected_theme || "default"; + settingsState.selectedColorMode = data.selected_color_mode || "dark"; const elements = settingsElements(); if (elements.showThumbnailsInput) { elements.showThumbnailsInput.checked = settingsState.showThumbnails; @@ -534,6 +550,10 @@ async function saveSettings(update) { if (elements.startupPathRightInput) { elements.startupPathRightInput.value = settingsState.preferredStartupPathRight || ""; } + if (elements.selectedThemeInput) { + elements.selectedThemeInput.value = settingsState.selectedTheme; + } + applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode); renderPaneItems("left"); renderPaneItems("right"); return data; @@ -1323,7 +1343,7 @@ async function loadMonacoModule() { monacoState.loadPromise = import("https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/+esm") .then((module) => { monacoState.module = module; - module.editor.setTheme(document.documentElement.dataset.theme === "light" ? "vs" : "vs-dark"); + module.editor.setTheme(currentColorMode() === "light" ? "vs" : "vs-dark"); return module; }); } @@ -1359,7 +1379,7 @@ async function ensureMonacoEditor(path, content) { const model = monaco.editor.createModel(content, monacoLanguageForName(baseName(path))); const instance = monaco.editor.create(editor.host, { model, - theme: document.documentElement.dataset.theme === "light" ? "vs" : "vs-dark", + theme: currentColorMode() === "light" ? "vs" : "vs-dark", language: monacoLanguageForName(baseName(path)), automaticLayout: false, lineNumbers: "on", @@ -1868,14 +1888,19 @@ async function submitSearch() { function setSettingsTab(tab) { const elements = settingsElements(); - settingsState.activeTab = tab === "logs" ? "logs" : "general"; + settingsState.activeTab = tab === "logs" ? "logs" : (tab === "interface" ? "interface" : "general"); const isGeneral = settingsState.activeTab === "general"; + const isInterface = settingsState.activeTab === "interface"; + const isLogs = settingsState.activeTab === "logs"; elements.generalTab.classList.toggle("is-active", isGeneral); elements.generalTab.setAttribute("aria-selected", isGeneral ? "true" : "false"); - elements.logsTab.classList.toggle("is-active", !isGeneral); - elements.logsTab.setAttribute("aria-selected", !isGeneral ? "true" : "false"); + elements.interfaceTab.classList.toggle("is-active", isInterface); + elements.interfaceTab.setAttribute("aria-selected", isInterface ? "true" : "false"); + elements.logsTab.classList.toggle("is-active", isLogs); + elements.logsTab.setAttribute("aria-selected", isLogs ? "true" : "false"); elements.generalPanel.classList.toggle("hidden", !isGeneral); - elements.logsPanel.classList.toggle("hidden", isGeneral); + elements.interfacePanel.classList.toggle("hidden", !isInterface); + elements.logsPanel.classList.toggle("hidden", !isLogs); } function formatHistoryLine(item) { @@ -1965,6 +1990,18 @@ async function handlePreferredStartupPathSave() { } } +async function handleInterfaceSave() { + const settings = settingsElements(); + const themeValue = settings.selectedThemeInput ? settings.selectedThemeInput.value : "default"; + settings.interfaceError.textContent = ""; + try { + await saveSettings({ selected_theme: themeValue }); + setStatus("Theme saved"); + } catch (err) { + settings.interfaceError.textContent = err.message; + } +} + function closeSettings() { settingsElements().overlay.classList.add("hidden"); } @@ -1977,7 +2014,11 @@ async function openSettings(tab = "general") { if (settingsState.activeTab === "logs") { await loadHistoryForSettings(); } - (settingsState.activeTab === "logs" ? elements.logsTab : elements.generalTab).focus(); + (settingsState.activeTab === "logs" + ? elements.logsTab + : settingsState.activeTab === "interface" + ? elements.interfaceTab + : elements.generalTab).focus(); } function editorIsDirty() { @@ -2469,12 +2510,14 @@ function setupEvents() { const settings = settingsElements(); settings.closeButton.onclick = closeSettings; settings.generalTab.onclick = () => setSettingsTab("general"); + settings.interfaceTab.onclick = () => setSettingsTab("interface"); settings.logsTab.onclick = async () => { setSettingsTab("logs"); await loadHistoryForSettings(); }; settings.showThumbnailsInput.onchange = handleShowThumbnailsChange; settings.generalSaveButton.onclick = handlePreferredStartupPathSave; + settings.interfaceSaveButton.onclick = handleInterfaceSave; settings.overlay.onclick = (event) => { if (event.target === settings.overlay) { closeSettings(); @@ -2582,10 +2625,11 @@ function setupEvents() { async function init() { setError("actions-error", ""); - applyTheme(preferredTheme()); + applyTheme("default", "dark"); setActivePane("left"); setupEvents(); await loadSettings(); + applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode); paneState("left").currentPath = settingsState.preferredStartupPathLeft || "/Volumes"; paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes"; await loadBrowsePane("left"); diff --git a/webui/html/index.html b/webui/html/index.html index 5df2549..d0c3a5f 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -86,6 +86,7 @@

Settings

+
@@ -108,6 +109,19 @@
+