shuffle smaller words better
This commit is contained in:
@@ -229,6 +229,10 @@ export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuz
|
||||
<span style="color:var(--text-secondary);">Current Rank:</span>
|
||||
<strong>${state.getRank().current.badge} ${state.getRank().current.name}</strong>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; margin-top:4px; padding-top:4px; border-top:1px dashed var(--border-subtle);">
|
||||
<span style="color:var(--text-secondary);">Quartiles Discovered:</span>
|
||||
<strong>${state.getUniqueQuartilesSeenCount?.() || 0} / ${state.getTotalTargetWordsCount?.() || 1352}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -135,11 +135,20 @@ export function createWordsDrawer({ onRevealAll }) {
|
||||
</button>
|
||||
` : ''}
|
||||
</div>`
|
||||
: displayWords
|
||||
: `
|
||||
${
|
||||
currentTab === 'quartiles'
|
||||
? `<div class="quartile-tab-banner">
|
||||
<span>⭐ <strong>4-Tile Quartiles:</strong> Showing times seen across boards. Boards prioritize least-seen words.</span>
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
${displayWords
|
||||
.map(w => {
|
||||
const isFound = state.foundWords.has(w.word.toLowerCase());
|
||||
const isQuartile = w.tileCount === 4;
|
||||
const clue = quartileDetailsMap.get(w.word.toLowerCase());
|
||||
const seenCount = isQuartile ? (state.getQuartileSeenCount?.(w.word) || 0) : 0;
|
||||
|
||||
return `
|
||||
<div class="word-card ${isQuartile ? 'quartile-card' : ''} ${!isFound ? 'revealed-card' : ''}">
|
||||
@@ -154,6 +163,11 @@ export function createWordsDrawer({ onRevealAll }) {
|
||||
: '<span class="status-chip found">Found</span>')
|
||||
: '<span class="status-chip revealed">Revealed</span>'
|
||||
}
|
||||
${
|
||||
isQuartile
|
||||
? `<span class="status-chip quartile-seen" title="Times this 4-tile Quartile has been seen across boards">Seen ${seenCount}x</span>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<div class="word-card-tiles">
|
||||
${(w.tileSegments || []).map(s => `<span class="tile-chip-mini">${s.toUpperCase()}</span>`).join('+')}
|
||||
@@ -164,7 +178,8 @@ export function createWordsDrawer({ onRevealAll }) {
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('')
|
||||
.join('')}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import { TARGET_WORDS_POOL } from '../data/puzzles.js';
|
||||
import { solveBoard } from './solver.js';
|
||||
import { DICTIONARY_SET } from '../data/lexicon.js';
|
||||
import { createSeededRandom } from './random.js';
|
||||
import {
|
||||
getQuartileSeenCount,
|
||||
recordQuartilesSeen,
|
||||
selectTargetWordsWithSeenFilter
|
||||
} from './quartile-tracker.js';
|
||||
|
||||
export { createSeededRandom };
|
||||
|
||||
@@ -97,19 +102,12 @@ function pickWords(pool, { enforceVariety = true } = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectWords(candidatePool, rng) {
|
||||
const attempts = [
|
||||
() => pickWords(weightedShuffle(candidatePool, rng)),
|
||||
() => pickWords(weightedShuffle(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;
|
||||
}
|
||||
// Fallback if extreme constraints somehow failed
|
||||
return shuffle(TARGET_WORDS_POOL, rng).slice(0, 5);
|
||||
function selectWords(candidatePool, rng, getCountFn = getQuartileSeenCount) {
|
||||
return selectTargetWordsWithSeenFilter(candidatePool, rng, getCountFn, {
|
||||
pickWordsFn: pickWords,
|
||||
weightedShuffleFn: weightedShuffle,
|
||||
shuffleFn: shuffle
|
||||
});
|
||||
}
|
||||
|
||||
function buildPuzzle(selectedWords, meta) {
|
||||
@@ -144,50 +142,28 @@ function themeLabel(selectedWords) {
|
||||
* - All tiles are at least 2 characters (never 1 letter)
|
||||
* - Variety: at most one -tion/-sion answer, two 11+ letter answers, and one
|
||||
* tile that is a short word by itself (e.g. "in", "per")
|
||||
* - Anti-repetition: tracks recently used words in localStorage across games
|
||||
* (skipped when a seed is given, so seeded puzzles stay deterministic)
|
||||
* - Fair distribution: prioritizes words with least views (0 views before 1 view)
|
||||
* and increments seen counts when new boards are created
|
||||
*/
|
||||
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 = [];
|
||||
const selectedWords = selectWords(
|
||||
TARGET_WORDS_POOL,
|
||||
rng,
|
||||
seeded ? () => 0 : getQuartileSeenCount
|
||||
);
|
||||
|
||||
const puzzleId = 'custom-' + Date.now();
|
||||
|
||||
// If live game (unseeded), track newly seen quartiles
|
||||
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
|
||||
}
|
||||
recordQuartilesSeen(selectedWords.map(w => w.word), puzzleId);
|
||||
}
|
||||
|
||||
return buildPuzzle(selectedWords, {
|
||||
id: 'custom-' + Date.now(),
|
||||
id: puzzleId,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
title: `Unlimited Puzzle #${Math.floor(rng() * 9000 + 1000)}`,
|
||||
theme: themeLabel(selectedWords),
|
||||
@@ -195,9 +171,62 @@ export function generateRandomPuzzle(seed) {
|
||||
});
|
||||
}
|
||||
|
||||
const DAILY_EPOCH = '2026-01-01';
|
||||
const dailySelectionsCache = new Map();
|
||||
const dailySequenceSeenCounts = new Map();
|
||||
let lastComputedDailyDate = null;
|
||||
|
||||
function computeDailySelectionsUpTo(targetDateStr) {
|
||||
if (dailySelectionsCache.has(targetDateStr)) {
|
||||
return dailySelectionsCache.get(targetDateStr);
|
||||
}
|
||||
|
||||
// Handle dates before epoch
|
||||
if (targetDateStr < DAILY_EPOCH) {
|
||||
const rng = createSeededRandom(`quartiles-daily-${targetDateStr}`);
|
||||
const selected = selectWords(TARGET_WORDS_POOL, rng, () => 0);
|
||||
dailySelectionsCache.set(targetDateStr, selected);
|
||||
return selected;
|
||||
}
|
||||
|
||||
let cur;
|
||||
if (!lastComputedDailyDate) {
|
||||
cur = new Date(DAILY_EPOCH + 'T00:00:00');
|
||||
} else {
|
||||
cur = new Date(lastComputedDailyDate + 'T00:00:00');
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
|
||||
const target = new Date(targetDateStr + 'T00:00:00');
|
||||
|
||||
while (cur <= target) {
|
||||
const y = cur.getFullYear();
|
||||
const m = String(cur.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(cur.getDate()).padStart(2, '0');
|
||||
const dStr = `${y}-${m}-${d}`;
|
||||
|
||||
if (!dailySelectionsCache.has(dStr)) {
|
||||
const rng = createSeededRandom(`quartiles-daily-${dStr}`);
|
||||
const selected = selectWords(
|
||||
TARGET_WORDS_POOL,
|
||||
rng,
|
||||
(w) => dailySequenceSeenCounts.get(w.toLowerCase()) || 0
|
||||
);
|
||||
dailySelectionsCache.set(dStr, selected);
|
||||
for (const item of selected) {
|
||||
const key = item.word.toLowerCase();
|
||||
dailySequenceSeenCounts.set(key, (dailySequenceSeenCounts.get(key) || 0) + 1);
|
||||
}
|
||||
lastComputedDailyDate = dStr;
|
||||
}
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
|
||||
return dailySelectionsCache.get(targetDateStr);
|
||||
}
|
||||
|
||||
function selectDaily(dateStr) {
|
||||
const rng = createSeededRandom(`quartiles-daily-${dateStr}`);
|
||||
const selectedWords = selectWords(TARGET_WORDS_POOL, rng);
|
||||
const selectedWords = computeDailySelectionsUpTo(dateStr);
|
||||
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 = {
|
||||
|
||||
258
src/engine/quartile-tracker.js
Normal file
258
src/engine/quartile-tracker.js
Normal file
@@ -0,0 +1,258 @@
|
||||
import { TARGET_WORDS_POOL } from '../data/puzzles.js';
|
||||
|
||||
const STORAGE_KEY_SEEN_COUNTS = 'quartiles_quartile_seen_counts';
|
||||
const STORAGE_KEY_RECORDED_PUZZLES = 'quartiles_recorded_puzzle_ids';
|
||||
|
||||
let inMemoryCounts = null;
|
||||
let inMemoryRecordedPuzzles = null;
|
||||
|
||||
function isStorageAvailable() {
|
||||
try {
|
||||
return typeof localStorage !== 'undefined' && localStorage !== null;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadCounts() {
|
||||
if (inMemoryCounts) return inMemoryCounts;
|
||||
inMemoryCounts = new Map();
|
||||
if (isStorageAvailable()) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_SEEN_COUNTS);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v === 'number' && v > 0) {
|
||||
inMemoryCounts.set(k.toLowerCase(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load quartile seen counts:', e);
|
||||
}
|
||||
}
|
||||
return inMemoryCounts;
|
||||
}
|
||||
|
||||
function saveCounts() {
|
||||
if (!inMemoryCounts || !isStorageAvailable()) return;
|
||||
try {
|
||||
const obj = {};
|
||||
for (const [k, v] of inMemoryCounts.entries()) {
|
||||
obj[k] = v;
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY_SEEN_COUNTS, JSON.stringify(obj));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save quartile seen counts:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadRecordedPuzzles() {
|
||||
if (inMemoryRecordedPuzzles) return inMemoryRecordedPuzzles;
|
||||
inMemoryRecordedPuzzles = new Set();
|
||||
if (isStorageAvailable()) {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_RECORDED_PUZZLES);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const id of parsed) {
|
||||
inMemoryRecordedPuzzles.add(String(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load recorded puzzles:', e);
|
||||
}
|
||||
}
|
||||
return inMemoryRecordedPuzzles;
|
||||
}
|
||||
|
||||
function saveRecordedPuzzles() {
|
||||
if (!inMemoryRecordedPuzzles || !isStorageAvailable()) return;
|
||||
try {
|
||||
const arr = Array.from(inMemoryRecordedPuzzles);
|
||||
localStorage.setItem(STORAGE_KEY_RECORDED_PUZZLES, JSON.stringify(arr.slice(-500)));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save recorded puzzles:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how many times a given 4-tile quartile word has been seen
|
||||
*/
|
||||
export function getQuartileSeenCount(word) {
|
||||
if (!word) return 0;
|
||||
const counts = loadCounts();
|
||||
return counts.get(word.toLowerCase().trim()) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all quartile seen counts as a plain object { word: count }
|
||||
*/
|
||||
export function getAllQuartileSeenCounts() {
|
||||
const counts = loadCounts();
|
||||
const obj = {};
|
||||
for (const [k, v] of counts.entries()) {
|
||||
obj[k] = v;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns count of distinct quartile words seen at least once
|
||||
*/
|
||||
export function getUniqueQuartilesSeenCount() {
|
||||
const counts = loadCounts();
|
||||
return counts.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns total number of target quartile words in the pool
|
||||
*/
|
||||
export function getTotalTargetWordsCount() {
|
||||
return TARGET_WORDS_POOL.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a puzzle ID has already had its quartiles recorded
|
||||
*/
|
||||
export function hasRecordedPuzzle(puzzleId) {
|
||||
if (!puzzleId) return false;
|
||||
const set = loadRecordedPuzzles();
|
||||
return set.has(String(puzzleId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments seen count for the given quartile words.
|
||||
* If puzzleId is provided, guarantees each puzzle is recorded at most once.
|
||||
*/
|
||||
export function recordQuartilesSeen(words, puzzleId = null) {
|
||||
if (!Array.isArray(words) || words.length === 0) return false;
|
||||
|
||||
if (puzzleId) {
|
||||
const recorded = loadRecordedPuzzles();
|
||||
if (recorded.has(String(puzzleId))) {
|
||||
return false; // Already recorded for this puzzle instance
|
||||
}
|
||||
recorded.add(String(puzzleId));
|
||||
saveRecordedPuzzles();
|
||||
}
|
||||
|
||||
const counts = loadCounts();
|
||||
for (const w of words) {
|
||||
if (!w) continue;
|
||||
const key = (typeof w === 'string' ? w : w.word || '').toLowerCase().trim();
|
||||
if (!key) continue;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
saveCounts();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all tracked quartile view counts and recorded puzzles
|
||||
*/
|
||||
export function resetQuartileSeenCounts() {
|
||||
inMemoryCounts = new Map();
|
||||
inMemoryRecordedPuzzles = new Set();
|
||||
if (isStorageAvailable()) {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY_SEEN_COUNTS);
|
||||
localStorage.removeItem(STORAGE_KEY_RECORDED_PUZZLES);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core selection algorithm:
|
||||
* "Don't include words that have been seen more than the rest of the words.
|
||||
* So if there's still words with 0 views show those before any that have seen 1 time.
|
||||
* If there's not enough words left not seen more than the rest and you are making a new board
|
||||
* then use the ones seen and increment those being seen.
|
||||
* Basically, seen less than the other words == include in board, seen more == exclude from boards"
|
||||
*/
|
||||
export function selectTargetWordsWithSeenFilter(
|
||||
pool,
|
||||
rng,
|
||||
getCountFn,
|
||||
{ pickWordsFn, weightedShuffleFn, shuffleFn } = {}
|
||||
) {
|
||||
const getCount = getCountFn || getQuartileSeenCount;
|
||||
if (!pool || pool.length === 0) return [];
|
||||
if (pool.length <= 5) return pool;
|
||||
|
||||
// 1. Find minimum seen count in the pool
|
||||
let minCount = Infinity;
|
||||
for (const item of pool) {
|
||||
const c = getCount(item.word);
|
||||
if (c < minCount) minCount = c;
|
||||
}
|
||||
|
||||
// 2. Partition candidate pool into tiers by seen count
|
||||
const tierMin = [];
|
||||
const tierNext = [];
|
||||
const tierRest = [];
|
||||
|
||||
for (const item of pool) {
|
||||
const c = getCount(item.word);
|
||||
if (c === minCount) {
|
||||
tierMin.push(item);
|
||||
} else if (c === minCount + 1) {
|
||||
tierNext.push(item);
|
||||
} else {
|
||||
tierRest.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const shuffle =
|
||||
shuffleFn ||
|
||||
(arr => {
|
||||
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;
|
||||
});
|
||||
|
||||
const weightShuffle = weightedShuffleFn || shuffle;
|
||||
const pick = pickWordsFn;
|
||||
|
||||
// Attempt 1: Strict minSeen only (0 views before 1 view)
|
||||
let result = pick(weightShuffle(tierMin, rng));
|
||||
if (result) return result;
|
||||
|
||||
// Attempt 2: If minSeen alone cannot form 5 non-clashing words,
|
||||
// greedily pick from tierMin first, filling only remaining slots from tierNext
|
||||
if (tierNext.length > 0) {
|
||||
result = pick([...weightShuffle(tierMin, rng), ...weightShuffle(tierNext, rng)]);
|
||||
if (result) return result;
|
||||
|
||||
result = pick([...shuffle(tierMin, rng), ...shuffle(tierNext, rng)]);
|
||||
if (result) return result;
|
||||
|
||||
result = pick([...shuffle(tierMin, rng), ...shuffle(tierNext, rng)], { enforceVariety: false });
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
// Attempt 3: Relax variety limits on tierMin
|
||||
result = pick(shuffle(tierMin, rng), { enforceVariety: false });
|
||||
if (result) return result;
|
||||
|
||||
// Attempt 4: Expand to tierRest if strictly needed
|
||||
if (tierRest.length > 0) {
|
||||
result = pick(
|
||||
[...shuffle(tierMin, rng), ...shuffle(tierNext, rng), ...shuffle(tierRest, rng)],
|
||||
{ enforceVariety: false }
|
||||
);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
// Fallback fallback
|
||||
return shuffle(pool, rng).slice(0, 5);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ import { getRankInfo, QUARTILE_COMPLETION_BONUS, POINTS_BY_TILE_COUNT } from '..
|
||||
import { isAcceptedWord } from '../data/lexicon.js';
|
||||
import { resolveBuiltInPuzzle, isBuiltInPuzzleId, getDailyPuzzle, toDateString } from '../engine/daily.js';
|
||||
import { trackGameStart, trackGameFinish } from '../engine/analytics.js';
|
||||
import {
|
||||
recordQuartilesSeen,
|
||||
getQuartileSeenCount,
|
||||
getUniqueQuartilesSeenCount,
|
||||
getTotalTargetWordsCount
|
||||
} from '../engine/quartile-tracker.js';
|
||||
|
||||
// Identifies the exact board, so saves from an older version of a puzzle id are ignored
|
||||
function boardSignature(puzzle) {
|
||||
@@ -81,6 +87,12 @@ class GameStateManager {
|
||||
// Organize board tiles according to lock state
|
||||
this.reorganizeBoard();
|
||||
|
||||
// Track quartiles seen for this puzzle (at most once per puzzle ID)
|
||||
const quartilesToRecord = puzzle.targetQuartiles || puzzle.quartileDetails?.map(q => q.word) || [];
|
||||
if (quartilesToRecord.length > 0) {
|
||||
recordQuartilesSeen(quartilesToRecord, puzzle.id);
|
||||
}
|
||||
|
||||
// Track game start in analytics
|
||||
const rank = this.getRank();
|
||||
const isCompleted = rank.isPerfect || this.isRevealed;
|
||||
@@ -665,6 +677,19 @@ class GameStateManager {
|
||||
return { totalWordsFound: 0, expertCount: 0, perfectCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Quartile view tracking helpers
|
||||
getQuartileSeenCount(word) {
|
||||
return getQuartileSeenCount(word);
|
||||
}
|
||||
|
||||
getUniqueQuartilesSeenCount() {
|
||||
return getUniqueQuartilesSeenCount();
|
||||
}
|
||||
|
||||
getTotalTargetWordsCount() {
|
||||
return getTotalTargetWordsCount();
|
||||
}
|
||||
}
|
||||
|
||||
export const gameState = new GameStateManager();
|
||||
|
||||
@@ -596,6 +596,27 @@
|
||||
color: var(--accent-quartile);
|
||||
}
|
||||
|
||||
.status-chip.quartile-seen {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: var(--accent-blue);
|
||||
border: 1px solid rgba(59, 130, 246, 0.28);
|
||||
}
|
||||
|
||||
.quartile-tab-banner {
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.08), rgba(59, 130, 246, 0.06));
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.quartile-tab-banner strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quartile-clue {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "1.0.6"
|
||||
"version": "1.0.7"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user