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
tests/.auth.log
tests/.server.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 diff is collapsed.
......@@ -8,7 +8,7 @@ header('Expires: 0');
<head>
<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="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-status-bar-style" content="black-translucent">
<title>EL3AB</title>
......@@ -17,12 +17,20 @@ header('Expires: 0');
<link rel="manifest" href="/manifest.json">
<link rel="preconnect" href="https://fonts.googleapis.com">
<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(); ?>
<link rel="stylesheet" href="/public/css/tokens.css?v=<?=$v?>">
<link rel="stylesheet" href="/public/css/core.css?v=<?=$v?>">
</head>
<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="scene-container"></div>
</div>
......
......@@ -5,6 +5,7 @@
"packages": {
"": {
"devDependencies": {
"lucide-static": "^1.59.0",
"puppeteer": "^24.43.1"
}
},
......@@ -726,6 +727,13 @@
"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": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
......
This diff is collapsed.
This diff is collapsed.
......@@ -2,7 +2,8 @@ import * as store from './store.js';
import * as bus from './bus.js';
import * as scene from './scene.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 isGuestMode = false;
......@@ -30,33 +31,40 @@ export function init(guestMode = false) {
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() {
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 name = player?.display_name || player?.username || t('guest.name') || '';
hudEl.innerHTML = `
<div class="hud-profile" id="hud-profile">
<div class="hud-avatar">${avatarContent}</div>
<div class="hud-level-badge">${level}</div>
</div>
<button class="hud-profile" id="hud-profile" aria-label="${t('nav.profile')}">
<span class="hud-avatar">${avatarMarkup(player, 18)}</span>
<span class="hud-who">
<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-stat hud-coins">
<span class="icon">${emoji('hud_coin', '●', 16)}</span>
<span id="hud-coins">${formatNum(player?.coins || 0)}</span>
<div class="hud-stat hud-coins" aria-label="${t('hud.coins')}">
<span class="icon">${icon('coin', 18)}</span>
<span id="hud-coins" class="num">${formatNum(player?.coins || 0)}</span>
</div>
<div class="hud-stat hud-gems">
<span class="icon">${emoji('hud_gem', '◆', 16)}</span>
<span id="hud-gems">${formatNum(player?.gems || 0)}</span>
<div class="hud-stat hud-gems" aria-label="${t('hud.gems')}">
<span class="icon">${icon('gem', 17)}</span>
<span id="hud-gems" class="num">${formatNum(player?.gems || 0)}</span>
</div>
</div>
<div class="hud-actions">
<div class="hud-btn" id="hud-notif">
${emoji('bell', '🔔', 18)}
<div class="hud-badge" id="hud-badge"></div>
</div>
<button class="hud-btn" id="hud-notif" aria-label="${t('hud.notifications')}">
${icon('bell', 22)}
<span class="hud-badge num" id="hud-badge"></span>
</button>
</div>
`;
......@@ -69,23 +77,16 @@ function renderHud() {
}
function renderTabBar() {
tabBar = document.createElement('div');
tabBar = document.createElement('nav');
tabBar.className = 'tab-bar';
const worlds = isGuestMode ? ['play'] : scene.getWorlds();
tabBar.setAttribute('aria-label', 'EL3AB');
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 = {
rank: assetImg('tab_rank', svgFallback.rank, 22, 22),
social: assetImg('tab_social', svgFallback.social, 22, 22),
play: assetImg('tab_play', svgFallback.play, 22, 22),
tournaments: assetImg('tab_tournaments', svgFallback.tournaments, 22, 22),
profile: assetImg('tab_profile', svgFallback.profile, 22, 22),
rank: assetImg('tab_rank', '', 24, 24),
social: assetImg('tab_social', '', 24, 24),
play: assetImg('tab_play', '', 24, 24),
tournaments: assetImg('tab_tournaments', '', 24, 24),
profile: assetImg('tab_profile', '', 24, 24),
};
const labels = { rank: 'nav.rank', social: 'nav.social', play: 'nav.play', tournaments: 'nav.tournaments', profile: 'nav.profile' };
const current = scene.getCurrentWorld();
......@@ -94,10 +95,10 @@ function renderTabBar() {
tabBar.innerHTML = allWorlds.map(w => {
const locked = isGuestMode && guestLocked.includes(w);
return `
<div class="tab-item ${w === current ? 'active' : ''} ${locked ? 'tab-locked' : ''}" data-world="${w}" ${locked ? 'data-locked="true"' : ''}>
<div class="tab-icon" ${locked ? 'style="opacity:0.4;"' : ''}>${icons[w]}</div>
<span ${locked ? 'style="opacity:0.4;"' : ''}>${t(labels[w])}</span>
</div>`;
<button class="tab-item ${w === current ? 'active' : ''} ${locked ? 'tab-locked' : ''}" data-world="${w}" ${locked ? 'data-locked="true"' : ''} ${w === current ? 'aria-current="page"' : ''}>
<span class="tab-icon">${locked ? icon('lock', 22) : icons[w]}</span>
<span>${t(labels[w])}</span>
</button>`;
}).join('');
tabBar.addEventListener('click', (e) => {
......@@ -117,14 +118,19 @@ function renderTabBar() {
document.body.appendChild(tabBar);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function showGuestToast() {
const container = document.getElementById('toast-container');
if (!container) return;
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 = `
<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.remove();
......@@ -136,7 +142,9 @@ function showGuestToast() {
function updateTabs({ to }) {
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() {
if (coins) coins.textContent = formatNum(player.coins || 0);
if (gems) gems.textContent = formatNum(player.gems || 0);
const levelBadge = hudEl.querySelector('.hud-level-badge');
if (levelBadge) levelBadge.textContent = player.level || 1;
const levelEl = document.getElementById('hud-level');
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');
if (avatarEl && player.avatar_url) {
avatarEl.innerHTML = `<img src="${player.avatar_url}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">`;
}
if (avatarEl && player.avatar_url) avatarEl.innerHTML = `<img src="${player.avatar_url}" alt="">`;
}
function updateBell(count) {
......@@ -188,8 +196,7 @@ function updateTournamentBadge(pending) {
if (!badge) {
badge = document.createElement('div');
badge.className = 'tour-badge';
badge.style.cssText = 'position:absolute;top:2px;right:8px;width:8px;height:8px;background:var(--gold);border-radius:50%;';
tourTab.style.position = 'relative';
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.appendChild(badge);
}
} else if (badge) {
......
......@@ -113,7 +113,7 @@ const strings = {
'play.retry_or_cancel': 'لم يتم العثور على خصم. هل تريد المتابعة؟',
'play.retry': 'إعادة',
'play.challenge_friend': 'تحدّي صديق',
'play.daily_reward': 'المكافأة اليومية',
'play.daily_reward': 'مكافأة اليوم',
'play.my_tournaments': 'بطولاتي',
'play.leaderboard': 'الترتيب',
'play.my_games': 'مبارياتي',
......@@ -726,7 +726,23 @@ const strings = {
'ludo.phrase_hurry': 'يلا بسرعة!',
'ludo.phrase_nice': 'نايس!',
'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: {
'app.name': 'EL3AB',
......@@ -840,7 +856,7 @@ const strings = {
'play.retry_or_cancel': 'No opponent found. Keep searching?',
'play.retry': 'Retry',
'play.challenge_friend': 'Challenge Friend',
'play.daily_reward': 'Daily Reward',
'play.daily_reward': 'Daily reward',
'play.my_tournaments': 'My Tournaments',
'play.leaderboard': 'Leaderboard',
'play.my_games': 'My Games',
......@@ -1453,7 +1469,23 @@ const strings = {
'ludo.phrase_hurry': 'Hurry up!',
'ludo.phrase_nice': 'Nice!',
'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'
}
};
......
This diff is collapsed.
......@@ -33,7 +33,8 @@ function persist() {
player: state.player,
activeWorld: state.activeWorld,
audioEnabled: state.audioEnabled,
language: state.language
language: state.language,
home: state.home
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
} catch (e) {}
......
This diff is collapsed.
This diff is collapsed.
// 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