Skip to content

fix(deck): make ghost card rows distinguishable for a11y and scope their Trash to the membership#91

Merged
jcserv merged 3 commits into
mainfrom
fm/ghost-rows-a11y-q7
Jul 15, 2026
Merged

fix(deck): make ghost card rows distinguishable for a11y and scope their Trash to the membership#91
jcserv merged 3 commits into
mainfrom
fm/ghost-rows-a11y-q7

Conversation

@jcserv

@jcserv jcserv commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Intent

Give multi-category ghost rows an accessible qualifier and make their Trash remove only the secondary-category membership, not delete the whole card

What Changed

  • Ghost (secondary-category) card rows now carry an sr-only qualifier naming their primary category (e.g. "(also in ramp)"), are marked non-draggable, and label their Trash control as "Remove … from " instead of "Remove … from deck" so assistive tech can tell them apart from primary rows.
  • A ghost row's Trash now strips only that one DeckCardCategory membership rather than deleting the whole DeckCard; primary-row Trash still removes the card from the deck. Threaded through card-row-sortable.tsx, card-row.tsx, card-stack-sortable.tsx, card-stack.tsx, group-sort.ts, and zone-view.ts.
  • Added unit tests for the ghost-row rendering, labels, and membership-scoped removal, and documented the ghost-row Trash behavior in CONTEXT.md.

Risk Assessment

✅ Low: Small, well-bounded a11y change that follows established component patterns; optimistic dispatch and server action wiring are correct and consistent with sibling mutations.

Testing

Ran the change's vitest suite (51 pass) covering the qualifier text, both Trash aria-labels, and that ghost Trash calls setCardCategories rather than removeCardFromDeck. Then verified end-to-end in the running app: seeded a multi-category deck, signed in as owner, and confirmed in the accessibility tree that the ghost Sol Ring row shows the sr-only "(also in ramp)" qualifier with a membership-scoped Trash label, while primary rows keep "from deck". Clicking the ghost Trash dropped the removal count 2→1, left Sol Ring in ramp, and the database confirmed only the removal membership was deleted (card row intact) — proving no whole-card deletion. Qualifier evidence is an accessibility-tree capture because the span is sr-only by design; screenshots show the editor surface but not the invisible text.

  • Evidence: Deck read view (removal section with ghost Sol Ring) (local file: /var/folders/b2/rft4yv4177d6mpkbhbwj7svc0000gn/T/no-mistakes-evidence/01KXECW6GEKBG3Q0A5RTPW4P47/01-read-view-ghost.png)
  • Evidence: Owner editor before ghost Trash (removal 2/12, ghost + membership-scoped Trash) (local file: /var/folders/b2/rft4yv4177d6mpkbhbwj7svc0000gn/T/no-mistakes-evidence/01KXECW6GEKBG3Q0A5RTPW4P47/02-editor-ghost-trash-labels.png)
  • Evidence: After ghost Trash (removal 1/12, Sol Ring still in ramp) (local file: /var/folders/b2/rft4yv4177d6mpkbhbwj7svc0000gn/T/no-mistakes-evidence/01KXECW6GEKBG3Q0A5RTPW4P47/03-after-ghost-trash.png)
Evidence: Accessibility-tree + DB before/after evidence
Accessibility-tree evidence — deck editor /deck/ghostdeck0001 (owner: ghosttester)
Deck: "ramp" and "removal" categories. Sol Ring is primary in ramp, secondary (ghost) in removal.
The "(also in …)" qualifier is an sr-only span, so it surfaces in the accessibility tree rather than as visible pixels.

=== BEFORE clicking the ghost Trash — accessibility tree ===
region "removal (2/12)"
  heading "REMOVAL (2/12)"
    button "Drag Lightning Bolt"                 <- primary is draggable
    button "Lightning Bolt"
    button "Remove Lightning Bolt from deck"     <- primary Trash = full delete
    button "Sol Ring"                            <- ghost, NOT draggable (no "Drag Sol Ring")
    StaticText "(also in "  StaticText "ramp"    <- sr-only accessible qualifier
    button "Remove Sol Ring from removal"        <- ghost Trash = membership-scoped
region "ramp (1/10)"
  heading "RAMP (1/10)"
    button "Drag Sol Ring"
    button "Sol Ring"
    button "Remove Sol Ring from deck"           <- primary Trash = full delete

=== AFTER clicking "Remove Sol Ring from removal" ===
region "removal (1/12)"                           <- count dropped 2 -> 1
  heading "REMOVAL (1/12)"
    button "Lightning Bolt"
    button "Remove Lightning Bolt from deck"      <- Sol Ring ghost gone from removal
region "ramp (1/10)"                              <- unchanged: Sol Ring still here
  heading "RAMP (1/10)"
    button "Sol Ring"
    button "Remove Sol Ring from deck"

=== Persisted DB state after the action ===
deck_card_category rows for deck ghostdeck0001:
  Lightning Bolt | removal | 0
  Sol Ring       | ramp    | 0            <- removal membership stripped, ramp kept
deck_card rows (card NOT deleted):
  Lightning Bolt | qty 1 | MAINBOARD
  Sol Ring       | qty 1 | MAINBOARD      <- Sol Ring still in the deck

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ℹ️ app/_components/builder/card-row-sortable.tsx:277 - Ghost-row sr-only qualifier announces only the primary category (dc.categories[0]). For a card with 3+ memberships (e.g. primary A, secondaries B and C), the ghost under C reads "(also in A)" and never mentions B, and the announcement doesn't name the section the ghost currently sits in (that context comes only from the section heading). Acceptable since the primary is the most useful single anchor, but the qualifier is incomplete for many-category cards.
✅ **Test** - passed

✅ No issues found.

  • pnpm vitest run app/_components/builder/card-row-sortable.test.tsx app/_components/builder/card-row.test.tsx lib/deck/__tests__/group-sort.test.ts app/_components/builder/card-stack.tsx — 51 pass
  • Seeded a COMMANDER deck (Sol Ring primary in ramp, secondary/ghost in removal; Lightning Bolt in removal) and drove /deck/ghostdeck0001 in Chrome as the signed-in owner
  • Captured accessibility tree: ghost row exposes sr-only (also in ramp), is non-draggable, and its Trash reads Remove Sol Ring from removal; primary rows read Remove … from deck
  • Clicked Remove Sol Ring from removal: removal section 2/12→1/12, Sol Ring remained in ramp
  • SQL check: only the removal deck_card_category row deleted; Sol Ring deck_card row (qty 1, MAINBOARD) preserved — card not deleted
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Summary by CodeRabbit

  • New Features
    • Improved handling of cards shown in multiple categories via “ghost” entries.
    • Removing from a secondary (“ghost”) category now strips only that membership while preserving the card in the deck and other categories.
    • Added “also in …” screen reader/label cues for secondary entries.
  • Bug Fixes
    • Confirmed primary-category removal continues to delete the card from the deck.
  • Documentation
    • Clarified how removing secondary vs primary category memberships behaves.
  • Tests
    • Added coverage for ghost-row/stack removal with mouse and keyboard interactions.

jcserv added 2 commits July 13, 2026 14:33
… the membership

Ghost rows (secondary-category fan-out copies of a multi-category DeckCard)
were indistinguishable to assistive tech and their Trash deleted the card from
the whole deck.

- Record the section category on each fan-out copy (groupByCategory) so a ghost
  knows which membership it represents.
- Add an "(also in {primary category})" qualifier to ghost rows/tiles across the
  sortable and read-only variants.
- Ghost-row Trash now strips only that secondary membership via setCardCategories;
  the primary row keeps full-delete semantics.

Refs #89 (M10 from #30 multi-category cards review).
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 02f93976-0f59-45b4-a4b4-c54064c6cd4d

📥 Commits

Reviewing files that changed from the base of the PR and between cb521df and c5adecd.

📒 Files selected for processing (2)
  • app/_components/builder/card-row-sortable.test.tsx
  • app/_components/builder/card-row-sortable.tsx

📝 Walkthrough

Walkthrough

Ghost category entries now retain their originating section category. Row and stack controls identify secondary membership and remove only that membership, while primary entries continue deleting the card from the deck. Tests cover metadata, accessibility labels, and removal side effects.

Changes

Ghost membership handling

Layer / File(s) Summary
Membership metadata and fan-out
lib/deck/group-sort.ts, lib/deck/zone-view.ts, lib/deck/__tests__/group-sort.test.ts
Fan-out secondary entries now carry sectionCategory, with updated DeckCard and GroupSortCard types and coverage for primary versus secondary entries.
Sortable row membership removal
app/_components/builder/card-row-sortable.tsx, app/_components/builder/card-row-sortable.test.tsx, app/_components/builder/card-row.tsx, app/_components/builder/card-row.test.tsx, CONTEXT.md
Secondary rows display primary-category context and remove only their section membership through updated category state; primary rows retain deck removal behavior. Documentation and tests cover the distinction.
Stack tile membership removal
app/_components/builder/card-stack-sortable.tsx, app/_components/builder/card-stack.tsx
Secondary stack tiles include category context and use membership-only removal, while primary tiles retain the existing deck-removal flow.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Editor
  participant CardRowSortable
  participant DeckEditorState
  participant DeckCategoriesActions
  Editor->>CardRowSortable: remove secondary category entry
  CardRowSortable->>DeckEditorState: dispatch move with remaining categories
  CardRowSortable->>DeckCategoriesActions: persist remaining categories
  DeckCategoriesActions-->>CardRowSortable: complete category update
Loading

Possibly related issues

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: ghost row accessibility distinction and membership-scoped trash removal.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fm/ghost-rows-a11y-q7

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/_components/builder/card-row-sortable.tsx`:
- Around line 183-198: Update the ghost-row keyboard deletion wiring in
handleRowKeyDown to pass dc.isSecondary ? removeMembership : remove, ensuring
secondary rows strip only their membership while primary rows retain full
deletion behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f6ebf322-155c-4e8d-b265-312d7bed6104

📥 Commits

Reviewing files that changed from the base of the PR and between 02f8b99 and cb521df.

📒 Files selected for processing (10)
  • CONTEXT.md
  • app/_components/builder/card-row-sortable.test.tsx
  • app/_components/builder/card-row-sortable.tsx
  • app/_components/builder/card-row.test.tsx
  • app/_components/builder/card-row.tsx
  • app/_components/builder/card-stack-sortable.tsx
  • app/_components/builder/card-stack.tsx
  • lib/deck/__tests__/group-sort.test.ts
  • lib/deck/group-sort.ts
  • lib/deck/zone-view.ts

Comment thread app/_components/builder/card-row-sortable.tsx
Keyboard Delete/Backspace on a ghost (secondary) row passed only `remove`
into handleRowKeyDown, fully deleting the DeckCard instead of stripping
its secondary-category membership. Wire the keyboard path to route
`dc.isSecondary ? removeMembership : remove`, mirroring the Trash button.

Add regression tests covering Delete/Backspace on both ghost and primary
rows.
@jcserv
jcserv merged commit d773475 into main Jul 15, 2026
5 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