A read-then-write race in processMediaFile allowed the same file to be
imported twice: two concurrent scan jobs (startup scan, fsnotify dirty-
directory scan, periodic backup poll, or a manual scan each run on
separate worker goroutines with separate MediaScanner instances) could
both SELECT 'not found' and both INSERT. There was no transaction, no
row lock, no unique constraint on (library_id, file_path), and no
ON CONFLICT clause, so nothing stopped the double insert. Observed in
production as two identical 'Head First SQL' rows created in the same
second (same sha256, size, path, library).
Database enforcement:
- schema.sql: add UNIQUE(library_id, file_path) constraint, guarded so
re-runs don't error
- schema.sql: add self-healing migration that runs on every startup -
dedup_media_items_by_path() collapses existing path-duplicates and
reparent_media_item_children() moves all child rows (progress,
highlights, bookmarks, notes, collections, formats, aliases, kobo
entitlements, etc.) onto a survivor before deleting losers, so the
constraint applies cleanly on already-duplicated servers without
losing reading history. Survivor picks the row with the most user
data, ties broken by lowest id
- CreateMediaItem: upsert via ON CONFLICT (library_id, file_path) DO
UPDATE so concurrent inserts collapse to one row and return it
- CreateMediaItemFormat: upsert via ON CONFLICT (media_item_id,
format_type), closing the same race on format rows
Application-level guards:
- media_scanner processMediaFile: after computing the file hash, check
GetMediaItemBySHA256AndLibrary (new query) and treat the file as
existing when identical content is already in the library under a
different path (content dedup, library-scoped so multi-library
setups still work)
Ops tooling:
- scripts/dedup_media_items.sql: standalone idempotent maintenance
script with a dry-run report (path + content duplicate groups, child
row counts) and transactional cleanup, for servers that prefer to
dedup manually before upgrading
Verified against the live database: the duplicate pair was collapsed
(reading_progress preserved on the survivor), schema.sql re-runs are a
no-op, and the constraint is in place with 62 unique books remaining.
Switch the @bookhoard/foliate-js dependency from the github: shorthand
(d164d6f) to the explicit git+https URL form (d4d87a9). The newer
revision is required by the double-page-spread support (renderer
'spread' attribute) and the explicit URL form resolves more reliably
across npm/podman builds.
The double_page_spread checkbox in the reader settings panel was inert:
it had no Alpine binding, no apply logic, and no persistence. Default
was also inconsistent (false in settings-manager, absent from server
defaults).
- Add doublePageSpread state to the reader Alpine component, loaded
from saved settings (default true)
- Add applyDoublePageSpread() which sets the renderer's 'spread'
attribute to auto/none and persists the setting via saveSettings
- Apply the spread attribute during fixed-layout renderer init
- Bind the settings checkbox with x-model and @change
- Add double_page_spread: true to ReaderService server defaults so
new users get the same starting value the client expects
- Also improve the PDF pan/select toolbar button: distinct smart-
select vs pan icons, highlighted state while pan mode is active,
and dynamic tooltips/aria-labels explaining each mode
The scan-complete handler in dashboard.ts attempted to deduplicate book
cards by querying [data-media-item-id], but neither the client-side
renderBookCard nor the server-side BookCard template ever set that
attribute. As a result the dedup Set was always empty, every item from
the API response was treated as new, and all items were prepended via
insertAdjacentHTML('afterbegin', ...) on every 5-minute scan — causing
visible duplication (doubling, tripling) that only cleared on page
refresh.
Fix by replacing the fragile dedup-and-prepend logic with a per-track
full innerHTML replace. This is simpler, correctly handles items that
should be removed after a scan (the old code never removed anything),
and also removes stale sections no longer returned by the API.
Additional hardening:
- Add data-media-item-id to both renderBookCard (dashboard.ts) and the
server-side card wrapper (dashboard.templ) so server-rendered and
JS-rendered cards are structurally identical.
- Guard the bookhoard:scan-complete listener registration with a
module-level boolean (scanListenerRegistered) so the handler cannot
accumulate if Alpine ever re-inits the body subtree.
- Remove debug console.log statements from the scan handler.
Sidebar appearance menu improvements:
Accordion behavior:
- Lift panel open/close state to a shared 'openPanel' variable on the
parent container so only one sidebar panel (User, Appearance, Admin,
Sign In) can be open at a time; all can be closed.
- Admin panel still auto-opens on /admin/* pages via initial state.
- Add chevron rotation to User and Appearance panels (previously only
Admin rotated); add a chevron to the Sign In panel for consistency.
Wood texture previews:
- Generate 48x48 WebP thumbnails (~200 bytes each) from the full-size
PNG textures (873 KB – 1.9 MB) so the bookshelf option circles show
the actual wood grain instead of a flat grey dot.
- Use unquoted url() in the inline style to avoid templ's double-HTML
escaping of single quotes (SanitizeStyleAttributeValues + EscapeString
turned url('...') into url('...) which is invalid CSS).
- 'None' keeps the flat neutral circle.
CSS resilience:
- Move the wood background-image: url() rules from the compiled
style.css into input.css (the Tailwind source) so they survive CSS
rebuilds instead of being silently lost.
The checkmark in the Appearance theme menu was rendered server-side
(if user.Theme == opt.Name), so it never moved after switching themes
in the browser.
- Always render the check for every theme option, hidden by default,
using a new themeCheckClass(name, current) helper that returns the
hidden class unless the option is the active theme.
- Give each theme button a data-theme attribute and add
updateThemeIndicators() to web/src/theme.ts, which reads the applied
theme from the body class (theme-<name>) and toggles the hidden class
on each check accordingly.
- Call updateThemeIndicators() from changeTheme (before the async save
and on failure), initializeTheme, and loadUserTheme so the menu stays
in sync with the applied theme.
- Add unit test for themeCheckClass (templates/utils_test.go) and
regenerate templ output.
Bring the tighten-ui brand treatment into new-ui:
- Replace the emoji book (📚) in the sidebar header with the book-open
icon rendered in the theme accent color, matching the tighten-ui
header brand (templates/header.templ).
- Add web/static/favicon.svg (book-open glyph, tokyo-night accent
#7aa2f7 stroke) and reference it from the <head> of all 27 page
templates, so the favicon is present on login/setup/error pages too.
- Regenerate templ output for all affected templates.
CleanupExpiredRefreshTokens and CleanupExpiredOpdsTokens were generated
by sqlc but never invoked anywhere in the codebase, so expired/revoked
tokens accumulated in the database indefinitely. The refresh-token query
was parameterized in the settings-registry work specifically so its
retention window could follow the configurable session duration, but the
periodic caller was never wired up.
annotations.go:
- Rename StartTombstonePurger to StartDailyMaintenance, which now runs
all periodic cleanup tasks from a single 24h-tick goroutine.
- Add runDailyMaintenance helper: tombstones, then OPDS tokens, then
refresh tokens, each logging independently so one failure never skips
the others.
- Refresh-token retention is read from the registry (SessionDuration)
on every tick so live admin edits are honored; guarded on the registry
being wired so unwired test paths simply skip cleanup.
- All three queries only delete rows that are already expired or
revoked, so active sessions are never logged out.
main.go:
- Update the call site: tombstonePurgerCancel becomes maintenanceCancel
and calls StartDailyMaintenance.
Net footprint: still one goroutine and one ticker; the cleanup adds one
DELETE per table per day.
Replace the read-only "System Information" card (which listed hardcoded
values) with editable HTMX forms, organized so the live vs restart
distinction and related settings are visually clear.
admin_settings.templ:
- AdminSettings signature now takes liveGroups and restartGroups
([]SettingGroup) instead of a flat entry list.
- Remove the static System Information list. Render two cards: "Live"
(green, applies immediately) and "Restart Required" (warning header,
saved but only takes effect after restart).
- Within each card, TunableSettingsSection clusters entries into
labeled sub-sections by Group (e.g. "Password Quality", "Device Rate
Limits", "Login Lockout", "Worker Pool") with uppercase tracked
sub-headers.
- TunableSettingRow renders an inline HTMX form per setting: a Yes/No
select for bools, a number input with min/max for ints, text
otherwise, posting to /admin/settings/tunable. Rows show "modified
from default" when the value differs from the compiled default.
types.go:
- Add SettingEntry (template-local mirror of database.SettingEntry,
keeps templates from importing database) and SettingGroup.
utils.go:
- Add GroupTunableSettings: splits a flat, group-sorted entry list into
live and restart []SettingGroup buckets preserving source order.
utils_test.go covers the multi-group + empty cases.
frontend.go:
- The /admin/settings page handler now loads entries from the registry,
drops the three keys that have dedicated UI cards (default_timezone
dropdown, scan_poll_interval_seconds, auto_scan_enabled) so they are
not listed twice, groups the rest, and passes liveGroups/restartGroups
into the template.
Construct the SettingsRegistry at boot, load it, and thread it through
every consumer so the configurable values take effect and stay cached.
cmd/server/main.go:
- Build the registry from the Queries handle and Load() it right after
schema init; a load failure logs and continues (getters fall back to
compiled defaults, so startup is never blocked).
- Wire the registry into the package-level password validator
(SetDefaultPasswordSettings) and call SetSettings on every handler/
service that reads tunables: AuthHandler, DeviceAuthMiddleware,
OPDSHandler, SidecarHandler, SystemSettingsHandler,
AnnotationService, ConversionService.
- Source the restart-time values from the registry: login lockout
(max attempts + duration) feeds NewLoginAttemptTracker, and the new
NewSyncQueueProcessorWithConfig / NewWorkerWithConfig take the sync
queue and worker pool configs.
router.go:
- Config gains a Settings *database.SettingsRegistry field.
- The global auth rate limiter now reads RequestsPerMinute from
registry.AuthRateLimit() (env stays as the enabled/disabled switch
and as the fallback if the registry is unset).
admin_library.go:
- The HTMX scan-settings save endpoint reloads the registry after
writing so the change is visible without a page reload.
- Add PUT /admin/settings/tunable: a small HTMX endpoint that calls
SystemSettingsHandler.ApplySetting and returns a colored status
snippet ("Saved" or "Saved — restart required") for the admin UI's
per-row forms.
Add a single pair of admin-only endpoints that supersede the scattered
scan-settings JSON routes as the canonical way to read and write
tunable system settings. Existing legacy routes are kept working for
backward compatibility and now refresh the registry cache on write.
system_settings.go:
- GET /api/system/settings returns every known setting with full
metadata (value, type, min, max, requires_restart, category, group,
description, is_default) via SettingsRegistry.All().
- PUT /api/system/settings accepts {key, value}; ApplySetting() looks
up the compiled Default for the key, runs type-aware validation
(int range, bool parse, non-empty string, timezone via
time.LoadLocation), upserts via UpsertSystemSetting, reloads the
registry, and reports whether a restart is needed for the change to
take full effect. Shared by the JSON endpoint and the HTMX endpoint.
- Legacy UpdateScanSettings / GetScanSettings / UpdateTimezoneSettings
now reload the registry after writing and prefer the registry when
reading, so the cache stays consistent regardless of entry point.
sidecar.go:
- SidecarHandler gains an optional registry; the timezone branch of
UpdateSystemConfiguration (PUT /api/system/config) calls
settings.Reload() after the write so the new value is visible
immediately. base_url handling is unchanged.
system.go:
- Register GET/PUT /api/system/settings under the existing admin
/api/system group.
Split each constructor into a default-args wrapper and a config-accepting
variant so the sync queue interval/batch size and the worker pool size/
queue cap can be sourced from the settings registry at startup. These
values are constructed once at boot, so they are tagged requires_restart
in the admin UI.
queue.go:
- NewSyncQueueProcessorWithConfig(db, interval, batchSize) takes the
flush interval and batch size as parameters; NewSyncQueueProcessor
becomes a thin wrapper with the historical 5s / 50 defaults.
worker.go:
- NewWorkerWithConfig(numWorkers, queueCap, connManager) takes the
queue capacity as a parameter; NewWorker becomes a thin wrapper with
the historical cap of 100.
No behavior change for existing callers; main.go will switch to the
config-accepting variants in a follow-up wiring commit.
The 30-day retention window for soft-deleted annotations was a package
const; move it behind the registry so it can be tuned live.
annotations.go:
- AnnotationService gains an optional *database.SettingsRegistry and a
tombstoneTTL() helper. The skip-resurrect checks and the purge cutoff
now call it instead of reading the TombstoneTTL const directly.
- Add ActiveTombstoneTTL() so callers outside the sync package can
compute cutoffs consistently with the service.
- The package-level TombstoneTTL const is retained as the fallback for
tests / unwired code paths.
kobo.go, koreader.go:
- The per-book tombstone sweep cutoff now uses
h.annotationSvc.ActiveTombstoneTTL() instead of the wsync.TombstoneTTL
const, so both the service and the handlers honor the configured TTL.
Move three more hardcoded values behind the settings registry. All
apply immediately on the next request (no restart needed).
device_auth.go:
- DeviceAuthMiddleware reads per-route device rate limits (sync /
progress / metadata per minute) from the registry on each
authenticated request via a rateLimitConfig() helper, falling back to
the Default* constants when no registry is wired.
- The X-RateLimit-Limit response header previously hardcoded "60" for
every request type; it now reflects the actual configured limit for
the request type via rateLimitForRequestType().
opds.go:
- Default (50) and maximum (200) OPDS page sizes come from the
registry's OpdsDefaultPageSize()/OpdsMaxPageSize() instead of inline
literals, so catalog pagination can be tuned without a redeploy.
conversion_service.go:
- The 24h kepub cache lifetime is read from the registry via a
cacheTTL() helper (was a bare 24 * time.Hour literal in the
constructor). The field default is retained for tests that construct
the service directly.
- conversion_service_test.go updated to assert both the field default
and the cacheTTL() accessor return 24h.
Replace the hardcoded 7-day session lifetime and fixed password
complexity rules with registry-backed accessors so they can be tuned
from the admin UI without a code change.
auth.go:
- Drop the SessionDuration const; keep DefaultSessionDuration (7 days)
as the fallback used when no registry is wired (e.g. in tests).
- AuthHandler gains an optional *database.SettingsRegistry and a
sessionDuration() helper that reads the registry, falling back to
DefaultSessionDuration.
- Cookie MaxAge, JWT exp claim, and ExpiresIn responses now derive from
sessionDuration() instead of the package-level SessionDurationSec, so
a settings change takes effect on the next login.
refresh_token.go:
- Refresh-token lifetime follows sessionDuration() via a new
refreshTokenTTL() helper (was a separate refreshTokenExpiration const
that silently had to be kept in sync with the session duration).
password_validator.go:
- PasswordValidator now reads min length and the upper/lower/number/
special toggles from the registry at validation time, so rule
changes apply immediately. The special-character regex is compiled
once and reused (sync.Once).
- GetPasswordRequirements() and ValidatePassword() reflect the active
configured rules instead of a static list.
- Add SetDefaultPasswordSettings() so the package-level default
validator (used by echo's struct-tag validator) follows live config.
All paths degrade gracefully to the historical defaults when no
registry is wired.
Add a typed, cached registry over the system_settings table so that
values which used to be hardcoded Go literals can be changed at runtime.
Schema (database/schema/schema.sql):
- Extend system_settings with setting_type, min_value, max_value,
requires_restart, and category columns (all ADD COLUMN IF NOT EXISTS,
nullable for backward compat with the original three rows).
- Seed rows for every tunable: session duration, password rules,
login lockout, auth/device rate limits, OPDS page size, tombstone TTL,
conversion cache TTL, sync queue interval/batch, and worker pool
size/cap. Seed values equal the previous hardcoded literals, so
behavior is unchanged on upgrade. ON CONFLICT DO NOTHING preserves
any admin-modified values.
Queries (queries.sql):
- Add UpsertSystemSetting (RETURNING *) so new keys without a seed row
can still be written through the API.
- Add GetSystemSettingFull + GetAllSystemSettingsFull returning the
full typed row.
- Refactor CleanupExpiredRefreshTokens to take the retention window as
a parameter (make_interval(secs => $1)) instead of the INTERVAL '7
days' literal, so it can follow a configurable session duration.
Registry (internal/database/settings_registry.go):
- SettingsRegistry holds an in-memory cache of all known settings,
populated by Load at startup and refreshed by Reload on writes.
- Typed domain getters (SessionDuration, PasswordRules, DeviceRateLimits,
TombstoneTTL, OpdsPageSize, ConversionCacheTTL, SyncQueueConfig,
WorkerPoolConfig, LoginLockout, AuthRateLimit, ...) with compiled-in
fallback defaults and min/max clamping, so a corrupt or missing row
can never break the app.
- SettingDefaults is the single source of truth for keys, types, bounds,
and human descriptions; All() exposes metadata + current values for
the admin UI/API.
The registry lives in the database package (rather than its own
internal/settings package) because a quirk in this custom go1.26.5
toolchain prevented the large handlers package from importing any
newly-created package; every consumer already imports database.
Tests: settings_registry_test.go covers default validity per type,
int clamping at both bounds, garbage-value fallback, and unknown-key
lookup.
Convert string-interpolation attributes (value="{ x }") to templ
expression attributes (value={ x }) for IDs, paths, and titles, and
fix indentation in header.templ and progress.templ.
- Admin section in sidebar now uses same expandable panel pattern as
Appearance and User sections (toggle button with chevron, auto-opens
when on /admin pages)
- Library Manage button toggles open/close instead of only opening
(uses htmx.ajax for open, clears panel for close)
Alpine doesn't ship the IANA timezone database, causing
time.LoadLocation('America/New_York') to fail with 'Invalid timezone'
for every non-UTC option in the profile settings dropdown.
- Add gear icon (proper cog) and use it for device settings button
- Replace KOReader manual identifier input with step-by-step plugin
setup instructions (clone repo, enter server URL, approve pending reg)
- Show server URL with copy button pre-filled from baseURL
- Kobo keeps manual registration flow (device name + identifier)
- Comment out Web Browser and Mobile App options (not implemented)
- Use Alpine x-model on device-type select to toggle between
KOReader instructions and Kobo registration form
GetAllProgressData (SSR handler) was missing LastUpdated, DeviceIcon,
DeviceName, DeviceType, and EpubCFI fields that the template expects.
All showed blank. Now matches the API handler's field population.
Three bugs fixed:
1. Schema seeded base_url with fake placeholder 'bookhoard.example.com'.
Removed seed; startup now seeds from BASE_URL env var only if DB row
is empty (admin changes persist across restarts). One-time UPDATE
clears the placeholder in existing installs.
2. config.GetBaseURL() had a broken type assertion (local SystemConfigRow
vs database.SystemConfig) that always failed, returning . Admin panel
showed env var fallback instead of actual DB value. Fixed with a
function-type getter that properly wraps the DB query.
3. OPDS handler read base_url only from DB with no fallback. When DB had
the placeholder, all feed links pointed to an unreachable domain,
breaking KOReader search/download. Added deriveBaseURL() helper that
falls back to the request Host/scheme when DB value is empty.
Setup gate improvements:
- isSetupComplete now requires both admin user AND non-empty base_url
- Setup middleware no longer exempts all /api/ routes; only allows
/api/auth/register, /api/auth/login, /api/system/config before setup
is complete. All other API routes get 503.
- Cache invalidated when base_url is saved via admin settings
Dev workflow:
- New bruno/NewDevDBSetup/SetBaseUrl.yml for dev DB setup
- NewDB.sh runs SetBaseUrl between RegisterUser and CreateEbookLibrary
Three root causes, all fixed:
1. Icon buttons were created with setAttribute('onclick', ...) in
populateIconGrid, but selectIcon is module-scoped (not on window),
so clicking threw ReferenceError. Switch to addEventListener with
a closure. Icon search/focus used plain oninput/onfocus attributes
with the same problem — convert to Alpine @input/@focus.
2. selectColor's highlight selector queried [onclick="selectColor(...)\]
System collections (Not Started, Continue Reading, etc.) compute
their contents dynamically from reading_progress, so manual
add/remove has no effect. Hide the search bar, Remove Selected
button, Add Books button, per-card checkboxes, and Remove buttons
when the collection is system, making the page visually read-only.
- Add IsSystem bool to CollectionData, populated from the database
is_system_collection flag.
- Add data-is-system to #collection-data so the JS renderer can
also conditionally omit controls on library switch.
- Wrap toolbar controls, book picker modal, card checkboxes, and
remove buttons in if !collection.IsSystem in the template.
Three fixes for the collection detail page:
1. Picker modal never showed because the outer overlay div had
style="display:none" with no x-show binding — add x-show bound
to $store.bookPicker.isOpen plus a backdrop and click-to-close.
2. Replace native confirm() with an in-page Alpine modal for UI
continuity. Add confirm dialog state (showConfirm, confirmMessage,
pendingAction) and methods (requestRemoveBook, requestBulkRemove,
executeConfirmed, closeConfirm) to the collections component.
The actual API calls (doRemoveBook/doBulkRemove) are triggered only
when the user confirms.
3. Rename removeBook → requestRemoveBook and bulkRemove →
requestBulkRemove in template + JS renderer so the dialog opens
instead of navigating or failing silently.
The /collections/:id page had several broken features because three
referenced functions (removeBook, toggleBookForRemoval,
filterCollectionBooks) were never defined, and every book card was
wrapped in <a href="/media/..."> so clicking the checkbox or remove
button navigated to the book detail page instead.
Card restructure:
- Remove the <a> wrapper; title and cover are now individual links.
- Checkbox sits in a <label> with expanded click area (p-2 -m-2).
- Checkbox uses Alpine :checked/@change bound to a reactive
selectedBooks array on the collections component.
Remove (single + bulk):
- Add removeBook(id) and bulkRemove() methods with confirm() dialogs.
- Wire the "Remove Selected" button with :disabled binding and @click.
- Selected-count badge is now Alpine-reactive (x-show/x-text).
Search within collection:
- Add filterCollectionBooks() that filters cards client-side by
title/author via data-* attributes and @input.
Book picker ("Add Books"):
- Point the HTMX search inputs at the existing /api/media-items/search
endpoint instead of the non-existent /api/media-items/filtered.
- Add hx-trigger="loadBooks" + hx-get to the grid so loadBooks()
actually fires an initial request when the picker opens.
- Merge the hidden limit/offset inputs into the #book-picker-filters
div so hx-include picks them up (was a separate <form id=filter-form>
that nobody referenced).
- Add show_checkbox mode to handleSearchHTML: when present, render a
new BookPickerGrid template with clickable, selectable cards instead
of the reader BookCard.
- Fix bookPicker submit() to location.reload() instead of a non-existent
reloadCollection HTMX event, and clearFilters() to target text inputs.
The reader's back button was hardcoded to the book detail page
(/media/{id}), so even when you launched the reader straight from the
dashboard the back button ignored that and sent you to the detail view.
Mirror the existing book-detail.ts referrer pattern: capture
document.referrer into sessionStorage on load (excluding other reader
pages and the reader's own URL), then override the back link's click to
navigate there. Falls back to the link's original href (/media/{id}) when
no valid referrer exists (direct URL access).
The dashboard re-renders its sections client-side (library switch,
refresh, saving settings) via renderBookCard in dashboard.ts, which was
still the old markup with no .book-card-action overlay. So the read
button appeared on the server-rendered cards but vanished as soon as the
dashboard re-rendered, while the bookshelf (always templ-rendered) kept
working.
- Rewrite renderBookCard to match the templ BookCard: detail link plus
the play/read action overlay, routing to the reader or the detail page
when the book has an active conflict.
- Add has_conflict to the BookInfo TS type and stamp it in the dashboard
sections API (GetSections) so client-rendered cards can route correctly.
- Add pointer-events-none / group-hover:pointer-events-auto to the
client-rendered carousel nav buttons so they no longer swallow hover
over edge cards, matching the templ fix.
The settings icon is a circle with radiating spokes, which reads as a
sun/light-mode toggle rather than a dashboard control. Swap it for the
grid icon, the standard dashboard-layout affordance.
The account and appearance menus sat in one container with no separation,
so expanding one made the other hard to find. Each collapsible menu now
lives in its own subtly lifted, bordered panel (.sidebar-panel) so the
expanded items stay visually contained and the menus never blend together.
Applied to the account, appearance, and sign-in menus.
The play button on book cards now opens the reader directly, instead of
always going to the detail page. Cards with an active progress sync
conflict route the play button to the detail page (which hosts the
conflict dialogue and resolves before writing progress), so the user is
never silently dropped into the reader with an unresolved conflict.
Backend:
- Add HasConflict to BookInfo and stamp it via ListSyncConflictsByUser
(MarkActiveConflicts / MarkActiveConflictsSections) on the dashboard,
bookshelf, series, tag, and search result card builders.
- Each page issues a single conflict query regardless of card count.
BookCard:
- Restructure into a detail link (cover + meta) with the play action as a
sibling overlay using a pointer-events split: the container passes
clicks through to detail while only the circular button routes to the
reader. No nested anchors.
- On touch devices (hover: none) the play button stays visible.
Fix: carousel nav buttons had opacity-0 without pointer-events-none, so
they swallowed hover/clicks over book cards on the dashboard. They are
now click-through until the carousel is hovered.
On phones the footer's progress cell rendered the full chapter title (e.g. 'Long Chapter Name · 5 / 12') in a div with no max-width or nowrap, so the text wrapped to multiple lines and ballooned the bottom bar. Combined with viewport offsets that were computed from assumed pixel heights with ~0px margin, the book text slipped underneath the bars.
Chapter label in footer: split #progress-display into two spans (progressLabel hidden on mobile via 'hidden sm:inline', progressMain always shown) and cap it with 'truncate whitespace-nowrap max-w-[5rem] sm:max-w-none' so it can never wrap or grow the bar. Phones now show just '5 / 12'; larger screens keep 'Chapter · 5 / 12'.
Viewport offset: replace the fragile hardcoded calc() with runtime measurement. Gave the chrome bars ids (reader-topbar/reader-bottombar) and added updateViewportInsets(), which sets #reader-viewport top/bottom from each bar's real offsetHeight (which already includes env(safe-area-inset-*) padding) plus a 6px margin. It runs on init and refreshes on resize, orientationchange, and via a ResizeObserver, so the content area tracks the actual chrome height on any DPI, notch, home-indicator, or zoom level instead of guessing.
Refactored formatProgress into formatProgressParts (returns {label, main}; only chapter mode sets a label) with a setProgress() helper wiring progressLabel/progressMain/progressText across the relocate, cycleProgressMode, and applyProgressMode call sites. Rebuilt reader_templ.go and style.css.
On phones the footer's progress cell rendered the full chapter title (e.g. 'Long Chapter Name · 5 / 12') in a div with no max-width or nowrap, so the text wrapped to multiple lines and ballooned the bottom bar. Combined with viewport offsets that were computed from assumed pixel heights with ~0px margin, the book text slipped underneath the bars.
Chapter label in footer: split #progress-display into two spans (progressLabel hidden on mobile via 'hidden sm:inline', progressMain always shown) and cap it with 'truncate whitespace-nowrap max-w-[5rem] sm:max-w-none' so it can never wrap or grow the bar. Phones now show just '5 / 12'; larger screens keep 'Chapter · 5 / 12'.
Viewport offset: replace the fragile hardcoded calc() with runtime measurement. Gave the chrome bars ids (reader-topbar/reader-bottombar) and added updateViewportInsets(), which sets #reader-viewport top/bottom from each bar's real offsetHeight (which already includes env(safe-area-inset-*) padding) plus a 6px margin. It runs on init and refreshes on resize, orientationchange, and via a ResizeObserver, so the content area tracks the actual chrome height on any DPI, notch, home-indicator, or zoom level instead of guessing.
Refactored formatProgress into formatProgressParts (returns {label, main}; only chapter mode sets a label) with a setProgress() helper wiring progressLabel/progressMain/progressText across the relocate, cycleProgressMode, and applyProgressMode call sites. Rebuilt reader_templ.go and style.css.
- Dashboard: page heading, bold section headers with accent icon tiles,
carousel chevrons as SVG icons with theme-aware gradients (wood-paneling
gradient classes preserved), and a cinematic BookCard (hover overlay with
a quick-action button). BookCard is now fluid so it fills both the
carousel slot and the bookshelf grid.
- Bookshelf: the filter wall becomes a search toolbar + a slide-in filter
drawer (filtersOpen state added to the bookshelf Alpine component). Every
filter input, the tristate cover toggle, tag autocomplete, save/load/clear
actions, HTMX search/sort, and pagination are preserved.
- Book detail: blurred cover backdrop hero, rounded-2xl cover, bold
typography, .btn action bar, progress/metadata cards, chip-style external
links. All interactive rating, modals, and data-attrs preserved.
- Auth/landing: brand-gradient hero for index/login/register, icon feature
cards, data-driven theme select (ThemeOptions). All ids (#theme-select,
#result, #auth-result, password-requirement ids) and Alpine init preserved.
Replace the top-nav with a fixed left sidebar + slim content topbar
(the Komga/Audiobookshelf layout), the signature change versus the
conservative tighten-ui branch.
- App shell: .app-sidebar (off-canvas on mobile via Alpine mobileMenuOpen,
pinned at 16rem on lg), .app-topbar (fixed, blurred, 4rem), and
.app-subbar for in-page sticky bars (parks under the topbar). Content is
auto-offset via body:has(.app-sidebar) so reader.templ/error.templ (which
have no sidebar) are untouched.
- Header rebuilt as the sidebar: logo + vertical nav (activeClass), an
inline Appearance picker driven by ThemeOptions/WoodOptions, and an
inline account menu / sign-in (preserving the inline htmx login). The
search lives in the topbar so its dropdown still anchors correctly.
- Bolder primitives: .card -> rounded-2xl, cinematic .book-card-cover
hover overlay with a quick-action affordance, .brand-gradient hero
surface, .hero-backdrop (blurred cover) and .stat-card utilities.
Add semantic design tokens and base primitives to replace the verbose
inline var() styling that made the UI feel dated.
- Rename colliding Tailwind color tokens (bg-primary/bg-secondary) to
semantic names (surface/surface-raised/content/content-muted/brand/line)
- Derive hover, overlay, border-strong, accent-muted and elevation tokens
once on <body> so they adapt to every theme automatically
- Make :root mirror Tokyo Night to kill the first-paint theme flash
- Add component primitives in @layer components: .card (surface + soft
shadow, no hard border), .btn variants, .input, .chip, .badge, .icon-btn
- Add theme-aware status/priority badges and global focus-visible styling
- Add an inline-SVG Icon() component (consistent stroke language) to
replace the mixed emoji/SVG iconography
- Data-drive theme/wood options and add an active-nav helper
- Add skeleton shimmer + x-cloak support
The reader chrome used the same dimensions at every screen size, and the book viewport offset was hardcoded to 52px. This made the header/footer oversized on phones and left the body text flush against (or overlapping) the bars, with no handling for notched-device safe areas.
- Add viewport-fit=cover so notched devices expose safe-area insets.
- Shrink the top bar on mobile (px-3 py-2 / text-base, scaling up at sm:) and hide the chapter title on phones (hidden sm:block sm:truncate).
- Shrink the bottom bar on mobile (tighter padding/gap, p-1.5 sm:p-2 on buttons) while keeping all controls visible.
- Replace the hardcoded top-[52px] bottom-[52px] viewport offsets with responsive calc() values (44/48px mobile, 60px at sm:) that fold in env(safe-area-inset-*), plus matching safe-area padding on the bars, so the book content always clears the chrome with a visible margin.
Regenerates reader_templ.go and rebuilds style.css.