diff --git a/project_docs/UI_THEME_V1_DESIGN.md b/project_docs/UI_THEME_V1_DESIGN.md new file mode 100644 index 0000000..f9b9c9a --- /dev/null +++ b/project_docs/UI_THEME_V1_DESIGN.md @@ -0,0 +1,256 @@ +# UI Theme v1 Design + +## 1. Doel + +Doel van deze stap is de huidige webui visueel moderner, rustiger en prettiger te maken zonder de dual-pane file manager workflow te verzwakken. + +De huidige UI heeft al een functionele structuur: +- compacte topbar +- twee dominante browsepanelen +- compacte functiebalk +- modals voor view/edit/rename-move + +De visuele refresh moet daarom niet proberen de informatiearchitectuur opnieuw uit te vinden, maar moet vooral: +- duidelijkere visuele hiërarchie geven +- betere contrastverhoudingen bieden +- light en dark mode ondersteunen +- selectie/current-row/actieve paneelstatus leesbaarder maken +- de interface consistenter laten aanvoelen + +De dual-pane bestandslijst blijft het hoofdonderdeel van het scherm. Decoratie is ondergeschikt aan bruikbaarheid. + +## 2. Themastructuur + +Aanbevolen richting: CSS custom properties met één gedeeld tokenmodel, aangestuurd via een `data-theme` attribuut op `html` of `body`. + +### Basistokens + +Aanbevolen tokenset: +- `--color-page-bg` +- `--color-surface` +- `--color-surface-elevated` +- `--color-border` +- `--color-border-strong` +- `--color-text-primary` +- `--color-text-muted` +- `--color-accent` +- `--color-accent-contrast` +- `--color-selection-bg` +- `--color-selection-border` +- `--color-current-row-bg` +- `--color-current-row-border` +- `--color-active-pane-border` +- `--color-button-bg` +- `--color-button-hover` +- `--color-button-secondary-bg` +- `--color-danger` +- `--color-danger-bg` +- `--color-overlay-bg` +- `--shadow-elevated` +- `--radius-sm` +- `--radius-md` + +### Dark mode voorstel + +Dark mode moet geen pure zwarte UI zijn, maar een rustige donkergrijze werkruimte. + +Voorstel: +- page background: donker koel grijs-blauw +- panel surface: iets lichter dan page background +- elevated surface: nog een stap lichter voor modals/topbar/footer +- borders: lage contrastgrijze lijnen +- text primary: bijna wit maar niet hard wit +- text muted: gedempt blauwgrijs +- selected row: duidelijke, maar niet schreeuwerige accentvulling +- current row: subtiele focuslaag bovenop de lijst +- active pane border: heldere accentkleur +- primary buttons: rustige accentkleur +- secondary buttons: neutrale dark surface +- danger state: warm rood met goede contrasttekst +- overlay/modal background: transparant donker vlak + +### Light mode voorstel + +Light mode moet frisser en moderner zijn dan de huidige variant, zonder fel of vlak te worden. + +Voorstel: +- page background: zacht koel lichtgrijs +- panel surface: wit of bijna wit +- elevated surface: iets warmere/luxere lichte toon voor topbar/footer/modals +- borders: verfijnde grijsblauwe lijn +- text primary: diep donkerblauwgrijs +- text muted: medium koel grijs +- selected row: zachte accenttint +- current row: subtiele focusrand en lichte achtergrond +- active pane border: sterke accentkleur +- primary buttons: accentkleur met heldere tekst +- secondary buttons: rustige lichte surface +- danger state: donker rood met zachte foutachtergrond +- overlay/modal background: transparant donkergrijs + +## 3. UI-onderdelen die moeten meedoen + +### Topbar +- achtergrond uit `surface-elevated` +- statusregel leesbaarder maken +- theme toggle rechts in dezelfde balk +- titel en status optisch beter uitlijnen + +### Dual-pane panels +- panel surface en border via tokens +- active pane alleen via rand/focus, niet via zware achtergrondwisseling +- subtiele elevation of contrastlaag mogelijk, maar beperkt + +### Bestandslijsten +- lijst blijft visueel dominant +- grid header moet in beide thema’s voldoende contrast houden +- directory-links mogen accent gebruiken, maar niet te fel + +### Current row / selected row +- current row en selected row moeten visueel onderscheidbaar blijven +- combinatie current + selected moet in beide thema’s bruikbaar blijven +- contrast moet ook werken bij lange sessies en veel selectie + +### Functiebalk onderaan +- dezelfde visuele taal als topbar/modals +- compacte maar duidelijke knopstates +- disabled buttons goed zichtbaar als niet-beschikbaar, zonder onleesbaar te worden + +### Modals +- view/edit/rename-move/wildcard/batch move moeten dezelfde elevated surface gebruiken +- overlay donker genoeg om focus te geven +- popup-card iets zachtere rounding en subtiele shadow + +### Meldingen / feedback +- statusregel, fouten in panelen en actiefeedback moeten in beide thema’s leesbaar zijn +- error-kleur moet duidelijk zijn zonder hard neon-effect + +### Breadcrumbs +- breadcrumbs en klikbare delen moeten goed zichtbaar blijven in beide thema’s +- hover/focus states moeten subtiel maar duidelijk zijn + +## 4. Light/dark mode gedrag + +### Toggle gedrag +- kleine theme toggle knop met zonnetje/maantje +- positie: in de bovenste balk, rechts van de status/actie tekst +- dus visueel: `titel | statusbericht | theme toggle` + +### Interactie +- knop toggelt tussen light en dark +- icoon reflecteert de actie of huidige mode, maar moet consequent gekozen worden + +Aanbevolen keuze: +- toon huidige mode als icoon +- dark mode: maantje zichtbaar +- light mode: zonnetje zichtbaar + +### Opslag +- keuze opslaan in `localStorage` +- sleutel bijvoorbeeld: `webmanager-theme` + +### Default +Aanbevolen default: +- als `localStorage` nog leeg is: volg `prefers-color-scheme` +- fallback daarna naar `dark` + +Motivatie: +- voelt moderner en meer app-achtig +- sluit goed aan op een dual-pane file manager werkruimte +- blijft respectvol naar systeeminstellingen + +## 5. Visuele principes + +### Compacter en rustiger +- minder visuele ruis in de topbar en functiebalk +- consistenter gebruik van spacing +- minder harde contrastwisselingen tussen onderdelen + +### Duidelijke hiërarchie +- bestandslijsten zijn primair +- topbar en footer zijn ondersteunend +- modals zijn duidelijk elevated maar niet zwaar gedecoreerd + +### Geen zware effecten +- geen sterke gradients +- geen glows of drukke schaduwen +- alleen subtiele shadows waar elevation functioneel is + +### Functionele rounding +- lichte rounding op panelen, buttons en modals +- niet overdreven rond; doel is rust en moderniteit, niet speelsheid + +### Lijst dominant +- meeste visuele aandacht blijft bij de twee paneellijsten +- kleuren en effecten moeten de lijst leesbaarder maken, niet concurreren met de inhoud + +## 6. Regressiebehoud + +Stylingwijzigingen mogen niet breken: +- selectiegedrag +- checkbox-hit areas +- current row zichtbaarheid +- keyboard navigation focusgevoel +- active pane herkenning +- popup interactie +- dual-pane layout + +Concreet: +- geen layoutwijziging waardoor paneelhoogte of interne scroll verslechtert +- geen topbar-uitbreiding die verticale ruimte van de lijst substantieel opslokt +- geen functiebalk-styling die de onderbalk hoger of drukker maakt dan nodig + +## 7. Impactanalyse + +Waarschijnlijk te wijzigen frontendbestanden: +- `webui/html/style.css` +- `webui/html/index.html` +- `webui/html/app.js` + +Waarom: +- `style.css`: nieuw tokenmodel en themaspecifieke styles +- `index.html`: plaatsing van de theme toggle in de topbar +- `app.js`: thema initialiseren, togglen en opslaan in `localStorage` + +Geen backendimpact verwacht. + +### Regressierisico + +Laag tot middel: +- laag voor backend en API, want geen backendwijzigingen +- middel voor frontend omdat de topbar en globale CSS geraakt worden +- grootste risico zit in contrast, selected/current row zichtbaarheid en behoud van compacte verticale ruimte + +## 8. Teststrategie + +### UI smoke tests aanpassen + +Minimaal toevoegen/controleren: +- theme toggle knop aanwezig in topbar +- status-element blijft aanwezig +- topbar bevat zowel status als toggle +- relevante modalcontainers blijven aanwezig +- statische assets blijven werken + +### Handmatige validatie + +Nodig voor: +- light mode leesbaarheid van bestandslijsten +- dark mode leesbaarheid van bestandslijsten +- selected row en current row in beide thema’s +- active pane border in beide thema’s +- breadcrumbs hover/focus states +- modals in beide thema’s +- localStorage-persistentie na refresh +- default gedrag bij lege localStorage en system preference + +## Aanbevolen implementatierichting + +Aanbevolen v1-richting: +- implementeer een klein, stabiel tokenmodel in `style.css` +- gebruik `data-theme="light|dark"` op `document.documentElement` +- voeg een compacte theme toggle toe in de topbar rechts van `#status` +- laat `app.js` de initiële theme bepalen via `localStorage` -> `prefers-color-scheme` -> fallback +- houd layout en spacing grotendeels gelijk, en focus de refresh op kleur, contrast, borders, surfaces en modals + +Dit geeft de hoogste UX-winst met het laagste regressierisico. diff --git a/project_docs/styles.css b/project_docs/styles.css new file mode 100644 index 0000000..2e30313 --- /dev/null +++ b/project_docs/styles.css @@ -0,0 +1,748 @@ +* { + box-sizing: border-box; +} + +:root { + --bg-page: #111827; + --text-primary: #e5e7eb; + --text-muted: #94a3b8; + --topbar-bg: #020617; + --topbar-text: #e2e8f0; + --surface: #1f2937; + --surface-elevated: #0f172a; + --surface-subtle: #1e293b; + --border: #334155; + --border-soft: #475569; + --divider: #334155; + --button-primary-bg: #2563eb; + --button-primary-border: #2563eb; + --button-primary-text: #ffffff; + --button-secondary-bg: #334155; + --button-secondary-border: #475569; + --button-secondary-text: #e2e8f0; + --button-secondary-hover-bg: #3b4a61; + --badge-bg: #0b3a6e; + --badge-border: #1d4f85; + --badge-text: #dbeafe; + --list-selected-bg: #1e3a8a; + --season-header-bg: #1e293b; + --season-header-border: #334155; + --danger-text: #f87171; + --overlay-bg: rgba(2, 6, 23, 0.7); + --shadow-lg: 0 8px 22px rgba(2, 6, 23, 0.45); + --series-image-bg: #0b1220; +} + +[data-theme="light"] { + --bg-page: #f2f4f8; + --text-primary: #1a1f2b; + --text-muted: #64748b; + --topbar-bg: #0f172a; + --topbar-text: #e2e8f0; + --surface: #ffffff; + --surface-elevated: #ffffff; + --surface-subtle: #f8fafc; + --border: #d7dee9; + --border-soft: #c3cedf; + --divider: #e4eaf2; + --button-primary-bg: #0f172a; + --button-primary-border: #0f172a; + --button-primary-text: #ffffff; + --button-secondary-bg: #e2e8f0; + --button-secondary-border: #c3cedf; + --button-secondary-text: #1a1f2b; + --button-secondary-hover-bg: #eef2f7; + --badge-bg: #dbeafe; + --badge-border: #bfdbfe; + --badge-text: #0f172a; + --list-selected-bg: #e0f2fe; + --season-header-bg: #eef2ff; + --season-header-border: #dbe4fb; + --danger-text: #b91c1c; + --overlay-bg: rgba(2, 6, 23, 0.55); + --shadow-lg: 0 8px 22px rgba(15, 23, 42, 0.12); + --series-image-bg: #f8fafc; +} + +body { + margin: 0; + padding: 0; + font-family: "Segoe UI", Tahoma, sans-serif; + background: var(--bg-page); + color: var(--text-primary); + height: 100vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--topbar-bg); + color: var(--topbar-text); +} + +.topbar h1 { + margin: 0; + font-size: 20px; +} + +#sessionMeta { + font-size: 12px; +} + +.topbar-right { + display: flex; + align-items: center; + gap: 10px; +} + +.theme-toggle-btn { + border: 1px solid var(--button-secondary-border); + background: var(--button-secondary-bg); + color: var(--button-secondary-text); + border-radius: 999px; + width: 34px; + height: 34px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.grid { + display: grid; + grid-template-columns: repeat(4, minmax(280px, 1fr)); + gap: 12px; + padding: 12px; + align-items: start; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + min-height: 420px; + display: flex; + flex-direction: column; +} + +#panelSearch, +#panelEpisodes, +#panelSelectedEpisodes, +#panelSelectedFiles { + height: 100%; + min-height: 0; + max-height: 100%; + align-self: stretch; + overflow: hidden; +} + +#panelSearch .panel-body { + flex: 1; + min-height: 0; +} + +.panel h2 { + margin: 0 0 10px; + font-size: 16px; +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; + min-height: 38px; +} + +.panel-head h2 { + margin: 0; +} + +.panel-head-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.panel-head-actions-empty { + min-width: 172px; +} + +.panel h3 { + margin: 10px 0 6px; + font-size: 14px; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.search-combobox-row input[type="text"] { + flex: 1; + min-width: 0; +} + +#searchDropdownBtn { + min-width: 34px; + padding-left: 8px; + padding-right: 8px; +} + +.search-combobox { + position: relative; +} + +.combobox-dropdown { + position: absolute; + left: 0; + right: 0; + top: calc(100% - 6px); + z-index: 20; + background: var(--surface-elevated); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: var(--shadow-lg); + padding: 6px; +} + +#rememberedDropdownList { + max-height: 220px; +} + +#rememberedDropdownList li { + align-items: center; +} + +#rememberedDropdownList .remembered-item-title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +#rememberedDropdownList .remembered-remove-btn { + border: none; + background: transparent; + color: var(--text-muted); + width: 22px; + height: 22px; + line-height: 1; + border-radius: 4px; + padding: 0; + font-size: 15px; + cursor: pointer; +} + +#rememberedDropdownList .remembered-remove-btn:hover { + background: var(--button-secondary-hover-bg); + color: var(--text-primary); +} + +.stack { + display: flex; + flex-direction: column; +} + +.panel-body { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +input[type="text"], +select { + border: 1px solid var(--border-soft); + border-radius: 6px; + padding: 6px 8px; + min-width: 160px; + background: var(--surface-subtle); + color: var(--text-primary); +} + +button { + border: 1px solid var(--button-primary-border); + background: var(--button-primary-bg); + color: var(--button-primary-text); + border-radius: 6px; + padding: 6px 10px; + cursor: pointer; +} + +button.secondary { + background: var(--button-secondary-bg); + color: var(--button-secondary-text); + border-color: var(--button-secondary-border); +} + +.list { + list-style: none; + margin: 0; + padding: 0; + max-height: 260px; + overflow: auto; + border: 1px solid var(--divider); + border-radius: 6px; + background: var(--surface-elevated); +} + +.linked-list-wrap { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.linked-list { + flex: 1; + height: 100%; + max-height: none; + overflow-y: auto; +} + +#panelEpisodes .panel-body, +#panelSelectedEpisodes .panel-body, +#panelSelectedFiles .panel-body { + flex: 1; + min-height: 0; + overflow: hidden; +} + +#panelEpisodes .linked-list-wrap, +#panelSelectedEpisodes .linked-list-wrap, +#panelSelectedFiles .linked-list-wrap { + flex: 1; + min-height: 0; +} + +#panelEpisodes .linked-list-wrap .list, +#panelSelectedEpisodes .linked-list-wrap .list, +#panelSelectedFiles .linked-list-wrap .list { + max-height: none; + height: 100%; +} + +#episodesList .episode-main { + display: flex; + flex-direction: column; + min-width: 0; +} + +#episodesList li.episode-row { + cursor: pointer; +} + +#episodesList .episode-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#episodesList .episode-date { + margin-top: 2px; + font-size: 12px; + color: var(--text-muted); +} + +#episodesList .episode-date-future { + color: var(--button-primary-bg); +} + +#episodesList li.episode-row.episode-anchor { + box-shadow: inset 0 0 0 1px var(--button-primary-border); +} + +.badge { + display: inline-block; + font-size: 11px; + font-weight: 700; + color: var(--badge-text); + background: var(--badge-bg); + border: 1px solid var(--badge-border); + border-radius: 999px; + padding: 1px 6px; + margin-right: 6px; +} + +.list li.selected { + background: var(--list-selected-bg); +} + +.list li.season-header { + background: var(--season-header-bg); + border-bottom: 1px solid var(--season-header-border); + color: var(--text-primary); + font-weight: 700; + justify-content: flex-start; + padding: 8px; +} + +.panel-footer { + position: sticky; + bottom: 0; + background: var(--surface); + border-top: 1px solid var(--divider); + padding-top: 8px; + margin-top: 10px; + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + min-height: 40px; +} + +.panel-search-footer { + justify-content: flex-end; +} + +.mismatch { + color: var(--danger-text); + font-weight: 700; +} + +.modal { + position: fixed; + inset: 0; + background: var(--overlay-bg); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal.hidden { + display: none; +} + +.modal-card { + width: min(1400px, 90vw); + height: 80vh; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + display: flex; + flex-direction: column; + min-height: 0; +} + +.tvdb-modal-card { + width: min(1500px, 92vw); + height: 86vh; +} + +.tvdb-modal-head { + margin-bottom: 6px; +} + +.tvdb-fallback { + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px; + margin-bottom: 8px; + background: var(--surface-subtle); +} + +.tvdb-fallback h4 { + margin: 0 0 6px; +} + +.tvdb-fallback p { + margin: 0; + color: var(--text-muted); +} + +.tvdb-modal-frame { + width: 100%; + flex: 1; + min-height: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.modal-root-row { + margin-bottom: 8px; +} + +.modal-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 4px; + flex: 1; + min-height: 0; +} + +.modal-pane { + display: flex; + flex-direction: column; + min-height: 0; +} + +.modal-pane .list { + flex: 1; + min-height: 0; + max-height: none; +} + +#modalFoldersList li, +#modalFilesList li { + cursor: pointer; +} + +#modalFilesList li.modal-anchor { + box-shadow: inset 0 0 0 1px var(--button-primary-border); +} + +.modal-files-tools { + margin-bottom: 8px; +} + +.modal-files-tools input[type="text"] { + flex: 1; + min-width: 180px; +} + +.modal-actions { + margin-top: 10px; + margin-bottom: 0; + justify-content: flex-end; + border-top: 1px solid var(--divider); + padding-top: 10px; +} + +.settings-card { + width: min(520px, 94vw); +} + +.settings-section h4 { + margin: 0 0 8px; + font-size: 14px; + color: var(--text-primary); +} + +.settings-field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 4px; +} + +.settings-field label { + font-size: 12px; + color: var(--text-muted); +} + +.settings-field select { + min-width: 0; + width: 100%; +} + +.settings-field input[type="number"] { + min-width: 0; + width: 100%; +} + +.settings-danger-row { + margin-top: 6px; +} + +.settings-check { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-primary); +} + +.settings-actions { + justify-content: flex-end; + margin-top: 12px; +} + +#panelSelectedEpisodes .panel-footer button:first-child, +#panelSelectedEpisodes .panel-footer button:last-child, +#panelSelectedFiles .panel-footer button:first-child, +#panelSelectedFiles .panel-footer button:last-child { + border-color: var(--button-primary-border); + background: var(--button-primary-bg); + color: var(--button-primary-text); +} + +.list li { + display: flex; + justify-content: space-between; + gap: 8px; + border-bottom: 1px solid var(--divider); + padding: 6px 8px; + font-size: 13px; +} + +.list li:last-child { + border-bottom: none; +} + +#selectedEpisodesList li, +#selectedFilesList li { + height: 38px; + min-height: 38px; + align-items: center; +} + +#selectedEpisodesList li > span, +#selectedFilesList li > span { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#selectedEpisodesList li > div, +#selectedFilesList li > div { + flex-shrink: 0; +} + +.muted { + color: var(--text-muted); + font-size: 12px; + margin-bottom: 8px; +} + +.hidden { + display: none !important; +} + +#panelSearch #searchResults { + margin-bottom: 10px; + height: 220px; + max-height: 220px; + overflow-y: auto; +} + +#panelSearch #searchResults li { + min-height: 38px; + height: 38px; + align-items: center; +} + +#panelSearch #searchResults li > span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.series-details { + border-top: 1px solid var(--divider); + padding-top: 10px; +} + +.series-media { + margin-bottom: 8px; + width: 100%; + padding: 0 4px; +} + +.series-media img { + width: 100%; + max-width: none; + height: 92px; + object-fit: contain; + object-position: center center; + display: block; + margin: 0; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--series-image-bg); +} + +.series-meta { + font-size: 12px; + color: var(--text-primary); + display: grid; + gap: 4px; + margin-bottom: 8px; +} + +.series-meta span:first-child { + color: var(--text-muted); +} + +.series-overview { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.35; + color: var(--text-primary); +} + +.series-link { + font-size: 12px; + color: var(--text-muted); + text-decoration: none; +} + +.series-link:hover { + text-decoration: underline; +} + +#outputBox { + margin: 0; + background: var(--surface-subtle); + color: var(--text-primary); + border-radius: 6px; + padding: 10px; + max-height: 320px; + overflow: auto; + font-size: 12px; + border: 1px solid var(--border); +} + +.debug-page { + margin: 12px; +} + +@media (max-width: 1600px) { + .grid { + grid-template-columns: repeat(2, minmax(280px, 1fr)); + } +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: 1fr; + } + + #panelEpisodes, + #panelSelectedEpisodes, + #panelSelectedFiles { + min-height: 420px; + } + + .modal-grid { + grid-template-columns: 1fr; + } +} diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index f045ea4..f4d768c 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 5a04505..0bae359 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index e74f713..24e6bc9 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -28,6 +28,10 @@ class UiSmokeGoldenTest(unittest.TestCase): body = index_path.read_text(encoding="utf-8") self.assertIn('id="workspace"', body) self.assertIn('id="footer-bar"', body) + self.assertIn('id="title-zone-actions"', body) + self.assertIn('id="status"', body) + self.assertIn('id="theme-toggle"', body) + self.assertIn('id="theme-toggle-icon"', body) self.assertIn('id="left-pane"', body) self.assertIn('id="right-pane"', body) self.assertIn('id="left-items"', body) @@ -82,6 +86,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("document.documentElement.dataset.theme", app_js) + self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js) self.assertIn('Cross-root directory move is not supported in v1', app_js) self.assertIn('Batch directory move is not supported in v1', app_js) self.assertIn('Batch move requires all selected items to be in the same root', app_js) @@ -89,6 +96,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('sources: selectedItems.map((item) => item.path)', app_js) 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('#theme-toggle', style_css) app_js_url = app.url_path_for("ui", path="/app.js") style_css_url = app.url_path_for("ui", path="/style.css") diff --git a/webui/html/app.js b/webui/html/app.js index 6c77034..759e143 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -36,6 +36,39 @@ let batchMoveState = { destinationBase: "", count: 0, }; +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 applyTheme(theme) { + const nextTheme = theme === "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" ? "☾" : "☀"; + } + if (button) { + button.setAttribute("aria-label", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`); + button.setAttribute("title", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`); + } +} + +function toggleTheme() { + const current = document.documentElement.dataset.theme === "light" ? "light" : "dark"; + const next = current === "dark" ? "light" : "dark"; + applyTheme(next); + window.localStorage.setItem(THEME_STORAGE_KEY, next); +} function paneState(pane) { return state.panes[pane]; @@ -1420,6 +1453,7 @@ function setupEvents() { setupPaneEvents("left"); setupPaneEvents("right"); document.addEventListener("keydown", handleKeyboardShortcuts); + document.getElementById("theme-toggle").onclick = toggleTheme; document.getElementById("view-btn").onclick = openViewer; document.getElementById("edit-btn").onclick = openEditor; document.getElementById("rename-btn").onclick = renameSelected; @@ -1498,6 +1532,7 @@ function setupEvents() { async function init() { setError("actions-error", ""); + applyTheme(preferredTheme()); setActivePane("left"); setupEvents(); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); diff --git a/webui/html/index.html b/webui/html/index.html index c01dc27..76a5da3 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -10,7 +10,12 @@