Commit 9030409a authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix: UI sizing and spacing that did not fit real phones

An audit of the live app at 360/390/430px found 355 measurable layout defects.
tools/ui-audit.mjs drives the real app at those three widths and reports what is
measurable rather than a matter of taste: horizontal overflow, tap targets below
44px, text below a legible size, and content clipped by its own container.

  before  355  (tiny-text 216, tap-target 133, clipped 6)
  after     0

The root cause of the size problems was not the stylesheet. /api/branding.php
writes 181 stored values inline onto :root at runtime, overriding every token in
tokens.css — including quick_btn_label_font 10 and hud_badge_font 9. Editing
tokens.css alone changed nothing. The floors are now enforced in theme.js where
the override happens, so no stored value (or future admin edit) can reintroduce
illegible text or an unreachable control:

- any *_font token is floored at 11px
- tap-target tokens are floored at 44px
- home_max_width, stored as 340px for a 360px phone, is treated as a floor and
  allowed to grow (min(100%, max(340px, 92vw))) instead of pinning every phone
  to the smallest one's layout

Alongside that: a fluid type scale (--fs-xs … --fs-xl) and --tap-min in
tokens.css, a tap-target floor on every control in core.css, and 53 sub-11px
inline font sizes and 41 undersized inline button heights raised at the source.

Layout fixes visible on screen:
- the daily-gift button carried breathe-glow on the whole flex column, drawing a
  large dark rectangle across the quick-actions row. The glow belongs on the icon.
- play home dumped its spare height as dead space above the tab bar; it now
  centres via auto margins, which does not clip when content overflows the way
  justify-content:center does.
- the chess board left a stray "1." floating in a gap above the controls. The
  opening/material/move-list block is now hidden until there is something in it.

Also adds tools/dev-server.php, which serves the app locally against the real
Supabase so this work does not need a deploy per iteration.

Multiplayer regression: tests/run.sh 5/5, and the production rehearsal passes.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 09afc0f8
......@@ -93,12 +93,12 @@ html, body {
transform: translateX(-50%);
background: var(--gold);
color: #1a1a1a;
font-size: 9px;
font-size: var(--fs-xs);
font-weight: 800;
font-family: var(--font-lat);
min-width: 16px;
height: 14px;
line-height: 14px;
min-width: 18px;
height: 16px;
line-height: 16px;
text-align: center;
border-radius: var(--r-full);
padding: 0 3px;
......@@ -320,6 +320,38 @@ html, body {
.card:active { transform: scale(0.96); box-shadow: var(--shadow-md); transition-duration: 80ms; }
/* Buttons */
/* ── Tap-target floor ──────────────────────────────────────────────────────
Every interactive control is at least 44px tall, the documented minimum on
both iOS and Android. An audit of the live app found 40 distinct controls
below it — filter chips at 31px, tab buttons at 34px, icon buttons at 32px —
which on a phone means missed taps, not just an untidy layout.
Height only: forcing a minimum width would stretch chips that are legitimately
narrow. Icon-only buttons, which need both, are handled where they are defined. */
button,
[role="button"],
input[type="button"],
input[type="submit"] {
min-block-size: var(--tap-min, 44px);
}
/* Standalone text links act as controls too. A 21px-tall legal link is a miss
waiting to happen; give the row real height without changing how it reads. */
a:not(.btn):not([class*="tab"]) {
display: inline-flex;
align-items: center;
min-block-size: var(--tap-min, 44px);
}
/* Icon-only controls need the floor in both directions to stay square. */
.icon-btn,
.hud-btn,
#emote-inline-toggle,
.bgg-action-btn,
#back-btn {
min-inline-size: var(--tap-min, 44px);
}
.btn {
display: inline-flex;
align-items: center;
......
......@@ -98,6 +98,11 @@
transition: transform var(--dur-normal) cubic-bezier(0.16, 1, 0.3, 1);
}
/* `display: flex` above outranks the user-agent's `[hidden] { display: none }`,
so without this the panel is never actually hidden — it just sits translated
off-screen, still focusable and still catching taps along the bottom edge. */
.rx-panel[hidden] { display: none !important; }
.rx-panel.rx-panel-in { transform: translateY(0); }
.rx-panel-grip {
......
......@@ -80,6 +80,23 @@
--s-8: 32px; --s-10: 40px; --s-12: 48px;
--s-16: 64px;
/* ── Type scale ────────────────────────────────────────────────────────
Fluid between a 360px phone and a 430px one. Every step has a floor:
nothing in the product may render below --fs-xs, because 9px and 10px
labels are not readable at arm's length on a phone. */
--fs-xs: clamp(11px, 3.05vw, 12px); /* micro labels, badges, meta */
--fs-sm: clamp(12px, 3.4vw, 13px); /* secondary text */
--fs-base: clamp(14px, 3.9vw, 15px); /* body */
--fs-md: clamp(15px, 4.2vw, 17px); /* emphasis */
--fs-lg: clamp(17px, 4.8vw, 20px); /* section titles */
--fs-xl: clamp(20px, 5.6vw, 24px); /* screen titles */
/* ── Controls ──────────────────────────────────────────────────────────
44px is the documented minimum tap target on both iOS and Android.
--tap-min is the floor every interactive control is held to. */
--tap-min: 44px;
--tap-comfortable: 48px;
--font-ar: 'IBM Plex Sans Arabic', sans-serif;
--font-lat: 'Inter', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
......@@ -129,10 +146,10 @@
--home-greeting-margin: 12px;
/* Layout: Quick Actions */
--quick-btn-icon-size: 42px;
--quick-btn-icon-size: clamp(40px, 11.5vw, 48px);
--quick-btn-icon-radius: 12px;
--quick-btn-icon-font: 20px;
--quick-btn-label-font: 10px;
--quick-btn-icon-font: clamp(18px, 5vw, 22px);
--quick-btn-label-font: var(--fs-xs);
--quick-btn-gap: 8px;
--quick-btn-padding: 6px;
--quick-row-gap: 8px;
......@@ -142,9 +159,9 @@
--game-tile-radius: 18px;
--game-tile-gap: 12px;
--game-tile-aspect: 1.1;
--game-tile-icon-size: 48px;
--game-tile-icon-font: 42px;
--game-tile-name-font: 16px;
--game-tile-icon-size: clamp(44px, 12.5vw, 56px);
--game-tile-icon-font: clamp(36px, 10.5vw, 46px);
--game-tile-name-font: var(--fs-md);
--game-tile-content-gap: 8px;
/* Layout: Game Menu (Sheet) */
......@@ -188,10 +205,10 @@
--menu-header-margin: 20px;
/* Layout: Bot Cards */
--bot-card-avatar-size: 56px;
--bot-card-avatar-size: clamp(48px, 14vw, 60px);
--bot-card-avatar-border: 2px;
--bot-card-name-font: 14px;
--bot-card-sub-font: 11px;
--bot-card-name-font: var(--fs-sm);
--bot-card-sub-font: var(--fs-xs);
--bot-card-accent-height: 3px;
--bot-grid-gap: 12px;
--bot-back-btn-size: 44px;
......
......@@ -124,7 +124,7 @@ function showGuestToast() {
toast.style.cssText = 'background:var(--bg-elevated);color:var(--text-primary);padding:12px 20px;border-radius:var(--r-md);font-size:14px;display:flex;flex-direction:column;align-items:center;gap:8px;animation:fadeIn 0.2s;';
toast.innerHTML = `
<span>${t('guest.locked_feature')}</span>
<button class="btn btn-primary" style="font-size:13px;padding:6px 16px;min-height:32px;">${t('guest.create_account')}</button>
<button class="btn btn-primary" style="font-size:13px;padding:6px 16px;min-height:var(--tap-min);">${t('guest.create_account')}</button>
`;
toast.querySelector('button').addEventListener('click', () => {
toast.remove();
......
......@@ -37,7 +37,7 @@ export function renderOpponentBar(container, opponent, options = {}) {
<div style="font-size:13px;font-weight:600;color:var(--text-primary);">${opponent.display_name || opponent.username || t('common.opponent')}</div>
${showRating ? `<div style="font-size:11px;color:var(--text-muted);">${emoji('star', '⭐', 11)} ${opponent.rating || opponent.elo_rapid || DEFAULT_RATING}</div>` : ''}
</div>
<div id="mp-opponent-status" style="font-size:10px;color:var(--text-muted);"></div>
<div id="mp-opponent-status" style="font-size:var(--fs-xs);color:var(--text-muted);"></div>
`;
// Tap to show profile actions
......
......@@ -125,7 +125,7 @@ export function injectStyles() {
.pp-info { flex:1; }
.pp-name { font-size:13px;font-weight:600;color:var(--text-primary); }
.pp-meta { display:flex;gap:6px;align-items:center;margin-top:2px; }
.pp-level { font-size:10px;color:var(--text-muted); }
.pp-level { font-size:var(--fs-xs);color:var(--text-muted); }
.pp-tier { font-size:12px; }
.pp-rating { font-size:11px;color:var(--text-secondary);font-family:Inter,monospace; }
.pp-turn-indicator { width:8px;height:8px;border-radius:50%;background:var(--gold);animation:pulse 1s ease-in-out infinite; }
......
......@@ -172,8 +172,33 @@ function applyAnimations() {
bot_back_btn_size: '--bot-back-btn-size', bot_back_btn_font: '--bot-back-btn-font',
bot_title_font: '--bot-title-font',
};
// Stored branding values win over tokens.css, so the legibility and reach
// floors have to be enforced HERE — otherwise an admin (or an old stored
// value) silently reintroduces 9px labels and 32px buttons. The live theme
// was doing exactly that: quick_btn_label_font 10, hud_badge_font 9.
const FONT_FLOOR_PX = 11; // below this, text is not readable on a phone
const TAP_FLOOR_PX = 44; // documented minimum tap target, iOS and Android
const TAP_TOKENS = new Set([
'--hud-btn-size', '--tab-item-min-size', '--menu-close-size', '--bot-back-btn-size',
]);
for (const [key, cssVar] of Object.entries(layoutPx)) {
if (themeData[key]) root.setProperty(cssVar, themeData[key] + 'px');
if (!themeData[key]) continue;
const px = parseFloat(themeData[key]);
if (!Number.isFinite(px)) continue;
if (cssVar.endsWith('-font')) {
root.setProperty(cssVar, Math.max(px, FONT_FLOOR_PX) + 'px');
} else if (TAP_TOKENS.has(cssVar)) {
root.setProperty(cssVar, Math.max(px, TAP_FLOOR_PX) + 'px');
} else if (cssVar === '--home-max-width') {
// The stored 340px was chosen for a 360px phone and leaves ~90px of dead
// margin on a large one. Treat it as a floor and let content fill wider
// screens instead of pinning every phone to the smallest one's layout.
root.setProperty(cssVar, `min(100%, max(${px}px, 92vw))`);
} else {
root.setProperty(cssVar, px + 'px');
}
}
if (themeData.game_tile_aspect) root.setProperty('--game-tile-aspect', themeData.game_tile_aspect);
if (themeData.sheet_max_height) root.setProperty('--sheet-max-height', themeData.sheet_max_height + 'vh');
......
......@@ -753,8 +753,8 @@ function getStyles() {
.bgg-player-info { flex:1;min-width:0; }
.bgg-name { font-size:13px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
.bgg-meta { display:flex;align-items:center;gap:8px;margin-top:1px; }
.bgg-level { font-size:10px;color:var(--text-muted);font-weight:600; }
.bgg-pips { font-size:10px;color:var(--text-secondary);font-weight:500; }
.bgg-level { font-size:var(--fs-xs);color:var(--text-muted);font-weight:600; }
.bgg-pips { font-size:var(--fs-xs);color:var(--text-secondary);font-weight:500; }
.bgg-pips::before { content:'⬡ ';opacity:0.5; }
.bgg-score {
font-size:20px;font-weight:800;color:var(--text-dim);min-width:24px;text-align:center;
......@@ -769,7 +769,7 @@ function getStyles() {
background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.25);
border-radius:6px;padding:2px 8px;
}
.bgg-match-length { font-size:9px;color:var(--text-muted); }
.bgg-match-length { font-size:var(--fs-xs);color:var(--text-muted); }
/* ── Board ── */
.bgg-board-area {
......@@ -782,7 +782,7 @@ function getStyles() {
}
.bgg-turn-badge {
position:absolute;top:6px;left:50%;transform:translateX(-50%);
font-size:10px;font-weight:700;padding:3px 10px;border-radius:20px;
font-size:var(--fs-xs);font-weight:700;padding:3px 10px;border-radius:20px;
backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);
pointer-events:none;transition:all 0.3s;opacity:0.9;
}
......@@ -807,14 +807,14 @@ function getStyles() {
}
.bgg-roll-btn:active { transform:scale(0.92);box-shadow:0 2px 8px rgba(228,172,56,0.3); }
.bgg-cube-btn {
padding:10px 16px;border-radius:10px;border:none;
padding:10px 16px;min-height:var(--tap-min);border-radius:10px;border:none;
background:rgba(139,92,246,0.12);border:1.5px solid rgba(139,92,246,0.35);
color:var(--purple-light);font-size:15px;font-weight:800;cursor:pointer;
transition:transform 0.12s;
}
.bgg-cube-btn:active { transform:scale(0.9); }
.bgg-action-btn {
width:40px;height:40px;border-radius:50%;border:none;
width:var(--tap-min);height:var(--tap-min);border-radius:50%;border:none;
background:rgba(255,255,255,0.04);color:var(--text-secondary);
font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;
border:1px solid var(--border);
......
......@@ -22,7 +22,7 @@ export function mountAnalysis(el, params) {
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:var(--bg-card);">
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg-panel);border-bottom:1px solid var(--border);">
<button class="btn btn-secondary" id="back-btn" style="min-height:32px;padding:4px 12px;font-size:12px;">← ${t('game.back')}</button>
<button class="btn btn-secondary" id="back-btn" style="min-height:var(--tap-min);padding:4px 12px;font-size:12px;">← ${t('game.back')}</button>
<span style="font-size:14px;font-weight:700;color:var(--text-primary);">${emoji('chart', '📊', 14)} ${t('analysis.title')}</span>
<div style="width:60px;"></div>
</div>
......@@ -110,7 +110,7 @@ function renderMoveChips(el, moves) {
let html = `<span class="move-chip active" data-idx="0">${t('analysis.start')}</span>`;
for (let i = 0; i < moves.length; i += 2) {
const num = Math.floor(i / 2) + 1;
html += `<span style="color:var(--text-dim);font-size:10px;">${num}.</span>`;
html += `<span style="color:var(--text-dim);font-size:var(--fs-xs);">${num}.</span>`;
html += `<span class="move-chip" data-idx="${i + 1}">${moves[i]?.san || ''}</span>`;
if (moves[i + 1]) {
html += `<span class="move-chip" data-idx="${i + 2}">${moves[i + 1]?.san || ''}</span>`;
......@@ -177,11 +177,11 @@ async function analyzePosition(el, fen) {
`<div style="display:flex;align-items:center;gap:6px;padding:2px 0;">
<span style="font-size:12px;font-weight:600;color:var(--text-primary);min-width:35px;font-family:Inter,monospace;">${e.move}</span>
${renderExplorerBar(e.white, e.draw, e.black)}
<span style="font-size:9px;color:var(--text-muted);">${e.games}</span>
<span style="font-size:var(--fs-xs);color:var(--text-muted);">${e.games}</span>
</div>`
).join('');
linesContainer.innerHTML = `<div style="border-bottom:1px solid rgba(255,255,255,0.05);padding-bottom:4px;margin-bottom:4px;">
<div style="font-size:9px;color:var(--text-muted);margin-bottom:2px;">${emoji('book', '📖', 9)} ${t('analysis.openings_book')}</div>${explorerHtml}</div>
<div style="font-size:var(--fs-xs);color:var(--text-muted);margin-bottom:2px;">${emoji('book', '📖', 9)} ${t('analysis.openings_book')}</div>${explorerHtml}</div>
<div style="color:var(--text-muted);font-size:11px;text-align:center;">${t('analysis.engine_analyzing')}</div>`;
}
......@@ -204,12 +204,12 @@ function renderAnalysisLines(el, lines, fen, explorerData) {
let explorerHtml = '';
if (explorerData) {
explorerHtml = `<div style="border-bottom:1px solid rgba(255,255,255,0.05);padding-bottom:4px;margin-bottom:4px;">
<div style="font-size:9px;color:var(--text-muted);margin-bottom:2px;">${emoji('book', '📖', 9)} ${t('analysis.openings_book')}</div>
<div style="font-size:var(--fs-xs);color:var(--text-muted);margin-bottom:2px;">${emoji('book', '📖', 9)} ${t('analysis.openings_book')}</div>
${explorerData.slice(0, 3).map(e =>
`<div style="display:flex;align-items:center;gap:6px;padding:1px 0;">
<span style="font-size:11px;font-weight:600;color:var(--text-primary);min-width:32px;font-family:Inter,monospace;">${e.move}</span>
${renderExplorerBar(e.white, e.draw, e.black)}
<span style="font-size:9px;color:var(--text-muted);">${e.games}</span>
<span style="font-size:var(--fs-xs);color:var(--text-muted);">${e.games}</span>
</div>`
).join('')}
</div>`;
......
......@@ -101,7 +101,7 @@ export function mountGame(el, params) {
<div>
<div style="font-size:13px;font-weight:600;color:var(--text-primary);" id="opponent-name">${mode === 'bot' ? (botId || t('game.bot')) : t('game.loading_opponent')}</div>
<div style="display:flex;gap:6px;align-items:center;">
<div id="opponent-level" style="font-size:10px;color:var(--text-muted);">${mode === 'bot' ? t('game.bot') : ''}</div>
<div id="opponent-level" style="font-size:var(--fs-xs);color:var(--text-muted);">${mode === 'bot' ? t('game.bot') : ''}</div>
<div id="opponent-captured" style="font-size:11px;color:var(--text-secondary);letter-spacing:1px;"></div>
</div>
</div>
......@@ -126,7 +126,7 @@ export function mountGame(el, params) {
<div>
<div style="font-size:13px;font-weight:600;color:var(--text-primary);">${store.get('player.display_name') || store.get('player.username') || 'You'}</div>
<div style="display:flex;gap:6px;align-items:center;">
<span style="font-size:10px;color:var(--text-muted);">Lv.${store.get('player.level') || 1}</span>
<span style="font-size:var(--fs-xs);color:var(--text-muted);">Lv.${store.get('player.level') || 1}</span>
<div id="player-captured" style="font-size:11px;color:var(--text-secondary);letter-spacing:1px;"></div>
</div>
</div>
......@@ -135,14 +135,16 @@ export function mountGame(el, params) {
<div id="clock-player" class="chess-clock active">${clock.format(tc.time)}</div>
</div>
<!-- Opening Name + Material -->
<div style="display:flex;justify-content:space-between;align-items:center;padding:2px 14px;">
<div id="opening-name" style="font-size:11px;color:var(--text-muted);font-style:italic;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:70%;"></div>
<div id="material-diff" style="font-size:12px;font-weight:700;font-family:'SF Mono',monospace;color:var(--gold);"></div>
<!-- Opening name, material, and the move list.
This block is empty until the first move, and an empty block used to
leave a stray "1." floating in a gap above the controls. It collapses
to nothing until there is something to show. -->
<div id="game-meta" class="chess-meta" hidden>
<div class="chess-meta-row">
<div id="opening-name" class="chess-opening"></div>
<div id="material-diff" class="chess-material"></div>
</div>
<!-- Move List -->
<div id="move-list" style="max-height:40px;overflow-x:auto;white-space:nowrap;padding:4px 14px;font-family:'SF Mono',monospace;font-size:12px;color:var(--text-secondary);display:flex;gap:4px;align-items:center;">
<span style="color:var(--text-dim);">1.</span>
<div id="move-list" class="chess-moves"></div>
</div>
<!-- Controls -->
......@@ -154,6 +156,12 @@ export function mountGame(el, params) {
</div>
<style>
.chess-layout { gap: 0; }
.chess-meta { display:flex;flex-direction:column;gap:var(--s-1);padding:var(--s-1) var(--s-4); }
.chess-meta[hidden] { display:none; }
.chess-meta-row { display:flex;justify-content:space-between;align-items:center;gap:var(--s-2); }
.chess-opening { font-size:var(--fs-xs);color:var(--text-muted);font-style:italic;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-inline-size:70%; }
.chess-material { font-size:var(--fs-sm);font-weight:700;font-family:var(--font-mono);color:var(--gold); }
.chess-moves { max-block-size:40px;overflow-x:auto;white-space:nowrap;font-family:var(--font-mono);font-size:var(--fs-sm);color:var(--text-secondary);display:flex;gap:var(--s-1);align-items:center; }
.chess-clock { font-size:18px;font-weight:700;font-family:'SF Mono',Inter,monospace;background:var(--bg-card);padding:6px 14px;border-radius:8px;color:var(--text-secondary);min-width:64px;text-align:center;border:1px solid var(--border); }
.chess-clock.active { color:var(--text-primary);border-color:var(--border-hover); }
.chess-clock.low-time { color:var(--error)!important;animation:clockPulse 1s infinite;border-color:rgba(239,68,68,0.3); }
......@@ -595,6 +603,10 @@ function updateMoveList(el, m) {
moveList.innerHTML = html;
moveList.scrollLeft = moveList.scrollWidth;
// There is something to show now, so reveal the block.
const meta = el.querySelector('#game-meta');
if (meta) meta.hidden = false;
// Update opening name
const openingEl = el.querySelector('#opening-name');
if (openingEl && history.length <= 12) {
......
......@@ -70,9 +70,9 @@ function renderHistory(el, matches) {
<span style="font-size:12px;font-weight:700;color:${cfg.color};font-family:Inter,monospace;">${ratingStr}</span>
</div>
<div style="display:flex;gap:8px;margin-top:3px;">
<span style="font-size:10px;color:var(--text-muted);">${cfg.label}</span>
<span style="font-size:10px;color:var(--text-dim);">${tc}</span>
<span style="font-size:10px;color:var(--text-dim);">${timeAgo}</span>
<span style="font-size:var(--fs-xs);color:var(--text-muted);">${cfg.label}</span>
<span style="font-size:var(--fs-xs);color:var(--text-dim);">${tc}</span>
<span style="font-size:var(--fs-xs);color:var(--text-dim);">${timeAgo}</span>
</div>
</div>
</div>
......
......@@ -17,7 +17,7 @@ export async function mountReview(el, params) {
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:var(--bg-card);overflow-y:auto;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:var(--bg-panel);border-bottom:1px solid var(--border);">
<button class="btn btn-secondary" id="back-btn" style="min-height:30px;padding:4px 12px;font-size:12px;">← ${t('game.back')}</button>
<button class="btn btn-secondary" id="back-btn" style="min-height:var(--tap-min);padding:4px 12px;font-size:12px;">← ${t('game.back')}</button>
<span style="font-size:15px;font-weight:700;color:var(--text-primary);">${emoji('star', '⭐', 15)} ${t('review.title')}</span>
<div style="width:50px;"></div>
</div>
......
......@@ -259,7 +259,7 @@ function injectStyles(el) {
.dg-score-me, .dg-score-opp {
display:flex;flex-direction:column;align-items:center;gap:1px;
}
.dg-score-label { font-size:10px;color:var(--text-muted);font-weight:500; }
.dg-score-label { font-size:var(--fs-xs);color:var(--text-muted);font-weight:500; }
.dg-score-me .dg-score-value { font-size:16px;font-weight:800;color:var(--green-light); }
.dg-score-opp .dg-score-value { font-size:16px;font-weight:800;color:var(--text-secondary); }
.dg-score-divider { width:1px;height:24px;background:var(--border); }
......@@ -293,7 +293,7 @@ function injectStyles(el) {
border-top:1px solid rgba(255,255,255,0.03);
}
.dg-ctrl-btn {
min-height:40px;border-radius:12px;border:none;
min-height:var(--tap-min);border-radius:12px;border:none;
font-size:12px;font-weight:600;cursor:pointer;
transition:transform 0.15s cubic-bezier(0.34,1.56,0.64,1), opacity 0.2s;
}
......
......@@ -58,7 +58,7 @@ function renderPanel(p) {
</div>
<div style="display:flex;flex-direction:column;">
<span class="pp-name" style="font-size:11px;font-weight:700;color:var(--text-primary);">${p.name}</span>
<span class="pp-status" style="font-size:9px;color:var(--text-muted);">${p.level || ''}</span>
<span class="pp-status" style="font-size:var(--fs-xs);color:var(--text-muted);">${p.level || ''}</span>
</div>
<div class="pp-dice" id="dice-${p.i}"></div>
</div>
......@@ -146,7 +146,7 @@ export function mountGame(el, params) {
<style>
.pp{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:10px;border:2px solid transparent;transition:all 0.3s cubic-bezier(0.34,1.56,0.64,1);position:relative;}
.pp.active{border-color:var(--pc);background:rgba(255,255,255,0.06);animation:panelPulse 2s ease-in-out infinite;box-shadow:0 0 12px color-mix(in srgb, var(--pc) 20%, transparent);}
.pp-turn-arrow{position:absolute;left:-2px;top:50%;transform:translateY(-50%) scale(0);font-size:10px;color:var(--pc);transition:transform 0.3s cubic-bezier(0.34,1.56,0.64,1);filter:drop-shadow(0 0 4px var(--pc));}
.pp-turn-arrow{position:absolute;left:-2px;top:50%;transform:translateY(-50%) scale(0);font-size:var(--fs-xs);color:var(--pc);transition:transform 0.3s cubic-bezier(0.34,1.56,0.64,1);filter:drop-shadow(0 0 4px var(--pc));}
.pp.active .pp-turn-arrow{transform:translateY(-50%) scale(1);animation:arrowBounce 1s ease-in-out infinite;}
.pp-name{transition:color 0.3s,text-shadow 0.3s;}
.pp.active .pp-name{color:#fff !important;text-shadow:0 0 8px color-mix(in srgb, var(--pc) 50%, transparent);}
......
......@@ -373,7 +373,7 @@ function getStyles() {
}
.lr-seat-active .lr-seat-dot { background:color-mix(in srgb, var(--seat-color) 20%, transparent); }
.lr-seat-empty .lr-seat-dot { opacity:0.2;border-style:dashed; }
.lr-seat-label { font-size:9px;color:var(--text-muted); }
.lr-seat-label { font-size:var(--fs-xs);color:var(--text-muted); }
.lr-seat-active .lr-seat-label { color:var(--seat-color); }
.lr-pulse-ring {
......
......@@ -39,7 +39,7 @@ function renderOrgs(el, orgs) {
<div style="font-size:14px;font-weight:600;">${org.name_ar || org.name}</div>
<div style="font-size:11px;color:var(--text-secondary);">${emoji('people', '👥', 11)} ${org.member_count || 0} ${t('profile.member')}</div>
</div>
<button class="btn btn-secondary" style="font-size:11px;min-height:32px;padding:var(--s-1) var(--s-3);">${t('org.join_btn')}</button>
<button class="btn btn-secondary" style="font-size:11px;min-height:var(--tap-min);padding:var(--s-1) var(--s-3);">${t('org.join_btn')}</button>
</div>
`).join('');
......
......@@ -59,7 +59,7 @@ function renderOrg(el, org) {
<div style="display:flex;align-items:center;gap:var(--s-2);padding:var(--s-1) 0;">
<div style="width:24px;height:24px;border-radius:50%;background:var(--bg-elevated);display:flex;align-items:center;justify-content:center;font-size:12px;">${emoji('person', '👤', 12)}</div>
<span style="font-size:13px;">${m.user_id?.substring(0, 8) || 'Member'}</span>
<span style="font-size:10px;color:var(--text-muted);margin-right:auto;">${m.role}</span>
<span style="font-size:var(--fs-xs);color:var(--text-muted);margin-right:auto;">${m.role}</span>
</div>
`).join('')}
</div>
......
......@@ -47,8 +47,8 @@ export function mountTable(el) {
<span class="qb-icon" style="background:linear-gradient(135deg,#854d0e,#ca8a04);">${emoji('trophy', '🏆', 20)}</span>
<span class="qb-label">${t('play.achievements')}</span>
</button>
<button class="quick-btn breathe-glow" id="btn-daily-reward">
<span class="qb-icon" style="background:linear-gradient(135deg,#92400e,var(--gold));">${emoji('gift', '🎁', 20)}</span>
<button class="quick-btn" id="btn-daily-reward">
<span class="qb-icon breathe-glow" style="background:linear-gradient(135deg,#92400e,var(--gold));">${emoji('gift', '🎁', 20)}</span>
<span class="qb-label">${t('play.gift')}</span>
</button>
</div>
......@@ -75,9 +75,15 @@ export function mountTable(el) {
align-items: center;
justify-content: flex-start;
height: 100%;
padding: var(--home-padding) var(--home-padding) 0;
padding: var(--home-padding);
overflow-y: auto;
}
/* Centre the content when the screen is taller than it needs, instead of
leaving a block of dead space above the tab bar. The auto margins do
this without the clipping that justify-content:center causes once the
content is taller than the scroll container. */
.play-home > :first-child { margin-block-start: auto; }
.play-home > :last-child { margin-block-end: auto; }
.quick-btn { display:flex;flex-direction:column;align-items:center;gap:var(--quick-btn-gap);flex:1;background:none;border:none;cursor:pointer;padding:var(--quick-btn-padding);transition:transform 0.1s; }
.quick-btn:active { transform:scale(0.9); }
.qb-icon { width:var(--quick-btn-icon-size);height:var(--quick-btn-icon-size);border-radius:var(--quick-btn-icon-radius);display:flex;align-items:center;justify-content:center;font-size:var(--quick-btn-icon-font);box-shadow:0 3px 10px rgba(0,0,0,0.3); }
......
......@@ -61,7 +61,7 @@ export function mountTimeSelect(el, params) {
${cat.controls.map(tc => `
<button class="time-btn" data-key="${tc.key}" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;height:58px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:transform 0.1s,background 0.15s,border-color 0.15s;">
<span style="font-size:14px;font-weight:700;color:var(--text-primary);font-family:var(--font-lat);">${tc.sub}</span>
<span style="font-size:10px;color:var(--text-secondary);">${tc.labelKey ? t(tc.labelKey) : tc.label}</span>
<span style="font-size:var(--fs-xs);color:var(--text-secondary);">${tc.labelKey ? t(tc.labelKey) : tc.label}</span>
</button>
`).join('')}
</div>
......
......@@ -42,7 +42,7 @@ async function loadBlockedList(el) {
<div style="font-size:14px;font-weight:600;color:var(--text-primary);">${item.profile?.display_name || 'Player'}</div>
<div style="font-size:12px;color:var(--text-secondary);">${item.type === 'mute' ? t('block.muted') : t('block.blocked')}</div>
</div>
<button class="btn btn-secondary unblock-btn" data-id="${item.blocked_id}" data-type="${item.type}" style="min-height:32px;padding:4px 12px;font-size:12px;">
<button class="btn btn-secondary unblock-btn" data-id="${item.blocked_id}" data-type="${item.type}" style="min-height:var(--tap-min);padding:4px 12px;font-size:12px;">
${item.type === 'mute' ? t('block.unmute') : t('block.unblock')}
</button>
</div>
......
......@@ -48,7 +48,7 @@ export async function mountEdit(el) {
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);padding-bottom:max(var(--s-4), env(safe-area-inset-bottom));">
<!-- Header -->
<div style="display:flex;align-items:center;gap:var(--s-3);">
<button id="btn-back" class="btn btn-secondary" style="min-width:40px;min-height:40px;padding:0;display:flex;align-items:center;justify-content:center;">
<button id="btn-back" class="btn btn-secondary" style="min-width:40px;min-height:var(--tap-min);padding:0;display:flex;align-items:center;justify-content:center;">
${emoji('arrow_back', '←', 18)}
</button>
<div style="font-size:18px;font-weight:700;">${t('profile.edit_title')}</div>
......@@ -103,8 +103,8 @@ export async function mountEdit(el) {
<div>
<label style="font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:block;">${t('profile.preferred_language')}</label>
<div id="lang-toggle" style="display:flex;gap:var(--s-2);">
<button class="btn lang-opt ${(player.preferred_language || 'ar') === 'ar' ? 'btn-primary' : 'btn-secondary'}" data-lang="ar" style="flex:1;min-height:40px;">العربية</button>
<button class="btn lang-opt ${player.preferred_language === 'en' ? 'btn-primary' : 'btn-secondary'}" data-lang="en" style="flex:1;min-height:40px;">English</button>
<button class="btn lang-opt ${(player.preferred_language || 'ar') === 'ar' ? 'btn-primary' : 'btn-secondary'}" data-lang="ar" style="flex:1;min-height:var(--tap-min);">العربية</button>
<button class="btn lang-opt ${player.preferred_language === 'en' ? 'btn-primary' : 'btn-secondary'}" data-lang="en" style="flex:1;min-height:var(--tap-min);">English</button>
</div>
</div>
</div>
......
......@@ -17,7 +17,7 @@ export async function mountOrgApply(el) {
el.innerHTML = `
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);padding-bottom:max(var(--s-4), env(safe-area-inset-bottom));">
<div style="display:flex;align-items:center;gap:var(--s-3);">
<button id="btn-back" class="btn btn-secondary" style="min-width:40px;min-height:40px;padding:0;display:flex;align-items:center;justify-content:center;">
<button id="btn-back" class="btn btn-secondary" style="min-width:40px;min-height:var(--tap-min);padding:0;display:flex;align-items:center;justify-content:center;">
${emoji('arrow_back', '←', 18)}
</button>
<div style="font-size:18px;font-weight:700;">${t('org.join_title')}</div>
......@@ -79,9 +79,9 @@ async function loadOrgs(el) {
if (app.rejection_reason) {
badge += `<div style="font-size:11px;color:var(--error);margin-top:4px;">${t('profile.rejection_reason', { reason: escHtml(app.rejection_reason) })}</div>`;
}
actionBtn = `<button class="btn btn-primary btn-apply" data-org-id="${org.id}" style="min-height:36px;font-size:13px;margin-top:var(--s-2);">${t('org.reapply')}</button>`;
actionBtn = `<button class="btn btn-primary btn-apply" data-org-id="${org.id}" style="min-height:var(--tap-min);font-size:13px;margin-top:var(--s-2);">${t('org.reapply')}</button>`;
} else {
actionBtn = `<button class="btn btn-primary btn-apply" data-org-id="${org.id}" style="min-height:36px;font-size:13px;margin-top:var(--s-2);">${t('org.apply')}</button>`;
actionBtn = `<button class="btn btn-primary btn-apply" data-org-id="${org.id}" style="min-height:var(--tap-min);font-size:13px;margin-top:var(--s-2);">${t('org.apply')}</button>`;
}
const logoHtml = org.logo_url
......
......@@ -20,11 +20,11 @@ export function mountSettings(el) {
<div class="card" style="display:flex;flex-direction:column;gap:var(--s-4);">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>${t('settings.sound')}</span>
<button class="btn btn-secondary" id="toggle-audio" style="min-height:36px;padding:var(--s-1) var(--s-3);">${audioOn ? emoji('speaker_on', '🔊', 16) + ' ' + t('settings.on') : emoji('speaker_off', '🔇', 16) + ' ' + t('settings.off')}</button>
<button class="btn btn-secondary" id="toggle-audio" style="min-height:var(--tap-min);padding:var(--s-1) var(--s-3);">${audioOn ? emoji('speaker_on', '🔊', 16) + ' ' + t('settings.on') : emoji('speaker_off', '🔇', 16) + ' ' + t('settings.off')}</button>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>${t('settings.language')}</span>
<button class="btn btn-secondary" id="toggle-lang" style="min-height:36px;padding:var(--s-1) var(--s-3);">${lang === 'ar' ? 'العربية' : 'English'}</button>
<button class="btn btn-secondary" id="toggle-lang" style="min-height:var(--tap-min);padding:var(--s-1) var(--s-3);">${lang === 'ar' ? 'العربية' : 'English'}</button>
</div>
</div>
......
......@@ -344,7 +344,7 @@ async function checkActiveMatch(el, playerId) {
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;color:var(--success);">${t('profile.playing_now', { game: gameLabel })}</div>
</div>
<button class="btn btn-primary" id="btn-spectate" style="min-height:32px;padding:4px 14px;font-size:12px;">${emoji('eye', '👁', 13)} ${t('profile.watch')}</button>
<button class="btn btn-primary" id="btn-spectate" style="min-height:var(--tap-min);padding:4px 14px;font-size:12px;">${emoji('eye', '👁', 13)} ${t('profile.watch')}</button>
</div>
<style>@keyframes specPulse{0%,100%{opacity:1}50%{opacity:0.3}}</style>
`;
......@@ -412,7 +412,7 @@ async function loadOrgMembership(el) {
<div style="font-size:13px;font-weight:600;color:var(--text-primary);">${org ? org.name : t('org.title')}</div>
<div style="font-size:11px;color:var(--text-secondary);">${m.role || t('profile.member')}</div>
</div>
<span style="background:var(--success);color:#fff;font-size:10px;padding:2px 6px;border-radius:10px;">${t('profile.member')}</span>
<span style="background:var(--success);color:#fff;font-size:var(--fs-xs);padding:2px 6px;border-radius:10px;">${t('profile.member')}</span>
</div>
`;
}).join('');
......@@ -428,8 +428,8 @@ async function loadOrgMembership(el) {
? `<img src="${org.logo_url}" style="width:28px;height:28px;border-radius:6px;object-fit:contain;" alt="">`
: `<div style="width:28px;height:28px;border-radius:6px;background:var(--bg-elevated);display:flex;align-items:center;justify-content:center;">${emoji('building', '🏢', 14)}</div>`;
const statusBadge = a.status === 'pending'
? `<span style="background:var(--warning);color:#000;font-size:10px;padding:2px 6px;border-radius:10px;">${t('profile.pending_review')}</span>`
: `<span style="background:var(--error);color:#fff;font-size:10px;padding:2px 6px;border-radius:10px;">${t('profile.rejected')}</span>`;
? `<span style="background:var(--warning);color:#000;font-size:var(--fs-xs);padding:2px 6px;border-radius:10px;">${t('profile.pending_review')}</span>`
: `<span style="background:var(--error);color:#fff;font-size:var(--fs-xs);padding:2px 6px;border-radius:10px;">${t('profile.rejected')}</span>`;
return `
<div style="display:flex;align-items:center;gap:var(--s-2);padding:var(--s-2) 0;">
${logoHtml}
......
......@@ -15,7 +15,7 @@ export async function mountPuzzle(el) {
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:var(--bg-deep);">
<div style="display:flex;align-items:center;justify-content:space-between;padding:var(--s-2) var(--s-3);background:var(--bg-base);">
<button class="btn btn-secondary" id="back-btn" style="min-height:32px;padding:var(--s-1) var(--s-3);font-size:12px;">${t('game.back')}</button>
<button class="btn btn-secondary" id="back-btn" style="min-height:var(--tap-min);padding:var(--s-1) var(--s-3);font-size:12px;">${t('game.back')}</button>
<span style="font-size:14px;font-weight:600;">${t('puzzle.title')}</span>
<span id="puzzle-rating" style="font-size:13px;color:var(--gold);font-family:var(--font-lat);"></span>
</div>
......
......@@ -9,12 +9,12 @@ export async function mountLeaderboard(el) {
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h2 style="font-size:20px;font-weight:700;">${t('rank.leaderboard')}</h2>
<button class="btn btn-secondary" id="btn-tournaments" style="font-size:12px;min-height:32px;">${t('rank.tournaments')}</button>
<button class="btn btn-secondary" id="btn-tournaments" style="font-size:12px;min-height:var(--tap-min);">${t('rank.tournaments')}</button>
</div>
<div style="display:flex;gap:var(--s-2);overflow-x:auto;" id="game-tabs">
<button class="btn btn-secondary tab-active" data-game="chess" style="font-size:12px;min-height:32px;">${emoji('chess_pawn', '♟', 12)} ${t('game.chess')}</button>
<button class="btn btn-secondary" data-game="domino" style="font-size:12px;min-height:32px;">${emoji('domino_tile', '⬚', 12)} ${t('game.domino')}</button>
<button class="btn btn-secondary" data-game="ludo" style="font-size:12px;min-height:32px;">${emoji('ludo_hex', '⬡', 12)} ${t('game.ludo')}</button>
<button class="btn btn-secondary tab-active" data-game="chess" style="font-size:12px;min-height:var(--tap-min);">${emoji('chess_pawn', '♟', 12)} ${t('game.chess')}</button>
<button class="btn btn-secondary" data-game="domino" style="font-size:12px;min-height:var(--tap-min);">${emoji('domino_tile', '⬚', 12)} ${t('game.domino')}</button>
<button class="btn btn-secondary" data-game="ludo" style="font-size:12px;min-height:var(--tap-min);">${emoji('ludo_hex', '⬡', 12)} ${t('game.ludo')}</button>
</div>
<div id="leaderboard-list">
<div class="skeleton" style="height:50px;margin-bottom:var(--s-2);"></div>
......
......@@ -18,7 +18,7 @@ export function mountTournamentArena(el, params) {
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:var(--bg-deep);">
<div style="display:flex;align-items:center;gap:12px;padding:10px 14px;background:var(--bg-panel);border-bottom:1px solid var(--border);">
<button class="btn btn-secondary" id="back-btn" style="min-height:32px;padding:4px 12px;font-size:12px;">←</button>
<button class="btn btn-secondary" id="back-btn" style="min-height:var(--tap-min);padding:4px 12px;font-size:12px;">←</button>
<span style="font-size:15px;font-weight:700;color:var(--text-primary);">${emoji('lightning', '⚡', 15)} ${t('tournament.arena')}</span>
</div>
<div id="arena-content" style="flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;align-items:center;gap:16px;">
......@@ -163,7 +163,7 @@ async function loadArenaStandings(el, tournamentId) {
<span style="width:24px;font-size:${i < 3 ? '14px' : '11px'};text-align:center;">${i < 3 ? medals[i] : (i + 1)}</span>
<span style="flex:1;font-size:12px;color:var(--text-primary);font-weight:${isMe ? '700' : '400'};">${p.name}${isMe ? ` (${t('common.you')})` : ''}</span>
<span style="font-size:12px;font-weight:700;color:var(--gold);">${p.points}</span>
<span style="font-size:10px;color:var(--text-muted);margin-right:4px;width:50px;text-align:left;">${p.wins}W ${p.draws}D ${p.losses}L</span>
<span style="font-size:var(--fs-xs);color:var(--text-muted);margin-right:4px;width:50px;text-align:left;">${p.wins}W ${p.draws}D ${p.losses}L</span>
</div>
`;
}).join('')}
......
......@@ -12,7 +12,7 @@ export async function mountTournamentBracket(el, params) {
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:var(--bg-deep);">
<div style="display:flex;align-items:center;gap:12px;padding:10px 14px;background:var(--bg-panel);border-bottom:1px solid var(--border);">
<button class="btn btn-secondary" id="back-btn" style="min-height:32px;padding:4px 12px;font-size:12px;">←</button>
<button class="btn btn-secondary" id="back-btn" style="min-height:var(--tap-min);padding:4px 12px;font-size:12px;">←</button>
<span style="font-size:15px;font-weight:700;color:var(--text-primary);">${t('tournament.bracket')}</span>
</div>
<div id="bracket-container" style="flex:1;overflow:auto;padding:12px;">
......@@ -57,17 +57,17 @@ export async function mountTournamentBracket(el, params) {
html += `
<div class="bracket-match" data-match-id="${m.match_id || ''}" style="background:${statusBg};border:1px solid ${borderColor};border-radius:8px;padding:6px 8px;position:relative;">
${isMyMatch ? `<div style="position:absolute;top:-6px;right:4px;font-size:9px;background:var(--gold);color:#000;padding:1px 4px;border-radius:4px;font-weight:700;">${t('common.you')}</div>` : ''}
${isMyMatch ? `<div style="position:absolute;top:-6px;right:4px;font-size:var(--fs-xs);background:var(--gold);color:#000;padding:1px 4px;border-radius:4px;font-weight:700;">${t('common.you')}</div>` : ''}
<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;${m.winner_id === m.player_a_id ? 'font-weight:700;' : ''}">
<span style="font-size:11px;color:${m.winner_id === m.player_a_id ? 'var(--success)' : 'var(--text-primary)'};max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${m.player_a_name || (m.player_a_id ? '...' : 'BYE')}</span>
<span style="font-size:10px;color:var(--text-muted);">${m.result ? m.result.split('-')[0] : ''}</span>
<span style="font-size:var(--fs-xs);color:var(--text-muted);">${m.result ? m.result.split('-')[0] : ''}</span>
</div>
<div style="height:1px;background:var(--border);margin:2px 0;"></div>
<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;${m.winner_id === m.player_b_id ? 'font-weight:700;' : ''}">
<span style="font-size:11px;color:${m.winner_id === m.player_b_id ? 'var(--success)' : 'var(--text-primary)'};max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${m.player_b_name || (m.player_b_id ? '...' : 'TBD')}</span>
<span style="font-size:10px;color:var(--text-muted);">${m.result ? m.result.split('-')[1] : ''}</span>
<span style="font-size:var(--fs-xs);color:var(--text-muted);">${m.result ? m.result.split('-')[1] : ''}</span>
</div>
${m.status === 'pending' && isMyMatch && m.player_a_id && m.player_b_id ? `<button class="bracket-play-btn" data-mid="${m.match_id}" style="width:100%;margin-top:4px;padding:4px;background:var(--gold);border:none;border-radius:4px;color:#000;font-weight:700;font-size:10px;cursor:pointer;">${t('common.play')}</button>` : ''}
${m.status === 'pending' && isMyMatch && m.player_a_id && m.player_b_id ? `<button class="bracket-play-btn" data-mid="${m.match_id}" style="width:100%;margin-top:4px;padding:4px;background:var(--gold);border:none;border-radius:4px;color:#000;font-weight:700;font-size:var(--fs-xs);cursor:pointer;">${t('common.play')}</button>` : ''}
</div>
`;
});
......
......@@ -15,7 +15,7 @@ export async function mountTournamentDetail(el, params) {
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:var(--bg-deep);">
<div style="display:flex;align-items:center;gap:12px;padding:10px 14px;background:var(--bg-panel);border-bottom:1px solid var(--border);">
<button class="btn btn-secondary" id="back-btn" style="min-height:32px;padding:4px 12px;font-size:12px;">←</button>
<button class="btn btn-secondary" id="back-btn" style="min-height:var(--tap-min);padding:4px 12px;font-size:12px;">←</button>
<span id="tour-title" style="font-size:15px;font-weight:700;color:var(--text-primary);flex:1;">${t('tournament.title')}</span>
</div>
<!-- Tabs -->
......
......@@ -15,7 +15,7 @@ export function mountTournamentLive(el, params) {
el.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;background:var(--bg-deep);">
<div style="display:flex;align-items:center;gap:12px;padding:10px 14px;background:var(--bg-panel);border-bottom:1px solid var(--border);">
<button class="btn btn-secondary" id="back-btn" style="min-height:32px;padding:4px 12px;font-size:12px;">←</button>
<button class="btn btn-secondary" id="back-btn" style="min-height:var(--tap-min);padding:4px 12px;font-size:12px;">←</button>
<span style="font-size:15px;font-weight:700;color:var(--text-primary);flex:1;">${emoji('live', '🔴', 12)} LIVE — ${tournamentName || t('tournament.title')}</span>
</div>
<div id="live-content" style="flex:1;overflow-y:auto;padding:14px;"></div>
......@@ -71,7 +71,7 @@ async function loadLiveData(el, tournamentId) {
<span style="flex:1;font-size:12px;color:var(--text-primary);text-align:right;">${p.white_name || p.player_a || '?'}</span>
<span style="padding:2px 8px;font-size:11px;font-weight:700;color:${hasResult ? 'var(--success)' : 'var(--gold)'};">${p.result || 'vs'}</span>
<span style="flex:1;font-size:12px;color:var(--text-primary);text-align:left;">${p.black_name || p.player_b || '?'}</span>
${!hasResult ? `<button class="spectate-btn" data-white="${p.player_a || p.white_id || ''}" data-black="${p.player_b || p.black_id || ''}" style="margin-right:4px;padding:2px 8px;background:var(--blue);border:none;border-radius:4px;color:#fff;font-size:10px;font-weight:600;cursor:pointer;">${t('spectate.watch')}</button>` : ''}
${!hasResult ? `<button class="spectate-btn" data-white="${p.player_a || p.white_id || ''}" data-black="${p.player_b || p.black_id || ''}" style="margin-right:4px;padding:2px 8px;background:var(--blue);border:none;border-radius:4px;color:#fff;font-size:var(--fs-xs);font-weight:600;cursor:pointer;">${t('spectate.watch')}</button>` : ''}
</div>
`;
});
......
......@@ -45,7 +45,7 @@ function renderTournaments(el, tournaments) {
<div style="font-size:12px;color:var(--text-secondary);margin-top:2px;">${tour.game_key || 'chess'} · ${formatName(tour.format)}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">${tour.starts_at ? new Date(tour.starts_at).toLocaleDateString('ar') : ''}</div>
</div>
<span style="font-size:10px;padding:3px 8px;border-radius:9999px;background:${getStatusColor(tour.status)};color:white;font-weight:600;">${getStatusLabel(tour.status)}</span>
<span style="font-size:var(--fs-xs);padding:3px 8px;border-radius:9999px;background:${getStatusColor(tour.status)};color:white;font-weight:600;">${getStatusLabel(tour.status)}</span>
</div>
<div style="display:flex;gap:16px;margin-top:12px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.05);">
<span style="font-size:11px;color:var(--text-secondary);">${emoji('people', '👥', 11)} ${tour.player_count || 0}/${tour.max_players || 32}</span>
......@@ -88,7 +88,7 @@ async function showTournamentDetail(el, tournamentId, tour) {
const list = el.querySelector('#tournament-list');
list.innerHTML = `
<div style="margin-bottom:16px;">
<button class="btn btn-secondary" id="detail-back" style="font-size:12px;min-height:32px;padding:4px 12px;">← ${t('tournament.back_to_list')}</button>
<button class="btn btn-secondary" id="detail-back" style="font-size:12px;min-height:var(--tap-min);padding:4px 12px;">← ${t('tournament.back_to_list')}</button>
</div>
<div class="card" style="padding:16px;">
<div style="font-size:18px;font-weight:700;margin-bottom:4px;">${tour?.name || t('tournament.title')}</div>
......@@ -96,11 +96,11 @@ async function showTournamentDetail(el, tournamentId, tour) {
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px;">
<div style="background:#1a2440;padding:8px;border-radius:8px;text-align:center;">
<div style="font-size:16px;font-weight:700;color:var(--gold);">${tour?.player_count || 0}</div>
<div style="font-size:10px;color:var(--text-muted);">${t('tournament.players_label')}</div>
<div style="font-size:var(--fs-xs);color:var(--text-muted);">${t('tournament.players_label')}</div>
</div>
<div style="background:#1a2440;padding:8px;border-radius:8px;text-align:center;">
<div style="font-size:16px;font-weight:700;color:#00FFFF;">${tour?.rounds_total || tour?.swiss_rounds || '?'}</div>
<div style="font-size:10px;color:var(--text-muted);">${t('tournament.rounds_label')}</div>
<div style="font-size:var(--fs-xs);color:var(--text-muted);">${t('tournament.rounds_label')}</div>
</div>
</div>
</div>
......
......@@ -57,7 +57,7 @@ function render(el, achievements, stats) {
<div style="display:flex;flex-direction:column;height:100%;background:var(--bg-deep);">
<!-- Header -->
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--bg-panel);border-bottom:1px solid var(--border);">
<button class="btn btn-secondary" id="back-btn" style="min-height:32px;padding:4px 12px;font-size:12px;">←</button>
<button class="btn btn-secondary" id="back-btn" style="min-height:var(--tap-min);padding:4px 12px;font-size:12px;">←</button>
<span style="font-size:16px;font-weight:700;color:var(--text-primary);">${emoji('trophy', '🏆', 16)} ${t('achievements.title')}</span>
<span style="margin-inline-start:auto;font-size:12px;color:var(--text-muted);">${stats.completed}/${stats.total}</span>
</div>
......@@ -68,8 +68,8 @@ function render(el, achievements, stats) {
<div style="background:linear-gradient(90deg,var(--gold),var(--gold-soft));height:100%;width:${progressPct}%;border-radius:99px;transition:width 0.5s;"></div>
</div>
<div style="display:flex;justify-content:space-between;margin-top:4px;">
<span style="font-size:10px;color:var(--text-muted);">${progressPct}% ${t('achievements.complete')}</span>
<span style="font-size:10px;color:var(--gold);">${stats.completed} ${t('achievements.title')}</span>
<span style="font-size:var(--fs-xs);color:var(--text-muted);">${progressPct}% ${t('achievements.complete')}</span>
<span style="font-size:var(--fs-xs);color:var(--gold);">${stats.completed} ${t('achievements.title')}</span>
</div>
</div>
......@@ -129,15 +129,15 @@ function renderList(achievements, category) {
<div style="flex:1;background:var(--bg-card);border-radius:99px;height:4px;overflow:hidden;">
<div style="background:${tierColor};height:100%;width:${pct}%;border-radius:99px;"></div>
</div>
<span style="font-size:10px;color:var(--text-muted);white-space:nowrap;">${a.progress}/${a.target}</span>
<span style="font-size:var(--fs-xs);color:var(--text-muted);white-space:nowrap;">${a.progress}/${a.target}</span>
</div>
` : `
<div style="font-size:10px;color:var(--green);">✓ ${t('achievements.complete')}</div>
<div style="font-size:var(--fs-xs);color:var(--green);">✓ ${t('achievements.complete')}</div>
`}
</div>
<div style="text-align:center;min-width:40px;">
<div style="font-size:11px;font-weight:700;color:var(--gold);">${a.coins_reward}</div>
<div style="font-size:9px;color:var(--text-muted);">${t('game.coins')}</div>
<div style="font-size:var(--fs-xs);color:var(--text-muted);">${t('game.coins')}</div>
</div>
</div>
`;
......
......@@ -11,7 +11,7 @@ export async function mountChallenges(el) {
el.innerHTML = `
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
<div style="display:flex;align-items:center;gap:12px;">
<button class="btn btn-secondary" id="back-btn" style="min-height:32px;padding:4px 12px;font-size:12px;">←</button>
<button class="btn btn-secondary" id="back-btn" style="min-height:var(--tap-min);padding:4px 12px;font-size:12px;">←</button>
<h2 style="font-size:18px;font-weight:800;color:var(--text-primary);flex:1;">${emoji('lightning', '⚡', 18)} ${t('challenges.title')}</h2>
<div id="streak-badge" style="background:linear-gradient(135deg,var(--gold),var(--gold-soft));color:var(--bg-dark);font-size:12px;font-weight:800;padding:5px 12px;border-radius:99px;"></div>
</div>
......@@ -48,7 +48,7 @@ function renderChallenges(el, data) {
<div style="flex:1;height:5px;background:var(--bg-hover);border-radius:3px;overflow:hidden;">
<div style="height:100%;width:${Math.min(100, (c.progress / c.target) * 100)}%;background:${c.completed ? 'var(--success)' : 'var(--gold)'};border-radius:3px;transition:width 0.3s;"></div>
</div>
<span style="font-size:10px;color:var(--text-muted);min-width:30px;text-align:left;">${c.progress}/${c.target}</span>
<span style="font-size:var(--fs-xs);color:var(--text-muted);min-width:30px;text-align:left;">${c.progress}/${c.target}</span>
</div>
</div>
<div style="text-align:center;min-width:50px;">
......
......@@ -44,7 +44,7 @@ function render(el, streak, alreadyClaimed, dayIndex, todayReward) {
<div style="display:flex;flex-direction:column;height:100%;background:var(--bg-deep);">
<!-- Header -->
<div style="display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--bg-panel);border-bottom:1px solid var(--border);">
<button class="btn btn-secondary" id="back-btn" style="min-height:32px;padding:4px 12px;font-size:12px;">←</button>
<button class="btn btn-secondary" id="back-btn" style="min-height:var(--tap-min);padding:4px 12px;font-size:12px;">←</button>
<span style="font-size:16px;font-weight:700;color:var(--text-primary);">${emoji('gift', '🎁', 16)} ${t('daily.title')}</span>
</div>
......@@ -69,7 +69,7 @@ function render(el, streak, alreadyClaimed, dayIndex, todayReward) {
color:${claimed ? 'var(--success)' : isToday ? 'var(--bg-dark)' : 'var(--text-muted)'};">
${claimed ? '✓' : coins}
</div>
<span style="font-size:9px;color:${isToday ? 'var(--gold)' : 'var(--text-dim)'};font-weight:${isToday ? '700' : '400'};">${t('daily.day', { n: i + 1 })}</span>
<span style="font-size:var(--fs-xs);color:${isToday ? 'var(--gold)' : 'var(--text-dim)'};font-weight:${isToday ? '700' : '400'};">${t('daily.day', { n: i + 1 })}</span>
</div>
`;
}).join('')}
......
......@@ -37,12 +37,12 @@ function renderItems(el, items) {
grid.innerHTML = items.map(item => `
<div class="card shop-item" data-id="${item.id}" style="cursor:pointer;padding:var(--s-3);border-color:${RARITY_COLORS[item.rarity] || 'var(--border)'}33;${item.owned ? 'opacity:0.7;' : ''}position:relative;">
${item.owned ? `<div style="position:absolute;top:6px;right:6px;background:var(--success);color:#fff;font-size:9px;font-weight:700;padding:2px 6px;border-radius:4px;">Owned</div>` : ''}
${item.owned ? `<div style="position:absolute;top:6px;right:6px;background:var(--success);color:#fff;font-size:var(--fs-xs);font-weight:700;padding:2px 6px;border-radius:4px;">Owned</div>` : ''}
<div style="height:60px;background:var(--bg-elevated);border-radius:var(--r-sm);margin-bottom:var(--s-2);display:flex;align-items:center;justify-content:center;font-size:24px;">
${emoji('art', '🎨', 24)}
</div>
<div style="font-size:13px;font-weight:600;margin-bottom:2px;">${item.name || item.id}</div>
<div style="font-size:10px;color:${RARITY_COLORS[item.rarity] || 'var(--text-muted)'};margin-bottom:var(--s-1);">${item.rarity || 'common'}</div>
<div style="font-size:var(--fs-xs);color:${RARITY_COLORS[item.rarity] || 'var(--text-muted)'};margin-bottom:var(--s-1);">${item.rarity || 'common'}</div>
<div style="font-size:12px;font-weight:700;color:var(--gold);">
${item.owned ? '' : (item.price_coins ? `${item.price_coins} ${emoji('coin', '🪙', 14)}` : '') + (item.price_gems ? ` ${item.price_gems} ${emoji('gem', '💎', 14)}` : '')}
</div>
......
......@@ -47,7 +47,7 @@ function renderActivity(el, activities) {
</div>
<div style="flex:1;">
<div style="font-size:13px;color:var(--text-primary);"><strong>${actor.display_name || actor.username || '?'}</strong> ${label}</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px;">${time}</div>
<div style="font-size:var(--fs-xs);color:var(--text-muted);margin-top:2px;">${time}</div>
</div>
</div>
`;
......
......@@ -65,7 +65,7 @@ export function mountChat(el, params = {}) {
.chat-bubble.mine { align-self:flex-end;background:var(--chess-primary);color:#fff;border-bottom-left-radius:16px;border-bottom-right-radius:4px; }
.chat-bubble.theirs { align-self:flex-start;background:var(--bg-card);color:var(--text-light);border-bottom-left-radius:4px;border-bottom-right-radius:16px; }
.chat-bubble.system { align-self:center;background:rgba(228,172,56,0.1);color:var(--gold);font-size:12px;border-radius:12px;padding:6px 14px; }
.chat-bubble .chat-time { font-size:10px;opacity:0.6;margin-top:2px; }
.chat-bubble .chat-time { font-size:var(--fs-xs);opacity:0.6;margin-top:2px; }
.chat-bubble.mine .chat-time { text-align:left; }
.chat-bubble.theirs .chat-time { text-align:right; }
.chat-input-bar { display:flex;gap:8px;padding:10px 14px;background:var(--bg-panel);border-top:1px solid var(--border);padding-bottom:max(10px, env(safe-area-inset-bottom, 0px)); }
......
......@@ -22,11 +22,11 @@ export function mountFriends(el) {
<div style="padding:12px 16px;background:var(--bg-panel);">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<h2 style="font-size:18px;font-weight:700;color:var(--text-primary);">${t('social.friends')}</h2>
<button class="btn btn-secondary" id="btn-search" style="min-height:34px;padding:6px 14px;font-size:12px;">${emoji('search_icon', '🔍', 13)} ${t('social.search_btn')}</button>
<button class="btn btn-secondary" id="btn-search" style="min-height:var(--tap-min);padding:6px 14px;font-size:12px;">${emoji('search_icon', '🔍', 13)} ${t('social.search_btn')}</button>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="social-tab active" data-tab="friends">${t('social.friends_tab')}</button>
<button class="social-tab" data-tab="pending">${t('social.pending_tab')} <span id="pending-count" style="display:none;font-size:10px;background:var(--error);color:#fff;border-radius:50%;padding:1px 5px;margin-right:2px;"></span></button>
<button class="social-tab" data-tab="pending">${t('social.pending_tab')} <span id="pending-count" style="display:none;font-size:var(--fs-xs);background:var(--error);color:#fff;border-radius:50%;padding:1px 5px;margin-right:2px;"></span></button>
<button class="social-tab" data-tab="online">${t('social.online_tab')}</button>
<button class="social-tab" data-tab="groups">${emoji('group', '👥', 12)} ${t('social.groups_tab')}</button>
<button class="social-tab" data-tab="activity">${emoji('news', '📰', 12)} ${t('social.activity_tab')}</button>
......@@ -111,8 +111,8 @@ async function checkInvites(el) {
<div style="font-size:13px;font-weight:600;color:var(--success);">${t('social.challenges_you', { name })}</div>
<div style="font-size:11px;color:var(--text-muted);">${gameLabel}</div>
</div>
<button class="btn btn-primary" data-accept-invite="${inv.match_id}" style="min-height:30px;padding:5px 14px;font-size:11px;">${t('social.accept')}</button>
<button class="btn btn-secondary" data-decline-invite="${inv.match_id}" style="min-height:30px;padding:5px 10px;font-size:11px;">✕</button>
<button class="btn btn-primary" data-accept-invite="${inv.match_id}" style="min-height:var(--tap-min);padding:5px 14px;font-size:11px;">${t('social.accept')}</button>
<button class="btn btn-secondary" data-decline-invite="${inv.match_id}" style="min-height:var(--tap-min);padding:5px 10px;font-size:11px;">✕</button>
</div>
`;
}).join('');
......@@ -527,7 +527,7 @@ function showSearch(el) {
<div style="margin-bottom:16px;">
<div style="display:flex;gap:8px;">
<input class="input" id="search-input" type="text" placeholder="${t('social.search_placeholder')}" style="flex:1;min-height:40px;font-size:14px;" autocomplete="off">
<button class="btn btn-primary" id="search-go" style="min-height:40px;padding:8px 16px;font-size:13px;">${t('social.search_btn')}</button>
<button class="btn btn-primary" id="search-go" style="min-height:var(--tap-min);padding:8px 16px;font-size:13px;">${t('social.search_btn')}</button>
</div>
<div style="font-size:11px;color:var(--text-dim);margin-top:4px;">${t('social.search_hint')}</div>
</div>
......@@ -565,7 +565,7 @@ function showSearch(el) {
<div style="font-size:13px;font-weight:600;color:var(--text-primary);">${p.display_name || p.username || t('common.player')}</div>
<div style="font-size:11px;color:var(--text-muted);">${p.is_online ? t('common.online') : t('common.offline')}${t('common.level', { n: p.level || 1 })}</div>
</div>
<button class="btn btn-primary" data-add="${p.id}" style="min-height:32px;padding:6px 12px;font-size:11px;flex-shrink:0;">${t('social.add_btn')}</button>
<button class="btn btn-primary" data-add="${p.id}" style="min-height:var(--tap-min);padding:6px 12px;font-size:11px;flex-shrink:0;">${t('social.add_btn')}</button>
</div>
`).join('');
......@@ -648,7 +648,7 @@ async function loadActivity(content) {
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:13px;color:var(--text-primary);"><strong>${actor.display_name || actor.username || '?'}</strong> ${label}</div>
${a.created_at ? `<div style="font-size:10px;color:var(--text-dim);margin-top:2px;">${timeAgo(a.created_at)}</div>` : ''}
${a.created_at ? `<div style="font-size:var(--fs-xs);color:var(--text-dim);margin-top:2px;">${timeAgo(a.created_at)}</div>` : ''}
</div>
</div>`;
}).join('');
......
......@@ -23,13 +23,13 @@ export async function mountGroupChat(el, params = {}) {
<div id="group-header" style="flex:1;cursor:pointer;">
<div style="font-size:15px;font-weight:600;color:var(--text-primary);">${t('common.loading')}</div>
</div>
<button class="btn btn-secondary" id="invite-game-btn" style="min-height:32px;padding:5px 10px;font-size:12px;">${emoji('swords', '⚔️', 13)} ${t('group.play')}</button>
<button class="btn btn-secondary" id="invite-game-btn" style="min-height:var(--tap-min);padding:5px 10px;font-size:12px;">${emoji('swords', '⚔️', 13)} ${t('group.play')}</button>
</div>
<div id="invite-banner" style="display:none;"></div>
<div id="chat-messages" style="flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:6px;"></div>
<div style="padding:10px 16px;background:var(--bg-panel);border-top:1px solid var(--border);display:flex;gap:8px;align-items:center;">
<input class="input" id="msg-input" type="text" placeholder="${t('chat.placeholder')}" style="flex:1;min-height:38px;" maxlength="500">
<button class="btn btn-primary" id="send-btn" style="min-height:38px;padding:0 14px;">${t('common.send')}</button>
<button class="btn btn-primary" id="send-btn" style="min-height:var(--tap-min);padding:0 14px;">${t('common.send')}</button>
</div>
</div>
`;
......@@ -153,7 +153,7 @@ function messageHtml(msg, myId, memberMap) {
<div style="display:flex;justify-content:flex-end;">
<div style="max-width:75%;background:var(--chess-primary);border-radius:12px 12px 4px 12px;padding:8px 12px;">
<div style="font-size:13px;color:#fff;word-wrap:break-word;">${escapeHtml(msg.content)}</div>
<div style="font-size:9px;color:rgba(255,255,255,0.5);margin-top:2px;">${time}</div>
<div style="font-size:var(--fs-xs);color:rgba(255,255,255,0.5);margin-top:2px;">${time}</div>
</div>
</div>`;
}
......@@ -164,9 +164,9 @@ function messageHtml(msg, myId, memberMap) {
${profile.avatar_url ? `<img src="${profile.avatar_url}" style="width:28px;height:28px;object-fit:cover;border-radius:50%;">` : emoji('person', '👤', 12)}
</div>
<div style="max-width:70%;background:var(--bg-card);border-radius:12px 12px 12px 4px;padding:8px 12px;">
<div style="font-size:10px;font-weight:600;color:var(--text-muted);margin-bottom:2px;">${escapeHtml(name)}</div>
<div style="font-size:var(--fs-xs);font-weight:600;color:var(--text-muted);margin-bottom:2px;">${escapeHtml(name)}</div>
<div style="font-size:13px;color:var(--text-light);word-wrap:break-word;">${escapeHtml(msg.content)}</div>
<div style="font-size:9px;color:#4a5568;margin-top:2px;">${time}</div>
<div style="font-size:var(--fs-xs);color:#4a5568;margin-top:2px;">${time}</div>
</div>
</div>`;
}
......@@ -197,7 +197,7 @@ async function checkGroupInvites(el, groupId, myId) {
<div style="flex:1;">
<div style="font-size:12px;font-weight:600;color:var(--success);">${gameLabel}${accepted.length}/${inv.required_players}</div>
</div>
${isAccepted || isInviter ? `<span style="font-size:11px;color:var(--text-muted);">${t('group.accepted')}</span>` : `<button class="btn btn-primary accept-invite-btn" data-match="${inv.match_id}" data-game="${inv.game_key}" style="min-height:28px;padding:4px 12px;font-size:11px;">${t('group.join')}</button>`}
${isAccepted || isInviter ? `<span style="font-size:11px;color:var(--text-muted);">${t('group.accepted')}</span>` : `<button class="btn btn-primary accept-invite-btn" data-match="${inv.match_id}" data-game="${inv.game_key}" style="min-height:var(--tap-min);padding:4px 12px;font-size:11px;">${t('group.join')}</button>`}
</div>`;
}).join('');
......@@ -271,7 +271,7 @@ function showGamePicker(el, groupId) {
<div style="font-size:11px;color:var(--text-muted);">${t('group.players')}</div>
</div>
</button>
<button id="cancel-pick" class="btn btn-secondary w-full" style="min-height:40px;">${t('common.cancel')}</button>
<button id="cancel-pick" class="btn btn-secondary w-full" style="min-height:var(--tap-min);">${t('common.cancel')}</button>
</div>
`;
......
......@@ -19,13 +19,13 @@ export async function mountGroupMembers(el, params = {}) {
<div style="padding:12px 16px;background:var(--bg-panel);display:flex;align-items:center;gap:12px;">
<button class="btn btn-secondary" id="back-btn" style="width:36px;height:36px;padding:0;">←</button>
<h2 style="font-size:18px;font-weight:700;color:var(--text-primary);flex:1;">${t('group.members')}</h2>
${canManage ? `<button class="btn btn-primary" id="add-member-btn" style="min-height:34px;padding:6px 12px;font-size:12px;">+ ${t('group.add')}</button>` : ''}
${canManage ? `<button class="btn btn-primary" id="add-member-btn" style="min-height:var(--tap-min);padding:6px 12px;font-size:12px;">+ ${t('group.add')}</button>` : ''}
</div>
<div id="members-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
<div style="text-align:center;padding:24px;color:var(--text-secondary);">${t('common.loading')}</div>
</div>
<div style="padding:12px 16px;border-top:1px solid rgba(255,255,255,0.06);">
<button class="btn btn-secondary w-full" id="leave-btn" style="min-height:40px;color:var(--error);">${t('group.leave')}</button>
<button class="btn btn-secondary w-full" id="leave-btn" style="min-height:var(--tap-min);color:var(--error);">${t('group.leave')}</button>
</div>
</div>
`;
......@@ -54,7 +54,7 @@ export async function mountGroupMembers(el, params = {}) {
${roleLabel ? `<div style="font-size:11px;color:var(--text-muted);">${roleLabel}</div>` : ''}
</div>
${p.is_online ? '<div style="width:8px;height:8px;border-radius:50%;background:var(--success);"></div>' : ''}
${showRemove ? `<button class="btn btn-secondary remove-btn" data-id="${m.user_id}" style="min-height:28px;padding:4px 10px;font-size:11px;color:var(--error);">${t('group.remove')}</button>` : ''}
${showRemove ? `<button class="btn btn-secondary remove-btn" data-id="${m.user_id}" style="min-height:var(--tap-min);padding:4px 10px;font-size:11px;color:var(--error);">${t('group.remove')}</button>` : ''}
</div>`;
}).join('');
......@@ -162,7 +162,7 @@ async function showAddMemberPicker(el, groupId) {
${f.avatar_url ? `<img src="${f.avatar_url}" style="width:32px;height:32px;object-fit:cover;border-radius:50%;">` : emoji('person', '👤', 14)}
</div>
<span style="flex:1;font-size:13px;color:var(--text-primary);">${escapeHtml(f.display_name || 'Player')}</span>
<button class="btn btn-primary add-one-btn" data-id="${f.id}" style="min-height:28px;padding:4px 12px;font-size:11px;">${t('group.add')}</button>
<button class="btn btn-primary add-one-btn" data-id="${f.id}" style="min-height:var(--tap-min);padding:4px 12px;font-size:11px;">${t('group.add')}</button>
</div>
`).join('');
......
......@@ -10,7 +10,7 @@ export async function mountGroups(el) {
<div style="padding:12px 16px;background:var(--bg-panel);display:flex;align-items:center;gap:12px;">
<button class="btn btn-secondary" id="back-btn" style="width:36px;height:36px;padding:0;">←</button>
<h2 style="font-size:18px;font-weight:700;color:var(--text-primary);flex:1;">${t('social.groups_tab')}</h2>
<button class="btn btn-primary" id="create-btn" style="min-height:34px;padding:6px 14px;font-size:12px;">+ ${t('group.create')}</button>
<button class="btn btn-primary" id="create-btn" style="min-height:var(--tap-min);padding:6px 14px;font-size:12px;">+ ${t('group.create')}</button>
</div>
<div id="groups-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
<div style="text-align:center;padding:24px;color:var(--text-secondary);">${t('common.loading')}</div>
......
......@@ -37,7 +37,7 @@ function renderNotifications(el, notifications) {
<div class="card notif-item" data-id="${n.id}" style="padding:var(--s-3);margin-bottom:var(--s-2);opacity:${n.is_read ? '0.7' : '1'};cursor:pointer;">
<div style="font-size:14px;font-weight:${n.is_read ? '400' : '600'};">${n.title_ar || n.title || ''}</div>
<div style="font-size:12px;color:var(--text-secondary);margin-top:2px;">${n.body_ar || n.body || ''}</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:4px;">${timeAgo(n.created_at)}</div>
<div style="font-size:var(--fs-xs);color:var(--text-muted);margin-top:4px;">${timeAgo(n.created_at)}</div>
</div>
`).join('');
......
......@@ -34,10 +34,10 @@ export async function mountTournamentsHub(el) {
.tour-hub-card:active{transform:scale(0.98);}
.tour-hub-card.registered{border-color:rgba(228,172,56,0.3);}
.tour-hub-card.has-pending{border-color:var(--gold);box-shadow:0 0 12px rgba(228,172,56,0.15);}
.tour-status-pill{font-size:10px;padding:3px 10px;border-radius:99px;font-weight:700;display:inline-block;}
.tour-status-pill{font-size:var(--fs-xs);padding:3px 10px;border-radius:99px;font-weight:700;display:inline-block;}
.tour-stat{display:flex;flex-direction:column;align-items:center;gap:2px;}
.tour-stat-val{font-size:16px;font-weight:800;color:var(--text-primary);}
.tour-stat-label{font-size:10px;color:var(--text-muted);}
.tour-stat-label{font-size:var(--fs-xs);color:var(--text-muted);}
</style>
`;
......
<?php
/**
* Local dev router for the UI audit.
*
* Mirrors the .htaccess rules: /public and /api are served as-is, everything
* else falls through to index.php. Points at the real Supabase so scenes have
* real data to lay out.
*
* php -S 127.0.0.1:8080 tools/dev-server.php
*/
foreach ([
'SUPABASE_URL' => 'https://safe-supabase-kong.caprover.al-arcade.com',
'SUPABASE_ANON_KEY' => 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84',
'SUPABASE_SERVICE_KEY' => 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4',
'STOCKFISH_API' => 'https://stockfishapi.caprover.al-arcade.com',
] as $k => $v) putenv("$k=$v");
$root = dirname(__DIR__);
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if (preg_match('#^/(public|api)/#', $path)) {
$file = $root . $path;
if (is_file($file)) {
if (str_ends_with($file, '.php')) { require $file; return true; }
return false; // let the built-in server serve the static file
}
http_response_code(404);
return true;
}
// Root-level static files (manifest.json, icons, logo) are served directly in
// production by the .htaccess "file exists" rule; mirror that here so the audit
// does not report a phantom manifest error.
$rootFile = $root . $path;
if ($path !== '/' && is_file($rootFile) && !str_ends_with($rootFile, '.php')) return false;
require $root . '/index.php';
......@@ -84,6 +84,19 @@ const AUDIT_FN = () => {
issues.push({ type: 'page-overflow-x', el: 'document', detail: `scrollWidth ${de.scrollWidth} > viewport ${vw}` });
}
const isControl = (n) => n instanceof Element
&& (n.matches('button,a[href],[role="button"],input,select,textarea')
|| getComputedStyle(n).cursor === 'pointer');
// A carousel or chip row scrolls its children out of view on purpose.
const inScroller = (n) => {
for (let p = n.parentElement; p && p !== document.body; p = p.parentElement) {
const o = getComputedStyle(p).overflowX;
if (o === 'auto' || o === 'scroll') return true;
}
return false;
};
const all = Array.from(document.querySelectorAll('body *'));
for (const el of all) {
const cs = getComputedStyle(el);
......@@ -97,14 +110,20 @@ const AUDIT_FN = () => {
}
// 3. Sticking out past either edge.
if (!inScroller(el)) {
if (r.left < -1) push('clipped-start', el, `left ${Math.round(r.left)}px`);
if (r.right > vw + 1) push('clipped-end', el, `right ${Math.round(r.right)}px past ${vw}px`);
}
// 4. Tap targets. 44x44 is the documented minimum on both platforms.
const tappable = el.matches('button,a,[role="button"],input,select,textarea,[onclick]')
|| cs.cursor === 'pointer';
if (tappable && r.width > 0 && r.height > 0) {
if (r.height < 44 || r.width < 44) {
// Only the OUTERMOST control counts: a 20px label inside a 48px button is
// not a small target, and counting it buries the real ones.
if (isControl(el)) {
let ancestorIsControl = false;
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
if (isControl(p)) { ancestorIsControl = true; break; }
}
if (!ancestorIsControl && (r.height < 44 || r.width < 44)) {
push('tap-target', el, `${Math.round(r.width)}x${Math.round(r.height)} (min 44x44)`);
}
}
......@@ -122,13 +141,6 @@ const AUDIT_FN = () => {
if (clipsY) push('clipped-content-y', el, `content ${el.scrollHeight}px in ${el.clientHeight}px box`);
if (clipsX) push('clipped-content-x', el, `content ${el.scrollWidth}px in ${el.clientWidth}px box`);
// 7. Fixed pixel widths that cannot fit the narrowest supported phone (360).
if (cs.width.endsWith('px') && !cs.width.startsWith('0')) {
const w = parseFloat(cs.width);
if (w > 360 && cs.maxWidth === 'none' && r.width > vw * 0.98) {
push('fixed-width', el, `width:${cs.width} with no max-width`);
}
}
}
return {
......@@ -139,12 +151,31 @@ const AUDIT_FN = () => {
};
};
// Every guest sign-in creates a real account. Track them and remove them at the
// end, so repeated audit runs do not litter the database with throwaway players.
const SB = 'https://safe-supabase-kong.caprover.al-arcade.com';
const SK = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4';
const createdGuests = new Set();
async function removeGuests() {
for (const id of createdGuests) {
for (const path of [`chat_messages?sender_id=eq.${id}`, `matchmaking_queue?player_id=eq.${id}`,
`mp_log?player_id=eq.${id}`, `matches?white_player_id=eq.${id}`,
`matches?black_player_id=eq.${id}`, `player_achievements?player_id=eq.${id}`,
`profiles?id=eq.${id}`]) {
await fetch(`${SB}/rest/v1/${path}`, { method: 'DELETE', headers: { apikey: SK, Authorization: `Bearer ${SK}` } }).catch(() => {});
}
await fetch(`${SB}/auth/v1/admin/users/${id}`, { method: 'DELETE', headers: { apikey: SK, Authorization: `Bearer ${SK}` } }).catch(() => {});
}
if (createdGuests.size) console.log(`\ncleaned up ${createdGuests.size} throwaway guest account(s)`);
}
async function guestLogin(page) {
await page.goto(BASE + '/', { waitUntil: 'networkidle2', timeout: 60000 });
await new Promise(r => setTimeout(r, 1500));
// Already signed in from a previous run?
const needsLogin = await page.evaluate(() => !localStorage.getItem('el3ab_auth_token')
const needsLogin = await page.evaluate(() => !JSON.parse(localStorage.getItem('el3ab_state')||'{}')?.auth?.token
&& !!document.body.innerText.match(/كضيف|Guest/));
if (!needsLogin) return true;
......@@ -156,6 +187,12 @@ async function guestLogin(page) {
});
if (!clicked) return false;
await new Promise(r => setTimeout(r, 4000));
const id = await page.evaluate(() => {
try { return JSON.parse(localStorage.getItem('el3ab_state') || '{}')?.auth?.userId || null; }
catch (e) { return null; }
});
if (id) createdGuests.add(id);
return true;
}
......@@ -209,6 +246,7 @@ for (const device of DEVICES) {
}
report.consoleErrors = consoleErrors;
await browser.close();
await removeGuests();
fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 2));
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment