Sync repo to deployed state: SEO recovery, Publishing Desk, Play games, emoji picker
The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.
SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
(a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
rep (URL stability) -> quality score, so an accepted page never retires to a
rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).
Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
picker (bundled data, no CDN) for the blurb editor.
Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.
Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
import { pushGameState } from '$lib/gamesync.js';
|
||||
import { COLOR_BY_KEY } from '$lib/games/match/palette.js';
|
||||
import { buildBoard, freeSeed } from '$lib/games/match/board.js';
|
||||
import MatchIcon from './MatchIcon.svelte';
|
||||
|
||||
// mode: 'daily' (shared, synced, feeds the ritual) | 'free' (local-only, replayable)
|
||||
// format: 'icons' | 'colors' · tier: 'gentle' | 'standard' | 'expert'
|
||||
let { mode = 'daily', format = 'icons', tier = 'standard', date = '', onstatus } = $props();
|
||||
|
||||
let isFree = $derived(mode === 'free');
|
||||
let variant = $derived(`${tier}-${format}`);
|
||||
// Storage: daily keyed by the server date (cross-device synced); free keeps one
|
||||
// resumable scratch board per variant.
|
||||
let stateKey = $derived(isFree ? `goodnews:match:free:${variant}` : `goodnews:match:${variant}:${date}`);
|
||||
const ASSIST_KEY = 'goodnews:match:assist';
|
||||
|
||||
let board = $state(null);
|
||||
let seed = $state('');
|
||||
let flipped = $state([]); // card ids face-up, not yet resolved
|
||||
let matchedKeys = $state(new Set());
|
||||
let moves = $state(0);
|
||||
let locked = $state(false); // brief hold while a mismatch is shown
|
||||
let ready = $state(false);
|
||||
let assist = $state(false); // colorblind assist: show a tiny letter on swatches
|
||||
let celebrated = $state(false);
|
||||
|
||||
let matchedIds = $derived(board ? new Set(board.cards.filter((c) => matchedKeys.has(c.key)).map((c) => c.id)) : new Set());
|
||||
let done = $derived(!!board && matchedKeys.size >= board.faces.length);
|
||||
|
||||
function read(key) { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } }
|
||||
function write(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch { /* ignore */ } }
|
||||
|
||||
function emitStatus() {
|
||||
onstatus?.({ count: matchedKeys.size, total: board?.faces.length ?? 0, moves, done });
|
||||
}
|
||||
|
||||
function persist() {
|
||||
const snap = { matched: [...matchedKeys], moves, done, seed: isFree ? seed : undefined };
|
||||
write(stateKey, snap);
|
||||
emitStatus();
|
||||
// Daily resumes across devices; free play stays local.
|
||||
if (!isFree && date) pushGameState('match', variant, date, { matched: snap.matched, moves, done });
|
||||
}
|
||||
|
||||
let loadSeq = 0;
|
||||
|
||||
async function load(forceNew = false) {
|
||||
const myLoad = ++loadSeq;
|
||||
ready = false;
|
||||
// A pending mismatch flip-back must not fire onto a freshly switched board.
|
||||
if (flipTimer) { clearTimeout(flipTimer); flipTimer = null; }
|
||||
try { assist = localStorage.getItem(ASSIST_KEY) === '1'; } catch { /* ignore */ }
|
||||
|
||||
const useFree = isFree;
|
||||
let saved, theSeed;
|
||||
if (useFree) {
|
||||
saved = forceNew ? null : read(stateKey);
|
||||
// a free board derives a stable seed so a reload resumes the same layout
|
||||
theSeed = saved?.seed || freeSeed((Date.now() ^ Math.floor(Math.random() * 1e9)) >>> 0);
|
||||
} else {
|
||||
theSeed = date;
|
||||
saved = read(stateKey);
|
||||
// pull/merge the server's copy first (cross-device resume)
|
||||
if (date) {
|
||||
const merged = await pushGameState('match', variant, date, {
|
||||
matched: saved?.matched || [], moves: saved?.moves || 0, done: !!saved?.done,
|
||||
});
|
||||
if (myLoad !== loadSeq) return; // a newer board load started — drop this stale one
|
||||
if (merged) saved = merged;
|
||||
}
|
||||
}
|
||||
if (myLoad !== loadSeq) return;
|
||||
|
||||
// Commit the new board + restored progress atomically AFTER any await, so a rapid
|
||||
// daily/free, icon/color, or tier switch can't hydrate an old response onto the
|
||||
// newer board.
|
||||
const b = buildBoard({ format, tier, seed: theSeed });
|
||||
const valid = new Set(b.faces);
|
||||
board = b;
|
||||
seed = theSeed;
|
||||
flipped = []; locked = false;
|
||||
matchedKeys = new Set((saved?.matched || []).filter((k) => valid.has(k)));
|
||||
moves = Number.isFinite(saved?.moves) ? saved.moves : 0;
|
||||
celebrated = matchedKeys.size >= b.faces.length; // don't replay the celebration on resume
|
||||
// Persist immediately so a freshly-generated free board's seed survives a reload
|
||||
// even before the first move.
|
||||
write(stateKey, { matched: [...matchedKeys], moves, done: matchedKeys.size >= b.faces.length,
|
||||
seed: useFree ? theSeed : undefined });
|
||||
ready = true;
|
||||
emitStatus();
|
||||
}
|
||||
|
||||
function reset() { if (isFree) load(true); }
|
||||
|
||||
function toggleAssist() {
|
||||
assist = !assist;
|
||||
try { localStorage.setItem(ASSIST_KEY, assist ? '1' : '0'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
let flipTimer = null;
|
||||
|
||||
function flip(card) {
|
||||
if (!ready || locked || done) return;
|
||||
if (matchedKeys.has(card.key) || flipped.includes(card.id)) return;
|
||||
|
||||
// Match off the LOCAL `next`, not the just-assigned reactive `flipped`, so the
|
||||
// evaluation never runs a click behind.
|
||||
const next = [...flipped, card.id];
|
||||
flipped = next;
|
||||
if (next.length < board.matchN) return; // Expert needs all 3 before judging
|
||||
|
||||
moves += 1;
|
||||
const keys = next.map((id) => board.cards[id].key);
|
||||
const allSame = keys.every((k) => k === keys[0]);
|
||||
if (allSame) {
|
||||
matchedKeys = new Set([...matchedKeys, keys[0]]);
|
||||
flipped = [];
|
||||
persist();
|
||||
if (matchedKeys.size >= board.faces.length && !celebrated) celebrated = true;
|
||||
} else {
|
||||
locked = true; // show the mismatch briefly, then flip back
|
||||
persist();
|
||||
flipTimer = setTimeout(() => { flipped = []; locked = false; flipTimer = null; }, 850);
|
||||
}
|
||||
}
|
||||
|
||||
function faceLabel(card) {
|
||||
if (format === 'colors') return COLOR_BY_KEY[card.key]?.name ?? 'color';
|
||||
return card.key;
|
||||
}
|
||||
|
||||
// Reload whenever the board identity (mode/format/tier/date, all encoded in
|
||||
// stateKey) changes. untrack() keeps the effect from tracking load()'s internal
|
||||
// state reads (board/matchedKeys/moves/done via emitStatus) — which load() also
|
||||
// writes — so a synchronous free-play load can't self-retrigger into a loop.
|
||||
$effect(() => {
|
||||
stateKey;
|
||||
untrack(() => load());
|
||||
return () => { if (flipTimer) clearTimeout(flipTimer); };
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="match">
|
||||
<div class="topbar">
|
||||
<div class="meta">
|
||||
{#if board}
|
||||
<span class="tier">{board.label}{format === 'colors' ? ' · Color' : ''}</span>
|
||||
<span class="prog">{matchedKeys.size}/{board.faces.length}{moves ? ` · ${moves} moves` : ''}</span>
|
||||
<span class="rule" class:tri={board.matchN === 3}>{#if board.matchN === 3 && flipped.length > 0 && flipped.length < 3}Pick 3 · {flipped.length}/3{:else}{board.matchN === 3 ? 'Match 3 of a kind' : 'Match pairs'}{/if}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="controls">
|
||||
{#if format === 'colors'}
|
||||
<button class="ctl" aria-pressed={assist} onclick={toggleAssist}>Assist {assist ? 'on' : 'off'}</button>
|
||||
{/if}
|
||||
{#if isFree}
|
||||
<button class="ctl" onclick={reset}>New board</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if board}
|
||||
<div class="grid" class:locked
|
||||
style="grid-template-columns: repeat({board.cols}, 1fr); max-width: {board.cols * 76}px;">
|
||||
{#each board.cards as card (card.id)}
|
||||
{@const up = matchedIds.has(card.id) || flipped.includes(card.id)}
|
||||
{@const matched = matchedIds.has(card.id)}
|
||||
<button class="card" class:up class:matched
|
||||
aria-label={up ? faceLabel(card) : 'card, face down'}
|
||||
disabled={matched}
|
||||
onclick={() => flip(card)}>
|
||||
<span class="inner">
|
||||
<span class="back" aria-hidden="true"></span>
|
||||
<span class="front" class:colored={format === 'colors'} aria-hidden="true">
|
||||
{#if format === 'colors'}
|
||||
<span class="colorfill" style="--c: {COLOR_BY_KEY[card.key]?.hex}">
|
||||
{#if assist}<span class="assist">{COLOR_BY_KEY[card.key]?.assist}</span>{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<MatchIcon name={card.key} />
|
||||
{/if}
|
||||
{#if matched}<span class="check" aria-hidden="true">✓</span>{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if done && celebrated}
|
||||
<p class="done">Lovely — you cleared {isFree ? 'the board' : "today's set"}.{isFree ? '' : ' Fresh one tomorrow.'}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.match { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||
.topbar { width: 100%; max-width: 460px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.meta { display: flex; flex-direction: column; gap: 1px; }
|
||||
.tier { font-family: var(--label); font-weight: 600; }
|
||||
.prog { color: var(--muted); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
|
||||
.rule { font-size: 0.8rem; color: var(--muted); margin-top: 2px; }
|
||||
/* Expert's 3-of-a-kind rule is the easy-to-forget one — give it a clear chip. */
|
||||
.rule.tri { align-self: flex-start; color: var(--accent-deep); background: var(--accent-soft);
|
||||
padding: 1px 9px; border-radius: 999px; font-weight: 600; }
|
||||
.controls { display: flex; gap: 8px; }
|
||||
.ctl { font-size: 0.8rem; padding: 5px 11px; border: 1px solid var(--line); border-radius: 9px;
|
||||
background: var(--surface); color: var(--accent-deep); cursor: pointer; }
|
||||
|
||||
.grid { display: grid; gap: 8px; width: 100%; }
|
||||
.card { aspect-ratio: 1; min-width: 0; padding: 0; border: none; background: none; cursor: pointer;
|
||||
perspective: 600px; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
|
||||
.card[disabled] { cursor: default; }
|
||||
.inner { position: relative; display: block; width: 100%; height: 100%;
|
||||
transition: transform 0.32s ease; transform-style: preserve-3d; }
|
||||
.card.up .inner { transform: rotateY(180deg); }
|
||||
.back, .front { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 13px; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
|
||||
/* Face-down: a branded card back — azure gradient under a faint dot motif. */
|
||||
.back { background:
|
||||
radial-gradient(circle at 50% 50%, rgba(255,255,255,0.18) 1.2px, transparent 1.6px) 0 0 / 11px 11px,
|
||||
linear-gradient(150deg, var(--accent) 0%, var(--accent-deep) 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18); }
|
||||
.front { background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
|
||||
transform: rotateY(180deg); }
|
||||
.front.colored { background: none; border: none; } /* the colorfill is the whole face */
|
||||
/* Icon front-and-center: ~70% of the card (≈50% area) so the symbol is memorable. */
|
||||
.front :global(svg) { width: 70%; height: 70%; }
|
||||
.colorfill { position: absolute; inset: 0; border-radius: 13px; background: var(--c);
|
||||
border: 2px solid #3a3a3a; }
|
||||
.assist { position: absolute; top: 5px; right: 6px; font-family: var(--label); font-weight: 700;
|
||||
font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.45); padding: 1px 5px; border-radius: 6px; }
|
||||
|
||||
/* State hierarchy ----------------------------------------------------------
|
||||
Inspecting (flipped, unmatched): vivid + lifted, so the unknown cards pop. */
|
||||
.card.up:not(.matched) .front:not(.colored) { box-shadow: 0 2px 8px rgba(0,0,0,0.12); }
|
||||
.card.up:not(.matched) .colorfill { box-shadow: 0 2px 8px rgba(0,0,0,0.20); }
|
||||
/* Matched: a settled "paint-chip" tile — receded, desaturated, with a quiet check,
|
||||
so the last few unknown cards stay obvious instead of blending in. */
|
||||
.card.matched .inner { transform: rotateY(180deg) scale(0.9); }
|
||||
.card.matched .front { box-shadow: none; }
|
||||
.card.matched .front.colored { background: var(--surface); border: 1px solid var(--line); }
|
||||
.card.matched .colorfill { inset: 5px; border-radius: 8px; border: 1px solid rgba(0,0,0,0.22);
|
||||
filter: saturate(0.6) brightness(1.06); box-shadow: none; }
|
||||
.card.matched :global(svg) { opacity: 0.42; }
|
||||
.card.matched .assist { display: none; }
|
||||
.check { position: absolute; bottom: 4px; right: 6px; font-size: 0.82rem; line-height: 1;
|
||||
color: var(--accent-deep); font-weight: 700; }
|
||||
|
||||
.done { margin: 4px 0 0; color: var(--accent-deep); font-family: var(--label); text-align: center; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.inner { transition: none; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user