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 @@

WebManager v2

-
+
+
+ +
diff --git a/webui/html/style.css b/webui/html/style.css index a09d86d..c8e26e3 100644 --- a/webui/html/style.css +++ b/webui/html/style.css @@ -1,14 +1,66 @@ :root { - --bg: #f4f7fb; - --panel: #ffffff; - --border: #d7deea; - --text: #192232; - --muted: #5c687c; - --error: #b11d1d; - --accent: #1b5ec9; - --active-border: #1b5ec9; - --active-bg: #ffffff; --bottom-reserve: 0px; + --radius-sm: 4px; + --radius-md: 8px; + --shadow-elevated: 0 10px 28px rgba(12, 20, 32, 0.18); +} + +:root[data-theme="dark"] { + --color-page-bg: #161c25; + --color-surface: #1d2531; + --color-surface-elevated: #222c39; + --color-border: #324052; + --color-border-strong: #55739f; + --color-text-primary: #e7edf6; + --color-text-muted: #9aa9bd; + --color-accent: #6aa5ff; + --color-accent-contrast: #07192f; + --color-selection-bg: #233754; + --color-selection-border: #5c8fda; + --color-current-row-bg: #1f2d42; + --color-current-row-border: #6a87b5; + --color-active-pane-border: #78adff; + --color-button-bg: #283444; + --color-button-hover: #314258; + --color-button-secondary-bg: #202935; + --color-danger: #ff8e8e; + --color-danger-bg: #462328; + --color-overlay-bg: rgba(8, 12, 18, 0.62); +} + +:root[data-theme="light"] { + --color-page-bg: #eef3f9; + --color-surface: #ffffff; + --color-surface-elevated: #f8fbff; + --color-border: #d6e0ec; + --color-border-strong: #7ca0d1; + --color-text-primary: #172233; + --color-text-muted: #617086; + --color-accent: #235ec7; + --color-accent-contrast: #ffffff; + --color-selection-bg: #e5eefc; + --color-selection-border: #7b9fdb; + --color-current-row-bg: #f1f6ff; + --color-current-row-border: #a2bce8; + --color-active-pane-border: #235ec7; + --color-button-bg: #f6f9fd; + --color-button-hover: #edf3fb; + --color-button-secondary-bg: #f3f6fb; + --color-danger: #b42323; + --color-danger-bg: #fdecec; + --color-overlay-bg: rgba(18, 28, 40, 0.30); +} + +:root { + --bg: var(--color-page-bg); + --panel: var(--color-surface); + --border: var(--color-border); + --text: var(--color-text-primary); + --muted: var(--color-text-muted); + --error: var(--color-danger); + --accent: var(--color-accent); + --active-border: var(--color-active-pane-border); + --active-bg: var(--color-surface); } * { box-sizing: border-box; } @@ -20,8 +72,8 @@ html, body { body { margin: 0; font-family: "Segoe UI", Tahoma, sans-serif; - background: var(--bg); - color: var(--text); + background: var(--color-page-bg); + color: var(--color-text-primary); overflow: hidden; } @@ -32,14 +84,21 @@ body { } #title-zone { - padding: 6px 10px; - border-bottom: 1px solid var(--border); - background: var(--panel); + padding: 7px 10px; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface-elevated); display: flex; align-items: center; justify-content: space-between; } +#title-zone-actions { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + h1, h2, h3 { margin: 0; } @@ -58,9 +117,9 @@ h1 { } .panel { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 6px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); padding: 8px; } @@ -72,9 +131,9 @@ h1 { } .pane.active-pane { - border-color: var(--active-border); - box-shadow: 0 0 0 1px var(--active-border) inset; - background: var(--panel); + border-color: var(--color-active-pane-border); + box-shadow: 0 0 0 1px var(--color-active-pane-border) inset; + background: var(--color-surface); } .pane-header { @@ -86,7 +145,7 @@ h1 { flex: 1 1 auto; min-height: 0; overflow-y: auto; - border-top: 1px solid var(--border); + border-top: 1px solid var(--color-border); padding-top: 4px; } @@ -114,24 +173,36 @@ h1 { } .checkbox { - color: var(--muted); + color: var(--color-text-muted); font-size: 13px; } -input, button { +input, button, textarea { font: inherit; - padding: 4px 6px; font-size: 13px; } +input, textarea { + padding: 5px 7px; + color: var(--color-text-primary); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + button { - border: 1px solid var(--border); - background: #f8fafc; + padding: 4px 7px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-button-bg); + color: var(--color-text-primary); cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; } button:hover { - border-color: var(--accent); + background: var(--color-button-hover); + border-color: var(--color-accent); } button:disabled { @@ -139,8 +210,25 @@ button:disabled { cursor: not-allowed; } +#theme-toggle { + width: 28px; + min-width: 28px; + height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--color-button-secondary-bg); +} + +#theme-toggle-icon { + font-size: 14px; + line-height: 1; +} + .pathline { - color: var(--muted); + color: var(--color-text-muted); margin-bottom: 3px; font-size: 12px; } @@ -156,22 +244,20 @@ button:disabled { flex-wrap: wrap; gap: 4px; margin-bottom: 3px; - color: var(--muted); + color: var(--color-text-muted); font-size: 12px; } .breadcrumbs button { padding: 1px 4px; font-size: 12px; + background: transparent; + color: var(--color-text-muted); } -.list-label { - font-size: 11px; - margin: 0; - padding: 2px 0; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.03em; +.breadcrumbs button:hover { + color: var(--color-accent); + background: var(--color-button-secondary-bg); } .list { @@ -185,9 +271,9 @@ button:disabled { grid-template-columns: 14px minmax(0, 1fr) 88px 138px; gap: 6px; padding: 2px 0 4px 0; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid var(--color-border); margin-bottom: 2px; - color: var(--muted); + color: var(--color-text-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.03em; @@ -199,7 +285,7 @@ button:disabled { } .list li { - border-top: 1px solid var(--border); + border-top: 1px solid var(--color-border); padding: 5px 0 4px 0; display: grid; grid-template-columns: 14px minmax(0, 1fr) 88px 138px; @@ -212,19 +298,20 @@ button:disabled { } .list li.is-selected { - background: #e9f0fd; + background: var(--color-selection-bg); } .list li.is-current-row { - box-shadow: inset 0 0 0 1px #9cb7e8; + box-shadow: inset 0 0 0 1px var(--color-current-row-border); } .list li.is-current-row:not(.is-selected) { - background: #f4f8ff; + background: var(--color-current-row-bg); } .list li.is-current-row.is-selected { - background: #e3edff; + background: var(--color-selection-bg); + box-shadow: inset 0 0 0 1px var(--color-current-row-border); } .select-marker { @@ -232,21 +319,18 @@ button:disabled { width: 10px; min-width: 10px; height: 10px; - border: 1px solid var(--border); + border: 1px solid var(--color-border); border-radius: 2px; display: inline-block; margin: 0; cursor: pointer; + background: var(--color-surface); } -.list li.is-selected .select-marker { - background: var(--accent); - border-color: var(--accent); -} - +.list li.is-selected .select-marker, .select-marker:checked { - background: var(--accent); - border-color: var(--accent); + background: var(--color-accent); + border-color: var(--color-accent); } .entry-name { @@ -264,7 +348,7 @@ button:disabled { border: 0; background: transparent; padding: 0; - color: var(--accent); + color: var(--color-accent); text-decoration: underline; cursor: pointer; overflow: hidden; @@ -272,29 +356,35 @@ button:disabled { white-space: nowrap; } +.dir-link:hover { + background: transparent; +} + .entry-size, .entry-modified { font-size: 12px; - color: var(--muted); + color: var(--color-text-muted); text-align: right; white-space: nowrap; } .error { - color: var(--error); + color: var(--color-danger); min-height: 12px; margin-bottom: 2px; font-size: 12px; } #status { - color: var(--muted); + color: var(--color-text-muted); font-size: 12px; + min-width: 0; + text-align: right; } #footer-bar { - border-top: 1px solid var(--border); - background: var(--panel); + border-top: 1px solid var(--color-border); + background: var(--color-surface-elevated); padding: 4px 10px 3px 10px; display: flex; flex-direction: column; @@ -325,7 +415,7 @@ button:disabled { } .shortcut-hint { - color: var(--muted); + color: var(--color-text-muted); font-size: 10px; line-height: 1; letter-spacing: 0.02em; @@ -335,7 +425,7 @@ button:disabled { .popup-overlay { position: fixed; inset: 0; - background: rgba(20, 32, 50, 0.25); + background: var(--color-overlay-bg); display: flex; align-items: center; justify-content: center; @@ -348,30 +438,25 @@ button:disabled { .popup-card { width: min(420px, calc(100vw - 24px)); - background: #fff; - border: 1px solid var(--border); - border-radius: 6px; + background: var(--color-surface-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); padding: 10px; - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.16); + box-shadow: var(--shadow-elevated); } .popup-meta { - color: var(--muted); + color: var(--color-text-muted); font-size: 12px; margin: 4px 0 8px 0; } .popup-label { font-size: 12px; - color: var(--muted); -} - -#wildcard-pattern-input { - width: 100%; - margin-top: 4px; - margin-bottom: 6px; + color: var(--color-text-muted); } +#wildcard-pattern-input, #rename-move-input { width: 100%; margin-top: 4px; @@ -400,33 +485,32 @@ button:disabled { padding: 2px 8px; } -.viewer-content { +.viewer-content, +.editor-content { margin: 6px 0 0 0; padding: 10px; + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text-primary); + font: 12px/1.45 "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.viewer-content { min-height: 240px; max-height: calc(100vh - 180px); - overflow: auto; - border: 1px solid var(--border); - background: #f8fafc; - color: var(--text); - font: 12px/1.45 "SFMono-Regular", Consolas, "Liberation Mono", monospace; white-space: pre-wrap; word-break: break-word; user-select: text; } .editor-content { - margin: 6px 0 8px 0; - padding: 10px; + margin-bottom: 8px; min-height: 280px; max-height: calc(100vh - 220px); width: 100%; resize: vertical; - overflow: auto; - border: 1px solid var(--border); - background: #f8fafc; - color: var(--text); - font: 12px/1.45 "SFMono-Regular", Consolas, "Liberation Mono", monospace; white-space: pre; }