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,166 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { auth } from '$lib/auth.svelte.js';
|
||||
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
|
||||
|
||||
let canvas = $state();
|
||||
let failed = $state(false);
|
||||
let loading = $state(true);
|
||||
|
||||
// Live tuning panel (admins, /zen?debug=1) — dial in the render without redeploys.
|
||||
let handle = $state(null);
|
||||
let dbg = $state(null);
|
||||
let debug = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
function apply() { handle?.setParams($state.snapshot(dbg)); }
|
||||
async function copyValues() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify($state.snapshot(dbg), null, 2));
|
||||
copied = true; setTimeout(() => (copied = false), 1500);
|
||||
} catch { /* clipboard blocked — values are still visible in the panel */ }
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Dev-gated while UB is being ironed out: non-admins (no preview token) bounce.
|
||||
if (blockedForViewer('zen', auth.user, $page.url)) { goto('/play'); return; }
|
||||
debug = $page.url.searchParams.get('debug') === '1';
|
||||
let h;
|
||||
let cancelled = false; // guard the async load against an early unmount
|
||||
(async () => {
|
||||
try {
|
||||
// WebGL guard — fall back to a warm card rather than a blank canvas.
|
||||
const t = document.createElement('canvas');
|
||||
if (!t.getContext('webgl2') && !t.getContext('webgl')) throw new Error('no-webgl');
|
||||
const { createAquarium } = await import('$lib/zen/aquarium.js'); // lazy: three loads only here
|
||||
h = await createAquarium(canvas);
|
||||
if (cancelled) { h.dispose(); return; } // left /zen mid-load — don't start a loop
|
||||
handle = h;
|
||||
if (debug) dbg = h.getParams();
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
console.warn('Zen Den could not start:', e);
|
||||
if (!cancelled) { failed = true; loading = false; }
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; h?.dispose(); };
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>The Zen Den · Upbeat Bytes</title>
|
||||
{#if isDevGated('zen')}<meta name="robots" content="noindex" />{/if}
|
||||
</svelte:head>
|
||||
|
||||
<header class="bar">
|
||||
<div class="container inner">
|
||||
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
||||
<a class="back" href="/play">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Play
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="zen container">
|
||||
<h1>The Zen Den</h1>
|
||||
<p class="sub">Drop in with UB for a quiet minute.</p>
|
||||
|
||||
<div class="tankwrap">
|
||||
{#if failed}
|
||||
<div class="fallback">
|
||||
<div class="fish">🐟</div>
|
||||
<p>UB's tank needs a browser with WebGL. He's resting for now — try again on a different device or browser.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<canvas bind:this={canvas} class="tank" aria-label="UB the koi, swimming"></canvas>
|
||||
{#if loading}<p class="loadnote">UB is settling in…</p>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if debug && dbg}
|
||||
<div class="panel">
|
||||
<div class="prow"><strong>UB render tuner</strong><button class="copy" onclick={copyValues}>{copied ? 'copied ✓' : 'copy values'}</button></div>
|
||||
|
||||
<label>yaw <span>{dbg.yaw.toFixed(2)}</span>
|
||||
<input type="range" min="-3.15" max="3.15" step="0.01" bind:value={dbg.yaw} oninput={apply} /></label>
|
||||
<label>pitch <span>{dbg.pitch.toFixed(2)}</span>
|
||||
<input type="range" min="-0.6" max="0.6" step="0.01" bind:value={dbg.pitch} oninput={apply} /></label>
|
||||
|
||||
<hr />
|
||||
<div class="ph">Tail</div>
|
||||
<label class="chk"><input type="checkbox" bind:checked={dbg.tailTranslucent} onchange={apply} /> translucent (off = opaque, coherent)</label>
|
||||
<label>side
|
||||
<select bind:value={dbg.tailSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
|
||||
<label>alphaTest <span>{dbg.tailAlphaTest.toFixed(3)}</span>
|
||||
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.tailAlphaTest} oninput={apply} /></label>
|
||||
{#if dbg.tailTranslucent}
|
||||
<label>opacity <span>{dbg.tailOpacity.toFixed(2)}</span>
|
||||
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.tailOpacity} oninput={apply} /></label>
|
||||
{/if}
|
||||
|
||||
<hr />
|
||||
<div class="ph">Fins</div>
|
||||
<label>side
|
||||
<select bind:value={dbg.finSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
|
||||
<label>opacity <span>{dbg.finOpacity.toFixed(2)}</span>
|
||||
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.finOpacity} oninput={apply} /></label>
|
||||
<label>alphaTest <span>{dbg.finAlphaTest.toFixed(3)}</span>
|
||||
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.finAlphaTest} oninput={apply} /></label>
|
||||
|
||||
<hr />
|
||||
<label class="chk"><input type="checkbox" bind:checked={dbg.paused} onchange={apply} /> freeze frame</label>
|
||||
{#if dbg.paused}
|
||||
<label>frame <span>{dbg.frame.toFixed(2)}</span>
|
||||
<input type="range" min="0" max="1" step="0.01" bind:value={dbg.frame} oninput={apply} /></label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
||||
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
||||
.logo { height: 40px; display: block; }
|
||||
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; }
|
||||
.back svg { width: 17px; height: 17px; display: block; }
|
||||
|
||||
.zen { padding: 22px 20px 60px; }
|
||||
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 4px; }
|
||||
.sub { color: var(--muted); margin: 0 0 18px; }
|
||||
|
||||
/* A calm tank: soft aqua gradient backdrop; the transparent WebGL canvas sits
|
||||
on top so the water reads even before we build the real bowl (Phase C). */
|
||||
.tankwrap {
|
||||
position: relative; width: 100%; max-width: 640px; aspect-ratio: 4 / 3;
|
||||
border-radius: 20px; overflow: hidden; box-shadow: var(--shadow);
|
||||
background: radial-gradient(120% 100% at 50% 0%, #d6f0f2 0%, #aadbe0 45%, #7cc3cc 100%);
|
||||
}
|
||||
.tank { display: block; width: 100%; height: 100%; }
|
||||
.loadnote { position: absolute; inset: auto 0 16px 0; text-align: center; color: var(--accent-deep);
|
||||
font-family: var(--label); font-size: 0.9rem; }
|
||||
.fallback { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 12px; text-align: center; padding: 24px; color: #2b5560; }
|
||||
.fallback .fish { font-size: 3rem; }
|
||||
.fallback p { max-width: 340px; margin: 0; }
|
||||
|
||||
/* Dev tuning panel (admins only, ?debug=1). */
|
||||
.panel { margin-top: 18px; max-width: 360px; padding: 14px 16px; border: 1px solid var(--line);
|
||||
border-radius: 14px; background: var(--surface); font-size: 0.82rem; }
|
||||
.prow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.panel hr { border: none; border-top: 1px solid var(--line); margin: 10px 0 6px; }
|
||||
.ph { font-family: var(--label); text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.72rem;
|
||||
color: var(--muted); margin-bottom: 4px; }
|
||||
.panel label { display: block; margin: 6px 0; color: var(--ink); }
|
||||
.panel label span { float: right; color: var(--accent-deep); font-variant-numeric: tabular-nums; }
|
||||
.panel label.chk { display: flex; align-items: center; gap: 7px; }
|
||||
.panel input[type="range"] { width: 100%; margin-top: 3px; }
|
||||
.panel select { width: 100%; margin-top: 3px; }
|
||||
.copy { font-size: 0.75rem; padding: 4px 9px; border: 1px solid var(--line); border-radius: 8px;
|
||||
background: var(--bg); color: var(--accent-deep); cursor: pointer; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.tankwrap { aspect-ratio: 3 / 4; } /* taller on phones */
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user