init
This commit is contained in:
47
src/components/ActionBar.js
Normal file
47
src/components/ActionBar.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { sound } from '../engine/audio.js';
|
||||
|
||||
export function createActionBar({ onShuffle, onSortAlpha, onToggleLock, onGetHint }) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'action-bar';
|
||||
|
||||
function render(state) {
|
||||
const isLocked = state.lockQuartiles;
|
||||
|
||||
container.innerHTML = `
|
||||
<button class="btn-action" id="btn-action-shuffle" title="Shuffle unlocked tiles">
|
||||
<span class="action-icon">🔀</span>
|
||||
<span>Shuffle</span>
|
||||
</button>
|
||||
|
||||
<button class="btn-action" id="btn-action-sort" title="Sort tiles alphabetically A-Z">
|
||||
<span class="action-icon">🔤</span>
|
||||
<span>Sort A-Z</span>
|
||||
</button>
|
||||
|
||||
<button class="btn-action ${isLocked ? 'active' : ''}" id="btn-action-lock" title="${isLocked ? 'Unlock Quartiles to shuffle freely' : 'Lock found Quartiles at top rows'}">
|
||||
<span class="action-icon">${isLocked ? '🔒' : '🔓'}</span>
|
||||
<span>${isLocked ? 'Locked' : 'Unlocked'}</span>
|
||||
</button>
|
||||
|
||||
<button class="btn-action" id="btn-action-hint" title="Get a hint for an unfound word">
|
||||
<span class="action-icon">💡</span>
|
||||
<span>Hint</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
container.querySelector('#btn-action-shuffle')?.addEventListener('click', () => {
|
||||
sound.playShuffle();
|
||||
onShuffle();
|
||||
});
|
||||
|
||||
container.querySelector('#btn-action-sort')?.addEventListener('click', () => {
|
||||
sound.playShuffle();
|
||||
onSortAlpha();
|
||||
});
|
||||
|
||||
container.querySelector('#btn-action-lock')?.addEventListener('click', onToggleLock);
|
||||
container.querySelector('#btn-action-hint')?.addEventListener('click', onGetHint);
|
||||
}
|
||||
|
||||
return { element: container, render };
|
||||
}
|
||||
64
src/components/Header.js
Normal file
64
src/components/Header.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { sound } from '../engine/audio.js';
|
||||
|
||||
export function createHeader({
|
||||
onOpenCalendar,
|
||||
onOpenHowToPlay,
|
||||
onOpenStats,
|
||||
onOpenCustomBuilder,
|
||||
onOpenSettings,
|
||||
onNewRandomPuzzle
|
||||
}) {
|
||||
const header = document.createElement('header');
|
||||
header.className = 'app-header';
|
||||
|
||||
function render(state) {
|
||||
const puzzle = state.currentPuzzle;
|
||||
const isMuted = sound.isMuted();
|
||||
|
||||
header.innerHTML = `
|
||||
<div class="header-brand">
|
||||
<div>
|
||||
<h1 class="app-title">
|
||||
QUARTILES
|
||||
<span class="title-badge">DAILY</span>
|
||||
</h1>
|
||||
<div class="header-date-badge">${puzzle ? puzzle.title : 'Word Game'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<button class="btn-header-icon" id="btn-sound-toggle" title="${isMuted ? 'Unmute Sound' : 'Mute Sound'}">
|
||||
${isMuted ? '🔇' : '🔊'}
|
||||
</button>
|
||||
<button class="btn-header-icon" id="btn-random-puzzle" title="New Random Unlimited Puzzle">
|
||||
🎲
|
||||
</button>
|
||||
<button class="btn-header-icon" id="btn-open-calendar" title="Puzzles Archive">
|
||||
📅
|
||||
</button>
|
||||
<button class="btn-header-icon" id="btn-open-help" title="How to Play">
|
||||
❓
|
||||
</button>
|
||||
<button class="btn-header-icon" id="btn-open-stats" title="Statistics">
|
||||
🏆
|
||||
</button>
|
||||
<button class="btn-header-icon" id="btn-open-settings" title="Settings">
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
header.querySelector('#btn-sound-toggle')?.addEventListener('click', () => {
|
||||
const nowMuted = sound.toggleMute();
|
||||
header.querySelector('#btn-sound-toggle').innerHTML = nowMuted ? '🔇' : '🔊';
|
||||
});
|
||||
|
||||
header.querySelector('#btn-random-puzzle')?.addEventListener('click', onNewRandomPuzzle);
|
||||
header.querySelector('#btn-open-calendar')?.addEventListener('click', onOpenCalendar);
|
||||
header.querySelector('#btn-open-help')?.addEventListener('click', onOpenHowToPlay);
|
||||
header.querySelector('#btn-open-stats')?.addEventListener('click', onOpenStats);
|
||||
header.querySelector('#btn-open-settings')?.addEventListener('click', onOpenSettings);
|
||||
}
|
||||
|
||||
return { element: header, render };
|
||||
}
|
||||
405
src/components/Modals.js
Normal file
405
src/components/Modals.js
Normal file
@@ -0,0 +1,405 @@
|
||||
import { CURATED_PUZZLES } from '../data/puzzles.js';
|
||||
import { sound } from '../engine/audio.js';
|
||||
|
||||
export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuzzle }) {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'app-modals-container';
|
||||
|
||||
let activeModal = null; // 'help', 'calendar', 'builder', 'stats', 'settings'
|
||||
|
||||
function render(state) {
|
||||
if (!activeModal) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="modal-backdrop open" id="generic-modal-backdrop">
|
||||
<div class="modal-sheet" style="max-height: 85vh;">
|
||||
<div class="sheet-handle"></div>
|
||||
<div class="sheet-header">
|
||||
<div class="sheet-title">${getModalTitle(activeModal)}</div>
|
||||
<button class="btn-close-sheet" id="btn-close-generic-modal">✕</button>
|
||||
</div>
|
||||
<div class="sheet-body" style="padding: 16px 20px 24px;">
|
||||
${getModalBodyHtml(activeModal, state)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
container.querySelector('#btn-close-generic-modal')?.addEventListener('click', close);
|
||||
container.querySelector('#generic-modal-backdrop')?.addEventListener('click', e => {
|
||||
if (e.target.id === 'generic-modal-backdrop') {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
wireModalEvents(activeModal, state);
|
||||
}
|
||||
|
||||
function getModalTitle(type) {
|
||||
switch (type) {
|
||||
case 'help': return 'How to Play Quartiles';
|
||||
case 'calendar': return 'Daily Puzzles & Archives';
|
||||
case 'builder': return 'Create Custom Puzzle';
|
||||
case 'stats': return 'Player Statistics';
|
||||
case 'settings': return 'Game Settings';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getModalBodyHtml(type, state) {
|
||||
if (type === 'help') {
|
||||
return `
|
||||
<div style="display:flex; flex-direction:column; gap:16px; font-size:0.92rem; line-height:1.5;">
|
||||
<div>
|
||||
<h3 style="font-size:1.05rem; margin-bottom:4px; color:var(--text-primary);">🎯 The Objective</h3>
|
||||
<p style="color:var(--text-secondary);">Combine letter tiles to form valid English words, find the 5 hidden <strong>Quartiles</strong>, and score at least <strong>100 points</strong> to achieve the <strong>Expert</strong> rank!</p>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg-primary); padding:12px; border-radius:var(--radius-md); border:1px solid var(--border-subtle);">
|
||||
<h3 style="font-size:1rem; margin-bottom:8px; color:var(--text-primary);">⭐ Points System</h3>
|
||||
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:8px;">
|
||||
<div style="background:var(--bg-secondary); padding:8px 12px; border-radius:8px;">
|
||||
<strong>1 Tile:</strong> 1 point
|
||||
</div>
|
||||
<div style="background:var(--bg-secondary); padding:8px 12px; border-radius:8px;">
|
||||
<strong>2 Tiles:</strong> 2 points
|
||||
</div>
|
||||
<div style="background:var(--bg-secondary); padding:8px 12px; border-radius:8px;">
|
||||
<strong>3 Tiles:</strong> 4 points
|
||||
</div>
|
||||
<div style="background:var(--bg-secondary); padding:8px 12px; border-radius:8px; border:1px solid var(--accent-quartile);">
|
||||
<strong style="color:var(--accent-quartile);">4 Tiles (Quartile):</strong> 8 points
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style="font-size:1.05rem; margin-bottom:4px; color:var(--accent-quartile);">✨ The 40-Point Bonus</h3>
|
||||
<p style="color:var(--text-secondary);">Every puzzle has <strong>5 target Quartiles</strong> (4-tile words). Finding all 5 unlocks an instant <strong>40-point completion bonus</strong>!</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style="font-size:1.05rem; margin-bottom:4px; color:var(--text-primary);">🔤 Rules & Gameplay</h3>
|
||||
<ul style="padding-left:18px; color:var(--text-secondary); display:flex; flex-direction:column; gap:4px;">
|
||||
<li>Words can be 1, 2, 3, or 4 tiles long.</li>
|
||||
<li>Each tile contains 2 to 4 letters.</li>
|
||||
<li>Tiles stay on the board and can be reused for other words.</li>
|
||||
<li>Use <strong>Shuffle</strong> to mix tiles, or <strong>Sort A-Z</strong> to alphabetize.</li>
|
||||
<li>Enable <strong>Lock Quartiles</strong> to pin solved 4-tile words at the top.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === 'calendar') {
|
||||
return `
|
||||
<div style="display:flex; flex-direction:column; gap:12px;">
|
||||
<p style="font-size:0.88rem; color:var(--text-secondary);">
|
||||
Choose from the official daily puzzles or themed challenge packs:
|
||||
</p>
|
||||
<div class="puzzles-list-grid">
|
||||
${CURATED_PUZZLES.map(p => {
|
||||
const isCurrent = state.currentPuzzle && state.currentPuzzle.id === p.id;
|
||||
const saved = localStorage.getItem(`quartiles_save_${p.id}`);
|
||||
let progressBadge = '';
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.score >= 100) {
|
||||
progressBadge = '<span style="font-size:0.72rem; color:#10b981; font-weight:700;">🏆 Expert</span>';
|
||||
} else if (parsed.score > 0) {
|
||||
progressBadge = `<span style="font-size:0.72rem; color:var(--accent-quartile); font-weight:700;">${parsed.score} pts</span>`;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return `
|
||||
<button class="puzzle-card-select ${isCurrent ? 'active' : ''}" data-id="${p.id}">
|
||||
<div style="font-weight:700; font-size:0.92rem; color:var(--text-primary);">${p.date || p.id}</div>
|
||||
<div style="font-size:0.78rem; color:var(--text-secondary); margin:2px 0 4px;">${p.theme}</div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span style="font-size:0.72rem; background:var(--bg-tertiary); padding:1px 6px; border-radius:4px;">${p.difficulty}</span>
|
||||
${progressBadge}
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === 'builder') {
|
||||
return `
|
||||
<div style="display:flex; flex-direction:column; gap:14px;">
|
||||
<p style="font-size:0.88rem; color:var(--text-secondary);">
|
||||
Create your own Quartiles puzzle! Enter <strong>5 four-tile words</strong> (each tile must be 2 to 4 letters):
|
||||
</p>
|
||||
|
||||
<div class="custom-builder-form" id="custom-builder-form">
|
||||
<div style="font-size:0.8rem; font-weight:700; color:var(--text-secondary);">Quartile #1</div>
|
||||
<div class="custom-quartet-row">
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="UN" value="UN" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="FOR" value="FOR" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="GET" value="GET" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="TABLE" value="TABLE" />
|
||||
</div>
|
||||
|
||||
<div style="font-size:0.8rem; font-weight:700; color:var(--text-secondary);">Quartile #2</div>
|
||||
<div class="custom-quartet-row">
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="PHO" value="PHO" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="TO" value="TO" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="GRA" value="GRA" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="PHY" value="PHY" />
|
||||
</div>
|
||||
|
||||
<div style="font-size:0.8rem; font-weight:700; color:var(--text-secondary);">Quartile #3</div>
|
||||
<div class="custom-quartet-row">
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="AD" value="AD" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="VEN" value="VEN" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="TUR" value="TUR" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="OUS" value="OUS" />
|
||||
</div>
|
||||
|
||||
<div style="font-size:0.8rem; font-weight:700; color:var(--text-secondary);">Quartile #4</div>
|
||||
<div class="custom-quartet-row">
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="IN" value="IN" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="SPI" value="SPI" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="RA" value="RA" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="TION" value="TION" />
|
||||
</div>
|
||||
|
||||
<div style="font-size:0.8rem; font-weight:700; color:var(--text-secondary);">Quartile #5</div>
|
||||
<div class="custom-quartet-row">
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="RE" value="RE" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="MAR" value="MAR" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="KA" value="KA" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="BLE" value="BLE" />
|
||||
</div>
|
||||
|
||||
<div style="margin-top:6px;">
|
||||
<input id="custom-title-input" type="text" placeholder="Puzzle Title (e.g. Maya's Word Challenge)"
|
||||
style="width:100%; height:38px; padding:0 12px; border-radius:8px; border:1px solid var(--border-color); background:var(--bg-primary); color:var(--text-primary); font-size:0.9rem;" />
|
||||
</div>
|
||||
|
||||
<button class="btn-primary-modal" id="btn-submit-custom-puzzle" style="margin-top:8px;">
|
||||
Build & Play Puzzle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === 'stats') {
|
||||
const stats = state.getStats();
|
||||
return `
|
||||
<div style="display:flex; flex-direction:column; gap:16px;">
|
||||
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:10px; text-align:center;">
|
||||
<div style="background:var(--bg-primary); padding:14px 8px; border-radius:var(--radius-md);">
|
||||
<div style="font-size:1.8rem; font-weight:800; color:var(--accent-submit);">${stats.totalWordsFound}</div>
|
||||
<div style="font-size:0.75rem; color:var(--text-secondary); text-transform:uppercase; font-weight:600;">Words Found</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-primary); padding:14px 8px; border-radius:var(--radius-md);">
|
||||
<div style="font-size:1.8rem; font-weight:800; color:var(--accent-quartile);">${stats.expertCount}</div>
|
||||
<div style="font-size:0.75rem; color:var(--text-secondary); text-transform:uppercase; font-weight:600;">Expert Ranks</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-primary); padding:14px 8px; border-radius:var(--radius-md);">
|
||||
<div style="font-size:1.8rem; font-weight:800; color:var(--accent-blue);">${stats.perfectCount}</div>
|
||||
<div style="font-size:0.75rem; color:var(--text-secondary); text-transform:uppercase; font-weight:600;">Perfect Clears</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg-primary); padding:14px; border-radius:var(--radius-md); font-size:0.88rem;">
|
||||
<div style="font-weight:700; margin-bottom:6px; color:var(--text-primary);">Current Puzzle Progress</div>
|
||||
<div style="display:flex; justify-content:space-between; margin-bottom:4px;">
|
||||
<span style="color:var(--text-secondary);">Score:</span>
|
||||
<strong>${state.score} pts</strong>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; margin-bottom:4px;">
|
||||
<span style="color:var(--text-secondary);">Quartiles:</span>
|
||||
<strong>${state.getTargetQuartilesSolvedCount()} / 5</strong>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between;">
|
||||
<span style="color:var(--text-secondary);">Current Rank:</span>
|
||||
<strong>${state.getRank().current.badge} ${state.getRank().current.name}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === 'settings') {
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
const isMuted = sound.isMuted();
|
||||
const lockSetting = state.lockQuartiles;
|
||||
|
||||
return `
|
||||
<div style="display:flex; flex-direction:column; gap:16px; font-size:0.92rem;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:10px 0; border-bottom:1px solid var(--border-subtle);">
|
||||
<div>
|
||||
<div style="font-weight:600; color:var(--text-primary);">Dark Theme</div>
|
||||
<div style="font-size:0.78rem; color:var(--text-secondary);">Toggle sleek dark appearance</div>
|
||||
</div>
|
||||
<button id="setting-toggle-theme" style="padding:6px 14px; border-radius:var(--radius-full); background:var(--bg-tertiary); font-weight:600;">
|
||||
${isDark ? '🌙 Dark' : '☀️ Light'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:10px 0; border-bottom:1px solid var(--border-subtle);">
|
||||
<div>
|
||||
<div style="font-weight:600; color:var(--text-primary);">Sound Effects</div>
|
||||
<div style="font-size:0.78rem; color:var(--text-secondary);">Tactile audio feedback and chimes</div>
|
||||
</div>
|
||||
<button id="setting-toggle-sound" style="padding:6px 14px; border-radius:var(--radius-full); background:var(--bg-tertiary); font-weight:600;">
|
||||
${isMuted ? '🔇 Off' : '🔊 On'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:10px 0; border-bottom:1px solid var(--border-subtle);">
|
||||
<div>
|
||||
<div style="font-weight:600; color:var(--text-primary);">Auto-Lock Quartiles</div>
|
||||
<div style="font-size:0.78rem; color:var(--text-secondary);">Lock completed Quartiles at top rows</div>
|
||||
</div>
|
||||
<button id="setting-toggle-lock" style="padding:6px 14px; border-radius:var(--radius-full); background:var(--bg-tertiary); font-weight:600;">
|
||||
${lockSetting ? '🔒 Locked' : '🔓 Off'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="padding-top:10px;">
|
||||
<button id="setting-reset-puzzle" style="width:100%; height:40px; border-radius:var(--radius-md); background:rgba(239, 68, 68, 0.1); color:var(--accent-danger); font-weight:700;">
|
||||
Reset Current Puzzle Progress
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function wireModalEvents(type, state) {
|
||||
if (type === 'calendar') {
|
||||
const cards = container.querySelectorAll('.puzzle-card-select');
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const id = card.getAttribute('data-id');
|
||||
onSelectPuzzle(id);
|
||||
close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'builder') {
|
||||
const submitBtn = container.querySelector('#btn-submit-custom-puzzle');
|
||||
submitBtn?.addEventListener('click', async () => {
|
||||
const rows = container.querySelectorAll('.custom-quartet-row');
|
||||
const quartets = [];
|
||||
let hasError = false;
|
||||
|
||||
rows.forEach((row, rIdx) => {
|
||||
const inputs = row.querySelectorAll('.custom-quartet-input');
|
||||
const quartet = [];
|
||||
inputs.forEach(input => {
|
||||
const val = input.value.trim().toLowerCase();
|
||||
if (val.length < 2 || val.length > 4 || !/^[a-z]+$/.test(val)) {
|
||||
state.notifyToast(`Tile "${val.toUpperCase()}" in Quartile #${rIdx + 1} must be 2 to 4 letters!`, 'warning', 3500);
|
||||
input.style.borderColor = 'var(--accent-danger)';
|
||||
hasError = true;
|
||||
} else {
|
||||
input.style.borderColor = '';
|
||||
}
|
||||
quartet.push(val);
|
||||
});
|
||||
quartets.push(quartet);
|
||||
});
|
||||
|
||||
if (hasError) return;
|
||||
|
||||
const titleInput = container.querySelector('#custom-title-input');
|
||||
const title = (titleInput && titleInput.value.trim()) || 'Custom Community Puzzle';
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerText = 'Analyzing & Building...';
|
||||
|
||||
try {
|
||||
const { createCustomPuzzle, encodePuzzleToHash } = await import('../engine/generator.js');
|
||||
const customPuzzle = createCustomPuzzle(quartets, title);
|
||||
onCustomPuzzleCreated(customPuzzle);
|
||||
|
||||
// Update URL hash with encoded puzzle
|
||||
const hash = encodePuzzleToHash(quartets, title);
|
||||
window.location.hash = `puzzle=${hash}`;
|
||||
|
||||
close();
|
||||
} catch (err) {
|
||||
state.notifyToast('Error creating puzzle: ' + err.message, 'error', 4000);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerText = 'Build & Play Puzzle';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'settings') {
|
||||
container.querySelector('#setting-toggle-theme')?.addEventListener('click', () => {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
const nextTheme = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', nextTheme);
|
||||
localStorage.setItem('quartiles_theme', nextTheme);
|
||||
render(state);
|
||||
});
|
||||
|
||||
container.querySelector('#setting-toggle-sound')?.addEventListener('click', () => {
|
||||
sound.toggleMute();
|
||||
render(state);
|
||||
});
|
||||
|
||||
container.querySelector('#setting-toggle-lock')?.addEventListener('click', () => {
|
||||
state.toggleLockQuartiles();
|
||||
render(state);
|
||||
});
|
||||
|
||||
const resetBtn = container.querySelector('#setting-reset-puzzle');
|
||||
if (resetBtn) {
|
||||
let isConfirming = false;
|
||||
let timer = null;
|
||||
resetBtn.addEventListener('click', () => {
|
||||
if (!isConfirming) {
|
||||
isConfirming = true;
|
||||
resetBtn.innerText = 'Tap again to confirm reset';
|
||||
resetBtn.style.background = 'var(--accent-danger)';
|
||||
resetBtn.style.color = '#ffffff';
|
||||
timer = setTimeout(() => {
|
||||
isConfirming = false;
|
||||
if (resetBtn) {
|
||||
resetBtn.innerText = 'Reset Current Puzzle Progress';
|
||||
resetBtn.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
resetBtn.style.color = 'var(--accent-danger)';
|
||||
}
|
||||
}, 3500);
|
||||
} else {
|
||||
clearTimeout(timer);
|
||||
onResetPuzzle();
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function open(modalType, state) {
|
||||
activeModal = modalType;
|
||||
render(state);
|
||||
}
|
||||
|
||||
function close() {
|
||||
activeModal = null;
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
return { element: container, render, open, close };
|
||||
}
|
||||
60
src/components/ScoreRankBar.js
Normal file
60
src/components/ScoreRankBar.js
Normal file
@@ -0,0 +1,60 @@
|
||||
export function createScoreRankBar({ onOpenWordsDrawer }) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'score-rank-container';
|
||||
|
||||
function render(state) {
|
||||
const rankInfo = state.getRank();
|
||||
const quartilesSolved = state.getTargetQuartilesSolvedCount();
|
||||
const totalWords = state.currentPuzzle ? state.currentPuzzle.wordCount : 0;
|
||||
const foundWordsCount = state.foundWords.size;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="score-rank-card">
|
||||
<div class="score-rank-top">
|
||||
<div class="score-display">
|
||||
<span class="score-num" id="current-score-num">${state.score}</span>
|
||||
<span class="score-label">Points</span>
|
||||
</div>
|
||||
<div class="rank-badge-wrap">
|
||||
<div class="rank-pill ${rankInfo.isExpert ? 'expert' : ''}">
|
||||
<span>${rankInfo.current.badge}</span>
|
||||
<span>${rankInfo.current.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar-container" title="${rankInfo.next ? `Next: ${rankInfo.next.name} in ${rankInfo.pointsToNext} pts` : 'Top Rank!'}">
|
||||
<div class="progress-bar-fill ${rankInfo.isExpert ? 'expert' : ''}" style="width: ${rankInfo.progressPercent}%"></div>
|
||||
</div>
|
||||
|
||||
<div class="quartiles-indicator-row">
|
||||
<div class="quartiles-counter">
|
||||
<span>Quartiles:</span>
|
||||
<strong>${quartilesSolved} / 5</strong>
|
||||
${state.quartilesBonusAwarded ? '<span class="quartile-bonus-badge">+40 BONUS</span>' : ''}
|
||||
</div>
|
||||
<div class="quartiles-segments">
|
||||
${[0, 1, 2, 3, 4]
|
||||
.map(i => `<div class="quartile-segment-pip ${i < quartilesSolved ? 'solved' : ''}"></div>`)
|
||||
.join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="words-found-strip">
|
||||
<button class="btn-words-drawer" id="btn-words-found-toggle">
|
||||
<span>Words Found</span>
|
||||
<span class="badge-count">${foundWordsCount} ${rankInfo.isExpert ? `/ ${totalWords}` : ''}</span>
|
||||
<span style="font-size: 0.75rem;">▾</span>
|
||||
</button>
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); font-weight: 500;">
|
||||
${rankInfo.next ? `Next rank in <strong>${rankInfo.pointsToNext}</strong> pts` : '🌟 Expert Reached!'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.querySelector('#btn-words-found-toggle')?.addEventListener('click', onOpenWordsDrawer);
|
||||
}
|
||||
|
||||
return { element: container, render };
|
||||
}
|
||||
52
src/components/TileGrid.js
Normal file
52
src/components/TileGrid.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { sound } from '../engine/audio.js';
|
||||
|
||||
export function createTileGrid({ onTileSelected }) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'board-container';
|
||||
|
||||
function render(state) {
|
||||
const activeSet = new Set(state.activeTileIds);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="tiles-grid" id="main-tiles-grid">
|
||||
${state.boardTiles
|
||||
.map(tile => {
|
||||
const isSelected = activeSet.has(tile.id);
|
||||
const isLocked = tile.isLocked;
|
||||
|
||||
return `
|
||||
<button
|
||||
class="board-tile ${isSelected ? 'selected' : ''} ${isLocked ? 'locked' : ''}"
|
||||
data-id="${tile.id}"
|
||||
aria-label="Tile ${tile.text}"
|
||||
>
|
||||
${tile.text}
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const tiles = container.querySelectorAll('.board-tile');
|
||||
tiles.forEach(tileBtn => {
|
||||
const id = parseInt(tileBtn.getAttribute('data-id'), 10);
|
||||
tileBtn.addEventListener('click', () => {
|
||||
sound.playTileClick();
|
||||
onTileSelected(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function triggerShuffleAnimation() {
|
||||
const tiles = container.querySelectorAll('.board-tile:not(.locked)');
|
||||
tiles.forEach((tile, index) => {
|
||||
setTimeout(() => {
|
||||
tile.classList.add('flipping');
|
||||
setTimeout(() => tile.classList.remove('flipping'), 400);
|
||||
}, (index % 4) * 40);
|
||||
});
|
||||
}
|
||||
|
||||
return { element: container, render, triggerShuffleAnimation };
|
||||
}
|
||||
35
src/components/Toast.js
Normal file
35
src/components/Toast.js
Normal file
@@ -0,0 +1,35 @@
|
||||
export function createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'toast-container';
|
||||
|
||||
function show({ message, type = 'info', duration = 2500 }) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
let icon = 'ℹ️';
|
||||
if (type === 'success') icon = '✓';
|
||||
if (type === 'quartile') icon = '⭐';
|
||||
if (type === 'warning') icon = '⚠️';
|
||||
if (type === 'error') icon = '✕';
|
||||
|
||||
toast.innerHTML = `
|
||||
<span>${icon}</span>
|
||||
<span>${message}</span>
|
||||
`;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'scale(0.85) translateY(-10px)';
|
||||
toast.style.transition = 'all 0.25s ease';
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 260);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return { element: container, show };
|
||||
}
|
||||
159
src/components/WordBuilder.js
Normal file
159
src/components/WordBuilder.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import { sound } from '../engine/audio.js';
|
||||
|
||||
export function createWordBuilder({ onTileRemoved, onTilesReordered, onSubmitWord, onClearWord, onBackspaceWord }) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'word-builder-wrapper';
|
||||
|
||||
let draggedIndex = null;
|
||||
let touchStartX = 0;
|
||||
let touchMoved = false;
|
||||
|
||||
function render(state) {
|
||||
const hasTiles = state.activeTileIds.length > 0;
|
||||
const tileObjects = state.activeTileIds.map(id => state.boardTiles.find(t => t.id === id)).filter(Boolean);
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="word-builder ${hasTiles ? 'has-tiles' : ''}" id="active-word-builder">
|
||||
<div class="active-tiles-container" id="active-tiles-tray">
|
||||
${
|
||||
hasTiles
|
||||
? tileObjects
|
||||
.map(
|
||||
(tile, idx) => `
|
||||
<div class="active-tile-chip" data-idx="${idx}" draggable="true" title="Tap to remove or drag to reorder">
|
||||
<span>${tile.text}</span>
|
||||
<span class="remove-icon">✕</span>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('')
|
||||
: `<span class="builder-placeholder">Tap tiles below to build a word</span>`
|
||||
}
|
||||
</div>
|
||||
<div class="builder-actions">
|
||||
<button class="btn-icon-tray" id="btn-builder-clear" title="Clear all tiles" ${!hasTiles ? 'style="opacity:0.3;pointer-events:none;"' : ''}>
|
||||
✕
|
||||
</button>
|
||||
<button class="btn-icon-tray" id="btn-builder-backspace" title="Remove last tile" ${!hasTiles ? 'style="opacity:0.3;pointer-events:none;"' : ''}>
|
||||
⌫
|
||||
</button>
|
||||
<button class="btn-submit-tray" id="btn-builder-submit" ${!hasTiles ? 'disabled' : ''}>
|
||||
<span>✓</span>
|
||||
<span>ENTER</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners for active chips
|
||||
const chips = wrapper.querySelectorAll('.active-tile-chip');
|
||||
chips.forEach(chip => {
|
||||
const idx = parseInt(chip.getAttribute('data-idx'), 10);
|
||||
|
||||
// Tap chip to remove (only if not dragged)
|
||||
chip.addEventListener('click', e => {
|
||||
if (!touchMoved) {
|
||||
sound.playTileRemove();
|
||||
onTileRemoved(idx);
|
||||
}
|
||||
});
|
||||
|
||||
// Desktop HTML5 drag and drop
|
||||
chip.addEventListener('dragstart', e => {
|
||||
draggedIndex = idx;
|
||||
chip.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
|
||||
chip.addEventListener('dragend', () => {
|
||||
chip.classList.remove('dragging');
|
||||
chips.forEach(c => c.classList.remove('drag-over'));
|
||||
});
|
||||
|
||||
chip.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
chip.classList.add('drag-over');
|
||||
});
|
||||
|
||||
chip.addEventListener('dragleave', () => {
|
||||
chip.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
chip.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
chip.classList.remove('drag-over');
|
||||
if (draggedIndex !== null && draggedIndex !== idx) {
|
||||
onTilesReordered(draggedIndex, idx);
|
||||
}
|
||||
draggedIndex = null;
|
||||
});
|
||||
|
||||
// Touch events for mobile reordering
|
||||
chip.addEventListener(
|
||||
'touchstart',
|
||||
e => {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchMoved = false;
|
||||
draggedIndex = idx;
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
chip.addEventListener(
|
||||
'touchmove',
|
||||
e => {
|
||||
const currentX = e.touches[0].clientX;
|
||||
if (Math.abs(currentX - touchStartX) > 10) {
|
||||
touchMoved = true;
|
||||
chip.classList.add('dragging');
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
chip.addEventListener('touchend', e => {
|
||||
chip.classList.remove('dragging');
|
||||
if (touchMoved) {
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
// Find which chip the touch ended on
|
||||
const targetEl = document.elementFromPoint(endX, endY);
|
||||
const targetChip = targetEl ? targetEl.closest('.active-tile-chip') : null;
|
||||
if (targetChip) {
|
||||
const targetIdx = parseInt(targetChip.getAttribute('data-idx'), 10);
|
||||
if (!isNaN(targetIdx) && targetIdx !== idx) {
|
||||
onTilesReordered(idx, targetIdx);
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
touchMoved = false;
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
wrapper.querySelector('#btn-builder-clear')?.addEventListener('click', () => {
|
||||
sound.playTileRemove();
|
||||
onClearWord();
|
||||
});
|
||||
|
||||
wrapper.querySelector('#btn-builder-backspace')?.addEventListener('click', () => {
|
||||
sound.playTileRemove();
|
||||
onBackspaceWord();
|
||||
});
|
||||
|
||||
wrapper.querySelector('#btn-builder-submit')?.addEventListener('click', onSubmitWord);
|
||||
}
|
||||
|
||||
function triggerShake() {
|
||||
const builder = wrapper.querySelector('#active-word-builder');
|
||||
if (builder) {
|
||||
builder.classList.remove('invalid-shake');
|
||||
void builder.offsetWidth; // Trigger reflow
|
||||
builder.classList.add('invalid-shake');
|
||||
}
|
||||
}
|
||||
|
||||
return { element: wrapper, render, triggerShake };
|
||||
}
|
||||
173
src/components/WordsDrawer.js
Normal file
173
src/components/WordsDrawer.js
Normal file
@@ -0,0 +1,173 @@
|
||||
export function createWordsDrawer({ onRevealAll }) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
backdrop.id = 'words-drawer-backdrop';
|
||||
|
||||
let currentTab = 'all'; // 'all', 'quartiles', '3-tiles', '2-tiles', '1-tiles'
|
||||
|
||||
function render(state) {
|
||||
if (!state.currentPuzzle) return;
|
||||
|
||||
const foundWordsList = Array.from(state.foundWords.values());
|
||||
const allPuzzleWords = state.currentPuzzle.words;
|
||||
const isRevealed = state.isRevealed;
|
||||
|
||||
// Filter words according to current tab
|
||||
let displayWords = isRevealed ? allPuzzleWords : foundWordsList;
|
||||
|
||||
if (currentTab === 'quartiles') {
|
||||
displayWords = displayWords.filter(w => w.tileCount === 4);
|
||||
} else if (currentTab === '3-tiles') {
|
||||
displayWords = displayWords.filter(w => w.tileCount === 3);
|
||||
} else if (currentTab === '2-tiles') {
|
||||
displayWords = displayWords.filter(w => w.tileCount === 2);
|
||||
} else if (currentTab === '1-tiles') {
|
||||
displayWords = displayWords.filter(w => w.tileCount === 1);
|
||||
}
|
||||
|
||||
// Sort display words: target quartiles first, then points desc, then alphabetical
|
||||
displayWords.sort((a, b) => {
|
||||
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
|
||||
if (b.points !== a.points) return b.points - a.points;
|
||||
return a.word.localeCompare(b.word);
|
||||
});
|
||||
|
||||
const quartilesCount = foundWordsList.filter(w => w.tileCount === 4).length;
|
||||
const threeTilesCount = foundWordsList.filter(w => w.tileCount === 3).length;
|
||||
const twoTilesCount = foundWordsList.filter(w => w.tileCount === 2).length;
|
||||
const oneTilesCount = foundWordsList.filter(w => w.tileCount === 1).length;
|
||||
|
||||
backdrop.innerHTML = `
|
||||
<div class="modal-sheet" id="words-drawer-sheet">
|
||||
<div class="sheet-handle"></div>
|
||||
<div class="sheet-header">
|
||||
<div>
|
||||
<div class="sheet-title">Words Found</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px;">
|
||||
${foundWordsList.length} of ${allPuzzleWords.length} words found (${state.score} pts)
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-close-sheet" id="btn-close-words-drawer">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-tabs">
|
||||
<button class="tab-btn ${currentTab === 'all' ? 'active' : ''}" data-tab="all">
|
||||
All (${foundWordsList.length})
|
||||
</button>
|
||||
<button class="tab-btn ${currentTab === 'quartiles' ? 'active' : ''}" data-tab="quartiles">
|
||||
Quartiles (${quartilesCount}/5)
|
||||
</button>
|
||||
<button class="tab-btn ${currentTab === '3-tiles' ? 'active' : ''}" data-tab="3-tiles">
|
||||
3-Tiles (${threeTilesCount})
|
||||
</button>
|
||||
<button class="tab-btn ${currentTab === '2-tiles' ? 'active' : ''}" data-tab="2-tiles">
|
||||
2-Tiles (${twoTilesCount})
|
||||
</button>
|
||||
<button class="tab-btn ${currentTab === '1-tiles' ? 'active' : ''}" data-tab="1-tiles">
|
||||
1-Tile (${oneTilesCount})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-body">
|
||||
${
|
||||
displayWords.length === 0
|
||||
? `<div style="text-align:center; padding: 32px 16px; color: var(--text-secondary);">
|
||||
<div style="font-size: 2rem; margin-bottom: 8px;">🔍</div>
|
||||
<strong>No words found in this category yet</strong>
|
||||
<div style="font-size: 0.85rem; margin-top: 4px;">Combine tiles on the board to discover valid words!</div>
|
||||
</div>`
|
||||
: displayWords
|
||||
.map(w => {
|
||||
const isFound = state.foundWords.has(w.word.toLowerCase());
|
||||
const isQuartile = w.tileCount === 4;
|
||||
|
||||
return `
|
||||
<div class="word-card ${isQuartile ? 'quartile-card' : ''}" style="${!isFound ? 'opacity: 0.6; border-style: dashed;' : ''}">
|
||||
<div class="word-card-left">
|
||||
<div class="word-card-word">
|
||||
${w.word.toUpperCase()}
|
||||
${isQuartile ? ' ⭐' : ''}
|
||||
${!isFound ? ' <span style="font-size:0.75rem; color:var(--text-tertiary);">(Unfound)</span>' : ''}
|
||||
</div>
|
||||
<div class="word-card-tiles">
|
||||
${w.tileSegments.map(s => `<span class="tile-chip-mini">${s.toUpperCase()}</span>`).join('+')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="word-card-points">+${w.points}</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sheet-footer">
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
${isRevealed ? 'All solutions revealed' : state.getRank().isExpert ? '🏆 Expert reached! You can keep hunting or reveal solutions.' : 'Reach 100 points for Expert rank!'}
|
||||
</div>
|
||||
${
|
||||
!isRevealed
|
||||
? `<button class="btn-reveal-all" id="btn-trigger-reveal">
|
||||
Reveal All Words
|
||||
</button>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
backdrop.querySelector('#btn-close-words-drawer')?.addEventListener('click', () => {
|
||||
close();
|
||||
});
|
||||
|
||||
backdrop.addEventListener('click', e => {
|
||||
if (e.target === backdrop) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
const tabBtns = backdrop.querySelectorAll('.tab-btn');
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
currentTab = btn.getAttribute('data-tab');
|
||||
render(state);
|
||||
});
|
||||
});
|
||||
|
||||
const revealBtn = backdrop.querySelector('#btn-trigger-reveal');
|
||||
if (revealBtn) {
|
||||
let isConfirming = false;
|
||||
let timer = null;
|
||||
revealBtn.addEventListener('click', () => {
|
||||
if (!isConfirming) {
|
||||
isConfirming = true;
|
||||
revealBtn.innerText = 'Tap again to reveal all words';
|
||||
revealBtn.style.background = 'var(--accent-danger)';
|
||||
revealBtn.style.color = '#ffffff';
|
||||
timer = setTimeout(() => {
|
||||
isConfirming = false;
|
||||
if (revealBtn) {
|
||||
revealBtn.innerText = 'Reveal All Words';
|
||||
revealBtn.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
revealBtn.style.color = 'var(--accent-danger)';
|
||||
}
|
||||
}, 3500);
|
||||
} else {
|
||||
clearTimeout(timer);
|
||||
onRevealAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
backdrop.classList.add('open');
|
||||
}
|
||||
|
||||
function close() {
|
||||
backdrop.classList.remove('open');
|
||||
}
|
||||
|
||||
return { element: backdrop, render, open, close };
|
||||
}
|
||||
17
src/data/lexicon.js
Normal file
17
src/data/lexicon.js
Normal file
File diff suppressed because one or more lines are too long
10611
src/data/puzzles.js
Normal file
10611
src/data/puzzles.js
Normal file
File diff suppressed because it is too large
Load Diff
265
src/engine/audio.js
Normal file
265
src/engine/audio.js
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Web Audio API synthesizer for tactile, Apple-grade acoustic feedback
|
||||
* Requires 0 external audio files, operates instantly with zero latency
|
||||
*/
|
||||
class SoundEngine {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.muted = localStorage.getItem('quartiles_sound_muted') === 'true';
|
||||
}
|
||||
|
||||
initContext() {
|
||||
if (!this.ctx) {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
if (AudioCtx) {
|
||||
this.ctx = new AudioCtx();
|
||||
}
|
||||
}
|
||||
if (this.ctx && this.ctx.state === 'suspended') {
|
||||
this.ctx.resume();
|
||||
}
|
||||
}
|
||||
|
||||
isMuted() {
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
setMuted(muted) {
|
||||
this.muted = !!muted;
|
||||
localStorage.setItem('quartiles_sound_muted', this.muted ? 'true' : 'false');
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.setMuted(!this.muted);
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
// Soft tactile click for tile selection
|
||||
playTileClick() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const now = this.ctx.currentTime;
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(620, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(380, now + 0.04);
|
||||
|
||||
gain.gain.setValueAtTime(0.12, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.04);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.045);
|
||||
}
|
||||
|
||||
// Gentle acoustic pop when tile is removed
|
||||
playTileRemove() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const now = this.ctx.currentTime;
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(320, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(540, now + 0.05);
|
||||
|
||||
gain.gain.setValueAtTime(0.1, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.055);
|
||||
}
|
||||
|
||||
// Shuffle swoosh sound
|
||||
playShuffle() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const delay = i * 0.025;
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(280 + i * 70, now + delay);
|
||||
osc.frequency.exponentialRampToValueAtTime(160, now + delay + 0.06);
|
||||
|
||||
gain.gain.setValueAtTime(0.06, now + delay);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.06);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now + delay);
|
||||
osc.stop(now + delay + 0.065);
|
||||
}
|
||||
}
|
||||
|
||||
// Gentle double boop on invalid submission
|
||||
playError() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
[0, 0.09].forEach(delay => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(140, now + delay);
|
||||
osc.frequency.exponentialRampToValueAtTime(110, now + delay + 0.08);
|
||||
|
||||
gain.gain.setValueAtTime(0.08, now + delay);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.08);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now + delay);
|
||||
osc.stop(now + delay + 0.085);
|
||||
});
|
||||
}
|
||||
|
||||
// Cheerful chime on valid word
|
||||
playWordValid(tileCount) {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
const baseFreqs = {
|
||||
1: [523.25, 659.25], // C5, E5
|
||||
2: [523.25, 659.25, 783.99], // C5, E5, G5
|
||||
3: [587.33, 739.99, 880.00, 1046.50] // D5, F#5, A5, C6
|
||||
};
|
||||
|
||||
const freqs = baseFreqs[tileCount] || baseFreqs[2];
|
||||
|
||||
freqs.forEach((freq, idx) => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const delay = idx * 0.045;
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(freq, now + delay);
|
||||
|
||||
gain.gain.setValueAtTime(0.12, now + delay);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.22);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now + delay);
|
||||
osc.stop(now + delay + 0.23);
|
||||
});
|
||||
}
|
||||
|
||||
// Sparkling golden fanfare for 4-tile Quartile
|
||||
playQuartileFound() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
// Major 7th arpeggio: C5, E5, G5, B5, C6
|
||||
const notes = [523.25, 659.25, 783.99, 987.77, 1046.50];
|
||||
|
||||
notes.forEach((freq, idx) => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const delay = idx * 0.055;
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(freq, now + delay);
|
||||
|
||||
gain.gain.setValueAtTime(0.15, now + delay);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.45);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now + delay);
|
||||
osc.stop(now + delay + 0.46);
|
||||
});
|
||||
}
|
||||
|
||||
// Grand celebration fanfare when all 5 Quartiles are discovered (+40 pts bonus!)
|
||||
playBonusCelebration() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
// Fanfare chord progression
|
||||
const chords = [
|
||||
{ delay: 0.0, freqs: [523.25, 659.25, 783.99], dur: 0.2 },
|
||||
{ delay: 0.2, freqs: [587.33, 739.99, 880.00], dur: 0.2 },
|
||||
{ delay: 0.4, freqs: [659.25, 830.61, 987.77], dur: 0.25 },
|
||||
{ delay: 0.7, freqs: [783.99, 987.77, 1174.66, 1567.98], dur: 0.8 }
|
||||
];
|
||||
|
||||
chords.forEach(chord => {
|
||||
chord.freqs.forEach(freq => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const startTime = now + chord.delay;
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(freq, startTime);
|
||||
|
||||
gain.gain.setValueAtTime(0.14, startTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, startTime + chord.dur);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + chord.dur + 0.05);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Uplifting chord for rank progression (e.g. hitting Expert)
|
||||
playRankUp() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
const freqs = [440, 554.37, 659.25, 880];
|
||||
freqs.forEach((freq, idx) => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const delay = idx * 0.08;
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(freq, now + delay);
|
||||
|
||||
gain.gain.setValueAtTime(0.13, now + delay);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.35);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now + delay);
|
||||
osc.stop(now + delay + 0.36);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const sound = new SoundEngine();
|
||||
107
src/engine/confetti.js
Normal file
107
src/engine/confetti.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Lightweight canvas confetti system with multi-colored ribbons, stars and sparkles
|
||||
*/
|
||||
export function fireConfetti({
|
||||
particleCount = 75,
|
||||
spread = 70,
|
||||
origin = { x: 0.5, y: 0.6 },
|
||||
colors = ['#f59e0b', '#10b981', '#6366f1', '#ec4899', '#3b82f6', '#fbbf24']
|
||||
} = {}) {
|
||||
let canvas = document.getElementById('quartiles-confetti-canvas');
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.id = 'quartiles-confetti-canvas';
|
||||
canvas.style.position = 'fixed';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.width = '100vw';
|
||||
canvas.style.height = '100vh';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.zIndex = '9999';
|
||||
document.body.appendChild(canvas);
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = window.innerWidth * dpr;
|
||||
canvas.height = window.innerHeight * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const particles = [];
|
||||
const startX = origin.x * window.innerWidth;
|
||||
const startY = origin.y * window.innerHeight;
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const angle = (Math.PI / 180) * (-90 + (Math.random() - 0.5) * spread * 2);
|
||||
const speed = 7 + Math.random() * 9;
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
const size = Math.random() * 8 + 6;
|
||||
const shape = Math.random() > 0.4 ? 'rect' : 'circle';
|
||||
|
||||
particles.push({
|
||||
x: startX,
|
||||
y: startY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
rotation: Math.random() * 360,
|
||||
vRotation: (Math.random() - 0.5) * 12,
|
||||
scaleX: 1,
|
||||
vScale: (Math.random() - 0.5) * 0.1,
|
||||
color,
|
||||
size,
|
||||
shape,
|
||||
gravity: 0.28,
|
||||
drag: 0.96,
|
||||
opacity: 1,
|
||||
decay: 0.012 + Math.random() * 0.008
|
||||
});
|
||||
}
|
||||
|
||||
let animationFrame;
|
||||
|
||||
function render() {
|
||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
|
||||
|
||||
let activeCount = 0;
|
||||
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i];
|
||||
if (p.opacity <= 0) continue;
|
||||
|
||||
activeCount++;
|
||||
p.vx *= p.drag;
|
||||
p.vy = p.vy * p.drag + p.gravity;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.rotation += p.vRotation;
|
||||
p.scaleX += p.vScale;
|
||||
if (Math.abs(p.scaleX) > 1) p.vScale *= -1;
|
||||
p.opacity -= p.decay;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate((p.rotation * Math.PI) / 180);
|
||||
ctx.scale(p.scaleX, 1);
|
||||
ctx.globalAlpha = Math.max(0, p.opacity);
|
||||
ctx.fillStyle = p.color;
|
||||
|
||||
if (p.shape === 'rect') {
|
||||
ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size / 1.5);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, p.size / 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (activeCount > 0) {
|
||||
animationFrame = requestAnimationFrame(render);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
}
|
||||
69
src/engine/constants.js
Normal file
69
src/engine/constants.js
Normal file
@@ -0,0 +1,69 @@
|
||||
export const POINTS_BY_TILE_COUNT = {
|
||||
1: 1,
|
||||
2: 2,
|
||||
3: 4,
|
||||
4: 8
|
||||
};
|
||||
|
||||
export const QUARTILE_COMPLETION_BONUS = 40;
|
||||
export const EXPERT_RANK_POINTS = 100;
|
||||
|
||||
export const RANK_TIERS = [
|
||||
{ name: 'Novice', minPoints: 0, badge: '🌱' },
|
||||
{ name: 'Apprentice', minPoints: 25, badge: '🛠️' },
|
||||
{ name: 'Builder', minPoints: 50, badge: '📐' },
|
||||
{ name: 'Artisan', minPoints: 70, badge: '🎨' },
|
||||
{ name: 'Wordsmith', minPoints: 85, badge: '✒️' },
|
||||
{ name: 'Expert', minPoints: 100, badge: '🏆' },
|
||||
{ name: 'Perfect', minPoints: 999999, badge: '👑' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the current rank object and progress to the next rank
|
||||
*/
|
||||
export function getRankInfo(currentScore, foundCount, totalCount) {
|
||||
// If player found every word, rank is Perfect!
|
||||
if (totalCount > 0 && foundCount >= totalCount) {
|
||||
return {
|
||||
current: RANK_TIERS[6], // Perfect
|
||||
next: null,
|
||||
progressPercent: 100,
|
||||
pointsToNext: 0,
|
||||
isExpert: true,
|
||||
isPerfect: true
|
||||
};
|
||||
}
|
||||
|
||||
let currentRank = RANK_TIERS[0];
|
||||
let nextRank = RANK_TIERS[1];
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (currentScore >= RANK_TIERS[i].minPoints) {
|
||||
currentRank = RANK_TIERS[i];
|
||||
nextRank = RANK_TIERS[i + 1] || null;
|
||||
}
|
||||
}
|
||||
|
||||
let progressPercent = 0;
|
||||
let pointsToNext = 0;
|
||||
|
||||
if (nextRank && nextRank.minPoints <= EXPERT_RANK_POINTS) {
|
||||
const range = nextRank.minPoints - currentRank.minPoints;
|
||||
const earned = currentScore - currentRank.minPoints;
|
||||
progressPercent = Math.min(100, Math.max(0, Math.round((earned / range) * 100)));
|
||||
pointsToNext = Math.max(0, nextRank.minPoints - currentScore);
|
||||
} else {
|
||||
// Already Expert, heading towards Perfect
|
||||
progressPercent = totalCount > 0 ? Math.min(100, Math.round((foundCount / totalCount) * 100)) : 100;
|
||||
pointsToNext = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
current: currentRank,
|
||||
next: nextRank,
|
||||
progressPercent: isNaN(progressPercent) ? 0 : progressPercent,
|
||||
pointsToNext,
|
||||
isExpert: currentScore >= EXPERT_RANK_POINTS,
|
||||
isPerfect: false
|
||||
};
|
||||
}
|
||||
114
src/engine/generator.js
Normal file
114
src/engine/generator.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { TARGET_WORDS_POOL } from '../data/puzzles.js';
|
||||
import { solveBoard } from './solver.js';
|
||||
|
||||
/**
|
||||
* Generates a random valid Quartiles puzzle from the target word pool
|
||||
*/
|
||||
export function generateRandomPuzzle(seed) {
|
||||
const pool = [...TARGET_WORDS_POOL];
|
||||
// Shuffle pool
|
||||
for (let i = pool.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||
}
|
||||
|
||||
// Pick 5 words that don't repeat letters/roots too excessively
|
||||
const selectedWords = pool.slice(0, 5);
|
||||
const quartets = selectedWords.map(w => w.tiles);
|
||||
|
||||
const solved = solveBoard(quartets);
|
||||
|
||||
const id = 'custom-' + Date.now();
|
||||
const theme = selectedWords[0].theme ? `${selectedWords[0].theme.toUpperCase()} & MORE` : 'Unlimited Challenge';
|
||||
|
||||
return {
|
||||
id,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
title: `Unlimited Puzzle #${Math.floor(Math.random() * 9000 + 1000)}`,
|
||||
theme,
|
||||
difficulty: 'Random',
|
||||
quartets,
|
||||
tiles: solved.tiles,
|
||||
targetQuartiles: solved.targetQuartiles,
|
||||
quartileDetails: selectedWords.map(w => ({
|
||||
word: w.word,
|
||||
tiles: w.tiles,
|
||||
clue: w.clue || 'A 4-part Quartile word'
|
||||
})),
|
||||
words: solved.words,
|
||||
wordCount: solved.wordCount,
|
||||
maxPossibleScore: solved.maxPossibleScore
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and creates a custom puzzle from user-provided quartets
|
||||
* Expects array of 5 quartets: [[t1,t2,t3,t4], [t5,t6,t7,t8], ...]
|
||||
*/
|
||||
export function createCustomPuzzle(quartets, customTitle = 'Custom Community Puzzle') {
|
||||
if (!Array.isArray(quartets) || quartets.length !== 5) {
|
||||
throw new Error('Puzzle must have exactly 5 quartets (4-tile words)');
|
||||
}
|
||||
|
||||
for (let i = 0; i < quartets.length; i++) {
|
||||
const q = quartets[i];
|
||||
if (!Array.isArray(q) || q.length !== 4) {
|
||||
throw new Error(`Quartet #${i + 1} must contain exactly 4 tiles`);
|
||||
}
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const tile = q[j].toLowerCase().trim();
|
||||
if (!/^[a-z]{2,4}$/.test(tile)) {
|
||||
throw new Error(`Tile "${tile}" in quartet #${i + 1} must be 2 to 4 letters`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedQuartets = quartets.map(q => q.map(t => t.toLowerCase().trim()));
|
||||
const solved = solveBoard(normalizedQuartets);
|
||||
|
||||
return {
|
||||
id: 'user-' + btoa(normalizedQuartets.map(q => q.join('-')).join('|')).slice(0, 16),
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
title: customTitle,
|
||||
theme: 'Community Creation',
|
||||
difficulty: 'Custom',
|
||||
quartets: normalizedQuartets,
|
||||
tiles: solved.tiles,
|
||||
targetQuartiles: solved.targetQuartiles,
|
||||
quartileDetails: normalizedQuartets.map(q => ({
|
||||
word: q.join(''),
|
||||
tiles: q,
|
||||
clue: 'Player created Quartile'
|
||||
})),
|
||||
words: solved.words,
|
||||
wordCount: solved.wordCount,
|
||||
maxPossibleScore: solved.maxPossibleScore
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes custom quartets to URL safe hash string
|
||||
*/
|
||||
export function encodePuzzleToHash(quartets, title) {
|
||||
const payload = {
|
||||
t: title || 'Custom Puzzle',
|
||||
q: quartets
|
||||
};
|
||||
return encodeURIComponent(btoa(JSON.stringify(payload)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes URL safe hash string to custom puzzle
|
||||
*/
|
||||
export function decodePuzzleFromHash(hash) {
|
||||
try {
|
||||
const json = atob(decodeURIComponent(hash));
|
||||
const payload = JSON.parse(json);
|
||||
if (payload && payload.q) {
|
||||
return createCustomPuzzle(payload.q, payload.t);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to decode puzzle from hash:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
79
src/engine/solver.js
Normal file
79
src/engine/solver.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { DICTIONARY_SET, PREFIX_SET } from '../data/lexicon.js';
|
||||
import { POINTS_BY_TILE_COUNT, QUARTILE_COMPLETION_BONUS, EXPERT_RANK_POINTS, RANK_TIERS, getRankInfo } from './constants.js';
|
||||
|
||||
export { POINTS_BY_TILE_COUNT, QUARTILE_COMPLETION_BONUS, EXPERT_RANK_POINTS, RANK_TIERS, getRankInfo };
|
||||
|
||||
/**
|
||||
* Solves a 20-tile board (5 quartets)
|
||||
* Returns all constructible valid words, point values, and tile paths
|
||||
*/
|
||||
export function solveBoard(quartets) {
|
||||
const tiles = quartets.flat();
|
||||
const foundMap = new Map();
|
||||
|
||||
function search(prefix, usedIndices) {
|
||||
if (prefix.length > 0) {
|
||||
const checkLen = Math.min(prefix.length, 6);
|
||||
if (!PREFIX_SET.has(prefix.slice(0, checkLen))) return;
|
||||
}
|
||||
|
||||
if (prefix.length >= 2 && DICTIONARY_SET.has(prefix)) {
|
||||
if (!foundMap.has(prefix)) {
|
||||
foundMap.set(prefix, [...usedIndices]);
|
||||
}
|
||||
}
|
||||
if (usedIndices.length >= 4) return;
|
||||
|
||||
for (let i = 0; i < tiles.length; i++) {
|
||||
if (usedIndices.includes(i)) continue;
|
||||
usedIndices.push(i);
|
||||
search(prefix + tiles[i], usedIndices);
|
||||
usedIndices.pop();
|
||||
}
|
||||
}
|
||||
|
||||
search('', []);
|
||||
|
||||
const quartilesList = quartets.map(q => q.join('').toLowerCase());
|
||||
const quartileSet = new Set(quartilesList);
|
||||
|
||||
const words = [];
|
||||
let totalBaseScore = 0;
|
||||
|
||||
for (const [word, path] of foundMap.entries()) {
|
||||
const tileCount = path.length;
|
||||
const points = POINTS_BY_TILE_COUNT[tileCount] || 1;
|
||||
const isTargetQuartile = quartileSet.has(word);
|
||||
const isQuartile = tileCount === 4;
|
||||
|
||||
totalBaseScore += points;
|
||||
|
||||
words.push({
|
||||
word,
|
||||
points,
|
||||
tileCount,
|
||||
tileIndices: path,
|
||||
tileSegments: path.map(idx => tiles[idx]),
|
||||
isTargetQuartile,
|
||||
isQuartile
|
||||
});
|
||||
}
|
||||
|
||||
// Sort words: Quartiles first, then by points descending, then alphabetically
|
||||
words.sort((a, b) => {
|
||||
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
|
||||
if (b.points !== a.points) return b.points - a.points;
|
||||
return a.word.localeCompare(b.word);
|
||||
});
|
||||
|
||||
return {
|
||||
tiles,
|
||||
quartets,
|
||||
targetQuartiles: quartilesList,
|
||||
words,
|
||||
wordCount: words.length,
|
||||
quartileCount: 5,
|
||||
totalBaseScore,
|
||||
maxPossibleScore: totalBaseScore + QUARTILE_COMPLETION_BONUS
|
||||
};
|
||||
}
|
||||
326
src/main.js
Normal file
326
src/main.js
Normal file
@@ -0,0 +1,326 @@
|
||||
import './styles/main.css';
|
||||
import './styles/board.css';
|
||||
import './styles/components.css';
|
||||
|
||||
import { gameState } from './state/game-state.js';
|
||||
import { CURATED_PUZZLES } from './data/puzzles.js';
|
||||
import { sound } from './engine/audio.js';
|
||||
import { fireConfetti } from './engine/confetti.js';
|
||||
|
||||
import { createHeader } from './components/Header.js';
|
||||
import { createScoreRankBar } from './components/ScoreRankBar.js';
|
||||
import { createWordBuilder } from './components/WordBuilder.js';
|
||||
import { createTileGrid } from './components/TileGrid.js';
|
||||
import { createActionBar } from './components/ActionBar.js';
|
||||
import { createWordsDrawer } from './components/WordsDrawer.js';
|
||||
import { createModals } from './components/Modals.js';
|
||||
import { createToastContainer } from './components/Toast.js';
|
||||
|
||||
// PWA Service Worker Registration
|
||||
if ('serviceWorker' in navigator && window.location.protocol.startsWith('http')) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then(reg => {
|
||||
console.log('Quartiles PWA Service Worker registered:', reg.scope);
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('PWA Service Worker registration error:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function initApp() {
|
||||
const root = document.getElementById('app');
|
||||
if (!root) return;
|
||||
|
||||
// Initialize theme (dark/light)
|
||||
const savedTheme = localStorage.getItem('quartiles_theme');
|
||||
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const initialTheme = savedTheme || (systemPrefersDark ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
||||
|
||||
// App Layout Container
|
||||
const appContainer = document.createElement('div');
|
||||
appContainer.className = 'app-container';
|
||||
root.appendChild(appContainer);
|
||||
|
||||
// Create Toast System
|
||||
const toastContainer = createToastContainer();
|
||||
document.body.appendChild(toastContainer.element);
|
||||
|
||||
gameState.subscribeToast(data => {
|
||||
toastContainer.show(data);
|
||||
});
|
||||
|
||||
// Create Modals System
|
||||
const modals = createModals({
|
||||
onSelectPuzzle: puzzleId => {
|
||||
gameState.loadPuzzle(puzzleId);
|
||||
},
|
||||
onCustomPuzzleCreated: customPuzzle => {
|
||||
gameState.loadPuzzle(customPuzzle);
|
||||
gameState.notifyToast('Custom puzzle loaded! Share your link with friends.', 'success');
|
||||
},
|
||||
onResetPuzzle: () => {
|
||||
if (gameState.currentPuzzle) {
|
||||
localStorage.removeItem(`quartiles_save_${gameState.currentPuzzle.id}`);
|
||||
gameState.loadPuzzle(gameState.currentPuzzle);
|
||||
gameState.notifyToast('Progress reset', 'info');
|
||||
}
|
||||
}
|
||||
});
|
||||
document.body.appendChild(modals.element);
|
||||
|
||||
// Create Words Found Drawer
|
||||
const wordsDrawer = createWordsDrawer({
|
||||
onRevealAll: () => {
|
||||
gameState.revealAllWords();
|
||||
gameState.notifyToast('All words revealed!', 'info');
|
||||
}
|
||||
});
|
||||
document.body.appendChild(wordsDrawer.element);
|
||||
|
||||
// Create Header Component
|
||||
const header = createHeader({
|
||||
onOpenCalendar: () => modals.open('calendar', gameState),
|
||||
onOpenHowToPlay: () => modals.open('help', gameState),
|
||||
onOpenStats: () => modals.open('stats', gameState),
|
||||
onOpenCustomBuilder: () => modals.open('builder', gameState),
|
||||
onOpenSettings: () => modals.open('settings', gameState),
|
||||
onNewRandomPuzzle: async () => {
|
||||
try {
|
||||
const { generateRandomPuzzle } = await import('./engine/generator.js');
|
||||
const randomPuzzle = generateRandomPuzzle();
|
||||
gameState.loadPuzzle(randomPuzzle);
|
||||
gameState.notifyToast('Loaded fresh unlimited puzzle!', 'success');
|
||||
fireConfetti({ particleCount: 30, spread: 50 });
|
||||
} catch (err) {
|
||||
console.error('Failed to generate puzzle:', err);
|
||||
gameState.notifyToast('Failed to generate puzzle', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
appContainer.appendChild(header.element);
|
||||
|
||||
// Create Score & Rank Bar
|
||||
const scoreRankBar = createScoreRankBar({
|
||||
onOpenWordsDrawer: () => {
|
||||
wordsDrawer.open();
|
||||
}
|
||||
});
|
||||
appContainer.appendChild(scoreRankBar.element);
|
||||
|
||||
// Create Word Construction Tray
|
||||
const wordBuilder = createWordBuilder({
|
||||
onTileRemoved: index => {
|
||||
gameState.removeActiveTile(index);
|
||||
},
|
||||
onTilesReordered: (from, to) => {
|
||||
gameState.reorderActiveTiles(from, to);
|
||||
},
|
||||
onSubmitWord: () => {
|
||||
handleWordSubmission();
|
||||
},
|
||||
onClearWord: () => {
|
||||
gameState.clearActive();
|
||||
},
|
||||
onBackspaceWord: () => {
|
||||
gameState.backspace();
|
||||
}
|
||||
});
|
||||
appContainer.appendChild(wordBuilder.element);
|
||||
|
||||
// Create 4x5 Board Grid
|
||||
const tileGrid = createTileGrid({
|
||||
onTileSelected: tileId => {
|
||||
gameState.onTileClicked(tileId);
|
||||
}
|
||||
});
|
||||
appContainer.appendChild(tileGrid.element);
|
||||
|
||||
// Create Action Bar (Shuffle, Sort, Lock, Hint)
|
||||
const actionBar = createActionBar({
|
||||
onShuffle: () => {
|
||||
tileGrid.triggerShuffleAnimation();
|
||||
gameState.shuffle();
|
||||
},
|
||||
onSortAlpha: () => {
|
||||
tileGrid.triggerShuffleAnimation();
|
||||
gameState.sortAlphabetical();
|
||||
},
|
||||
onToggleLock: () => {
|
||||
gameState.toggleLockQuartiles();
|
||||
},
|
||||
onGetHint: () => {
|
||||
const hint = gameState.getHint();
|
||||
if (!hint) {
|
||||
gameState.notifyToast('You have found every single word!', 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hint.type === 'quartile') {
|
||||
gameState.notifyToast(`💡 Quartile Clue: "${hint.clue}" (Starts with "${hint.firstTile}")`, 'warning', 4000);
|
||||
} else {
|
||||
gameState.notifyToast(`💡 Word Hint: ${hint.tileCount}-tile word starting with "${hint.firstTile}" (+${hint.points} pts)`, 'info', 3500);
|
||||
}
|
||||
}
|
||||
});
|
||||
appContainer.appendChild(actionBar.element);
|
||||
|
||||
// Word submission handler
|
||||
function handleWordSubmission() {
|
||||
const prevRank = gameState.getRank();
|
||||
const result = gameState.submitWord();
|
||||
|
||||
if (!result.success) {
|
||||
sound.playError();
|
||||
wordBuilder.triggerShake();
|
||||
return;
|
||||
}
|
||||
|
||||
// Success!
|
||||
if (result.bonusUnlocked) {
|
||||
// 5/5 Quartiles bonus!
|
||||
sound.playBonusCelebration();
|
||||
gameState.notifyToast(`🎉 5/5 QUARTILES BONUS! +40 POINTS!`, 'quartile', 4500);
|
||||
fireConfetti({ particleCount: 160, spread: 100 });
|
||||
} else if (result.isQuartile) {
|
||||
// Quartile found!
|
||||
sound.playQuartileFound();
|
||||
gameState.notifyToast(`+${result.points} pts! QUARTILE! ⭐`, 'quartile', 3000);
|
||||
fireConfetti({ particleCount: 65, spread: 65 });
|
||||
} else {
|
||||
// Standard word
|
||||
sound.playWordValid(result.points >= 4 ? 3 : result.points);
|
||||
gameState.notifyToast(`+${result.points} pts: ${result.word.toUpperCase()}`, 'success', 2200);
|
||||
}
|
||||
|
||||
// Check rank progression
|
||||
const currentRank = gameState.getRank();
|
||||
if (!prevRank.isExpert && currentRank.isExpert) {
|
||||
setTimeout(() => {
|
||||
sound.playRankUp();
|
||||
gameState.notifyToast('🏆 EXPERT RANK ACHIEVED! Outstanding!', 'quartile', 4000);
|
||||
fireConfetti({ particleCount: 120, spread: 90 });
|
||||
}, 700);
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard typing buffer for intelligent physical keyboard support
|
||||
let typingBuffer = '';
|
||||
let typingClearTimer = null;
|
||||
|
||||
// Keyboard controls
|
||||
window.addEventListener('keydown', e => {
|
||||
// If inside an input field, do not hijack
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleWordSubmission();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
sound.playTileRemove();
|
||||
gameState.backspace();
|
||||
typingBuffer = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
sound.playTileRemove();
|
||||
gameState.clearActive();
|
||||
typingBuffer = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
sound.playShuffle();
|
||||
tileGrid.triggerShuffleAnimation();
|
||||
gameState.shuffle();
|
||||
typingBuffer = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// A-Z letter typing to select board tiles
|
||||
if (/^[a-zA-Z]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const letter = e.key.toLowerCase();
|
||||
typingBuffer += letter;
|
||||
|
||||
clearTimeout(typingClearTimer);
|
||||
typingClearTimer = setTimeout(() => {
|
||||
typingBuffer = '';
|
||||
}, 1500);
|
||||
|
||||
const activeSet = new Set(gameState.activeTileIds);
|
||||
const availableTiles = gameState.boardTiles.filter(t => !activeSet.has(t.id));
|
||||
|
||||
// 1. Exact match of accumulated typing buffer
|
||||
const exactMatch = availableTiles.find(t => t.text.toLowerCase() === typingBuffer);
|
||||
if (exactMatch) {
|
||||
sound.playTileClick();
|
||||
gameState.onTileClicked(exactMatch.id);
|
||||
typingBuffer = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check if typingBuffer is a prefix of any available tile
|
||||
const hasPrefix = availableTiles.some(t => t.text.toLowerCase().startsWith(typingBuffer));
|
||||
if (!hasPrefix) {
|
||||
// Reset to just the newly typed letter and re-check
|
||||
typingBuffer = letter;
|
||||
const singleExact = availableTiles.find(t => t.text.toLowerCase() === letter);
|
||||
if (singleExact) {
|
||||
sound.playTileClick();
|
||||
gameState.onTileClicked(singleExact.id);
|
||||
typingBuffer = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe components to state changes
|
||||
gameState.subscribe(state => {
|
||||
header.render(state);
|
||||
scoreRankBar.render(state);
|
||||
wordBuilder.render(state);
|
||||
tileGrid.render(state);
|
||||
actionBar.render(state);
|
||||
wordsDrawer.render(state);
|
||||
modals.render(state);
|
||||
});
|
||||
|
||||
// Check URL hash for shared custom puzzle
|
||||
const hash = window.location.hash;
|
||||
if (hash && hash.includes('puzzle=')) {
|
||||
try {
|
||||
const rawHash = hash.split('puzzle=')[1];
|
||||
const { decodePuzzleFromHash } = await import('./engine/generator.js');
|
||||
const decoded = decodePuzzleFromHash(rawHash);
|
||||
if (decoded) {
|
||||
gameState.loadPuzzle(decoded);
|
||||
gameState.notifyToast(`Loaded "${decoded.title}"`, 'success');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load shared puzzle from hash:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Load today's puzzle or default
|
||||
const todayStr = gameState.getTodayDateString();
|
||||
const todayPuzzle = CURATED_PUZZLES.find(p => p.date === todayStr) || CURATED_PUZZLES[0];
|
||||
gameState.loadPuzzle(todayPuzzle);
|
||||
}
|
||||
|
||||
// Start application when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
} else {
|
||||
initApp();
|
||||
}
|
||||
485
src/state/game-state.js
Normal file
485
src/state/game-state.js
Normal file
@@ -0,0 +1,485 @@
|
||||
import { CURATED_PUZZLES } from '../data/puzzles.js';
|
||||
import { getRankInfo, QUARTILE_COMPLETION_BONUS } from '../engine/constants.js';
|
||||
|
||||
class GameStateManager {
|
||||
constructor() {
|
||||
this.currentPuzzle = null;
|
||||
this.boardTiles = []; // Array of { id, text, isQuartileSolved, isLocked }
|
||||
this.activeTileIds = []; // Array of tile IDs in construction tray
|
||||
this.foundWords = new Map(); // word -> wordData
|
||||
this.score = 0;
|
||||
this.quartilesBonusAwarded = false;
|
||||
this.lockQuartiles = localStorage.getItem('quartiles_lock_setting') !== 'false';
|
||||
this.listeners = new Set();
|
||||
this.toastListeners = new Set();
|
||||
this.isRevealed = false;
|
||||
this.hintIndex = 0;
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
notify() {
|
||||
for (const listener of this.listeners) {
|
||||
listener(this);
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToast(listener) {
|
||||
this.toastListeners.add(listener);
|
||||
return () => this.toastListeners.delete(listener);
|
||||
}
|
||||
|
||||
notifyToast(message, type = 'info', duration = 2500) {
|
||||
for (const listener of this.toastListeners) {
|
||||
listener({ message, type, duration });
|
||||
}
|
||||
}
|
||||
|
||||
// Load a puzzle (by object or find by date/id)
|
||||
loadPuzzle(puzzleOrId) {
|
||||
let puzzle = puzzleOrId;
|
||||
if (typeof puzzleOrId === 'string') {
|
||||
puzzle = CURATED_PUZZLES.find(p => p.id === puzzleOrId || p.date === puzzleOrId) || CURATED_PUZZLES[0];
|
||||
}
|
||||
|
||||
this.currentPuzzle = puzzle;
|
||||
this.activeTileIds = [];
|
||||
this.foundWords = new Map();
|
||||
this.score = 0;
|
||||
this.quartilesBonusAwarded = false;
|
||||
this.isRevealed = false;
|
||||
this.hintIndex = 0;
|
||||
|
||||
// Initialize board tiles from the 20 puzzle tiles
|
||||
this.boardTiles = puzzle.tiles.map((text, id) => ({
|
||||
id,
|
||||
text: text.toUpperCase(),
|
||||
originalText: text,
|
||||
isQuartileSolved: false,
|
||||
isLocked: false
|
||||
}));
|
||||
|
||||
// Restore saved state from LocalStorage if available
|
||||
this.restoreProgress();
|
||||
|
||||
// Organize board tiles according to lock state
|
||||
this.reorganizeBoard();
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// Get current date string in YYYY-MM-DD
|
||||
getTodayDateString() {
|
||||
const today = new Date();
|
||||
const y = today.getFullYear();
|
||||
const m = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(today.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
// Save current puzzle progress to LocalStorage
|
||||
saveProgress() {
|
||||
if (!this.currentPuzzle) return;
|
||||
const saveKey = `quartiles_save_${this.currentPuzzle.id}`;
|
||||
const payload = {
|
||||
foundWords: Array.from(this.foundWords.entries()),
|
||||
score: this.score,
|
||||
bonus: this.quartilesBonusAwarded,
|
||||
tileOrder: this.boardTiles.map(t => t.id)
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(saveKey, JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
console.warn('LocalStorage save failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore puzzle progress
|
||||
restoreProgress() {
|
||||
if (!this.currentPuzzle) return;
|
||||
const saveKey = `quartiles_save_${this.currentPuzzle.id}`;
|
||||
try {
|
||||
const saved = localStorage.getItem(saveKey);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed && Array.isArray(parsed.foundWords)) {
|
||||
this.foundWords = new Map(parsed.foundWords);
|
||||
this.score = parsed.score || 0;
|
||||
this.quartilesBonusAwarded = !!parsed.bonus;
|
||||
|
||||
// Re-mark solved quartiles
|
||||
this.updateSolvedQuartileTiles();
|
||||
|
||||
// Restore tile order if saved
|
||||
if (Array.isArray(parsed.tileOrder) && parsed.tileOrder.length === 20) {
|
||||
const tileMap = new Map(this.boardTiles.map(t => [t.id, t]));
|
||||
const reordered = parsed.tileOrder.map(id => tileMap.get(id)).filter(Boolean);
|
||||
if (reordered.length === 20) {
|
||||
this.boardTiles = reordered;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to restore progress:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a tile belongs to an already found Quartile
|
||||
updateSolvedQuartileTiles() {
|
||||
const solvedQuartileWords = new Set();
|
||||
for (const [w, data] of this.foundWords.entries()) {
|
||||
if (data.isTargetQuartile) {
|
||||
solvedQuartileWords.add(w.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// For each target quartet, check if solved
|
||||
this.boardTiles.forEach(tile => {
|
||||
tile.isQuartileSolved = false;
|
||||
tile.isLocked = false;
|
||||
});
|
||||
|
||||
for (let qIdx = 0; qIdx < this.currentPuzzle.quartets.length; qIdx++) {
|
||||
const quartet = this.currentPuzzle.quartets[qIdx];
|
||||
const word = quartet.join('').toLowerCase();
|
||||
if (solvedQuartileWords.has(word)) {
|
||||
// Mark tiles belonging to this quartet
|
||||
const startTileIndex = qIdx * 4;
|
||||
for (let i = startTileIndex; i < startTileIndex + 4; i++) {
|
||||
const tile = this.boardTiles.find(t => t.id === i);
|
||||
if (tile) {
|
||||
tile.isQuartileSolved = true;
|
||||
if (this.lockQuartiles) {
|
||||
tile.isLocked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reorganizes the board: locked solved quartiles go to top rows
|
||||
reorganizeBoard() {
|
||||
this.updateSolvedQuartileTiles();
|
||||
|
||||
if (this.lockQuartiles) {
|
||||
// Group locked solved quartiles first in neat rows
|
||||
const lockedTiles = [];
|
||||
for (let qIdx = 0; qIdx < this.currentPuzzle.quartets.length; qIdx++) {
|
||||
const quartet = this.currentPuzzle.quartets[qIdx];
|
||||
const word = quartet.join('').toLowerCase();
|
||||
if (this.foundWords.has(word)) {
|
||||
const start = qIdx * 4;
|
||||
for (let i = start; i < start + 4; i++) {
|
||||
const t = this.boardTiles.find(tile => tile.id === i);
|
||||
if (t) lockedTiles.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lockedSet = new Set(lockedTiles.map(t => t.id));
|
||||
const unlockedTiles = this.boardTiles.filter(t => !lockedSet.has(t.id));
|
||||
|
||||
this.boardTiles = [...lockedTiles, ...unlockedTiles];
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle lock quartiles setting
|
||||
toggleLockQuartiles() {
|
||||
this.lockQuartiles = !this.lockQuartiles;
|
||||
localStorage.setItem('quartiles_lock_setting', this.lockQuartiles ? 'true' : 'false');
|
||||
this.reorganizeBoard();
|
||||
this.notify();
|
||||
this.notifyToast(
|
||||
this.lockQuartiles ? 'Quartiles locked at top' : 'Quartiles unlocked for free shuffle',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
// Tile clicked on board: add to tray or remove if already in tray
|
||||
onTileClicked(tileId) {
|
||||
const existingIndex = this.activeTileIds.indexOf(tileId);
|
||||
if (existingIndex !== -1) {
|
||||
// Already selected, remove it
|
||||
this.removeActiveTile(existingIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeTileIds.length >= 4) {
|
||||
this.notifyToast('Maximum 4 tiles per word', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeTileIds.push(tileId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// Remove tile from active tray by index
|
||||
removeActiveTile(index) {
|
||||
if (index >= 0 && index < this.activeTileIds.length) {
|
||||
this.activeTileIds.splice(index, 1);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder tiles in active tray (e.g. from drag & drop)
|
||||
reorderActiveTiles(fromIndex, toIndex) {
|
||||
if (
|
||||
fromIndex >= 0 &&
|
||||
fromIndex < this.activeTileIds.length &&
|
||||
toIndex >= 0 &&
|
||||
toIndex < this.activeTileIds.length &&
|
||||
fromIndex !== toIndex
|
||||
) {
|
||||
const [item] = this.activeTileIds.splice(fromIndex, 1);
|
||||
this.activeTileIds.splice(toIndex, 0, item);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace: removes last tile in tray
|
||||
backspace() {
|
||||
if (this.activeTileIds.length > 0) {
|
||||
this.activeTileIds.pop();
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all tiles in tray
|
||||
clearActive() {
|
||||
if (this.activeTileIds.length > 0) {
|
||||
this.activeTileIds = [];
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle unlocked tiles
|
||||
shuffle() {
|
||||
const lockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => t.isLocked) : [];
|
||||
const unlockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => !t.isLocked) : [...this.boardTiles];
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = unlockedTiles.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[unlockedTiles[i], unlockedTiles[j]] = [unlockedTiles[j], unlockedTiles[i]];
|
||||
}
|
||||
|
||||
this.boardTiles = [...lockedTiles, ...unlockedTiles];
|
||||
this.saveProgress();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// Sort unlocked tiles alphabetically
|
||||
sortAlphabetical() {
|
||||
const lockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => t.isLocked) : [];
|
||||
const unlockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => !t.isLocked) : [...this.boardTiles];
|
||||
|
||||
unlockedTiles.sort((a, b) => a.text.localeCompare(b.text));
|
||||
|
||||
this.boardTiles = [...lockedTiles, ...unlockedTiles];
|
||||
this.saveProgress();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// Submit current word in active construction tray
|
||||
submitWord() {
|
||||
if (this.activeTileIds.length === 0) {
|
||||
this.notifyToast('Tap tiles to form a word', 'info');
|
||||
return { success: false, reason: 'empty' };
|
||||
}
|
||||
|
||||
const constructedWord = this.activeTileIds
|
||||
.map(id => {
|
||||
const tile = this.boardTiles.find(t => t.id === id);
|
||||
return tile ? tile.text.toLowerCase() : '';
|
||||
})
|
||||
.join('');
|
||||
|
||||
// Check if already found
|
||||
if (this.foundWords.has(constructedWord)) {
|
||||
this.notifyToast(`"${constructedWord.toUpperCase()}" already found!`, 'warning');
|
||||
this.activeTileIds = [];
|
||||
this.notify();
|
||||
return { success: false, reason: 'duplicate' };
|
||||
}
|
||||
|
||||
// Look up in current puzzle's valid words list
|
||||
const match = this.currentPuzzle.words.find(w => w.word.toLowerCase() === constructedWord);
|
||||
|
||||
if (!match) {
|
||||
this.notifyToast(`"${constructedWord.toUpperCase()}" is not in the word list`, 'error');
|
||||
return { success: false, reason: 'invalid' };
|
||||
}
|
||||
|
||||
// Valid word!
|
||||
const tileSegments = this.activeTileIds.map(id => {
|
||||
const tile = this.boardTiles.find(t => t.id === id);
|
||||
return tile ? tile.text : '';
|
||||
});
|
||||
const tileCount = this.activeTileIds.length;
|
||||
const isTargetQuartile = this.currentPuzzle.targetQuartiles.some(t => t.toLowerCase() === constructedWord);
|
||||
const isQuartile = tileCount === 4;
|
||||
|
||||
const wordEntry = {
|
||||
word: constructedWord,
|
||||
points: match.points,
|
||||
tileCount,
|
||||
tileSegments,
|
||||
isTargetQuartile,
|
||||
isQuartile,
|
||||
foundAt: Date.now()
|
||||
};
|
||||
|
||||
this.foundWords.set(constructedWord, wordEntry);
|
||||
this.score += match.points;
|
||||
|
||||
// Check if all 5 target quartiles are solved
|
||||
let quartilesCount = 0;
|
||||
for (const target of this.currentPuzzle.targetQuartiles) {
|
||||
if (this.foundWords.has(target.toLowerCase())) {
|
||||
quartilesCount++;
|
||||
}
|
||||
}
|
||||
|
||||
let bonusUnlocked = false;
|
||||
if (quartilesCount === 5 && !this.quartilesBonusAwarded) {
|
||||
this.quartilesBonusAwarded = true;
|
||||
this.score += QUARTILE_COMPLETION_BONUS;
|
||||
bonusUnlocked = true;
|
||||
}
|
||||
|
||||
// Clear active tray
|
||||
this.activeTileIds = [];
|
||||
|
||||
// Reorganize board if quartiles locked
|
||||
this.reorganizeBoard();
|
||||
|
||||
// Save progress and update player stats
|
||||
this.saveProgress();
|
||||
this.updateStats();
|
||||
|
||||
this.notify();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
word: constructedWord,
|
||||
points: match.points,
|
||||
isQuartile,
|
||||
isTargetQuartile,
|
||||
quartilesFound: quartilesCount,
|
||||
bonusUnlocked
|
||||
};
|
||||
}
|
||||
|
||||
// Get target quartiles solved count
|
||||
getTargetQuartilesSolvedCount() {
|
||||
if (!this.currentPuzzle) return 0;
|
||||
let count = 0;
|
||||
for (const q of this.currentPuzzle.targetQuartiles) {
|
||||
if (this.foundWords.has(q.toLowerCase())) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Get rank data
|
||||
getRank() {
|
||||
return getRankInfo(
|
||||
this.score,
|
||||
this.foundWords.size,
|
||||
this.currentPuzzle ? this.currentPuzzle.wordCount : 0
|
||||
);
|
||||
}
|
||||
|
||||
// Progressive hint system
|
||||
getHint() {
|
||||
if (!this.currentPuzzle) return null;
|
||||
|
||||
// 1. Prioritize unfound target Quartiles
|
||||
const unfoundQuartiles = this.currentPuzzle.quartileDetails.filter(
|
||||
qd => !this.foundWords.has(qd.word.toLowerCase())
|
||||
);
|
||||
|
||||
if (unfoundQuartiles.length > 0) {
|
||||
const target = unfoundQuartiles[this.hintIndex % unfoundQuartiles.length];
|
||||
this.hintIndex++;
|
||||
return {
|
||||
type: 'quartile',
|
||||
word: target.word,
|
||||
firstTile: target.tiles[0].toUpperCase(),
|
||||
clue: target.clue || 'A 4-part Quartile word',
|
||||
remainingQuartiles: unfoundQuartiles.length
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Unfound other words
|
||||
const unfoundWords = this.currentPuzzle.words.filter(
|
||||
w => !this.foundWords.has(w.word.toLowerCase())
|
||||
);
|
||||
|
||||
if (unfoundWords.length > 0) {
|
||||
const sample = unfoundWords[Math.floor(Math.random() * unfoundWords.length)];
|
||||
return {
|
||||
type: 'word',
|
||||
tileCount: sample.tileCount,
|
||||
firstTile: sample.tileSegments[0].toUpperCase(),
|
||||
points: sample.points,
|
||||
remainingWords: unfoundWords.length
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reveal all words (when expert reached or explicitly requested)
|
||||
revealAllWords() {
|
||||
this.isRevealed = true;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// Update persistent stats in LocalStorage
|
||||
updateStats() {
|
||||
try {
|
||||
const statsKey = 'quartiles_player_stats';
|
||||
const stats = JSON.parse(localStorage.getItem(statsKey) || '{}');
|
||||
|
||||
stats.totalWordsFound = (stats.totalWordsFound || 0) + 1;
|
||||
const rank = this.getRank();
|
||||
if (rank.isExpert) {
|
||||
stats.expertPuzzles = stats.expertPuzzles || {};
|
||||
stats.expertPuzzles[this.currentPuzzle.id] = true;
|
||||
}
|
||||
if (rank.isPerfect) {
|
||||
stats.perfectPuzzles = stats.perfectPuzzles || {};
|
||||
stats.perfectPuzzles[this.currentPuzzle.id] = true;
|
||||
}
|
||||
|
||||
localStorage.setItem(statsKey, JSON.stringify(stats));
|
||||
} catch (err) {
|
||||
console.warn('Stats update error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Get player stats
|
||||
getStats() {
|
||||
try {
|
||||
const statsKey = 'quartiles_player_stats';
|
||||
const stats = JSON.parse(localStorage.getItem(statsKey) || '{}');
|
||||
const expertCount = Object.keys(stats.expertPuzzles || {}).length;
|
||||
const perfectCount = Object.keys(stats.perfectPuzzles || {}).length;
|
||||
|
||||
return {
|
||||
totalWordsFound: stats.totalWordsFound || 0,
|
||||
expertCount,
|
||||
perfectCount
|
||||
};
|
||||
} catch (err) {
|
||||
return { totalWordsFound: 0, expertCount: 0, perfectCount: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const gameState = new GameStateManager();
|
||||
300
src/styles/board.css
Normal file
300
src/styles/board.css
Normal file
@@ -0,0 +1,300 @@
|
||||
/* ==========================================================================
|
||||
BOARD & WORD BUILDER STYLES
|
||||
========================================================================== */
|
||||
|
||||
/* Active Word Construction Tray */
|
||||
.word-builder-wrapper {
|
||||
margin: 6px 0 10px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.word-builder {
|
||||
min-height: 52px;
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1.5px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.word-builder.has-tiles {
|
||||
border-color: var(--border-highlight);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.word-builder.invalid-shake {
|
||||
animation: shake 0.45s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
border-color: var(--accent-danger);
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
10%, 90% { transform: translate3d(-2px, 0, 0); }
|
||||
20%, 80% { transform: translate3d(4px, 0, 0); }
|
||||
30%, 50%, 70% { transform: translate3d(-6px, 0, 0); }
|
||||
40%, 60% { transform: translate3d(6px, 0, 0); }
|
||||
}
|
||||
|
||||
/* Selected Tiles in Tray */
|
||||
.active-tiles-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-height: 48px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.active-tiles-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.builder-placeholder {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* Active Tile Chip */
|
||||
.active-tile-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 14px;
|
||||
background: var(--tile-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1.5px solid var(--tile-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 1.12rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
box-shadow: var(--tile-shadow);
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
transition: transform var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast);
|
||||
animation: chipInsert 0.22s var(--ease-spring);
|
||||
}
|
||||
|
||||
.active-tile-chip:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.active-tile-chip.dragging {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.active-tile-chip.drag-over {
|
||||
border-color: var(--accent-blue);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.active-tile-chip .remove-icon {
|
||||
margin-left: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@keyframes chipInsert {
|
||||
0% { transform: scale(0.7) translateY(8px); opacity: 0; }
|
||||
100% { transform: scale(1) translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Action controls inside the tray */
|
||||
.builder-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.btn-icon-tray {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon-tray:hover {
|
||||
background: var(--border-highlight);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-icon-tray:active {
|
||||
transform: translateY(1px) scale(0.94);
|
||||
}
|
||||
|
||||
.btn-submit-tray {
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-submit-gradient);
|
||||
color: #ffffff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 2px 8px var(--accent-submit-glow);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-submit-tray:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px var(--accent-submit-glow);
|
||||
}
|
||||
|
||||
.btn-submit-tray:active {
|
||||
transform: translateY(1px) scale(0.96);
|
||||
}
|
||||
|
||||
.btn-submit-tray:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
THE 4x5 BOARD GRID (20 TILES)
|
||||
========================================================================== */
|
||||
|
||||
.board-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tiles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.tiles-grid {
|
||||
grid-gap: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Single Board Tile */
|
||||
.board-tile {
|
||||
aspect-ratio: 1.35 / 1;
|
||||
max-height: 56px;
|
||||
min-height: 44px;
|
||||
background: var(--tile-bg);
|
||||
border: 1.5px solid var(--tile-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--tile-shadow);
|
||||
color: var(--tile-text);
|
||||
font-size: 1.12rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
transition: transform var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.board-tile {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.board-tile:hover:not(.selected) {
|
||||
background: var(--tile-bg-hover);
|
||||
border-color: var(--border-highlight);
|
||||
box-shadow: var(--tile-shadow-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.board-tile:active:not(.selected) {
|
||||
background: var(--tile-bg-active);
|
||||
transform: translateY(1px) scale(0.96);
|
||||
box-shadow: var(--tile-shadow-selected);
|
||||
}
|
||||
|
||||
/* Selected state: tile is currently in construction tray */
|
||||
.board-tile.selected {
|
||||
opacity: 0.38;
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-subtle);
|
||||
transform: scale(0.95);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.board-tile.selected::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Locked solved Quartile tile state */
|
||||
.board-tile.locked {
|
||||
background: linear-gradient(145deg, var(--tile-bg), var(--bg-tertiary));
|
||||
border-color: var(--accent-quartile);
|
||||
}
|
||||
|
||||
.board-tile.locked::before {
|
||||
content: '🔒';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 6px;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Board tile shuffle animation */
|
||||
.board-tile.flipping {
|
||||
animation: tileFlip 0.4s var(--ease-smooth);
|
||||
}
|
||||
|
||||
@keyframes tileFlip {
|
||||
0% { transform: scale(1) rotateY(0); }
|
||||
50% { transform: scale(0.85) rotateY(90deg); opacity: 0.5; }
|
||||
100% { transform: scale(1) rotateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Solved Quartile Section Header */
|
||||
.locked-section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--accent-quartile);
|
||||
}
|
||||
664
src/styles/components.css
Normal file
664
src/styles/components.css
Normal file
@@ -0,0 +1,664 @@
|
||||
/* ==========================================================================
|
||||
COMPONENTS: HEADER, SCORE BAR, ACTION BAR, DRAWER & MODALS
|
||||
========================================================================== */
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title-badge {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
background: var(--accent-quartile-gradient);
|
||||
color: #ffffff;
|
||||
border-radius: var(--radius-full);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header-date-badge {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-header-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.95rem;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-header-icon:hover {
|
||||
background: var(--bg-tertiary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-header-icon:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
SCORE, RANK & QUARTILES PROGRESS BAR
|
||||
========================================================================== */
|
||||
|
||||
.score-rank-card {
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 6px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.score-rank-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.score-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.score-num {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.rank-badge-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rank-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.rank-pill.expert {
|
||||
background: var(--accent-quartile-gradient);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 8px var(--accent-quartile-glow);
|
||||
}
|
||||
|
||||
/* Rank Progress Bar */
|
||||
.progress-bar-container {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-submit-gradient);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.4s var(--ease-smooth);
|
||||
}
|
||||
|
||||
.progress-bar-fill.expert {
|
||||
background: var(--accent-quartile-gradient);
|
||||
}
|
||||
|
||||
/* Quartiles 5-Segment Indicator */
|
||||
.quartiles-indicator-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.quartiles-counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quartiles-segments {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.quartile-segment-pip {
|
||||
width: 28px;
|
||||
height: 9px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s var(--ease-spring);
|
||||
}
|
||||
|
||||
.quartile-segment-pip.solved {
|
||||
background: var(--accent-quartile-gradient);
|
||||
border-color: var(--accent-quartile);
|
||||
box-shadow: 0 0 8px var(--accent-quartile-glow);
|
||||
transform: scaleY(1.15);
|
||||
}
|
||||
|
||||
.quartile-bonus-badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
padding: 2px 8px;
|
||||
background: var(--accent-quartile-gradient);
|
||||
color: #ffffff;
|
||||
border-radius: var(--radius-full);
|
||||
box-shadow: 0 2px 6px var(--accent-quartile-glow);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
WORDS FOUND STRIP / BUTTON
|
||||
========================================================================== */
|
||||
|
||||
.words-found-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.btn-words-drawer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-words-drawer:hover {
|
||||
background: var(--bg-tertiary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-words-drawer .badge-count {
|
||||
background: var(--accent-blue);
|
||||
color: #ffffff;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
ACTION BAR (SHUFFLE, SORT, LOCK, HINT)
|
||||
========================================================================== */
|
||||
|
||||
.action-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
height: 38px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.03);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-highlight);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-action:active {
|
||||
transform: translateY(1px) scale(0.96);
|
||||
}
|
||||
|
||||
.btn-action.active {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent-quartile);
|
||||
color: var(--accent-quartile);
|
||||
}
|
||||
|
||||
.btn-action .action-icon {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
WORDS FOUND DRAWER / MODAL
|
||||
========================================================================== */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-backdrop {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-backdrop.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-sheet {
|
||||
background: var(--bg-secondary);
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 85vh;
|
||||
border-radius: 24px 24px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.25);
|
||||
transform: translateY(100%);
|
||||
transition: transform var(--transition-normal);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-sheet {
|
||||
border-radius: 20px;
|
||||
max-height: 80vh;
|
||||
transform: scale(0.95) translateY(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-backdrop.open .modal-sheet {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-backdrop.open .modal-sheet {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-handle {
|
||||
width: 36px;
|
||||
height: 5px;
|
||||
background: var(--border-color);
|
||||
border-radius: var(--radius-full);
|
||||
margin: 10px auto 4px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sheet-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-header {
|
||||
padding: 14px 20px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sheet-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-close-sheet {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Tabs inside Drawer */
|
||||
.sheet-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sheet-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
white-space: nowrap;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--text-primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* Words List Content */
|
||||
.sheet-body {
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.word-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.word-card.quartile-card {
|
||||
border-color: var(--accent-quartile);
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.08), transparent);
|
||||
}
|
||||
|
||||
.word-card-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.word-card-word {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.word-card-tiles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tile-chip-mini {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.word-card-points {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
color: var(--accent-submit);
|
||||
}
|
||||
|
||||
.word-card.quartile-card .word-card-points {
|
||||
color: var(--accent-quartile);
|
||||
}
|
||||
|
||||
.sheet-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.btn-reveal-all {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-danger);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-reveal-all:hover {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
TOAST NOTIFICATIONS
|
||||
========================================================================== */
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
animation: toastPop 0.3s var(--ease-spring);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@keyframes toastPop {
|
||||
0% { transform: scale(0.8) translateY(-16px); opacity: 0; }
|
||||
100% { transform: scale(1) translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.toast.toast-info {
|
||||
background: var(--text-primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.toast.toast-success {
|
||||
background: var(--accent-submit-gradient);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toast.toast-quartile {
|
||||
background: var(--accent-quartile-gradient);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toast.toast-warning {
|
||||
background: #f59e0b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toast.toast-error {
|
||||
background: var(--accent-danger);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Calendar & Custom Modals Content */
|
||||
.puzzles-list-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.puzzle-card-select {
|
||||
padding: 12px;
|
||||
background: var(--bg-primary);
|
||||
border: 1.5px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: left;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.puzzle-card-select:hover {
|
||||
border-color: var(--accent-quartile);
|
||||
background: var(--bg-tertiary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.puzzle-card-select.active {
|
||||
border-color: var(--accent-quartile);
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1), transparent);
|
||||
}
|
||||
|
||||
.custom-builder-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.custom-quartet-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.custom-quartet-input {
|
||||
height: 38px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.custom-quartet-input:focus {
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.btn-primary-modal {
|
||||
height: 44px;
|
||||
background: var(--accent-submit-gradient);
|
||||
color: #ffffff;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 4px 12px var(--accent-submit-glow);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary-modal:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
142
src/styles/main.css
Normal file
142
src/styles/main.css
Normal file
@@ -0,0 +1,142 @@
|
||||
/* ==========================================================================
|
||||
QUARTILES DESIGN SYSTEM - APPLE NEWS+ PREMIUM AESTHETICS
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* Color Palette - Light Theme */
|
||||
--bg-primary: #f5f5f7;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #e5e5ea;
|
||||
--bg-surface: rgba(255, 255, 255, 0.85);
|
||||
--bg-glass: rgba(255, 255, 255, 0.72);
|
||||
--border-color: rgba(0, 0, 0, 0.08);
|
||||
--border-subtle: rgba(0, 0, 0, 0.04);
|
||||
--border-highlight: rgba(0, 0, 0, 0.16);
|
||||
|
||||
--text-primary: #1c1c1e;
|
||||
--text-secondary: #636366;
|
||||
--text-tertiary: #8e8e93;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
/* Accent Colors */
|
||||
--accent-quartile: #d97706;
|
||||
--accent-quartile-gradient: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
--accent-quartile-glow: rgba(245, 158, 11, 0.35);
|
||||
|
||||
--accent-submit: #10b981;
|
||||
--accent-submit-gradient: linear-gradient(135deg, #34d399, #059669);
|
||||
--accent-submit-glow: rgba(16, 185, 129, 0.3);
|
||||
|
||||
--accent-blue: #0284c7;
|
||||
--accent-blue-gradient: linear-gradient(135deg, #38bdf8, #0284c7);
|
||||
|
||||
--accent-purple: #6366f1;
|
||||
--accent-danger: #ef4444;
|
||||
|
||||
/* Tile Styling */
|
||||
--tile-bg: #ffffff;
|
||||
--tile-bg-hover: #fcfcfd;
|
||||
--tile-bg-active: #ebebed;
|
||||
--tile-text: #1d1d1f;
|
||||
--tile-border: #d2d2d7;
|
||||
--tile-shadow: 0 2px 4px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--tile-shadow-hover: 0 4px 8px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
--tile-shadow-selected: inset 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* Dimensions & Radius */
|
||||
--radius-xs: 6px;
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 18px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
--ease-smooth: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-normal: 0.25s var(--ease-smooth);
|
||||
}
|
||||
|
||||
/* Dark Theme Overrides */
|
||||
[data-theme='dark'] {
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: #1c1c1e;
|
||||
--bg-tertiary: #2c2c2e;
|
||||
--bg-surface: rgba(28, 28, 30, 0.88);
|
||||
--bg-glass: rgba(28, 28, 30, 0.75);
|
||||
--border-color: rgba(255, 255, 255, 0.12);
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--border-highlight: rgba(255, 255, 255, 0.24);
|
||||
|
||||
--text-primary: #f5f5f7;
|
||||
--text-secondary: #a1a1a6;
|
||||
--text-tertiary: #6e6e73;
|
||||
--text-inverse: #000000;
|
||||
|
||||
/* Tile Styling Dark */
|
||||
--tile-bg: #2c2c2e;
|
||||
--tile-bg-hover: #3a3a3c;
|
||||
--tile-bg-active: #1c1c1e;
|
||||
--tile-text: #f5f5f7;
|
||||
--tile-border: rgba(255, 255, 255, 0.14);
|
||||
--tile-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
|
||||
--tile-shadow-hover: 0 6px 14px rgba(0, 0, 0, 0.55);
|
||||
--tile-shadow-selected: inset 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--accent-quartile: #f59e0b;
|
||||
--accent-quartile-glow: rgba(245, 158, 11, 0.45);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
RESET & TYPOGRAPHY
|
||||
========================================================================== */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
letter-spacing: -0.01em;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
transition: background-color var(--transition-normal), color var(--transition-normal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* App Container */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding: 6px 14px 16px;
|
||||
position: relative;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-container {
|
||||
max-width: 480px;
|
||||
padding: 10px 16px 20px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user