This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/docs/superpowers/plans/2026-03-19-ui-ux-improvements.md
T
bvandeusen 64b13ac490 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 <noreply@anthropic.com>
2026-03-19 23:47:32 -04:00

38 KiB
Raw Blame History

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

Files:

  • Modify: app/templates/_gallery_item.html
  • Modify: app/static/style.css (.image-date, .play-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 opacity and transition to the existing rule:
    .image-date {
      /* ... existing properties ... */
      opacity: 0.5;
      transition: opacity 0.2s ease;
    }
    
  • Add a hover rule directly after the .image-date block:
    .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:

    .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:

    // 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:
    /* 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 ~160167):
    // 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):

    <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-hint rule (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 #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:
    /* 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:
    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:
    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:

    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:

    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.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
    <!-- /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>
    

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

    // 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/<id>/tags/add and /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.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:
    /* 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 floatingPageIndicator div 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 86233) and confirm that reader.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 in reader.js, add it before proceeding.

  • Remove the entire <script> block at the bottom of reader.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.js with 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 / 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:
    /* 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 <h1> with the tab bar
    2. Wrap existing section groups in <div class="settings-tab-panel" data-tab="overview|import|maintenance">
    3. Remove all style="..." inline attributes
    4. Wrap Import tab sections in <div class="settings-two-col">
    5. Add tab JS at the bottom
  • 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.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/<id>, /settings
  • Check no regressions on mobile viewport (< 768px)
  • Confirm the black/transparent brand is intact throughout — no light surfaces introduced