feat(frontend): restructure main menu into Solo/Private/Matchmaking

Replace the single pick-a-theme-and-start intro modal with a three-section
menu (Solo/Private/Matchmaking). Solo keeps the existing gameplay reachable
via a direct Play button; Private (Enter Code/Create Room) and Matchmaking
(Find Match) are stubbed with console.log no-ops pending tickets #17/#15/#14.

main.ts gains a small showView(name) helper that toggles .hidden across the
menu/result overlay screens, keeping it a thin composition root.
This commit is contained in:
Gabriel Franco 2026-09-10 11:45:28 -03:00
parent d51113469f
commit 18cf6a00ca
3 changed files with 113 additions and 21 deletions

View file

@ -60,18 +60,38 @@
</div> </div>
<div id="intro-modal" class="modal-overlay"> <div id="intro-modal" class="modal-overlay">
<div class="modal"> <div class="modal menu-modal">
<h2>🕹️ THEME GUESS</h2> <h2>🕹️ THEME GUESS</h2>
<p>A snippet of code is hiding a classic color theme.</p> <p>A snippet of code is hiding a classic color theme. Pick how you want to play.</p>
<ol>
<li>Click any word in the code — every token of that <em>kind</em> lights up together.</li> <div class="menu-sections">
<li>Pick a color for that kind. Watch it paint everywhere at once.</li> <section id="menu-solo" class="menu-section" aria-label="Solo">
<li>Repeat until all 12 kinds have a color, including the background.</li> <h3>🧑‍💻 SOLO</h3>
<li>Hit <strong>REVEAL MATCH</strong> to see how close your palette lands to the real thing.</li> <p class="menu-section-desc">Click any word to color it, then reveal how close your palette lands to the real theme.</p>
</ol> <p class="pick-theme-label">▼ PICK A THEME ▼</p>
<p class="pick-theme-label">▼ PICK YOUR TARGET THEME ▼</p> <div id="theme-grid" class="theme-grid"></div>
<div id="theme-grid" class="theme-grid"></div> <div class="menu-section-actions">
<button id="start-btn" class="btn primary big">▶ PRESS START</button> <button id="start-btn" class="btn primary">▶ PLAY</button>
</div>
</section>
<section id="menu-private" class="menu-section" aria-label="Private">
<h3>🔒 PRIVATE</h3>
<p class="menu-section-desc">Play with friends in a private room, by invite code or a fresh room.</p>
<div class="menu-section-actions">
<button id="enter-code-btn" class="btn ghost">Enter Code</button>
<button id="create-room-btn" class="btn ghost">Create Room</button>
</div>
</section>
<section id="menu-matchmaking" class="menu-section" aria-label="Matchmaking">
<h3>🌐 MATCHMAKING</h3>
<p class="menu-section-desc">Get paired with another player looking for a match right now.</p>
<div class="menu-section-actions">
<button id="find-match-btn" class="btn primary">Find Match</button>
</div>
</section>
</div>
</div> </div>
</div> </div>

View file

@ -12,14 +12,17 @@ function requireEl<T extends HTMLElement>(id: string): T {
return el as T; return el as T;
} }
const introModal = requireEl<HTMLElement>('intro-modal'); const menuModal = requireEl<HTMLElement>('intro-modal');
const resultModal = requireEl<HTMLElement>('result-modal');
const themeGridEl = requireEl<HTMLElement>('theme-grid'); const themeGridEl = requireEl<HTMLElement>('theme-grid');
const startBtn = requireEl<HTMLButtonElement>('start-btn'); const startBtn = requireEl<HTMLButtonElement>('start-btn');
const enterCodeBtn = requireEl<HTMLButtonElement>('enter-code-btn');
const createRoomBtn = requireEl<HTMLButtonElement>('create-room-btn');
const findMatchBtn = requireEl<HTMLButtonElement>('find-match-btn');
const helpBtn = requireEl<HTMLButtonElement>('help-btn'); const helpBtn = requireEl<HTMLButtonElement>('help-btn');
const muteBtn = requireEl<HTMLButtonElement>('mute-btn'); const muteBtn = requireEl<HTMLButtonElement>('mute-btn');
const themeNameBadge = requireEl<HTMLElement>('theme-name-badge'); const themeNameBadge = requireEl<HTMLElement>('theme-name-badge');
const changeThemeBtn = requireEl<HTMLButtonElement>('change-theme-btn'); const changeThemeBtn = requireEl<HTMLButtonElement>('change-theme-btn');
const resultModal = requireEl<HTMLElement>('result-modal');
const previewElements = { const previewElements = {
overlay: requireEl<HTMLElement>('preview-overlay'), overlay: requireEl<HTMLElement>('preview-overlay'),
@ -28,13 +31,32 @@ const previewElements = {
canvas: requireEl<HTMLCanvasElement>('preview-canvas'), canvas: requireEl<HTMLCanvasElement>('preview-canvas'),
}; };
/** Top-level overlay screens the composition root switches between. At most
* one is visible at a time; the game board underneath is always present and
* simply gets obscured while a screen is shown. */
const screens = {
menu: menuModal,
result: resultModal,
} as const;
type ScreenName = keyof typeof screens;
function showView(name: ScreenName | null): void {
for (const key of Object.keys(screens) as ScreenName[]) {
screens[key].classList.toggle('hidden', key !== name);
}
}
let game: ThemeGuessGame | null = null; let game: ThemeGuessGame | null = null;
const themeGrid = createThemeGrid(themeGridEl, 'gruvbox', () => sound.playOpen()); const themeGrid = createThemeGrid(themeGridEl, 'gruvbox', () => sound.playOpen());
function startWithSelectedTheme(): void { // Solo is the only flow this ticket wires end-to-end. Ticket #17 will
// replace this direct "pick a theme, hit play" shortcut with a fuller solo
// config flow inside #menu-solo; keep it working in the meantime.
function playSolo(): void {
const themeId: ThemeId = themeGrid.selected; const themeId: ThemeId = themeGrid.selected;
introModal.classList.add('hidden'); showView(null);
runThemePreview(previewElements, themeId, () => { runThemePreview(previewElements, themeId, () => {
if (game) game.setTheme(themeId); if (game) game.setTheme(themeId);
else game = new ThemeGuessGame(themeId); else game = new ThemeGuessGame(themeId);
@ -43,14 +65,21 @@ function startWithSelectedTheme(): void {
}, () => sound.playOpen()); }, () => sound.playOpen());
} }
startBtn.addEventListener('click', startWithSelectedTheme); startBtn.addEventListener('click', playSolo);
helpBtn.addEventListener('click', () => introModal.classList.remove('hidden'));
changeThemeBtn.addEventListener('click', () => { // TODO(#17/#15/#14): wire real flow
resultModal.classList.add('hidden'); enterCodeBtn.addEventListener('click', () => console.log('[menu] enter code clicked (not implemented yet)'));
introModal.classList.remove('hidden'); // TODO(#17/#15/#14): wire real flow
}); createRoomBtn.addEventListener('click', () => console.log('[menu] create room clicked (not implemented yet)'));
// TODO(#17/#15/#14): wire real flow
findMatchBtn.addEventListener('click', () => console.log('[menu] find match clicked (not implemented yet)'));
helpBtn.addEventListener('click', () => showView('menu'));
changeThemeBtn.addEventListener('click', () => showView('menu'));
muteBtn.addEventListener('click', () => { muteBtn.addEventListener('click', () => {
const muted = sound.toggleMute(); const muted = sound.toggleMute();
muteBtn.textContent = muted ? '🔇' : '🔊'; muteBtn.textContent = muted ? '🔇' : '🔊';
}); });
showView('menu');

View file

@ -380,6 +380,49 @@ body {
text-align: center; text-align: center;
} }
/* ---------- main menu (Solo / Private / Matchmaking) ---------- */
.menu-modal { max-width: 760px; }
.menu-sections {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin: 18px 0 4px;
}
.menu-section {
background: var(--panel-2);
border: 3px solid var(--ink);
box-shadow: 4px 4px 0 var(--ink);
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.menu-section h3 {
margin: 0;
font-family: var(--pixel);
font-size: 11px;
text-shadow: 1px 1px 0 var(--ink);
}
#menu-solo h3 { color: var(--cyan); }
#menu-private h3 { color: var(--pink); }
#menu-matchmaking h3 { color: var(--green); }
.menu-section-desc { margin: 0; font-size: 11px; line-height: 1.5; color: var(--fg-dim); }
.menu-section-actions { display: flex; flex-direction: column; gap: 8px; margin-top: auto; }
.menu-section-actions .btn { width: 100%; }
#menu-solo .theme-grid { margin-bottom: 0; }
@media (max-width: 640px) {
.menu-sections { grid-template-columns: 1fr; }
}
/* ---------- theme preview countdown ---------- */ /* ---------- theme preview countdown ---------- */
.preview-box { .preview-box {