Skip to content

Dropper v2 - #13399

Open
lokesh wants to merge 24 commits into
internetarchive:masterfrom
lokesh:feat/shelf-button-book-actions
Open

Dropper v2#13399
lokesh wants to merge 24 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-book-actions for the rest: the four shelves, a star rating, and an "Add to list" pane with a filter and inline list creation. 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 reading-log writes now reach FastAPI from the web.py port in dev — ratings had no DEPRECATED_PATHS entry, and the lists entries never took effect because infogami keys pages by the literal regex text. Related: the editions read in request_context.py needed _method="GET", without which web.py drains a multipart POST body before the handler sees it. Both fix the sitewide rating form and the list dropper too.

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, the design-page demos, and the routing-table lines below.
  • The DEPRECATED_PATHS changes only touch paths nginx already routes to FastAPI. Verified against live traffic: POST /works/…/ratings.json, POST …/bookshelves.json and GET …/lists.json on openlibrary.org all answer x-served-by: FastAPI today, so web.py never sees these requests in production. The entries are the safety net — a misrouted environment fails loudly instead of silently using the legacy handler, which is the mechanism's purpose.
  • 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=componentsBooks. 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.

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

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

Definitely a lot to review but I'm working through and reading it. Trying to give you feedback as much as I can now now instead of waiting until I fully review.

There are two things here that I think are more up to your judgement but are overall seem like a good idea to me.

Split out into focused components

This component has grown into a small application rather than a UI component. Split into focused components:

  • OlBookActions (coordinator, ~300 lines) — owns pane switching and event delegation
  • OlShelfPicker (shelves UI, ~200 lines)
  • OlRatingPicker (stars UI, ~150 lines)
  • OlListsPane (lists browser, ~300 lines)
  • OlCheckInPane (date picker, ~250 lines)

Each pane should be a self-contained component with clear props/events.

I understand these things may not be likely to be used anywhere else but I think if we can do this it would make it a lot easier to review/work on each individual part rather than having it all in one huge file. However, if this add more complexity/boilerplate than you deem worth it then I wouldn't say it's a blocker.

Extract Optimistic Update Pattern into Reusable Mixin

The optimistic update pattern is repeated inconsistently across OlBookActions.js and OlShelfButton.js, with each mutation handler implementing its own rollback logic. This is fragile:

  • _onRate() rolls back 2 properties, easy to miss one
  • Manual rollback order matters and is error-prone
  • Error handling duplicated but inconsistent
  • No prevention of concurrent mutation race conditions

Extract this into a reusable Lit mixin following the existing pattern used by FormAssociatedMixin and FocusableHostMixin. The mixin should provide a _performOptimisticMutation() method that:

  • Captures state snapshot for guaranteed consistent rollback
  • Orchestrates: optimistic apply → server action → rollback on error
  • Prevents concurrent mutations
  • Centralizes error handling (401 → login, others → toast)

Refactor _postShelf(), _onRate(), _saveCheckIn(), _onCreateSubmit(), and OlShelfButton._onMainClick() to use it.

This eliminates a class of bugs where developers forget to rollback properties or get the order wrong. Future mutations will use a tested pattern instead of hand-rolling fragile error handling.

Again, I think this would be very nice to have, especially because it's a pattern we should be using quite often, but I'll leave it up to you what's best for it.

@RayBB

RayBB commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Also seems we could: Use repeat() — OlBookActions.js:735, 861, 898, 984.

Without repeat(), Lit reconciles by index. Filtering Add to list (OlBookActions.js:982 filter) or toggling shelf recreates DOM nodes, loses input:focus/checked state and wastes work. repeat(items, keyFn, tplFn) keys by stable id.

lokesh added 2 commits August 25, 2026 21:28
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
@lokesh

lokesh commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

@RayBB Both good calls.

Optimistic updates: adopted the pattern, skipped the mixin. Rollback now works off a state snapshot instead of hand-restoring properties one at a time, so a handler that moves two things can't put only one of them back, and a write can't start while another is in flight. That second part was a real race, not just tidiness.

Kept it local to the component rather than a shared mixin, because only 2 of the 5 call sites are actually this pattern. The others apply state after the response, get rollback from the lists store, or hold no state at all: OlShelfButton is stateless by design, it reports changes and the surface applies them. A mixin here would be a shared abstraction with one real consumer. Worth promoting once there's a third.

Splitting the component: I'd rather not. The line count is mostly styles, and the panes aren't independent parts. They're one track: a shared layout that derives its width and offset from the pane list, measures the active pane to animate the panel height, and moves focus between panes inside a single shadow root. Splitting trades one file for five shadow roots, duplicated styles, prop plumbing, and focus management across shadow boundaries, which has been the fiddliest area in this component set.

Tests came with both fixes, each checked to fail without them.

@lokesh
lokesh requested a review from RayBB August 26, 2026 04:45
…-book-actions

# Conflicts:
#	openlibrary/plugins/openlibrary/deprecated_handler.py
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] On Testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants