Skip to content

Shared reading preference + author-page filter bar (results-toolbar Phase 0–1)#12949

Draft
lokesh wants to merge 6 commits into
internetarchive:masterfrom
lokesh:toolbar/feat/results-toolbar-phase-0-1
Draft

Shared reading preference + author-page filter bar (results-toolbar Phase 0–1)#12949
lokesh wants to merge 6 commits into
internetarchive:masterfrom
lokesh:toolbar/feat/results-toolbar-phase-0-1

Conversation

@lokesh

@lokesh lokesh commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

What this does

Lays the foundation for a shared, capability-driven results toolbar across Open Library's book-listing surfaces, and ships the first real consumer: a live filter bar on author pages.

  • Phase 0 — promotes availability + language from per-search UI state into a durable, global reading preference, and adds a CI guard against the duplicated availability taxonomy drifting between Python and JS.
  • Phase 1 — promotes the author page's read-only facets + "Show only ebooks?" link into an actual filter bar (availability toggle + language popover), reusing the /search filter-bar controller.

This is intentionally scoped as a proof-of-concept for the broader effort; subject pages, lists, reading log, and the extraction of a single <results-toolbar> component are follow-ups (see Roadmap).


How we think about filter/sort state

The central design question this PR answers: when a user sets a filter on one listing surface, where else should it apply, and for how long? The guiding principle:

Stickiness should track context-independent intent, never context-dependent scope — and a sticky value may only be inherited onto a surface whose toolbar visibly renders the control that reflects it.

Intent vs. scope

  • Availability ("Readable only", "Borrow online", "Free to read now") and language are intent — they describe how a person reads and what they can read. That intent is the same whether you're on search, an author page, or (later) a subject page. So it's durable and shared.
  • Facets (subject, publisher, year, author) are scope — they only mean something within one listing context. "Subject: cooking" is meaningless to carry from a search onto an author page. So facets are never shared and never stored — they live in the URL only.

What's shared vs. not, and across what span

State Shared across surfaces? Persists across sessions? Stored where
Availability ✅ yes — one global preference ✅ yes localStorage (ol-reading-pref-availability)
Language ✅ yes — one global preference ✅ yes localStorage (ol-reading-pref-languages)
Facets (subject/publisher/year/…) ❌ never ❌ never URL query params only
Sort / view / pagination per-surface within a context (URL) URL query params

Availability + language are a single durable reading preference: set it anywhere it's offered, and it's read everywhere it's offered, including on a return visit. We moved this state from sessionStorage (per-tab) to localStorage (per-device) and renamed the keys from surface-specific (ol-header-search-*) to a neutral global namespace (ol-reading-pref-*), because the value is owned by no single surface.

Two guardrails that make global, cross-session stickiness safe

A durable filter that silently prunes results with no visible cause is a footgun. Two rules prevent that:

  1. Visibility requirement — a stored preference is only inherited onto a surface that renders the control reflecting it. We never apply a filter the user can't see is on. (Consequence: a single-work author with no active filter has nothing to filter, so the bar — and any inheritance — is suppressed.)
  2. Empty-results escape hatch — when an inherited/active filter matches zero works, we show an inline "Show all N works?" that clears the filters back to the full bibliography (with its real size), rather than implying the author has none. Clearing wipes the global preference too, so "show all" actually sticks.

The capability model (where this is heading)

Each surface's toolbar is the intersection of two layers:

  • Scheme capability (static) — what the backend can do. Solr-backed surfaces (search, author, subject) get the full sort + facet vocabulary; DB-backed surfaces (lists via Infogami seeds) can only offer what the DB sorts cheaply.
  • Instance capability (dynamic) — given this result set, which controls would actually change anything. A sort over one item, or a language popover where every work is English, is a no-op and shouldn't render.

Rendering the intersection is what makes the single-work / small-N / uniform-facet cases fall out of one rule instead of per-page special-casing. This PR implements the visibility half of that on author pages; the full descriptor lands when the component is extracted.


De-duplication

AVAILABILITY_TO_PARAMS (the availability → Solr-param mapping) genuinely runs in two runtimes — Python (worksearch/code.py, server-rendered URLs + active-filter detection) and JS (constants.js, client-side URL building with no round-trip). A single shared literal isn't practical, so instead of the old "keep in sync" comment, test_availability_sync.py parses both copies from source and fails CI on any drift (and checks the availability value set is consistent on both sides).


Commits

  • Phase 0 — durable global reading preference: storage sessionStoragelocalStorage, global key namespace, centralized read/write helpers shared by the modal + filter row, and the Python/JS taxonomy sync test.
  • Phase 1 (server)works_by_author accepts availability + language, merged into the Solr query exactly as /search does; Author.get_books reads them off the URL.
  • Phase 1 (UI) — author-page filter bar; SearchFilterBar.js generalized from a hardcoded /search to the current page's path so one controller drives both surfaces; visibility rule + escape hatch.
  • Fixes — escape hatch clears the stored preference (not just the URL) so it doesn't re-inherit on reload; the Readable Only toggle renders its on/off state server-side so it paints in its final position instead of animating on after load.

Verification

  • 469/469 JS unit tests pass; new coverage for the storage round-trip and the works_by_author param merge.
  • test_availability_sync.py + test_worksearch.py pass in Docker.
  • pre-commit (ruff / mypy / eslint) clean; POT regenerates with the new strings.
  • Exercised against a running instance: filters reach Solr, the escape hatch fires with the real total and clears correctly, the single-work visibility rule holds, and the toggle reflects state on first paint.

Roadmap (not in this PR)

  • Phase 2 — extract a single <results-toolbar> web component (composing the segmented view control + sort dropper + availability toggle + language popover) driven by the capability descriptor, now that /search and author pages share the pattern.
  • Phase 3 — subject pages (carousel → filterable grid).
  • Phase 4 — lists & reading log (DB-backed: only the sorts the DB does cheaply + client-side filtering; no faceting until list membership is indexed).

Notes for reviewers

  • The author page's subject/people/place chips still link out to /subjects/ — promoting those to in-place facet filters is deliberately out of scope here.
  • Clicking the author-page "Show all N works" escape hatch clears the global availability + language preference (so it also clears on /search). This is intentional — "show all" means "stop filtering" — and consistent with the single-preference model.

lokesh added 6 commits June 16, 2026 23:03
Foundations for unifying filter/sort across listing surfaces.

Storage: availability and language move from per-session sessionStorage to
cross-session localStorage, re-scoped from surface-specific keys
(ol-header-search-*) to a global reading-preference namespace
(ol-reading-pref-*). The read/write helpers are centralized in constants.js
(readStoredAvailability/writeStoredAvailability/writeStoredLanguages) and shared
by the header modal and the /search filter row, so every surface persists the
preference identically. This makes availability + language durable across visits
and ready to be inherited by author and other listing pages (Phase 1+).

De-dup guard: AVAILABILITY_TO_PARAMS is mirrored in code.py and constants.js;
test_availability_sync.py now parses both from source and fails CI on any drift,
replacing the bare 'keep in sync' comment.
works_by_author now accepts an availability value ('all'/'readable'/'borrowable'/
'open') and a list of language codes, merging them into the Solr param dict
exactly as /search does — availability via the shared AVAILABILITY_TO_PARAMS
(rewritten into ebook_access filters by run_solr_query), language as an OR'd fq.
'all' with no languages leaves the author scope unchanged, so existing behavior
is preserved.

Author.get_books reads these off the request: the legacy ?mode=ebooks link still
maps to the readable filter, and an explicit availability filter in the URL
(has_fulltext/public_scan) is detected via get_active_availability and wins.

This is the server half of promoting the author page's read-only facets to a
live filter bar; the template + client wiring follow. Tests cover the param
merge and the unchanged default scope.
Promotes the author works list from a single 'Show only ebooks?' link to the
shared filter bar (availability toggle + language popover), reusing
SearchFilterBar.js — generalized to navigate the current page's path instead of
a hardcoded /search, so the same controller drives /search and author pages.
The global reading preference is inherited here: a stored 'Readable only' lands
pre-applied and visibly reflected in the toggle.

Guardrails per the agreed model:
- Visibility: the bar shows when there's more than one work to act on, OR
  whenever a filter is active (so an inherited filter is always clearable). A
  single-work author with no filter has nothing to filter, so it's hidden.
- Escape hatch: when an active/inherited filter matches zero works, an inline
  'Show all N works?' clears the filters back to the full bibliography rather
  than implying the author has none.

The search-within-author box now preserves the active availability/language/sort
instead of unconditionally forcing has_fulltext. Verified against the running
app: filters reach Solr (3->0 works), escape hatch fires with the real total,
and the visibility rule holds across 1-work and 3-work authors.
The 'Show all N works' link cleared the filter params from the URL, but the
durable global reading preference was untouched — so maybeApplyStickyFilters
re-inherited it on reload and zeroed the page again (e.g. a stored French
language filter). The link now carries js-clear-reading-prefs; SearchFilterBar.js
wipes the stored availability + language preference on click so 'show all'
sticks. Degrades cleanly without JS (no inheritance runs without JS).
The toggle rendered in its default off state, then SearchFilterBar.js set
checked=true after the JS bundle loaded — so a readable-scoped page visibly
animated the toggle on after first paint. Both /search and author pages now
emit `checked` when availability is active (get_active_availability(param) !=
'all'), so the toggle paints in its final position with no transition; the
client-side seed becomes a no-op when the state already matches.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant