- ←
-
+
-
ハンドレンジ
+ +
+ ロビー
+
+
+
+
-
+
ハンドレンジ
+ +
+
+
-
+
-
+
—
セルをタップすると分類を確認できます
問題数(全169ハンドからランダム)
-
+
@@ -64,12 +71,13 @@
CPU戦のレビュー - Hold'em Night
-
-
-
-
-
-
+
+
+
diff --git a/admin/range.js b/admin/range.js
index 147563e..638a564 100644
--- a/admin/range.js
+++ b/admin/range.js
@@ -5,6 +5,10 @@
const $ = (id) => document.getElementById(id);
const { RANKS, CATEGORIES, FACING_ACTIONS, handAt, categoryOf, labelOf, facingOf, facingAction } = window.HandRange;
+ Fluid.navBar($('nav'), $('large-title'));
+ const tabsSeg = Fluid.Segmented($('tabs'));
+ Fluid.Segmented($('count'));
+
function swatch(id) {
const s = document.createElement('span');
s.className = `swatch-dot cat-${id}`;
@@ -22,7 +26,8 @@
function showView(name) {
$('view-range').hidden = name !== 'range';
$('view-quiz').hidden = name !== 'quiz';
- for (const b of $('tabs').children) b.classList.toggle('active', b.dataset.view === name);
+ for (const b of $('tabs').querySelectorAll('[data-view]')) b.classList.toggle('active', b.dataset.view === name);
+ tabsSeg.update();
}
$('tabs').addEventListener('click', (e) => {
const b = e.target.closest('[data-view]');
@@ -99,7 +104,7 @@
const b = e.target.closest('[data-count]');
if (!b) return;
quizCount = Number(b.dataset.count);
- for (const x of $('count').children) x.classList.toggle('active', x === b);
+ for (const x of $('count').querySelectorAll('[data-count]')) x.classList.toggle('active', x === b);
});
function shuffle(a) {
@@ -186,6 +191,9 @@
fb.className = `quiz-feedback ${ok ? 'good' : 'bad'}`;
fb.textContent = `${ok ? '正解!' : '不正解'} ${hand} は「${labelOf(right)}」です。`;
fb.hidden = false;
+ // 間違えたら選んだボタンが揺れる(振動と同じ瞬間に)
+ if (!ok) Fluid.shake(button);
+ Fluid.haptic(ok ? 10 : [18, 50, 18]);
updateStats();
$('next-question').disabled = false;
}
diff --git a/admin/review.css b/admin/review.css
index 85a9296..93005e6 100644
--- a/admin/review.css
+++ b/admin/review.css
@@ -1,146 +1,465 @@
-/* CPU戦のレビュー(管理者専用)。基本のデザインは /assets/style.css、タブは range.css、
+/* CPU戦のレビュー(管理者専用)。基本のデザイン・セグメント・リストは /assets/style.css、
ハンドレンジの色・点数の印(.rv-color / .rv-mark など)は style.css の「レビュー付きCPU戦」を使う */
-.review-wrap { padding-bottom: 40px; }
-.plus { color: var(--green); }
-.minus { color: var(--red); }
-
/* 点数の色(◎ ○ △ ✕ と同じ段階) */
.score-great { color: var(--green); }
-.score-good { color: #8fd18f; }
+.score-good { color: #64b96a; }
.score-warn { color: var(--orange); }
.score-bad { color: var(--red); }
-.score-none { color: var(--muted); }
+.score-none { color: var(--label-2); }
/* 対局の一覧 */
-.rv-sessions { display: grid; gap: 8px; margin-top: 12px; }
-.rv-session {
- display: grid;
- gap: 4px;
- text-align: left;
- font: inherit;
- color: var(--text);
- background: var(--panel);
- border: 0;
- border-radius: var(--radius);
- padding: 14px 16px;
- cursor: pointer;
+.rv-sessions {
+ margin-bottom: 28px;
+}
+.rv-session {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 100%;
+ border: 0;
+ text-align: left;
+}
+.rv-session-name {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
}
-.rv-session:hover, .rv-session:focus-visible { background: var(--panel-2); }
-.rv-session-name { font-weight: 800; font-size: 16px; display: flex; align-items: center; gap: 8px; }
-.rv-session-meta { color: var(--muted); font-size: 12px; }
-/* 概要とタイル */
-.rv-overview { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 12px; }
-.rv-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
-.rv-tile { background: var(--panel); border-radius: 12px; padding: 10px 12px; display: grid; gap: 2px; min-width: 0; }
-.rv-tile span { font-size: 12px; color: var(--muted); font-weight: 700; }
-.rv-tile b { font-size: 22px; font-variant-numeric: tabular-nums; }
-.rv-tile small { font-size: 11px; color: var(--muted); line-height: 1.4; }
-.rv-tabs .seg-btn { font-size: 13px; padding: 9px 2px; }
+/* 概要とタイル(ウィジェットのような小さな面) */
+.rv-overview {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ margin-bottom: 16px;
+}
+.rv-tiles {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ gap: 8px;
+}
+.rv-tile {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+ padding: 12px 14px;
+ border-radius: 16px;
+ background: var(--bg-2);
+}
+.rv-tile span {
+ color: var(--label-2);
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+.rv-tile b {
+ font-family: var(--font-num);
+ font-size: 1.5rem;
+ line-height: 1.2;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+}
+.rv-tile small {
+ color: var(--label-2);
+ font-size: 0.6875rem;
+ line-height: 1.45;
+}
+.rv-tabs {
+ margin-bottom: 14px;
+}
+.rv-tabs .seg-btn {
+ padding: 4px 2px;
+ font-size: 0.8125rem;
+}
+#tab-body > .hint,
+.review-wrap p.hint {
+ margin: 10px 4px;
+}
/* 表 */
-.rv-table-wrap { overflow-x: auto; margin-top: 8px; border-radius: 12px; background: var(--panel); }
-.rv-table { width: 100%; border-collapse: collapse; font-size: 13px; }
-.rv-table th, .rv-table td { padding: 8px 6px; text-align: left; white-space: nowrap; border-bottom: 1px solid var(--line); }
-.rv-table th { color: var(--muted); font-size: 11px; font-weight: 700; }
-.rv-table .num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; }
-.rv-table tbody tr[data-hand] { cursor: pointer; }
-.rv-table tbody tr[data-hand]:hover, .rv-table tbody tr[data-hand]:focus-visible { background: var(--panel-2); outline: none; }
-.rv-table tfoot td { font-weight: 800; border-bottom: 0; }
-.rv-hand-cell b { margin-right: 2px; }
-.rv-table .rv-color { font-size: 11px; padding: 1px 6px; }
+.rv-table-wrap {
+ margin-top: 8px;
+ overflow-x: auto;
+ border-radius: 14px;
+ background: var(--bg-2);
+}
+.rv-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8125rem;
+}
+.rv-table th,
+.rv-table td {
+ padding: 9px 8px;
+ border-bottom: 0.5px solid var(--separator);
+ text-align: left;
+ white-space: nowrap;
+}
+.rv-table th {
+ color: var(--label-2);
+ font-size: 0.6875rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+.rv-table .num {
+ text-align: right;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+.rv-table tbody tr[data-hand] {
+ cursor: pointer;
+ transition: background-color 0.35s ease;
+}
+.rv-table tbody tr[data-hand]:active {
+ background: var(--fill-3);
+ transition: none;
+}
+.rv-table tbody tr[data-hand]:focus-visible {
+ outline: 2px solid var(--blue);
+ outline-offset: -2px;
+}
+@media (hover: hover) {
+ .rv-table tbody tr[data-hand]:hover {
+ background: var(--fill-3);
+ }
+}
+.rv-table tfoot td {
+ border-bottom: 0;
+ font-weight: 700;
+}
+.rv-hand-cell b {
+ margin-right: 2px;
+}
+.rv-table .rv-color {
+ padding: 1px 6px;
+ font-size: 0.6875rem;
+}
-.rv-leaks { margin: 0; padding-left: 20px; display: grid; gap: 4px; font-size: 14px; }
-.rv-leaks b { color: var(--gold); }
+.rv-leaks {
+ display: grid;
+ gap: 6px;
+ margin: 0;
+ padding: 12px 14px 12px 32px;
+ border-radius: 14px;
+ background: var(--bg-2);
+ font-size: 0.9375rem;
+}
+.rv-leaks b {
+ color: var(--orange);
+}
/* 見直したいハンド */
-.rv-worst { display: grid; gap: 8px; }
-.rv-worst-item {
- display: grid;
- grid-template-columns: 28px 1fr;
- gap: 2px 8px;
- align-items: center;
- text-align: left;
- font: inherit;
- color: var(--text);
- background: var(--panel);
- border: 0;
- border-radius: 12px;
- padding: 10px 12px;
- cursor: pointer;
+.rv-worst {
+ overflow: hidden;
+ border-radius: 14px;
+ background: var(--bg-2);
+}
+.rv-worst-item {
+ -webkit-appearance: none;
+ appearance: none;
+ position: relative;
+ width: 100%;
+ display: grid;
+ grid-template-columns: 32px 1fr;
+ gap: 2px 10px;
+ align-items: center;
+ padding: 11px 14px;
+ border: 0;
+ background: transparent;
+ color: var(--label);
+ text-align: left;
+ cursor: pointer;
+ transition: background-color 0.35s ease;
+}
+.rv-worst-item + .rv-worst-item::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: 56px;
+ border-top: 0.5px solid var(--separator);
+}
+.rv-worst-item:active {
+ background: var(--fill-3);
+ transition: none;
+}
+.rv-rank {
+ grid-row: span 2;
+ color: var(--orange);
+ font-family: var(--font-num);
+ font-size: 1.375rem;
+ font-weight: 700;
+ text-align: center;
+}
+.rv-worst-meta {
+ color: var(--label-2);
+ font-size: 0.75rem;
+ font-variant-numeric: tabular-nums;
+}
+.rv-worst-item .rv-color {
+ width: 14px;
+ height: 14px;
+ padding: 0;
+ vertical-align: -2px;
}
-.rv-worst-item:hover, .rv-worst-item:focus-visible { background: var(--panel-2); }
-.rv-rank { grid-row: span 2; font-size: 20px; font-weight: 900; color: var(--gold); text-align: center; }
-.rv-worst-meta { font-size: 12px; color: var(--muted); }
-.rv-worst-item .rv-color { width: 14px; height: 14px; padding: 0; vertical-align: -2px; }
/* 収支のグラフ(実際 = 実線、運を除く = 破線。色だけに頼らない) */
-.rv-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 13px; margin-bottom: 6px; }
-.sw { display: inline-block; width: 16px; height: 0; border-top: 2px solid; vertical-align: middle; margin-right: 6px; }
-.sw.actual { border-color: #b8891c; }
-.sw.ev { border-color: #4a90e2; border-top-style: dashed; }
-.rv-chart { position: relative; background: var(--panel); border-radius: 12px; padding: 8px; }
-.rv-chart svg { display: block; width: 100%; height: auto; touch-action: pan-y; }
-.rv-chart .gl { stroke: var(--line); stroke-width: 1; }
-.rv-chart .zero { stroke: var(--muted); stroke-width: 1; stroke-dasharray: 2 3; }
-.rv-chart .tick { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
-.rv-chart .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
-.rv-chart .line.actual { stroke: #b8891c; }
-.rv-chart .line.ev { stroke: #4a90e2; stroke-dasharray: 6 4; }
-.rv-chart .cross { stroke: var(--muted); stroke-width: 1; }
-.rv-chart .dot { stroke: var(--panel); stroke-width: 2; }
-.rv-chart .dot.actual { fill: #b8891c; }
-.rv-chart .dot.ev { fill: #4a90e2; }
-.rv-chart .hit { fill: transparent; cursor: crosshair; }
+.rv-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 16px;
+ margin: 0 4px 8px;
+ font-size: 0.8125rem;
+}
+.sw {
+ display: inline-block;
+ width: 16px;
+ height: 0;
+ margin-right: 6px;
+ border-top: 2px solid;
+ vertical-align: middle;
+}
+.sw.actual {
+ border-color: var(--orange);
+}
+.sw.ev {
+ border-color: var(--blue);
+ border-top-style: dashed;
+}
+.rv-chart {
+ position: relative;
+ padding: 10px;
+ border-radius: 16px;
+ background: var(--bg-2);
+}
+.rv-chart svg {
+ display: block;
+ width: 100%;
+ height: auto;
+ touch-action: pan-y;
+}
+.rv-chart .gl {
+ stroke: var(--separator);
+ stroke-width: 0.5;
+}
+.rv-chart .zero {
+ stroke: var(--label-3);
+ stroke-width: 1;
+ stroke-dasharray: 2 3;
+}
+.rv-chart .tick {
+ fill: var(--label-2);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+}
+.rv-chart .line {
+ fill: none;
+ stroke-width: 2;
+ stroke-linejoin: round;
+ stroke-linecap: round;
+}
+.rv-chart .line.actual {
+ stroke: var(--orange);
+}
+.rv-chart .line.ev {
+ stroke: var(--blue);
+ stroke-dasharray: 6 4;
+}
+.rv-chart .cross {
+ stroke: var(--label-3);
+ stroke-width: 1;
+}
+.rv-chart .dot {
+ stroke: var(--bg-2);
+ stroke-width: 2;
+}
+.rv-chart .dot.actual {
+ fill: var(--orange);
+}
+.rv-chart .dot.ev {
+ fill: var(--blue);
+}
+.rv-chart .hit {
+ fill: transparent;
+ cursor: crosshair;
+}
.rv-tip {
position: absolute;
- top: 8px;
- background: var(--bg-deep);
- border: 1px solid var(--line);
- border-radius: 8px;
- padding: 6px 10px;
- font-size: 12px;
- pointer-events: none;
+ top: 10px;
+ padding: 7px 11px;
+ border-radius: 12px;
+ background: var(--material-thick);
+ -webkit-backdrop-filter: blur(20px) saturate(180%);
+ backdrop-filter: blur(20px) saturate(180%);
+ box-shadow: var(--shadow-m), inset 0 0 0 0.5px var(--material-edge);
+ font-size: 0.75rem;
+ font-variant-numeric: tabular-nums;
white-space: nowrap;
+ pointer-events: none;
}
/* リプレイ */
-.rv-note { margin-top: 10px; font-size: 13px; background: var(--panel); border-radius: 10px; padding: 8px 10px; }
-.rv-copy { width: 100%; margin-top: 10px; }
-.rv-board-box { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 14px; background: var(--felt-dark); border-radius: 12px; padding: 10px; }
-.rv-board { display: flex; gap: 3px; }
-.rv-pot { font-weight: 900; color: #fff; font-variant-numeric: tabular-nums; }
-.rv-step { margin-top: 10px; background: var(--panel); border-radius: 12px; padding: 10px 12px; font-size: 14px; min-height: 64px; }
-.rv-step-title { font-size: 12px; font-weight: 800; color: var(--gold); margin-bottom: 2px; }
-.rv-controls { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 8px; }
-.rv-controls .btn { padding: 10px 0; font-size: 16px; }
-.rv-analysis { margin-top: 10px; background: var(--panel); border: 1px solid var(--gold); border-radius: 12px; padding: 6px 12px 10px; }
-.rv-analysis .rv-item:first-child { border-top: 0; }
-.rv-ev { margin-top: 6px; }
-.rv-ev tr.actual td { background: rgba(246, 199, 68, 0.08); }
-.rv-ev .best { color: var(--green); }
-.rv-players { display: grid; gap: 6px; margin-top: 12px; }
-.rv-player { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; background: var(--panel); border-radius: 10px; padding: 6px 10px; border: 2px solid transparent; }
-.rv-player.hero { border-color: rgba(246, 199, 68, 0.35); }
-.rv-player.acting { border-color: var(--gold); }
-.rv-player.folded { opacity: 0.55; }
-.rv-player-info { min-width: 0; font-size: 14px; }
-.rv-player-act { font-size: 12px; font-weight: 800; color: var(--muted); text-align: right; }
-.rv-pos { font-size: 11px; font-weight: 800; color: var(--gold); margin-left: 4px; }
-.rv-log { display: flex; flex-wrap: wrap; gap: 4px; }
-.rv-log-street { width: 100%; font-size: 11px; color: var(--muted); font-weight: 800; margin-top: 6px; }
-.rv-log-item {
- font: inherit;
- font-size: 12px;
- color: var(--text);
- background: var(--panel);
- border: 1px solid var(--line);
- border-radius: 8px;
- padding: 4px 8px;
- cursor: pointer;
+.rv-note {
+ margin-top: 10px;
+ padding: 10px 12px;
+ border-radius: 14px;
+ background: var(--bg-2);
+ font-size: 0.8125rem;
+}
+.rv-copy {
+ margin-top: 10px;
+}
+.rv-board-box {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-top: 14px;
+ padding: 12px 14px;
+ border-radius: 18px;
+ background: radial-gradient(120% 140% at 30% 20%, var(--felt-hi), var(--felt) 50%, var(--felt-lo));
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25), inset 0 8px 20px rgba(0, 0, 0, 0.25);
+}
+.rv-board {
+ display: flex;
+ gap: 4px;
+}
+.rv-pot {
+ color: #fff;
+ font-family: var(--font-num);
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+.rv-step {
+ min-height: 68px;
+ margin-top: 10px;
+ padding: 10px 14px;
+ border-radius: 14px;
+ background: var(--bg-2);
+ font-size: 0.9375rem;
+}
+.rv-step-title {
+ margin-bottom: 2px;
+ color: var(--label-2);
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+.rv-controls {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+ margin-top: 8px;
+}
+.rv-controls .btn {
+ padding: 10px 0;
+}
+.rv-controls .btn .i {
+ width: 20px;
+ height: 20px;
+}
+.rv-analysis {
+ margin-top: 10px;
+ padding: 4px 14px 12px;
+ border-radius: 14px;
+ background: var(--bg-2);
+ box-shadow: inset 0 0 0 1.5px var(--orange);
+}
+.rv-ev {
+ margin-top: 6px;
+}
+.rv-ev tr.actual td {
+ background: rgba(255, 149, 0, 0.1);
+}
+.rv-ev .best {
+ color: var(--green);
+}
+.rv-players {
+ display: grid;
+ gap: 6px;
+ margin-top: 12px;
+}
+.rv-player {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 10px;
+ align-items: center;
+ padding: 8px 12px;
+ border-radius: 14px;
+ background: var(--bg-2);
+ box-shadow: inset 0 0 0 0 transparent;
+ transition: box-shadow 0.3s ease, opacity 0.3s ease;
+}
+.rv-player.hero {
+ box-shadow: inset 0 0 0 1.5px var(--tint-soft);
+}
+.rv-player.acting {
+ box-shadow: inset 0 0 0 2px var(--orange);
+}
+.rv-player.folded {
+ opacity: 0.5;
+}
+.rv-player-info {
+ min-width: 0;
+ font-size: 0.875rem;
+}
+.rv-player-act {
+ color: var(--label-2);
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-align: right;
+}
+.rv-pos {
+ margin-left: 4px;
+ color: var(--tint);
+ font-size: 0.6875rem;
+ font-weight: 700;
+}
+.rv-log {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.rv-log-street {
+ width: 100%;
+ margin-top: 6px;
+ color: var(--label-2);
+ font-size: 0.6875rem;
+ font-weight: 600;
+}
+.rv-log-item {
+ -webkit-appearance: none;
+ appearance: none;
+ padding: 5px 10px;
+ border: 0;
+ border-radius: 999px;
+ background: var(--bg-2);
+ color: var(--label);
+ font-size: 0.75rem;
+ cursor: pointer;
+ box-shadow: inset 0 0 0 0.5px var(--separator);
+ transition: background-color 0.25s ease, color 0.25s ease, transform 0.45s var(--ease-spring);
+}
+.rv-log-item:active {
+ transform: scale(0.94);
+ transition: transform 0.1s ease-out;
+}
+.rv-log-item .hint {
+ margin: 0 4px;
+ font-size: 0.6875rem;
+}
+.rv-log-item.mine {
+ box-shadow: inset 0 0 0 1.5px var(--tint);
+}
+.rv-log-item.current {
+ background: var(--orange);
+ color: var(--on-orange);
+}
+.rv-log-item.current .hint {
+ color: rgba(0, 0, 0, 0.6);
+}
+.plus {
+ color: var(--green);
+}
+.minus {
+ color: var(--red);
}
-.rv-log-item .hint { margin: 0 4px; font-size: 11px; }
-.rv-log-item.mine { border-color: rgba(246, 199, 68, 0.6); }
-.rv-log-item.current { background: var(--gold); color: #1a1a1a; }
-.rv-log-item.current .hint { color: #333; }
diff --git a/admin/review.html b/admin/review.html
index d2ccbf9..fa029fa 100644
--- a/admin/review.html
+++ b/admin/review.html
@@ -3,31 +3,35 @@
-
+
+
- ←
-
+
+
+
+
-
- 🤖 AIに質問する用にコピー `;
+ ${SPARKLES}AIに質問する用にコピー `;
$('replay-copy').addEventListener('click', () => copyHandPrompt(h));
$('replay-log').innerHTML = logHtml(h);
renderStep();
diff --git a/public/assets/common.js b/public/assets/common.js
index d0ce261..a9a9776 100644
--- a/public/assets/common.js
+++ b/public/assets/common.js
@@ -38,21 +38,25 @@
return '$' + Number(n).toLocaleString('en-US');
}
+ // 上から降りてきて、少ししたら同じ道を上へ戻る通知
let toastTimer;
function toast(msg) {
let el = document.querySelector('.toast');
if (!el) {
el = document.createElement('div');
el.className = 'toast';
+ el.setAttribute('role', 'status');
+ el.setAttribute('aria-live', 'polite');
document.body.appendChild(el);
+ void el.offsetWidth; // 最初の1回も画面の外から動かす
}
el.textContent = msg;
- el.hidden = false;
+ el.classList.add('show');
clearTimeout(toastTimer);
- toastTimer = setTimeout(() => { el.hidden = true; }, 2500);
+ toastTimer = setTimeout(() => el.classList.remove('show'), 2600);
}
- // ブラウザ標準の confirm() の代わりの確認ダイアログ。OK なら true で解決する。
+ // ブラウザ標準の confirm() の代わりの確認ダイアログ(iOS のアラートの形)。OK なら true で解決する。
// opts: { title, message(改行可), ok, cancel, danger(OK を赤にする) }
// 新しいダイアログを開くと、開いていたものはキャンセル扱いで閉じる
let closeConfirm = null;
@@ -61,22 +65,24 @@
if (closeConfirm) closeConfirm(false);
const backdrop = document.createElement('div');
- backdrop.className = 'modal-backdrop confirm-backdrop';
+ backdrop.className = 'alert-backdrop';
backdrop.innerHTML =
- '
CPU戦のレビュー
+ +
+ ロビー
+
+ CPU戦のレビュー
レビュー付きCPU戦の対局です(新しい順・最大50件)。
- +レビュー付きCPU戦の対局(新しい順・最大50件)
+
+
@@ -46,10 +50,10 @@
-
-
-
-
+
+
+
+
@@ -58,6 +62,7 @@
+
diff --git a/admin/review.js b/admin/review.js
index 1eddcf2..3bd1a8b 100644
--- a/admin/review.js
+++ b/admin/review.js
@@ -19,6 +19,12 @@
let tab = 'scores';
let replay = null; // { hand, step }
+ const CHEVRON = '';
+ const SPARKLES =
+ '';
+ Fluid.navBar($('nav'), $('title'));
+ const tabsSeg = Fluid.Segmented($('tabs'));
+
const markOf = (score) => (score == null ? '' : score >= 90 ? '◎' : score >= 70 ? '○' : score >= 40 ? '△' : '✕');
const scoreClass = (score) => (score == null ? 'none' : MARK_CLASS[markOf(score)]);
const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '±') + chips(Math.abs(n));
@@ -55,6 +61,7 @@
if (!id) {
$('title').textContent = 'CPU戦のレビュー';
$('back').href = '/';
+ $('back-label').textContent = 'ロビー';
show('list');
return loadList();
}
@@ -71,10 +78,12 @@
const h = session.hands.find((x) => x.number === hand);
if (h) {
$('back').href = `?s=${encodeURIComponent(id)}`;
+ $('back-label').textContent = '対局';
return openReplay(h);
}
}
$('back').href = '/review';
+ $('back-label').textContent = '一覧';
show('session');
renderSession();
}
@@ -105,9 +114,12 @@
$('sessions').innerHTML = list
.map(
(s) => `
- ${STREETS.map((st) => `
${allInHtml(h)}
- ${STREET_SHORT[st]}${h.streetScores[st] == null ? '—' : h.streetScores[st]}
`).join('')}' +
- (o.title ? '' : '') +
- '' +
- '
' +
- ' ' +
- ` ` +
+ '${esc(nameOf(seat))} `
- )
- .join('');
- return ``;
- })
- .join('');
+ patch(
+ $('award-pots'),
+ pots
+ .map((p, i) => {
+ const title = pots.length === 1 ? 'ポット' : i === 0 ? 'メインポット' : `サイドポット${pots.length > 2 ? i : ''}`;
+ const names = p.eligible
+ .map((seat) => {
+ const on = awardSel[i].has(seat);
+ return `${CHECK_SVG}${esc(nameOf(seat))} `;
+ })
+ .join('');
+ return ``;
+ })
+ .join('')
+ );
const ready = awardSel.every((set) => set.size > 0);
const split = awardSel.some((set) => set.size > 1);
$('award-confirm').disabled = !ready || pending;
@@ -580,20 +684,56 @@
return Math.min(o.maxRaiseTo, Math.max(o.minRaiseTo, Math.round(v)));
}
+ // ベット額のスライダー: つまみはつかんだ位置のまま指に付いてくる。溝を押せばそこへ飛ぶ。
+ // 端を越えて引っぱるとゴムのように抵抗し、離すと端へ戻る
+ const slider = $('raise-range');
+ const sliderThumb = slider.querySelector('.bet-thumb');
+ const sliderFill = slider.querySelector('.bet-fill');
+ const THUMB = 28;
+ const knob = new Fluid.Spring(0, { precision: 0.0005 }); // つまみの位置(0 = 最小、1 = オールイン)
+ const sliderTracker = new Fluid.VelocityTracker();
+ let sliderDrag = null;
+
+ function paintKnob(f) {
+ const w = slider.clientWidth;
+ if (!w) return;
+ const x = f * (w - THUMB);
+ sliderThumb.style.transform = `translate3d(${x.toFixed(2)}px, 0, 0)`;
+ sliderFill.style.transform = `scaleX(${Fluid.clamp((x + THUMB / 2) / w, 0, 1).toFixed(4)})`;
+ }
+ knob.onUpdate = paintKnob;
+ if ('ResizeObserver' in window) new ResizeObserver(() => paintKnob(knob.value)).observe(slider);
+
+ const fracOf = (o, v) => (o.maxRaiseTo > o.minRaiseTo ? (v - o.minRaiseTo) / (o.maxRaiseTo - o.minRaiseTo) : 1);
+ // SB 単位に丸める(最大はオールイン額そのまま)
+ function valueAt(o, f) {
+ if (f >= 1) return o.maxRaiseTo;
+ const unit = Math.max(1, state.smallBlind);
+ const raw = o.minRaiseTo + f * (o.maxRaiseTo - o.minRaiseTo);
+ return clampRaise(o, o.minRaiseTo + Math.round((raw - o.minRaiseTo) / unit) * unit);
+ }
+ const formatBB = (x) => (Math.round(x * 10) / 10).toLocaleString('en-US');
+
function renderRaisePanel(o) {
raiseValue = clampRaise(o, raiseValue);
- const range = $('raise-range');
- range.min = o.minRaiseTo;
- range.max = o.maxRaiseTo;
- range.step = 1;
- range.value = raiseValue;
const amount = $('raise-amount');
if (document.activeElement !== amount) amount.value = raiseValue;
amount.min = o.minRaiseTo;
amount.max = o.maxRaiseTo;
+ slider.setAttribute('aria-valuemin', o.minRaiseTo);
+ slider.setAttribute('aria-valuemax', o.maxRaiseTo);
+ slider.setAttribute('aria-valuenow', raiseValue);
+ slider.setAttribute('aria-valuetext', chips(raiseValue));
+ // 指で動かしている間は指の位置を優先する。それ以外(プリセット・数字の入力)はばねで滑らせる
+ if (!sliderDrag) {
+ const f = fracOf(o, raiseValue);
+ if (knob.target !== f) knob.to(f, { damping: 1, response: 0.3 });
+ }
+ paintKnob(knob.value);
for (const b of $('presets').children) {
b.classList.toggle('active', presetValue(o, b.dataset.preset) === raiseValue);
}
+ $('raise-meta').textContent = state.bigBlind > 0 ? `${formatBB(raiseValue / state.bigBlind)} BB` : '';
const allIn = raiseValue >= o.maxRaiseTo;
$('raise-confirm').textContent = allIn
? `All-in ${chips(raiseValue)}`
@@ -601,6 +741,85 @@
$('raise-confirm').disabled = pending;
}
+ function setRaise(o, v) {
+ raiseValue = clampRaise(o, v);
+ $('raise-amount').value = raiseValue;
+ renderRaisePanel(o);
+ }
+
+ function dragKnob(e, animate) {
+ const d = sliderDrag;
+ const o = options();
+ if (!d || !o) return;
+ const span = d.width - THUMB;
+ let px = e.clientX - d.left - d.grab;
+ if (px < 0) px = -Fluid.rubberband(-px, span);
+ else if (px > span) px = span + Fluid.rubberband(px - span, span);
+ const f = span > 0 ? px / span : 1;
+ if (animate) knob.to(f, { damping: 1, response: 0.25 });
+ else knob.set(f);
+ const before = raiseValue;
+ raiseValue = valueAt(o, Fluid.clamp(f, 0, 1));
+ if (raiseValue === before) return;
+ // 端(最小・オールイン)に着いた瞬間だけ軽く振動する
+ if (raiseValue === o.maxRaiseTo || raiseValue === o.minRaiseTo) Fluid.haptic(8);
+ $('raise-amount').value = raiseValue;
+ renderRaisePanel(o);
+ }
+
+ slider.addEventListener('pointerdown', (e) => {
+ const o = options();
+ if (!o || (e.pointerType === 'mouse' && e.button !== 0)) return;
+ e.preventDefault();
+ slider.focus({ preventScroll: true });
+ slider.setPointerCapture(e.pointerId);
+ knob.stop();
+ const rect = slider.getBoundingClientRect();
+ const thumbLeft = rect.left + knob.value * (rect.width - THUMB);
+ const onThumb = e.clientX >= thumbLeft - 8 && e.clientX <= thumbLeft + THUMB + 8;
+ sliderDrag = {
+ id: e.pointerId,
+ left: rect.left,
+ width: rect.width,
+ grab: onThumb ? e.clientX - thumbLeft : THUMB / 2,
+ };
+ sliderTracker.reset();
+ sliderTracker.add(e.clientX, 0, e.timeStamp);
+ slider.classList.add('active');
+ if (!onThumb) dragKnob(e, true);
+ });
+ slider.addEventListener('pointermove', (e) => {
+ if (!sliderDrag || e.pointerId !== sliderDrag.id) return;
+ sliderTracker.add(e.clientX, 0, e.timeStamp);
+ dragKnob(e, false);
+ });
+ const releaseKnob = (e) => {
+ if (!sliderDrag || e.pointerId !== sliderDrag.id) return;
+ const span = sliderDrag.width - THUMB;
+ sliderDrag = null;
+ slider.classList.remove('active');
+ const o = options();
+ if (!o) return;
+ sliderTracker.add(e.clientX, 0, e.timeStamp);
+ // 端を越えていたら、離したときの速さを引き継いで端へ戻る
+ const out = knob.value < 0 || knob.value > 1;
+ const velocity = out && span > 0 ? sliderTracker.velocity().x / span : 0;
+ knob.to(fracOf(o, raiseValue), { damping: 1, response: 0.3, velocity });
+ };
+ slider.addEventListener('pointerup', releaseKnob);
+ slider.addEventListener('pointercancel', releaseKnob);
+ slider.addEventListener('keydown', (e) => {
+ const o = options();
+ if (!o) return;
+ const unit = Math.max(1, state.bigBlind);
+ const step = { ArrowRight: unit, ArrowUp: unit, ArrowLeft: -unit, ArrowDown: -unit, PageUp: unit * 5, PageDown: -unit * 5 }[e.key];
+ if (e.key === 'Home') setRaise(o, o.minRaiseTo);
+ else if (e.key === 'End') setRaise(o, o.maxRaiseTo);
+ else if (step) setRaise(o, raiseValue + step);
+ else return;
+ e.preventDefault();
+ });
+
const options = () => (state && state.me && state.me.options) || null;
$('act-fold').addEventListener('click', async () => {
@@ -629,6 +848,7 @@
if (o.minRaiseTo >= o.maxRaiseTo) return sendAction('raise', o.maxRaiseTo); // オールインしかない
raising = true;
raiseValue = o.minRaiseTo;
+ knob.set(0);
render();
});
$('raise-cancel').addEventListener('click', () => {
@@ -642,21 +862,7 @@
$('presets').addEventListener('click', (e) => {
const b = e.target.closest('[data-preset]');
const o = options();
- if (!b || !o) return;
- raiseValue = presetValue(o, b.dataset.preset);
- $('raise-amount').value = raiseValue;
- renderRaisePanel(o);
- });
- $('raise-range').addEventListener('input', (e) => {
- const o = options();
- if (!o) return;
- // SB 単位に丸める(最大はオールイン額そのまま)
- const unit = Math.max(1, state.smallBlind);
- let v = Number(e.target.value);
- if (v < o.maxRaiseTo) v = o.minRaiseTo + Math.round((v - o.minRaiseTo) / unit) * unit;
- raiseValue = clampRaise(o, v);
- $('raise-amount').value = raiseValue;
- renderRaisePanel(o);
+ if (b && o) setRaise(o, presetValue(o, b.dataset.preset));
});
$('raise-amount').addEventListener('input', (e) => {
const o = options();
@@ -724,16 +930,17 @@
});
// iOS Safari は CSS だけではページのスクロール・引っ張り(バウンス)を止められないので、
- // 1本指のドラッグを止める。スライダーと、中身がはみ出している操作エリアは除く。
+ // 1本指のドラッグを止める。スライダーと、中身がはみ出しているシート・勝者選択は除く。
// 2本指(ピンチ拡大)はアクセシビリティのため止めない。
document.addEventListener(
'touchmove',
(e) => {
if (e.touches.length !== 1) return;
const t = e.target;
- if (t.closest && t.closest('input[type="range"]')) return;
- const bar = t.closest && (t.closest('.action-bar') || t.closest('.review-modal'));
- if (bar && bar.scrollHeight > bar.clientHeight) return;
+ if (!t.closest) return;
+ if (t.closest('.bet-slider')) return;
+ const box = t.closest('.sheet-scroll') || t.closest('.award-pots') || t.closest('.copy-text');
+ if (box && box.scrollHeight > box.clientHeight) return;
if (e.cancelable) e.preventDefault();
},
{ passive: false }
diff --git a/public/create.html b/public/create.html
index e612f6d..1b39c97 100644
--- a/public/create.html
+++ b/public/create.html
@@ -3,81 +3,136 @@
-
+
+
テーブル作成 - Hold'em Night
-
-
-
' +
+ '
';
const dialog = backdrop.firstChild;
- const msgEl = dialog.querySelector('.confirm-message');
+ const msgEl = dialog.querySelector('.alert-message');
msgEl.textContent = o.message || '';
msgEl.id = 'confirm-message-' + randomHex(4);
dialog.setAttribute('aria-describedby', msgEl.id);
if (o.title) {
- const titleEl = dialog.querySelector('.confirm-title');
+ const titleEl = dialog.querySelector('.alert-title');
titleEl.textContent = o.title;
titleEl.id = 'confirm-title-' + randomHex(4);
dialog.setAttribute('aria-labelledby', titleEl.id);
@@ -86,13 +92,24 @@
okBtn.textContent = o.ok || 'OK';
cancelBtn.textContent = o.cancel || 'キャンセル';
+ // 現れるときは少し大きい所から縮み、消えるときはその場で薄くなる
+ let closing = false;
+ const appear = new Fluid.Spring(0, { precision: 0.002 });
+ appear.onUpdate = (v) => {
+ backdrop.style.setProperty('--appear', v.toFixed(3));
+ dialog.style.opacity = v >= 1 ? '' : v.toFixed(3);
+ dialog.style.transform = closing || Fluid.reducedMotion() ? '' : `scale(${(1.12 - 0.12 * v).toFixed(4)})`;
+ };
+
const prevFocus = document.activeElement;
return new Promise((resolve) => {
function close(result) {
if (closeConfirm !== close) return;
closeConfirm = null;
+ closing = true;
document.removeEventListener('keydown', onKey, true);
- backdrop.remove();
+ backdrop.style.pointerEvents = 'none';
+ appear.to(0, { damping: 1, response: 0.2, allowReduced: true, onRest: () => backdrop.remove() });
if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus();
resolve(result);
}
@@ -109,15 +126,18 @@
okBtn.addEventListener('click', () => close(true));
cancelBtn.addEventListener('click', () => close(false));
// 背景を押したらキャンセル(ダイアログ内から背景へのドラッグは除く)
+ let downOnBackdrop = false;
backdrop.addEventListener('pointerdown', (e) => {
- backdrop.dataset.downOnBackdrop = e.target === backdrop ? '1' : '';
+ downOnBackdrop = e.target === backdrop;
});
backdrop.addEventListener('click', (e) => {
- if (e.target === backdrop && backdrop.dataset.downOnBackdrop) close(false);
+ if (e.target === backdrop && downOnBackdrop) close(false);
});
document.addEventListener('keydown', onKey, true);
closeConfirm = close;
document.body.appendChild(backdrop);
+ appear.set(0);
+ appear.to(1, { damping: 1, response: 0.3, allowReduced: true });
// 危険な操作はうっかり Enter で確定しないようキャンセルにフォーカスする
(o.danger ? cancelBtn : okBtn).focus();
});
@@ -161,24 +181,26 @@
}
function showCopyDialog(text) {
- const backdrop = document.createElement('div');
- backdrop.className = 'modal-backdrop confirm-backdrop';
- backdrop.innerHTML =
- '' +
+ (o.title ? '' : '') +
+ '' +
+ '
' +
+ '' +
+ ' ' +
+ ` ` +
'
' +
- '${full ? '観戦' : '参加'} `;
+ }
+
+ // ---------- 左へスワイプして削除(管理者) ----------
+ // 行は指に 1:1 で付いてきて、削除ボタンの幅を越えるとゴムのように抵抗する。
+ // 離したときは速度から止まる位置を先読みし、半分を越えていれば開いたまま止める
+ const ACTION_WIDTH = 88;
+ let openRow = null;
+
+ function attachSwipe(row) {
+ const content = row.querySelector('.table-row');
+ const action = row.querySelector('.swipe-action');
+ const x = new Fluid.Spring(0, { precision: 0.2 });
+ const tracker = new Fluid.VelocityTracker();
+ let drag = null;
+ let suppressClick = false;
+
+ x.onUpdate = (v) => {
+ content.style.transform = v ? `translate3d(${v.toFixed(2)}px, 0, 0)` : '';
+ row.style.setProperty('--reveal', Fluid.clamp(-v / ACTION_WIDTH, 0, 1).toFixed(3));
+ };
+ const settle = (open, velocity) => {
+ x.to(open ? -ACTION_WIDTH : 0, { damping: velocity ? 0.8 : 1, response: 0.3, velocity });
+ if (open) openRow = row;
+ else if (openRow === row) openRow = null;
+ };
+ row.swipe = { close: () => settle(false) };
+
+ content.addEventListener('pointerdown', (e) => {
+ if (!admin || (e.pointerType === 'mouse' && e.button !== 0)) return;
+ drag = { id: e.pointerId, x: e.clientX, y: e.clientY, origin: 0, active: false };
+ tracker.reset();
+ tracker.add(e.clientX, e.clientY, e.timeStamp);
+ });
+ content.addEventListener('pointermove', (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ tracker.add(e.clientX, e.clientY, e.timeStamp);
+ if (!drag.active) {
+ const dx = e.clientX - drag.x;
+ const dy = e.clientY - drag.y;
+ if (Math.hypot(dx, dy) < 10) return;
+ // 縦の動きはページのスクロールに任せる
+ if (Math.abs(dy) > Math.abs(dx)) {
+ drag = null;
+ return;
+ }
+ drag.active = true;
+ drag.x = e.clientX;
+ x.stop();
+ drag.origin = x.value;
+ content.setPointerCapture(e.pointerId);
+ if (openRow && openRow !== row) openRow.swipe.close();
+ }
+ let raw = drag.origin + (e.clientX - drag.x);
+ if (raw > 0) raw = Fluid.rubberband(raw, row.offsetWidth);
+ else if (raw < -ACTION_WIDTH) raw = -ACTION_WIDTH - Fluid.rubberband(-ACTION_WIDTH - raw, row.offsetWidth);
+ x.set(raw);
+ });
+ const end = (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ const wasActive = drag.active;
+ drag = null;
+ if (!wasActive) return;
+ suppressClick = true;
+ setTimeout(() => {
+ suppressClick = false;
+ }, 0);
+ tracker.add(e.clientX, e.clientY, e.timeStamp);
+ const v = tracker.velocity().x;
+ settle(x.value + Fluid.project(v) < -ACTION_WIDTH / 2, v);
+ };
+ content.addEventListener('pointerup', end);
+ content.addEventListener('pointercancel', end);
+ content.addEventListener(
+ 'click',
+ (e) => {
+ // スワイプの後のクリックや、開いている行のタップ(参加ボタン以外)は「閉じる」だけにする
+ const tapOnOpenRow = openRow === row && !e.target.closest('[data-join]');
+ if (!suppressClick && !tapOnOpenRow) return;
+ e.preventDefault();
+ e.stopPropagation();
+ if (!suppressClick) row.swipe.close();
+ },
+ true
+ );
+ // キーボードでは削除ボタンにフォーカスが来たら開く
+ action.addEventListener('focus', () => settle(true));
+ action.addEventListener('blur', () => settle(false));
+ }
+
+ document.addEventListener('pointerdown', (e) => {
+ if (openRow && !openRow.contains(e.target)) openRow.swipe.close();
+ });
+
+ // ---------- 一覧(行は使い回す。スワイプの途中で作り直さない) ----------
+ const rows = new Map();
+
+ function rowEl(id) {
+ let row = rows.get(id);
+ if (row) return row;
+ row = document.createElement('div');
+ row.className = 'swipe-row';
+ row.innerHTML = `${ICON.trash}削除 `;
+ attachSwipe(row);
+ rows.set(id, row);
+ return row;
}
function render(list) {
tables = list;
countEl.textContent = tables.length ? `${tables.length} テーブル` : '';
+ footerEl.hidden = !admin || !tables.length;
if (!tables.length) {
+ rows.clear();
+ openRow = null;
listEl.innerHTML = '
コピーしてください
' + + const root = document.createElement('div'); + root.className = 'sheet-backdrop'; + root.hidden = true; + root.innerHTML = + '' +
+ '閉じる ' +
'
';
- const area = backdrop.querySelector('textarea');
+ const area = root.querySelector('textarea');
area.value = text;
- const close = () => backdrop.remove();
- backdrop.querySelector('.copy-close').addEventListener('click', close);
- backdrop.addEventListener('click', (e) => {
- if (e.target === backdrop) close();
- });
- document.body.appendChild(backdrop);
- area.focus();
+ document.body.appendChild(root);
+ const sheet = Fluid.Sheet(root, { onClosed: () => root.remove() });
+ root.querySelector('.copy-close').addEventListener('click', () => sheet.close());
+ sheet.open();
+ area.focus({ preventScroll: true });
area.select();
area.scrollTop = 0; // select() で末尾までスクロールするので先頭に戻す
}
diff --git a/public/assets/create.js b/public/assets/create.js
index 36ca963..c37cb58 100644
--- a/public/assets/create.js
+++ b/public/assets/create.js
@@ -16,8 +16,8 @@
const timerInput = $('timer');
const intervalInput = $('interval');
const scheduleEl = $('schedule');
- const modeHint = $('mode-hint');
const cpuOptions = $('cpu-options');
+ const intervalRow = $('interval-row');
const blindHint = $('blind-hint');
const errorEl = $('error');
const submitBtn = $('submit');
@@ -49,7 +49,7 @@
const stack = int(stackInput);
const bb = int(bbInput);
if (blindsTouched && stack > 0 && bb > 0) {
- blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ おすすめに戻す`;
+ blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ おすすめに戻す`;
} else {
blindHint.textContent = 'スタックに合わせて自動設定(BB ≒ スタックの1/100)';
}
@@ -60,6 +60,7 @@
const bb = int(bbInput);
const on = timerInput.checked;
intervalInput.disabled = !on;
+ setOpen(intervalRow, on);
scheduleEl.hidden = !on || !(sb > 0 && bb > sb);
if (scheduleEl.hidden) return;
const mins = int(intervalInput) > 0 ? int(intervalInput) : 0;
@@ -68,18 +69,17 @@
.join('');
}
- const MODE_HINTS = {
- chips: '実物のカードで遊び、サイトはチップだけを管理。ショーダウンの勝者はホストが選ぶ',
- friends: '各自のスマホに手札が配られ、勝敗とポットの分配は自動',
- cpu: 'CPU と対戦(友達も空いている席に参加できる)。カードの配布・勝敗の判定は自動',
- review: 'あなたと CPU だけで対戦(友達は入れない)。ハンドごとにハンドレンジの色・行動の点数を表示し、対局全体の成績も振り返れる',
- };
const radioValue = (name) => (form.querySelector(`input[name="${name}"]:checked`) || {}).value;
+ // 行の開け閉め(高さはばねで変わる)。閉じている間はフォーカスも届かないようにする
+ function setOpen(el, open) {
+ el.classList.toggle('collapsed', !open);
+ el.inert = !open;
+ }
+
function updateMode() {
const mode = radioValue('mode');
- modeHint.textContent = MODE_HINTS[mode] || '';
- cpuOptions.hidden = mode !== 'cpu' && mode !== 'review';
+ setOpen(cpuOptions, mode === 'cpu' || mode === 'review');
}
function refresh() {
@@ -110,6 +110,8 @@
intervalInput.addEventListener('input', refresh);
form.querySelectorAll('input[type="radio"]').forEach((el) => el.addEventListener('change', refresh));
$('reroll').addEventListener('click', suggestName);
+ Fluid.Segmented(form.querySelector('.segmented.numbers'));
+ Fluid.navBar($('nav'), $('large-title'));
function validate() {
const name = nameInput.value.trim();
diff --git a/public/assets/fluid.js b/public/assets/fluid.js
new file mode 100644
index 0000000..48fb574
--- /dev/null
+++ b/public/assets/fluid.js
@@ -0,0 +1,542 @@
+// 動きと手ざわりの共通部品。
+// Apple の "Designing Fluid Interfaces"(WWDC 2018)の考え方をウェブで使える形にしたもの:
+// - Spring: 減衰比(damping)と応答(response, 秒)で決めるばね。途中で目標が変わっても、
+// 画面上のいまの位置と速度から続くので、動いている最中につかんだり戻したりできる
+// - VelocityTracker / project / rubberband: 指の速度・勢いから止まる位置の先読み・端での抵抗
+// - Sheet: 下から出るシート(つまみを下へ払うと閉じる)
+// - Segmented: つまみが滑るセグメント(つまみをドラッグしても選べる)
+// - presence / countTo / shake / haptic / navBar: 出し入れ・数字の変化・揺れ・振動・ナビゲーションバー
+// CSP でインラインスタイルを禁止しているので、動きはすべて CSSOM(el.style)で付ける
+(function () {
+ 'use strict';
+
+ const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
+ const reducedMotion = () => motionQuery.matches;
+ const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
+
+ // ---------- ばね ----------
+ const running = new Set();
+ let frame = 0;
+ let lastTime = 0;
+
+ function tick(now) {
+ // タブが裏にあった後などに大きく飛ばないよう、1フレームは最大 50ms として進める
+ const dt = clamp((now - lastTime) / 1000, 0, 0.05);
+ lastTime = now;
+ for (const s of [...running]) s.step(dt);
+ frame = running.size ? requestAnimationFrame(tick) : 0;
+ }
+
+ class Spring {
+ // opts: { damping, response, precision(止まったとみなす差), onUpdate(value) }
+ constructor(value = 0, opts = {}) {
+ this.value = value;
+ this.target = value;
+ this.velocity = 0;
+ this.precision = opts.precision || 0.01;
+ this.onUpdate = opts.onUpdate || null;
+ this.onRest = null;
+ this.configure(opts.damping ?? 1, opts.response ?? 0.4);
+ }
+
+ // 質量 1 のばねに直す: 硬さ = (2π / response)²、抵抗 = 4π · damping / response
+ // damping 1 = 行き過ぎずに止まる、1 未満 = 少し弾む
+ configure(damping, response) {
+ this.damping = damping;
+ this.response = response;
+ this.stiffness = ((2 * Math.PI) / response) ** 2;
+ this.friction = (4 * Math.PI * damping) / response;
+ }
+
+ get animating() {
+ return running.has(this);
+ }
+
+ // 目標へ動かす。いまの値と速度から続ける(velocity を渡すと指の速さを引き継ぐ)。
+ // 視差効果を減らす設定のときはすぐに目標の値にする(allowReduced: 透明度など、そのままでよい動き)
+ to(target, opts = {}) {
+ if (opts.damping != null || opts.response != null) {
+ this.configure(opts.damping ?? this.damping, opts.response ?? this.response);
+ }
+ if (opts.velocity != null) this.velocity = opts.velocity;
+ this.target = target;
+ this.onRest = opts.onRest || null;
+ if (reducedMotion() && !opts.allowReduced) {
+ this.set(target);
+ this.rest();
+ return this;
+ }
+ running.add(this);
+ if (!frame) {
+ lastTime = performance.now();
+ frame = requestAnimationFrame(tick);
+ }
+ return this;
+ }
+
+ // すぐにその値にする(ドラッグ中に指へ 1:1 で付いていくときもこれ)
+ set(value) {
+ running.delete(this);
+ this.value = this.target = value;
+ this.velocity = 0;
+ if (this.onUpdate) this.onUpdate(value);
+ return this;
+ }
+
+ // 画面上のいまの位置で止める(動いている最中につかんだとき)
+ stop() {
+ running.delete(this);
+ this.target = this.value;
+ this.velocity = 0;
+ this.onRest = null;
+ return this;
+ }
+
+ step(dt) {
+ // 硬いばねでも発散しないよう 1/240 秒刻みで積分する
+ const n = Math.max(1, Math.ceil(dt * 240));
+ const h = dt / n;
+ for (let i = 0; i < n; i++) {
+ const force = -this.stiffness * (this.value - this.target) - this.friction * this.velocity;
+ this.velocity += force * h;
+ this.value += this.velocity * h;
+ }
+ const settled =
+ Math.abs(this.value - this.target) < this.precision && Math.abs(this.velocity) < this.precision * 10;
+ if (settled) {
+ this.value = this.target;
+ this.velocity = 0;
+ running.delete(this);
+ }
+ if (this.onUpdate) this.onUpdate(this.value);
+ if (settled) this.rest();
+ }
+
+ rest() {
+ const fn = this.onRest;
+ this.onRest = null;
+ if (fn) fn();
+ }
+ }
+
+ // ---------- 指の動き ----------
+ // 直近 100ms の位置から速度(px/秒)を求める。離す直前に指を止めていれば速度は 0 になる
+ class VelocityTracker {
+ constructor() {
+ this.samples = [];
+ }
+ reset() {
+ this.samples.length = 0;
+ }
+ add(x, y, t = performance.now()) {
+ this.samples.push({ x, y, t });
+ while (this.samples.length > 2 && t - this.samples[0].t > 100) this.samples.shift();
+ }
+ velocity() {
+ const s = this.samples;
+ if (s.length < 2) return { x: 0, y: 0 };
+ const a = s[0];
+ const b = s[s.length - 1];
+ const dt = (b.t - a.t) / 1000;
+ return dt > 0 ? { x: (b.x - a.x) / dt, y: (b.y - a.y) / dt } : { x: 0, y: 0 };
+ }
+ }
+
+ // 勢いで進む距離(スクロールの減速と同じ指数関数の形)。0.998 = 普通、0.99 = 早く止まる
+ function project(velocity, decelerationRate = 0.998) {
+ return ((velocity / 1000) * decelerationRate) / (1 - decelerationRate);
+ }
+
+ // 端を越えた分は、越えるほど付いてこなくなる(いきなり止めない)
+ function rubberband(overshoot, dimension, constant = 0.55) {
+ return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
+ }
+
+ const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), textarea, select, [tabindex]:not([tabindex="-1"])';
+
+ function trapFocus(box, e) {
+ const list = [...box.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null);
+ if (!list.length) return;
+ const first = list[0];
+ const last = list[list.length - 1];
+ if (!box.contains(document.activeElement)) {
+ e.preventDefault();
+ first.focus();
+ } else if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ }
+
+ // ---------- シート ----------
+ // root は背景(.sheet-backdrop)、中に .sheet。
+ // [data-sheet-drag](つまみ・見出し)をつかむと指に 1:1 で付いてくる。離したときは速度から止まる位置を
+ // 先読みし、シートの半分より下なら閉じる(閉じる途中でつかみ直すこともできる)。
+ // opts: { dismissible(false なら閉じられない), focus(開いたときにフォーカスする要素のセレクタ), onClose, onClosed }
+ function Sheet(root, opts = {}) {
+ const sheet = root.querySelector('.sheet');
+ const dismissible = opts.dismissible !== false;
+ const y = new Spring(0, { precision: 0.5 });
+ const tracker = new VelocityTracker();
+ let height = 0;
+ let isOpen = false;
+ let prevFocus = null;
+ let drag = null;
+ let downOnBackdrop = false;
+
+ const measure = () => {
+ height = sheet.offsetHeight + 24; // 影まで画面の外へ出す
+ };
+
+ y.onUpdate = (v) => {
+ sheet.style.transform = v ? `translate3d(0, ${v.toFixed(2)}px, 0)` : '';
+ root.style.setProperty('--sheet-progress', height ? clamp(1 - v / height, 0, 1).toFixed(3) : '1');
+ };
+
+ function onKey(e) {
+ if (e.key === 'Escape' && dismissible) {
+ e.preventDefault();
+ close();
+ } else if (e.key === 'Tab') {
+ trapFocus(sheet, e);
+ }
+ }
+
+ function open() {
+ if (isOpen) return;
+ isOpen = true;
+ if (root.hidden) {
+ root.hidden = false;
+ measure();
+ y.set(height);
+ }
+ y.to(0, { damping: 1, response: 0.38 });
+ prevFocus = document.activeElement;
+ document.addEventListener('keydown', onKey);
+ const target = opts.focus ? sheet.querySelector(opts.focus) : null;
+ (target || sheet).focus({ preventScroll: true });
+ }
+
+ function close(velocity) {
+ if (!isOpen) return;
+ isOpen = false;
+ document.removeEventListener('keydown', onKey);
+ measure();
+ y.to(height, {
+ damping: 1,
+ response: 0.3,
+ velocity: typeof velocity === 'number' ? velocity : undefined,
+ onRest: () => {
+ if (isOpen) return;
+ root.hidden = true;
+ if (opts.onClosed) opts.onClosed();
+ },
+ });
+ if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus({ preventScroll: true });
+ if (opts.onClose) opts.onClose();
+ }
+
+ root.addEventListener('pointerdown', (e) => {
+ downOnBackdrop = e.target === root;
+ });
+ root.addEventListener('click', (e) => {
+ if (dismissible && e.target === root && downOnBackdrop) close();
+ });
+
+ sheet.addEventListener('pointerdown', (e) => {
+ if (!dismissible || root.hidden || (e.pointerType === 'mouse' && e.button !== 0)) return;
+ if (!e.target.closest('[data-sheet-drag]')) return;
+ drag = { id: e.pointerId, x: e.clientX, y: e.clientY, origin: 0, active: false };
+ tracker.reset();
+ tracker.add(e.clientX, e.clientY, e.timeStamp);
+ });
+ sheet.addEventListener('pointermove', (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ tracker.add(e.clientX, e.clientY, e.timeStamp);
+ if (!drag.active) {
+ const dx = e.clientX - drag.x;
+ const dy = e.clientY - drag.y;
+ if (Math.hypot(dx, dy) < 8) return;
+ if (Math.abs(dx) > Math.abs(dy)) {
+ drag = null;
+ return;
+ }
+ // ここで初めてつかむ。動いている最中なら画面上のいまの位置から
+ drag.active = true;
+ drag.y = e.clientY;
+ y.stop();
+ drag.origin = y.value;
+ measure();
+ sheet.setPointerCapture(e.pointerId);
+ sheet.classList.add('dragging');
+ if (!isOpen) {
+ isOpen = true;
+ document.addEventListener('keydown', onKey);
+ }
+ }
+ const raw = drag.origin + (e.clientY - drag.y);
+ y.set(raw < 0 ? -rubberband(-raw, height) : raw);
+ });
+ const end = (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ const wasActive = drag.active;
+ drag = null;
+ sheet.classList.remove('dragging');
+ if (!wasActive) return;
+ tracker.add(e.clientX, e.clientY, e.timeStamp);
+ const v = tracker.velocity().y;
+ if (e.type !== 'pointercancel' && y.value + project(v) > height * 0.5) close(v);
+ // 払った勢いがあるので少しだけ弾ませて戻す
+ else y.to(0, { damping: 0.8, response: 0.3, velocity: v });
+ };
+ sheet.addEventListener('pointerup', end);
+ sheet.addEventListener('pointercancel', end);
+
+ return {
+ open,
+ close,
+ get isOpen() {
+ return isOpen;
+ },
+ };
+ }
+
+ // ---------- セグメント ----------
+ // container の子(label > input[type=radio] か、.active の付いた button)を選ぶつまみを滑らせる。
+ // 選ばれている所を押すとつまみが少し縮み、そのまま横へドラッグすると指に付いてくる。
+ // 離すと速度から止まる位置を先読みして、一番近い選択肢を選ぶ
+ function Segmented(container) {
+ const thumb = document.createElement('span');
+ thumb.className = 'segmented-thumb';
+ thumb.setAttribute('aria-hidden', 'true');
+ container.prepend(thumb);
+ container.classList.add('has-thumb');
+
+ const x = new Spring(0, { precision: 0.1 });
+ const w = new Spring(0, { precision: 0.1 });
+ const press = new Spring(1, { precision: 0.001 });
+ const tracker = new VelocityTracker();
+ let placed = false;
+ let drag = null;
+ let suppressClick = false;
+
+ const paint = () => {
+ thumb.style.width = `${w.value.toFixed(2)}px`;
+ thumb.style.transform = `translate3d(${x.value.toFixed(2)}px, 0, 0) scale(${press.value.toFixed(4)})`;
+ };
+ x.onUpdate = w.onUpdate = press.onUpdate = paint;
+
+ const items = () => [...container.children].filter((el) => el !== thumb && !el.hidden);
+ const isSelected = (el) =>
+ el.matches('label') ? Boolean(el.querySelector('input:checked')) : el.classList.contains('active');
+ const center = (el) => el.offsetLeft + el.offsetWidth / 2;
+
+ function sync(animate = true) {
+ if (drag && drag.active) return;
+ const el = items().find(isSelected);
+ thumb.hidden = !el;
+ if (!el || !el.offsetWidth) return; // 非表示の間は測れない(表示されたら ResizeObserver で合わせる)
+ if (animate && placed) {
+ if (x.target !== el.offsetLeft) x.to(el.offsetLeft, { damping: 1, response: 0.32 });
+ if (w.target !== el.offsetWidth) w.to(el.offsetWidth, { damping: 1, response: 0.32 });
+ } else {
+ x.set(el.offsetLeft);
+ w.set(el.offsetWidth);
+ }
+ placed = true;
+ }
+
+ function select(el) {
+ if (isSelected(el)) return;
+ if (el.matches('label')) {
+ const input = el.querySelector('input');
+ input.checked = true;
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ } else {
+ el.click();
+ }
+ }
+
+ container.addEventListener('pointerdown', (e) => {
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
+ const el = items().find((i) => i.contains(e.target));
+ if (!el || !isSelected(el)) return;
+ drag = { id: e.pointerId, x: e.clientX, origin: 0, active: false };
+ tracker.reset();
+ tracker.add(e.clientX, 0, e.timeStamp);
+ press.to(0.95, { damping: 1, response: 0.2 });
+ });
+ container.addEventListener('pointermove', (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ tracker.add(e.clientX, 0, e.timeStamp);
+ if (!drag.active) {
+ if (Math.abs(e.clientX - drag.x) < 6) return;
+ drag.active = true;
+ drag.x = e.clientX;
+ x.stop();
+ drag.origin = x.value;
+ container.setPointerCapture(e.pointerId);
+ }
+ const list = items();
+ const last = list[list.length - 1];
+ const min = list[0].offsetLeft;
+ const max = last.offsetLeft + last.offsetWidth - w.value;
+ let raw = drag.origin + (e.clientX - drag.x);
+ if (raw < min) raw = min - rubberband(min - raw, container.offsetWidth);
+ else if (raw > max) raw = max + rubberband(raw - max, container.offsetWidth);
+ x.set(raw);
+ });
+ const end = (e) => {
+ if (!drag || e.pointerId !== drag.id) return;
+ const wasActive = drag.active;
+ drag = null;
+ press.to(1, { damping: 1, response: 0.25 });
+ if (!wasActive) return;
+ suppressClick = true;
+ setTimeout(() => {
+ suppressClick = false;
+ }, 0);
+ tracker.add(e.clientX, 0, e.timeStamp);
+ const v = tracker.velocity().x;
+ const aim = x.value + w.value / 2 + project(v, 0.99);
+ const best = items().reduce((a, b) => (Math.abs(center(b) - aim) < Math.abs(center(a) - aim) ? b : a));
+ x.to(best.offsetLeft, { damping: 0.8, response: 0.3, velocity: v });
+ w.to(best.offsetWidth, { damping: 1, response: 0.3 });
+ select(best);
+ };
+ container.addEventListener('pointerup', end);
+ container.addEventListener('pointercancel', end);
+ // ドラッグで選んだ後のクリックで、指を離した所の選択肢まで選ばれないように
+ container.addEventListener(
+ 'click',
+ (e) => {
+ if (!suppressClick) return;
+ e.preventDefault();
+ e.stopPropagation();
+ },
+ true
+ );
+ container.addEventListener('change', () => sync());
+ // ボタンの .active はクリックの処理で付け替わるので、描画の直前に合わせる
+ container.addEventListener('click', () => requestAnimationFrame(() => sync()));
+ if ('ResizeObserver' in window) new ResizeObserver(() => sync(false)).observe(container);
+ sync(false);
+
+ return { update: sync };
+ }
+
+ // ---------- 出し入れ ----------
+ // 要素を少し小さく透明な所からばねで出し、消すときは同じ道を戻る(hidden は消え終わってから付ける)。
+ // from: { scale, y }。視差効果を減らす設定では透明度だけ変える
+ function presence(el, show, from = {}) {
+ let p = el._presence;
+ if (!p) {
+ p = el._presence = new Spring(show ? 1 : 0, { precision: 0.002 });
+ el.hidden = !show;
+ }
+ const scale = from.scale ?? 0.96;
+ const dy = from.y ?? 8;
+ p.onUpdate = (v) => {
+ el.style.opacity = v >= 1 ? '' : v.toFixed(3);
+ el.style.transform =
+ v >= 1 || reducedMotion()
+ ? ''
+ : `translate3d(0, ${((1 - v) * dy).toFixed(2)}px, 0) scale(${(scale + (1 - scale) * v).toFixed(4)})`;
+ };
+ if (show) {
+ if (el.hidden) {
+ el.hidden = false;
+ p.set(0);
+ }
+ if (p.target !== 1) p.to(1, { damping: 1, response: 0.32, allowReduced: true });
+ } else if (!el.hidden && p.target !== 0) {
+ p.to(0, {
+ damping: 1,
+ response: 0.22,
+ allowReduced: true,
+ onRest: () => {
+ el.hidden = true;
+ },
+ });
+ }
+ }
+
+ // 数字をばねで変える(ポット・スタックなど)
+ function countTo(el, value, format) {
+ const show = (v) => {
+ el.textContent = format(Math.round(v));
+ };
+ let s = el._count;
+ if (!s) {
+ s = el._count = new Spring(value, { precision: 0.5 });
+ s.onUpdate = show;
+ show(value);
+ return;
+ }
+ s.onUpdate = show;
+ if (s.target !== value) s.to(value, { damping: 1, response: 0.5 });
+ }
+
+ // 横に揺らす(鍵の入力ミスなど)。勢いを与えて、減衰するばねに任せる
+ function shake(el) {
+ if (reducedMotion()) return;
+ let s = el._shake;
+ if (!s) {
+ s = el._shake = new Spring(0, { precision: 0.05 });
+ s.onUpdate = (v) => {
+ el.style.transform = v ? `translate3d(${v.toFixed(2)}px, 0, 0)` : '';
+ };
+ }
+ s.to(0, { damping: 0.22, response: 0.26, velocity: -320 });
+ }
+
+ // 振動(対応している端末だけ。画面に触れる前は振動できない)
+ function haptic(pattern = 10) {
+ try {
+ const touched = !navigator.userActivation || navigator.userActivation.hasBeenActive;
+ if (navigator.vibrate && touched) navigator.vibrate(pattern);
+ } catch {
+ /* 振動できない環境 */
+ }
+ }
+
+ // ナビゲーションバー: 中身が下に潜り込んだら半透明の素材と境目のぼかしを出し、
+ // 大きな見出しが隠れたらバーの中に小さな見出しを出す
+ function navBar(nav, largeTitle) {
+ if (!nav) return;
+ const update = () => nav.classList.toggle('scrolled', window.scrollY > 2);
+ update();
+ window.addEventListener('scroll', update, { passive: true });
+ const inline = nav.querySelector('.nav-title');
+ if (!largeTitle || !inline) return;
+ const mirror = () => {
+ inline.textContent = largeTitle.textContent;
+ };
+ mirror();
+ new MutationObserver(mirror).observe(largeTitle, { childList: true, characterData: true, subtree: true });
+ if ('IntersectionObserver' in window) {
+ new IntersectionObserver(([entry]) => nav.classList.toggle('show-title', !entry.isIntersecting), {
+ rootMargin: `-${nav.offsetHeight}px 0px 0px 0px`,
+ }).observe(largeTitle);
+ }
+ }
+
+ window.Fluid = {
+ Spring,
+ VelocityTracker,
+ project,
+ rubberband,
+ clamp,
+ reducedMotion,
+ Sheet,
+ Segmented,
+ presence,
+ countTo,
+ shake,
+ haptic,
+ navBar,
+ };
+})();
diff --git a/public/assets/key.js b/public/assets/key.js
index 23d4918..53f3070 100644
--- a/public/assets/key.js
+++ b/public/assets/key.js
@@ -16,12 +16,18 @@
const value = () => inputs.map((i) => i.value).join('');
+ // 数字が入った枠は小さく弾む(空に戻ったら印を外す)
+ function markFilled() {
+ for (const i of inputs) i.classList.toggle('filled', Boolean(i.value));
+ }
+
function fill(digits, from) {
let i = from;
for (const d of digits) {
if (i >= inputs.length) break;
inputs[i++].value = d;
}
+ markFilled();
inputs[Math.min(i, inputs.length - 1)].focus();
if (value().length === 6) submit();
}
@@ -31,11 +37,13 @@
const digits = input.value.replace(/\D/g, '');
input.value = '';
if (digits) fill(digits, idx);
+ else markFilled();
errorEl.textContent = '';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !input.value && idx > 0) {
inputs[idx - 1].value = '';
+ markFilled();
inputs[idx - 1].focus();
e.preventDefault();
} else if (e.key === 'ArrowLeft' && idx > 0) {
@@ -68,10 +76,11 @@
location.replace(App.safeNext(next));
} catch (err) {
errorEl.textContent = err.message;
- box.classList.remove('shake');
- void box.offsetWidth;
- box.classList.add('shake');
+ // 揺れと振動は同じ瞬間に(どちらも「違う」の合図)
+ Fluid.shake(box);
+ Fluid.haptic([18, 50, 18]);
inputs.forEach((i) => { i.value = ''; });
+ markFilled();
inputs[0].focus();
} finally {
busy = false;
diff --git a/public/assets/lobby.js b/public/assets/lobby.js
index a12a59d..40093dc 100644
--- a/public/assets/lobby.js
+++ b/public/assets/lobby.js
@@ -6,6 +6,9 @@
const nameError = document.getElementById('name-error');
const listEl = document.getElementById('list');
const countEl = document.getElementById('count');
+ const footerEl = document.getElementById('list-footer');
+
+ Fluid.navBar(document.getElementById('nav'), document.getElementById('large-title'));
nameInput.value = Store.name;
nameInput.addEventListener('input', () => {
@@ -25,13 +28,12 @@
let admin = false;
let tables = null;
- // 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除ボタンを出す
+ // 管理者だけにハンドレンジ学習・CPU戦のレビューへのリンクとテーブルの削除(左へスワイプ)を出す
// (ページ・API 自体もサーバ側で管理者以外は 404)
App.api('GET', '/api/me')
.then((me) => {
admin = Boolean(me.admin);
- document.getElementById('range-link').hidden = !admin;
- document.getElementById('review-link').hidden = !admin;
+ document.getElementById('admin-group').hidden = !admin;
if (tables) render(tables);
})
.catch(() => {});
@@ -58,56 +60,205 @@
ok: '削除する',
danger: true,
});
- if (!ok || !document.contains(btn)) return;
+ const row = btn.closest('.swipe-row');
+ if (!ok || !document.contains(btn)) {
+ if (row && row.swipe) row.swipe.close();
+ return;
+ }
btn.disabled = true;
App.api('DELETE', '/api/tables/' + encodeURIComponent(id))
.then(() => App.toast('テーブルを削除しました'))
.catch((err) => {
btn.disabled = false;
+ if (row && row.swipe) row.swipe.close();
App.toast(err.message);
});
}
- const MODE_LABEL = { chips: '💰 キャッシュオンリー', friends: '🃏 友人戦', cpu: '🤖 CPU戦', review: '📝 レビュー付きCPU戦' };
+ const ICON = {
+ chips:
+ '',
+ cards:
+ '',
+ cpu:
+ '',
+ review:
+ '',
+ person: '',
+ clock: '',
+ trash:
+ '',
+ };
- function phaseBadge(t) {
- if (t.paused) return 'PAUSED';
- if (t.phase === 'WAITING') return 'WAITING';
- return 'PLAYING';
+ const MODE = {
+ chips: { label: 'キャッシュオンリー', icon: 'chips', tone: 'green' },
+ friends: { label: '友人戦', icon: 'cards', tone: 'blue' },
+ cpu: { label: 'CPU戦', icon: 'cpu', tone: 'indigo' },
+ review: { label: 'レビュー付きCPU戦', icon: 'review', tone: 'orange' },
+ };
+
+ function statusHtml(t) {
+ if (t.paused) return '一時停止中';
+ if (t.phase === 'WAITING') return '待機中';
+ return 'プレイ中';
+ }
+
+ function rowHtml(t) {
+ const m = MODE[t.mode] || MODE.chips;
+ const full = t.playerCount >= t.maxSeats;
+ const timer = t.timerEnabled ? `${t.intervalMin}分` : 'なし';
+ return `
+ ${ICON[m.icon]}
+
+ ${esc(t.name)}
+ ${statusHtml(t)}${m.label}
+
+ ${ICON.person}${t.playerCount}/${t.maxSeats}
+ Lv.${t.level} ${chips(t.smallBlind)}/${chips(t.bigBlind)}
+ 開始 ${chips(t.startingStack)}
+ ${ICON.clock}${timer}
+
+
+ ' +
+ '
' +
+ 'コピーしてください
' +
'閉じる ' +
+ '
' +
+ '自動でコピーできませんでした。下の文章を全選択してコピーしてください。
' + '' + - '開催中のテーブルはありません。
テーブルを作成して友達を招待しよう。
';
return;
}
- listEl.innerHTML = tables
- .map((t) => {
- const full = t.playerCount >= t.maxSeats;
- const timer = t.timerEnabled ? `⏱ ${t.intervalMin}分` : '⏱ なし';
- return `
- テーブルを作成して友達を招待しよう。
-
`;
- })
- .join('');
+ const empty = listEl.querySelector('.empty');
+ if (empty) empty.remove();
+ const ids = new Set(tables.map((t) => t.id));
+ for (const [id, row] of rows) {
+ if (ids.has(id)) continue;
+ if (openRow === row) openRow = null;
+ row.remove();
+ rows.delete(id);
+ }
+ tables.forEach((t, i) => {
+ const row = rowEl(t.id);
+ const html = rowHtml(t);
+ const content = row.querySelector('.table-row');
+ if (content.html !== html) {
+ content.html = html;
+ content.innerHTML = html;
+ }
+ const action = row.querySelector('.swipe-action');
+ action.hidden = !admin;
+ action.tabIndex = admin ? 0 : -1;
+ if (listEl.children[i] !== row) listEl.insertBefore(row, listEl.children[i] || null);
+ });
}
const socket = App.connectSocket();
diff --git a/public/assets/style.css b/public/assets/style.css
index df3b424..abc95e0 100644
--- a/public/assets/style.css
+++ b/public/assets/style.css
@@ -1,319 +1,1453 @@
+/* Hold'em Night のデザインの土台。
+ Apple の考え方で組んでいる: 意味で決める色(light / dark の両方)、半透明の素材で階層を表す、
+ 大きさごとに字間と行間を変える、押した瞬間に反応する、動きはばね(JS は fluid.js、CSS は --ease-spring)。
+ CSP でインラインスタイルを禁止しているので、動的な値は JS から CSSOM で入れる */
+
+/* ---------- 色と素材 ---------- */
:root {
- --bg: #24493b;
- --bg-deep: #1b3a2e;
- --panel: #1a2620;
- --panel-2: #26332c;
- --input: #303a35;
- --line: #3a4a42;
- --text: #f2f5f3;
- --muted: #9fb2a8;
- --felt: #2ecc71;
- --felt-dark: #23a85b;
- --gold: #f6c744;
- --orange: #f0a43a;
- --blue: #4a90e2;
- --green: #2ecc71;
- --red: #ff5a5a;
- --radius: 14px;
- color-scheme: dark;
+ color-scheme: light dark;
+
+ /* 背景(グループ表示): 画面 → セル → セルの中 */
+ --bg: #f2f2f7;
+ --bg-2: #ffffff;
+ --bg-3: #f2f2f7;
+ --sheet-bg: #f2f2f7;
+ --sheet-cell: #ffffff;
+
+ /* 文字(主 → 補足 → 控えめ) */
+ --label: #000000;
+ --label-2: rgba(60, 60, 67, 0.6);
+ --label-3: rgba(60, 60, 67, 0.3);
+ --separator: rgba(60, 60, 67, 0.29);
+
+ /* 塗り(濃い → 薄い) */
+ --fill: rgba(120, 120, 128, 0.2);
+ --fill-2: rgba(120, 120, 128, 0.16);
+ --fill-3: rgba(118, 118, 128, 0.12);
+
+ /* アクセント(フェルトの緑)と意味のある色 */
+ --tint: #0b8549;
+ --on-tint: #ffffff;
+ --tint-soft: rgba(11, 133, 73, 0.12);
+ --tint-glow: rgba(11, 133, 73, 0.22);
+ --blue: #007aff;
+ --orange: #ff9500;
+ --on-orange: rgba(0, 0, 0, 0.86);
+ --red: #ff3b30;
+ --green: #248a3d;
+ --indigo: #5856d6;
+ --purple: #af52de;
+ --gold: #a26f00;
+
+ /* 半透明の素材。大きい面ほど厚く(不透明に)する */
+ --material-thin: rgba(255, 255, 255, 0.55);
+ --material: rgba(250, 250, 252, 0.74);
+ --material-thick: rgba(246, 246, 249, 0.86);
+ --material-edge: rgba(255, 255, 255, 0.7);
+ --plate: rgba(255, 255, 255, 0.84);
+ --scrim: rgba(0, 0, 0, 0.3);
+ --thumb: #ffffff;
+
+ --shadow-s: 0 1px 2px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.06);
+ --shadow-m: 0 6px 18px rgba(0, 0, 0, 0.1), 0 1px 4px rgba(0, 0, 0, 0.06);
+ --shadow-l: 0 24px 60px rgba(0, 0, 0, 0.2), 0 6px 16px rgba(0, 0, 0, 0.08);
+
+ /* プレイ画面の背景(テーブルを上から照らすライト) */
+ --room: radial-gradient(120% 70% at 50% 40%, #ffffff 0%, #eceef1 55%, #e1e4e8 100%);
+
+ /* フェルトとチップ(どちらの外観でも同じ。テーブルという「物」の色) */
+ --felt-hi: #2c9e64;
+ --felt: #1a7b4c;
+ --felt-lo: #0d4f33;
+ --chip: #ffd25e;
+ --on-chip: #2b1d00;
+
+ /* 文字 */
+ --font: -apple-system, BlinkMacSystemFont, system-ui, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP",
+ "Yu Gothic UI", "Meiryo", "Segoe UI", sans-serif;
+ --font-num: ui-rounded, "SF Pro Rounded", -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", sans-serif;
+
+ /* ばね(減衰比 1・応答 0.4秒 ≒ 0.5秒)と、少し弾むばね(減衰比 0.75)。linear() が使えない環境は近い曲線 */
+ --ease-spring: cubic-bezier(0.2, 0.8, 0.2, 1);
+ --ease-bounce: cubic-bezier(0.34, 1.3, 0.64, 1);
+ --ease-out-mirror: cubic-bezier(0.8, 0, 0.8, 0.2);
}
-* { box-sizing: border-box; }
+@supports (transition-timing-function: linear(0, 1)) {
+ :root {
+ --ease-spring: linear(0, 0.06 5%, 0.186 10%, 0.329 15%, 0.465 20%, 0.584 25%, 0.682 30%, 0.76 35%, 0.821 40%,
+ 0.868 45%, 0.903 50%, 0.929 55%, 0.949 60%, 0.963 65%, 0.973 70%, 0.981 75%, 0.986 80%, 0.99 85%, 0.993 90%,
+ 0.995 95%, 1);
+ --ease-bounce: linear(0, 0.087 5%, 0.273 10%, 0.478 15%, 0.661 20%, 0.804 25%, 0.905 30%, 0.97 35%, 1.007 40%,
+ 1.024 45%, 1.028 50%, 1.026 55%, 1.021 60%, 1.01 70%, 1.003 80%, 1);
+ }
+}
-html, body {
- margin: 0;
- min-height: 100%;
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #000000;
+ --bg-2: #1c1c1e;
+ --bg-3: #2c2c2e;
+ --sheet-bg: #1c1c1e;
+ --sheet-cell: #2c2c2e;
+ --label: #ffffff;
+ --label-2: rgba(235, 235, 245, 0.6);
+ --label-3: rgba(235, 235, 245, 0.3);
+ --separator: rgba(84, 84, 88, 0.6);
+ --fill: rgba(120, 120, 128, 0.36);
+ --fill-2: rgba(120, 120, 128, 0.32);
+ --fill-3: rgba(118, 118, 128, 0.24);
+ --tint: #26b05c;
+ --tint-soft: rgba(48, 209, 88, 0.16);
+ --tint-glow: rgba(48, 209, 88, 0.22);
+ --blue: #0a84ff;
+ --orange: #ff9f0a;
+ --red: #ff453a;
+ --green: #30d158;
+ --indigo: #5e5ce6;
+ --purple: #bf5af2;
+ --gold: #ffd25e;
+ --material-thin: rgba(44, 44, 46, 0.5);
+ --material: rgba(30, 30, 32, 0.7);
+ --material-thick: rgba(24, 24, 26, 0.84);
+ --material-edge: rgba(255, 255, 255, 0.09);
+ --plate: rgba(28, 28, 30, 0.8);
+ --scrim: rgba(0, 0, 0, 0.5);
+ --thumb: #636366;
+ --shadow-s: 0 1px 2px rgba(0, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.25);
+ --shadow-m: 0 8px 22px rgba(0, 0, 0, 0.4), 0 1px 4px rgba(0, 0, 0, 0.3);
+ --shadow-l: 0 24px 60px rgba(0, 0, 0, 0.6), 0 6px 16px rgba(0, 0, 0, 0.35);
+ --room: radial-gradient(120% 70% at 50% 40%, #13241c 0%, #070c0a 58%, #000000 100%);
+ }
+}
+
+/* 透明度を下げる設定: 素材をすりガラスから不透明に */
+@media (prefers-reduced-transparency: reduce) {
+ :root {
+ --material-thin: #f2f2f7;
+ --material: #f7f7f9;
+ --material-thick: #f2f2f7;
+ --plate: #ffffff;
+ }
+}
+@media (prefers-reduced-transparency: reduce) and (prefers-color-scheme: dark) {
+ :root {
+ --material-thin: #2c2c2e;
+ --material: #1c1c1e;
+ --material-thick: #1c1c1e;
+ --plate: #1c1c1e;
+ }
+}
+
+/* コントラストを上げる設定: 補足の文字と境界をはっきり */
+@media (prefers-contrast: more) {
+ :root {
+ --label-2: rgba(60, 60, 67, 0.85);
+ --label-3: rgba(60, 60, 67, 0.6);
+ --separator: rgba(60, 60, 67, 0.6);
+ --material-edge: rgba(0, 0, 0, 0.35);
+ }
+}
+@media (prefers-contrast: more) and (prefers-color-scheme: dark) {
+ :root {
+ --label-2: rgba(235, 235, 245, 0.85);
+ --label-3: rgba(235, 235, 245, 0.6);
+ --separator: rgba(235, 235, 245, 0.45);
+ --material-edge: rgba(255, 255, 255, 0.45);
+ }
+}
+
+/* ---------- 基本 ---------- */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
background: var(--bg);
- color: var(--text);
- font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", "Segoe UI", sans-serif;
+}
+
+body {
+ margin: 0;
+ min-height: 100dvh;
+ background: var(--bg);
+ color: var(--label);
+ font-family: var(--font);
+ font-size: 1.0625rem;
+ line-height: 1.47;
+ letter-spacing: -0.005em;
+ font-optical-sizing: auto;
-webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
+ /* 外観(ライト・ダーク)の切り替えは急に明るさを変えない */
+ transition: background-color 0.3s ease, color 0.3s ease;
}
-body { min-height: 100dvh; }
-
-.wrap {
- max-width: 480px;
- margin: 0 auto;
- padding: 24px 16px 40px;
+h1,
+h2,
+h3,
+p {
+ margin: 0;
}
-h1, h2, h3 { margin: 0; }
-
-.logo {
- text-align: center;
- font-weight: 900;
- line-height: 0.95;
- letter-spacing: -0.02em;
- font-size: 48px;
- margin: 28px 0 32px;
-}
-.logo small {
- display: block;
- font-size: 13px;
- font-weight: 700;
- letter-spacing: 0.3em;
- color: var(--gold);
- margin-top: 10px;
-}
-
-.section-title {
- font-size: 18px;
- font-weight: 800;
- margin: 28px 0 12px;
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-.section-title .sub { font-size: 12px; font-weight: 600; color: var(--muted); }
-
-label, .label {
- display: block;
- font-size: 14px;
- color: var(--muted);
- margin: 18px 0 8px;
-}
-
-input[type="text"], input[type="number"], input[type="tel"] {
- width: 100%;
+button,
+input,
+textarea,
+select {
font: inherit;
- font-size: 18px;
- color: var(--text);
- background: var(--input);
- border: 2px solid transparent;
- border-radius: 10px;
- padding: 14px 16px;
- outline: none;
- transition: border-color 0.15s;
+ color: inherit;
+ letter-spacing: inherit;
}
-input:focus { border-color: var(--felt); }
-input:disabled { opacity: 0.4; }
-input.invalid { border-color: var(--red); }
-.row { display: flex; gap: 10px; align-items: center; }
-.row > * { flex: 1; min-width: 0; }
-
-.btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
- font: inherit;
- font-size: 17px;
- font-weight: 800;
- border: 0;
- border-radius: 10px;
- padding: 14px 18px;
- color: #fff;
- cursor: pointer;
- text-decoration: none;
- transition: transform 0.06s, filter 0.15s, opacity 0.15s;
- white-space: nowrap;
+a {
+ color: var(--tint);
}
-.btn:active:not(:disabled) { transform: scale(0.97); }
-.btn:disabled { opacity: 0.4; cursor: default; }
-.btn-orange { background: var(--orange); }
-.btn-blue { background: var(--blue); }
-.btn-green { background: var(--green); }
-.btn-red { background: var(--red); }
-.btn-dark { background: var(--panel-2); color: var(--text); }
-.btn-ghost { background: transparent; color: var(--muted); border: 2px solid var(--line); }
-.btn-block { width: 100%; }
-.btn-sm { font-size: 14px; padding: 9px 14px; border-radius: 8px; }
-.icon-btn {
- width: 44px;
- height: 44px;
- border-radius: 50%;
- border: 2px solid rgba(255, 255, 255, 0.15);
- background: rgba(0, 0, 0, 0.18);
- color: var(--text);
- display: inline-flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- font-size: 18px;
- text-decoration: none;
+:focus-visible {
+ outline: 2px solid var(--blue);
+ outline-offset: 2px;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+/* アイコン(SF Symbols 風の線) */
+.i {
+ width: 22px;
+ height: 22px;
flex: none;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+.i-fill {
+ fill: currentColor;
+ stroke: none;
}
-.divider { height: 1px; background: var(--line); margin: 24px 0; }
-
-.error-text { color: var(--red); font-size: 14px; min-height: 20px; margin-top: 10px; }
-.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
-
-.card {
- background: var(--panel);
- border-radius: var(--radius);
- padding: 16px;
+/* ---------- ページの骨組み ---------- */
+.wrap {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 0 16px calc(40px + env(safe-area-inset-bottom));
}
-/* ---------- 鍵入力 ---------- */
-.key-inputs { display: flex; gap: 6px; justify-content: center; margin: 8px 0 4px; }
-.key-inputs input {
- flex: 1;
- min-width: 0;
- max-width: 52px;
- height: 60px;
- padding: 0;
- text-align: center;
- font-size: 28px;
- font-weight: 800;
+/* ナビゲーションバー: 中身が下に潜ったら素材と境目のぼかしが出る */
+.nav-bar {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ padding-top: env(safe-area-inset-top);
}
-.shake { animation: shake 0.35s; }
-@keyframes shake {
- 20%, 60% { transform: translateX(-8px); }
- 40%, 80% { transform: translateX(8px); }
+.nav-bar::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: var(--material);
+ -webkit-backdrop-filter: blur(20px) saturate(180%);
+ backdrop-filter: blur(20px) saturate(180%);
+ opacity: 0;
+ transition: opacity 0.2s ease;
}
-
-/* ---------- ロビー ---------- */
-.table-list { display: flex; flex-direction: column; gap: 10px; }
-.table-item {
- background: var(--panel);
- border-radius: var(--radius);
- padding: 14px 14px 14px 16px;
- display: flex;
- gap: 12px;
+.nav-bar::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 100%;
+ height: 16px;
+ pointer-events: none;
+ background: linear-gradient(var(--material), transparent);
+ -webkit-backdrop-filter: blur(6px);
+ backdrop-filter: blur(6px);
+ -webkit-mask-image: linear-gradient(#000, transparent);
+ mask-image: linear-gradient(#000, transparent);
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+.nav-bar.scrolled::before,
+.nav-bar.scrolled::after {
+ opacity: 1;
+}
+.nav-inner {
+ position: relative;
+ max-width: 600px;
+ height: 44px;
+ margin: 0 auto;
+ padding: 0 8px;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
align-items: center;
}
-.table-item .info { flex: 1; min-width: 0; }
-.table-item .name {
- font-weight: 800;
- font-size: 17px;
+.nav-title {
+ grid-column: 2;
+ max-width: 60vw;
+ font-size: 1.0625rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- white-space: nowrap;
+ opacity: 0;
+ transform: translateY(4px);
+ transition: opacity 0.2s ease, transform 0.4s var(--ease-spring);
}
-.table-item .meta {
+.nav-bar.show-title .nav-title {
+ opacity: 1;
+ transform: none;
+}
+.nav-back {
+ grid-column: 1;
+ justify-self: start;
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ min-height: 44px;
+ padding: 0 8px 0 2px;
+ color: var(--tint);
+ font-size: 1.0625rem;
+ text-decoration: none;
+ border-radius: 10px;
+ transition: opacity 0.25s;
+}
+.nav-back:active {
+ opacity: 0.4;
+ transition: none;
+}
+.nav-back .i {
+ width: 26px;
+ height: 26px;
+ stroke-width: 2.4;
+}
+
+/* 大きな見出し(字が大きいほど字間を詰め、行間も詰める) */
+.large-title {
+ margin: 2px 0 20px;
+ font-size: 2.125rem;
+ line-height: 1.18;
+ font-weight: 700;
+ letter-spacing: -0.022em;
+}
+
+/* ---------- グループ表示のリスト ---------- */
+.group {
+ margin: 0 0 28px;
+}
+.group-header {
display: flex;
- flex-wrap: wrap;
- gap: 4px 12px;
- font-size: 13px;
- color: var(--muted);
- margin-top: 6px;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+ padding: 0 16px 7px;
+ font-size: 0.8125rem;
+ line-height: 1.35;
+ letter-spacing: 0.01em;
+ color: var(--label-2);
}
-.table-item .meta b { color: var(--text); font-weight: 700; }
-.table-item .meta .gold { color: var(--gold); }
-.table-item .buttons { display: flex; gap: 8px; flex-shrink: 0; }
-
-.badge {
- display: inline-block;
- font-size: 11px;
- font-weight: 800;
- letter-spacing: 0.05em;
- padding: 3px 8px;
- border-radius: 999px;
- background: var(--panel-2);
- color: var(--muted);
- vertical-align: 2px;
- margin-left: 6px;
+.group-footer {
+ padding: 7px 16px 0;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ letter-spacing: 0.01em;
+ color: var(--label-2);
}
-.badge.live { background: rgba(46, 204, 113, 0.18); color: var(--green); }
-.badge.paused { background: rgba(255, 90, 90, 0.15); color: var(--red); }
-
-.empty {
- text-align: center;
- color: var(--muted);
- padding: 32px 16px;
- border: 2px dashed var(--line);
- border-radius: var(--radius);
- font-size: 14px;
+.group-footer:empty {
+ display: none;
}
-
-/* ---------- テーブル作成 ---------- */
-.topbar { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
-.topbar h1 { font-size: 20px; flex: 1; }
-
-.switch-row {
+.list {
+ background: var(--bg-2);
+ border-radius: 14px;
+ overflow: hidden;
+}
+.cell {
+ position: relative;
display: flex;
align-items: center;
- justify-content: space-between;
- margin-top: 22px;
- font-weight: 800;
- font-size: 17px;
+ gap: 14px;
+ min-height: 48px;
+ padding: 11px 16px;
+ margin: 0;
+ color: inherit;
+ text-decoration: none;
+ background: transparent;
+ /* 離したときはゆっくり戻す */
+ transition: background-color 0.35s ease;
+}
+/* 区切り線は文字の始まりから(アイコンの下には引かない) */
+.cell + .cell::before,
+.cell + .collapsible .cell::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: 16px;
+ border-top: 0.5px solid var(--separator);
+}
+.cell:has(.cell-icon) + .cell::before {
+ left: 60px;
+}
+.cell-link,
+.choice {
+ cursor: pointer;
+}
+/* 押した瞬間に反応する */
+.cell-link:active,
+.choice:active {
+ background: var(--fill-3);
+ transition: none;
+}
+.cell-title {
+ flex: 1;
+ min-width: 0;
+ font-weight: 400;
+}
+.cell-sub {
+ display: block;
+ margin-top: 1px;
+ font-size: 0.8125rem;
+ line-height: 1.45;
+ letter-spacing: 0.01em;
+ color: var(--label-2);
+}
+.chevron {
+ width: 14px;
+ height: 14px;
+ color: var(--label-3);
+ stroke-width: 2.6;
+}
+.cell-icon {
+ width: 30px;
+ height: 30px;
+ flex: none;
+ display: grid;
+ place-items: center;
+ border-radius: 8px;
+ color: #fff;
+ background: var(--tint);
+}
+.cell-icon .i {
+ width: 18px;
+ height: 18px;
+}
+.tone-green {
+ background: linear-gradient(180deg, #34c759, #1f9a45);
+}
+.tone-blue {
+ background: linear-gradient(180deg, #3d9bff, #0a6cdf);
+}
+.tone-indigo {
+ background: linear-gradient(180deg, #7472ff, #4a48c9);
+}
+.tone-orange {
+ background: linear-gradient(180deg, #ffad33, #f08300);
+}
+.tone-purple {
+ background: linear-gradient(180deg, #c86ff0, #9440c2);
+}
+
+/* セルの中の入力(ラベルが左、値が右) */
+.field-cell {
+ cursor: text;
+}
+.field-cell > label,
+.field-cell > .cell-label {
+ flex: none;
+ margin: 0;
+ color: var(--label);
+}
+.field-cell input {
+ flex: 1;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ outline: none;
+ background: transparent;
+ font-size: 1.0625rem;
+}
+.field-cell input::placeholder {
+ color: var(--label-3);
+}
+.field-cell.value-right input {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ color: var(--label-2);
+}
+.field-cell.value-right input:focus {
+ color: var(--label);
+}
+.field-cell .unit {
+ flex: none;
+ margin-left: -8px;
+ color: var(--label-2);
+}
+.field-cell:has(input.invalid) {
+ box-shadow: inset 3px 0 0 var(--red);
+}
+.field-cell input.invalid {
+ color: var(--red);
+}
+.field-cell:has(input:disabled) {
+ opacity: 0.4;
+}
+
+/* 単独の入力欄 */
+.field {
+ width: 100%;
+ min-height: 48px;
+ padding: 12px 14px;
+ border: 0;
+ border-radius: 12px;
+ outline: none;
+ background: var(--bg-2);
+ color: var(--label);
+ font-size: 1.0625rem;
+ box-shadow: inset 0 0 0 0.5px var(--separator);
+ transition: box-shadow 0.2s ease;
+}
+.field:focus {
+ box-shadow: inset 0 0 0 2px var(--tint);
+}
+.field.invalid {
+ box-shadow: inset 0 0 0 2px var(--red);
+}
+.field::placeholder {
+ color: var(--label-3);
+}
+
+/* 説明・エラー */
+.hint {
+ color: var(--label-2);
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ letter-spacing: 0.01em;
+}
+.error-text {
+ min-height: 20px;
+ margin-top: 8px;
+ padding: 0 16px;
+ color: var(--red);
+ font-size: 0.8125rem;
+ line-height: 1.5;
+}
+.error-text:empty {
+ visibility: hidden;
+}
+.group > .error-text:empty {
+ display: none;
+}
+.link {
+ color: var(--tint);
+ font-weight: 600;
+ text-decoration: none;
+}
+.label {
+ display: block;
+ margin: 20px 16px 8px;
+ font-size: 0.8125rem;
+ color: var(--label-2);
+}
+.card {
+ background: var(--bg-2);
+ border-radius: 14px;
+ padding: 16px;
+}
+.row {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+}
+.row > * {
+ flex: 1;
+ min-width: 0;
+}
+.mt-8 {
+ margin-top: 8px;
+}
+.mt-12 {
+ margin-top: 12px;
+}
+
+/* ---------- ボタン ---------- */
+.btn {
+ -webkit-appearance: none;
+ appearance: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-height: 44px;
+ padding: 10px 18px;
+ border: 0;
+ border-radius: 12px;
+ background: var(--fill-2);
+ color: var(--label);
+ font-size: 1.0625rem;
+ font-weight: 600;
+ line-height: 1.2;
+ letter-spacing: -0.01em;
+ text-decoration: none;
+ white-space: nowrap;
+ cursor: pointer;
+ user-select: none;
+ touch-action: manipulation;
+ /* 離したときはばねで戻る */
+ transition: transform 0.45s var(--ease-spring), opacity 0.25s ease, background-color 0.25s ease, color 0.25s ease;
+}
+/* 押した瞬間に縮む(指を置いたところで反応する) */
+.btn:active:not(:disabled) {
+ transform: scale(0.97);
+ opacity: 0.78;
+ transition: transform 0.1s ease-out, opacity 0.1s ease-out;
+}
+.btn:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+.btn .i {
+ width: 1.15em;
+ height: 1.15em;
+ stroke-width: 2.3;
+}
+.btn-tint {
+ background: var(--tint);
+ color: var(--on-tint);
+}
+.btn-blue {
+ background: var(--blue);
+ color: #fff;
+}
+.btn-sm {
+ min-height: 32px;
+ padding: 5px 14px;
+ border-radius: 999px;
+ font-size: 0.9375rem;
+}
+.btn-lg {
+ min-height: 52px;
+ border-radius: 14px;
+}
+.btn-block {
+ width: 100%;
+}
+
+/* すりガラスの丸いボタン(プレイ画面の上) */
+.glass-btn {
+ -webkit-appearance: none;
+ appearance: none;
+ position: relative;
+ flex: none;
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ background: var(--material);
+ color: var(--label);
+ -webkit-backdrop-filter: blur(16px) saturate(180%);
+ backdrop-filter: blur(16px) saturate(180%);
+ box-shadow: inset 0 0 0 0.5px var(--material-edge), var(--shadow-s);
+ cursor: pointer;
+ text-decoration: none;
+ transition: transform 0.45s var(--ease-spring), color 0.2s;
+}
+.glass-btn:active {
+ transform: scale(0.9);
+ transition: transform 0.1s ease-out;
+}
+.glass-btn .i {
+ width: 20px;
+ height: 20px;
+}
+
+/* ---------- セグメント(つまみは fluid.js が滑らせる) ---------- */
+.segmented {
+ position: relative;
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: 1fr;
+ padding: 2px;
+ border-radius: 10px;
+ background: var(--fill-3);
+ touch-action: pan-y;
+ user-select: none;
+ -webkit-user-select: none;
+}
+.segmented > label,
+.segmented > button {
+ position: relative;
+ z-index: 1;
+ margin: 0;
+ min-width: 0;
+}
+.segmented input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+}
+.segmented label > span,
+.seg-btn {
+ -webkit-appearance: none;
+ appearance: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ min-height: 32px;
+ padding: 4px 6px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--label);
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.2;
+ text-align: center;
+ cursor: pointer;
+}
+.segmented input:checked + span,
+.seg-btn.active {
+ font-weight: 600;
+}
+.segmented input:focus-visible + span {
+ outline: 2px solid var(--blue);
+ outline-offset: 0;
+}
+.segmented-thumb {
+ position: absolute;
+ top: 2px;
+ bottom: 2px;
+ left: 0;
+ width: 0;
+ border-radius: 8px;
+ background: var(--thumb);
+ box-shadow: 0 3px 8px rgba(0, 0, 0, 0.12), 0 3px 1px rgba(0, 0, 0, 0.04), inset 0 0 0 0.5px rgba(0, 0, 0, 0.04);
+ will-change: transform;
+}
+/* JS が動く前の見た目 */
+.segmented:not(.has-thumb) input:checked + span,
+.segmented:not(.has-thumb) .seg-btn.active {
+ background: var(--thumb);
+ box-shadow: 0 3px 8px rgba(0, 0, 0, 0.12);
+}
+
+/* ---------- スイッチ(押している間はつまみが伸びる) ---------- */
+.switch {
+ position: relative;
+ flex: none;
+ display: inline-block;
+ width: 51px;
+ height: 31px;
+ margin: 0;
+}
+.switch input {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ opacity: 0;
+ cursor: pointer;
}
-.switch { position: relative; width: 52px; height: 30px; flex: none; }
-.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.switch span {
position: absolute;
inset: 0;
- background: var(--input);
border-radius: 999px;
- cursor: pointer;
- transition: background 0.2s;
+ background: var(--fill);
+ transition: background-color 0.25s ease;
}
.switch span::before {
content: "";
position: absolute;
- width: 24px;
- height: 24px;
- left: 3px;
- top: 3px;
- background: #ddd;
- border-radius: 50%;
- transition: transform 0.2s, background 0.2s;
+ left: 2px;
+ top: 2px;
+ width: 27px;
+ height: 27px;
+ border-radius: 999px;
+ background: #fff;
+ box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15), 0 3px 1px rgba(0, 0, 0, 0.06);
+ transition: transform 0.45s var(--ease-spring), width 0.3s var(--ease-spring);
}
-.switch input:checked + span { background: var(--green); }
-.switch input:checked + span::before { transform: translateX(22px); background: #fff; }
-.switch input:focus-visible + span { outline: 2px solid var(--gold); }
-
-/* 選択肢を横に並べたラジオボタン(テーブルの種類・CPU の人数) */
-.segmented { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 6px; }
-.segmented label { margin: 0; display: block; color: var(--text); }
-.segmented label[hidden] { display: none; }
-.segmented input { position: absolute; opacity: 0; width: 0; height: 0; }
-.segmented span {
- display: flex;
- align-items: center;
- justify-content: center;
- text-align: center;
- min-height: 52px;
- padding: 6px 4px;
- border-radius: 12px;
- background: var(--input);
- border: 2px solid transparent;
- font-weight: 800;
- font-size: 14px;
- line-height: 1.2;
- cursor: pointer;
+.switch input:active + span::before {
+ width: 33px;
+}
+.switch input:checked + span {
+ background: var(--tint);
+}
+.switch input:checked + span::before {
+ transform: translateX(20px);
+}
+.switch input:checked:active + span::before {
+ transform: translateX(14px);
+}
+.switch input:focus-visible + span {
+ outline: 2px solid var(--blue);
+ outline-offset: 2px;
}
-.segmented input:checked + span { background: rgba(246, 199, 68, 0.15); border-color: var(--gold); color: var(--gold); }
-.segmented input:focus-visible + span { outline: 2px solid var(--gold); outline-offset: 2px; }
-.segmented.numbers span { min-height: 40px; font-size: 15px; }
-.unit-input { position: relative; }
-.unit-input input { padding-right: 56px; }
-.unit-input .unit {
+/* ---------- 選択肢のリスト(チェックマーク) ---------- */
+.choice input {
position: absolute;
- right: 16px;
- top: 50%;
- transform: translateY(-50%);
- color: var(--muted);
- font-weight: 700;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
pointer-events: none;
}
+.choice .check {
+ width: 20px;
+ height: 20px;
+ color: var(--tint);
+ stroke-width: 2.6;
+ opacity: 0;
+ transform: scale(0.4);
+ transition: opacity 0.15s ease, transform 0.5s var(--ease-bounce);
+}
+.choice input:checked ~ .check {
+ opacity: 1;
+ transform: none;
+}
+.choice:has(input:focus-visible) {
+ outline: 2px solid var(--blue);
+ outline-offset: -2px;
+}
+.choice-text {
+ flex: 1;
+ min-width: 0;
+}
+/* 開閉する行(高さをばねで変える) */
+.collapsible {
+ display: grid;
+ grid-template-rows: 1fr;
+ transition: grid-template-rows 0.5s var(--ease-spring), opacity 0.3s ease;
+}
+.collapsible > .collapse-inner {
+ min-height: 0;
+ overflow: hidden;
+}
+.collapsible.collapsed {
+ grid-template-rows: 0fr;
+ opacity: 0;
+}
+
+/* ---------- 状態の印 ---------- */
+.status {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-weight: 600;
+}
+.status::before {
+ content: "";
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: currentColor;
+}
+.status.live {
+ color: var(--green);
+}
+.status.waiting {
+ color: var(--label-2);
+}
+.status.paused {
+ color: var(--orange);
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--fill-2);
+ color: var(--label-2);
+ font-size: 0.6875rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+}
+.badge.live {
+ background: var(--tint-soft);
+ color: var(--tint);
+}
+.badge.paused {
+ background: rgba(255, 149, 0, 0.16);
+ color: var(--orange);
+}
+
+.empty {
+ padding: 28px 20px;
+ border-radius: 14px;
+ background: var(--bg-2);
+ color: var(--label-2);
+ font-size: 0.9375rem;
+ line-height: 1.6;
+ text-align: center;
+}
+.list > .empty {
+ border-radius: 0;
+}
+
+.spinner {
+ width: 14px;
+ height: 14px;
+ flex: none;
+ border: 2px solid rgba(255, 255, 255, 0.35);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: spin 0.9s linear infinite;
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* ---------- シート(fluid.js の Sheet) ---------- */
+.sheet-backdrop {
+ --sheet-progress: 1;
+ position: fixed;
+ inset: 0;
+ z-index: 30;
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+}
+/* 暗さはシートの位置に合わせて変わる(下ろすほど明るくなる) */
+.sheet-backdrop::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: var(--scrim);
+ opacity: var(--sheet-progress);
+}
+.sheet {
+ --bg-2: var(--sheet-cell);
+ position: relative;
+ width: 100%;
+ max-width: 600px;
+ max-height: calc(100dvh - env(safe-area-inset-top) - 20px);
+ display: flex;
+ flex-direction: column;
+ padding-bottom: env(safe-area-inset-bottom);
+ border-radius: 24px 24px 0 0;
+ background: var(--sheet-bg);
+ box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.18);
+ outline: none;
+ will-change: transform;
+}
+/* 弾んで上へ行き過ぎたときに、下に隙間が見えないよう画面の外まで伸ばす */
+.sheet::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 100%;
+ height: 120px;
+ background: inherit;
+}
+.sheet-header {
+ flex: none;
+ padding: 6px 20px 10px;
+ text-align: center;
+ touch-action: none;
+ cursor: grab;
+}
+.sheet.dragging .sheet-header {
+ cursor: grabbing;
+}
+.sheet-grabber {
+ display: block;
+ width: 36px;
+ height: 5px;
+ margin: 0 auto 12px;
+ border-radius: 3px;
+ background: var(--label-3);
+}
+.sheet-title {
+ font-size: 1.0625rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+.sheet-scroll {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: 2px 20px 8px;
+}
+.sheet-footer {
+ flex: none;
+ display: grid;
+ gap: 8px;
+ padding: 10px 20px 14px;
+}
+.sheet-footer .btn-row {
+ display: grid;
+ grid-template-columns: 1fr 2fr;
+ gap: 8px;
+}
+.sheet-footer .btn-row:has(> [hidden]) {
+ grid-template-columns: 1fr;
+}
+.sheet-form {
+ padding: 18px 20px 20px;
+}
+.sheet-form .sheet-title {
+ font-size: 1.375rem;
+ font-weight: 700;
+ letter-spacing: -0.015em;
+}
+
+/* ---------- アラート(App.confirm): 少し大きい所から現れ、その場で消える ---------- */
+.alert-backdrop {
+ --appear: 1;
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+}
+.alert-backdrop::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.35);
+ opacity: var(--appear);
+}
+.alert {
+ position: relative;
+ width: min(300px, 100%);
+ overflow: hidden;
+ border-radius: 18px;
+ background: var(--material-thick);
+ -webkit-backdrop-filter: blur(30px) saturate(180%);
+ backdrop-filter: blur(30px) saturate(180%);
+ box-shadow: var(--shadow-l), inset 0 0 0 0.5px var(--material-edge);
+ text-align: center;
+}
+.alert-body {
+ padding: 20px 18px 18px;
+}
+.alert-title {
+ margin-bottom: 4px;
+ font-size: 1.0625rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ line-height: 1.35;
+}
+.alert-message {
+ font-size: 0.8125rem;
+ line-height: 1.55;
+ letter-spacing: 0.01em;
+ white-space: pre-line;
+ overflow-wrap: anywhere;
+}
+.alert-buttons {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ border-top: 0.5px solid var(--separator);
+}
+.alert-btn {
+ -webkit-appearance: none;
+ appearance: none;
+ min-height: 46px;
+ padding: 10px 8px;
+ border: 0;
+ background: transparent;
+ color: var(--blue);
+ font-size: 1.0625rem;
+ cursor: pointer;
+ transition: background-color 0.3s ease;
+}
+.alert-btn + .alert-btn {
+ border-left: 0.5px solid var(--separator);
+}
+.alert-btn:active {
+ background: var(--fill-2);
+ transition: none;
+}
+.alert-btn:focus-visible {
+ outline-offset: -3px;
+}
+.alert-btn.default {
+ font-weight: 600;
+}
+.alert-btn.destructive {
+ color: var(--red);
+}
+/* 危険な操作のときは「やめる」側を太字に(うっかり進めないように) */
+.alert:has(.destructive) [data-confirm="cancel"] {
+ font-weight: 600;
+}
+
+/* 自動でコピーできなかったときのシート */
+.copy-text {
+ width: 100%;
+ min-height: 240px;
+ margin-top: 10px;
+ padding: 12px;
+ resize: vertical;
+ border: 0;
+ border-radius: 12px;
+ background: var(--bg-2);
+ font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
+ user-select: text;
+ -webkit-user-select: text;
+}
+
+/* ---------- 通知(上から降りてきて、同じ道を上へ戻る) ---------- */
+.toast {
+ position: fixed;
+ top: calc(env(safe-area-inset-top) + 10px);
+ left: 50%;
+ z-index: 50;
+ max-width: calc(100% - 32px);
+ padding: 11px 18px;
+ border-radius: 999px;
+ background: var(--material-thick);
+ color: var(--label);
+ -webkit-backdrop-filter: blur(24px) saturate(180%);
+ backdrop-filter: blur(24px) saturate(180%);
+ box-shadow: var(--shadow-m), inset 0 0 0 0.5px var(--material-edge);
+ font-size: 0.9375rem;
+ font-weight: 600;
+ line-height: 1.4;
+ text-align: center;
+ pointer-events: none;
+ opacity: 0;
+ transform: translate3d(-50%, calc(-100% - 24px), 0) scale(0.92);
+ transition: transform 0.3s var(--ease-out-mirror), opacity 0.25s ease-in;
+}
+.toast.show {
+ opacity: 1;
+ transform: translate3d(-50%, 0, 0);
+ transition: transform 0.55s var(--ease-spring), opacity 0.2s ease-out;
+}
+
+.conn-lost {
+ position: fixed;
+ top: calc(env(safe-area-inset-top) + 8px);
+ left: 50%;
+ z-index: 45;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 16px 8px 12px;
+ border-radius: 999px;
+ background: var(--red);
+ color: #fff;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ white-space: nowrap;
+ box-shadow: var(--shadow-m);
+ transform: translateX(-50%);
+ animation: drop-in 0.5s var(--ease-spring) both;
+}
+@keyframes drop-in {
+ from {
+ opacity: 0;
+ transform: translate(-50%, -24px) scale(0.92);
+ }
+}
+
+/* ---------- アプリのマーク ---------- */
+.app-mark {
+ display: grid;
+ place-items: center;
+ flex: none;
+ width: 84px;
+ height: 84px;
+ border-radius: 22.5%;
+ background: linear-gradient(165deg, #34b873 0%, #138152 55%, #0b5636 100%);
+ box-shadow: 0 12px 28px rgba(11, 86, 54, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.28),
+ inset 0 -1px 0 rgba(0, 0, 0, 0.15);
+ color: #fff;
+}
+.app-mark svg {
+ width: 52%;
+ height: 52%;
+ fill: currentColor;
+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.2));
+}
+.app-mark.small {
+ width: 52px;
+ height: 52px;
+ box-shadow: 0 6px 14px rgba(11, 86, 54, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.28);
+}
+
+/* ---------- 鍵入力 ---------- */
+.page-key {
+ display: grid;
+ place-items: center;
+}
+.key-screen {
+ width: 100%;
+ max-width: 400px;
+ padding: calc(env(safe-area-inset-top) + 40px) 24px calc(40px + env(safe-area-inset-bottom));
+ text-align: center;
+}
+.key-screen .app-mark {
+ margin: 0 auto 18px;
+}
+.brand {
+ font-size: 2.125rem;
+ font-weight: 700;
+ line-height: 1.15;
+ letter-spacing: -0.022em;
+}
+.brand-sub {
+ margin: 6px 0 40px;
+ color: var(--label-2);
+ font-size: 0.6875rem;
+ font-weight: 600;
+ letter-spacing: 0.26em;
+}
+.key-title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ letter-spacing: -0.012em;
+}
+.key-hint {
+ margin: 6px 0 24px;
+ color: var(--label-2);
+ font-size: 0.9375rem;
+}
+.key-inputs {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+}
+.key-inputs input {
+ flex: 1;
+ min-width: 0;
+ max-width: 50px;
+ height: 60px;
+ padding: 0;
+ border: 0;
+ border-radius: 12px;
+ outline: none;
+ background: var(--bg-2);
+ color: var(--label);
+ caret-color: var(--tint);
+ font-family: var(--font-num);
+ font-size: 1.75rem;
+ font-weight: 600;
+ text-align: center;
+ box-shadow: inset 0 0 0 0.5px var(--separator), var(--shadow-s);
+ transition: box-shadow 0.2s ease;
+}
+.key-inputs input:focus {
+ box-shadow: inset 0 0 0 2px var(--tint), var(--shadow-s);
+}
+.key-inputs input.filled {
+ animation: pop 0.45s var(--ease-bounce);
+}
+.key-form .error-text {
+ padding: 0;
+ margin: 12px 0 8px;
+}
+@keyframes pop {
+ from {
+ transform: scale(0.86);
+ }
+}
+
+/* ---------- ロビー ---------- */
+.hero {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin: 10px 0 26px;
+}
+.hero .large-title {
+ margin: 0;
+}
+.hero .brand-sub {
+ margin: 2px 0 0;
+}
+.lobby-actions {
+ margin: -8px 0 28px;
+}
+
+/* テーブルの行(管理者は左へスワイプすると削除ボタンが出る) */
+.swipe-row {
+ --reveal: 0;
+ position: relative;
+ overflow: hidden;
+}
+.swipe-action {
+ -webkit-appearance: none;
+ appearance: none;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 88px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ border: 0;
+ background: var(--red);
+ color: #fff;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+/* 引き出すほどアイコンが大きくなる(どこへ向かっているかを途中で見せる) */
+.swipe-action .i {
+ opacity: var(--reveal);
+ transform: scale(calc(0.5 + 0.5 * var(--reveal)));
+}
+.table-row {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px 12px 16px;
+ background: var(--bg-2);
+ touch-action: pan-y;
+ -webkit-user-select: none;
+ user-select: none;
+ will-change: transform;
+}
+.swipe-row + .swipe-row .table-row::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: 60px;
+ border-top: 0.5px solid var(--separator);
+}
+.table-row-main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.table-row .name {
+ font-size: 1.0625rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.table-row-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 1px 10px;
+ color: var(--label-2);
+ font-size: 0.8125rem;
+ line-height: 1.45;
+ letter-spacing: 0.01em;
+ font-variant-numeric: tabular-nums;
+}
+.meta-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+}
+.meta-item .i {
+ width: 13px;
+ height: 13px;
+ stroke-width: 2.4;
+}
+/* App Store の「入手」と同じ形の参加ボタン */
+.btn-get {
+ -webkit-appearance: none;
+ appearance: none;
+ flex: none;
+ min-width: 68px;
+ height: 30px;
+ padding: 0 14px;
+ border: 0;
+ border-radius: 999px;
+ background: var(--fill-2);
+ color: var(--tint);
+ font-size: 0.9375rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: transform 0.45s var(--ease-spring), opacity 0.25s;
+}
+.btn-get:active {
+ transform: scale(0.92);
+ opacity: 0.7;
+ transition: transform 0.1s ease-out, opacity 0.1s;
+}
+
+/* ---------- テーブル作成 ---------- */
.schedule {
display: flex;
flex-wrap: wrap;
gap: 6px;
- margin-top: 10px;
+ padding: 10px 4px 0;
}
.schedule span {
- font-size: 12px;
- background: var(--panel);
- color: var(--muted);
- padding: 4px 8px;
- border-radius: 6px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: var(--fill-3);
+ color: var(--label-2);
+ font-size: 0.75rem;
+ font-variant-numeric: tabular-nums;
+}
+.schedule b {
+ color: var(--label);
+ font-weight: 600;
+}
+.icon-plain {
+ -webkit-appearance: none;
+ appearance: none;
+ flex: none;
+ display: grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ margin: -6px -8px -6px 0;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--tint);
+ cursor: pointer;
+ transition: transform 0.5s var(--ease-bounce);
+}
+.icon-plain:active {
+ transform: rotate(-25deg) scale(0.88);
+ transition: transform 0.1s ease-out;
+}
+.segmented.numbers label > span {
+ min-height: 34px;
+ font-family: var(--font-num);
+ font-size: 0.9375rem;
+ font-variant-numeric: tabular-nums;
+}
+.submit-area {
+ margin-top: 8px;
}
-.schedule span b { color: var(--gold); font-weight: 700; }
/* ---------- プレイ画面 ---------- */
-/* プレイ画面はスクロールさせない。
- iOS Safari は body の overflow: hidden だけではスクロール・バウンスが止まらないため、
+/* スクロールさせない。iOS Safari は body の overflow: hidden だけではスクロール・バウンスが止まらないため、
body を position: fixed で画面に固定し、全体を画面の高さに収める(1本指の touchmove は table.js で止める) */
-html:has(body.table-page) { overflow: hidden; overscroll-behavior: none; }
-body.table-page {
+html:has(body.page-table) {
+ overflow: hidden;
+ overscroll-behavior: none;
+}
+body.page-table {
position: fixed;
inset: 0;
height: 100%;
@@ -322,525 +1456,1436 @@ body.table-page {
overscroll-behavior: none;
display: flex;
flex-direction: column;
+ background: var(--room);
touch-action: manipulation; /* ダブルタップでの拡大を防ぐ(ピンチ拡大は残す) */
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
-.table-page input { -webkit-user-select: text; user-select: text; }
-.table-page .wrap { padding-top: 12px; padding-bottom: 0; flex: 1; min-height: 0; display: flex; flex-direction: column; width: 100%; }
-
-.table-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
-.table-header h1 {
- flex: 1;
- min-width: 0;
- font-size: 22px;
- font-weight: 800;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+.page-table input {
+ -webkit-user-select: text;
+ user-select: text;
+}
+.table-main {
+ flex: 1;
+ min-height: 0;
+ width: 100%;
+ max-width: 520px;
+ margin: 0 auto;
+ padding: calc(env(safe-area-inset-top) + 10px) 16px 0;
+ display: flex;
+ flex-direction: column;
}
-.icon-btn.leave { color: var(--red); border-color: rgba(255, 90, 90, 0.35); }
-/* ホスト操作(テーブル名の右) */
-.host-controls { display: flex; align-items: center; gap: 8px; flex: none; }
-.host-controls .btn { border-radius: 999px; padding: 9px 14px; font-size: 13px; }
-.host-controls .icon-btn.paused { color: var(--green); border-color: rgba(46, 204, 113, 0.5); }
-
-.level-bar {
- background: var(--panel);
- border-radius: var(--radius);
- padding: 12px 16px;
+.table-bar {
display: flex;
align-items: center;
- gap: 12px;
+ gap: 8px;
+}
+.table-bar h1 {
+ flex: 1;
+ min-width: 0;
+ padding-left: 4px;
+ font-size: 1.25rem;
+ font-weight: 700;
+ line-height: 1.2;
+ letter-spacing: -0.015em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+#leave {
+ color: var(--red);
+}
+#leave .icon-back,
+#leave.spectator .icon-leave {
+ display: none;
+}
+#leave.spectator {
+ color: var(--label);
+}
+#leave.spectator .icon-back {
+ display: block;
+}
+.host-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: none;
+}
+#start {
+ min-height: 34px;
+ padding: 6px 16px;
+ font-size: 0.875rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ box-shadow: var(--shadow-s);
+}
+#pause .icon-play,
+#pause.paused .icon-pause {
+ display: none;
+}
+#pause.paused .icon-play {
+ display: block;
+}
+#pause.paused {
+ color: var(--tint);
}
-.level-bar .lv { color: var(--gold); font-weight: 800; }
-.level-bar .blinds { font-weight: 800; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.level-bar .timer { font-variant-numeric: tabular-nums; font-weight: 800; font-size: 18px; }
-.level-bar .timer.warn { color: var(--orange); }
-.level-bar .timer.paused { color: var(--red); }
-.level-sub { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); margin: 6px 4px 0; }
+.level-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 10px;
+ padding: 7px 8px 7px 8px;
+ border-radius: 16px;
+ background: var(--material);
+ -webkit-backdrop-filter: blur(20px) saturate(180%);
+ backdrop-filter: blur(20px) saturate(180%);
+ box-shadow: inset 0 0 0 0.5px var(--material-edge), var(--shadow-s);
+}
+.level-bar .lv {
+ flex: none;
+ padding: 3px 9px;
+ border-radius: 999px;
+ background: var(--tint-soft);
+ color: var(--tint);
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+.level-bar .blinds {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.level-bar .timer {
+ flex: none;
+ padding: 1px 8px;
+ border-radius: 8px;
+ font-family: var(--font-num);
+ font-size: 1.0625rem;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+.level-bar .timer:empty {
+ display: none;
+}
+.level-bar .timer.warn {
+ color: var(--orange);
+}
+.level-bar .timer.paused {
+ color: var(--red);
+}
+.level-sub {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ margin: 6px 8px 0;
+ color: var(--label-2);
+ font-size: 0.75rem;
+ letter-spacing: 0.01em;
+ font-variant-numeric: tabular-nums;
+}
+
+/* テーブル */
.felt-area {
position: relative;
flex: 1;
min-height: 0;
- margin: 46px 0 40px; /* 上下の席が楕円の外にはみ出す分 */
+ margin: 48px 0 44px; /* 上下の席が楕円の外にはみ出す分 */
}
-.felt {
+.rail {
position: absolute;
left: 50%;
top: 50%;
- width: 62%;
- height: 72%;
+ width: 64%;
+ height: 74%;
transform: translate(-50%, -50%);
border-radius: 999px;
- background: radial-gradient(ellipse at center, var(--felt) 0%, var(--felt-dark) 100%);
- box-shadow: 0 0 0 14px rgba(46, 204, 113, 0.18), inset 0 0 40px rgba(0, 0, 0, 0.15);
+ background: linear-gradient(165deg, #43494f 0%, #1d2023 40%, #0b0c0e 100%);
+ box-shadow: 0 34px 60px -24px rgba(0, 0, 0, 0.6), 0 12px 24px -12px rgba(0, 0, 0, 0.4),
+ inset 0 1px 0 rgba(255, 255, 255, 0.14), inset 0 -1px 0 rgba(0, 0, 0, 0.5);
+}
+.felt {
+ position: absolute;
+ inset: 9px;
+ border-radius: 999px;
+ background: radial-gradient(115% 80% at 50% 36%, var(--felt-hi) 0%, var(--felt) 46%, var(--felt-lo) 100%);
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35), inset 0 14px 32px rgba(0, 0, 0, 0.32),
+ inset 0 -8px 24px rgba(255, 255, 255, 0.04);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- gap: 4px;
+ gap: 2px;
+}
+/* ベッティングライン */
+.felt::before {
+ content: "";
+ position: absolute;
+ inset: 8%;
+ border-radius: inherit;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ pointer-events: none;
+}
+.felt > * {
+ position: relative;
+}
+.pot-label {
+ color: rgba(255, 255, 255, 0.55);
+ font-size: 0.6875rem;
+ font-weight: 700;
+ letter-spacing: 0.16em;
}
-.pot-label { font-weight: 900; color: rgba(0, 0, 0, 0.3); letter-spacing: 0.1em; font-size: 14px; }
.pot {
- font-size: 40px;
- font-weight: 900;
- color: var(--gold);
- text-shadow: 0 3px 0 rgba(0, 0, 0, 0.3);
- line-height: 1;
+ color: #fff;
+ font-family: var(--font-num);
+ font-size: 2.25rem;
+ font-weight: 700;
+ line-height: 1.05;
+ letter-spacing: -0.025em;
+ font-variant-numeric: tabular-nums;
+ text-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
.phase {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
margin-top: 8px;
- background: rgba(0, 0, 0, 0.18);
- color: #fff;
- font-weight: 900;
- letter-spacing: 0.08em;
- font-size: 13px;
- padding: 5px 14px;
+ padding: 5px 12px;
border-radius: 999px;
-}
-.phase .hand-no { margin-left: 8px; font-size: 11px; opacity: 0.6; letter-spacing: 0; }
-
-.seat {
- position: absolute;
- transform: translate(-50%, -50%);
- display: flex;
- flex-direction: column;
- align-items: center;
- width: 92px;
- transition: left 0.3s, top 0.3s;
-}
-.avatar {
- width: 58px;
- height: 58px;
- border-radius: 50%;
- background: #3b4a5c;
- border: 3px solid #8a96a3;
- display: flex;
- align-items: center;
- justify-content: center;
- position: relative;
-}
-.avatar svg { width: 28px; height: 28px; fill: #fff; }
-.seat.me .avatar { background: #d35400; border-color: var(--gold); }
-.seat.bot .avatar { background: #3d4a6b; border-color: #8fa2d6; }
-.seat.offline .avatar { opacity: 0.45; }
-.nameplate {
- margin-top: -10px;
- background: rgba(10, 30, 20, 0.88);
- border-radius: 10px;
- padding: 4px 8px;
- min-width: 80px;
- max-width: 88px;
- text-align: center;
- position: relative;
-}
-.nameplate .pname {
- font-size: 12px;
+ background: rgba(0, 0, 0, 0.24);
+ color: rgba(255, 255, 255, 0.94);
+ font-size: 0.6875rem;
font-weight: 700;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ letter-spacing: 0.1em;
+ transition: background-color 0.3s ease, color 0.3s ease;
}
-.nameplate .pstack { font-size: 13px; font-weight: 800; color: var(--gold); }
-.chip-marker {
- position: absolute;
- width: 24px;
- height: 24px;
- border-radius: 50%;
- font-size: 11px;
- font-weight: 900;
- display: flex;
- align-items: center;
- justify-content: center;
- border: 2px solid #222;
+.phase .hand-no {
+ font-weight: 600;
+ letter-spacing: 0;
+ opacity: 0.6;
}
-.chip-marker.dealer { background: #fff; color: #111; top: -4px; right: -8px; z-index: 2; }
-.blind-marker {
- position: absolute;
- top: -4px;
- left: -10px;
- z-index: 2;
- font-size: 10px;
- font-weight: 900;
- padding: 2px 5px;
- border-radius: 6px;
- color: #111;
-}
-.blind-marker.sb { background: #7fd3ff; }
-.blind-marker.bb { background: var(--gold); }
-.offline-tag { font-size: 10px; color: var(--muted); }
-
-.spectator-note {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 12px;
- font-size: 13px;
- color: var(--muted);
- margin-bottom: 12px;
-}
-
-.action-bar {
- flex: none;
- position: relative;
- z-index: 5;
- max-height: 62%;
- overflow-y: auto; /* サイドポットが多いときだけ中でスクロール */
- overscroll-behavior: contain;
- background: #161c19;
- padding: 12px 16px calc(14px + env(safe-area-inset-bottom));
-}
-.action-bar .inner { max-width: 480px; margin: 0 auto; }
-.my-stack {
- font-size: 24px;
- font-weight: 900;
- display: flex;
- align-items: center;
- gap: 8px;
- line-height: 1.1;
-}
-.my-stack .wallet { color: var(--gold); font-size: 18px; }
-.actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
-.actions.raising { grid-template-columns: 1fr 2fr; }
-.actions.single { grid-template-columns: 1fr; }
-.actions .btn { font-size: 16px; padding: 14px 4px; white-space: normal; line-height: 1.15; min-height: 52px; }
-.act-fold, .act-call, .act-raise { background: #555; color: #bbb; }
-.act-fold:not(:disabled) { background: #5d646b; color: #fff; }
-.act-call:not(:disabled) { background: var(--blue); color: #fff; }
-.act-raise:not(:disabled) { background: var(--orange); color: #fff; }
-
-.modal-backdrop {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.6);
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 16px;
- z-index: 10;
-}
-.modal { background: var(--bg-deep); border-radius: var(--radius); padding: 20px; width: 100%; max-width: 380px; }
-.modal h2 { font-size: 18px; margin-bottom: 4px; }
-
-/* 確認ダイアログ(App.confirm)。接続切れの帯やレビューのモーダルより手前に出す */
-.confirm-backdrop { z-index: 40; animation: confirm-fade 0.12s ease-out; }
-.confirm-modal { max-width: 340px; border: 1px solid var(--line); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); animation: confirm-pop 0.14s ease-out; }
-.confirm-title { margin-bottom: 8px; }
-.confirm-message { margin: 0; font-size: 15px; line-height: 1.6; white-space: pre-line; overflow-wrap: anywhere; }
-.confirm-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 20px; }
-.confirm-buttons .btn { padding: 12px 8px; font-size: 16px; white-space: normal; }
-.confirm-buttons .btn:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
-/* 自動でコピーできなかったときに文章を見せるダイアログ(App.copyText) */
-.copy-modal { max-width: 520px; display: flex; flex-direction: column; gap: 10px; max-height: calc(100dvh - 32px); }
-.copy-text { width: 100%; min-height: 240px; flex: 1; resize: vertical; padding: 10px; font: 12px/1.5 ui-monospace, monospace; color: inherit; background: rgba(0, 0, 0, 0.3); border: 1px solid var(--line); border-radius: 8px; }
-@keyframes confirm-fade { from { opacity: 0; } }
-@keyframes confirm-pop { from { opacity: 0; transform: scale(0.94); } }
-@media (prefers-reduced-motion: reduce) {
- .confirm-backdrop, .confirm-modal { animation: none; }
-}
-
-.toast {
- position: fixed;
- left: 50%;
- bottom: 210px;
- transform: translateX(-50%);
- background: rgba(0, 0, 0, 0.85);
- color: #fff;
- padding: 10px 16px;
- border-radius: 10px;
- font-size: 14px;
- z-index: 20;
- pointer-events: none;
- max-width: calc(100% - 32px);
- text-align: center;
-}
-
-.center-message { text-align: center; padding: 60px 16px; color: var(--muted); }
-.center-message h2 { color: var(--text); margin-bottom: 12px; }
-
-.conn-lost {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
+.phase.paused {
background: var(--red);
+}
+.phase.showdown {
+ background: var(--chip);
+ color: var(--on-chip);
+}
+.result {
+ max-width: 94%;
+ margin-top: 6px;
+ padding: 5px 10px;
+ border-radius: 10px;
+ background: rgba(0, 0, 0, 0.36);
color: #fff;
+ font-size: 0.6875rem;
+ line-height: 1.5;
text-align: center;
- font-size: 13px;
- font-weight: 700;
- padding: 6px;
- z-index: 30;
+ animation: fade-up 0.5s var(--ease-spring) both;
+}
+.result b {
+ color: var(--chip);
+}
+.result .rhand {
+ opacity: 0.8;
+}
+.pot-tag {
+ margin-right: 4px;
+ padding: 0 5px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.2);
+ font-size: 0.5625rem;
+}
+@keyframes fade-up {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
}
-/* ---------- カード(カード機能ありのテーブル) ---------- */
+/* カード */
.pcard {
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: none;
- width: 28px;
- height: 40px;
- background: #fff;
- border-radius: 5px;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
- font-weight: 900;
+ width: 30px;
+ height: 42px;
+ border-radius: 6px;
+ background: linear-gradient(180deg, #ffffff, #f2f3f5);
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.14), 0 3px 8px rgba(0, 0, 0, 0.22), inset 0 0 0 0.5px rgba(0, 0, 0, 0.12);
+ font-family: var(--font);
+ font-weight: 700;
line-height: 1;
user-select: none;
- transition: filter 0.2s;
+ transition: filter 0.35s ease, box-shadow 0.35s ease, transform 0.5s var(--ease-spring);
+ backface-visibility: hidden;
}
-.pcard b { font-size: 15px; letter-spacing: -0.05em; }
-.pcard i { font-style: normal; font-size: 13px; margin-top: 1px; }
-.suit-s { color: #1a1a1a; }
-.suit-h { color: #d62839; }
-.suit-d { color: #1f6fd1; }
-.suit-c { color: #16884a; }
-.pcard.back {
- background: repeating-linear-gradient(45deg, #c0392b 0 3px, #a93226 3px 6px);
- border: 2px solid #fff;
-}
-.pcard.slot { background: rgba(0, 0, 0, 0.12); box-shadow: none; border: 1px dashed rgba(255, 255, 255, 0.3); }
-.pcard.deal { animation: deal 0.35s ease-out both; }
-@keyframes deal {
- from { opacity: 0; transform: translateY(-10px) scale(0.8); }
-}
-.pcard.dim { filter: brightness(0.5); }
-.pcard.hl { box-shadow: 0 0 0 2px var(--gold), 0 0 10px rgba(246, 199, 68, 0.8); }
-
-.board { display: flex; gap: 3px; margin-top: 6px; min-height: 40px; }
-
-.hole {
- position: absolute;
- top: -18px;
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- gap: 2px;
- z-index: 1;
-}
-.hole .pcard { width: 25px; height: 35px; border-radius: 4px; }
-.hole .pcard b { font-size: 13px; }
-.hole .pcard i { font-size: 11px; }
-.hole .pcard.back { border-width: 1.5px; }
-/* 手札と重ならないよう D・SB/BB を横に出す */
-.with-cards .chip-marker.dealer { top: 22px; right: -16px; }
-.with-cards .blind-marker { top: 26px; left: -20px; }
-.felt-area.with-cards { margin-top: 62px; }
-.with-cards .bet.above { bottom: calc(100% + 20px); }
-.crowded.with-cards .bet.above { bottom: calc(100% + 14px); }
-.nameplate .phand {
- font-size: 10px;
+.pcard b {
+ font-size: 1rem;
font-weight: 700;
- color: var(--gold);
- white-space: nowrap;
+ letter-spacing: -0.05em;
+}
+.pcard i {
+ margin-top: 1px;
+ font-size: 0.875rem;
+ font-style: normal;
+}
+.suit-s {
+ color: #1c1c1e;
+}
+.suit-h {
+ color: #e0263a;
+}
+.suit-d {
+ color: #1f6fe0;
+}
+.suit-c {
+ color: #13874b;
+}
+.pcard.back {
+ background: repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.1) 0 2px, transparent 2px 5px),
+ linear-gradient(160deg, #c03a48, #6e1220);
+ box-shadow: inset 0 0 0 2px #fff, 0 1px 1px rgba(0, 0, 0, 0.14), 0 3px 8px rgba(0, 0, 0, 0.22);
+}
+.pcard.slot {
+ background: rgba(0, 0, 0, 0.14);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14);
+}
+.pcard.dim {
+ filter: brightness(0.5) saturate(0.6);
+}
+.pcard.hl {
+ box-shadow: 0 0 0 2px var(--chip), 0 0 16px rgba(255, 210, 94, 0.75);
+ transform: translateY(-3px);
+}
+/* 配る: テーブルの中央(山札)から各席へ飛んでくる。--deal-x / --deal-y は table.js が席ごとに入れる */
+.pcard.deal {
+ animation: card-deal 0.6s var(--ease-spring) both;
+}
+@keyframes card-deal {
+ from {
+ opacity: 0;
+ transform: translate(var(--deal-x, 0), var(--deal-y, -40px)) scale(0.5) rotate(-14deg);
+ }
+ 25% {
+ opacity: 1;
+ }
+}
+/* めくる: 場札とショーダウンで公開された手札 */
+.pcard.flip-in {
+ animation: card-flip 0.65s var(--ease-spring) both;
+}
+@keyframes card-flip {
+ from {
+ opacity: 0;
+ transform: perspective(500px) translateY(-8px) rotateY(-110deg) scale(0.92);
+ }
+ 30% {
+ opacity: 1;
+ }
+}
+.board {
+ display: flex;
+ gap: 4px;
+ min-height: 42px;
+ margin-top: 8px;
+}
+
+/* 席 */
+.seat {
+ position: absolute;
+ width: 92px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ transform: translate(-50%, -50%);
+ /* 人が増減したら新しい位置へ滑る */
+ transition: left 0.6s var(--ease-spring), top 0.6s var(--ease-spring);
+}
+.seat.entering {
+ animation: seat-in 0.55s var(--ease-spring) both;
+}
+@keyframes seat-in {
+ from {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.6);
+ }
+}
+.avatar {
+ --hue: 210;
+ position: relative;
+ width: 54px;
+ height: 54px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: linear-gradient(180deg, hsl(var(--hue) 48% 64%), hsl(var(--hue) 46% 42%));
+ color: #fff;
+ box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.28);
+ transition: transform 0.5s var(--ease-spring), box-shadow 0.35s ease, opacity 0.35s ease, filter 0.35s ease;
+}
+.avatar .face {
+ display: grid;
+ place-items: center;
+}
+.avatar .mono {
+ font-size: 1.375rem;
+ font-weight: 600;
+ line-height: 1;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+}
+.avatar svg {
+ width: 28px;
+ height: 28px;
+ fill: #fff;
+}
+/* CPU は角の丸い四角(人は丸) */
+.seat.bot .avatar {
+ border-radius: 30%;
+ background: linear-gradient(180deg, #8a91b4, #4c5274);
+}
+.seat.me .avatar {
+ box-shadow: 0 0 0 2.5px var(--tint), 0 6px 14px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.28);
+}
+.seat.contender .avatar {
+ box-shadow: 0 0 0 3px var(--orange), 0 6px 14px rgba(0, 0, 0, 0.3);
+}
+.seat.turn .avatar {
+ transform: scale(1.08);
+ box-shadow: 0 0 0 3px var(--chip), 0 0 26px 4px rgba(255, 210, 94, 0.55), 0 6px 14px rgba(0, 0, 0, 0.3);
+}
+.seat.winner .avatar {
+ box-shadow: 0 0 0 3px var(--chip), 0 0 30px 6px rgba(255, 210, 94, 0.6);
+}
+.seat.folded .avatar,
+.seat.folded .nameplate {
+ opacity: 0.42;
+}
+.seat.away .avatar {
+ opacity: 0.4;
+}
+.seat.offline .avatar {
+ opacity: 0.5;
+ filter: grayscale(1);
+}
+.nameplate {
+ position: relative;
+ min-width: 76px;
+ max-width: 92px;
+ margin-top: -8px;
+ padding: 4px 9px 5px;
+ border-radius: 12px;
+ background: var(--plate);
+ color: var(--label);
+ -webkit-backdrop-filter: blur(16px) saturate(180%);
+ backdrop-filter: blur(16px) saturate(180%);
+ box-shadow: inset 0 0 0 0.5px var(--material-edge), 0 4px 12px rgba(0, 0, 0, 0.2);
+ text-align: center;
+ line-height: 1.25;
+ transition: opacity 0.35s ease;
+}
+.pname,
+.pstack,
+.pnote {
+ display: block;
overflow: hidden;
text-overflow: ellipsis;
-}
-
-/* ---------- 席の状態 ---------- */
-.seat.folded .avatar, .seat.folded .nameplate { opacity: 0.45; }
-.seat.away .avatar { opacity: 0.4; }
-.seat.turn .avatar {
- border-color: var(--gold);
- box-shadow: 0 0 0 3px rgba(246, 199, 68, 0.35), 0 0 18px rgba(246, 199, 68, 0.7);
-}
-.seat.winner .nameplate { box-shadow: 0 0 0 2px var(--gold), 0 0 12px rgba(246, 199, 68, 0.6); }
-.seat.contender .avatar { border-color: var(--orange); }
-
-.act-tag {
- position: absolute;
- top: -10px;
- left: 50%;
- transform: translateX(-50%);
- font-size: 10px;
- font-weight: 900;
- padding: 1px 7px;
- border-radius: 6px;
- color: #fff;
white-space: nowrap;
- z-index: 2;
}
-.tag-fold { background: #555; color: #ccc; }
-.tag-check { background: #3b7dd8; }
-.tag-call { background: #2e9e5b; }
-.tag-bet, .tag-raise { background: #e67e22; }
-.tag-allin { background: var(--red); }
-.tag-away { background: #444; color: #bbb; }
+.pname {
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.005em;
+}
+.pstack {
+ font-family: var(--font-num);
+ font-size: 0.8125rem;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+.pnote {
+ color: var(--gold);
+ font-size: 0.625rem;
+ font-weight: 700;
+}
+.pnote.offline {
+ color: var(--label-2);
+}
+.pnote:empty {
+ display: none;
+}
+.markers {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+.chip-marker.dealer {
+ position: absolute;
+ top: -4px;
+ right: -9px;
+ z-index: 2;
+ width: 22px;
+ height: 22px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: #fff;
+ color: #111;
+ font-size: 0.6875rem;
+ font-weight: 800;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), inset 0 0 0 2px #fff, inset 0 0 0 3px rgba(0, 0, 0, 0.14);
+}
+.blind-marker {
+ position: absolute;
+ top: -4px;
+ left: -12px;
+ z-index: 2;
+ padding: 2px 6px;
+ border-radius: 999px;
+ color: #111;
+ font-size: 0.625rem;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
+}
+.blind-marker.sb {
+ background: #8fd3ff;
+}
+.blind-marker.bb {
+ background: var(--chip);
+}
+.hole {
+ position: absolute;
+ top: -20px;
+ left: 50%;
+ z-index: 1;
+ display: flex;
+ gap: 2px;
+ transform: translateX(-50%);
+}
+.hole .pcard {
+ width: 26px;
+ height: 36px;
+ border-radius: 5px;
+}
+.hole .pcard b {
+ font-size: 0.8125rem;
+}
+.hole .pcard i {
+ font-size: 0.6875rem;
+}
+.hole .pcard.back {
+ box-shadow: inset 0 0 0 1.5px #fff, 0 2px 6px rgba(0, 0, 0, 0.3);
+}
+/* 手札と重ならないよう D・SB/BB を横に出す */
+.with-cards .chip-marker.dealer {
+ top: 22px;
+ right: -16px;
+}
+.with-cards .blind-marker {
+ top: 26px;
+ left: -20px;
+}
+.felt-area.with-cards {
+ margin-top: 62px;
+}
+/* 直前のアクション(ぽんと出る) */
+.tag-slot {
+ position: absolute;
+ top: -15px;
+ left: 50%;
+ z-index: 2;
+ transform: translateX(-50%);
+}
+.act-tag {
+ display: block;
+ padding: 2px 8px;
+ border-radius: 999px;
+ color: #fff;
+ font-size: 0.625rem;
+ font-weight: 800;
+ letter-spacing: 0.03em;
+ white-space: nowrap;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
+ animation: tag-in 0.5s var(--ease-bounce) both;
+}
+@keyframes tag-in {
+ from {
+ opacity: 0;
+ transform: translateY(4px) scale(0.7);
+ }
+}
+.tag-fold {
+ background: #636366;
+ color: #e5e5ea;
+}
+.tag-check {
+ background: var(--blue);
+}
+.tag-call {
+ background: #28a745;
+}
+.tag-bet,
+.tag-raise {
+ background: var(--orange);
+ color: var(--on-orange);
+}
+.tag-allin {
+ background: var(--red);
+}
+.tag-away {
+ background: #48484a;
+ color: #d1d1d6;
+}
+
+/* ベット(席から中央へ押し出される) */
+.bet-slot {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
.bet {
+ --from: 8px;
position: absolute;
left: 50%;
- transform: translateX(-50%);
+ z-index: 3;
display: flex;
align-items: center;
- gap: 4px;
- background: rgba(0, 0, 0, 0.55);
- color: var(--gold);
- font-weight: 800;
- font-size: 12px;
- padding: 2px 8px 2px 4px;
+ gap: 5px;
+ padding: 3px 9px 3px 4px;
border-radius: 999px;
+ background: rgba(0, 0, 0, 0.56);
+ color: var(--chip);
+ -webkit-backdrop-filter: blur(10px);
+ backdrop-filter: blur(10px);
+ font-family: var(--font-num);
+ font-size: 0.75rem;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
white-space: nowrap;
- z-index: 3;
+ transform: translateX(-50%);
+ animation: bet-in 0.5s var(--ease-spring) both;
}
.bet::before {
content: "";
- width: 10px;
- height: 10px;
+ width: 13px;
+ height: 13px;
border-radius: 50%;
- background: var(--gold);
- border: 2px dashed #fff;
- box-sizing: border-box;
+ background: radial-gradient(circle, var(--chip) 0 38%, transparent 40%),
+ repeating-conic-gradient(#fff 0 22.5deg, #d79a00 22.5deg 45deg);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
+}
+.bet.above {
+ bottom: calc(100% + 4px);
+}
+.bet.below {
+ --from: -8px;
+ top: calc(100% + 4px);
+}
+.bet.win {
+ padding: 3px 10px;
+ background: var(--chip);
+ color: var(--on-chip);
+}
+.bet.win::before {
+ display: none;
+}
+@keyframes bet-in {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(var(--from)) scale(0.8);
+ }
+}
+.with-cards .bet.above {
+ bottom: calc(100% + 20px);
+}
+.crowded.with-cards .bet.above {
+ bottom: calc(100% + 14px);
}
-.bet.above { bottom: calc(100% + 4px); }
-.bet.below { top: calc(100% + 4px); }
-.bet.win { background: var(--gold); color: #111; padding: 2px 8px; }
-.bet.win::before { display: none; }
-.phase.paused { background: var(--red); }
-.phase.showdown { background: var(--orange); color: #111; }
-.result {
- margin-top: 6px;
- background: rgba(0, 0, 0, 0.4);
+/* 7人以上は小さめに */
+.crowded .seat {
+ width: 80px;
+}
+.crowded .avatar {
+ width: 46px;
+ height: 46px;
+}
+.crowded .avatar svg {
+ width: 22px;
+ height: 22px;
+}
+.crowded .avatar .mono {
+ font-size: 1.125rem;
+}
+.crowded .nameplate {
+ min-width: 70px;
+ max-width: 80px;
+ padding: 3px 6px 4px;
+}
+.crowded .pname {
+ font-size: 0.6875rem;
+}
+.crowded .pstack {
+ font-size: 0.75rem;
+}
+.crowded .hole {
+ top: -12px;
+}
+.crowded .hole .pcard {
+ width: 20px;
+ height: 28px;
+ border-radius: 4px;
+}
+.crowded .hole .pcard b {
+ font-size: 0.6875rem;
+}
+.crowded .hole .pcard i {
+ font-size: 0.5625rem;
+}
+.crowded.with-cards .chip-marker.dealer {
+ top: 14px;
+}
+.crowded.with-cards .blind-marker {
+ top: 18px;
+}
+
+.spectator-note {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ align-self: center;
+ margin-bottom: calc(14px + env(safe-area-inset-bottom));
+ padding: 6px 6px 6px 16px;
+ border-radius: 999px;
+ background: var(--material);
+ color: var(--label-2);
+ -webkit-backdrop-filter: blur(20px) saturate(180%);
+ backdrop-filter: blur(20px) saturate(180%);
+ box-shadow: inset 0 0 0 0.5px var(--material-edge), var(--shadow-s);
+ font-size: 0.8125rem;
+}
+.spectator-note:not(:has(#take-seat:not([hidden]))) {
+ padding-right: 16px;
+}
+
+/* 自分の操作エリア(厚い素材のシート) */
+.action-bar {
+ position: relative;
+ z-index: 5;
+ flex: none;
+}
+.action-bar .inner {
+ position: relative;
+ max-width: 520px;
+ margin: 0 auto;
+ padding: 14px 16px calc(12px + env(safe-area-inset-bottom));
+ border-radius: 28px 28px 0 0;
+ background: var(--material-thick);
+ -webkit-backdrop-filter: blur(30px) saturate(180%);
+ backdrop-filter: blur(30px) saturate(180%);
+ box-shadow: 0 -10px 34px rgba(0, 0, 0, 0.14), inset 0 0.5px 0 var(--material-edge);
+}
+/* 自分の番: シートの上の縁がほんのり光る */
+.action-bar .inner::before {
+ content: "";
+ position: absolute;
+ inset: 0 0 auto;
+ height: 56px;
+ border-radius: inherit;
+ background: radial-gradient(60% 100% at 50% 0%, var(--tint-glow), transparent);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.5s ease;
+}
+.action-bar.my-turn .inner::before {
+ opacity: 1;
+}
+.action-bar .inner > * {
+ position: relative;
+}
+.me-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.my-cards {
+ display: flex;
+ flex: none;
+ gap: 4px;
+ --deal-x: 0px;
+ --deal-y: -70px;
+}
+.my-cards .pcard {
+ width: 44px;
+ height: 62px;
border-radius: 8px;
- padding: 4px 8px;
- font-size: 11px;
- line-height: 1.45;
+}
+.my-cards .pcard b {
+ font-size: 1.5rem;
+}
+.my-cards .pcard i {
+ font-size: 1.25rem;
+}
+.my-cards .pcard.slot {
+ background: var(--fill-3);
+ box-shadow: inset 0 0 0 1px var(--separator);
+}
+.me-info {
+ flex: 1;
+ min-width: 0;
+}
+.my-stack {
+ font-family: var(--font-num);
+ font-size: 1.75rem;
+ font-weight: 700;
+ line-height: 1.1;
+ letter-spacing: -0.022em;
+ font-variant-numeric: tabular-nums;
+}
+.me-sub {
+ display: flex;
+ gap: 8px;
+ margin-top: 3px;
+ overflow: hidden;
+ color: var(--label-2);
+ font-size: 0.75rem;
+ letter-spacing: 0.01em;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+.me-sub .my-hand {
+ color: var(--gold);
+ font-weight: 700;
+}
+.me-sub .my-bet {
+ color: var(--label);
+ font-weight: 600;
+}
+.plus {
+ color: var(--green);
+}
+.minus {
+ color: var(--red);
+}
+#sit-out {
+ flex: none;
+ color: var(--label-2);
+ font-size: 0.875rem;
+}
+.status-line {
+ min-height: 18px;
+ margin: 8px 0 10px;
+ color: var(--label-2);
+ font-size: 0.8125rem;
+ line-height: 1.4;
text-align: center;
- max-width: 94%;
}
-.result b { color: var(--gold); }
-.result .rhand { color: #fff; opacity: 0.85; }
-.pot-tag { font-size: 9px; background: rgba(255, 255, 255, 0.2); padding: 0 4px; border-radius: 4px; margin-right: 4px; }
-
-/* 7人以上 */
-.crowded .seat { width: 80px; }
-.crowded .avatar { width: 46px; height: 46px; }
-.crowded .avatar svg { width: 22px; height: 22px; }
-.crowded .nameplate { min-width: 72px; max-width: 80px; padding: 3px 6px; }
-.crowded .nameplate .pname { font-size: 11px; }
-.crowded .nameplate .pstack { font-size: 12px; }
-.crowded .hole { top: -12px; }
-.crowded .hole .pcard { width: 20px; height: 28px; }
-.crowded .hole .pcard b { font-size: 11px; }
-.crowded .hole .pcard i { font-size: 9px; }
-.crowded.with-cards .chip-marker.dealer { top: 14px; }
-.crowded.with-cards .blind-marker { top: 18px; }
-
-/* ---------- 自分の操作エリア ---------- */
-.me-row { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
-.me-info { flex: 1; min-width: 0; }
-.my-cards { display: flex; gap: 4px; flex: none; }
-.my-cards .pcard { width: 42px; height: 58px; border-radius: 6px; }
-.my-cards .pcard b { font-size: 22px; }
-.my-cards .pcard i { font-size: 19px; }
-.me-sub .my-hand { color: var(--gold); font-weight: 800; }
-.me-sub { font-size: 12px; color: var(--muted); display: flex; gap: 8px; margin-top: 3px; white-space: nowrap; }
-.me-sub .my-bet { color: var(--gold); font-weight: 700; }
-.me-sub .plus { color: var(--green); }
-.me-sub .minus { color: var(--red); }
-.status-line { text-align: center; font-size: 12px; color: var(--muted); margin-bottom: 8px; min-height: 16px; }
-.status-line.my-turn { color: var(--gold); font-weight: 800; }
-.raise-panel { margin-bottom: 8px; }
-.award-panel { margin-bottom: 4px; }
-.award-pot { background: var(--panel-2); border-radius: 10px; padding: 8px 10px; margin-bottom: 8px; }
-.award-title { font-size: 12px; color: var(--muted); margin-bottom: 6px; }
-.award-title b { color: var(--gold); font-size: 14px; }
-.picks { display: flex; flex-wrap: wrap; gap: 6px; }
-.picks .pick { background: var(--input); color: var(--text); border: 2px solid transparent; }
-.picks .pick.on { background: rgba(246, 199, 68, 0.18); border-color: var(--gold); color: var(--gold); }
-.presets { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
-.presets .btn { padding: 8px 0; font-size: 13px; }
-.presets .btn.active { background: var(--orange); color: #111; }
-.raise-row { display: flex; gap: 10px; align-items: center; margin-top: 8px; }
-.raise-row input[type="range"] { flex: 1; min-width: 0; accent-color: var(--orange); height: 28px; }
-.raise-row input[type="number"] { width: 100px; flex: none; font-size: 16px; padding: 8px 10px; }
-
-/* 背の低い画面(iPhone SE など)では詰める */
-@media (max-height: 700px) {
- .table-page .wrap { padding-top: 8px; }
- .table-header .icon-btn { width: 38px; height: 38px; font-size: 16px; }
- .table-header h1 { font-size: 18px; }
- .table-header { margin-bottom: 8px; }
- .host-controls .btn { padding: 7px 12px; }
- .level-bar { padding: 8px 14px; }
- .level-sub { display: none; }
- .felt-area { margin: 40px 0 34px; }
- .pot { font-size: 30px; }
- .avatar { width: 46px; height: 46px; }
- .avatar svg { width: 22px; height: 22px; }
- .action-bar { padding-top: 8px; }
- .my-stack { font-size: 20px; }
- .me-row { margin-bottom: 2px; }
- .status-line { margin-bottom: 6px; }
- .actions .btn { min-height: 46px; padding: 10px 4px; }
- .crowded .avatar { width: 34px; height: 34px; border-width: 2px; }
- .crowded .avatar svg { width: 16px; height: 16px; }
- .crowded .nameplate { margin-top: -6px; padding: 2px 5px; min-width: 64px; }
- .crowded .nameplate .pname { font-size: 10px; }
- .crowded .nameplate .pstack { font-size: 11px; }
- .crowded .chip-marker { width: 20px; height: 20px; font-size: 10px; }
- .felt-area.with-cards { margin-top: 52px; }
- .board .pcard { width: 24px; height: 34px; }
- .board .pcard b { font-size: 13px; }
- .board .pcard i { font-size: 11px; }
- .board { min-height: 34px; margin-top: 4px; }
- .my-cards .pcard { width: 34px; height: 48px; }
- .my-cards .pcard b { font-size: 18px; }
- .my-cards .pcard i { font-size: 15px; }
- .crowded .hole { top: -10px; }
- .crowded .hole .pcard { width: 17px; height: 24px; }
- .crowded .hole .pcard b { font-size: 10px; }
- .crowded .hole .pcard i { font-size: 8px; }
+.status-line.my-turn {
+ color: var(--tint);
+ font-weight: 700;
+}
+.actions {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+.actions.raising {
+ grid-template-columns: 1fr 2fr;
+}
+.actions.single {
+ grid-template-columns: 1fr;
+}
+.actions .btn {
+ min-height: 54px;
+ padding: 8px 4px;
+ border-radius: 16px;
+ font-size: 1.0625rem;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.15;
+ white-space: normal;
+}
+.act-fold {
+ background: var(--fill);
+ color: var(--label);
+}
+.act-call {
+ background: var(--blue);
+ color: #fff;
+}
+.act-raise {
+ background: var(--orange);
+ color: var(--on-orange);
+}
+.actions .btn:disabled {
+ opacity: 1;
+ background: var(--fill-3);
+ color: var(--label-3);
}
-[hidden] { display: none !important; }
+/* レイズ額: レイズボタンから上に開き、閉じるときはボタンへ戻る */
+.raise-panel {
+ position: absolute;
+ left: 12px;
+ right: 12px;
+ bottom: calc(100% + 10px);
+ z-index: 6;
+ max-width: 496px;
+ margin: 0 auto;
+ padding: 14px 16px 14px;
+ border-radius: 24px;
+ background: var(--material-thick);
+ -webkit-backdrop-filter: blur(30px) saturate(180%);
+ backdrop-filter: blur(30px) saturate(180%);
+ box-shadow: var(--shadow-l), inset 0 0 0 0.5px var(--material-edge);
+ transform-origin: 82% 100%;
+}
+.raise-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+}
+.raise-value {
+ display: flex;
+ align-items: baseline;
+ min-width: 0;
+ font-family: var(--font-num);
+}
+.raise-value .cur {
+ color: var(--label-2);
+ font-size: 1.5rem;
+ font-weight: 700;
+}
+.raise-amount {
+ width: 8ch;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ outline: none;
+ background: transparent;
+ font-family: var(--font-num);
+ font-size: 2.125rem;
+ font-weight: 700;
+ letter-spacing: -0.022em;
+ font-variant-numeric: tabular-nums;
+ -moz-appearance: textfield;
+ appearance: textfield;
+}
+.raise-amount::-webkit-inner-spin-button,
+.raise-amount::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+.raise-amount:focus {
+ box-shadow: inset 0 -2px 0 var(--orange);
+}
+.raise-meta {
+ flex: none;
+ color: var(--label-2);
+ font-size: 0.8125rem;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+/* 自前のスライダー: つかんだ位置のまま指に付いてくる。端を越えるとゴムのように抵抗する */
+.bet-slider {
+ position: relative;
+ height: 44px;
+ margin: 4px 0 8px;
+ outline: none;
+ touch-action: none;
+ cursor: pointer;
+}
+.bet-track {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 50%;
+ height: 6px;
+ margin-top: -3px;
+ overflow: hidden;
+ border-radius: 3px;
+ background: var(--fill);
+}
+.bet-fill {
+ position: absolute;
+ inset: 0;
+ background: var(--orange);
+ transform: scaleX(0);
+ transform-origin: left center;
+ will-change: transform;
+}
+.bet-thumb {
+ position: absolute;
+ left: 0;
+ top: 50%;
+ width: 28px;
+ height: 28px;
+ margin-top: -14px;
+ will-change: transform;
+}
+.bet-thumb::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.12), inset 0 0 0 0.5px rgba(0, 0, 0, 0.06);
+ transition: transform 0.45s var(--ease-spring);
+}
+.bet-slider.active .bet-thumb::before {
+ transform: scale(1.18);
+ transition: transform 0.15s ease-out;
+}
+.bet-slider:focus-visible .bet-thumb::before {
+ box-shadow: 0 0 0 3px var(--blue), 0 3px 8px rgba(0, 0, 0, 0.2);
+}
+.presets {
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 6px;
+}
+.chip-btn {
+ -webkit-appearance: none;
+ appearance: none;
+ min-height: 34px;
+ padding: 4px 0;
+ border: 0;
+ border-radius: 999px;
+ background: var(--fill-2);
+ color: var(--label);
+ font-size: 0.8125rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background-color 0.2s ease, color 0.2s ease, transform 0.45s var(--ease-spring);
+}
+.chip-btn:active {
+ transform: scale(0.93);
+ transition: transform 0.1s ease-out;
+}
+.chip-btn.active {
+ background: var(--orange);
+ color: var(--on-orange);
+}
-/* ---------- ユーティリティ(CSP で style 属性を使わないため) ---------- */
-.text-center { text-align: center; }
-.mt-4 { margin-top: 4px; }
-.mt-8 { margin-top: 8px; }
-.mt-12 { margin-top: 12px; }
-.key-title { font-size: 18px; text-align: center; }
-.key-hint { text-align: center; margin: 6px 0 16px; }
-.slash { flex: none !important; color: var(--muted); font-weight: 800; }
-label.switch { margin: 0; display: inline-block; }
-.link-gold { color: var(--gold); }
+/* ショーダウンの勝者選択(キャッシュオンリー) */
+.award-panel {
+ margin-bottom: 2px;
+}
+.award-pots {
+ display: grid;
+ gap: 8px;
+ max-height: 36dvh;
+ margin-bottom: 10px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+.award-pot {
+ padding: 10px 12px;
+ border-radius: 14px;
+ background: var(--fill-3);
+}
+.award-title {
+ margin-bottom: 8px;
+ color: var(--label-2);
+ font-size: 0.8125rem;
+}
+.award-title b {
+ color: var(--label);
+ font-family: var(--font-num);
+ font-size: 1rem;
+ font-variant-numeric: tabular-nums;
+}
+.picks {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.pick {
+ -webkit-appearance: none;
+ appearance: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ min-height: 36px;
+ padding: 6px 14px;
+ border: 0;
+ border-radius: 999px;
+ background: var(--fill-2);
+ color: var(--label);
+ font-size: 0.9375rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background-color 0.2s ease, color 0.2s ease, transform 0.45s var(--ease-spring);
+}
+.pick:active {
+ transform: scale(0.94);
+ transition: transform 0.1s ease-out;
+}
+.pick .i {
+ display: none;
+ width: 16px;
+ height: 16px;
+ stroke-width: 2.8;
+}
+.pick.on {
+ background: var(--tint);
+ color: var(--on-tint);
+}
+.pick.on .i {
+ display: block;
+ animation: pop 0.45s var(--ease-bounce);
+}
-/* ---------- レビュー付きCPU戦(ハンドごとのレビュー) ---------- */
-.review-modal { max-width: 440px; max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; }
-.review-buttons { display: grid; grid-template-columns: 1fr 2fr; gap: 8px; margin-top: 16px; }
-.review-buttons:has(> [hidden]) { grid-template-columns: 1fr; }
-.review-copy { width: 100%; margin-top: 8px; }
-.review-link { display: block; margin-top: 12px; font-size: 13px; text-align: center; }
-.rv-head { display: flex; align-items: center; gap: 12px; margin-top: 10px; }
-.rv-cards { display: flex; gap: 3px; }
-.rv-hand { font-size: 20px; }
-.rv-sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
-.rv-sub b { color: var(--text); }
-.rv-section { font-size: 12px; font-weight: 800; color: var(--gold); margin: 14px 0 6px; }
-.rv-item { display: flex; gap: 8px; align-items: flex-start; font-size: 14px; padding: 6px 0; border-top: 1px solid var(--line); }
-.rv-mark { flex: none; width: 24px; text-align: center; font-weight: 900; font-size: 16px; }
-.rv-mark.great { color: var(--green); }
-.rv-mark.good { color: #8fd18f; }
-.rv-mark.warn { color: var(--orange); }
-.rv-mark.bad { color: var(--red); }
-.rv-score { color: var(--gold); font-weight: 800; font-size: 13px; }
-.rv-streets { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 12px; }
-.rv-street { background: var(--panel); border-radius: 10px; padding: 6px 4px; text-align: center; }
-.rv-street span { display: block; font-size: 11px; color: var(--muted); }
-.rv-street b { font-size: 18px; }
-.rv-foot { margin-top: 14px; font-size: 13px; color: var(--muted); }
+/* テーブルが見つからない・終了した */
+.center-message {
+ margin: auto;
+ max-width: 360px;
+ padding: 40px 24px;
+ color: var(--label-2);
+ text-align: center;
+ animation: fade-up 0.5s var(--ease-spring) both;
+}
+.center-message .badge-icon {
+ width: 64px;
+ height: 64px;
+ margin: 0 auto 16px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: var(--fill-3);
+ color: var(--label-2);
+}
+.center-message .badge-icon .i {
+ width: 30px;
+ height: 30px;
+}
+.center-message h2 {
+ margin-bottom: 8px;
+ color: var(--label);
+ font-size: 1.375rem;
+ font-weight: 700;
+ letter-spacing: -0.015em;
+}
+.center-message p {
+ margin-bottom: 24px;
+ line-height: 1.6;
+}
+
+/* ---------- レビュー付きCPU戦(テーブルのシートと /review で共用) ---------- */
+.rv-head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 4px;
+}
+.rv-cards {
+ display: flex;
+ gap: 3px;
+}
+.rv-hand {
+ font-size: 1.25rem;
+ letter-spacing: -0.01em;
+}
+.rv-sub {
+ margin-top: 2px;
+ color: var(--label-2);
+ font-size: 0.8125rem;
+ line-height: 1.5;
+}
+.rv-sub b {
+ color: var(--label);
+}
+.rv-section {
+ margin: 20px 4px 8px;
+ color: var(--label-2);
+ font-size: 0.8125rem;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+}
+.rv-items {
+ padding: 0 14px;
+ border-radius: 14px;
+ background: var(--bg-2);
+}
+.rv-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 10px 0;
+ font-size: 0.9375rem;
+ line-height: 1.5;
+}
+.rv-item + .rv-item {
+ border-top: 0.5px solid var(--separator);
+}
+.rv-mark {
+ flex: none;
+ width: 24px;
+ font-size: 1.0625rem;
+ font-weight: 800;
+ text-align: center;
+}
+.rv-mark.great {
+ color: var(--green);
+}
+.rv-mark.good {
+ color: #64b96a;
+}
+.rv-mark.warn {
+ color: var(--orange);
+}
+.rv-mark.bad {
+ color: var(--red);
+}
+.rv-score {
+ color: var(--gold);
+ font-size: 0.8125rem;
+ font-weight: 700;
+}
+.rv-streets {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 6px;
+ margin-top: 14px;
+}
+.rv-street {
+ padding: 8px 4px;
+ border-radius: 12px;
+ background: var(--bg-2);
+ text-align: center;
+}
+.rv-street span {
+ display: block;
+ color: var(--label-2);
+ font-size: 0.6875rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+.rv-street b {
+ font-family: var(--font-num);
+ font-size: 1.25rem;
+ font-variant-numeric: tabular-nums;
+}
+.rv-foot {
+ margin-top: 14px;
+ color: var(--label-2);
+ font-size: 0.8125rem;
+}
/* 登録してあるハンドレンジの色(レンジ表のデータとしての色なのでサイトの配色とは別) */
-.rv-color { display: inline-block; font-size: 13px; font-weight: 800; padding: 2px 8px; border-radius: 6px; vertical-align: middle; background: var(--c); color: var(--fg); }
-.rv-color.cat-navy { --c: #20365d; --fg: #fff; box-shadow: inset 0 0 0 1px #4d6a9c; }
-.rv-color.cat-red { --c: #9e342e; --fg: #fff; }
-.rv-color.cat-yellow { --c: #ddb356; --fg: #14191f; }
-.rv-color.cat-green { --c: #4e8051; --fg: #101910; }
-.rv-color.cat-blue { --c: #6aa5c9; --fg: #14191f; }
-.rv-color.cat-white { --c: #f7f7f7; --fg: #222; }
-.rv-color.cat-gray { --c: #929292; --fg: #222; }
+.rv-color {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 6px;
+ background: var(--c);
+ color: var(--fg);
+ font-size: 0.8125rem;
+ font-weight: 700;
+ vertical-align: middle;
+}
+.rv-color.cat-navy {
+ --c: #20365d;
+ --fg: #fff;
+ box-shadow: inset 0 0 0 1px #4d6a9c;
+}
+.rv-color.cat-red {
+ --c: #9e342e;
+ --fg: #fff;
+}
+.rv-color.cat-yellow {
+ --c: #ddb356;
+ --fg: #14191f;
+}
+.rv-color.cat-green {
+ --c: #4e8051;
+ --fg: #101910;
+}
+.rv-color.cat-blue {
+ --c: #6aa5c9;
+ --fg: #14191f;
+}
+.rv-color.cat-white {
+ --c: #f7f7f7;
+ --fg: #222;
+ box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.2);
+}
+.rv-color.cat-gray {
+ --c: #929292;
+ --fg: #222;
+}
+
+/* ---------- 背の低い画面(iPhone SE など)では詰める ---------- */
+@media (max-height: 700px) {
+ .table-main {
+ padding-top: calc(env(safe-area-inset-top) + 6px);
+ }
+ .table-bar .glass-btn {
+ width: 36px;
+ height: 36px;
+ }
+ .table-bar h1 {
+ font-size: 1.125rem;
+ }
+ .level-bar {
+ margin-top: 6px;
+ padding-block: 5px;
+ }
+ .level-sub {
+ display: none;
+ }
+ .felt-area {
+ margin: 40px 0 34px;
+ }
+ .felt-area.with-cards {
+ margin-top: 52px;
+ }
+ .pot {
+ font-size: 1.75rem;
+ }
+ .avatar {
+ width: 46px;
+ height: 46px;
+ }
+ .avatar svg {
+ width: 22px;
+ height: 22px;
+ }
+ .avatar .mono {
+ font-size: 1.125rem;
+ }
+ .action-bar .inner {
+ padding-top: 10px;
+ }
+ .my-stack {
+ font-size: 1.375rem;
+ }
+ .status-line {
+ margin: 6px 0 8px;
+ }
+ .actions .btn {
+ min-height: 46px;
+ border-radius: 14px;
+ }
+ .crowded .avatar {
+ width: 36px;
+ height: 36px;
+ }
+ .crowded .avatar svg {
+ width: 17px;
+ height: 17px;
+ }
+ .crowded .avatar .mono {
+ font-size: 0.9375rem;
+ }
+ .crowded .nameplate {
+ min-width: 64px;
+ margin-top: -6px;
+ padding: 2px 5px 3px;
+ }
+ .crowded .pname {
+ font-size: 0.625rem;
+ }
+ .crowded .pstack {
+ font-size: 0.6875rem;
+ }
+ .crowded .chip-marker {
+ width: 19px;
+ height: 19px;
+ font-size: 0.625rem;
+ }
+ .board {
+ min-height: 34px;
+ margin-top: 4px;
+ }
+ .board .pcard {
+ width: 24px;
+ height: 34px;
+ }
+ .board .pcard b {
+ font-size: 0.8125rem;
+ }
+ .board .pcard i {
+ font-size: 0.6875rem;
+ }
+ .my-cards .pcard {
+ width: 36px;
+ height: 50px;
+ }
+ .my-cards .pcard b {
+ font-size: 1.125rem;
+ }
+ .my-cards .pcard i {
+ font-size: 0.9375rem;
+ }
+ .crowded .hole {
+ top: -10px;
+ }
+ .crowded .hole .pcard {
+ width: 17px;
+ height: 24px;
+ }
+ .crowded .hole .pcard b {
+ font-size: 0.625rem;
+ }
+ .crowded .hole .pcard i {
+ font-size: 0.5rem;
+ }
+}
+
+/* ---------- 視差効果を減らす: 動きは短いクロスフェードに ---------- */
+@media (prefers-reduced-motion: reduce) {
+ .pcard.deal,
+ .pcard.flip-in,
+ .act-tag,
+ .bet,
+ .seat.entering,
+ .result,
+ .center-message,
+ .conn-lost,
+ .key-inputs input.filled,
+ .pick.on .i {
+ animation: fade-in 0.2s ease both !important;
+ }
+ .conn-lost {
+ transform: translateX(-50%);
+ }
+ .seat {
+ transition: none;
+ }
+ .seat.turn .avatar,
+ .pcard.hl {
+ transform: none;
+ }
+ .toast,
+ .toast.show {
+ transform: translate3d(-50%, 0, 0);
+ transition: opacity 0.2s ease;
+ }
+ .collapsible {
+ transition: opacity 0.2s ease;
+ }
+ .sheet-backdrop:not([hidden]) {
+ animation: fade-in 0.2s ease both;
+ }
+ .nav-title {
+ transform: none;
+ }
+}
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+}
diff --git a/public/assets/table.js b/public/assets/table.js
index 1938b89..ff12a60 100644
--- a/public/assets/table.js
+++ b/public/assets/table.js
@@ -1,5 +1,6 @@
// プレイ画面: サーバから受け取ったテーブル状態を描画し、アクションを送る
// カード機能がオフのテーブルではカードは実物で配るので、ここで扱うのはチップのやり取りだけ
+// 席・場札・手札の要素は使い回し、変わった所だけ書き換える(配る・めくるなどの動きを途中で作り直さない)
(function () {
'use strict';
@@ -7,10 +8,9 @@
const $ = (id) => document.getElementById(id);
const tableId = decodeURIComponent(location.pathname.split('/').pop());
- const AVATAR_SVG =
- '';
const BOT_SVG =
'';
+ const CHECK_SVG = '';
// U+FE0E: iOS で絵文字として描画されないように
const SUIT_SYMBOL = { s: '♠︎', h: '♥︎', d: '♦︎', c: '♣︎' };
const ACTION_LABEL = { FOLD: 'Fold', CHECK: 'Check', CALL: 'Call', BET: 'Bet', RAISE: 'Raise', ALLIN: 'All-in' };
@@ -22,26 +22,31 @@
let raising = false;
let raiseValue = 0;
let wasMyTurn = false;
- let seenBoard = ''; // 新しくめくられたカードだけアニメーションする
- let seenHand = 0;
+ let seenHand = 0; // 新しいハンドの手札だけ配る動きを付ける
// ショーダウンの勝者選択(ポットごとの席番号の Set)
let awardKey = '';
let awardSel = [];
let seenReview = 0; // 自動で開いたレビューのハンド番号(レビュー付きCPU戦)
let reviewKey = '';
+ const reviewSheet = Fluid.Sheet($('review-modal'));
+ const nameSheet = Fluid.Sheet($('name-modal'), { dismissible: false, focus: '#name-input' });
+
// ---------- 名前 ----------
function ensureName() {
if (Store.name) return connect();
- $('name-modal').hidden = false;
- $('name-input').focus();
+ nameSheet.open();
}
$('name-form').addEventListener('submit', (e) => {
e.preventDefault();
const name = $('name-input').value.trim();
- if (!name) return $('name-input').classList.add('invalid');
+ if (!name) {
+ $('name-input').classList.add('invalid');
+ Fluid.shake($('name-input'));
+ return;
+ }
Store.name = name;
- $('name-modal').hidden = true;
+ nameSheet.close();
connect();
});
@@ -102,6 +107,7 @@
}
$('main').hidden = true;
$('action-bar').hidden = true;
+ reviewSheet.close();
$('not-found').hidden = false;
// 自分から切断するので「接続が切れました」は出さない
if (socket) {
@@ -114,6 +120,14 @@
const nameOf = (seat) => (state.seats[seat] ? state.seats[seat].name : '退席者');
+ // 中身が変わったときだけ書き換える(書き換えたら true)。key を変えると同じ中身でも書き換える
+ function patch(el, html, key = html) {
+ if (el.patchKey === key) return false;
+ el.patchKey = key;
+ el.innerHTML = html;
+ return true;
+ }
+
// ---------- カード(カード機能ありのテーブルのみ) ----------
function cardHtml(c, extra = '') {
if (typeof c !== 'string' || !/^[2-9TJQKA][shdc]$/.test(c)) return ``;
@@ -121,6 +135,30 @@
return `${rank}${SUIT_SYMBOL[c[1]]}`;
}
+ function cardNode(c) {
+ const t = document.createElement('template');
+ t.innerHTML = c ? cardHtml(c) : '';
+ return t.content.firstElementChild;
+ }
+
+ // 入れたばかりのカードに動きを付ける。delay(i): i 枚目を何ms遅らせるか(1枚ずつ配る・めくる)
+ function animateCards(container, cls, delay) {
+ [...container.children].forEach((card, i) => {
+ if (card.classList.contains('slot')) return;
+ card.classList.add(cls);
+ card.style.animationDelay = `${Math.round(delay(i))}ms`;
+ });
+ }
+
+ // 勝った5枚を強調し、それ以外を暗くする(カードは作り直さず印だけ付け替える)
+ function markCards(container, list, markOf) {
+ [...container.children].forEach((node, i) => {
+ const m = list[i] ? markOf(list[i]) : '';
+ node.classList.toggle('hl', m === 'hl');
+ node.classList.toggle('dim', m === 'dim');
+ });
+ }
+
// ショーダウンで勝った5枚(メインポット)
function winningCards(h) {
return h && h.finished && h.showdown && h.results && h.results[0] && h.results[0].bestCards
@@ -128,29 +166,34 @@
: null;
}
+ // 場札: 5つの枠を使い回し、新しく出たカードだけめくる(フロップは少しずつずらして)
function renderBoard(s) {
const el = $('board');
const h = s.hand;
el.hidden = !s.cardsEnabled;
if (!s.cardsEnabled) return;
- if (!h) {
- el.innerHTML = '';
- return;
+ if (!el.slots) {
+ el.innerHTML = ''.repeat(5);
+ el.slots = ['', '', '', '', ''];
}
- const [seenNo, seenLen] = seenBoard.split(':').map(Number);
- const oldLen = seenNo === h.number ? seenLen : 0;
const best = winningCards(h);
- let html = '';
+ let fresh = 0;
for (let i = 0; i < 5; i++) {
- const c = h.board[i];
- if (!c) {
- html += '';
- continue;
+ const c = (h && h.board[i]) || '';
+ if (el.slots[i] !== c) {
+ el.slots[i] = c;
+ const next = cardNode(c);
+ if (c) {
+ next.classList.add('flip-in');
+ next.style.animationDelay = `${fresh++ * 90}ms`;
+ }
+ el.children[i].replaceWith(next);
}
- const cls = [i >= oldLen ? 'deal' : '', best ? (best.has(c) ? 'hl' : 'dim') : ''].join(' ');
- html += cardHtml(c, cls);
+ const node = el.children[i];
+ const mark = c && best ? (best.has(c) ? 'hl' : 'dim') : '';
+ node.classList.toggle('hl', mark === 'hl');
+ node.classList.toggle('dim', mark === 'dim');
}
- el.innerHTML = html;
}
// ---------- 描画 ----------
@@ -165,14 +208,14 @@
$('table-name').textContent = s.name;
$('level').textContent = `Lv.${s.level}`;
- $('blinds').textContent = `SB: ${chips(s.smallBlind)} / BB: ${chips(s.bigBlind)}`;
+ $('blinds').textContent = `SB ${chips(s.smallBlind)} / BB ${chips(s.bigBlind)}`;
$('next-blinds').textContent = s.timer.enabled
- ? `次: ${chips(s.nextBlinds.smallBlind)} / ${chips(s.nextBlinds.bigBlind)}(${s.timer.intervalMin}分毎)`
+ ? `次 ${chips(s.nextBlinds.smallBlind)} / ${chips(s.nextBlinds.bigBlind)}(${s.timer.intervalMin}分毎)`
: 'ブラインドタイマーなし';
$('stack-info').textContent = `開始スタック ${chips(s.startingStack)}`;
// 中央
- $('pot').textContent = chips(h ? h.potTotal : 0);
+ Fluid.countTo($('pot'), h ? h.potTotal : 0, chips);
$('phase').textContent = s.paused ? 'PAUSED' : App.PHASE_LABELS[s.phase] || s.phase;
$('phase').parentElement.classList.toggle('paused', s.paused);
$('phase').parentElement.classList.toggle('showdown', s.phase === 'SHOWDOWN' && !s.paused);
@@ -187,7 +230,6 @@
$('start').disabled = eligible < 2;
$('start').title = eligible < 2 ? '2人以上で開始できます' : '';
$('pause').hidden = !s.started;
- $('pause').textContent = s.paused ? '▶' : '⏸';
$('pause').title = s.paused ? '再開' : '一時停止';
$('pause').setAttribute('aria-label', $('pause').title);
$('pause').classList.toggle('paused', s.paused);
@@ -199,20 +241,18 @@
$('take-seat').hidden = Boolean(me) || !hasFreeSeat;
$('spectator-text').textContent = hasFreeSeat ? '観戦中です' : '満席のため観戦中です';
$('leave').title = me ? '退席する' : 'ロビーへ戻る';
+ $('leave').setAttribute('aria-label', $('leave').title);
+ $('leave').classList.toggle('spectator', !me);
renderSeats(s);
if (me) renderActionBar(s, me);
renderReview(s);
renderTimer();
- // 自分の番になったら知らせる(タップ前は振動できない)
- const tapped = !navigator.userActivation || navigator.userActivation.hasBeenActive;
- if (myTurn && !wasMyTurn && navigator.vibrate && tapped) navigator.vibrate(80);
+ // 自分の番になったら知らせる
+ if (myTurn && !wasMyTurn) Fluid.haptic(80);
wasMyTurn = myTurn;
- if (h) {
- seenBoard = `${h.number}:${h.board.length}`;
- seenHand = h.number;
- }
+ if (h) seenHand = h.number;
}
function renderResult(s) {
@@ -222,17 +262,60 @@
el.hidden = true;
return;
}
- el.innerHTML = h.results
- .map((r, i) => {
- const label = h.results.length > 1 ? `${i === 0 ? 'メイン' : 'サイド'}` : '';
- const who = r.winners.map((w) => esc(nameOf(w))).join('・');
- const hand = r.handName ? ` ${esc(r.handName)}` : '';
- return `
-
- ${esc(t.name)}${phaseBadge(t)}
-
- 👤 ${t.playerCount}/${t.maxSeats}
- 開始 ${chips(t.startingStack)}
- Lv.${t.level} ${chips(t.smallBlind)}/${chips(t.bigBlind)}
- ${timer}
- ${MODE_LABEL[t.mode] || ''}
-
-
- ${admin ? `削除 ` : ''}
-
- ${full ? '観戦' : '参加'}
-
-
- ${label}${who} +${chips(r.amount)}${hand}
`;
- })
- .join('');
+ patch(
+ el,
+ h.results
+ .map((r, i) => {
+ const label = h.results.length > 1 ? `${i === 0 ? 'メイン' : 'サイド'}` : '';
+ const who = r.winners.map((w) => esc(nameOf(w))).join('・');
+ const hand = r.handName ? ` ${esc(r.handName)}` : '';
+ return `${label}${who} +${chips(r.amount)}${hand}
`;
+ })
+ .join('')
+ );
el.hidden = false;
}
+ // ---------- 席 ----------
+ const seatEls = new Map(); // 席番号 → 要素
+
+ function seatEl(no) {
+ let el = seatEls.get(no);
+ if (el) return el;
+ el = document.createElement('div');
+ el.className = 'seat entering';
+ el.innerHTML =
+ '' +
+ '
' +
+ '';
+ const q = (sel) => el.querySelector(sel);
+ el.parts = {
+ avatar: q('.avatar'),
+ face: q('.face'),
+ markers: q('.markers'),
+ hole: q('.hole'),
+ tag: q('.tag-slot'),
+ name: q('.pname'),
+ stack: q('.pstack'),
+ note: q('.pnote'),
+ bet: q('.bet-slot'),
+ };
+ el.addEventListener('animationend', (e) => {
+ if (e.target === el) el.classList.remove('entering');
+ });
+ seatEls.set(no, el);
+ $('seats').append(el);
+ return el;
+ }
+
+ // 名前の頭文字と、名前から決まる色(同じ名前なら毎回同じ色)
+ const initialOf = (name) => [...String(name).trim()][0] || '?';
+ function hueOf(name) {
+ let h = 0;
+ for (const ch of String(name)) h = (h * 31 + ch.codePointAt(0)) % 360;
+ return h;
+ }
+
// 着席者を楕円上に配置。自分が一番下、そこから時計回り。
function renderSeats(s) {
const h = s.hand;
@@ -240,84 +323,98 @@
const startIdx = Math.max(0, occupied.findIndex((p) => p.seat === s.mySeat));
const ordered = occupied.slice(startIdx).concat(occupied.slice(0, startIdx));
const n = ordered.length;
- const angles = ordered.map((_, i) => Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1));
const showdown = s.phase === 'SHOWDOWN';
const finished = Boolean(h && h.finished);
const best = winningCards(h);
+ const newHand = Boolean(h && h.number !== seenHand);
+ const area = $('felt-area');
+ area.classList.toggle('crowded', n >= 7); // 7人以上は小さめに表示
+ area.classList.toggle('with-cards', s.cardsEnabled);
+ const live = new Set();
- const container = $('seats');
- $('felt-area').classList.toggle('crowded', n >= 7); // 7人以上は小さめに表示
- $('felt-area').classList.toggle('with-cards', s.cardsEnabled);
- container.innerHTML = ordered
- .map((p, idx) => {
- const classes = ['seat'];
- if (p.seat === s.mySeat) classes.push('me');
- if (p.bot) classes.push('bot');
- if (!p.connected) classes.push('offline');
- if (p.inHand && p.folded) classes.push('folded');
- if (s.toAct === p.seat) classes.push('turn');
- if (showdown && p.inHand && !p.folded) classes.push('contender');
- if (finished && p.won > 0) classes.push('winner');
- if (p.sittingOut && !p.inHand) classes.push('away');
+ ordered.forEach((p, idx) => {
+ live.add(p.seat);
+ const el = seatEl(p.seat);
+ const q = el.parts;
+ const angle = Math.PI / 2 + (idx * 2 * Math.PI) / Math.max(n, 1);
+ const left = 50 + 38 * Math.cos(angle);
+ const top = 50 + 41 * Math.sin(angle);
+ // 位置は CSSOM で設定(CSP で style 属性を禁止しているため)
+ el.style.left = `${left.toFixed(2)}%`;
+ el.style.top = `${top.toFixed(2)}%`;
+ // 配るカードはテーブルの中央(山札)から飛んでくる
+ el.style.setProperty('--deal-x', `${(((50 - left) / 100) * area.clientWidth).toFixed(1)}px`);
+ el.style.setProperty('--deal-y', `${(((50 - top) / 100) * area.clientHeight).toFixed(1)}px`);
- const markers = h
+ const set = (cls, on) => el.classList.toggle(cls, Boolean(on));
+ set('me', p.seat === s.mySeat);
+ set('bot', p.bot);
+ set('offline', !p.connected);
+ set('folded', p.inHand && p.folded);
+ set('turn', s.toAct === p.seat);
+ set('contender', showdown && p.inHand && !p.folded);
+ set('winner', finished && p.won > 0);
+ set('away', p.sittingOut && !p.inHand);
+
+ patch(q.face, p.bot ? BOT_SVG : `${esc(initialOf(p.name))}`);
+ q.avatar.style.setProperty('--hue', hueOf(p.name));
+
+ patch(
+ q.markers,
+ h
? [
p.seat === h.button ? 'D' : '',
p.seat === h.sbSeat ? 'SB' : '',
p.seat === h.bbSeat ? 'BB' : '',
].join('')
- : '';
+ : ''
+ );
- // 手札(自分と公開された人は表、それ以外は裏)
- let cards = '';
- if (s.cardsEnabled && p.inHand && (!p.folded || p.cards)) {
- const deal = h.number !== seenHand ? 'deal' : '';
- const list = p.cards || [null, null];
- const mark = (c) => (best && c ? (best.has(c) ? 'hl' : 'dim') : p.folded ? 'dim' : '');
- cards = `
${list.map((c) => cardHtml(c, `${deal} ${mark(c)}`)).join('')}
`;
- }
+ // 手札(自分と公開された人は表、それ以外は裏)
+ const list = s.cardsEnabled && h && p.inHand && (!p.folded || p.cards) ? p.cards || [null, null] : [];
+ const hadBacks = Boolean(q.hole.querySelector('.back'));
+ if (patch(q.hole, list.map((c) => cardHtml(c)).join(''), `${h ? h.number : 0}|${list.join()}`) && list.length) {
+ // 新しいハンドは中央から1人1枚ずつ2周配り、ショーダウンで公開された手札はめくる
+ if (newHand) animateCards(q.hole, 'deal', (i) => (i * n + idx) * 55);
+ else if (hadBacks && !q.hole.querySelector('.back')) animateCards(q.hole, 'flip-in', (i) => i * 90);
+ }
+ markCards(q.hole, list, (c) => (best ? (best.has(c) ? 'hl' : 'dim') : p.folded ? 'dim' : ''));
- // ベット(中央寄り)・獲得額
- const below = Math.sin(angles[idx]) <= 0.35;
- let bet = '';
- if (finished && p.won > 0) bet = `+${chips(p.won)}
`;
- else if (p.bet > 0) bet = `${chips(p.bet)}
`;
+ let tag = '';
+ if (p.last && !(finished && p.won > 0)) {
+ tag = `${ACTION_LABEL[p.last.type] || ''}`;
+ } else if (p.sittingOut && !p.inHand) {
+ tag = '離席中';
+ }
+ patch(q.tag, tag);
- let tag = '';
- if (p.last && !(finished && p.won > 0)) {
- tag = `${ACTION_LABEL[p.last.type] || ''}
`;
- } else if (p.sittingOut && !p.inHand) {
- tag = '離席中
';
- }
+ q.name.textContent = p.name;
+ q.stack.textContent = p.allIn && !finished ? 'ALL-IN' : chips(p.stack);
+ q.note.textContent = !p.connected ? 'オフライン' : p.handName && p.seat !== s.mySeat ? p.handName : '';
+ q.note.classList.toggle('offline', !p.connected);
- return `
-
-
`;
- })
- .join('');
-
- // 位置は CSSOM で設定(CSP で style 属性を禁止しているため)
- [...container.children].forEach((el, i) => {
- el.style.left = `${(50 + 38 * Math.cos(angles[i])).toFixed(2)}%`;
- el.style.top = `${(50 + 41 * Math.sin(angles[i])).toFixed(2)}%`;
+ // ベット(中央寄り)・獲得額
+ const side = Math.sin(angle) <= 0.35 ? 'below' : 'above';
+ let bet = '';
+ if (finished && p.won > 0) bet = `+${chips(p.won)}`;
+ else if (p.bet > 0) bet = `${chips(p.bet)}`;
+ patch(q.bet, bet);
});
+
+ for (const [no, el] of seatEls) {
+ if (live.has(no)) continue;
+ el.remove();
+ seatEls.delete(no);
+ }
}
+ // ---------- 自分の操作エリア ----------
function renderActionBar(s, seat) {
const m = s.me;
const h = s.hand;
const o = m.options;
- $('my-stack').textContent = chips(m.stack);
+ Fluid.countTo($('my-stack'), m.stack, chips);
const net = m.net;
$('my-net').textContent = `収支 ${net > 0 ? '+' : net < 0 ? '−' : '±'}${chips(Math.abs(net))}`;
$('my-net').className = net > 0 ? 'plus' : net < 0 ? 'minus' : '';
@@ -326,11 +423,16 @@
$('my-cards').hidden = !s.cardsEnabled;
if (s.cardsEnabled) {
- const myCards = seat.inHand && seat.cards ? seat.cards : null;
- const best = winningCards(h);
- $('my-cards').innerHTML = myCards
- ? myCards.map((c) => cardHtml(c, [seat.folded ? 'dim' : '', best && best.has(c) ? 'hl' : ''].join(' '))).join('')
+ const el = $('my-cards');
+ const myCards = seat.inHand && seat.cards ? seat.cards : [];
+ const base = myCards.length
+ ? myCards.map((c) => cardHtml(c)).join('')
: '';
+ if (patch(el, base, `${h ? h.number : 0}|${myCards.join()}`) && myCards.length) {
+ animateCards(el, 'deal', (i) => 120 + i * 140);
+ }
+ const best = winningCards(h);
+ markCards(el, myCards, (c) => (best && best.has(c) ? 'hl' : seat.folded ? 'dim' : ''));
}
$('sit-out').hidden = m.sittingOut;
@@ -352,8 +454,10 @@
else if (h && !h.finished && !seat.inHand) status = '次のハンドから参加します';
else if (h && h.finished) status = '次のハンドを準備中…';
else if (!h) status = '参加者を待っています(2人以上で再開)';
+ const myMove = Boolean(o) || (awaiting && m.canAward);
$('status-line').textContent = status;
- $('status-line').classList.toggle('my-turn', Boolean(o) || (awaiting && m.canAward));
+ $('status-line').classList.toggle('my-turn', myMove);
+ $('action-bar').classList.toggle('my-turn', myMove && !s.paused);
// ショーダウンの勝者選択
const awarding = awaiting && m.canAward && !s.paused;
@@ -371,8 +475,8 @@
$('rebuy').textContent = `リバイ(${chips(s.startingStack)})`;
$('actions').hidden = alt || raising || awarding || reviewWaiting;
$('raise-actions').hidden = alt || !raising;
- $('raise-panel').hidden = alt || !raising;
- $('status-line').hidden = raising;
+ // レイズ額のパネルはレイズボタンから出て、閉じるときはボタンへ戻る
+ Fluid.presence($('raise-panel'), !alt && raising, { scale: 0.86, y: 28 });
const fold = $('act-fold');
const call = $('act-call');
@@ -408,7 +512,7 @@
const l = r.last;
const h = l.hero;
const pre = l.preflop.length
- ? l.preflop
+ ? `${p.bot ? BOT_SVG : AVATAR_SVG}${markers}${cards}
-
- ${tag}
-
- ${bet}
- ${esc(p.name)}
- ${p.allIn && !finished ? 'ALL-IN' : chips(p.stack)}
- ${p.handName && p.seat !== s.mySeat ? `${esc(p.handName)}
` : ''}
- ${p.connected ? '' : 'オフライン
'}
- ${l.preflop
.map(
(j) => `
`
: '${markHtml(j.mark)}
`
)
- .join('')
+ .join('')}
@@ -417,7 +521,7 @@
${j.equity != null ? `
推定勝率 ${j.equity}%(コールに必要 ${j.need}%)
` : ''}
プリフロップで判断する場面はありませんでした
';
const post = l.decisions
.map(
@@ -449,7 +553,7 @@
${scores}
プリフロップ(ハンドレンジ)
${pre}
- ${post ? `フロップ以降(期待値の目安)
${post}` : ''}
+ ${post ? `フロップ以降(期待値の目安)
${post}
` : ''}
${allIn}
このハンドの収支 ${signed(h.net)} ・ 平均 ${r.average == null ? '—' : r.average}点(${r.hands}ハンド)
`;
}
@@ -458,7 +562,7 @@
const r = s.review;
$('review-open').hidden = !(r && r.last);
if (!r || !r.last) {
- $('review-modal').hidden = true;
+ reviewSheet.close();
return;
}
$('review-link').href = r.sessionId ? `/review?s=${encodeURIComponent(r.sessionId)}` : '/review';
@@ -471,7 +575,7 @@
// 新しいハンドのレビューが届いたら自動で開く
if (r.last.number !== seenReview) {
seenReview = r.last.number;
- if (r.waiting) $('review-modal').hidden = false;
+ if (r.waiting) reviewSheet.open();
}
}
@@ -486,15 +590,12 @@
}
}
- const openReview = () => { $('review-modal').hidden = false; };
+ const openReview = () => reviewSheet.open();
$('review-open').addEventListener('click', openReview);
$('review-show').addEventListener('click', openReview);
- $('review-close').addEventListener('click', () => { $('review-modal').hidden = true; });
- $('review-modal').addEventListener('click', (e) => {
- if (e.target === $('review-modal')) $('review-modal').hidden = true;
- });
+ $('review-close').addEventListener('click', () => reviewSheet.close());
const nextHand = () => {
- $('review-modal').hidden = true;
+ reviewSheet.close();
emit('table:next');
};
$('review-copy').addEventListener('click', () => copyHandPrompt(state && state.review));
@@ -509,18 +610,21 @@
// 参加者が1人だけのポットは最初から選んでおく
awardSel = pots.map((p) => new Set(p.eligible.length === 1 ? p.eligible : []));
}
- $('award-pots').innerHTML = pots
- .map((p, i) => {
- const title = pots.length === 1 ? 'ポット' : i === 0 ? 'メインポット' : `サイドポット${pots.length > 2 ? i : ''}`;
- const names = p.eligible
- .map(
- (seat) =>
- `${title} ${chips(p.amount)}
${names}
${title} ${chips(p.amount)}
${names}