From 64b13ac490e5d07fe268251ed143c4b3a68586ac Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 19 Mar 2026 23:47:32 -0400 Subject: [PATCH] add UI/UX improvements implementation plan Six-task frontend-only plan covering gallery tag filtering, showcase stagger animation and ghost badges, modal sidebar hierarchy, series reader immersive mode, and settings tab structure. Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-03-19-ui-ux-improvements.md | 1118 +++++++++++++++++ 1 file changed, 1118 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-19-ui-ux-improvements.md diff --git a/docs/superpowers/plans/2026-03-19-ui-ux-improvements.md b/docs/superpowers/plans/2026-03-19-ui-ux-improvements.md new file mode 100644 index 0000000..2d3d6bd --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-ui-ux-improvements.md @@ -0,0 +1,1118 @@ +# UI/UX Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Apply a content-first redesign across Showcase, Gallery, Image Modal, Series Reader, and Settings — keeping the black/transparent brand, reducing chrome noise, and improving interaction clarity. + +**Architecture:** All changes are frontend-only (HTML templates, CSS, vanilla JS). No backend or API changes. The five task groups are fully independent and can be worked in any order. The existing two-panel modal layout is already structurally correct; improvements are visual hierarchy and interaction refinements within it. + +**Tech Stack:** Jinja2 templates, vanilla JavaScript (ES6 modules pattern already in use), single CSS file (`app/static/style.css`) + +**Spec:** `docs/superpowers/specs/2026-03-19-ui-ux-improvements-design.md` + +--- + +## File Map + +| File | Task(s) | What changes | +|------|---------|--------------| +| `app/templates/_gallery_item.html` | 1 | Filter source tags; refine date opacity; refine play overlay | +| `app/static/style.css` | 1,2,3,4,5 | All new/updated CSS classes | +| `app/templates/showcase.html` | 2 | Ghost badge markup; navbar hint refinement | +| `app/static/js/showcase.js` | 2,3 | Ghost badge rendering; stagger animation | +| `app/templates/_gallery_modal.html` | 4 | Series block collapse toggle; section order | +| `app/static/js/view-modal.js` | 4 | Series block toggle logic; in-context feedback | +| `app/templates/reader.html` | 5 | Progress bar element; remove per-page indicator; load reader.js | +| `app/static/js/reader.js` | 5 | NEW FILE — extract + extend reader inline JS | +| `app/templates/settings.html` | 6 | Tab structure; reorganized sections; remove inline styles | + +--- + +## Task 1: Gallery Item — Filter Source Tags + Visual Refinements + +**Files:** +- Modify: `app/templates/_gallery_item.html` +- Modify: `app/static/style.css` (`.image-date`, `.play-overlay`) + +### Step 1.1: Filter source tags from gallery item overlay + +- [ ] Open `app/templates/_gallery_item.html` +- [ ] Find this line (line 11): + ```jinja + {% set visible_tags = image.tags | selectattr('kind', 'ne', 'archive') | list %} + ``` +- [ ] Replace with: + ```jinja + {% set visible_tags = image.tags | rejectattr('kind', 'in', ['archive', 'source']) | list %} + ``` + +### Step 1.2: Refine date label — subtle at rest, visible on hover + +- [ ] Open `app/static/style.css`, find `.image-date` (around line 344) +- [ ] Add `opacity` and `transition` to the existing rule: + ```css + .image-date { + /* ... existing properties ... */ + opacity: 0.5; + transition: opacity 0.2s ease; + } + ``` +- [ ] Add a hover rule directly after the `.image-date` block: + ```css + .gallery-item:hover .image-date { + opacity: 1; + } + ``` + +### Step 1.3: Refine video play overlay + +- [ ] Find `.play-overlay` in `app/static/style.css` (around line 367) +- [ ] Replace the existing rule with: + ```css + .play-overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + z-index: 1; + pointer-events: none; + } + + .play-overlay::before { + content: '▶'; + font-size: 2rem; + color: rgba(255, 255, 255, 0.85); + text-shadow: 0 0 12px rgba(0,0,0,0.9), 0 2px 6px rgba(0,0,0,0.8); + width: 52px; + height: 52px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + padding-left: 4px; /* optical centering of triangle */ + } + ``` + +- [ ] Remove the `textContent = '▶'` line from `showcase.js` `createImageElement()` (line 173) since the glyph now comes from CSS `::before`. The `.play-overlay` div still needs to be appended — just remove `.textContent`: + ```js + // Before: + const play = document.createElement('div'); + play.className = 'play-overlay'; + play.textContent = '▶'; + + // After: + const play = document.createElement('div'); + play.className = 'play-overlay'; + ``` + +### Step 1.4: Verify + +- [ ] Open the app in the browser and navigate to `/gallery` +- [ ] Confirm: source tags (e.g. `🌐 source:patreon`) no longer appear on gallery thumbnails +- [ ] Confirm: date label is dimmed on images, full opacity on hover +- [ ] Confirm: video items show a refined play indicator (no regression) + +### Step 1.5: Commit + +- [ ] `git add app/templates/_gallery_item.html app/static/style.css app/static/js/showcase.js` +- [ ] `git commit -m "feat: filter source tags from gallery items, refine date/video overlays"` + +--- + +## Task 2: Showcase — Ghost Badge + Navbar Hint Refinement + +**Files:** +- Modify: `app/templates/showcase.html` +- Modify: `app/static/js/showcase.js` +- Modify: `app/static/style.css` + +The showcase uses JS-built items (not the `_gallery_item.html` template), so tag display changes happen in `showcase.js`. + +### Step 2.1: Add ghost badge CSS + +- [ ] Open `app/static/style.css` +- [ ] Find the showcase/masonry section (search for `.masonry-item` or `.masonry-container`) +- [ ] Add these new rules in the masonry/showcase section: + ```css + /* Ghost tag count badge on showcase items */ + .tag-count-badge { + position: absolute; + bottom: 8px; + right: 8px; + padding: 2px 7px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.45); + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.55); + font-size: 0.7rem; + font-weight: 500; + z-index: 2; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; + } + + .masonry-item:hover .tag-count-badge { + opacity: 1; + } + + /* Touch devices: always show badge */ + @media (hover: none) { + .tag-count-badge { + opacity: 1; + } + } + ``` + +### Step 2.2: Replace tag overlay with ghost badge in showcase.js + +- [ ] Open `app/static/js/showcase.js` +- [ ] Find the `createImageElement` function (around line 143) +- [ ] Replace the tag overlay block (lines ~160–167): + ```js + // Before: + const visibleTags = (img.tags || []).filter(t => t.kind !== 'archive'); + if (visibleTags.length > 0) { + const overlay = document.createElement('div'); + overlay.className = 'tag-overlay'; + overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).join(''); + item.appendChild(overlay); + } + + // After: + const visibleTags = (img.tags || []).filter(t => t.kind !== 'archive' && t.kind !== 'source'); + if (visibleTags.length > 0) { + const badge = document.createElement('span'); + badge.className = 'tag-count-badge'; + badge.textContent = `#${visibleTags.length}`; + item.appendChild(badge); + } + ``` + +### Step 2.3: Navbar hint — hide at rest, show on hover + +- [ ] Open `app/templates/showcase.html` +- [ ] Find the `.nav-hint` span (line 30): + ```html + Press R to shuffle + ``` +- [ ] It stays as-is in markup. Add CSS to hide it at rest: + +- [ ] Open `app/static/style.css`, find the showcase nav section (search for `.showcase-nav`) +- [ ] Add to the existing `.nav-hint` rule (or create it if missing): + ```css + .nav-hint { + opacity: 0; + transition: opacity 0.2s ease; + font-size: 0.8rem; + color: var(--text-muted); + } + + .showcase-nav:hover .nav-hint { + opacity: 1; + } + ``` + +### Step 2.4: Verify + +- [ ] Open `/` (showcase) in browser +- [ ] Confirm: thumbnails no longer show tag chip overlays +- [ ] Hover over an item — confirm a small semi-transparent badge like `#4` appears bottom-right +- [ ] Confirm: "Press R to shuffle" hint is hidden at rest, appears when hovering the nav bar +- [ ] Press R — confirm shuffle still works + +### Step 2.5: Commit + +- [ ] `git add app/templates/showcase.html app/static/js/showcase.js app/static/style.css` +- [ ] `git commit -m "feat: replace showcase tag overlays with ghost count badge, refine nav hint"` + +--- + +## Task 3: Showcase — Stagger Entry Animation + +**Files:** +- Modify: `app/static/style.css` +- Modify: `app/static/js/showcase.js` + +### Step 3.1: Add stagger animation CSS + +- [ ] Open `app/static/style.css`, find the masonry/showcase section +- [ ] Add: + ```css + /* Stagger entry animation for showcase items */ + @keyframes itemFadeIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .masonry-item.animating-in { + animation: itemFadeIn 0.25s ease forwards; + animation-delay: calc(var(--stagger-index, 0) * 60ms); + opacity: 0; /* Start hidden until animation fires */ + } + ``` + +### Step 3.2: Apply stagger on distribute + +- [ ] Open `app/static/js/showcase.js` +- [ ] Find the `distributeImages` function (around line 179) +- [ ] Add a stagger counter and apply it when appending items. Replace: + ```js + function distributeImages(images, useLazyLoading = false) { + images.forEach(img => { + const colIndex = getShortestColumnIndex(); + const item = createImageElement(img, useLazyLoading); + columns[colIndex].appendChild(item); + columnHeights[colIndex] += 300; + const imgEl = item.querySelector('img'); + imgEl.onload = () => { updateColumnHeights(); }; + }); + document.dispatchEvent(new CustomEvent('showcaseUpdated')); + } + ``` + With: + ```js + function distributeImages(images, useLazyLoading = false, animate = false) { + images.forEach((img, i) => { + const colIndex = getShortestColumnIndex(); + const item = createImageElement(img, useLazyLoading); + + if (animate) { + item.style.setProperty('--stagger-index', i); + item.classList.add('animating-in'); + // Remove class after animation completes so it doesn't block interactions + const delay = i * 60; + setTimeout(() => item.classList.remove('animating-in'), delay + 300); + } + + columns[colIndex].appendChild(item); + columnHeights[colIndex] += 300; + const imgEl = item.querySelector('img'); + imgEl.onload = () => { updateColumnHeights(); }; + }); + document.dispatchEvent(new CustomEvent('showcaseUpdated')); + } + ``` + +### Step 3.3: Trigger animation on load and shuffle + +- [ ] In `loadInitialImages()`, change the call to pass `animate = true`: + ```js + function loadInitialImages() { + const dataScript = document.getElementById('initialImages'); + if (!dataScript) return; + try { + const images = JSON.parse(dataScript.textContent); + distributeImages(images, true, true); // animate on initial load + } catch (e) { + console.error('Failed to parse initial images:', e); + } + } + ``` + +- [ ] In `shuffle()`, change the `distributeImages` call to pass `animate = true`: + ```js + distributeImages(data.images, false, true); // animate on shuffle + ``` + +- [ ] The `loadMore()` function should NOT animate (continuous scroll should be seamless): + ```js + distributeImages(data.images); // no animate on scroll load + ``` + +### Step 3.4: Verify + +- [ ] Load `/` — confirm images stagger in with a subtle fade+translate on page load +- [ ] Press R to shuffle — confirm new images stagger in again +- [ ] Scroll down to trigger `loadMore` — confirm new items appear without animation (seamless) +- [ ] Confirm no layout shift or jumpiness + +### Step 3.5: Commit + +- [ ] `git add app/static/style.css app/static/js/showcase.js` +- [ ] `git commit -m "feat: add stagger entry animation to showcase on load and shuffle"` + +--- + +## Task 4: Image Modal — Visual Hierarchy + Series Block Collapse + In-Context Feedback + +**Files:** +- Modify: `app/templates/_gallery_modal.html` +- Modify: `app/static/js/view-modal.js` +- Modify: `app/static/style.css` + +The modal already has a two-panel layout (`modal-body` flex row, image left, `tag-editor` right). This task refines the visual hierarchy within the right panel. + +### Step 4.1: Restructure modal HTML for section hierarchy + +- [ ] Open `app/templates/_gallery_modal.html` +- [ ] Replace the entire file content with the following restructured version. Key changes: + - Add `.modal-sidebar` wrapper around the tag editor content + - Add `.modal-sidebar-section` dividers + - Add a toggle button for "Add to Series" collapsed state + - Add `.tag-feedback` span for in-context feedback + + ```html + + + ``` + +### Step 4.2: Add CSS for new modal sidebar sections + +- [ ] Open `app/static/style.css`, find the modal section (around line 382) +- [ ] Add after the existing modal rules: + ```css + /* Modal sidebar sections */ + .modal-sidebar-section { + padding: 0.75rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + } + .modal-sidebar-section:last-child { + border-bottom: none; + } + + /* Series toggle button (for "Add to Series" collapsed state) */ + .modal-series-toggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: none; + border: none; + color: var(--text-muted); + font-size: 0.85rem; + cursor: pointer; + padding: 0.25rem 0; + transition: color 0.15s ease; + } + .modal-series-toggle:hover { + color: var(--text-dim); + } + .modal-series-toggle .toggle-arrow { + transition: transform 0.15s ease; + font-size: 0.9rem; + } + .modal-series-toggle.open .toggle-arrow { + transform: rotate(90deg); + } + + .add-series-body { + margin-top: 0.5rem; + } + + /* In-context feedback label */ + .tag-feedback { + font-size: 0.75rem; + color: var(--flash-success); + opacity: 0; + transition: opacity 0.2s ease; + display: inline-block; + margin-left: 0.4rem; + } + .tag-feedback.visible { + opacity: 1; + } + .tag-feedback-inline { + display: block; + margin-left: 0; + margin-top: 0.3rem; + min-height: 1.1em; + } + + /* Tag chip remove button — hidden at rest, visible on hover */ + .tag-chip .x { + opacity: 0; + transition: opacity 0.15s ease; + margin-left: 4px; + } + .tag-chip:hover .x { + opacity: 1; + } + ``` + +### Step 4.3: Wire up the "Add to Series" toggle in view-modal.js + +- [ ] Open `app/static/js/view-modal.js` +- [ ] After the line `let seriesTagsLoaded = false;` (around line 31), add: + ```js + const addToSeriesToggle = document.getElementById('addToSeriesToggle'); + const addToSeriesBody = document.getElementById('addToSeriesBody'); + ``` + +- [ ] After the existing event listeners section, add the toggle handler. Find a good place (e.g. near the other addToSeries event listeners) and add: + ```js + // Toggle "Add to Series" collapse + if (addToSeriesToggle) { + addToSeriesToggle.addEventListener('click', () => { + const isOpen = addToSeriesToggle.classList.toggle('open'); + addToSeriesBody.style.display = isOpen ? 'block' : 'none'; + // Load series options on first open + if (isOpen && !seriesTagsLoaded) { + loadSeriesTags(); + } + }); + } + ``` + +- [ ] Find where `addToSeriesEl` is shown (the call to `addToSeriesEl.style.display = 'block'` or similar). The current code shows the entire `#modalAddToSeries` div. The toggle body (`#addToSeriesBody`) starts collapsed — that's the new default. No change needed to the show/hide logic for `#modalAddToSeries` itself; only the inner body is collapsed. + +### Step 4.4: Add in-context feedback helper to view-modal.js + +- [ ] In `view-modal.js`, add this utility function near the top of the `DOMContentLoaded` callback: + ```js + // In-context feedback: show a brief message next to an element, auto-fades + function showFeedback(el, message, isError = false) { + if (!el) return; + el.textContent = message; + el.style.color = isError ? 'var(--flash-danger)' : 'var(--flash-success)'; + el.classList.add('visible'); + clearTimeout(el._feedbackTimeout); + el._feedbackTimeout = setTimeout(() => { + el.classList.remove('visible'); + }, 1500); + } + ``` + +- [ ] Find the existing `pageUpdateStatus` usage (where tag save success/failure is set). Replace any direct `.textContent` assignments on `pageUpdateStatus` with `showFeedback(pageUpdateStatus, message)`. + +- [ ] Similarly for `addToSeriesStatus` — replace direct `.textContent` with `showFeedback(addToSeriesStatus, message)`. + +- [ ] Find where tags are added/removed (the fetch calls for `/image//tags/add` and `/image//tags/remove`). After a successful add/remove, add: + ```js + const feedbackEl = document.getElementById('tagActionFeedback'); + showFeedback(feedbackEl, 'Tag added'); // or 'Tag removed' + ``` + +### Step 4.5: Verify + +- [ ] Open any image in the gallery modal +- [ ] Confirm: tag chips have no visible `×` at rest; hovering a chip reveals the `×` +- [ ] Confirm: "Add to Series" shows as a collapsed toggle when image is not in a series +- [ ] Click "Add to Series" toggle — confirm it expands with the series select controls +- [ ] Add a tag — confirm a brief "Tag added" message appears and fades near the form +- [ ] Remove a tag — confirm "Tag removed" fades in and out + +### Step 4.6: Commit + +- [ ] `git add app/templates/_gallery_modal.html app/static/js/view-modal.js app/static/style.css` +- [ ] `git commit -m "feat: improve modal sidebar hierarchy, collapse add-to-series, in-context feedback"` + +--- + +## Task 5: Series Reader — Progress Bar + Auto-Hide Header + Active Thumbnail Tracking + +**Files:** +- Modify: `app/templates/reader.html` +- Create: `app/static/js/reader.js` +- Modify: `app/static/style.css` + +**Note:** Reader logic is currently inline in `reader.html`. This task extracts it to `reader.js` and adds new features. + +### Step 5.1: Add progress bar + auto-hide header CSS + +- [ ] Open `app/static/style.css`, find the reader section (search for `.reader-container`) +- [ ] Add these rules in/after the reader section: + ```css + /* Reading progress bar */ + .reader-progress-bar { + position: fixed; + top: 0; + left: 0; + height: 2px; + width: 0%; + background: var(--btn-primary); + z-index: 1100; + transition: width 0.1s linear; + pointer-events: none; + } + + /* Reader header auto-hide */ + .reader-header { + transition: opacity 0.3s ease, transform 0.3s ease; + } + .reader-header.hidden { + opacity: 0; + pointer-events: none; + transform: translateY(-4px); + } + + /* Active nav thumbnail */ + .reader-nav-thumb.active { + border-left: 3px solid var(--btn-primary); + opacity: 1; + } + .reader-nav-thumb:not(.active) { + opacity: 0.5; + transition: opacity 0.2s ease; + } + .reader-nav-thumb:hover { + opacity: 1; + } + + /* Single floating page indicator (replaces per-page indicators) */ + .floating-page-indicator { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + padding: 4px 10px; + background: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + font-size: 0.8rem; + color: var(--text-muted); + z-index: 100; + pointer-events: none; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + } + ``` + +### Step 5.2: Update reader.html — add progress bar, remove per-page indicators, load reader.js + +- [ ] Open `app/templates/reader.html` +- [ ] Add the progress bar element as the very first element inside `{% block content %}`: + ```html + +
+ ``` + +- [ ] Find the per-page indicator inside the `{% for page in pages %}` loop (around line 57-59): + ```html +
{{ page.page_number }} / {{ total_pages }}
+ ``` + Remove this div entirely (the floating indicator replaces it). + +- [ ] The `floatingPageIndicator` div already exists in the template (lines 73-75): + ```html +
+ 1 / {{ total_pages }} +
+ ``` + This stays — it becomes the single global indicator. + +- [ ] **Before deleting:** Read through the existing inline ` + + ``` + +### Step 5.3: Create reader.js — extracted + new features + +- [ ] Create `app/static/js/reader.js` with the following content: + ```js + // /app/static/js/reader.js + 'use strict'; + + document.addEventListener('DOMContentLoaded', () => { + const { startPage, totalPages } = window.readerConfig || { startPage: 1, totalPages: 1 }; + + // Elements + const readerContent = document.getElementById('readerMain'); + const pageJumpSelect = document.getElementById('pageJumpSelect'); + const floatingIndicator = document.getElementById('floatingPageIndicator'); + const currentPageNum = document.getElementById('currentPageNum'); + const readerNav = document.getElementById('readerNav'); + const readerHeader = document.querySelector('.reader-header'); + const toggleNavBtn = document.getElementById('toggleNavBtn'); + const closeNavBtn = document.getElementById('closeNavBtn'); + const navThumbs = document.querySelectorAll('.reader-nav-thumb'); + const scrollToTopBtn = document.getElementById('scrollToTopBtn'); + const scrollToBottomBtn = document.getElementById('scrollToBottomBtn'); + const pages = document.querySelectorAll('.reader-page'); + const progressBar = document.getElementById('readerProgressBar'); + + let currentPage = startPage; + let isNavOpen = false; + let headerHideTimeout = null; + + // ------------------------- + // Scroll to starting page + // ------------------------- + if (startPage > 1) { + const targetPage = document.getElementById('page-' + startPage); + if (targetPage) { + setTimeout(() => targetPage.scrollIntoView({ behavior: 'auto' }), 100); + } + } + + // ------------------------- + // Progress bar + // ------------------------- + function updateProgressBar() { + if (!progressBar || !readerContent) return; + const scrollTop = readerContent.scrollTop; + const scrollHeight = readerContent.scrollHeight - readerContent.clientHeight; + const pct = scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0; + progressBar.style.width = `${Math.min(pct, 100)}%`; + } + + // ------------------------- + // Auto-hide header + // ------------------------- + function showHeader() { + if (!readerHeader) return; + readerHeader.classList.remove('hidden'); + clearTimeout(headerHideTimeout); + } + + function scheduleHideHeader() { + clearTimeout(headerHideTimeout); + headerHideTimeout = setTimeout(() => { + if (readerHeader) readerHeader.classList.add('hidden'); + }, 2500); + } + + // ------------------------- + // Current page tracking + // ------------------------- + function updateCurrentPage() { + if (!readerContent) return; + const scrollTop = readerContent.scrollTop; + const viewportCenter = scrollTop + (readerContent.clientHeight / 3); + + let newCurrentPage = currentPage; + pages.forEach(page => { + const pageTop = page.offsetTop; + const pageBottom = pageTop + page.offsetHeight; + if (viewportCenter >= pageTop && viewportCenter < pageBottom) { + newCurrentPage = parseInt(page.dataset.page); + } + }); + + if (newCurrentPage !== currentPage) { + currentPage = newCurrentPage; + + // Update floating indicator + if (currentPageNum) currentPageNum.textContent = currentPage; + + // Update jump select + if (pageJumpSelect) pageJumpSelect.value = currentPage; + + // Update active nav thumb + scroll it into view + navThumbs.forEach(thumb => { + const isActive = parseInt(thumb.dataset.page) === currentPage; + thumb.classList.toggle('active', isActive); + if (isActive) { + thumb.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }); + + // Update URL without reload + const url = new URL(window.location); + url.searchParams.set('page', currentPage); + history.replaceState({}, '', url); + } + } + + // ------------------------- + // Scroll handler + // ------------------------- + let scrollTimeout = null; + if (readerContent) { + readerContent.addEventListener('scroll', () => { + updateProgressBar(); + showHeader(); + scheduleHideHeader(); + + if (scrollTimeout) return; + scrollTimeout = setTimeout(() => { + updateCurrentPage(); + scrollTimeout = null; + }, 50); + }); + } + + // Show header on mouse move + document.addEventListener('mousemove', showHeader); + + // ------------------------- + // Jump to page + // ------------------------- + if (pageJumpSelect) { + pageJumpSelect.addEventListener('change', (e) => { + const pageNum = parseInt(e.target.value); + const targetPage = document.getElementById('page-' + pageNum); + if (targetPage) targetPage.scrollIntoView({ behavior: 'smooth' }); + }); + } + + // ------------------------- + // Sidebar toggle + // ------------------------- + if (toggleNavBtn) { + toggleNavBtn.addEventListener('click', () => { + isNavOpen = !isNavOpen; + readerNav.classList.toggle('open', isNavOpen); + }); + } + + if (closeNavBtn) { + closeNavBtn.addEventListener('click', () => { + isNavOpen = false; + readerNav.classList.remove('open'); + }); + } + + // Click thumbnail to jump + navThumbs.forEach(thumb => { + thumb.addEventListener('click', () => { + const pageNum = parseInt(thumb.dataset.page); + const targetPage = document.getElementById('page-' + pageNum); + if (targetPage) targetPage.scrollIntoView({ behavior: 'smooth' }); + if (window.innerWidth < 768) { + isNavOpen = false; + readerNav.classList.remove('open'); + } + }); + }); + + // ------------------------- + // Quick nav buttons + // ------------------------- + if (scrollToTopBtn) { + scrollToTopBtn.addEventListener('click', () => { + readerContent.scrollTo({ top: 0, behavior: 'smooth' }); + }); + } + + if (scrollToBottomBtn) { + scrollToBottomBtn.addEventListener('click', () => { + readerContent.scrollTo({ top: readerContent.scrollHeight, behavior: 'smooth' }); + }); + } + + // ------------------------- + // Keyboard navigation + // ------------------------- + document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; + showHeader(); + + switch (e.key) { + case 'Home': + e.preventDefault(); + readerContent.scrollTo({ top: 0, behavior: 'smooth' }); + break; + case 'End': + e.preventDefault(); + readerContent.scrollTo({ top: readerContent.scrollHeight, behavior: 'smooth' }); + break; + case 'PageUp': + e.preventDefault(); + readerContent.scrollBy({ top: -readerContent.clientHeight * 0.9, behavior: 'smooth' }); + break; + case 'PageDown': + case ' ': + e.preventDefault(); + readerContent.scrollBy({ top: readerContent.clientHeight * 0.9, behavior: 'smooth' }); + break; + case 'n': + isNavOpen = !isNavOpen; + readerNav.classList.toggle('open', isNavOpen); + break; + } + }); + + // ------------------------- + // Initial state + // ------------------------- + updateCurrentPage(); + updateProgressBar(); + }); + ``` + +### Step 5.4: Verify + +- [ ] Navigate to any series reader page (e.g. `/read/`) +- [ ] Confirm: thin progress bar appears at very top of viewport, fills as you scroll +- [ ] Scroll down — confirm header fades out after ~2.5s +- [ ] Move mouse or stop scrolling — confirm header reappears immediately +- [ ] Confirm: the floating page indicator (bottom-right) updates correctly as you scroll +- [ ] Confirm: no per-page `1 / N` indicators rendered on each image +- [ ] Confirm: active thumbnail in sidebar gets a left border accent and scrolls into view +- [ ] Confirm: all existing keyboard shortcuts still work (PageDown, Home, End, n) + +### Step 5.5: Commit + +- [ ] `git add app/templates/reader.html app/static/js/reader.js app/static/style.css` +- [ ] `git commit -m "feat: add progress bar, auto-hide header, active thumb tracking to series reader"` + +--- + +## Task 6: Settings — Tab Structure + Section Reorganization + +**Files:** +- Modify: `app/templates/settings.html` +- Modify: `app/static/style.css` + +**Note:** Before editing, read the full `settings.html` to understand all existing sections. This task is a reorganization — no content is removed, only restructured. + +### Step 6.1: Read and understand the full settings.html + +- [ ] Read `app/templates/settings.html` in full (it is long — may need multiple reads) +- [ ] Note which sections map to which tab: + - **Overview tab:** System Overview stats, Import Queue Status (with trigger buttons, batch progress) + - **Import tab:** Import Filter Settings, Filter Scan/Delete, Duplicate Detection + - **Maintenance tab:** Thumbnail Regeneration (all + missing), Reapply Artist Tags, Orphaned Tag Cleanup, Celery Worker Status + +### Step 6.2: Add settings tab CSS + +- [ ] Open `app/static/style.css`, find the settings section (search for `.settings-container`) +- [ ] Add: + ```css + /* Settings tab bar */ + .settings-tab-bar { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; + } + + .settings-tab-btn { + padding: 0.4rem 1.1rem; + border-radius: 999px; + border: 1px solid var(--filter-border); + background: var(--filter-bg); + color: var(--filter-fg); + font-size: 0.9rem; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; + } + + .settings-tab-btn:hover { + background: rgba(255, 255, 255, 0.12); + } + + .settings-tab-btn.is-active { + background: var(--filter-active-bg); + border-color: var(--filter-active-border); + color: var(--filter-active-fg); + } + + /* Settings tab panels */ + .settings-tab-panel { + display: none; + } + + .settings-tab-panel.is-active { + display: block; + } + + /* Maintenance tab — subdued cards */ + .settings-tab-panel[data-tab="maintenance"] .settings-container { + opacity: 0.8; + } + .settings-tab-panel[data-tab="maintenance"] .settings-container:hover { + opacity: 1; + } + + /* Two-column layout for Import tab */ + .settings-two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + align-items: start; + } + + @media (max-width: 900px) { + .settings-two-col { + grid-template-columns: 1fr; + } + } + ``` + +### Step 6.3: Restructure settings.html with tabs + +- [ ] Open `app/templates/settings.html` +- [ ] The restructuring strategy: + 1. Replace the page `

` with the tab bar + 2. Wrap existing section groups in `
` + 3. Remove all `style="..."` inline attributes + 4. Wrap Import tab sections in `
` + 5. Add tab JS at the bottom + +- [ ] Replace the opening `

` with: + ```html +

Settings

+ + +
+ + + +
+ ``` + +- [ ] Wrap System Overview and Import Queue Status sections in: + ```html +
+ + +
+ ``` + +- [ ] Wrap Import Filter Settings, Filter Scan/Delete, and Duplicate Detection in: + ```html +
+
+
+
+ + +
+
+
+ ``` + +- [ ] Wrap Thumbnail Regeneration, Reapply Artist Tags, Orphaned Tag Cleanup, and Celery Status in: + ```html +
+ +
+ ``` + +- [ ] Remove all inline `style="..."` attributes from the settings sections (replace with appropriate CSS classes or just remove cosmetic ones that are now handled by the tab/card structure). + +- [ ] Add the settings-title CSS class: + ```css + .settings-title { + text-align: center; + margin-top: 1rem; + margin-bottom: 1rem; + } + ``` + +### Step 6.4: Add tab switching JS at bottom of settings.html + +- [ ] At the very bottom of `settings.html`, before `{% endblock %}`, add: + ```html + + ``` + +### Step 6.5: Verify + +- [ ] Navigate to `/settings` +- [ ] Confirm: three tab buttons visible — Overview, Import, Maintenance +- [ ] Confirm: Overview tab is active by default with System Overview and Import Queue visible +- [ ] Click Import tab — confirm filter settings and tools appear +- [ ] Click Maintenance tab — confirm maintenance tools appear with slightly subdued styling +- [ ] Reload the page while on `/settings#import` — confirm Import tab is restored +- [ ] Confirm: no inline `style=""` attributes remain on settings elements (use browser devtools inspect) + +### Step 6.6: Commit + +- [ ] `git add app/templates/settings.html app/static/style.css` +- [ ] `git commit -m "feat: add tabbed layout to settings page with Overview, Import, Maintenance tabs"` + +--- + +## Deliberately Deferred + +**Selection mode visual consistency** (spec section 2): The spec mentions ensuring the bulk-select checkbox overlay uses semi-transparent styling consistent with the dark visual language. This was deferred from the plan as it has no concrete spec detail. If the current selection highlight appears visually jarring after the other changes ship, address it as a follow-up. + +--- + +## Final Verification + +- [ ] Run through each page: `/`, `/gallery`, modal, `/read/`, `/settings` +- [ ] Check no regressions on mobile viewport (< 768px) +- [ ] Confirm the black/transparent brand is intact throughout — no light surfaces introduced