anvilsign in

collin/mahjong

1@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;600;900&display=swap');
2
3:root {
4 /* Depth of each player's strip. `--h-len` is floored so a window too narrow
5 to hold two strips shrinks the tiles rather than going negative.
6
7 Shallower than it looks like it should be, on purpose: discards live in the
8 middle of the table now rather than in a row inside each strip, and the
9 depth that freed up was given to the centre instead of to the tiles. The
10 wall square and the pile inside it are what wanted the room. */
11 --depth: clamp(226px, 30vh, 307px);
12 --h-len: max(160px, calc(100vw - 2 * var(--depth)));
13 --v-len: 100vh;
14 /* How tall an action button is: a tile, because that is what they are now.
15 They sit in the row your hand is in, in the room it already keeps above the
16 tiles, and they are the same tile as the ones under them — 打出 and 碰 are
17 things you do with a tile, so they are drawn as one. */
18 --bar-h: calc(var(--tile-w) * 1.375);
19 /* The daylight between two tiles standing side by side in a row, and between
20 two buttons — they are tiles too. */
21 --hand-gap: 2px;
22 /* The wider daylight: what a tile pushed out of the row gets. The tile just
23 drawn is set this far from the rest of the hand, and the buttons are set
24 this far from *it* — because that tile is the one they are usually about
25 to act on, and it is the one wearing a 摸 tag wider than itself and a gold
26 ring when it is lifted. Anything tighter and the two touch. */
27 --drawn-gap: calc(var(--tile-w) * 0.45);
28 /* Largest tile a strip can hold. Edge inwards it has to fit the melds (a
29 full tile, 1.375 tall, since they are the same tiles as the ones in the
30 hand), the button bar, the hand (1.71 + 6px) and the nameplate (28px), plus
31 its own padding, gaps and margins — so 3.085 tiles plus 122px.
32
33 The melds used to be drawn at 0.62 of a tile and budgeted at 0.86, which is
34 where the old 2.57 came from; `--depth` grew by the difference rather than
35 the tiles shrinking, for the same reason it shrank when the discards moved
36 out to the middle. The strip is deeper, but the felt is *bigger* than it
37 was for any seat that has not claimed anything — see geometry.ts.
38
39 It used to be 3.45 tiles: the missing 0.88 is the row of discards, which is
40 in the middle of the table now. `--depth` came down by the same amount
41 rather than the tiles growing, so a tile is the size it always was and the
42 centre is bigger.
43
44 And it used to be 122px rather than 70px: the missing 52 is the action bar,
45 which had a row of its own and had to be budgeted for whether or not
46 anything was on it. It has no row now — it hangs at the end of the row the
47 hand is in, out of the flow — so the strip stopped paying for one.
48
49 Tile size otherwise follows the strip's *length*, which is why a window
50 with less height than F11 kept full-size tiles and left the strip's
51 contents to be cut off. */
52 --tile-fit: calc((var(--depth) - 70px) / 3.085);
53
54 /* One size for every tile on the table — in a hand, in the wall, and lying in
55 the middle. They are the same tiles, so drawing them at different sizes was
56 always a lie, and a visible one: a tile that shrank as it was thrown read as
57 being dropped away from you rather than skidded across a flat table.
58
59 Taken from whichever way round is tighter, so all four hands match rather
60 than the side seats getting their own size off `--v-len`.
61
62 What a strip has to fit along its length, written out: sixteen tiles and
63 fifteen gaps of hand, the slot it keeps for the tile it is about to draw
64 (a gap, a `--drawn-gap` and a tile), the `--drawn-gap` on either side of
65 the hand, a tile of button at the end of the row — and, because the hand
66 is centred rather than pushed along, that same button's width again on the
67 other side. That comes to 20.35 tiles and 48px, and the row is 16px inside
68 the strip's own padding.
69
70 Sized off 18 tiles it was not enough: a hand down the side of a 765px
71 window ran off both ends of its own strip, and 碰 and 看牌 stood in felt
72 that was not on the screen. Nothing about the numbers below changes when
73 the buttons wrap — the room for the first one is always there. */
74 --tile-w: min(
75 calc((var(--h-len) - 48px) / 20.35),
76 calc((var(--v-len) - 48px) / 20.35),
77 var(--tile-fit),
78 72px
79 );
80
81 --felt: #0f4534;
82 --felt-2: #0a3126;
83 --ivory: #f6f0e2;
84 --ivory-edge: #d9cfb6;
85 --ink: #21262b;
86 --gold: #d9b45c;
87 --red: #b3261e;
88 --green: #1c7a4b;
89 --blue: #1d5aa8;
90}
91
92* {
93 box-sizing: border-box;
94 -webkit-tap-highlight-color: transparent;
95}
96
97html,
98body,
99#root {
100 height: 100%;
101 margin: 0;
102 overflow: hidden;
103 overscroll-behavior: none;
104 touch-action: manipulation;
105 user-select: none;
106}
107
108/* Phone browsers hand out a `100%`/`100vh` viewport that includes the address
109 bar they are about to slide away over the top of it; `dvh` is the height the
110 table can actually have. */
111@supports (height: 100dvh) {
112 html,
113 body,
114 #root {
115 height: 100dvh;
116 }
117}
118
119body {
120 font-family: 'Noto Sans TC', system-ui, sans-serif;
121 color: var(--ivory);
122 background: var(--felt-2);
123}
124
125/* ---------- lobby ---------- */
126.lobby {
127 height: 100%;
128 display: flex;
129 flex-direction: column;
130 align-items: center;
131 /* Centred while the whole lobby fits, and pinned to the top the moment it
132 does not. Plain `center` spills equally out of both ends of a short window,
133 and the half that goes off the top is cut away with no scroll that reaches
134 it: the title was losing its lower stroke and the settings row was falling
135 off the bottom on any laptop window under about 800px. `safe` keeps the
136 overflow on the end you can actually scroll to. */
137 justify-content: safe center;
138 overflow-y: auto;
139 padding: 16px 16px calc(16px + env(safe-area-inset-bottom));
140 gap: 0.5rem;
141 background: radial-gradient(ellipse at center, var(--felt) 0%, var(--felt-2) 75%);
142}
143/* Nothing in here gives up height to fit. A squeezed button is a button with
144 its own two lines of text crushed inside it, which is the cut-off text the
145 scroll is here to avoid. */
146.lobby > * {
147 flex: none;
148}
149.lobby h1 {
150 font-size: clamp(3rem, 9vw, 7rem);
151 font-weight: 900;
152 letter-spacing: 0.1em;
153 margin: 0 0 0.5rem;
154 color: var(--gold);
155}
156.lobby p {
157 margin: 0;
158 opacity: 0.75;
159 letter-spacing: 0.15em;
160}
161.lobby .hint {
162 opacity: 0.45;
163 font-size: 0.9rem;
164 margin-bottom: 1.5rem;
165}
166
167/* ---------- table ---------- */
168.table {
169 position: relative;
170 height: 100%;
171 width: 100%;
172 background: radial-gradient(ellipse at center, var(--felt) 0%, var(--felt-2) 80%);
173}
174
175.slot {
176 position: absolute;
177 overflow: hidden;
178}
179.slot-bottom {
180 bottom: 0;
181 left: var(--depth);
182 right: var(--depth);
183 height: var(--depth);
184}
185.slot-top {
186 top: 0;
187 left: var(--depth);
188 right: var(--depth);
189 height: var(--depth);
190}
191.slot-left {
192 left: 0;
193 top: 0;
194 width: var(--depth);
195 height: 100%;
196}
197.slot-right {
198 right: 0;
199 top: 0;
200 width: var(--depth);
201 height: 100%;
202}
203
204.seat {
205 position: absolute;
206 top: 50%;
207 left: 50%;
208 height: var(--depth);
209 display: flex;
210 flex-direction: column;
211 justify-content: flex-end;
212 align-items: center;
213 gap: 2px;
214 padding: 2px 8px 4px;
215 border-radius: 10px;
216 overflow: hidden;
217 transition: background 0.2s;
218}
219/* `--tile-w` is the table's, set once in :root — a seat only says how long its
220 own strip is. */
221.slot-bottom .seat,
222.slot-top .seat {
223 width: var(--h-len);
224}
225.slot-left .seat,
226.slot-right .seat {
227 width: var(--v-len);
228}
229.seat.active {
230 background: linear-gradient(to top, rgba(217, 180, 92, 0.22), transparent 70%);
231}
232/* Whose go it is, on a screen with four people round it. `active` is the same
233 thing said to a player who is already looking at their own tiles; this is the
234 version the *room* can read — it goes on a covered hand too, since whose turn
235 it is was never one of the things the card over a hand is hiding, and the
236 seat that has not noticed is exactly the one it is for.
237
238 Twice the gold `active` has and up twice as far into the strip: at a party
239 table the person being waited on may be looking at their phone, across the
240 room, or at somebody else's joke. */
241.seat.awaited {
242 background: linear-gradient(to top, rgba(217, 180, 92, 0.34), transparent 78%);
243}
244/* And the name at the outer end of the strip, since that is what somebody
245 glancing up reads to find out who — the felt says *where*, the plate says
246 *who*, and at a table of people who have known each other five minutes those
247 are not the same question. */
248.seat.awaited .nameplate {
249 opacity: 1;
250}
251.seat.awaited .nameplate .pname {
252 color: var(--gold);
253 text-shadow: 0 0 10px rgba(217, 180, 92, 0.55);
254}
255.seat.claiming {
256 background: linear-gradient(to top, rgba(179, 38, 30, 0.25), transparent 70%);
257 animation: pulse 1.1s ease-in-out infinite;
258}
259@keyframes pulse {
260 50% {
261 background: linear-gradient(to top, rgba(179, 38, 30, 0.45), transparent 70%);
262 }
263}
264
265/* ---------- the seven seconds ---------- */
266/* A bar of gold along the inner edge of the strip — the edge that faces the
267 middle of the table, which is the one everybody at the screen is looking
268 over. Every seat is rotated so that its own top is the inner edge, so `top`
269 is the right answer at all four of them without asking which.
270
271 It drains from both ends towards the middle rather than from one end to the
272 other, because two of the four strips are upside down from wherever you are
273 sitting and a bar that empties leftwards for you empties rightwards for the
274 person opposite. Draining symmetrically reads the same from every chair.
275
276 Nothing happens when it reaches zero. The bar is gone and the line it leaves
277 breathes, and the table goes on waiting — see ui/TurnClock. */
278.turn-clock {
279 position: absolute;
280 top: 0;
281 left: 8px;
282 right: 8px;
283 height: 5px;
284 border-radius: 999px;
285 overflow: hidden;
286 pointer-events: none;
287 z-index: 3;
288}
289.turn-clock i {
290 display: block;
291 height: 100%;
292 border-radius: inherit;
293 background: linear-gradient(
294 90deg,
295 rgba(217, 180, 92, 0.45),
296 var(--gold) 28%,
297 var(--gold) 72%,
298 rgba(217, 180, 92, 0.45)
299 );
300 box-shadow: 0 0 9px rgba(217, 180, 92, 0.65);
301 animation: turn-drain var(--turn-secs, 7s) linear forwards;
302}
303@keyframes turn-drain {
304 from {
305 transform: scaleX(1);
306 }
307 to {
308 transform: scaleX(0);
309 }
310}
311/* What is left at zero: the same line, dim, breathing. Delayed by exactly the
312 seconds the bar took, so it starts as the gold runs out. */
313.turn-clock::after {
314 content: '';
315 position: absolute;
316 inset: 1px 0;
317 border-radius: inherit;
318 background: rgba(217, 180, 92, 0.5);
319 opacity: 0;
320 animation: turn-empty 1.5s ease-in-out var(--turn-secs, 7s) infinite;
321}
322@keyframes turn-empty {
323 0%,
324 100% {
325 opacity: 0.12;
326 }
327 50% {
328 opacity: 0.55;
329 }
330}
331
332/* ---------- tiles ---------- */
333/* Faces come from a 43x2 sprite sheet; the second row is the highlighted
334 variant, which we use for the tile a player has lifted to discard. */
335.tile {
336 position: relative;
337 flex: none;
338 --tw: var(--tile-w);
339 width: var(--tw);
340 height: calc(var(--tw) * 1.375);
341 border-radius: calc(var(--tw) * 0.1);
342 transition: transform 0.12s ease, filter 0.12s ease;
343}
344/* A melded tile is the same tile it was in the hand it came out of, so it is
345 the same size. It used to be drawn at 0.62 and read as a different, smaller
346 set of tiles sitting beside the real ones. */
347.tile.t-meld {
348 --tw: var(--tile-w);
349}
350.tile.t-small {
351 --tw: calc(var(--tile-w) * 0.64);
352}
353.tile.face {
354 background-image: url('./assets/tiles.svg');
355 background-repeat: no-repeat;
356 background-size: calc(var(--tw) * 43) calc(var(--tw) * 2.75);
357 background-position: calc(var(--col) * var(--tw) * -1) 0;
358 filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45));
359}
360.tile.back {
361 background-image: url('./assets/back.png');
362 background-size: 100% 100%;
363 filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45));
364}
365.tile.tappable {
366 cursor: pointer;
367}
368.tile.raised {
369 transform: translateY(calc(var(--tw) * -0.28));
370 filter: drop-shadow(0 10px 12px rgba(0, 0, 0, 0.55));
371}
372/* Lifted tiles switch to the sheet's highlighted row. */
373.tile.face.raised {
374 background-position: calc(var(--col) * var(--tw) * -1) calc(var(--tw) * -1.375);
375}
376.tile.hl {
377 outline: 2px solid var(--gold);
378 outline-offset: 1px;
379 border-radius: calc(var(--tw) * 0.1);
380}
381.tile.dim {
382 opacity: 0.45;
383}
384
385/* ---------- seat sections ---------- */
386.hand {
387 display: flex;
388 gap: var(--hand-gap);
389 /* the extra top room is where the 摸 tag sits */
390 height: calc(var(--tile-w) * 1.71 + 6px);
391 padding-top: calc(var(--tile-w) * 0.34);
392 align-items: flex-end;
393 flex: none;
394}
395/* A hand keeps the room for the tile it is about to draw, whether or not it is
396 holding it yet.
397
398 The row is centred in the strip, so without this every draw slid all sixteen
399 tiles half a tile along and every discard slid them back — the tile you were
400 looking at moved out from under you between one glance and the next. The slot
401 is the same width the drawn tile takes when it arrives (its own gap, the
402 daylight it is set apart by, and the tile), so a draw fills a space that was
403 already there and nothing else moves at all.
404
405 Every hand, not only your own: the four strips are the same strip, and each
406 of them is one draw away from needing it. Not on a phone, where the row wraps
407 to as many lines as it needs and a draw re-flows it anyway — there the slot
408 would only push the whole hand off its own middle.
409
410 A face-down hand is the reason for the seventeenth-tile test. Its tiles are
411 all one tile — which one was drawn is exactly what a covered hand is not
412 saying — so it has no `.drawn-slot` to go by, and a dealt seventeen was
413 keeping room for an eighteenth tile that cannot exist. Seventeen is the most
414 a hand ever holds (a kong takes three out and gives one back), so a row that
415 long is a row already holding its draw, and it is the same width either way:
416 sixteen tiles and the slot, or seventeen tiles and none. */
417:root:not(.compact) .hand:not(:has(.drawn-slot)):not(:has(.hand-cell:nth-child(17))) {
418 padding-right: calc(var(--hand-gap) + var(--drawn-gap) + var(--tile-w));
419}
420/* And the seventeenth tile of a covered hand still stands the daylight away
421 from the rest that a drawn tile stands, so that the sixteen under it are at
422 the same sixteen places they were before the draw. It is at the end of the
423 row rather than beside the tile, since a covered hand is not saying which of
424 them arrived last. */
425:root:not(.compact) .hand:not(:has(.drawn-slot)):has(.hand-cell:nth-child(17)) {
426 padding-right: var(--drawn-gap);
427}
428/* Your own hand, face down at a shared screen until you tap it. The backs are
429 already the picture; all this adds is that it is *askable* — a hand that
430 lifts a little under the finger, the way a card laid over a strip does. The
431 three other hands, which are face down because they are not yours, get none
432 of it and stay flat. See the `shared` prop in ui/Seat. */
433.hand.covered {
434 cursor: pointer;
435 transition: transform 0.12s ease-out, filter 0.12s ease-out;
436}
437.hand.covered:hover,
438.hand.covered:focus-visible {
439 transform: translateY(-3px);
440 filter: brightness(1.12);
441 outline: none;
442}
443.hand.covered:active {
444 transform: translateY(-1px);
445}
446
447/* Somebody has thrown a tile hard enough to come off the pool and into this
448 row. It takes the knock and rocks back — the sound is `barge` in game/sound,
449 and what decides it happened is the physics, in ui/Pool. Inside the seat's
450 own rotation, so it is always knocked *away from* the middle of the table. */
451.hand.shoved {
452 animation: shoved 0.34s ease-out;
453}
454@keyframes shoved {
455 0% {
456 transform: none;
457 }
458 22% {
459 transform: translateY(5px) rotate(0.5deg);
460 }
461 55% {
462 transform: translateY(-2px) rotate(-0.3deg);
463 }
464 80% {
465 transform: translateY(1px);
466 }
467 100% {
468 transform: none;
469 }
470}
471/* Every hand tile sits in a draggable cell. */
472.hand-cell {
473 position: relative;
474 display: flex;
475 align-items: flex-end;
476 touch-action: none;
477 cursor: grab;
478}
479.hand-cell.dragging {
480 z-index: 5;
481 cursor: grabbing;
482}
483.hand-cell.dragging .tile {
484 transform: translateY(calc(var(--tile-w) * -0.2)) scale(1.06);
485 filter: drop-shadow(0 14px 18px rgba(0, 0, 0, 0.65));
486}
487/* Lifted out of the row and being carried: the canvas is drawing it now, so the
488 element steps aside. Its slot stays, so the row does not shuffle around under
489 the tile you are holding and you can still drop it back where it came from. */
490.hand-cell.carrying .tile {
491 visibility: hidden;
492}
493.drawn-slot {
494 margin-left: var(--drawn-gap);
495}
496.drawn-slot .tile {
497 outline: 2px solid var(--gold);
498 outline-offset: 1px;
499 border-radius: calc(var(--tw) * 0.1);
500}
501.drawn-tag {
502 position: absolute;
503 top: calc(var(--tile-w) * -0.3);
504 left: 0;
505 right: 0;
506 text-align: center;
507 font-size: calc(var(--tile-w) * 0.24);
508 font-weight: 600;
509 letter-spacing: 0.1em;
510 color: var(--gold);
511 white-space: nowrap;
512 pointer-events: none;
513}
514
515/* No reservation. The strip is packed towards its outer edge and this is the
516 innermost thing in it, so a meld appearing grows inwards and moves nothing —
517 the height was only ever holding open a band of empty green between the seat
518 and the middle, which table/geometry.ts then had to hand back to the felt.
519 Room for one is still budgeted in `--tile-fit`, so a claim cannot overflow
520 the strip. */
521.melds {
522 display: flex;
523 gap: 10px;
524 flex-wrap: wrap;
525 justify-content: center;
526 flex: none;
527}
528.meld {
529 display: flex;
530 gap: 1px;
531}
532.meld.flowers {
533 margin-left: 12px;
534 padding-left: 12px;
535 border-left: 1px dashed rgba(246, 240, 226, 0.3);
536}
537/* Only the phone layout has these now — at full size a discard is out in the
538 middle with the others. Kept as the shared base the `.compact` rules below
539 build on, not as something the four strips render. */
540.discards {
541 display: flex;
542 flex-wrap: wrap;
543 gap: 2px;
544 justify-content: center;
545 align-content: flex-start;
546 max-width: 94%;
547 min-height: 0;
548 margin-bottom: 2px;
549 flex: 1 1 0;
550 overflow-y: auto;
551 scrollbar-width: none;
552}
553
554.nameplate {
555 display: flex;
556 align-items: center;
557 gap: 8px;
558 font-size: 13px;
559 letter-spacing: 0.08em;
560 opacity: 0.9;
561 padding-top: 2px;
562 flex: none;
563}
564.nameplate .wind {
565 display: flex;
566 flex-direction: column;
567 align-items: center;
568 line-height: 1;
569 font-size: 18px;
570 font-weight: 900;
571 color: var(--gold);
572}
573.nameplate .wind em {
574 font-style: normal;
575 font-size: 8px;
576 font-weight: 600;
577 letter-spacing: 0.1em;
578 text-transform: uppercase;
579 opacity: 0.6;
580}
581.nameplate .wind.dealer {
582 color: #ff7a68;
583}
584.nameplate .tag {
585 font-size: 11px;
586 padding: 1px 6px;
587 border-radius: 999px;
588 background: rgba(217, 180, 92, 0.2);
589 border: 1px solid rgba(217, 180, 92, 0.5);
590}
591/* Why a tile lobs over the square instead of sliding into it. */
592.nameplate .tag.walled {
593 background: rgba(246, 240, 226, 0.08);
594 border-color: rgba(246, 240, 226, 0.28);
595 opacity: 0.75;
596}
597.nameplate .tag.ting {
598 background: rgba(46, 170, 120, 0.2);
599 border-color: rgba(46, 170, 120, 0.6);
600}
601.nameplate .tag.sacred {
602 background: rgba(179, 38, 30, 0.22);
603 border-color: rgba(224, 108, 96, 0.6);
604 color: #ffc9c2;
605}
606.nameplate .chips {
607 margin-left: 4px;
608 font-weight: 900;
609 font-size: 16px;
610 color: var(--gold);
611}
612
613/* The tiles and the buttons that act on them, on one row. The hand stays in the
614 middle of the strip where it has always been, and the bar is taken out of the
615 flow and hung on the right-hand end — so a button arriving moves nothing at
616 all, neither the tiles under it nor the sets and flowers above. */
617/* Three columns: the room to the left of the hand, the hand, and the room to
618 the right of it. The two outside columns are the same `1fr`, so the hand is
619 centred in the strip and stays centred whether or not there is a button — and
620 the bar starts at the *start* of the right-hand column, which is exactly
621 where the tiles stop. A button is one more tile at the end of the row, one
622 tile-gap along, rather than something parked at the far edge of the strip.
623
624 `1fr` rather than `minmax(0, 1fr)`: a bar crowded with claim buttons is
625 allowed to push the hand along instead of being cut off by the strip. */
626.hand-row {
627 position: relative;
628 display: grid;
629 grid-template-columns: 1fr auto 1fr;
630 column-gap: var(--drawn-gap);
631 align-items: end;
632 align-self: stretch;
633 flex: none;
634}
635:root:not(.compact) .hand-row > .hand {
636 grid-column: 2;
637}
638/* Out of the flow, in the column the grid gave it: the grid decides *where* the
639 bar starts (the start of the right-hand column, which is where the tiles
640 stop) and the bar then costs the row nothing. So the hand keeps the middle of
641 the strip whatever is on the bar, and a crowded bar — 碰 槓 胡 過 all at once,
642 or a hand with two kongs to declare — stacks up the strip in tile-wide lines
643 instead of running off the end of it. The room for the first line is in
644 `--tile-w` itself; the lines above it are over the felt, which on the seat
645 being waited on is the emptiest part of the strip. */
646:root:not(.compact) .hand-row > .actionbar {
647 grid-column: 3;
648 position: absolute;
649 left: 0;
650 bottom: 0;
651 width: 100%;
652 /* Bottom line first, later ones above it: the buttons grow away from the
653 hand rather than down through it. */
654 flex-wrap: wrap-reverse;
655 align-content: flex-start;
656}
657.actionbar {
658 display: flex;
659 /* The gap the tiles keep between themselves, so a run of buttons reads as a
660 run of tiles rather than as a toolbar. */
661 gap: var(--hand-gap);
662 /* On the same line the tiles stand on, since they are tiles too. */
663 align-items: flex-end;
664 justify-content: flex-start;
665 flex-wrap: wrap;
666 flex: none;
667}
668/* A phone has no room beside a hand. There the wrapper steps out of the way
669 entirely and the bar is a row of its own again, where the grid and the
670 `order`s further down put it. */
671.compact .hand-row {
672 display: contents;
673}
674.compact .actionbar {
675 position: static;
676 height: auto;
677 min-height: var(--bar-h);
678 align-self: stretch;
679}
680/* Every button on the bar is the same height, so the row is the same height
681 whichever ones are up: without this a 打出 五萬 stands taller than a bare 過
682 and the strip shuffles itself every time you lift a tile. */
683.actionbar .btn {
684 height: var(--bar-h);
685 min-height: 0;
686 padding-top: 4px;
687 padding-bottom: 4px;
688}
689.waiting {
690 font-size: 12px;
691 opacity: 0.5;
692}
693.delta {
694 font-size: 20px;
695 font-weight: 900;
696}
697.delta.up {
698 color: #5fd39a;
699}
700.delta.down {
701 color: #ff8a80;
702}
703
704/* ---------- buttons ---------- */
705.btn {
706 font-family: inherit;
707 font-size: 17px;
708 font-weight: 400;
709 color: #1a1a1a;
710 background: linear-gradient(180deg, #f3e6c4, #d9b45c);
711 border: none;
712 border-radius: 8px;
713 padding: 6px 15px;
714 min-height: 46px;
715 box-shadow: 0 2px 0 #9a7d38;
716 cursor: pointer;
717 display: inline-flex;
718 align-items: center;
719 gap: 6px;
720}
721.btn:active {
722 transform: translateY(2px);
723 box-shadow: none;
724}
725.btn-sub {
726 font-size: 12px;
727 opacity: 0.7;
728}
729
730/* Bilingual label: the called term above, English cue below. */
731.bi {
732 display: flex;
733 flex-direction: column;
734 align-items: center;
735 justify-content: center;
736 line-height: 1.05;
737 gap: 1px;
738}
739.bi .zh {
740 font-size: 19px;
741 font-weight: 400;
742 letter-spacing: 0.06em;
743}
744.bi .en {
745 font-size: 9.5px;
746 font-weight: 600;
747 letter-spacing: 0.11em;
748 text-transform: uppercase;
749 opacity: 0.68;
750 white-space: nowrap;
751}
752.bi .bi-sub {
753 font-size: 11px;
754 opacity: 0.72;
755 white-space: nowrap;
756}
757.big-btn .bi .zh {
758 font-size: 24px;
759}
760.big-btn .bi .en {
761 font-size: 11px;
762}
763.btn.claim.hu {
764 background: linear-gradient(180deg, #ff8a7a, #c0392b);
765 color: #fff;
766 box-shadow: 0 2px 0 #7c231a;
767}
768.btn.claim.kong,
769.btn.claim.pung {
770 background: linear-gradient(180deg, #8fd3ff, #2b7ec0);
771 color: #fff;
772 box-shadow: 0 2px 0 #1c527d;
773}
774.btn.claim.chow {
775 background: linear-gradient(180deg, #9be3b7, #2e9e63);
776 color: #fff;
777 box-shadow: 0 2px 0 #1d6640;
778}
779.btn.sort {
780 min-height: 20px;
781 padding: 1px 8px;
782 font-size: 11px;
783 letter-spacing: 0.06em;
784 border-radius: 999px;
785}
786.btn.proceed {
787 background: linear-gradient(180deg, #f3e6c4, #d9b45c);
788}
789.btn.pass,
790.btn.ghost {
791 background: transparent;
792 color: rgba(246, 240, 226, 0.75);
793 border: 1px solid rgba(246, 240, 226, 0.35);
794 box-shadow: none;
795}
796.btn.discard {
797 background: linear-gradient(180deg, #fff2cf, #e0c070);
798}
799/* Somebody pulling up a chair at a hand the computer has been playing. It is
800 the only button on that strip, so it may as well be the warm one — and it is
801 the same gold as 摸牌, because both are the table's way of saying this edge
802 is yours now. */
803.btn.sit {
804 background: rgba(217, 180, 92, 0.18);
805 border-color: rgba(217, 180, 92, 0.55);
806 color: #f6f0e2;
807}
808
809/* ---------- the action buttons are tiles ---------- */
810/* 打出, 碰, 吃, 槓, 胡, 過 are all things you do *with* a tile, and they sit in
811 the row your tiles are in — so they are drawn as tiles. Exactly the size of
812 the ones under them, `--tile-w` by 1.375 of it, ivory face and all, in the
813 room the hand row already keeps above its tiles for the 摸 tag. The room is
814 kept whether or not anything is on the bar, which is why the strip does not
815 move when one arrives.
816
817 Ivory, not coloured: a mahjong tile is ivory and it is the *character* that
818 is red or green. So the call takes the colour and the face never does.
819
820 Full size only. A phone's bar is a row of its own with room to write on, and
821 a thumb wants a button it can hit. */
822/* Not a white rectangle standing in for a tile: the tile itself. The sheet the
823 hand is drawn from has a forty-third cell with no character on it — a blank
824 tile — and that is what a button is drawn with, at the same size, off the
825 same file, through the same `background-size` arithmetic as `.tile.face`. So
826 a button has the bevel the tiles have, the two grey sides they stand on
827 (`sideLight` down the left, `sideDark` along the bottom), the black hairline
828 round the outside, and the shadow the drop-shadow filter takes from the art's
829 own alpha rather than from a rectangle. It cannot drift from the tiles beside
830 it, because it is one of them.
831
832 Pressed, it turns over to the sheet's second row — the highlighted variant,
833 which is what a tile lifted out of the hand switches to. The same gesture
834 lights the same way whether it is a tile or a call. */
835:root:not(.compact) .actionbar .btn {
836 position: relative;
837 flex-direction: column;
838 width: var(--tile-w);
839 height: var(--bar-h);
840 min-height: 0;
841 padding: 0;
842 gap: 0;
843 border: none;
844 border-radius: calc(var(--tile-w) * 0.1);
845 background: url('./assets/tiles.svg') no-repeat;
846 background-size: calc(var(--tile-w) * 43) calc(var(--tile-w) * 2.75);
847 background-position: calc(var(--tile-w) * -42) 0;
848 box-shadow: none;
849 filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45));
850 color: var(--ink);
851 transition: transform 0.12s ease, filter 0.12s ease;
852}
853/* Under the finger it lifts, the way a tile does — a good deal less far, since
854 a button that jumped a quarter of its own height would be a button that moved
855 out from under the click. */
856:root:not(.compact) .actionbar .btn:hover {
857 transform: translateY(calc(var(--tile-w) * -0.07));
858 filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.5));
859}
860:root:not(.compact) .actionbar .btn:active {
861 transform: translateY(1px);
862 background-position: calc(var(--tile-w) * -42) calc(var(--tile-w) * -1.375);
863 filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.45));
864}
865/* The call is written down the tile, the way the character on a tile's face is
866 written: one on top of the next, in the middle of the face, with the English
867 along the bottom edge under it.
868
869 A row of these is a row of tiles, and a tile is taller than it is wide — so
870 two characters side by side had to be drawn small enough to sit across a
871 38px face, while the same two stacked are the size the tile's own character
872 is. Four (過.摸牌) run out of height and take a second column to the left of
873 the first, which is where the next column of vertical Chinese goes.
874
875 The grid is what puts them there: one row that takes all the height left over
876 and centres what is in it, one row that is exactly the English. The reading
877 (打出) is the right-hand column, and the tile it is about (五萬) the column to
878 its left, because vertical Chinese reads right to left. */
879/* Written on the face, which is not the middle of the tile: the art stands the
880 tile up on `LOOK.depth` of thickness towards the bottom left, so the face is
881 inset by the left side's width and the bottom side's height and hardly at all
882 on the other two. Measured off the sheet rather than guessed — the blank cell
883 at column 42 is white from 14% of its width to 98%, and from 1% of its height
884 to 91%. As padding rather than a nudge, so the grid inside centres on the
885 face and the English sits along the face's own bottom edge. */
886:root:not(.compact) .actionbar .btn .bi {
887 display: grid;
888 grid-template-columns: auto auto;
889 grid-template-rows: 1fr auto;
890 justify-content: center;
891 align-items: center;
892 gap: 0;
893 line-height: 1;
894 height: 100%;
895 width: 100%;
896 padding: calc(var(--tile-w) * 0.015) calc(var(--tile-w) * 0.02)
897 calc(var(--tile-w) * 0.125) calc(var(--tile-w) * 0.14);
898}
899:root:not(.compact) .actionbar .btn .bi .zh {
900 grid-row: 1;
901 grid-column: 2;
902 /* Its own width, centred in the column, rather than stretched to fill it —
903 a stretched box in `vertical-rl` puts its column of characters against the
904 right-hand edge, which is a second way of ending up off centre. */
905 justify-self: center;
906 writing-mode: vertical-rl;
907 /* The room above the English, so a long call wraps into a second column
908 rather than growing out through the top of the tile. */
909 max-height: calc(var(--bar-h) - var(--tile-w) * 0.36);
910 font-size: calc(var(--tile-w) * 0.44);
911 letter-spacing: 0;
912}
913/* Under the call, in the call's own column — not spanning both.
914 Spanning was what pushed 打出 off the middle of the face: the English is
915 wider than one column of characters, and a spanning item shares the width it
916 needs out across every track it covers, so the empty 五萬 column took half of
917 the difference and carried the reading right along with it. In one column it
918 sizes that column and nothing else, and the pair of them centre together. */
919:root:not(.compact) .actionbar .btn .bi .en {
920 grid-row: 2;
921 grid-column: 2;
922 justify-self: center;
923 text-align: center;
924 font-size: calc(var(--tile-w) * 0.185);
925 letter-spacing: 0.03em;
926 white-space: normal;
927 opacity: 0.6;
928}
929/* An English-only table has nothing standing above it, so the name is the face
930 rather than the caption under one. */
931:root:not(.compact) .actionbar .btn .bi .en:first-child {
932 grid-row: 1 / -1;
933 font-size: calc(var(--tile-w) * 0.22);
934 opacity: 0.85;
935}
936/* Which tile it is about — 打出 五萬, 吃 三萬 四萬. Small, because the tile it
937 names is lifted out of the row right beside it, and written the same way
938 down the face as the call it belongs to. */
939:root:not(.compact) .actionbar .btn .bi .bi-sub {
940 grid-row: 1;
941 grid-column: 1;
942 justify-self: center;
943 writing-mode: vertical-rl;
944 max-height: calc(var(--bar-h) - var(--tile-w) * 0.36);
945 font-size: calc(var(--tile-w) * 0.22);
946 white-space: normal;
947 opacity: 0.75;
948}
949/* The character carries the colour, the way the dragons do. */
950:root:not(.compact) .actionbar .btn.claim.hu {
951 color: #b3261e;
952}
953:root:not(.compact) .actionbar .btn.claim.pung,
954:root:not(.compact) .actionbar .btn.claim.kong {
955 color: #1c527d;
956}
957:root:not(.compact) .actionbar .btn.claim.chow {
958 color: #1d6640;
959}
960/* 看牌 is the only button a covered hand has, and it is the one button here
961 that is not done *with* a tile — it is done to the card lying over them. So
962 it wears the back rather than the face, and reads as the thing beside it. */
963:root:not(.compact) .actionbar .btn.reveal {
964 background: url('./assets/back.png') center / 100% 100%;
965 color: var(--ivory);
966 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
967}
968/* And the table is waiting on this one. Whose go it is was never a secret, so
969 the ring says so — which is what stops four face-down hands and no buttons
970 reading as a table that has stopped rather than one waiting for somebody to
971 look at their tiles. */
972:root:not(.compact) .actionbar .btn.reveal.hot {
973 outline: 2px solid var(--gold);
974 outline-offset: -2px;
975 /* Gold off the tile's own outline rather than off a rectangle behind it: the
976 shadow already comes from the art's alpha, and a second glow drawn as a box
977 would put a corner where the tile has none. */
978 filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45)) drop-shadow(0 0 6px rgba(217, 180, 92, 0.75));
979}
980/* 過 and 摸牌 are not calls, so they are the plain face — and a pass reads as
981 the quiet one it is. */
982:root:not(.compact) .actionbar .btn.pass,
983:root:not(.compact) .actionbar .btn.ghost {
984 color: rgba(33, 38, 43, 0.65);
985}
986/* The one that plays a tile wore a gold rim drawn round the face. The face is
987 also where the English is written, along its bottom edge, and a line ruled
988 there is a line ruled through the reading — so the rim is gone and the call
989 itself carries the weight, the way the coloured ones do. */
990.big-btn {
991 font-size: 22px;
992 padding: 12px 34px;
993 min-height: 56px;
994}
995
996/* ---------- the tiles in the middle ---------- */
997/* One canvas over the whole table, drawn by ui/Pool.tsx. It has to span the
998 table and not just the centre: the strips clip their own contents, so a tile
999 thrown out of a hand would be cut off at the edge of its seat if it were an
1000 element. On the canvas there is nothing to clip it.
1001
1002 Layering: above .wall-ring (0), since the tiles land inside the square and in
1003 front of it; level with .facing, which sits later in the DOM and so keeps the
1004 centre readout on top; below the panels, which raise themselves. */
1005.tile-pool {
1006 position: absolute;
1007 inset: 0;
1008 width: 100%;
1009 height: 100%;
1010 pointer-events: none;
1011 z-index: 1;
1012}
1013/* The pile is handled directly: a discard is picked up, carried over the top of
1014 the others and set down or flicked away. The tiles are on a canvas with no
1015 elements to hit, so the hit test is against the physics and the listener is a
1016 capturing one on the table itself — see `dragging` in ui/Pool.tsx. Nothing
1017 here needs a box; `touch-action` is what stops a drag scrolling the page. */
1018.table {
1019 touch-action: none;
1020}
1021
1022/* ---------- center ---------- */
1023.center {
1024 position: absolute;
1025 inset: var(--depth);
1026 display: flex;
1027 flex-direction: column;
1028 align-items: center;
1029 justify-content: center;
1030 gap: 10px;
1031 pointer-events: none;
1032}
1033.center > * {
1034 pointer-events: auto;
1035}
1036/* Everything in the centre reads on top of the wall square. */
1037.info,
1038.last-discard,
1039.prompt,
1040.result,
1041.info {
1042 display: flex;
1043 flex-direction: column;
1044 align-items: center;
1045 gap: 2px;
1046 opacity: 0.9;
1047 text-align: center;
1048}
1049.info .round,
1050.info .wall {
1051 display: flex;
1052 flex-direction: column;
1053 align-items: center;
1054 line-height: 1.05;
1055}
1056.info .wall {
1057 margin-top: 10px;
1058 padding-top: 10px;
1059 border-top: 1px solid rgba(246, 240, 226, 0.14);
1060}
1061.info .big {
1062 font-size: 40px;
1063 font-weight: 400;
1064 color: var(--gold);
1065}
1066.info .lbl {
1067 font-size: 11px;
1068 letter-spacing: 0.06em;
1069 opacity: 0.6;
1070}
1071.info .hand-no {
1072 margin-top: 8px;
1073 font-size: 11px;
1074 opacity: 0.5;
1075 letter-spacing: 0.1em;
1076}
1077/* The wall, built the way it is built on a table: four walls laid out like a #
1078 rather than mitred into a closed square, each one carrying on past the corner
1079 of the next so no corner is a joint anybody had to cut.
1080
1081 Nothing here is turned. A square wants no rotation to describe it: each wall
1082 is pinned to its own edge of the opening, the two flat ones run their stacks
1083 across and the two upright ones run them down, and that is the whole
1084 difference between them. How big the square comes out is `wallSquare` in
1085 `game/wall.ts`, measured and applied by `ui/WallRing`; the size below is only
1086 of one stack.
1087
1088 `.wall-ring` is the opening, not the outside: the walls hang off its edges,
1089 so the square encloses the pool the way it does on a table. */
1090.wall-ring {
1091 /* A stack's short side: the table's tile, and nothing else. Every tile on the
1092 table is one size — in a hand, in the wall, and lying in the middle — so
1093 this is not a size the wall gets to have an opinion about. ui/WallRing
1094 measures what it came out as and says how long a wall of eighteen of them
1095 is (`--wall-len`); it does not decide either of them. */
1096 --ws: var(--tile-w);
1097 --wd: calc(var(--ws) * 1.375);
1098 /* A wall, end to end: eighteen stacks, set from ui/WallRing. It is longer
1099 than the edge of the opening by one wall-depth, and that overhang is what
1100 covers the corner. */
1101 --wall-len: 0px;
1102 position: absolute;
1103 top: 50%;
1104 left: 50%;
1105 width: 0;
1106 height: 0;
1107 transform: translate(-50%, -50%);
1108 pointer-events: none;
1109 z-index: 0;
1110}
1111/* Never drawn. It is here to be measured: how big a stack came out is CSS's
1112 answer, and the layout needs it in stacks before it has placed any. */
1113.wall-probe {
1114 position: absolute;
1115 visibility: hidden;
1116 width: var(--ws);
1117 height: var(--wd);
1118}
1119/* Each wall sits outside its own edge of the opening, flush with the corner it
1120 is built from and running one wall-depth past the far one — the same way
1121 round each time, which is what makes four straight walls a pinwheel. Every
1122 corner of the square is covered by exactly one of them, and no two overlap.
1123
1124 Every place in the wall is here whether or not there is still a stack on it —
1125 see `ringLayout` — so the row never closes up and the gap stays where the
1126 drawing actually got to. */
1127.wall-side {
1128 position: absolute;
1129 display: flex;
1130 /* Where it has been pushed to, in the table's own axes — a square wall goes
1131 where the finger goes. */
1132 transform: translate(var(--dx, 0px), var(--dy, 0px));
1133 /* The ring lets everything through; a wall is the one thing you can take
1134 hold of. */
1135 pointer-events: auto;
1136 cursor: grab;
1137}
1138.wall-side.top,
1139.wall-side.bottom {
1140 width: var(--wall-len);
1141 height: var(--wd);
1142}
1143.wall-side.left,
1144.wall-side.right {
1145 width: var(--wd);
1146 height: var(--wall-len);
1147}
1148/* Built from the top left corner, running right over the head of the right. */
1149.wall-side.top {
1150 left: 0;
1151 bottom: 100%;
1152}
1153/* From the top right, running down. */
1154.wall-side.right {
1155 left: 100%;
1156 top: 0;
1157 flex-direction: column;
1158}
1159/* From the bottom right, running left. */
1160.wall-side.bottom {
1161 right: 0;
1162 top: 100%;
1163 flex-direction: row-reverse;
1164}
1165/* From the bottom left, running up. */
1166.wall-side.left {
1167 right: 100%;
1168 bottom: 0;
1169 flex-direction: column-reverse;
1170}
1171.wall-stack {
1172 position: relative;
1173 flex: none;
1174 width: var(--ws);
1175 height: var(--wd);
1176}
1177/* Lying the other way about, which is how the two upright walls are built. */
1178.wall-side.left > .wall-stack,
1179.wall-side.right > .wall-stack {
1180 width: var(--wd);
1181 height: var(--ws);
1182}
1183/* The tile itself fills its stack, whichever way round the stack is lying — so
1184 the two upright walls need nothing said about them here. */
1185.wall-stack i {
1186 position: absolute;
1187 inset: 0;
1188 background-image: url('./assets/back.png');
1189 background-size: 100% 100%;
1190 /* A box-shadow, not a drop-shadow. The square is seventy-two stacks now
1191 rather than the twenty-odd that were left after a deal: `filter` gives each
1192 its own layer to rasterise, and seventy-two of those being redrawn while a
1193 wall is pushed about is enough to make the page stop answering. The tile is
1194 a rectangle, so the cheap shadow is the same picture. */
1195 box-shadow: 0 1px 1px rgba(0, 0, 0, 0.45);
1196}
1197/* One tile left in the stack. */
1198.wall-stack.s1 i {
1199 opacity: 0.8;
1200 transform: scale(0.86);
1201}
1202.wall-stack.next::after {
1203 content: '';
1204 position: absolute;
1205 inset: -2px;
1206 border: 2px solid var(--gold);
1207 border-radius: 3px;
1208 box-shadow: 0 0 12px rgba(217, 180, 92, 0.7);
1209}
1210
1211/* Text only, and it sits over the pool — so it must not swallow a finger that
1212 is reaching in to wash the tiles about. */
1213.facing {
1214 pointer-events: none;
1215 position: relative;
1216 z-index: 1;
1217 display: flex;
1218 flex-direction: column;
1219 align-items: center;
1220 gap: 8px;
1221 transition: transform 0.35s ease;
1222}
1223
1224.last-discard {
1225 display: flex;
1226 flex-direction: column;
1227 align-items: center;
1228 gap: 6px;
1229 --tile-w: 64px;
1230}
1231.ld-label {
1232 font-size: 12px;
1233 opacity: 0.6;
1234}
1235.prompt {
1236 font-size: 15px;
1237 letter-spacing: 0.1em;
1238 opacity: 0.8;
1239}
1240.prompt.warn {
1241 color: #ffb4a2;
1242 font-weight: 600;
1243}
1244.result {
1245 /* Over the pile in the middle, not under it. */
1246 z-index: 2;
1247 background: rgba(10, 30, 24, 0.94);
1248 border: 1px solid rgba(217, 180, 92, 0.45);
1249 border-radius: 14px;
1250 padding: 18px 26px;
1251 display: flex;
1252 flex-direction: column;
1253 align-items: center;
1254 gap: 10px;
1255 max-height: 90%;
1256 overflow: auto;
1257}
1258.result h2 {
1259 margin: 0;
1260 font-size: 24px;
1261 color: var(--gold);
1262}
1263.result .wt {
1264 color: var(--ivory);
1265 opacity: 0.8;
1266}
1267.tai-list {
1268 display: grid;
1269 grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
1270 gap: 2px 18px;
1271 font-size: 14px;
1272 width: 100%;
1273}
1274.tai-item {
1275 display: flex;
1276 align-items: baseline;
1277 justify-content: space-between;
1278 gap: 12px;
1279 padding: 2px 0;
1280 border-bottom: 1px dotted rgba(246, 240, 226, 0.2);
1281}
1282.tai-name {
1283 display: flex;
1284 flex-direction: column;
1285 line-height: 1.15;
1286}
1287.tai-name em {
1288 font-style: normal;
1289 font-size: 10px;
1290 letter-spacing: 0.08em;
1291 text-transform: uppercase;
1292 opacity: 0.55;
1293}
1294.tai-val {
1295 font-weight: 700;
1296 color: var(--gold);
1297}
1298/* 胡牌 — the arrow that lands on the winner's edge of the table. Sits over the
1299 strip rather than in it, so the hand underneath keeps the height it had and
1300 nothing shifts when it appears. */
1301.win-arrow {
1302 position: absolute;
1303 top: 0;
1304 left: 50%;
1305 font-size: 52px;
1306 line-height: 0.9;
1307 color: var(--gold);
1308 text-shadow: 0 0 6px #0a3126, 0 0 20px rgba(217, 180, 92, 0.9);
1309 pointer-events: none;
1310 z-index: 4;
1311 animation: arrow-nudge 0.9s ease-in-out infinite;
1312}
1313@keyframes arrow-nudge {
1314 0%,
1315 100% {
1316 transform: translate(-50%, 0);
1317 }
1318 50% {
1319 transform: translate(-50%, 9px);
1320 }
1321}
1322
1323/* 流局 — who was there and what they were sitting on. */
1324.ready-list {
1325 display: flex;
1326 flex-direction: column;
1327 gap: 6px;
1328 margin-top: 2px;
1329}
1330.ready-row {
1331 display: flex;
1332 align-items: center;
1333 gap: 8px;
1334 font-size: 14px;
1335}
1336.ready-row.none {
1337 opacity: 0.45;
1338}
1339.ready-name {
1340 min-width: 6.5em;
1341 text-align: right;
1342}
1343.ready-row .tag.ting {
1344 font-size: 11px;
1345 padding: 1px 6px;
1346 border-radius: 999px;
1347 background: rgba(46, 170, 120, 0.2);
1348 border: 1px solid rgba(46, 170, 120, 0.6);
1349 white-space: nowrap;
1350}
1351.ready-tiles {
1352 display: flex;
1353 flex-wrap: wrap;
1354 gap: 2px;
1355 /* Tiles size themselves off their strip's --tile-w, and the centre is not in
1356 one — without a size of its own here they come out 0 x 0 and vanish. */
1357 --tile-w: 46px;
1358}
1359.ready-no {
1360 font-size: 12px;
1361 letter-spacing: 0.04em;
1362}
1363
1364.deltas {
1365 display: flex;
1366 gap: 16px;
1367 font-size: 14px;
1368 opacity: 0.9;
1369}
1370.deltas .up {
1371 color: #5fd39a;
1372}
1373.deltas .down {
1374 color: #ff8a80;
1375}
1376
1377/* ---------- help panel ---------- */
1378.helpbtn.gear {
1379 font-size: 13px;
1380}
1381.helpbtn {
1382 margin-left: 2px;
1383 width: 22px;
1384 height: 22px;
1385 border-radius: 50%;
1386 border: 1px solid rgba(246, 240, 226, 0.35);
1387 background: transparent;
1388 color: rgba(246, 240, 226, 0.75);
1389 font-family: inherit;
1390 font-size: 12px;
1391 line-height: 1;
1392 cursor: pointer;
1393}
1394/* The rules panel gets its own deeper slot so it is comfortable to read. */
1395.help-slot {
1396 position: absolute;
1397 inset: 0;
1398 z-index: 30;
1399 background: rgba(4, 18, 14, 0.55);
1400}
1401.help-slot.slot-bottom,
1402.help-slot.slot-top,
1403.help-slot.slot-left,
1404.help-slot.slot-right {
1405 inset: 0;
1406 width: auto;
1407 height: auto;
1408}
1409.help {
1410 position: absolute;
1411 top: 50%;
1412 left: 50%;
1413 height: var(--hd);
1414 z-index: 31;
1415 display: flex;
1416 flex-direction: column;
1417 background: rgba(8, 32, 25, 0.985);
1418 border: 1px solid rgba(217, 180, 92, 0.4);
1419 border-radius: 12px;
1420 padding: 10px 18px 14px;
1421 box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
1422}
1423/* Anchored to the asking player's own edge, and rotated to face them. The
1424 leading translate is applied last, so it shifts the box in screen space. */
1425.help-slot.slot-bottom .help,
1426.help-slot.slot-top .help {
1427 width: calc(100vw - 60px);
1428 --hd: min(52vh, 560px);
1429 --off: calc(50vh - var(--hd) / 2 - 14px);
1430}
1431.help-slot.slot-left .help,
1432.help-slot.slot-right .help {
1433 width: calc(100vh - 60px);
1434 --hd: min(52vw, 560px);
1435 --off: calc(50vw - var(--hd) / 2 - 14px);
1436}
1437.help-slot.slot-bottom .help {
1438 translate: 0 var(--off);
1439}
1440.help-slot.slot-top .help {
1441 translate: 0 calc(var(--off) * -1);
1442}
1443.help-slot.slot-left .help {
1444 translate: calc(var(--off) * -1) 0;
1445}
1446.help-slot.slot-right .help {
1447 translate: var(--off) 0;
1448}
1449.help-head {
1450 display: flex;
1451 align-items: center;
1452 justify-content: space-between;
1453 gap: 16px;
1454 flex: none;
1455}
1456.help-head h3 {
1457 margin: 0;
1458 font-size: 15px;
1459 font-weight: 400;
1460 letter-spacing: 0.08em;
1461 color: var(--gold);
1462}
1463.help-head .btn {
1464 min-height: 30px;
1465 padding: 2px 12px;
1466 font-size: 13px;
1467}
1468.help-body {
1469 flex: 1 1 0;
1470 min-height: 0;
1471 overflow-y: auto;
1472 columns: 4;
1473 column-gap: 26px;
1474 column-fill: auto;
1475 padding-top: 8px;
1476 overflow-x: auto;
1477 scrollbar-width: thin;
1478}
1479.help-body section {
1480 break-inside: avoid;
1481 margin-bottom: 10px;
1482}
1483.help-body h4 {
1484 margin: 0 0 2px;
1485 font-size: 13px;
1486 font-weight: 400;
1487 color: var(--gold);
1488 letter-spacing: 0.06em;
1489}
1490.help-body h4 em {
1491 font-style: normal;
1492 font-size: 9px;
1493 letter-spacing: 0.12em;
1494 text-transform: uppercase;
1495 opacity: 0.55;
1496 margin-left: 4px;
1497}
1498.help-body p {
1499 margin: 0 0 4px;
1500 display: flex;
1501 flex-direction: column;
1502 line-height: 1.35;
1503 font-size: 11.5px;
1504}
1505.help-body p em {
1506 font-style: normal;
1507 font-size: 10.5px;
1508 opacity: 0.6;
1509}
1510.help-body .credit {
1511 opacity: 0.6;
1512}
1513
1514/* ---------- lobby resume ---------- */
1515.lobby .sub {
1516 font-size: 0.95rem;
1517}
1518.resume {
1519 margin-top: 1.4rem;
1520 padding: 12px 26px;
1521 border: 1px solid rgba(217, 180, 92, 0.35);
1522 border-radius: 12px;
1523 background: rgba(0, 0, 0, 0.2);
1524 text-align: center;
1525 display: flex;
1526 flex-direction: column;
1527 gap: 6px;
1528}
1529.resume-line {
1530 font-size: 14px;
1531 color: var(--gold);
1532 letter-spacing: 0.06em;
1533}
1534.resume-scores {
1535 display: flex;
1536 gap: 18px;
1537 justify-content: center;
1538 font-size: 13px;
1539 opacity: 0.8;
1540}
1541.resume-scores b {
1542 color: var(--gold);
1543 font-weight: 600;
1544}
1545.resume-when {
1546 font-size: 11px;
1547 opacity: 0.45;
1548 letter-spacing: 0.08em;
1549}
1550.lobby-buttons {
1551 display: flex;
1552 flex-direction: column;
1553 gap: 12px;
1554 margin-top: 1.6rem;
1555 align-items: stretch;
1556}
1557/* The two ways to play carry a line of explanation under the name. */
1558.lobby-buttons .big-btn {
1559 display: flex;
1560 flex-direction: column;
1561 gap: 3px;
1562 min-width: 360px;
1563 padding: 12px 28px;
1564}
1565.lobby-buttons .big-btn em {
1566 font-style: normal;
1567 font-size: 12px;
1568 font-weight: 400;
1569 letter-spacing: 0.03em;
1570 opacity: 0.72;
1571}
1572
1573/* A window with the width for the table but not the height for the lobby. The
1574 compact layout does not start until 620px tall, so a 700px laptop window got
1575 the full-size lobby: a 112px title, a saved game, four ways to start and the
1576 settings row, which comes to more than the window has. Everything gives a
1577 little, the same way the compact rules give, so the last of it lands above
1578 the fold rather than behind a scroll nobody goes looking for.
1579
1580 The title is the first to go, because it is the one thing sized off width
1581 alone: 9vw of a wide short window is a title taller than a button. */
1582@media (max-height: 860px) {
1583 .lobby {
1584 gap: 0.25rem;
1585 }
1586 .lobby h1 {
1587 font-size: clamp(2.4rem, min(9vw, 10vh), 7rem);
1588 margin-bottom: 0.2rem;
1589 }
1590 .resume {
1591 margin-top: 0.8rem;
1592 padding: 8px 20px;
1593 }
1594 .lobby .hint {
1595 margin-bottom: 0.6rem;
1596 }
1597 .lobby-buttons {
1598 margin-top: 0.9rem;
1599 gap: 8px;
1600 }
1601 .lobby-buttons .big-btn {
1602 padding: 7px 28px;
1603 }
1604 .lobby-extras {
1605 margin-top: 0.8rem;
1606 }
1607}
1608
1609/* ---------- sound ---------- */
1610.lobby-extras {
1611 margin-top: 1.4rem;
1612 display: flex;
1613 gap: 12px;
1614 align-items: center;
1615}
1616/* Small rounded button for the things that sit under the main choices. */
1617.btn.pill {
1618 min-height: 34px;
1619 padding: 2px 14px;
1620 font-size: 13px;
1621 letter-spacing: 0.06em;
1622 border-radius: 999px;
1623 gap: 8px;
1624}
1625.sound-toggle.on {
1626 border-color: rgba(217, 180, 92, 0.7);
1627 color: var(--gold);
1628}
1629.sound-toggle.compact {
1630 min-height: 28px;
1631 padding: 2px 9px;
1632 font-size: 15px;
1633}
1634.sound-toggle .spk {
1635 font-size: 15px;
1636 line-height: 1;
1637}
1638
1639/* ---------- language ---------- */
1640/* Sized to stand beside the sound and fullscreen pills in both their forms. */
1641.lang-toggle {
1642 min-height: 34px;
1643 padding: 2px 14px;
1644}
1645.lang-toggle.compact {
1646 min-height: 28px;
1647 padding: 2px 9px;
1648}
1649/* A boxed glyph, so 雙 / 中 / A all take the same room and the row does not
1650 shuffle sideways as the mode steps round. */
1651.lang-toggle .lang-icon {
1652 display: inline-grid;
1653 place-items: center;
1654 width: 18px;
1655 height: 18px;
1656 flex: none;
1657 border: 1px solid rgba(230, 226, 214, 0.45);
1658 border-radius: 4px;
1659 font-size: 12px;
1660 line-height: 1;
1661}
1662
1663/* ---------- shared table controls ---------- */
1664/* Bottom-right of the centre square, opposite the log. Unrotated on purpose:
1665 it belongs to the table, not to a seat. */
1666/* Out of the way of a throw. The undo button is a wide target lying on the
1667 playing surface at the bottom-right of the middle, which is exactly where a
1668 tile leaves seat 0's hand — see the `carrying` class in ui/Pool.tsx. */
1669.table.carrying .table-controls {
1670 pointer-events: none;
1671}
1672.table-controls {
1673 position: absolute;
1674 /* Over the pile, same as .result. */
1675 z-index: 2;
1676 right: 10px;
1677 bottom: 8px;
1678 display: flex;
1679 align-items: center;
1680 gap: 8px;
1681}
1682.btn.undo {
1683 min-height: 34px;
1684 padding: 3px 14px;
1685 border-radius: 999px;
1686 gap: 9px;
1687 max-width: 40vh;
1688}
1689.undo-arrow {
1690 font-size: 17px;
1691 line-height: 1;
1692 color: var(--gold);
1693}
1694.undo-what {
1695 display: flex;
1696 flex-direction: column;
1697 align-items: flex-start;
1698 line-height: 1.15;
1699 min-width: 0;
1700}
1701.undo-what .zh {
1702 font-size: 13px;
1703 letter-spacing: 0.05em;
1704}
1705.undo-what .en {
1706 font-size: 9px;
1707 letter-spacing: 0.09em;
1708 text-transform: uppercase;
1709 opacity: 0.55;
1710 white-space: nowrap;
1711 overflow: hidden;
1712 text-overflow: ellipsis;
1713 max-width: 100%;
1714}
1715
1716/* ---------- result: one block per winner ---------- */
1717.result .win {
1718 display: flex;
1719 flex-direction: column;
1720 align-items: center;
1721 gap: 8px;
1722 width: 100%;
1723}
1724.result .win + .win {
1725 padding-top: 10px;
1726 border-top: 1px solid rgba(217, 180, 92, 0.3);
1727}
1728.result .liable {
1729 font-size: 12px;
1730 letter-spacing: 0.08em;
1731 color: #ffb4a2;
1732}
1733.result .multi {
1734 font-size: 12px;
1735 letter-spacing: 0.1em;
1736 color: var(--gold);
1737 opacity: 0.85;
1738}
1739
1740/* ---------- settings ---------- */
1741.over-buttons {
1742 display: flex;
1743 gap: 12px;
1744 align-items: center;
1745}
1746.settings-scrim {
1747 position: absolute;
1748 inset: 0;
1749 z-index: 40;
1750 background: rgba(4, 18, 14, 0.72);
1751 display: flex;
1752 align-items: center;
1753 justify-content: center;
1754 padding: 20px;
1755}
1756.settings {
1757 width: min(1180px, 100%);
1758 max-height: 100%;
1759 display: flex;
1760 flex-direction: column;
1761 background: rgba(8, 32, 25, 0.99);
1762 border: 1px solid rgba(217, 180, 92, 0.4);
1763 border-radius: 14px;
1764 box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
1765 padding: 16px 28px 22px;
1766}
1767.settings-head {
1768 display: flex;
1769 align-items: center;
1770 justify-content: space-between;
1771 gap: 16px;
1772 flex: none;
1773 padding-bottom: 12px;
1774 border-bottom: 1px solid rgba(246, 240, 226, 0.12);
1775}
1776.settings-head h3 {
1777 margin: 0;
1778 font-size: 22px;
1779 font-weight: 400;
1780 letter-spacing: 0.08em;
1781 color: var(--gold);
1782}
1783.settings-head-btns {
1784 display: flex;
1785 gap: 10px;
1786}
1787.settings-head .btn {
1788 min-height: 46px;
1789 padding: 2px 22px;
1790 font-size: 17px;
1791}
1792.settings-body {
1793 /* `1 1 auto`, not `1 1 0`: the panel's own height comes from this content,
1794 so a zero basis collapses the pair of them to nothing. `max-height` on the
1795 panel is what brings the scrollbar back at the other end. */
1796 flex: 1 1 auto;
1797 min-height: 0;
1798 overflow-y: auto;
1799 scrollbar-width: thin;
1800 padding-top: 14px;
1801 /* Columns rather than a grid: grid rows would size to the tallest section
1802 and leave the shorter one padded with air, which is what pushed the last
1803 house rule off the bottom. */
1804 columns: 2;
1805 column-gap: 44px;
1806}
1807.settings-body section {
1808 break-inside: avoid;
1809 margin-bottom: 22px;
1810}
1811.settings-body h4 {
1812 margin: 0 0 10px;
1813 font-size: 16px;
1814 font-weight: 400;
1815 color: var(--gold);
1816 letter-spacing: 0.06em;
1817}
1818.settings-body h4 em {
1819 font-style: normal;
1820 font-size: 11px;
1821 letter-spacing: 0.12em;
1822 text-transform: uppercase;
1823 opacity: 0.55;
1824 margin-left: 5px;
1825}
1826.settings .field {
1827 display: flex;
1828 align-items: center;
1829 gap: 14px;
1830 padding: 5px 0;
1831}
1832.settings .f-label {
1833 display: flex;
1834 align-items: baseline;
1835 gap: 6px;
1836 font-size: 16px;
1837 /* Wide enough for 起始籌碼 STARTING CHIPS, so every input in a section
1838 starts at the same place. */
1839 min-width: 196px;
1840 opacity: 0.85;
1841}
1842/* ...except the player rows, which are labelled 1–4 and need no such room. */
1843.settings .names .f-label {
1844 min-width: 26px;
1845}
1846.settings .f-label em {
1847 font-style: normal;
1848 font-size: 11px;
1849 letter-spacing: 0.1em;
1850 text-transform: uppercase;
1851 opacity: 0.55;
1852}
1853.settings input[type='text'],
1854.settings input:not([type]),
1855.settings input[type='number'] {
1856 flex: 1 1 0;
1857 min-width: 0;
1858 font-family: inherit;
1859 font-size: 18px;
1860 color: var(--ivory);
1861 background: rgba(0, 0, 0, 0.28);
1862 border: 1px solid rgba(246, 240, 226, 0.25);
1863 border-radius: 8px;
1864 padding: 11px 14px;
1865 min-height: 48px;
1866 user-select: text;
1867}
1868.settings input:focus {
1869 outline: none;
1870 border-color: rgba(217, 180, 92, 0.8);
1871}
1872.settings input[type='number'] {
1873 max-width: 150px;
1874}
1875.settings input[type='range'] {
1876 flex: 1 1 0;
1877 height: 34px;
1878 accent-color: var(--gold);
1879}
1880.settings input:disabled {
1881 opacity: 0.4;
1882}
1883.settings .switch {
1884 display: grid;
1885 grid-template-columns: auto 1fr;
1886 gap: 3px 14px;
1887 align-items: center;
1888 padding: 9px 0;
1889 cursor: pointer;
1890}
1891.settings .switch input {
1892 width: 26px;
1893 height: 26px;
1894 accent-color: var(--gold);
1895 cursor: pointer;
1896}
1897.settings .sw-name {
1898 display: flex;
1899 align-items: baseline;
1900 gap: 8px;
1901 font-size: 17px;
1902}
1903.settings .sw-name em {
1904 font-style: normal;
1905 font-size: 11px;
1906 letter-spacing: 0.1em;
1907 text-transform: uppercase;
1908 opacity: 0.55;
1909}
1910.settings .note {
1911 grid-column: 2;
1912 font-size: 13px;
1913 line-height: 1.4;
1914 opacity: 0.55;
1915}
1916.settings .switch.dead {
1917 opacity: 0.4;
1918 cursor: default;
1919}
1920.settings .note.lock {
1921 grid-column: auto;
1922 margin: 4px 0 0;
1923 color: #ffb4a2;
1924 opacity: 0.75;
1925}
1926
1927/* ============================================================================
1928 compact — a phone
1929
1930 The table layout is four strips of --depth arranged around a centre square,
1931 which wants about 700px each way; a phone has that in neither, and in
1932 landscape the top and bottom strips simply overlap. It also assumes four
1933 people sitting around a screen lying flat, which is not what a phone is.
1934
1935 Compact throws both assumptions out. Nothing is rotated — every label reads
1936 upright for the one person holding the phone — the table becomes a grid, the
1937 three other seats shrink to cards showing what is public about them, and the
1938 depth that frees up goes to the hand, which wraps when it has to.
1939
1940 The `compact` class is put on <html> by useCompact(); the breakpoint itself
1941 lives in ui/compact.ts, so it is written down once.
1942 ============================================================================ */
1943
1944.compact .table {
1945 display: grid;
1946 gap: 2px;
1947 /* Landscape is where a notch eats into the sides. */
1948 padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
1949 env(safe-area-inset-left);
1950 grid-template-columns: 1fr 1fr 1fr;
1951 grid-template-rows: minmax(0, 1fr) auto auto;
1952 grid-template-areas:
1953 'left across right'
1954 'bar bar bar'
1955 'mine mine mine';
1956}
1957.compact .slot {
1958 position: relative;
1959 inset: auto;
1960 width: auto;
1961 height: auto;
1962 min-width: 0;
1963 min-height: 0;
1964}
1965/* `.slot` and not the edge class alone: the rules sheet carries an edge class
1966 of its own, and would otherwise be parked in the grid area named here. */
1967.compact .slot.slot-left {
1968 grid-area: left;
1969}
1970.compact .slot.slot-top {
1971 grid-area: across;
1972}
1973.compact .slot.slot-right {
1974 grid-area: right;
1975}
1976.compact .slot.slot-bottom {
1977 grid-area: mine;
1978}
1979.compact .center {
1980 grid-area: bar;
1981}
1982/* The strip no longer floats in the middle of a slot — it is the slot. */
1983.compact .seat {
1984 position: relative;
1985 top: auto;
1986 left: auto;
1987 width: auto;
1988 height: 100%;
1989 padding: 2px 4px 3px;
1990}
1991/* Off to one side rather than dead centre: a card is narrow enough that the
1992 arrow would otherwise land on the name it is pointing out. */
1993.compact .win-arrow {
1994 font-size: 24px;
1995 left: 14px;
1996}
1997
1998/* ---------- the other three seats ---------- */
1999/* Read top-down: who they are, how many tiles they hold, what they have laid
2000 down, what they have thrown. The hand is the least interesting of those and
2001 goes smallest; the discards get whatever room is left, since reading them is
2002 the whole of playing against somebody. */
2003.compact .opp .seat {
2004 --tile-w: clamp(13px, calc((100vw / 3 - 20px) / 8.6), 32px);
2005 justify-content: flex-start;
2006 gap: 2px;
2007 border: 1px solid rgba(246, 240, 226, 0.09);
2008 border-radius: 8px;
2009 background: rgba(0, 0, 0, 0.14);
2010}
2011.compact .opp .nameplate {
2012 order: 0;
2013}
2014.compact .opp .hand {
2015 order: 1;
2016}
2017.compact .opp .melds {
2018 order: 2;
2019}
2020.compact .opp .discards {
2021 order: 3;
2022}
2023.compact .opp .actionbar {
2024 order: 4;
2025}
2026
2027.compact .opp .nameplate {
2028 flex-wrap: wrap;
2029 justify-content: center;
2030 gap: 3px 5px;
2031 font-size: 10px;
2032 padding-top: 0;
2033 row-gap: 1px;
2034}
2035.compact .opp .nameplate .wind {
2036 font-size: 13px;
2037}
2038.compact .opp .nameplate .wind em,
2039.compact .opp .nameplate .helpbtn,
2040.compact .opp .nameplate .sort {
2041 display: none;
2042}
2043.compact .opp .nameplate .chips {
2044 font-size: 13px;
2045 margin-left: 0;
2046}
2047.compact .opp .nameplate .tag {
2048 font-size: 9px;
2049 padding: 0 4px;
2050}
2051/* Face-down backs: a count you can see at a glance, nothing more. */
2052.compact .opp .hand {
2053 flex-wrap: wrap;
2054 justify-content: center;
2055 height: auto;
2056 padding-top: 0;
2057 gap: 1px;
2058 max-width: 100%;
2059}
2060.compact .opp .hand .tile {
2061 --tw: calc(var(--tile-w) * 0.52);
2062}
2063.compact .opp .melds {
2064 gap: 5px;
2065 min-height: 0;
2066 max-width: 100%;
2067}
2068/* Same size as the hand they were laid down from — which on an opponent's card
2069 is that card's own smaller tile, not the table's. */
2070.compact .opp .melds .tile {
2071 --tw: calc(var(--tile-w) * 0.52);
2072}
2073.compact .opp .meld.flowers {
2074 margin-left: 5px;
2075 padding-left: 5px;
2076}
2077.compact .opp .discards {
2078 flex: 1 1 auto;
2079 max-width: 100%;
2080 min-height: 0;
2081 overflow-y: auto;
2082 align-content: flex-start;
2083}
2084/* No height is reserved here: an opponent's bar is empty except in hotseat,
2085 and a card that stays still matters less than the room it costs. */
2086.compact .opp .actionbar {
2087 min-height: 0;
2088 padding-right: 0;
2089 justify-content: center;
2090 gap: 4px;
2091}
2092.compact .opp .actionbar .btn {
2093 height: auto;
2094 min-height: 34px;
2095 padding: 2px 8px;
2096}
2097.compact .opp .actionbar .bi .zh {
2098 font-size: 14px;
2099}
2100.compact .opp .delta {
2101 font-size: 15px;
2102}
2103
2104/* ---------- your own seat ---------- */
2105/* A grid rather than the stack the strips use, so the melds and the discards
2106 share one line and the hand gets the depth that saves. */
2107.compact .mine .seat {
2108 --tile-w: min(calc((100vw - 24px) / 18.3), 54px);
2109 display: grid;
2110 /* `minmax(0, auto)`, so a full set of melds wraps inside its column instead
2111 of shouldering the discards off the end of the strip. */
2112 grid-template-columns: minmax(0, auto) minmax(0, 1fr);
2113 grid-template-areas:
2114 'melds discards'
2115 'bar bar'
2116 'hand hand'
2117 'name name';
2118 align-content: end;
2119 align-items: end;
2120 justify-items: center;
2121 gap: 2px 8px;
2122 padding: 2px 6px 3px;
2123}
2124.compact .mine .melds {
2125 grid-area: melds;
2126 justify-content: flex-start;
2127 min-height: 0;
2128 gap: 6px;
2129}
2130/* Full size, like the hand under it — the discards beside them stay at half,
2131 which is now the whole of what tells the two rows apart. */
2132.compact .mine .melds .tile {
2133 --tw: calc(var(--tile-w) * 0.72);
2134}
2135.compact .mine .discards {
2136 grid-area: discards;
2137 /* One line that scrolls, kept pinned to its end by Seat.tsx — wrapping would
2138 cost a second row of depth the hand wants more. Reserved even while empty,
2139 since this row and the melds beside it share it: without that the strip
2140 would jump the first time either one appeared. */
2141 flex: none;
2142 flex-wrap: nowrap;
2143 justify-content: flex-start;
2144 align-self: end;
2145 max-width: 100%;
2146 width: 100%;
2147 min-height: calc(var(--tile-w) * 0.9);
2148 margin-bottom: 0;
2149 overflow-x: auto;
2150 overflow-y: hidden;
2151 /* Melds and discards end up shoulder to shoulder here, and both are rows of
2152 tiles — the rule and the smaller face are what tell them apart. */
2153 border-left: 1px solid rgba(246, 240, 226, 0.16);
2154 padding-left: 8px;
2155 opacity: 0.85;
2156}
2157.compact .mine .discards .tile {
2158 --tw: calc(var(--tile-w) * 0.5);
2159}
2160.compact .mine .actionbar {
2161 grid-area: bar;
2162 --bar-h: 44px;
2163 justify-content: center;
2164 padding-right: 0;
2165 gap: 6px;
2166}
2167.compact .mine .hand {
2168 grid-area: hand;
2169 flex-wrap: wrap;
2170 align-content: flex-end;
2171 justify-content: center;
2172 height: auto;
2173 max-width: 100%;
2174 /* The row gap matches the padding for the same reason: the 摸 tag hangs above
2175 its tile, and on a wrapped hand that is somebody else's row. */
2176 gap: calc(var(--tile-w) * 0.34) 2px;
2177 padding-top: calc(var(--tile-w) * 0.34);
2178}
2179.compact .mine .nameplate {
2180 grid-area: name;
2181 flex-wrap: wrap;
2182 justify-content: center;
2183 gap: 3px 7px;
2184 font-size: 11px;
2185 padding-top: 0;
2186}
2187.compact .mine .nameplate .wind {
2188 font-size: 15px;
2189}
2190.compact .mine .nameplate .wind em {
2191 display: none;
2192}
2193.compact .mine .nameplate .chips {
2194 font-size: 14px;
2195}
2196.compact .mine .nameplate .btn.sort {
2197 min-height: 26px;
2198 padding: 1px 9px;
2199 font-size: 12px;
2200}
2201.compact .mine .nameplate .helpbtn {
2202 width: 26px;
2203 height: 26px;
2204 font-size: 13px;
2205}
2206
2207/* ---------- the middle ---------- */
2208/* One bar across the table rather than a square in the middle of it. */
2209.compact .center {
2210 position: relative;
2211 inset: auto;
2212 flex-direction: row;
2213 align-items: center;
2214 justify-content: space-between;
2215 gap: 8px;
2216 padding: 1px 6px;
2217 min-height: 44px;
2218}
2219.compact .facing {
2220 flex: 1 1 auto;
2221 flex-direction: row;
2222 align-items: center;
2223 justify-content: center;
2224 gap: 10px;
2225 min-width: 0;
2226}
2227.compact .last-discard {
2228 --tile-w: 30px;
2229 flex-direction: row;
2230 align-items: center;
2231 gap: 6px;
2232}
2233.compact .ld-label {
2234 font-size: 10px;
2235}
2236.compact .prompt {
2237 font-size: 12px;
2238 text-align: center;
2239}
2240.compact .table-controls {
2241 position: static;
2242 flex: none;
2243}
2244.compact .btn.undo {
2245 max-width: 34vw;
2246 min-height: 30px;
2247 padding: 2px 10px;
2248}
2249.compact .undo-what .zh {
2250 font-size: 11px;
2251}
2252.compact .undo-what .en {
2253 display: none;
2254}
2255/* What the wall square was telling you, in the room a bar has. */
2256.wall-pill {
2257 display: flex;
2258 align-items: baseline;
2259 gap: 5px;
2260 flex: none;
2261 letter-spacing: 0.06em;
2262 opacity: 0.75;
2263}
2264.wall-pill b {
2265 font-size: 18px;
2266 font-weight: 900;
2267 color: var(--gold);
2268}
2269.wall-pill em {
2270 font-style: normal;
2271 font-size: 9px;
2272 text-transform: uppercase;
2273 opacity: 0.7;
2274}
2275
2276/* The hand result covers the table instead of sitting in a square that is no
2277 longer there. */
2278.compact .result {
2279 position: fixed;
2280 top: 50%;
2281 left: 50%;
2282 transform: translate(-50%, -50%);
2283 z-index: 25;
2284 max-width: min(560px, 94vw);
2285 max-height: 92dvh;
2286 padding: 12px 16px;
2287 gap: 8px;
2288}
2289.compact .result h2 {
2290 font-size: 18px;
2291}
2292.compact .tai-list {
2293 grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
2294 gap: 1px 12px;
2295 font-size: 12px;
2296}
2297.compact .ready-row {
2298 font-size: 12px;
2299}
2300.compact .ready-name {
2301 min-width: 4.5em;
2302}
2303.compact .ready-tiles {
2304 --tile-w: 26px;
2305}
2306.compact .deltas {
2307 flex-wrap: wrap;
2308 justify-content: center;
2309 gap: 6px 14px;
2310 font-size: 12px;
2311}
2312.compact .big-btn {
2313 font-size: 18px;
2314 padding: 8px 22px;
2315 min-height: 44px;
2316}
2317
2318/* ---------- portrait ---------- */
2319/* Held upright there is no room for three cards side by side, so they stack in
2320 the order play reaches them, and the hand wraps to two rows. */
2321@media (orientation: portrait) {
2322 .compact .table {
2323 grid-template-columns: 1fr;
2324 grid-template-rows: repeat(3, minmax(0, 1fr)) auto auto;
2325 grid-template-areas:
2326 'right'
2327 'across'
2328 'left'
2329 'bar'
2330 'mine';
2331 }
2332 /* A card gets the whole width here, so the tiles can be read rather than
2333 merely counted; the discards wrap to a second line when they need it. */
2334 .compact .opp .seat {
2335 --tile-w: clamp(18px, calc((100vw - 26px) / 13), 34px);
2336 }
2337 .compact .mine .seat {
2338 --tile-w: min(calc((100vw - 26px) / 9.4), 54px);
2339 }
2340 /* A phone's width will not hold the wall count, who discarded what, the claim
2341 prompt and the table's own buttons on one line. The readout — the part that
2342 changes — takes a line of its own underneath. The room comes out of the
2343 other seats, which are the flexible rows, so the hand never moves. */
2344 .compact .center {
2345 flex-wrap: wrap;
2346 row-gap: 2px;
2347 }
2348 .compact .wall-pill {
2349 order: 0;
2350 }
2351 .compact .table-controls {
2352 order: 1;
2353 }
2354 .compact .facing {
2355 order: 2;
2356 flex-basis: 100%;
2357 }
2358}
2359
2360/* ---------- the panels ---------- */
2361.compact .lobby {
2362 justify-content: flex-start;
2363 overflow-y: auto;
2364 gap: 0.25rem;
2365 padding: 12px 14px calc(14px + env(safe-area-inset-bottom));
2366}
2367.compact .lobby > * {
2368 flex: none;
2369}
2370/* Sized off both axes: a phone held sideways has width to spare and no height
2371 at all, and the title was the first thing to push the buttons off. */
2372.compact .lobby h1 {
2373 font-size: clamp(1.9rem, min(9vw, 11vh), 3.2rem);
2374 margin-bottom: 0.2rem;
2375}
2376.compact .lobby .sub {
2377 font-size: 0.78rem;
2378}
2379/* The screen dividers this asks for are a four-round-a-table idea, and nobody
2380 props cards against a phone. */
2381.compact .lobby .hint {
2382 display: none;
2383}
2384.compact .resume {
2385 margin-top: 0.8rem;
2386 padding: 8px 16px;
2387}
2388.compact .resume-scores {
2389 flex-wrap: wrap;
2390 gap: 4px 12px;
2391 font-size: 12px;
2392}
2393.compact .lobby-buttons {
2394 margin-top: 0.9rem;
2395 gap: 8px;
2396 width: min(460px, 100%);
2397}
2398.compact .lobby-buttons .big-btn {
2399 min-width: 0;
2400 width: 100%;
2401 padding: 8px 16px;
2402}
2403.compact .lobby-extras {
2404 margin-top: 0.9rem;
2405}
2406/* Sideways there is barely 390px of height for a title, a saved game, three
2407 ways to start and the settings link. Everything gives a little so the last of
2408 them lands above the fold, rather than behind a scroll nobody looks for. */
2409@media (orientation: landscape) {
2410 .compact .lobby h1 {
2411 font-size: clamp(1.6rem, 9vh, 2.6rem);
2412 }
2413 .compact .lobby .sub,
2414 .compact .resume-when {
2415 display: none;
2416 }
2417 .compact .resume {
2418 margin-top: 0.4rem;
2419 padding: 6px 14px;
2420 }
2421 .compact .resume-line {
2422 font-size: 12px;
2423 }
2424 .compact .lobby-buttons {
2425 margin-top: 0.5rem;
2426 }
2427 .compact .lobby-buttons .big-btn {
2428 min-height: 40px;
2429 padding: 5px 16px;
2430 font-size: 19px;
2431 }
2432 .compact .lobby-extras {
2433 margin-top: 0.5rem;
2434 }
2435}
2436
2437.compact .help-slot .help {
2438 width: calc(100vw - 16px);
2439 --hd: calc(100dvh - 16px);
2440 translate: none;
2441 padding: 8px 12px 10px;
2442}
2443.compact .help-head h3 {
2444 font-size: 13px;
2445}
2446/* The strip version fills four columns and scrolls sideways, which reads fine
2447 across a laptop's width and not at all on a phone. One column, scrolled the
2448 way every other sheet on the device is. */
2449.compact .help-body {
2450 columns: auto;
2451 column-gap: normal;
2452 overflow-x: hidden;
2453}
2454
2455.compact .settings-scrim {
2456 padding: 6px;
2457}
2458.compact .settings {
2459 padding: 10px 14px 14px;
2460}
2461.compact .settings-head h3 {
2462 font-size: 16px;
2463}
2464.compact .settings-head .btn {
2465 min-height: 38px;
2466 padding: 2px 14px;
2467 font-size: 14px;
2468}
2469/* Two columns sideways, where there is width and no height; one held upright,
2470 where it is the other way round. */
2471.compact .settings-body {
2472 columns: 2;
2473 column-gap: 26px;
2474 padding-top: 10px;
2475}
2476@media (orientation: portrait) {
2477 .compact .settings-body {
2478 columns: 1;
2479 }
2480}
2481.compact .settings-body section {
2482 margin-bottom: 14px;
2483}
2484.compact .settings-body h4 {
2485 margin-bottom: 6px;
2486 font-size: 14px;
2487}
2488.compact .settings .f-label {
2489 min-width: 116px;
2490 font-size: 14px;
2491}
2492.compact .settings input[type='text'],
2493.compact .settings input:not([type]),
2494.compact .settings input[type='number'] {
2495 font-size: 15px;
2496 min-height: 40px;
2497 padding: 8px 10px;
2498}
2499.compact .settings .sw-name {
2500 font-size: 14px;
2501}
2502.compact .settings .note {
2503 font-size: 11px;
2504}
2505
2506/* ---------- fullscreen ---------- */
2507/* Sized to match the sound toggle it stands beside, in both its forms. */
2508.fs-toggle {
2509 min-height: 34px;
2510 padding: 2px 14px;
2511}
2512.fs-toggle.compact {
2513 min-height: 28px;
2514 padding: 3px 9px;
2515}
2516.fs-toggle.on {
2517 border-color: rgba(217, 180, 92, 0.7);
2518 color: var(--gold);
2519}
2520.fs-toggle .fs-icon {
2521 display: block;
2522 flex: none;
2523}
2524
2525/* ---------- the party table's QR ---------- */
2526/* One join code for the whole table, lying in the middle of the felt like a
2527 tile someone left face up. It may cover the pool while it shows — it steps
2528 aside the moment every hand is held, and comes back while a 📱 tag is held
2529 down. Below the result panel (z-index 2, later in the DOM), above the pool
2530 and the centre readout (1): scannable while it matters, never over the
2531 scores. */
2532.qr-dock {
2533 position: absolute;
2534 left: 50%;
2535 top: 50%;
2536 transform: translate(-50%, -50%);
2537 z-index: 2;
2538 display: flex;
2539 flex-direction: column;
2540 align-items: center;
2541 gap: 8px;
2542 /* Purely something to look at: taps fall through to the table under it. */
2543 pointer-events: none;
2544}
2545/* The QR wears the face of a giant tile: ivory, softly bevelled, the same
2546 rounded corners and resting shadow the sprite gives the real ones, with the
2547 code engraved in the deep green the dragons are cut in. */
2548.seat-qr {
2549 --qw: clamp(96px, calc(var(--depth) * 0.56), 168px);
2550 width: var(--qw);
2551 height: calc(var(--qw) * 1.2);
2552 display: grid;
2553 place-items: center;
2554 padding: calc(var(--qw) * 0.09);
2555 border-radius: calc(var(--qw) * 0.1);
2556 background: linear-gradient(165deg, #fffdf3, #f4edd9 58%, #e6dcbf);
2557 box-shadow:
2558 inset 0 2px 1px rgba(255, 255, 255, 0.85),
2559 inset 0 calc(var(--qw) * -0.05) calc(var(--qw) * 0.04) rgba(148, 130, 90, 0.4),
2560 inset calc(var(--qw) * -0.02) 0 calc(var(--qw) * 0.03) rgba(148, 130, 90, 0.22),
2561 0 6px 12px rgba(0, 0, 0, 0.5);
2562}
2563.seat-qr svg {
2564 display: block;
2565 width: 100%;
2566 height: auto;
2567 aspect-ratio: 1;
2568}
2569.qr-hint {
2570 font-size: 11px;
2571 line-height: 1.35;
2572 color: var(--ivory);
2573 text-align: center;
2574 max-width: 190px;
2575 padding: 5px 12px;
2576 border-radius: 999px;
2577 background: rgba(10, 49, 38, 0.88);
2578 border: 1px solid rgba(217, 180, 92, 0.4);
2579}
2580.qr-hint em {
2581 display: block;
2582 font-style: normal;
2583 opacity: 0.6;
2584 font-size: 9px;
2585}
2586.nameplate .tag.phone {
2587 background: rgba(29, 90, 168, 0.35);
2588 border-color: rgba(29, 90, 168, 0.7);
2589 cursor: pointer;
2590 user-select: none;
2591 touch-action: none;
2592}
2593/* The device holding this hand has gone quiet and the hand is being kept for
2594 it. Amber rather than red: nothing is lost, and in a few seconds the
2595 computer picks the tiles up so the table can carry on. */
2596.nameplate .tag.waiting {
2597 background: rgba(217, 180, 92, 0.28);
2598 border-color: rgba(217, 180, 92, 0.6);
2599 padding-inline: 5px;
2600}
2601/* This hand is calling for itself, out of the phone holding it. Sits next to
2602 the 📱 tag and reads as part of it, which is what it is. */
2603.nameplate .tag.voice {
2604 background: rgba(29, 90, 168, 0.2);
2605 border-color: rgba(29, 90, 168, 0.55);
2606 padding-inline: 5px;
2607}
2608
2609/* ---------- the phone controller ---------- */
2610/* A hand held in landscape: status, buttons, tiles, in that order down the
2611 screen, with the common screen doing everything else. Tiles get the room —
2612 they are the only thing this screen is for. */
2613.ctl {
2614 /* The whole hand in one row: 17 tiles and their gaps must fit the width. */
2615 --tile-w: clamp(30px, calc((100vw - 56px) / 17.6), 76px);
2616 position: fixed;
2617 inset: 0;
2618 display: flex;
2619 flex-direction: column;
2620 background: radial-gradient(ellipse at center, var(--felt) 0%, var(--felt-2) 80%);
2621 color: var(--ivory);
2622 overflow: hidden;
2623 touch-action: none;
2624}
2625.ctl-top {
2626 display: flex;
2627 align-items: center;
2628 gap: 8px;
2629 padding: 8px 12px 4px;
2630 flex-wrap: wrap;
2631 font-size: 13px;
2632}
2633.ctl-top .wind {
2634 font-weight: 900;
2635 font-size: 18px;
2636 color: var(--gold);
2637}
2638.ctl-top .wind em {
2639 font-style: normal;
2640 font-size: 10px;
2641 margin-left: 2px;
2642 opacity: 0.7;
2643}
2644.ctl-top .pname {
2645 font-weight: 600;
2646}
2647.ctl-top .tag {
2648 border: 1px solid rgba(246, 240, 226, 0.4);
2649 border-radius: 20px;
2650 padding: 1px 8px;
2651 font-size: 11px;
2652}
2653.ctl-top .tag.ting {
2654 border-color: var(--gold);
2655 color: var(--gold);
2656}
2657.ctl-status {
2658 opacity: 0.75;
2659}
2660.ctl-status em {
2661 font-style: normal;
2662 opacity: 0.6;
2663 margin-left: 4px;
2664}
2665.ctl-status.hot {
2666 opacity: 1;
2667 color: var(--gold);
2668 font-weight: 600;
2669}
2670/* The table has come round to this phone. Four people at one screen and a
2671 phone each is a game that stalls on whoever is not looking at either, so the
2672 whole screen answers rather than a line of text at the top of it: a gold rim
2673 edge to edge, breathing, with the seven seconds running along the top. It
2674 buzzes once as well — see `calling` in ui/Controller.
2675
2676 Red for a claim, because a claim window is somebody else's tile going past
2677 and it is the one thing here with a clock on it that is not yours. */
2678.ctl-turn::before,
2679.ctl-claim::before {
2680 content: '';
2681 position: fixed;
2682 inset: 0;
2683 pointer-events: none;
2684 z-index: 2;
2685 animation: ctl-breathe 2.1s ease-in-out infinite;
2686}
2687.ctl-turn::before {
2688 box-shadow: inset 0 0 0 3px var(--gold), inset 0 0 36px rgba(217, 180, 92, 0.3);
2689}
2690.ctl-claim::before {
2691 box-shadow: inset 0 0 0 3px #e0574a, inset 0 0 36px rgba(179, 38, 30, 0.32);
2692}
2693@keyframes ctl-breathe {
2694 50% {
2695 opacity: 0.45;
2696 }
2697}
2698.ctl-turn .ctl-status,
2699.ctl-claim .ctl-status {
2700 font-size: 16px;
2701}
2702/* Across the top of the phone, edge to edge — there is no strip to sit inside
2703 here, the screen is the strip. */
2704.ctl .turn-clock {
2705 position: fixed;
2706 left: 0;
2707 right: 0;
2708 height: 6px;
2709 border-radius: 0;
2710 z-index: 4;
2711}
2712.ctl-last {
2713 display: inline-flex;
2714 align-items: center;
2715 gap: 4px;
2716 margin-left: auto;
2717}
2718.ctl-top .chips {
2719 font-weight: 600;
2720 color: var(--gold);
2721}
2722.ctl-actions {
2723 display: flex;
2724 align-items: center;
2725 justify-content: center;
2726 gap: 8px;
2727 flex-wrap: wrap;
2728 min-height: var(--bar-h);
2729 padding: 2px 10px;
2730}
2731.ctl-hint {
2732 font-size: 11px;
2733 opacity: 0.55;
2734}
2735.ctl-hand {
2736 margin-top: auto;
2737 display: flex;
2738 justify-content: center;
2739 padding: 0 8px calc(10px + env(safe-area-inset-bottom, 0px));
2740}
2741.ctl-hand .hand {
2742 flex-wrap: nowrap;
2743}
2744/* Held upright, seventeen tiles will never make one row. Break into two and
2745 size the tiles off half the count instead of shrinking them past reading.
2746 The row gap leaves room for the 摸 tag over a tile in the lower row. */
2747@media (orientation: portrait) {
2748 .ctl {
2749 --tile-w: clamp(34px, calc((100vw - 44px) / 9.4), 76px);
2750 }
2751 .ctl-hand .hand {
2752 flex-wrap: wrap;
2753 justify-content: center;
2754 /* Bounded, or it stays one long row: a `flex: none` item is happy to
2755 overflow its container rather than wrap inside it. */
2756 width: 100%;
2757 height: auto;
2758 row-gap: calc(var(--tile-w) * 0.4);
2759 }
2760}
2761/* The tile in the fingers once it has left the row, and its exit: flung, it
2762 keeps going up off the screen — the common screen takes it from here. */
2763.ctl-held {
2764 position: fixed;
2765 z-index: 5;
2766 pointer-events: none;
2767 transform: translate(-50%, -50%);
2768}
2769.ctl-held .tile {
2770 width: 100%;
2771 height: 100%;
2772}
2773.ctl-held.flung {
2774 animation: fling-up 0.3s ease-in forwards;
2775}
2776@keyframes fling-up {
2777 to {
2778 transform: translate(-50%, -140vh) rotate(18deg);
2779 opacity: 0.9;
2780 }
2781}
2782.ctl-result {
2783 position: fixed;
2784 left: 50%;
2785 top: 44%;
2786 transform: translate(-50%, -50%);
2787 z-index: 6;
2788 background: rgba(10, 49, 38, 0.96);
2789 border: 1px solid rgba(217, 180, 92, 0.5);
2790 border-radius: 14px;
2791 padding: 16px 22px;
2792 text-align: center;
2793 max-width: min(92vw, 480px);
2794}
2795.ctl-result h2 {
2796 margin: 0 0 8px;
2797 font-size: 18px;
2798}
2799.ctl-result .deltas {
2800 display: flex;
2801 gap: 12px;
2802 flex-wrap: wrap;
2803 justify-content: center;
2804 margin-bottom: 12px;
2805 font-size: 13px;
2806}
2807.ctl-result .deltas .up {
2808 color: var(--gold);
2809}
2810.ctl-result .deltas .down {
2811 color: #e08a84;
2812}
2813
2814/* Seat picking, on the phone and in the online lobby. */
2815.ctl-pick {
2816 align-items: center;
2817 justify-content: center;
2818 text-align: center;
2819 overflow-y: auto;
2820 touch-action: pan-y;
2821}
2822.pick-grid {
2823 display: grid;
2824 grid-template-columns: repeat(2, minmax(150px, 220px));
2825 gap: 10px;
2826 justify-content: center;
2827 margin: 12px 0;
2828}
2829.pick-grid .btn em {
2830 display: block;
2831 font-style: normal;
2832 font-size: 11px;
2833 opacity: 0.65;
2834 margin-top: 2px;
2835}
2836/* Under the seats: the phone's own mute and the fullscreen button. Turning
2837 sound on here is also what wakes this phone's audio, which is what lets it
2838 do the calling for whichever seat it ends up on. */
2839.pick-extras {
2840 display: flex;
2841 gap: 12px;
2842 align-items: center;
2843 justify-content: center;
2844 flex-wrap: wrap;
2845}
2846
2847/* The gear's panel. Same shell as the rules sheet — anchored in the player's
2848 own strip and turned to face it — but it is four buttons, not a book, so it
2849 is only as tall as they are. */
2850/* `--hd` is how deep it stands, and `--off` — how far out of the middle it is
2851 pushed towards its own edge — is worked out from it, so the menu sets the
2852 depth rather than letting its content decide it. Four buttons' worth. */
2853.help-slot.slot-bottom .help.menu,
2854.help-slot.slot-top .help.menu,
2855.help-slot.slot-left .help.menu,
2856.help-slot.slot-right .help.menu {
2857 width: min(420px, calc(100vw - 60px));
2858 --hd: 268px;
2859}
2860.help.menu {
2861 padding: 10px 18px 16px;
2862}
2863.menu-body {
2864 display: flex;
2865 flex-direction: column;
2866 gap: 8px;
2867 margin-top: 10px;
2868 /* The panel is a fixed height at its player's edge, and the QR at the foot
2869 of this list is what first made the list taller than it. Scroll rather
2870 than spill: the panel is turned to face one player, and a menu that ran
2871 off the table would run off it upside down for two of them. */
2872 min-height: 0;
2873 overflow-y: auto;
2874}
2875.menu-body .btn {
2876 justify-content: flex-start;
2877 width: 100%;
2878}
2879.menu-body .btn:disabled {
2880 opacity: 0.4;
2881 cursor: default;
2882}
2883/* The way off this screen: the QR for taking *this* hand onto a phone, turned
2884 to face the player whose hand it is along with everything else in here.
2885 Smaller than the one that lies in the middle of the felt — a phone held over
2886 an open menu is a foot away, not across the table. */
2887.menu-qr {
2888 display: flex;
2889 align-items: center;
2890 gap: 10px;
2891 padding-top: 4px;
2892 border-top: 1px solid rgba(246, 240, 226, 0.14);
2893}
2894.menu-qr .seat-qr {
2895 --qw: 92px;
2896 flex: none;
2897}
2898.menu-qr-hint {
2899 font-size: 12px;
2900 line-height: 1.35;
2901 opacity: 0.75;
2902}
2903.menu-qr-hint em {
2904 display: block;
2905 font-style: normal;
2906 font-size: 11px;
2907 opacity: 0.75;
2908}
2909
2910/* ---------- the wire, when it goes ---------- */
2911/* One line across the top of the felt. The room is already reconnecting on its
2912 own, so this says what is happening rather than asking for anything, and it
2913 goes away by itself when state starts flowing again. Above the tiles and out
2914 of the way of every strip, which all sit against their own edge. */
2915.net-warn {
2916 position: absolute;
2917 top: 6px;
2918 left: 50%;
2919 transform: translateX(-50%);
2920 z-index: 60;
2921 max-width: 70vw;
2922 padding: 4px 12px;
2923 border-radius: 999px;
2924 background: rgba(20, 40, 30, 0.82);
2925 border: 1px solid rgba(217, 180, 92, 0.5);
2926 color: rgba(246, 240, 226, 0.85);
2927 font-size: 12px;
2928 text-align: center;
2929 pointer-events: none;
2930}