Commit 7119c142 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(design): The Cafe Deck, foundation and home

El3ab is redesigned as the deck on a cafe table: an indigo card-back ground
printed with a fine lattice, white card-stock faces in black and red ink, gold
foil kept for the crown, coins and first place. One face, Alexandria, for every
role, with tabular figures so clocks and ratings never jitter.

- tokens.css and core.css rewritten. The old token names still resolve, into
  the new palette, so every screen picks up the world before its own pass.
- New icon family (public/js/core/icons.js): Lucide for utility icons plus
  El3ab's own crown, coin, gem, suits and four game marks. emoji() and the old
  PNG icon slots now render these, so the 300 emoji call sites stop printing
  emoji. Admins can still override any icon via an "icon_<name>" asset.
- theme.js applies only the new keys; the retired palette's stored values can
  no longer repaint the app. getColor() falls back to the matching token.
- HUD: avatar, name and level, coin and gem purses, bell. Tab bar marks the
  active world with a red diamond pip.
- Home is now a fanned hand of the four games with your rating in each card's
  corner; the chosen card is pulled up and its lobby sits below it.
- tools/shoot.mjs screenshots scenes as a throwaway signed-in player.
- docs/branding-backup-2026-10-19.json: the stored theme before this change.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent aa75a0c8
...@@ -28,3 +28,7 @@ tests/.pg.log ...@@ -28,3 +28,7 @@ tests/.pg.log
tests/.auth.log tests/.auth.log
tests/.server.log tests/.server.log
tests/rpc.log tests/rpc.log
# Impeccable local state
.impeccable/hook.cache.json
.impeccable/questions/
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
Mobile-first web app, served from CapRover (`el3ab-player`). It also ships inside a Flutter
Android wrapper that loads the same pages in a WebView (`public/js/core/native-bridge.js`);
in the wrapper `html.native-app` is set and the native shell can draw its own bottom navigation.
## Users
Arabic speakers playing classic table games on their phones, one-handed, in short sessions.
Two groups carry equal weight (confirmed 2026-10-19):
- **Social players**: ludo and domino with friends, family and strangers, chat and reactions,
coins, daily rewards. The table is the reason to open the app.
- **Competitive players**: rated chess (and the other games), leaderboards, Swiss + knockout
tournaments, live broadcast of championships, clubs/organisations. Rank is the reason.
## Product Purpose
EL3AB (العب, "play") puts four classic games, chess, ludo, domino and backgammon, in one
Arabic-first hub, with bots, live matchmaking, friend challenges, ratings per game and time
control, and organised competition. Success: under three taps from opening the app to a
move on the board, and a competitive layer that makes a rating and a tournament result feel
like it matters.
## Positioning
One table for the games the Arab world already plays in cafés and living rooms, with real
competitive structure behind it: the same app hosts a family ludo game and a rated Swiss
championship with a live broadcast.
## Operating Context
- Phones first (360 to 430 px wide), Android WebView and mobile browsers; portrait.
- Arabic (RTL) is the default language; English exists through `public/js/core/i18n.js`.
- Players are often signed in as guests; many features are locked until registration.
- Tournaments are run by an operator from the separate El3ab-management app; players
register, get paired, play and follow standings here.
## Capabilities and Constraints
- Games: chess (Stockfish bots, analysis, review, puzzles, spectate), ludo, domino,
backgammon; vs bot, vs human (matchmaking), vs friend (challenge).
- Competition: per-game ratings, leaderboards, Swiss and knockout tournaments, arena,
live tournament view, brackets.
- Social: friends, chat, groups, notifications, activity, organisations/clubs.
- Economy: coins, gems, daily reward, achievements, battle pass, shop.
- Stack: vanilla ES modules, no build step; PHP 8.3 JSON API proxying Supabase; scenes are
DOM rendered from template strings; game boards are Canvas 2D.
- Theming contract (`.claude/rules/theming.md`): every visual value goes through
`public/css/tokens.css`, `public/js/core/theme.js` and `/admin/branding.php`; stored
values in Supabase `platform_theme` / `platform_assets` override code defaults.
- Hard rules: no emojis anywhere (icons only); CSS logical properties for RTL; tap targets
at least 44 px; text at least 11 px.
- Pushing `main` deploys production. The 2026-10 revamp ships live screen by screen.
## Brand Commitments
- Name: EL3AB / العب.
- The crown mark (a king-like crown with diamond accents) is kept; source files in
`Logo El3ab/`. Exists in gold and in a cyan-to-blue gradient.
- Parent studio: Al-Arcade.
- Voice: Arabic-born, not translated. Would it feel foreign in Cairo or Riyadh? Fix it.
## Evidence on Hand
- Real game engines, bots, ratings, tournaments and leaderboards in production.
- Logo files in `Logo El3ab/`; legacy icon PNGs in `app icons/` and uploaded to
`platform_assets` (being replaced by a code-drawn SVG icon family, admin-overridable).
- No player testimonials, store ratings, player counts or prize figures are on hand; do
not invent any.
## Product Principles
1. Game in thirty seconds: anything that adds steps before the first move must earn it.
2. The table is sacred: nothing competes with the board while a game is on.
3. Competition carries prestige: a rating, a rank and a result should feel earned.
4. Arab-born, not Arab-translated.
5. Social and competitive are equals; neither is a side feature of the other.
## Accessibility & Inclusion
Legibility floors are enforced in `theme.js` (11 px text, 44 px tap targets) even against
stored admin values. Respect `prefers-reduced-motion` for decorative motion.
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -8,7 +8,7 @@ header('Expires: 0'); ...@@ -8,7 +8,7 @@ header('Expires: 0');
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#050810"> <meta name="theme-color" content="#090E2C">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>EL3AB</title> <title>EL3AB</title>
...@@ -17,12 +17,20 @@ header('Expires: 0'); ...@@ -17,12 +17,20 @@ header('Expires: 0');
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Alexandria:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<?php $v = filemtime(__DIR__ . '/public/js/engine.js') ?: time(); ?> <?php $v = filemtime(__DIR__ . '/public/js/engine.js') ?: time(); ?>
<link rel="stylesheet" href="/public/css/tokens.css?v=<?=$v?>"> <link rel="stylesheet" href="/public/css/tokens.css?v=<?=$v?>">
<link rel="stylesheet" href="/public/css/core.css?v=<?=$v?>"> <link rel="stylesheet" href="/public/css/core.css?v=<?=$v?>">
</head> </head>
<body> <body>
<!--
THESIS: El3ab is the deck on a cafe table. Every game is a suit, every screen is dealt; it refuses the glossy game-tile grid on navy.
OWN-WORLD: Indigo card-back ground printed with an eight-point lattice; white card stock faces with an inner frame and corner indices; black and red ink; gold foil only for crown, coins, first place. Alexandria for every role, tabular figures.
STORY: A player sees their four games as a hand with their rating on each card, pulls one up, and is two taps from a move.
FIRST VIEWPORT: A fanned hand of four tall cards under a lamp, the chosen one raised; below it the game's name, a red Play online button and a white vs-computer card.
FORM: The Cafe Deck (koutchina), grounded list position 5, seed 88773dda.
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance
-->
<div id="game"> <div id="game">
<div id="scene-container"></div> <div id="scene-container"></div>
</div> </div>
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"devDependencies": { "devDependencies": {
"lucide-static": "^1.59.0",
"puppeteer": "^24.43.1" "puppeteer": "^24.43.1"
} }
}, },
...@@ -726,6 +727,13 @@ ...@@ -726,6 +727,13 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/lucide-static": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.59.0.tgz",
"integrity": "sha512-MuHZYaXbHgzN2QO6D3OM19bj09A+8GJiFSdXIMlbnLLEGQQ9+NRH2KaCxuivevD3Y23vM74kV17XBifgtqhfEg==",
"dev": true,
"license": "ISC"
},
"node_modules/mitt": { "node_modules/mitt": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
......
{ {
"devDependencies": { "devDependencies": {
"lucide-static": "^1.59.0",
"puppeteer": "^24.43.1" "puppeteer": "^24.43.1"
} }
} }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } /* ==========================================================================
EL3AB core: The Cafe Deck.
Ground = the card back. Faces = card stock. See tokens.css.
========================================================================== */
/* 60fps GPU compositing — force hardware acceleration on all animated elements */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.btn, .card, .tab-item, .hud-btn, .hud-avatar, .scene,
.toast, .profile-avatar, [style*="animation"],
#game-modal, #game-modal-backdrop, #match-overlay,
#thinking-indicator {
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform: translateZ(0);
}
html, body { html, body {
width: 100%; height: 100%; width: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: var(--bg-deep); background-color: var(--back-900);
background-image: var(--back-pattern);
background-size: 28px 28px;
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-ar); font-family: var(--font-ar);
font-size: 16px; font-size: 16px;
line-height: 1.5; line-height: 1.5;
font-feature-settings: "kern" 1;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
user-select: none; user-select: none;
touch-action: manipulation; touch-action: manipulation;
caret-color: var(--gold);
} }
/* Numbers are set like a scorecard: every digit the same width, so clocks,
ratings and coin counts never jitter as they change. */
body, input, button, textarea, select { font-variant-numeric: tabular-nums; }
input, button, textarea, select { font-family: inherit; }
::selection { background: var(--gold); color: var(--ink); }
:focus { outline: none; }
:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
border-radius: var(--r-sm);
}
/* Icons: one family, one stroke. Reading-direction icons mirror in RTL. */
.i { display: inline-block; flex-shrink: 0; vertical-align: middle; }
[dir="rtl"] .i-dir { transform: scaleX(-1); }
.i-coin, .i-crown { color: var(--gold); }
.i-gem { color: var(--red); }
.i-online { color: var(--win); }
.i-offline { color: var(--text-dim); }
#game { #game {
position: fixed; position: fixed;
inset: 0; inset: 0;
...@@ -35,8 +55,8 @@ html, body { ...@@ -35,8 +55,8 @@ html, body {
#scene-container { #scene-container {
position: absolute; position: absolute;
inset: 0; inset: 0;
top: var(--hud-height); top: calc(var(--hud-height) + var(--safe-top));
bottom: var(--tab-height); bottom: calc(var(--tab-height) + var(--safe-bottom));
} }
#scene-container.game-mode { #scene-container.game-mode {
...@@ -44,18 +64,19 @@ html, body { ...@@ -44,18 +64,19 @@ html, body {
bottom: 0; bottom: 0;
} }
/* ── HUD: the edge of the table ─────────────────────────────────────────── */
#hud { #hud {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; top: 0; inset-inline: 0;
height: var(--hud-height); height: calc(var(--hud-height) + var(--safe-top));
padding-top: var(--safe-top); padding-top: var(--safe-top);
padding-inline: var(--hud-padding-x);
z-index: 100; z-index: 100;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: var(--s-2);
padding-inline: var(--hud-padding-x); background: var(--back-950);
background: var(--bg-base); border-bottom: 1px solid var(--back-line);
border-bottom: 1px solid var(--border);
transition: transform var(--dur-normal) var(--ease-out); transition: transform var(--dur-normal) var(--ease-out);
} }
...@@ -64,101 +85,110 @@ html, body { ...@@ -64,101 +85,110 @@ html, body {
.hud-profile { .hud-profile {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: var(--s-2);
cursor: pointer; cursor: pointer;
position: relative; position: relative;
min-width: 44px;
min-height: 44px; min-height: 44px;
min-width: 44px;
padding-inline-end: var(--s-1);
border: none;
background: none;
color: inherit;
} }
.hud-avatar { .hud-avatar {
width: var(--hud-avatar-size); width: var(--hud-avatar-size);
height: var(--hud-avatar-size); height: var(--hud-avatar-size);
border-radius: 50%; border-radius: 50%;
background: var(--bg-elevated); background: var(--back-700);
border: var(--hud-avatar-border) solid var(--gold); box-shadow: 0 0 0 var(--hud-avatar-border) var(--back-950), 0 0 0 calc(var(--hud-avatar-border) + 1px) var(--gold);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
transition: transform var(--dur-fast) var(--ease-spring); color: var(--text-secondary);
transition: transform var(--dur-fast) var(--ease-out);
} }
.hud-avatar img { width: 100%; height: 100%; object-fit: cover; }
.hud-profile:active .hud-avatar { transform: scale(0.9); } .hud-profile:active .hud-avatar { transform: scale(0.94); }
.hud-level-badge { .hud-who { display: flex; flex-direction: column; line-height: 1.15; text-align: start; min-width: 0; }
position: absolute; .hud-name {
bottom: -4px; font-size: var(--fs-sm);
left: 50%; font-weight: 700;
transform: translateX(-50%); color: var(--text-primary);
background: var(--gold); max-width: 30vw;
color: #1a1a1a; white-space: nowrap;
font-size: var(--fs-xs); overflow: hidden;
font-weight: 800; text-overflow: ellipsis;
font-family: var(--font-lat);
min-width: 18px;
height: 16px;
line-height: 16px;
text-align: center;
border-radius: var(--r-full);
padding: 0 3px;
} }
.hud-level { font-size: var(--fs-xs); font-weight: 500; color: var(--text-muted); }
.hud-level-badge { display: none; }
.hud-stats { .hud-stats {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--hud-stat-gap); gap: var(--hud-stat-gap);
margin-inline-start: auto;
} }
/* Purse chips: a strip of card stock with the amount printed on it. */
.hud-stat { .hud-stat {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--s-1); gap: 6px;
font-family: var(--font-lat); height: 32px;
padding-inline: 8px 10px;
border-radius: var(--r-md);
background: var(--back-800);
border: 1px solid var(--back-line);
font-size: var(--hud-stat-font); font-size: var(--hud-stat-font);
font-weight: 700; font-weight: 700;
color: var(--text-primary);
} }
.hud-stat .icon { display: flex; align-items: center; } .hud-stat .icon { display: flex; align-items: center; }
.hud-stat.hud-coins { color: var(--gold); }
.hud-stat.hud-gems { color: var(--purple); }
.hud-actions { .hud-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--s-2); gap: var(--s-1);
} }
.hud-btn { .hud-btn {
width: var(--hud-btn-size); width: var(--hud-btn-size);
height: var(--hud-btn-size); height: var(--hud-btn-size);
border-radius: 50%; border-radius: var(--r-md);
background: var(--bg-elevated); background: transparent;
border: none;
color: var(--text-secondary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
transition: transform var(--dur-fast) var(--ease-spring); transition: transform var(--dur-fast) var(--ease-out), color var(--dur-fast);
} }
.hud-btn:active { transform: scale(0.88); } .hud-btn:active { transform: scale(0.92); color: var(--text-primary); }
.hud-badge { .hud-badge {
position: absolute; position: absolute;
top: -2px; top: 6px;
right: -2px; inset-inline-end: 4px;
min-width: var(--hud-badge-size); min-width: var(--hud-badge-size);
height: var(--hud-badge-size); height: var(--hud-badge-size);
line-height: var(--hud-badge-size); line-height: var(--hud-badge-size);
background: var(--orange); background: var(--red-ink);
color: white; color: var(--card);
font-size: var(--hud-badge-font); font-size: var(--hud-badge-font);
font-weight: 700; font-weight: 700;
font-family: var(--font-lat);
border-radius: var(--r-full); border-radius: var(--r-full);
box-shadow: 0 0 0 2px var(--back-950);
text-align: center; text-align: center;
padding: 0 4px; padding: 0 5px;
display: none; display: none;
} }
...@@ -174,49 +204,54 @@ html, body { ...@@ -174,49 +204,54 @@ html, body {
justify-content: center; justify-content: center;
} }
#overlay.active { pointer-events: all; background: rgba(0,0,0,0.7); } #overlay.active { pointer-events: all; background: rgba(5, 8, 30, 0.72); }
/* ── Toasts: a small card dealt from the top of the table ───────────────── */
#toast-container { #toast-container {
position: fixed; position: fixed;
top: calc(var(--hud-height) + var(--s-2)); top: calc(var(--hud-height) + var(--safe-top) + var(--s-2));
left: 50%; transform: translateX(-50%); inset-inline: var(--gutter);
z-index: 300; z-index: 300;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
gap: var(--s-2); gap: var(--s-2);
pointer-events: none; pointer-events: none;
} }
.toast { .toast {
background: var(--bg-elevated); position: relative;
border: 1px solid var(--border-hover); max-width: 420px;
background: var(--card);
color: var(--ink);
border-radius: var(--r-lg); border-radius: var(--r-lg);
padding: var(--s-3) var(--s-4); padding: var(--s-3) var(--s-4);
color: var(--text-primary); font-size: var(--fs-base);
font-size: 14px;
font-weight: 600; font-weight: 600;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
animation: toastIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); outline: 1px solid var(--card-line);
outline-offset: -4px;
animation: toastIn var(--dur-slow) var(--ease-deal);
pointer-events: all; pointer-events: all;
} }
.toast.out { animation: toastOut 0.2s ease-in forwards; } .toast.out { animation: toastOut var(--dur-fast) var(--ease-in) forwards; }
.toast.reward { background: linear-gradient(135deg, #1a2440, #2a1a40); border-color: var(--gold); } .toast.reward { outline-color: var(--gold); }
.toast.error { border-color: var(--error); } .toast.error { outline-color: var(--red-ink); color: var(--red-ink-deep); }
@keyframes toastIn { from { opacity: 0; transform: translateY(-30px) scale(0.8); } } @keyframes toastIn { from { opacity: 0; transform: translateY(-18px) rotate(-1.5deg); } }
@keyframes toastOut { to { opacity: 0; transform: translateY(-15px) scale(0.9); } } @keyframes toastOut { to { opacity: 0; transform: translateY(-10px); } }
/* Tab Bar */ /* ── Tab bar ────────────────────────────────────────────────────────────── */
.tab-bar { .tab-bar {
position: fixed; position: fixed;
bottom: 0; left: 0; right: 0; bottom: 0; inset-inline: 0;
height: var(--tab-height); height: calc(var(--tab-height) + var(--safe-bottom));
padding-bottom: var(--safe-bottom); padding-bottom: var(--safe-bottom);
background: var(--bg-base); background: var(--back-950);
border-top: 1px solid var(--border); border-top: 1px solid var(--back-line);
display: flex; display: flex;
align-items: center; align-items: stretch;
justify-content: space-around; justify-content: space-around;
z-index: 100; z-index: 100;
transition: transform var(--dur-normal) var(--ease-out); transition: transform var(--dur-normal) var(--ease-out);
...@@ -225,33 +260,47 @@ html, body { ...@@ -225,33 +260,47 @@ html, body {
.tab-bar.hidden { transform: translateY(100%); } .tab-bar.hidden { transform: translateY(100%); }
.tab-item { .tab-item {
position: relative;
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
gap: var(--tab-item-gap); gap: var(--tab-item-gap);
padding: var(--tab-item-padding-y) var(--tab-item-padding-x); padding: var(--tab-item-padding-y) var(--tab-item-padding-x);
border-radius: var(--r-md);
color: var(--text-muted); color: var(--text-muted);
font-size: var(--tab-label-font); font-size: var(--tab-label-font);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: color var(--dur-fast), transform var(--dur-fast);
min-width: var(--tab-item-min-size); min-width: var(--tab-item-min-size);
min-height: var(--tab-item-min-size); min-height: var(--tab-item-min-size);
justify-content: center; background: none;
border: none;
transition: color var(--dur-fast);
} }
.tab-item:active { transform: scale(0.9); } /* The active tab is marked the way a card is: a small red diamond pip. */
.tab-item.active { color: var(--gold); } .tab-item::before {
.tab-item.active .tab-icon { filter: drop-shadow(0 0 6px var(--gold)); } content: '';
position: absolute;
top: 5px;
width: 7px; height: 7px;
background: var(--red-ink);
transform: rotate(45deg) scale(0);
transition: transform var(--dur-normal) var(--ease-deal);
}
.tab-item.active { color: var(--text-primary); }
.tab-item.active::before { transform: rotate(45deg) scale(1); }
.tab-item:active .tab-icon { transform: translateY(1px); }
.tab-icon { .tab-icon {
width: var(--tab-icon-size); height: var(--tab-icon-size); width: var(--tab-icon-size); height: var(--tab-icon-size);
fill: currentColor; display: flex; align-items: center; justify-content: center;
transition: transform var(--dur-fast);
} }
.tab-icon img { width: 100%; height: 100%; object-fit: contain; } .tab-icon img, .tab-icon svg { width: 100%; height: 100%; object-fit: contain; }
/* Scene transitions - game feel */ /* ── Scenes: each one is dealt onto the table ───────────────────────────── */
.scene { .scene {
position: absolute; position: absolute;
inset: 0; inset: 0;
...@@ -260,15 +309,66 @@ html, body { ...@@ -260,15 +309,66 @@ html, body {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: var(--back-600) transparent;
}
.scene-enter { animation: sceneIn var(--dur-slow) var(--ease-deal); }
.scene-exit { animation: sceneOut var(--dur-fast) var(--ease-in) forwards; }
@keyframes sceneIn { from { opacity: 0; transform: translateY(10px); } }
@keyframes sceneOut { to { opacity: 0; } }
/* ── Screen furniture ───────────────────────────────────────────────────── */
.screen {
width: 100%;
max-width: 560px;
margin-inline: auto;
padding: var(--s-4) var(--gutter) var(--s-8);
} }
.scene-enter { animation: sceneIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); } .screen-head {
.scene-exit { animation: sceneOut 0.15s ease-in forwards; } display: flex;
align-items: center;
gap: var(--s-3);
min-height: 52px;
margin-bottom: var(--s-4);
}
.screen-title {
font-size: var(--fs-xl);
font-weight: 800;
line-height: 1.2;
color: var(--text-primary);
text-wrap: balance;
}
.screen-sub { font-size: var(--fs-sm); color: var(--text-secondary); }
@keyframes sceneIn { 0% { opacity: 0; transform: scale(0.88); } 60% { opacity: 1; transform: scale(1.02); } 100% { transform: scale(1); } } .section-title {
@keyframes sceneOut { to { opacity: 0; transform: scale(1.05); } } display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-2);
margin: var(--s-6) 0 var(--s-3);
font-size: var(--fs-md);
font-weight: 700;
color: var(--text-primary);
}
.section-title .link { font-size: var(--fs-sm); font-weight: 600; color: var(--sky); }
/* Profile Avatar */ .icon-btn {
width: 44px; height: 44px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--r-md);
background: var(--back-800);
border: 1px solid var(--back-line);
color: var(--text-primary);
cursor: pointer;
flex-shrink: 0;
transition: transform var(--dur-fast) var(--ease-out), background var(--dur-fast);
}
.icon-btn:active { transform: translateY(1px); background: var(--back-700); }
/* ── Profile avatar ─────────────────────────────────────────────────────── */
.profile-avatar-wrap { .profile-avatar-wrap {
position: relative; position: relative;
width: 88px; width: 88px;
...@@ -281,53 +381,105 @@ html, body { ...@@ -281,53 +381,105 @@ html, body {
width: 88px; width: 88px;
height: 88px; height: 88px;
border-radius: 50%; border-radius: 50%;
background: var(--bg-elevated); background: var(--back-700);
border: 3px solid var(--gold); box-shadow: 0 0 0 3px var(--back-900), 0 0 0 4px var(--gold);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 40px; color: var(--text-secondary);
overflow: hidden; overflow: hidden;
transition: transform var(--dur-fast) var(--ease-spring); transition: transform var(--dur-fast) var(--ease-out);
} }
.profile-avatar-wrap:active .profile-avatar { transform: scale(0.93); } .profile-avatar-wrap:active .profile-avatar { transform: scale(0.96); }
.profile-avatar-edit { .profile-avatar-edit {
position: absolute; position: absolute;
bottom: 2px; bottom: 2px;
right: 2px; inset-inline-end: 2px;
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 50%; border-radius: 50%;
background: var(--gold); background: var(--card);
color: var(--ink);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 2px solid var(--bg-card); box-shadow: 0 0 0 2px var(--back-900);
} }
/* Cards */ /* ── Panels on the ground (the old .card) ───────────────────────────────── */
.card { .card {
background: var(--bg-card); background: var(--back-800);
border: var(--card-border-width, 1px) solid var(--border); border: var(--card-border-width, 1px) solid var(--back-line);
border-radius: var(--r-xl); border-radius: var(--r-xl);
padding: var(--s-4); padding: var(--s-4);
box-shadow: var(--shadow-sm); transition: transform var(--dur-fast) var(--ease-out), background var(--dur-fast);
transition: transform var(--dur-fast) var(--ease-spring), box-shadow var(--dur-fast); }
.card:active { transform: translateY(1px); }
/* ── Card faces: printed card stock, inner frame, corner indices ───────── */
.deck-card {
position: relative;
background: var(--card);
color: var(--ink);
border-radius: var(--r-card);
box-shadow: var(--shadow-card);
isolation: isolate;
}
.deck-card::before {
content: '';
position: absolute;
inset: 6px;
border: 1px solid var(--card-line);
border-radius: calc(var(--r-card) - 5px);
pointer-events: none;
z-index: -1;
}
.deck-card.ink-red { --suit: var(--red-ink); }
.deck-card { --suit: var(--ink); }
.deck-card .index {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
color: var(--suit);
font-weight: 800;
line-height: 1;
} }
.deck-card .index.top { top: 12px; inset-inline-start: 12px; }
.deck-card .index.bottom { bottom: 12px; inset-inline-end: 12px; transform: rotate(180deg); }
.card:active { transform: scale(0.96); box-shadow: var(--shadow-md); transition-duration: 80ms; } /* Pressed: the card sinks into the table. */
button.deck-card, .deck-card[role="button"] { border: none; cursor: pointer; font: inherit; text-align: inherit; }
.deck-card[role="button"]:active, button.deck-card:active {
transform: translateY(2px);
box-shadow: 0 1px 1px rgba(3, 6, 25, 0.35), 0 6px 12px -8px rgba(3, 6, 25, 0.9);
}
/* Buttons */ /* Disabled: the card lies face down. */
/* ── Tap-target floor ────────────────────────────────────────────────────── .deck-back,
Every interactive control is at least 44px tall, the documented minimum on .deck-card.face-down {
both iOS and Android. An audit of the live app found 40 distinct controls background-color: var(--back-600);
below it — filter chips at 31px, tab buttons at 34px, icon buttons at 32px — background-image: var(--back-pattern-dense);
which on a phone means missed taps, not just an untidy layout. background-size: 14px 14px;
color: var(--text-secondary);
}
.deck-card.face-down::before { border-color: rgba(247, 244, 236, 0.28); }
Height only: forcing a minimum width would stretch chips that are legitimately /* Spent: a punched corner. */
narrow. Icon-only buttons, which need both, are handled where they are defined. */ .deck-card.spent {
-webkit-mask: radial-gradient(circle 11px at calc(100% - 16px) 16px, transparent 98%, #000 100%);
mask: radial-gradient(circle 11px at calc(100% - 16px) 16px, transparent 98%, #000 100%);
}
[dir="rtl"] .deck-card.spent {
-webkit-mask: radial-gradient(circle 11px at 16px 16px, transparent 98%, #000 100%);
mask: radial-gradient(circle 11px at 16px 16px, transparent 98%, #000 100%);
}
/* ── Buttons ────────────────────────────────────────────────────────────── */
button, button,
[role="button"], [role="button"],
input[type="button"], input[type="button"],
...@@ -335,15 +487,13 @@ input[type="submit"] { ...@@ -335,15 +487,13 @@ input[type="submit"] {
min-block-size: var(--tap-min, 44px); min-block-size: var(--tap-min, 44px);
} }
/* Standalone text links act as controls too. A 21px-tall legal link is a miss a { color: var(--sky); text-underline-offset: 3px; }
waiting to happen; give the row real height without changing how it reads. */
a:not(.btn):not([class*="tab"]) { a:not(.btn):not([class*="tab"]) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-block-size: var(--tap-min, 44px); min-block-size: var(--tap-min, 44px);
} }
/* Icon-only controls need the floor in both directions to stay square. */
.icon-btn, .icon-btn,
.hud-btn, .hud-btn,
#emote-inline-toggle, #emote-inline-toggle,
...@@ -359,57 +509,167 @@ a:not(.btn):not([class*="tab"]) { ...@@ -359,57 +509,167 @@ a:not(.btn):not([class*="tab"]) {
gap: var(--s-2); gap: var(--s-2);
padding: var(--s-3) var(--s-5); padding: var(--s-3) var(--s-5);
border: none; border: none;
border-radius: var(--r-full, 9999px); border-radius: var(--r-btn);
font-family: var(--font-ar); font-family: var(--font-ar);
font-size: 15px; font-size: var(--fs-base);
font-weight: var(--btn-weight, 700); font-weight: var(--btn-weight, 700);
line-height: 1.2;
cursor: pointer; cursor: pointer;
transition: transform var(--dur-fast) var(--ease-spring), box-shadow var(--dur-fast); transition: transform var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast), background var(--dur-fast);
min-height: var(--btn-min-height, 44px); min-height: var(--btn-min-height, 48px);
-webkit-user-select: none;
} }
.btn:active { transform: scale(var(--btn-press-scale, 0.92)); transition-duration: 50ms; } .btn:active { transform: translateY(1px) scale(var(--btn-press-scale, 0.98)); }
.btn:disabled, .btn[aria-disabled="true"] { opacity: 0.45; cursor: default; transform: none; }
.btn-primary { /* Primary: printed in red ink, with the card's inner frame. */
background: linear-gradient(135deg, var(--gold), var(--gold-soft)); .btn-primary,
color: #1a1a1a; .btn-game {
box-shadow: var(--btn-shadow, 0 4px 16px rgba(228,172,56,0.3)); background: var(--red-ink);
color: var(--card);
box-shadow: var(--btn-shadow);
outline: 1px solid rgba(247, 244, 236, 0.34);
outline-offset: -4px;
} }
.btn-primary:active, .btn-game:active { background: var(--red-ink-deep); box-shadow: 0 1px 0 var(--red-ink-deep); }
/* Secondary: a face-down card on the table. */
.btn-secondary { .btn-secondary {
background: var(--bg-elevated); background: var(--back-700);
color: var(--text-primary); color: var(--text-primary);
border: var(--card-border-width, 1px) solid var(--border-hover); border: 1px solid var(--back-line-strong);
border-radius: var(--r-btn-secondary, var(--r-full, 9999px)); border-radius: var(--r-btn-secondary, var(--r-btn));
} }
.btn-secondary:active { background: var(--back-600); }
.btn-game { /* A white card face, for the second action that still deserves weight. */
background: linear-gradient(135deg, var(--game-primary, var(--blue)), var(--game-secondary, var(--cyan))); .btn-card {
color: white; background: var(--card);
box-shadow: 0 4px 16px rgba(32,130,240,0.3); color: var(--ink);
font-size: 18px; box-shadow: var(--shadow-sm);
padding: var(--s-4) var(--s-8); outline: 1px solid var(--card-line);
outline-offset: -4px;
}
.btn-card:active { background: var(--card-2); }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--back-line);
}
/* Gold foil: claim a reward, enter a final. Rare by design. */
.btn-gold {
background: var(--gold);
color: var(--ink);
box-shadow: 0 2px 0 var(--gold-dark), 0 8px 18px -8px rgba(184, 132, 31, 0.8);
outline: 1px solid rgba(23, 21, 15, 0.22);
outline-offset: -4px;
} }
/* Inputs */ .btn-block { width: 100%; }
.btn-lg { min-height: 56px; font-size: var(--fs-md); padding-inline: var(--s-6); }
/* ── Inputs ─────────────────────────────────────────────────────────────── */
.input { .input {
width: 100%; width: 100%;
padding: var(--s-3) var(--s-4); padding: var(--s-3) var(--s-4);
background: var(--bg-elevated); background: var(--back-950);
border: var(--card-border-width, 1px) solid var(--border); border: 1px solid var(--back-line-strong);
border-radius: var(--r-input, var(--r-md)); border-radius: var(--r-input);
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-ar); font-family: var(--font-ar);
font-size: 15px; font-size: 16px;
outline: none; outline: none;
transition: border-color var(--dur-fast); transition: border-color var(--dur-fast), box-shadow var(--dur-fast);
min-height: var(--btn-min-height, 44px); min-height: var(--btn-min-height, 48px);
user-select: text;
} }
.input:focus { border-color: var(--gold); } .input:focus { border-color: var(--gold); box-shadow: 0 0 0 3px rgba(228, 172, 56, 0.18); }
.input::placeholder { color: var(--text-muted); } .input::placeholder { color: var(--text-muted); }
/* Utility */ /* ── Chips: a row of choices, the chosen one printed on card stock ──────── */
.chips {
display: flex;
gap: var(--s-2);
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.chips::-webkit-scrollbar { display: none; }
.chip {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 40px;
padding: 0 var(--s-4);
border-radius: var(--r-md);
border: 1px solid var(--back-line-strong);
background: transparent;
color: var(--text-secondary);
font-size: var(--fs-sm);
font-weight: 600;
cursor: pointer;
transition: background var(--dur-fast), color var(--dur-fast);
}
.chip.active, .chip[aria-pressed="true"], .chip[aria-selected="true"] {
background: var(--card);
color: var(--ink);
border-color: var(--card);
}
/* ── Rows: lists printed straight on the ground, hairline-ruled ─────────── */
.rows { display: flex; flex-direction: column; }
.row {
display: flex;
align-items: center;
gap: var(--s-3);
min-height: 60px;
padding: var(--s-2) 0;
border-bottom: 1px solid var(--back-line);
}
.row:last-child { border-bottom: none; }
.row-main { flex: 1; min-width: 0; }
.row-title { font-size: var(--fs-base); font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row-sub { font-size: var(--fs-sm); color: var(--text-muted); }
.avatar {
width: 40px; height: 40px;
border-radius: 50%;
background: var(--back-700);
color: var(--text-secondary);
display: flex; align-items: center; justify-content: center;
overflow: hidden;
flex-shrink: 0;
font-weight: 700;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
/* ── Empty states: a face-down card with one line under it ──────────────── */
.empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--s-3);
padding: var(--s-10) var(--s-6);
color: var(--text-secondary);
}
.empty .deck-back {
width: 72px; height: 100px;
border-radius: 10px;
box-shadow: var(--shadow-md);
display: flex; align-items: center; justify-content: center;
color: var(--card);
transform: rotate(-6deg);
}
.empty-title { font-size: var(--fs-md); font-weight: 700; color: var(--text-primary); }
.empty-sub { font-size: var(--fs-sm); max-width: 32ch; }
/* ── Utility ────────────────────────────────────────────────────────────── */
.flex { display: flex; } .flex { display: flex; }
.flex-col { flex-direction: column; } .flex-col { flex-direction: column; }
.items-center { align-items: center; } .items-center { align-items: center; }
...@@ -424,72 +684,67 @@ a:not(.btn):not([class*="tab"]) { ...@@ -424,72 +684,67 @@ a:not(.btn):not([class*="tab"]) {
.w-full { width: 100%; } .w-full { width: 100%; }
.p-4 { padding: var(--s-4); } .p-4 { padding: var(--s-4); }
.mt-4 { margin-top: var(--s-4); } .mt-4 { margin-top: var(--s-4); }
.num { font-variant-numeric: tabular-nums; direction: ltr; unicode-bidi: isolate; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ── Loading ────────────────────────────────────────────────────────────── */
/* Loading / Skeleton */
.skeleton { .skeleton {
background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-elevated) 50%, var(--bg-card) 75%); background: linear-gradient(90deg, var(--back-800) 25%, var(--back-700) 50%, var(--back-800) 75%);
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 1.5s infinite; animation: shimmer 1.6s infinite linear;
border-radius: var(--r-md); border-radius: var(--r-md);
} }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* ========= JUICE SYSTEM ========= */ /* ── Motion vocabulary. Names are kept because scene code calls them. ───── */
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } } @keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
@keyframes glow { 0%, 100% { box-shadow: 0 0 5px var(--gold); } 50% { box-shadow: 0 0 20px var(--gold); } } @keyframes glow { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
@keyframes shake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-4px); } 40% { transform: translateX(4px); } 60% { transform: translateX(-2px); } 80% { transform: translateX(2px); } } @keyframes shake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-4px); } 40% { transform: translateX(4px); } 60% { transform: translateX(-2px); } 80% { transform: translateX(2px); } }
@keyframes popIn { 0% { transform: scale(0); opacity: 0; } 60% { transform: scale(1.15); } 100% { transform: scale(1); opacity: 1; } } @keyframes popIn { from { transform: scale(0.9); opacity: 0; } }
@keyframes slamIn { 0% { transform: scale(1.5); opacity: 0; } 60% { transform: scale(0.92); opacity: 1; } 80% { transform: scale(1.04); } 100% { transform: scale(1); } } @keyframes slamIn { 0% { transform: translateY(-14px) rotate(-3deg); opacity: 0; } 100% { transform: none; opacity: 1; } }
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes slideUpBounce { 0% { transform: translateY(100%); } 70% { transform: translateY(-3%); } 100% { transform: translateY(0); } } @keyframes slideUpBounce { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes bounceIn { 0% { transform: scale(0.3); opacity: 0; } 50% { transform: scale(1.08); } 70% { transform: scale(0.96); } 100% { transform: scale(1); opacity: 1; } } @keyframes bounceIn { from { transform: translateY(8px); opacity: 0; } }
@keyframes breatheGlow { 0%, 100% { box-shadow: 0 0 6px rgba(228,172,56,0.15); } 50% { box-shadow: 0 0 20px rgba(228,172,56,0.4); } } @keyframes breatheGlow { 0%, 100% { opacity: 1; } 50% { opacity: 0.88; } }
@keyframes bgGradientMove { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } } @keyframes bgGradientMove { 0%, 100% { background-position: 0 0; } }
@keyframes numberPop { 0% { transform: scale(1); } 50% { transform: scale(1.3); } 100% { transform: scale(1); } } @keyframes numberPop { 0% { transform: scale(1); } 50% { transform: scale(1.12); } 100% { transform: scale(1); } }
@keyframes ringExpand { 0% { transform: scale(0.5); opacity: 1; } 100% { transform: scale(2.5); opacity: 0; } } @keyframes ringExpand { 0% { transform: scale(0.5); opacity: 1; } 100% { transform: scale(2.2); opacity: 0; } }
@keyframes wobble { 0%,100% { transform: rotate(0); } 25% { transform: rotate(-2deg); } 75% { transform: rotate(2deg); } } @keyframes wobble { 0%,100% { transform: rotate(0); } 25% { transform: rotate(-1.5deg); } 75% { transform: rotate(1.5deg); } }
@keyframes shimmerBorder { 0% { border-color: rgba(228,172,56,0.1); } 50% { border-color: rgba(228,172,56,0.3); } 100% { border-color: rgba(228,172,56,0.1); } } @keyframes shimmerBorder { 0%, 100% { border-color: var(--back-line); } 50% { border-color: var(--back-line-strong); } }
@keyframes fadeIn { from { opacity: 0; } }
/* A card dealt from the deck: comes in from above, turning as it lands. */
@keyframes deal { from { opacity: 0; transform: translateY(-28px) rotate(var(--deal-rot, -6deg)) scale(0.96); } }
@keyframes flipIn { from { transform: perspective(900px) rotateY(90deg); opacity: 0.4; } }
.pulse { animation: pulse 2s ease-in-out infinite; } .pulse { animation: pulse 2s ease-in-out infinite; }
.float { animation: float 3s ease-in-out infinite; } .float { animation: float 4s ease-in-out infinite; }
.shake { animation: shake 0.3s ease; } .shake { animation: shake 0.3s ease; }
.pop-in { animation: popIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); } .pop-in { animation: popIn var(--dur-normal) var(--ease-out); }
.slam-in { animation: slamIn 0.45s cubic-bezier(0.34, 1.56, 0.64, 1); } .slam-in { animation: slamIn var(--dur-slow) var(--ease-deal); }
.bounce-in { animation: bounceIn 0.45s cubic-bezier(0.34, 1.56, 0.64, 1); } .bounce-in { animation: bounceIn var(--dur-normal) var(--ease-out); }
.slide-up { animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .slide-up { animation: slideUp var(--dur-normal) var(--ease-out); }
.slide-up-bounce { animation: slideUpBounce 0.4s cubic-bezier(0.16, 1, 0.3, 1); } .slide-up-bounce { animation: slideUp var(--dur-slow) var(--ease-deal); }
.breathe-glow { animation: breatheGlow 2.5s ease-in-out infinite; } .breathe-glow { animation: none; }
.wobble { animation: wobble 0.5s ease; } .wobble { animation: wobble 0.5s ease; }
.number-pop { animation: numberPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } .number-pop { animation: numberPop var(--dur-normal) var(--ease-out); }
.dealt { animation: deal var(--dur-slow) var(--ease-deal) both; }
/* Ambient background — subtle alive gradient */ .flip-in { animation: flipIn 520ms var(--ease-deal) both; }
body::before {
content: '';
position: fixed;
inset: 0;
background: radial-gradient(ellipse at 30% 20%, rgba(228,172,56,0.03) 0%, transparent 50%),
radial-gradient(ellipse at 70% 80%, rgba(32,130,240,0.02) 0%, transparent 50%);
background-size: 200% 200%;
animation: bgGradientMove 25s ease infinite;
pointer-events: none;
z-index: 0;
}
/* Primary buttons breathe when idle */ /* Scrollbars, where a platform shows them. */
.btn-primary { animation: breatheGlow 2.5s ease-in-out infinite; } ::-webkit-scrollbar { width: 4px; height: 4px; }
.btn-primary:active { animation: none; }
/* Performance hints */
.btn, .card, .game-tile { will-change: transform; }
/* Scrollbar */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 2px; } ::-webkit-scrollbar-thumb { background: var(--back-600); border-radius: 2px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
}
}
/* Native App Mode */ /* Native app mode */
.native-app .bottom-nav { display: none !important; } .native-app .bottom-nav { display: none !important; }
.native-app .tab-bar { padding-bottom: calc(var(--safe-bottom) + 8px); } .native-app .tab-bar { padding-bottom: calc(var(--safe-bottom) + 8px); }
/* ==========================================================================
EL3AB tokens: The Cafe Deck.
The app is the deck on a cafe table. The ground is the card back, a deep
indigo printed with a fine lattice. Things that ARE cards (games, bots, your
player card, results, rewards, tournaments) are printed on white card stock
in two inks, black and red. Gold is foil: reserved for the crown, coins and
first place.
Every value here can be overridden from /admin/branding.php; theme.js maps
the stored keys onto these properties.
========================================================================== */
:root { :root {
--bg-deep: #0a0a1a; /* ── The card back: the ground every screen sits on ─────────────────── */
--bg-base: #0a0a14; --back-950: #090E2C;
--bg-card: #1a1a2e; --back-900: #0E1540;
--bg-elevated: #1e1e3a; --back-800: #151F52;
--bg-surface: #1c1c34; --back-700: #1D2963;
--bg-hover: #2a2a4a; --back-600: #283679;
--bg-panel: #0f0f1e; --back-500: #3A4A9C;
--bg-inset: #0a1420; --back-line: rgba(150, 165, 240, 0.14);
--bg-dark: #1a1a1a; --back-line-strong: rgba(150, 165, 240, 0.28);
--back-ink: #8C9CF0; /* the lattice is printed in this */
/* ── Card stock and its two inks ─────────────────────────────────────── */
--card: #F7F4EC;
--card-2: #EEE9DD;
--card-3: #E2DBCB;
--card-line: #D6CEBC;
--ink: #17150F;
--ink-2: #4A463C;
--ink-3: #6F6858;
--red-ink: #C41E2E;
--red-ink-deep: #A01624;
/* ── Foil and signals (on the dark ground) ───────────────────────────── */
--gold: #E4AC38; --gold: #E4AC38;
--gold-soft: #FFCC66; --gold-soft: #F2C766;
--gold-dark: #D4940A; --gold-dark: #B8841F;
--blue: #3B82F6; --gold-ink: #93650F; /* gold printed on card stock */
--cyan: #06B6D4; --red: #EF4D55; /* red ink as it reads on indigo */
--orange: #F97316; --win: #3DBE7E;
--purple: #8B5CF6; --win-ink: #1C7448;
--purple-light: #a78bfa; --loss: #EF4D55;
--violet: #7C3AED; --draw: #B7BCE0;
--emerald: #10B981; --sky: #8FA2FA; /* links and information on indigo */
--green: #22C55E;
--green-light: #4ade80; /* ── Text on the card back ───────────────────────────────────────────── */
--pink: #EC4899; --text-primary: #F3F0E8;
--amber: #FBBF24; --text-secondary: #B7BCE0;
--red: #EF4444; --text-muted: #8C92C4;
--red-light: #F87171; --text-dim: #6A72A8;
--red-soft: #fca5a5; --text-light: #DCDDEB;
--chess-primary: #2563EB; /* ── Ludo's four players, printed inks ───────────────────────────────── */
--chess-secondary: #F5B731; --ludo-red: #C41E2E;
--ludo-primary: #8B5CF6; --ludo-blue: #2447B8;
--ludo-secondary: #EC4899; --ludo-green: #1F7A4D;
--domino-primary: #10B981; --ludo-yellow: #E0A526;
--domino-secondary: #06B6D4;
--backgammon-primary: #F59E0B; /* ── Legacy names. Old scene code reads these; they now resolve into the
--backgammon-secondary: #EF4444; deck so nothing renders in the retired palette. ───────────────── */
--bg-deep: var(--back-950);
--bg-board-border: #5C3A0E; --bg-base: var(--back-900);
--bg-board-light: #3D6B1E; --bg-panel: var(--back-900);
--bg-board-dark: #2D5016; --bg-card: var(--back-800);
--bg-bar-dark: #2A1800; --bg-surface: var(--back-800);
--bg-bar-light: #3D2B1A; --bg-elevated: var(--back-700);
--bg-bearoff: #1A0E00; --bg-hover: var(--back-600);
--bg-point-dark: #8B4513; --bg-inset: var(--back-950);
--bg-point-dark-tip: #5C2D0A; --bg-dark: var(--back-950);
--bg-point-light: #D2691E; --blue: var(--sky);
--bg-point-light-tip: #A0522D; --cyan: var(--sky);
--bg-checker-white: #F5E6D3; --info: var(--sky);
--bg-checker-black: #2A2A2A; --purple: var(--sky);
--bg-highlight: #10B981; --purple-light: var(--sky);
--bg-selected: #FFD700; --violet: var(--back-500);
--emerald: var(--win);
--text-primary: #f8fafc; --green: var(--win);
--text-secondary: #94a3b8; --green-light: var(--win);
--text-muted: #64748b; --success: var(--win);
--text-dim: #475569; --orange: var(--gold);
--text-light: #e2e8f0; --amber: var(--gold);
--warning: var(--gold);
--success: #34D399; --pink: var(--red);
--error: #EF4444; --red-light: var(--red);
--warning: #F59E0B; --red-soft: #F7A4A8;
--win: #34D399; --error: var(--red);
--loss: #F87171;
--info: #3B82F6; --chess-primary: var(--red-ink);
--chess-secondary: var(--red-ink-deep);
--border: rgba(255,255,255,0.06); --ludo-primary: var(--red-ink);
--border-hover: rgba(255,255,255,0.12); --ludo-secondary: var(--red-ink-deep);
--border-active: rgba(32,130,240,0.4); --domino-primary: var(--red-ink);
--domino-secondary: var(--red-ink-deep);
--r-sm: 8px; --backgammon-primary: var(--red-ink);
--r-md: 12px; --backgammon-secondary: var(--red-ink-deep);
--r-lg: 16px;
--r-xl: 20px; /* Boards are printed on card stock in the same inks. */
--r-2xl: 24px; --board-light: #EFEADF;
--board-dark: #3A4A9C;
--board-highlight-light: #F2D58A;
--board-highlight-dark: #C9A24A;
--board-check: #C41E2E;
--board-frame: #0B1035;
--bg-board-border: #0B1035;
--bg-board-light: #EFEADF;
--bg-board-dark: #E6DFD0;
--bg-bar-dark: #151F52;
--bg-bar-light: #1D2963;
--bg-bearoff: #0B1035;
--bg-point-dark: #17150F;
--bg-point-dark-tip: #17150F;
--bg-point-light: #C41E2E;
--bg-point-light-tip: #C41E2E;
--bg-checker-white: #F7F4EC;
--bg-checker-black: #17150F;
--bg-highlight: #3DBE7E;
--bg-selected: #E4AC38;
--border: var(--back-line);
--border-hover: var(--back-line-strong);
--border-active: rgba(228, 172, 56, 0.55);
/* ── Shape: printed card corners, not pills ──────────────────────────── */
--r-xs: 4px;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 12px;
--r-xl: 14px;
--r-2xl: 18px;
--r-full: 9999px; --r-full: 9999px;
--r-card: 14px;
--r-btn: 10px;
--r-btn-secondary: var(--r-btn);
--r-input: 10px;
--s-1: 4px; --s-2: 8px; --s-3: 12px; --s-1: 4px; --s-2: 8px; --s-3: 12px;
--s-4: 16px; --s-5: 20px; --s-6: 24px; --s-4: 16px; --s-5: 20px; --s-6: 24px;
--s-8: 32px; --s-10: 40px; --s-12: 48px; --s-8: 32px; --s-10: 40px; --s-12: 48px;
--s-16: 64px; --s-16: 64px;
--gutter: 16px;
/* ── Type scale ──────────────────────────────────────────────────────── /* ── Type ─────────────────────────────────────────────────────────────
Fluid between a 360px phone and a 430px one. Every step has a floor: One face for every role: Alexandria, drawn by the Egyptian designer
nothing in the product may render below --fs-xs, because 9px and 10px Mohamed Gaber, Arabic and Latin from one hand. Fluid between a 360px
labels are not readable at arm's length on a phone. */ phone and a 430px one, with an 11px floor. */
--fs-xs: clamp(11px, 3.05vw, 12px); /* micro labels, badges, meta */ --font-ar: 'Alexandria', 'Noto Sans Arabic', system-ui, sans-serif;
--fs-sm: clamp(12px, 3.4vw, 13px); /* secondary text */ --font-lat: var(--font-ar);
--fs-base: clamp(14px, 3.9vw, 15px); /* body */ --font-mono: var(--font-ar);
--fs-md: clamp(15px, 4.2vw, 17px); /* emphasis */ --fs-xs: clamp(11px, 3.05vw, 12px);
--fs-lg: clamp(17px, 4.8vw, 20px); /* section titles */ --fs-sm: clamp(12px, 3.4vw, 13px);
--fs-xl: clamp(20px, 5.6vw, 24px); /* screen titles */ --fs-base: clamp(14px, 3.9vw, 15px);
--fs-md: clamp(15px, 4.2vw, 17px);
/* ── Controls ────────────────────────────────────────────────────────── --fs-lg: clamp(17px, 4.8vw, 20px);
44px is the documented minimum tap target on both iOS and Android. --fs-xl: clamp(20px, 5.6vw, 24px);
--tap-min is the floor every interactive control is held to. */ --fs-2xl: clamp(24px, 7vw, 30px);
--fs-3xl: clamp(30px, 9vw, 40px);
/* ── Controls ──────────────────────────────────────────────────────── */
--tap-min: 44px; --tap-min: 44px;
--tap-comfortable: 48px; --tap-comfortable: 48px;
--btn-min-height: 48px;
--btn-weight: 700;
--btn-press-scale: 0.98;
--btn-shadow: 0 1px 0 rgba(255, 255, 255, 0.18) inset, 0 2px 0 var(--red-ink-deep), 0 8px 18px -8px rgba(160, 22, 36, 0.8);
--card-border-width: 1px;
--font-ar: 'IBM Plex Sans Arabic', sans-serif; /* ── Motion: cards are dealt, flipped and slid; nothing bounces ───── */
--font-lat: 'Inter', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); --ease-deal: cubic-bezier(0.2, 0.9, 0.25, 1);
--ease-spring: cubic-bezier(0.2, 0.9, 0.25, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1); --ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--dur-fast: 150ms; --dur-fast: 140ms;
--dur-normal: 250ms; --dur-normal: 240ms;
--dur-slow: 400ms; --dur-slow: 420ms;
/* ── Depth: a card resting on the table ──────────────────────────────── */
--shadow-sm: 0 1px 2px rgba(3, 6, 25, 0.4), 0 3px 8px -3px rgba(3, 6, 25, 0.5);
--shadow-md: 0 2px 4px rgba(3, 6, 25, 0.35), 0 12px 26px -12px rgba(3, 6, 25, 0.85);
--shadow-lg: 0 4px 10px rgba(3, 6, 25, 0.4), 0 26px 50px -18px rgba(3, 6, 25, 0.95);
--shadow-card: 0 1px 1px rgba(3, 6, 25, 0.35), 0 3px 6px -2px rgba(3, 6, 25, 0.45), 0 18px 32px -16px rgba(3, 6, 25, 0.9);
--shadow-glow: var(--shadow-md);
--shadow-sm: 0 2px 8px rgba(0,0,0,0.3); /* The card-back lattice: an eight-point star and its diagonals. */
--shadow-md: 0 4px 16px rgba(0,0,0,0.4); --back-pattern: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'%3E%3Cg fill='none' stroke='%238C9CF0' stroke-opacity='.075' stroke-width='1'%3E%3Cpath d='M14 4.5 23.5 14 14 23.5 4.5 14z'/%3E%3Cpath d='M7.3 7.3h13.4v13.4H7.3z'/%3E%3Cpath d='M0 0l4.5 4.5M28 0l-4.5 4.5M0 28l4.5-4.5M28 28l-4.5-4.5'/%3E%3C/g%3E%3C/svg%3E");
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5); --back-pattern-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14'%3E%3Cg fill='none' stroke='%23F7F4EC' stroke-opacity='.16' stroke-width='.8'%3E%3Cpath d='M7 1.5 12.5 7 7 12.5 1.5 7z'/%3E%3Cpath d='M0 0l1.5 1.5M14 0l-1.5 1.5M0 14l1.5-1.5M14 14l-1.5-1.5'/%3E%3C/g%3E%3C/svg%3E");
--shadow-glow: 0 0 20px rgba(228,172,56,0.3);
/* ── Chrome sizes ──────────────────────────────────────────────────── */
--hud-height: 56px; --hud-height: 56px;
--tab-height: 64px; --tab-height: 64px;
--safe-top: env(safe-area-inset-top, 0px); --safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px);
/* Layout: HUD */ --hud-avatar-size: 36px;
--hud-avatar-size: 34px;
--hud-avatar-border: 2px; --hud-avatar-border: 2px;
--hud-icon-size: 16px; --hud-icon-size: 18px;
--hud-bell-size: 18px; --hud-bell-size: 20px;
--hud-stat-font: 14px; --hud-stat-font: 14px;
--hud-stat-gap: 16px; --hud-stat-gap: 8px;
--hud-btn-size: 44px; --hud-btn-size: 44px;
--hud-badge-size: 16px; --hud-badge-size: 18px;
--hud-badge-font: 9px; --hud-badge-font: 11px;
--hud-padding-x: 16px; --hud-padding-x: 12px;
/* Layout: Tab Bar */
--tab-icon-size: 24px; --tab-icon-size: 24px;
--tab-label-font: 11px; --tab-label-font: 11px;
--tab-item-gap: 3px; --tab-item-gap: 4px;
--tab-item-padding-x: 12px; --tab-item-padding-x: 8px;
--tab-item-padding-y: 8px; --tab-item-padding-y: 6px;
--tab-item-min-size: 48px; --tab-item-min-size: 48px;
/* Layout: Home Page */ /* ── Legacy layout tokens, still read by scenes awaiting their pass ── */
--home-padding: 16px; --home-padding: 16px;
--home-max-width: 340px; --home-max-width: 520px;
--home-greeting-font: 16px; --home-greeting-font: 16px;
--home-greeting-margin: 12px; --home-greeting-margin: 12px;
/* Layout: Quick Actions */
--quick-btn-icon-size: clamp(40px, 11.5vw, 48px); --quick-btn-icon-size: clamp(40px, 11.5vw, 48px);
--quick-btn-icon-radius: 12px; --quick-btn-icon-radius: 10px;
--quick-btn-icon-font: clamp(18px, 5vw, 22px); --quick-btn-icon-font: clamp(18px, 5vw, 22px);
--quick-btn-label-font: var(--fs-xs); --quick-btn-label-font: var(--fs-xs);
--quick-btn-gap: 8px; --quick-btn-gap: 8px;
--quick-btn-padding: 6px; --quick-btn-padding: 6px;
--quick-row-gap: 8px; --quick-row-gap: 8px;
--quick-row-margin: 16px; --quick-row-margin: 16px;
--game-tile-radius: var(--r-card);
/* Layout: Game Tiles */
--game-tile-radius: 18px;
--game-tile-gap: 12px; --game-tile-gap: 12px;
--game-tile-aspect: 1.1; --game-tile-aspect: 1.1;
--game-tile-icon-size: clamp(44px, 12.5vw, 56px); --game-tile-icon-size: clamp(44px, 12.5vw, 56px);
--game-tile-icon-font: clamp(36px, 10.5vw, 46px); --game-tile-icon-font: clamp(36px, 10.5vw, 46px);
--game-tile-name-font: var(--fs-md); --game-tile-name-font: var(--fs-md);
--game-tile-content-gap: 8px; --game-tile-content-gap: 8px;
--sheet-radius: 18px;
/* Layout: Game Menu (Sheet) */
--sheet-radius: 28px;
--sheet-padding-x: 20px; --sheet-padding-x: 20px;
--sheet-padding-top: 28px; --sheet-padding-top: 24px;
--sheet-max-height: 75vh; --sheet-max-height: 80vh;
--sheet-bg: var(--bg-panel); --sheet-bg: var(--card);
--gm-btn-padding-y: 16px;
/* Layout: Menu Buttons */
--gm-btn-padding-y: 18px;
--gm-btn-padding-x: 16px; --gm-btn-padding-x: 16px;
--gm-btn-gap: 14px; --gm-btn-gap: 14px;
--gm-btn-radius: 16px; --gm-btn-radius: var(--r-lg);
--gm-btn-margin-bottom: 10px; --gm-btn-margin-bottom: 10px;
--gm-btn-icon-size: 52px; --gm-btn-icon-size: 48px;
--gm-btn-icon-radius: 14px; --gm-btn-icon-radius: var(--r-md);
--gm-btn-icon-font: 26px; --gm-btn-icon-font: 24px;
--gm-btn-title-font: 17px; --gm-btn-title-font: 17px;
--gm-btn-sub-font: 11px; --gm-btn-sub-font: 12px;
--gm-btn-arrow-font: 22px; --gm-btn-arrow-font: 22px;
--gm-chip-padding-y: 12px;
/* Layout: Menu Chips */
--gm-chip-padding-y: 14px;
--gm-chip-padding-x: 8px; --gm-chip-padding-x: 8px;
--gm-chip-gap: 4px; --gm-chip-gap: 6px;
--gm-chip-radius: 12px; --gm-chip-radius: var(--r-md);
--gm-chip-font: 11px; --gm-chip-font: 12px;
--gm-chip-icon-font: 18px; --gm-chip-icon-font: 18px;
--gm-chips-grid-gap: 8px; --gm-chips-grid-gap: 8px;
--gm-chips-margin-top: 14px; --gm-chips-margin-top: 14px;
--menu-close-size: 44px;
/* Layout: Menu Close */
--menu-close-size: 36px;
--menu-close-font: 20px; --menu-close-font: 20px;
--menu-header-icon-size: 42px; --menu-header-icon-size: 44px;
--menu-header-icon-radius: 12px; --menu-header-icon-radius: var(--r-md);
--menu-header-icon-font: 22px; --menu-header-icon-font: 22px;
--menu-header-title-font: 22px; --menu-header-title-font: 22px;
--menu-header-gap: 10px; --menu-header-gap: 12px;
--menu-header-margin: 20px; --menu-header-margin: 20px;
/* Layout: Bot Cards */
--bot-card-avatar-size: clamp(48px, 14vw, 60px); --bot-card-avatar-size: clamp(48px, 14vw, 60px);
--bot-card-avatar-border: 2px; --bot-card-avatar-border: 2px;
--bot-card-name-font: var(--fs-sm); --bot-card-name-font: var(--fs-sm);
......
...@@ -2,7 +2,8 @@ import * as store from './store.js'; ...@@ -2,7 +2,8 @@ import * as store from './store.js';
import * as bus from './bus.js'; import * as bus from './bus.js';
import * as scene from './scene.js'; import * as scene from './scene.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
import { emoji, assetImg } from './theme.js'; import { assetImg } from './theme.js';
import { icon } from './icons.js';
let hudEl, tabBar; let hudEl, tabBar;
let isGuestMode = false; let isGuestMode = false;
...@@ -30,33 +31,40 @@ export function init(guestMode = false) { ...@@ -30,33 +31,40 @@ export function init(guestMode = false) {
bus.on('tournament:paired', () => updateTournamentBadge([1])); bus.on('tournament:paired', () => updateTournamentBadge([1]));
} }
function avatarMarkup(player, size) {
return player?.avatar_url
? `<img src="${player.avatar_url}" alt="">`
: icon('user', size);
}
function renderHud() { function renderHud() {
const player = store.get('player'); const player = store.get('player');
const avatarContent = player?.avatar_url
? `<img src="${player.avatar_url}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">`
: `<span style="font-size:16px;color:var(--text-secondary);">${emoji('person', '👤', 16)}</span>`;
const level = player?.level || 1; const level = player?.level || 1;
const name = player?.display_name || player?.username || t('guest.name') || '';
hudEl.innerHTML = ` hudEl.innerHTML = `
<div class="hud-profile" id="hud-profile"> <button class="hud-profile" id="hud-profile" aria-label="${t('nav.profile')}">
<div class="hud-avatar">${avatarContent}</div> <span class="hud-avatar">${avatarMarkup(player, 18)}</span>
<div class="hud-level-badge">${level}</div> <span class="hud-who">
</div> <span class="hud-name">${escapeHtml(name)}</span>
<span class="hud-level">${t('hud.level')} <span class="num" id="hud-level">${level}</span></span>
</span>
</button>
<div class="hud-stats"> <div class="hud-stats">
<div class="hud-stat hud-coins"> <div class="hud-stat hud-coins" aria-label="${t('hud.coins')}">
<span class="icon">${emoji('hud_coin', '●', 16)}</span> <span class="icon">${icon('coin', 18)}</span>
<span id="hud-coins">${formatNum(player?.coins || 0)}</span> <span id="hud-coins" class="num">${formatNum(player?.coins || 0)}</span>
</div> </div>
<div class="hud-stat hud-gems"> <div class="hud-stat hud-gems" aria-label="${t('hud.gems')}">
<span class="icon">${emoji('hud_gem', '◆', 16)}</span> <span class="icon">${icon('gem', 17)}</span>
<span id="hud-gems">${formatNum(player?.gems || 0)}</span> <span id="hud-gems" class="num">${formatNum(player?.gems || 0)}</span>
</div> </div>
</div> </div>
<div class="hud-actions"> <div class="hud-actions">
<div class="hud-btn" id="hud-notif"> <button class="hud-btn" id="hud-notif" aria-label="${t('hud.notifications')}">
${emoji('bell', '🔔', 18)} ${icon('bell', 22)}
<div class="hud-badge" id="hud-badge"></div> <span class="hud-badge num" id="hud-badge"></span>
</div> </button>
</div> </div>
`; `;
...@@ -69,23 +77,16 @@ function renderHud() { ...@@ -69,23 +77,16 @@ function renderHud() {
} }
function renderTabBar() { function renderTabBar() {
tabBar = document.createElement('div'); tabBar = document.createElement('nav');
tabBar.className = 'tab-bar'; tabBar.className = 'tab-bar';
const worlds = isGuestMode ? ['play'] : scene.getWorlds(); tabBar.setAttribute('aria-label', 'EL3AB');
const allWorlds = scene.getWorlds(); const allWorlds = scene.getWorlds();
const svgFallback = {
rank: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3 7h7l-5.5 4.5 2 7L12 16l-6.5 4.5 2-7L2 9h7z"/></svg>',
social: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 3-1.34 3-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>',
play: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>',
tournaments: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v1c0 2.55 1.92 4.63 4.39 4.94.63 1.5 1.98 2.63 3.61 2.96V19H7v2h10v-2h-4v-3.1c1.63-.33 2.98-1.46 3.61-2.96C19.08 12.63 21 10.55 21 8V7c0-1.1-.9-2-2-2zM5 8V7h2v3.82C5.84 10.4 5 9.3 5 8zm14 0c0 1.3-.84 2.4-2 2.82V7h2v1z"/></svg>',
profile: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>'
};
const icons = { const icons = {
rank: assetImg('tab_rank', svgFallback.rank, 22, 22), rank: assetImg('tab_rank', '', 24, 24),
social: assetImg('tab_social', svgFallback.social, 22, 22), social: assetImg('tab_social', '', 24, 24),
play: assetImg('tab_play', svgFallback.play, 22, 22), play: assetImg('tab_play', '', 24, 24),
tournaments: assetImg('tab_tournaments', svgFallback.tournaments, 22, 22), tournaments: assetImg('tab_tournaments', '', 24, 24),
profile: assetImg('tab_profile', svgFallback.profile, 22, 22), profile: assetImg('tab_profile', '', 24, 24),
}; };
const labels = { rank: 'nav.rank', social: 'nav.social', play: 'nav.play', tournaments: 'nav.tournaments', profile: 'nav.profile' }; const labels = { rank: 'nav.rank', social: 'nav.social', play: 'nav.play', tournaments: 'nav.tournaments', profile: 'nav.profile' };
const current = scene.getCurrentWorld(); const current = scene.getCurrentWorld();
...@@ -94,10 +95,10 @@ function renderTabBar() { ...@@ -94,10 +95,10 @@ function renderTabBar() {
tabBar.innerHTML = allWorlds.map(w => { tabBar.innerHTML = allWorlds.map(w => {
const locked = isGuestMode && guestLocked.includes(w); const locked = isGuestMode && guestLocked.includes(w);
return ` return `
<div class="tab-item ${w === current ? 'active' : ''} ${locked ? 'tab-locked' : ''}" data-world="${w}" ${locked ? 'data-locked="true"' : ''}> <button class="tab-item ${w === current ? 'active' : ''} ${locked ? 'tab-locked' : ''}" data-world="${w}" ${locked ? 'data-locked="true"' : ''} ${w === current ? 'aria-current="page"' : ''}>
<div class="tab-icon" ${locked ? 'style="opacity:0.4;"' : ''}>${icons[w]}</div> <span class="tab-icon">${locked ? icon('lock', 22) : icons[w]}</span>
<span ${locked ? 'style="opacity:0.4;"' : ''}>${t(labels[w])}</span> <span>${t(labels[w])}</span>
</div>`; </button>`;
}).join(''); }).join('');
tabBar.addEventListener('click', (e) => { tabBar.addEventListener('click', (e) => {
...@@ -117,14 +118,19 @@ function renderTabBar() { ...@@ -117,14 +118,19 @@ function renderTabBar() {
document.body.appendChild(tabBar); document.body.appendChild(tabBar);
} }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function showGuestToast() { function showGuestToast() {
const container = document.getElementById('toast-container'); const container = document.getElementById('toast-container');
if (!container) return; if (!container) return;
const toast = document.createElement('div'); const toast = document.createElement('div');
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.className = 'toast';
toast.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center;';
toast.innerHTML = ` toast.innerHTML = `
<span>${t('guest.locked_feature')}</span> <span>${t('guest.locked_feature')}</span>
<button class="btn btn-primary" style="font-size:13px;padding:6px 16px;min-height:var(--tap-min);">${t('guest.create_account')}</button> <button class="btn btn-primary">${t('guest.create_account')}</button>
`; `;
toast.querySelector('button').addEventListener('click', () => { toast.querySelector('button').addEventListener('click', () => {
toast.remove(); toast.remove();
...@@ -136,7 +142,9 @@ function showGuestToast() { ...@@ -136,7 +142,9 @@ function showGuestToast() {
function updateTabs({ to }) { function updateTabs({ to }) {
tabBar.querySelectorAll('.tab-item').forEach(el => { tabBar.querySelectorAll('.tab-item').forEach(el => {
el.classList.toggle('active', el.dataset.world === to); const on = el.dataset.world === to;
el.classList.toggle('active', on);
if (on) el.setAttribute('aria-current', 'page'); else el.removeAttribute('aria-current');
}); });
} }
...@@ -148,13 +156,13 @@ function updateHud() { ...@@ -148,13 +156,13 @@ function updateHud() {
if (coins) coins.textContent = formatNum(player.coins || 0); if (coins) coins.textContent = formatNum(player.coins || 0);
if (gems) gems.textContent = formatNum(player.gems || 0); if (gems) gems.textContent = formatNum(player.gems || 0);
const levelBadge = hudEl.querySelector('.hud-level-badge'); const levelEl = document.getElementById('hud-level');
if (levelBadge) levelBadge.textContent = player.level || 1; if (levelEl) levelEl.textContent = player.level || 1;
const nameEl = hudEl.querySelector('.hud-name');
if (nameEl) nameEl.textContent = player.display_name || player.username || '';
const avatarEl = hudEl.querySelector('.hud-avatar'); const avatarEl = hudEl.querySelector('.hud-avatar');
if (avatarEl && player.avatar_url) { if (avatarEl && player.avatar_url) avatarEl.innerHTML = `<img src="${player.avatar_url}" alt="">`;
avatarEl.innerHTML = `<img src="${player.avatar_url}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">`;
}
} }
function updateBell(count) { function updateBell(count) {
...@@ -188,8 +196,7 @@ function updateTournamentBadge(pending) { ...@@ -188,8 +196,7 @@ function updateTournamentBadge(pending) {
if (!badge) { if (!badge) {
badge = document.createElement('div'); badge = document.createElement('div');
badge.className = 'tour-badge'; badge.className = 'tour-badge';
badge.style.cssText = 'position:absolute;top:2px;right:8px;width:8px;height:8px;background:var(--gold);border-radius:50%;'; badge.style.cssText = 'position:absolute;top:8px;inset-inline-end:calc(50% - 18px);width:8px;height:8px;background:var(--gold);border-radius:50%;box-shadow:0 0 0 2px var(--back-950);';
tourTab.style.position = 'relative';
tourTab.appendChild(badge); tourTab.appendChild(badge);
} }
} else if (badge) { } else if (badge) {
......
...@@ -113,7 +113,7 @@ const strings = { ...@@ -113,7 +113,7 @@ const strings = {
'play.retry_or_cancel': 'لم يتم العثور على خصم. هل تريد المتابعة؟', 'play.retry_or_cancel': 'لم يتم العثور على خصم. هل تريد المتابعة؟',
'play.retry': 'إعادة', 'play.retry': 'إعادة',
'play.challenge_friend': 'تحدّي صديق', 'play.challenge_friend': 'تحدّي صديق',
'play.daily_reward': 'المكافأة اليومية', 'play.daily_reward': 'مكافأة اليوم',
'play.my_tournaments': 'بطولاتي', 'play.my_tournaments': 'بطولاتي',
'play.leaderboard': 'الترتيب', 'play.leaderboard': 'الترتيب',
'play.my_games': 'مبارياتي', 'play.my_games': 'مبارياتي',
...@@ -726,7 +726,23 @@ const strings = { ...@@ -726,7 +726,23 @@ const strings = {
'ludo.phrase_hurry': 'يلا بسرعة!', 'ludo.phrase_hurry': 'يلا بسرعة!',
'ludo.phrase_nice': 'نايس!', 'ludo.phrase_nice': 'نايس!',
'ludo.phrase_oops': 'يا ساتر!', 'ludo.phrase_oops': 'يا ساتر!',
'ludo.phrase_wow': 'ما شاء الله!' 'ludo.phrase_wow': 'ما شاء الله!',
'guest.name': 'ضيف',
'hud.level': 'مستوى',
'hud.coins': 'العملات',
'hud.gems': 'الجواهر',
'hud.notifications': 'الإشعارات',
'home.choose_game': 'اختر لعبتك',
'home.blurb_chess': 'مُصنَّف · بوتات · ألغاز',
'home.blurb_ludo': 'حتى ٤ لاعبين · مع أصحابك',
'home.blurb_domino': 'مُصنَّف · ضد البوت أو أونلاين',
'home.blurb_backgammon': 'طاولة زهر · ضد البوت أو أونلاين',
'home.play_online': 'العب أونلاين',
'home.online_locked': 'سجّل حسابك للعب أونلاين',
'home.streak': 'سلسلة {n} يوم',
'home.daily_sub': 'عملات كل يوم',
'home.daily_ready': 'جاهزة: {n} عملة',
'home.achievements_sub': 'أوسمتك وتقدّمك'
}, },
en: { en: {
'app.name': 'EL3AB', 'app.name': 'EL3AB',
...@@ -840,7 +856,7 @@ const strings = { ...@@ -840,7 +856,7 @@ const strings = {
'play.retry_or_cancel': 'No opponent found. Keep searching?', 'play.retry_or_cancel': 'No opponent found. Keep searching?',
'play.retry': 'Retry', 'play.retry': 'Retry',
'play.challenge_friend': 'Challenge Friend', 'play.challenge_friend': 'Challenge Friend',
'play.daily_reward': 'Daily Reward', 'play.daily_reward': 'Daily reward',
'play.my_tournaments': 'My Tournaments', 'play.my_tournaments': 'My Tournaments',
'play.leaderboard': 'Leaderboard', 'play.leaderboard': 'Leaderboard',
'play.my_games': 'My Games', 'play.my_games': 'My Games',
...@@ -1453,7 +1469,23 @@ const strings = { ...@@ -1453,7 +1469,23 @@ const strings = {
'ludo.phrase_hurry': 'Hurry up!', 'ludo.phrase_hurry': 'Hurry up!',
'ludo.phrase_nice': 'Nice!', 'ludo.phrase_nice': 'Nice!',
'ludo.phrase_oops': 'Oops!', 'ludo.phrase_oops': 'Oops!',
'ludo.phrase_wow': 'Wow!' 'ludo.phrase_wow': 'Wow!',
'guest.name': 'Guest',
'hud.level': 'Level',
'hud.coins': 'Coins',
'hud.gems': 'Gems',
'hud.notifications': 'Notifications',
'home.choose_game': 'Choose your game',
'home.blurb_chess': 'Rated · bots · puzzles',
'home.blurb_ludo': 'Up to 4 players · with friends',
'home.blurb_domino': 'Rated · vs bot or online',
'home.blurb_backgammon': 'Tawla · vs bot or online',
'home.play_online': 'Play online',
'home.online_locked': 'Sign up to play online',
'home.streak': '{n}-day streak',
'home.daily_sub': 'Coins every day',
'home.daily_ready': 'Ready: {n} coins',
'home.achievements_sub': 'Your badges and progress'
} }
}; };
......
// EL3AB icon family. Generated by tools/build-icons.mjs: edit the lists there and re-run it, never this file.
//
// Utility icons are Lucide (ISC licence, https://lucide.dev), re-stroked at 1.75.
// The crown, coin, gem, card suits and the four game marks are EL3AB's own, drawn
// on the same 24px grid. Any icon can be replaced from /admin/branding.php by
// uploading an asset to the slot "icon_<name>".
//
// Never put an emoji or a typed symbol where an icon belongs: call icon().
import { getAsset } from './theme.js';
const STROKE = {"user":"<circle cx=\"12\" cy=\"8\" r=\"5\"/><path d=\"M20 21a8 8 0 0 0-16 0\"/>",
"users":"<path d=\"M18 21a8 8 0 0 0-16 0\"/><circle cx=\"10\" cy=\"8\" r=\"5\"/><path d=\"M22 20c0-3.37-2-6.5-4-8a5 5 0 0 0-.45-8.3\"/>",
"user-plus":"<path d=\"M2 21a8 8 0 0 1 13.292-6\"/><circle cx=\"10\" cy=\"8\" r=\"5\"/><path d=\"M19 16v6\"/><path d=\"M22 19h-6\"/>",
"user-check":"<path d=\"M2 21a8 8 0 0 1 13.292-6\"/><circle cx=\"10\" cy=\"8\" r=\"5\"/><path d=\"m16 19 2 2 4-4\"/>",
"user-x":"<path d=\"m16.5 16.5 5 5\"/><path d=\"M2 21a8 8 0 0 1 11.531-7.18\"/><path d=\"m21.5 16.5-5 5\"/><circle cx=\"10\" cy=\"8\" r=\"5\"/>",
"user-minus":"<path d=\"M2 21a8 8 0 0 1 13.292-6\"/><circle cx=\"10\" cy=\"8\" r=\"5\"/><path d=\"M22 19h-6\"/>",
"trophy":"<path d=\"M10 14.66V17a1 1 0 0 1-1 1 2 2 0 0 0-2 2v2\"/><path d=\"M14 14.66V17a1 1 0 0 0 1 1 2 2 0 0 1 2 2v2\"/><path d=\"M17.916 10H19.5A2.5 2.5 0 0 0 22 7.5V5a1 1 0 0 0-1-1h-3\"/><path d=\"M4 22h16\"/><path d=\"M6 9a6 6 0 0 0 12 0V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1z\"/><path d=\"M6.084 10H4.5A2.5 2.5 0 0 1 2 7.5V5a1 1 0 0 1 1-1h3\"/>",
"swords":"<path d=\"m13 19 6-6\"/><path d=\"M14.5 17.5 3.586 6.586A2 2 0 013 5.172V3h2.172a2 2 0 011.414.586L17.5 14.5\"/><path d=\"m14.828 6.172 2.586-2.586A2 2 0 0118.828 3H21v2.172a2 2 0 01-.586 1.414l-2.586 2.586\"/><path d=\"m16 16 4 4\"/><path d=\"m19 21 2-2\"/><path d=\"m5 14 4 4\"/><path d=\"m5 21-2-2\"/><path d=\"M7.5 16.5 4 20\"/>",
"sword":"<path d=\"m11 19-6-6\"/><path d=\"m5 21-2-2\"/><path d=\"m8 16-4 4\"/><path d=\"M9.5 17.5 20.414 6.586A2 2 0 0021 5.172V3h-2.172a2 2 0 00-1.414.586L6.5 14.5\"/>",
"star":"<path d=\"M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z\"/>",
"sparkle":"<path d=\"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z\"/>",
"sparkles":"<path d=\"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z\"/><path d=\"M20 2v4\"/><path d=\"M22 4h-4\"/><circle cx=\"4\" cy=\"20\" r=\"2\"/>",
"dice":"<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\"/><path d=\"M16 8h.01\"/><path d=\"M8 8h.01\"/><path d=\"M8 16h.01\"/><path d=\"M16 16h.01\"/><path d=\"M12 12h.01\"/>",
"dices":"<rect width=\"12\" height=\"12\" x=\"2\" y=\"10\" rx=\"2\" ry=\"2\"/><path d=\"m17.92 14 3.5-3.5a2.24 2.24 0 0 0 0-3l-5-4.92a2.24 2.24 0 0 0-3 0L10 6\"/><path d=\"M6 18h.01\"/><path d=\"M10 14h.01\"/><path d=\"M15 6h.01\"/><path d=\"M18 9h.01\"/>",
"handshake":"<path d=\"m11 17 2 2a1 1 0 1 0 3-3\"/><path d=\"m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4\"/><path d=\"m21 3 1 11h-2\"/><path d=\"M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3\"/><path d=\"M3 4h8\"/>",
"check":"<path d=\"M20 6 9 17l-5-5\"/>",
"check-circle":"<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"m16 9-5.5 5.5L8 12\"/>",
"building":"<path d=\"M10 12h4\"/><path d=\"M10 8h4\"/><path d=\"M14 21v-3a2 2 0 0 0-4 0v3\"/><path d=\"M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2\"/><path d=\"M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16\"/>",
"skull":"<path d=\"m12.5 17-.5-1-.5 1h1z\"/><path d=\"M15 22a1 1 0 0 0 1-1v-1a2 2 0 0 0 1.56-3.25 8 8 0 1 0-11.12 0A2 2 0 0 0 8 20v1a1 1 0 0 0 1 1z\"/><circle cx=\"15\" cy=\"12\" r=\"1\"/><circle cx=\"9\" cy=\"12\" r=\"1\"/>",
"bot":"<path d=\"M12 8V4H8\"/><rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\"/><path d=\"M2 14h2\"/><path d=\"M20 14h2\"/><path d=\"M15 13v2\"/><path d=\"M9 13v2\"/>",
"medal":"<path d=\"M7.21 15 2.66 7.14a2 2 0 0 1 .13-2.2L4.4 2.8A2 2 0 0 1 6 2h12a2 2 0 0 1 1.6.8l1.6 2.14a2 2 0 0 1 .14 2.2L16.79 15\"/><path d=\"M11 12 5.12 2.2\"/><path d=\"m13 12 5.88-9.8\"/><path d=\"M8 7h8\"/><circle cx=\"12\" cy=\"17\" r=\"5\"/><path d=\"M12 18v-2h-.5\"/>",
"zap":"<path d=\"M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z\"/>",
"flame":"<path d=\"M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4\"/>",
"book":"<path d=\"M12 5v16\"/><path d=\"M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z\"/>",
"globe":"<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\"/><path d=\"M2 12h20\"/>",
"gamepad":"<line x1=\"6\" x2=\"10\" y1=\"11\" y2=\"11\"/><line x1=\"8\" x2=\"8\" y1=\"9\" y2=\"13\"/><line x1=\"15\" x2=\"15.01\" y1=\"12\" y2=\"12\"/><line x1=\"18\" x2=\"18.01\" y1=\"10\" y2=\"10\"/><path d=\"M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z\"/>",
"clipboard":"<rect width=\"8\" height=\"4\" x=\"8\" y=\"2\" rx=\"1\" ry=\"1\"/><path d=\"M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2\"/><path d=\"M12 11h4\"/><path d=\"M12 16h4\"/><path d=\"M8 11h.01\"/><path d=\"M8 16h.01\"/>",
"hand":"<path d=\"M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2\"/><path d=\"M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2\"/><path d=\"M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8\"/><path d=\"M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15\"/>",
"search":"<path d=\"m21 21-4.34-4.34\"/><circle cx=\"11\" cy=\"11\" r=\"8\"/>",
"newspaper":"<path d=\"M15 18h-5\"/><path d=\"M18 14h-8\"/><path d=\"M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2\"/><rect width=\"8\" height=\"4\" x=\"10\" y=\"6\" rx=\"1\"/>",
"gift":"<path d=\"M12 7v14\"/><path d=\"M20 11v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8\"/><path d=\"M7.5 7a1 1 0 0 1 0-5A4.8 8 0 0 1 12 7a4.8 8 0 0 1 4.5-5 1 1 0 0 1 0 5\"/><rect x=\"3\" y=\"7\" width=\"18\" height=\"4\" rx=\"1\"/>",
"clock":"<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 6v6l4 2\"/>",
"timer":"<line x1=\"10\" x2=\"14\" y1=\"2\" y2=\"2\"/><line x1=\"12\" x2=\"15\" y1=\"14\" y2=\"11\"/><circle cx=\"12\" cy=\"14\" r=\"8\"/>",
"chart":"<path d=\"M3 3v16a2 2 0 0 0 2 2h16\"/><path d=\"M18 17V9\"/><path d=\"M13 17V5\"/><path d=\"M8 17v-3\"/>",
"trending-up":"<path d=\"M16 7h6v6\"/><path d=\"m22 7-8.5 8.5-5-5L2 17\"/>",
"trending-down":"<path d=\"M16 17h6v-6\"/><path d=\"m22 17-8.5-8.5-5 5L2 7\"/>",
"ban":"<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M4.929 4.929 19.07 19.071\"/>",
"alert":"<path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/>",
"smile":"<path d=\"M15 10V9\"/><path d=\"M16.472 15a6 6 0 01-8.943 0\"/><path d=\"M9 10V9\"/><circle cx=\"12\" cy=\"12\" r=\"10\"/>",
"frown":"<path d=\"M15 10V9\"/><path d=\"M9 10V9\"/><path d=\"M9 16a5 5 0 016 0\"/><circle cx=\"12\" cy=\"12\" r=\"10\"/>",
"save":"<path d=\"M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z\"/><path d=\"M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7\"/><path d=\"M7 3v4a1 1 0 0 0 1 1h7\"/>",
"loader":"<path d=\"M21 12a9 9 0 1 1-6.219-8.56\"/>",
"hourglass":"<path d=\"M5 22h14\"/><path d=\"M5 2h14\"/><path d=\"M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22\"/><path d=\"M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2\"/>",
"flag":"<path d=\"M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528\"/>",
"camera":"<path d=\"M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z\"/><circle cx=\"12\" cy=\"13\" r=\"3\"/>",
"back":"<path d=\"m12 19-7-7 7-7\"/><path d=\"M19 12H5\"/>",
"forward":"<path d=\"M5 12h14\"/><path d=\"m12 5 7 7-7 7\"/>",
"chevron-end":"<path d=\"m9 18 6-6-6-6\"/>",
"chevron-start":"<path d=\"m15 18-6-6 6-6\"/>",
"chevron-down":"<path d=\"m6 9 6 6 6-6\"/>",
"chevron-up":"<path d=\"m18 15-6-6-6 6\"/>",
"wind":"<path d=\"M12.8 19.6A2 2 0 1 0 14 16H2\"/><path d=\"M17.5 8a2.5 2.5 0 1 1 2 4H2\"/><path d=\"M9.8 4.4A2 2 0 1 1 11 8H2\"/>",
"ellipsis":"<circle cx=\"12\" cy=\"12\" r=\"1\"/><circle cx=\"19\" cy=\"12\" r=\"1\"/><circle cx=\"5\" cy=\"12\" r=\"1\"/>",
"target":"<circle cx=\"12\" cy=\"12\" r=\"10\"/><circle cx=\"12\" cy=\"12\" r=\"6\"/><circle cx=\"12\" cy=\"12\" r=\"2\"/>",
"message":"<path d=\"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719\"/>",
"chat":"<path d=\"M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z\"/>",
"chat-more":"<path d=\"M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719\"/><path d=\"M8 12h.01\"/><path d=\"M12 12h.01\"/><path d=\"M16 12h.01\"/>",
"volume":"<path d=\"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z\"/><path d=\"M16 9a5 5 0 0 1 0 6\"/><path d=\"M19.364 18.364a9 9 0 0 0 0-12.728\"/>",
"mute":"<path d=\"M11 4.702a.7.7 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.7.7 0 0 0 11 19.298z\"/><path d=\"m16.5 14.5 5-5\"/><path d=\"m16.5 9.5 5 5\"/>",
"moon":"<path d=\"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401\"/>",
"share":"<circle cx=\"18\" cy=\"5\" r=\"3\"/><circle cx=\"6\" cy=\"12\" r=\"3\"/><circle cx=\"18\" cy=\"19\" r=\"3\"/><line x1=\"8.59\" x2=\"15.42\" y1=\"13.51\" y2=\"17.49\"/><line x1=\"15.41\" x2=\"8.59\" y1=\"6.51\" y2=\"10.49\"/>",
"send":"<path d=\"M3.714 3.048a.498.498 0 0 0-.683.627l2.843 7.627a2 2 0 0 1 0 1.396l-2.842 7.627a.498.498 0 0 0 .682.627l18-8.5a.5.5 0 0 0 0-.904z\"/><path d=\"M6 12h16\"/>",
"puzzle":"<path d=\"M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z\"/>",
"plus":"<path d=\"M5 12h14\"/><path d=\"M12 5v14\"/>",
"minus":"<path d=\"M5 12h14\"/>",
"play":"<path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/>",
"pin":"<path d=\"M12 17v5\"/><path d=\"M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z\"/>",
"lock":"<rect width=\"18\" height=\"11\" x=\"3\" y=\"11\" rx=\"2\" ry=\"2\"/><path d=\"M7 11V7a5 5 0 0 1 10 0v4\"/>",
"unlock":"<rect width=\"18\" height=\"11\" x=\"3\" y=\"11\" rx=\"2\" ry=\"2\"/><path d=\"M7 11V7a5 5 0 0 1 9.9-1\"/>",
"inbox":"<polyline points=\"22 12 16 12 14 15 10 15 8 12 2 12\"/><path d=\"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z\"/>",
"live":"<path d=\"M16.247 7.761a6 6 0 0 1 0 8.478\"/><path d=\"M19.075 4.933a10 10 0 0 1 0 14.134\"/><path d=\"M4.925 19.067a10 10 0 0 1 0-14.134\"/><path d=\"M7.753 16.239a6 6 0 0 1 0-8.478\"/><circle cx=\"12\" cy=\"12\" r=\"2\"/>",
"level-up":"<path d=\"m17 11-5-5-5 5\"/><path d=\"m17 18-5-5-5 5\"/>",
"eye":"<path d=\"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>",
"logout":"<path d=\"m16 17 5-5-5-5\"/><path d=\"M21 12H9\"/><path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\"/>",
"edit":"<path d=\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\"/><path d=\"m15 5 4 4\"/>",
"document":"<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\"/><path d=\"M14 2v5a1 1 0 0 0 1 1h5\"/><path d=\"M10 9H8\"/><path d=\"M16 13H8\"/><path d=\"M16 17H8\"/>",
"diamond-line":"<path d=\"M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41l-7.59-7.59a2.41 2.41 0 0 0-3.41 0Z\"/>",
"x":"<path d=\"M18 6 6 18\"/><path d=\"m6 6 12 12\"/>",
"heart-crack":"<path d=\"M12.409 5.824c-.702.792-1.15 1.496-1.415 2.166l2.153 2.156a.5.5 0 0 1 0 .707l-2.293 2.293a.5.5 0 0 0 0 .707L12 15\"/><path d=\"M13.508 20.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5a5.5 5.5 0 0 1 9.591-3.677.6.6 0 0 0 .818.001A5.5 5.5 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5z\"/>",
"calendar":"<path d=\"M8 2v3\"/><path d=\"M16 2v3\"/><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/><path d=\"M3 9h18\"/>",
"layers":"<path d=\"M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z\"/><path d=\"M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12\"/><path d=\"M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17\"/>",
"palette":"<path d=\"M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z\"/><circle cx=\"13.5\" cy=\"6.5\" r=\".5\" fill=\"currentColor\"/><circle cx=\"17.5\" cy=\"10.5\" r=\".5\" fill=\"currentColor\"/><circle cx=\"6.5\" cy=\"12.5\" r=\".5\" fill=\"currentColor\"/><circle cx=\"8.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\"/>",
"bell":"<path d=\"M10.268 21a2 2 0 0 0 3.464 0\"/><path d=\"M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326\"/>",
"settings":"<path d=\"M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>",
"history":"<path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"/><path d=\"M3 3v5h5\"/><path d=\"M12 7v5l4 2\"/>",
"refresh":"<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\"/><path d=\"M21 3v5h-5\"/><path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\"/><path d=\"M8 16H3v5\"/>",
"undo":"<path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"/><path d=\"M3 3v5h5\"/>",
"flip":"<path d=\"m21 16-4 4-4-4\"/><path d=\"M17 20V4\"/><path d=\"m3 8 4-4 4 4\"/><path d=\"M7 4v16\"/>",
"podium":"<path d=\"M12 6V2h-1\"/><path d=\"M9 15a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1\"/><path d=\"M9 21V11a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v10\"/>",
"cards":"<path d=\"M14.832 8.445a1 1 0 00-1.589-.098l-2.075 3.098a1 1 0 000 1.11l2 3a1 1 0 001.664 0l2-3a1 1 0 000-1.11z\"/><path d=\"m7.18 20.827-5-11a2 2 0 01.993-2.647L7 5.44\"/><rect x=\"7\" y=\"2\" width=\"14\" height=\"20\" rx=\"2\"/>",
"store":"<path d=\"M15 21v-5a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v5\"/><path d=\"M17.774 10.31a1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.451 0 1.12 1.12 0 0 0-1.548 0 2.5 2.5 0 0 1-3.452 0 1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.77-3.248l2.889-4.184A2 2 0 0 1 7 2h10a2 2 0 0 1 1.653.873l2.895 4.192a2.5 2.5 0 0 1-3.774 3.244\"/><path d=\"M4 10.95V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8.05\"/>",
"bag":"<path d=\"M16 10a4 4 0 0 1-8 0\"/><path d=\"M3.103 6.034h17.794\"/><path d=\"M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z\"/>",
"ticket":"<path d=\"M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z\"/><path d=\"M13 5v2\"/><path d=\"M13 17v2\"/><path d=\"M13 11v2\"/>",
"wallet":"<path d=\"M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1\"/><path d=\"M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4\"/>",
"link":"<path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"/><path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"/>",
"copy":"<rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/>",
"info":"<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 16v-4\"/><path d=\"M12 8h.01\"/>",
"help":"<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3\"/><path d=\"M12 17h.01\"/>",
"shield":"<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\"/>",
"key":"<path d=\"M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z\"/><circle cx=\"16.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\"/>",
"mail":"<path d=\"m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7\"/><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"/>",
"trash":"<path d=\"M10 11v6\"/><path d=\"M14 11v6\"/><path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6\"/><path d=\"M3 6h18\"/><path d=\"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\"/>",
"filter":"<path d=\"M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z\"/>",
"list":"<path d=\"M3 5h.01\"/><path d=\"M3 12h.01\"/><path d=\"M3 19h.01\"/><path d=\"M8 5h13\"/><path d=\"M8 12h13\"/><path d=\"M8 19h13\"/>",
"menu":"<path d=\"M4 5h16\"/><path d=\"M4 12h16\"/><path d=\"M4 19h16\"/>",
"award":"<path d=\"m15.477 12.89 1.515 8.526a.5.5 0 0 1-.81.47l-3.58-2.687a1 1 0 0 0-1.197 0l-3.586 2.686a.5.5 0 0 1-.81-.469l1.514-8.526\"/><circle cx=\"12\" cy=\"8\" r=\"6\"/>",
"badge-check":"<path d=\"M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z\"/><path d=\"m16 9-5.5 5.5L8 12\"/>",
"qr":"<rect width=\"5\" height=\"5\" x=\"3\" y=\"3\" rx=\"1\"/><rect width=\"5\" height=\"5\" x=\"16\" y=\"3\" rx=\"1\"/><rect width=\"5\" height=\"5\" x=\"3\" y=\"16\" rx=\"1\"/><path d=\"M21 16h-3a2 2 0 0 0-2 2v3\"/><path d=\"M21 21v.01\"/><path d=\"M12 7v3a2 2 0 0 1-2 2H7\"/><path d=\"M3 12h.01\"/><path d=\"M12 3h.01\"/><path d=\"M12 16v.01\"/><path d=\"M16 12h1\"/><path d=\"M21 12v.01\"/><path d=\"M12 21v-1\"/>",
"map-pin":"<path d=\"M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0\"/><circle cx=\"12\" cy=\"10\" r=\"3\"/>",
"home":"<path d=\"M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8\"/><path d=\"M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"/>",
"scroll":"<path d=\"M19 17V5a2 2 0 0 0-2-2H4\"/><path d=\"M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3\"/>",
"party":"<path d=\"M5.8 11.3 2 22l10.7-3.79\"/><path d=\"M4 3h.01\"/><path d=\"M22 8h.01\"/><path d=\"M15 2h.01\"/><path d=\"M22 20h.01\"/><path d=\"m22 2-2.24.75a2.9 2.9 0 0 0-1.96 3.12c.1.86-.57 1.63-1.45 1.63h-.38c-.86 0-1.6.6-1.76 1.44L14 10\"/><path d=\"m22 13-.82-.33c-.86-.34-1.82.2-1.98 1.11c-.11.7-.72 1.22-1.43 1.22H17\"/><path d=\"m11 2 .33.82c.34.86-.2 1.82-1.11 1.98C9.52 4.9 9 5.52 9 6.23V7\"/><path d=\"M11 13c1.93 1.93 2.83 4.17 2 5-.83.83-3.07-.07-5-2-1.93-1.93-2.83-4.17-2-5 .83-.83 3.07.07 5 2Z\"/>",
"external":"<path d=\"M15 3h6v6\"/><path d=\"M10 14 21 3\"/><path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"/>",
"circle-plus":"<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M8 12h8\"/><path d=\"M12 8v8\"/>",
"circle-x":"<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"m15 9-6 6\"/><path d=\"m9 9 6 6\"/>",
"chess-king":"<path d=\"M4 20a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z\"/><path d=\"m6.7 18-1-1C4.35 15.682 3 14.09 3 12a5 5 0 0 1 4.95-5c1.584 0 2.7.455 4.05 1.818C13.35 7.455 14.466 7 16.05 7A5 5 0 0 1 21 12c0 2.082-1.359 3.673-2.7 5l-1 1\"/><path d=\"M10 4h4\"/><path d=\"M12 2v6.818\"/>",
"chess-queen":"<path d=\"m10.68 5.499-.996 4.982a.65.65 0 01-1.131.295L5.885 7.663\"/><path d=\"m13.32 5.5.995 4.98a.65.65 0 001.131.296l2.668-3.113\"/><path d=\"M20.428 8.952 17.5 18\"/><path d=\"M4 20a2 2 0 012-2h12a2 2 0 012 2v1a1 1 0 01-1 1H5a1 1 0 01-1-1z\"/><path d=\"M6.5 18 3.571 8.952\"/><circle cx=\"12\" cy=\"4\" r=\"2\"/><circle cx=\"20\" cy=\"7\" r=\"2\"/><circle cx=\"4\" cy=\"7\" r=\"2\"/>",
"chess-rook":"<path d=\"M5 20a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z\"/><path d=\"M10 2v2\"/><path d=\"M14 2v2\"/><path d=\"m17 18-1-9\"/><path d=\"M6 2v5a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2\"/><path d=\"M6 4h12\"/><path d=\"m7 18 1-9\"/>",
"chess-bishop":"<path d=\"M5 20a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z\"/><path d=\"M15 18c1.5-.615 3-2.461 3-4.923C18 8.769 14.5 4.462 12 2 9.5 4.462 6 8.77 6 13.077 6 15.539 7.5 17.385 9 18\"/><path d=\"m16 7-2.5 2.5\"/><path d=\"M9 2h6\"/>",
"chess-knight":"<path d=\"M5 20a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z\"/><path d=\"M16.5 18c1-2 2.5-5 2.5-9a7 7 0 0 0-7-7H6.635a1 1 0 0 0-.768 1.64L7 5l-2.32 5.802a2 2 0 0 0 .95 2.526l2.87 1.456\"/><path d=\"m15 5 1.425-1.425\"/><path d=\"m17 8 1.53-1.53\"/><path d=\"M9.713 12.185 7 18\"/>",
"chess-pawn":"<path d=\"M5 20a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1z\"/><path d=\"m14.5 10 1.5 8\"/><path d=\"M7 10h10\"/><path d=\"m8 18 1.5-8\"/><circle cx=\"12\" cy=\"6\" r=\"4\"/>",
"crown-line":"<path d=\"M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.734H5.81a1 1 0 0 1-.957-.734L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z\"/><path d=\"M5 21h14\"/>",
"grid":"<rect width=\"7\" height=\"7\" x=\"3\" y=\"3\" rx=\"1\"/><rect width=\"7\" height=\"7\" x=\"14\" y=\"3\" rx=\"1\"/><rect width=\"7\" height=\"7\" x=\"14\" y=\"14\" rx=\"1\"/><rect width=\"7\" height=\"7\" x=\"3\" y=\"14\" rx=\"1\"/>",
"radio-tower":"<path d=\"M4.9 16.1C1 12.2 1 5.8 4.9 1.9\"/><path d=\"M7.8 4.7a6.14 6.14 0 0 0-.8 7.5\"/><circle cx=\"12\" cy=\"9\" r=\"2\"/><path d=\"M16.2 4.8c2 2 2.26 5.11.8 7.47\"/><path d=\"M19.1 1.9a9.96 9.96 0 0 1 0 14.1\"/><path d=\"M9.5 18h5\"/><path d=\"m8 22 4-11 4 11\"/>",
"rewind":"<path d=\"M12 6a2 2 0 0 0-3.414-1.414l-6 6a2 2 0 0 0 0 2.828l6 6A2 2 0 0 0 12 18z\"/><path d=\"M22 6a2 2 0 0 0-3.414-1.414l-6 6a2 2 0 0 0 0 2.828l6 6A2 2 0 0 0 22 18z\"/>"};
const FILLED = {"crown":"<path fill-rule=\"evenodd\" d=\"M9 17.6V12c0-3.4 1.4-5.4 3-6.4 1.6 1 3 3 3 6.4v5.6zm3-6.6-1.15 1.9L12 14.8l1.15-1.9z\"/><circle cx=\"12\" cy=\"3.3\" r=\"1.7\"/><path d=\"M3.3 8.3 7.7 12.6v5H6.1zM20.7 8.3l-4.4 4.3v5h1.6z\"/><circle cx=\"3.2\" cy=\"6.8\" r=\"1.15\"/><circle cx=\"20.8\" cy=\"6.8\" r=\"1.15\"/><path d=\"m7.3 6.1 1.2 1.2-1.2 1.2-1.2-1.2zM16.7 6.1l1.2 1.2-1.2 1.2-1.2-1.2z\"/><rect x=\"5\" y=\"19\" width=\"14\" height=\"2.2\" rx=\"1.1\"/>",
"coin":"<circle cx=\"12\" cy=\"12\" r=\"9.5\"/><circle cx=\"12\" cy=\"12\" r=\"6.6\" fill=\"none\" stroke=\"#000\" stroke-opacity=\".26\" stroke-width=\"1.3\"/><path d=\"m12 8.4 2.3 3.6-2.3 3.6-2.3-3.6z\" fill=\"#000\" fill-opacity=\".3\"/>",
"gem":"<path d=\"M6.6 3.8h10.8l3.8 5.3L12 20.6 2.8 9.1z\"/><path d=\"M2.8 9.1h18.4M8.8 9.1 12 20.6l3.2-11.5M9.3 3.8l-.5 5.3M14.7 3.8l.5 5.3\" fill=\"none\" stroke=\"#fff\" stroke-opacity=\".5\" stroke-width=\"1\" stroke-linejoin=\"round\"/>",
"suit-diamond":"<path d=\"m12 2.5 7 9.5-7 9.5-7-9.5z\"/>",
"suit-spade":"<path d=\"M12 2.6c3.8 4 7.4 6.6 7.4 10.2a3.8 3.8 0 0 1-6.4 2.8l.9 4.8H10.1l.9-4.8a3.8 3.8 0 0 1-6.4-2.8c0-3.6 3.6-6.2 7.4-10.2z\"/>",
"suit-heart":"<path d=\"M12 20.6C6.3 16.1 3 12.9 3 9.1A4.6 4.6 0 0 1 12 7.4a4.6 4.6 0 0 1 9 1.7c0 3.8-3.3 7-9 11.5z\"/>",
"suit-club":"<circle cx=\"12\" cy=\"7\" r=\"3.9\"/><circle cx=\"7\" cy=\"13\" r=\"3.9\"/><circle cx=\"17\" cy=\"13\" r=\"3.9\"/><path d=\"m11.2 12 .1 0h1.4l.1 0 1.2 8.8H10z\"/>",
"game-chess":"<path fill-rule=\"evenodd\" d=\"M7.7 17.2h9.5c.6-2.7 1.7-4.7 1.7-8.3 0-3.8-3-6.7-6.8-6.7H9.6L8.7.9 7.5 3.3 4.8 7.6c-.4.7-.3 1.6.3 2.1l.7.6c.5.4 1.2.4 1.7 0l1.9-1.4h1.4l-2 3.6c-.9 1.6-1.4 3.2-1.1 4.7zm3.6-12.4a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z\"/><path d=\"M5.4 21.6h13.2v-1.8c0-.8-.6-1.4-1.4-1.4H6.8c-.8 0-1.4.6-1.4 1.4z\"/>",
"game-ludo":"<path fill-rule=\"evenodd\" d=\"M12 1.8a7.2 7.2 0 0 1 7.2 7.2c0 4.7-4.9 9.8-6.5 11.4a1 1 0 0 1-1.4 0C9.7 18.8 4.8 13.7 4.8 9A7.2 7.2 0 0 1 12 1.8zm0 4.5a2.7 2.7 0 1 0 0 5.4 2.7 2.7 0 0 0 0-5.4z\"/><rect x=\"7.5\" y=\"20.6\" width=\"9\" height=\"1.8\" rx=\".9\"/>",
"game-domino":"<path fill-rule=\"evenodd\" d=\"M9 1.8h6a2.6 2.6 0 0 1 2.6 2.6v15.2A2.6 2.6 0 0 1 15 22.2H9a2.6 2.6 0 0 1-2.6-2.6V4.4A2.6 2.6 0 0 1 9 1.8zM8.6 11.4v1.2h6.8v-1.2zM9.9 4.6a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4zm4.2 2.9a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4zm-4.2 7a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4zm2.1 2a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4zm2.1 2a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4z\"/>",
"game-backgammon":"<path d=\"M2.6 21.6 6.6 2.4l4 19.2z\"/><path fill-rule=\"evenodd\" d=\"M16.9 13.9a3.85 3.85 0 1 1 0 7.7 3.85 3.85 0 0 1 0-7.7zm0 2.1a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5zM16.9 5.7a3.85 3.85 0 1 1 0 7.7 3.85 3.85 0 0 1 0-7.7zm0 2.1a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5z\"/>",
"dot":"<circle cx=\"12\" cy=\"12\" r=\"5\"/>"};
// Icons that point along the reading direction. In RTL they mirror, so "back"
// still points toward where the reader came from.
const DIRECTIONAL = new Set(['back', 'forward', 'chevron-end', 'chevron-start', 'send', 'logout', 'external']);
export function hasIcon(name) { return name in STROKE || name in FILLED; }
/**
* icon('trophy', 20) -> inline SVG string.
* opts.cls adds classes; opts.label makes it announced instead of decorative.
*/
export function icon(name, size = 20, opts = {}) {
const override = getAsset('icon_' + name);
const label = opts.label ? ` role="img" aria-label="${opts.label}"` : ' aria-hidden="true"';
const cls = `i i-${name}${DIRECTIONAL.has(name) ? ' i-dir' : ''}${opts.cls ? ' ' + opts.cls : ''}`;
if (override) {
return `<img class="${cls}" src="${override}" alt="${opts.label || ''}" width="${size}" height="${size}" style="width:${size}px;height:${size}px;object-fit:contain;" draggable="false">`;
}
if (name in FILLED) {
return `<svg class="${cls}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="currentColor"${label}>${FILLED[name]}</svg>`;
}
const body = STROKE[name] ?? STROKE.sparkle;
const sw = opts.weight || 1.75;
return `<svg class="${cls}" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"${label}>${body}</svg>`;
}
// The game each suit mark belongs to, and the ink it prints in.
export const GAME_MARK = {
chess: { icon: 'game-chess', ink: 'black' },
ludo: { icon: 'game-ludo', ink: 'red' },
domino: { icon: 'game-domino', ink: 'black' },
backgammon: { icon: 'game-backgammon', ink: 'red' },
};
...@@ -33,7 +33,8 @@ function persist() { ...@@ -33,7 +33,8 @@ function persist() {
player: state.player, player: state.player,
activeWorld: state.activeWorld, activeWorld: state.activeWorld,
audioEnabled: state.audioEnabled, audioEnabled: state.audioEnabled,
language: state.language language: state.language,
home: state.home
}; };
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)); localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
} catch (e) {} } catch (e) {}
......
// Theme loads from Supabase via API (survives deploys) // Theme loads from Supabase via API (survives deploys).
//
// tokens.css holds the design (The Cafe Deck). Values stored from
// /admin/branding.php override it, but only for the keys listed in COLOR_KEYS
// and LAYOUT_PX below: the retired palette's keys (bg_card, blue, purple...) are
// ignored on purpose, so an old stored value can never repaint the app.
import { icon, hasIcon } from './icons.js';
const THEME_API = '/api/branding.php'; const THEME_API = '/api/branding.php';
let themeData = null; let themeData = null;
...@@ -9,8 +16,8 @@ export async function load() { ...@@ -9,8 +16,8 @@ export async function load() {
if (!res.ok) { console.warn('[theme] API responded', res.status); return; } if (!res.ok) { console.warn('[theme] API responded', res.status); return; }
themeData = await res.json(); themeData = await res.json();
applyColors(); applyColors();
applyAnimations(); applyLayout();
if (themeData?.assets) console.log('[theme] loaded', Object.keys(themeData.assets).length, 'assets'); window.__EL3AB_THEME = themeData;
} catch (e) { console.warn('[theme] load failed', e); } } catch (e) { console.warn('[theme] load failed', e); }
} }
...@@ -19,208 +26,125 @@ export function getAsset(slot, fallback) { ...@@ -19,208 +26,125 @@ export function getAsset(slot, fallback) {
return fallback || null; return fallback || null;
} }
/** A stored value, else the matching token from tokens.css, else the fallback. */
export function getColor(key, fallback) { export function getColor(key, fallback) {
return themeData?.[key] || fallback; if (themeData?.[key]) return themeData[key];
const token = getComputedStyle(document.documentElement)
.getPropertyValue('--' + key.replace(/_/g, '-')).trim();
return token || fallback;
} }
// Admin-editable colours. Key = CSS property without the dashes, '-' as '_'.
export const COLOR_KEYS = [
'back_950', 'back_900', 'back_800', 'back_700', 'back_600', 'back_500',
'card', 'card_2', 'card_line', 'ink', 'ink_2', 'ink_3', 'red_ink', 'red_ink_deep',
'gold', 'gold_soft', 'gold_dark', 'red', 'win', 'loss', 'sky',
'text_primary', 'text_secondary', 'text_muted',
'ludo_red', 'ludo_blue', 'ludo_green', 'ludo_yellow',
'board_light', 'board_dark', 'board_highlight_light', 'board_highlight_dark', 'board_check',
'bg_point_dark', 'bg_point_light', 'bg_checker_white', 'bg_checker_black', 'bg_board_border',
'bg_board_light',
];
function applyColors() { function applyColors() {
if (!themeData) return; if (!themeData) return;
const root = document.documentElement.style; const root = document.documentElement.style;
for (const key of COLOR_KEYS) {
const map = { if (themeData[key]) root.setProperty('--' + key.replace(/_/g, '-'), themeData[key]);
bg_deep: '--bg-deep',
bg_base: '--bg-base',
bg_card: '--bg-card',
bg_elevated: '--bg-elevated',
bg_hover: '--bg-hover',
bg_panel: '--bg-panel',
bg_inset: '--bg-inset',
bg_dark: '--bg-dark',
gold: '--gold',
gold_soft: '--gold-soft',
gold_dark: '--gold-dark',
blue: '--blue',
cyan: '--cyan',
orange: '--orange',
purple: '--purple',
purple_light: '--purple-light',
violet: '--violet',
emerald: '--emerald',
green: '--green',
green_light: '--green-light',
pink: '--pink',
amber: '--amber',
red: '--red',
red_light: '--red-light',
red_soft: '--red-soft',
success: '--success',
error: '--error',
warning: '--warning',
info: '--info',
text_primary: '--text-primary',
text_secondary: '--text-secondary',
text_muted: '--text-muted',
text_dim: '--text-dim',
text_light: '--text-light',
chess_primary: '--chess-primary',
chess_secondary: '--chess-secondary',
domino_primary: '--domino-primary',
domino_secondary: '--domino-secondary',
ludo_primary: '--ludo-primary',
ludo_secondary: '--ludo-secondary',
backgammon_primary: '--backgammon-primary',
backgammon_secondary: '--backgammon-secondary'
};
for (const [key, cssVar] of Object.entries(map)) {
if (themeData[key]) {
root.setProperty(cssVar, themeData[key]);
}
}
// Ludo board colors
const ludoMap = {
ludo_red: '--ludo-red', ludo_blue: '--ludo-blue',
ludo_green: '--ludo-green', ludo_yellow: '--ludo-yellow',
ludo_board_bg: '--ludo-board-bg', ludo_path: '--ludo-path',
ludo_safe: '--ludo-safe', ludo_piece_border: '--ludo-piece-border',
ludo_grid_line: '--ludo-grid-line',
// Chess board
board_light: '--board-light', board_dark: '--board-dark',
board_highlight_light: '--board-highlight-light', board_highlight_dark: '--board-highlight-dark',
board_check: '--board-check'
};
for (const [key, cssVar] of Object.entries(ludoMap)) {
if (themeData[key]) root.setProperty(cssVar, themeData[key]);
}
// Backgammon board colors
const bgMap = {
bg_board_border: '--bg-board-border', bg_board_light: '--bg-board-light',
bg_board_dark: '--bg-board-dark', bg_bar_dark: '--bg-bar-dark',
bg_bar_light: '--bg-bar-light', bg_bearoff: '--bg-bearoff',
bg_point_dark: '--bg-point-dark', bg_point_dark_tip: '--bg-point-dark-tip',
bg_point_light: '--bg-point-light', bg_point_light_tip: '--bg-point-light-tip',
bg_checker_white: '--bg-checker-white', bg_checker_black: '--bg-checker-black',
bg_highlight: '--bg-highlight', bg_selected: '--bg-selected'
};
for (const [key, cssVar] of Object.entries(bgMap)) {
if (themeData[key]) root.setProperty(cssVar, themeData[key]);
} }
// Also expose ALL theme values as a flat map for JS access
window.__EL3AB_THEME = themeData;
} }
function applyAnimations() { function applyLayout() {
if (!themeData) return; if (!themeData) return;
const root = document.documentElement.style; const root = document.documentElement.style;
if (themeData.anim_speed) root.setProperty('--dur-normal', themeData.anim_speed + 'ms'); if (themeData.anim_speed) root.setProperty('--dur-normal', themeData.anim_speed + 'ms');
if (themeData.anim_fast) root.setProperty('--dur-fast', themeData.anim_fast + 'ms'); if (themeData.anim_fast) root.setProperty('--dur-fast', themeData.anim_fast + 'ms');
if (themeData.anim_slow) root.setProperty('--dur-slow', themeData.anim_slow + 'ms'); if (themeData.anim_slow) root.setProperty('--dur-slow', themeData.anim_slow + 'ms');
if (themeData.border_radius) root.setProperty('--r-md', themeData.border_radius + 'px'); if (themeData.card_radius) root.setProperty('--r-card', themeData.card_radius + 'px');
if (themeData.card_radius) root.setProperty('--r-xl', themeData.card_radius + 'px'); if (themeData.btn_radius_px) root.setProperty('--r-btn', themeData.btn_radius_px + 'px');
if (themeData.btn_scale) root.setProperty('--btn-press-scale', themeData.btn_scale);
if (themeData.btn_radius) root.setProperty('--r-full', themeData.btn_radius + 'px');
if (themeData.btn_secondary_radius) root.setProperty('--r-btn-secondary', themeData.btn_secondary_radius + 'px');
if (themeData.btn_height) root.setProperty('--btn-min-height', themeData.btn_height + 'px');
if (themeData.btn_font_weight) root.setProperty('--btn-weight', themeData.btn_font_weight);
if (themeData.btn_shadow) root.setProperty('--btn-shadow', themeData.btn_shadow);
if (themeData.card_border_width) root.setProperty('--card-border-width', themeData.card_border_width + 'px');
if (themeData.input_radius) root.setProperty('--r-input', themeData.input_radius + 'px');
const layoutPx = { const LAYOUT_PX = {
hud_height: '--hud-height', hud_avatar_size: '--hud-avatar-size', hud_height: '--hud-height', hud_avatar_size: '--hud-avatar-size', hud_stat_font: '--hud-stat-font',
hud_avatar_border: '--hud-avatar-border', hud_icon_size: '--hud-icon-size', hud_btn_size: '--hud-btn-size', hud_padding_x: '--hud-padding-x',
hud_bell_size: '--hud-bell-size', hud_stat_font: '--hud-stat-font', tab_height: '--tab-height', tab_icon_size: '--tab-icon-size', tab_label_font: '--tab-label-font',
hud_stat_gap: '--hud-stat-gap', hud_btn_size: '--hud-btn-size',
hud_badge_size: '--hud-badge-size', hud_badge_font: '--hud-badge-font',
hud_padding_x: '--hud-padding-x',
tab_height: '--tab-height', tab_icon_size: '--tab-icon-size',
tab_label_font: '--tab-label-font', tab_item_gap: '--tab-item-gap',
tab_item_padding_x: '--tab-item-padding-x', tab_item_padding_y: '--tab-item-padding-y',
tab_item_min_size: '--tab-item-min-size', tab_item_min_size: '--tab-item-min-size',
home_padding: '--home-padding', home_max_width: '--home-max-width',
home_greeting_font: '--home-greeting-font', home_greeting_margin: '--home-greeting-margin',
quick_btn_icon_size: '--quick-btn-icon-size', quick_btn_icon_radius: '--quick-btn-icon-radius',
quick_btn_icon_font: '--quick-btn-icon-font', quick_btn_label_font: '--quick-btn-label-font',
quick_btn_gap: '--quick-btn-gap', quick_btn_padding: '--quick-btn-padding',
quick_row_gap: '--quick-row-gap', quick_row_margin: '--quick-row-margin',
game_tile_radius: '--game-tile-radius', game_tile_gap: '--game-tile-gap',
game_tile_icon_size: '--game-tile-icon-size', game_tile_icon_font: '--game-tile-icon-font',
game_tile_name_font: '--game-tile-name-font', game_tile_content_gap: '--game-tile-content-gap',
sheet_radius: '--sheet-radius', sheet_padding_x: '--sheet-padding-x',
sheet_padding_top: '--sheet-padding-top',
gm_btn_padding_y: '--gm-btn-padding-y', gm_btn_padding_x: '--gm-btn-padding-x',
gm_btn_gap: '--gm-btn-gap', gm_btn_radius: '--gm-btn-radius',
gm_btn_margin_bottom: '--gm-btn-margin-bottom', gm_btn_icon_size: '--gm-btn-icon-size',
gm_btn_icon_radius: '--gm-btn-icon-radius', gm_btn_icon_font: '--gm-btn-icon-font',
gm_btn_title_font: '--gm-btn-title-font', gm_btn_sub_font: '--gm-btn-sub-font',
gm_btn_arrow_font: '--gm-btn-arrow-font',
gm_chip_padding_y: '--gm-chip-padding-y', gm_chip_padding_x: '--gm-chip-padding-x',
gm_chip_gap: '--gm-chip-gap', gm_chip_radius: '--gm-chip-radius',
gm_chip_font: '--gm-chip-font', gm_chip_icon_font: '--gm-chip-icon-font',
gm_chips_grid_gap: '--gm-chips-grid-gap', gm_chips_margin_top: '--gm-chips-margin-top',
menu_close_size: '--menu-close-size', menu_close_font: '--menu-close-font',
menu_header_icon_size: '--menu-header-icon-size', menu_header_icon_radius: '--menu-header-icon-radius',
menu_header_icon_font: '--menu-header-icon-font', menu_header_title_font: '--menu-header-title-font',
menu_header_gap: '--menu-header-gap', menu_header_margin: '--menu-header-margin',
bot_card_avatar_size: '--bot-card-avatar-size', bot_card_avatar_border: '--bot-card-avatar-border',
bot_card_name_font: '--bot-card-name-font', bot_card_sub_font: '--bot-card-sub-font',
bot_card_accent_height: '--bot-card-accent-height', bot_grid_gap: '--bot-grid-gap',
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 // Stored values win over tokens.css, so the legibility and reach floors are
// floors have to be enforced HERE — otherwise an admin (or an old stored // enforced HERE; otherwise an old stored value silently brings back 9px
// value) silently reintroduces 9px labels and 32px buttons. The live theme // labels and 32px buttons.
// was doing exactly that: quick_btn_label_font 10, hud_badge_font 9. const FONT_FLOOR_PX = 11;
const FONT_FLOOR_PX = 11; // below this, text is not readable on a phone const TAP_FLOOR_PX = 44;
const TAP_FLOOR_PX = 44; // documented minimum tap target, iOS and Android const TAP_TOKENS = new Set(['--hud-btn-size', '--tab-item-min-size']);
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(LAYOUT_PX)) {
]);
for (const [key, cssVar] of Object.entries(layoutPx)) {
if (!themeData[key]) continue; if (!themeData[key]) continue;
const px = parseFloat(themeData[key]); const px = parseFloat(themeData[key]);
if (!Number.isFinite(px)) continue; if (!Number.isFinite(px)) continue;
if (cssVar.endsWith('-font')) root.setProperty(cssVar, Math.max(px, FONT_FLOOR_PX) + 'px');
if (cssVar.endsWith('-font')) { else if (TAP_TOKENS.has(cssVar)) root.setProperty(cssVar, Math.max(px, TAP_FLOOR_PX) + 'px');
root.setProperty(cssVar, Math.max(px, FONT_FLOOR_PX) + 'px'); else root.setProperty(cssVar, 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');
if (themeData.sheet_bg) root.setProperty('--sheet-bg', themeData.sheet_bg);
} }
export function assetImg(slot, fallbackEmoji, width, height) { // Slots that used to hold uploaded PNG icons now resolve to the icon family.
// An admin can still replace any of them by uploading to "icon_<name>".
const SLOT_ICONS = {
tab_play: 'cards', tab_rank: 'podium', tab_social: 'users', tab_tournaments: 'trophy',
tab_profile: 'user', tab_shop: 'store',
chess_icon: 'game-chess', ludo_icon: 'game-ludo', domino_icon: 'game-domino',
backgammon_icon: 'game-backgammon',
coin_icon: 'coin', notification_bell: 'bell',
};
/**
* An image slot (logo, uploaded art). Retired icon slots render the new icon
* instead of the old PNG; anything else falls back to a drawn icon, never to a
* typed emoji.
*/
export function assetImg(slot, fallback, width, height) {
if (SLOT_ICONS[slot]) return icon(SLOT_ICONS[slot], Math.min(width, height));
const url = getAsset(slot); const url = getAsset(slot);
if (url) { if (url) {
const isSvg = url.includes('.svg'); const isSvg = url.includes('.svg');
const rendering = isSvg ? 'image-rendering:-webkit-optimize-contrast;' : ''; const rendering = isSvg ? 'image-rendering:-webkit-optimize-contrast;' : '';
return `<img src="${url}" alt="" style="width:${width}px;height:${height}px;object-fit:contain;${rendering}" draggable="false">`; return `<img src="${url}" alt="" style="width:${width}px;height:${height}px;object-fit:contain;${rendering}" draggable="false">`;
} }
return `<span style="font-size:${Math.min(width, height) * 0.7}px;line-height:1;">${fallbackEmoji}</span>`; if (typeof fallback === 'string' && fallback.trim().startsWith('<svg')) return fallback;
return icon('sparkle', Math.min(width, height));
} }
// The legacy emoji keys, each mapped to the icon that now stands in its place.
const EMOJI_ICONS = {
person: 'user', people: 'users', group: 'users', add_friend: 'user-plus',
trophy: 'trophy', tournament_cup: 'trophy', theme_crown: 'crown', crown: 'crown',
medal: 'medal', medal_gold: 'medal', medal_silver: 'medal', medal_bronze: 'medal',
challenge_swords: 'swords', swords: 'swords', coin: 'coin', hud_coin: 'coin', money_bag: 'coin',
gem: 'gem', hud_gem: 'gem', diamond: 'gem',
star: 'star', best_move_star: 'star', star_glow: 'sparkle', particle_star: 'sparkle', sparkles: 'sparkles',
dice: 'dice', game_die: 'dice', domino_tile: 'game-domino', boneyard: 'layers', ludo_hex: 'game-ludo',
chess_pawn: 'game-chess', backgammon: 'game-backgammon', backgammon_icon: 'game-backgammon',
handshake: 'handshake', checkmark: 'check', check: 'check', building: 'building', skull: 'skull',
robot: 'bot', bot_easy: 'bot', bot_medium: 'bot', bot_hard: 'bot',
lightning: 'zap', fire: 'flame', book: 'book', globe: 'globe', gamepad: 'cards',
clipboard: 'clipboard', wave: 'hand', search_icon: 'search', news: 'newspaper', gift: 'gift',
clock: 'clock', hourglass: 'hourglass', chart: 'chart', chart_up: 'trending-up', block: 'ban',
warning: 'alert', smiley: 'smile', pensive: 'frown', broken_heart: 'heart-crack', save: 'save',
loading: 'loader', flag: 'flag', camera: 'camera', arrow_back: 'back', wind: 'wind',
thinking_dots: 'ellipsis', target: 'target', speech_bubble: 'message', chat_bubble: 'message',
chat: 'chat', speaker_on: 'volume', speaker_off: 'mute', mute: 'mute', sleeping: 'moon',
share: 'share', send: 'send', puzzle: 'puzzle', plus: 'plus', play: 'play', pin: 'pin', lock: 'lock',
live: 'live', level_up: 'level-up', inbox: 'inbox', green_circle: 'dot', gray_circle: 'dot',
eye: 'eye', exit: 'logout', edit: 'edit', document: 'document', cross: 'x', calendar: 'calendar',
bell: 'bell', art: 'palette',
};
/**
* Legacy entry point. Every call site that once printed an emoji now prints an
* icon from the family. `fallback` is ignored by design: it held the emoji.
*/
export function emoji(key, fallback, size = 20) { export function emoji(key, fallback, size = 20) {
const url = getAsset('emoji_' + key); const name = EMOJI_ICONS[key] || (hasIcon(key) ? key : 'sparkle');
if (url) { const cls = key === 'green_circle' ? 'i-online' : key === 'gray_circle' ? 'i-offline' : '';
const isSvg = url.includes('.svg'); return icon(name, size, { cls });
const rendering = isSvg ? 'image-rendering:-webkit-optimize-contrast;' : '';
return `<img src="${url}" alt="" style="width:${size}px;height:${size}px;object-fit:contain;vertical-align:middle;${rendering}" draggable="false">`;
}
return fallback;
} }
// Home: the hand.
//
// The four games are dealt as a fanned hand of cards. Each card's corner index
// carries your rating in that game, the way a playing card carries its rank, so
// the hand reads at a glance even while fanned. Touching a card pulls it up out
// of the hand; the table below it is that game's lobby, two taps from a move.
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as net from '../../../core/net.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { assetImg, emoji, getColor } from '../../../core/theme.js'; import { icon, GAME_MARK } from '../../../core/icons.js';
import * as juice from '../../../core/juice.js'; import * as juice from '../../../core/juice.js';
function getGames() { const GAME_KEYS = ['chess', 'ludo', 'domino', 'backgammon'];
const chessPri = getColor('chess_primary', '#2563EB');
const chessSec = getColor('chess_secondary', '#F5B731');
const dominoPri = getColor('domino_primary', '#10B981');
const dominoSec = getColor('domino_secondary', '#06B6D4');
const ludoPri = getColor('ludo_primary', '#8B5CF6');
const ludoSec = getColor('ludo_secondary', '#EC4899');
const bgPri = getColor('backgammon_primary', '#F59E0B');
const bgSec = getColor('backgammon_secondary', '#EF4444');
return [ function games() {
{ key: 'chess', name: t('game.chess'), nameEn: 'Chess', color: chessPri, secondary: chessSec, icon: '♟', gradient: `linear-gradient(135deg, ${chessPri}, ${chessSec})` }, return GAME_KEYS.map(key => ({
{ key: 'domino', name: t('game.domino'), nameEn: 'Domino', color: dominoPri, secondary: dominoSec, icon: '🁣', gradient: `linear-gradient(135deg, ${dominoPri}, ${dominoSec})` }, key,
{ key: 'ludo', name: t('game.ludo'), nameEn: 'Ludo', color: ludoPri, secondary: ludoSec, icon: '🎲', gradient: `linear-gradient(135deg, ${ludoPri}, ${ludoSec})` }, name: t('game.' + key),
{ key: 'backgammon', name: t('game.backgammon'), nameEn: 'Backgammon', color: bgPri, secondary: bgSec, icon: '◎', gradient: `linear-gradient(135deg, ${bgPri}, ${bgSec})` } blurb: t('home.blurb_' + key),
]; mark: GAME_MARK[key].icon,
red: GAME_MARK[key].ink === 'red',
}));
}
/** Your strongest rating in a game, or null when you have none yet. */
function ratingFor(player, key) {
const rows = (player?.ratings || []).filter(r => r.game_key === key && r.rating);
if (rows.length) return Math.max(...rows.map(r => Math.round(r.rating)));
if (key === 'chess') return player?.elo_rapid || player?.elo_blitz || null;
if (key === 'domino') return player?.elo_domino || null;
return null;
}
function cardMarkup(g, i, player) {
const rating = ratingFor(player, g.key);
const index = `${rating ? `<span class="num">${rating}</span>` : ''}${icon(g.mark, 15)}`;
return `
<button class="deck-card hand-card ${g.red ? 'ink-red' : ''}" role="radio" aria-checked="false"
data-game="${g.key}" style="--i:${i}" aria-label="${g.name}${rating ? ' ' + rating : ''}">
<span class="index top">${index}</span>
<span class="hand-face">
<span class="hand-mark">${icon(g.mark, 68)}</span>
<span class="hand-name">${g.name}</span>
</span>
<span class="index bottom">${index}</span>
</button>`;
} }
export function mountTable(el) { export function mountTable(el) {
const games = getGames(); const list = games();
const player = store.get('player'); const player = store.get('player') || {};
const username = player?.display_name || player?.username || 'Player'; const isGuest = !!store.get('auth.isGuest');
let selected = store.get('home.game');
if (!GAME_KEYS.includes(selected)) selected = 'chess';
el.innerHTML = ` el.innerHTML = `
<div class="play-home"> <div class="home">
<!-- Player greeting --> <div class="hand" role="radiogroup" aria-label="${t('home.choose_game')}">
<div style="width:100%;max-width:var(--home-max-width);margin-bottom:var(--home-greeting-margin);display:flex;align-items:center;justify-content:space-between;"> ${list.map((g, i) => cardMarkup(g, i, player)).join('')}
<div style="font-size:var(--home-greeting-font);font-weight:700;color:var(--text-primary);">${username} ${emoji('wave', '👋', 18)}</div>
<div style="font-size:var(--gm-btn-sub-font);color:var(--text-muted);">Lv. ${player?.level || 1}</div>
</div> </div>
<section class="lobby" id="lobby" aria-live="polite"></section>
<!-- Quick actions row --> <div class="home-extras">
<div id="daily-widget" style="display:flex;gap:var(--quick-row-gap);width:100%;max-width:var(--home-max-width);margin-bottom:var(--quick-row-margin);"> <button class="home-extra" id="btn-daily-reward">
<button class="quick-btn" id="btn-challenge-friend"> <span class="home-extra-icon">${icon('gift', 22)}</span>
<span class="qb-icon" style="background:linear-gradient(135deg,var(--violet),var(--purple-light));">${emoji('challenge_swords', '⚔️', 20)}</span> <span class="home-extra-text">
<span class="qb-label">${t('challenge.title')}</span> <span class="home-extra-title">${t('play.daily_reward')}</span>
<span class="home-extra-sub" id="daily-sub">${player.daily_streak ? t('home.streak', { n: player.daily_streak }) : t('home.daily_sub')}</span>
</span>
<span class="home-extra-dot" id="daily-dot" hidden></span>
</button> </button>
<button class="quick-btn" id="btn-achievements"> <button class="home-extra" id="btn-achievements">
<span class="qb-icon" style="background:linear-gradient(135deg,#854d0e,#ca8a04);">${emoji('trophy', '🏆', 20)}</span> <span class="home-extra-icon">${icon('medal', 22)}</span>
<span class="qb-label">${t('play.achievements')}</span> <span class="home-extra-text">
<span class="home-extra-title">${t('play.achievements')}</span>
<span class="home-extra-sub">${t('home.achievements_sub')}</span>
</span>
</button> </button>
<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>
<!-- Games -->
<div class="games-grid">
${games.map(g => {
return `
<div class="game-tile" data-game="${g.key}" style="--game-color:${g.color};--game-gradient:${g.gradient};">
<div class="game-tile-bg" style="background:${g.gradient};"></div>
<div class="game-tile-content">
<div class="game-tile-icon">${assetImg(g.key + '_icon', g.icon, 48, 48)}</div>
<div class="game-tile-name">${g.name}</div>
</div>
</div>`;
}).join('')}
</div> </div>
</div> </div>
<div id="game-menu" class="game-menu hidden"></div> <style>${STYLE}</style>
<style>
.play-home {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
height: 100%;
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); }
.qb-label { font-size:var(--quick-btn-label-font);font-weight:600;color:var(--text-secondary); }
.games-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--game-tile-gap);
width: 100%;
max-width: var(--home-max-width);
}
.game-tile {
position: relative;
aspect-ratio: var(--game-tile-aspect);
border-radius: var(--game-tile-radius);
overflow: hidden;
cursor: pointer;
transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s;
box-shadow: var(--shadow-md);
}
.game-tile:active {
transform: scale(0.93);
box-shadow: var(--shadow-sm);
}
.game-tile-disabled {
opacity: 0.4;
pointer-events: none;
filter: grayscale(0.5);
}
.game-tile-disabled:active { transform: none; }
.game-tile-soon {
font-size: var(--gm-btn-sub-font);
font-weight: 700;
color: rgba(255,255,255,0.7);
background: rgba(0,0,0,0.3);
padding: 2px 10px;
border-radius: var(--r-sm);
margin-top: 2px;
}
.game-tile-bg {
position: absolute;
inset: 0;
opacity: 0.9;
}
.game-tile-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--game-tile-content-gap);
}
.game-tile-icon {
font-size: var(--game-tile-icon-font);
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
}
.game-tile-icon img { width: var(--game-tile-icon-size); height: var(--game-tile-icon-size); object-fit: contain; }
.game-tile-name {
font-size: var(--game-tile-name-font);
font-weight: 800;
color: white;
text-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.game-menu {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--sheet-bg);
border-top-left-radius: var(--sheet-radius);
border-top-right-radius: var(--sheet-radius);
padding: var(--sheet-padding-top) var(--sheet-padding-x);
padding-bottom: calc(var(--sheet-padding-top) + var(--tab-height, 60px) + var(--safe-bottom, 0px));
z-index: 50;
transform: translateY(0);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 -8px 40px rgba(0,0,0,0.6);
max-height: var(--sheet-max-height);
}
.game-menu.hidden {
transform: translateY(100%);
pointer-events: none;
}
.game-menu-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--menu-header-margin);
}
/* Game menu buttons */
.gm-btn {
display: flex;
align-items: center;
gap: var(--gm-btn-gap);
width: 100%;
padding: var(--gm-btn-padding-y) var(--gm-btn-padding-x);
background: var(--bg-card);
border: 1.5px solid var(--border);
border-radius: var(--gm-btn-radius);
cursor: pointer;
margin-bottom: var(--gm-btn-margin-bottom);
text-align: right;
transition: transform 0.1s, border-color 0.2s;
font-family: inherit;
}
.gm-btn:active { transform: scale(0.96); border-color: rgba(255,255,255,0.2); }
.gm-btn-icon {
width: var(--gm-btn-icon-size);
height: var(--gm-btn-icon-size);
border-radius: var(--gm-btn-icon-radius);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--gm-btn-icon-font);
flex-shrink: 0;
box-shadow: 0 3px 10px rgba(0,0,0,0.3);
}
.gm-btn-body { flex: 1; }
.gm-btn-title { font-size: var(--gm-btn-title-font); font-weight: 800; color: var(--text-primary); }
.gm-btn-sub { font-size: var(--gm-btn-sub-font); color: var(--text-muted); margin-top: 2px; }
.gm-btn-arrow { font-size: var(--gm-btn-arrow-font); color: var(--text-muted); font-weight: 300; }
.gm-chip {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--gm-chip-gap);
padding: var(--gm-chip-padding-y) var(--gm-chip-padding-x);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--gm-chip-radius);
cursor: pointer;
font-family: inherit;
color: var(--text-primary);
font-size: var(--gm-chip-font);
font-weight: 600;
transition: transform 0.1s, background 0.15s;
}
.gm-chip:active { transform: scale(0.93); background: rgba(255,255,255,0.08); }
</style>
`; `;
const menu = el.querySelector('#game-menu'); const hand = el.querySelector('.hand');
const cards = Array.from(el.querySelectorAll('.hand-card'));
const lobby = el.querySelector('#lobby');
const isGuest = store.get('auth.isGuest'); layoutHand(cards, selected);
renderLobby(lobby, list.find(g => g.key === selected), isGuest);
// Hide multiplayer quick actions for guests // Deal the hand: each card lands from the deck in turn.
if (isGuest) { cards.forEach((c, i) => {
const challengeBtn = el.querySelector('#btn-challenge-friend'); c.classList.add('dealing');
if (challengeBtn) challengeBtn.style.display = 'none'; c.style.animationDelay = `${60 + i * 70}ms`;
} c.addEventListener('animationend', () => { c.classList.remove('dealing'); c.style.animationDelay = ''; }, { once: true });
});
// Daily widget buttons hand.addEventListener('click', (e) => {
el.querySelector('#btn-challenge-friend')?.addEventListener('click', () => { const card = e.target.closest('.hand-card');
if (!card) return;
const key = card.dataset.game;
audio.play('click'); audio.play('click');
scene.push('challenge-friend'); juice.hapticLight?.();
if (key === selected) {
// A second touch on the raised card plays the game's first action.
lobby.querySelector('#btn-multi, #btn-single')?.focus();
return;
}
selected = key;
store.set('home.game', key);
layoutHand(cards, key);
renderLobby(lobby, list.find(g => g.key === key), isGuest);
}); });
el.querySelector('#btn-achievements')?.addEventListener('click', () => {
audio.play('click'); // Arrow keys move through the hand like any radio group.
scene.push('achievements'); hand.addEventListener('keydown', (e) => {
if (!['ArrowLeft', 'ArrowRight'].includes(e.key)) return;
const rtl = document.documentElement.dir === 'rtl';
const step = (e.key === 'ArrowLeft') === rtl ? 1 : -1;
const i = (GAME_KEYS.indexOf(selected) + step + GAME_KEYS.length) % GAME_KEYS.length;
cards[i].click();
cards[i].focus();
e.preventDefault();
}); });
el.querySelector('#btn-daily-reward')?.addEventListener('click', () => {
el.querySelector('#btn-daily-reward').addEventListener('click', () => {
audio.play('click'); audio.play('click');
scene.push('daily-reward'); scene.push('daily-reward');
}); });
el.querySelector('#btn-achievements').addEventListener('click', () => {
// Juice: staggered bounce-in for tiles + floating idle
const tiles = el.querySelectorAll('.game-tile');
juice.stagger(Array.from(tiles), juice.bounceIn, 100, 80);
setTimeout(() => {
tiles.forEach((tile, i) => juice.floatY(tile, 4 + i, 2500 + i * 500));
}, 600);
tiles.forEach(tile => {
tile.addEventListener('click', () => {
audio.play('click'); audio.play('click');
juice.hapticMedium(); scene.push('achievements');
juice.pulseElement(tile, tile.style.getPropertyValue('--game-color'));
const gameKey = tile.dataset.game;
const game = games.find(g => g.key === gameKey);
showGameMenu(menu, game);
});
}); });
// Show whether today's reward is waiting, without blocking the hand.
if (store.get('auth.token')) {
net.get('daily-reward.php').then(d => {
if (!d || d.error) return;
const dot = el.querySelector('#daily-dot');
const sub = el.querySelector('#daily-sub');
if (!d.already_claimed) {
if (dot) dot.hidden = false;
if (sub) sub.textContent = t('home.daily_ready', { n: d.today_reward || '' });
} else if (sub && d.streak) {
sub.textContent = t('home.streak', { n: d.streak });
}
}).catch(() => {});
}
} }
function showGameMenu(menu, game) { /**
menu.classList.remove('hidden'); * Fan the cards. Each card is overlapped on its far side by the next, so its
menu.style.animation = 'slideUpBounce 0.4s cubic-bezier(0.16, 1, 0.3, 1)'; * near corner (index, mark, name) always shows, the way a hand is held. The
menu.innerHTML = ` * chosen card is pulled up out of the hand; it keeps its place in the fan.
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--menu-header-margin);"> */
<div style="display:flex;align-items:center;gap:var(--menu-header-gap);"> function layoutHand(cards, selectedKey) {
<div style="width:var(--menu-header-icon-size);height:var(--menu-header-icon-size);border-radius:var(--menu-header-icon-radius);background:${game.gradient};display:flex;align-items:center;justify-content:center;font-size:var(--menu-header-icon-font);">${game.icon}</div> const rtl = document.documentElement.dir === 'rtl';
<div style="font-size:var(--menu-header-title-font);font-weight:800;color:var(--text-primary);">${game.name}</div> const n = cards.length;
</div> cards.forEach((card, i) => {
<button id="menu-close" style="width:var(--menu-close-size);height:var(--menu-close-size);border-radius:50%;background:var(--border);border:none;color:var(--text-secondary);font-size:var(--menu-close-font);cursor:pointer;display:flex;align-items:center;justify-content:center;">✕</button> const centred = i - (n - 1) / 2; // -1.5 .. 1.5
</div> const side = rtl ? -1 : 1; // first game sits on the reading side
const on = card.dataset.game === selectedKey;
card.style.setProperty('--slot', String(centred * side));
card.style.setProperty('--angle', `${centred * side * 4}deg`);
card.style.setProperty('--arc', `${Math.abs(centred) * Math.abs(centred) * 5}px`);
card.style.setProperty('--lift', on ? '-30px' : '0px');
card.style.zIndex = i + 1;
card.classList.toggle('raised', on);
card.setAttribute('aria-checked', on ? 'true' : 'false');
card.tabIndex = on ? 0 : -1;
});
}
<!-- Main play buttons --> function renderLobby(lobby, game, isGuest) {
<button class="gm-btn" id="btn-single"> const chips = [
<div class="gm-btn-icon" style="background:${game.gradient};">${emoji('gamepad', '🎮', 26)}</div> !isGuest && `<button class="chip" id="btn-challenge">${icon('swords', 16)}${t('play.challenge_friend')}</button>`,
<div class="gm-btn-body"> `<button class="chip" id="btn-history">${icon('history', 16)}${t('game.my_games')}</button>`,
<div class="gm-btn-title">${t('play.single_player')}</div> game.key === 'chess' && `<button class="chip" id="btn-puzzles">${icon('puzzle', 16)}${t('play.puzzles')}</button>`,
<div class="gm-btn-sub">${t('play.single_player_desc')}</div> `<button class="chip" id="btn-leaderboard">${icon('podium', 16)}${t('play.leaderboard')}</button>`,
</div> ].filter(Boolean).join('');
<div class="gm-btn-arrow">›</div>
</button>
<button class="gm-btn" id="btn-multi"> lobby.innerHTML = `
<div class="gm-btn-icon" style="background:linear-gradient(135deg,#dc2626,var(--orange));">${emoji('swords', '⚔️', 26)}</div> <div class="lobby-inner">
<div class="gm-btn-body"> <h2 class="lobby-title">${game.name}</h2>
<div class="gm-btn-title">${t('game.online')}</div> ${isGuest
<div class="gm-btn-sub">${t('play.online_desc')}</div> ? `<button class="btn btn-secondary btn-lg btn-block" id="btn-multi-locked">${icon('lock', 18)}${t('home.online_locked')}</button>`
</div> : `<button class="btn btn-primary btn-lg btn-block" id="btn-multi">${icon('swords', 20)}${t('home.play_online')}</button>`}
<div class="gm-btn-arrow">›</div> <button class="btn btn-card btn-lg btn-block" id="btn-single">${icon('bot', 20)}${t('play.single_player_desc')}</button>
</button> <div class="lobby-chips">${chips}</div>
</div>`;
<!-- Secondary actions --> const on = (id, fn) => lobby.querySelector('#' + id)?.addEventListener('click', () => { audio.play('click'); fn(); });
<div style="display:grid;grid-template-columns:${game.key === 'chess' ? '1fr 1fr 1fr' : '1fr'};gap:var(--gm-chips-grid-gap);margin-top:var(--gm-chips-margin-top);">
<button class="gm-chip" id="btn-leaderboard">
<span style="font-size:var(--gm-chip-icon-font);">${emoji('tournament_cup', '🏆', 18)}</span>
<span>Leaderboard</span>
</button>
<button class="gm-chip" id="btn-history">
<span style="font-size:var(--gm-chip-icon-font);">${emoji('clipboard', '📋', 18)}</span>
<span>${t('game.my_games')}</span>
</button>
${game.key === 'chess' ? `<button class="gm-chip" id="btn-puzzles">
<span style="font-size:var(--gm-chip-icon-font);">${emoji('puzzle', '🧩', 18)}</span>
<span>Puzzles</span>
</button>` : ''}
</div>
`;
// Staggered entrance for menu buttons on('btn-single', () => {
const menuBtns = menu.querySelectorAll('.gm-btn, .gm-chip'); if (game.key === 'chess') scene.push('play-bot-select', { game: game.key });
menuBtns.forEach((btn, i) => { else if (game.key === 'domino') scene.push('domino-room', { mode: 'bot-pick' });
btn.style.opacity = '0'; else if (game.key === 'ludo') scene.push('ludo-room', { mode: 'setup', type: 'local' });
btn.style.transform = 'translateY(12px)'; else if (game.key === 'backgammon') scene.push('backgammon-room', { mode: 'menu' });
setTimeout(() => {
btn.style.transition = 'opacity 0.25s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)';
btn.style.opacity = '1';
btn.style.transform = 'translateY(0)';
}, 80 + i * 60);
}); });
menu.querySelector('#menu-close').addEventListener('click', () => { on('btn-multi', () => {
audio.play('click'); if (game.key === 'chess') scene.push('play-time-select', { game: game.key, mode: 'human' });
menu.classList.add('hidden'); else if (game.key === 'ludo') scene.push('ludo-room', { mode: 'setup', type: 'online' });
else if (game.key === 'backgammon') scene.push('backgammon-room', { mode: 'setup', type: 'online' });
else scene.push('play-queue', { game: game.key, mode: 'human', timeControl: 'standard' });
}); });
menu.querySelector('#btn-single').addEventListener('click', () => { on('btn-multi-locked', () => scene.push('auth-register'));
audio.play('click'); on('btn-challenge', () => scene.push('challenge-friend'));
menu.classList.add('hidden'); on('btn-history', () => scene.push('chess-history', { game: game.key, gameKey: game.key }));
if (game.key === 'chess') { on('btn-puzzles', () => scene.push('puzzles'));
scene.push('play-bot-select', { game: game.key }); on('btn-leaderboard', () => bus.emit('navigate', { world: 'rank', scene: 'leaderboard' }));
} else if (game.key === 'domino') { }
scene.push('domino-room', { mode: 'bot-pick' });
} else if (game.key === 'ludo') { const STYLE = `
scene.push('ludo-room', { mode: 'setup', type: 'local' }); .home {
} else if (game.key === 'backgammon') { width: 100%;
scene.push('backgammon-room', { mode: 'menu' }); max-width: 560px;
} else { margin-inline: auto;
const gameScene = game.key + '-game'; padding: var(--s-2) var(--gutter) var(--s-8);
scene.push(gameScene, { mode: 'bot', game: game.key }); display: flex;
flex-direction: column;
} }
});
menu.querySelector('#btn-multi').addEventListener('click', () => { /* The hand sits under a lamp: the only light on the table. */
audio.play('click'); .hand {
if (store.get('auth.isGuest')) { --card-w: clamp(112px, 31vw, 148px);
const toast = document.getElementById('toast-container'); --card-h: calc(var(--card-w) * 1.42);
if (toast) { --step: min(calc((100vw - 2 * var(--gutter) - var(--card-w) - 24px) / 3), calc((500px - var(--card-w)) / 3));
const msg = document.createElement('div'); position: relative;
msg.style.cssText = 'background:var(--bg-elevated);color:var(--text-primary);padding:12px 20px;border-radius:var(--r-md);font-size:14px;text-align:center;animation:fadeIn 0.2s;'; height: calc(var(--card-h) + 58px);
msg.textContent = t('guest.upgrade_prompt'); margin-top: var(--s-1);
toast.appendChild(msg); background: radial-gradient(62% 60% at 50% 60%, rgba(143, 162, 250, 0.11), transparent 72%);
setTimeout(() => msg.remove(), 3000);
} }
return;
.hand-card {
position: absolute;
inset-inline-start: 50%;
top: 40px;
width: var(--card-w);
height: var(--card-h);
margin-inline-start: calc(var(--card-w) / -2);
padding: 0;
transform-origin: 50% 100%;
transform: translateX(calc(var(--slot, 0) * var(--step))) translateY(calc(var(--arc, 0px) + var(--lift, 0px))) rotate(var(--angle, 0deg));
transition: transform var(--dur-slow) var(--ease-deal), box-shadow var(--dur-slow) var(--ease-deal);
display: block;
} }
menu.classList.add('hidden'); .hand-card.raised { box-shadow: var(--shadow-lg); }
if (game.key === 'chess') { .hand-card.raised::after {
scene.push('play-time-select', { game: game.key, mode: 'human' }); content: '';
} else if (game.key === 'ludo') { position: absolute;
scene.push('ludo-room', { mode: 'setup', type: 'online' }); bottom: -18px;
} else if (game.key === 'backgammon') { inset-inline-start: 22px;
scene.push('backgammon-room', { mode: 'setup', type: 'online' }); width: 8px; height: 8px;
} else { background: var(--red-ink);
scene.push('play-queue', { game: game.key, mode: 'human', timeControl: 'standard' }); transform: rotate(45deg);
}
.hand-card.dealing { animation: handDeal 560ms var(--ease-deal) both; }
@keyframes handDeal {
from { opacity: 0; transform: translateY(-110px) rotate(-14deg) scale(0.9); }
} }
});
menu.querySelector('#btn-leaderboard')?.addEventListener('click', () => { .hand-card .index { font-size: 13px; }
audio.play('click'); .hand-card .index .num { font-size: 14px; letter-spacing: -0.01em; }
menu.classList.add('hidden'); .hand-card .index.bottom { display: none; }
bus.emit('navigate', { world: 'rank', scene: 'leaderboard' });
});
menu.querySelector('#btn-history')?.addEventListener('click', () => { /* The face is set toward the near side, where the hand leaves it showing. */
audio.play('click'); .hand-face {
menu.classList.add('hidden'); position: absolute;
scene.push('chess-history', { game: game.key, gameKey: game.key }); inset-inline-start: 14px;
}); top: 52px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
color: var(--suit);
text-align: start;
max-width: calc(var(--step) - 10px);
}
.hand-card:last-child .hand-face, .hand-card.raised .hand-face { max-width: calc(var(--card-w) - 28px); }
.hand-mark { display: flex; margin-bottom: 2px; }
.hand-mark .i { width: 44px; height: 44px; }
.hand-name { font-size: var(--fs-md); font-weight: 800; color: var(--ink); line-height: 1.2; }
.hand-blurb { display: none; }
menu.querySelector('#btn-puzzles')?.addEventListener('click', () => { /* The lobby for the card in your hand. */
audio.play('click'); .lobby { margin-top: var(--s-2); }
menu.classList.add('hidden'); .lobby-inner {
scene.push('puzzles'); display: flex;
}); flex-direction: column;
} gap: var(--s-3);
animation: fadeUp var(--dur-normal) var(--ease-out);
}
@keyframes fadeUp { from { opacity: 0; transform: translateY(6px); } }
.lobby-title { font-size: var(--fs-2xl); font-weight: 800; line-height: 1.15; margin-bottom: var(--s-1); }
.lobby-chips { display: flex; flex-wrap: wrap; gap: var(--s-2); margin-top: var(--s-1); }
.home-extras {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s-3);
margin-top: var(--s-6);
padding-top: var(--s-5);
border-top: 1px solid var(--back-line);
}
.home-extra {
position: relative;
display: flex;
align-items: center;
gap: var(--s-3);
min-height: 64px;
padding: var(--s-3);
border-radius: var(--r-lg);
border: 1px solid var(--back-line);
background: var(--back-800);
color: var(--text-primary);
cursor: pointer;
text-align: start;
transition: background var(--dur-fast), transform var(--dur-fast);
}
.home-extra:active { transform: translateY(1px); background: var(--back-700); }
.home-extra-icon {
width: 40px; height: 40px;
border-radius: var(--r-md);
background: var(--back-950);
color: var(--gold);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.home-extra-text { display: flex; flex-direction: column; min-width: 0; }
.home-extra-title { font-size: var(--fs-sm); font-weight: 700; }
.home-extra-sub { font-size: var(--fs-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.home-extra-dot {
position: absolute;
top: 10px; inset-inline-end: 10px;
width: 9px; height: 9px;
background: var(--gold);
transform: rotate(45deg);
}
`;
// Builds public/js/core/icons.js from Lucide (ISC, devDependency lucide-static) plus EL3AB's own marks.
// npm install && node tools/build-icons.mjs
import fs from 'node:fs';
const DIR = new URL('../node_modules/lucide-static/icons/', import.meta.url).pathname;
const OUT = new URL('../public/js/core/icons.js', import.meta.url).pathname;
// our name -> lucide file
const LUCIDE = {
'user': 'user-round', 'users': 'users-round', 'user-plus': 'user-round-plus', 'user-check': 'user-round-check',
'user-x': 'user-round-x', 'user-minus': 'user-round-minus', 'trophy': 'trophy', 'swords': 'swords', 'sword': 'sword',
'star': 'star', 'sparkle': 'sparkle', 'sparkles': 'sparkles', 'dice': 'dice-5', 'dices': 'dices',
'handshake': 'handshake', 'check': 'check', 'check-circle': 'circle-check', 'building': 'building-2',
'skull': 'skull', 'bot': 'bot', 'medal': 'medal', 'zap': 'zap', 'flame': 'flame', 'book': 'book-open',
'globe': 'globe', 'gamepad': 'gamepad-2', 'clipboard': 'clipboard-list', 'hand': 'hand', 'search': 'search',
'newspaper': 'newspaper', 'gift': 'gift', 'clock': 'clock', 'timer': 'timer', 'chart': 'chart-column',
'trending-up': 'trending-up', 'trending-down': 'trending-down', 'ban': 'ban', 'alert': 'triangle-alert',
'smile': 'smile', 'frown': 'frown', 'save': 'save', 'loader': 'loader-circle', 'hourglass': 'hourglass',
'flag': 'flag', 'camera': 'camera', 'back': 'arrow-left', 'forward': 'arrow-right',
'chevron-end': 'chevron-right', 'chevron-start': 'chevron-left', 'chevron-down': 'chevron-down',
'chevron-up': 'chevron-up', 'wind': 'wind', 'ellipsis': 'ellipsis', 'target': 'target',
'message': 'message-circle', 'chat': 'message-square', 'chat-more': 'message-circle-more',
'volume': 'volume-2', 'mute': 'volume-x', 'moon': 'moon', 'share': 'share-2', 'send': 'send-horizontal',
'puzzle': 'puzzle', 'plus': 'plus', 'minus': 'minus', 'play': 'play', 'pin': 'pin', 'lock': 'lock',
'unlock': 'lock-open', 'inbox': 'inbox', 'live': 'radio', 'level-up': 'chevrons-up', 'eye': 'eye',
'logout': 'log-out', 'edit': 'pencil', 'document': 'file-text', 'diamond-line': 'diamond', 'x': 'x',
'heart-crack': 'heart-crack', 'calendar': 'calendar', 'layers': 'layers', 'palette': 'palette',
'bell': 'bell', 'settings': 'settings', 'history': 'history', 'refresh': 'refresh-cw', 'undo': 'rotate-ccw',
'flip': 'arrow-up-down', 'podium': 'podium', 'cards': 'playing-cards', 'store': 'store',
'bag': 'shopping-bag', 'ticket': 'ticket', 'wallet': 'wallet', 'link': 'link', 'copy': 'copy',
'info': 'info', 'help': 'circle-help', 'shield': 'shield', 'key': 'key-round', 'mail': 'mail',
'trash': 'trash-2', 'filter': 'filter', 'list': 'list', 'menu': 'menu', 'award': 'award',
'badge-check': 'badge-check', 'qr': 'qr-code', 'map-pin': 'map-pin', 'home': 'house', 'scroll': 'scroll',
'party': 'party-popper', 'external': 'external-link', 'circle-plus': 'circle-plus', 'circle-x': 'circle-x',
'chess-king': 'chess-king', 'chess-queen': 'chess-queen', 'chess-rook': 'chess-rook',
'chess-bishop': 'chess-bishop', 'chess-knight': 'chess-knight', 'chess-pawn': 'chess-pawn',
'crown-line': 'crown', 'grid': 'layout-grid', 'radio-tower': 'radio-tower', 'rewind': 'rewind',
'mic': null,
};
function inner(file) {
const s = fs.readFileSync(DIR + file + '.svg', 'utf8');
const body = s.slice(s.indexOf('>', s.indexOf('<svg')) + 1, s.lastIndexOf('</svg>'));
return body.replace(/\s*\n\s*/g, '').replace(/\s+\/>/g, '/>').trim();
}
const stroke = {};
for (const [name, file] of Object.entries(LUCIDE)) {
if (!file) continue;
stroke[name] = inner(file);
}
// EL3AB's own marks: filled, drawn on the same 24 grid. Holes use evenodd so the
// mark prints like ink on whatever surface it sits on.
const filled = {
// The crown from the logo, reduced to a 24px mark.
'crown': '<path fill-rule="evenodd" d="M9 17.6V12c0-3.4 1.4-5.4 3-6.4 1.6 1 3 3 3 6.4v5.6zm3-6.6-1.15 1.9L12 14.8l1.15-1.9z"/><circle cx="12" cy="3.3" r="1.7"/><path d="M3.3 8.3 7.7 12.6v5H6.1zM20.7 8.3l-4.4 4.3v5h1.6z"/><circle cx="3.2" cy="6.8" r="1.15"/><circle cx="20.8" cy="6.8" r="1.15"/><path d="m7.3 6.1 1.2 1.2-1.2 1.2-1.2-1.2zM16.7 6.1l1.2 1.2-1.2 1.2-1.2-1.2z"/><rect x="5" y="19" width="14" height="2.2" rx="1.1"/>',
// Coin: gold disc, inner ring, the crown's diamond struck in the middle.
'coin': '<circle cx="12" cy="12" r="9.5"/><circle cx="12" cy="12" r="6.6" fill="none" stroke="#000" stroke-opacity=".26" stroke-width="1.3"/><path d="m12 8.4 2.3 3.6-2.3 3.6-2.3-3.6z" fill="#000" fill-opacity=".3"/>',
// Gem: a cut diamond, facets in lighter ink.
'gem': '<path d="M6.6 3.8h10.8l3.8 5.3L12 20.6 2.8 9.1z"/><path d="M2.8 9.1h18.4M8.8 9.1 12 20.6l3.2-11.5M9.3 3.8l-.5 5.3M14.7 3.8l.5 5.3" fill="none" stroke="#fff" stroke-opacity=".5" stroke-width="1" stroke-linejoin="round"/>',
// Card suits, drawn rather than typed.
'suit-diamond': '<path d="m12 2.5 7 9.5-7 9.5-7-9.5z"/>',
'suit-spade': '<path d="M12 2.6c3.8 4 7.4 6.6 7.4 10.2a3.8 3.8 0 0 1-6.4 2.8l.9 4.8H10.1l.9-4.8a3.8 3.8 0 0 1-6.4-2.8c0-3.6 3.6-6.2 7.4-10.2z"/>',
'suit-heart': '<path d="M12 20.6C6.3 16.1 3 12.9 3 9.1A4.6 4.6 0 0 1 12 7.4a4.6 4.6 0 0 1 9 1.7c0 3.8-3.3 7-9 11.5z"/>',
'suit-club': '<circle cx="12" cy="7" r="3.9"/><circle cx="7" cy="13" r="3.9"/><circle cx="17" cy="13" r="3.9"/><path d="m11.2 12 .1 0h1.4l.1 0 1.2 8.8H10z"/>',
// The four games, as suit marks.
'game-chess': '<path fill-rule="evenodd" d="M7.7 17.2h9.5c.6-2.7 1.7-4.7 1.7-8.3 0-3.8-3-6.7-6.8-6.7H9.6L8.7.9 7.5 3.3 4.8 7.6c-.4.7-.3 1.6.3 2.1l.7.6c.5.4 1.2.4 1.7 0l1.9-1.4h1.4l-2 3.6c-.9 1.6-1.4 3.2-1.1 4.7zm3.6-12.4a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"/><path d="M5.4 21.6h13.2v-1.8c0-.8-.6-1.4-1.4-1.4H6.8c-.8 0-1.4.6-1.4 1.4z"/>',
'game-ludo': '<path fill-rule="evenodd" d="M12 1.8a7.2 7.2 0 0 1 7.2 7.2c0 4.7-4.9 9.8-6.5 11.4a1 1 0 0 1-1.4 0C9.7 18.8 4.8 13.7 4.8 9A7.2 7.2 0 0 1 12 1.8zm0 4.5a2.7 2.7 0 1 0 0 5.4 2.7 2.7 0 0 0 0-5.4z"/><rect x="7.5" y="20.6" width="9" height="1.8" rx=".9"/>',
'game-domino': '<path fill-rule="evenodd" d="M9 1.8h6a2.6 2.6 0 0 1 2.6 2.6v15.2A2.6 2.6 0 0 1 15 22.2H9a2.6 2.6 0 0 1-2.6-2.6V4.4A2.6 2.6 0 0 1 9 1.8zM8.6 11.4v1.2h6.8v-1.2zM9.9 4.6a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4zm4.2 2.9a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4zm-4.2 7a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4zm2.1 2a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4zm2.1 2a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4z"/>',
'game-backgammon': '<path d="M2.6 21.6 6.6 2.4l4 19.2z"/><path fill-rule="evenodd" d="M16.9 13.9a3.85 3.85 0 1 1 0 7.7 3.85 3.85 0 0 1 0-7.7zm0 2.1a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5zM16.9 5.7a3.85 3.85 0 1 1 0 7.7 3.85 3.85 0 0 1 0-7.7zm0 2.1a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5z"/>',
'dot': '<circle cx="12" cy="12" r="5"/>',
};
const js = `// EL3AB icon family. Generated by tools/build-icons.mjs: edit the lists there and re-run it, never this file.
//
// Utility icons are Lucide (ISC licence, https://lucide.dev), re-stroked at 1.75.
// The crown, coin, gem, card suits and the four game marks are EL3AB's own, drawn
// on the same 24px grid. Any icon can be replaced from /admin/branding.php by
// uploading an asset to the slot "icon_<name>".
//
// Never put an emoji or a typed symbol where an icon belongs: call icon().
import { getAsset } from './theme.js';
const STROKE = ${JSON.stringify(stroke, null, 0).replace(/","/g, '",\n "')};
const FILLED = ${JSON.stringify(filled, null, 0).replace(/","/g, '",\n "')};
// Icons that point along the reading direction. In RTL they mirror, so "back"
// still points toward where the reader came from.
const DIRECTIONAL = new Set(['back', 'forward', 'chevron-end', 'chevron-start', 'send', 'logout', 'external']);
export function hasIcon(name) { return name in STROKE || name in FILLED; }
/**
* icon('trophy', 20) -> inline SVG string.
* opts.cls adds classes; opts.label makes it announced instead of decorative.
*/
export function icon(name, size = 20, opts = {}) {
const override = getAsset('icon_' + name);
const label = opts.label ? \` role="img" aria-label="\${opts.label}"\` : ' aria-hidden="true"';
const cls = \`i i-\${name}\${DIRECTIONAL.has(name) ? ' i-dir' : ''}\${opts.cls ? ' ' + opts.cls : ''}\`;
if (override) {
return \`<img class="\${cls}" src="\${override}" alt="\${opts.label || ''}" width="\${size}" height="\${size}" style="width:\${size}px;height:\${size}px;object-fit:contain;" draggable="false">\`;
}
if (name in FILLED) {
return \`<svg class="\${cls}" width="\${size}" height="\${size}" viewBox="0 0 24 24" fill="currentColor"\${label}>\${FILLED[name]}</svg>\`;
}
const body = STROKE[name] ?? STROKE.sparkle;
const sw = opts.weight || 1.75;
return \`<svg class="\${cls}" width="\${size}" height="\${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="\${sw}" stroke-linecap="round" stroke-linejoin="round"\${label}>\${body}</svg>\`;
}
// The game each suit mark belongs to, and the ink it prints in.
export const GAME_MARK = {
chess: { icon: 'game-chess', ink: 'black' },
ludo: { icon: 'game-ludo', ink: 'red' },
domino: { icon: 'game-domino', ink: 'black' },
backgammon: { icon: 'game-backgammon', ink: 'red' },
};
`;
fs.writeFileSync(OUT, js);
console.log('icons:', Object.keys(stroke).length, 'stroke +', Object.keys(filled).length, 'filled');
/**
* Screenshot named scenes as a signed-in throwaway player.
*
* node tools/shoot.mjs --out <dir> [--base <url>] [--width 390 --height 844]
* [--only play,rank] [--guest] [--raw-theme]
*
* Creates one account through the real auth endpoint, gives it a name and a
* few ratings so screens have something to lay out, and deletes it at the end
* (even when the browser dies). --guest keeps the account in guest mode.
*
* By default the stored theme's colour and layout values are dropped from the
* /api/branding.php response, so the shot shows tokens.css as written. Pass
* --raw-theme to see exactly what production would apply.
*/
import fs from 'node:fs';
import path from 'node:path';
import puppeteer from 'puppeteer';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const args = process.argv.slice(2);
const argOf = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
const BASE = argOf('--base', 'http://127.0.0.1:8080');
const OUT = argOf('--out', './shots');
const W = +argOf('--width', 390), H = +argOf('--height', 844);
const ONLY = (argOf('--only', '') || '').split(',').filter(Boolean);
const GUEST = args.includes('--guest');
const RAW_THEME = args.includes('--raw-theme');
const SB = 'https://safe-supabase-kong.caprover.al-arcade.com';
const SK = process.env.SUPABASE_SERVICE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4';
export const SCENES = [
{ id: 'play', world: 'play' },
{ id: 'rank', world: 'rank' },
{ id: 'social', world: 'social' },
{ id: 'tournaments', world: 'tournaments' },
{ id: 'profile', world: 'profile' },
{ id: 'time-select', push: ['play-time-select', { game: 'chess', mode: 'human' }] },
{ id: 'bot-select', push: ['play-bot-select', { game: 'chess' }] },
{ id: 'chess-bot', push: ['chess-game', { mode: 'bot', botId: 'amina', timeControl: 'rapid_10_0' }], settle: 3000 },
{ id: 'ludo-room', push: ['ludo-room', { mode: 'setup', type: 'local' }] },
{ id: 'ludo-bot', push: ['ludo-game', { mode: 'bot' }], settle: 3000 },
{ id: 'domino-room', push: ['domino-room', { mode: 'bot-pick' }] },
{ id: 'domino-bot', push: ['domino-game', { mode: 'bot' }], settle: 3000 },
{ id: 'backgammon-room', push: ['backgammon-room', { mode: 'menu' }] },
{ id: 'backgammon-bot', push: ['backgammon-game', { mode: 'bot' }], settle: 3000 },
{ id: 'leaderboard', push: ['leaderboard', {}] },
{ id: 'friends', push: ['friends', {}] },
{ id: 'notifications', push: ['notifications', {}] },
{ id: 'shop', push: ['shop-browse', {}] },
{ id: 'achievements', push: ['achievements', {}] },
{ id: 'settings', push: ['profile-settings', {}] },
{ id: 'puzzles', push: ['puzzles', {}] },
{ id: 'daily', push: ['daily-reward', {}] },
{ id: 'challenge', push: ['challenge-friend', {}] },
{ id: 'history', push: ['chess-history', { game: 'chess', gameKey: 'chess' }] },
{ id: 'auth-splash', push: ['auth-splash', {}] },
{ id: 'auth-login', push: ['auth-login', {}] },
{ id: 'auth-register', push: ['auth-register', {}] },
];
// Keys theme.js would apply from storage. Dropped unless --raw-theme.
const THEME_KEYS = /^(back_|card|ink|red|gold|win|loss|sky|text_|ludo_(red|blue|green|yellow)$|board_|bg_(point|checker|board)|hud_|tab_|anim_|card_radius|btn_radius_px)/;
async function sb(p, { method = 'GET', body } = {}) {
const r = await fetch(`${SB}/rest/v1/${p}`, {
method,
headers: { apikey: SK, Authorization: `Bearer ${SK}`, 'Content-Type': 'application/json', Prefer: 'return=minimal' },
body: body ? JSON.stringify(body) : undefined,
});
return r.ok && method === 'GET' ? r.json() : null;
}
let account = null;
async function createAccount() {
const r = await fetch(`${BASE}/api/auth.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'guest' }),
}).then(x => x.json());
if (!r.access_token || !r.profile?.id) throw new Error('account creation failed: ' + JSON.stringify(r).slice(0, 160));
account = { id: r.profile.id, token: r.access_token, refresh: r.refresh_token };
await sb(`profiles?id=eq.${account.id}`, {
method: 'PATCH',
body: { display_name: 'ليلى السيد', elo_rapid: 1486, elo_blitz: 1412, elo_domino: 1240, coins: 2350, gems: 18, level: 7, daily_streak: 3 },
});
const profile = (await sb(`profiles?id=eq.${account.id}&select=*`))?.[0] || r.profile;
account.profile = profile;
}
async function removeAccount() {
if (!account) return;
const id = account.id;
for (const p 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}`,
`rating_history?player_id=eq.${id}`, `profiles?id=eq.${id}`]) {
await sb(p, { method: 'DELETE' }).catch(() => {});
}
await fetch(`${SB}/auth/v1/admin/users/${id}`, { method: 'DELETE', headers: { apikey: SK, Authorization: `Bearer ${SK}` } }).catch(() => {});
console.log('removed throwaway account');
}
async function goTo(page, s) {
await page.evaluate(async (s) => {
const mod = await import('/public/js/core/scene.js');
if (s.world) { mod.switchWorld(s.world, true); return; }
mod.push(s.push[0], s.push[1] || {});
}, s);
await new Promise(r => setTimeout(r, s.settle || 1500));
}
fs.mkdirSync(OUT, { recursive: true });
let browser;
try {
await createAccount();
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-sandbox', '--font-render-hinting=none'] });
const page = await browser.newPage();
const errors = [];
page.on('pageerror', e => errors.push(String(e).slice(0, 200)));
page.on('console', m => { if (m.type() === 'error') errors.push(m.text().slice(0, 200)); });
await page.setViewport({ width: W, height: H, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
await page.setRequestInterception(true);
page.on('request', async req => {
if (!RAW_THEME && req.url().includes('/api/branding.php') && req.method() === 'GET') {
const res = await fetch(req.url()).then(x => x.json()).catch(() => ({}));
for (const k of Object.keys(res)) if (THEME_KEYS.test(k)) delete res[k];
return req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(res) });
}
req.continue();
});
// Seed the session before the app boots.
await page.goto(BASE + '/public/css/tokens.css');
await page.evaluate((a, guest) => {
const state = {
auth: { token: a.token, refreshToken: a.refresh, userId: a.id, isGuest: guest, guestId: guest ? a.id : null },
player: a.profile, language: 'ar', activeWorld: 'play',
};
localStorage.setItem('el3ab_state', JSON.stringify(state));
}, account, GUEST);
await page.goto(BASE + '/', { waitUntil: 'networkidle2', timeout: 60000 });
await new Promise(r => setTimeout(r, 2000));
for (const s of SCENES) {
if (ONLY.length && !ONLY.includes(s.id)) continue;
try {
await goTo(page, s);
await page.screenshot({ path: path.join(OUT, `${s.id}.png`) });
console.log('shot', s.id);
} catch (e) { console.log('FAIL', s.id, e.message.slice(0, 100)); }
}
if (errors.length) console.log('\nconsole errors:\n ' + [...new Set(errors)].slice(0, 15).join('\n '));
} finally {
try { await browser?.close(); } catch (e) {}
await removeAccount();
}
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