Rebuild the UI in an Apple style with spring-driven motion

Replace the look of every page (key, lobby, create, table, range, review)
with light/dark semantic colors, translucent materials, iOS-style grouped
lists and system typography. Add fluid.js, a small spring engine with
interruptible, velocity-aware animations, and use it for draggable bottom
sheets, sliding segmented controls, a custom bet slider with rubber-banding,
swipe-to-delete in the lobby, and dealt/flipped cards. Seats and cards are
now reused between renders so animations are not cut off. Honors reduced
motion, reduced transparency and increased contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-21 21:44:21 +09:00
1 parent d5ef2d310f
commit c3fbfdd12e
18 files changed
+4877 -1278

No files matched your search

+51 -29
View File
@@ -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 =
'<div class="modal confirm-modal" role="alertdialog" aria-modal="true">' +
(o.title ? '<h2 class="confirm-title"></h2>' : '') +
'<p class="confirm-message"></p>' +
'<div class="confirm-buttons">' +
'<button type="button" class="btn btn-dark" data-confirm="cancel"></button>' +
`<button type="button" class="btn ${o.danger ? 'btn-red' : 'btn-orange'}" data-confirm="ok"></button>` +
'<div class="alert" role="alertdialog" aria-modal="true">' +
'<div class="alert-body">' +
(o.title ? '<h2 class="alert-title"></h2>' : '') +
'<p class="alert-message"></p>' +
'</div>' +
'<div class="alert-buttons">' +
'<button type="button" class="alert-btn" data-confirm="cancel"></button>' +
`<button type="button" class="alert-btn ${o.danger ? 'destructive' : 'default'}" data-confirm="ok"></button>` +
'</div></div>';
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 =
'<div class="modal copy-modal" role="dialog" aria-modal="true">' +
'<h2 class="confirm-title">コピーしてください</h2>' +
const root = document.createElement('div');
root.className = 'sheet-backdrop';
root.hidden = true;
root.innerHTML =
'<div class="sheet" role="dialog" aria-modal="true" aria-label="コピーしてください" tabindex="-1">' +
'<div class="sheet-header" data-sheet-drag><span class="sheet-grabber" aria-hidden="true"></span>' +
'<h2 class="sheet-title">コピーしてください</h2></div>' +
'<div class="sheet-scroll">' +
'<p class="hint">自動でコピーできませんでした。下の文章を全選択してコピーしてください。</p>' +
'<textarea class="copy-text" readonly></textarea>' +
'<button type="button" class="btn btn-dark copy-close">閉じる</button>' +
'</div>' +
'<div class="sheet-footer"><button type="button" class="btn btn-lg btn-block copy-close">閉じる</button></div>' +
'</div>';
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() で末尾までスクロールするので先頭に戻す
}