Skip to content

ol-shelf-actions - #13399

Merged
lokesh merged 47 commits into
internetarchive:masterfrom
lokesh:feat/shelf-button-book-actions
Sep 9, 2026
Merged

lokesh merged 47 commits into
internetarchive:masterfrom
lokesh:feat/shelf-button-book-actions

Conversation

@lokesh

@lokesh lokesh commented Aug 22, 2026 •

Copy link
Copy Markdown
Collaborator
new-dropper.mp4
Screenshot 2026-08-24 at 7 12 04β€―PM Screenshot 2026-08-24 at 7 12 13β€―PM

ol-shelf-button shelves a book in a click and opens ol-shelf-actions for the rest: the four shelves, a star rating, and an "Add to list" pane with a filter, inline list creation, and the lists the patron used most recently pinned to the top (the last one is also a one-tap row on the main pane). Two shapes β€” a split button for a row, a bookmark for the corner of cover art β€” so the same component drops into a book row, a search result or a carousel card instead of being rebuilt for each. ol-book-cover provides that corner. Signed out, the trigger goes to login and the patron lands back where they were.

Gallery-only for now, in a new Books group at /developers/design?section=components. Carousels and search results come next.

Technical

The components call only endpoints FastAPI already serves: bookshelves.json, ratings.json, check-ins, the MyBooksDropperLists partial, and the list seed/create writes. No new endpoints in this PR.

The editions read in request_context.py needed _method="GET", without which web.py drains a multipart POST body before the handler sees it β€” that also fixes the sitewide rating form and the list dropper. In lists.py, the seed-lists regex the two handlers must share is now one constant, since infogami keys its pages registry by the literal regex text. (Earlier versions of this branch also carried DEPRECATED_PATHS routing entries; master has since removed that mechanism, so they are gone from the diff.)

Accessibility

The popover is a named dialog (Actions for <title>) and the split button's main half is a toggle button that names the book and its pressed state, so twenty of them in a list still tell a screen reader which is which. Choosing a shelf or a star keeps focus on the control through the request β€” the rows dim with aria-busy rather than disabling, since disabling the focused button drops focus to the document β€” and a live region announces every change ("Added to Currently Reading", "Rated 4 of 5. Added to Already Read", "Finished Sep 3, 2026"). The shelf and date rows are aria-pressed toggle buttons in named groups, the check-in question labels its group, and the stars are a real radio group: one tab stop on the current rating, arrows move and preview like hover, Enter or Space commits, 24px targets at 3:1. Escape steps back a pane before closing, and leaving a sub-pane returns focus to the row that opened it. Labels can be plural sets picked by Intl.PluralRules, so list counts read "1 item". Axe reports no violations on any pane. Two small changes reach every ol-popover: triggers no longer carry an aria-controls that could never resolve across the shadow boundary, and the mobile tray declares aria-modal.

Why this is low risk

  • No new server surface. Zero new FastAPI or web.py routes. The Python diff is a read-only batch query (Ratings.get_users_ratings_of_works, twin of the existing bookshelves one), a type-hint widening, and the design-page demos.
  • request_context.py is one line, scoped to the solr-editions query read. It stops web.py from parsing multipart POST bodies during that read; nothing passes editions in a body.
  • Nothing outside /developers/design renders differently. The new components ship in the site-wide bundle but nothing instantiates them elsewhere yet. The touched shared components are behavior-preserving: the bottom-start popover default matches what every existing consumer already set explicitly (each one checked), and the ol-icon filled prop is additive.

Testing

Sign in and open /developers/design?section=components β†’ Books. The demos write to your real reading log: shelve the book from either shape, rate it, add it to a list, reload. Both demos are the same work, so one updates the other. Signed out, a trigger goes to login. With a keyboard: open the chevron, Tab through the rows, pick a shelf with Enter and confirm focus stays put, arrow across the stars and rate with Space, Escape back out of the lists pane. With VoiceOver or NVDA, each change should be spoken.

Screenshot

See above

Stakeholders

@cdrini @mekarpeles

lokesh added 10 commits August 21, 2026 23:01
POST /works/OL…W/ratings.json and POST /people/…/lists.json both answered 405
on the web.py port. Ratings had no entry in DEPRECATED_PATHS at all; the lists
entries were there but never took effect, because infogami keys its `pages`
registry by the literal regex text, so an equivalent pattern spelled
differently registers alongside the old GET-only handler instead of replacing
it. Collapsing the five per-seed-type patterns into the one string lists.py
actually registers covers all of them β€” works, books, authors and subjects
were shadowed the same way people was.

The editions read in the request context also has to declare _method="GET".
Without it web.py parses multipart POST bodies there, draining wsgi.input
before the handler sees it, which surfaces as an empty form β€” "Invalid
bookshelf" on a shelf post that carried one.

This is dev-only routing, but it takes the sitewide star-rating form and the
list dropper's "create list" with it, not just the components that follow.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg
The book components render whatever shelf and rating they are handed, so a
server-rendered page sets them directly. Anything that renders books on the
client β€” a carousel, a search result list β€” has to ask, and asking once for
the whole batch is what keeps the public payload cacheable and user-agnostic.

Ratings had no batch query, only per-work reads; Bookshelves had one but its
signature disagreed with the numeric work ids every caller actually passes.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg
Two components that let a reader shelve, rate and list a book from wherever
the book appears. ol-shelf-button is the control β€” a split button for a row,
a bookmark for a cover β€” and ol-book-actions is the popover both shapes open:
the four reading-log shelves, a star rating, and an "Add to list" pane with a
filter and inline list creation.

State is optimistic. The UI updates first and an error toast rolls it back,
and neither component writes its own shelf: they emit ol-book-state-change
and the surface that owns the book applies it, which then flows back down.
That is what keeps two cards for the same work in step, and it makes an
optimistic update and its rollback the same code path.

Signed out, the trigger stands alone and its click goes to login with the
intent remembered β€” without user-key no popover is built at all.

ol-icon gains a `filled` attribute along the way, for the on/off states these
need: a saved bookmark, a rated star.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg
The domain layer gets its own group: everything in it knows what a book is,
while the generic pieces it composes β€” Button, Popover β€” stay where they are.

Both demos write to the signed-in reader's real reading log and lists, so the
gallery hands them the reader's key; empty sends the demo to log in instead.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg
The surface the icon-shaped shelf button was built to sit on: a cover at a
fixed 2:3 ratio with an `overlay` slot that owns its top-right corner, so a
consumer never positions the control itself and clicking it does not follow
the cover's link.

With no artwork it draws a title/author panel rather than leaving a hole, so
a shelf of covers keeps its rhythm. With `tooltip` a pointer gets the title,
year and author over the cover, for layouts with no room to print them
underneath.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg
700 on a generated cover was heavier than any real jacket; 500 reads as a
title without shouting. The padding drops a step at both sizes β€” a step
further at small, where a 72px cover was giving up a quarter of its width to
inset β€” and the small title drops to 12px, below where the title scale
bottoms out.

`size` reflects now, so the small-size styles apply when it is set as a
property and not only when it is authored as an attribute.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg
Both components were overriding ol-popover's own bottom-center default with
bottom-end. Right-aligning a 300px panel to a narrow trigger throws almost all
of it to one side, so it reads as unattached β€” centering keeps the panel under
the thing that opened it.

Nothing is at risk near a viewport edge: the positioner clamps horizontally
either way, and transform-origin already tracks the trigger's centre, so the
open still radiates from the button. `placement` is still there for a caller
that needs an edge alignment.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg
A popover panel is nearly always wider than the control that opens it, so
centering makes it straddle the trigger and edge-aligning to the far side
throws it clear across. Leading edges together is the alignment that reads as
attached, and it was already what every menu on the site asked for by hand:
ol-menu-popover, ol-select-popover and ol-options-popover each spelled out
bottom-start, and those overrides can go.

The book components stop naming a placement at all β€” the pass-through only
sets the attribute when a caller supplies one, so an unset `placement` is
ol-popover's choice rather than a copy of it.

The one surface that genuinely wants centering, the masthead's Browse menu,
already says so explicitly, so nothing changes for it.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg
The book-cover overlay example never passed user-key, so its shelf
button rendered permanently signed-out and every click went to the login
page instead of opening the actions popover. It joins the shelf-button
demos on the same work, so the shared listener there keeps all three in
step. The example gets an explicit code snippet so the copyable markup
shows /people/you rather than the reader's own key.
"On a shelf" hardcoded shelf 1, 2 and 3 on three placeholder works to
illustrate the three states, but the buttons were live: clicking one wrote to
the reader's real account while the shelf it displayed stayed whatever the
attribute said. A demo that shows one thing and does another is worse than no
demo, and the note admitting it was longer than the example earned.

The two remaining demos open on the reader's real state for The Secret Garden,
so the shelved appearance is already on show β€” and honestly.

Claude-Session: https://claude.ai/code/session_01XJ9QNXNg3UfbtH3dmDxUfV
lokesh added 9 commits August 22, 2026 05:08
Found while putting these on a real surface; they belong with the
components rather than with the first page to use them.

bookshelves.json answers a rejected write with 200 and an `error` key, so
checking the response status alone let a failed write look like a save β€”
the optimistic update stayed on screen and the reader believed it stuck.

Signed out, the button sent the reader to the book's own page after
logging in. On a book page that is where they already were, but from a
list of results it strands them somewhere they never asked to go. It now
resumes on the page they were on, as the reading-log dropper does.

Matomo's click trigger matches `data-ol-link-track` attributes, which it
cannot see inside a shadow root β€” events retarget to the host on the way
out. So shelf, rating and list actions now push to `_paq` directly, under
the same category/action names the dropper's forms use, and the existing
dashboards stay continuous across a migration.

Adds `hide-rating`, which drops the popover's stars for surfaces that
already show a rating control for the same book. The reading log is the
case that needs it: its `macros.StarRatings` is a real input, not the
aggregate byline that search results carries.

Claude-Session: https://claude.ai/code/session_01XJ9QNXNg3UfbtH3dmDxUfV
Marking a book Already Read now slides in a pane asking for a finish date,
the way "Add to list" already slides in. Answering was previously a
separate prompt rendered outside the popover, which opened a modal β€” two
overlay paradigms for one question.

The check-ins schema stores partial dates: a year alone, or a year and
month, are both valid. That is what makes "In 2026" offerable as one tap
rather than a date picker. "Other date" swaps in year/month/day selects,
each enabling the next, in the same pane rather than a fourth one β€” the
lists pane swaps in its create form the same way. Month names come from
Intl in the page's language, so they cost no translated strings.

Only an explicit shelf choice opens it. Rating a book moves it to Already
Read server-side too, and interrupting that would turn one tap into two.
Nor does it open when the reader is taking a book *off* the shelf.

The pane track was hardcoded to two panes β€” 200% wide, halves, a class
that translated by -50%. Width and slide now both derive from the PANES
list, so a third pane is an entry there plus a `_render*`. Escape in any
sub-pane goes back one step instead of only in the lists pane.

Not yet handled: showing or editing a date that already exists. The
finish date is write-once from here until we decide how the row surfaces
it β€” relabelling the main button "Read in 2026" is one idea.

Claude-Session: https://claude.ai/code/session_01XJ9QNXNg3UfbtH3dmDxUfV
The tooltip opt-in on ol-book-cover is gone β€” every cover renders the
hover card, with ol-tooltip's pointer media query still deciding when it
arms. Also drops the implementation-detail notes from the shelf-button
and book-actions gallery pages.

Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD
…ve#13400

Remove from shelf row, read-date display and amending (readDate/eventId
through to setCheckIn's event_id), quickYears, the Other-date disclosure,
the ol-list-created bridge and ol-book-check-in event, press feedback,
fixed row heights, 16px list checkboxes, per-option select seeding, and
the accent-color token fix β€” taken from the search-results branch so this
PR carries the component work.

Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD
testing.openlibrary.org only routes already-launched paths to its FastAPI
container, so the new /reading-state.json (and the bookshelves route its
nginx is missing) 500 with the web.py deprecation error there. The /_fast/
prefix exposes the whole FastAPI app on testing, so prefix every request
with it on that host until the olsystem nginx routes land. Production has
no /_fast and is unaffected.

Claude-Session: https://claude.ai/code/session_01D5yRkR7WnUVDXxjc9Gv6Tz
Only the design-system demos called the endpoint: search results pass the
state as server-rendered attributes, and the client-rendered surfaces that
genuinely need a batch endpoint (carousels) are not in this PR β€” it can
land with them instead, alongside its olsystem routing. The demos now get
shelf/rating the same way real surfaces do, from design.py at render time.
Also reverts the /_fast testing shim; the testing nginx routing is being
fixed properly instead.

Claude-Session: https://claude.ai/code/session_01D5yRkR7WnUVDXxjc9Gv6Tz
The Lit bundle can import the webpack modules directly, so the copied
analytics and pending-action-cookie helpers go away, the lists partial
URL regains its lang handling via buildPartialsUrl, and the shelf
label/event vocabulary now lives once in books-api. The popover's three
panes render on first click instead of at page load, which was building
thousands of nodes on book-heavy pages for popovers never opened. Also
drops the fictional legacy-dropper list-created protocol, a fake fmt
compatibility re-export, and shares the lists route regex between
lists.py and deprecated_handler.py instead of a keep-in-sync comment.

Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD
@lokesh
lokesh requested a review from RayBB August 25, 2026 02:05
@lokesh lokesh added Needs: Review This issue/PR needs to be reviewed in order to be closed or merged (see comments). [managed] On Testing labels Aug 25, 2026
@lokesh lokesh changed the title Shelve, rate and list a book from wherever it appears Dropper v2 Aug 25, 2026
@lokesh
lokesh marked this pull request as ready for review August 25, 2026 02:18

@RayBB RayBB left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks beautiful but have some first draft code changes here.
Here is some feedback I think we should really consider. It's just the first round based on high level things.

1. The date helpers do not belong in OlBookActions.js

The file has date functions inside it (MONTHS(), formatReadDate(), quickYears(), partialDate()).

We checked the client code. There is no shared date utility yet β€” these are the first. But the component also does not own this idea. Dates are not part of a book-actions popover; other components will need them next.

Suggestion: Give the date helpers a shared home beside the other shared modules (lit/utils/books-api.js, lit/utils/labels.js). Then the component keeps only its panes, and the date logic is there for whatever comes next.

2. Four copies of the same write-then-undo pattern

Four places repeat the same flow: change the UI, send the request, on success record the event, on failure undo the change.

  • OlShelfButton._onMainClick
  • OlBookActions._postShelf
  • OlBookActions._onRate
  • OlBookActions._onListToggle

Problem: Four copies of one idea will drift apart over time.

Suggestion: We think there is a better shape here than four copies. Please look at these four flows together before merge and see if they can share something.

3. The lists cache state looks tangled

The list data is held in more than one place: a module-level promise, a copy on each component instance, and manual sync code after creates.

Problem: When one piece of data lives in several places, they can stop agreeing, and readers must trace all the sync code to trust it.

Suggestion: This looks like it should be improved before more surfaces build on it. We do not have one answer in mind β€” please consider how to keep fewer copies of this state.

lokesh added 2 commits August 24, 2026 20:51
The row rendered the date but nothing ever supplied one: the popover
did not keep its own copy after a save, and the design demo never
server-rendered it. Now the component updates readDate/eventId
optimistically like shelf and rating β€” which also makes a re-save
amend the event instead of recording a second finish β€” and the demo
passes the latest finish event down.

Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD
Review feedback (internetarchive#13399): the date helpers belonged beside the other
shared utils, and the lists cache lived in three places (a module-level
promise, per-instance copies, and event-based syncing) that could drift.

lit/utils/dates.js takes MONTHS, formatReadDate, quickYears and
partialDate unchanged. lit/utils/lists-store.js holds the one canonical
lists object; components read getLists() and re-render on subscribe,
and the store owns each optimistic update and its rollback. That
retires the per-instance copies, the document-level ol-list-created
listener, and the promise-swap after a create. The event still fires
outward as documented API.

Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD
@lokesh

lokesh commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

@RayBB Thanks for the review. 1 and 3 are addressed in 1a1dd17; here's where each landed.

1. Date helpers β€” Moved to lit/utils/dates.js beside books-api.js and labels.js. OlBookActions now only owns its panes.

3. Lists state β€” There's now one canonical copy in lit/utils/lists-store.js. Components read getLists() and re-render via subscribe, and the store owns each optimistic update and its rollback. That retired all three sync mechanisms: the per-instance _lists copies, the document-level ol-list-created listener, and the promise swap after a create. The ol-list-created event still fires outward as documented API for surfaces outside the components. A side benefit: toggling a list in one popover now re-renders the same book's other popovers immediately, which the old copies didn't do.

2. The write-then-undo copies β€” Looked at all four together and decided to leave them for now, for a couple of reasons. The store refactor already absorbed one: the list-toggle undo now lives in toggleListSeed. Of the remaining three, OlShelfButton._onMainClick is stateless by design (its undo is emitting the previous state, and it carries the login-resume path), so merging it in would blur that contract. That leaves _postShelf and _onRate, which really are twins, but each is about 12 lines and the differences (what gets snapshotted, opening the check-in pane on success) would all become callbacks in a shared helper. I'd argue two short explicit functions read better than one parameterized one. Happy to extract a small _commit() helper for those two if you feel strongly.

Ready for another review.

@lokesh
lokesh requested a review from RayBB August 25, 2026 06:32
The pane could set a finish date and replace it, but never take it back.
DELETE /check-ins/:id closes that gap; `send` splits out of `request`
because that endpoint answers 200 with an empty body.

The three take-back actions now read alike. Clear rating, Clear date and
Remove from shelf are quiet text links rather than icon rows, leaving the
rows that give an answer as the only ones carrying weight. Remove from
shelf is offered on Already Read alone, since clicking any other active
shelf row toggles it off β€” Already Read's row leads to the date pane
instead, so it was the one shelf with no way out. The selected date row
takes the active shelf's blue for the same reason both mark an answer.

The date also rides the Already Read row only while the book is on that
shelf. Moving a book keeps its check-ins and only coming off the shelves
entirely deletes them, so the date outlives the shelf it was given on and
would otherwise show against a shelf the book has left. The date pane
still opens on it.
@lokesh

lokesh commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

@cdrini
Updates since Fri Aug 28th Design meeting:

  • Renamed ol-book-actions to ol-shelf-actions
  • Fixed error when the patron did a check-in, moved the book to a different shelf, then back to Already Read, and then updated the check-in during one popover session. The check-in data was out of sync with server.

image Added 'Clear date' action on the check-in pane.
image Two changes to Remove from shelf: 1. Removed icon and used smaller font size to match the other quieter links for the clear/remove behaviors in this popover 2. Only showing the Remove from shelf action when the book is on the Already Read shelf

@RayBB RayBB left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I finally got around to giving this one another look in depth.
Code wise, all of my concerns were fully addressed and I don't see anything else at all that should be a show stopper.

So, as far as I am concerned this is good to go in terms of the code.

I also have tried it on testing and the UX seems really solid to me.

Menu and popover rows each sized themselves, so a shelf row, a select
option and a menu item all landed on slightly different heights. Promote
the shelf-actions local row height to a shared --menu-row-height token
and apply it as a min-height across the menu, select and options
popovers, letting described or wrapping rows grow past it.

The link, text and date fields sit below 16px, which makes iOS zoom the
page on focus; bump them to the large body size on mobile. On mobile the
lists pane now focuses its back button rather than the filter input, so
the soft keyboard doesn't cover the lists the user came to see.
Curation runs in bursts β€” many books into one list in a sitting β€” but
every book is a fresh popover, so the working list had to be hunted for
each time. The server already orders lists by last_modified and a seeds
write bumps it, so the recency was there; the client just froze the
order it loaded with and, on a listing surface, that page is the whole
session.

The store now moves a toggled list to the front, matching what a reload
would show, and utils/recent-lists.js carries the last three lists per
user across page loads in localStorage. The most recent one gets a
one-tap row on the main pane, so the common add never opens the lists
pane at all β€” which matters most on mobile, where the filter costs a tap
and a soft keyboard over the names and ordering is the only lever left.
From five lists up the recent ones are also pinned above the rest, and
Enter in the filter toggles the first row for readers on a keyboard.
Order and recents are snapshotted when the popover opens and held until
it closes, so a row never moves under the reader mid-session.

The filter itself now answers to a shared FILTER_THRESHOLD, the count
ol-select-popover was already using, so the two pickers offer their
field on the same terms instead of by coincidence. Below it the pane is
rows and nothing else, and with no lists at all the pane becomes the
create form rather than a filter above an empty-state line.
…-book-actions

Resolves a semantic conflict git could not see: upstream 5c46a77 dropped
Component.use_when from design.py and the line rendering it from
_example.html.jinja, while this branch added three Books rows that still
passed a use_when string positionally. Both sides merged cleanly and the
result raised at import time, taking mypy and the whole pytest collection
with it. The three rows drop the string; each of their partials already
opens with the same description in its ds-lead paragraph.
…-book-actions

# Conflicts:
#	openlibrary/components/lit/index.js
internetarchive#13490 tightened Ratings.add so rating a book only auto-shelves it as
Already Read when it's unshelved or on Want to Read β€” Currently Reading
and Stopped Reading are explicit choices the server won't overwrite. The
legacy dropper got the matching client-side guard there; <ol-shelf-actions>
was written against the old rule, so rating a Currently Reading book showed
Already Read in the panel and in ol-book-state-change while the server kept
it where it was, with no failure to roll the optimistic update back.
Picking a shelf or star disabled the focused control for the request,
which dropped focus to the document with nothing announced. Rows now
stay enabled behind an aria-busy dim, and a live region reads out shelf,
rating and date changes. Shelf rows become aria-pressed toggle buttons
(the menuitemradio role had no menu and failed axe), stars keep stable
names with one tab stop and arrow keys, the check-in question labels
its group, and the split button's main half names the book and its
pressed state. Labels can be plural sets picked by Intl.PluralRules,
so list counts no longer read "1 items". ol-popover drops the
aria-controls that could never resolve across the shadow boundary and
marks the mobile tray aria-modal.
The panel offered two ways to take back an answer β€” a "Remove from shelf"
link on the main pane and a "Clear date" link on the check-in pane β€” and
neither said what the other did. Both are gone, replaced by a single
"I didn't read this" row at the foot of the check-in pane. It takes the
book off the shelf, and the server deletes the book's check-ins with it,
so one request clears the status and the date together. Adds lucide's
ban icon for the row.

Also in the tree: the lists pane pins the lists a book is already on
above the recently used ones, since which of them it is on is the first
question that pane answers; and menu rows and drawer items drop the
:active press squeeze, keeping only the fill β€” a shrinking row reads as
the panel moving rather than the row being pressed.
@lokesh lokesh removed the On Testing label Sep 4, 2026
@lokesh lokesh changed the title Dropper v2 ol-shelf-actions Sep 8, 2026
Light-DOM CSS could not tell a saved book from an unsaved one, since the
shelf lived only as a property. Reflecting it gives the page
`ol-shelf-button[shelf]`, which a carousel uses to keep the saved mark
visible while showing the rest on hover.
A page that reveals the bookmark on hover needs to keep it visible while
its popover is open, and focus inside a top-layer popover does not
register as :focus-within on the host in Chrome. The button now sets an
`open` attribute from the popover's open and close events, ignoring a
close the panel cancels (Escape stepping back a pane).
The badge over a cover said only "saved"; now it says which shelf, with
the same glyphs the popover rows use: the bookmark filled for Want to
Read, and book-open, circle-check or circle-pause in blue for the rest.
Only the bookmark fills well β€” the others are stroked shapes that would
turn into blobs. The icon map moves to books-api.js next to the label
map so both components draw a shelf the same way.
@lokesh

lokesh commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

I'm going to go ahead and merge this in.

It has been reviewed, is low-risk as this is not patron facing, and I'd rather have staff invest time in the follow-up PR which implements this component across the site and does impact patrons.

@lokesh
lokesh merged commit 682a339 into internetarchive:master Sep 9, 2026
4 checks passed
lokesh added a commit that referenced this pull request Sep 24, 2026
… page (#13588)

* Route the reading-log JSON writes to FastAPI in dev

POST /works/OL…W/ratings.json and POST /people/…/lists.json both answered 405
on the web.py port. Ratings had no entry in DEPRECATED_PATHS at all; the lists
entries were there but never took effect, because infogami keys its `pages`
registry by the literal regex text, so an equivalent pattern spelled
differently registers alongside the old GET-only handler instead of replacing
it. Collapsing the five per-seed-type patterns into the one string lists.py
actually registers covers all of them β€” works, books, authors and subjects
were shadowed the same way people was.

The editions read in the request context also has to declare _method="GET".
Without it web.py parses multipart POST bodies there, draining wsgi.input
before the handler sees it, which surfaces as an empty form β€” "Invalid
bookshelf" on a shelf post that carried one.

This is dev-only routing, but it takes the sitewide star-rating form and the
list dropper's "create list" with it, not just the components that follow.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg

* Add /reading-state.json for a batch of works

The book components render whatever shelf and rating they are handed, so a
server-rendered page sets them directly. Anything that renders books on the
client β€” a carousel, a search result list β€” has to ask, and asking once for
the whole batch is what keeps the public payload cacheable and user-agnostic.

Ratings had no batch query, only per-work reads; Bookshelves had one but its
signature disagreed with the numeric work ids every caller actually passes.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg

* Add ol-shelf-button and ol-book-actions

Two components that let a reader shelve, rate and list a book from wherever
the book appears. ol-shelf-button is the control β€” a split button for a row,
a bookmark for a cover β€” and ol-book-actions is the popover both shapes open:
the four reading-log shelves, a star rating, and an "Add to list" pane with a
filter and inline list creation.

State is optimistic. The UI updates first and an error toast rolls it back,
and neither component writes its own shelf: they emit ol-book-state-change
and the surface that owns the book applies it, which then flows back down.
That is what keeps two cards for the same work in step, and it makes an
optimistic update and its rollback the same code path.

Signed out, the trigger stands alone and its click goes to login with the
intent remembered β€” without user-key no popover is built at all.

ol-icon gains a `filled` attribute along the way, for the on/off states these
need: a saved bookmark, a rated star.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg

* Give the design gallery a Books section

The domain layer gets its own group: everything in it knows what a book is,
while the generic pieces it composes β€” Button, Popover β€” stay where they are.

Both demos write to the signed-in reader's real reading log and lists, so the
gallery hands them the reader's key; empty sends the demo to log in instead.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg

* Add ol-book-cover

The surface the icon-shaped shelf button was built to sit on: a cover at a
fixed 2:3 ratio with an `overlay` slot that owns its top-right corner, so a
consumer never positions the control itself and clicking it does not follow
the cover's link.

With no artwork it draws a title/author panel rather than leaving a hole, so
a shelf of covers keeps its rhythm. With `tooltip` a pointer gets the title,
year and author over the cover, for layouts with no room to print them
underneath.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg

* Lighten and tighten the blank cover

700 on a generated cover was heavier than any real jacket; 500 reads as a
title without shouting. The padding drops a step at both sizes β€” a step
further at small, where a 72px cover was giving up a quarter of its width to
inset β€” and the small title drops to 12px, below where the title scale
bottoms out.

`size` reflects now, so the small-size styles apply when it is set as a
property and not only when it is authored as an attribute.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg

* Centre the actions popover under its trigger

Both components were overriding ol-popover's own bottom-center default with
bottom-end. Right-aligning a 300px panel to a narrow trigger throws almost all
of it to one side, so it reads as unattached β€” centering keeps the panel under
the thing that opened it.

Nothing is at risk near a viewport edge: the positioner clamps horizontally
either way, and transform-origin already tracks the trigger's centre, so the
open still radiates from the button. `placement` is still there for a caller
that needs an edge alignment.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg

* Make bottom-start the popover default

A popover panel is nearly always wider than the control that opens it, so
centering makes it straddle the trigger and edge-aligning to the far side
throws it clear across. Leading edges together is the alignment that reads as
attached, and it was already what every menu on the site asked for by hand:
ol-menu-popover, ol-select-popover and ol-options-popover each spelled out
bottom-start, and those overrides can go.

The book components stop naming a placement at all β€” the pass-through only
sets the attribute when a caller supplies one, so an unset `placement` is
ol-popover's choice rather than a copy of it.

The one surface that genuinely wants centering, the masthead's Browse menu,
already says so explicitly, so nothing changes for it.

Claude-Session: https://claude.ai/code/session_01CCv12GjjKDZZGeTGQb6GHg

* Stop the overlay demo bouncing to login

The book-cover overlay example never passed user-key, so its shelf
button rendered permanently signed-out and every click went to the login
page instead of opening the actions popover. It joins the shelf-button
demos on the same work, so the shared listener there keeps all three in
step. The example gets an explicit code snippet so the copyable markup
shows /people/you rather than the reader's own key.

* Drop the shelf-button demo that showed a shelf it did not have

"On a shelf" hardcoded shelf 1, 2 and 3 on three placeholder works to
illustrate the three states, but the buttons were live: clicking one wrote to
the reader's real account while the shelf it displayed stayed whatever the
attribute said. A demo that shows one thing and does another is worse than no
demo, and the note admitting it was longer than the example earned.

The two remaining demos open on the reader's real state for The Secret Garden,
so the shelved appearance is already on show β€” and honestly.

Claude-Session: https://claude.ai/code/session_01XJ9QNXNg3UfbtH3dmDxUfV

* Fix four things the components got wrong

Found while putting these on a real surface; they belong with the
components rather than with the first page to use them.

bookshelves.json answers a rejected write with 200 and an `error` key, so
checking the response status alone let a failed write look like a save β€”
the optimistic update stayed on screen and the reader believed it stuck.

Signed out, the button sent the reader to the book's own page after
logging in. On a book page that is where they already were, but from a
list of results it strands them somewhere they never asked to go. It now
resumes on the page they were on, as the reading-log dropper does.

Matomo's click trigger matches `data-ol-link-track` attributes, which it
cannot see inside a shadow root β€” events retarget to the host on the way
out. So shelf, rating and list actions now push to `_paq` directly, under
the same category/action names the dropper's forms use, and the existing
dashboards stay continuous across a migration.

Adds `hide-rating`, which drops the popover's stars for surfaces that
already show a rating control for the same book. The reading log is the
case that needs it: its `macros.StarRatings` is a real input, not the
aggregate byline that search results carries.

Claude-Session: https://claude.ai/code/session_01XJ9QNXNg3UfbtH3dmDxUfV

* Ask when the reader finished the book

Marking a book Already Read now slides in a pane asking for a finish date,
the way "Add to list" already slides in. Answering was previously a
separate prompt rendered outside the popover, which opened a modal β€” two
overlay paradigms for one question.

The check-ins schema stores partial dates: a year alone, or a year and
month, are both valid. That is what makes "In 2026" offerable as one tap
rather than a date picker. "Other date" swaps in year/month/day selects,
each enabling the next, in the same pane rather than a fourth one β€” the
lists pane swaps in its create form the same way. Month names come from
Intl in the page's language, so they cost no translated strings.

Only an explicit shelf choice opens it. Rating a book moves it to Already
Read server-side too, and interrupting that would turn one tap into two.
Nor does it open when the reader is taking a book *off* the shelf.

The pane track was hardcoded to two panes β€” 200% wide, halves, a class
that translated by -50%. Width and slide now both derive from the PANES
list, so a third pane is an entry there plus a `_render*`. Escape in any
sub-pane goes back one step instead of only in the lists pane.

Not yet handled: showing or editing a date that already exists. The
finish date is write-once from here until we decide how the row surfaces
it β€” relabelling the main button "Read in 2026" is one idea.

Claude-Session: https://claude.ai/code/session_01XJ9QNXNg3UfbtH3dmDxUfV

* Make the cover hover card always on and trim the design notes

The tooltip opt-in on ol-book-cover is gone β€” every cover renders the
hover card, with ol-tooltip's pointer media query still deciding when it
arms. Also drops the implementation-detail notes from the shelf-button
and book-actions gallery pages.

Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD

* Give the split shelf button the raised secondary treatment

* Bring the check-in editing and popover polish over from #13400

Remove from shelf row, read-date display and amending (readDate/eventId
through to setCheckIn's event_id), quickYears, the Other-date disclosure,
the ol-list-created bridge and ol-book-check-in event, press feedback,
fixed row heights, 16px list checkboxes, per-option select seeding, and
the accent-color token fix β€” taken from the search-results branch so this
PR carries the component work.

Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD

* Route book-component API calls through /_fast on testing

testing.openlibrary.org only routes already-launched paths to its FastAPI
container, so the new /reading-state.json (and the bookshelves route its
nginx is missing) 500 with the web.py deprecation error there. The /_fast/
prefix exposes the whole FastAPI app on testing, so prefix every request
with it on that host until the olsystem nginx routes land. Production has
no /_fast and is unaffected.

Claude-Session: https://claude.ai/code/session_01D5yRkR7WnUVDXxjc9Gv6Tz

* Drop /reading-state.json and server-render the demo state

Only the design-system demos called the endpoint: search results pass the
state as server-rendered attributes, and the client-rendered surfaces that
genuinely need a batch endpoint (carousels) are not in this PR β€” it can
land with them instead, alongside its olsystem routing. The demos now get
shelf/rating the same way real surfaces do, from design.py at render time.
Also reverts the /_fast testing shim; the testing nginx routing is being
fixed properly instead.

Claude-Session: https://claude.ai/code/session_01D5yRkR7WnUVDXxjc9Gv6Tz

* Keep the shelf button's hover blue when it is selected

Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD

* Reuse the webpack-side helpers and build popover panes on demand

The Lit bundle can import the webpack modules directly, so the copied
analytics and pending-action-cookie helpers go away, the lists partial
URL regains its lang handling via buildPartialsUrl, and the shelf
label/event vocabulary now lives once in books-api. The popover's three
panes render on first click instead of at page load, which was building
thousands of nodes on book-heavy pages for popovers never opened. Also
drops the fictional legacy-dropper list-created protocol, a fake fmt
compatibility re-export, and shares the lists route regex between
lists.py and deprecated_handler.py instead of a keep-in-sync comment.

Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD

* Show the check-in date on the Already Read row

The row rendered the date but nothing ever supplied one: the popover
did not keep its own copy after a save, and the design demo never
server-rendered it. Now the component updates readDate/eventId
optimistically like shelf and rating β€” which also makes a re-save
amend the event instead of recording a second finish β€” and the demo
passes the latest finish event down.

Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD

* Move date helpers to a shared module and centralize lists state

Review feedback (#13399): the date helpers belonged beside the other
shared utils, and the lists cache lived in three places (a module-level
promise, per-instance copies, and event-based syncing) that could drift.

lit/utils/dates.js takes MONTHS, formatReadDate, quickYears and
partialDate unchanged. lit/utils/lists-store.js holds the one canonical
lists object; components read getLists() and re-render on subscribe,
and the store owns each optimistic update and its rollback. That
retires the per-instance copies, the document-level ol-list-created
listener, and the promise-swap after a create. The event still fires
outward as documented API.

Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD

* Roll optimistic writes back from a snapshot

A rating moves two properties, so restoring them by hand is a place to
forget one. _mutate() captures whatever it is about to change and puts
all of it back on failure, and refuses to start while another write is in
flight β€” the disabled rows only take effect on the next render, so two
fast clicks could otherwise both go out.

The shelf button owns no state to snapshot, but it had the same race:
guard its main click too.

Claude-Session: https://claude.ai/code/session_011AqgrMKn29JVncut49JNtf

* Key the filtered list rows with repeat()

The Add-to-list pane is the one list in ol-book-actions whose
membership changes between renders, so key its rows by list key
instead of letting Lit reconcile by index. The other mapped lists
(shelves, quick years, date options) are static and stay as maps.

Claude-Session: https://claude.ai/code/session_01RtTEd3rAHJYz3zKi3zUWwD

* Rename ol-book-actions to ol-shelf-actions

* Clear the check-in date when a book leaves its shelf

Removing a book from a shelf deletes its check-in events server-side, but
the popover kept its copy of the date and event id, so the next check-in
posted an event id the server no longer had and came back 404.

* Add a Clear date action to the check-in pane

The pane could set a finish date and replace it, but never take it back.
DELETE /check-ins/:id closes that gap; `send` splits out of `request`
because that endpoint answers 200 with an empty body.

The three take-back actions now read alike. Clear rating, Clear date and
Remove from shelf are quiet text links rather than icon rows, leaving the
rows that give an answer as the only ones carrying weight. Remove from
shelf is offered on Already Read alone, since clicking any other active
shelf row toggles it off β€” Already Read's row leads to the date pane
instead, so it was the one shelf with no way out. The selected date row
takes the active shelf's blue for the same reason both mark an answer.

The date also rides the Already Read row only while the book is on that
shelf. Moving a book keeps its check-ins and only coming off the shelves
entirely deletes them, so the date outlives the shelf it was given on and
would otherwise show against a shelf the book has left. The date pane
still opens on it.

* Unify menu row height and fix mobile input zoom in popovers

Menu and popover rows each sized themselves, so a shelf row, a select
option and a menu item all landed on slightly different heights. Promote
the shelf-actions local row height to a shared --menu-row-height token
and apply it as a min-height across the menu, select and options
popovers, letting described or wrapping rows grow past it.

The link, text and date fields sit below 16px, which makes iOS zoom the
page on focus; bump them to the large body size on mobile. On mobile the
lists pane now focuses its back button rather than the filter input, so
the soft keyboard doesn't cover the lists the user came to see.

* Remember the lists a reader is working in

Curation runs in bursts β€” many books into one list in a sitting β€” but
every book is a fresh popover, so the working list had to be hunted for
each time. The server already orders lists by last_modified and a seeds
write bumps it, so the recency was there; the client just froze the
order it loaded with and, on a listing surface, that page is the whole
session.

The store now moves a toggled list to the front, matching what a reload
would show, and utils/recent-lists.js carries the last three lists per
user across page loads in localStorage. The most recent one gets a
one-tap row on the main pane, so the common add never opens the lists
pane at all β€” which matters most on mobile, where the filter costs a tap
and a soft keyboard over the names and ordering is the only lever left.
From five lists up the recent ones are also pinned above the rest, and
Enter in the filter toggles the first row for readers on a keyboard.
Order and recents are snapshotted when the popover opens and held until
it closes, so a row never moves under the reader mid-session.

The filter itself now answers to a shared FILTER_THRESHOLD, the count
ol-select-popover was already using, so the two pickers offer their
field on the same terms instead of by coincidence. Below it the pane is
rows and nothing else, and with no lists at all the pane becomes the
create form rather than a filter above an empty-state line.

* fix(shelf-actions): don't optimistically move a rated book off its shelf

#13490 tightened Ratings.add so rating a book only auto-shelves it as
Already Read when it's unshelved or on Want to Read β€” Currently Reading
and Stopped Reading are explicit choices the server won't overwrite. The
legacy dropper got the matching client-side guard there; <ol-shelf-actions>
was written against the old rule, so rating a Currently Reading book showed
Already Read in the panel and in ol-book-state-change while the server kept
it where it was, with no failure to roll the optimistic update back.

* fix(shelf-actions): keep focus and speak changes for screen readers

Picking a shelf or star disabled the focused control for the request,
which dropped focus to the document with nothing announced. Rows now
stay enabled behind an aria-busy dim, and a live region reads out shelf,
rating and date changes. Shelf rows become aria-pressed toggle buttons
(the menuitemradio role had no menu and failed axe), stars keep stable
names with one tab stop and arrow keys, the check-in question labels
its group, and the split button's main half names the book and its
pressed state. Labels can be plural sets picked by Intl.PluralRules,
so list counts no longer read "1 items". ol-popover drops the
aria-controls that could never resolve across the shadow boundary and
marks the mobile tray aria-modal.

* test(ol-popover): stop asserting the aria-controls that could not resolve

* feat(shelf-actions): one way off Already Read, in the check-in pane

The panel offered two ways to take back an answer β€” a "Remove from shelf"
link on the main pane and a "Clear date" link on the check-in pane β€” and
neither said what the other did. Both are gone, replaced by a single
"I didn't read this" row at the foot of the check-in pane. It takes the
book off the shelf, and the server deletes the book's check-ins with it,
so one request clears the status and the date together. Adds lucide's
ban icon for the row.

Also in the tree: the lists pane pins the lists a book is already on
above the recently used ones, since which of them it is on is the first
question that pane answers; and menu rows and drawer items drop the
:active press squeeze, keeping only the fill β€” a shrinking row reads as
the panel moving rather than the row being pressed.

* Reflect the shelf on ol-shelf-button's host

Light-DOM CSS could not tell a saved book from an unsaved one, since the
shelf lived only as a property. Reflecting it gives the page
`ol-shelf-button[shelf]`, which a carousel uses to keep the saved mark
visible while showing the rest on hover.

* Carry the popover's open state on ol-shelf-button's host

A page that reveals the bookmark on hover needs to keep it visible while
its popover is open, and focus inside a top-layer popover does not
register as :focus-within on the host in Chrome. The button now sets an
`open` attribute from the popover's open and close events, ignoring a
close the panel cancels (Escape stepping back a pane).

* Draw the shelf's own glyph on the icon shape once a book is shelved

The badge over a cover said only "saved"; now it says which shelf, with
the same glyphs the popover rows use: the bookmark filled for Want to
Read, and book-open, circle-check or circle-pause in blue for the rest.
Only the bookmark fills well β€” the others are stroked shapes that would
turn into blobs. The icon map moves to books-api.js next to the label
map so both components draw a shelf the same way.

* Put the shelf button on carousels, rows and the book page

The ol-shelf-button / ol-shelf-actions components were gallery-only. This
wires them across the site and retires the legacy My Books dropper
everywhere a work is available to shelve.

Carousel cards get the icon shape over the cover, rendered without any
reader state because that HTML is cached across readers or fetched
lazily. Search results, search inside, trending, author works, list
pages, the reading log and the book page get the split shape, with the
reader's state rendered in from one batched lookup per page
(reading_state_for). A new page-level controller, book-state.js, is the
surface the stateless button needs: it fans every change out to each
button for the same work, hands out the translated labels and the
reader's key from the page shell, and hydrates carousel buttons from the
new GET /partials/ReadingState.json in batches, re-running for lazy
carousels and load-more.

The book page's "lists this book is on" strip is now fed by
lists/active-showcase.js from the popover's ol-list-created and new
ol-list-change events. The legacy dropper survives only where there is
no work to shelve: author pages and orphaned editions.

* Show the carousel bookmark on hover on desktop, always once shelved

Six white circles across a carousel is a lot of chrome for an action most
readers won't take on most covers. On a fine pointer the bookmark now
appears on hover or focus, which also keeps it while its popover is open,
and stays visible once the book is on a shelf, where the filled mark reads
as status. Touch devices see it always; there is nothing to hover.

* Keep the carousel bookmark while its popover is open

Focus inside a top-layer popover does not register as :focus-within on the
host in Chrome, so the bookmark faded out under its own menu. The button
now carries an `open` attribute while the popover is open; the carousel
keys on it.

* Reveal the carousel bookmark instantly

No fade in or out: the bookmark appears the moment the cover is hovered,
which also removes the reduced-motion override it needed.

* Put the shelf button in the search modal

Each result row gets an "outline" ol-shelf-button: the icon variant's glyph in
a bordered square, with the shelf glyph drawn solid once the book is shelved
and a short crossfade when it changes. Rows start pending until the reading
state arrives so the popover can't act on a guess, and ol-popover grows a
block-outside-clicks guard so the click that dismisses the menu doesn't also
follow the result link beneath it. The check-in pane only offers a way off the
shelf when amending an existing entry.

Alongside, the row's "Readable" status moves from a trailing column to a badge
beside the title, with a single muted "Readable in <language>" badge when the
readable copy isn't in the patron's site language.

* Show the shelf badge on the cover in grid views

In the 145px grid column the split's label had exactly the width of
"Want to Read", so subpixel rounding turned it into "Want to Re…", and
"Currently Reading" or a longer translation never fit at all. Grid cards
are the same shape as carousel cards, so they now carry the same
cover-corner badge; the list keeps the labelled split. The card macro
renders both slots and list_books.js moves the button between them when
the layout flips. The split's chevron half also shrinks to 32px so the
label has room in the list at narrow widths.

Also lets a click on a button inside a shadow root (the shelf button's
own controls) through the ILE selection manager, which only saw the
host, and trims comments across the shelf-button files.

* Add space between the shelf button and star rating in reading log rows

* Open the menu from the main half once a book is on a reading shelf

Want to Read stays a one-tap bookmark. Currently, Already and Stopped
Reading carry dates, ratings and goal progress, so leaving one goes
through the popover, which already takes Already Read via its date pane.
Before this, one tap on the main half posted the current shelf, which
toggled it off and deleted the book's check-ins.

* Tell the shelf buttons when the star widget rates a book

The widget still looked for a legacy dropper, which no longer exists on a
work page or an Already Read row, so the server's auto-shelve to Already
Read never reached the button beside it.

* Keep the popover's stars in grid, where the row's star form is hidden

* Fetch an unknown book whole when it changes while the search modal wants state

A patch alone would let the popover act on the shelf without knowing the
date, so the modal keeps refusing partial pictures and refetches instead.

* Put the shelf badge on the My Books mobile showcase

The showcase already worked out the work key for each cover and then
dropped it; the desktop carousel beside it has carried the badge since
the rollout.

* Keep a shelf page honest when a book leaves it

A reading log shelf page lists one shelf, and until now a row stayed
after its book moved, saying the new shelf as if it belonged there, while
the sidebar counts and the heading held the old numbers.

The row still stays: removing it would take the undo with it and jump
the layout under the reader. It now carries a note, "Moved to Already
Read" or "Removed from shelf", with an Undo that puts the book back the
way the button would. Every count on the page that names a shelf moves
with the change: the sidebar, the page heading, and the My Books
carousel titles. The next load drops the row.

* Let the popover decide for itself when a star form already covers the book

Three templates each had their own formula for hide-rating, and the
layout toggle updated none of them: a row rendered in list with a star
form kept hide-rating in grid, where CSS hides that form, leaving no
rating control at all.

The popover now checks at each open for a visible star form with the
same work key and drops its stars only then. hide-rating stays as an
explicit override.

* Make one server helper the only producer of the shelf button

Four templates wrote the tag out by hand, each with its own copy of the
work-and-edition sniffing: the row macro, the book page widget, the
desktop carousel card and the mobile showcase. The mobile one computed
a work key and then dropped it, which is the class of bug this invites.

shelf_button_for(doc, variant, reading_state, cached) resolves the work,
edition and title for a Solr or Infogami work or edition, and renders
the Jinja partial. Rows pass the page's reading_state; a cached carousel
card asks for no reader and no state, and book-state.js fills both in.
The partial now takes hydrated explicitly for that case.

* Retire the legacy My Books dropper

It survived for two surfaces, the author sidebar and orphaned-edition
rows, and on both there is no work to shelve, so all it rendered was an
"Add to List" button. Its reading-log forms, check-in prompt and check-in
components had no page left to render on.

Those two surfaces now get the same ol-shelf-button as everything else,
with a lists-only mode: the split shape becomes one "Add to list" trigger,
the icon shape a list-plus glyph, and the popover opens on its lists pane
with the seed's name as heading. shelf_button_for() emits it for an
author or an edition without a work.

Gone with it: the generic dropper shell, which had no other renderer
(the access button's caret menu is a native details element and keeps
its rules in generic-dropper.css); the dropper's lists HTML from the
MyBooksDropperLists partial; three server helpers only its templates
called; the showcase store, which ShowcaseItem now keeps for itself.

* Stack My Books grid cards from the top so covers and buttons line up

* Keep the popover's list pane current after a list is created

The lists partial asked for the user's lists with the one call
signature that goes through the memcache memo, and that memo is only
evicted by infobase. When the eviction does not land, as it currently
does not on testing, a list created moments ago is missing from every
popover until the entry is cleared by hand. An explicit limit runs the
key query fresh, at the same cap My Books already uses.

* Render the facets and subject partials through their Jinja templates

* Swap the shelf badge's glyph in place instead of animating the change

* Drop the orange focus border on carousel items

Slick gives every slide tabindex="-1", so a mouse click on a card's padding
focused the slide div and the 5px border ate into the fixed-width card,
shifting the cover and button. Keyboard users never reach the slide itself,
and nothing sets the .selected class the rule was written for.

* Keep the soft keyboard down when the list create form opens on mobile

* Add the edition to lists, and flag other copies already there

The popover wrote the work as a list's seed and matched only that key.
Most entries on existing lists are editions, so a list that already held
the book read as empty and ticking it filed the book a second time, while
the book page's strip beside it checks both keys and said the opposite.

It now records the edition the reader was looking at, which is what the
dropper did by default, and a new /partials/WorkEditions.json gives the
work's editions, fetched once per book when a popover opens so carousels
and other rows pay nothing for it. A list holding another edition, or the
book with no edition named, says so under its name instead of reading as
empty; ticking such a row adds this edition alongside, because some lists
collect editions on purpose, and unticking takes only this one.

* Port shelf-button JS tests from Jest to Vitest globals

* Unify list marking across shelf popover panes and link to My lists

List rows in the lists pane now wear the same plus/check marking as the
main pane's recent-list shortcuts (up to two now), with the checkbox kept
for assistive tech and the keyboard. A "My lists" link sits below the rows
once the reader has a list, so there is a way from filing books to seeing
them. Back becomes a ghost ol-button, and "Add to list" states its count
as a pluralised "On N lists".

* Rename the lists pane footer link to Manage lists

* Keep check-in analytics continuous across the move into the popover

The date pane reported CheckInPrompt events named by how precise the saved
date was, where the old prompt and form reported by which control was used.
Each answer now sends the event its predecessor did (SetDateToday,
SetDateCurrentYear, SetDateCustom, SubmitCheckIn, EditDate, DeleteCheckIn),
so the dashboards built on those names keep counting. Skip stays as the one
new event.

trackEvent also now sends the athena event ping alongside the Matomo push,
which is what a data-ol-link-track click always did; JS-reported events from
the shelf components and the search modal were only reaching Matomo.

* Drop the divider between recent and other lists in the shelf popover

The lists pane now reads as one column; the recent and other groups keep
their own accessible labels but no longer draw a rule between them.

Also carried in the working tree: the popover reports every open labelled
by button shape, and the check-in prompt reports itself shown when it
follows a shelf change, so the saves and skips those dashboards count
have a denominator.

* Skip a work's lazy editions backreference when picking the shelf edition

On a work Thing, doc.get("editions") is infogami's backreference LazyObject,
which can't be indexed, so every work page 500'd.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Say what the shelf button's server helpers are for

reading_state_for() took the reader from the request; it now takes one,
as get_reading_states(user, docs), so a caller can see whose state it is
asking for. `cached` becomes `async_load`, which is what the flag means
to the template: the HTML carries no reader because book-state.js will
fill one in, whether it was cached or fetched lazily.

The preset shelf ids were spelled out in three templates; shelf_ids()
is now the one source. Their count elements move to
data-shelf-count-for, and the " (27)" after a carousel title gets its
own macro rather than being built twice.

The button's state attributes now test `is not none`, so a rating of 0
or event id 0 still renders.

* Hold a shelf button until the reader's state arrives

hydrate() gave every button the reader's key straight away but awaited
ReadingState.json for the shelf, so a carousel bookmark was live while
it still read as unshelved. The POST is a toggle: a tap in that window
took a book off Want to Read and filled the bookmark to say it had been
saved, and the in-flight response, computed before the write, corrected
nothing.

The buttons now go pending until their state lands, which is the gate
the search modal already used, and pending finally looks like something:
the trigger dims and drops its clicks, and reports aria-busy.

While in here: pool insertions for 100ms instead of setTimeout 0. Each
lazy carousel fetches and inserts in its own macrotask, so a zero wait
coalesced nothing and a page of carousels asked once per carousel. The
row notice and shelf counts follow the names the templates now use.

* Let a reader remove a read date and keep the shelf

The check-in pane's only destructive row was "I didn't read this", which
takes the book off Already Read, so a mis-tapped date could not be
cleared on its own β€” the legacy form's Delete Event could, and
deleteCheckIn() has been sitting in books-api.js unused ever since.

The amending pane now offers Remove date above it, and CheckInForm /
DeleteCheckIn goes back to naming that, rather than being fired from
_removeFromShelf beside ReadingLog / RemoveFromShelf, where it counted
the opposite of what the old dashboards recorded.

* Read the reader's lists from memcache again

get_user_lists() passed limit=1000 to get past the memo, because only
infobase evicts it and a list made in the popover had to show up. That
took every logged-in book page through an uncached infobase things()
query, get_many over the lists and get_seeds on each, where master was
served from memcache at the default limit.

The default limit is back, and the two write paths that the popover
reaches β€” creating a list and deleting one β€” evict the entry themselves.

* Draw the book's list strip from the page's lists store

active-showcase.js fetched MyBooksDropperLists.json for itself, so a
logged-in book page asked for the reader's lists twice and then kept a
second copy of them, updated from the popover's events. It now renders
from the shared lists store and re-renders on every change, which makes
the strip and the popover the same answer by construction, and removal
from the strip goes through the store too. Renamed list-showcase.js,
since there is only one.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs: Review This issue/PR needs to be reviewed in order to be closed or merged (see comments). [managed]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants