This commit is contained in:
2026-10-24 00:38:55 -04:00
parent cd831940ea
commit cf18b5a99e
10 changed files with 534 additions and 853 deletions

View File

@@ -19,6 +19,20 @@ for (const raw of wordlistEnglish.english) {
}
}
// Commonness tiers for intelligent word quality ranking
const t10 = new Set(wordlistEnglish['english/10'] || []);
const t20 = new Set(wordlistEnglish['english/20'] || []);
const t35 = new Set(wordlistEnglish['english/35'] || []);
const t50 = new Set(wordlistEnglish['english/50'] || []);
function getCommonnessScore(word) {
if (t10.has(word)) return 4;
if (t20.has(word)) return 3;
if (t35.has(word)) return 2;
if (t50.has(word)) return 1;
return 0;
}
// Build prefix set for super fast solver pruning (up to 6 letters)
const prefixSet = new Set();
for (const word of cleanWordSet) {
@@ -28,7 +42,7 @@ for (const word of cleanWordSet) {
}
// Function to solve a 20-tile board and return all valid words and paths
export function solveBoard(quartets) {
export function solveBoard(quartets, maxWords = 30) {
const tiles = quartets.flat();
const foundMap = new Map();
@@ -55,12 +69,10 @@ export function solveBoard(quartets) {
search('', []);
const quartilesList = quartets.map(q => q.join(''));
const quartilesList = quartets.map(q => q.join('').toLowerCase());
const quartileSet = new Set(quartilesList);
const words = [];
let totalScore = 0;
let quartileCount = 0;
let words = [];
for (const [word, path] of foundMap.entries()) {
const tileSegments = path.map(idx => tiles[idx]);
@@ -69,9 +81,6 @@ export function solveBoard(quartets) {
const isTargetQuartile = quartileSet.has(word);
const isQuartile = tileCount === 4;
if (isQuartile) quartileCount++;
totalScore += points;
words.push({
word,
points,
@@ -83,16 +92,37 @@ export function solveBoard(quartets) {
});
}
// Add 40 bonus points for finding all 5 quartiles
const maxPossibleScore = totalScore + 40;
// Sort candidate words:
// 1. Target quartiles always first (all 5 guaranteed)
// 2. Tile count descending (4-tiles > 3-tiles > 2-tiles > 1-tile)
// 3. Commonness score descending (familiar words first)
// 4. Word length descending
// 5. 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;
const ca = getCommonnessScore(a.word);
const cb = getCommonnessScore(b.word);
if (ca !== cb) return cb - ca;
if (b.word.length !== a.word.length) return b.word.length - a.word.length;
return a.word.localeCompare(b.word);
});
// Sort words: Quartiles first, then by points descending, then alphabetically
// Cap words at maxWords (default: 30)
if (maxWords && words.length > maxWords) {
words = words.slice(0, maxWords);
}
// Final display sorting: Target quartiles first, then points desc, then alphabetical
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);
});
const totalBaseScore = words.reduce((sum, w) => sum + w.points, 0);
const maxPossibleScore = totalBaseScore + 40; // 40 bonus points for all 5 quartiles
return {
tiles,
quartets,
@@ -100,7 +130,7 @@ export function solveBoard(quartets) {
words,
wordCount: words.length,
quartileCount: 5,
totalBaseScore: totalScore,
totalBaseScore,
maxPossibleScore
};
}

View File

@@ -18,9 +18,9 @@ export function createActionBar({ onShuffle, onSortAlpha, onToggleLock, onGetHin
<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 class="btn-action ${isLocked ? 'active' : ''}" id="btn-action-lock" title="${isLocked ? 'Solved Quartiles are pinned to top rows. Tap to allow free shuffle' : 'Pin solved Quartiles to top rows'}">
<span class="action-icon">${isLocked ? '📌' : '🔓'}</span>
<span>${isLocked ? 'Pinned' : 'Free'}</span>
</button>
<button class="btn-action" id="btn-action-hint" title="Get a hint for an unfound word">

View File

@@ -262,11 +262,11 @@ export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuz
<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 style="font-weight:600; color:var(--text-primary);">Pin Solved Quartiles</div>
<div style="font-size:0.78rem; color:var(--text-secondary);">Keep completed Quartiles organized at top rows of the board</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'}
${lockSetting ? '📌 Pinned' : '🔓 Free'}
</button>
</div>

View File

@@ -1,12 +1,13 @@
export function createScoreRankBar({ onOpenWordsDrawer }) {
export function createScoreRankBar({ onOpenWordsDrawer, onOpenReveal }) {
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 totalWords = state.currentPuzzle ? (state.currentPuzzle.wordCount || state.currentPuzzle.words?.length || 0) : 0;
const foundWordsCount = state.foundWords.size;
const isRevealed = !!state.isRevealed;
container.innerHTML = `
<div class="score-rank-card">
@@ -43,17 +44,28 @@ export function createScoreRankBar({ onOpenWordsDrawer }) {
<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>${isRevealed ? 'All Words & Solutions' : 'Words Found'}</span>
<span class="badge-count">${isRevealed ? `👁️ ${foundWordsCount}/${totalWords}` : `${foundWordsCount} ${rankInfo.isExpert ? `/ ${totalWords}` : ''}`}</span>
<span style="font-size: 0.75rem;">▾</span>
</button>
<div style="display:flex; align-items:center; gap:8px;">
${
!isRevealed
? `<button class="btn-strip-reveal" id="btn-strip-reveal" title="Reveal all words and solutions for this puzzle">
💡 Reveal
</button>`
: `<span class="strip-revealed-tag" title="Solutions revealed">👁️ Revealed</span>`
}
<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!'}
${rankInfo.next ? `Next in <strong>${rankInfo.pointsToNext}</strong> pts` : '🌟 Expert!'}
</div>
</div>
</div>
`;
container.querySelector('#btn-words-found-toggle')?.addEventListener('click', onOpenWordsDrawer);
container.querySelector('#btn-words-found-toggle')?.addEventListener('click', () => onOpenWordsDrawer());
container.querySelector('#btn-strip-reveal')?.addEventListener('click', () => onOpenReveal?.());
}
return { element: container, render };

View File

@@ -4,17 +4,25 @@ export function createWordsDrawer({ onRevealAll }) {
backdrop.id = 'words-drawer-backdrop';
let currentTab = 'all'; // 'all', 'quartiles', '3-tiles', '2-tiles', '1-tiles'
let viewMode = null; // 'all' (all solutions) or 'found' (words found)
let isConfirmingReveal = false;
let lastState = null;
function render(state) {
if (!state.currentPuzzle) return;
if (!state || !state.currentPuzzle) return;
lastState = state;
const foundWordsList = Array.from(state.foundWords.values());
const allPuzzleWords = state.currentPuzzle.words;
const isRevealed = state.isRevealed;
const allPuzzleWords = state.currentPuzzle.words || [];
const isRevealed = !!state.isRevealed;
// Active view: if user picked one use it, otherwise default to 'all' if revealed, 'found' if not
const activeView = viewMode !== null ? viewMode : (isRevealed ? 'all' : 'found');
const sourceWords = (activeView === 'all' && isRevealed) ? allPuzzleWords : foundWordsList;
// Filter words according to current tab
let displayWords = isRevealed ? allPuzzleWords : foundWordsList;
let displayWords = [...sourceWords];
if (currentTab === 'quartiles') {
displayWords = displayWords.filter(w => w.tileCount === 4);
} else if (currentTab === '3-tiles') {
@@ -32,30 +40,58 @@ export function createWordsDrawer({ onRevealAll }) {
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;
const countingSource = (activeView === 'all' && isRevealed) ? allPuzzleWords : foundWordsList;
const quartilesCount = countingSource.filter(w => w.tileCount === 4).length;
const threeTilesCount = countingSource.filter(w => w.tileCount === 3).length;
const twoTilesCount = countingSource.filter(w => w.tileCount === 2).length;
const oneTilesCount = countingSource.filter(w => w.tileCount === 1).length;
const quartileDetailsMap = new Map();
if (state.currentPuzzle.quartileDetails) {
state.currentPuzzle.quartileDetails.forEach(q => {
quartileDetailsMap.set(q.word.toLowerCase(), q.clue);
});
}
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="display:flex; align-items:center; gap:8px;">
<div class="sheet-title">${isRevealed ? 'Words & Solutions' : 'Words Found'}</div>
${isRevealed ? '<span class="badge-revealed">👁️ Revealed</span>' : ''}
</div>
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px;">
${foundWordsList.length} of ${allPuzzleWords.length} words found (${state.score} pts)
${
isRevealed
? `<strong>${foundWordsList.length}</strong> of ${allPuzzleWords.length} found (${state.score} pts) • ${allPuzzleWords.length - foundWordsList.length} revealed`
: `<strong>${foundWordsList.length}</strong> of ${allPuzzleWords.length} words found (${state.score} pts)`
}
</div>
</div>
<button class="btn-close-sheet" id="btn-close-words-drawer">✕</button>
<button class="btn-close-sheet" id="btn-close-words-drawer" aria-label="Close words drawer">✕</button>
</div>
${
isRevealed ? `
<div class="drawer-view-mode-bar">
<button class="view-mode-btn ${activeView === 'all' ? 'active' : ''}" data-view="all">
👁️ All Solutions (${allPuzzleWords.length})
</button>
<button class="view-mode-btn ${activeView === 'found' ? 'active' : ''}" data-view="found">
✓ Words You Found (${foundWordsList.length})
</button>
</div>
` : ''
}
<div class="sheet-tabs">
<button class="tab-btn ${currentTab === 'all' ? 'active' : ''}" data-tab="all">
All (${foundWordsList.length})
All (${countingSource.length})
</button>
<button class="tab-btn ${currentTab === 'quartiles' ? 'active' : ''}" data-tab="quartiles">
Quartiles (${quartilesCount}/5)
Quartiles (${quartilesCount}${activeView === 'all' ? '' : '/5'})
</button>
<button class="tab-btn ${currentTab === '3-tiles' ? 'active' : ''}" data-tab="3-tiles">
3-Tiles (${threeTilesCount})
@@ -70,28 +106,55 @@ export function createWordsDrawer({ onRevealAll }) {
<div class="sheet-body">
${
displayWords.length === 0
isConfirmingReveal ? `
<div class="reveal-confirm-box">
<div class="reveal-confirm-icon">💡</div>
<h3 class="reveal-confirm-title">Reveal All Word Solutions?</h3>
<p class="reveal-confirm-desc">
This will reveal all <strong>${allPuzzleWords.length}</strong> words and their tile combinations for today's puzzle.
</p>
<div class="reveal-confirm-actions">
<button class="btn-confirm-reveal-yes" id="btn-confirm-reveal-yes">
Reveal All Solutions
</button>
<button class="btn-confirm-reveal-cancel" id="btn-confirm-reveal-cancel">
Keep Playing
</button>
</div>
</div>
` : 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>
${!isRevealed ? `
<button class="btn-reveal-all" id="btn-inline-reveal" style="margin-top:16px;">
💡 Reveal All Word Solutions
</button>
` : ''}
</div>`
: displayWords
.map(w => {
const isFound = state.foundWords.has(w.word.toLowerCase());
const isQuartile = w.tileCount === 4;
const clue = quartileDetailsMap.get(w.word.toLowerCase());
return `
<div class="word-card ${isQuartile ? 'quartile-card' : ''}" style="${!isFound ? 'opacity: 0.6; border-style: dashed;' : ''}">
<div class="word-card ${isQuartile ? 'quartile-card' : ''} ${!isFound ? 'revealed-card' : ''}">
<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>' : ''}
${isQuartile ? '<span title="Target Quartile (8 pts)"> ⭐</span>' : ''}
${
isFound
? '<span class="status-chip found">Found</span>'
: '<span class="status-chip revealed">Revealed</span>'
}
</div>
<div class="word-card-tiles">
${w.tileSegments.map(s => `<span class="tile-chip-mini">${s.toUpperCase()}</span>`).join('+')}
${(w.tileSegments || []).map(s => `<span class="tile-chip-mini">${s.toUpperCase()}</span>`).join('+')}
</div>
${clue ? `<div class="quartile-clue">💡 ${clue}</div>` : ''}
</div>
<div class="word-card-points">+${w.points}</div>
</div>
@@ -103,12 +166,18 @@ export function createWordsDrawer({ onRevealAll }) {
<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!'}
${
isRevealed
? 'All solutions revealed • You can keep testing words'
: state.getRank().isExpert
? '🏆 Expert reached! You can keep hunting or reveal solutions.'
: 'Reach 100 points for Expert rank!'
}
</div>
${
!isRevealed
!isRevealed && !isConfirmingReveal
? `<button class="btn-reveal-all" id="btn-trigger-reveal">
Reveal All Words
💡 Reveal All Words
</button>`
: ''
}
@@ -127,6 +196,7 @@ export function createWordsDrawer({ onRevealAll }) {
}
});
// Tab buttons
const tabBtns = backdrop.querySelectorAll('.tab-btn');
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
@@ -135,37 +205,54 @@ export function createWordsDrawer({ onRevealAll }) {
});
});
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);
// View mode buttons (All Solutions vs Words You Found)
const viewBtns = backdrop.querySelectorAll('.view-mode-btn');
viewBtns.forEach(btn => {
btn.addEventListener('click', () => {
viewMode = btn.getAttribute('data-view');
render(state);
});
});
// Trigger reveal prompt
const triggerBtn = backdrop.querySelector('#btn-trigger-reveal');
triggerBtn?.addEventListener('click', () => {
isConfirmingReveal = true;
render(state);
});
const inlineRevealBtn = backdrop.querySelector('#btn-inline-reveal');
inlineRevealBtn?.addEventListener('click', () => {
isConfirmingReveal = true;
render(state);
});
// Confirmation actions
backdrop.querySelector('#btn-confirm-reveal-yes')?.addEventListener('click', () => {
isConfirmingReveal = false;
viewMode = 'all';
onRevealAll();
}
});
backdrop.querySelector('#btn-confirm-reveal-cancel')?.addEventListener('click', () => {
isConfirmingReveal = false;
render(state);
});
}
}
function open() {
function open(showRevealPrompt = false) {
if (showRevealPrompt && lastState && !lastState.isRevealed) {
isConfirmingReveal = true;
render(lastState);
} else {
isConfirmingReveal = false;
if (lastState) render(lastState);
}
backdrop.classList.add('open');
}
function close() {
isConfirmingReveal = false;
backdrop.classList.remove('open');
}

View File

@@ -389,21 +389,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "gettable",
"points": 2,
"tileCount": 2,
"tileIndices": [
6,
7
],
"tileSegments": [
"get",
"table"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "grain",
"points": 2,
@@ -449,21 +434,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "kain",
"points": 2,
"tileCount": 2,
"tileIndices": [
2,
16
],
"tileSegments": [
"ka",
"in"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "marble",
"points": 2,
@@ -479,21 +449,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "marka",
"points": 2,
"tileCount": 2,
"tileIndices": [
1,
2
],
"tileSegments": [
"mar",
"ka"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "photo",
"points": 2,
@@ -539,21 +494,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "ratable",
"points": 2,
"tileCount": 2,
"tileIndices": [
18,
7
],
"tileSegments": [
"ra",
"table"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "ration",
"points": 2,
@@ -614,21 +554,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "retable",
"points": 2,
"tileCount": 2,
"tileIndices": [
0,
7
],
"tileSegments": [
"re",
"table"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "spire",
"points": 2,
@@ -688,131 +613,10 @@ export const CURATED_PUZZLES = [
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "venin",
"points": 2,
"tileCount": 2,
"tileIndices": [
13,
16
],
"tileSegments": [
"ven",
"in"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "venous",
"points": 2,
"tileCount": 2,
"tileIndices": [
13,
15
],
"tileSegments": [
"ven",
"ous"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "for",
"points": 1,
"tileCount": 1,
"tileIndices": [
5
],
"tileSegments": [
"for"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "get",
"points": 1,
"tileCount": 1,
"tileIndices": [
6
],
"tileSegments": [
"get"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "in",
"points": 1,
"tileCount": 1,
"tileIndices": [
16
],
"tileSegments": [
"in"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "mar",
"points": 1,
"tileCount": 1,
"tileIndices": [
1
],
"tileSegments": [
"mar"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "pho",
"points": 1,
"tileCount": 1,
"tileIndices": [
8
],
"tileSegments": [
"pho"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "table",
"points": 1,
"tileCount": 1,
"tileIndices": [
7
],
"tileSegments": [
"table"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "to",
"points": 1,
"tileCount": 1,
"tileIndices": [
9
],
"tileSegments": [
"to"
],
"isTargetQuartile": false,
"isQuartile": false
}
],
"wordCount": 44,
"maxPossibleScore": 181
"wordCount": 30,
"maxPossibleScore": 160
},
{
"id": "2026-10-24",
@@ -1188,36 +992,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "aspic",
"points": 2,
"tileCount": 2,
"tileIndices": [
0,
19
],
"tileSegments": [
"as",
"pic"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "aster",
"points": 2,
"tileCount": 2,
"tileIndices": [
0,
13
],
"tileSegments": [
"as",
"ter"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "coat",
"points": 2,
@@ -1263,21 +1037,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "ester",
"points": 2,
"tileCount": 2,
"tileIndices": [
17,
13
],
"tileSegments": [
"es",
"ter"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "grain",
"points": 2,
@@ -1323,21 +1082,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "moas",
"points": 2,
"tileCount": 2,
"tileIndices": [
9,
0
],
"tileSegments": [
"mo",
"as"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "moat",
"points": 2,
@@ -1353,21 +1097,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "mono",
"points": 2,
"tileCount": 2,
"tileIndices": [
9,
2
],
"tileSegments": [
"mo",
"no"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "motel",
"points": 2,
@@ -1443,21 +1172,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "stein",
"points": 2,
"tileCount": 2,
"tileIndices": [
14,
12
],
"tileSegments": [
"ste",
"in"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "stellar",
"points": 2,
@@ -1473,21 +1187,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "steno",
"points": 2,
"tileCount": 2,
"tileIndices": [
14,
2
],
"tileSegments": [
"ste",
"no"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "taco",
"points": 2,
@@ -1503,21 +1202,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "tain",
"points": 2,
"tileCount": 2,
"tileIndices": [
6,
12
],
"tileSegments": [
"ta",
"in"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "taste",
"points": 2,
@@ -1533,51 +1217,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "tater",
"points": 2,
"tileCount": 2,
"tileIndices": [
6,
13
],
"tileSegments": [
"ta",
"ter"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "troat",
"points": 2,
"tileCount": 2,
"tileIndices": [
1,
8
],
"tileSegments": [
"tro",
"at"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "tropic",
"points": 2,
"tileCount": 2,
"tileIndices": [
1,
19
],
"tileSegments": [
"tro",
"pic"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "vies",
"points": 2,
@@ -1592,131 +1231,10 @@ export const CURATED_PUZZLES = [
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "vino",
"points": 2,
"tileCount": 2,
"tileIndices": [
5,
2
],
"tileSegments": [
"vi",
"no"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "vita",
"points": 2,
"tileCount": 2,
"tileIndices": [
5,
6
],
"tileSegments": [
"vi",
"ta"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "as",
"points": 1,
"tileCount": 1,
"tileIndices": [
0
],
"tileSegments": [
"as"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "at",
"points": 1,
"tileCount": 1,
"tileIndices": [
8
],
"tileSegments": [
"at"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "ere",
"points": 1,
"tileCount": 1,
"tileIndices": [
11
],
"tileSegments": [
"ere"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "in",
"points": 1,
"tileCount": 1,
"tileIndices": [
12
],
"tileSegments": [
"in"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "my",
"points": 1,
"tileCount": 1,
"tileIndices": [
3
],
"tileSegments": [
"my"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "no",
"points": 1,
"tileCount": 1,
"tileIndices": [
2
],
"tileSegments": [
"no"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "pic",
"points": 1,
"tileCount": 1,
"tileIndices": [
19
],
"tileSegments": [
"pic"
],
"isTargetQuartile": false,
"isQuartile": false
}
],
"wordCount": 50,
"maxPossibleScore": 193
"wordCount": 30,
"maxPossibleScore": 160
},
{
"id": "2026-10-25",
@@ -3780,21 +3298,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "noma",
"points": 2,
"tileCount": 2,
"tileIndices": [
13,
10
],
"tileSegments": [
"no",
"ma"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "notion",
"points": 2,
@@ -3840,51 +3343,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "rota",
"points": 2,
"tileCount": 2,
"tileIndices": [
6,
18
],
"tileSegments": [
"ro",
"ta"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "tael",
"points": 2,
"tileCount": 2,
"tileIndices": [
18,
4
],
"tileSegments": [
"ta",
"el"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "tain",
"points": 2,
"tileCount": 2,
"tileIndices": [
18,
12
],
"tileSegments": [
"ta",
"in"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "taro",
"points": 2,
@@ -3944,49 +3402,10 @@ export const CURATED_PUZZLES = [
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "in",
"points": 1,
"tileCount": 1,
"tileIndices": [
12
],
"tileSegments": [
"in"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "no",
"points": 1,
"tileCount": 1,
"tileIndices": [
13
],
"tileSegments": [
"no"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "to",
"points": 1,
"tileCount": 1,
"tileIndices": [
9
],
"tileSegments": [
"to"
],
"isTargetQuartile": false,
"isQuartile": false
}
],
"wordCount": 37,
"maxPossibleScore": 151
"wordCount": 30,
"maxPossibleScore": 140
},
{
"id": "2026-10-29",
@@ -5082,21 +4501,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "stover",
"points": 2,
"tileCount": 2,
"tileIndices": [
18,
2
],
"tileSegments": [
"sto",
"ver"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "thunder",
"points": 2,
@@ -5201,36 +4605,10 @@ export const CURATED_PUZZLES = [
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "cat",
"points": 1,
"tileCount": 1,
"tileIndices": [
8
],
"tileSegments": [
"cat"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "lar",
"points": 1,
"tileCount": 1,
"tileIndices": [
11
],
"tileSegments": [
"lar"
],
"isTargetQuartile": false,
"isQuartile": false
}
],
"wordCount": 33,
"maxPossibleScore": 158
"wordCount": 30,
"maxPossibleScore": 154
},
{
"id": "2026-10-31",
@@ -5810,45 +5188,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "an",
"points": 1,
"tileCount": 1,
"tileIndices": [
16
],
"tileSegments": [
"an"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "in",
"points": 1,
"tileCount": 1,
"tileIndices": [
12
],
"tileSegments": [
"in"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "mys",
"points": 1,
"tileCount": 1,
"tileIndices": [
0
],
"tileSegments": [
"mys"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "nation",
"points": 1,
@@ -5874,23 +5213,10 @@ export const CURATED_PUZZLES = [
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "us",
"points": 1,
"tileCount": 1,
"tileIndices": [
3
],
"tileSegments": [
"us"
],
"isTargetQuartile": false,
"isQuartile": false
}
],
"wordCount": 34,
"maxPossibleScore": 152
"wordCount": 30,
"maxPossibleScore": 148
},
{
"id": "2026-10-22",
@@ -7516,21 +6842,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "vina",
"points": 2,
"tileCount": 2,
"tileIndices": [
5,
2
],
"tileSegments": [
"vi",
"na"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "vital",
"points": 2,
@@ -7545,23 +6856,10 @@ export const CURATED_PUZZLES = [
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "con",
"points": 1,
"tileCount": 1,
"tileIndices": [
8
],
"tileSegments": [
"con"
],
"isTargetQuartile": false,
"isQuartile": false
}
],
"wordCount": 32,
"maxPossibleScore": 161
"wordCount": 30,
"maxPossibleScore": 158
},
{
"id": "2026-10-19",
@@ -8980,19 +8278,6 @@ export const CURATED_PUZZLES = [
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "dis",
"points": 1,
"tileCount": 1,
"tileIndices": [
16
],
"tileSegments": [
"dis"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "nation",
"points": 1,
@@ -9018,36 +8303,10 @@ export const CURATED_PUZZLES = [
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "shed",
"points": 1,
"tileCount": 1,
"tileIndices": [
11
],
"tileSegments": [
"shed"
],
"isTargetQuartile": false,
"isQuartile": false
},
{
"word": "tin",
"points": 1,
"tileCount": 1,
"tileIndices": [
17
],
"tileSegments": [
"tin"
],
"isTargetQuartile": false,
"isQuartile": false
}
],
"wordCount": 33,
"maxPossibleScore": 149
"wordCount": 30,
"maxPossibleScore": 146
},
{
"id": "2026-10-16",

View File

@@ -7,7 +7,7 @@ export { POINTS_BY_TILE_COUNT, QUARTILE_COMPLETION_BONUS, EXPERT_RANK_POINTS, RA
* Solves a 20-tile board (5 quartets)
* Returns all constructible valid words, point values, and tile paths
*/
export function solveBoard(quartets) {
export function solveBoard(quartets, maxWords = 30) {
const tiles = quartets.flat();
const foundMap = new Map();
@@ -37,8 +37,7 @@ export function solveBoard(quartets) {
const quartilesList = quartets.map(q => q.join('').toLowerCase());
const quartileSet = new Set(quartilesList);
const words = [];
let totalBaseScore = 0;
let words = [];
for (const [word, path] of foundMap.entries()) {
const tileCount = path.length;
@@ -46,8 +45,6 @@ export function solveBoard(quartets) {
const isTargetQuartile = quartileSet.has(word);
const isQuartile = tileCount === 4;
totalBaseScore += points;
words.push({
word,
points,
@@ -59,13 +56,32 @@ export function solveBoard(quartets) {
});
}
// Sort words: Quartiles first, then by points descending, then alphabetically
// 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);
});
// Cap at maxWords (default: 30)
if (maxWords && words.length > maxWords) {
words = words.slice(0, maxWords);
}
// Final display sort: 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);
});
const totalBaseScore = words.reduce((sum, w) => sum + w.points, 0);
return {
tiles,
quartets,

View File

@@ -71,8 +71,9 @@ async function initApp() {
},
onRevealAll: () => {
gameState.revealAllWords();
gameState.notifyToast('All words revealed!', 'info');
wordsDrawer.open();
const count = gameState.currentPuzzle?.words?.length || 0;
gameState.notifyToast(`All ${count} word solutions revealed!`, 'info');
wordsDrawer.open(false);
}
});
document.body.appendChild(modals.element);
@@ -81,7 +82,8 @@ async function initApp() {
const wordsDrawer = createWordsDrawer({
onRevealAll: () => {
gameState.revealAllWords();
gameState.notifyToast('All words revealed!', 'info');
const count = gameState.currentPuzzle?.words?.length || 0;
gameState.notifyToast(`All ${count} word solutions revealed!`, 'info');
}
});
document.body.appendChild(wordsDrawer.element);
@@ -111,7 +113,10 @@ async function initApp() {
// Create Score & Rank Bar
const scoreRankBar = createScoreRankBar({
onOpenWordsDrawer: () => {
wordsDrawer.open();
wordsDrawer.open(false);
},
onOpenReveal: () => {
wordsDrawer.open(true);
}
});
appContainer.appendChild(scoreRankBar.element);
@@ -306,6 +311,12 @@ async function initApp() {
modals.render(state);
});
// Save state on unload/refresh
window.addEventListener('beforeunload', () => {
gameState.saveActivePuzzle();
gameState.saveProgress();
});
// Check URL hash for shared custom puzzle
const hash = window.location.hash;
if (hash && hash.includes('puzzle=')) {
@@ -323,7 +334,34 @@ async function initApp() {
}
}
// Load today's puzzle or default
// Restore previously active puzzle if available
const savedActivePuzzleId = localStorage.getItem('quartiles_active_puzzle_id');
const isCustomActive = localStorage.getItem('quartiles_active_is_custom') === 'true';
if (savedActivePuzzleId) {
if (isCustomActive) {
try {
const rawCustom = localStorage.getItem(`quartiles_custom_puzzle_${savedActivePuzzleId}`);
if (rawCustom) {
const parsedCustom = JSON.parse(rawCustom);
if (parsedCustom && parsedCustom.id) {
gameState.loadPuzzle(parsedCustom);
return;
}
}
} catch (e) {
console.warn('Failed to restore custom active puzzle:', e);
}
} else {
const matched = CURATED_PUZZLES.find(p => p.id === savedActivePuzzleId || p.date === savedActivePuzzleId);
if (matched) {
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);

View File

@@ -46,6 +46,7 @@ class GameStateManager {
}
this.currentPuzzle = puzzle;
this.saveActivePuzzle();
this.activeTileIds = [];
this.foundWords = new Map();
this.score = 0;
@@ -107,15 +108,38 @@ class GameStateManager {
return `${y}-${m}-${d}`;
}
// Save current active puzzle reference to LocalStorage
saveActivePuzzle() {
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_custom_puzzle_${this.currentPuzzle.id}`, JSON.stringify(this.currentPuzzle));
localStorage.setItem('quartiles_active_is_custom', 'true');
} else {
localStorage.removeItem('quartiles_active_is_custom');
}
} catch (err) {
console.warn('Failed to save active puzzle reference:', err);
}
}
// Save current puzzle progress to LocalStorage
saveProgress() {
if (!this.currentPuzzle) return;
this.saveActivePuzzle();
const saveKey = `quartiles_save_${this.currentPuzzle.id}`;
const payload = {
puzzleId: this.currentPuzzle.id,
foundWords: Array.from(this.foundWords.entries()),
score: this.score,
bonus: this.quartilesBonusAwarded,
tileOrder: this.boardTiles.map(t => t.id)
tileOrder: this.boardTiles.map(t => t.id),
activeTileIds: this.activeTileIds,
isRevealed: this.isRevealed,
hintIndex: this.hintIndex,
lastPlayed: Date.now()
};
try {
localStorage.setItem(saveKey, JSON.stringify(payload));
@@ -132,22 +156,45 @@ class GameStateManager {
const saved = localStorage.getItem(saveKey);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed && Array.isArray(parsed.foundWords)) {
this.foundWords = new Map(parsed.foundWords);
if (parsed) {
if (Array.isArray(parsed.foundWords)) {
const validWordsMap = new Map(this.currentPuzzle.words.map(w => [w.word.toLowerCase(), w]));
const filteredEntries = [];
let recalculatedScore = 0;
for (const [w, entry] of parsed.foundWords) {
const matchedWord = validWordsMap.get(w.toLowerCase());
if (matchedWord) {
entry.points = matchedWord.points;
entry.tileCount = matchedWord.tileCount;
entry.tileSegments = matchedWord.tileSegments;
filteredEntries.push([w.toLowerCase(), entry]);
recalculatedScore += matchedWord.points;
}
}
this.foundWords = new Map(filteredEntries);
this.score = recalculatedScore;
if (parsed.bonus) {
this.quartilesBonusAwarded = true;
this.score += 40;
}
} else {
this.score = parsed.score || 0;
this.quartilesBonusAwarded = !!parsed.bonus;
}
this.isRevealed = !!parsed.isRevealed;
this.hintIndex = parsed.hintIndex || 0;
// Re-mark solved quartiles
this.updateSolvedQuartileTiles();
// Restore tile order if saved
if (Array.isArray(parsed.tileOrder) && parsed.tileOrder.length === 20) {
// Check if saved order was the trivial unscrambled [0..19] order
const isSequential = parsed.tileOrder.every((id, idx) => id === idx);
if (isSequential && this.foundWords.size === 0) {
return false;
// Restore active tray tiles if any
if (Array.isArray(parsed.activeTileIds)) {
this.activeTileIds = parsed.activeTileIds.filter(id => id >= 0 && id < 20);
}
// Restore tile order if saved
if (Array.isArray(parsed.tileOrder) && parsed.tileOrder.length === 20) {
const isSequential = parsed.tileOrder.every((id, idx) => id === idx);
if (!isSequential || this.foundWords.size > 0) {
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) {
@@ -156,6 +203,8 @@ class GameStateManager {
}
}
}
return this.foundWords.size > 0;
}
}
} catch (err) {
console.warn('Failed to restore progress:', err);
@@ -228,6 +277,7 @@ class GameStateManager {
this.lockQuartiles = !this.lockQuartiles;
localStorage.setItem('quartiles_lock_setting', this.lockQuartiles ? 'true' : 'false');
this.reorganizeBoard();
this.saveProgress();
this.notify();
this.notifyToast(
this.lockQuartiles ? 'Quartiles locked at top' : 'Quartiles unlocked for free shuffle',
@@ -250,6 +300,7 @@ class GameStateManager {
}
this.activeTileIds.push(tileId);
this.saveProgress();
this.notify();
}
@@ -257,6 +308,7 @@ class GameStateManager {
removeActiveTile(index) {
if (index >= 0 && index < this.activeTileIds.length) {
this.activeTileIds.splice(index, 1);
this.saveProgress();
this.notify();
}
}
@@ -272,6 +324,7 @@ class GameStateManager {
) {
const [item] = this.activeTileIds.splice(fromIndex, 1);
this.activeTileIds.splice(toIndex, 0, item);
this.saveProgress();
this.notify();
}
}
@@ -280,6 +333,7 @@ class GameStateManager {
backspace() {
if (this.activeTileIds.length > 0) {
this.activeTileIds.pop();
this.saveProgress();
this.notify();
}
}
@@ -288,6 +342,7 @@ class GameStateManager {
clearActive() {
if (this.activeTileIds.length > 0) {
this.activeTileIds = [];
this.saveProgress();
this.notify();
}
}
@@ -470,6 +525,7 @@ class GameStateManager {
// Reveal all words (when expert reached or explicitly requested)
revealAllWords() {
this.isRevealed = true;
this.saveProgress();
this.notify();
}

View File

@@ -517,16 +517,199 @@
font-size: 0.82rem;
font-weight: 600;
color: var(--accent-danger);
padding: 6px 12px;
padding: 6px 14px;
border-radius: var(--radius-md);
background: rgba(239, 68, 68, 0.1);
transition: all var(--transition-fast);
cursor: pointer;
}
.btn-reveal-all:hover {
background: rgba(239, 68, 68, 0.18);
}
.badge-revealed {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: rgba(245, 158, 11, 0.15);
color: var(--accent-quartile);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: var(--radius-full);
font-size: 0.72rem;
font-weight: 700;
}
.drawer-view-mode-bar {
display: flex;
padding: 6px 16px;
gap: 8px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-subtle);
}
.view-mode-btn {
flex: 1;
padding: 6px 10px;
font-size: 0.8rem;
font-weight: 600;
border-radius: var(--radius-md);
color: var(--text-secondary);
background: transparent;
transition: all var(--transition-fast);
text-align: center;
cursor: pointer;
}
.view-mode-btn.active {
background: var(--bg-primary);
color: var(--text-primary);
font-weight: 700;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.word-card.revealed-card {
opacity: 0.9;
border-style: solid;
border-color: rgba(99, 102, 241, 0.25);
background: linear-gradient(135deg, rgba(99, 102, 241, 0.04), transparent);
}
.status-chip {
font-size: 0.68rem;
font-weight: 700;
padding: 1px 6px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-left: 6px;
}
.status-chip.found {
background: rgba(16, 185, 129, 0.15);
color: var(--accent-submit);
}
.status-chip.revealed {
background: rgba(245, 158, 11, 0.18);
color: var(--accent-quartile);
}
.quartile-clue {
font-size: 0.75rem;
color: var(--text-secondary);
font-style: italic;
margin-top: 2px;
}
.reveal-confirm-box {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 24px 20px;
text-align: center;
margin: 16px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.reveal-confirm-icon {
font-size: 2.4rem;
}
.reveal-confirm-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.reveal-confirm-desc {
font-size: 0.88rem;
color: var(--text-secondary);
line-height: 1.45;
margin: 0;
max-width: 320px;
}
.reveal-confirm-actions {
display: flex;
gap: 10px;
width: 100%;
max-width: 320px;
margin-top: 6px;
}
.btn-confirm-reveal-yes {
flex: 1;
padding: 10px 14px;
background: var(--accent-danger);
color: #ffffff;
font-weight: 700;
font-size: 0.88rem;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
cursor: pointer;
}
.btn-confirm-reveal-yes:hover {
opacity: 0.92;
transform: translateY(-1px);
}
.btn-confirm-reveal-cancel {
flex: 1;
padding: 10px 14px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-weight: 600;
font-size: 0.88rem;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
cursor: pointer;
}
.btn-confirm-reveal-cancel:hover {
background: var(--border-color);
}
.btn-strip-reveal {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: var(--radius-full);
font-size: 0.78rem;
font-weight: 700;
color: var(--accent-quartile);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-strip-reveal:hover {
background: rgba(245, 158, 11, 0.22);
transform: translateY(-1px);
}
.strip-revealed-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: var(--radius-full);
font-size: 0.72rem;
font-weight: 700;
color: var(--accent-quartile);
}
/* ==========================================================================
TOAST NOTIFICATIONS
========================================================================== */