Skip to content

castle: a table that fits a phone - #316

Merged
aaylward merged 9 commits into
mainfrom
claude/castle-mobile
Sep 7, 2026
Merged

castle: a table that fits a phone#316
aaylward merged 9 commits into
mainfrom
claude/castle-mobile

Conversation

@aaylward

@aaylward aaylward commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Four things from mobile play tests, in one PR, plus the notes from the first look at it. Screenshots are from a fixture harness at 390×844 (iPhone 14-ish), 2×. Neither the harness nor the images are in the tree; the images live at the commit that carried them, which the links below pin.

Cards carry their index in the corners

Rank and suit top-left and, turned round, bottom-right, the way a real card does; the suit is the pip in the middle. The marks scale with the card's font size, so the phone's cards and the small flipped one in the last-play line have the same face. Every card has an edge now — two white cards side by side read as one white shape without it — and one lying on another casts onto it.

One fan for every hand, scrolling when it is wide

The fan is the hand at every size. Its overlap grows with the hand but is capped where a card would lose its corner index or a thumb's width to tap (1.4rem, 1rem on a phone), and a hand wider than the chair scrolls — by finger on touch, dragged by mouse on desktop — with the last card in view cut off, which is how a hand says there is more. The arc is bounded at thirty degrees however many cards, so the ends of a big hand still face the player and stay inside the hand's own edges. A mouse drag that moved the hand is not a tap on the card it started on; one that moved nothing, on a hand that fits, is. A drag on any button but the primary, or one whose button came up where the hand could not see, ends without eating the next tap. The pointer is captured once the drag has moved, never on the press: a captured press retargets its click to the hand and the card under it never hears the tap (checked in Chromium, where a real mouse tap toggled nothing before and does now). A pointed-at or tabbed-to card comes to the front; a selected one does not, so the neighbour it would cover keeps the index the next pick needs. The hand is rank-sorted, so the cards you play together are neighbours, and the Play button lists what is selected even when part of it is scrolled away.

14 cards two 7s selected scrolled to the end

Desktop, same 14 cards, all of them in the chair:

The middle says less

Three lines of prose became: the draw pile as a thing (a back with its count), the pile's count on the run, one short price line only on your turn (Play 8 or higher, Play two or more, 8 or higher, Anything goes), and the last play shown for a moment and then gone.

your turn not your turn

(The last-play line under the price is mid-fade in both; it lingers 3s.) It is one live region for the table's life with the moment as a keyed child, so a screen reader hears a change rather than a region appearing, and the finished fade takes it out of the tree and the layout, so the pile holds no empty line, or card-high hole after a pick-up, until the next play. The pile is a group named by its count, not an image, so the run inside stays readable.

A failed flip explains itself

The pile shows the card that did not play, and stays six seconds instead of three; the pile it brought slides into the hand one card after another. The slide-in is keyed on "faces not in the hand last time", so a normal draw-back gets it too — every turn, since an arriving card's key carries a generation and is a fresh node each time. A new table's deal is not an arrival. A wide hand is scrolled to its first new card, so the arrival is seen. prefers-reduced-motion keeps the fades and drops the movement.

before the flip the cards arriving settled

The other chairs are further away

Opponents' seats are drawn at 0.72 scale, layout and all, so the viewer's own hand has the phone. What their chairs need to say — how many in hand, which castle stacks still stand, what is face up — still reads at that size, and nothing at their chairs is tapped. zoom rather than transform, so the shrunk chair takes up less room instead of leaving a scaled-down picture in a full-size box; Firefox before 126 draws them full size.

four seats, phone desktop

No toast for someone else's turn

The felt already lights the seat on turn. "Your turn" stays. Golf had the same line and gets the same change; turnMessage had no other caller and is gone.

Tests

The fan's spread bounded and its overlap growing with the hand; a wide hand with every card a button; a tap toggling, a mouse drag scrolling and not toggling, a drag on a hand that fits still a tap, a touch not dragging and its tap toggling, a press that did not move still a tap, a drag on the wrong button not eating the next tap, movement without the button down not scrolling, and the pointer captured by the drag and not by the press. Price line on turn and absent off it; counts as the piles' labels; the flipped card inside the pick-up line; the last play as one region across plays, gone from it when its fade ends and back with the next play, the flipped card's own animation ending not taking it; an arrival scrolled into view and a deal not. Cards that arrived carrying the slide-in class in arrival order, the ones already there not, a second arrival at the same index a fresh node, and a new table's deal not an arrival. enteredSince as a multiset diff; describePile's wording; neither hook toasting another seat's turn. The card face, the overlap cap and the arc's geometry are CSS and are what the screenshots are for.

501 tests, lint, typecheck and build clean. The perspective is CSS and is what the four-seat screenshot is for.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM

Four things from mobile play tests.

A hand too big to fan is a strip: every card whole, scrolled by finger
or dragged by mouse, the ends faded where there is more. A drag that
went anywhere is not a tap on the card it started on.

The middle says less. The counts are on the piles — the draw pile is a
thing now, not a number in prose — the price is one line and only the
mover's, and the last play shows for a moment then goes.

A failed flip shows the card that did not play and stays longer, while
the pile it brought slides into the hand one card after another.

Another seat's turn is the felt's to show, not a toast's. Golf too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 7, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
muchq 6a8aeea Commit Preview URL

Branch Preview URL
Sep 07 2026, 04:47 PM

Review panel findings.

A draw-back lands at the same index every turn, and a node React keeps
does not run its animation again — so the slide ran once per game. An
arriving card's key carries a generation now, and each arrival is a
fresh node.

A drag on any button but the first ends in a context menu and never a
click, and would have left the next tap eaten. A move without the
primary button down ends the drag.

The last play is one live region for the table's life, with the moment
as a keyed child, so a screen reader hears a change rather than a region
appearing; the finished fade hides it from the tree as well as the eye.

enteredSince lives in rules.ts with the other card helpers and has its
multiset test; the castle hook's turn toast has the test golf's had; the
flip's animation respects reduced motion, which the phone card size's
tie-break had defeated.

The screenshots are out of the tree: the PR body reaches them at the
commit that carried them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM
A card's rank and suit sit top-left and, turned round, bottom-right, the
way a real card carries them — so a card mostly under its neighbour
still says what it is. The suit is the pip in the middle. The marks
scale with the card's font size, so the phone's cards and the flipped
one in the last-play line have the same face.

That is what lets the strip overlap: each card shows a thumb's width
and its index, so a hand of fourteen fits a phone with less scrolling.
Every card has an edge now, since two white cards side by side read as
one white shape without it, and one lying on another casts onto it.

A pointed-at or tabbed-to card comes to the front; a selected one does
not, so its right-hand neighbour keeps the index the next pick needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM
The PR body reaches them at the commit that carried them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM
The strip is gone: the fan is the hand at every size. Its overlap is
capped where a card would lose its corner index or a thumb's width, and
a hand wider than the chair scrolls — by finger, or dragged by mouse —
with the last card in view cut off, which is how a hand says there is
more. A drag that moved nothing, on a hand that fits, is a tap.

The arc is bounded at thirty degrees however many cards, so the ends of
a big hand still face the player and stay inside the hand's own edges;
the scroller keeps room at its sides for the lean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM
The PR body reaches them at the commit that carried them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM
@cursor

cursor Bot commented Sep 7, 2026

Copy link
Copy Markdown

Review

Strong PR overall — the fan-everywhere model, corner indexes, and the pick-up explain-itself work are coherent, and the drag/tap tests are careful about the cases they can reach under jsdom. CI is green.

One ship-blocker on desktop mouse, then a few real follow-ups. Verified the click bug under headless Chrome with React 18 (count stays 0 when setPointerCapture is used; the PR’s fireEvent.click(button) path never hits capture retargeting).

Blocker

  1. Mouse taps on hand cards no longer selectsetPointerCapture on every mouse pointerdown retargets the subsequent click to the hand, so React never runs the card onClick. Touch is fine; desktop mouse is broken. Details on the line.

Should fix

  1. role="img" on the pile wraps the run — AT treats children as presentational, so the top cards disappear from the accessibility tree just as the price becomes turn-only and last-play self-hides.
  2. Faded last-play still occupies layoutvisibility: hidden + forwards leaves an empty hole (worse on pick-ups with the flipped card) until the next play replaces the child.
  3. .entered’s finished animation pins transform: none — newly arrived cards keep the gold selected edge but never lift like the rest of the hand, until the hand changes again.

Worth fixing / clarifying

  1. Arriving cards can land off-screen — slide-in runs, but nothing scrolls the hand toward the new indexes, so a pick-up into a wide hand can animate cards the player never sees.
  2. Off-turn price removal vs match count — when run.length !== lastPlay.cards.length (queen-on-queen / the default fixture), the run alone misleads about how many to play once last-play has faded. On main the full price line stayed. Either keep a compact off-turn price, or accept and document the trade.

Happy to re-review after the capture fix.

Comment thread src/apps/castle/components/CastleTable.module.css
Comment thread src/apps/castle/components/CastleTable.tsx Outdated
Comment thread src/apps/castle/components/CastleTable.tsx Outdated
Comment thread src/apps/castle/components/CastleTable.module.css
Comment thread src/apps/castle/components/CastleTable.tsx
Comment thread src/apps/castle/components/CastleTable.tsx
Opponents' seat frames render at 0.72 zoom, layout and all, so the
viewer's own hand has the phone. Their hand count, castle stacks and
face-up cards still read at that size; nothing at their chairs is
tapped. Screenshots for the PR ride along and come back out next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM
Pointer capture is taken once a drag has moved, not on the press: a
captured press retargets its click to the hand and the card never
hears the tap. Checked in Chromium; jsdom asserts the press does not
capture and the drag does.

Also: the pile is a group, so the run inside it stays readable; a
faded last play unmounts on animation end instead of holding its
line; an arriving card is scrolled into view; the slide-in fills
backwards so a landed card still lifts when selected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvyU4MCzgsmRNa33yW1YNM
@aaylward
aaylward merged commit 81b1b56 into main Sep 7, 2026
2 checks passed
@aaylward
aaylward deleted the claude/castle-mobile branch September 7, 2026 17:01
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.

2 participants