From 3825dbacec9666e297bde3d2dc2866325f0e4637 Mon Sep 17 00:00:00 2001
From: Greyson Parrelli
Date: Thu, 23 Jul 2026 15:26:29 -0400
Subject: [PATCH] Improve arrangement phase UI.
---
web/src/components/ArrangePhase.tsx | 218 ++++++++++++++++++++++------
web/src/styles.css | 51 ++++++-
2 files changed, 219 insertions(+), 50 deletions(-)
diff --git a/web/src/components/ArrangePhase.tsx b/web/src/components/ArrangePhase.tsx
index 23b2e88..4cf1992 100644
--- a/web/src/components/ArrangePhase.tsx
+++ b/web/src/components/ArrangePhase.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView } from './CardView'
@@ -19,6 +19,16 @@ export function ArrangePhase({ view, you, send }: Props) {
const [dragging, setDragging] = useState(false)
const locked = you.ready
+ // Connector arrows drawn in the gaps between wrapped rows. The battle line
+ // reads right-to-left (card 0 fights first, top-right) and wraps downward, so
+ // when the line spills onto a new row we draw a curve from the leftmost card
+ // of a row to the rightmost card of the row below it — i.e. following the
+ // play order across the wrap, arrowhead landing on the next pet to fight.
+ const trayRef = useRef(null)
+ const cardRefs = useRef<(HTMLDivElement | null)[]>([])
+ const [connectors, setConnectors] = useState([])
+ const [svgSize, setSvgSize] = useState({ w: 0, h: 0 })
+
// Resync only if the deck's actual contents changed — every broadcast
// creates a fresh array, and blindly resetting would wipe an in-progress
// ordering whenever the opponent acts.
@@ -33,6 +43,96 @@ export function ArrangePhase({ view, you, send }: Props) {
})
}, [you.deck])
+ // Measure the laid-out cards and recompute the inter-row connector paths.
+ // Runs whenever the order changes and whenever the tray resizes (which is
+ // what actually moves the wrap points), so the arrows track the real layout.
+ useLayoutEffect(() => {
+ const tray = trayRef.current
+ if (!tray) return
+
+ const measure = () => {
+ const trayBox = tray.getBoundingClientRect()
+ setSvgSize({ w: trayBox.width, h: trayBox.height })
+
+ // Card boxes in play order. The `.card` gives the art rect; the wrapper
+ // also spans the reorder buttons, so its bottom marks the true row floor.
+ const boxes: {
+ left: number
+ right: number
+ top: number
+ bottom: number
+ wrapBottom: number
+ }[] = []
+ for (let i = 0; i < order.length; i++) {
+ const wrap = cardRefs.current[i]
+ if (!wrap) continue
+ const cardEl = (wrap.querySelector('.card') as HTMLElement | null) ?? wrap
+ const cb = cardEl.getBoundingClientRect()
+ const wb = wrap.getBoundingClientRect()
+ boxes.push({
+ left: cb.left - trayBox.left,
+ right: cb.right - trayBox.left,
+ top: cb.top - trayBox.top,
+ bottom: cb.bottom - trayBox.top,
+ wrapBottom: wb.bottom - trayBox.top,
+ })
+ }
+
+ // Cards run in play order and the RTL wrap makes each visual row a
+ // contiguous run (rightmost = earliest to play). Group by shared top edge.
+ const rows: number[][] = []
+ boxes.forEach((b, i) => {
+ const row = rows[rows.length - 1]
+ if (row && Math.abs(boxes[row[0]].top - b.top) < 24) row.push(i)
+ else rows.push([i])
+ })
+
+ // One connector per row break, running flat along the band between rows.
+ // It comes out the right side (mid-height) of the lower row's rightmost
+ // card — the next pet to play — sweeps across, and points into the left
+ // side (mid-height) of the row above's leftmost card, so the wrap reads as
+ // a return to where the line began. OUT is how far the risers sit outside
+ // the cards — kept generous so the arrowhead has a clean straight run to
+ // render on rather than being crammed against the card edge.
+ const OUT = 26
+ const conns: string[] = []
+ for (let r = 0; r < rows.length - 1; r++) {
+ const upper = rows[r]
+ const lower = rows[r + 1]
+ const head = boxes[upper[upper.length - 1]] // upper row, leftmost card
+ const tail = boxes[lower[0]] // lower row, rightmost card
+ const upperFloor = Math.max(...upper.map((i) => boxes[i].wrapBottom))
+ const lowerTop = Math.min(...lower.map((i) => boxes[i].top))
+ const flatY = (upperFloor + lowerTop) / 2
+ const headMidY = (head.top + head.bottom) / 2
+ const tailMidY = (tail.top + tail.bottom) / 2
+ const xL = head.left - OUT
+ const xR = tail.right + OUT
+ const rad = Math.min(10, OUT, (tailMidY - flatY) / 2, (flatY - headMidY) / 2)
+ conns.push(
+ [
+ `M ${tail.right},${tailMidY}`,
+ `L ${xR - rad},${tailMidY}`,
+ `Q ${xR},${tailMidY} ${xR},${tailMidY - rad}`,
+ `L ${xR},${flatY + rad}`,
+ `Q ${xR},${flatY} ${xR - rad},${flatY}`,
+ `L ${xL + rad},${flatY}`,
+ `Q ${xL},${flatY} ${xL},${flatY - rad}`,
+ `L ${xL},${headMidY + rad}`,
+ `Q ${xL},${headMidY} ${xL + rad},${headMidY}`,
+ `L ${head.left},${headMidY}`,
+ ].join(' '),
+ )
+ }
+ setConnectors(conns)
+ }
+
+ measure()
+ const ro = new ResizeObserver(measure)
+ ro.observe(tray)
+ return () => ro.disconnect()
+ }, [order])
+
function move(from: number, to: number) {
if (to < 0 || to >= order.length) return
setOrder((o) => {
@@ -92,53 +192,77 @@ export function ArrangePhase({ view, you, send }: Props) {
)}
-
- {order
- .map((c, i) => ({ c, i }))
- .reverse()
- .map(({ c, i }) => (
-
{
- dragIndex.current = i
- setDragging(true)
- }}
- onDragOver={(e) => {
- e.preventDefault()
- if (dragIndex.current !== null && dragIndex.current !== i) {
- move(dragIndex.current, i)
- dragIndex.current = i
- }
- }}
- onDragEnd={() => {
- dragIndex.current = null
- setDragging(false)
- }}
- >
-
-
-
-
-
-
- ))}
+
⚔️ first
+ {order.map((c, i) => (
+
{
+ cardRefs.current[i] = el
+ }}
+ draggable
+ onDragStart={() => {
+ dragIndex.current = i
+ setDragging(true)
+ }}
+ onDragOver={(e) => {
+ e.preventDefault()
+ if (dragIndex.current !== null && dragIndex.current !== i) {
+ move(dragIndex.current, i)
+ dragIndex.current = i
+ }
+ }}
+ onDragEnd={() => {
+ dragIndex.current = null
+ setDragging(false)
+ }}
+ >
+
+
+
+
+
+
+ ))}
+
diff --git a/web/src/styles.css b/web/src/styles.css
index 842bee9..487f9d6 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -1093,22 +1093,40 @@ h3 {
}
.arrange-row {
+ position: relative;
display: flex;
- gap: 14px;
+ /* Battle line reads right-to-left, wrapping downward: card 0 (fights first)
+ sits top-right and later pets flow left, then onto rows below. The wide
+ row-gap leaves a band for the wrap-around connector arrows. */
+ direction: rtl;
+ column-gap: 14px;
+ row-gap: 56px;
align-items: center;
flex-wrap: wrap;
- justify-content: center;
+ justify-content: flex-start;
background:
radial-gradient(ellipse 80% 120% at 50% 0%, rgba(255, 255, 255, 0.04), transparent 70%),
rgba(0, 0, 0, 0.24);
border: 1px solid rgba(0, 0, 0, 0.3);
border-radius: 18px;
- padding: 20px 18px;
+ /* Extra right padding reserves the strip the "first" marker sits in, so its
+ width doesn't shove the top row left of the wrapped rows below it. */
+ padding: 20px 52px 20px 18px;
min-height: 224px;
box-shadow: var(--tray-inset);
}
+/* Pinned out of flow to the reserved strip beside the first (rightmost) card,
+ vertically centred on the top row, so it never affects card alignment. */
.arrange-marker {
+ position: absolute;
+ top: 20px;
+ right: 16px;
+ height: 148px;
+ display: flex;
+ align-items: center;
+ z-index: 2;
+ direction: ltr;
font-family: var(--font-display);
color: var(--gold);
writing-mode: vertical-rl;
@@ -1118,7 +1136,31 @@ h3 {
opacity: 0.85;
}
+/* SVG overlay for the wrap-around connector arrows. Absolute so it sits out of
+ the flex flow; pointer-events off so it never blocks dragging. overflow is
+ visible because the curve control points can bow just past the tray edge. */
+.arrange-connectors {
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+ overflow: visible;
+ pointer-events: none;
+}
+
+.arrange-connector {
+ fill: none;
+ stroke: rgba(190, 194, 200, 0.55);
+ stroke-width: 2.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.arrange-arrowhead-shape {
+ fill: rgba(190, 194, 200, 0.55);
+}
+
.arrange-card {
+ direction: ltr;
display: flex;
flex-direction: column;
align-items: center;
@@ -1836,6 +1878,9 @@ h3 {
width: 92px;
height: 128px;
}
+ .arrange-marker {
+ height: 128px;
+ }
.card-lg {
width: 104px;
height: 146px;