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 <noreply@anthropic.com>
38 KiB
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):
{% set visible_tags = image.tags | selectattr('kind', 'ne', 'archive') | list %} - Replace with:
{% 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
opacityandtransitionto the existing rule:.image-date { /* ... existing properties ... */ opacity: 0.5; transition: opacity 0.2s ease; } - Add a hover rule directly after the
.image-dateblock:.gallery-item:hover .image-date { opacity: 1; }
Step 1.3: Refine video play overlay
-
Find
.play-overlayinapp/static/style.css(around line 367) -
Replace the existing rule with:
.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 fromshowcase.jscreateImageElement()(line 173) since the glyph now comes from CSS::before. The.play-overlaydiv still needs to be appended — just remove.textContent:// 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.jsgit 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-itemor.masonry-container) - Add these new rules in the masonry/showcase section:
/* 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
createImageElementfunction (around line 143) - Replace the tag overlay block (lines ~160–167):
// 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-hintspan (line 30):<span class="nav-hint">Press R to shuffle</span> -
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-hintrule (or create it if missing):.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
#4appears 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.cssgit 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:
/* 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
distributeImagesfunction (around line 179) - Add a stagger counter and apply it when appending items. Replace:
With:
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')); }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 passanimate = true: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 thedistributeImagescall to passanimate = true:distributeImages(data.images, false, true); // animate on shuffle -
The
loadMore()function should NOT animate (continuous scroll should be seamless):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.jsgit 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-sidebarwrapper around the tag editor content - Add
.modal-sidebar-sectiondividers - Add a toggle button for "Add to Series" collapsed state
- Add
.tag-feedbackspan for in-context feedback
<!-- /app/templates/_gallery_modal.html --> <div id="imageModal" class="modal"> <div class="modal-content"> <button id="modalClose" class="modal-close-btn" title="Close (ESC)">×</button> <button id="modalPrev" class="modal-button modal-prev">‹</button> <button id="modalNext" class="modal-button modal-next">›</button> <span class="modal-close-hint">ESC to close</span> <div class="modal-body"> <div id="modalImageWrapper" class="modal-image-wrapper"> <img id="modalImage" src="" alt="Full View"> </div> <div id="modalTagEditor" class="tag-editor modal-sidebar" data-image-id=""> <!-- SECTION: Series --> <div class="modal-sidebar-section modal-series-section"> <!-- Shown when image IS in a series --> <div id="modalSeriesInfo" class="modal-series-info" style="display: none;"> <div class="series-info-row"> <span class="series-info-label">📚</span> <span class="series-info-label">Page</span> <input type="number" id="modalPageNumber" min="1" class="series-page-input"> <span class="series-info-label">of</span> <a id="modalSeriesName" href="#" class="series-info-link"></a> <button id="updatePageBtn" class="btn-small" title="Update page number">Save</button> <span id="pageUpdateStatus" class="tag-feedback"></span> </div> </div> <!-- Shown when image is NOT in a series --> <div id="modalAddToSeries" class="modal-add-to-series" style="display: none;"> <button id="addToSeriesToggle" class="modal-series-toggle"> <span>📚 Add to Series</span> <span class="toggle-arrow">›</span> </button> <div id="addToSeriesBody" class="add-series-body" style="display: none;"> <div class="add-series-controls"> <select id="seriesSelect" class="series-select"> <option value="">Select series...</option> </select> <input type="number" id="addSeriesPageNumber" min="1" value="1" class="series-page-input" placeholder="#"> <button id="addToSeriesBtn" class="btn-small" disabled>Add</button> </div> <span id="addToSeriesStatus" class="tag-feedback"></span> </div> </div> </div> <!-- SECTION: Tags --> <div class="modal-sidebar-section modal-tags-section"> <div id="modalTagList" class="tags"></div> <form id="modalTagForm" class="tag-form" autocomplete="off"> <div class="tag-form-wrapper"> <input type="text" id="tagInput" name="name" placeholder="Add tag..." autocomplete="off"> <div id="tagAutocomplete" class="tag-autocomplete"></div> </div> <button type="submit">Add</button> </form> <span id="tagActionFeedback" class="tag-feedback tag-feedback-inline"></span> </div> </div> </div> </div> </div> - Add
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:
/* 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: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:
// 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
addToSeriesElis shown (the call toaddToSeriesEl.style.display = 'block'or similar). The current code shows the entire#modalAddToSeriesdiv. The toggle body (#addToSeriesBody) starts collapsed — that's the new default. No change needed to the show/hide logic for#modalAddToSeriesitself; 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 theDOMContentLoadedcallback:// 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
pageUpdateStatususage (where tag save success/failure is set). Replace any direct.textContentassignments onpageUpdateStatuswithshowFeedback(pageUpdateStatus, message). -
Similarly for
addToSeriesStatus— replace direct.textContentwithshowFeedback(addToSeriesStatus, message). -
Find where tags are added/removed (the fetch calls for
/image/<id>/tags/addand/image/<id>/tags/remove). After a successful add/remove, add: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.cssgit 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:
/* 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 %}:<!-- Reading progress bar --> <div id="readerProgressBar" class="reader-progress-bar"></div> -
Find the per-page indicator inside the
{% for page in pages %}loop (around line 57-59):<div class="page-indicator">{{ page.page_number }} / {{ total_pages }}</div>Remove this div entirely (the floating indicator replaces it).
-
The
floatingPageIndicatordiv already exists in the template (lines 73-75):<div id="floatingPageIndicator" class="floating-page-indicator"> <span id="currentPageNum">1</span> / {{ total_pages }} </div>This stays — it becomes the single global indicator.
-
Before deleting: Read through the existing inline
<script>block (lines 86–233) and confirm thatreader.js(Step 5.3) captures all its logic. The key elements are:startPage,totalPages, scroll tracking, page jump, sidebar toggle, thumbnail click, quick nav buttons, keyboard shortcuts. If you find anything not present inreader.js, add it before proceeding. -
Remove the entire
<script>block at the bottom ofreader.html(lines 86-233). Replace it with:<script> window.readerConfig = { startPage: {{ start_page }}, totalPages: {{ total_pages }} }; </script> <script src="{{ url_for('static', filename='js/reader.js') }}"></script>
Step 5.3: Create reader.js — extracted + new features
- Create
app/static/js/reader.jswith the following content:// /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/<tag_id>) - 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 / Nindicators 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.cssgit 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.htmlin 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:
/* 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:
- Replace the page
<h1>with the tab bar - Wrap existing section groups in
<div class="settings-tab-panel" data-tab="overview|import|maintenance"> - Remove all
style="..."inline attributes - Wrap Import tab sections in
<div class="settings-two-col"> - Add tab JS at the bottom
- Replace the page
-
Replace the opening
<h1>with:<h1 class="settings-title">Settings</h1> <!-- Tab bar --> <div class="settings-tab-bar" role="tablist"> <button class="settings-tab-btn is-active" data-tab="overview" role="tab">Overview</button> <button class="settings-tab-btn" data-tab="import" role="tab">Import</button> <button class="settings-tab-btn" data-tab="maintenance" role="tab">Maintenance</button> </div> -
Wrap System Overview and Import Queue Status sections in:
<div class="settings-tab-panel is-active" data-tab="overview"> <!-- System Overview section here --> <!-- Import Queue Status section here --> </div> -
Wrap Import Filter Settings, Filter Scan/Delete, and Duplicate Detection in:
<div class="settings-tab-panel" data-tab="import"> <div class="settings-two-col"> <div><!-- Import Filter Settings --></div> <div> <!-- Filter Scan/Delete --> <!-- Duplicate Detection --> </div> </div> </div> -
Wrap Thumbnail Regeneration, Reapply Artist Tags, Orphaned Tag Cleanup, and Celery Status in:
<div class="settings-tab-panel" data-tab="maintenance"> <!-- All maintenance sections --> </div> -
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:
.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:<script> (function() { const tabBtns = document.querySelectorAll('.settings-tab-btn'); const tabPanels = document.querySelectorAll('.settings-tab-panel'); function activateTab(tabName) { tabBtns.forEach(btn => btn.classList.toggle('is-active', btn.dataset.tab === tabName)); tabPanels.forEach(panel => panel.classList.toggle('is-active', panel.dataset.tab === tabName)); history.replaceState({}, '', `#${tabName}`); } tabBtns.forEach(btn => { btn.addEventListener('click', () => activateTab(btn.dataset.tab)); }); // Restore from URL hash on load const hash = window.location.hash.replace('#', ''); const validTabs = ['overview', 'import', 'maintenance']; if (validTabs.includes(hash)) { activateTab(hash); } })(); </script>
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.cssgit 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/<id>,/settings - Check no regressions on mobile viewport (< 768px)
- Confirm the black/transparent brand is intact throughout — no light surfaces introduced