Skip to content

Library: tag hints, a description that is read, and fifty objects a shelf - #215

Open
manager wants to merge 7 commits into
devfrom
feat/library-tag-unused-hint
Open

Library: tag hints, a description that is read, and fifty objects a shelf#215
manager wants to merge 7 commits into
devfrom
feat/library-tag-unused-hint

Conversation

@manager

@manager manager commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Everything queued for the next staging build, in one place.

Tags. An unused tag no longer stands there with a text caret: it carries a label pointer and says Tag not used, on hover and on keyboard focus, keeping its tab stop as a control that is off. A tag description was written and never seen again, though the form promises it appears on hover; the pill now says it, under the tag name when the name is clipped. That sentence is capped at 180 characters at both ends, counted under the field. The form used to allow 500 where the CMS refused past 150, so anything in between was silently lost.

Shelves. The per-shelf cap goes from 30 to 50, all types counted together. The full-shelf error is matched on its shape rather than its number, so the two sides cannot disagree mid-rollout.

CMS sides, both already live on the staging CMS and proved there against the live API: keepsimple-cms-new #409 (180 characters) and #410 (fifty objects, the 51st refused).

🤖 Generated with Claude Code

A tag on no book stood in the panel with a text caret over it, offering a
selection its label never answers and no reason for the click it refuses. It
now carries the pointer of a label and says Tag not used on hover, through the
shared Tooltip every other disabled control explains itself with. A clipped
name keeps its word in that hint. In the object form's menu the pill follows
the row it stands in.

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

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Claude finished @manager's task in 2m 18s —— View job


Claude finished reviewing this PR

  • Gather context (diff, changed files, CLAUDE.md/AGENTS.md conventions)
  • Understand the request (code review)
  • Review correctness, TS, conventions, a11y, security, styling
  • Post inline comments for issues found
  • Post summary

Summary

Small, focused change: an unused tag (tag.count === 0, owner-only view) now gets cursor: default + user-select: none instead of a text caret, and explains itself via the shared Tooltip (Tag not used) instead of sitting there as a dead control. CLAUDE.md's tag-filter section is updated to match, and TagMultiSelect.module.scss gets a matching cursor: inherit on option children.

One issue posted inline (Tag.tsx:87): the new hint is currently hover-only. When hint is set without onClick, the pill has no role/tabIndex (unchanged lines 35-39), so it's never a tab stop — react-tooltip triggers on focus as well as hover, but an unfocusable element can't receive that focus via keyboard. Keyboard and screen-reader users get no way to discover why an unused tag doesn't respond, even though the hint prop's own doc comment says it should behave like every other disabled control in the Library (which, per Dropdown.tsx and TagMultiSelect.tsx, stay focusable with aria-disabled rather than dropping out of the tab order).

Everything else checks out:

  • Convention: props typed inline via Tag.types.ts (matches AGENTS.md), no any, import order looks correct, SCSS stays in modules with existing radius/scrollbar tokens — no new hardcoded values.
  • The tooltipContent composition logic (label + hint when truncated, hint alone otherwise, label alone when only truncated) reads correctly against the four input combinations.
  • Sidebar.tsx's tag.count === 0 filtering already restricts unused tags to the owner (canEdit) view server-side (.filter(tag => canEdit || tag.count > 0)), consistent with "a visitor is not shown it at all" in the updated CLAUDE.md.
  • No SSR/hydration, security, or state-management concerns — this is presentational only.

I was not able to run yarn lint / yarn check:library in this sandbox (Bash commands required approval that wasn't available); the fix above is a scoped, low-risk change and doesn't affect anything gated by the Library release checklist.
· branch feat/library-tag-unused-hint

? (label ?? '')
: '';

return tooltipContent ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Accessibility: the "Tag not used" hint is mouse-only.

When hint is set without onClick (the unused-tag case in Sidebar.tsx:527), role/tabIndex/aria-pressed on the pill (lines 35-39, unchanged by this diff) all fall through to undefined, so the pill is never a tab stop. react-tooltip triggers on hover and focus by default, but with no tabIndex the element can never receive keyboard focus in the first place — keyboard users and screen-reader users get no way to learn why an unused tag doesn't respond. Only mouse users see the tooltip.

This also contradicts the hint doc comment in Tag.types.ts:12-13 ("the same way every other disabled control in the Library explains itself") — the actual convention elsewhere (Dropdown.tsx:233-236, TagMultiSelect.tsx:128-131) keeps the element focusable and adds aria-disabled/role even when the action is unavailable, rather than dropping focusability entirely.

Suggest keeping the pill in the tab order and marking it disabled when a hint exists without a click, e.g.:

const isDisabledHint = !!hint && !onClick;
...
role={onClick || isDisabledHint ? 'button' : undefined}
tabIndex={onClick || isDisabledHint ? 0 : undefined}
aria-disabled={isDisabledHint || undefined}

so the tooltip (and the reason for the dead click) is reachable via keyboard, not just :hover.

Fix this →

The hint was hover only: with no click to give, the pill left the tab order,
so a reader on the keyboard met a tag that ignored them and no sentence saying
why. It keeps its tab stop as a control that is off, marked aria-disabled, and
answers focus with the same ring and the same words the pointer gets.

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

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Claude finished @manager's task in 1m 16s —— View job


Claude finished reviewing this PR

  • Gather context (diff, changed files, CLAUDE.md/AGENTS.md conventions)
  • Understand the request (re-review after new commit pushed)
  • Review correctness, TS, conventions, a11y, security, styling
  • Post inline comments for issues found
  • Post summary

Summary

This push (d1e22c1, "let a keyboard reach the unused tag's reason") directly fixes the accessibility issue flagged in the prior review round on this PR: the unused-tag hint (hint="Tag not used" with no onClick, set in Sidebar.tsx:527) was hover-only because the pill had no tabIndex/role, so keyboard/screen-reader users couldn't discover it.

The fix is correct and matches the suggested approach:

  • Tag.tsx:37const explained = !onClick && !!hint;
  • Tag.tsx:41,44,46role/tabIndex now also fire on explained, and aria-disabled={explained || undefined} is added — this mirrors the existing focusable-but-disabled convention in Dropdown.tsx:233-236 (role="button" + aria-disabled, not a dropped tab stop), so it's consistent with the codebase rather than inventing a new pattern.
  • Tag.module.scss:37-44.explained:focus-visible reuses the exact same --white/--brown ring token pair as .clickable:hover/:focus-visible and .active, so keyboard-focus parity with hover is pixel-for-pixel, no new hardcoded values.
  • The tooltipContent composition (Tag.tsx:87-93) correctly falls back to hint alone when not truncated, and prefixes the label when both truncated and hinted — verified against all four input combinations again after the change, still correct.

Also good:

  • TagMultiSelect.module.scss:97-100 — the new * { cursor: inherit } on .option is necessary now that Tag.module.scss sets cursor: default on .wrapper; without it, hovering the pill inside a multi-select row would show a text cursor mid-row. Correct fix for a real regression the base commit would otherwise have introduced.
  • CLAUDE.md tag-filter section update accurately describes the new behavior ("says Tag not used on hover and on keyboard focus, keeping its tab stop as a control that is off").
  • No SSR/hydration, security, TypeScript (any), import-order, or state-management concerns. Everything stays within SCSS Modules and existing design tokens.

No new issues found — this is a clean, scoped fix. Nothing further to flag.

I was not able to run yarn lint / yarn check:library in this sandbox (no Bash approval available); the change is low-risk and self-contained to the Tag/TagMultiSelect/Sidebar components reviewed above.
· branch feat/library-tag-unused-hint

manager and others added 2 commits September 9, 2026 10:21
The tag form promised it in its own placeholder and nothing ever showed it: a
description was saved and then only ever seen again in the form that wrote it.
The pill now says it on hover, under the tag's own name when the pill is too
narrow to hold the word, and above the note that no book carries it.

The sentence is capped at 180 characters, counted under the field as it is
typed. The form used to allow 500 while the CMS refused past 150, so a longer
description was written, sent and lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@manager manager changed the title Library: a tag nobody used says so Library tags: an unused tag says so, and a description is finally read Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Claude finished @manager's task in 2m 48s —— View job


Reviewing PR #215

  • Gather context (diff, changed files, CLAUDE.md/AGENTS.md conventions)
  • Understand the request (re-review after synchronize — commits d1b1255 "say on hover what the tag was written to mean" and 5887874 docs update, on top of the previously-reviewed unused-tag hint)
  • Review correctness, TS, conventions, a11y, security, styling
  • Post inline comments for issues found
  • Post summary

Summary

This push adds the tag-description hover hint on top of the already-reviewed "Tag not used" a11y fix. The keyboard-accessibility issue flagged in the earlier review round (d1e22c1) is confirmed fixed and unchanged here.

One issue posted inline (src/constants/library/common.ts:103): the new MAX_TAG_DESCRIPTION_LENGTH = 180 may not match the actual backend limit. This PR's own CLAUDE.md update states the reason for adding a cap is that "the form used to allow 500 where the CMS refused past 150" — i.e. the real Strapi-side limit is 150, not 180. No CMS schema change is part of this PR's changed files, and unlike other CMS-contract entries in CLAUDE.md (e.g. the AI Shelf preference section, which cites a specific keepsimple-cms-new commit and deployment), there's no evidence cited that the CMS cap was actually raised to 180. If it wasn't, descriptions of 151–180 characters will pass client-side validation (createTagSchema, the Textarea maxLength, and CharCount) and still be rejected by the CMS — reproducing the same class of bug this change sets out to fix, just with a narrower window. Given this repo's release-evidence gates specifically warn against declaring CMS-side contracts satisfied without deployment provenance, this is worth confirming before merge.

Everything else checks out:

  • Tag.tsx's tooltipContent composition (isTruncated ? label, description?.trim(), hint) correctly builds 0/1/multi-line tooltips, falls back to a plain string for the single-line case (preserving prior behavior/width), and only wraps in the .tip/.tipLine layout when there's more than one line.
  • Tooltip.types.ts already types tooltipContent as ReactNode, so passing the composed JSX is safe and doesn't require any Tooltip changes.
  • explained (!onClick && !!hint) keeps role="button"/tabIndex={0}/aria-disabled on the unused-tag pill, consistent with the focusable-but-disabled convention elsewhere (Dropdown.tsx) — the keyboard fix from the prior round holds up.
  • cursor: default on .wrapper is correctly overridden by .wrapper.clickable { cursor: pointer } via specificity, and the new .explained:focus-visible ring reuses the same --white/--brown tokens as .clickable/.active — no new hardcoded values.
  • TagMultiSelect.module.scss's * { cursor: inherit } correctly prevents the new pill cursor: default from showing a stray default cursor mid-row.
  • CreateTagModal.tsx reuses the existing CharCount atom and wires MAX_TAG_DESCRIPTION_LENGTH through the Textarea's native maxLength, the counter, and createTagSchema's Zod validator from one shared constant — matches the "counter and validator cannot drift apart" pattern already established for the About/Author caps.
  • No SSR/hydration, security, any, import-order, or state-management concerns.

Minor (non-blocking) nit: Tag.tsx's tooltip lines are keyed by key={line} (the line's own text) — if description ever duplicates the visible label or the hint text exactly, React will warn about duplicate keys. Not worth blocking on, but keying by index would avoid the theoretical collision.

I was not able to run yarn lint / yarn check:library in this sandbox (no Bash approval available); the flagged cap mismatch is the one item I'd want resolved (or explicitly confirmed as already handled CMS-side) before this ships.
· branch feat/library-tag-unused-hint


// Mirrors the `description` cap in the CMS tag schema. Shared so the counter
// in the form and the validator behind it cannot drift apart.
export const MAX_TAG_DESCRIPTION_LENGTH = 180;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Possible mismatch with the actual CMS limit.

This PR's own CLAUDE.md update says the reason for this cap is that "the form used to allow 500 where the CMS refused past 150" — i.e. the backend (Strapi) rejects tag descriptions longer than 150 characters. But MAX_TAG_DESCRIPTION_LENGTH here is set to 180, and that same constant drives the client-side maxLength, the CharCount display, and the createTagSchema Zod validator (src/utils/library/schema/createTagSchema.ts:20-26).

If the CMS-side validation is still 150 (no CMS schema change is included in this PR's changed files, and no CMS commit/deployment is referenced the way other CMS-contract changes in CLAUDE.md are — e.g. the AI Shelf preference section cites a specific keepsimple-cms-new commit), then descriptions of 151-180 characters will pass client validation and still be rejected by the backend with a raw Strapi error — reintroducing the exact mismatch this change is meant to fix, just with a narrower gap (30 chars instead of 350).

Worth confirming: has the CMS-side cap actually been raised to 180? If yes, please cite that (commit/deployment) the way other CMS-touching entries in CLAUDE.md do. If no, this constant should be 150, not 180.

[Fix this →](https://claude.ai/code?q=In%20src%2Fconstants%2Flibrary%2Fcommon.ts%2C%20MAX_TAG_DESCRIPTION_LENGTH%20is%20set%20to%20180%2C%20but%20the%20CLAUDE.md%20update%20in%20this%20same%20PR%20states%20the%20CMS%20(Strapi)%20refuses%20tag%20descriptions%20past%20150%20characters.%20Verify%20whether%20the%20CMS-side%20validation%20was%20actually%20raised%20to%20180%3B%20if%20not%2C%20change%20MAX_TAG_DESCRIPTION_LENGTH%20to%20150%20to%20match%20the%20real%20backend%20limit%2C%20so%20the%20client%20cap%2C%20createTagSchema%20validator%2C%20and%20CMS%20agree.&repo=keepsimpleio/KeepSimpleOSS

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

manager commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

The 180 is real on the CMS side, not assumed: keepsimple-cms-new #409 (merge ad1cb40) raises the schema cap from 150 to 180, and it is on the staging CMS since the image built 2026-09-09T10:30Z.

Proved against the live staging API as the library owner, not by CI: a 180-character description saved and read back at 180, a 181-character one refused with description must be at most 180 characters, and the probed tag restored to its original value. Cited in CLAUDE.md alongside the rest of the entry (b0c594d).

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Claude finished @manager's task in 50s —— View job


Reviewing PR #215 (new commits pushed)

  • Gather context (diff, changed files since last review)
  • Check whether the CMS-limit mismatch flagged in the previous round was resolved
  • Review remaining diff for correctness, TS, conventions, a11y, security, styling
  • Post summary

Summary

The only change since the last review round (5887874) is b0c594d, "docs(library): name the CMS side of the tag description cap" — a docs-only commit, 4 lines added to CLAUDE.md, no code touched. Confirmed via git diff 5887874..HEAD --stat.

This directly resolves the one open item from the previous review: MAX_TAG_DESCRIPTION_LENGTH (src/constants/library/common.ts:103, still 180, unchanged) previously had no cited evidence that the CMS cap actually matched. The new CLAUDE.md text now cites:

  • CMS commit keepsimple-cms-new@ad1cb40, deployed to staging CMS since 2026-09-09.
  • Live verification as the library owner: 180 chars saved and read back at 180, 181 refused with "description must be at most 180 characters", probed tag restored afterward.

That's the same evidence-citation pattern the AI Shelf preference section already uses elsewhere in CLAUDE.md (commit + deployment + live read-back), and it's exactly what the earlier review round asked for. No remaining mismatch between the client cap, createTagSchema, and the CMS.

Everything from earlier rounds still holds (unchanged since the last full review):

  • Keyboard/screen-reader accessibility for the unused-tag hint (explained = !onClick && !!hint, role/tabIndex/aria-disabled) — fixed in d1e22c1, confirmed correct.
  • tooltipContent composition in Tag.tsx for the four label/hint/truncation combinations — correct.
  • TagMultiSelect.module.scss's cursor: inherit fix for the new cursor: default on .wrapper — correct, prevents a stray text cursor mid-row.
  • No SSR/hydration, security, any, import-order, or state-management concerns anywhere in this PR.

No new issues found. This PR is clean from a code-review standpoint; the one open question (CMS-side cap parity) is now closed with cited deployment evidence per this repo's release-evidence gates.

I was not able to run yarn lint / yarn check:library in this sandbox (no Bash approval available for those specific commands); nothing in this final docs-only commit is gated by them.
· branch feat/library-tag-unused-hint

Thirty was the figure the shelves were built against and the owners filling
them ran out of room, so it is fifty. One number on each side: the CMS refuses
the fifty-first, the Add control goes quiet at fifty, and the full-shelf copy
still names the same rule. The backend message is matched on its shape, not on
its number, so the two never disagree while a rollout is halfway through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@manager manager changed the title Library tags: an unused tag says so, and a description is finally read Library: tag hints, a description that is read, and fifty objects a shelf Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Claude finished @manager's task in 1m 27s —— View job


Reviewing PR #215 (new commits pushed)

  • Gather context (diff since last reviewed commit b0c594d)
  • Check whether anything changed in the tag-hint/description work already reviewed
  • Review the new commit for correctness, TS, conventions, a11y, security, styling
  • Post summary

Summary

The tag-hint/description work (0eaee02b0c594d) is unchanged since the last review round — all prior findings (keyboard accessibility for the "Tag not used" hint, and the CMS description-cap mismatch) were resolved and confirmed with cited evidence.

New since then: 01afd6a, "a shelf holds fifty objects" — raises MAX_OBJECTS_PER_SHELF from 30 to 50 and updates the three comments/messages that cite the old number (AddObjectModal.tsx, ObjectOverviewModal.tsx, Shelf.tsx, src/constants/library/common.ts, src/lib/library/shelfFull.ts). This is unrelated to the tag-hint/description feature the PR title and description describe — flagging as a scope note in case it wasn't meant to ride this branch, though CLAUDE.md's "one agreed release branch" guidance suggests batching fixes here may be intentional.

The change itself is correct and consistent:

  • MAX_OBJECTS_PER_SHELF = 50 is the single source of truth; both Shelf.tsx's pre-disable check and AddObjectModal/ObjectOverviewModal's error copy reference it or the shared SHELF_FULL_MESSAGE.
  • shelfFull.ts's SHELF_FULL_PATTERN already matched on \d+ rather than a hardcoded 30, so the backend-message matcher needed no logic change — only the comment describing it was updated for accuracy.
  • Grepped the tree for stray 30/MAX_OBJECTS_PER_SHELF references and found none left over.
  • No new SSR/hydration, any, import-order, or styling concerns — this commit touches only a numeric constant and code comments.

One anomaly worth surfacing, not a PR-diff issue: the local checkout has an uncommitted working-tree change to CLAUDE.md that strips out the "Library tag filter" doc bullets this PR's own commits (d1b1255, b0c594d) added — including the CMS evidence citation (ad1cb40, live read-back proof) that closed the earlier review round. It isn't part of any commit on this branch (git diff b0c594d..HEAD doesn't touch CLAUDE.md, and HEAD:CLAUDE.md still has the full text), so it won't ship as-is, but it's sitting in the working directory and would silently delete that audit trail if it were ever committed. Worth double-checking where that unstaged edit came from before it gets swept into a future commit.

No blocking issues found in the new commit. I was not able to run yarn lint / yarn check:library in this sandbox (no Bash approval available for those specific commands).
· branch feat/library-tag-unused-hint

The palette could be created, renamed and filtered by, but the only place
that put a tag on a book was step 2 of the edit form. The object overview
now carries the picker in its own action row: one icon button opening the
library's tags as a multi-select, each click saved on its own, the Tags row
under the notes answering it and the panel's counts re-read after it.

An edit now states the whole set on save, the empty set included, so taking
the last tag off a book takes it off instead of reading as no change. Both
pickers read one cap.

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

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Claude finished @manager's task in 3m 8s —— View job


Reviewing PR #215 (new commits pushed)

  • Gather context (diff since last reviewed commit b0c594d)
  • Review new commit for correctness, TS, conventions, a11y, security, styling
  • Post summary

Summary

New since the last review round (01afd6a, already reviewed clean): 2a70cee, "put a tag on a book from the book" — adds a compact TagMultiSelect variant as an icon-button tag picker in ObjectOverviewModal's action row, wires save-on-click through PUT /api/objects/:id, animates the Tags row with useAnimatedList, and makes AddObjectModal always send the tag set on edit (so clearing the last tag actually clears it).

Two issues posted inline, both in ObjectOverviewModal.tsx:

  1. ObjectOverviewModal.tsx:806-816 — removed tag pills never leave. The rendered <span> sets data-flip-id but never sets data-flip-leaving, unlike every other useAnimatedList consumer in the codebase (Sidebar.tsx, Shelf.tsx, LibraryToolbar.tsx, RecommendedShelf.tsx, Home.tsx, Library.tsx, ShareSelectionPanel.tsx all set it). useAnimatedList.ts:157 reads slot.dataset.flipLeaving from the DOM to drive both the exit animation and the cleanup that removes the entry from departing state. Without it, a removed tag's pill never animates out and never gets cleaned up — it becomes a permanent ghost pill (inert via pointer-events: none, but still rendered) that accumulates with every subsequent removal.

  2. ObjectOverviewModal.tsx:336-373 — a queued tag change is silently dropped if the save it's queued behind fails. flushTagSave's own comment says a save-in-flight is "never raced: the newest choice is queued behind it." True on success, but on failure the catch block unconditionally does pendingTags.current = null, discarding any newer change queued while the failed request was in flight, then reverts the UI to the pre-failure state via setTags(savedTags.current). A user who clicks twice quickly while the first save fails loses the second click entirely — no retry, no visible trace, just a generic error and a UI snap-back past their second choice.

One minor nit (src/constants/library/common.ts:112): TAG_PER_OBJECT_LIMIT_MESSAGE is exported but never used — TagMultiSelect.tsx:169 hardcodes the identical string inline instead, so the two can drift even though the point of the shared constant (per this commit's own comment) is to keep both surfaces in agreement.

Everything else checks out:

  • AddObjectModal.tsx's tags = objectType === 'book' && (editing || selectedTags.length > 0) ? ... : undefined correctly sends an empty array on edit-to-clear while still omitting the field entirely on create-with-no-tags, so non-book/legacy objects aren't handed a field they were never offered. MAX_TAGS_PER_OBJECT correctly replaces the old hardcoded maxItems={10}.
  • TagMultiSelect's new compact variant is a clean additive branch (fieldTrigger/compactTrigger), doesn't disturb the existing field variant's markup or behavior, and reuses the shared Tooltip/design tokens (--radius-control, existing scrollbar passport) rather than inventing new ones — matches this PR's own CLAUDE.md "Library tag assignment" passport (36px button, 260px menu, 8px action row).
  • showTags (isOwner && objectType === 'book') vs. tags.length > 0 correctly keeps the row visible for non-owners with existing tags while adding the "always-visible for the owner" behavior only for the owner's own book — the visitor-facing path is unchanged.
  • No SSR/hydration, any, import-order, or global-CSS concerns. SCSS additions stay within existing tokens (--brown-border, --gray-darkest, --taupe/--white-100 scrollbar).

I was not able to run yarn lint / yarn check:library in this sandbox (no Bash approval available for those specific commands); both flagged issues were confirmed by reading the hook implementation and the save-queue logic directly, not by running anything.
· branch feat/library-tag-unused-hint

Comment on lines +806 to 816
{tagEntries.map(({ item: tag, leaving }) => (
<span
key={tag.id}
data-flip-id={String(tag.id)}
className={classNames(styles.tagSlot, {
[styles.tagLeaving]: leaving,
})}
>
<Tag label={tag.name} color={tag.color} />
</span>
))}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Removed tag pills never actually leave — missing data-flip-leaving.

Every other useAnimatedList consumer in this codebase (Sidebar.tsx:515, Shelf.tsx:164, LibraryToolbar.tsx:232, RecommendedShelf.tsx:375, Home.tsx:230, Library.tsx:1467, ShareSelectionPanel.tsx:416) sets data-flip-leaving={leaving ? 'true' : undefined} on the slot. This one doesn't — the <span> here only uses leaving to toggle the styles.tagLeaving CSS class, never to set the data-flip-leaving attribute.

useAnimatedList.ts:157 reads the state purely from the DOM attribute: const leaving = slot.dataset.flipLeaving === 'true';. Without that attribute, the if (leaving) { ... } branch (useAnimatedList.ts:159-184) never runs for a departing tag, which means:

  • the fade/collapse-width exit animation never plays, and
  • finish() (which calls setDeparting(list => list.filter(d => d.key !== key))) is never called.

So once a tag is removed from an object, its pill stays in departing state forever — a ghost pill (rendered with pointer-events: none via .tagLeaving) that never gets cleaned up and accumulates with every further removal.

Fix: add data-flip-leaving={leaving ? 'true' : undefined} to the <span>, matching every other consumer of this hook.

[Fix this →](https://claude.ai/code?q=In%20src%2Fcomponents%2Flibrary%2Forganisms%2FObjectOverviewModal%2FObjectOverviewModal.tsx%20around%20line%20806-816%2C%20the%20tagEntries.map%20renders%20a%20span%20with%20data-flip-id%20but%20never%20sets%20data-flip-leaving%3D%7Bleaving%20%3F%20%27true%27%20%3A%20undefined%7D.%20useAnimatedList.ts%20reads%20slot.dataset.flipLeaving%20to%20drive%20its%20exit%20animation%20and%20cleanup%2C%20so%20without%20this%20attribute%20removed%20tags%20never%20fade%20out%20and%20never%20get%20removed%20from%20the%20departing%20state%2C%20leaving%20ghost%20pills%20forever.%20Add%20the%20data-flip-leaving%20attribute%20to%20match%20every%20other%20useAnimatedList%20consumer%20in%20the%20codebase%20(e.g.%20Shelf.tsx%2C%20Sidebar.tsx%2C%20LibraryToolbar.tsx).&repo=keepsimpleio/KeepSimpleOSS

Comment on lines +336 to +373
const flushTagSave = async () => {
if (tagSaveInFlight.current) return;
const next = pendingTags.current;
if (!next) return;
pendingTags.current = null;
tagSaveInFlight.current = true;
try {
const res = await updateObject(id, { tags: next.map(t => t.id) });
savedTags.current = next;
const withRelations = preserveRelations(res.data);
onUpdated?.({
...withRelations,
attributes: {
...withRelations.attributes,
// A PUT answers without the relation it just wrote, and a cleared
// set has nothing to carry forward, so the saved list is written in
// here for the card and the hover dossier to read.
tags: {
data: next.map(t => ({
id: t.id,
attributes: { name: t.name, color: t.color },
})),
},
},
});
// The panel counts the books behind each tag and the gathered row is
// drawn from the same list, so both are re-read once the tag lands.
await refreshLibraryTags();
} catch (e) {
console.error('[ObjectOverviewModal] tag save failed', e);
pendingTags.current = null;
setTags(savedTags.current);
setTagsError('Could not save these tags. Please try again.');
} finally {
tagSaveInFlight.current = false;
if (pendingTags.current) void flushTagSave();
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A queued tag change is silently dropped if the in-flight save it's queued behind fails.

The comment at line 319-321 promises "a save in flight is never raced: the newest choice is queued behind it, so a run of quick clicks ends with the server holding exactly what is on screen." That holds on the success path, but not on failure:

  1. User adds tag A → handleTagsChange sets tags=[A], pendingTags.current=[A], calls flushTagSave.
  2. flushTagSave captures next=[A], clears pendingTags.current=null, sets tagSaveInFlight=true, and awaits updateObject.
  3. While that request is in flight, the user adds tag B → handleTagsChange sets tags=[A,B] and pendingTags.current=[A,B] (queued, since flushTagSave returns immediately due to tagSaveInFlight).
  4. The [A] request fails. The catch block runs pendingTags.current = null;this discards the queued [A,B], then setTags(savedTags.current) reverts the UI to whatever was saved before step 1.

The user's second click (B) is lost entirely: never sent to the server, and silently erased from the local UI on the very next render after the revert (since pendingTags.current is now null, finally's if (pendingTags.current) void flushTagSave(); does nothing). The user sees the picker snap back to the pre-A state with only the generic "Could not save these tags" error, with no indication their second click ever happened.

Consider preserving a still-current queued change across a failed save, e.g. only reset pendingTags.current in the catch block if it still matches next (nothing newer queued), otherwise let the newer pending value flow through to the next flushTagSave call in finally.

[Fix this →](https://claude.ai/code?q=In%20src%2Fcomponents%2Flibrary%2Forganisms%2FObjectOverviewModal%2FObjectOverviewModal.tsx%2C%20flushTagSave%20(around%20lines%20336-373)%20unconditionally%20sets%20pendingTags.current%20%3D%20null%20in%20its%20catch%20block.%20If%20a%20newer%20tag%20change%20was%20queued%20while%20the%20failed%20request%20was%20in%20flight%2C%20that%20queued%20change%20is%20silently%20dropped%20instead%20of%20being%20retried%2C%20and%20the%20UI%20reverts%20past%20it%20via%20setTags(savedTags.current).%20Fix%20so%20a%20newer%20queued%20pendingTags%20value%20survives%20a%20failed%20save%20of%20an%20older%20value%20and%20still%20gets%20flushed.&repo=keepsimpleio/KeepSimpleOSS

// cap on one surface and refused on the other.
export const MAX_TAGS_PER_OBJECT = 10;

export const TAG_PER_OBJECT_LIMIT_MESSAGE = `Up to ${MAX_TAGS_PER_OBJECT} tags per item. Remove one to add another.`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: TAG_PER_OBJECT_LIMIT_MESSAGE is defined but never imported/used anywhere. TagMultiSelect.tsx:169 renders the identical text (`Up to {maxItems} tags per item. Remove one to add another.`) as an inline template literal instead of this constant, so the two can drift apart later even though this commit's own comment says it exists so "a book cannot be filled past the cap on one surface and refused on the other." Worth wiring TagMultiSelect.tsx to use this constant (or dropping it if the generic maxItems-based copy in TagMultiSelect is meant to stay independent of any one caller's wording).

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