Skip to content

Release 2026-09-10: the Library batch - #223

Merged
manager merged 21 commits into
mainfrom
dev
Sep 10, 2026
Merged

manager merged 21 commits into
mainfrom
dev

Conversation

@manager

@manager manager commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Authorized by Wolf on 2026-09-10.

Depends on keepsimple-cms-new dev → main landing first (description columns). Merged only after staging serves this commit and the CMS release is live on production.

🤖 Generated with Claude Code

manager and others added 21 commits September 9, 2026 21:05
Books on the public shelves of the shared libraries (Wolf's, for now) are
offered ahead of Google Books and Open Library while a title is typed in the
Add book wizard. A member row carries title, author, date, source URL and the
owner's own cover through the cover proxy, never the notes, rating or
difficulty. It stands in a soft blue ring and says on hover whose library it
comes from. Provider rows that repeat a member title are dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…napshot

Additive, idempotent: shelves by name, books by slug, covers by production
hash through the staging upload API, links by SQL through keepsimple-ctl.
Journals every run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The owner writes a description of up to 180 characters in the Add shelf and
Edit shelf forms. When one is set, a (?) mark after the shelf name says it on
hover through the shared Tooltip, for owner and visitor alike; with nothing
written there is no mark. Saving sends only what changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Its description lives on the library, saved the way its privacy is. The
owner opens it from the shelf name; the form shows the description alone,
and the same (?) mark answers on hover once it is set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A lamp beside the search turns the light down and up. The choice lives in a
cookie the pages read on the server, so a dark reader never sees a light
flash, and is mirrored onto <html> for surfaces portaled to <body>. Every
paper token gets a night value in themes.scss; the hardcoded colors that
would have ignored it become tokens; the wood plank photograph is dimmed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The rename modal reads name and description back from the update response
and reports a failed save when the CMS kept neither, as the Favorites path
already did. The (?) glyph takes its face from the font token.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#218)

Library: shared memory, shelf descriptions, staging mirror
The edit form draws one slice per shelf in the tag's own colour, biggest
share first, with the percentage beside each shelf name and the total in the
ring's eye. Resting on a slice or its legend row names the books that shelf
holds under this tag, in the shelf's own sequence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A share under a percent is spoken as it is drawn, the legend steps back as
far as the ring does, and the slices are laid out inside the room the gaps
leave, so a shelf too thin to see can never be drawn over its neighbour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eared

useAnimatedList held a leaver until its node finished fading, but a list put
aside by a tag filter has no node: the leaver was never dropped and came back
as a live shelf, in full, the moment the filter cleared, while the toolbar
still counted the search. The hook now refuses to queue a departure while its
container is off the page, and lets go of any leaver whose container leaves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The name field was held to Strapi's uid character class, a leftover from when
the client stamped its own slug: it refused "To begin" and every other name
with a space. The CMS derives the address from the name itself, so the name
only has to be a word the owner recognises. Spaces, accents, punctuation and
any script now pass; the name is trimmed and the field stops at the shared cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
keepsimple already carries a dark and light switch in its navbar; the Library
had grown a second one in its toolbar, a second answer to the same question.
The lamp, its cookie and the theme state are gone: the night tokens now key
off the `darkTheme` class the site's switch puts on <body>, which also covers
the surfaces portaled there. The passport says the Library ships no switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The class list uses the shared helper, and the global entry no longer
describes a mechanism the Library does not have any more.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Library: a tag says where it is used as a ring
Every label, counter, hint and error in the Library is ink that was black at
a fraction, or a signal colour tuned for paper: on night paper they sat at
1.1:1 to 3.7:1, which is what the tag form, the rating box, the About editor
and the object forms all showed. The night sheet now carries light ink at a
fraction, lifted reds, green and purple, a stronger dim behind a dialog, and
one error colour that pointed at a token this project never had.

The Libraries page kept its daylight paper because the sheet is a canvas tile
handed to the element inline, with washes that multiply: at night the tile is
put away and the flat night sheet carries the page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Answers the review: --gray-medium was set twice in the night sheet, so the
first line was dead weight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Library: the night sheet reads everywhere it is written on
…222)

* feat(library): the library is written into the page, not fetched after it

/library/wolf answered with a shell. The title, the description and the
schema.org list were in the first HTML; every word a reader could read
arrived later by script. A crawler that runs no scripts, which is most of
them and every AI one, indexed "Loading", "0 Books" and "No tags yet" on a
page elea.co, the CV and LinkedIn link to as proof of 165 read books.

The read the browser made is now made once on the request, anonymously, so
the shelves, the books, the About text and the tags leave the server in the
response. A private shelf never reaches the page source, even on the owner's
own request, and the browser still re-reads on mount, silently, so an owner's
own view arrives without the shelves blinking out.

A book's address is now that book: its own title, a description drawn from
the owner's note, its cover as the shared image, the note itself in the page,
and a Book entry whose review carries the note and the rating. Before this
165 addresses answered with the library's own title and picture.

A dialog cannot be server-rendered, so Modal stands down where there is no
document and the browser opens it on arrival; notes render through an escape
first, allow after sanitizer that needs no DOM. /library-sitemap.xml lists
every public library and every object on a public shelf, none of which
Strapi's own sitemap knows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: the Library charter moves to LIBRARY.md

CLAUDE.md had grown to 624 lines, about 430 of them Library passports,
contracts and release gates that every session on this repo paid for
whether or not it touched the Library. They now live in LIBRARY.md at the
root, in their original order and wording, and CLAUDE.md keeps a seven-line
pointer that says when to read it. Not imported on purpose: the file is
read when the Library is the work. Every non-blank line of the old file is
in one of the two new ones; the split was checked line by line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(library): the server reads a note in the editor's own dialect

The allowlist now equals EDITOR_TAGS in richText.ts less the anchor, which
is rebuilt with its address checked: strike and del pass, lists do not, so
the dialog and the book page never read one note two ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(library): five tools an agent may hold a library by

The backend already knew how to do this: a tag has a name, a description, a
colour and its own order of books, and a book has a note and a rating. What
was missing was a key a machine may hold and a wrapper that reads as tools.

Books and tags are named the way a person names them, by title or by word,
and a word that matches two books is refused with both rather than guessed
at. A partial order is enough: what is named takes the front and the rest
keep their sequence behind it. Nothing here deletes a book, a shelf or a
tag; that asks the owner first, in the Library.

The key buys the owner's ordinary session and no exemption: the same
ownership checks, feature flag and limits decide every write, and a call
that would reach another library fails here before it is sent. Every call
leaves one line in logs/library-mcp.jsonl, the key and the session never in
it. probe.mjs runs all five against a live library and takes back every
write it makes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(library): the journal names the caller and carries the instruction

A production write through the MCP is the owner's word relayed. Each write
tool now takes an optional instruction, the words the change was asked in,
and every journal line names the client that asked, from the handshake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(library): the MCP answers the review

The limits are read from the constants the forms read, so they cannot
drift; a new tag is refused locally at the thirteen-tag cap; tagging many
books answers for each by name and fails as a whole only when nothing
changed; the post-write check folds whitespace before comparing a note and
puts no value in its message, and the journal cuts details as it cuts
arguments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Claude finished @manager's task in 5m 13s —— View job


Review complete

  • Gather context (PR diff, changed files, AGENTS.md/CLAUDE.md conventions)
  • Understand the request (code review of PR Release 2026-09-10: the Library batch #223, the Library batch release)
  • Review correctness & React best practices
  • Review TypeScript quality
  • Review project convention adherence (AGENTS.md)
  • Review accessibility & security
  • Review styling/SCSS hygiene
  • Post inline comments for issues found
  • Post final summary

Summary

Large, well-scoped release batch (shared-memory title search, tag usage ring, free-text tag names, dark theme tokens, object-level SSR pages + sitemap, Library MCP). Overall quality is high — SSR seeding via lazy useState initializers, privacy-conscious trimming in getPublicLibraryView.ts (private shelves/notes never reach the page source), a careful hand-written server-side note sanitizer (noteHtml.ts) with a documented threat model, SSRF-hardened cover proxy (cover.ts) with an explicit host/path allowlist for the new CMS-upload source, and a correct document === undefined guard in Modal.tsx for the new object-page SSR path (safe with createPortal, no hydration mismatch).

Inline comment posted:

  • src/components/library/organisms/ObjectArticle/ObjectArticle.tsx:94-103 — sourceUrl is rendered as a raw anchor href with no scheme check, on the new public/crawlable per-object page. Books can't set this via the manual form today, but it's now populated by the new cross-library "member library" autofill search and could be set directly via CMS admin/API. Suggest reusing the safeHref (https:// only) pattern already added in noteHtml.ts in this same PR, since a javascript: URL here would execute in-origin for any visitor who clicks "Source." The identical unguarded pattern already exists for video/audio in ObjectOverviewModal.tsx (titleHref), so this isn't a new class of risk, just a new, more exposed surface for it.

Not blocking, worth a look:

  • mcp/library/config.mjs:38 — KS_LIBRARY_TARGET defaults to prod when unset. The documented wiring example always sets it explicitly, and probe.mjs usage sets staging explicitly too, so this is low-risk in practice, but given how much of this repo's CLAUDE.md is about requiring an explicit go before any prod write, failing loudly on a missing target (instead of silently defaulting to prod) would remove one way to point an agent at production by accident.

No violations found of App Router patterns, Tailwind/CSS-in-JS, new state libraries, global CSS import location, <img>-for-SVG, barrel export rules, or import ordering in the diff. JSON-LD in the new objectSeo() schema goes through SeoGenerator's existing </>/& escaping before dangerouslySetInnerHTML, so no script-tag-breakout risk there. createTagSchema.ts's relaxation to free-text tag names and the 180-char shelf/tag description caps match what CLAUDE.md documents for this release.

Comment on lines +94 to +103
{sourceUrl && (
<a
className={styles.link}
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
>
Source
</a>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unsanitized sourceUrl used as href on a publicly crawlable page.

sourceUrl is rendered directly as an anchor href with no scheme check. For books, the field isn't reachable through the manual add form (addObjectSchema.ts has no sourceUrl for books, and AddObjectModal.config.ts omits it from book.fields), so today it can only be populated by the trusted autofill providers or, now, the new "member library" search (src/pages/api/library/autofill/book.ts), which forwards o.attributes?.sourceUrl from another owner's stored object as‑is. Anything that can be written directly through the CMS admin/API (bypassing the frontend zod schema) — e.g. a javascript: URL — would render as a real link on this new server-rendered, crawler-facing object page (/library/[username]/[[...object]]), and any visitor who clicks "Source" would execute it in the page's origin.

Video/audio already have the same pattern in ObjectOverviewModal.tsx (titleHref), gated by a URL_REGEX at input time — so this is pre-existing exposure being extended to a new, more widely reachable surface (public SSR page vs. an authenticated dialog).

This PR already ships a safeHref-style scheme allowlist in src/lib/library/noteHtml.ts (/^https?:\/\//i check) — worth reusing the same guard here (and possibly in ObjectOverviewModal) before rendering sourceUrl as a live href, rather than trusting upstream validation that a CMS-side edit can bypass.

Fix this →

@manager
manager merged commit 20eef20 into main Sep 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant