many many fixes

This commit is contained in:
2026-10-26 10:06:30 -04:00
parent 79e15cb8c3
commit f1a13a3065
25 changed files with 642 additions and 9964 deletions

View File

@@ -1,5 +1,6 @@
import { CURATED_PUZZLES } from '../data/puzzles.js';
import { getRecentDailyPuzzles } from '../engine/daily.js';
import { sound } from '../engine/audio.js';
import { APP_VERSION } from '../engine/version.js';
export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuzzle, onRevealAll }) {
const container = document.createElement('div');
@@ -100,10 +101,10 @@ export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuz
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:
Today's puzzle and the last three weeks of dailies:
</p>
<div class="puzzles-list-grid">
${CURATED_PUZZLES.map(p => {
${getRecentDailyPuzzles(21).map(p => {
const isCurrent = state.currentPuzzle && state.currentPuzzle.id === p.id;
const saved = localStorage.getItem(`quartiles_save_${p.id}`);
let progressBadge = '';
@@ -278,6 +279,8 @@ export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuz
Reset Current Puzzle Progress
</button>
</div>
<div class="app-version-label" id="settings-app-version">Quartiles v${APP_VERSION}</div>
</div>
`;
}

View File

@@ -0,0 +1,37 @@
import { APP_VERSION } from '../engine/version.js';
/**
* Persistent banner asking the user to reload when a newer version is deployed.
* Progress is saved to localStorage continuously, so reloading is safe.
*/
export function createUpdateBanner() {
const element = document.createElement('div');
element.className = 'update-banner';
element.id = 'update-banner';
element.setAttribute('role', 'status');
element.setAttribute('aria-live', 'polite');
element.hidden = true;
function show(latestVersion) {
element.innerHTML = `
<div class="update-banner-text">
<strong>New version available</strong>
<span>v${APP_VERSION} → v${latestVersion}</span>
</div>
<button class="update-banner-reload" id="btn-update-reload">Reload</button>
<button class="update-banner-dismiss" id="btn-update-dismiss" aria-label="Dismiss">✕</button>
`;
element.hidden = false;
requestAnimationFrame(() => element.classList.add('visible'));
element.querySelector('#btn-update-reload').addEventListener('click', () => {
window.location.reload();
});
element.querySelector('#btn-update-dismiss').addEventListener('click', () => {
element.classList.remove('visible');
setTimeout(() => { element.hidden = true; }, 250);
});
}
return { element, show };
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

51
src/engine/daily.js Normal file
View File

@@ -0,0 +1,51 @@
import { generateDailyPuzzle, getDailyPuzzleMeta } from './generator.js';
const DAILY_ID_PREFIX = 'daily-';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const dailyCache = new Map();
/** Local date as YYYY-MM-DD */
export function toDateString(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function isDailyId(id) {
return typeof id === 'string' && id.startsWith(DAILY_ID_PREFIX);
}
/** Deterministic generated puzzle for a date (same board for everyone) */
export function getDailyPuzzle(dateStr) {
if (!dailyCache.has(dateStr)) {
dailyCache.set(dateStr, generateDailyPuzzle(dateStr));
}
return dailyCache.get(dateStr);
}
/** Resolve a daily puzzle from "daily-YYYY-MM-DD" or "YYYY-MM-DD" */
export function resolveBuiltInPuzzle(idOrDate) {
if (typeof idOrDate !== 'string') return null;
const dateStr = isDailyId(idOrDate) ? idOrDate.slice(DAILY_ID_PREFIX.length) : idOrDate;
return DATE_RE.test(dateStr) ? getDailyPuzzle(dateStr) : null;
}
/** True for puzzles that can be rebuilt from their id (no need to persist the full object) */
export function isBuiltInPuzzleId(id) {
return isDailyId(id);
}
/**
* Daily puzzle summaries ({ id, date, theme, difficulty }) for the last `days`
* days, today first. Never includes future dates. Doesn't run the solver.
*/
export function getRecentDailyPuzzles(days = 21) {
const list = [];
const cursor = new Date();
for (let i = 0; i < days; i++) {
list.push(getDailyPuzzleMeta(toDateString(cursor)));
cursor.setDate(cursor.getDate() - 1);
}
return list;
}

View File

@@ -1,101 +1,91 @@
import { TARGET_WORDS_POOL } from '../data/puzzles.js';
import { solveBoard } from './solver.js';
// Variety limits per board, so puzzles don't feel samey
const MAX_SUFFIX_WORDS = 1; // words ending in -tion / -sion
const MAX_LONG_WORDS = 2; // words with 11+ letters
const LONG_WORD_LENGTH = 11;
const SUFFIX_RE = /(tion|sion)$/;
/** Deterministic PRNG (mulberry32) seeded from a string via FNV-1a */
export function createSeededRandom(seed) {
let h = 0x811c9dc5;
const str = String(seed);
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
let a = h >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, rng) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/**
* Generates a random valid Quartiles puzzle from the target word pool
* Greedily picks 5 words with 20 unique tiles from a shuffled pool while
* respecting the variety limits. Returns null if it can't fill the board.
*/
/**
* Generates a random valid Quartiles puzzle from the target word pool
* Guarantees:
* - 20 strictly unique tiles (no duplicates anywhere on board)
* - All tiles are at least 2 characters (never 1 letter)
* - Anti-repetition: tracks recently used words in localStorage across games
* - Highly varied and random puzzles
*/
export function generateRandomPuzzle(seed) {
// Load recently used target words to prevent repetition across games
let recentWords = [];
try {
const rawRecent = localStorage.getItem('quartiles_recent_target_words');
if (rawRecent) {
recentWords = JSON.parse(rawRecent);
if (!Array.isArray(recentWords)) recentWords = [];
function pickWords(pool, { enforceVariety = true } = {}) {
const picked = [];
const usedTiles = new Set();
let suffixCount = 0;
let longCount = 0;
for (const item of pool) {
if (!item.tiles || item.tiles.length !== 4) continue;
if (!item.tiles.every(t => t && t.length >= 2)) continue;
if (item.tiles.some(t => usedTiles.has(t.toLowerCase()))) continue;
const isSuffix = SUFFIX_RE.test(item.word);
const isLong = item.word.length >= LONG_WORD_LENGTH;
if (enforceVariety) {
if (isSuffix && suffixCount >= MAX_SUFFIX_WORDS) continue;
if (isLong && longCount >= MAX_LONG_WORDS) continue;
}
} catch (e) {
recentWords = [];
picked.push(item);
item.tiles.forEach(t => usedTiles.add(t.toLowerCase()));
if (isSuffix) suffixCount++;
if (isLong) longCount++;
if (picked.length === 5) return picked;
}
const recentSet = new Set(recentWords.map(w => w.toLowerCase()));
return null;
}
// Filter pool to prioritize words not played recently
const nonRecentPool = TARGET_WORDS_POOL.filter(w => !recentSet.has(w.word.toLowerCase()));
// If non-recent pool is too small, use full pool
const poolCandidate = nonRecentPool.length >= 25 ? nonRecentPool : [...TARGET_WORDS_POOL];
// Helper to shuffle an array
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
function selectWords(candidatePool, rng) {
const attempts = [
() => pickWords(shuffle(candidatePool, rng)),
() => pickWords(shuffle(TARGET_WORDS_POOL, rng)),
() => pickWords(shuffle(TARGET_WORDS_POOL, rng)),
() => pickWords(shuffle(TARGET_WORDS_POOL, rng), { enforceVariety: false })
];
for (const attempt of attempts) {
const result = attempt();
if (result) return result;
}
// Attempt to find 5 words with 0 tile collisions (strictly 20 unique tiles)
let selectedWords = null;
const attemptsPools = [shuffle(poolCandidate), shuffle(TARGET_WORDS_POOL), shuffle(TARGET_WORDS_POOL)];
for (const pool of attemptsPools) {
const picked = [];
const usedTiles = new Set();
for (const item of pool) {
if (!item.tiles || item.tiles.length !== 4) continue;
// Ensure no 1-letter tiles
if (!item.tiles.every(t => t && t.length >= 2)) continue;
// Check collision with already used tiles
const hasCollision = item.tiles.some(t => usedTiles.has(t.toLowerCase()));
if (!hasCollision) {
picked.push(item);
item.tiles.forEach(t => usedTiles.add(t.toLowerCase()));
if (picked.length === 5) {
selectedWords = picked;
break;
}
}
}
if (selectedWords && selectedWords.length === 5) break;
}
// Fallback if extreme constraints somehow failed
if (!selectedWords || selectedWords.length < 5) {
selectedWords = shuffle(TARGET_WORDS_POOL).slice(0, 5);
}
// Save selected words into recent words history (keep up to last 50 words)
try {
const updatedRecent = [
...selectedWords.map(w => w.word.toLowerCase()),
...recentWords.filter(w => !selectedWords.some(sw => sw.word.toLowerCase() === w))
].slice(0, 50);
localStorage.setItem('quartiles_recent_target_words', JSON.stringify(updatedRecent));
} catch (e) {
// Non-fatal if localStorage unavailable
}
return shuffle(TARGET_WORDS_POOL, rng).slice(0, 5);
}
function buildPuzzle(selectedWords, meta) {
const quartets = selectedWords.map(w => w.tiles.map(t => t.toLowerCase().trim()));
const solved = solveBoard(quartets, 30);
const id = 'custom-' + Date.now();
const theme = selectedWords[0].theme ? `${selectedWords[0].theme.toUpperCase()} & BEYOND` : 'Unlimited Challenge';
return {
id,
date: new Date().toISOString().slice(0, 10),
title: `Unlimited Puzzle #${Math.floor(Math.random() * 9000 + 1000)}`,
theme,
difficulty: 'Random',
...meta,
quartets,
tiles: solved.tiles,
targetQuartiles: solved.targetQuartiles,
@@ -110,6 +100,97 @@ export function generateRandomPuzzle(seed) {
};
}
function themeLabel(selectedWords) {
const theme = selectedWords[0]?.theme;
return theme ? `${theme.charAt(0).toUpperCase()}${theme.slice(1)} & Beyond` : 'Unlimited Challenge';
}
/**
* Generates a random valid Quartiles puzzle from the target word pool
* Guarantees:
* - 20 strictly unique tiles (no duplicates anywhere on board)
* - All tiles are at least 2 characters (never 1 letter)
* - Variety: at most one -tion/-sion answer and two 11+ letter answers
* - Anti-repetition: tracks recently used words in localStorage across games
* (skipped when a seed is given, so seeded puzzles stay deterministic)
*/
export function generateRandomPuzzle(seed) {
const seeded = seed !== undefined && seed !== null;
const rng = seeded ? createSeededRandom(seed) : Math.random;
// Load recently used target words to prevent repetition across games
let recentWords = [];
if (!seeded) {
try {
const rawRecent = localStorage.getItem('quartiles_recent_target_words');
if (rawRecent) {
recentWords = JSON.parse(rawRecent);
if (!Array.isArray(recentWords)) recentWords = [];
}
} catch (e) {
recentWords = [];
}
}
const recentSet = new Set(recentWords.map(w => w.toLowerCase()));
// Filter pool to prioritize words not played recently
const nonRecentPool = TARGET_WORDS_POOL.filter(w => !recentSet.has(w.word.toLowerCase()));
// If non-recent pool is too small, use full pool
const poolCandidate = nonRecentPool.length >= 25 ? nonRecentPool : [...TARGET_WORDS_POOL];
const selectedWords = selectWords(poolCandidate, rng);
// Save selected words into recent words history (keep up to last 50 words)
if (!seeded) {
try {
const updatedRecent = [
...selectedWords.map(w => w.word.toLowerCase()),
...recentWords.filter(w => !selectedWords.some(sw => sw.word.toLowerCase() === w))
].slice(0, 50);
localStorage.setItem('quartiles_recent_target_words', JSON.stringify(updatedRecent));
} catch (e) {
// Non-fatal if localStorage unavailable
}
}
return buildPuzzle(selectedWords, {
id: 'custom-' + Date.now(),
date: new Date().toISOString().slice(0, 10),
title: `Unlimited Puzzle #${Math.floor(rng() * 9000 + 1000)}`,
theme: themeLabel(selectedWords),
difficulty: 'Random'
});
}
function selectDaily(dateStr) {
const rng = createSeededRandom(`quartiles-daily-${dateStr}`);
const selectedWords = selectWords(TARGET_WORDS_POOL, rng);
const [y, m, d] = dateStr.split('-').map(Number);
const label = new Date(y, m - 1, d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const meta = {
id: `daily-${dateStr}`,
date: dateStr,
title: `Daily Puzzle — ${label}`,
theme: themeLabel(selectedWords).replace(' & Beyond', ' Mix'),
difficulty: 'Normal'
};
return { selectedWords, meta };
}
/** Lightweight daily puzzle info (no solving) for lists like the calendar */
export function getDailyPuzzleMeta(dateStr) {
return selectDaily(dateStr).meta;
}
/**
* Deterministic daily puzzle for a YYYY-MM-DD date. Every player gets the
* same board for the same date.
*/
export function generateDailyPuzzle(dateStr) {
const { selectedWords, meta } = selectDaily(dateStr);
return buildPuzzle(selectedWords, meta);
}
/**
* Validates and creates a custom puzzle from user-provided quartets
* Expects array of 5 quartets: [[t1,t2,t3,t4], [t5,t6,t7,t8], ...]

View File

@@ -1,5 +1,5 @@
import { DICTIONARY_SET, PREFIX_SET } from '../data/lexicon.js';
import { capWordList } from './word-cap.js';
import { DICTIONARY_SET, PREFIX_SET, getCommonnessScore } from '../data/lexicon.js';
import { capWordList, compareCandidates } from './word-cap.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 };
@@ -66,17 +66,10 @@ export function solveBoard(quartets, maxWords = 30) {
});
}
// Sort candidate words:
// 1. Target quartiles always first (all 5 guaranteed)
// 2. Tile count descending (4-tiles > 3-tiles > 2-tiles > 1-tile)
// 3. Word length descending
// 4. Alphabetical
words.sort((a, b) => {
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
if (a.tileCount !== b.tileCount) return b.tileCount - a.tileCount;
if (b.word.length !== a.word.length) return b.word.length - a.word.length;
return a.word.localeCompare(b.word);
});
// Rank candidates: target quartiles first, then familiar words, then tile
// count (see compareCandidates). Shorter words win ties so the list isn't
// dominated by the longest possible combos.
words.sort((a, b) => compareCandidates(a, b, getCommonnessScore));
// Cap at maxWords (default: 30) while keeping a fair share of 2-tile words.
// Valid words beyond the cap are still accepted in play as bonus words.

44
src/engine/version.js Normal file
View File

@@ -0,0 +1,44 @@
import versionInfo from '../version.json';
/** Version baked into this bundle at build time */
export const APP_VERSION = versionInfo.version;
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
/** Fetches the latest deployed version, bypassing HTTP and service worker caches */
export async function fetchLatestVersion() {
const res = await fetch(`/version.json?t=${Date.now()}`, { cache: 'no-store' });
if (!res.ok) throw new Error(`version.json request failed: ${res.status}`);
const data = await res.json();
return data?.version || null;
}
/**
* Polls /version.json (on start, every 5 minutes, and whenever the tab
* becomes visible). Calls onUpdateAvailable(latestVersion) once when the
* deployed version differs from APP_VERSION.
*/
export function startVersionCheck(onUpdateAvailable) {
let notified = false;
async function check() {
if (notified || !navigator.onLine) return;
try {
const latest = await fetchLatestVersion();
if (latest && latest !== APP_VERSION) {
notified = true;
onUpdateAvailable(latest);
}
} catch (err) {
// Offline or file missing: try again on the next tick
}
}
check();
const timer = setInterval(check, CHECK_INTERVAL_MS);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') check();
});
return () => clearInterval(timer);
}

View File

@@ -1,3 +1,27 @@
/**
* Ranking for solver candidates (used by src/engine/solver.js).
*
* 1. Target quartiles first.
* 2. Familiar words (commonness >= 2) before rare ones, so long obscure
* combos can't crowd out everyday words.
* 3. Higher commonness tier.
* 4. More tiles (worth more points).
* 5. Shorter word (avoids a bias toward the longest possible combos).
* 6. Alphabetical.
*/
export function compareCandidates(a, b, getCommonness) {
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
const ca = getCommonness(a.word);
const cb = getCommonness(b.word);
const fa = ca >= 2 ? 1 : 0;
const fb = cb >= 2 ? 1 : 0;
if (fa !== fb) return fb - fa;
if (ca !== cb) return cb - ca;
if (a.tileCount !== b.tileCount) return b.tileCount - a.tileCount;
if (a.word.length !== b.word.length) return a.word.length - b.word.length;
return a.word.localeCompare(b.word);
}
/**
* Caps a sorted candidate word list to `maxWords` entries.
*

View File

@@ -3,7 +3,7 @@ import './styles/board.css';
import './styles/components.css';
import { gameState } from './state/game-state.js';
import { CURATED_PUZZLES } from './data/puzzles.js';
import { getDailyPuzzle, resolveBuiltInPuzzle, toDateString } from './engine/daily.js';
import { sound } from './engine/audio.js';
import { fireConfetti } from './engine/confetti.js';
@@ -15,19 +15,26 @@ import { createActionBar } from './components/ActionBar.js';
import { createWordsDrawer } from './components/WordsDrawer.js';
import { createModals } from './components/Modals.js';
import { createToastContainer } from './components/Toast.js';
import { createUpdateBanner } from './components/UpdateBanner.js';
import { APP_VERSION, startVersionCheck } from './engine/version.js';
// PWA Service Worker Registration
// PWA Service Worker Registration (production only: in dev it would cache
// unhashed modules and serve stale code after a reload)
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);
});
});
if (import.meta.env.PROD) {
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);
});
});
} else {
navigator.serviceWorker.getRegistrations().then(regs => regs.forEach(r => r.unregister()));
}
}
async function initApp() {
@@ -178,6 +185,18 @@ async function initApp() {
});
appContainer.appendChild(actionBar.element);
// Version label
const versionLabel = document.createElement('div');
versionLabel.className = 'app-version-label';
versionLabel.id = 'app-version-label';
versionLabel.textContent = `v${APP_VERSION}`;
appContainer.appendChild(versionLabel);
// Prompt to reload when a newer version has been deployed
const updateBanner = createUpdateBanner();
document.body.appendChild(updateBanner.element);
startVersionCheck(latest => updateBanner.show(latest));
// Word submission handler
function handleWordSubmission() {
const prevRank = gameState.getRank();
@@ -364,18 +383,18 @@ async function initApp() {
console.warn('Failed to restore custom active puzzle:', e);
}
} else {
const matched = CURATED_PUZZLES.find(p => p.id === savedActivePuzzleId || p.date === savedActivePuzzleId);
if (matched) {
const matched = resolveBuiltInPuzzle(savedActivePuzzleId);
// A daily opened on a previous day yields to today's daily
const activeDay = localStorage.getItem('quartiles_active_day');
if (matched && activeDay === toDateString()) {
gameState.loadPuzzle(matched);
return;
}
}
}
// Fallback: 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);
// Fallback: Load today's generated daily puzzle
gameState.loadPuzzle(getDailyPuzzle(toDateString()));
}
// Start application when DOM is ready

View File

@@ -1,6 +1,11 @@
import { CURATED_PUZZLES } from '../data/puzzles.js';
import { getRankInfo, QUARTILE_COMPLETION_BONUS, POINTS_BY_TILE_COUNT } from '../engine/constants.js';
import { isAcceptedWord } from '../data/lexicon.js';
import { resolveBuiltInPuzzle, isBuiltInPuzzleId, getDailyPuzzle, toDateString } from '../engine/daily.js';
// Identifies the exact board, so saves from an older version of a puzzle id are ignored
function boardSignature(puzzle) {
return (puzzle?.tiles || []).join('|');
}
class GameStateManager {
constructor() {
@@ -43,7 +48,7 @@ class GameStateManager {
loadPuzzle(puzzleOrId) {
let puzzle = puzzleOrId;
if (typeof puzzleOrId === 'string') {
puzzle = CURATED_PUZZLES.find(p => p.id === puzzleOrId || p.date === puzzleOrId) || CURATED_PUZZLES[0];
puzzle = resolveBuiltInPuzzle(puzzleOrId) || getDailyPuzzle(toDateString());
}
this.currentPuzzle = puzzle;
@@ -114,8 +119,9 @@ class GameStateManager {
if (!this.currentPuzzle) return;
try {
localStorage.setItem('quartiles_active_puzzle_id', this.currentPuzzle.id);
const isCurated = CURATED_PUZZLES.some(p => p.id === this.currentPuzzle.id);
if (!isCurated) {
localStorage.setItem('quartiles_active_day', this.getTodayDateString());
const isBuiltIn = isBuiltInPuzzleId(this.currentPuzzle.id);
if (!isBuiltIn) {
localStorage.setItem(`quartiles_custom_puzzle_${this.currentPuzzle.id}`, JSON.stringify(this.currentPuzzle));
localStorage.setItem('quartiles_active_is_custom', 'true');
} else {
@@ -133,6 +139,7 @@ class GameStateManager {
const saveKey = `quartiles_save_${this.currentPuzzle.id}`;
const payload = {
puzzleId: this.currentPuzzle.id,
boardSignature: boardSignature(this.currentPuzzle),
foundWords: Array.from(this.foundWords.entries()),
score: this.score,
bonus: this.quartilesBonusAwarded,
@@ -163,6 +170,22 @@ class GameStateManager {
const saved = localStorage.getItem(saveKey);
if (saved) {
const parsed = JSON.parse(saved);
// Ignore saves made for a different board under the same id
if (parsed && parsed.boardSignature && parsed.boardSignature !== boardSignature(this.currentPuzzle)) {
localStorage.removeItem(saveKey);
return false;
}
// Legacy saves (no signature): drop if they used tiles not on this board
if (parsed && !parsed.boardSignature && Array.isArray(parsed.foundWords)) {
const boardTileSet = new Set(this.currentPuzzle.tiles.map(t => t.toLowerCase()));
const stale = parsed.foundWords.some(([, entry]) =>
Array.isArray(entry?.tileSegments) && entry.tileSegments.some(t => !boardTileSet.has(String(t).toLowerCase()))
);
if (stale) {
localStorage.removeItem(saveKey);
return false;
}
}
if (parsed) {
if (Array.isArray(parsed.foundWords)) {
const validWordsMap = new Map(this.currentPuzzle.words.map(w => [w.word.toLowerCase(), w]));

View File

@@ -845,3 +845,86 @@
.btn-primary-modal:hover {
transform: translateY(-1px);
}
/* ==========================================================================
UPDATE BANNER & VERSION LABEL
========================================================================== */
.update-banner {
position: fixed;
left: 50%;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
transform: translate(-50%, 24px);
opacity: 0;
z-index: 1100;
width: min(440px, calc(100vw - 24px));
display: flex;
align-items: center;
gap: 10px;
padding: 10px 10px 10px 16px;
background: var(--bg-glass);
backdrop-filter: blur(16px) saturate(1.4);
-webkit-backdrop-filter: blur(16px) saturate(1.4);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22);
transition: transform 0.3s var(--ease-spring), opacity 0.25s ease;
}
.update-banner.visible {
transform: translate(-50%, 0);
opacity: 1;
}
.update-banner-text {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
font-size: 0.86rem;
color: var(--text-primary);
}
.update-banner-text span {
font-size: 0.76rem;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.update-banner-reload {
height: 36px;
padding: 0 16px;
border-radius: var(--radius-full);
background: var(--accent-submit-gradient);
color: #ffffff;
font-weight: 700;
font-size: 0.86rem;
box-shadow: 0 4px 12px var(--accent-submit-glow);
transition: transform var(--transition-fast);
}
.update-banner-reload:hover {
transform: translateY(-1px);
}
.update-banner-dismiss {
width: 30px;
height: 30px;
border-radius: var(--radius-full);
color: var(--text-secondary);
font-size: 0.8rem;
transition: background var(--transition-fast);
}
.update-banner-dismiss:hover {
background: var(--bg-tertiary);
}
.app-version-label {
text-align: center;
font-size: 0.72rem;
color: var(--text-secondary);
opacity: 0.7;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}

3
src/version.json Normal file
View File

@@ -0,0 +1,3 @@
{
"version": "1.0.1"
}