Compare commits
171 Commits
39d682f353
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cf65243e07 | |||
| 45bd44834e | |||
| d13811319d | |||
| 9365f69b4a | |||
| a47897e7b1 | |||
| ce69b8cd18 | |||
| e64c5ced3c | |||
| c42f29537b | |||
| 494e9dfcdd | |||
| 6b2458f674 | |||
| f3005e626d | |||
| d79c52dab4 | |||
| 4d2f5e3703 | |||
| 35aa8ff544 | |||
| 2dc4419024 | |||
| 9d46e03ab8 | |||
| f416e13700 | |||
| 8a7606e20d | |||
| a55ba185a8 | |||
| c350a2713b | |||
| ee43bb0df6 | |||
| 27022108b4 | |||
| 86d9897113 | |||
| 3740e09d02 | |||
| c33dad9832 | |||
| 8a3c00db3b | |||
| cb06d550bd | |||
| d98cec9ded | |||
| bddb8d22b0 | |||
| b8ac82e897 | |||
| 0ae789752e | |||
| 414a4c4b8b | |||
| 03aed9c37d | |||
| 667b1a82c3 | |||
| f8628b3b14 | |||
| 1bd86e30e5 | |||
| 2cfffdfd6a | |||
| 1c1ecefde8 | |||
| c600145ba5 | |||
| 0d21231597 | |||
| 54761f5083 | |||
| 39b38f0cf1 | |||
| 036e7ed7e8 | |||
| e974fc4942 | |||
| 2fd28fa719 | |||
| f4a7a7bcc7 | |||
| 099bf55711 | |||
| 6c10ad99a9 | |||
| e3e6f24753 | |||
| 022908392b | |||
| 998e758614 | |||
| 14c2648f8f | |||
| fe0c2988c2 | |||
| 883c37b428 | |||
| 36b3df5d40 | |||
| 719a2c5052 | |||
| 2e43766d71 | |||
| aa8ee674d5 | |||
| d969810c10 | |||
| e2e59bfdc4 | |||
| 783b853aee | |||
| cf018dc36d | |||
| ba0838dd94 | |||
| 50488a1885 | |||
| ecfc49eda0 | |||
| 123602dc52 | |||
| 3dc72b1d31 | |||
| a74a363728 | |||
| 60a1b50376 | |||
| 5a7c90e7be | |||
| f6ae44e126 | |||
| aa15cf119c | |||
| 6348835099 | |||
| 485c4a7805 | |||
| 89987b8316 | |||
| 420150a532 | |||
| 9c41da5fcc | |||
| 5157c221ed | |||
| ed814c97b9 | |||
| 79ecb800af | |||
| 44610703c2 | |||
| 376f6263fd | |||
| 8dc22afbdc | |||
| 5657494988 | |||
| 1c6c907f7f | |||
| f0e02b40e5 | |||
| 8a79b14c80 | |||
| eaeb8d3139 | |||
| 1150ae916b | |||
| 06dd293da0 | |||
| 4720ded29c | |||
| cf66d99e21 | |||
| dc23277b38 | |||
| bdf3b1f47b | |||
| 26b23a8f09 | |||
| ccdc764b2b | |||
| c809594b43 | |||
| cebbed58ab | |||
| e5f3d942e2 | |||
| d3c7282850 | |||
| b174d8d2a7 | |||
| 2e9d81bd86 | |||
| 84b1fb514f | |||
| 3bde6534e9 | |||
| 67d4bc32cb | |||
| a7da8362ab | |||
| 4739d87f4b | |||
| ea85e9cdfb | |||
| 48a0cc1111 | |||
| 147701e7dc | |||
| de53873b4f | |||
| 440e8391d6 | |||
| d3017598a2 | |||
| 18e950bbf7 | |||
| 319c5f6b7e | |||
| 6925a6951b | |||
| 390bf5f43d | |||
| 1ffc9c6e92 | |||
| 8037e72f17 | |||
| 772cb47d69 | |||
| e38081733d | |||
| 89352e7123 | |||
| 8cf061d214 | |||
| 3c519a5f50 | |||
| 2173802c70 | |||
| de94d179bd | |||
| 65cb770514 | |||
| f4b20bdf79 | |||
| 2e3cc5f382 | |||
| 5306c65c7b | |||
| 703ea22e0b | |||
| c95ca8775a | |||
| a8762a1dd8 | |||
| 79fb9b44fc | |||
| d85e174a35 | |||
| b83a3797e4 | |||
| 84e251e8ec | |||
| e298d25c92 | |||
| 4684a216b8 | |||
| b172c5eefd | |||
| 5a8e178f51 | |||
| dd8706e2fc | |||
| 39c666b4ee | |||
| 006c1b2792 | |||
| c2c5b25ab1 | |||
| 90d3e8cbe6 | |||
| 71fb16761b | |||
| cd8175be81 | |||
| 27788ba2a8 | |||
| 9bfec573e2 | |||
| db967bb7fa | |||
| 308516a263 | |||
| 0c68c22221 | |||
| b4b02b5050 | |||
| 3486f3102a | |||
| d2a6293a13 | |||
| 2239549799 | |||
| 3861ed4060 | |||
| e7e8f5515e | |||
| ad4e88c8f2 | |||
| 1c05554a28 | |||
| 59ff48ae90 | |||
| 89c0fbe1f6 | |||
| 2dbe73430c | |||
| 7279b18fdc | |||
| ddcfab3a11 | |||
| 64339aafb0 | |||
| de59cf49d8 | |||
| 065ab98598 | |||
| dd0df64d76 | |||
| 2ef0efd909 |
@@ -1,8 +1,14 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
|
node_modules/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
data/*.sqlite3
|
data/*.sqlite3
|
||||||
data/*.sqlite3-*
|
data/*.sqlite3-*
|
||||||
|
data/*.db
|
||||||
|
data/geo_audit*.json
|
||||||
|
|
||||||
logs/
|
logs/
|
||||||
|
data/art_cache/
|
||||||
|
data/img_cache/
|
||||||
|
tools/glb-split/incoming/
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Upbeat Bytes
|
# upbeatBytes
|
||||||
|
|
||||||
Calm, constructive news — local-first ingestion, scoring, and a daily brief.
|
Calm, constructive news — local-first ingestion, scoring, and a daily brief.
|
||||||
(The Python package and CLI are named `goodnews` for historical reasons; the
|
(The Python package and CLI are named `goodnews` for historical reasons; the
|
||||||
product is **Upbeat Bytes**, at upbeatbytes.com.)
|
product is **upbeatBytes**, at upbeatbytes.com.)
|
||||||
|
|
||||||
The first milestone is intentionally small: collect public RSS/Atom metadata, dedupe it, store short source-provided snippets, and attach early reason-coded heuristic scores. It does not store full article bodies.
|
The first milestone is intentionally small: collect public RSS/Atom metadata, dedupe it, store short source-provided snippets, and attach early reason-coded heuristic scores. It does not store full article bodies.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,177 @@
|
|||||||
|
# SNAPSHOT (read-only) of the live Caddy config.
|
||||||
|
# Live source of truth: /home/jay/srv/caddy/caddy-config/Caddyfile (mounted into the 'caddy' container).
|
||||||
|
# Captured so the upbeatbytes try_files {path} {path}.html change is tracked. Do not edit here expecting it to deploy.
|
||||||
|
{
|
||||||
|
email thejayman77@gmail.com
|
||||||
|
}
|
||||||
|
|
||||||
|
tjm77.com, www.tjm77.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
root * /srv/sites/tjm77
|
||||||
|
file_server
|
||||||
|
encode gzip zstd
|
||||||
|
log {
|
||||||
|
output file /data/access-tjm77.log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsj-designs.com, www.jsj-designs.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
root * /srv/sites/jsj
|
||||||
|
file_server
|
||||||
|
encode gzip zstd
|
||||||
|
log {
|
||||||
|
output file /data/access-jsj.log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Canonical host = apex. www redirects to it BEFORE the app, so OAuth always
|
||||||
|
# starts from the same host its callback uses (the ub_oauth cookie is host-only;
|
||||||
|
# starting from www then bouncing to the apex callback loses it → error=google).
|
||||||
|
www.upbeatbytes.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
redir https://upbeatbytes.com{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
upbeatbytes.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
encode gzip zstd
|
||||||
|
|
||||||
|
# Drop vuln-scanner probes for stacks we don't run. We're a SvelteKit SPA + FastAPI:
|
||||||
|
# zero PHP, no WordPress, no exposed dotfiles — so these paths can NEVER be a real
|
||||||
|
# user or a wanted search crawler (matching is path-only, never by User-Agent, so
|
||||||
|
# Googlebot/Bing are untouched). Without this they fall through try_files to the SPA
|
||||||
|
# shell and get a 200; now they get a clean 403 (still logged, so probes stay visible).
|
||||||
|
@junk path *.php /wp-admin* /wp-login* /wp-includes* /wp-content* /wp-json* /xmlrpc.php /.env /.env.* /.git /.git/* /phpmyadmin* /pma* /myadmin* /dbadmin* /vendor/* /.aws/* /.ssh/* /cgi-bin/* /administrator/*
|
||||||
|
handle @junk {
|
||||||
|
respond 403
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retired prototype routes (promoted/removed at the news relaunch) → the hub.
|
||||||
|
@oldhome path /home2 /home2.html /home3 /home3.html
|
||||||
|
handle @oldhome {
|
||||||
|
redir https://upbeatbytes.com/ permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dynamic API + server-rendered pages (share, digest, sitemap) → FastAPI.
|
||||||
|
@api path /api/* /healthz /docs /docs/* /openapi.json /a/* /today /sitemap.xml
|
||||||
|
handle @api {
|
||||||
|
reverse_proxy upbeatbytes-api:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
# Everything else → the static SvelteKit SPA. try_files falls back to
|
||||||
|
# index.html so deep client routes (e.g. /auth/verify) boot the app
|
||||||
|
# instead of 404ing.
|
||||||
|
handle {
|
||||||
|
root * /srv/sites/upbeatbytes
|
||||||
|
|
||||||
|
# Hidden in-progress prototypes — keep crawlers out at the HTTP level (the JS
|
||||||
|
# <meta robots> isn't seen by non-JS bots since the static shell is generic).
|
||||||
|
# Only admin stays out of the index now — news, art, play, and the joy pages are public.
|
||||||
|
@hidden path /admin /admin.html
|
||||||
|
header @hidden X-Robots-Tag "noindex, nofollow"
|
||||||
|
|
||||||
|
# Content-hashed assets never change for a given URL — cache them forever.
|
||||||
|
@immutable path /_app/immutable/*
|
||||||
|
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# Static texture + font assets — large/unchanging. Cache them forever like
|
||||||
|
# immutable assets; rename the file if one ever changes.
|
||||||
|
@assets path /textures/* /fonts/*
|
||||||
|
header @assets Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# The SPA shell: "/" and extensionless client routes (try_files → index.html).
|
||||||
|
# Briefly cacheable at the CDN edge (s-maxage) so a first paint never depends
|
||||||
|
# on this origin's uplink; browsers still revalidate every visit (max-age=0).
|
||||||
|
# A deploy propagates within ≤2min and old immutable chunks are kept for a
|
||||||
|
# 14-day grace window, so a briefly-stale shell still boots cleanly.
|
||||||
|
# (Requires a Cloudflare Cache Rule marking these paths eligible — CF does
|
||||||
|
# not cache HTML by default.)
|
||||||
|
@shell {
|
||||||
|
not path /_app/immutable/*
|
||||||
|
not path *.*
|
||||||
|
}
|
||||||
|
header @shell Cache-Control "public, max-age=0, s-maxage=120, stale-while-revalidate=600"
|
||||||
|
|
||||||
|
# Mutable FILES (service worker, version manifest, webmanifest, word lists,
|
||||||
|
# icons) must revalidate every time — a pinned stale service worker is the
|
||||||
|
# classic blank-screen cause behind a CDN.
|
||||||
|
@revalidate {
|
||||||
|
not path /_app/immutable/*
|
||||||
|
not path /textures/*
|
||||||
|
not path /fonts/*
|
||||||
|
path *.*
|
||||||
|
}
|
||||||
|
header @revalidate Cache-Control "no-cache"
|
||||||
|
|
||||||
|
# Serve a route's own prerendered HTML when it exists (e.g. /play -> play.html,
|
||||||
|
# which carries its own canonical/OG metadata), else fall back to the SPA shell.
|
||||||
|
# Cache-Control matchers above run on the ORIGINAL extensionless path, so /play
|
||||||
|
# still gets the @shell header before this rewrite.
|
||||||
|
try_files {path} {path}.html /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /data/access-upbeatbytes.log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
git.tjm77.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
reverse_proxy gitea:3000
|
||||||
|
encode gzip zstd
|
||||||
|
log {
|
||||||
|
output file /data/access-gitea.log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.tjm77.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
encode gzip zstd
|
||||||
|
# Recipe finder (What can I make? web tier) — must precede the arbiter catch-all.
|
||||||
|
@finder path /v1/find-recipes /v1/find-recipes/*
|
||||||
|
handle @finder {
|
||||||
|
reverse_proxy finder:8090
|
||||||
|
}
|
||||||
|
# Per-device token registration → auth service.
|
||||||
|
@register path /v1/register
|
||||||
|
handle @register {
|
||||||
|
reverse_proxy auth:8070
|
||||||
|
}
|
||||||
|
# In-app feedback relay → auth service (validates the device token + SMTP-sends).
|
||||||
|
# Keeps the arbiter a pure LLM gateway. Must precede the arbiter catch-all.
|
||||||
|
@feedback path /v1/feedback
|
||||||
|
handle @feedback {
|
||||||
|
reverse_proxy auth:8070
|
||||||
|
}
|
||||||
|
# App update policy — public static JSON (no secrets). Edit ~/srv/sites/api/app-version.json
|
||||||
|
# to change what testers are prompted to update to; no redeploy needed. Precedes the catch-all.
|
||||||
|
@appversion path /v1/app-version
|
||||||
|
handle @appversion {
|
||||||
|
root * /srv/sites
|
||||||
|
rewrite * /api/app-version.json
|
||||||
|
header Content-Type application/json
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
# LLM Arbiter (everything else); Bearer auth enforced by the arbiter.
|
||||||
|
handle {
|
||||||
|
reverse_proxy arbiter:8080
|
||||||
|
}
|
||||||
|
log {
|
||||||
|
output file /data/access-arbiter.log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Publish Upbeat Bytes: build the frontend, sync it to the live Caddy site,
|
# Publish upbeatBytes: build the frontend, sync it to the live Caddy site,
|
||||||
# rebuild/restart the API container, and reload Caddy. One command to redeploy.
|
# rebuild/restart the API container, and reload Caddy. One command to redeploy.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -26,4 +26,4 @@ curl -fsS -o /dev/null -w ' logo-email.png → %{http_code} %{content_type}\n'
|
|||||||
https://upbeatbytes.com/logo-email.png \
|
https://upbeatbytes.com/logo-email.png \
|
||||||
|| echo " ⚠ logo-email.png is not being served — the digest masthead would break!"
|
|| echo " ⚠ logo-email.png is not being served — the digest masthead would break!"
|
||||||
|
|
||||||
echo "✓ Published Upbeat Bytes → https://upbeatbytes.com"
|
echo "✓ Published upbeatBytes → https://upbeatbytes.com"
|
||||||
|
|||||||
+14
-35
@@ -1,51 +1,30 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Sync the built static site to the live root in an order that avoids deploy-race
|
# Sync the built static site to the live root in an order that avoids deploy-race
|
||||||
# blank screens AND post-deploy slow loads. rsync isn't atomic, so a naïve
|
# blank screens. rsync isn't atomic, so a naïve `rsync --delete` can briefly serve
|
||||||
# `rsync --delete` can briefly serve a NEW index.html that points at chunks not
|
# a NEW index.html that points at chunks not synced yet (→ failed load), or delete
|
||||||
# synced yet (→ failed load), or delete old chunks an in-flight client still
|
# old chunks an in-flight client still needs. So the order is:
|
||||||
# needs. And even once synced, a new shell can point at chunks that aren't warm
|
# 1. new hashed chunks first, and DON'T prune old ones (14-day grace window)
|
||||||
# at the CDN edge yet, so the unlucky first visitor fetches them cold from the
|
# 2. other static assets (version.json, icons…), pruning removed files
|
||||||
# (residential) origin — the post-deploy slow-load window. So the order is:
|
# 3. the shell HTML — only once its chunks exist
|
||||||
# 1. new hashed chunks first, and DON'T prune old ones (grace window)
|
# 4. the service worker last — a returning client adopts it only after the rest
|
||||||
# 2. WARM those chunks at the edge BEFORE the shell goes live, so a new shell
|
# Old immutable chunks are pruned after the grace window to bound disk growth.
|
||||||
# never references cold chunks
|
#
|
||||||
# 3. other static assets (version.json, icons…), pruning removed files
|
# NOTE: there is intentionally NO edge cache-warming. Cloudflare is DNS-only
|
||||||
# 4. the shell HTML — only once its chunks exist AND are warm
|
# (grey-cloud) for upbeatbytes.com — no proxy/CDN/POP — so warming would just GET
|
||||||
# 5. the service worker last — a returning client adopts it only after the rest
|
# every asset from the residential origin (already µs-fast static): a no-op that
|
||||||
# 6. warm the shell + key routes after publish
|
# generated lots of internal request volume. Removed 2026-06-29.
|
||||||
# Old immutable chunks are pruned after a grace window to bound disk growth.
|
|
||||||
# NOTE: warming runs from this host, so it primes the Cloudflare POP nearest the
|
|
||||||
# origin (great for local/nearby users + our own post-deploy testing); a distant
|
|
||||||
# POP still cold-fills once on its first hit — inherent to a residential origin.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
src="$1"; site="$2"
|
src="$1"; site="$2"
|
||||||
base="https://upbeatbytes.com"
|
|
||||||
|
|
||||||
# Warm a newline-separated list of paths (on stdin) through the public domain.
|
|
||||||
# Best-effort: a warm miss must never fail the deploy.
|
|
||||||
warm() { xargs -P 8 -I{} curl -fsS -o /dev/null --max-time 20 "$base{}" 2>/dev/null || true; }
|
|
||||||
|
|
||||||
# 1. New hashed chunks first (old ones kept — 14-day grace for in-flight clients).
|
# 1. New hashed chunks first (old ones kept — 14-day grace for in-flight clients).
|
||||||
rsync -a "$src/_app/immutable/" "$site/_app/immutable/"
|
rsync -a "$src/_app/immutable/" "$site/_app/immutable/"
|
||||||
|
|
||||||
# 2. Warm the chunks BEFORE publishing the shell, so the new shell never points
|
# 2. Other static assets (prune removed files), then 3. the shell, then 4. the SW.
|
||||||
# at chunks still cold at the edge (the post-deploy slow-load cause).
|
|
||||||
echo " warming new chunks (before publish)…"
|
|
||||||
find "$site/_app/immutable" -type f \( -name '*.js' -o -name '*.css' \) -printf '/_app/immutable/%P\n' | warm
|
|
||||||
|
|
||||||
# 3. Other static assets (prune removed files), then 4. the shell, then 5. the SW.
|
|
||||||
rsync -a --delete \
|
rsync -a --delete \
|
||||||
--exclude='_app/immutable/***' --exclude='index.html' --exclude='service-worker.js' \
|
--exclude='_app/immutable/***' --exclude='index.html' --exclude='service-worker.js' \
|
||||||
"$src/" "$site/"
|
"$src/" "$site/"
|
||||||
rsync -a "$src/index.html" "$site/index.html"
|
rsync -a "$src/index.html" "$site/index.html"
|
||||||
rsync -a "$src/service-worker.js" "$site/service-worker.js"
|
rsync -a "$src/service-worker.js" "$site/service-worker.js"
|
||||||
|
|
||||||
# 6. Warm the shell + key routes + remaining static now that they're published
|
|
||||||
# (primes CF↔origin even where no-cache; caches the cacheable shell/routes).
|
|
||||||
echo " warming shell + routes (after publish)…"
|
|
||||||
printf '%s\n' / /play /account /admin /service-worker.js /_app/version.json \
|
|
||||||
/manifest.webmanifest /words-5.json /words-6.json /logo.svg /favicon.svg \
|
|
||||||
/icon-192.png /icon-512.png /fonts/inter-latin-wght-normal.woff2 | warm
|
|
||||||
|
|
||||||
# Bound disk growth: prune immutable chunks older than the grace window.
|
# Bound disk growth: prune immutable chunks older than the grace window.
|
||||||
find "$site/_app/immutable" -type f -mtime +14 -delete 2>/dev/null || true
|
find "$site/_app/immutable" -type f -mtime +14 -delete 2>/dev/null || true
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Images & visitor metrics — policy of record
|
||||||
|
|
||||||
|
Encodes the decisions from the 2026-06-30 Codex audit. Update this doc when the policy changes.
|
||||||
|
|
||||||
|
## Article images — source-level rights policy
|
||||||
|
|
||||||
|
We do **not** blanket-cache publisher images. Caching (re-hosting a copy on our origin) is
|
||||||
|
**opt-in per source**; the safe default is to display the publisher's own image without copying it.
|
||||||
|
|
||||||
|
`sources.image_policy` (one of):
|
||||||
|
|
||||||
|
| policy | meaning | when to use |
|
||||||
|
|---|---|---|
|
||||||
|
| `cache` | Re-host a downscaled WebP (≤800px) at `/api/img/<id>` | ONLY sources we've cleared: open license (CC etc.), explicit permission, public domain, or our own/gov public-domain material |
|
||||||
|
| `remote` (**default**) | Hotlink the publisher's image URL (with the frontend's graceful retry) | Anything not explicitly cleared — display, never copy |
|
||||||
|
| `none` | No image (typographic topic cover) | Sources whose terms don't support display, or known-bad imagery |
|
||||||
|
|
||||||
|
**Default for new/unknown sources is `remote`.** Nothing is re-hosted until an admin sets a
|
||||||
|
source to `cache` (admin → Sources → expand a source → Images selector, or
|
||||||
|
`POST /api/admin/sources/{id}/image-policy {"policy": "cache"}`).
|
||||||
|
|
||||||
|
Why conservative: fair use is case-specific and balances four factors; "reduced + attributed +
|
||||||
|
linked" is good practice but **not permission**, and the search-engine-thumbnail precedent
|
||||||
|
(Perfect 10 v. Amazon) turned on a specifically transformative image-search function, not a
|
||||||
|
universal thumbnail exemption. So re-hosting waits on a per-source rights basis.
|
||||||
|
|
||||||
|
### How resolution works
|
||||||
|
- `newsimg.display_url(article_id, image_policy, raw_url)` returns the display URL: `/api/img/<id>`
|
||||||
|
for `cache`, the publisher URL for `remote`, `None` for `none`/no-image.
|
||||||
|
- Applied server-side in `Article.from_row` (feed/brief/history) and `share.py` (the `/a/<id>`
|
||||||
|
page). `og:image`/`twitter:image` always reference the **publisher's own** image URL (a link,
|
||||||
|
not a copy) — never our cached path — so social crawlers don't hit our endpoint.
|
||||||
|
- The frontend uses the resolved `image_url` as-is; the hub probe-retry + ArticleCard `onerror`
|
||||||
|
cover slow/failed loads for both cached and hotlinked URLs.
|
||||||
|
|
||||||
|
### Cache mechanics (`goodnews/newsimg.py`)
|
||||||
|
- **The cycle owns all fetching** (`warm()`), gated on `image_policy='cache'`, under the cycle lock.
|
||||||
|
`GET /api/img/<id>` serves **cache hits only** (never fetches), restricted to accepted + canonical
|
||||||
|
articles whose source is `cache`. No SSRF/worker-exhaustion surface on the public endpoint.
|
||||||
|
- `_safe_fetch`: http(s) only, `_host_is_public` on every redirect hop (HTTPError-based redirects
|
||||||
|
followed), body capped. 4xx≠429 = permanent (negative-cached via `<sha1>.fail`); 429/5xx/network = transient.
|
||||||
|
- `_encode`: decoded raster → WebP only; rejects SVG/undecodable; pixel ceiling enforced
|
||||||
|
(`w*h > _MAX_PIXELS`) **before** decode. Originals are never retained.
|
||||||
|
- Bounded: hard size cap (default 1 GB, `GOODNEWS_IMG_CACHE_CAP`) with LRU eviction; `.fail`
|
||||||
|
markers swept after `_FAIL_TTL_S`. `data/img_cache/` is gitignored (runtime data).
|
||||||
|
- **Revocation:** when a source leaves `cache` (set to `remote`/`none` in admin), the endpoint
|
||||||
|
calls `newsimg.purge_source()` to delete that source's re-hosted copies **immediately** — they
|
||||||
|
don't linger on disk. (Setting *to* `cache` just flips the flag; the cycle warms it.)
|
||||||
|
|
||||||
|
## Visitor metrics — Recorded visits vs Engaged readers
|
||||||
|
|
||||||
|
A JS-capable bot can trip the visit beacon, so the admin shows two numbers:
|
||||||
|
|
||||||
|
- **Recorded visits** — raw count: one daily `visit` beacon per device. Known-bot User-Agents are
|
||||||
|
filtered at `/api/events` (`queries.is_bot_ua`), but UA-spoofing bots still land here. Noisy.
|
||||||
|
- **Engaged readers** — distinct visitor-day with **deliberate** activity (the honest number):
|
||||||
|
- the gesture-gated `engaged` beacon (`analytics.armEngaged`, mirrored on the share page) — fires
|
||||||
|
once/day only after ~8s visible **and** a real scroll/pointer/key/touch; or
|
||||||
|
- a deliberate action: `source_click`, `full_story`, `share_ub`/`copy_source`/`native_share`,
|
||||||
|
`replace_used`, `paywall_replace`, `paywalled_source_open`, `not_today`/`less_like_this`/`hide_topic`,
|
||||||
|
or a game `started`/`completed`/`shared`.
|
||||||
|
- **Never** counts auto-fired `visit`/`summary_viewed`/`open`, `replace_none`, or game `*_arrival`.
|
||||||
|
- Defined by `queries.ENGAGED_EVENT_KINDS`; surfaced as `visitors.engaged_today/d7/d30`.
|
||||||
|
|
||||||
|
**Warm-up caveat:** the `engaged` beacon began **2026-06-30**, so rolling windows fill over time —
|
||||||
|
a low `engaged_d7`/`engaged_d30` is partly warm-up, NOT proof the gap to recorded visits was all
|
||||||
|
bots. Compare `d7` after a full week, `d30` after thirty days. (Admin shows this note inline.)
|
||||||
|
|
||||||
|
Privacy unchanged: only a salted `visitor_hash` is stored (no IP, no raw token, no fingerprint).
|
||||||
|
|
||||||
|
### Referrer suppression on remote images
|
||||||
|
Every on-site image request for a `remote` source sets `referrerpolicy="no-referrer"` so the
|
||||||
|
publisher CDN doesn't get the referring URL: article cards, the share page, AND the homepage hero
|
||||||
|
(converted from a CSS `background-image` to a real `<img>` — the retry probe sets
|
||||||
|
`probe.referrerPolicy='no-referrer'` too). This hides the *referrer*, **not** the visitor's IP —
|
||||||
|
any remote image necessarily exposes the IP to the CDN. For zero third-party image requests, the
|
||||||
|
source must be `none` or explicitly cleared for local caching (`cache`).
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# News relaunch — link/redirect map + interim-routing plan
|
||||||
|
|
||||||
|
Scope: stand up `/news` (the feed's new home) and cut `/home3` → `/` (hub) without breaking
|
||||||
|
the feed, deep links, or SEO. Verified against the codebase 2026-06-28. Settled with the
|
||||||
|
user + Codex; amendments folded in. **Next build = the extraction (§A, pure refactor).**
|
||||||
|
|
||||||
|
## A. Interim routing — no broken window, no duplicated impl
|
||||||
|
|
||||||
|
The feed currently IS `routes/+page.svelte` (~1,065 lines: views, BottomNav, MoodNav,
|
||||||
|
LanePicker, SavedFlyout, search). Don't clone it. Extract once, mount twice.
|
||||||
|
|
||||||
|
1. **Extract** the feed UI from `routes/+page.svelte` → `lib/components/NewsFeed.svelte`
|
||||||
|
(verbatim move; URL base becomes `/news`). `parseView`/`urlForView` switch base `/` → `/news`.
|
||||||
|
2. **Mount at both** during transition:
|
||||||
|
- `routes/news/+page.svelte` → `<NewsFeed/>`
|
||||||
|
- `routes/+page.svelte` → `<NewsFeed/>` (interim; identical component)
|
||||||
|
One implementation, both routes live, zero interim breakage.
|
||||||
|
- **`/news` stays hidden during transition**: `X-Robots-Tag: noindex, follow` (so we don't
|
||||||
|
publish a duplicate indexable feed). Removed at cutover, when `/news` is added to the sitemap.
|
||||||
|
3. **Parity test** at `/news` (every view, deep link, Back/Forward, account action) — see §F.
|
||||||
|
4. **Restyle** `NewsFeed` once (CD editorial + reusable card — replaces `ArticleCard`).
|
||||||
|
5. **Behavior fix** (separate, deliberate, post-parity): Latest stays chronological; Highlights
|
||||||
|
gets prefs + geo scope.
|
||||||
|
6. **Cutover**: `routes/+page.svelte` → hub content (move from `home3`); add the legacy-query
|
||||||
|
redirect shim (§C); drop `/news` noindex + add to sitemap; `/news` stays the feed.
|
||||||
|
|
||||||
|
## B. Link/redirect map
|
||||||
|
|
||||||
|
Rule of thumb: **brand/logo/"home"/"back to UB" → `/` (hub)**; **anything "more news /
|
||||||
|
browse / a feed view" → `/news`**; **feed-internal view URLs → `/news?…` base**;
|
||||||
|
**`/home3` fallbacks → `/`**.
|
||||||
|
|
||||||
|
### B1. Brand / home / back → `/` (KEEP)
|
||||||
|
- `Header.svelte:9` brand `/`; `account:103` brand `/`, `:108` back `/`; `admin:678` brand `/`,
|
||||||
|
`:28` non-admin bounce `/`; `auth/verify:24` post-signin `/` (DECIDED: always hub; future
|
||||||
|
optional start-screen setting), `:38` back `/`; `zen:59` brand `/`;
|
||||||
|
`share.py:197,327` brand logo `/`, `:354` "Back to Upbeat Bytes" `/`; `api.py:781` unsubscribe back `/`.
|
||||||
|
|
||||||
|
### B2. News CTAs → `/news` (CHANGE)
|
||||||
|
- `home3:198` "Read more good news" `/` → **`/news`** (the loop bug)
|
||||||
|
- `share.py:210` "Explore Upbeat Bytes →" → **`/news`**
|
||||||
|
- `share.py:332` "Browse more on Upbeat Bytes →" → **`/news`**
|
||||||
|
|
||||||
|
### B3. Feed-internal view URLs → `/news?…` base (CHANGE, via the extraction)
|
||||||
|
- `routes/+page.svelte` (→ NewsFeed): `urlForView` builds `/?source=`,`/?tag=`,`/?view=`
|
||||||
|
(`:39-41`), search `goto('/?q=')` (`:50`), clear `goto('/')` (`:689`), `urlForView('today')`
|
||||||
|
returns `/` (`:344,503`) → all rebase to `/news`.
|
||||||
|
- `account:196` Following `/?view=following` → **`/news?view=following`**
|
||||||
|
- `share.py:30` source link `/?source={id}` → **`/news?source={id}`**
|
||||||
|
|
||||||
|
### B4. `/home3` fallbacks → `/` (CHANGE)
|
||||||
|
- `HubBar:18` home nav `/home3` → **`/`**; `:28` brand `/home3` → **`/`**
|
||||||
|
- `HubShell:17` back fallback `goto('/home3')` → **`/`**
|
||||||
|
- `art:41`, `play:213` back fallback `goto('/home3')` → **`/`**
|
||||||
|
|
||||||
|
## C. Redirects
|
||||||
|
- **Legacy root-query shim** on the new hub `/` — runs **before the hub fetches/renders** (no
|
||||||
|
hub flash), `replaceState`:
|
||||||
|
- `/?view=today` → `/news?view=highlights`
|
||||||
|
- `/?view=latest` → `/news` (Latest is the default)
|
||||||
|
- `?tag=`, `?source=`, `?q=`, other `?view=` → carry across to `/news?…`
|
||||||
|
- `/news?view=today` remains an accepted **alias** (old links never break)
|
||||||
|
- **`/home2`, `/home2.html`, `/home3`, `/home3.html` → `/` permanent (301)** (Caddy). A bare
|
||||||
|
route delete would just serve the SPA fallback — redirect explicitly.
|
||||||
|
|
||||||
|
## D. Infra
|
||||||
|
- **Caddy `@hidden`** (currently `/home2 /home3 /word /word.html /quote /quote.html
|
||||||
|
/onthisday /onthisday.html /admin /admin.html`):
|
||||||
|
- REMOVE `/word* /quote* /onthisday*` (indexable at launch)
|
||||||
|
- `/home3*`,`/home2*` → 301 redirects (out of @hidden)
|
||||||
|
- **`/news` carries `X-Robots-Tag: noindex, follow` until cutover**, then removed
|
||||||
|
- KEEP `/admin*`; `/a/*` still routed to FastAPI
|
||||||
|
- **Sitemap** (`api.py sitemap()`): raise `LIMIT 5000` → ~50000; gate on having a real summary
|
||||||
|
(skip ~31 incomplete; ~+1,000 URLs); add static `/news` (at cutover) + `/art /play /word
|
||||||
|
/quote /onthisday` (keep `/`,`/today`). Fix **`HEAD /sitemap.xml`** (currently 404).
|
||||||
|
- **Head patcher** (`patch-static-heads.mjs`): add `/news` (title/desc/canonical/OG).
|
||||||
|
- **PWA description** (`manifest.webmanifest` + `app.html` description/og/twitter): currently
|
||||||
|
news-only — broaden to the hub (news + daily art + games + small resets).
|
||||||
|
- **Footer**: ONE shared `Footer.svelte` — consistent core (**motto + Send feedback**) + a
|
||||||
|
default **slot for per-section extras**. **`FeedbackModal` stays in the global layout**;
|
||||||
|
only the layout's `<footer.site>` *markup* is removed (and HubShell's `<footer.foot>`).
|
||||||
|
**Coverage inventory** — the shared footer must be explicitly added to every public surface:
|
||||||
|
**Hub, News, Play, Art, HubShell details, Account, Zen** (if dev-visible). Admin/auth get a
|
||||||
|
deliberate **minimal** treatment (explicit, not by omission).
|
||||||
|
|
||||||
|
## E. Behavior (deliberate, post-parity)
|
||||||
|
- **Latest** = newest accepted after safety/boundary exclusions; **stop passing `home`** (or add
|
||||||
|
an explicit "Local first" lane) so Latest ≠ local-first.
|
||||||
|
- **Highlights** = ranked around interests + geo scope dial (kept at launch).
|
||||||
|
- `trackVisit()` stays global; `markBriefSeen()` only inside Highlights.
|
||||||
|
|
||||||
|
## F. Cutover checklist (rehearsal first, hidden)
|
||||||
|
1. **GSC review** (coverage / manual-actions / crawl) BEFORE rehearsal.
|
||||||
|
2. **Parity** at `/news`: each view (today/latest/following/tag/source/search/mood/topic),
|
||||||
|
deep links, Back/Forward single-history, account actions (save/follow/hide/replace), PWA.
|
||||||
|
3. **Legacy redirects**: `/?view=today`→`/news?view=highlights`, `/?view=latest`→`/news`,
|
||||||
|
`/?tag=…`,`/?source=…`,`/?q=…`→`/news?…`; `/home2*`,`/home3*`→`/`.
|
||||||
|
4. **SEO**: `/` 200 + indexable + canonical; `/a/*` 200 + self-canonical (unchanged); `/today`
|
||||||
|
200 indexable; `/news` noindex dropped + in sitemap; joy/art/play noindex removed + in
|
||||||
|
sitemap; sitemap GET + HEAD 200.
|
||||||
|
5. **Caches**: anon Latest/Brief edge-cacheable (45s) intact; personalized private/no-store.
|
||||||
|
SW is a kill-switch (no bump) — just verify.
|
||||||
|
6. **Promote** → live 200/301/canonical/cache checks; resubmit sitemap in GSC.
|
||||||
Generated
+8
@@ -7,6 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "goodnews-web",
|
"name": "goodnews-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.184.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.8.0",
|
"@sveltejs/kit": "^2.8.0",
|
||||||
@@ -1540,6 +1543,11 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.184.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||||
|
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host",
|
"dev": "vite dev --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"postbuild": "node scripts/patch-static-heads.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
@@ -17,5 +18,8 @@
|
|||||||
"svelte": "^5.1.0",
|
"svelte": "^5.1.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.184.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Post-build: give specific prerendered shells their OWN social/canonical metadata.
|
||||||
|
//
|
||||||
|
// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's
|
||||||
|
// HOMEPAGE <head> — meaning a shared /play or /word link previews as the news homepage,
|
||||||
|
// and its canonical points at "/". Client svelte:head can't fix that for non-JS scrapers
|
||||||
|
// (Twitter/Slack/iMessage) or for canonical dedup. So we rewrite each page's static head
|
||||||
|
// here, at build time. Deep-linked variants (e.g. /play?game=…) inherit the same file.
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const BASE = 'https://upbeatbytes.com';
|
||||||
|
|
||||||
|
// Per-page <head> overrides. Keep titles/descriptions in sync with each page's intent.
|
||||||
|
const PAGES = [
|
||||||
|
{
|
||||||
|
file: 'news.html', path: '/news',
|
||||||
|
title: 'News · upbeatBytes — calm, constructive news',
|
||||||
|
desc: 'Calm, constructive news, newest first — and a daily Highlights brief. ' +
|
||||||
|
'No ads, no paywalls, no doomscrolling.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'play.html', path: '/play',
|
||||||
|
title: 'Play · upbeatBytes — calm daily games',
|
||||||
|
desc: 'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
|
||||||
|
'A friendly little break from the doomscroll.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'word.html', path: '/word',
|
||||||
|
title: 'Word of the Day · upbeatBytes',
|
||||||
|
desc: 'A new uplifting word every day — its meaning in plain language, how to say it, and how to use it.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'quote.html', path: '/quote',
|
||||||
|
title: 'Quote of the Day · upbeatBytes',
|
||||||
|
desc: 'A hopeful, hand-picked quote each day, with a short note on what it means.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'onthisday.html', path: '/onthisday',
|
||||||
|
title: 'On This Day · upbeatBytes',
|
||||||
|
desc: 'One genuinely good thing that happened on this day in history.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'art.html', path: '/art',
|
||||||
|
title: 'Daily Art · upbeatBytes',
|
||||||
|
desc: "A masterwork a day from the world's open museum collections — beautifully framed, " +
|
||||||
|
'with a short note on what you’re looking at.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function subsFor(url, title, desc) {
|
||||||
|
return [
|
||||||
|
[/<title>[\s\S]*?<\/title>/, `<title>${title}</title>`],
|
||||||
|
[/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${desc}" />`],
|
||||||
|
[/<link rel="canonical" href="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<link rel="canonical" href="${url}" />`],
|
||||||
|
[/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${title}" />`],
|
||||||
|
[/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${desc}" />`],
|
||||||
|
[/<meta property="og:url" content="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<meta property="og:url" content="${url}" />`],
|
||||||
|
[/<meta name="twitter:title" content="[^"]*"\s*\/>/, `<meta name="twitter:title" content="${title}" />`],
|
||||||
|
[/<meta name="twitter:description" content="[^"]*"\s*\/>/, `<meta name="twitter:description" content="${desc}" />`],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { file, path, title, desc } of PAGES) {
|
||||||
|
const fileUrl = new URL(`../build/${file}`, import.meta.url);
|
||||||
|
let html;
|
||||||
|
try {
|
||||||
|
html = await readFile(fileUrl, 'utf8');
|
||||||
|
} catch {
|
||||||
|
console.error(`patch-static-heads: build/${file} is missing (route renamed/removed?)`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const missed = [];
|
||||||
|
for (const [re, repl] of subsFor(BASE + path, title, desc)) {
|
||||||
|
if (!re.test(html)) { missed.push(re.source.slice(0, 40)); continue; }
|
||||||
|
html = html.replace(re, repl);
|
||||||
|
}
|
||||||
|
// Fail loudly if the homepage head drifted — better a broken build than silently
|
||||||
|
// shipping the wrong preview/canonical again.
|
||||||
|
if (missed.length) {
|
||||||
|
console.error(`patch-static-heads: ${file} — these head tags were not found (app.html changed?):\n ` + missed.join('\n '));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await writeFile(fileUrl, html);
|
||||||
|
console.log(`patch-static-heads: rewrote build/${file} head → ${path}`);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Upbeat Bytes calm design system.
|
/* upbeatBytes calm design system.
|
||||||
Sand, sea, and sun: warm paper surfaces, a vivid-azure accent, gold highlight,
|
Sand, sea, and sun: warm paper surfaces, a vivid-azure accent, gold highlight,
|
||||||
a serif voice for headlines, strong readable contrast, generous space.
|
a serif voice for headlines, strong readable contrast, generous space.
|
||||||
No urgency colors (no red). Built around the logo's #0083ad azure. */
|
No urgency colors (no red). Built around the logo's #0083ad azure. */
|
||||||
@@ -34,7 +34,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
|
/* Always reserve the scrollbar gutter so the centered top bar / content doesn't jump
|
||||||
|
left-right between pages that scroll and pages that don't. (No-op on overlay-scrollbar
|
||||||
|
platforms like mobile, which never shifted anyway.) */
|
||||||
|
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; scrollbar-gutter: stable; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -72,5 +75,5 @@ button { font-family: inherit; cursor: pointer; }
|
|||||||
/play and always removed on navigation via effect cleanup. Mobile only. */
|
/play and always removed on navigation via effect cleanup. Mobile only. */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
html.playing-game, html.playing-game body { overflow: hidden; }
|
html.playing-game, html.playing-game body { overflow: hidden; }
|
||||||
html.playing-game footer.site { display: none; }
|
html.playing-game footer.ub-foot { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,17 @@
|
|||||||
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#0083ad" />
|
<meta name="theme-color" content="#0083ad" />
|
||||||
<meta name="description" content="Calm, constructive news worth your attention — and nothing that isn't." />
|
<meta name="description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
|
||||||
<title>Upbeat Bytes — calm, constructive news</title>
|
<title>upbeatBytes — a calmer, brighter corner of the internet</title>
|
||||||
<link rel="canonical" href="https://upbeatbytes.com/" />
|
<link rel="canonical" href="https://upbeatbytes.com/" />
|
||||||
<meta property="og:site_name" content="Upbeat Bytes" />
|
<meta property="og:site_name" content="upbeatBytes" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:title" content="Upbeat Bytes — calm, constructive news" />
|
<meta property="og:title" content="upbeatBytes — a calmer, brighter corner of the internet" />
|
||||||
<meta property="og:description" content="Calm, constructive news worth your attention — and nothing that isn't. Summarized, so you get the gist and go deeper only if you want." />
|
<meta property="og:description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets. No ads, no paywalls, no doomscrolling." />
|
||||||
<meta property="og:url" content="https://upbeatbytes.com/" />
|
<meta property="og:url" content="https://upbeatbytes.com/" />
|
||||||
<meta name="twitter:card" content="summary" />
|
<meta name="twitter:card" content="summary" />
|
||||||
<meta name="twitter:title" content="Upbeat Bytes — calm, constructive news" />
|
<meta name="twitter:title" content="upbeatBytes — a calmer, brighter corner of the internet" />
|
||||||
<meta name="twitter:description" content="Calm, constructive news, summarized — get the gist, go deeper only if you want." />
|
<meta name="twitter:description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
<style>
|
<style>
|
||||||
#boot-fallback {
|
#boot-fallback {
|
||||||
@@ -128,9 +128,9 @@
|
|||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
<div id="boot-fallback" role="alert" aria-live="polite">
|
<div id="boot-fallback" role="alert" aria-live="polite">
|
||||||
<div class="bf">
|
<div class="bf">
|
||||||
<img src="%sveltekit.assets%/logo.svg" alt="Upbeat Bytes" />
|
<img src="%sveltekit.assets%/logo.svg" alt="upbeatBytes" />
|
||||||
<p>We had a little trouble loading. A quick refresh usually sorts it out.</p>
|
<p>We had a little trouble loading. A quick refresh usually sorts it out.</p>
|
||||||
<button type="button" onclick="location.reload()">Refresh Upbeat Bytes</button>
|
<button type="button" onclick="location.reload()">Refresh upbeatBytes</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script>
|
||||||
|
// Full searchable emoji picker for the Publishing Desk. Data is bundled locally
|
||||||
|
// (emoji-data.js) — no runtime/CDN fetch. Calls onpick(char) for each selection;
|
||||||
|
// stays open so several can be inserted in a row. Recents persist in localStorage.
|
||||||
|
import { EMOJI_GROUPS } from './emoji-data.js';
|
||||||
|
|
||||||
|
let { onpick } = $props();
|
||||||
|
|
||||||
|
let q = $state('');
|
||||||
|
let recent = $state([]);
|
||||||
|
const RECENT_KEY = 'ub.pub.emojiRecent';
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
try { recent = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); } catch { recent = []; }
|
||||||
|
});
|
||||||
|
|
||||||
|
function pick(c) {
|
||||||
|
onpick(c);
|
||||||
|
const next = [c, ...recent.filter((x) => x !== c)].slice(0, 24);
|
||||||
|
recent = next;
|
||||||
|
try { localStorage.setItem(RECENT_KEY, JSON.stringify(next)); } catch { /* private mode */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// search across name + keyword slug; null when the box is empty (→ show grouped view)
|
||||||
|
const matches = $derived.by(() => {
|
||||||
|
const term = q.trim().toLowerCase();
|
||||||
|
if (!term) return null;
|
||||||
|
const res = [];
|
||||||
|
for (const g of EMOJI_GROUPS)
|
||||||
|
for (const e of g.emojis)
|
||||||
|
if (e.n.includes(term) || e.k.includes(term)) res.push(e);
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
|
||||||
|
let sections = {}; // group name -> section element, for the jump tabs
|
||||||
|
function jump(name) { sections[name]?.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ep">
|
||||||
|
<input class="ep-search" placeholder="Search emoji…" bind:value={q} autocomplete="off" />
|
||||||
|
|
||||||
|
{#if !q.trim()}
|
||||||
|
<div class="ep-tabs">
|
||||||
|
{#if recent.length}<button type="button" title="Recent" onclick={() => jump('__recent')}>🕘</button>{/if}
|
||||||
|
{#each EMOJI_GROUPS as g (g.name)}
|
||||||
|
<button type="button" title={g.name} onclick={() => jump(g.name)}>{g.emojis[0].c}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="ep-scroll">
|
||||||
|
{#if matches}
|
||||||
|
{#if matches.length}
|
||||||
|
<div class="ep-grid">
|
||||||
|
{#each matches as e (e.c)}<button type="button" class="ep-em" title={e.n} onclick={() => pick(e.c)}>{e.c}</button>{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="ep-none">No emoji match “{q}”.</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#if recent.length}
|
||||||
|
<div class="ep-sec" bind:this={sections['__recent']}>
|
||||||
|
<div class="ep-h">Recent</div>
|
||||||
|
<div class="ep-grid">
|
||||||
|
{#each recent as c (c)}<button type="button" class="ep-em" onclick={() => pick(c)}>{c}</button>{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each EMOJI_GROUPS as g (g.name)}
|
||||||
|
<div class="ep-sec" bind:this={sections[g.name]}>
|
||||||
|
<div class="ep-h">{g.name}</div>
|
||||||
|
<div class="ep-grid">
|
||||||
|
{#each g.emojis as e (e.c)}<button type="button" class="ep-em" title={e.n} onclick={() => pick(e.c)}>{e.c}</button>{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ep { width: 300px; }
|
||||||
|
.ep-search { width: 100%; box-sizing: border-box; font: inherit; font-size: 0.85rem; padding: 6px 9px;
|
||||||
|
border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); }
|
||||||
|
.ep-tabs { display: flex; flex-wrap: wrap; gap: 1px; margin: 6px 0; }
|
||||||
|
.ep-tabs button { font-size: 1.05rem; line-height: 1; padding: 3px; border: 0; border-radius: 6px;
|
||||||
|
background: transparent; cursor: pointer; }
|
||||||
|
.ep-tabs button:hover { background: var(--accent-soft); }
|
||||||
|
.ep-scroll { max-height: 230px; overflow-y: auto; margin-top: 4px; }
|
||||||
|
.ep-sec { scroll-margin-top: 2px; }
|
||||||
|
.ep-h { position: sticky; top: 0; background: var(--card, var(--bg)); font-size: 0.72rem; font-weight: 700;
|
||||||
|
color: var(--muted); text-transform: uppercase; letter-spacing: .04em; padding: 4px 2px; }
|
||||||
|
.ep-grid { display: grid; grid-template-columns: repeat(8, 1fr); }
|
||||||
|
.ep-em { font-size: 1.25rem; line-height: 1; padding: 4px 0; border: 0; border-radius: 7px;
|
||||||
|
background: transparent; cursor: pointer; }
|
||||||
|
.ep-em:hover { background: var(--accent-soft); }
|
||||||
|
.ep-none { font-size: 0.82rem; color: var(--muted); padding: 8px 2px; }
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
// admin dashboard tell "new vs returning" in aggregate. Server hashes it.
|
// admin dashboard tell "new vs returning" in aggregate. Server hashes it.
|
||||||
const VISITOR_KEY = 'goodnews:visitor';
|
const VISITOR_KEY = 'goodnews:visitor';
|
||||||
const VISITDAY_KEY = 'goodnews:visitday';
|
const VISITDAY_KEY = 'goodnews:visitday';
|
||||||
|
const ENGAGEDDAY_KEY = 'goodnews:engagedday';
|
||||||
|
|
||||||
export function visitorId() {
|
export function visitorId() {
|
||||||
try {
|
try {
|
||||||
@@ -35,6 +36,22 @@ export function track(kind, articleId = 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deep link for a shared game result: full https:// URL straight to the exact game +
|
||||||
|
// variant (not generic /play), tagged so we can attribute arrivals to the share loop.
|
||||||
|
const SITE = 'https://upbeatbytes.com';
|
||||||
|
export function gameShareUrl(game, variant) {
|
||||||
|
const v = variant ? `&v=${encodeURIComponent(variant)}` : '';
|
||||||
|
return `${SITE}/play?game=${game}${v}&utm_source=game_share`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-game funnel: trackGame('word', 'started'|'completed'|'shared'). Kinds must mirror
|
||||||
|
// the backend allowlist (_GAME_EVENT_KINDS in api.py). Deduped per game/kind/day server-side.
|
||||||
|
const GAME_NAMES = ['word', 'wordsearch', 'bloom', 'match'];
|
||||||
|
const GAME_EVENTS = ['started', 'completed', 'shared', 'arrival'];
|
||||||
|
export function trackGame(game, event) {
|
||||||
|
if (GAME_NAMES.includes(game) && GAME_EVENTS.includes(event)) track(`${game}_${event}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Count a visit at most once per day per device.
|
// Count a visit at most once per day per device.
|
||||||
export function trackVisit() {
|
export function trackVisit() {
|
||||||
try {
|
try {
|
||||||
@@ -47,3 +64,38 @@ export function trackVisit() {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Engaged reader" signal — what separates real readers from a JS-capable bot that
|
||||||
|
// trips a raw visit. Fire 'engaged' at most once/day, and ONLY once the page has been
|
||||||
|
// VISIBLE for ~8s AND we've seen a genuine gesture (scroll/pointer/key/touch). Captures
|
||||||
|
// nothing beyond the day-deduped visitor token. Returns a cleanup fn. (A deliberate
|
||||||
|
// action — source-click, share, game start — also counts as engagement, server-side.)
|
||||||
|
export function armEngaged() {
|
||||||
|
let fired = false, gesture = false, visibleSecs = 0;
|
||||||
|
function fire() {
|
||||||
|
if (fired || !gesture || visibleSecs < 8) return;
|
||||||
|
fired = true;
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
if (localStorage.getItem(ENGAGEDDAY_KEY) !== today) {
|
||||||
|
localStorage.setItem(ENGAGEDDAY_KEY, today);
|
||||||
|
track('engaged');
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
const onGesture = () => { gesture = true; fire(); };
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
|
||||||
|
visibleSecs += 1;
|
||||||
|
fire();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
const evs = ['scroll', 'pointerdown', 'keydown', 'touchstart'];
|
||||||
|
function cleanup() {
|
||||||
|
clearInterval(tick);
|
||||||
|
if (typeof window !== 'undefined') evs.forEach((e) => window.removeEventListener(e, onGesture));
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') evs.forEach((e) => window.addEventListener(e, onGesture, { passive: true }));
|
||||||
|
return cleanup;
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
if (value) onaction?.(kind, value);
|
if (value) onaction?.(kind, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sharing: share the branded Upbeat Bytes card page (default), with copy-source.
|
// Sharing: share the branded upbeatBytes card page (default), with copy-source.
|
||||||
let shareOpen = $state(false);
|
let shareOpen = $state(false);
|
||||||
let copied = $state('');
|
let copied = $state('');
|
||||||
const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
|
const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
|
||||||
@@ -100,12 +100,13 @@
|
|||||||
>
|
>
|
||||||
{#if showImage}
|
{#if showImage}
|
||||||
<a class="media" href={summaryHref} onclick={opened}>
|
<a class="media" href={summaryHref} onclick={opened}>
|
||||||
|
<!-- image_url is resolved server-side per source image policy (cached copy or publisher URL) -->
|
||||||
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
|
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
|
||||||
onerror={() => { failed = true; onimageerror?.(); }} />
|
onerror={() => { failed = true; onimageerror?.(); }} />
|
||||||
</a>
|
</a>
|
||||||
{:else if usePlaceholder}
|
{:else if usePlaceholder}
|
||||||
<a class="media placeholder" href={summaryHref} onclick={opened} style="--c:{accentColor}" tabindex="-1" aria-hidden="true">
|
<a class="media placeholder" href={summaryHref} onclick={opened} style="--c:{accentColor}" tabindex="-1" aria-hidden="true">
|
||||||
<span class="ph-word">{humanize(article.topic) || 'upbeat bytes'}</span>
|
<span class="ph-word">{humanize(article.topic) || 'upbeatBytes'}</span>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
<script>
|
||||||
|
import { getJSON, postJSON } from '$lib/api.js';
|
||||||
|
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
|
||||||
|
import { trackGame, gameShareUrl } from '$lib/analytics.js';
|
||||||
|
|
||||||
|
// mode: 'daily' (shared, synced, ritual) | 'free' (local-only, infinite wheels)
|
||||||
|
// format: 'center' (center letter required) | 'wild' (any word from the 7)
|
||||||
|
let { mode = 'daily', format = 'center', onstatus } = $props();
|
||||||
|
// $derived (not const) so a prop change can't leave the mode "stuck".
|
||||||
|
let isFree = $derived(mode === 'free');
|
||||||
|
let isWild = $derived(format === 'wild');
|
||||||
|
|
||||||
|
let center = $state('');
|
||||||
|
let outer = $state([]); // Center Circle: 6 petals (order shuffles)
|
||||||
|
let wildRing = $state([]); // Wild Bloom: all 7 letters, equal (order shuffles)
|
||||||
|
let accepted = $state(new Set()); // sha256(salt:word) hex — no plaintext answers
|
||||||
|
let maxScore = $state(0);
|
||||||
|
let tiers = $state([]);
|
||||||
|
let date = $state(''); // daily mode
|
||||||
|
let seed = $state(''); // free mode (lets us resume the same wheel)
|
||||||
|
|
||||||
|
let found = $state([]); // plaintext words this device has found
|
||||||
|
let current = $state(''); // the word being built
|
||||||
|
let loading = $state(true);
|
||||||
|
let ready = $state(false);
|
||||||
|
let message = $state('');
|
||||||
|
let shake = $state(false);
|
||||||
|
let pulse = $state(false); // bloom flourish on a pangram
|
||||||
|
let fullShown = $state(false); // Full Bloom celebration latch
|
||||||
|
let reportWord = $state(''); // a rejected real-looking word offered to flag
|
||||||
|
let reported = $state(false); // "thanks" after flagging
|
||||||
|
|
||||||
|
let salt = $derived(isFree ? seed : date); // the hash salt + storage discriminator
|
||||||
|
const stateKey = $derived(isFree ? `goodnews:bloom:free:${format}` : `goodnews:bloom:${date}`);
|
||||||
|
// The active letter set matches what's actually on screen (Wild ring vs center+petals).
|
||||||
|
let activeLetters = $derived(isWild ? wildRing : [center, ...outer]);
|
||||||
|
let wheel = $derived(new Set(activeLetters));
|
||||||
|
// The flower's center: azure (Center Circle = "required") vs slate (Wild =
|
||||||
|
// "just a letter"); both bloom to jewel hues as you find words.
|
||||||
|
let centerColor = $derived(found.length ? bloomColor(found.length) : (isWild ? '#5b6b78' : 'var(--accent)'));
|
||||||
|
|
||||||
|
function scoreWord(w) {
|
||||||
|
let s = w.length === 4 ? 1 : w.length;
|
||||||
|
if (isPangram(w)) s += 7;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
function isPangram(w) {
|
||||||
|
return w.length >= 7 && [...wheel].every((l) => w.includes(l));
|
||||||
|
}
|
||||||
|
// Curated calm jewel hues (all readable under the white letter). The flower's
|
||||||
|
// center blooms a fresh hue with each found word; each found chip keeps its own.
|
||||||
|
const PALETTE = ['#0083ad', '#117a8b', '#2e8b57', '#b8732e', '#c0563f', '#bb4a63',
|
||||||
|
'#c2569b', '#8e5bb0', '#6a5fc4', '#4f6fc6', '#b14a8a', '#2f8f8f'];
|
||||||
|
function bloomColor(n) { return n ? PALETTE[(n * 5) % PALETTE.length] : 'var(--accent)'; }
|
||||||
|
function hueFor(w) {
|
||||||
|
let h = 0;
|
||||||
|
for (const c of w) h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||||
|
return PALETTE[h % PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
let score = $derived(found.reduce((s, w) => s + scoreWord(w), 0));
|
||||||
|
let tierIdx = $derived.by(() => { let idx = 0; tiers.forEach((t, i) => { if (score >= t.score) idx = i; }); return idx; });
|
||||||
|
let tier = $derived(tiers[tierIdx] || { name: '', score: 0 });
|
||||||
|
let nextTier = $derived(tiers[tierIdx + 1] || null);
|
||||||
|
// The ring fills toward the NEXT goal — the next tier, or (at the top tier)
|
||||||
|
// Full Bloom — so a full ring never falsely implies "every word found."
|
||||||
|
let progress = $derived.by(() => {
|
||||||
|
const start = tier.score;
|
||||||
|
const target = nextTier ? nextTier.score : maxScore;
|
||||||
|
return target > start ? Math.min(1, (score - start) / (target - start)) : 1;
|
||||||
|
});
|
||||||
|
let fullBloom = $derived(maxScore > 0 && score >= maxScore);
|
||||||
|
// Reached the top tier (Flourishing) — the daily's "saw it through" point;
|
||||||
|
// persisted so the calm-set ritual can read it without the puzzle payload.
|
||||||
|
let reachedTop = $derived(score >= (tiers.find((t) => t.name === 'Flourishing')?.score ?? Infinity));
|
||||||
|
|
||||||
|
async function sha256hex(str) {
|
||||||
|
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
||||||
|
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStored() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(stateKey) || 'null'); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(forceNew = false) {
|
||||||
|
loading = true; ready = false;
|
||||||
|
found = []; current = ''; message = ''; fullShown = false;
|
||||||
|
try {
|
||||||
|
let p, savedFound = [];
|
||||||
|
if (isFree) {
|
||||||
|
const stored = forceNew ? null : readStored(); // resume the same wheel by its seed
|
||||||
|
const q = stored?.seed ? `&seed=${encodeURIComponent(stored.seed)}` : '';
|
||||||
|
p = await getJSON(`/api/puzzle/bloom/free?format=${format}${q}`);
|
||||||
|
seed = p.seed;
|
||||||
|
if (stored && stored.seed === p.seed && Array.isArray(stored.found)) savedFound = stored.found;
|
||||||
|
} else {
|
||||||
|
p = await getJSON('/api/puzzle/bloom'); // holds NO plaintext words
|
||||||
|
date = p.date;
|
||||||
|
const stored = readStored();
|
||||||
|
if (stored && Array.isArray(stored.found)) savedFound = stored.found;
|
||||||
|
}
|
||||||
|
center = p.center; outer = p.outer;
|
||||||
|
if (isWild) wildRing = [center, ...outer]; // 7 equal petals — no required center
|
||||||
|
accepted = new Set(p.accepted); maxScore = p.max_score; tiers = p.tiers;
|
||||||
|
await restore(savedFound);
|
||||||
|
} catch {
|
||||||
|
message = isFree ? 'Could not load a wheel.' : 'Could not load today’s Bloom.';
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
requestAnimationFrame(() => (ready = true));
|
||||||
|
if (!isFree) syncNow(); // free play is local-only — never syncs
|
||||||
|
}
|
||||||
|
|
||||||
|
function newWheel() { if (!loading) load(true); } // free: deal a fresh wheel
|
||||||
|
|
||||||
|
async function restore(savedFound) {
|
||||||
|
// Keep only finds valid for THIS wheel (drops stale/junk / wrong-seed words).
|
||||||
|
const valid = [];
|
||||||
|
for (const w of (savedFound || [])) {
|
||||||
|
if (typeof w === 'string' && accepted.has(await sha256hex(`${salt}:${w}`))) valid.push(w);
|
||||||
|
}
|
||||||
|
found = valid;
|
||||||
|
persist();
|
||||||
|
if (!isFree) onstatus?.(summary());
|
||||||
|
}
|
||||||
|
function persist() {
|
||||||
|
// `full` lets the hub card show "Full Bloom" after reload; `seed` lets free
|
||||||
|
// play resume the same wheel.
|
||||||
|
const data = isFree ? { seed, found, score, full: fullBloom, top: reachedTop }
|
||||||
|
: { found, score, full: fullBloom, top: reachedTop };
|
||||||
|
try { localStorage.setItem(stateKey, JSON.stringify(data)); } catch { /* ignore */ }
|
||||||
|
if (!isFree) onstatus?.(summary()); // only the daily feeds the hub status
|
||||||
|
}
|
||||||
|
function summary() {
|
||||||
|
return { date, count: found.length, tier: tier.name, full: fullBloom };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- cross-device sync (signed-in; union of found words merged server-side) ---
|
||||||
|
let serverStats = $state(null);
|
||||||
|
let syncTimer;
|
||||||
|
async function adopt(merged) {
|
||||||
|
if (!merged || !Array.isArray(merged.found)) return;
|
||||||
|
// The server state is authoritative: it has already merged this device's push
|
||||||
|
// with any other device AND sanitized against today's accept set, so we adopt
|
||||||
|
// it wholesale — which also removes any local words the server dropped.
|
||||||
|
const valid = [];
|
||||||
|
for (const w of merged.found) {
|
||||||
|
if (typeof w === 'string' && accepted.has(await sha256hex(`${date}:${w}`))) valid.push(w);
|
||||||
|
}
|
||||||
|
found = valid;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
async function syncNow() {
|
||||||
|
if (isFree) return; // free play is local-only
|
||||||
|
const d = date;
|
||||||
|
const merged = await pushGameState('bloom', '', d, { found, score });
|
||||||
|
if (d === date) await adopt(merged);
|
||||||
|
serverStats = await fetchGameStats('bloom', '');
|
||||||
|
}
|
||||||
|
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1000); }
|
||||||
|
|
||||||
|
function flash(m) { message = m; setTimeout(() => (message = ''), 1300); }
|
||||||
|
function shakeIt() { shake = true; setTimeout(() => (shake = false), 400); }
|
||||||
|
function bloomPulse() { pulse = true; setTimeout(() => (pulse = false), 700); }
|
||||||
|
|
||||||
|
function tap(l) { if (!loading) current += l; }
|
||||||
|
// Touch/mouse act on pointerdown (instant, and preventDefault stops focus-scroll).
|
||||||
|
// There is deliberately NO onclick — a click handler alongside pointerdown
|
||||||
|
// double-enters. Keyboard users type directly (global handler) or can Tab to a
|
||||||
|
// petal and press Enter/Space (onkeydown) — neither collides with pointer events.
|
||||||
|
function petalDown(e, l) { e.preventDefault(); tap(l); }
|
||||||
|
function petalKey(e, l) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tap(l); } }
|
||||||
|
function del() { current = current.slice(0, -1); }
|
||||||
|
// Fisher-Yates, retried until it actually feels shuffled (≥minMoved positions
|
||||||
|
// change) — sort(()=>Math.random()-0.5) is biased and often leaves letters put.
|
||||||
|
function shuffled(arr, minMoved) {
|
||||||
|
let out = arr;
|
||||||
|
for (let t = 0; t < 12; t++) {
|
||||||
|
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]];
|
||||||
|
}
|
||||||
|
out = a;
|
||||||
|
if (a.filter((l, i) => l !== arr[i]).length >= minMoved) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function shuffle() {
|
||||||
|
if (isWild) wildRing = shuffled(wildRing, 4);
|
||||||
|
else outer = shuffled(outer, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const w = current.trim().toLowerCase();
|
||||||
|
current = '';
|
||||||
|
reportWord = ''; reported = false;
|
||||||
|
if (!w) return;
|
||||||
|
if (w.length < 4) { flash('Too short'); shakeIt(); return; }
|
||||||
|
if (!isWild && !w.includes(center)) { flash('Needs the center letter'); shakeIt(); return; }
|
||||||
|
if ([...w].some((l) => !wheel.has(l))) { flash('Letters not in the bloom'); shakeIt(); return; }
|
||||||
|
if (found.includes(w)) { flash('Already found'); shakeIt(); return; }
|
||||||
|
const h = await sha256hex(`${salt}:${w}`);
|
||||||
|
if (!accepted.has(h)) {
|
||||||
|
flash('Not in the word list'); shakeIt();
|
||||||
|
reportWord = w; // it's a well-formed wheel word — offer to flag it
|
||||||
|
setTimeout(() => { if (reportWord === w) reportWord = ''; }, 7000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pan = isPangram(w);
|
||||||
|
if (found.length === 0) trackGame('bloom', 'started'); // first word = started
|
||||||
|
found = [w, ...found];
|
||||||
|
flash(pan ? 'Pangram! 🌸 +' + scoreWord(w) : '+' + scoreWord(w));
|
||||||
|
if (pan) bloomPulse();
|
||||||
|
persist();
|
||||||
|
if (!isFree) syncSoon();
|
||||||
|
if (found.reduce((s, x) => s + scoreWord(x), 0) >= maxScore && !fullShown) {
|
||||||
|
fullShown = true; bloomPulse(); trackGame('bloom', 'completed'); // Full Bloom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spoiler-safe share: achievement + a word-length breakdown, never the words.
|
||||||
|
let copied = $state(false);
|
||||||
|
function share() {
|
||||||
|
const byLen = {};
|
||||||
|
found.forEach((w) => (byLen[w.length] = (byLen[w.length] || 0) + 1));
|
||||||
|
const breakdown = Object.keys(byLen).sort((a, b) => b - a).map((l) => `${l}×${byLen[l]}`).join(' ');
|
||||||
|
const pang = found.some(isPangram) ? ' · pangram ✓' : '';
|
||||||
|
const bloomV = mode === 'daily' ? 'daily' : (format === 'wild' ? 'free-wild' : 'free-center');
|
||||||
|
const text = `upbeatBytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\n${gameShareUrl('bloom', bloomV)}`;
|
||||||
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('bloom', 'shared')).catch(() => {});
|
||||||
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('bloom', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quiet "this should count?" — flags a rejected word for the admin queue.
|
||||||
|
async function reportMissing() {
|
||||||
|
const w = reportWord;
|
||||||
|
if (!w) return;
|
||||||
|
reported = true;
|
||||||
|
try {
|
||||||
|
await postJSON('/api/bloom/report', {
|
||||||
|
word: w, date: isFree ? null : date, mode, format,
|
||||||
|
letters: [center, ...outer].join(''), reason: 'not in the word list',
|
||||||
|
});
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
setTimeout(() => { if (reportWord === w) { reportWord = ''; reported = false; } }, 2600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey || loading) return;
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
if (k === 'enter') { e.preventDefault(); submit(); }
|
||||||
|
else if (k === 'backspace') { e.preventDefault(); del(); }
|
||||||
|
else if (/^[a-z]$/.test(k) && wheel.has(k)) { e.preventDefault(); tap(k); }
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { load(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
|
<div class="bloomgame" class:ready>
|
||||||
|
{#if loading}
|
||||||
|
<p class="muted">Loading today’s Bloom…</p>
|
||||||
|
{:else}
|
||||||
|
{#if isFree}<p class="freecap">Free Play · {isWild ? 'Wild Bloom' : 'Center Circle'}</p>{/if}
|
||||||
|
|
||||||
|
<!-- progress: tier name + ring toward the next goal (total stays hidden) -->
|
||||||
|
<div class="meter">
|
||||||
|
<div class="ring" style="--p:{progress}">
|
||||||
|
<span class="rscore">{found.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tierline">
|
||||||
|
<span class="tname" class:full={fullBloom}>{fullBloom ? 'Full Bloom 🌸' : tier.name}</span>
|
||||||
|
<span class="tsub">{found.length} {found.length === 1 ? 'word' : 'words'}</span>
|
||||||
|
</div>
|
||||||
|
{#if isFree}
|
||||||
|
<button class="share top" onclick={newWheel}>New wheel</button>
|
||||||
|
{:else}
|
||||||
|
<button class="share top" onclick={share}>{copied ? 'Copied!' : 'Share'}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fixed-height entry + feedback rows so the bloom never reflows. -->
|
||||||
|
<div class="entry" class:shake>
|
||||||
|
{#if current}
|
||||||
|
{#each current.split('') as ch, i (i)}
|
||||||
|
<span class="ec" class:cen={!isWild && ch === center}>{ch.toUpperCase()}</span>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<span class="ph">Type or tap letters</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- One fixed-height feedback slot (flash OR the "should count?" flag) so the
|
||||||
|
bloom never shifts whether or not a message/report is showing. -->
|
||||||
|
<div class="feedback">
|
||||||
|
{#if reportWord}
|
||||||
|
{#if reported}
|
||||||
|
<span class="thanks">Thanks — flagged for review 🌱</span>
|
||||||
|
{:else}
|
||||||
|
<button class="reportbtn" onclick={reportMissing}>“{reportWord}” should count?</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="flash" class:show={!!message} class:pan={message.includes('Pangram')}>{message || ' '}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Petals: pointerdown for instant touch + no focus-scroll. Center Circle =
|
||||||
|
1 azure center letter + 6 petals. Wild = 7 equal petals ringing a small
|
||||||
|
decorative bloom dot (letterless, non-tappable — never a required letter). -->
|
||||||
|
<div class="bloom" class:pulse class:wild={isWild}>
|
||||||
|
{#if isWild}
|
||||||
|
<span class="bloomcenter" style="background: {bloomColor(found.length)}" aria-hidden="true"></span>
|
||||||
|
{#each wildRing as letter, i (letter)}
|
||||||
|
<button class="petal" style="--a:{i * (360 / 7)}deg"
|
||||||
|
onpointerdown={(e) => petalDown(e, letter)} onkeydown={(e) => petalKey(e, letter)}>{letter.toUpperCase()}</button>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<button class="petal center" style="background: {centerColor}"
|
||||||
|
onpointerdown={(e) => petalDown(e, center)} onkeydown={(e) => petalKey(e, center)}>{center.toUpperCase()}</button>
|
||||||
|
{#each outer as letter, i (letter)}
|
||||||
|
<button class="petal" style="--a:{i * 60}deg"
|
||||||
|
onpointerdown={(e) => petalDown(e, letter)} onkeydown={(e) => petalKey(e, letter)}>{letter.toUpperCase()}</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls use click so they stay keyboard-activatable (Shuffle especially);
|
||||||
|
touch-action keeps them snappy on phones. -->
|
||||||
|
<div class="controls">
|
||||||
|
<button class="ctl" onclick={del} aria-label="Delete">Delete</button>
|
||||||
|
<button class="ctl round" onclick={shuffle} aria-label="Shuffle letters">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"/><path d="M4 20 21 3"/><path d="M21 16v5h-5"/><path d="M15 15l6 6"/><path d="M4 4l5 5"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ctl enter" onclick={submit}>Enter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if fullShown}
|
||||||
|
<p class="fullmsg rise">🌸 Full Bloom — you found today's whole bloom. Lovely.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if found.length}
|
||||||
|
<div class="found">
|
||||||
|
{#each found.slice().sort() as w (w)}
|
||||||
|
<span class="chip" class:pan={isPangram(w)} style="background: {hueFor(w)}">{w}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bloomgame { max-width: 480px; margin: 0 auto; opacity: 0; transform: translateY(6px);
|
||||||
|
display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.bloomgame.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
|
||||||
|
.muted { color: var(--muted); text-align: center; }
|
||||||
|
|
||||||
|
/* Everything above the found-list holds its height (flex-shrink:0) so the
|
||||||
|
height-constrained mobile column can't compress/redistribute it — only the
|
||||||
|
found-list flexes. This is what was nudging the bloom on mobile. */
|
||||||
|
.meter, .entry, .feedback, .controls, .fullmsg, .freecap { flex-shrink: 0; }
|
||||||
|
.freecap { margin: 0 0 6px; font-family: var(--label); font-size: 0.72rem; letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase; color: var(--muted); font-weight: 600; }
|
||||||
|
.meter { display: flex; align-items: center; gap: 12px; width: 100%; max-width: 360px; margin: 2px 0 14px; }
|
||||||
|
.ring {
|
||||||
|
width: 46px; height: 46px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
background: conic-gradient(var(--accent) calc(var(--p) * 360deg), var(--line) 0);
|
||||||
|
display: grid; place-items: center; transition: background 0.4s ease;
|
||||||
|
}
|
||||||
|
.ring .rscore { width: 36px; height: 36px; border-radius: 50%; background: var(--surface);
|
||||||
|
display: grid; place-items: center; font-family: var(--label); font-weight: 700;
|
||||||
|
font-size: 0.95rem; color: var(--accent-deep); }
|
||||||
|
.tierline { display: flex; flex-direction: column; line-height: 1.15; margin-right: auto; }
|
||||||
|
.tname { font-family: var(--serif); font-size: 1.2rem; color: var(--accent-deep); }
|
||||||
|
.tname.full { color: #c2569b; }
|
||||||
|
.tsub { color: var(--muted); font-size: 0.8rem; }
|
||||||
|
.share.top { background: none; border: 1px solid var(--line); color: var(--accent-deep);
|
||||||
|
border-radius: 999px; padding: 6px 16px; font: inherit; font-size: 0.85rem; cursor: pointer; }
|
||||||
|
.share.top:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Fixed heights → the bloom below never shifts as you type or messages appear. */
|
||||||
|
.entry { height: 46px; display: flex; align-items: center; justify-content: center; gap: 3px; }
|
||||||
|
.entry.shake { animation: shake 0.4s ease; }
|
||||||
|
.ec { font-family: var(--label); font-weight: 700; font-size: 1.8rem; letter-spacing: 0.04em; color: var(--ink); }
|
||||||
|
.ec.cen { color: var(--accent); }
|
||||||
|
.ph { color: var(--muted); font-style: italic; font-size: 1rem; }
|
||||||
|
.feedback { height: 26px; margin: 2px 0 4px; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.flash { font-family: var(--label); font-size: 0.86rem; color: var(--accent-deep);
|
||||||
|
white-space: nowrap; opacity: 0; transition: opacity 0.15s ease; }
|
||||||
|
.flash.show { opacity: 1; }
|
||||||
|
.flash.pan { color: #c2569b; font-weight: 700; }
|
||||||
|
.reportbtn { background: none; border: none; cursor: pointer; font-family: var(--label);
|
||||||
|
font-size: 0.84rem; color: var(--accent-deep); text-decoration: underline;
|
||||||
|
text-underline-offset: 3px; padding: 0; }
|
||||||
|
.reportbtn:hover { color: var(--accent); }
|
||||||
|
.thanks { font-family: var(--label); font-size: 0.84rem; color: #2e8b57; }
|
||||||
|
|
||||||
|
/* The bloom: a flower — a larger azure center surrounded by 6 evenly-spaced
|
||||||
|
circular petals. Generous gaps, no overlap (our identity, not a honeycomb). */
|
||||||
|
.bloom { position: relative; width: 300px; height: 300px; margin: 2px 0 10px; --r: 100px; flex-shrink: 0; }
|
||||||
|
.bloom.pulse { animation: bloompulse 0.7s ease; }
|
||||||
|
.petal {
|
||||||
|
position: absolute; left: 50%; top: 50%; --a: 0deg;
|
||||||
|
transform: translate(-50%, -50%) rotate(var(--a)) translateY(calc(-1 * var(--r))) rotate(calc(-1 * var(--a)));
|
||||||
|
width: 84px; height: 84px; border-radius: 50%; border: none; cursor: pointer; z-index: 1;
|
||||||
|
background: var(--surface); color: var(--accent-deep);
|
||||||
|
font-family: var(--label); font-weight: 700; font-size: 1.7rem; text-transform: uppercase;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line), 0 1px 5px rgba(60, 50, 30, 0.07);
|
||||||
|
transition: transform 0.45s cubic-bezier(.2, .8, .2, 1), background 0.12s ease, filter 0.1s ease;
|
||||||
|
touch-action: manipulation; -webkit-tap-highlight-color: transparent; user-select: none;
|
||||||
|
}
|
||||||
|
.petal.center {
|
||||||
|
z-index: 2; width: 96px; height: 96px; transform: translate(-50%, -50%);
|
||||||
|
background: var(--accent); color: #fff; box-shadow: 0 4px 14px rgba(60, 50, 30, 0.24);
|
||||||
|
/* gentle hue morph as the flower blooms (background set inline per word) */
|
||||||
|
transition: background 0.6s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.petal:hover { filter: brightness(0.97); }
|
||||||
|
.petal:not(.center):active {
|
||||||
|
transform: translate(-50%, -50%) rotate(var(--a)) translateY(calc(-1 * var(--r))) rotate(calc(-1 * var(--a))) scale(0.92);
|
||||||
|
}
|
||||||
|
.petal.center:active { transform: translate(-50%, -50%) scale(0.93); }
|
||||||
|
|
||||||
|
/* Wild Bloom: 7 equal petals in an open ring around a small decorative bloom
|
||||||
|
dot (letterless, non-tappable — never reads as a required letter). */
|
||||||
|
.bloom.wild { --r: 106px; }
|
||||||
|
.bloom.wild .petal { width: 76px; height: 76px; font-size: 1.6rem; }
|
||||||
|
/* The whole center circle IS the bloom — a petal-sized disc that shifts hue
|
||||||
|
with each found word (letterless + non-tappable, so never a required letter). */
|
||||||
|
.bloomcenter {
|
||||||
|
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||||||
|
width: 76px; height: 76px; border-radius: 50%; pointer-events: none; z-index: 1;
|
||||||
|
/* Ring sits OUTSIDE the fill (box-shadow, not a border) so the color reaches
|
||||||
|
the very edge — no white gap. No inset highlight (that was the white sliver). */
|
||||||
|
box-shadow: 0 0 0 2px rgba(20, 20, 20, 0.65), 0 1px 6px rgba(60, 50, 30, 0.12);
|
||||||
|
transition: background 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls { display: flex; align-items: center; gap: 12px; margin: 0 0 22px; }
|
||||||
|
.ctl {
|
||||||
|
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
||||||
|
border-radius: 999px; padding: 11px 22px; font: inherit; font-weight: 600; cursor: pointer;
|
||||||
|
box-shadow: 0 2px 0 rgba(120, 108, 84, 0.18);
|
||||||
|
touch-action: manipulation; -webkit-tap-highlight-color: transparent; user-select: none;
|
||||||
|
}
|
||||||
|
.ctl:active { transform: translateY(2px); box-shadow: none; }
|
||||||
|
.ctl.round { padding: 0; width: 46px; height: 46px; display: grid; place-items: center; }
|
||||||
|
.ctl.round svg { width: 22px; height: 22px; }
|
||||||
|
.ctl.enter { background: var(--accent); border-color: var(--accent); color: #fff;
|
||||||
|
box-shadow: 0 2px 0 var(--accent-deep); }
|
||||||
|
.ctl.enter:hover { background: var(--accent-deep); }
|
||||||
|
|
||||||
|
.fullmsg { text-align: center; color: #c2569b; font-family: var(--serif); font-style: italic;
|
||||||
|
font-size: 1.05rem; margin: 0 0 12px; }
|
||||||
|
|
||||||
|
.found { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; max-width: 440px; }
|
||||||
|
/* Each found word keeps its own hue — a little garden of finds (bg set inline). */
|
||||||
|
.chip { border: none; border-radius: 999px; padding: 5px 13px; font-size: 0.9rem; color: #fff;
|
||||||
|
text-transform: capitalize; box-shadow: 0 1px 3px rgba(40, 38, 28, 0.12);
|
||||||
|
transition: background 0.5s ease; }
|
||||||
|
.chip.pan { font-weight: 700; box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.75), 0 1px 4px rgba(40, 38, 28, 0.18); }
|
||||||
|
|
||||||
|
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } }
|
||||||
|
@keyframes bloompulse { 0% { transform: scale(1); } 40% { transform: scale(1.04); } 100% { transform: scale(1); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .entry, .bloom, .petal { animation: none !important; transition: none !important; } }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
/* /play locks the viewport (overflow:hidden), so the found list gets its own
|
||||||
|
scroll region — the bloom + controls stay put, words scroll under them. */
|
||||||
|
.bloomgame { height: 100%; max-width: 100%; }
|
||||||
|
.bloom { width: 280px; height: 280px; --r: 92px; }
|
||||||
|
.petal { width: 76px; height: 76px; font-size: 1.6rem; }
|
||||||
|
.petal.center { width: 88px; height: 88px; }
|
||||||
|
.bloom.wild { --r: 90px; }
|
||||||
|
.bloom.wild .petal { width: 64px; height: 64px; font-size: 1.45rem; }
|
||||||
|
.bloom.wild .bloomcenter { width: 64px; height: 64px; }
|
||||||
|
.found { flex: 1 1 auto; min-height: 0; width: 100%; overflow-y: auto;
|
||||||
|
align-content: flex-start; padding-bottom: calc(env(safe-area-inset-bottom) + 8px);
|
||||||
|
scrollbar-width: none; }
|
||||||
|
.found::-webkit-scrollbar { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script>
|
||||||
|
// The one footer across the hub family. Consistent core — the motto + a Send
|
||||||
|
// feedback link (a low-friction "talk to a human") — plus an optional per-section
|
||||||
|
// slot. `padBottomNav` reserves space on pages with the fixed mobile BottomNav
|
||||||
|
// (the /news feed) so the footer never hides behind it. FeedbackModal lives in the
|
||||||
|
// global layout; this only triggers it.
|
||||||
|
import { openFeedback } from '$lib/feedback.svelte.js';
|
||||||
|
let { children, padBottomNav = false } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer class="ub-foot" class:padnav={padBottomNav}>
|
||||||
|
<p class="motto">upbeatBytes — no ads, no paywalls, no doomscrolling.</p>
|
||||||
|
<p class="acts">
|
||||||
|
<button class="fb" onclick={openFeedback}>Send feedback</button>
|
||||||
|
{#if children}<span class="sep">·</span>{@render children()}{/if}
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
|
||||||
|
.ub-foot {
|
||||||
|
text-align: center; padding: 30px 18px 36px; margin-top: 10px;
|
||||||
|
border-top: 1px solid rgba(120, 90, 40, 0.14);
|
||||||
|
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.motto { margin: 0; font-size: 13px; color: #a4977f; }
|
||||||
|
.acts { margin: 7px 0 0; font-size: 13px; color: #a4977f; }
|
||||||
|
.fb { background: none; border: none; color: #7c6444; font: inherit; font-size: 13px; cursor: pointer; text-decoration: underline; padding: 0; }
|
||||||
|
.fb:hover { color: #5f4d2e; }
|
||||||
|
.sep { margin: 0 6px; }
|
||||||
|
.ub-foot :global(a) { color: #7c6444; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.ub-foot.padnav { padding-bottom: calc(36px + 64px + env(safe-area-inset-bottom)); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
<header class="appbar">
|
<header class="appbar">
|
||||||
<div class="container bar">
|
<div class="container bar">
|
||||||
<a class="brand" href="/" aria-label="Upbeat Bytes — home">
|
<a class="brand" href="/" aria-label="upbeatBytes — home">
|
||||||
<img class="logo" src="/logo.svg" alt="Upbeat Bytes" width="586" height="196" />
|
<img class="logo" src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="utils" aria-label="Your controls">
|
<nav class="utils" aria-label="Your controls">
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script>
|
||||||
|
// Shared editorial top bar for the hub (`/`) + its detail pages (/news, /word, /quote,
|
||||||
|
// /onthisday, /art, /play). Full horizontal nav on wide screens; a hamburger + drop panel
|
||||||
|
// on phones so the bar stays clean. `active` highlights the current section.
|
||||||
|
// News now lives at /news (the hub is `/`). `newsHref` stays overridable for safety.
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
||||||
|
import Avatar from './Avatar.svelte';
|
||||||
|
let { active = '', newsHref = '/news' } = $props();
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
// auth.user paints immediately from its localStorage cache; revalidate once if no
|
||||||
|
// page has yet (e.g. the hub is the entry point) so the avatar + signed-in state are
|
||||||
|
// correct everywhere the bar shows, not just on the feed/account pages.
|
||||||
|
onMount(() => { if (!auth.ready) refreshAuth(); });
|
||||||
|
|
||||||
|
// Close the menu when we cross into desktop width, so it can't linger open and reappear
|
||||||
|
// if the viewport shrinks back to mobile (the CSS hide alone left `open` stale).
|
||||||
|
$effect(() => {
|
||||||
|
const mq = window.matchMedia('(min-width: 721px)');
|
||||||
|
const sync = (e) => { if (e.matches) open = false; };
|
||||||
|
mq.addEventListener('change', sync);
|
||||||
|
return () => mq.removeEventListener('change', sync);
|
||||||
|
});
|
||||||
|
|
||||||
|
let LINKS = $derived([
|
||||||
|
{ key: 'home', href: '/', label: 'Home' },
|
||||||
|
{ key: 'news', href: newsHref, label: 'News' },
|
||||||
|
{ key: 'games', href: '/play', label: 'Games' },
|
||||||
|
{ key: 'art', href: '/art', label: 'Art' },
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={(e) => { if (e.key === 'Escape') open = false; }} />
|
||||||
|
|
||||||
|
<header class="bar">
|
||||||
|
<a class="brand" href="/" aria-label="upbeatBytes home">
|
||||||
|
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="bar-end">
|
||||||
|
<nav class="nav">
|
||||||
|
{#each LINKS as l}
|
||||||
|
<a class:on={active === l.key} href={l.href}>{l.label}</a>
|
||||||
|
{/each}
|
||||||
|
<span class="nav-soon">Entertainment</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<a class="acct" class:hasimg={auth.user?.avatar_url} href="/account" aria-label="Your account">
|
||||||
|
{#if auth.user?.avatar_url}
|
||||||
|
<Avatar user={auth.user} size={32} />
|
||||||
|
{:else}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#C98A2E" stroke-width="2" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="8" r="4" /><path d="M4 21c0-4 4-6 8-6s8 2 8 6" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="burger" class:open aria-label="Menu" aria-expanded={open} aria-controls="hub-menu"
|
||||||
|
onclick={() => (open = !open)}>
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="menu-wrap">
|
||||||
|
<nav id="hub-menu" class="menu">
|
||||||
|
{#each LINKS as l}
|
||||||
|
<a class:on={active === l.key} href={l.href} onclick={() => (open = false)}>{l.label}</a>
|
||||||
|
{/each}
|
||||||
|
<span class="menu-soon">Entertainment <em>soon</em></span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Self-contained so the bar looks identical on EVERY page, regardless of the host
|
||||||
|
page's own fonts/tokens — one cohesive top bar across the whole site. */
|
||||||
|
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
|
||||||
|
padding: 26px clamp(18px, 5vw, 44px) 0;
|
||||||
|
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.brand { display: block; line-height: 0; }
|
||||||
|
.brand img { height: 48px; width: auto; display: block; }
|
||||||
|
|
||||||
|
.bar-end { display: flex; align-items: center; gap: clamp(16px, 2.4vw, 32px); }
|
||||||
|
.nav { display: flex; align-items: center; gap: clamp(16px, 2.4vw, 32px); font-size: 16.5px; font-weight: 500; }
|
||||||
|
.nav a { color: #6b6256; text-decoration: none; }
|
||||||
|
.nav a.on { color: #23201b; }
|
||||||
|
.nav a:hover { color: #0083ad; }
|
||||||
|
.nav-soon { color: #b3a890; }
|
||||||
|
.acct {
|
||||||
|
width: 32px; height: 32px; border-radius: 50%; border: 1.5px solid #e6c9a0; background: #FCEFD7;
|
||||||
|
display: flex; align-items: center; justify-content: center; flex: none;
|
||||||
|
}
|
||||||
|
/* when showing the profile picture, let it fill the circle cleanly (no tint ring/peek) */
|
||||||
|
.acct.hasimg { background: none; overflow: hidden; padding: 0; }
|
||||||
|
.acct:hover { background: #fbe6c4; }
|
||||||
|
|
||||||
|
/* hamburger — phones only */
|
||||||
|
.burger {
|
||||||
|
display: none; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
|
||||||
|
width: 40px; height: 40px; border-radius: 11px; border: 1.5px solid #e6c9a0; background: #FCEFD7;
|
||||||
|
cursor: pointer; padding: 0; flex: none;
|
||||||
|
}
|
||||||
|
.burger:hover { background: #fbe6c4; }
|
||||||
|
.burger span { width: 18px; height: 2px; border-radius: 2px; background: #7a6a52; transition: transform 0.2s ease, opacity 0.15s ease; }
|
||||||
|
.burger.open span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
|
||||||
|
.burger.open span:nth-child(2) { opacity: 0; }
|
||||||
|
.burger.open span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
|
||||||
|
|
||||||
|
/* drop panel */
|
||||||
|
.menu-wrap { max-width: 1180px; width: 100%; margin: 10px auto 0; box-sizing: border-box; padding: 0 clamp(18px, 5vw, 44px); font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif; }
|
||||||
|
.menu {
|
||||||
|
display: flex; flex-direction: column; background: #fff; border: 1px solid #f2e7d3;
|
||||||
|
border-radius: 14px; overflow: hidden; box-shadow: 0 14px 34px -20px rgba(60, 50, 30, 0.4);
|
||||||
|
}
|
||||||
|
.menu a, .menu .menu-soon {
|
||||||
|
padding: 14px 18px; font-size: 16px; font-weight: 500; text-decoration: none;
|
||||||
|
color: #6b6256; border-top: 1px solid #f3ece0;
|
||||||
|
}
|
||||||
|
.menu a:first-child { border-top: none; }
|
||||||
|
.menu a.on { color: #23201b; }
|
||||||
|
.menu a:hover { background: #FFF9EF; color: #0083ad; }
|
||||||
|
.menu-soon { display: flex; align-items: center; justify-content: space-between; color: #b3a890; }
|
||||||
|
.menu-soon em { font-style: normal; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #c3b69c; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.nav { display: none; }
|
||||||
|
.burger { display: flex; }
|
||||||
|
}
|
||||||
|
@media (min-width: 721px) {
|
||||||
|
.menu-wrap { display: none; } /* safety: never show the panel once back on desktop */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script>
|
||||||
|
// Shared shell for the hub + its detail pages (/word, /quote, /onthisday): the editorial
|
||||||
|
// top bar, footer, fonts, and design tokens. Content goes in the default slot.
|
||||||
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
|
import HubBar from './HubBar.svelte';
|
||||||
|
import Footer from './Footer.svelte';
|
||||||
|
let { active = '', back = true, backLabel = 'Back', children } = $props();
|
||||||
|
|
||||||
|
// Same single-history rule as the News reader's in-page Back: if we arrived here by an
|
||||||
|
// in-app navigation, history.back() returns to wherever (the hub) with state intact; a
|
||||||
|
// direct deep-link has no app history, so fall back to the hub.
|
||||||
|
let cameFromApp = $state(false);
|
||||||
|
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
|
||||||
|
function goBack() {
|
||||||
|
if (cameFromApp && typeof history !== 'undefined') history.back();
|
||||||
|
// Cold deep-link: no in-app origin. REPLACE this entry rather than pushing one, so the
|
||||||
|
// browser Back from the hub doesn't bounce the reader straight back into the detail page.
|
||||||
|
else goto('/', { replaceState: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<HubBar {active} />
|
||||||
|
|
||||||
|
<main class="shell-main">
|
||||||
|
{#if back}
|
||||||
|
<button class="back" onclick={goBack} aria-label="Go back">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
{backLabel}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{@render children?.()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-italic-var.woff2') format('woff2'); font-weight: 400 500; font-style: italic; font-display: swap; }
|
||||||
|
|
||||||
|
.page {
|
||||||
|
--ink: #1c1916; --body: #6b6256; --muted: #a89e8c; --teal: #0083ad;
|
||||||
|
--canvas: #FFF9EF; --news-border: #f2e7d3;
|
||||||
|
min-height: 100vh; background: var(--canvas); color: #23201b;
|
||||||
|
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.page :global(*) { box-sizing: border-box; }
|
||||||
|
|
||||||
|
.shell-main {
|
||||||
|
flex: 1; width: 100%; max-width: 1180px; margin: 0 auto; box-sizing: border-box;
|
||||||
|
padding: clamp(20px, 4vw, 40px) clamp(18px, 5vw, 44px) clamp(40px, 6vw, 72px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* top-left Back — returns to the hub (or wherever you came from), preserving history */
|
||||||
|
.back {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; margin: 0 0 clamp(14px, 3vw, 24px);
|
||||||
|
background: none; border: none; cursor: pointer; padding: 6px 10px 6px 0;
|
||||||
|
font-family: inherit; font-size: 14px; font-weight: 600; color: var(--body);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
.back:hover { color: var(--teal); }
|
||||||
|
.back svg { transition: transform 0.15s ease; }
|
||||||
|
.back:hover svg { transform: translateX(-2px); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<script>
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { pushGameState } from '$lib/gamesync.js';
|
||||||
|
import { trackGame, gameShareUrl } from '$lib/analytics.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;
|
||||||
|
if (moves === 0 && flipped.length === 0 && matchedKeys.size === 0) trackGame('match', 'started');
|
||||||
|
|
||||||
|
// 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; trackGame('match', 'completed'); }
|
||||||
|
} else {
|
||||||
|
locked = true; // show the mismatch briefly, then flip back
|
||||||
|
persist();
|
||||||
|
flipTimer = setTimeout(() => { flipped = []; locked = false; flipTimer = null; }, 850);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIER_LABEL = { gentle: 'Gentle', standard: 'Standard', expert: 'Expert' };
|
||||||
|
let copied = $state(false);
|
||||||
|
function share() {
|
||||||
|
const label = `${TIER_LABEL[tier] || tier} · ${format === 'colors' ? 'colors' : 'icons'}`;
|
||||||
|
const when = isFree ? 'Free play' : date;
|
||||||
|
const text = `upbeatBytes · Memory Match (${label}) ${when}\nCleared in ${moves} moves\n${gameShareUrl('match', `${mode}-${format}-${tier}`)}`;
|
||||||
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('match', 'shared')).catch(() => {});
|
||||||
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('match', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
|
||||||
|
{/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; }
|
||||||
|
.share { margin-top: 10px; background: var(--accent); color: #fff; border: none; border-radius: 999px;
|
||||||
|
padding: 9px 22px; font: inherit; font-weight: 600; cursor: pointer; }
|
||||||
|
.share:hover { background: var(--accent-deep); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.inner { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script>
|
||||||
|
import { ICONS } from '$lib/games/match/icons.js';
|
||||||
|
let { name, size = 34 } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
{@html ICONS[name] ?? ''}
|
||||||
|
</svg>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
|||||||
|
<script>
|
||||||
|
// A single hub "room" card. Presentational — the page passes live preview data in.
|
||||||
|
// Every card is tinted with a darker top strip; layout varies by preview type:
|
||||||
|
// news = inset photo on top, art = inset photo on the right, static = centered header.
|
||||||
|
let { room, artImg = null, newsImg = null, newsHeadline = '' } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a class="card card--{room.size} card--{room.preview}" href={room.href}
|
||||||
|
style={room.tint ? `--tint:${room.tint}` : ''}>
|
||||||
|
{#if room.preview === 'news'}
|
||||||
|
{#if newsImg}<div class="photo photo--top" style="background-image:url({newsImg})"></div>{/if}
|
||||||
|
<div class="content">
|
||||||
|
<div class="head"><span class="ic">{room.icon}</span><span class="title">{room.title}</span></div>
|
||||||
|
{#if newsHeadline}<p class="headline">“{newsHeadline}”</p>{:else}<p class="blurb">{room.blurb}</p>{/if}
|
||||||
|
<span class="cta">{room.cta} <span class="arr">→</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if room.preview === 'art'}
|
||||||
|
<div class="content">
|
||||||
|
<div class="head"><span class="ic">{room.icon}</span><span class="title">{room.title}</span></div>
|
||||||
|
<p class="blurb">{room.blurb}</p>
|
||||||
|
<span class="cta">{room.cta} <span class="arr">→</span></span>
|
||||||
|
</div>
|
||||||
|
{#if artImg}<div class="photo photo--right" style="background-image:url({artImg})"></div>{/if}
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="content">
|
||||||
|
<div class="head"><span class="ic">{room.icon}</span><span class="title">{room.title}</span></div>
|
||||||
|
<p class="blurb">{room.blurb}</p>
|
||||||
|
<span class="cta">{room.cta} <span class="arr">→</span></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
--pad: clamp(13px, 1.7vw, 18px);
|
||||||
|
position: relative; display: flex; overflow: hidden; text-decoration: none; color: var(--ink);
|
||||||
|
background: var(--tint, var(--surface)); border: 1px solid rgba(20, 30, 45, 0.07);
|
||||||
|
border-radius: 18px; box-shadow: 0 10px 30px rgba(20, 30, 45, 0.06);
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
padding: var(--pad);
|
||||||
|
}
|
||||||
|
.card:hover { transform: translateY(-4px); box-shadow: 0 20px 46px rgba(20, 30, 45, 0.12); }
|
||||||
|
|
||||||
|
/* size variants — grid spans set by the page; these set the heights */
|
||||||
|
.card--large { min-height: 340px; }
|
||||||
|
.card--wide { min-height: 200px; }
|
||||||
|
.card--small { min-height: 168px; }
|
||||||
|
|
||||||
|
.content { display: flex; flex-direction: column; flex: 1; }
|
||||||
|
|
||||||
|
/* shared side-by-side header (icon + title), clean modern sans title */
|
||||||
|
.head { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.ic { font-size: clamp(1.7rem, 2.4vw, 2.1rem); line-height: 1; }
|
||||||
|
.title {
|
||||||
|
font-family: var(--sans); font-weight: 700; letter-spacing: -0.015em;
|
||||||
|
font-size: clamp(1.25rem, 2vw, 1.5rem);
|
||||||
|
}
|
||||||
|
.blurb { margin: 11px 0 14px; color: var(--muted); font-size: 1rem; line-height: 1.45; max-width: 42ch; }
|
||||||
|
.cta { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; color: var(--accent); font-size: 0.98rem; }
|
||||||
|
.arr { transition: transform 0.18s ease; }
|
||||||
|
.card:hover .arr { transform: translateX(4px); }
|
||||||
|
|
||||||
|
/* photos sit inset (rounded) so the card colour shows all around them */
|
||||||
|
.photo { background-size: cover; background-position: center; border-radius: 11px; }
|
||||||
|
|
||||||
|
/* News — inset photo on top, headline below; larger hero header */
|
||||||
|
.card--news { flex-direction: column; }
|
||||||
|
.card--news .photo--top { width: 100%; flex: 1 1 auto; min-height: 150px; }
|
||||||
|
.card--news .content { flex: 0 0 auto; padding-top: 14px; }
|
||||||
|
.card--news .ic { font-size: clamp(2rem, 2.8vw, 2.5rem); }
|
||||||
|
.card--news .title { font-size: clamp(1.5rem, 2.6vw, 2rem); }
|
||||||
|
.card--news .headline {
|
||||||
|
margin: 12px 0 4px; font-family: Georgia, serif; font-style: italic;
|
||||||
|
font-size: clamp(1.1rem, 1.9vw, 1.4rem); line-height: 1.34; max-width: 40ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Art — inset rectangular photo on the RIGHT (cover-cropped to hide ragged edges) */
|
||||||
|
.card--art { flex-direction: row; align-items: stretch; gap: var(--pad); }
|
||||||
|
.card--art .content { justify-content: center; }
|
||||||
|
.card--art .photo--right { flex: 0 0 40%; min-width: 120px; align-self: stretch; }
|
||||||
|
|
||||||
|
/* Static — centered header, body left, link pinned to the bottom (cards line up) */
|
||||||
|
.card--static .head { justify-content: center; }
|
||||||
|
.card--static .blurb { margin: 12px 0 14px; }
|
||||||
|
.card--static .cta { margin-top: auto; }
|
||||||
|
</style>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<button class="primary" onclick={onclose}>Done</button>
|
<button class="primary" onclick={onclose}>Done</button>
|
||||||
{:else}
|
{:else}
|
||||||
<h2>Sign in to Upbeat Bytes</h2>
|
<h2>Sign in to upbeatBytes</h2>
|
||||||
<p class="sub">
|
<p class="sub">
|
||||||
Save articles and keep your history across devices.
|
Save articles and keep your history across devices.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getJSON, postJSON } from '$lib/api.js';
|
import { getJSON, postJSON } from '$lib/api.js';
|
||||||
|
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
|
||||||
|
import { trackGame, gameShareUrl } from '$lib/analytics.js';
|
||||||
|
|
||||||
let { variant = '5', onstatus } = $props();
|
let { variant = '5', onstatus } = $props();
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
requestAnimationFrame(() => (ready = true));
|
requestAnimationFrame(() => (ready = true));
|
||||||
|
syncNow(); // reconcile with the server in the background (signed-in only)
|
||||||
}
|
}
|
||||||
|
|
||||||
function restore() {
|
function restore() {
|
||||||
@@ -59,6 +62,30 @@
|
|||||||
try { localStorage.setItem(stateKey, JSON.stringify({ guesses, cols, status, answer, why })); } catch { /* ignore */ }
|
try { localStorage.setItem(stateKey, JSON.stringify({ guesses, cols, status, answer, why })); } catch { /* ignore */ }
|
||||||
onstatus?.(summary());
|
onstatus?.(summary());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- cross-device sync (signed-in only; "furthest progress" merged server-side) ---
|
||||||
|
let serverStats = $state(null);
|
||||||
|
let syncTimer;
|
||||||
|
function adopt(merged) {
|
||||||
|
if (!merged || !Array.isArray(merged.guesses)) return;
|
||||||
|
const lr = [status !== 'playing' ? 1 : 0, guesses.length];
|
||||||
|
const mr = [merged.status && merged.status !== 'playing' ? 1 : 0, merged.guesses.length];
|
||||||
|
if (mr[0] > lr[0] || (mr[0] === lr[0] && mr[1] > lr[1])) { // server is further along
|
||||||
|
guesses = merged.guesses;
|
||||||
|
cols = Array.isArray(merged.cols) ? merged.cols : cols;
|
||||||
|
status = merged.status || 'playing';
|
||||||
|
answer = merged.answer ?? answer;
|
||||||
|
why = merged.why ?? why;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function syncNow() {
|
||||||
|
const d = date, v = variant;
|
||||||
|
const merged = await pushGameState('word', v, d, { guesses, cols, status, answer, why });
|
||||||
|
if (d === date && v === variant) adopt(merged);
|
||||||
|
serverStats = await fetchGameStats('word', variant); // streak/dist follow you across devices
|
||||||
|
}
|
||||||
|
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1000); }
|
||||||
function summary() {
|
function summary() {
|
||||||
return { variant, date, status, tries: guesses.length, max: maxGuesses };
|
return { variant, date, status, tries: guesses.length, max: maxGuesses };
|
||||||
}
|
}
|
||||||
@@ -95,12 +122,14 @@
|
|||||||
submitting = true;
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 });
|
const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 });
|
||||||
|
if (guesses.length === 0) trackGame('word', 'started'); // first real guess = started
|
||||||
guesses = [...guesses, current];
|
guesses = [...guesses, current];
|
||||||
cols = [...cols, res.colors];
|
cols = [...cols, res.colors];
|
||||||
current = '';
|
current = '';
|
||||||
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); }
|
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); trackGame('word', 'completed'); }
|
||||||
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); }
|
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); trackGame('word', 'completed'); }
|
||||||
persist();
|
persist();
|
||||||
|
syncSoon(); // push this guess (and any completion) to the server, debounced
|
||||||
} catch {
|
} catch {
|
||||||
flash('Hmm — couldn’t check that. Try again.');
|
flash('Hmm — couldn’t check that. Try again.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -119,6 +148,7 @@
|
|||||||
}
|
}
|
||||||
let stats = $derived.by(() => {
|
let stats = $derived.by(() => {
|
||||||
if (status === 'playing') return null;
|
if (status === 'playing') return null;
|
||||||
|
if (serverStats) return serverStats; // signed-in: consistent across devices
|
||||||
try { return JSON.parse(localStorage.getItem(statsKey) || 'null'); } catch { return null; }
|
try { return JSON.parse(localStorage.getItem(statsKey) || 'null'); } catch { return null; }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,9 +167,11 @@
|
|||||||
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
||||||
const score = status === 'won' ? guesses.length : 'X';
|
const score = status === 'won' ? guesses.length : 'X';
|
||||||
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
|
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
|
||||||
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\nupbeatbytes.com/play`;
|
const text = `upbeatBytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
|
||||||
if (navigator.share) navigator.share({ text }).catch(() => {});
|
// Count a share only once it actually happens (sheet completed / clipboard wrote),
|
||||||
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
// never on a cancelled share sheet or denied clipboard.
|
||||||
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('word', 'shared')).catch(() => {});
|
||||||
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('word', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load on mount and whenever the variant toggles. Tracks ONLY `variant` — it
|
// Load on mount and whenever the variant toggles. Tracks ONLY `variant` — it
|
||||||
@@ -165,7 +197,7 @@
|
|||||||
{#each Array(length) as _, c (c)}
|
{#each Array(length) as _, c (c)}
|
||||||
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
|
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
|
||||||
<div class="tile {cs ? cs[c] : ''}" class:filled={!!ch}
|
<div class="tile {cs ? cs[c] : ''}" class:filled={!!ch}
|
||||||
style={cs ? `animation-delay:${c * 0.08}s` : ''}>{(ch || '').toUpperCase()}</div>
|
style={cs ? `--d:${c * 0.08}s` : ''}>{(ch || '').toUpperCase()}</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -225,22 +257,53 @@
|
|||||||
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
||||||
border: 2px solid var(--line); border-radius: 8px; font-family: var(--label);
|
border: 2px solid var(--line); border-radius: 8px; font-family: var(--label);
|
||||||
font-weight: 700; font-size: 1.5rem; color: var(--ink); text-transform: uppercase;
|
font-weight: 700; font-size: 1.5rem; color: var(--ink); text-transform: uppercase;
|
||||||
background: var(--surface);
|
background: var(--surface); position: relative; overflow: hidden;
|
||||||
}
|
}
|
||||||
.tile.filled { border-color: #b7c0cb; }
|
.tile.filled { border-color: #b7c0cb; }
|
||||||
.tile.correct { background: #4a9d6e; border-color: #4a9d6e; color: #fff; }
|
/* Judged tiles set like glazed enamel: a soft top-light gradient over the
|
||||||
.tile.present { background: #d8b24a; border-color: #d8b24a; color: #fff; }
|
colour, an inner bevel, and a little lift off the board. Pending tiles stay
|
||||||
.tile.absent { background: #9aa6b2; border-color: #9aa6b2; color: #fff; }
|
flat on purpose — depth marks the moment a letter is settled. */
|
||||||
|
.tile.correct, .tile.present, .tile.absent { color: #fff; text-shadow: 0 1px 2px rgba(20, 30, 25, 0.22); }
|
||||||
|
.tile.correct {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0) 45%),
|
||||||
|
linear-gradient(165deg, #56ac7c, #4a9d6e 55%, #3e8a5e);
|
||||||
|
border-color: #3e8a5e;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.32), inset 0 -3px 5px rgba(22, 64, 42, 0.2),
|
||||||
|
0 2px 5px rgba(58, 125, 86, 0.28);
|
||||||
|
}
|
||||||
|
.tile.present {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0) 45%),
|
||||||
|
linear-gradient(165deg, #e2c163, #d8b24a 55%, #c29c38);
|
||||||
|
border-color: #c29c38;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35), inset 0 -3px 5px rgba(122, 92, 22, 0.2),
|
||||||
|
0 2px 5px rgba(184, 148, 58, 0.28);
|
||||||
|
}
|
||||||
|
.tile.absent {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0) 45%),
|
||||||
|
linear-gradient(165deg, #a7b2bd, #9aa6b2 55%, #87939f);
|
||||||
|
border-color: #87939f;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.26), inset 0 -3px 5px rgba(50, 60, 70, 0.16),
|
||||||
|
0 2px 4px rgba(110, 122, 134, 0.24);
|
||||||
|
}
|
||||||
|
/* One quiet glint sweeps each tile just after its flip lands — once, not a loop. */
|
||||||
|
.tile.correct::after, .tile.present::after, .tile.absent::after {
|
||||||
|
content: ''; position: absolute; inset: 0; pointer-events: none;
|
||||||
|
background: linear-gradient(115deg, transparent 38%, rgba(255, 255, 255, 0.38) 50%, transparent 62%);
|
||||||
|
transform: translateX(-130%);
|
||||||
|
animation: sheen 0.65s ease-out both;
|
||||||
|
animation-delay: calc(var(--d, 0s) + 0.32s);
|
||||||
|
}
|
||||||
|
|
||||||
/* Juice: a tile pops as you type; the row reveals with a staggered bounce when
|
/* Juice: a tile pops as you type; the row reveals with a staggered bounce when
|
||||||
you submit; the row shakes on an invalid word. */
|
you submit; the row shakes on an invalid word. */
|
||||||
.tile.filled:not(.correct):not(.present):not(.absent) { animation: pop 0.13s ease; }
|
.tile.filled:not(.correct):not(.present):not(.absent) { animation: pop 0.13s ease; }
|
||||||
.tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; }
|
.tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; animation-delay: var(--d, 0s); }
|
||||||
.row.shake { animation: shake 0.4s ease; }
|
.row.shake { animation: shake 0.4s ease; }
|
||||||
@keyframes pop { 0% { transform: scale(1); } 45% { transform: scale(1.09); } 100% { transform: scale(1); } }
|
@keyframes pop { 0% { transform: scale(1); } 45% { transform: scale(1.09); } 100% { transform: scale(1); } }
|
||||||
@keyframes reveal { 0% { transform: scale(0.5); opacity: 0.3; } 55% { transform: scale(1.12); } 100% { transform: scale(1); opacity: 1; } }
|
@keyframes reveal { 0% { transform: scale(0.5); opacity: 0.3; } 55% { transform: scale(1.12); } 100% { transform: scale(1); opacity: 1; } }
|
||||||
|
@keyframes sheen { to { transform: translateX(130%); } }
|
||||||
@keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-7px); } 40% { transform: translateX(7px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(5px); } }
|
@keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-7px); } 40% { transform: translateX(7px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(5px); } }
|
||||||
@media (prefers-reduced-motion: reduce) { .tile, .row { animation: none !important; } }
|
@media (prefers-reduced-motion: reduce) { .tile, .tile::after, .row { animation: none !important; } }
|
||||||
.flash {
|
.flash {
|
||||||
text-align: center; background: var(--ink); color: #fff; border-radius: 8px;
|
text-align: center; background: var(--ink); color: #fff; border-radius: 8px;
|
||||||
padding: 7px 14px; width: fit-content; margin: 0 auto 12px; font-size: 0.86rem;
|
padding: 7px 14px; width: fit-content; margin: 0 auto 12px; font-size: 0.86rem;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getJSON } from '$lib/api.js';
|
import { getJSON } from '$lib/api.js';
|
||||||
import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js';
|
import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js';
|
||||||
|
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
|
||||||
|
import { trackGame, gameShareUrl } from '$lib/analytics.js';
|
||||||
|
|
||||||
let { size = 'med', onstatus } = $props();
|
let { size = 'med', onstatus } = $props();
|
||||||
|
|
||||||
@@ -15,7 +17,8 @@
|
|||||||
let foundWords = $state([]); // {word, cells:[[r,c]], ci}
|
let foundWords = $state([]); // {word, cells:[[r,c]], ci}
|
||||||
let sel = $state([]); // current selection cells
|
let sel = $state([]); // current selection cells
|
||||||
let selecting = false;
|
let selecting = false;
|
||||||
let startTime = 0;
|
let playedMs = 0; // accumulated ACTIVE play time (closed segments)
|
||||||
|
let segStart = 0; // wall-clock start of the open segment (0 = paused)
|
||||||
let resultMs = $state(0);
|
let resultMs = $state(0);
|
||||||
let best = $state(0);
|
let best = $state(0);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -32,26 +35,74 @@
|
|||||||
const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing');
|
const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing');
|
||||||
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
|
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
|
||||||
const cellColor = $derived.by(() => {
|
const cellColor = $derived.by(() => {
|
||||||
const m = new Map();
|
const m = new Map(); // "r,c" -> [colour index, ...] (all words covering it)
|
||||||
for (const w of foundWords) for (const [r, c] of w.cells) m.set(r + ',' + c, w.ci);
|
for (const w of foundWords) for (const [r, c] of w.cells) {
|
||||||
|
const k = r + ',' + c;
|
||||||
|
if (!m.has(k)) m.set(k, []);
|
||||||
|
m.get(k).push(w.ci);
|
||||||
|
}
|
||||||
return m;
|
return m;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Multiply-blend the palette colours of every word covering a cell, so where
|
||||||
|
// words cross the cell DEEPENS into a darker shade instead of one colour
|
||||||
|
// stomping the others. Single-word cells keep their plain pastel.
|
||||||
|
function cellStyle(indices) {
|
||||||
|
let r = 255, g = 255, b = 255;
|
||||||
|
for (const ci of indices) {
|
||||||
|
const h = PALETTE[ci];
|
||||||
|
r = (r * parseInt(h.slice(1, 3), 16)) / 255;
|
||||||
|
g = (g * parseInt(h.slice(3, 5), 16)) / 255;
|
||||||
|
b = (b * parseInt(h.slice(5, 7), 16)) / 255;
|
||||||
|
}
|
||||||
|
r = Math.round(r); g = Math.round(g); b = Math.round(b);
|
||||||
|
const lum = 0.299 * r + 0.587 * g + 0.114 * b; // keep text readable as cells darken
|
||||||
|
return `background:rgb(${r},${g},${b});color:${lum < 140 ? '#fff' : '#2a2f36'}`;
|
||||||
|
}
|
||||||
const wordColor = $derived.by(() => {
|
const wordColor = $derived.by(() => {
|
||||||
const m = new Map();
|
const m = new Map();
|
||||||
for (const w of foundWords) m.set(w.word, w.ci);
|
for (const w of foundWords) m.set(w.word, w.ci);
|
||||||
return m;
|
return m;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- the clock counts ACTIVE play only -----------------------------------
|
||||||
|
// Wall-clock timing made "finish in one sitting" feel mandatory — the
|
||||||
|
// opposite of calm. The clock runs only while the puzzle is on screen
|
||||||
|
// (tab visible, window focused, game unfinished); stepping away pauses it,
|
||||||
|
// coming back resumes it, and several sittings simply add up.
|
||||||
|
function playedNow() { return playedMs + (segStart ? Date.now() - segStart : 0); }
|
||||||
|
function pauseClock(save = true) {
|
||||||
|
if (!segStart) return;
|
||||||
|
playedMs += Date.now() - segStart; segStart = 0;
|
||||||
|
if (save) persist(); // don't lose the segment if the tab dies
|
||||||
|
}
|
||||||
|
function resumeClock() {
|
||||||
|
if (!segStart && !loading && status === 'playing' && !document.hidden) segStart = Date.now();
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
const onVis = () => (document.hidden ? pauseClock() : resumeClock());
|
||||||
|
const onAway = () => pauseClock();
|
||||||
|
document.addEventListener('visibilitychange', onVis);
|
||||||
|
window.addEventListener('pagehide', onAway);
|
||||||
|
window.addEventListener('blur', onAway);
|
||||||
|
window.addEventListener('focus', onVis);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVis);
|
||||||
|
window.removeEventListener('pagehide', onAway);
|
||||||
|
window.removeEventListener('blur', onAway);
|
||||||
|
window.removeEventListener('focus', onVis);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const seq = ++loadSeq; // stale-load guard for rapid size switches
|
const seq = ++loadSeq; // stale-load guard for rapid size switches
|
||||||
loading = true; ready = false;
|
loading = true; ready = false;
|
||||||
foundWords = []; sel = []; resultMs = 0; startTime = 0;
|
foundWords = []; sel = []; resultMs = 0; playedMs = 0; segStart = 0;
|
||||||
try {
|
try {
|
||||||
const p = await getJSON('/api/puzzle/wordsearch?variant=' + size);
|
const p = await getJSON('/api/puzzle/wordsearch?variant=' + size);
|
||||||
if (seq !== loadSeq) return; // a newer size was selected — abandon
|
if (seq !== loadSeq) return; // a newer size was selected — abandon
|
||||||
theme = p.theme; words = p.words; grid = p.grid; date = p.date;
|
theme = p.theme; words = p.words; grid = p.grid; date = p.date;
|
||||||
restore();
|
restore();
|
||||||
if (!startTime) startTime = Date.now();
|
|
||||||
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
|
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
|
||||||
} catch {
|
} catch {
|
||||||
if (seq !== loadSeq) return;
|
if (seq !== loadSeq) return;
|
||||||
@@ -59,30 +110,66 @@
|
|||||||
}
|
}
|
||||||
if (seq !== loadSeq) return;
|
if (seq !== loadSeq) return;
|
||||||
loading = false;
|
loading = false;
|
||||||
|
resumeClock();
|
||||||
requestAnimationFrame(() => (ready = true));
|
requestAnimationFrame(() => (ready = true));
|
||||||
|
// Reconcile with the server in the background (signed-in only): pull any
|
||||||
|
// progress from another device, and pull the cross-device best time.
|
||||||
|
syncNow();
|
||||||
|
fetchGameStats('wordsearch', size).then((st) => {
|
||||||
|
if (st && st.best && (!best || st.best < best)) best = st.best;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only finds whose cells still spell their word in the CURRENT grid —
|
||||||
|
// guards stale highlights if the puzzle/layout changed (and validates finds
|
||||||
|
// arriving from another device, which is the same date+size grid).
|
||||||
|
function validFinds(list) {
|
||||||
|
return (list || []).filter((fw) =>
|
||||||
|
fw && Array.isArray(fw.cells) && words.includes(fw.word) &&
|
||||||
|
fw.cells.map(([r, c]) => (grid[r] && grid[r][c]) || '').join('') === fw.word);
|
||||||
}
|
}
|
||||||
|
|
||||||
function restore() {
|
function restore() {
|
||||||
try {
|
try {
|
||||||
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
||||||
if (s && Array.isArray(s.foundWords)) {
|
if (s && Array.isArray(s.foundWords)) {
|
||||||
// Keep only finds whose stored cells still spell their word in the CURRENT
|
foundWords = validFinds(s.foundWords);
|
||||||
// grid — guards against stale highlights if the day's puzzle changed.
|
playedMs = s.played || 0; // pre-"active clock" saves restart at 0:00 — the kind direction
|
||||||
const valid = s.foundWords.filter((fw) =>
|
resultMs = foundWords.length === words.length ? (s.ms || 0) : 0;
|
||||||
fw && Array.isArray(fw.cells) && words.includes(fw.word) &&
|
|
||||||
fw.cells.map(([r, c]) => (grid[r] && grid[r][c]) || '').join('') === fw.word);
|
|
||||||
foundWords = valid;
|
|
||||||
startTime = s.startTime || 0;
|
|
||||||
resultMs = valid.length === words.length ? (s.ms || 0) : 0;
|
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
onstatus?.(summary());
|
onstatus?.(summary());
|
||||||
}
|
}
|
||||||
function persist() {
|
function persist() {
|
||||||
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, startTime, ms: resultMs, status })); }
|
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, played: playedNow(), ms: resultMs, status })); }
|
||||||
catch { /* ignore */ }
|
catch { /* ignore */ }
|
||||||
onstatus?.(summary());
|
onstatus?.(summary());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- cross-device sync (signed-in only; merged server-side) ---
|
||||||
|
let syncTimer;
|
||||||
|
function adopt(merged) {
|
||||||
|
if (!merged) return;
|
||||||
|
const have = new Set(foundWords.map((w) => w.word));
|
||||||
|
for (const fw of validFinds(merged.foundWords)) {
|
||||||
|
if (!have.has(fw.word)) { foundWords = [...foundWords, fw]; have.add(fw.word); }
|
||||||
|
}
|
||||||
|
// renumber colours by find order so overlap blends stay consistent
|
||||||
|
foundWords = foundWords.map((fw, i) => ({ ...fw, ci: i % PALETTE.length }));
|
||||||
|
// another device may have accumulated more active time — credit the larger
|
||||||
|
if ((merged.played || 0) > playedNow()) {
|
||||||
|
playedMs = merged.played;
|
||||||
|
if (segStart) segStart = Date.now();
|
||||||
|
}
|
||||||
|
if (foundWords.length === words.length && merged.ms) resultMs = Math.min(resultMs || merged.ms, merged.ms);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
async function syncNow() {
|
||||||
|
const d = date, sz = size; // pin against a size switch mid-flight
|
||||||
|
const merged = await pushGameState('wordsearch', sz, d, { foundWords, played: playedNow(), ms: resultMs });
|
||||||
|
if (d === date && sz === size) adopt(merged); // ignore if the user switched away
|
||||||
|
}
|
||||||
|
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1200); }
|
||||||
function summary() { return { game: 'wordsearch', size, date, status, found: foundWords.length, total: words.length, ms: resultMs }; }
|
function summary() { return { game: 'wordsearch', size, date, status, found: foundWords.length, total: words.length, ms: resultMs }; }
|
||||||
|
|
||||||
function cellAt(e) {
|
function cellAt(e) {
|
||||||
@@ -92,7 +179,7 @@
|
|||||||
function down(e) {
|
function down(e) {
|
||||||
if (status === 'done') return;
|
if (status === 'done') return;
|
||||||
selecting = true;
|
selecting = true;
|
||||||
if (!startTime) startTime = Date.now();
|
resumeClock(); // safety net if a focus event was missed
|
||||||
sel = [cellAt(e)];
|
sel = [cellAt(e)];
|
||||||
gridEl.setPointerCapture?.(e.pointerId);
|
gridEl.setPointerCapture?.(e.pointerId);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -111,14 +198,18 @@
|
|||||||
function evaluate(cells) {
|
function evaluate(cells) {
|
||||||
const hit = matchWord(cells, grid, words, found);
|
const hit = matchWord(cells, grid, words, found);
|
||||||
if (!hit) return;
|
if (!hit) return;
|
||||||
|
if (foundWords.length === 0) trackGame('wordsearch', 'started'); // first word found = started
|
||||||
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
|
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
|
||||||
okFlash = true; setTimeout(() => (okFlash = false), 500);
|
okFlash = true; setTimeout(() => (okFlash = false), 500);
|
||||||
if (foundWords.length === words.length) finish();
|
if (foundWords.length === words.length) finish();
|
||||||
persist();
|
persist();
|
||||||
|
syncSoon(); // push this find (and completion) to the server, debounced
|
||||||
}
|
}
|
||||||
|
|
||||||
function finish() {
|
function finish() {
|
||||||
resultMs = startTime ? Date.now() - startTime : 0;
|
pauseClock(false); // close the open segment; persist follows in evaluate()
|
||||||
|
trackGame('wordsearch', 'completed');
|
||||||
|
resultMs = playedMs;
|
||||||
if (resultMs && (!best || resultMs < best)) {
|
if (resultMs && (!best || resultMs < best)) {
|
||||||
best = resultMs;
|
best = resultMs;
|
||||||
try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ }
|
try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ }
|
||||||
@@ -132,9 +223,9 @@
|
|||||||
|
|
||||||
function share() {
|
function share() {
|
||||||
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
|
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
|
||||||
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\nupbeatbytes.com/play`;
|
const text = `upbeatBytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
|
||||||
if (navigator.share) navigator.share({ text }).catch(() => {});
|
if (navigator.share) navigator.share({ text }).then(() => trackGame('wordsearch', 'shared')).catch(() => {});
|
||||||
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
|
else navigator.clipboard?.writeText(text).then(() => { trackGame('wordsearch', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load on mount and whenever the size changes. Tracks ONLY `size`.
|
// Load on mount and whenever the size changes. Tracks ONLY `size`.
|
||||||
@@ -157,7 +248,7 @@
|
|||||||
{#each rowStr.split('') as ch, c (c)}
|
{#each rowStr.split('') as ch, c (c)}
|
||||||
{@const key = r + ',' + c}
|
{@const key = r + ',' + c}
|
||||||
<div class="cell" class:sel={selSet.has(key)}
|
<div class="cell" class:sel={selSet.has(key)}
|
||||||
style={cellColor.has(key) && !selSet.has(key) ? `background:${PALETTE[cellColor.get(key)]};color:#2a2f36` : ''}>{ch}</div>
|
style={cellColor.has(key) && !selSet.has(key) ? cellStyle(cellColor.get(key)) : ''}>{ch}</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Reusable in-development gate. Lets us ship a half-built game LIVE (so we can
|
||||||
|
// test the real, fully-wired experience) without any public visitor stumbling
|
||||||
|
// onto it:
|
||||||
|
// • its card shows on /play only for admins (or a preview link)
|
||||||
|
// • its route bounces non-admins back to the hub
|
||||||
|
// • search engines never index it (the route guard + noindex)
|
||||||
|
// Launch = remove the game key from IN_DEV. Future dev games just join the set.
|
||||||
|
//
|
||||||
|
// This is casual concealment, not security — the API is harmless on its own.
|
||||||
|
|
||||||
|
// 'bloom' launched 2026-06-15 — public. 'match' (Memory/Color Match) launched
|
||||||
|
// 2026-06-17 — public. 'zen' (UB/Zen Den) gated while UB is ironed out (model
|
||||||
|
// tabled). Add a key here to gate any in-dev game/section.
|
||||||
|
export const IN_DEV = new Set(['zen']);
|
||||||
|
|
||||||
|
// A non-obvious bypass so we can test logged-out / incognito: /play?...&preview=KEY
|
||||||
|
const PREVIEW_KEY = 'sunflower';
|
||||||
|
|
||||||
|
export function isDevGated(gameKey) {
|
||||||
|
return IN_DEV.has(gameKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can THIS viewer see in-dev games? Admins always; anyone with the preview token.
|
||||||
|
export function canSeeDev(user, url) {
|
||||||
|
if (user?.is_admin) return true;
|
||||||
|
try {
|
||||||
|
return url?.searchParams?.get('preview') === PREVIEW_KEY;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when this game should be hidden/blocked for this viewer.
|
||||||
|
export function blockedForViewer(gameKey, user, url) {
|
||||||
|
return isDevGated(gameKey) && !canSeeDev(user, url);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,40 @@
|
|||||||
|
// Pure routing helpers for the news feed, shared by its two mounts: `/` (interim,
|
||||||
|
// becomes the hub at cutover) and `/news` (its permanent home). Kept framework-free
|
||||||
|
// so they're unit-testable without mounting the Svelte component.
|
||||||
|
|
||||||
|
// Which base path the feed is rendering at. `/news` is permanent; `/` is the interim
|
||||||
|
// mount. Anything not under /news is treated as `/`.
|
||||||
|
export function feedBase(pathname) {
|
||||||
|
return (pathname || '').startsWith('/news') ? '/news' : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default (bare-path) view per base: `/news` leads with Latest — the live, "as it
|
||||||
|
// comes in" firehose — while the interim `/` keeps leading with Highlights (frozen). At
|
||||||
|
// cutover `/` becomes the hub, so only the /news default remains.
|
||||||
|
export function defaultView(base) {
|
||||||
|
return base === '/news' ? 'latest' : 'today';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current view key, derived from query params only (path-agnostic, so a deep link
|
||||||
|
// works at either base): search > source > tag > explicit view > the base's default.
|
||||||
|
// 'highlights' is the canonical param for the Brief; 'today' stays accepted as an alias.
|
||||||
|
export function parseView(url, def = 'today') {
|
||||||
|
const p = url.searchParams;
|
||||||
|
if ((p.get('q') || '').trim()) return 'search';
|
||||||
|
if (p.get('source')) return 'source:' + p.get('source');
|
||||||
|
if (p.get('tag')) return 'tag:' + p.get('tag');
|
||||||
|
const v = p.get('view');
|
||||||
|
if (!v) return def;
|
||||||
|
if (v === 'highlights' || v === 'today') return 'today'; // Brief (alias-tolerant)
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link to a view at the given base path. The base's default view is the bare path;
|
||||||
|
// the Brief is canonically `?view=highlights`; everything else carries its own param.
|
||||||
|
export function viewUrl(base, key, def = 'today') {
|
||||||
|
if (key === def) return base;
|
||||||
|
if (key === 'today') return base + '?view=highlights';
|
||||||
|
if (key.startsWith('source:')) return base + '?source=' + encodeURIComponent(key.slice(7));
|
||||||
|
if (key.startsWith('tag:')) return base + '?tag=' + encodeURIComponent(key.slice(4));
|
||||||
|
return base + '?view=' + encodeURIComponent(key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { feedBase, defaultView, parseView, viewUrl } from './feednav.js';
|
||||||
|
|
||||||
|
const view = (path, def) => parseView(new URL('https://x' + path), def);
|
||||||
|
|
||||||
|
describe('feedBase', () => {
|
||||||
|
it('is /news only under the /news path; everything else is /', () => {
|
||||||
|
expect(feedBase('/news')).toBe('/news');
|
||||||
|
expect(feedBase('/news?view=highlights')).toBe('/news');
|
||||||
|
expect(feedBase('/')).toBe('/');
|
||||||
|
expect(feedBase('/?view=latest')).toBe('/');
|
||||||
|
expect(feedBase('')).toBe('/');
|
||||||
|
expect(feedBase(undefined)).toBe('/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defaultView', () => {
|
||||||
|
it('/news leads with Latest; / (frozen) leads with Highlights', () => {
|
||||||
|
expect(defaultView('/news')).toBe('latest');
|
||||||
|
expect(defaultView('/')).toBe('today');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseView', () => {
|
||||||
|
it('bare path resolves to the base default', () => {
|
||||||
|
expect(view('/')).toBe('today'); // / default (frozen)
|
||||||
|
expect(view('/news', 'latest')).toBe('latest'); // /news default = Latest
|
||||||
|
});
|
||||||
|
it('Brief is reachable via ?view=highlights, with ?view=today as an alias', () => {
|
||||||
|
expect(view('/news?view=highlights', 'latest')).toBe('today');
|
||||||
|
expect(view('/news?view=today', 'latest')).toBe('today'); // alias kept
|
||||||
|
expect(view('/?view=today')).toBe('today');
|
||||||
|
});
|
||||||
|
it('reads the standard views regardless of base', () => {
|
||||||
|
expect(view('/news?view=latest', 'latest')).toBe('latest');
|
||||||
|
expect(view('/?view=following')).toBe('following');
|
||||||
|
expect(view('/?tag=clean-energy')).toBe('tag:clean-energy');
|
||||||
|
expect(view('/?source=7')).toBe('source:7');
|
||||||
|
expect(view('/?q=solar')).toBe('search');
|
||||||
|
});
|
||||||
|
it('prioritizes search > source > tag > view, ignores whitespace q', () => {
|
||||||
|
expect(view('/?q=a&source=7&tag=b&view=latest')).toBe('search');
|
||||||
|
expect(view('/?source=7&tag=b&view=latest')).toBe('source:7');
|
||||||
|
expect(view('/?tag=b&view=latest')).toBe('tag:b');
|
||||||
|
expect(view('/?q=%20')).toBe('today');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewUrl', () => {
|
||||||
|
it('the base default is the bare path; the Brief is ?view=highlights', () => {
|
||||||
|
// /news: Latest is the default (bare), Highlights is explicit
|
||||||
|
expect(viewUrl('/news', 'latest', 'latest')).toBe('/news');
|
||||||
|
expect(viewUrl('/news', 'today', 'latest')).toBe('/news?view=highlights');
|
||||||
|
expect(viewUrl('/news', 'following', 'latest')).toBe('/news?view=following');
|
||||||
|
// / (frozen): Highlights is the default (bare), Latest is explicit
|
||||||
|
expect(viewUrl('/', 'today', 'today')).toBe('/');
|
||||||
|
expect(viewUrl('/', 'latest', 'today')).toBe('/?view=latest');
|
||||||
|
});
|
||||||
|
it('round-trips: a generated link parses back to the same view', () => {
|
||||||
|
expect(view(viewUrl('/news', 'today', 'latest'), 'latest')).toBe('today');
|
||||||
|
expect(view(viewUrl('/news', 'latest', 'latest'), 'latest')).toBe('latest');
|
||||||
|
expect(view(viewUrl('/', 'latest', 'today').replace('/', '/'), 'today')).toBe('latest');
|
||||||
|
});
|
||||||
|
it('encodes tag/source/view values', () => {
|
||||||
|
expect(viewUrl('/news', 'tag:good news', 'latest')).toBe('/news?tag=good%20news');
|
||||||
|
expect(viewUrl('/', 'source:42', 'today')).toBe('/?source=42');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Memory Match board engine — fully deterministic ("code disposes"): a given seed
|
||||||
|
// always yields the same board, so the daily is shared across devices with no
|
||||||
|
// server round-trip. Faces are stored as KEYS (icon name or color key), never raw
|
||||||
|
// indices, so saved progress survives layout tweaks (Codex).
|
||||||
|
import { ICON_KEYS } from './icons.js';
|
||||||
|
import { COLORS, deltaE } from './palette.js';
|
||||||
|
|
||||||
|
// Tiers — Gentle/Standard match pairs; Expert matches THREE of a kind and uses a
|
||||||
|
// bigger grid so it reads as a real step up (Codex's sizing).
|
||||||
|
// gentle 4×3 = 12 cards = 6 pairs
|
||||||
|
// standard 4×4 = 16 cards = 8 pairs
|
||||||
|
// expert 6×4 = 24 cards = 8 triples
|
||||||
|
export const TIERS = {
|
||||||
|
gentle: { label: 'Gentle', cols: 4, rows: 3, faces: 6, matchN: 2 },
|
||||||
|
standard: { label: 'Standard', cols: 4, rows: 4, faces: 8, matchN: 2 },
|
||||||
|
expert: { label: 'Expert', cols: 6, rows: 4, faces: 8, matchN: 3 },
|
||||||
|
};
|
||||||
|
export const TIER_KEYS = Object.keys(TIERS);
|
||||||
|
export const FORMAT_KEYS = ['icons', 'colors'];
|
||||||
|
|
||||||
|
// Minimum perceptual gap (ΔE) we try to keep between a board's colors so two
|
||||||
|
// near-identical shades never appear together; relaxed only if a board can't fill.
|
||||||
|
const MIN_DELTA_E = 26;
|
||||||
|
|
||||||
|
function hashStr(s) {
|
||||||
|
let h = 2166136261 >>> 0;
|
||||||
|
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
function mulberry32(a) {
|
||||||
|
return function () {
|
||||||
|
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function shuffle(arr, rng) {
|
||||||
|
const a = arr.slice();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick `n` colors, seed-varied but spaced: walk a seeded shuffle keeping only
|
||||||
|
// colors at least `threshold` ΔE from those already chosen; relax if we can't fill.
|
||||||
|
function pickColors(n, rng) {
|
||||||
|
const order = shuffle(COLORS, rng);
|
||||||
|
for (let threshold = MIN_DELTA_E; threshold >= 0; threshold -= 3) {
|
||||||
|
const chosen = [];
|
||||||
|
for (const c of order) {
|
||||||
|
if (chosen.every((p) => deltaE(p.lab, c.lab) >= threshold)) chosen.push(c);
|
||||||
|
if (chosen.length === n) break;
|
||||||
|
}
|
||||||
|
if (chosen.length === n) return chosen.map((c) => c.key);
|
||||||
|
}
|
||||||
|
return order.slice(0, n).map((c) => c.key); // pool too small (shouldn't happen)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFaces(format, n, rng) {
|
||||||
|
if (format === 'colors') return pickColors(n, rng);
|
||||||
|
return shuffle(ICON_KEYS, rng).slice(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a board. `seed` is the day's date (daily) or a random token (free play).
|
||||||
|
export function buildBoard({ format = 'icons', tier = 'standard', seed = '' } = {}) {
|
||||||
|
const t = TIERS[tier] || TIERS.standard;
|
||||||
|
const fmt = FORMAT_KEYS.includes(format) ? format : 'icons';
|
||||||
|
const rng = mulberry32(hashStr(`match:v1:${fmt}:${tier}:${seed}`));
|
||||||
|
const faces = pickFaces(fmt, t.faces, rng);
|
||||||
|
const multiset = [];
|
||||||
|
for (const key of faces) for (let c = 0; c < t.matchN; c++) multiset.push(key);
|
||||||
|
const cards = shuffle(multiset, rng).map((key, id) => ({ id, key }));
|
||||||
|
return { cards, faces, matchN: t.matchN, cols: t.cols, rows: t.rows, format: fmt, tier, label: t.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short token for a fresh free-play board (deterministic given the inputs so a
|
||||||
|
// reload resumes the same board); callers persist it.
|
||||||
|
export function freeSeed(n) {
|
||||||
|
return 'f' + (n >>> 0).toString(36);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildBoard, TIERS, TIER_KEYS } from './board.js';
|
||||||
|
import { COLOR_BY_KEY, deltaE } from './palette.js';
|
||||||
|
import { ICON_KEYS } from './icons.js';
|
||||||
|
|
||||||
|
describe('match board', () => {
|
||||||
|
it('is deterministic for a given seed', () => {
|
||||||
|
const a = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-16' });
|
||||||
|
const b = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-16' });
|
||||||
|
expect(a.cards).toEqual(b.cards);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('different days give different boards', () => {
|
||||||
|
const a = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-16' });
|
||||||
|
const b = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-17' });
|
||||||
|
expect(a.cards).not.toEqual(b.cards);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tier of TIER_KEYS) {
|
||||||
|
it(`${tier}: correct card count and exactly matchN of each face`, () => {
|
||||||
|
const t = TIERS[tier];
|
||||||
|
const { cards, faces, matchN } = buildBoard({ tier, seed: 's' });
|
||||||
|
expect(cards.length).toBe(t.cols * t.rows);
|
||||||
|
expect(faces.length).toBe(t.faces);
|
||||||
|
expect(matchN).toBe(t.matchN);
|
||||||
|
for (const f of faces) {
|
||||||
|
expect(cards.filter((c) => c.key === f).length).toBe(matchN);
|
||||||
|
}
|
||||||
|
// every card is one of the chosen faces
|
||||||
|
expect(cards.every((c) => faces.includes(c.key))).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('icon boards draw from the icon set', () => {
|
||||||
|
const { faces } = buildBoard({ format: 'icons', tier: 'expert', seed: 'x' });
|
||||||
|
expect(faces.every((k) => ICON_KEYS.includes(k))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('color boards are perceptually spaced (no near-identical pair)', () => {
|
||||||
|
// sweep many seeds; every board's colors must be comfortably distinct
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
const { faces } = buildBoard({ format: 'colors', tier: 'standard', seed: 'd' + i });
|
||||||
|
const labs = faces.map((k) => COLOR_BY_KEY[k].lab);
|
||||||
|
let min = Infinity;
|
||||||
|
for (let a = 0; a < labs.length; a++)
|
||||||
|
for (let b = a + 1; b < labs.length; b++)
|
||||||
|
min = Math.min(min, deltaE(labs[a], labs[b]));
|
||||||
|
expect(min).toBeGreaterThan(15); // never confusably close
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Memory Match icon set — in-repo, owned, no dependency. Each value is the inner
|
||||||
|
// SVG markup on a 0 0 24 24 viewBox; MatchIcon.svelte supplies the shared styling
|
||||||
|
// (stroke=currentColor, fill=none, round caps) so every icon reads as one calm
|
||||||
|
// line family. Keep additions simple and recognizable; quality over count.
|
||||||
|
export const ICONS = {
|
||||||
|
sun: '<circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7"/>',
|
||||||
|
moon: '<path d="M20 14.5A8 8 0 1 1 11 4a6.5 6.5 0 0 0 9 10.5z"/>',
|
||||||
|
star: '<path d="M12 3.5l2.6 5.3 5.9.9-4.25 4.15 1 5.85L12 17l-5.25 2.75 1-5.85L3.5 9.7l5.9-.9z"/>',
|
||||||
|
cloud: '<path d="M7 18h9.5a3.5 3.5 0 0 0 .3-7 5 5 0 0 0-9.6-1.3A3.8 3.8 0 0 0 7 18z"/>',
|
||||||
|
raindrop: '<path d="M12 3.5c3.5 4.2 5.5 7 5.5 9.8a5.5 5.5 0 0 1-11 0C6.5 10.5 8.5 7.7 12 3.5z"/>',
|
||||||
|
wave: '<path d="M3 9c2 0 2.5 2 4.5 2S10 9 12 9s2.5 2 4.5 2S19 9 21 9M3 15c2 0 2.5 2 4.5 2S10 15 12 15s2.5 2 4.5 2S19 15 21 15"/>',
|
||||||
|
leaf: '<path d="M5 19C5 11 11 5 19 5c0 8-6 14-14 14z"/><path d="M5 19C9 15 12 12 16 9"/>',
|
||||||
|
flower: '<circle cx="12" cy="12" r="2.3"/><circle cx="12" cy="6.6" r="2.3"/><circle cx="12" cy="17.4" r="2.3"/><circle cx="7.3" cy="9.3" r="2.3"/><circle cx="16.7" cy="9.3" r="2.3"/><circle cx="7.3" cy="14.7" r="2.3"/><circle cx="16.7" cy="14.7" r="2.3"/>',
|
||||||
|
seedling: '<path d="M12 20v-7M12 13c0-3 2.5-5 6-5 0 3.5-2.5 5-6 5zM12 13c0-2.5-2-4.5-5-4.5 0 3 2 4.5 5 4.5z"/>',
|
||||||
|
tree: '<path d="M12 3 6 11h3l-4 6h14l-4-6h3z"/><path d="M12 17v4"/>',
|
||||||
|
mountain: '<path d="M3 19l6-10 4 6 2.5-4L21 19z"/>',
|
||||||
|
shell: '<path d="M4 18C4 10 7.5 5 12 5s8 5 8 13z"/><path d="M12 18V6M8 18 9.5 7M16 18 14.5 7"/>',
|
||||||
|
feather: '<path d="M5 19C7 9 12 5 19 5c0 7-4 12-12 13z"/><path d="M5 19 13 11"/>',
|
||||||
|
acorn: '<path d="M6 8c0-2 2.7-3.5 6-3.5S18 6 18 8zM7 8a5 5 0 0 0 10 0M12 18v2"/>',
|
||||||
|
butterfly: '<path d="M12 6v12M12 9C8 4 4 6 4 10s4 5 8 2M12 9c4-5 8-3 8 1s-4 5-8 2"/>',
|
||||||
|
rainbow: '<path d="M3 18a9 9 0 0 1 18 0M6 18a6 6 0 0 1 12 0M9 18a3 3 0 0 1 6 0"/>',
|
||||||
|
heart: '<path d="M12 20S4 14.5 4 9a4 4 0 0 1 8-1 4 4 0 0 1 8 1c0 5.5-8 11-8 11z"/>',
|
||||||
|
sparkle: '<path d="M12 3c.6 4.5 1.5 5.4 6 6-4.5.6-5.4 1.5-6 6-.6-4.5-1.5-5.4-6-6 4.5-.6 5.4-1.5 6-6z"/>',
|
||||||
|
home: '<path d="M4 11 12 4l8 7M6 10v9h12v-9M10 19v-5h4v5"/>',
|
||||||
|
book: '<path d="M12 6C10 4.5 7 4.5 4 5v13c3-.5 6-.5 8 1 2-1.5 5-1.5 8-1V5c-3-.5-6-.5-8 1zM12 6v13"/>',
|
||||||
|
teacup: '<path d="M5 9h12v3a6 6 0 0 1-12 0zM17 10h2a2 2 0 0 1 0 4h-2M5 19h12"/>',
|
||||||
|
candle: '<path d="M9 11h6v9H9zM12 11V8M12 8c0-1.6-2-2-2-3.5C10 3.4 11 3 12 3s2 .4 2 1.5C14 6 12 6.4 12 8z"/>',
|
||||||
|
lantern: '<path d="M10 3h4M8 6h8l-1 12H9zM9 6V4.5h6V6M8 18h8M12 9v6"/>',
|
||||||
|
compass: '<circle cx="12" cy="12" r="9"/><path d="M15 9l-2.5 5.5L9 15l2.5-5.5z"/>',
|
||||||
|
kite: '<path d="M12 3 19 10 12 17 5 10zM12 3v14M5 10h14M12 17l-2 4M12 17l2 4"/>',
|
||||||
|
note: '<path d="M9 18V6l9-2v12"/><circle cx="7" cy="18" r="2"/><circle cx="16" cy="16" r="2"/>',
|
||||||
|
boat: '<path d="M4 16h16l-2.5 4H6.5zM12 14V3l6 8zM12 14H6l6-7"/>',
|
||||||
|
fish: '<path d="M3 12c2.5-3.5 6-5 9-5s5.5 1.5 7 5c-1.5 3.5-4 5-7 5s-6.5-1.5-9-5z"/><path d="M19 12l3-2.5v5z"/><circle cx="8" cy="11" r="0.9"/>',
|
||||||
|
bird: '<path d="M3 9c3-2 6 0 9 0s6-2 9 0c-3 3-5 4-9 4S6 12 3 9z"/>',
|
||||||
|
mushroom: '<path d="M4 11a8 8 0 0 1 16 0zM9 11v5a3 3 0 0 0 6 0v-5"/>',
|
||||||
|
bell: '<path d="M6.5 17c1.3-1 1.8-3 1.8-6a3.7 3.7 0 0 1 7.4 0c0 3 .5 5 1.8 6zM10 17.5a2 2 0 0 0 4 0M12 4.2V2.6"/>',
|
||||||
|
snowflake: '<path d="M12 3v18M4.2 7.5l15.6 9M19.8 7.5l-15.6 9M9 5l3 2 3-2M9 19l3-2 3 2"/>',
|
||||||
|
clover: '<circle cx="9" cy="10" r="3"/><circle cx="15" cy="10" r="3"/><circle cx="12" cy="14.5" r="3"/><path d="M12 16.5V21"/>',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ICON_KEYS = Object.keys(ICONS);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Color Match palette — a hand-curated set of calm-but-distinct colors. The board
|
||||||
|
// builder picks a perceptually-spaced subset (see board.js) so two near-identical
|
||||||
|
// shades never land on the same board. Each color is NAMED (drives the aria-label
|
||||||
|
// and the optional colorblind "assist" glyph), and varies in LIGHTNESS as well as
|
||||||
|
// hue so confusable hue pairs (e.g. green/teal) still differ by brightness.
|
||||||
|
//
|
||||||
|
// `assist` is a short letter shown in the corner when the player turns assist on —
|
||||||
|
// kept to 1–2 chars, unique enough to disambiguate the swatch without reading as
|
||||||
|
// a full label. Default play is pure color; assist is opt-in.
|
||||||
|
|
||||||
|
const RAW = [
|
||||||
|
{ key: 'color-rose', name: 'Rose', hex: '#d76a86', assist: 'Ro' },
|
||||||
|
{ key: 'color-coral', name: 'Coral', hex: '#e07a52', assist: 'Co' },
|
||||||
|
{ key: 'color-amber', name: 'Amber', hex: '#e3a32f', assist: 'Am' },
|
||||||
|
{ key: 'color-gold', name: 'Gold', hex: '#cdb63c', assist: 'Go' },
|
||||||
|
{ key: 'color-lime', name: 'Lime', hex: '#8fb24a', assist: 'Li' },
|
||||||
|
{ key: 'color-green', name: 'Green', hex: '#4a9b5c', assist: 'Gr' },
|
||||||
|
{ key: 'color-teal', name: 'Teal', hex: '#2f9c8e', assist: 'Te' },
|
||||||
|
{ key: 'color-cyan', name: 'Cyan', hex: '#3aa6c4', assist: 'Cy' },
|
||||||
|
{ key: 'color-sky', name: 'Sky', hex: '#5b8fd4', assist: 'Sk' },
|
||||||
|
{ key: 'color-blue', name: 'Blue', hex: '#4f63c6', assist: 'Bl' },
|
||||||
|
{ key: 'color-indigo', name: 'Indigo', hex: '#6a59b2', assist: 'In' },
|
||||||
|
{ key: 'color-violet', name: 'Violet', hex: '#9460ba', assist: 'Vi' },
|
||||||
|
{ key: 'color-plum', name: 'Plum', hex: '#b25a95', assist: 'Pl' },
|
||||||
|
{ key: 'color-brown', name: 'Brown', hex: '#9c6b45', assist: 'Br' },
|
||||||
|
{ key: 'color-sand', name: 'Sand', hex: '#cdb38c', assist: 'Sa' },
|
||||||
|
{ key: 'color-slate', name: 'Slate', hex: '#5d6b78', assist: 'Sl' },
|
||||||
|
{ key: 'color-charcoal', name: 'Charcoal', hex: '#3a4250', assist: 'Ch' },
|
||||||
|
{ key: 'color-cream', name: 'Cream', hex: '#e6dfca', assist: 'Cm' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function srgbToLin(c) { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
|
||||||
|
function hexToLab(hex) {
|
||||||
|
const r = srgbToLin(parseInt(hex.slice(1, 3), 16));
|
||||||
|
const g = srgbToLin(parseInt(hex.slice(3, 5), 16));
|
||||||
|
const b = srgbToLin(parseInt(hex.slice(5, 7), 16));
|
||||||
|
const X = r * 0.4124 + g * 0.3576 + b * 0.1805;
|
||||||
|
const Y = r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||||||
|
const Z = r * 0.0193 + g * 0.1192 + b * 0.9505;
|
||||||
|
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
|
||||||
|
const fx = f(X / 0.95047), fy = f(Y / 1), fz = f(Z / 1.08883);
|
||||||
|
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// CIE76 ΔE — adequate for "are these two swatches obviously different".
|
||||||
|
export function deltaE(a, b) { return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); }
|
||||||
|
|
||||||
|
export const COLORS = RAW.map((c) => ({ ...c, lab: hexToLab(c.hex) }));
|
||||||
|
export const COLOR_BY_KEY = Object.fromEntries(COLORS.map((c) => [c.key, c]));
|
||||||
|
export const COLOR_KEYS = COLORS.map((c) => c.key);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Cross-device game-state sync. Local-first: games always paint from
|
||||||
|
// localStorage instantly; these calls reconcile with the server in the
|
||||||
|
// background. Signed-out players stay purely local (no-ops here).
|
||||||
|
import { getJSON, putJSON } from './api.js';
|
||||||
|
import { auth } from './auth.svelte.js';
|
||||||
|
|
||||||
|
// Push this device's state → the server merges it with any other device's
|
||||||
|
// progress → returns the merged state to adopt. null when signed out / on error
|
||||||
|
// (the game just stays on its local copy).
|
||||||
|
export async function pushGameState(game, variant, date, local) {
|
||||||
|
if (!auth.user) return null;
|
||||||
|
try {
|
||||||
|
const r = await putJSON('/api/games/state', { game, variant, date, state: local || {} }, { timeout: 8000 });
|
||||||
|
return r?.state ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile MANY (game, variant) states for one date in a single request — used by
|
||||||
|
// the hub so a /play load doesn't fan out a dozen calls. items: [{game, variant,
|
||||||
|
// state}]. Returns the merged states array to adopt, or null when signed out / on error.
|
||||||
|
export async function pushGameStatesBatch(date, items) {
|
||||||
|
if (!auth.user) return null;
|
||||||
|
try {
|
||||||
|
const r = await putJSON('/api/games/state/batch', { date, items }, { timeout: 8000 });
|
||||||
|
return r?.states ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derived record (streak / distribution / best) computed server-side from the
|
||||||
|
// player's synced states, so it's consistent across devices. null when signed out.
|
||||||
|
export async function fetchGameStats(game, variant) {
|
||||||
|
if (!auth.user) return null;
|
||||||
|
try {
|
||||||
|
const q = `game=${encodeURIComponent(game)}&variant=${encodeURIComponent(variant)}`;
|
||||||
|
return (await getJSON('/api/games/stats?' + q))?.stats ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ export function blank() {
|
|||||||
// [] means "not customized" — the feed falls back to the default lane set.
|
// [] means "not customized" — the feed falls back to the default lane set.
|
||||||
// The backend filter parser ignores unknown keys, so this rides along safely.
|
// The backend filter parser ignores unknown keys, so this rides along safely.
|
||||||
lanes: [],
|
lanes: [],
|
||||||
|
// UI-only: which daily items fill the reader's "calm set" (ritual item keys).
|
||||||
|
// null = not customized → all eligible items. Rides along like `lanes`.
|
||||||
|
ritual: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +80,16 @@ export function saveJSON(key, value) {
|
|||||||
|
|
||||||
// "" or "prefs=<encoded json>" for a query string.
|
// "" or "prefs=<encoded json>" for a query string.
|
||||||
export function param(prefs) {
|
export function param(prefs) {
|
||||||
|
// Serialize ONLY real filter fields — UI-only keys (lanes, ritual) must not
|
||||||
|
// ride in ?prefs= or they churn the feed URL / edge-cache key for no reason.
|
||||||
|
const f = {
|
||||||
|
include_topics: prefs.include_topics, include_flavors: prefs.include_flavors,
|
||||||
|
mute_topics: prefs.mute_topics, mute_flavors: prefs.mute_flavors,
|
||||||
|
avoid_terms: prefs.avoid_terms, pauses: prefs.pauses, max_cortisol: prefs.max_cortisol,
|
||||||
|
};
|
||||||
const empty =
|
const empty =
|
||||||
!prefs.include_topics.length && !prefs.include_flavors.length &&
|
!f.include_topics.length && !f.include_flavors.length &&
|
||||||
!prefs.mute_topics.length && !prefs.mute_flavors.length &&
|
!f.mute_topics.length && !f.mute_flavors.length &&
|
||||||
!prefs.avoid_terms.length && !prefs.pauses.length && prefs.max_cortisol == null;
|
!f.avoid_terms.length && !f.pauses.length && f.max_cortisol == null;
|
||||||
return empty ? '' : 'prefs=' + encodeURIComponent(JSON.stringify(prefs));
|
return empty ? '' : 'prefs=' + encodeURIComponent(JSON.stringify(f));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Daily Ritual — the day's "calm set", a finite daily loop. Derived ENTIRELY
|
||||||
|
// from signals we already store (game state in localStorage + a brief-seen flag)
|
||||||
|
// — no backend, no new sync.
|
||||||
|
//
|
||||||
|
// "Today" is ALWAYS the server's puzzle/brief date passed in by the caller,
|
||||||
|
// never the browser's local date, so the daily reset matches the site's actual
|
||||||
|
// daily content (a reader in another timezone never sees "fresh set tomorrow"
|
||||||
|
// while today's puzzle is still up).
|
||||||
|
//
|
||||||
|
// Spirit (Claude + Codex): gentle and non-instrumental. "Enjoyed," not
|
||||||
|
// "completed"; "N of M · fresh set tomorrow," never "finish the rest." Brief
|
||||||
|
// counts only when the end-cap is reached (the finite read), not on page open.
|
||||||
|
//
|
||||||
|
// The set is CURATABLE: only daily-cadence "one-a-day, finish-and-done" things
|
||||||
|
// are eligible (never Free Play / ambient toys), and the reader chooses which of
|
||||||
|
// those fill THEIR set (settings → Calm set). Default = all eligible items.
|
||||||
|
|
||||||
|
const BRIEF_SEEN_KEY = (date) => `goodnews:briefSeen:${date}`;
|
||||||
|
|
||||||
|
function read(key) {
|
||||||
|
try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the brief as enjoyed for `date` — call only when the reader actually
|
||||||
|
// reaches the end-cap, not merely on open.
|
||||||
|
export function markBriefSeen(date) {
|
||||||
|
if (!date) return;
|
||||||
|
try { localStorage.setItem(BRIEF_SEEN_KEY(date), '1'); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function briefSeen(date) {
|
||||||
|
if (!date) return false;
|
||||||
|
try { return localStorage.getItem(BRIEF_SEEN_KEY(date)) === '1'; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A word is enjoyed once you've seen it through (won or lost) on either length.
|
||||||
|
function wordEnjoyed(date) {
|
||||||
|
for (const v of ['5', '6']) {
|
||||||
|
const s = read(`goodnews:word:${v}:${date}`);
|
||||||
|
if (s && (s.status === 'won' || s.status === 'lost')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A word search is enjoyed once any size is cleared.
|
||||||
|
function wordsearchEnjoyed(date) {
|
||||||
|
for (const sz of ['small', 'med', 'large']) {
|
||||||
|
const s = read(`goodnews:wordsearch:${sz}:${date}`);
|
||||||
|
if (s && s.status === 'done') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloom is enjoyed once the daily reaches the top tier (Flourishing) — the day's
|
||||||
|
// goal, the "saw it through" point (BloomGame persists `top`). Free Play doesn't count.
|
||||||
|
function bloomEnjoyed(date) {
|
||||||
|
const s = read(`goodnews:bloom:${date}`);
|
||||||
|
return !!(s && s.top);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory Match is enjoyed once ANY daily board (any tier/format) is cleared — the
|
||||||
|
// reader's chosen mood-level counts; Free Play never does.
|
||||||
|
const MATCH_VARIANTS = ['gentle', 'standard', 'expert'].flatMap(
|
||||||
|
(t) => ['icons', 'colors'].map((f) => `${t}-${f}`));
|
||||||
|
function matchEnjoyed(date) {
|
||||||
|
for (const v of MATCH_VARIANTS) {
|
||||||
|
const s = read(`goodnews:match:${v}:${date}`);
|
||||||
|
if (s && s.done) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eligible daily-cadence items, in display order. New daily games join here.
|
||||||
|
export const RITUAL_ITEMS = [
|
||||||
|
{ key: 'brief', label: 'Brief', href: '/', done: briefSeen },
|
||||||
|
{ key: 'word', label: 'Daily Word', href: '/play?game=word', done: wordEnjoyed },
|
||||||
|
{ key: 'wordsearch', label: 'Word Search', href: '/play?game=wordsearch', done: wordsearchEnjoyed },
|
||||||
|
{ key: 'bloom', label: 'Bloom', href: '/play?game=bloom&v=daily', done: bloomEnjoyed },
|
||||||
|
{ key: 'match', label: 'Memory Match', href: '/play?game=match&v=daily-icons-standard', done: matchEnjoyed },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_KEYS = RITUAL_ITEMS.map((i) => i.key);
|
||||||
|
// The default (out-of-box) set is a CURATED subset of the eligible items, kept
|
||||||
|
// separate from RITUAL_ITEMS on purpose: adding a new eligible daily game must
|
||||||
|
// NOT auto-bloat every uncustomized reader's set. A new game becomes selectable
|
||||||
|
// in the chooser immediately; whether it joins the default is a deliberate
|
||||||
|
// case-by-case decision (add its key here). `null` pref = "follow this default."
|
||||||
|
export const DEFAULT_KEYS = ['brief', 'word', 'wordsearch', 'bloom'];
|
||||||
|
|
||||||
|
// Normalize the reader's selection: null/undefined → the curated default;
|
||||||
|
// an array → those keys in canonical order; unknown keys ignored.
|
||||||
|
export function ritualKeys(enabled) {
|
||||||
|
if (!Array.isArray(enabled)) return DEFAULT_KEYS.filter((k) => ALL_KEYS.includes(k));
|
||||||
|
return ALL_KEYS.filter((k) => enabled.includes(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The day's ritual snapshot for the reader's chosen set. Reads localStorage on
|
||||||
|
// each call (cheap) — callers recompute on mount / navigation / focus.
|
||||||
|
export function ritualState(date, enabled) {
|
||||||
|
const keys = ritualKeys(enabled);
|
||||||
|
const items = RITUAL_ITEMS
|
||||||
|
.filter((i) => keys.includes(i.key))
|
||||||
|
.map((i) => ({ key: i.key, label: i.label, href: i.href, done: i.done(date) }));
|
||||||
|
return { items, count: items.filter((i) => i.done).length, total: items.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { ritualState, ritualKeys, markBriefSeen, briefSeen, RITUAL_ITEMS, DEFAULT_KEYS } from './ritual.js';
|
||||||
|
|
||||||
|
// Minimal localStorage stand-in (jsdom may not provide one in this config).
|
||||||
|
beforeEach(() => {
|
||||||
|
const store = new Map();
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||||
|
setItem: (k, v) => store.set(k, String(v)),
|
||||||
|
removeItem: (k) => store.delete(k),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const D = '2026-06-14';
|
||||||
|
const done = (s, key) => s.items.find((i) => i.key === key)?.done;
|
||||||
|
|
||||||
|
describe('ritualState', () => {
|
||||||
|
it('defaults to the curated DEFAULT_KEYS set, none done', () => {
|
||||||
|
const s = ritualState(D);
|
||||||
|
expect(s.items.map((i) => i.key)).toEqual(DEFAULT_KEYS);
|
||||||
|
expect(s.count).toBe(0);
|
||||||
|
expect(s.total).toBe(DEFAULT_KEYS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the brief only once the end-cap is marked', () => {
|
||||||
|
expect(briefSeen(D)).toBe(false);
|
||||||
|
markBriefSeen(D);
|
||||||
|
expect(done(ritualState(D), 'brief')).toBe(true);
|
||||||
|
expect(ritualState(D).count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a word at a terminal state, a word search when cleared', () => {
|
||||||
|
localStorage.setItem(`goodnews:word:5:${D}`, JSON.stringify({ status: 'playing', guesses: ['aaaaa'] }));
|
||||||
|
expect(done(ritualState(D), 'word')).toBe(false); // in-progress isn't "enjoyed"
|
||||||
|
localStorage.setItem(`goodnews:word:6:${D}`, JSON.stringify({ status: 'lost' }));
|
||||||
|
expect(done(ritualState(D), 'word')).toBe(true);
|
||||||
|
localStorage.setItem(`goodnews:wordsearch:med:${D}`, JSON.stringify({ status: 'done', foundWords: ['x'] }));
|
||||||
|
expect(done(ritualState(D), 'wordsearch')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts Bloom only when the daily reached the top tier (top flag)', () => {
|
||||||
|
localStorage.setItem(`goodnews:bloom:${D}`, JSON.stringify({ found: ['area'], score: 4 }));
|
||||||
|
expect(done(ritualState(D), 'bloom')).toBe(false); // played but not top tier
|
||||||
|
localStorage.setItem(`goodnews:bloom:${D}`, JSON.stringify({ found: ['area'], score: 99, top: true }));
|
||||||
|
expect(done(ritualState(D), 'bloom')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a curated set — only chosen items appear and count', () => {
|
||||||
|
markBriefSeen(D);
|
||||||
|
localStorage.setItem(`goodnews:bloom:${D}`, JSON.stringify({ top: true }));
|
||||||
|
const s = ritualState(D, ['brief', 'bloom']); // reader's chosen set
|
||||||
|
expect(s.items.map((i) => i.key)).toEqual(['brief', 'bloom']);
|
||||||
|
expect(s.total).toBe(2);
|
||||||
|
expect(s.count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is scoped per server date — yesterday does not leak into today', () => {
|
||||||
|
markBriefSeen('2026-06-13');
|
||||||
|
expect(ritualState(D).count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty/missing date → nothing done (offline-safe)', () => {
|
||||||
|
expect(ritualState('').count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ritualKeys', () => {
|
||||||
|
it('null/undefined → the curated default (NOT all eligible items)', () => {
|
||||||
|
expect(ritualKeys(null)).toEqual(DEFAULT_KEYS);
|
||||||
|
expect(ritualKeys(undefined)).toEqual(DEFAULT_KEYS);
|
||||||
|
// default is a subset of eligible — adding a future eligible game must not auto-join it
|
||||||
|
expect(DEFAULT_KEYS.every((k) => RITUAL_ITEMS.some((i) => i.key === k))).toBe(true);
|
||||||
|
});
|
||||||
|
it('an array → those keys in canonical order, unknown ignored', () => {
|
||||||
|
expect(ritualKeys(['bloom', 'brief', 'nope'])).toEqual(['brief', 'bloom']);
|
||||||
|
expect(ritualKeys([])).toEqual([]); // explicitly empty set
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// The homepage hub as data. Each "room" is one entry — add, reorder, or resize by
|
||||||
|
// editing this list. `size` drives the grid footprint (large | wide | tall | small);
|
||||||
|
// `preview` picks how the card shows itself (news/art = live snippet, static = blurb).
|
||||||
|
// Copy here is placeholder — owner-written final wording lands later.
|
||||||
|
export const ROOMS = [
|
||||||
|
{
|
||||||
|
id: 'news',
|
||||||
|
title: 'Good News',
|
||||||
|
blurb: "The world's quiet good, gathered each morning.",
|
||||||
|
href: '/', // becomes /news once the Brief is split out
|
||||||
|
cta: 'Read the Brief',
|
||||||
|
size: 'large',
|
||||||
|
preview: 'news',
|
||||||
|
icon: '📰',
|
||||||
|
tint: '#fbecdb', // soft amber
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'art',
|
||||||
|
title: 'Daily Art',
|
||||||
|
blurb: 'A masterwork a day, beautifully framed.',
|
||||||
|
href: '/art',
|
||||||
|
cta: 'View today',
|
||||||
|
size: 'wide',
|
||||||
|
preview: 'art',
|
||||||
|
icon: '🖼️',
|
||||||
|
tint: '#f4ecf3', // soft lilac
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'play',
|
||||||
|
title: 'Play',
|
||||||
|
blurb: 'Daily Word, Word Search, Bloom, Memory Match.',
|
||||||
|
href: '/play',
|
||||||
|
cta: 'Enter',
|
||||||
|
size: 'small',
|
||||||
|
preview: 'static',
|
||||||
|
icon: '🎲',
|
||||||
|
tint: '#e9f1f9', // soft sky
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moment',
|
||||||
|
title: 'Daily Moment',
|
||||||
|
blurb: 'A small calm to carry with you.',
|
||||||
|
href: '#',
|
||||||
|
cta: 'Soon',
|
||||||
|
size: 'small',
|
||||||
|
preview: 'static',
|
||||||
|
icon: '🌿',
|
||||||
|
tint: '#eaf4ea', // soft sage
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// The Zen Den aquarium — raw Three.js, lazy-loaded only on /zen (keeps three's
|
||||||
|
// ~150KB off every other page). Phase A: get UB alive on screen (Idle_swim),
|
||||||
|
// soft light, simple backdrop, capped pixel ratio, reduced-motion aware, clean
|
||||||
|
// teardown. The behavior engine (steering / spine-bend turns / darts) lands in
|
||||||
|
// Phase B; the structure here (kept animation actions, a single update loop)
|
||||||
|
// is set up to grow into it.
|
||||||
|
//
|
||||||
|
// UB ships (ub-split.glb) as THREE materials — UB_Body, UB_Fins, UB_Tail — so each
|
||||||
|
// renders differently. The single-mesh approach hit a hard ceiling: a transparent,
|
||||||
|
// double-sided whole fish can't depth-sort its own triangles (tail/eye drew through
|
||||||
|
// the face), and any alpha cutoff that cleaned the fin fringe also chewed the fins.
|
||||||
|
// Thin midline fans (tail especially) also self-overlap as they fold/sweep, so even
|
||||||
|
// single-sided *transparent* tail triangles bleed through each other → "two tails".
|
||||||
|
// The render is fully parameter-driven (see DEFAULTS) so it can be tuned live via
|
||||||
|
// /zen?debug=1 — tuning a blind WebGL fish through redeploys was the slow path.
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||||
|
import { createSwimmer } from './behavior.js';
|
||||||
|
|
||||||
|
const MODEL_URL = '/models/ub-angelfish.glb';
|
||||||
|
|
||||||
|
// UB is the Queen angelfish: ONE mesh, TWO materials (…_body opaque single-sided; …_fins
|
||||||
|
// opaque alpha-tested), and a multi-clip GLB (idle / cruise / burst / turnL / turnR). A
|
||||||
|
// behavior engine (behavior.js) owns locomotion — UB wanders, cruises, drifts, rests,
|
||||||
|
// darts, and banks through U-turns; the loop applies its position/heading and crossfades
|
||||||
|
// to the clip it asks for. Render + behavior are live-tunable at /zen?debug=1.
|
||||||
|
export const DEFAULTS = {
|
||||||
|
scale: 1.0, // multiplier on the auto-fit size
|
||||||
|
cruiseSpeed: 0.5, // roaming speed (tail cadence follows it)
|
||||||
|
boundsX: 1.5, // tank half-extents UB roams within (soft-avoided)
|
||||||
|
boundsY: 0.7,
|
||||||
|
boundsZ: 0.6, // depth → near/far passes
|
||||||
|
liveliness: 1.0, // 0 = placid cruiser · higher = more rests + darts
|
||||||
|
finTranslucent: false, // false = opaque alpha-tested (coherent); true = blended
|
||||||
|
finSide: 'double', // front | back | double (fins are thin → double reads fuller)
|
||||||
|
finOpacity: 0.9, // only when translucent
|
||||||
|
finAlphaTest: 0.5, // clip the fin-edge alpha
|
||||||
|
preview: '', // '' = live behavior; a clip name = freeze + loop it in place (diagnostic)
|
||||||
|
paused: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIDE = { front: THREE.FrontSide, back: THREE.BackSide, double: THREE.DoubleSide };
|
||||||
|
|
||||||
|
export async function createAquarium(canvas, initial = {}) {
|
||||||
|
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||||
|
const params = { ...DEFAULTS, ...initial };
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); // mobile thermal guard
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
|
||||||
|
camera.position.set(0, 0.25, 4.6);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
// Soft, warm light — calm, gently dimensional (no harsh shadows in Phase A).
|
||||||
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x8aa0b0, 1.05));
|
||||||
|
const key = new THREE.DirectionalLight(0xfff2e0, 1.25);
|
||||||
|
key.position.set(2.5, 4, 3);
|
||||||
|
scene.add(key);
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const w = canvas.clientWidth || 1;
|
||||||
|
const h = canvas.clientHeight || 1;
|
||||||
|
renderer.setSize(w, h, false);
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load UB.
|
||||||
|
const gltf = await new GLTFLoader().loadAsync(MODEL_URL);
|
||||||
|
const ub = gltf.scene;
|
||||||
|
|
||||||
|
// Center at origin and scale to a consistent on-screen size, regardless of the
|
||||||
|
// model's native units.
|
||||||
|
const box = new THREE.Box3().setFromObject(ub);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
ub.position.sub(center);
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||||
|
const baseScale = 2.6 / maxDim; // auto-fit; params.scale fine-tunes it live
|
||||||
|
|
||||||
|
// Classify the two meshes by material-name substring (…_body / …_fins) so
|
||||||
|
// applyMaterials can retarget them live regardless of the species prefix.
|
||||||
|
const part = {};
|
||||||
|
ub.traverse((o) => {
|
||||||
|
if (!o.isMesh || !o.material?.name) return;
|
||||||
|
const n = o.material.name.toLowerCase();
|
||||||
|
if (n.includes('body')) part.body = o; else if (n.includes('fin')) part.fins = o;
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyMaterials() {
|
||||||
|
const body = part.body, fins = part.fins;
|
||||||
|
if (body) { // body: always OPAQUE, single-sided
|
||||||
|
const m = body.material;
|
||||||
|
m.transparent = false; m.opacity = 1; m.alphaTest = 0;
|
||||||
|
m.depthWrite = true; m.depthTest = true; m.side = THREE.FrontSide;
|
||||||
|
body.renderOrder = 1; m.needsUpdate = true;
|
||||||
|
}
|
||||||
|
if (fins) { // fins (+ tail): opaque alpha-tested by default
|
||||||
|
const m = fins.material;
|
||||||
|
m.alphaTest = params.finAlphaTest; m.alphaToCoverage = true; m.depthTest = true;
|
||||||
|
m.side = SIDE[params.finSide] ?? THREE.DoubleSide;
|
||||||
|
if (params.finTranslucent) {
|
||||||
|
m.transparent = true; m.opacity = params.finOpacity; m.depthWrite = false;
|
||||||
|
} else {
|
||||||
|
m.transparent = false; m.opacity = 1; m.depthWrite = true; // clean, coherent
|
||||||
|
}
|
||||||
|
fins.renderOrder = 2; m.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyMaterials();
|
||||||
|
ub.scale.setScalar(baseScale * (params.scale ?? 1));
|
||||||
|
|
||||||
|
// motionRoot flies UB around the tank (position + heading + bank); the visual rig (the
|
||||||
|
// model) just plays in-place body clips inside it — so navigation never fights the
|
||||||
|
// skeleton (Codex). The controller is the sole authority on world movement + heading.
|
||||||
|
const motionRoot = new THREE.Group();
|
||||||
|
motionRoot.add(ub);
|
||||||
|
scene.add(motionRoot);
|
||||||
|
|
||||||
|
// Clips by name (idle / cruise / fast / turn{L,R}{in,loop,out} / eat*). The controller
|
||||||
|
// asks for one each frame; crossfade() blends to it. Authored clips play at their own
|
||||||
|
// timing; only cruise's cadence scales with swim speed.
|
||||||
|
const mixer = new THREE.AnimationMixer(ub);
|
||||||
|
const actions = {};
|
||||||
|
for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip);
|
||||||
|
let curClip = actions.cruise ? 'cruise' : Object.keys(actions)[0];
|
||||||
|
actions[curClip]?.play();
|
||||||
|
function crossfade(name) {
|
||||||
|
if (name === curClip || !actions[name]) return;
|
||||||
|
actions[name].reset().play();
|
||||||
|
actions[curClip]?.crossFadeTo(actions[name], 0.35, false);
|
||||||
|
curClip = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The swim controller — owns UB's position + heading + which clip to show.
|
||||||
|
const swimmer = createSwimmer({
|
||||||
|
reduced,
|
||||||
|
cruiseSpeed: params.cruiseSpeed * (reduced ? 0.7 : 1),
|
||||||
|
boundsX: params.boundsX, boundsY: params.boundsY, boundsZ: params.boundsZ,
|
||||||
|
liveliness: params.liveliness,
|
||||||
|
});
|
||||||
|
|
||||||
|
resize();
|
||||||
|
// The canvas lives in a responsive container; a ResizeObserver catches layout
|
||||||
|
// shifts (mobile browser-chrome show/hide, future side panels) a bare 'resize' misses.
|
||||||
|
const onResize = () => resize();
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(onResize) : null;
|
||||||
|
ro?.observe(canvas);
|
||||||
|
|
||||||
|
const clock = new THREE.Clock();
|
||||||
|
renderer.setAnimationLoop(() => {
|
||||||
|
const dt = clock.getDelta();
|
||||||
|
if (params.paused) { renderer.render(scene, camera); return; }
|
||||||
|
if (params.preview && actions[params.preview]) {
|
||||||
|
// diagnostic: freeze locomotion, loop one raw clip broadside at centre
|
||||||
|
motionRoot.position.set(0, 0, 0);
|
||||||
|
motionRoot.rotation.set(0, Math.PI / 2, 0);
|
||||||
|
crossfade(params.preview);
|
||||||
|
if (actions[params.preview]) actions[params.preview].timeScale = 1;
|
||||||
|
} else {
|
||||||
|
const s = swimmer.update(dt);
|
||||||
|
motionRoot.position.copy(s.pos);
|
||||||
|
motionRoot.quaternion.copy(s.quat); // heading + bank, sole authority
|
||||||
|
crossfade(s.clip);
|
||||||
|
if (actions.cruise) actions.cruise.timeScale = THREE.MathUtils.clamp(s.speed / (params.cruiseSpeed || 0.5), 0.6, 1.5);
|
||||||
|
}
|
||||||
|
mixer.update(dt);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// exposed for tuning + the /zen?debug=1 panel
|
||||||
|
ub, motionRoot, actions, mixer, swimmer, scene, camera, params,
|
||||||
|
// live setter: merge new values, re-apply materials + scale + controller params
|
||||||
|
setParams(next = {}) {
|
||||||
|
Object.assign(params, next);
|
||||||
|
applyMaterials();
|
||||||
|
ub.scale.setScalar(baseScale * (params.scale ?? 1));
|
||||||
|
swimmer.setParams({
|
||||||
|
cruiseSpeed: params.cruiseSpeed * (reduced ? 0.7 : 1),
|
||||||
|
boundsX: params.boundsX, boundsY: params.boundsY, boundsZ: params.boundsZ,
|
||||||
|
liveliness: params.liveliness,
|
||||||
|
});
|
||||||
|
return { ...params };
|
||||||
|
},
|
||||||
|
getParams() { return { ...params }; },
|
||||||
|
dispose() {
|
||||||
|
renderer.setAnimationLoop(null);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
ro?.disconnect();
|
||||||
|
scene.traverse((o) => {
|
||||||
|
o.geometry?.dispose?.();
|
||||||
|
const mats = Array.isArray(o.material) ? o.material : o.material ? [o.material] : [];
|
||||||
|
for (const m of mats) {
|
||||||
|
for (const v of Object.values(m)) v?.isTexture && v.dispose();
|
||||||
|
m.dispose?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderer.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// UB's swim controller — real 3D steering (Codex rebuild). Owns world position, velocity
|
||||||
|
// and heading; the visual rig just plays in-place body clips. UB seeks wandering waypoints
|
||||||
|
// through the whole tank (XYZ, incl. near/far depth passes), eases speed up and down, banks
|
||||||
|
// into curves, and softly veers away from walls BEFORE reaching them — no scripted U-turns.
|
||||||
|
// The controller is the SOLE authority on heading (clips carry no root motion), so the
|
||||||
|
// animation never fights the steering. Frame-rate-independent.
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const rand = (a, b) => a + Math.random() * (b - a);
|
||||||
|
const damp = (cur, tgt, rate, dt) => cur + (tgt - cur) * (1 - Math.exp(-rate * dt));
|
||||||
|
const FORWARD = new THREE.Vector3(0, 0, 1); // model nose = local +Z
|
||||||
|
const UP = new THREE.Vector3(0, 1, 0);
|
||||||
|
|
||||||
|
export function createSwimmer(opts = {}) {
|
||||||
|
const reduced = opts.reduced ?? false;
|
||||||
|
const P = {
|
||||||
|
boundsX: 1.5, boundsY: 0.7, boundsZ: 0.6,
|
||||||
|
cruiseSpeed: 0.5, maxSpeed: 1.15, accel: 1.1, maxTurn: 1.7, // maxTurn rad/s
|
||||||
|
liveliness: 1, ...opts,
|
||||||
|
};
|
||||||
|
const pos = new THREE.Vector3(0, 0, 0);
|
||||||
|
const vel = new THREE.Vector3(P.cruiseSpeed, 0, 0);
|
||||||
|
const heading = new THREE.Quaternion();
|
||||||
|
const wp = new THREE.Vector3();
|
||||||
|
let targetSpeed = P.cruiseSpeed, mode = 'cruise', timer = 3, roll = 0, yawRate = 0;
|
||||||
|
const prevF = new THREE.Vector3(0, 0, 1);
|
||||||
|
const S = { pos, quat: new THREE.Quaternion(), speed: P.cruiseSpeed, mode: 'cruise', clip: 'cruise', turnRate: 0 };
|
||||||
|
|
||||||
|
// scratch
|
||||||
|
const _des = new THREE.Vector3(), _steer = new THREE.Vector3(), _to = new THREE.Vector3();
|
||||||
|
const _f = new THREE.Vector3(), _r = new THREE.Vector3(), _u = new THREE.Vector3();
|
||||||
|
const _m = new THREE.Matrix4(), _tq = new THREE.Quaternion(), _curF = new THREE.Vector3(), _roll = new THREE.Quaternion();
|
||||||
|
|
||||||
|
const bnd = () => new THREE.Vector3(P.boundsX, P.boundsY, P.boundsZ);
|
||||||
|
function newWaypoint() {
|
||||||
|
const b = bnd();
|
||||||
|
wp.set(rand(-b.x, b.x) * 0.9, rand(-b.y, b.y) * 0.85, rand(-b.z, b.z) * 0.9);
|
||||||
|
}
|
||||||
|
function pickMode() {
|
||||||
|
const live = P.liveliness * (reduced ? 0.5 : 1);
|
||||||
|
const r = Math.random();
|
||||||
|
if (r < 0.12 * live) { mode = 'rest'; targetSpeed = P.cruiseSpeed * 0.06; timer = rand(3, 6); }
|
||||||
|
else if (r < 0.12 * live + 0.09 * live) { mode = 'dart'; targetSpeed = P.maxSpeed * rand(0.8, 1); timer = rand(1.2, 2.4); newWaypoint(); }
|
||||||
|
else { mode = 'cruise'; targetSpeed = P.cruiseSpeed * rand(0.85, 1.15); timer = rand(4, 8); }
|
||||||
|
}
|
||||||
|
newWaypoint(); pickMode();
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: S,
|
||||||
|
params: P,
|
||||||
|
setParams(n = {}) { Object.assign(P, n); },
|
||||||
|
update(dt) {
|
||||||
|
dt = Math.min(dt, 0.05);
|
||||||
|
timer -= dt; if (timer <= 0) pickMode();
|
||||||
|
|
||||||
|
_to.copy(wp).sub(pos);
|
||||||
|
if (_to.lengthSq() < 0.12) newWaypoint();
|
||||||
|
_des.copy(_to).normalize().multiplyScalar(targetSpeed);
|
||||||
|
|
||||||
|
// soft wall/surface/floor avoidance — veer inward as a boundary nears
|
||||||
|
const b = bnd(), margin = 0.55;
|
||||||
|
for (const ax of ['x', 'y', 'z']) {
|
||||||
|
const lim = ax === 'x' ? b.x : ax === 'y' ? b.y : b.z;
|
||||||
|
if (pos[ax] > lim - margin) _des[ax] -= ((pos[ax] - (lim - margin)) / margin) * targetSpeed * 1.6;
|
||||||
|
if (pos[ax] < -lim + margin) _des[ax] += (((-lim + margin) - pos[ax]) / margin) * targetSpeed * 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// limited-accel steering, capped speed
|
||||||
|
_steer.copy(_des).sub(vel).clampLength(0, P.accel);
|
||||||
|
vel.addScaledVector(_steer, dt).clampLength(0, P.maxSpeed);
|
||||||
|
pos.addScaledVector(vel, dt);
|
||||||
|
pos.set(THREE.MathUtils.clamp(pos.x, -b.x, b.x), THREE.MathUtils.clamp(pos.y, -b.y, b.y), THREE.MathUtils.clamp(pos.z, -b.z, b.z));
|
||||||
|
|
||||||
|
const speed = vel.length();
|
||||||
|
S.speed = speed;
|
||||||
|
|
||||||
|
// heading toward velocity (belly-down basis), rate-limited so it can't snap
|
||||||
|
if (speed > 1e-4) {
|
||||||
|
_f.copy(vel).normalize();
|
||||||
|
_u.copy(UP).addScaledVector(_f, -UP.dot(_f)).normalize();
|
||||||
|
_r.crossVectors(_u, _f).normalize();
|
||||||
|
_m.makeBasis(_r, _u, _f);
|
||||||
|
_tq.setFromRotationMatrix(_m);
|
||||||
|
heading.rotateTowards(_tq, P.maxTurn * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// banking: signed yaw rate → lean into the curve
|
||||||
|
_curF.copy(FORWARD).applyQuaternion(heading);
|
||||||
|
const cross = prevF.x * _curF.z - prevF.z * _curF.x; // horizontal component of prevF × curF
|
||||||
|
yawRate = damp(yawRate, (Math.asin(THREE.MathUtils.clamp(cross, -1, 1)) / dt) || 0, 6, dt);
|
||||||
|
prevF.copy(_curF);
|
||||||
|
roll = damp(roll, THREE.MathUtils.clamp(-yawRate * 0.22, -0.5, 0.5), 3, dt);
|
||||||
|
_roll.setFromAxisAngle(FORWARD, roll);
|
||||||
|
S.quat.copy(heading).multiply(_roll);
|
||||||
|
S.turnRate = yawRate;
|
||||||
|
|
||||||
|
// clip: rest→idle, dart→fast, hard turn→turn-loop (by sign), else cruise
|
||||||
|
S.mode = mode;
|
||||||
|
if (mode === 'rest') S.clip = 'idle';
|
||||||
|
else if (mode === 'dart') S.clip = 'fast';
|
||||||
|
else if (Math.abs(yawRate) > 0.6) S.clip = yawRate > 0 ? 'turnLloop' : 'turnRloop';
|
||||||
|
else S.clip = 'cruise';
|
||||||
|
return S;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,38 +2,24 @@
|
|||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
|
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
|
||||||
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
|
import { fb, closeFeedback } from '$lib/feedback.svelte.js';
|
||||||
|
import { trackVisit, armEngaged } from '$lib/analytics.js';
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
|
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
|
||||||
// recovery card + timeout as soon as the shell hydrates.
|
// recovery card + timeout as soon as the shell hydrates.
|
||||||
onMount(() => window.__ubBooted?.());
|
onMount(() => {
|
||||||
|
window.__ubBooted?.();
|
||||||
|
// Count the daily visit at the LAYOUT level so every landing page counts —
|
||||||
|
// direct /play and /a/ arrivals included, not just the news homepage.
|
||||||
|
trackVisit();
|
||||||
|
// Arm the once-daily "engaged reader" signal (≈8s visible + a real gesture).
|
||||||
|
return armEngaged(); // cleanup on layout teardown
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
{#if fb.open}<FeedbackModal onclose={closeFeedback} />{/if}
|
{#if fb.open}<FeedbackModal onclose={closeFeedback} />{/if}
|
||||||
|
|
||||||
<footer class="site">
|
<!-- Footer is per-surface now (shared $lib/components/Footer.svelte) so each section
|
||||||
<div class="container">
|
can add its own extras; FeedbackModal stays global, triggered from any footer. -->
|
||||||
<button class="fb" onclick={openFeedback}>Send feedback</button>
|
|
||||||
<span class="dot">·</span>
|
|
||||||
Upbeat Bytes · metadata & links only, no stored articles · <a href="/docs">API</a>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
footer.site {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
padding: 26px 0 34px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
footer.site a { color: var(--accent-deep); }
|
|
||||||
footer.site .fb { background: none; border: none; color: var(--accent-deep); font: inherit; font-size: 0.82rem; cursor: pointer; text-decoration: underline; padding: 0; }
|
|
||||||
footer.site .dot { margin: 0 4px; }
|
|
||||||
/* room for the mobile bottom tab bar */
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
footer.site { padding-bottom: calc(34px + 64px + env(safe-area-inset-bottom)); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// Keep legacy root-query links alive after the cutover: the old feed lived at `/`
|
||||||
|
// (e.g. /?view=latest, /?tag=…, /?source=…, /?q=…); the feed is now /news. Redirect
|
||||||
|
// here in load() — before the hub renders — so bookmarks/old shares never flash the
|
||||||
|
// hub. Bare `/` (no query) falls through to the hub. parseView's alias keeps
|
||||||
|
// /news?view=today working too.
|
||||||
|
export function load({ url }) {
|
||||||
|
const p = url.searchParams;
|
||||||
|
if (p.get('q')) throw redirect(307, '/news?q=' + encodeURIComponent(p.get('q')));
|
||||||
|
if (p.get('source')) throw redirect(307, '/news?source=' + encodeURIComponent(p.get('source')));
|
||||||
|
if (p.get('tag')) throw redirect(307, '/news?tag=' + encodeURIComponent(p.get('tag')));
|
||||||
|
const v = p.get('view');
|
||||||
|
if (v === 'today') throw redirect(307, '/news?view=highlights');
|
||||||
|
if (v === 'latest') throw redirect(307, '/news');
|
||||||
|
if (v) throw redirect(307, '/news?view=' + encodeURIComponent(v));
|
||||||
|
}
|
||||||
+540
-699
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,11 @@
|
|||||||
import { getJSON, postJSON } from '$lib/api.js';
|
import { getJSON, postJSON } from '$lib/api.js';
|
||||||
import { auth, savedIds, refresh, toggleFollow } from '$lib/auth.svelte.js';
|
import { auth, savedIds, refresh, toggleFollow } from '$lib/auth.svelte.js';
|
||||||
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
|
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
|
||||||
|
import { RITUAL_ITEMS, ritualKeys, DEFAULT_KEYS } from '$lib/ritual.js';
|
||||||
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
||||||
import { track } from '$lib/analytics.js';
|
import { track } from '$lib/analytics.js';
|
||||||
import { openFeedback } from '$lib/feedback.svelte.js';
|
import { openFeedback } from '$lib/feedback.svelte.js';
|
||||||
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
import AccountPanel from '$lib/components/AccountPanel.svelte';
|
import AccountPanel from '$lib/components/AccountPanel.svelte';
|
||||||
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
||||||
import LanePicker from '$lib/components/LanePicker.svelte';
|
import LanePicker from '$lib/components/LanePicker.svelte';
|
||||||
@@ -25,9 +27,24 @@
|
|||||||
{ key: 'following', label: 'Following' },
|
{ key: 'following', label: 'Following' },
|
||||||
{ key: 'history', label: 'History' },
|
{ key: 'history', label: 'History' },
|
||||||
{ key: 'lanes', label: 'Lanes' },
|
{ key: 'lanes', label: 'Lanes' },
|
||||||
|
{ key: 'calmset', label: 'Calm set' },
|
||||||
{ key: 'boundaries', label: 'Boundaries' },
|
{ key: 'boundaries', label: 'Boundaries' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Calm set: which daily items make up the reader's gentle daily ritual.
|
||||||
|
let calmEnabled = $derived(new Set(ritualKeys(prefs.data.ritual)));
|
||||||
|
function toggleCalmItem(key) {
|
||||||
|
const cur = new Set(ritualKeys(prefs.data.ritual));
|
||||||
|
cur.has(key) ? cur.delete(key) : cur.add(key);
|
||||||
|
const keys = RITUAL_ITEMS.map((i) => i.key).filter((k) => cur.has(k));
|
||||||
|
// null = "follow the curated default" (only when the selection IS the default);
|
||||||
|
// otherwise store the explicit chosen list (frozen — future games won't auto-join).
|
||||||
|
const isDefault = keys.length === DEFAULT_KEYS.length && DEFAULT_KEYS.every((k) => cur.has(k));
|
||||||
|
prefs.data.ritual = isDefault ? null : keys;
|
||||||
|
prefs.data = { ...prefs.data };
|
||||||
|
persistPrefs();
|
||||||
|
}
|
||||||
|
|
||||||
let follows = $state([]);
|
let follows = $state([]);
|
||||||
let followsReady = $state(false);
|
let followsReady = $state(false);
|
||||||
async function loadFollowsList() {
|
async function loadFollowsList() {
|
||||||
@@ -84,7 +101,7 @@
|
|||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<div class="container inner">
|
<div class="container inner">
|
||||||
<a class="brand" href="/" aria-label="Upbeat Bytes — home"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
<a class="brand" href="/" aria-label="upbeatBytes — home"><img class="logo" src="/logo.svg" alt="upbeatBytes" /></a>
|
||||||
<div class="baractions">
|
<div class="baractions">
|
||||||
<button class="fb" onclick={openFeedback} aria-label="Share feedback" title="Share feedback">
|
<button class="fb" onclick={openFeedback} aria-label="Share feedback" title="Share feedback">
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 5h16v11H8l-4 3z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 5h16v11H8l-4 3z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||||||
@@ -112,6 +129,25 @@
|
|||||||
<p class="muted">Loading…</p>
|
<p class="muted">Loading…</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{:else if section === 'calmset'}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Your calm set</h2>
|
||||||
|
<p class="dnote">The gentle daily loop shown on Highlights and Play — a finite "today's set"
|
||||||
|
you can finish and feel caught up. Choose what fills yours, like building a workout. No
|
||||||
|
pressure, no streaks; uncheck anything you'd rather not see there.</p>
|
||||||
|
<ul class="calmpick">
|
||||||
|
{#each RITUAL_ITEMS as it (it.key)}
|
||||||
|
<li>
|
||||||
|
<button class="cpitem" class:on={calmEnabled.has(it.key)} onclick={() => toggleCalmItem(it.key)}
|
||||||
|
aria-pressed={calmEnabled.has(it.key)}>
|
||||||
|
<span class="cpmark" aria-hidden="true"></span>{it.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if calmEnabled.size === 0}<p class="empty">Your calm set is empty — it won't show until you add something.</p>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
{:else if section === 'boundaries'}
|
{:else if section === 'boundaries'}
|
||||||
<BoundariesPanel prefs={prefs.data} onchange={persistPrefs} />
|
<BoundariesPanel prefs={prefs.data} onchange={persistPrefs} />
|
||||||
|
|
||||||
@@ -158,7 +194,7 @@
|
|||||||
<p class="gate">Sign in, then follow sources and topics to build your own calm lane.</p>
|
<p class="gate">Sign in, then follow sources and topics to build your own calm lane.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<h2>Following</h2>
|
<h2>Following</h2>
|
||||||
<p class="dnote">Sources and topics you follow feed your <a href="/?view=following">Following</a> lane. Open any source or grouping and tap <strong>Follow</strong> to add it.</p>
|
<p class="dnote">Sources and topics you follow feed your <a href="/news?view=following">Following</a> lane. Open any source or grouping and tap <strong>Follow</strong> to add it.</p>
|
||||||
{#if follows.length}
|
{#if follows.length}
|
||||||
<ul class="follows">
|
<ul class="follows">
|
||||||
{#each follows as f (f.kind + ':' + f.value)}
|
{#each follows as f (f.kind + ':' + f.value)}
|
||||||
@@ -193,6 +229,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
.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; }
|
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
||||||
@@ -226,6 +264,22 @@
|
|||||||
.digest { margin-top: 16px; }
|
.digest { margin-top: 16px; }
|
||||||
.digest h2 { font-size: 1.1rem; margin: 0 0 6px; }
|
.digest h2 { font-size: 1.1rem; margin: 0 0 6px; }
|
||||||
.dnote { color: var(--muted); font-size: 0.9rem; margin: 0 0 14px; line-height: 1.5; }
|
.dnote { color: var(--muted); font-size: 0.9rem; margin: 0 0 14px; line-height: 1.5; }
|
||||||
|
.calmpick { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; max-width: 360px; }
|
||||||
|
.cpitem {
|
||||||
|
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
|
||||||
|
padding: 12px 16px; font: inherit; font-weight: 600; color: var(--muted); cursor: pointer;
|
||||||
|
transition: border-color 0.14s ease, color 0.14s ease;
|
||||||
|
}
|
||||||
|
.cpitem.on { color: var(--ink); border-color: var(--accent); }
|
||||||
|
.cpitem:hover { border-color: var(--accent); }
|
||||||
|
.cpmark { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--line); flex-shrink: 0;
|
||||||
|
transition: background 0.14s ease, border-color 0.14s ease; }
|
||||||
|
.cpitem.on .cpmark {
|
||||||
|
background: var(--accent); border-color: var(--accent);
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12l5 5 9-10' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-size: 14px; background-repeat: no-repeat; background-position: center;
|
||||||
|
}
|
||||||
.dtoggle {
|
.dtoggle {
|
||||||
font: inherit; font-size: 0.9rem; border-radius: 999px; padding: 9px 18px; cursor: pointer;
|
font: inherit; font-size: 0.9rem; border-radius: 999px; padding: 9px 18px; cursor: pointer;
|
||||||
border: 1px solid var(--accent); background: var(--surface); color: var(--accent-deep);
|
border: 1px solid var(--accent); background: var(--surface); color: var(--accent-deep);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getJSON, postJSON, delJSON } from '$lib/api.js';
|
import { getJSON, postJSON, delJSON } from '$lib/api.js';
|
||||||
import { auth, refresh } from '$lib/auth.svelte.js';
|
import { auth, refresh } from '$lib/auth.svelte.js';
|
||||||
|
import EmojiPicker from '$lib/EmojiPicker.svelte';
|
||||||
|
|
||||||
let stats = $state(null);
|
let stats = $state(null);
|
||||||
let feedback = $state([]);
|
let feedback = $state([]);
|
||||||
@@ -21,6 +22,22 @@
|
|||||||
try { await loadStats(); } catch { error = "Couldn't load stats."; }
|
try { await loadStats(); } catch { error = "Couldn't load stats."; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load errors: read/unread triage. Default view = unread; reading/marking clears them
|
||||||
|
// from the list (filter to see read/all). Re-load stats so the headline count tracks.
|
||||||
|
let ceFilter = $state('unread');
|
||||||
|
async function loadClientErrors() {
|
||||||
|
try { clientErrors = await getJSON('/api/admin/client-errors?show=' + ceFilter); } catch { /* keep */ }
|
||||||
|
}
|
||||||
|
async function setCeFilter(f) { if (f === ceFilter) return; ceFilter = f; await loadClientErrors(); }
|
||||||
|
async function markCeRead(id, read) {
|
||||||
|
try { await postJSON(`/api/admin/client-errors/${id}/read`, { read }); await loadClientErrors(); await loadStats(); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
async function markAllCeRead() {
|
||||||
|
try { await postJSON('/api/admin/client-errors/read-all', {}); await loadClientErrors(); await loadStats(); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!auth.ready) await refresh();
|
if (!auth.ready) await refresh();
|
||||||
if (!auth.user || !auth.user.is_admin) {
|
if (!auth.user || !auth.user.is_admin) {
|
||||||
@@ -31,26 +48,216 @@
|
|||||||
// Load all panels in PARALLEL — these were sequential awaits, so the page
|
// Load all panels in PARALLEL — these were sequential awaits, so the page
|
||||||
// paid the (uncached, origin) round-trip six times back-to-back. One batch
|
// paid the (uncached, origin) round-trip six times back-to-back. One batch
|
||||||
// instead of a chain.
|
// instead of a chain.
|
||||||
const [, fb, cand, wp, ce, ws] = await Promise.all([
|
const [, fb, cand, wp, ce, ws, bq] = await Promise.all([
|
||||||
loadStats(),
|
loadStats(),
|
||||||
getJSON('/api/admin/feedback'),
|
getJSON('/api/admin/feedback'),
|
||||||
getJSON('/api/admin/candidates'),
|
getJSON('/api/admin/candidates'),
|
||||||
getJSON('/api/admin/word/pool'),
|
getJSON('/api/admin/word/pool'),
|
||||||
getJSON('/api/admin/client-errors'),
|
getJSON('/api/admin/client-errors'),
|
||||||
getJSON('/api/admin/wordsearch/themes'),
|
getJSON('/api/admin/wordsearch/themes'),
|
||||||
|
getJSON('/api/admin/bloom/reports').catch(() => null),
|
||||||
]);
|
]);
|
||||||
feedback = fb;
|
feedback = fb;
|
||||||
candidates = cand;
|
candidates = cand;
|
||||||
wpPool = wp;
|
wpPool = wp;
|
||||||
clientErrors = ce;
|
clientErrors = ce;
|
||||||
wsThemes = ws;
|
wsThemes = ws;
|
||||||
|
if (bq) { bloomReports = bq.reports || []; bloomOverrides = bq.overrides || []; }
|
||||||
} catch {
|
} catch {
|
||||||
error = "Couldn't load stats.";
|
error = "Couldn't load stats.";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Publishing Desk: build the X share queue, write blurbs, open in X ---------
|
||||||
|
let pubItems = $state([]); // active: queued/drafting/opened
|
||||||
|
let pubArchived = $state([]); // recoverable tray: skipped/snoozed
|
||||||
|
let pubBuilding = $state(false);
|
||||||
|
let pubLast = $state(null); // last build summary {added, active, ranked_by}
|
||||||
|
let pubError = $state(null); // last build error (e.g. non-model failure), surfaced to the user
|
||||||
|
let pubLoaded = $state(false);
|
||||||
|
let pubHandleInput = $state({}); // keyed "itemId:entity" → handle being saved
|
||||||
|
let pubPollTimer = null;
|
||||||
|
const pubDraftTimers = {};
|
||||||
|
|
||||||
|
async function loadPublish() {
|
||||||
|
try {
|
||||||
|
const r = await getJSON('/api/admin/publishing/queue?archived=true');
|
||||||
|
pubBuilding = r.building; pubLast = r.last; pubError = r.error || null;
|
||||||
|
const items = r.items || [];
|
||||||
|
pubItems = items.filter((i) => ['queued', 'drafting', 'opened'].includes(i.status));
|
||||||
|
pubArchived = items.filter((i) => ['skipped', 'snoozed'].includes(i.status));
|
||||||
|
pubLoaded = true;
|
||||||
|
} catch { /* leave as-is */ }
|
||||||
|
}
|
||||||
|
function pollPublish() {
|
||||||
|
clearTimeout(pubPollTimer);
|
||||||
|
pubPollTimer = setTimeout(async () => {
|
||||||
|
await loadPublish();
|
||||||
|
if (pubBuilding) pollPublish();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
async function buildPublish() {
|
||||||
|
pubBuilding = true;
|
||||||
|
try { await postJSON('/api/admin/publishing/build', {}); pollPublish(); }
|
||||||
|
catch { pubBuilding = false; }
|
||||||
|
}
|
||||||
|
function onDraftInput(item) {
|
||||||
|
clearTimeout(pubDraftTimers[item.id]);
|
||||||
|
pubDraftTimers[item.id] = setTimeout(() => {
|
||||||
|
postJSON(`/api/admin/publishing/${item.id}/draft`, { draft_text: item.draft_text || '' }).catch(() => {});
|
||||||
|
}, 700); // debounced autosave
|
||||||
|
}
|
||||||
|
async function pubSetStatus(item, status, extra = {}) {
|
||||||
|
clearTimeout(pubDraftTimers[item.id]); // cancel a pending autosave so it can't land after the transition
|
||||||
|
try { await postJSON(`/api/admin/publishing/${item.id}/status`, { status, ...extra }); await loadPublish(); }
|
||||||
|
catch (e) { item._err = e?.message || 'Action failed.'; }
|
||||||
|
}
|
||||||
|
// "Posted" is a confirm step: the user may have edited the text inside X, so we let
|
||||||
|
// them correct the saved text + paste the real post URL before recording it.
|
||||||
|
async function pubConfirmPosted(item) {
|
||||||
|
await pubSetStatus(item, 'posted', {
|
||||||
|
final_text: item.draft_text || '',
|
||||||
|
post_url: (item._postUrl || '').trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function pubRestore(item) {
|
||||||
|
try { await postJSON(`/api/admin/publishing/${item.id}/restore`, {}); await loadPublish(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
function markOpened(item) {
|
||||||
|
// fire-and-forget so the X tab opens immediately from the click (no awaited call
|
||||||
|
// before navigation, or the browser may block the popup)
|
||||||
|
if (item.status !== 'opened') {
|
||||||
|
postJSON(`/api/admin/publishing/${item.id}/status`, { status: 'opened' }).catch(() => {});
|
||||||
|
item.status = 'opened';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function pubSaveHandle(item, entity) {
|
||||||
|
const h = (pubHandleInput[`${item.id}:${entity}`] || '').trim();
|
||||||
|
if (!h) return;
|
||||||
|
try { await postJSON('/api/admin/publishing/handles', { entity_name: entity, handle: h }); await loadPublish(); }
|
||||||
|
catch (e) { item._err = e?.message || 'Bad handle.'; }
|
||||||
|
}
|
||||||
|
function insertHandle(item, handle) {
|
||||||
|
item.draft_text = ((item.draft_text || '').trimEnd() + ' ' + handle + ' ').trimStart();
|
||||||
|
onDraftInput(item);
|
||||||
|
}
|
||||||
|
// Emoji picker: a full searchable set (EmojiPicker.svelte) inserted at the caret so it
|
||||||
|
// drops in mid-sentence cleanly. The textarea also still accepts your OS picker.
|
||||||
|
let pubEmojiOpen = $state(null); // id of the card whose palette is open
|
||||||
|
const pubTextareas = {}; // id -> <textarea> element, for caret-aware insert
|
||||||
|
function regTextarea(node, id) {
|
||||||
|
pubTextareas[id] = node;
|
||||||
|
return { destroy() { if (pubTextareas[id] === node) delete pubTextareas[id]; } };
|
||||||
|
}
|
||||||
|
function insertEmoji(item, emoji) {
|
||||||
|
const el = pubTextareas[item.id];
|
||||||
|
const cur = item.draft_text || '';
|
||||||
|
if (el && typeof el.selectionStart === 'number') {
|
||||||
|
const s = el.selectionStart, e = el.selectionEnd;
|
||||||
|
item.draft_text = cur.slice(0, s) + emoji + cur.slice(e);
|
||||||
|
const pos = s + emoji.length;
|
||||||
|
requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); }); // caret after the emoji
|
||||||
|
} else {
|
||||||
|
item.draft_text = cur + emoji;
|
||||||
|
}
|
||||||
|
onDraftInput(item);
|
||||||
|
}
|
||||||
|
function snoozeDate(days) {
|
||||||
|
return new Date(Date.now() + days * 86400000).toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
}
|
||||||
|
function intentURL(item) {
|
||||||
|
return `https://x.com/intent/tweet?text=${encodeURIComponent(item.draft_text || '')}&url=${encodeURIComponent(item.share_url || '')}`;
|
||||||
|
}
|
||||||
|
function findOnX(entity) {
|
||||||
|
return `https://x.com/search?q=${encodeURIComponent(entity)}&f=user`;
|
||||||
|
}
|
||||||
|
// Weighted length ≈ X's counting: any URL = 23. The share link X auto-appends adds
|
||||||
|
// ~24 (a space + the 23-char t.co link), so the live budget reflects the real tweet.
|
||||||
|
function weightedLen(t) {
|
||||||
|
const urls = (t || '').match(/https?:\/\/\S+/g) || [];
|
||||||
|
return (t || '').replace(/https?:\/\/\S+/g, '').length + urls.length * 23;
|
||||||
|
}
|
||||||
|
function pubRemaining(item) {
|
||||||
|
return 280 - (weightedLen(item.draft_text) + 24);
|
||||||
|
}
|
||||||
|
// Named entities (for Find-on-X + save-a-handle); the verified ones already show as
|
||||||
|
// chips above, so this is mostly the not-yet-known ones.
|
||||||
|
function namedEntities(item) {
|
||||||
|
return (item.entities || []).slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { if (section === 'publish' && !pubLoaded) loadPublish(); });
|
||||||
|
|
||||||
let clientErrors = $state([]);
|
let clientErrors = $state([]);
|
||||||
|
|
||||||
|
// "X ago" from a UTC 'YYYY-MM-DD HH:MM:SS' created_at — so a morning glance shows
|
||||||
|
// whether the newest error is fresh or just last night.
|
||||||
|
function ago(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
const d = new Date(ts.replace(' ', 'T') + 'Z');
|
||||||
|
const s = Math.max(0, (Date.now() - d.getTime()) / 1000);
|
||||||
|
if (s < 90) return 'just now';
|
||||||
|
if (s < 5400) return Math.round(s / 60) + 'm ago';
|
||||||
|
if (s < 129600) return Math.round(s / 3600) + 'h ago';
|
||||||
|
return Math.round(s / 86400) + 'd ago';
|
||||||
|
}
|
||||||
|
// Classify a beacon by layer so one scary count doesn't conflate a 30s HTML stall
|
||||||
|
// (incident) with a 5s app boot or a post-deploy chunk miss (benign).
|
||||||
|
function errType(e) {
|
||||||
|
if (e.bot) return { k: 'bot', label: 'bot' };
|
||||||
|
const r = e.reason || '';
|
||||||
|
if (/preload|dynamically imported|failed to fetch/i.test(r)) return { k: 'preload', label: 'preload' };
|
||||||
|
const m = r.match(/^boot-slow.*?\bhtml\s+(\d+)ms/i);
|
||||||
|
if (m) return Number(m[1]) >= 3000 ? { k: 'html', label: 'html-slow' } : { k: 'app', label: 'app-slow' };
|
||||||
|
if (/^boot-slow/i.test(r)) return { k: 'app', label: 'app-slow' };
|
||||||
|
return { k: 'runtime', label: 'runtime' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Games: Bloom word curation (reports queue + allow/block overrides) ---
|
||||||
|
let bloomReports = $state([]);
|
||||||
|
let bloomOverrides = $state([]);
|
||||||
|
let bloomOvrWord = $state('');
|
||||||
|
async function loadBloomQueue() {
|
||||||
|
try { const r = await getJSON('/api/admin/bloom/reports'); bloomReports = r.reports || []; bloomOverrides = r.overrides || []; }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
async function resolveBloom(id, action) {
|
||||||
|
try { await postJSON('/api/admin/bloom/reports/' + id, { action }); await loadBloomQueue(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
async function addBloomOverride(action) {
|
||||||
|
const w = bloomOvrWord.trim().toLowerCase();
|
||||||
|
if (!w) return;
|
||||||
|
try { await postJSON('/api/admin/bloom/overrides', { word: w, action }); bloomOvrWord = ''; await loadBloomQueue(); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
async function removeBloomOverride(w) {
|
||||||
|
try { await delJSON('/api/admin/bloom/overrides/' + encodeURIComponent(w)); await loadBloomQueue(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Small Joys: On This Day / Quote / Word pools ---
|
||||||
|
let joyKind = $state('onthisday');
|
||||||
|
let joyItems = $state([]);
|
||||||
|
let joyLoading = $state(false);
|
||||||
|
let joyAdd = $state({});
|
||||||
|
let joyAddErr = $state('');
|
||||||
|
async function loadJoys(k) {
|
||||||
|
joyLoading = true;
|
||||||
|
try { joyItems = await getJSON('/api/admin/joys/' + k); } catch { joyItems = []; }
|
||||||
|
joyLoading = false;
|
||||||
|
}
|
||||||
|
async function joyMutate(id, action, fields) {
|
||||||
|
try { await postJSON(`/api/admin/joys/${joyKind}/items/${id}`, { action, fields }); await loadJoys(joyKind); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
async function joyAddSubmit() {
|
||||||
|
joyAddErr = '';
|
||||||
|
try { await postJSON(`/api/admin/joys/${joyKind}/add`, joyAdd); joyAdd = {}; await loadJoys(joyKind); }
|
||||||
|
catch (e) { joyAddErr = e?.message || 'Could not add'; }
|
||||||
|
}
|
||||||
|
async function joyRepick() {
|
||||||
|
try { await postJSON(`/api/admin/joys/${joyKind}/repick`, {}); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
$effect(() => { const k = joyKind; if (section === 'joys') loadJoys(k); });
|
||||||
|
|
||||||
// --- Games: Daily Word pool ---
|
// --- Games: Daily Word pool ---
|
||||||
let wpWord = $state('');
|
let wpWord = $state('');
|
||||||
let wpResult = $state(null); // lookup result for the current input
|
let wpResult = $state(null); // lookup result for the current input
|
||||||
@@ -168,6 +375,8 @@
|
|||||||
{ key: 'audience', label: 'Audience' },
|
{ key: 'audience', label: 'Audience' },
|
||||||
{ key: 'feedback', label: 'Feedback' },
|
{ key: 'feedback', label: 'Feedback' },
|
||||||
{ key: 'games', label: 'Games' },
|
{ key: 'games', label: 'Games' },
|
||||||
|
{ key: 'joys', label: 'Small Joys' },
|
||||||
|
{ key: 'publish', label: 'Publishing' },
|
||||||
];
|
];
|
||||||
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
|
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
|
||||||
// Unknown ?section= values fall back to Overview so the page never renders blank.
|
// Unknown ?section= values fall back to Overview so the page never renders blank.
|
||||||
@@ -263,6 +472,55 @@
|
|||||||
}
|
}
|
||||||
function dismissCheck(s) { s._check = null; s._checkErr = ''; }
|
function dismissCheck(s) { s._check = null; s._checkErr = ''; }
|
||||||
|
|
||||||
|
// --- Source article inspector: the real articles behind the metrics ---
|
||||||
|
async function toggleArticles(s) {
|
||||||
|
if (s._showArts) { s._showArts = false; return; }
|
||||||
|
s._showArts = true;
|
||||||
|
if (!s._arts) await loadArticles(s, 'all', true);
|
||||||
|
}
|
||||||
|
async function loadArticles(s, filter, reset) {
|
||||||
|
s._artBusy = true; s._artErr = '';
|
||||||
|
if (reset) { s._artFilter = filter; s._artOffset = 0; s._arts = []; }
|
||||||
|
try {
|
||||||
|
const q = `filter=${s._artFilter}&limit=25&offset=${s._artOffset}`;
|
||||||
|
const r = await getJSON(`/api/admin/sources/${s.id}/articles?${q}`);
|
||||||
|
s._arts = reset ? r.articles : [...(s._arts || []), ...r.articles];
|
||||||
|
if (r.summary) s._artSummary = r.summary;
|
||||||
|
s._artMore = r.has_more;
|
||||||
|
s._artOffset += r.articles.length;
|
||||||
|
} catch (e) { s._artErr = e?.message || 'Could not load articles.'; }
|
||||||
|
finally { s._artBusy = false; }
|
||||||
|
}
|
||||||
|
const ART_FILTERS = [['all', 'All'], ['accepted', 'Accepted'], ['rejected', 'Rejected'], ['held', 'Held'], ['no_image', 'No image'], ['duplicates', 'Duplicates']];
|
||||||
|
function pwBasis(sum) {
|
||||||
|
if (!sum) return '';
|
||||||
|
if (sum.paywall_override === 'free') return 'OFF (override)';
|
||||||
|
if (sum.paywall_override === 'paywalled') return 'ON (override)';
|
||||||
|
return sum.paywalled ? 'ON (domain)' : 'off';
|
||||||
|
}
|
||||||
|
// Per-source paywall override — corrects a domain-rule false positive/negative
|
||||||
|
// and flows into feed/lead/brief ranking + the table's 🔒. Optimistic in place
|
||||||
|
// so the inspector panel stays open.
|
||||||
|
async function setPaywall(s, override) {
|
||||||
|
const ov = override || null;
|
||||||
|
try {
|
||||||
|
await postJSON(`/api/admin/sources/${s.id}/paywall`, { override: ov });
|
||||||
|
const eff = ov === 'free' ? false : ov === 'paywalled' ? true : (s._artSummary?.paywall_domain ?? s.paywalled);
|
||||||
|
s.paywall_override = ov; s.paywalled = eff; // updates the Media-column 🔒
|
||||||
|
if (s._artSummary) { s._artSummary.paywall_override = ov; s._artSummary.paywalled = eff; }
|
||||||
|
} catch (e) { s._artErr = e?.message || 'Could not set the override.'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-source image rights policy: 'cache' (re-host a downscaled copy — only for sources
|
||||||
|
// cleared by license/permission/PD), 'remote' (hotlink the publisher image — default),
|
||||||
|
// 'none' (no image). Conservative by default; caching is opt-in.
|
||||||
|
async function setImagePolicy(s, policy) {
|
||||||
|
const prev = s.image_policy;
|
||||||
|
s.image_policy = policy; // optimistic
|
||||||
|
try { await postJSON(`/api/admin/sources/${s.id}/image-policy`, { policy }); }
|
||||||
|
catch (e) { s.image_policy = prev; s._artErr = e?.message || 'Could not set the image policy.'; }
|
||||||
|
}
|
||||||
|
|
||||||
// --- Source candidates: supervised "add a source" pipeline ---
|
// --- Source candidates: supervised "add a source" pipeline ---
|
||||||
let candidates = $state([]);
|
let candidates = $state([]);
|
||||||
let newFeedUrl = $state('');
|
let newFeedUrl = $state('');
|
||||||
@@ -270,6 +528,7 @@
|
|||||||
let addBusy = $state(false);
|
let addBusy = $state(false);
|
||||||
let addErr = $state('');
|
let addErr = $state('');
|
||||||
let pendingCandidates = $derived(candidates.filter((c) => c.status !== 'promoted' && c.status !== 'rejected'));
|
let pendingCandidates = $derived(candidates.filter((c) => c.status !== 'promoted' && c.status !== 'rejected'));
|
||||||
|
let rejectedCandidates = $derived(candidates.filter((c) => c.status === 'rejected'));
|
||||||
|
|
||||||
async function addCandidate() {
|
async function addCandidate() {
|
||||||
const url = newFeedUrl.trim();
|
const url = newFeedUrl.trim();
|
||||||
@@ -299,7 +558,7 @@
|
|||||||
try {
|
try {
|
||||||
// ~5-7s/item on the LAN model; bound the wait so a model stall can't pin
|
// ~5-7s/item on the LAN model; bound the wait so a model stall can't pin
|
||||||
// the button on "Deep-checking…" forever.
|
// the button on "Deep-checking…" forever.
|
||||||
const res = await postJSON(`/api/admin/candidates/${c.id}/preview?deep=true`, undefined, { timeout: 120000 });
|
const res = await postJSON(`/api/admin/candidates/${c.id}/preview?deep=true`, undefined, { timeout: 180000 });
|
||||||
Object.assign(c, res, { _deep: false });
|
Object.assign(c, res, { _deep: false });
|
||||||
} catch (e) { c._err = e?.message || 'Deep preview failed.'; c._deep = false; }
|
} catch (e) { c._err = e?.message || 'Deep preview failed.'; c._deep = false; }
|
||||||
}
|
}
|
||||||
@@ -326,6 +585,19 @@
|
|||||||
try { Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/reject`)); }
|
try { Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/reject`)); }
|
||||||
catch { /* leave as-is */ }
|
catch { /* leave as-is */ }
|
||||||
}
|
}
|
||||||
|
// Codex's operating rule for the Deep-Preview access verdict, shown on hover.
|
||||||
|
const ACC_HELP = {
|
||||||
|
fine: 'Sample reads fine — judge mainly on content quality.',
|
||||||
|
review: 'Click 2–3 example links before promoting — catches false-positive paywalls and bot-blocks (readable in a browser, blocked to us).',
|
||||||
|
'reject-ready': 'Domain rule AND the sample agree it’s walled — usually reject, unless it’s a source you want and the examples open fine in your browser.',
|
||||||
|
};
|
||||||
|
// Send a rejected candidate back to staging for another look (status → suggested).
|
||||||
|
async function restoreCandidate(c) {
|
||||||
|
try {
|
||||||
|
Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/restore`));
|
||||||
|
candidates = [...candidates]; // nudge the derived queue/tray to recompute
|
||||||
|
} catch { /* leave as-is */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Feedback inbox: filter + read/unread + delete.
|
// Feedback inbox: filter + read/unread + delete.
|
||||||
let fbCat = $state('all'); // 'all' | 'unread' | a category
|
let fbCat = $state('all'); // 'all' | 'unread' | a category
|
||||||
@@ -429,7 +701,7 @@
|
|||||||
|
|
||||||
<header class="bar">
|
<header class="bar">
|
||||||
<div class="container inner">
|
<div class="container inner">
|
||||||
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="upbeatBytes" /></a>
|
||||||
<a class="back" href="/account"><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>Account</a>
|
<a class="back" href="/account"><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>Account</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -472,33 +744,49 @@
|
|||||||
|
|
||||||
<h2>Pulse</h2>
|
<h2>Pulse</h2>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Visitors today</span></div>
|
<div class="stat"><span class="n">{stats.visitors.engaged_today}</span><span class="l">Engaged today</span></div>
|
||||||
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Visitors (7d)</span></div>
|
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Visits today (raw)</span></div>
|
||||||
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Visitors ({range}d)</span></div>
|
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Visits (7d)</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Visits ({range}d)</span></div>
|
||||||
<div class="stat"><span class="n">{stats.content.served}</span><span class="l">Articles live</span></div>
|
<div class="stat"><span class="n">{stats.content.served}</span><span class="l">Articles live</span></div>
|
||||||
<div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh (7d)</span></div>
|
<div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh (7d)</span></div>
|
||||||
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
|
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
|
||||||
<div class="stat"><span class="n">{healthy}/{sources.length}</span><span class="l">Sources healthy</span></div>
|
<div class="stat"><span class="n">{healthy}/{sources.length}</span><span class="l">Sources healthy</span></div>
|
||||||
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Accounts</span></div>
|
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Accounts</span></div>
|
||||||
{#if stats.client_errors}
|
{#if stats.client_errors}
|
||||||
<div class="stat" class:alert={stats.client_errors.today > 0}>
|
<div class="stat" class:alert={stats.client_errors.unread > 0}>
|
||||||
<span class="n">{stats.client_errors.today}</span><span class="l">Load errors today</span>
|
<span class="n">{stats.client_errors.unread}</span><span class="l">Unread load errors</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 class="ce-head">
|
||||||
|
<span>Load errors</span>
|
||||||
|
<span class="ce-tabs">
|
||||||
|
{#each [['unread', 'Unread'], ['read', 'Read'], ['all', 'All']] as [k, lbl]}
|
||||||
|
<button class="ce-tab" class:on={ceFilter === k} onclick={() => setCeFilter(k)}>{lbl}</button>
|
||||||
|
{/each}
|
||||||
|
{#if clientErrors.some((e) => !e.read)}
|
||||||
|
<button class="ce-allread" onclick={markAllCeRead}>Mark all read</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
{#if clientErrors.length}
|
{#if clientErrors.length}
|
||||||
<h2>Recent load errors <span class="count">(last {clientErrors.length})</span></h2>
|
|
||||||
<ul class="cerrs">
|
<ul class="cerrs">
|
||||||
{#each clientErrors as e (e.created_at + e.reason)}
|
{#each clientErrors as e (e.id)}
|
||||||
<li class:bot={e.bot}>
|
{@const t = errType(e)}
|
||||||
|
<li class:bot={e.bot} class:isread={e.read}>
|
||||||
<span class="ce-when">{fdate(e.created_at)}</span>
|
<span class="ce-when">{fdate(e.created_at)}</span>
|
||||||
<span class="ce-reason">{e.reason || '—'}{#if e.bot}<span class="ce-bot">bot</span>{/if}</span>
|
<span class="ce-reason"><span class="ce-tag {t.k}">{t.label}</span>{e.reason || '—'}</span>
|
||||||
<span class="ce-path">{e.path || '/'}</span>
|
<span class="ce-path">{e.path || '/'}</span>
|
||||||
|
<button class="ce-mark" title={e.read ? 'Mark unread' : 'Mark read'}
|
||||||
|
onclick={() => markCeRead(e.id, !e.read)}>{e.read ? '↩ unread' : '✓ read'}</button>
|
||||||
<span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
|
<span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="ce-empty">{ceFilter === 'unread' ? 'All clear — no unread load errors. ✨' : 'Nothing to show.'}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{:else if section === 'content'}
|
{:else if section === 'content'}
|
||||||
@@ -600,9 +888,19 @@
|
|||||||
<div class="curl">{c.feed_url}</div>
|
<div class="curl">{c.feed_url}</div>
|
||||||
{#if c.preview}
|
{#if c.preview}
|
||||||
<div class="cprev">
|
<div class="cprev">
|
||||||
{c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
|
{c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.non_english} · <span class="held" title="Non-English items are held (English-only feed for now), not counted as rejections">{c.preview.non_english} held · non-English</span>{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
|
||||||
{#if c.preview.classified}<span class="vbadge model" title="Scored by the real classifier — the true acceptance view">model-checked</span>{:else}<span class="vbadge fast" title="Fast keyword heuristic — an estimate. Run Deep preview for the model's real verdict.">quick estimate</span>{/if}
|
{#if c.preview.classified}<span class="vbadge model" title="Scored by the real classifier — the true acceptance view">model-checked</span>{:else}<span class="vbadge fast" title="Fast keyword heuristic — an estimate. Run Deep preview for the model's real verdict.">quick estimate</span>{/if}
|
||||||
|
{#if c.preview.paywall_rule}<span class="vbadge wall" title="This domain is on the paywall list (a hint, not a verdict)">paywall domain</span>{/if}
|
||||||
{#if c.preview.examples_accepted?.length}<div class="cex">e.g. {c.preview.examples_accepted.slice(0, 3).join(' · ')}</div>{/if}
|
{#if c.preview.examples_accepted?.length}<div class="cex">e.g. {c.preview.examples_accepted.slice(0, 3).join(' · ')}</div>{/if}
|
||||||
|
{#if c.preview.access}
|
||||||
|
<div class="caccess">
|
||||||
|
<span class="acc-verdict {c.preview.access_verdict}" title={ACC_HELP[c.preview.access_verdict] || ''}>{c.preview.access_verdict}</span>
|
||||||
|
<span class="acc-counts">Access: {c.preview.access.readable} readable · {c.preview.access.paywalled} paywalled{#if c.preview.access.blocked} · <span title="Couldn't fetch — may be a bot-block (readable in a browser), not a reader paywall">{c.preview.access.blocked} blocked</span>{/if}{#if c.preview.access.unknown} · {c.preview.access.unknown} unknown{/if} <span class="acc-of">({c.preview.access.checked} sampled)</span></span>
|
||||||
|
{#if c.preview.access.examples?.length}
|
||||||
|
<div class="acc-ex-row">{#each c.preview.access.examples as ex (ex.url)}<a class="acc-ex {ex.access}" href={ex.url} target="_blank" rel="noopener">{ex.access}↗</a>{/each}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if c._err}<p class="cerr">{c._err}</p>{/if}
|
{#if c._err}<p class="cerr">{c._err}</p>{/if}
|
||||||
@@ -620,6 +918,29 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if rejectedCandidates.length}
|
||||||
|
<section>
|
||||||
|
<details class="rejtray">
|
||||||
|
<summary>Rejected candidates <span class="count">({rejectedCandidates.length})</span></summary>
|
||||||
|
<ul class="candlist rejlist">
|
||||||
|
{#each rejectedCandidates as c (c.id)}
|
||||||
|
<li>
|
||||||
|
<div class="chead">
|
||||||
|
<span class="cname">{c.name || c.feed_url}</span>
|
||||||
|
<span class="cstatus">rejected</span>
|
||||||
|
</div>
|
||||||
|
<div class="curl">{c.feed_url}</div>
|
||||||
|
{#if c._err}<p class="cerr">{c._err}</p>{/if}
|
||||||
|
<div class="cactions">
|
||||||
|
<button class="csend" onclick={() => restoreCandidate(c)}>Send back to staging</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<h2>Sources <a class="exportlink" href="/api/admin/export/sources.csv" download>export CSV ↓</a></h2>
|
<h2>Sources <a class="exportlink" href="/api/admin/export/sources.csv" download>export CSV ↓</a></h2>
|
||||||
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total</p>
|
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total</p>
|
||||||
<div class="srctools">
|
<div class="srctools">
|
||||||
@@ -677,6 +998,7 @@
|
|||||||
<button class="act" onclick={() => (s.review_flag ? clearReview(s) : openFlag(s))}>{s.review_flag ? 'Clear' : 'Flag'}</button>
|
<button class="act" onclick={() => (s.review_flag ? clearReview(s) : openFlag(s))}>{s.review_flag ? 'Clear' : 'Flag'}</button>
|
||||||
<button class="act" onclick={() => toggleVisible(s)}>{s.content_visible ? 'Hide' : 'Show'}</button>
|
<button class="act" onclick={() => toggleVisible(s)}>{s.content_visible ? 'Hide' : 'Show'}</button>
|
||||||
<button class="act" title="Read-only spot-check of the live feed" onclick={() => checkSource(s)} disabled={s._checking}>{s._checking ? 'Checking…' : 'Check'}</button>
|
<button class="act" title="Read-only spot-check of the live feed" onclick={() => checkSource(s)} disabled={s._checking}>{s._checking ? 'Checking…' : 'Check'}</button>
|
||||||
|
<button class="act" title="Inspect this source's real ingested articles" onclick={() => toggleArticles(s)}>{s._showArts ? 'Hide' : 'Articles'}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{#if s._checking || s._check || s._checkErr}
|
{#if s._checking || s._check || s._checkErr}
|
||||||
@@ -698,6 +1020,65 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if s._showArts}
|
||||||
|
<tr class="artrow">
|
||||||
|
<td colspan="10">
|
||||||
|
{#if s._artSummary}
|
||||||
|
<div class="artsum">
|
||||||
|
<strong>{s._artSummary.total}</strong> ingested · {s._artSummary.accepted} accepted ·
|
||||||
|
{s._artSummary.rejected} rejected{#if s._artSummary.non_english} · <span class="held">{s._artSummary.non_english} held · non-English</span>{/if} · {s._artSummary.no_image} no image · {s._artSummary.duplicates} dup
|
||||||
|
</div>
|
||||||
|
<div class="pwctl">
|
||||||
|
<span>Paywall: <span class="pwrule" class:on={s._artSummary.paywalled}>{pwBasis(s._artSummary)}</span></span>
|
||||||
|
<select class="pwsel" onchange={(e) => setPaywall(s, e.currentTarget.value)}>
|
||||||
|
<option value="" selected={!s._artSummary.paywall_override}>Use domain rule ({s._artSummary.paywall_domain ? 'on' : 'off'})</option>
|
||||||
|
<option value="free" selected={s._artSummary.paywall_override === 'free'}>Treat as free</option>
|
||||||
|
<option value="paywalled" selected={s._artSummary.paywall_override === 'paywalled'}>Treat as paywalled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="pwctl">
|
||||||
|
<span>Images: <span class="pwrule" class:on={s.image_policy === 'cache'}>{s.image_policy || 'remote'}</span></span>
|
||||||
|
<select class="pwsel" onchange={(e) => setImagePolicy(s, e.currentTarget.value)}>
|
||||||
|
<option value="remote" selected={(s.image_policy || 'remote') === 'remote'}>Remote — hotlink (default)</option>
|
||||||
|
<option value="cache" selected={s.image_policy === 'cache'}>Cache — re-host downscaled (cleared only)</option>
|
||||||
|
<option value="none" selected={s.image_policy === 'none'}>None — typographic cover</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="artfilters">
|
||||||
|
{#each ART_FILTERS as [key, label] (key)}
|
||||||
|
<button class="chip sm" class:on={(s._artFilter || 'all') === key} onclick={() => loadArticles(s, key, true)}>{label}</button>
|
||||||
|
{/each}
|
||||||
|
<button class="act" onclick={() => (s._showArts = false)}>close</button>
|
||||||
|
</div>
|
||||||
|
{#if s._artErr}<p class="cerr">{s._artErr}</p>{/if}
|
||||||
|
{#if s._arts?.length}
|
||||||
|
<ul class="artlist">
|
||||||
|
{#each s._arts as a (a.id)}
|
||||||
|
<li>
|
||||||
|
<div class="art-row">
|
||||||
|
{#if typeof a.url === 'string' && /^https?:\/\//.test(a.url)}
|
||||||
|
<a class="art-title" href={a.url} target="_blank" rel="noopener">{a.title}</a>
|
||||||
|
{:else}<span class="art-title plain">{a.title}</span>{/if}
|
||||||
|
{#if a.accepted === 1}<span class="badge ok">accepted</span>{:else if a.held}<span class="badge held">held · non-English</span>{:else if a.accepted === 0}<span class="badge no">rejected</span>{/if}
|
||||||
|
{#if a.paywalled}<span class="pw" title="domain paywall rule">🔒</span>{/if}
|
||||||
|
{#if !a.has_image}<span class="art-flag" title="no image extracted">no img</span>{/if}
|
||||||
|
{#if a.duplicate}<span class="art-flag" title="marked duplicate">dup</span>{/if}
|
||||||
|
{#if a.topic}<span class="art-cat">{a.topic}</span>{/if}
|
||||||
|
<span class="art-when">{a.published_at ? fdate(a.published_at) : ''}</span>
|
||||||
|
</div>
|
||||||
|
{#if a.reason}<div class="art-reason" title={a.reason}>{a.reason}</div>{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if s._artMore}<button class="act more" onclick={() => loadArticles(s, s._artFilter, false)} disabled={s._artBusy}>{s._artBusy ? 'Loading…' : 'Load more'}</button>{/if}
|
||||||
|
{:else if !s._artBusy}
|
||||||
|
<p class="muted small">No articles{s._artFilter && s._artFilter !== 'all' ? ' match this filter' : ' yet'}.</p>
|
||||||
|
{/if}
|
||||||
|
{#if s._artBusy && !s._arts?.length}<p class="muted small">Loading articles…</p>{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<tr><td colspan="10" class="srcempty">{srcSearch.trim() ? `No sources match “${srcSearch.trim()}”.` : 'No sources in this view.'}</td></tr>
|
<tr><td colspan="10" class="srcempty">{srcSearch.trim() ? `No sources match “${srcSearch.trim()}”.` : 'No sources in this view.'}</td></tr>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -709,7 +1090,16 @@
|
|||||||
{:else if section === 'audience'}
|
{:else if section === 'audience'}
|
||||||
<p class="exporthdr"><a class="exportlink" href={'/api/admin/export/audience.csv?days=' + range} download>export audience CSV ({range}d) ↓</a></p>
|
<p class="exporthdr"><a class="exportlink" href={'/api/admin/export/audience.csv?days=' + range} download>export audience CSV ({range}d) ↓</a></p>
|
||||||
<section>
|
<section>
|
||||||
<h2>Visitors</h2>
|
<h2>Engaged readers <span class="count">(distinct visitor-days with a real gesture or deliberate action)</span></h2>
|
||||||
|
<p class="muted" style="font-size:.85rem;margin:2px 0 12px;">Engagement tracking began 2026-06-30 — rolling windows are still filling, so a low 7d/{range}d here is partly warm-up, not all bots. Compare d7 confidently after a week, the {range}d window after {range} days.</p>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="stat"><span class="n">{stats.visitors.engaged_today}</span><span class="l">Today</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.visitors.engaged_d7}</span><span class="l">Last 7 days</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.visitors.engaged_d30}</span><span class="l">Last {range} days</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Recorded visits <span class="count">(raw — includes UA-spoofing bots)</span></h2>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Today</span></div>
|
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Today</span></div>
|
||||||
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Last 7 days</span></div>
|
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Last 7 days</span></div>
|
||||||
@@ -743,6 +1133,26 @@
|
|||||||
<div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
|
<div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{#if stats.games}
|
||||||
|
<section>
|
||||||
|
<h2>Games funnel <span class="muted small">· the share loop</span></h2>
|
||||||
|
<div class="cards">
|
||||||
|
<div class="stat"><span class="n">{stats.games.totals.arrival}</span><span class="l">Share arrivals</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.games.totals.started}</span><span class="l">Engaged (first move)</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.games.totals.completed}</span><span class="l">Completed</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.games.totals.shared}</span><span class="l">Shared</span></div>
|
||||||
|
</div>
|
||||||
|
<table class="gfunnel">
|
||||||
|
<thead><tr><th>Game</th><th>Arrivals</th><th>Engaged</th><th>Completed</th><th>Shared</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{#each [['word', 'Daily Word'], ['wordsearch', 'Word Search'], ['bloom', 'Bloom'], ['match', 'Memory Match']] as [k, label] (k)}
|
||||||
|
<tr><td>{label}</td><td>{stats.games.by_game[k].arrival}</td><td>{stats.games.by_game[k].started}</td><td>{stats.games.by_game[k].completed}</td><td>{stats.games.by_game[k].shared}</td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted small">“Share arrivals” = landings via a shared game link (utm_source=game_share). “Engaged” = first move (guess/find/flip), not just opening the page.</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
<section>
|
<section>
|
||||||
<h2>Emotional mix & friction</h2>
|
<h2>Emotional mix & friction</h2>
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
@@ -867,7 +1277,46 @@
|
|||||||
{:else}<p class="muted">No feedback yet.</p>{/if}
|
{:else}<p class="muted">No feedback yet.</p>{/if}
|
||||||
|
|
||||||
{:else if section === 'games'}
|
{:else if section === 'games'}
|
||||||
<h2>Daily Word pool</h2>
|
<h2>Bloom words <span class="count">({bloomReports.length} to review)</span></h2>
|
||||||
|
<p class="muted">Acceptance is broad (every valid dictionary word) — these are player
|
||||||
|
“should this count?” reports. <strong>Approve</strong> allows the word everywhere
|
||||||
|
(takes effect immediately, no deploy); <strong>Block</strong> hides it; <strong>Dismiss</strong>
|
||||||
|
clears the report without a rule.</p>
|
||||||
|
{#if bloomReports.length}
|
||||||
|
<ul class="bloom-reports">
|
||||||
|
{#each bloomReports as r (r.id)}
|
||||||
|
<li>
|
||||||
|
<span class="br-word">{r.word}</span>
|
||||||
|
<span class="br-ctx">{r.format || ''}{r.letters ? ' · ' + r.letters.toUpperCase() : ''}{r.puzzle_date ? ' · ' + r.puzzle_date : ''}</span>
|
||||||
|
<span class="br-acts">
|
||||||
|
<button class="wp-add" onclick={() => resolveBloom(r.id, 'approve')}>Approve</button>
|
||||||
|
<button class="act del" onclick={() => resolveBloom(r.id, 'block')}>Block</button>
|
||||||
|
<button class="link" onclick={() => resolveBloom(r.id, 'dismiss')}>Dismiss</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">No pending word reports.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="wp-lookup">
|
||||||
|
<input type="text" bind:value={bloomOvrWord} maxlength="24" autocapitalize="off"
|
||||||
|
autocomplete="off" spellcheck="false" placeholder="Manually allow/block a word…" />
|
||||||
|
<button class="wp-add" onclick={() => addBloomOverride('allow')}>Allow</button>
|
||||||
|
<button class="act del" onclick={() => addBloomOverride('block')}>Block</button>
|
||||||
|
</div>
|
||||||
|
{#if bloomOverrides.length}
|
||||||
|
<div class="bloom-ovr">
|
||||||
|
{#each bloomOverrides as o (o.word)}
|
||||||
|
<span class="ovr-chip {o.action}">{o.word} · {o.action}
|
||||||
|
<button class="ovr-x" aria-label="Remove override" onclick={() => removeBloomOverride(o.word)}>×</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h2 style="margin-top:32px">Daily Word pool</h2>
|
||||||
<p class="muted">Look up a word to add or remove it from the answer pool. Only real, 5- or 6-letter
|
<p class="muted">Look up a word to add or remove it from the answer pool. Only real, 5- or 6-letter
|
||||||
words in the guess dictionary qualify, so the daily answer is always solvable. Removals take
|
words in the guess dictionary qualify, so the daily answer is always solvable. Removals take
|
||||||
effect for future puzzles and can be restored any time.</p>
|
effect for future puzzles and can be restored any time.</p>
|
||||||
@@ -991,6 +1440,166 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else}<p class="muted small">No custom themes yet — the daily rotation uses the built-in ones.</p>{/if}
|
{:else}<p class="muted small">No custom themes yet — the daily rotation uses the built-in ones.</p>{/if}
|
||||||
|
|
||||||
|
{:else if section === 'publish'}
|
||||||
|
<h2>Publishing Desk <span class="count">({pubItems.length})</span></h2>
|
||||||
|
<p class="sub2">Build a queue of share-worthy stories, write the blurb in your own voice, and open it in X. Posts your <code>/a/</code> share link (your card, drives to UB, still credits the source). Opening X can't confirm a post — you mark Posted.</p>
|
||||||
|
<div class="pubtools">
|
||||||
|
<button class="csend" onclick={buildPublish} disabled={pubBuilding}>{pubBuilding ? 'Building…' : 'Build queue'}</button>
|
||||||
|
{#if pubLast}<span class="muted small">Last build: +{pubLast.added} · {pubLast.active} active · ranked {pubLast.ranked_by}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{#if pubError}<p class="cerr">Build error: {pubError}</p>{/if}
|
||||||
|
|
||||||
|
{#if !pubItems.length && !pubBuilding}
|
||||||
|
<p class="muted small">Queue is empty — hit “Build queue” to gather candidates.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul class="publist">
|
||||||
|
{#each pubItems as item (item.id)}
|
||||||
|
{@const remaining = pubRemaining(item)}
|
||||||
|
<li class="pubcard" class:opened={item.status === 'opened'}>
|
||||||
|
<div class="pub-head">
|
||||||
|
{#if item.image_url}<img class="pub-img" src={item.image_url} alt="" loading="lazy" />{/if}
|
||||||
|
<div class="pub-meta">
|
||||||
|
<a class="pub-title" href={item.share_url} target="_blank" rel="noopener">{item.title}</a>
|
||||||
|
<span class="pub-src">{item.source_name}{#if item.social_score != null} · interest {item.social_score}/10{/if}{#if item.status === 'opened'} · <span class="pub-openedtag">opened</span>{/if}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if item.rationale}<p class="pub-why">{item.rationale}</p>{/if}
|
||||||
|
{#if item.talking_points?.length}
|
||||||
|
<ul class="pub-points">{#each item.talking_points as p}<li>{p}</li>{/each}</ul>
|
||||||
|
{/if}
|
||||||
|
{#if item.angle}<p class="pub-angle"><span class="hlbl">Angle:</span> {item.angle}</p>{/if}
|
||||||
|
|
||||||
|
{#if item.suggested_handles?.length}
|
||||||
|
<div class="pub-handles">
|
||||||
|
<span class="hlbl">Tag:</span>
|
||||||
|
{#each item.suggested_handles as h}<button class="hchip" onclick={() => insertHandle(item, h.handle)} title="Insert into your post">{h.handle}</button>{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="pub-toolbar">
|
||||||
|
<button type="button" class="emoji-toggle" class:on={pubEmojiOpen === item.id}
|
||||||
|
onclick={() => (pubEmojiOpen = pubEmojiOpen === item.id ? null : item.id)}
|
||||||
|
title="Insert emoji">😊 Emoji</button>
|
||||||
|
{#if pubEmojiOpen === item.id}
|
||||||
|
<div class="emoji-pop">
|
||||||
|
<EmojiPicker onpick={(em) => insertEmoji(item, em)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<textarea class="pub-draft" rows="3" placeholder="Write your post — in your voice…"
|
||||||
|
use:regTextarea={item.id}
|
||||||
|
bind:value={item.draft_text} oninput={() => onDraftInput(item)}></textarea>
|
||||||
|
<div class="pub-count" class:over={remaining < 0}>{remaining} left <span class="muted">(+ your /a/ link)</span></div>
|
||||||
|
|
||||||
|
{#if namedEntities(item).length}
|
||||||
|
<details class="pub-find">
|
||||||
|
<summary>Find / save handles</summary>
|
||||||
|
{#each namedEntities(item) as ent (ent)}
|
||||||
|
<div class="pub-ent">
|
||||||
|
<span class="pub-entname">{ent}</span>
|
||||||
|
<a class="act mini" href={findOnX(ent)} target="_blank" rel="noopener">Find on X ↗</a>
|
||||||
|
<input class="hin" placeholder="@handle" bind:value={pubHandleInput[`${item.id}:${ent}`]} />
|
||||||
|
<button class="act mini" onclick={() => pubSaveHandle(item, ent)}>Save</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if item._err}<p class="cerr">{item._err}</p>{/if}
|
||||||
|
{#if item._confirm}
|
||||||
|
<div class="pub-confirm">
|
||||||
|
<p class="muted small">If you tweaked the post inside X, edit the text above to match before confirming — it's saved as your final wording.</p>
|
||||||
|
<input class="hin wide" placeholder="Post URL (optional)" bind:value={item._postUrl} />
|
||||||
|
<div class="pub-actions">
|
||||||
|
<button class="csend" onclick={() => pubConfirmPosted(item)}>Confirm posted</button>
|
||||||
|
<button class="act" onclick={() => (item._confirm = false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="pub-actions">
|
||||||
|
<a class="csend" href={intentURL(item)} target="_blank" rel="noopener" onclick={() => markOpened(item)}>Open in X ↗</a>
|
||||||
|
<button class="act" onclick={() => (item._confirm = true)}>Posted ✓</button>
|
||||||
|
<button class="act" onclick={() => pubSetStatus(item, 'snoozed', { snooze_until: snoozeDate(1) })}>Snooze 1d</button>
|
||||||
|
<button class="act del" onclick={() => pubSetStatus(item, 'skipped')}>Skip</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if pubArchived.length}
|
||||||
|
<details class="rejtray">
|
||||||
|
<summary>Archived · skipped & snoozed <span class="count">({pubArchived.length})</span></summary>
|
||||||
|
<ul class="candlist">
|
||||||
|
{#each pubArchived as item (item.id)}
|
||||||
|
<li>
|
||||||
|
<div class="chead"><span class="cname">{item.title}</span><span class="cstatus">{item.status}{#if item.snooze_until} · until {item.snooze_until.slice(0, 16)}{/if}</span></div>
|
||||||
|
<div class="curl">{item.source_name}</div>
|
||||||
|
<div class="cactions"><button class="csend" onclick={() => pubRestore(item)}>Restore to queue</button></div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else if section === 'joys'}
|
||||||
|
<div class="joy-bar">
|
||||||
|
<div class="joy-kinds">
|
||||||
|
<button class:on={joyKind === 'onthisday'} onclick={() => (joyKind = 'onthisday')}>On This Day</button>
|
||||||
|
<button class:on={joyKind === 'quote'} onclick={() => (joyKind = 'quote')}>Quote</button>
|
||||||
|
<button class:on={joyKind === 'word'} onclick={() => (joyKind = 'word')}>Word</button>
|
||||||
|
</div>
|
||||||
|
<button class="joy-repick" onclick={joyRepick}>Re-pick today</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="joy-add">
|
||||||
|
<summary>Add {joyKind === 'word' ? 'a word' : joyKind === 'quote' ? 'a quote' : 'a fact'}</summary>
|
||||||
|
<div class="joy-add-fields">
|
||||||
|
{#if joyKind === 'word'}
|
||||||
|
<input placeholder="word (looked up in the dictionary)" bind:value={joyAdd.word} />
|
||||||
|
{:else if joyKind === 'quote'}
|
||||||
|
<textarea placeholder="quote text" bind:value={joyAdd.text}></textarea>
|
||||||
|
<input placeholder="author" bind:value={joyAdd.author} />
|
||||||
|
<input placeholder="work / source (optional)" bind:value={joyAdd.work} />
|
||||||
|
{:else}
|
||||||
|
<input placeholder="MM-DD (e.g. 06-22)" bind:value={joyAdd.md} />
|
||||||
|
<input placeholder="year" type="number" bind:value={joyAdd.year} />
|
||||||
|
<textarea placeholder="what happened" bind:value={joyAdd.text}></textarea>
|
||||||
|
<textarea placeholder="summary (optional)" bind:value={joyAdd.summary}></textarea>
|
||||||
|
{/if}
|
||||||
|
<button onclick={joyAddSubmit}>Add</button>
|
||||||
|
{#if joyAddErr}<span class="cerr">{joyAddErr}</span>{/if}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{#if joyLoading}<p class="muted">Loading…</p>{/if}
|
||||||
|
<ul class="joy-list">
|
||||||
|
{#each joyItems as it (it.id)}
|
||||||
|
<li class="joy-row" class:blocked={it.blocked}>
|
||||||
|
<div class="joy-main">
|
||||||
|
{#if joyKind === 'word'}
|
||||||
|
<div><strong>{it.word}</strong> <span class="muted small">{it.part_of_speech ?? ''} {it.phonetic ?? ''}</span></div>
|
||||||
|
<div class="joy-sub">{it.definition}</div>
|
||||||
|
{:else if joyKind === 'quote'}
|
||||||
|
<div class="joy-sub">“{it.text}”</div>
|
||||||
|
<span class="muted small">— {it.author}{#if it.work}, {it.work}{/if}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="muted small">{it.md} · {it.year ?? ''}</span>
|
||||||
|
<div class="joy-sub">{it.text}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="joy-acts">
|
||||||
|
{#if it.featured}<span class="joy-badge">featured</span>{/if}
|
||||||
|
{#if it.shown_at}<span class="muted small">shown {it.shown_at}</span>{/if}
|
||||||
|
<button onclick={() => joyMutate(it.id, it.featured ? 'unfeature' : 'feature')}>{it.featured ? 'Unfeature' : 'Feature'}</button>
|
||||||
|
<button onclick={() => joyMutate(it.id, it.blocked ? 'unblock' : 'block')}>{it.blocked ? 'Unblock' : 'Block'}</button>
|
||||||
|
<button class="joy-del" onclick={() => joyMutate(it.id, 'delete')}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
@@ -1057,6 +1666,12 @@
|
|||||||
.stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
|
.stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
|
||||||
.stat .l { color: var(--muted); font-size: 0.8rem; }
|
.stat .l { color: var(--muted); font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.gfunnel { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.9rem; }
|
||||||
|
.gfunnel th, .gfunnel td { text-align: right; padding: 6px 10px; border-bottom: 1px solid var(--line); }
|
||||||
|
.gfunnel th:first-child, .gfunnel td:first-child { text-align: left; }
|
||||||
|
.gfunnel th { color: var(--muted); font-weight: 600; font-size: 0.78rem; }
|
||||||
|
.gfunnel td { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
||||||
@media (max-width: 620px) { .two { grid-template-columns: 1fr; } }
|
@media (max-width: 620px) { .two { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
@@ -1125,6 +1740,8 @@
|
|||||||
.addrow input { flex: 1 1 200px; box-sizing: border-box; font: inherit; font-size: 0.9rem; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg); color: var(--ink); }
|
.addrow input { flex: 1 1 200px; box-sizing: border-box; font: inherit; font-size: 0.9rem; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg); color: var(--ink); }
|
||||||
.addrow input:focus { outline: none; border-color: var(--accent); }
|
.addrow input:focus { outline: none; border-color: var(--accent); }
|
||||||
ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.rejtray > summary { cursor: pointer; font-family: var(--label); font-weight: 600; color: var(--muted); margin-bottom: 10px; }
|
||||||
|
.rejtray .rejlist { opacity: 0.85; } /* tucked-away, lower-emphasis tray */
|
||||||
ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
|
ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
|
||||||
.chead { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
.chead { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||||
.chead .cname { font-weight: 600; color: var(--ink); }
|
.chead .cname { font-weight: 600; color: var(--ink); }
|
||||||
@@ -1138,11 +1755,26 @@
|
|||||||
.curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; }
|
.curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; }
|
||||||
.cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; }
|
.cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; }
|
||||||
.cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; }
|
.cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; }
|
||||||
|
.held { color: #8a5a18; font-weight: 600; } /* language-held (not a rejection) */
|
||||||
/* Heuristic-vs-model preview badge */
|
/* Heuristic-vs-model preview badge */
|
||||||
.vbadge { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
|
.vbadge { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
|
||||||
font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; cursor: help; }
|
font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; cursor: help; }
|
||||||
.vbadge.model { background: #e3efe4; color: #3f7048; }
|
.vbadge.model { background: #e3efe4; color: #3f7048; }
|
||||||
.vbadge.fast { background: var(--line); color: var(--muted); }
|
.vbadge.fast { background: var(--line); color: var(--muted); }
|
||||||
|
.vbadge.wall { background: #fdf0d8; color: #8a5a18; }
|
||||||
|
/* Deep-preview accessibility sample */
|
||||||
|
.caccess { margin-top: 6px; font-size: 0.8rem; color: var(--ink); display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
|
.acc-verdict { padding: 1px 8px; border-radius: 999px; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.acc-verdict.fine { background: #e3efe4; color: #3f7048; }
|
||||||
|
.acc-verdict.review { background: #fdf0d8; color: #8a5a18; }
|
||||||
|
.acc-verdict.reject-ready { background: #fbe3e3; color: #9a3b3b; }
|
||||||
|
.acc-of { color: var(--muted); }
|
||||||
|
.acc-ex-row { display: flex; gap: 6px; flex-wrap: wrap; flex-basis: 100%; }
|
||||||
|
.acc-ex { font-size: 0.7rem; padding: 1px 7px; border-radius: 7px; text-decoration: none; }
|
||||||
|
.acc-ex.readable { background: #e3efe4; color: #3f7048; }
|
||||||
|
.acc-ex.paywalled { background: #fbe3e3; color: #9a3b3b; }
|
||||||
|
.acc-ex.blocked { background: #eceff2; color: #67727d; }
|
||||||
|
.acc-ex.unknown { background: var(--line); color: var(--muted); }
|
||||||
.cactions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
|
.cactions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
|
||||||
.cactions .ccat { font: inherit; font-size: 0.8rem; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); width: 150px; }
|
.cactions .ccat { font: inherit; font-size: 0.8rem; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); width: 150px; }
|
||||||
.cactions .cchk { font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; }
|
.cactions .cchk { font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; }
|
||||||
@@ -1188,6 +1820,33 @@
|
|||||||
.chkex { margin-top: 5px; color: var(--ink); }
|
.chkex { margin-top: 5px; color: var(--ink); }
|
||||||
.chkex .chklbl { color: var(--muted); }
|
.chkex .chklbl { color: var(--muted); }
|
||||||
.chkex.chkrej { color: var(--muted); }
|
.chkex.chkrej { color: var(--muted); }
|
||||||
|
|
||||||
|
/* Source article inspector */
|
||||||
|
.srctable tr.artrow td { background: var(--bg); font-size: 0.84rem; padding: 10px 12px; }
|
||||||
|
.artsum { color: var(--ink); margin-bottom: 8px; }
|
||||||
|
.pwrule { color: var(--muted); font-weight: 600; }
|
||||||
|
.pwrule.on { color: #9a3b3b; }
|
||||||
|
.pwctl { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; font-size: 0.8rem; color: var(--muted); }
|
||||||
|
.pwsel { font: inherit; font-size: 0.78rem; padding: 3px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); }
|
||||||
|
.artfilters { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 8px; }
|
||||||
|
.chip.sm { font-size: 0.74rem; padding: 3px 10px; }
|
||||||
|
.artlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; max-height: 360px; overflow-y: auto; }
|
||||||
|
.artlist li { border-bottom: 1px solid var(--line); padding-bottom: 6px; }
|
||||||
|
.art-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.art-title { color: var(--accent-deep); font-weight: 600; text-decoration: none; }
|
||||||
|
.art-title:hover { text-decoration: underline; }
|
||||||
|
.art-row .badge { font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 7px; border-radius: 999px; }
|
||||||
|
.badge.ok { background: #e3efe4; color: #3f7048; }
|
||||||
|
.badge.no { background: #f3e0e0; color: #9a3b3b; }
|
||||||
|
.badge.held { background: #fdf0d8; color: #8a5a18; } /* language-held, not rejected */
|
||||||
|
.art-row .pw { font-size: 0.78rem; }
|
||||||
|
.art-flag { font-size: 0.7rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }
|
||||||
|
.art-cat { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
|
||||||
|
.art-when { font-size: 0.72rem; color: var(--muted); margin-left: auto; white-space: nowrap; }
|
||||||
|
.art-title.plain { color: var(--ink); font-weight: 600; }
|
||||||
|
.art-reason { font-size: 0.76rem; color: var(--muted); font-style: italic; margin-top: 2px;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; cursor: help; }
|
||||||
|
.act.more { margin-top: 8px; }
|
||||||
.srctable .rowactions { white-space: nowrap; }
|
.srctable .rowactions { white-space: nowrap; }
|
||||||
.srctable .rowactions .act {
|
.srctable .rowactions .act {
|
||||||
background: none; border: 1px solid var(--line); color: var(--accent-deep);
|
background: none; border: 1px solid var(--line); color: var(--accent-deep);
|
||||||
@@ -1278,11 +1937,32 @@
|
|||||||
|
|
||||||
.stat.alert { background: #f3e0e0; }
|
.stat.alert { background: #f3e0e0; }
|
||||||
.stat.alert .n { color: #9a3b3b; }
|
.stat.alert .n { color: #9a3b3b; }
|
||||||
|
.ce-head { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
|
||||||
|
.ce-tabs { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.ce-tab, .ce-allread { border: 1px solid var(--line); background: var(--surface); color: var(--muted);
|
||||||
|
border-radius: 999px; padding: 2px 12px; font-size: 0.78rem; font-family: var(--label); }
|
||||||
|
.ce-tab.on { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--accent-soft); font-weight: 600; }
|
||||||
|
.ce-allread { margin-left: 6px; color: var(--accent-deep); }
|
||||||
|
.ce-allread:hover { background: var(--accent-soft); }
|
||||||
|
.ce-empty { color: var(--muted); font-size: 0.9rem; margin: 10px 0 0; }
|
||||||
.cerrs { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-direction: column; gap: 6px; }
|
.cerrs { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
.cerrs li { display: grid; grid-template-columns: auto 1fr auto; gap: 6px 12px; align-items: baseline;
|
.cerrs li { display: grid; grid-template-columns: auto 1fr auto auto; gap: 6px 12px; align-items: baseline;
|
||||||
font-size: 0.82rem; padding: 8px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
|
font-size: 0.82rem; padding: 8px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
|
||||||
|
.cerrs li.isread { opacity: 0.5; }
|
||||||
|
.ce-mark { border: 1px solid var(--line); background: var(--surface); color: var(--muted); white-space: nowrap;
|
||||||
|
border-radius: 6px; padding: 1px 9px; font-size: 0.72rem; font-family: var(--label); }
|
||||||
|
.ce-mark:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||||||
.ce-when { color: var(--muted); white-space: nowrap; }
|
.ce-when { color: var(--muted); white-space: nowrap; }
|
||||||
.ce-reason { font-family: var(--label); color: #9a3b3b; }
|
.ce-reason { font-family: var(--label); color: #9a3b3b; }
|
||||||
|
/* Layer tag — html-slow is the only incident-grade one, so it reads loudest. */
|
||||||
|
.ce-tag { display: inline-block; margin-right: 8px; padding: 1px 7px; border-radius: 999px;
|
||||||
|
font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
vertical-align: middle; }
|
||||||
|
.ce-tag.html { background: #fbe3e3; color: #9a3b3b; }
|
||||||
|
.ce-tag.app { background: #fdf0d8; color: #8a5a18; }
|
||||||
|
.ce-tag.preload { background: var(--accent-soft); color: var(--accent-deep); }
|
||||||
|
.ce-tag.runtime { background: #fbe3e3; color: #9a3b3b; }
|
||||||
|
.ce-tag.bot { background: #eceff2; color: #67727d; }
|
||||||
.cerrs li.bot { opacity: 0.6; }
|
.cerrs li.bot { opacity: 0.6; }
|
||||||
.cerrs li.bot .ce-reason { color: var(--muted); }
|
.cerrs li.bot .ce-reason { color: var(--muted); }
|
||||||
.ce-bot { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
|
.ce-bot { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
|
||||||
@@ -1305,6 +1985,20 @@
|
|||||||
font: inherit; font-weight: 600; cursor: pointer; }
|
font: inherit; font-weight: 600; cursor: pointer; }
|
||||||
.wp-add:hover { background: var(--accent-deep); }
|
.wp-add:hover { background: var(--accent-deep); }
|
||||||
.wp-msg { color: var(--accent-deep); font-size: 0.9rem; margin: 6px 0 0; }
|
.wp-msg { color: var(--accent-deep); font-size: 0.9rem; margin: 6px 0 0; }
|
||||||
|
.bloom-reports { list-style: none; padding: 0; margin: 10px 0 18px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.bloom-reports li { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }
|
||||||
|
.br-word { font-weight: 700; text-transform: capitalize; }
|
||||||
|
.br-ctx { color: var(--muted); font-size: 0.82rem; }
|
||||||
|
.br-acts { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.br-acts .wp-add { padding: 5px 13px; font-size: 0.85rem; }
|
||||||
|
.bloom-ovr { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 8px; }
|
||||||
|
.ovr-chip { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 4px 6px 4px 12px;
|
||||||
|
font-size: 0.85rem; color: #fff; }
|
||||||
|
.ovr-chip.allow { background: #2e8b57; }
|
||||||
|
.ovr-chip.block { background: #b8553f; }
|
||||||
|
.ovr-x { background: rgba(255,255,255,0.25); border: none; color: #fff; border-radius: 50%; width: 18px; height: 18px;
|
||||||
|
line-height: 1; cursor: pointer; font-size: 0.9rem; }
|
||||||
.wp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 22px; margin-top: 24px; }
|
.wp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 22px; margin-top: 24px; }
|
||||||
.wp-col h3 { margin: 0 0 2px; font-size: 1.05rem; }
|
.wp-col h3 { margin: 0 0 2px; font-size: 1.05rem; }
|
||||||
.wp-col .count { font-size: 0.85rem; }
|
.wp-col .count { font-size: 0.85rem; }
|
||||||
@@ -1357,4 +2051,67 @@
|
|||||||
border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
|
border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
|
||||||
.wt-name { font-weight: 600; }
|
.wt-name { font-weight: 600; }
|
||||||
.wt-count { color: var(--muted); font-size: 0.84rem; margin-right: auto; }
|
.wt-count { color: var(--muted); font-size: 0.84rem; margin-right: auto; }
|
||||||
|
|
||||||
|
/* Publishing Desk */
|
||||||
|
.pubtools { display: flex; align-items: center; gap: 12px; margin: 6px 0 16px; }
|
||||||
|
.publist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.pubcard { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
|
||||||
|
.pubcard.opened { border-color: var(--accent); }
|
||||||
|
.pub-head { display: flex; gap: 12px; align-items: flex-start; }
|
||||||
|
.pub-img { width: 88px; height: 64px; object-fit: cover; border-radius: 9px; flex-shrink: 0; }
|
||||||
|
.pub-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.pub-title { font-weight: 600; color: var(--ink); text-decoration: none; }
|
||||||
|
.pub-title:hover { text-decoration: underline; }
|
||||||
|
.pub-src { font-size: 0.8rem; color: var(--muted); }
|
||||||
|
.pub-openedtag { color: var(--accent-deep); font-weight: 600; }
|
||||||
|
.pub-why { margin: 10px 0 4px; font-style: italic; color: var(--ink); }
|
||||||
|
.pub-points { margin: 4px 0; padding-left: 18px; color: var(--ink); font-size: 0.9rem; }
|
||||||
|
.pub-points li { margin: 1px 0; }
|
||||||
|
.pub-angle { margin: 4px 0; font-size: 0.88rem; color: var(--muted); }
|
||||||
|
.hlbl { font-family: var(--label); font-weight: 600; font-size: 0.78rem; color: var(--muted); }
|
||||||
|
.pub-handles { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 8px 0; }
|
||||||
|
.hchip { font-size: 0.8rem; padding: 2px 9px; border: 1px solid var(--accent); border-radius: 999px;
|
||||||
|
background: var(--accent-soft); color: var(--accent-deep); cursor: pointer; }
|
||||||
|
.pub-draft { width: 100%; box-sizing: border-box; margin-top: 8px; padding: 9px 11px; font: inherit;
|
||||||
|
border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--ink); resize: vertical; }
|
||||||
|
.pub-toolbar { position: relative; margin-top: 8px; }
|
||||||
|
.emoji-toggle { font: inherit; font-size: 0.8rem; padding: 3px 10px; border: 1px solid var(--line);
|
||||||
|
border-radius: 999px; background: var(--bg); color: var(--ink); cursor: pointer; }
|
||||||
|
.emoji-toggle.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-deep); }
|
||||||
|
.emoji-pop { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; padding: 8px;
|
||||||
|
border: 1px solid var(--line); border-radius: 10px; background: var(--card, var(--bg));
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.18); }
|
||||||
|
.pub-count { font-size: 0.78rem; color: var(--muted); text-align: right; margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||||
|
.pub-count.over { color: #9a3b3b; font-weight: 700; }
|
||||||
|
.pub-find { margin: 8px 0; font-size: 0.85rem; }
|
||||||
|
.pub-find > summary { cursor: pointer; color: var(--accent-deep); }
|
||||||
|
.pub-ent { display: flex; align-items: center; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
|
||||||
|
.pub-entname { min-width: 120px; }
|
||||||
|
.hin { font: inherit; font-size: 0.82rem; padding: 4px 8px; border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
background: var(--bg); color: var(--ink); width: 130px; }
|
||||||
|
.pub-actions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
|
||||||
|
.pub-confirm { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 10px; background: var(--accent-soft); }
|
||||||
|
.hin.wide { width: 100%; box-sizing: border-box; margin: 4px 0; }
|
||||||
|
|
||||||
|
/* Small Joys */
|
||||||
|
.joy-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.joy-kinds { display: flex; gap: 8px; }
|
||||||
|
.joy-kinds button { padding: 6px 14px; border: 1px solid var(--line); border-radius: 999px; background: #fff; cursor: pointer; font-size: 0.9rem; }
|
||||||
|
.joy-kinds button.on { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.joy-repick { padding: 6px 14px; border: 1px solid var(--line); border-radius: 8px; background: #fff; cursor: pointer; font-size: 0.9rem; }
|
||||||
|
.joy-add { margin-bottom: 14px; }
|
||||||
|
.joy-add summary { cursor: pointer; font-weight: 600; color: var(--accent); }
|
||||||
|
.joy-add-fields { display: flex; flex-direction: column; gap: 8px; max-width: 540px; margin-top: 10px; }
|
||||||
|
.joy-add-fields input, .joy-add-fields textarea { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; font: inherit; }
|
||||||
|
.joy-add-fields textarea { min-height: 60px; resize: vertical; }
|
||||||
|
.joy-add-fields button { align-self: flex-start; padding: 7px 16px; border: none; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; }
|
||||||
|
.joy-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.joy-row { display: flex; justify-content: space-between; gap: 14px; align-items: flex-start; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
|
||||||
|
.joy-row.blocked { opacity: 0.5; }
|
||||||
|
.joy-main { min-width: 0; flex: 1; }
|
||||||
|
.joy-sub { margin-top: 3px; line-height: 1.4; }
|
||||||
|
.joy-acts { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; flex-shrink: 0; }
|
||||||
|
.joy-acts button { padding: 4px 10px; border: 1px solid var(--line); border-radius: 7px; background: #fff; cursor: pointer; font-size: 0.82rem; }
|
||||||
|
.joy-acts .joy-del { color: #b23b3b; border-color: #e6c4c4; }
|
||||||
|
.joy-badge { font-size: 0.72rem; font-weight: 700; color: var(--accent); background: var(--accent-soft); border-radius: 999px; padding: 2px 8px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,670 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
|
import HubBar from '$lib/components/HubBar.svelte';
|
||||||
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
|
||||||
|
// Virtual frames the viewer can switch between — remembered locally, no account needed.
|
||||||
|
const FRAMES = [
|
||||||
|
{ id: 'walnut', label: 'Walnut' },
|
||||||
|
{ id: 'oak', label: 'Oak' },
|
||||||
|
{ id: 'mahogany', label: 'Mahogany' },
|
||||||
|
{ id: 'gold', label: 'Gold' },
|
||||||
|
{ id: 'silver', label: 'Silver' },
|
||||||
|
{ id: 'black', label: 'Black' },
|
||||||
|
{ id: 'none', label: 'No frame' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let art = $state(null);
|
||||||
|
let state = $state('loading'); // loading | ready | empty
|
||||||
|
let zoom = $state(false);
|
||||||
|
let frame = $state('walnut');
|
||||||
|
let thickness = $state(1); // frame-scale multiplier, 0.7–1.9 (mat caps at 1.5)
|
||||||
|
let lightboxEl = $state(null);
|
||||||
|
// Wide paintings fill a portrait phone far better turned sideways: when the art is
|
||||||
|
// landscape we rotate the fullscreen view 90° (CSS handles WHEN — only narrow+portrait
|
||||||
|
// screens; desktop and portrait art stay upright). Aspect is read off the loaded image.
|
||||||
|
let landscape = $state(false);
|
||||||
|
|
||||||
|
// Desktop zoom: a gallery inspector inside the lightbox. Variable 1×–4× via a floating
|
||||||
|
// toolbar (− / slider / + / % / Fit); DRAG the artwork to pan (translate persists), so you
|
||||||
|
// can place a spot and keep zooming into it — zoom holds the viewport-centred point fixed.
|
||||||
|
// Mobile keeps native pinch — the Zoom button + toolbar are hidden there.
|
||||||
|
let zoomed = $state(false);
|
||||||
|
let zoomLevel = $state(1.5); // scale factor; enter at a gentle 1.5×
|
||||||
|
let tx = $state(0), ty = $state(0); // pan offset in px (translate)
|
||||||
|
let dragging = $state(false);
|
||||||
|
let zoomBoxEl, zoomImgEl; // refs for clamping pan to the image bounds
|
||||||
|
let lastX = 0, lastY = 0, activePointer = null;
|
||||||
|
|
||||||
|
function enterZoom() { zoomLevel = 1.5; tx = 0; ty = 0; dragging = false; activePointer = null; zoomed = true; }
|
||||||
|
function fit() { dragging = false; activePointer = null; zoomed = false; } // back to the framed gallery view
|
||||||
|
function clampPan() {
|
||||||
|
if (!zoomImgEl || !zoomBoxEl) return;
|
||||||
|
const maxX = Math.max(0, (zoomImgEl.offsetWidth * zoomLevel - zoomBoxEl.clientWidth) / 2);
|
||||||
|
const maxY = Math.max(0, (zoomImgEl.offsetHeight * zoomLevel - zoomBoxEl.clientHeight) / 2);
|
||||||
|
tx = Math.max(-maxX, Math.min(maxX, tx));
|
||||||
|
ty = Math.max(-maxY, Math.min(maxY, ty));
|
||||||
|
}
|
||||||
|
function setZoom(v) {
|
||||||
|
const z = Math.max(1, Math.min(4, Math.round(v * 10) / 10));
|
||||||
|
if (z === zoomLevel) return;
|
||||||
|
const ratio = z / zoomLevel; // keep the centred spot fixed as we zoom
|
||||||
|
tx *= ratio; ty *= ratio; zoomLevel = z;
|
||||||
|
clampPan();
|
||||||
|
}
|
||||||
|
function dragStart(e) {
|
||||||
|
if (dragging || e.button !== 0) return; // primary button only; ignore a 2nd pointer mid-drag
|
||||||
|
dragging = true; lastX = e.clientX; lastY = e.clientY; activePointer = e.pointerId;
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
}
|
||||||
|
function dragMove(e) {
|
||||||
|
if (!dragging || e.pointerId !== activePointer) return; // only the pointer that started the drag
|
||||||
|
tx += e.clientX - lastX; ty += e.clientY - lastY;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
clampPan();
|
||||||
|
}
|
||||||
|
// Ends drag on pointerup / pointercancel / lostpointercapture (NOT pointerleave, which
|
||||||
|
// fights the capture). Release only what we actually hold, so no double-release throws.
|
||||||
|
function dragEnd(e) {
|
||||||
|
dragging = false;
|
||||||
|
const el = e.currentTarget;
|
||||||
|
if (activePointer !== null && el?.hasPointerCapture?.(activePointer)) el.releasePointerCapture(activePointer);
|
||||||
|
activePointer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (zoomed) { zoomed = false; dragging = false; activePointer = null; } // Escape steps out of inspection first, then closes
|
||||||
|
else if (zoom) zoom = false;
|
||||||
|
}
|
||||||
|
// Leaving the lightbox always resets the inspector, so re-opening starts framed.
|
||||||
|
$effect(() => { if (!zoom) { zoomed = false; dragging = false; activePointer = null; zoomLevel = 1.5; tx = 0; ty = 0; } });
|
||||||
|
// Move focus to the lightbox when it opens, so Escape/Enter work and focus is trapped sanely.
|
||||||
|
$effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); });
|
||||||
|
|
||||||
|
// Same single-history Back as the hub pages: return to where you came from (the hub),
|
||||||
|
// or fall back to it on a cold deep-link without pushing a new entry.
|
||||||
|
let cameFromApp = $state(false);
|
||||||
|
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
|
||||||
|
function goBack() {
|
||||||
|
if (cameFromApp && typeof history !== 'undefined') history.back();
|
||||||
|
else goto('/', { replaceState: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Woods are built from four real mitered rails (grain turns at the corners); metals/none aren't.
|
||||||
|
let isWood = $derived(['walnut', 'oak', 'mahogany'].includes(frame));
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('ub_art_frame');
|
||||||
|
if (saved && FRAMES.some((f) => f.id === saved)) frame = saved;
|
||||||
|
const t = parseFloat(localStorage.getItem('ub_art_thickness'));
|
||||||
|
if (t >= 0.7 && t <= 1.9) thickness = t;
|
||||||
|
} catch { /* private mode — defaults are fine */ }
|
||||||
|
try {
|
||||||
|
art = await getJSON('/api/art/today');
|
||||||
|
state = art?.image_url ? 'ready' : 'empty';
|
||||||
|
} catch {
|
||||||
|
state = 'empty';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setFrame(id) {
|
||||||
|
frame = id;
|
||||||
|
try { localStorage.setItem('ub_art_frame', id); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveThickness() {
|
||||||
|
try { localStorage.setItem('ub_art_thickness', String(thickness)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
let dateLabel = $derived.by(() => {
|
||||||
|
if (!art?.date) return '';
|
||||||
|
const [, m, d] = art.date.split('-').map(Number);
|
||||||
|
return MONTHS[m - 1] ? `${MONTHS[m - 1]} ${d}` : '';
|
||||||
|
});
|
||||||
|
// "Winslow Homer, 1865 — oil on canvas"
|
||||||
|
let attribution = $derived.by(() => {
|
||||||
|
if (!art) return '';
|
||||||
|
const who = [art.artist, art.date_text].filter(Boolean).join(', ');
|
||||||
|
return art.medium ? (who ? `${who} — ${art.medium}` : art.medium) : who;
|
||||||
|
});
|
||||||
|
let dlName = $derived(art ? `${(art.title || 'artwork').replace(/[^\w \-]+/g, '').trim()}.jpg` : 'artwork.jpg');
|
||||||
|
|
||||||
|
let detailsOpen = $state(true); // mobile: collapse the writeup to bring the controls up near the art
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
async function share() {
|
||||||
|
const url = art?.source_url || location.href;
|
||||||
|
try {
|
||||||
|
if (navigator.share) { await navigator.share({ title: art?.title || 'Daily Art', url }); return; }
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
copied = true; setTimeout(() => (copied = false), 1800);
|
||||||
|
} catch { /* cancelled / unsupported — no-op */ }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Daily Art · upbeatBytes</title>
|
||||||
|
<meta name="description" content="A masterwork a day from the world's open museum collections — one piece, beautifully framed, on upbeatBytes." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#snippet woodRails()}
|
||||||
|
<span class="rail rail--t"></span>
|
||||||
|
<span class="rail rail--r"></span>
|
||||||
|
<span class="rail rail--b"></span>
|
||||||
|
<span class="rail rail--l"></span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="room">
|
||||||
|
<HubBar active="art" />
|
||||||
|
|
||||||
|
<main class="gallery">
|
||||||
|
<button class="back" onclick={goBack} aria-label="Go back">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
{#if state === 'ready'}
|
||||||
|
<!-- "The Story": the guide write-up on the left, the framed piece + controls on the right -->
|
||||||
|
<article class="story-card">
|
||||||
|
<div class="left">
|
||||||
|
<div class="head">
|
||||||
|
<div class="kicker"><span class="kicker-rule"></span>Daily Art{#if dateLabel} · {dateLabel}{/if}</div>
|
||||||
|
<h1 class="art-title">{art.title}</h1>
|
||||||
|
{#if attribution}<p class="attribution">{attribution}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<button class="details-toggle" onclick={() => (detailsOpen = !detailsOpen)}
|
||||||
|
aria-expanded={detailsOpen} aria-controls="art-details">
|
||||||
|
About this piece
|
||||||
|
<svg class="chev" class:open={detailsOpen} viewBox="0 0 24 24" width="14" height="14"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="body-content" id="art-details" class:collapsed={!detailsOpen}>
|
||||||
|
{#if art.blurb}<p class="blurb">{art.blurb}</p>{/if}
|
||||||
|
<div class="meta-cols">
|
||||||
|
<div class="meta"><span class="meta-label">Collection</span><span class="meta-val">{art.museum}</span></div>
|
||||||
|
{#if art.license}<div class="meta"><span class="meta-label">Rights</span><span class="meta-val">{art.license}</span></div>{/if}
|
||||||
|
</div>
|
||||||
|
{#if art.source_url}
|
||||||
|
<a class="cta" href={art.source_url} target="_blank" rel="noopener">View at {art.museum} →</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right">
|
||||||
|
<div class="art-stage">
|
||||||
|
<button class="frame frame--{frame} art-frame" style="--frame-scale:{thickness}"
|
||||||
|
onclick={() => (zoom = true)} aria-label="Expand artwork">
|
||||||
|
{#if isWood}{@render woodRails()}{/if}
|
||||||
|
<span class="mat">
|
||||||
|
<img src={art.image_url} alt={art.title}
|
||||||
|
onload={(e) => (landscape = e.currentTarget.naturalWidth > e.currentTarget.naturalHeight)} />
|
||||||
|
<span class="hint">
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" />
|
||||||
|
</svg>
|
||||||
|
Expand
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="ctl-row">
|
||||||
|
<span class="ctl-label">Frame</span>
|
||||||
|
<div class="swatches">
|
||||||
|
{#each FRAMES as f}
|
||||||
|
<button class="swatch swatch--{f.id}" class:on={frame === f.id}
|
||||||
|
onclick={() => setFrame(f.id)} aria-pressed={frame === f.id} title={f.label}>
|
||||||
|
<span class="sr">{f.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if frame !== 'none'}
|
||||||
|
<div class="ctl-row">
|
||||||
|
<span class="ctl-label">Thickness</span>
|
||||||
|
<input type="range" min="0.7" max="1.9" step="0.05"
|
||||||
|
bind:value={thickness} oninput={saveThickness} aria-label="Frame thickness" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if art.palette?.length}
|
||||||
|
<div class="ctl-divider"></div>
|
||||||
|
<div class="ctl-label">Colors in this piece</div>
|
||||||
|
<div class="palette" aria-hidden="true">
|
||||||
|
{#each art.palette as c}<span class="chip" style="background:{c}"></span>{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="act act-share" onclick={share}>{copied ? 'Link copied' : '↗ Share'}</button>
|
||||||
|
{#if art.is_public_domain}
|
||||||
|
<a class="act act-dl" href={art.image_url_large || art.image_url} download={dlName}>↓ Download</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{:else if state === 'empty'}
|
||||||
|
<p class="note">The gallery's resting — a new piece is hung each morning. Check back soon.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="note">Hanging today's piece…</p>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if zoom && art}
|
||||||
|
<div class="lightbox" class:rotate={landscape && !zoomed} class:zoomed bind:this={lightboxEl}
|
||||||
|
tabindex="-1" role="dialog" aria-modal="true" aria-label="{art.title}, full screen">
|
||||||
|
<button class="lb-backdrop" onclick={() => (zoom = false)} aria-label="Close artwork"></button>
|
||||||
|
<button class="lb-close" onclick={() => (zoom = false)} aria-label="Close">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||||||
|
</button>
|
||||||
|
{#if zoomed}
|
||||||
|
<!-- gallery inspector: DRAG to pan; zoom with the toolbar; Fit/Esc to exit -->
|
||||||
|
<div class="lb-zoom" class:dragging bind:this={zoomBoxEl}
|
||||||
|
onpointerdown={dragStart} onpointermove={dragMove} onpointerup={dragEnd}
|
||||||
|
onpointercancel={dragEnd} onlostpointercapture={dragEnd}
|
||||||
|
role="img" aria-label="{art.title}, magnified — drag to pan">
|
||||||
|
<img bind:this={zoomImgEl} src={art.image_url_large || art.image_url} alt={art.title}
|
||||||
|
style="transform:translate({tx}px,{ty}px) scale({zoomLevel})" draggable="false" />
|
||||||
|
</div>
|
||||||
|
<div class="lb-tools" role="group" aria-label="Zoom controls">
|
||||||
|
<button class="lb-t" onclick={() => setZoom(zoomLevel - 0.1)} aria-label="Zoom out">−</button>
|
||||||
|
<input class="lb-slider" type="range" min="1" max="4" step="0.1" value={zoomLevel}
|
||||||
|
oninput={(e) => setZoom(+e.currentTarget.value)}
|
||||||
|
aria-label="Zoom level" aria-valuetext="{Math.round(zoomLevel * 100)} percent" />
|
||||||
|
<button class="lb-t" onclick={() => setZoom(zoomLevel + 0.1)} aria-label="Zoom in">+</button>
|
||||||
|
<span class="lb-pct">{Math.round(zoomLevel * 100)}%</span>
|
||||||
|
<button class="lb-fit" onclick={fit}>Fit</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="lb-stage">
|
||||||
|
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}">
|
||||||
|
{#if isWood}{@render woodRails()}{/if}
|
||||||
|
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span>
|
||||||
|
</span>
|
||||||
|
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
|
||||||
|
<button class="lb-zoombtn" onclick={enterZoom}>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="M21 21l-3.5-3.5M11 8v6M8 11h6" /></svg>
|
||||||
|
Zoom in
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-italic-var.woff2') format('woff2'); font-weight: 400 500; font-style: italic; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Space Mono'; src: url('/fonts/space-mono-latin.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
|
||||||
|
|
||||||
|
/* --- "The Story": editorial Daily Art page. Daily-Art identity = the purple accent. --- */
|
||||||
|
.room {
|
||||||
|
--canvas: #faf6ee; /* warm room ground */
|
||||||
|
--card: #ece1cc; /* warm-tan story card */
|
||||||
|
--art-band: #ddcfb2; /* deeper tan behind the framed piece */
|
||||||
|
--ink: #232a31; /* dark slate — titles */
|
||||||
|
--story: #4f4a3f; /* warm body text */
|
||||||
|
--muted: #8a8273;
|
||||||
|
--label: #4a4334; /* Space Mono micro-labels — darker so they stand out */
|
||||||
|
--line: #ece5d8;
|
||||||
|
--accent: #8857C2; /* Daily-Art purple (matches the home tile) */
|
||||||
|
--accent-deep: #6f42a8;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--canvas);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-x: clip; /* seatbelt: nothing can scroll the page sideways */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* /art runs wider than the hub's 1180 column — the artwork is the point, and the
|
||||||
|
mobile view is separate, so the desktop card gets room for a properly-sized piece. */
|
||||||
|
.gallery {
|
||||||
|
flex: 1; width: 100%; max-width: 1280px; margin: 0 auto;
|
||||||
|
padding: clamp(10px, 2vw, 22px) clamp(18px, 5vw, 44px) clamp(28px, 5vw, 48px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex; flex-direction: column; justify-content: center;
|
||||||
|
}
|
||||||
|
/* top-left Back */
|
||||||
|
.back {
|
||||||
|
align-self: flex-start; display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
margin: 0 0 clamp(10px, 2vw, 16px); padding: 6px 10px 6px 0;
|
||||||
|
background: none; border: none; cursor: pointer; font: inherit; font-size: 14px;
|
||||||
|
font-weight: 600; color: var(--muted); transition: color 0.15s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.back:hover { color: var(--accent); }
|
||||||
|
.back svg { transition: transform 0.15s ease; }
|
||||||
|
.back:hover svg { transform: translateX(-2px); }
|
||||||
|
|
||||||
|
/* The Story card: writeup (left) + framed art & controls (right). On phones the wrappers
|
||||||
|
collapse (display:contents) so the pieces reflow to head → art → writeup → controls. */
|
||||||
|
.story-card {
|
||||||
|
display: flex; border-radius: 22px; overflow: hidden; background: var(--card);
|
||||||
|
box-shadow: 0 16px 40px -22px rgba(120, 95, 50, 0.32);
|
||||||
|
}
|
||||||
|
.left { flex: 1; min-width: 0; padding: clamp(28px, 4vw, 50px) clamp(24px, 3.4vw, 46px); display: flex; flex-direction: column; justify-content: center; }
|
||||||
|
.head, .body, .body-content { display: flex; flex-direction: column; } /* so .cta align-self works */
|
||||||
|
.details-toggle { display: none; } /* desktop: the writeup is always shown in the left column */
|
||||||
|
.chev { transition: transform 0.2s ease; }
|
||||||
|
.kicker { display: inline-flex; align-items: center; gap: 9px; font-family: 'Space Mono', monospace; font-size: 12px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent); }
|
||||||
|
.kicker-rule { width: 18px; height: 2px; background: var(--accent); border-radius: 2px; }
|
||||||
|
.art-title { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: clamp(2rem, 4.2vw, 2.9rem); line-height: 1.05; letter-spacing: -0.01em; color: var(--ink); margin: 14px 0 0; }
|
||||||
|
.attribution { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: clamp(1rem, 1.6vw, 1.13rem); color: #7a7263; margin: 12px 0 0; }
|
||||||
|
.blurb { font-size: clamp(0.97rem, 1.25vw, 1.02rem); line-height: 1.72; color: var(--story); margin: 20px 0 0; max-width: 44ch; text-wrap: pretty; }
|
||||||
|
.meta-cols { display: flex; gap: 36px; margin-top: 26px; padding-top: 22px; border-top: 1px solid rgba(120, 95, 50, 0.20); }
|
||||||
|
.meta { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.meta-label { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--label); }
|
||||||
|
.meta-val { font-size: 14px; font-weight: 600; color: #3f3a30; }
|
||||||
|
.cta { align-self: flex-start; margin-top: 26px; background: var(--accent); color: #fff; font-size: 14px; font-weight: 700; padding: 13px 22px; border-radius: 9px; text-decoration: none; transition: background 0.15s ease; -webkit-tap-highlight-color: transparent; }
|
||||||
|
.cta:hover { background: var(--accent-deep); }
|
||||||
|
|
||||||
|
/* right column: the framed piece on a deeper-tan ground, controls beneath */
|
||||||
|
.right { flex: 0 0 50%; background: var(--art-band); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: clamp(28px, 3vw, 42px) clamp(20px, 2.5vw, 34px); }
|
||||||
|
.art-stage { display: flex; justify-content: center; width: 100%; }
|
||||||
|
.art-frame { max-width: 100%; }
|
||||||
|
|
||||||
|
.controls { width: 100%; max-width: 440px; margin-top: clamp(24px, 3vw, 32px); }
|
||||||
|
.ctl-row { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
|
||||||
|
.ctl-row:first-child { margin-top: 0; }
|
||||||
|
.ctl-label { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--label); }
|
||||||
|
.ctl-row > .ctl-label { width: 78px; flex: none; }
|
||||||
|
.swatches { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
||||||
|
.ctl-row input[type="range"] { flex: 1; accent-color: var(--accent); cursor: pointer; }
|
||||||
|
.ctl-divider { height: 1px; background: rgba(120, 95, 50, 0.18); margin: 24px 0 18px; }
|
||||||
|
.palette { display: flex; gap: 9px; margin-top: 11px; }
|
||||||
|
.chip { flex: 1; max-width: 46px; height: 34px; border-radius: 8px; }
|
||||||
|
.actions { display: flex; gap: 11px; margin-top: 24px; }
|
||||||
|
.act { flex: 1; text-align: center; font-size: 13.5px; font-weight: 700; padding: 12px 0; border-radius: 9px; cursor: pointer; text-decoration: none; -webkit-tap-highlight-color: transparent; }
|
||||||
|
.act-share { background: var(--accent); color: #fff; border: none; font-family: inherit; transition: background 0.15s ease; }
|
||||||
|
.act-share:hover { background: var(--accent-deep); }
|
||||||
|
.act-dl { border: 1.5px solid #c7b48f; color: var(--story); background: transparent; display: inline-flex; align-items: center; justify-content: center; transition: border-color 0.15s ease, color 0.15s ease; }
|
||||||
|
.act-dl:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Phones: collapse the wrappers so the four blocks reflow into one calm column —
|
||||||
|
head → artwork → writeup → controls (the artwork sits high, seen before it's read). */
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.gallery { justify-content: flex-start; }
|
||||||
|
.story-card { flex-direction: column; }
|
||||||
|
.left, .right { display: contents; } /* promote head/body/art-stage/controls */
|
||||||
|
.head { order: 1; padding: clamp(22px, 5vw, 28px) clamp(18px, 5vw, 22px) 14px; }
|
||||||
|
.art-stage { order: 2; background: var(--art-band); padding: 22px 18px; box-sizing: border-box; }
|
||||||
|
.body { order: 3; padding: 6px clamp(18px, 5vw, 22px) 4px; }
|
||||||
|
/* writeup collapses (tap "About this piece") so the controls rise up beside the art */
|
||||||
|
.details-toggle {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%;
|
||||||
|
background: none; border: none; cursor: pointer; padding: 14px 0;
|
||||||
|
font-family: 'Space Mono', monospace; font-size: 11px; letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase; color: var(--label); -webkit-tap-highlight-color: transparent;
|
||||||
|
border-top: 1px solid rgba(120, 95, 50, 0.16);
|
||||||
|
}
|
||||||
|
.chev.open { transform: rotate(180deg); }
|
||||||
|
.body-content.collapsed { display: none; }
|
||||||
|
.controls { order: 4; width: auto; max-width: none; box-sizing: border-box;
|
||||||
|
margin: 16px clamp(14px, 4vw, 18px) clamp(20px, 5vw, 24px);
|
||||||
|
background: #fff; border: 1px solid #ece3d0; border-radius: 16px; padding: 18px 16px; }
|
||||||
|
.meta-cols { gap: 28px; }
|
||||||
|
.cta { align-self: stretch; text-align: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The frame: a beveled moulding (wood/metal) around a cream mat around the art.
|
||||||
|
--rail / --mat are the moulding and mat widths; both scale with --frame-scale (the
|
||||||
|
thickness slider). EVERY variant — even "No frame" — reserves the same footprint, so
|
||||||
|
switching frames never reflows the page. */
|
||||||
|
.frame {
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Rail scales across the whole slider; the mat caps at scale 1.5 so the top of the
|
||||||
|
slider thickens only the moulding (wood), holding the white border steady. */
|
||||||
|
--rail: calc(clamp(11px, 2.3vw, 22px) * var(--frame-scale, 1));
|
||||||
|
--mat: calc(clamp(10px, 2.4vw, 22px) * min(var(--frame-scale, 1), 1.5));
|
||||||
|
border: none; cursor: zoom-in; background: none;
|
||||||
|
position: relative; line-height: 0; display: inline-block; max-width: 100%;
|
||||||
|
border-radius: 4px; padding: var(--rail);
|
||||||
|
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20), 0 3px 10px rgba(20, 30, 45, 0.10);
|
||||||
|
}
|
||||||
|
/* The three woods share one real (CC0, Poly Haven) fine-grain texture, recolored per
|
||||||
|
species with a CSS filter, with a soft bevel baked into the texture layer. Metals
|
||||||
|
stay gradient. The texture sits on ::before (behind the mat); miters on ::after. */
|
||||||
|
.frame--walnut, .frame--oak, .frame--mahogany {
|
||||||
|
background: linear-gradient(135deg, #5a3a24, #34210f); /* fallback if texture misses */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Four real mitered rails: the grain TURNS at every corner — horizontal moulding on the
|
||||||
|
top/bottom rails (rotated texture), vertical on the sides — like four cut lengths of
|
||||||
|
wood. Each rail is a trapezoid clipped to 45° at the joints; --rail drives size + cut.
|
||||||
|
A directional bevel per rail (lit top-left) gives the moulding a rounded profile. */
|
||||||
|
.rail {
|
||||||
|
position: absolute; z-index: 0; pointer-events: none;
|
||||||
|
background-position: center; background-size: cover; background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
.rail--t { top: 0; left: 0; right: 0; height: var(--rail);
|
||||||
|
clip-path: polygon(0 0, 100% 0, calc(100% - var(--rail)) 100%, var(--rail) 100%);
|
||||||
|
background-image: linear-gradient(to bottom, rgba(255,255,255,0.22), rgba(0,0,0,0.22)), url("/textures/wood-grain-h.jpg"); }
|
||||||
|
.rail--b { bottom: 0; left: 0; right: 0; height: var(--rail);
|
||||||
|
clip-path: polygon(var(--rail) 0, calc(100% - var(--rail)) 0, 100% 100%, 0 100%);
|
||||||
|
background-image: linear-gradient(to top, rgba(255,255,255,0.05), rgba(0,0,0,0.36)), url("/textures/wood-grain-h.jpg"); }
|
||||||
|
.rail--l { top: 0; bottom: 0; left: 0; width: var(--rail);
|
||||||
|
clip-path: polygon(0 0, 100% var(--rail), 100% calc(100% - var(--rail)), 0 100%);
|
||||||
|
background-image: linear-gradient(to right, rgba(255,255,255,0.20), rgba(0,0,0,0.22)), url("/textures/wood-grain.jpg"); }
|
||||||
|
.rail--r { top: 0; bottom: 0; right: 0; width: var(--rail);
|
||||||
|
clip-path: polygon(0 var(--rail), 100% 0, 100% 100%, 0 calc(100% - var(--rail)));
|
||||||
|
background-image: linear-gradient(to left, rgba(255,255,255,0.05), rgba(0,0,0,0.36)), url("/textures/wood-grain.jpg"); }
|
||||||
|
/* per-species recolor, now applied to the rails */
|
||||||
|
.frame--walnut .rail { filter: brightness(1.08) saturate(1.05); }
|
||||||
|
.frame--oak .rail { filter: brightness(1.72) contrast(0.92) saturate(1.12) sepia(0.22) hue-rotate(-6deg); }
|
||||||
|
.frame--mahogany .rail { filter: brightness(0.98) contrast(1.06) saturate(1.55) sepia(0.4) hue-rotate(-16deg); }
|
||||||
|
|
||||||
|
/* Metals: fine brushed striations over an anisotropic sheen. */
|
||||||
|
.frame--gold {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(180deg, rgba(255,255,255,0.08) 0 1px, rgba(120,80,10,0.07) 1px 2px),
|
||||||
|
linear-gradient(135deg, #b88c3d, #ecd293 42%, #a9772f 60%, #dcbd71);
|
||||||
|
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.30),
|
||||||
|
inset 0 2px 3px rgba(255, 249, 226, 0.60),
|
||||||
|
inset 0 -3px 9px rgba(92, 62, 12, 0.48);
|
||||||
|
}
|
||||||
|
.frame--silver {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(180deg, rgba(255,255,255,0.11) 0 1px, rgba(40,50,60,0.07) 1px 2px),
|
||||||
|
linear-gradient(135deg, #a9b0ba, #edf0f3 45%, #98a0ab 62%, #d2d7de);
|
||||||
|
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.18),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.50),
|
||||||
|
inset 0 2px 3px rgba(255, 255, 255, 0.75),
|
||||||
|
inset 0 -3px 9px rgba(60, 70, 85, 0.42);
|
||||||
|
}
|
||||||
|
.frame--black {
|
||||||
|
background: linear-gradient(135deg, #2a2c30, #15171a 45%, #25282c 60%, #101113);
|
||||||
|
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.30),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
|
||||||
|
inset 0 2px 3px rgba(255, 255, 255, 0.10),
|
||||||
|
inset 0 -3px 9px rgba(0, 0, 0, 0.60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mitered (45°) corner joints for the woods — each seam runs from the outer corner to
|
||||||
|
the mat, sized to the rail width so it tracks the thickness slider. */
|
||||||
|
.frame--walnut::after, .frame--oak::after, .frame--mahogany::after {
|
||||||
|
content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none; border-radius: inherit;
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) top left / var(--rail) var(--rail) no-repeat,
|
||||||
|
linear-gradient(135deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) top right / var(--rail) var(--rail) no-repeat,
|
||||||
|
linear-gradient(135deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) bottom left / var(--rail) var(--rail) no-repeat,
|
||||||
|
linear-gradient(45deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) bottom right / var(--rail) var(--rail) no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat {
|
||||||
|
display: block; position: relative; z-index: 1; background: #fbf8f1; border-radius: 1px;
|
||||||
|
padding: var(--mat);
|
||||||
|
/* The mat sits recessed below the moulding: a crisp rabbet groove at the lip, the
|
||||||
|
frame casting shadow onto the top/left of the mat, the bottom/right catching light
|
||||||
|
— a small chiseled step instead of a flat transition. */
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(40, 28, 16, 0.18),
|
||||||
|
inset 0 6px 9px -3px rgba(0, 0, 0, 0.34),
|
||||||
|
inset 6px 0 9px -4px rgba(0, 0, 0, 0.24),
|
||||||
|
inset 0 -3px 6px -2px rgba(255, 255, 255, 0.55),
|
||||||
|
inset -3px 0 6px -2px rgba(255, 255, 255, 0.40);
|
||||||
|
}
|
||||||
|
.frame img { display: block; width: 100%; height: auto; max-height: 72vh; object-fit: contain; border-radius: 1px; }
|
||||||
|
|
||||||
|
/* No frame: keep the footprint (so switching never shifts the layout), drop the
|
||||||
|
moulding + mat, and let the bare art float with a soft drop shadow. */
|
||||||
|
.frame--none { background: transparent; box-shadow: none; }
|
||||||
|
.frame--none .mat { background: transparent; box-shadow: none; }
|
||||||
|
.frame--none img {
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 18px 44px rgba(20, 30, 45, 0.20), 0 4px 12px rgba(20, 30, 45, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A quiet, always-there affordance — no hover wobble. */
|
||||||
|
.hint {
|
||||||
|
position: absolute; right: 12px; bottom: 12px;
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 0.76rem; font-weight: 600; line-height: 1; color: #fff;
|
||||||
|
background: rgba(20, 26, 33, 0.50); border-radius: 999px; padding: 7px 11px;
|
||||||
|
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
||||||
|
pointer-events: none; transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
.frame:hover .hint, .frame:focus-visible .hint { background: rgba(20, 26, 33, 0.72); }
|
||||||
|
|
||||||
|
.sep { display: inline-block; margin: 0 0.5em; color: var(--muted); } /* used in the lightbox caption */
|
||||||
|
|
||||||
|
/* Frame chips read as little beveled beads; selection is a clean offset ring. */
|
||||||
|
.swatch {
|
||||||
|
width: 30px; height: 30px; border-radius: 50%; border: none; cursor: pointer; padding: 0;
|
||||||
|
box-shadow: inset 0 2px 3px rgba(255, 255, 255, 0.38),
|
||||||
|
inset 0 -3px 5px rgba(0, 0, 0, 0.30), 0 1px 3px rgba(0, 0, 0, 0.22);
|
||||||
|
outline: 2.5px solid transparent; outline-offset: 2px;
|
||||||
|
transition: outline-color 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.swatch:hover { transform: translateY(-1px); }
|
||||||
|
.swatch.on { outline-color: var(--accent); }
|
||||||
|
.swatch--walnut { background: linear-gradient(150deg, #6b4528, #8a5d31 55%, #4f3420); }
|
||||||
|
.swatch--oak { background: linear-gradient(150deg, #b07f46, #cb9c5b 55%, #8c5d2f); }
|
||||||
|
.swatch--mahogany { background: linear-gradient(150deg, #5a241a, #7e3826 55%, #3a160e); }
|
||||||
|
.swatch--gold { background: linear-gradient(150deg, #c79a45, #ecd293 55%, #a9772f); }
|
||||||
|
.swatch--silver { background: linear-gradient(150deg, #aab1bb, #edf0f3 55%, #98a0ab); }
|
||||||
|
.swatch--black { background: linear-gradient(150deg, #34373c, #16181b 55%, #0c0d0f); }
|
||||||
|
.swatch--none { background: linear-gradient(150deg, #ffffff, #ece6da); }
|
||||||
|
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||||
|
|
||||||
|
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
|
||||||
|
|
||||||
|
.lightbox {
|
||||||
|
position: fixed; inset: 0; z-index: 50;
|
||||||
|
/* A soft, top-lit gallery wall — lighter than the page so every frame (Black
|
||||||
|
included) reads, like a piece hung on a real wall. */
|
||||||
|
background: linear-gradient(180deg, #efe9dd, #e2dbcc);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; padding: 2.5vmin;
|
||||||
|
}
|
||||||
|
/* full-bleed click target behind the content — clicking the wall closes */
|
||||||
|
.lb-backdrop { position: absolute; inset: 0; z-index: 0; border: none; background: none; padding: 0; cursor: zoom-out; }
|
||||||
|
.lb-close {
|
||||||
|
position: absolute; top: clamp(10px, 2vw, 20px); right: clamp(10px, 2vw, 20px); z-index: 3;
|
||||||
|
width: 40px; height: 40px; border-radius: 999px; border: 1px solid rgba(40, 30, 20, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.72); color: #3a3a3a; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.lb-close:hover { background: #fff; }
|
||||||
|
/* frame + caption travel together so a rotated view turns as one piece */
|
||||||
|
.lb-stage { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||||
|
/* magnified inspection view (desktop): cursor pans via transform-origin */
|
||||||
|
.lb-zoom {
|
||||||
|
position: relative; z-index: 1; width: 96vw; height: 88vh; overflow: hidden;
|
||||||
|
cursor: grab; display: flex; align-items: center; justify-content: center; touch-action: none;
|
||||||
|
}
|
||||||
|
.lb-zoom.dragging { cursor: grabbing; }
|
||||||
|
.lb-zoom img {
|
||||||
|
max-width: 100%; max-height: 100%; width: auto; height: auto;
|
||||||
|
will-change: transform; user-select: none; -webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* floating gallery-inspector toolbar — a quiet translucent dark pill, white controls */
|
||||||
|
.lb-tools {
|
||||||
|
position: absolute; bottom: clamp(14px, 3vh, 28px); left: 50%; transform: translateX(-50%);
|
||||||
|
z-index: 3; display: flex; align-items: center; gap: 12px;
|
||||||
|
background: rgba(20, 18, 16, 0.72); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
|
||||||
|
border-radius: 999px; padding: 8px 14px; color: #fff; box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.lb-t {
|
||||||
|
width: 26px; height: 26px; flex: none; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
|
background: none; color: #fff; cursor: pointer; font-size: 1.05rem; line-height: 1;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.lb-t:hover { background: rgba(255, 255, 255, 0.18); }
|
||||||
|
.lb-slider { width: clamp(120px, 22vw, 220px); accent-color: #fff; cursor: pointer; }
|
||||||
|
.lb-pct { font-size: 0.8rem; font-variant-numeric: tabular-nums; min-width: 3.2em; text-align: center; opacity: 0.92; }
|
||||||
|
.lb-fit {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.32); background: none; color: #fff; cursor: pointer;
|
||||||
|
border-radius: 999px; padding: 4px 13px; font-size: 0.82rem; font-family: inherit; font-weight: 600;
|
||||||
|
}
|
||||||
|
.lb-fit:hover { background: rgba(255, 255, 255, 0.18); }
|
||||||
|
@media (hover: none) { .lb-tools { display: none; } } /* mobile keeps native pinch */
|
||||||
|
.lb-zoombtn {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
|
||||||
|
border: 1px solid #d9cdeb; background: #fff; color: var(--accent);
|
||||||
|
border-radius: 999px; padding: 8px 16px; font-family: inherit; font-size: 0.9rem; font-weight: 600;
|
||||||
|
transition: background 0.14s ease, color 0.14s ease;
|
||||||
|
}
|
||||||
|
.lb-zoombtn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
@media (hover: none) { .lb-zoombtn { display: none; } } /* mobile keeps native pinch-zoom */
|
||||||
|
/* The full-screen view wears the same frame. Rail + mat are only a touch larger than the
|
||||||
|
page (keeping the ~1:1 wood:white proportion), and the image is capped so the whole
|
||||||
|
framed piece — including the bottom rail — always fits on screen. */
|
||||||
|
.lb-frame {
|
||||||
|
max-width: 92vw;
|
||||||
|
--rail: calc(clamp(18px, 1.9vw, 36px) * var(--frame-scale, 1));
|
||||||
|
--mat: calc(clamp(16px, 1.6vw, 30px) * min(var(--frame-scale, 1), 1.5));
|
||||||
|
}
|
||||||
|
.lb-frame.frame--none { --rail: 0px; --mat: 0px; } /* bare art fills the screen */
|
||||||
|
.lb-frame img { max-width: 86vw; max-height: 66vh; width: auto; height: auto; object-fit: contain; }
|
||||||
|
.lb-frame.frame--none img { max-width: 96vw; max-height: 88vh; } /* no frame → go big */
|
||||||
|
.lb-cap { color: #5b636e; font-size: 0.9rem; }
|
||||||
|
.lb-cap .sep { color: #a6acb4; }
|
||||||
|
|
||||||
|
/* On a narrow portrait phone the framed piece (image + mat + rail) overflowed the
|
||||||
|
viewport — the rail got clipped and the whole thing read as distorted. Pull the
|
||||||
|
image cap in so the WHOLE frame fits on screen, and leave room for the caption. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.lb-frame { max-width: 94vw; }
|
||||||
|
.lb-frame img { max-width: 72vw; max-height: 60vh; }
|
||||||
|
.lb-frame.frame--none img { max-width: 92vw; max-height: 78vh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Landscape artwork on a PORTRAIT phone: turn the whole stage 90° so the painting
|
||||||
|
fills the long axis (turn the phone to view it level; tap anywhere to close). The
|
||||||
|
image caps are in SWAPPED units — vh drives the on-screen long edge after rotation,
|
||||||
|
vw the short edge — so it always fits. Desktop and portrait art never hit this. */
|
||||||
|
@media (max-width: 640px) and (orientation: portrait) {
|
||||||
|
.lightbox.rotate .lb-stage { transform: rotate(90deg); transform-origin: center; }
|
||||||
|
.lightbox.rotate .lb-cap { display: none; } /* would sit sideways; the placard's on the page */
|
||||||
|
/* The image's SHORT edge + the frame's rail/mat map to the phone's narrow width, so
|
||||||
|
cap it there (max-height, pre-rotation) with a modest fixed frame so the whole
|
||||||
|
moulding always fits; the long edge (max-width → screen height) stays generous. */
|
||||||
|
.lightbox.rotate .lb-frame { max-width: 92vh; } /* rail/mat keep scaling with thickness */
|
||||||
|
/* short edge (→ screen width after rotation) capped low enough that the frame fits even
|
||||||
|
at max thickness; the long edge stays generous */
|
||||||
|
.lightbox.rotate .lb-frame img { max-width: 88vh; max-height: 60vw; }
|
||||||
|
.lightbox.rotate .lb-frame.frame--none img { max-width: 94vh; max-height: 92vw; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<h1>Couldn't sign you in</h1>
|
<h1>Couldn't sign you in</h1>
|
||||||
<p class="muted">{error}</p>
|
<p class="muted">{error}</p>
|
||||||
<a class="back" href="/">← Back to Upbeat Bytes</a>
|
<a class="back" href="/">← Back to upbeatBytes</a>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script>
|
||||||
|
// /news — the feed's home: Latest by default, Highlights at ?view=highlights, plus
|
||||||
|
// search/categories/following/saved. Renders the shared NewsFeed in hub chrome.
|
||||||
|
import NewsFeed from '$lib/components/NewsFeed.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NewsFeed chrome="hub" />
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import HubShell from '$lib/components/HubShell.svelte';
|
||||||
|
|
||||||
|
let f = $state(null);
|
||||||
|
let state = $state('loading');
|
||||||
|
|
||||||
|
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
|
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
function dateline(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
const [y, m, d] = s.split('-').map(Number);
|
||||||
|
return `${WEEKDAYS[new Date(y, m - 1, d).getDay()]}, ${MONTHS[m - 1]} ${d}`;
|
||||||
|
}
|
||||||
|
let readLabel = $derived(f?.source_url?.includes('wikipedia') ? 'Read more on Wikipedia' : 'Read more');
|
||||||
|
|
||||||
|
// Figure-vs-photo: OTD images are uncontrolled (Wikimedia photos, but also seals,
|
||||||
|
// logos, maps, diagrams). Photos look best full-bleed (cover); figures must be shown
|
||||||
|
// WHOLE (contain on a clean matte) or they get cropped to nonsense. PNG/SVG sources
|
||||||
|
// are almost always figures/logos with transparency; refine with aspect ratio on load.
|
||||||
|
let heroAspect = $state(null);
|
||||||
|
let heroFit = $derived.by(() => {
|
||||||
|
const url = f?.image_url || '';
|
||||||
|
if (/\.(png|svg)(\?|#|$)/i.test(url)) return 'contain';
|
||||||
|
// filename hints catch JPEG logos/maps/crests the aspect check would crop
|
||||||
|
if (/(seal|flag|logo|map|diagram|crest|emblem|coat[_-]?of[_-]?arms)/i.test(url)) return 'contain';
|
||||||
|
if (heroAspect == null) return 'cover';
|
||||||
|
return (heroAspect < 0.9 || heroAspect > 2.0) ? 'contain' : 'cover';
|
||||||
|
});
|
||||||
|
function onHeroLoad(e) {
|
||||||
|
const img = e.currentTarget;
|
||||||
|
if (img.naturalWidth && img.naturalHeight) heroAspect = img.naturalWidth / img.naturalHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
f = await getJSON('/api/onthisday/today');
|
||||||
|
state = f ? 'ready' : 'empty';
|
||||||
|
} catch {
|
||||||
|
state = 'empty';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>On This Day · upbeatBytes</title>
|
||||||
|
<meta name="description" content="A good thing that happened on today's date in history." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<HubShell active="">
|
||||||
|
<article class="otd-page">
|
||||||
|
{#if state === 'ready'}
|
||||||
|
<!-- CD's "letter" treatment in a green key: deckle frame, dateline, hero with year overlay -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="deckle">
|
||||||
|
<div class="eyebrow">
|
||||||
|
<span class="eye-rule"></span>
|
||||||
|
<span class="eye-label">On This Day</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dateline">
|
||||||
|
<div class="year-block">
|
||||||
|
<span class="yb-label">In history</span>
|
||||||
|
<span class="yb-year">{f.year}</span>
|
||||||
|
</div>
|
||||||
|
<span class="dl-rule"></span>
|
||||||
|
<span class="dl-label">{dateline(f.date)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if f.image_url}
|
||||||
|
<div class="hero" class:figure={heroFit === 'contain'}>
|
||||||
|
<img class="hero-img" src={f.image_url} alt="" loading="lazy" onload={onHeroLoad} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="headline">{f.text}</p>
|
||||||
|
|
||||||
|
{#if f.summary}
|
||||||
|
<div class="story-head">
|
||||||
|
<span class="sh-rule"></span>
|
||||||
|
<span class="sh-label">A little context</span>
|
||||||
|
</div>
|
||||||
|
<p class="story">{f.summary}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if f.source_url}
|
||||||
|
<div class="cta-row">
|
||||||
|
<a class="cta" href={f.source_url} target="_blank" rel="noopener">
|
||||||
|
{readLabel}
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if state === 'empty'}
|
||||||
|
<p class="note">Today's moment in history is on its way. Check back soon.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="note">Finding today's good thing…</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
</HubShell>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-var.woff2') format('woff2'); font-weight: 500 700; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-italic-var.woff2') format('woff2'); font-weight: 500 700; font-style: italic; font-display: swap; }
|
||||||
|
|
||||||
|
.otd-page { max-width: 880px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative; margin-top: clamp(24px, 4vw, 40px); background: #f6ead0;
|
||||||
|
border: 1px solid rgba(120, 90, 40, 0.10); border-radius: 16px;
|
||||||
|
box-shadow: 0 26px 50px -32px rgba(60, 80, 55, 0.6); padding: clamp(12px, 1.6vw, 18px);
|
||||||
|
}
|
||||||
|
.deckle {
|
||||||
|
position: relative; border: 1.5px dashed rgba(70, 120, 90, 0.38); border-radius: 11px;
|
||||||
|
padding: clamp(26px, 5vw, 34px) clamp(20px, 5vw, 34px) clamp(34px, 6vw, 44px); overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eyebrow matches the homepage "On this day" card accent (clay) so the card → page read as one */
|
||||||
|
.eyebrow { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.eye-rule { width: 34px; height: 3px; background: #9a5a38; border-radius: 2px; flex: none; }
|
||||||
|
.eye-label { font-family: 'Hanken Grotesk', sans-serif; font-size: clamp(17px, 2.4vw, 22px); font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #9a5a38; }
|
||||||
|
|
||||||
|
/* extra breathing room between the title and the content below */
|
||||||
|
.dateline { display: flex; align-items: baseline; flex-wrap: wrap; gap: 10px 16px; margin: clamp(32px, 5vw, 48px) 0 16px; }
|
||||||
|
.dl-rule { flex: 1 1 30px; min-width: 24px; height: 1.5px; align-self: baseline; margin: 0; background: rgba(70, 120, 90, 0.26); }
|
||||||
|
.dl-label { font-family: 'Hanken Grotesk', sans-serif; font-weight: 700; font-size: clamp(14px, 1.7vw, 17px); letter-spacing: 0.1em; text-transform: uppercase; color: #2c5d44; }
|
||||||
|
|
||||||
|
/* "2013" is the SOLE in-flow baseline anchor (shares one baseline with the date + rule);
|
||||||
|
"IN HISTORY" is absolutely positioned in the reserved start-padding so raising it can never
|
||||||
|
drag that baseline (Codex's structure). Both green so the line ties in the Read-more button. */
|
||||||
|
.year-block { --ys: clamp(34px, 5.5vw, 46px); position: relative; display: inline-block; padding-inline-start: 90px; line-height: 1; flex: none; }
|
||||||
|
/* offset tied to the year size (≈cap-top), so it lands by construction and holds across
|
||||||
|
viewports instead of needing per-pixel re-guesses */
|
||||||
|
.yb-label { position: absolute; inset-inline-start: 0; top: calc(0.2 * var(--ys) + 1px); line-height: 1; font-family: 'Hanken Grotesk', sans-serif; font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #367653; }
|
||||||
|
.yb-year { position: static; display: inline-block; font-family: 'Playfair Display', Georgia, serif; font-weight: 700; font-size: var(--ys); line-height: 1; color: #2c5d44; }
|
||||||
|
|
||||||
|
/* the full line can't fit cleanly on a narrow phone — wrap it deliberately: year lockup on its
|
||||||
|
own row, rule + date on the next (instead of accidental mid-line flex wrapping) */
|
||||||
|
@media (max-width: 520px) { .year-block { flex: 0 0 100%; } }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative; border-radius: 12px; overflow: hidden; background: #21392e;
|
||||||
|
height: clamp(220px, 38vw, 330px);
|
||||||
|
}
|
||||||
|
.hero-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
/* figures (seals/logos/diagrams) shown whole on a clean light matte, never cropped */
|
||||||
|
.hero.figure { background: #f4f6f1; border: 1px solid rgba(70, 120, 90, 0.16); }
|
||||||
|
.hero.figure .hero-img { object-fit: contain; padding: clamp(18px, 3vw, 30px); box-sizing: border-box; }
|
||||||
|
|
||||||
|
.headline {
|
||||||
|
font-family: 'Playfair Display', Georgia, serif; font-style: italic; font-weight: 600;
|
||||||
|
font-size: clamp(1.45rem, 4vw, 2.06rem); line-height: 1.28; color: #243d31;
|
||||||
|
margin: clamp(24px, 4vw, 30px) 0 0; max-width: 40ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-head { display: flex; align-items: center; gap: 12px; margin-top: clamp(26px, 4vw, 34px); }
|
||||||
|
.sh-rule { width: 26px; height: 2px; background: #bcae93; border-radius: 2px; flex: none; }
|
||||||
|
.sh-label { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #74633f; }
|
||||||
|
.story {
|
||||||
|
font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.8vw, 1.13rem);
|
||||||
|
line-height: 1.68; color: #4f574f; margin: 12px 0 0; max-width: 64ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-row { margin-top: clamp(24px, 4vw, 30px); }
|
||||||
|
/* clay to match the "On This Day" title — gives this card its own identity (AA: white on #9a5a38) */
|
||||||
|
.cta {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px; background: #9a5a38; color: #fff;
|
||||||
|
text-decoration: none; font-family: 'Hanken Grotesk', sans-serif; font-size: 14px; font-weight: 600;
|
||||||
|
padding: 11px 20px; border-radius: 999px; -webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.cta:hover { background: #854c2f; }
|
||||||
|
|
||||||
|
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
|
||||||
|
</style>
|
||||||
@@ -3,27 +3,58 @@
|
|||||||
import { goto, afterNavigate } from '$app/navigation';
|
import { goto, afterNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getJSON } from '$lib/api.js';
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import HubBar from '$lib/components/HubBar.svelte';
|
||||||
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
import { pushGameStatesBatch } from '$lib/gamesync.js';
|
||||||
|
import { initPrefs } from '$lib/prefs.svelte.js';
|
||||||
|
import { auth } from '$lib/auth.svelte.js';
|
||||||
|
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
|
||||||
|
import { trackGame } from '$lib/analytics.js';
|
||||||
import WordGame from '$lib/components/WordGame.svelte';
|
import WordGame from '$lib/components/WordGame.svelte';
|
||||||
import WordSearchGame from '$lib/components/WordSearchGame.svelte';
|
import WordSearchGame from '$lib/components/WordSearchGame.svelte';
|
||||||
|
import BloomGame from '$lib/components/BloomGame.svelte';
|
||||||
|
import MatchGame from '$lib/components/MatchGame.svelte';
|
||||||
|
|
||||||
// Screen is derived from the URL so the device/browser Back button steps through
|
// Screen is derived from the URL so the device/browser Back button steps through
|
||||||
// Hub → Game Selection → Game (each screen is its own history entry), instead of
|
// Hub → Game Selection → Game (each screen is its own history entry), instead of
|
||||||
// jumping straight out of /play.
|
// jumping straight out of /play.
|
||||||
let sp = $derived($page.url.searchParams);
|
let sp = $derived($page.url.searchParams);
|
||||||
let game = $derived(sp.get('game') === 'wordsearch' ? 'wordsearch' : 'word');
|
let game = $derived(['wordsearch', 'bloom', 'match'].includes(sp.get('game')) ? sp.get('game') : 'word');
|
||||||
let view = $derived(!sp.get('game') ? 'hub' : (sp.get('v') ? 'play' : 'select'));
|
let view = $derived(!sp.get('game') ? 'hub' : (sp.get('v') ? 'play' : 'select'));
|
||||||
|
// Bloom v: 'daily' (shared Center Circle) | 'free-center' | 'free-wild'.
|
||||||
|
let bloomMode = $derived(sp.get('v') === 'daily' ? 'daily' : 'free');
|
||||||
|
let bloomFormat = $derived(sp.get('v') === 'free-wild' ? 'wild' : 'center');
|
||||||
|
// Hide an in-dev game's card from non-admins, and bounce them off its route.
|
||||||
|
let bloomBlocked = $derived(blockedForViewer('bloom', auth.user, $page.url));
|
||||||
|
let bloomStatus = $state(null);
|
||||||
let variant = $derived(['5', '6'].includes(sp.get('v')) ? sp.get('v') : '5');
|
let variant = $derived(['5', '6'].includes(sp.get('v')) ? sp.get('v') : '5');
|
||||||
let wsSize = $derived(['small', 'med', 'large'].includes(sp.get('v')) ? sp.get('v') : 'med');
|
let wsSize = $derived(['small', 'med', 'large'].includes(sp.get('v')) ? sp.get('v') : 'med');
|
||||||
|
// Memory Match. v = "<mode>-<format>-<tier>" e.g. daily-icons-standard / free-colors-expert.
|
||||||
|
let matchBlocked = $derived(blockedForViewer('match', auth.user, $page.url));
|
||||||
|
let matchParts = $derived((sp.get('v') || '').split('-'));
|
||||||
|
let matchMode = $derived(matchParts[0] === 'free' ? 'free' : 'daily');
|
||||||
|
let matchFormat = $derived(matchParts[1] === 'colors' ? 'colors' : 'icons');
|
||||||
|
let matchTier = $derived(['gentle', 'standard', 'expert'].includes(matchParts[2]) ? matchParts[2] : 'standard');
|
||||||
|
let matchStatus = $state(null);
|
||||||
|
let matchFmt = $state('icons'); // format toggle on the Match selection screen
|
||||||
|
|
||||||
let date = $state('');
|
let date = $state('');
|
||||||
let wordStatus = $state({ 5: null, 6: null });
|
let wordStatus = $state({ 5: null, 6: null });
|
||||||
let wsStatus = $state(null);
|
let wsStatus = $state(null);
|
||||||
|
|
||||||
|
// Decorative Word-Search tile motif: a 6×5 letter grid with BYTES "found" down the diagonal.
|
||||||
|
const WS_GRID = ['B', 'R', 'O', 'A', 'E', 'S', 'K', 'Y', 'I', 'M', 'U', 'H', 'G', 'E', 'T',
|
||||||
|
'B', 'O', 'R', 'F', 'I', 'N', 'E', 'L', 'A', 'Z', 'O', 'S', 'T', 'S', 'Y'];
|
||||||
|
const WS_HL = new Set([0, 7, 14, 21, 28]); // the diagonal B-Y-T-E-S cells
|
||||||
|
|
||||||
function readWord(v) {
|
function readWord(v) {
|
||||||
try {
|
try {
|
||||||
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
|
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
|
||||||
if (s && (s.status === 'won' || s.status === 'lost')) {
|
const tries = (s?.guesses || []).length;
|
||||||
return { status: s.status, tries: (s.guesses || []).length, max: v === '6' ? 7 : 6 };
|
// Surface in-progress too (so "continue on another device" shows on the card),
|
||||||
|
// not just finished games.
|
||||||
|
if (s && (s.status === 'won' || s.status === 'lost' || (s.status === 'playing' && tries > 0))) {
|
||||||
|
return { status: s.status, tries, max: v === '6' ? 7 : 6 };
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return null;
|
return null;
|
||||||
@@ -35,6 +66,36 @@
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
function readBloom() {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem(`goodnews:bloom:${date}`) || 'null');
|
||||||
|
if (s && Array.isArray(s.found)) return { count: s.found.length, full: !!s.full };
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// All daily Match boards (any tier × format) for the day.
|
||||||
|
const MATCH_TIERS = [
|
||||||
|
['gentle', 'Gentle', '4×3 · 6 pairs'],
|
||||||
|
['standard', 'Standard', '4×4 · 8 pairs'],
|
||||||
|
['expert', 'Expert', '6×4 · match 3 of a kind'],
|
||||||
|
];
|
||||||
|
const MATCH_VARIANTS = MATCH_TIERS.flatMap(([t]) => ['icons', 'colors'].map((f) => `${t}-${f}`));
|
||||||
|
const MATCH_VS = ['daily', 'free'].flatMap((m) =>
|
||||||
|
['icons', 'colors'].flatMap((f) => MATCH_TIERS.map(([t]) => `${m}-${f}-${t}`)));
|
||||||
|
function readMatchVariant(v) {
|
||||||
|
try { return JSON.parse(localStorage.getItem(`goodnews:match:${v}:${date}`) || 'null'); } catch { return null; }
|
||||||
|
}
|
||||||
|
function readMatch() {
|
||||||
|
let best = null;
|
||||||
|
for (const v of MATCH_VARIANTS) {
|
||||||
|
const s = readMatchVariant(v);
|
||||||
|
if (!s) continue;
|
||||||
|
if (s.done) return { done: true };
|
||||||
|
const count = (s.matched || []).length;
|
||||||
|
if (count > 0 && (!best || count > best.count)) best = { done: false, count };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
function refreshStatus() {
|
function refreshStatus() {
|
||||||
wordStatus = { 5: readWord('5'), 6: readWord('6') };
|
wordStatus = { 5: readWord('5'), 6: readWord('6') };
|
||||||
let ws = null;
|
let ws = null;
|
||||||
@@ -44,17 +105,61 @@
|
|||||||
if (s && s.found > 0 && !ws) ws = s;
|
if (s && s.found > 0 && !ws) ws = s;
|
||||||
}
|
}
|
||||||
wsStatus = ws;
|
wsStatus = ws;
|
||||||
|
bloomStatus = readBloom();
|
||||||
|
matchStatus = readMatch();
|
||||||
}
|
}
|
||||||
function fmtMs(ms) {
|
function fmtMs(ms) {
|
||||||
const s = Math.round(ms / 1000);
|
const s = Math.round(ms / 1000);
|
||||||
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The hub itself reconciles every game with the server (signed-in), so cards
|
||||||
|
// show cross-device status WITHOUT having to open each game first, and this
|
||||||
|
// device's local progress gets uploaded even for games it hasn't reopened.
|
||||||
|
// Every daily board the hub surfaces, as [game, variant, localStorage key]. Match
|
||||||
|
// includes all tier×format variants so cross-device progress shows without opening
|
||||||
|
// the game.
|
||||||
|
function gameSpecs() {
|
||||||
|
return [
|
||||||
|
['word', '5', `goodnews:word:5:${date}`],
|
||||||
|
['word', '6', `goodnews:word:6:${date}`],
|
||||||
|
['wordsearch', 'small', `goodnews:wordsearch:small:${date}`],
|
||||||
|
['wordsearch', 'med', `goodnews:wordsearch:med:${date}`],
|
||||||
|
['wordsearch', 'large', `goodnews:wordsearch:large:${date}`],
|
||||||
|
['bloom', '', `goodnews:bloom:${date}`],
|
||||||
|
...MATCH_VARIANTS.map((v) => ['match', v, `goodnews:match:${v}:${date}`]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// One batch request reconciles ALL boards (instead of a dozen calls on every /play
|
||||||
|
// load — that fan-out was tripping the boot-slow beacon). Still server-merged, so
|
||||||
|
// cross-device pull is preserved.
|
||||||
|
async function syncAllGames() {
|
||||||
|
if (!auth.user || !date) return;
|
||||||
|
const specs = gameSpecs();
|
||||||
|
const items = specs.map(([game, variant, key]) => {
|
||||||
|
let local = null;
|
||||||
|
try { local = JSON.parse(localStorage.getItem(key) || 'null'); } catch { /* ignore */ }
|
||||||
|
return { game, variant, state: local || {} };
|
||||||
|
});
|
||||||
|
const states = await pushGameStatesBatch(date, items);
|
||||||
|
if (states) {
|
||||||
|
const keyOf = (g, v) => specs.find((s) => s[0] === g && s[1] === v)?.[2];
|
||||||
|
for (const { game, variant, state } of states) {
|
||||||
|
if (!state) continue;
|
||||||
|
const merged = { ...state };
|
||||||
|
if (game === 'wordsearch') merged.status = merged.ms ? 'done' : 'playing'; // card reads .status
|
||||||
|
const key = keyOf(game, variant);
|
||||||
|
if (key) { try { localStorage.setItem(key, JSON.stringify(merged)); } catch { /* ignore */ } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
// Hub card one-liners
|
// Hub card one-liners
|
||||||
function wordLabel() {
|
function wordLabel() {
|
||||||
const a = wordStatus['5'], b = wordStatus['6'];
|
const a = wordStatus['5'], b = wordStatus['6'];
|
||||||
if (!a && !b) return 'Guess the day’s word';
|
if (!a && !b) return 'Guess the day’s word';
|
||||||
const part = (s, mx) => s ? (s.status === 'won' ? `${s.tries}/${mx}` : 'X') : '–';
|
const part = (s, mx) => !s ? '–' : s.status === 'won' ? `${s.tries}/${mx}` : s.status === 'lost' ? 'X' : `${s.tries}…`;
|
||||||
return `Today · 5:${part(a, 6)} 6:${part(b, 7)}`;
|
return `Today · 5:${part(a, 6)} 6:${part(b, 7)}`;
|
||||||
}
|
}
|
||||||
function wsHubLabel() {
|
function wsHubLabel() {
|
||||||
@@ -63,12 +168,32 @@
|
|||||||
if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`;
|
if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`;
|
||||||
return 'Find the day’s themed words';
|
return 'Find the day’s themed words';
|
||||||
}
|
}
|
||||||
|
function bloomHubLabel() {
|
||||||
|
if (!bloomStatus || !bloomStatus.count) return 'Make words from today’s letters';
|
||||||
|
if (bloomStatus.full) return 'Today: Full Bloom 🌸';
|
||||||
|
return `Today: ${bloomStatus.count} ${bloomStatus.count === 1 ? 'word' : 'words'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchHubLabel() {
|
||||||
|
if (!matchStatus) return 'Match the day’s pairs';
|
||||||
|
if (matchStatus.done) return 'Today: cleared';
|
||||||
|
return `Today: ${matchStatus.count} matched`;
|
||||||
|
}
|
||||||
|
function matchOpt(t) {
|
||||||
|
const s = readMatchVariant(`${t}-${matchFmt}`);
|
||||||
|
if (!s) return 'Play';
|
||||||
|
if (s.done) return 'Cleared';
|
||||||
|
const c = (s.matched || []).length;
|
||||||
|
return c > 0 ? `${c} matched` : 'Play';
|
||||||
|
}
|
||||||
|
|
||||||
// Game-selection option statuses
|
// Game-selection option statuses
|
||||||
function wordOpt(v) {
|
function wordOpt(v) {
|
||||||
const s = wordStatus[v];
|
const s = wordStatus[v];
|
||||||
if (!s) return 'Play';
|
if (!s) return 'Play';
|
||||||
return s.status === 'won' ? `Solved ${s.tries}/${s.max}` : 'Out of guesses';
|
if (s.status === 'won') return `Solved ${s.tries}/${s.max}`;
|
||||||
|
if (s.status === 'lost') return 'Out of guesses';
|
||||||
|
return `Continue · ${s.tries}/${s.max}`;
|
||||||
}
|
}
|
||||||
function wsOpt(sz) {
|
function wsOpt(sz) {
|
||||||
const s = readWsSize(sz);
|
const s = readWsSize(sz);
|
||||||
@@ -83,6 +208,11 @@
|
|||||||
// (no in-app history) it navigates to the parent screen instead of leaving the
|
// (no in-app history) it navigates to the parent screen instead of leaving the
|
||||||
// site. Device Back stays browser-native either way.
|
// site. Device Back stays browser-native either way.
|
||||||
let appNavDepth = 0;
|
let appNavDepth = 0;
|
||||||
|
let cameFromApp = $state(false); // arrived via in-app nav (e.g. the hub) → Back returns there
|
||||||
|
function leavePlay() {
|
||||||
|
if (cameFromApp && typeof history !== 'undefined') history.back();
|
||||||
|
else goto('/', { replaceState: true });
|
||||||
|
}
|
||||||
function openGame(g) { appNavDepth++; goto('/play?game=' + g); }
|
function openGame(g) { appNavDepth++; goto('/play?game=' + g); }
|
||||||
function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); }
|
function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); }
|
||||||
function back() {
|
function back() {
|
||||||
@@ -91,13 +221,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Canonicalize shareable/bookmarked URLs: unknown game → hub; invalid v for the
|
// Canonicalize shareable/bookmarked URLs: unknown game → hub; invalid v for the
|
||||||
// game → its default (replaceState, so it doesn't add a history entry).
|
// game → its default (replaceState, so it doesn't add a history entry). An in-dev
|
||||||
|
// game's route bounces non-admins back to the hub.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const g = sp.get('game'), v = sp.get('v');
|
const g = sp.get('game'), v = sp.get('v');
|
||||||
if (g && g !== 'word' && g !== 'wordsearch') { goto('/play', { replaceState: true }); return; }
|
if (g && !['word', 'wordsearch', 'bloom', 'match'].includes(g)) { goto('/play', { replaceState: true }); return; }
|
||||||
|
if (g && blockedForViewer(g, auth.user, $page.url)) { goto('/play', { replaceState: true }); return; }
|
||||||
if (g && v) {
|
if (g && v) {
|
||||||
const valid = g === 'word' ? ['5', '6'] : ['small', 'med', 'large'];
|
const valid = g === 'word' ? ['5', '6']
|
||||||
if (!valid.includes(v)) goto(`/play?game=${g}&v=${g === 'word' ? '5' : 'med'}`, { replaceState: true });
|
: g === 'wordsearch' ? ['small', 'med', 'large']
|
||||||
|
: g === 'match' ? MATCH_VS
|
||||||
|
: ['daily', 'free-center', 'free-wild'];
|
||||||
|
const def = g === 'word' ? '5' : g === 'wordsearch' ? 'med' : g === 'match' ? 'daily-icons-standard' : 'daily';
|
||||||
|
if (!valid.includes(v)) goto(`/play?game=${g}&v=${def}`, { replaceState: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,63 +256,180 @@
|
|||||||
let wsTheme = $state('');
|
let wsTheme = $state('');
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
// Share-loop acquisition: record an arrival when someone lands via a shared game
|
||||||
|
// link (gameShareUrl tags it utm_source=game_share), attributed to that game.
|
||||||
|
if ($page.url.searchParams.get('utm_source') === 'game_share') trackGame(game, 'arrival');
|
||||||
|
initPrefs(); // so the reader's chosen calm set is available on a direct /play landing
|
||||||
try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
|
try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
|
||||||
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
|
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
|
syncAllGames(); // signed-in: pull cross-device status into the cards + upload local progress
|
||||||
});
|
});
|
||||||
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
|
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
|
||||||
afterNavigate(() => refreshStatus());
|
afterNavigate(({ from }) => { if (from) cameFromApp = true; refreshStatus(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Play · Upbeat Bytes</title></svelte:head>
|
<svelte:head>
|
||||||
|
<!-- Canonical/OG/description for /play are baked into the static play.html at build
|
||||||
|
time (scripts/patch-static-heads.mjs) so non-JS social scrapers get them; we keep
|
||||||
|
only the browser-tab title + dev-gate noindex here to avoid duplicate tags. -->
|
||||||
|
<title>Play · upbeatBytes — calm daily games</title>
|
||||||
|
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<header class="bar">
|
<HubBar active="games" />
|
||||||
<div class="container inner">
|
|
||||||
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
|
||||||
{#if view === 'hub'}
|
|
||||||
<a class="back" href="/"><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>News</a>
|
|
||||||
{:else}
|
|
||||||
<button class="back" onclick={back}>
|
|
||||||
<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>{view === 'play' ? 'Game Selection' : 'Play Hub'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="container page" class:gameview={view === 'play'}>
|
<main class="container page" class:gameview={view === 'play'}>
|
||||||
|
<!-- In a game, a step-back row (selection / hub). On the games landing, Back lives in the
|
||||||
|
header row instead (right-justified, level with "Play"). -->
|
||||||
|
{#if view !== 'hub'}
|
||||||
|
<button class="gameback" onclick={back}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
{view === 'play' ? 'Game Selection' : 'Play Hub'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if view === 'hub'}
|
{#if view === 'hub'}
|
||||||
<h1>Play</h1>
|
<div class="arcade-head">
|
||||||
<p class="sub">A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.</p>
|
<div class="head-left">
|
||||||
<div class="cards">
|
<span class="eyelash" aria-hidden="true"></span>
|
||||||
<button class="gamecard" onclick={() => openGame('word')}>
|
<h1 class="play-title">Play</h1>
|
||||||
<div class="gc-icon">◧</div>
|
<span class="head-div" aria-hidden="true"></span>
|
||||||
<div class="gc-body">
|
<p class="head-sub">A few little games, fresh every morning. Pick one and dive in.</p>
|
||||||
<h2>Daily Word</h2>
|
|
||||||
<p class="gc-sub">Guess the hopeful word · 5 or 6 letters</p>
|
|
||||||
<p class="gc-status" class:played={wordStatus['5'] || wordStatus['6']}>{wordLabel()}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button class="gameback hub-back" onclick={leavePlay}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button class="gamecard" onclick={() => openGame('wordsearch')}>
|
|
||||||
<div class="gc-icon">▦</div>
|
|
||||||
<div class="gc-body">
|
|
||||||
<h2>Word Search</h2>
|
|
||||||
<p class="gc-sub">Find the day’s themed words</p>
|
|
||||||
<p class="gc-status" class:played={wsStatus && (wsStatus.status === 'done' || wsStatus.found > 0)}>{wsHubLabel()}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="arcade">
|
||||||
|
<button class="tile tile-word" onclick={() => openGame('word')}>
|
||||||
|
<span class="motif m-word" aria-hidden="true">
|
||||||
|
{#each ['B', 'Y', 'T', 'E', '', 'C', 'A', 'L', 'M', ''] as ch}
|
||||||
|
<span class="wt" class:on={ch}>{ch}</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
<span class="foot">
|
||||||
|
<span class="t-name">Daily Word</span>
|
||||||
|
<span class="t-clue">Today’s word · 5 or 6 letters</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button class="tile tile-ws" onclick={() => openGame('wordsearch')}>
|
||||||
|
<span class="motif m-ws" aria-hidden="true">
|
||||||
|
{#each WS_GRID as ch, i}<span class="wc" class:hl={WS_HL.has(i)}>{ch}</span>{/each}
|
||||||
|
</span>
|
||||||
|
<span class="foot">
|
||||||
|
<span class="t-name">Word Search</span>
|
||||||
|
<span class="t-clue">Today’s themed grid</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if !bloomBlocked}
|
||||||
|
<button class="tile tile-bloom" onclick={() => openGame('bloom')}>
|
||||||
|
<span class="motif m-bloom" aria-hidden="true">
|
||||||
|
<span class="bloom-ring">
|
||||||
|
<span class="petal pc">T</span>
|
||||||
|
<span class="petal p1">A</span><span class="petal p2">E</span><span class="petal p3">G</span>
|
||||||
|
<span class="petal p4">L</span><span class="petal p5">R</span><span class="petal p6">Y</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="foot">
|
||||||
|
<span class="t-name">Bloom{#if isDevGated('bloom')}<span class="devtag">dev</span>{/if}</span>
|
||||||
|
<span class="t-clue">Today’s letter garden</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !matchBlocked}
|
||||||
|
<button class="tile tile-match" onclick={() => openGame('match')}>
|
||||||
|
<span class="motif m-match" aria-hidden="true">
|
||||||
|
{#each [0, 1, 2, 3, 4, 5, 6, 7] as i}
|
||||||
|
<span class="mm" class:flip={i === 1 || i === 6}>{#if i === 1 || i === 6}<span class="dot"></span>{/if}</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
<span class="foot">
|
||||||
|
<span class="t-name">Memory Match{#if isDevGated('match')}<span class="devtag">dev</span>{/if}</span>
|
||||||
|
<span class="t-clue">Today’s board</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- teased, not built yet -->
|
||||||
|
<div class="tile tile-bubble soon">
|
||||||
|
<span class="motif m-bubble" aria-hidden="true">
|
||||||
|
<span class="bub b-a" style="left:38px;top:12px"></span>
|
||||||
|
<span class="bub b-b" style="left:73px;top:12px"></span>
|
||||||
|
<span class="bub b-d" style="left:108px;top:12px"></span>
|
||||||
|
<span class="bub b-a" style="left:143px;top:12px"></span>
|
||||||
|
<span class="bub b-b" style="left:178px;top:12px"></span>
|
||||||
|
<span class="bub b-d" style="left:55px;top:41px"></span>
|
||||||
|
<span class="bub b-a" style="left:90px;top:41px"></span>
|
||||||
|
<span class="bub b-b" style="left:125px;top:41px"></span>
|
||||||
|
<span class="bub b-d" style="left:160px;top:41px"></span>
|
||||||
|
<span class="bub b-b" style="left:73px;top:70px"></span>
|
||||||
|
<span class="bub b-c" style="left:108px;top:70px"></span>
|
||||||
|
<span class="bub b-d" style="left:143px;top:70px"></span>
|
||||||
|
<span class="bub-aim"></span>
|
||||||
|
<span class="bub-shooter b-c"></span>
|
||||||
|
</span>
|
||||||
|
<span class="ribbon">Coming soon</span>
|
||||||
|
<span class="foot">
|
||||||
|
<span class="t-name">Bubble Blaster</span>
|
||||||
|
<span class="t-clue">A gentle bubble-pop puzzle</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tile tile-more soon">
|
||||||
|
<span class="more-plus" aria-hidden="true">+</span>
|
||||||
|
<span class="t-name">More coming soon</span>
|
||||||
|
<span class="t-clue">Little games in the workshop</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else if view === 'select'}
|
{:else if view === 'select'}
|
||||||
<h1 class="seltitle">{game === 'word' ? 'Daily Word' : 'Word Search'}</h1>
|
<h1 class="seltitle">{game === 'word' ? 'Daily Word' : game === 'wordsearch' ? 'Word Search' : game === 'match' ? 'Memory Match' : 'Bloom'}</h1>
|
||||||
{#if game === 'wordsearch' && wsTheme}
|
{#if game === 'wordsearch' && wsTheme}
|
||||||
<div class="themecard">
|
<div class="themecard">
|
||||||
<span class="tc-label">Today’s theme</span>
|
<span class="tc-label">Today’s theme</span>
|
||||||
<span class="tc-name">{wsTheme}</span>
|
<span class="tc-name">{wsTheme}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="sub">{game === 'word' ? 'Pick your length.' : 'Pick your size.'}</p>
|
<p class="sub">{game === 'word' ? 'Pick your length.' : game === 'wordsearch' ? 'Pick your size.' : game === 'match' ? 'Pick a format, then today’s board or free play.' : 'Play today’s shared puzzle, or graze freely.'}</p>
|
||||||
<div class="opts">
|
<div class="opts">
|
||||||
{#if game === 'word'}
|
{#if game === 'match'}
|
||||||
|
<div class="seg">
|
||||||
|
<button class="segbtn" class:on={matchFmt === 'icons'} onclick={() => (matchFmt = 'icons')}>Memory · icons</button>
|
||||||
|
<button class="segbtn" class:on={matchFmt === 'colors'} onclick={() => (matchFmt = 'colors')}>Color Match</button>
|
||||||
|
</div>
|
||||||
|
<p class="grp">Today’s board — shared daily</p>
|
||||||
|
{#each MATCH_TIERS as [t, name, desc] (t)}
|
||||||
|
<button class="opt" onclick={() => pick(`daily-${matchFmt}-${t}`)}>
|
||||||
|
<span class="opt-main"><strong>{name}</strong><span>{desc}</span></span>
|
||||||
|
<span class="opt-go" class:done={readMatchVariant(`${t}-${matchFmt}`)?.done}>{matchOpt(t)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<p class="grp">Free play — fresh boards anytime</p>
|
||||||
|
{#each MATCH_TIERS as [t, name, desc] (t)}
|
||||||
|
<button class="opt" onclick={() => pick(`free-${matchFmt}-${t}`)}>
|
||||||
|
<span class="opt-main"><strong>{name}</strong><span>{desc}</span></span>
|
||||||
|
<span class="opt-go">Play</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{:else if game === 'bloom'}
|
||||||
|
<button class="opt" onclick={() => pick('daily')}>
|
||||||
|
<span class="opt-main"><strong>Today’s Bloom</strong><span>the shared daily · center letter</span></span>
|
||||||
|
<span class="opt-go" class:done={bloomStatus && bloomStatus.count > 0}>{bloomStatus && bloomStatus.count ? (bloomStatus.full ? 'Full Bloom 🌸' : `${bloomStatus.count} found`) : 'Play'}</span>
|
||||||
|
</button>
|
||||||
|
<button class="opt" onclick={() => pick('free-center')}>
|
||||||
|
<span class="opt-main"><strong>Free Play · Center Circle</strong><span>fresh wheels anytime · center letter</span></span>
|
||||||
|
<span class="opt-go">Play</span>
|
||||||
|
</button>
|
||||||
|
<button class="opt" onclick={() => pick('free-wild')}>
|
||||||
|
<span class="opt-main"><strong>Free Play · Wild Bloom</strong><span>fresh wheels · use any letters</span></span>
|
||||||
|
<span class="opt-go">Play</span>
|
||||||
|
</button>
|
||||||
|
{:else if game === 'word'}
|
||||||
<button class="opt" onclick={() => pick('5')}>
|
<button class="opt" onclick={() => pick('5')}>
|
||||||
<span class="opt-main"><strong>Daily Word</strong><span>5 letters · 6 guesses</span></span>
|
<span class="opt-main"><strong>Daily Word</strong><span>5 letters · 6 guesses</span></span>
|
||||||
<span class="opt-go" class:done={wordStatus['5']}>{wordOpt('5')}</span>
|
<span class="opt-go" class:done={wordStatus['5']}>{wordOpt('5')}</span>
|
||||||
@@ -198,20 +451,32 @@
|
|||||||
{:else if view === 'play'}
|
{:else if view === 'play'}
|
||||||
{#if game === 'word'}
|
{#if game === 'word'}
|
||||||
<WordGame {variant} onstatus={refreshStatus} />
|
<WordGame {variant} onstatus={refreshStatus} />
|
||||||
{:else}
|
{:else if game === 'wordsearch'}
|
||||||
<WordSearchGame size={wsSize} onstatus={refreshStatus} />
|
<WordSearchGame size={wsSize} onstatus={refreshStatus} />
|
||||||
|
{:else if game === 'bloom'}
|
||||||
|
{#key sp.get('v')}
|
||||||
|
<BloomGame mode={bloomMode} format={bloomFormat} onstatus={refreshStatus} />
|
||||||
|
{/key}
|
||||||
|
{:else if game === 'match'}
|
||||||
|
{#key sp.get('v')}
|
||||||
|
<MatchGame mode={matchMode} format={matchFormat} tier={matchTier} {date} onstatus={refreshStatus} />
|
||||||
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
/* in-game step-back, below the shared HubBar (matches the hub Back affordance) */
|
||||||
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
.gameback { color: var(--accent-deep); font-size: 0.9rem; font-weight: 600; display: inline-flex;
|
||||||
.logo { height: 40px; display: block; }
|
align-items: center; gap: 6px; background: none; border: none; font-family: inherit;
|
||||||
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px;
|
cursor: pointer; padding: 6px 10px 6px 0; margin-bottom: 4px;
|
||||||
background: none; border: none; font-family: inherit; cursor: pointer; }
|
-webkit-tap-highlight-color: transparent; }
|
||||||
.back svg { width: 17px; height: 17px; display: block; }
|
.gameback svg { width: 16px; height: 16px; display: block; transition: transform 0.15s ease; }
|
||||||
.page { padding: 22px 20px 70px; }
|
.gameback:hover { color: var(--accent); }
|
||||||
|
.gameback:hover svg { transform: translateX(-2px); }
|
||||||
|
.page { padding: 16px 20px 70px; }
|
||||||
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; }
|
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; }
|
||||||
.seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); }
|
.seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); }
|
||||||
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
|
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
|
||||||
@@ -219,20 +484,97 @@
|
|||||||
margin: 8px 0 24px; max-width: 460px; text-align: center; box-shadow: var(--shadow); }
|
margin: 8px 0 24px; max-width: 460px; text-align: center; box-shadow: var(--shadow); }
|
||||||
.tc-label { display: block; text-transform: uppercase; letter-spacing: 0.13em; font-size: 0.66rem;
|
.tc-label { display: block; text-transform: uppercase; letter-spacing: 0.13em; font-size: 0.66rem;
|
||||||
font-family: var(--label); font-weight: 600; color: var(--accent-deep); margin-bottom: 4px; }
|
font-family: var(--label); font-weight: 600; color: var(--accent-deep); margin-bottom: 4px; }
|
||||||
|
/* Memory Match selection: format toggle + grouped tier options */
|
||||||
|
.seg { display: flex; gap: 6px; background: var(--bg); border: 1px solid var(--line);
|
||||||
|
border-radius: 12px; padding: 4px; margin-bottom: 6px; }
|
||||||
|
.segbtn { flex: 1; padding: 9px 10px; border: none; border-radius: 9px; background: none;
|
||||||
|
font-family: inherit; font-size: 0.9rem; color: var(--muted); cursor: pointer; }
|
||||||
|
.segbtn.on { background: var(--surface); color: var(--accent-deep); font-weight: 600; box-shadow: var(--shadow); }
|
||||||
|
.grp { margin: 14px 0 2px; font-family: var(--label); text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
font-size: 0.7rem; color: var(--muted); }
|
||||||
.tc-name { font-family: var(--serif); font-size: 1.7rem; color: var(--accent-deep); line-height: 1.15; }
|
.tc-name { font-family: var(--serif); font-size: 1.7rem; color: var(--accent-deep); line-height: 1.15; }
|
||||||
|
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
|
/* ===== Arcade hub (CD "Play - Arcade"): colored game tiles, whole-card tap, no
|
||||||
.gamecard {
|
dashboard/streaks/XP — just games. Fonts match the rest of the hub. ===== */
|
||||||
display: flex; gap: 14px; align-items: center; text-align: left;
|
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 800; font-style: normal; font-display: swap; }
|
||||||
background: var(--surface); border: 1px solid var(--line); border-radius: 16px;
|
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
|
||||||
padding: 18px 20px; cursor: pointer; font: inherit; color: var(--ink);
|
@font-face { font-family: 'Space Mono'; src: url('/fonts/space-mono-latin.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
|
||||||
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
|
|
||||||
|
.arcade-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: clamp(16px, 3vw, 28px) 0 24px; }
|
||||||
|
.head-left { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; min-width: 0; }
|
||||||
|
.hub-back { flex: none; align-self: flex-start; }
|
||||||
|
.eyelash { width: 34px; height: 4px; border-radius: 2px; background: #f0a830; flex: none; }
|
||||||
|
.play-title { font-family: 'Newsreader', Georgia, serif; font-weight: 600; font-size: clamp(2.4rem, 6vw, 3.1rem); color: #e5882a; letter-spacing: -0.015em; line-height: 0.9; margin: 0; }
|
||||||
|
.head-div { width: 1px; height: 32px; background: rgba(120, 95, 50, 0.22); flex: none; }
|
||||||
|
.head-sub { color: var(--muted); font-size: 15px; line-height: 1.45; max-width: 340px; margin: 0; }
|
||||||
|
|
||||||
|
.arcade { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
||||||
|
.tile {
|
||||||
|
position: relative; min-width: 0; min-height: 244px; border-radius: 22px; overflow: hidden;
|
||||||
|
border: none; padding: 0; margin: 0; width: 100%; box-sizing: border-box; display: block;
|
||||||
|
cursor: pointer; text-align: left; color: inherit; font-family: 'Hanken Grotesk', sans-serif;
|
||||||
|
transition: transform 0.18s cubic-bezier(.34, 1.4, .5, 1), box-shadow 0.18s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
.gamecard:hover { border-color: var(--accent); transform: translateY(-1px); }
|
.tile:hover { transform: translateY(-5px); }
|
||||||
.gc-icon { font-size: 2rem; color: var(--accent); line-height: 1; flex-shrink: 0; }
|
.tile.soon { cursor: default; }
|
||||||
.gc-body h2 { font-size: 1.2rem; margin: 0 0 3px; }
|
.tile.soon:hover { transform: none; }
|
||||||
.gc-sub { color: var(--muted); font-size: 0.86rem; margin: 0 0 8px; }
|
@media (prefers-reduced-motion: reduce) { .tile { transition: none; } .tile:hover { transform: none; } }
|
||||||
.gc-status { font-size: 0.84rem; color: var(--accent-deep); font-weight: 600; margin: 0; }
|
.tile-word { background: #f0a830; box-shadow: 0 12px 26px -10px rgba(224, 148, 31, 0.55); }
|
||||||
|
.tile-ws { background: #2f7fb0; box-shadow: 0 12px 26px -10px rgba(47, 127, 176, 0.55); }
|
||||||
|
.tile-bloom { background: #5a9b63; box-shadow: 0 12px 26px -10px rgba(90, 155, 99, 0.55); }
|
||||||
|
.tile-match { background: #c0688c; box-shadow: 0 12px 26px -10px rgba(192, 104, 140, 0.55); }
|
||||||
|
.tile-bubble { background: linear-gradient(160deg, #6a6fc6, #4a4ea0); box-shadow: 0 12px 26px -10px rgba(74, 78, 160, 0.55); }
|
||||||
|
.tile-more { background: #efe6d4; border: 1.5px dashed #cdbb9a; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 24px; text-align: center; }
|
||||||
|
|
||||||
|
.foot { position: absolute; left: 0; right: 0; bottom: 0; padding: 30px 18px 15px; display: flex; flex-direction: column; gap: 3px; background: linear-gradient(to top, rgba(50, 33, 12, 0.62), transparent); }
|
||||||
|
.t-name { font-weight: 800; font-size: 19px; color: #fff; }
|
||||||
|
.t-clue { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.03em; color: rgba(255, 255, 255, 0.84); }
|
||||||
|
.tile-more .t-name { color: #6f6757; font-size: 16px; }
|
||||||
|
.tile-more .t-clue { color: #a89a7c; }
|
||||||
|
.more-plus { font-family: 'Newsreader', serif; font-size: 42px; line-height: 1; color: #cdbb9a; }
|
||||||
|
.ribbon { position: absolute; top: 14px; right: 14px; font-family: 'Space Mono', monospace; font-size: 9.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #fff; background: rgba(0, 0, 0, 0.3); padding: 5px 10px; border-radius: 12px; }
|
||||||
|
.devtag { margin-left: 8px; font-size: 0.58rem; font-family: 'Space Mono', monospace; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #fff; background: rgba(0, 0, 0, 0.32); border-radius: 5px; padding: 2px 6px; vertical-align: middle; }
|
||||||
|
|
||||||
|
/* tile motifs — decorative; clipped by the tile's overflow:hidden */
|
||||||
|
.motif { position: absolute; inset: 0; pointer-events: none; }
|
||||||
|
.m-word { padding: 18px 22px 66px; display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; align-content: center; }
|
||||||
|
.wt { aspect-ratio: 1; box-sizing: border-box; border: 2.5px solid rgba(255, 255, 255, 0.45); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: clamp(15px, 3.4vw, 22px); color: #cf7d1c; }
|
||||||
|
.wt.on { background: #fff; border-color: #fff; }
|
||||||
|
.m-ws { padding: 12px 16px 66px; display: grid; grid-template-columns: repeat(6, 29px); gap: 4px; justify-content: center; align-content: center; }
|
||||||
|
.wc { width: 29px; height: 29px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 12.5px; color: rgba(255, 255, 255, 0.42); }
|
||||||
|
.wc.hl { background: rgba(255, 255, 255, 0.93); color: #2f7fb0; border-radius: 7px; box-shadow: 0 2px 7px rgba(0, 0, 0, 0.12); }
|
||||||
|
/* Bloom is a circular medallion, so center it in the tile's visual field (~43%), lower than
|
||||||
|
the other top-illustration motifs — just a small padding-bottom to keep the lower petal
|
||||||
|
off the title/gradient. */
|
||||||
|
.m-bloom { display: flex; align-items: center; justify-content: center; padding-bottom: 32px; box-sizing: border-box; }
|
||||||
|
.bloom-ring { position: relative; width: 150px; height: 150px; }
|
||||||
|
.m-bloom .petal { position: absolute; width: 42px; height: 42px; border-radius: 50%; background: #fff; color: #4f8a58; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 18px; box-shadow: 0 3px 7px rgba(0, 0, 0, 0.1); }
|
||||||
|
.m-bloom .pc { width: 50px; height: 50px; background: #3e7a47; color: #fff; font-size: 22px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); z-index: 1; left: 50%; top: 50%; transform: translate(-50%, -50%); }
|
||||||
|
.m-bloom .p1 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(0, -56px); }
|
||||||
|
.m-bloom .p2 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(48px, -28px); }
|
||||||
|
.m-bloom .p3 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(48px, 28px); }
|
||||||
|
.m-bloom .p4 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(0, 56px); }
|
||||||
|
.m-bloom .p5 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(-48px, 28px); }
|
||||||
|
.m-bloom .p6 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(-48px, -28px); }
|
||||||
|
.m-match { padding: 18px 22px 66px; display: grid; grid-template-columns: repeat(4, 50px); gap: 10px; justify-content: center; align-content: center; }
|
||||||
|
.mm { width: 50px; height: 50px; border-radius: 9px; background: rgba(255, 255, 255, 0.2); display: flex; align-items: center; justify-content: center; }
|
||||||
|
.mm.flip { background: #fff; }
|
||||||
|
.mm .dot { width: 17px; height: 17px; border-radius: 50%; background: #c0688c; }
|
||||||
|
/* bubble shooter: a packed cluster up top, a dashed aim line, a loaded shooter below */
|
||||||
|
.bub { position: absolute; width: 34px; height: 34px; border-radius: 50%; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.16); }
|
||||||
|
/* shooter sits to the RIGHT (clear of the title); a diagonal dashed aim line runs up-left
|
||||||
|
to the matching blue bubble at the cluster's center, like a shot being lined up */
|
||||||
|
.bub-aim { position: absolute; left: 62%; bottom: 74px; width: 2px; height: 96px; transform-origin: bottom center; transform: translateX(-50%) rotate(-33deg);
|
||||||
|
background: repeating-linear-gradient(to top, rgba(255, 255, 255, 0.65) 0 5px, transparent 5px 11px); }
|
||||||
|
.bub-shooter { position: absolute; left: 62%; bottom: 46px; transform: translateX(-50%); width: 36px; height: 36px; border-radius: 50%; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24); }
|
||||||
|
.b-a { background: radial-gradient(circle at 32% 28%, #fff, #f0a830 62%); }
|
||||||
|
.b-b { background: radial-gradient(circle at 32% 28%, #fff, #e8607f 62%); }
|
||||||
|
.b-c { background: radial-gradient(circle at 32% 28%, #fff, #4fb0e0 62%); }
|
||||||
|
.b-d { background: radial-gradient(circle at 32% 28%, #fff, #6ad0a8 62%); }
|
||||||
|
|
||||||
|
@media (max-width: 880px) { .arcade { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 560px) { .arcade { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
/* Game-selection options */
|
/* Game-selection options */
|
||||||
.opts { display: flex; flex-direction: column; gap: 12px; max-width: 460px; }
|
.opts { display: flex; flex-direction: column; gap: 12px; max-width: 460px; }
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import HubShell from '$lib/components/HubShell.svelte';
|
||||||
|
|
||||||
|
let q = $state(null);
|
||||||
|
let state = $state('loading');
|
||||||
|
|
||||||
|
const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
|
||||||
|
let seal = $derived.by(() => {
|
||||||
|
if (!q?.date) return null;
|
||||||
|
const [, m, d] = q.date.split('-').map(Number);
|
||||||
|
return MONTHS[m - 1] ? { mon: MONTHS[m - 1], day: d } : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// one attributed string for both Copy and Share, so native sharing keeps the author too
|
||||||
|
let attributed = $derived(q ? `“${q.text}”${q.author ? ` — ${q.author}${q.work ? ', ' + q.work : ''}` : ''}` : '');
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
async function copyQuote() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(attributed);
|
||||||
|
copied = true; setTimeout(() => (copied = false), 1800);
|
||||||
|
} catch { /* no clipboard — silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let shared = $state(false);
|
||||||
|
async function share() {
|
||||||
|
const url = location.href;
|
||||||
|
try {
|
||||||
|
if (navigator.share) { await navigator.share({ title: 'Quote of the Day · upbeatBytes', text: attributed, url }); return; }
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
shared = true; setTimeout(() => (shared = false), 1800);
|
||||||
|
} catch { /* cancelled / unsupported */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
q = await getJSON('/api/quote/today');
|
||||||
|
state = q ? 'ready' : 'empty';
|
||||||
|
} catch {
|
||||||
|
state = 'empty';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Quote of the Day · upbeatBytes</title>
|
||||||
|
<meta name="description" content="A hopeful quote every day — with its source and a plain-language take on what it means." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<HubShell active="">
|
||||||
|
<article class="quote-page">
|
||||||
|
{#if state === 'ready'}
|
||||||
|
<!-- CD's "letter" treatment: warm card, dashed deckle frame, wax-seal date, faded quote-mark watermark -->
|
||||||
|
<div class="letter">
|
||||||
|
{#if seal}
|
||||||
|
<div class="seal" aria-hidden="true">
|
||||||
|
<span class="seal-mon">{seal.mon}</span>
|
||||||
|
<span class="seal-day">{seal.day}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="deckle">
|
||||||
|
<span class="wm" aria-hidden="true">”</span>
|
||||||
|
|
||||||
|
<div class="eyebrow">
|
||||||
|
<span class="eye-rule"></span>
|
||||||
|
<span class="eye-label">Quote of the Day</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="quote">{q.text}</p>
|
||||||
|
|
||||||
|
{#if q.author}
|
||||||
|
<div class="attrib">
|
||||||
|
<span class="at-rule"></span>
|
||||||
|
<span class="at-by">{q.author}{#if q.work}, <span class="work">{q.work}</span>{/if}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if q.meaning}
|
||||||
|
<div class="hr"></div>
|
||||||
|
<div class="means-head">
|
||||||
|
<span class="mh-rule"></span>
|
||||||
|
<span class="mh-label">What it means</span>
|
||||||
|
</div>
|
||||||
|
<p class="meaning">{q.meaning}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="act act-copy" onclick={copyQuote}>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
<button class="act act-share" onclick={share}>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/></svg>
|
||||||
|
{shared ? 'Link copied' : 'Share'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if state === 'empty'}
|
||||||
|
<p class="note">Today's quote is on its way. Check back soon.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="note">Finding today's quote…</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
</HubShell>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-var.woff2') format('woff2'); font-weight: 500 700; font-style: normal; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-italic-var.woff2') format('woff2'); font-weight: 500 700; font-style: italic; font-display: swap; }
|
||||||
|
|
||||||
|
.quote-page { max-width: 880px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* the card + its inner deckle frame */
|
||||||
|
.letter {
|
||||||
|
position: relative; margin-top: clamp(24px, 4vw, 40px); background: #f6ead0;
|
||||||
|
border: 1px solid rgba(120, 90, 40, 0.10); border-radius: 16px;
|
||||||
|
box-shadow: 0 26px 50px -32px rgba(90, 60, 30, 0.6); padding: clamp(12px, 1.6vw, 18px);
|
||||||
|
}
|
||||||
|
.deckle {
|
||||||
|
position: relative; border: 1.5px dashed rgba(170, 110, 135, 0.4); border-radius: 11px;
|
||||||
|
padding: clamp(28px, 5vw, 40px) clamp(24px, 6vw, 56px) clamp(36px, 6vw, 54px); overflow: hidden;
|
||||||
|
}
|
||||||
|
/* giant faded closing-quote watermark, bleeding off the bottom-right */
|
||||||
|
.wm {
|
||||||
|
position: absolute; right: clamp(-20px, -2vw, -30px); bottom: clamp(-70px, -12vw, -120px);
|
||||||
|
font-family: 'Playfair Display', Georgia, serif; font-size: clamp(220px, 46vw, 400px); line-height: 1;
|
||||||
|
color: rgba(170, 110, 135, 0.08); pointer-events: none; user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* wax-seal date stamp, overlapping the top-right corner */
|
||||||
|
.seal {
|
||||||
|
position: absolute; top: -26px; right: clamp(20px, 6vw, 52px); z-index: 2;
|
||||||
|
width: clamp(62px, 9vw, 74px); height: clamp(62px, 9vw, 74px); border-radius: 50%;
|
||||||
|
background: #fbf3e3; border: 1.5px solid rgba(170, 110, 135, 0.55);
|
||||||
|
box-shadow: 0 8px 20px -8px rgba(90, 60, 30, 0.5); transform: rotate(-8deg);
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; color: #a4607a;
|
||||||
|
}
|
||||||
|
.seal-mon { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.18em; }
|
||||||
|
.seal-day { font-family: 'Playfair Display', Georgia, serif; font-size: clamp(28px, 4vw, 33px); font-weight: 700; line-height: 0.82; }
|
||||||
|
|
||||||
|
.eyebrow { position: relative; display: flex; align-items: center; gap: 14px; }
|
||||||
|
.eye-rule { width: 34px; height: 3px; background: #8f5e72; border-radius: 2px; flex: none; }
|
||||||
|
.eye-label { font-family: 'Hanken Grotesk', sans-serif; font-size: clamp(17px, 2.4vw, 22px); font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #7e4f63; }
|
||||||
|
|
||||||
|
.quote {
|
||||||
|
position: relative; font-family: 'Playfair Display', Georgia, serif; font-style: italic; font-weight: 600;
|
||||||
|
font-size: clamp(1.7rem, 5vw, 2.85rem); line-height: 1.18; letter-spacing: -0.01em; color: #3c2530;
|
||||||
|
margin: clamp(18px, 3vw, 24px) 0 0; max-width: 34ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attrib { position: relative; display: flex; align-items: center; gap: 14px; margin-top: clamp(20px, 3vw, 26px); }
|
||||||
|
.at-rule { width: 28px; height: 1.5px; background: #c79bac; flex: none; }
|
||||||
|
.at-by { font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.6vw, 1.13rem); color: #8b596d; }
|
||||||
|
.work { font-style: italic; }
|
||||||
|
|
||||||
|
.hr { position: relative; height: 1px; background: rgba(120, 90, 40, 0.16); margin: clamp(28px, 4vw, 38px) 0 24px; }
|
||||||
|
.means-head { position: relative; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.mh-rule { width: 26px; height: 2px; background: #bcae93; border-radius: 2px; flex: none; }
|
||||||
|
.mh-label { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #74633f; }
|
||||||
|
.meaning {
|
||||||
|
position: relative; font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.8vw, 1.13rem);
|
||||||
|
line-height: 1.65; color: #5c5249; margin: 12px 0 0; max-width: 60ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions { position: relative; display: flex; gap: 12px; margin-top: clamp(26px, 4vw, 32px); flex-wrap: wrap; }
|
||||||
|
.act {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px; font-family: 'Hanken Grotesk', sans-serif;
|
||||||
|
font-size: 14px; font-weight: 600; padding: 10px 18px; border-radius: 999px; cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
/* AA contrast: white on #8b596d passes; rose text uses the accessible secondary */
|
||||||
|
.act-copy { background: #8b596d; color: #fff; border: none; }
|
||||||
|
.act-copy:hover { background: #774a5c; }
|
||||||
|
.act-share { background: transparent; color: #8b596d; border: 1px solid #d3b3c0; }
|
||||||
|
.act-share:hover { border-color: #8b596d; color: #774a5c; }
|
||||||
|
|
||||||
|
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
|
||||||
|
|
||||||
|
/* narrow phones: keep "QUOTE OF THE DAY" + its eyelash from crowding/clipping */
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.deckle { padding-left: clamp(18px, 6vw, 24px); padding-right: clamp(18px, 6vw, 24px); }
|
||||||
|
.eyebrow { gap: 9px; }
|
||||||
|
.eye-rule { width: 24px; }
|
||||||
|
.eye-label { letter-spacing: 0.08em; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import HubShell from '$lib/components/HubShell.svelte';
|
||||||
|
|
||||||
|
let w = $state(null);
|
||||||
|
let state = $state('loading'); // loading | ready | empty
|
||||||
|
|
||||||
|
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '');
|
||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
let dateLabel = $derived.by(() => {
|
||||||
|
if (!w?.date) return '';
|
||||||
|
const [, m, d] = w.date.split('-').map(Number);
|
||||||
|
return MONTHS[m - 1] ? `${MONTHS[m - 1]} ${d}` : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
w = await getJSON('/api/word/today');
|
||||||
|
state = w ? 'ready' : 'empty';
|
||||||
|
} catch {
|
||||||
|
state = 'empty';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function speak() {
|
||||||
|
if (w?.audio_url) {
|
||||||
|
const a = new Audio(w.audio_url);
|
||||||
|
a.play().catch(ttsFallback);
|
||||||
|
} else {
|
||||||
|
ttsFallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function ttsFallback() {
|
||||||
|
try {
|
||||||
|
const u = new SpeechSynthesisUtterance(w.word);
|
||||||
|
u.rate = 0.9;
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
speechSynthesis.speak(u);
|
||||||
|
} catch { /* no speech support — silent */ }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{w ? cap(w.word) : 'Word of the Day'} · upbeatBytes</title>
|
||||||
|
<meta name="description" content="A new uplifting word every day — with pronunciation, meaning, and how to use it." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<HubShell active="">
|
||||||
|
<article class="word-page">
|
||||||
|
{#if state === 'ready'}
|
||||||
|
<!-- "Editorial Asymmetric" (CD): faded oversized initial, vertical part-of-speech rail,
|
||||||
|
big serif word, airy definition, left-ruled example sentences. -->
|
||||||
|
<div class="wcard">
|
||||||
|
<span class="wm" aria-hidden="true">{cap(w.word)[0]}</span>
|
||||||
|
|
||||||
|
<div class="wtop">
|
||||||
|
<span class="eyebrow">Word of the day</span>
|
||||||
|
{#if dateLabel}<span class="edition">{dateLabel}</span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="whead">
|
||||||
|
{#if w.part_of_speech}<span class="vpos">{w.part_of_speech}</span>{/if}
|
||||||
|
<div class="whead-main">
|
||||||
|
<h1 class="word">{cap(w.word)}</h1>
|
||||||
|
<div class="pron-row">
|
||||||
|
{#if w.phonetic}<span class="pron">{w.phonetic}</span>{/if}
|
||||||
|
<button class="listen" onclick={speak} aria-label="Hear it spoken">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M11 5 6 9H3v6h3l5 4z" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /><path d="M18.5 6a8 8 0 0 1 0 12" />
|
||||||
|
</svg>
|
||||||
|
Listen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="definition">{w.definition}</p>
|
||||||
|
|
||||||
|
{#if w.examples?.length}
|
||||||
|
<section class="examples">
|
||||||
|
<div class="ex-label">In a sentence</div>
|
||||||
|
{#each w.examples as ex}<p class="ex">{ex}</p>{/each}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if state === 'empty'}
|
||||||
|
<p class="note">Today's word is on its way. Check back soon.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="note">Finding today's word…</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
</HubShell>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.word-page { max-width: 760px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.wcard {
|
||||||
|
position: relative; overflow: hidden; background: #FBF2E1; border: 1px solid #efe2c6;
|
||||||
|
border-radius: 18px; padding: clamp(32px, 6vw, 56px) clamp(26px, 5vw, 56px);
|
||||||
|
box-shadow: 0 18px 50px -34px rgba(80, 60, 25, 0.55);
|
||||||
|
}
|
||||||
|
/* faded oversized initial bleeding off the right edge */
|
||||||
|
/* sits JUST below the date in the top-right corner — clear it, don't drop far beneath */
|
||||||
|
.wm {
|
||||||
|
position: absolute; right: clamp(-60px, -4vw, -28px); top: clamp(45px, 7vw, 62px);
|
||||||
|
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-weight: 400;
|
||||||
|
font-size: clamp(220px, 42vw, 380px); line-height: 1; color: rgba(62, 110, 151, 0.06);
|
||||||
|
pointer-events: none; user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wtop { position: relative; display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
||||||
|
.eyebrow { font-size: 12px; font-weight: 700; letter-spacing: 0.22em; text-transform: uppercase; color: #3E6E97; }
|
||||||
|
.edition { font-family: 'Newsreader', Georgia, serif; font-size: 15px; color: #a99a80; white-space: nowrap; }
|
||||||
|
|
||||||
|
.whead { position: relative; display: flex; align-items: flex-start; gap: clamp(14px, 2.5vw, 22px); margin-top: clamp(28px, 5vw, 46px); }
|
||||||
|
.vpos {
|
||||||
|
writing-mode: vertical-rl; transform: rotate(180deg); flex: none; padding-top: 6px;
|
||||||
|
font-size: 13px; font-weight: 700; letter-spacing: 0.3em; text-transform: uppercase; color: #5f86a6;
|
||||||
|
}
|
||||||
|
.whead-main { min-width: 0; }
|
||||||
|
.word {
|
||||||
|
font-family: 'Newsreader', Georgia, serif; font-weight: 400; letter-spacing: -0.02em;
|
||||||
|
font-size: clamp(3.4rem, 13vw, 108px); line-height: 0.92; color: #1c1a16; margin: 0; overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.pron-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; margin-top: 18px; }
|
||||||
|
.pron { font-family: 'Newsreader', Georgia, serif; font-size: 23px; color: #8a8478; }
|
||||||
|
/* Always filled blue — a little warmth of colour on a laid-back page. Hover is gated to
|
||||||
|
true pointer devices so it can't "stick" lit after a tap on a phone, and the tap
|
||||||
|
highlight box is suppressed (that grey/blue flash mobile draws on tap). */
|
||||||
|
.listen {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font-family: inherit;
|
||||||
|
font-size: 0.82rem; font-weight: 600; letter-spacing: 0.02em; color: #fdf7ec;
|
||||||
|
background: #3E6E97; border: 1px solid #3E6E97; border-radius: 999px; padding: 7px 15px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none;
|
||||||
|
}
|
||||||
|
@media (hover: hover) { .listen:hover { background: #34608a; border-color: #34608a; } }
|
||||||
|
.listen:active { background: #2c557d; border-color: #2c557d; }
|
||||||
|
|
||||||
|
.definition {
|
||||||
|
position: relative; font-family: 'Newsreader', Georgia, serif; font-weight: 400;
|
||||||
|
font-size: clamp(1.4rem, 4vw, 1.95rem); line-height: 1.32; color: #2c3640;
|
||||||
|
margin: clamp(30px, 5vw, 44px) 0 0; max-width: 30em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.examples { position: relative; margin-top: clamp(28px, 5vw, 40px); max-width: 34em; }
|
||||||
|
.ex-label { font-size: 11px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #b3a487; margin-bottom: 16px; }
|
||||||
|
.ex {
|
||||||
|
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 1.08rem; line-height: 1.5;
|
||||||
|
color: #4a4439; padding-left: 16px; border-left: 2px solid #9bb6cf; margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.ex:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
|
||||||
|
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
|
||||||
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
|
||||||
|
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);
|
||||||
|
let stat = $state(null); // live controller readout (mode / speed / pos / turn)
|
||||||
|
let statTimer;
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
let h;
|
||||||
|
let cancelled = false; // guard the async load against an early unmount
|
||||||
|
(async () => {
|
||||||
|
// Don't decide the gate until we actually KNOW who's here. A cold load / hard
|
||||||
|
// refresh / direct link mounts before auth has revalidated, so auth.user is still
|
||||||
|
// null and an admin would be wrongly bounced. Revalidate first, THEN gate.
|
||||||
|
if (!auth.ready) { try { await refreshAuth(); } catch { /* offline → treat as gated */ } }
|
||||||
|
if (cancelled) return;
|
||||||
|
// 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';
|
||||||
|
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();
|
||||||
|
statTimer = setInterval(() => {
|
||||||
|
const s = h.swimmer?.state;
|
||||||
|
if (s) stat = { mode: s.mode, clip: s.clip, speed: s.speed, turn: s.turnRate, x: s.pos.x, y: s.pos.y, z: s.pos.z };
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Zen Den could not start:', e);
|
||||||
|
if (!cancelled) { failed = true; loading = false; }
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; clearInterval(statTimer); h?.dispose(); };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>The Zen Den · upbeatBytes</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="upbeatBytes" /></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>preview clip
|
||||||
|
<select bind:value={dbg.preview} onchange={apply}>
|
||||||
|
<option value="">— live behavior —</option>
|
||||||
|
<option>idle</option><option>cruise</option><option>burst</option><option>turnL</option><option>turnR</option>
|
||||||
|
</select></label>
|
||||||
|
|
||||||
|
<label>scale <span>{dbg.scale.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0.3" max="2.5" step="0.05" bind:value={dbg.scale} oninput={apply} /></label>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<div class="ph">Behavior</div>
|
||||||
|
{#if stat}
|
||||||
|
<div class="readout">{stat.mode} · {stat.clip} · spd {stat.speed.toFixed(2)} · turn {stat.turn.toFixed(2)}<br />pos {stat.x.toFixed(2)}, {stat.y.toFixed(2)}, {stat.z.toFixed(2)}</div>
|
||||||
|
{/if}
|
||||||
|
<label>cruise speed <span>{dbg.cruiseSpeed.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0.15" max="1.2" step="0.02" bind:value={dbg.cruiseSpeed} oninput={apply} /></label>
|
||||||
|
<label>roam width <span>{dbg.boundsX.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0.4" max="2.4" step="0.05" bind:value={dbg.boundsX} oninput={apply} /></label>
|
||||||
|
<label>roam height <span>{dbg.boundsY.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0.1" max="1.4" step="0.05" bind:value={dbg.boundsY} oninput={apply} /></label>
|
||||||
|
<label>roam depth <span>{dbg.boundsZ.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0" max="1.4" step="0.05" bind:value={dbg.boundsZ} oninput={apply} /></label>
|
||||||
|
<label>liveliness <span>{dbg.liveliness.toFixed(2)}</span>
|
||||||
|
<input type="range" min="0" max="2" step="0.1" bind:value={dbg.liveliness} oninput={apply} /></label>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<div class="ph">Fins & tail</div>
|
||||||
|
<label class="chk"><input type="checkbox" bind:checked={dbg.finTranslucent} onchange={apply} /> translucent (off = opaque, coherent)</label>
|
||||||
|
<label>side
|
||||||
|
<select bind:value={dbg.finSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
|
||||||
|
<label>alphaTest <span>{dbg.finAlphaTest.toFixed(3)}</span>
|
||||||
|
<input type="range" min="0" max="0.9" step="0.01" bind:value={dbg.finAlphaTest} oninput={apply} /></label>
|
||||||
|
{#if dbg.finTranslucent}
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<label class="chk"><input type="checkbox" bind:checked={dbg.paused} onchange={apply} /> freeze</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
|
||||||
|
<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; }
|
||||||
|
.readout { font-family: var(--label); font-size: 0.72rem; line-height: 1.5; color: var(--accent-deep);
|
||||||
|
background: rgba(136, 87, 194, 0.08); border-radius: 6px; padding: 5px 8px; margin: 4px 0 8px;
|
||||||
|
font-variant-numeric: tabular-nums; }
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
manrope-var.woff2 — "Manrope" variable font by Mikhail Sharanda / Mirko Velimirovic.
|
||||||
|
License: SIL Open Font License 1.1 (free to use/bundle, no attribution required in product UI).
|
||||||
|
Source: https://github.com/sharanda/manrope (via fontsource).
|
||||||
|
|
||||||
|
hanken-var.woff2 — "Hanken Grotesk" by Alfredo Marco Pradil. License: SIL Open Font License 1.1.
|
||||||
|
newsreader-var.woff2 / newsreader-italic-var.woff2 — "Newsreader" by Production Type (Google Fonts).
|
||||||
|
License: SIL Open Font License 1.1. All self-hosted (no Google hotlink) for the /home3 design direction.
|
||||||
|
|
||||||
|
Work Sans — Wei Huang, OFL 1.1 (Google Fonts). Latin subset, variable 400–700.
|
||||||
|
|
||||||
|
Space Mono — Colophon Foundry, OFL 1.1 (Google Fonts). Latin subset, 400.
|
||||||
|
|
||||||
|
Playfair Display — Claus Eggers Sørensen, OFL 1.1 (Google Fonts). Latin subset, var 500–700 + italic. (QOTD)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Upbeat Bytes",
|
"name": "upbeatBytes",
|
||||||
"short_name": "Upbeat Bytes",
|
"short_name": "upbeatBytes",
|
||||||
"description": "Calm, constructive news worth your attention — and nothing that isn't.",
|
"description": "A calmer, brighter corner of the internet: good news, daily art, small games, and little resets.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
|||||||
|
wood-grain.jpg — "Fine Grained Wood" by Rob Tuytel, Poly Haven (https://polyhaven.com/a/fine_grained_wood)
|
||||||
|
License: CC0 (Public Domain). Free to use, no attribution required. Recolored in CSS per frame species.
|
||||||
|
wood-grain-h.jpg — the same texture rotated 90° (horizontal grain), for the top/bottom rails so the grain
|
||||||
|
turns at each mitered corner. Same CC0 source/license.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 328 KiB |
+873
-55
File diff suppressed because it is too large
Load Diff
+276
@@ -0,0 +1,276 @@
|
|||||||
|
"""Daily Art — the /art room. One gorgeous public-domain masterwork a day, picked from
|
||||||
|
a curated pool of museum highlights and cached to OUR origin (image + metadata), so the
|
||||||
|
homepage never waits on, nor hotlinks, the museum.
|
||||||
|
|
||||||
|
Source: The Met Collection API (no key; public-domain works are CC0 — free, unrestricted,
|
||||||
|
caching encouraged). Curation = isHighlight + isPublicDomain + hasImages, so the pool is
|
||||||
|
masterworks, never potsherds. Built to be bulletproof: a failed pick falls through to the
|
||||||
|
next candidate, and a failed day keeps yesterday's piece — the room is never empty.
|
||||||
|
|
||||||
|
Network calls go through module-level _http_* helpers so tests can monkeypatch them.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .localtime import local_today
|
||||||
|
|
||||||
|
MET_BASE = "https://collectionapi.metmuseum.org/public/collection/v1"
|
||||||
|
# Broad, visual, museum-grade terms. Each is filtered to public-domain highlights with
|
||||||
|
# images, then deduped — a diverse pool of a few thousand masterworks.
|
||||||
|
HARVEST_QUERIES = ("painting", "portrait", "landscape", "still life", "flowers",
|
||||||
|
"sculpture", "drawing", "garden", "river", "sunset")
|
||||||
|
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
|
||||||
|
_PICK_ATTEMPTS = 8 # candidates to try before giving up for the day
|
||||||
|
_NO_REPEAT_POOL = 40 # pick the daily piece from the N least-recently-shown
|
||||||
|
_MIN_IMAGE_BYTES = 3000 # smaller than this = not a real image
|
||||||
|
|
||||||
|
|
||||||
|
def _http_json(url: str, timeout: int = 20) -> dict:
|
||||||
|
req = urllib.request.Request(url, headers=_UA)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _http_bytes(url: str, timeout: int = 30) -> tuple[bytes, str]:
|
||||||
|
req = urllib.request.Request(url, headers=_UA)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return r.read(), (r.headers.get("Content-Type") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def cache_dir() -> Path:
|
||||||
|
"""Where cached images live — beside the DB, so the host cycle writes and the API
|
||||||
|
container reads the same mounted volume."""
|
||||||
|
override = os.environ.get("GOODNEWS_ART_CACHE")
|
||||||
|
if override:
|
||||||
|
d = Path(override)
|
||||||
|
else:
|
||||||
|
db = Path(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
|
||||||
|
d = db.parent / "art_cache"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# --- harvest: build the curated pool of object IDs -------------------------------
|
||||||
|
|
||||||
|
def harvest_pool(conn: sqlite3.Connection, queries=HARVEST_QUERIES, source: str = "met") -> dict:
|
||||||
|
"""Query the Met for public-domain highlight images across broad art terms; store the
|
||||||
|
deduped object IDs. Cheap: each search returns all matching IDs in one call. Per-query
|
||||||
|
failure is non-fatal."""
|
||||||
|
found, errors = set(), 0
|
||||||
|
for q in queries:
|
||||||
|
url = (f"{MET_BASE}/search?isHighlight=true&hasImages=true&isPublicDomain=true"
|
||||||
|
f"&q={urllib.request.quote(q)}")
|
||||||
|
try:
|
||||||
|
data = _http_json(url)
|
||||||
|
for oid in (data.get("objectIDs") or []):
|
||||||
|
if isinstance(oid, int):
|
||||||
|
found.add(oid)
|
||||||
|
except Exception: # noqa: BLE001 — non-fatal per query
|
||||||
|
errors += 1
|
||||||
|
before = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO art_pool (source, object_id) VALUES (?, ?)",
|
||||||
|
[(source, oid) for oid in found],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
after = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
|
||||||
|
return {"queried": len(queries), "errors": errors, "found": len(found),
|
||||||
|
"added": after - before, "pool": after}
|
||||||
|
|
||||||
|
|
||||||
|
# --- daily pick: choose, fetch, cache --------------------------------------------
|
||||||
|
|
||||||
|
def _object(object_id: int) -> dict:
|
||||||
|
return _http_json(f"{MET_BASE}/objects/{object_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_to_cache(url: str | None, stem: str) -> str | None:
|
||||||
|
"""Download one image URL to cache as {stem}.{ext}; return the filename or None.
|
||||||
|
Writes to a temp file then atomically renames, so a reader never sees a half-file."""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data, ctype = _http_bytes(url)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
if not ctype.startswith("image/") or len(data) < _MIN_IMAGE_BYTES:
|
||||||
|
return None
|
||||||
|
ext = ".png" if "png" in ctype else ".jpg"
|
||||||
|
fname = f"{stem}{ext}"
|
||||||
|
cdir = cache_dir()
|
||||||
|
tmp = cdir / f".{stem}.tmp"
|
||||||
|
try:
|
||||||
|
tmp.write_bytes(data)
|
||||||
|
os.replace(tmp, cdir / fname) # atomic
|
||||||
|
except OSError:
|
||||||
|
try:
|
||||||
|
tmp.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
return fname
|
||||||
|
|
||||||
|
|
||||||
|
def _download_image(obj: dict, object_id: int) -> str | None:
|
||||||
|
"""Cache the day's images to our origin. Stores the web-large display copy as
|
||||||
|
{id}.{ext} (what the page shows) and, when available, the full-resolution copy as
|
||||||
|
{id}-full.{ext} (what the lightbox opens, so zoom fills the screen). Returns the
|
||||||
|
display filename or None if even the display copy couldn't be fetched."""
|
||||||
|
display = _fetch_to_cache(obj.get("primaryImageSmall") or obj.get("primaryImage"), str(object_id))
|
||||||
|
if not display:
|
||||||
|
return None
|
||||||
|
full_url = obj.get("primaryImage")
|
||||||
|
if full_url and full_url != obj.get("primaryImageSmall"):
|
||||||
|
_fetch_to_cache(full_url, f"{object_id}-full") # best-effort hi-res for zoom
|
||||||
|
return display
|
||||||
|
|
||||||
|
|
||||||
|
def _palette(image_path: "Path", n: int = 5) -> list[str]:
|
||||||
|
"""Extract ~n representative hex colors from the cached image (for the 'colors in this
|
||||||
|
piece' strip). Best-effort: any failure → empty list (the strip just hides)."""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(image_path) as im:
|
||||||
|
im = im.convert("RGB")
|
||||||
|
im.thumbnail((120, 120)) # tiny — palette, not fidelity
|
||||||
|
# Adaptive median-cut to a small palette, then order by how much of the image each covers.
|
||||||
|
q = im.quantize(colors=max(n * 2, 8), method=Image.Quantize.MEDIANCUT)
|
||||||
|
pal = q.getpalette()
|
||||||
|
counts = sorted(q.getcolors(), reverse=True) # [(count, index), ...] most-used first
|
||||||
|
out, seen = [], set()
|
||||||
|
for _count, idx in counts:
|
||||||
|
r, g, b = pal[idx * 3], pal[idx * 3 + 1], pal[idx * 3 + 2]
|
||||||
|
hexc = f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
if hexc in seen:
|
||||||
|
continue
|
||||||
|
seen.add(hexc)
|
||||||
|
out.append(hexc)
|
||||||
|
if len(out) >= n:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
except Exception: # noqa: BLE001 — palette is decorative; never break the pick
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
_BLURB_SYSTEM = (
|
||||||
|
"You are the calm, knowledgeable curator of a daily-art feature for a general audience — "
|
||||||
|
"people who enjoy a beautiful painting but aren't art historians. In 2 to 3 warm, plain "
|
||||||
|
"sentences, help them appreciate the piece and why it's worth a moment: its mood, the "
|
||||||
|
"artist, the era or movement, and a little real context or significance.\n"
|
||||||
|
"GROUNDING (important): the catalogue details below — especially the title and the "
|
||||||
|
"'Depicts' tags — are your only reliable guide to the SUBJECT. You cannot actually see the "
|
||||||
|
"image, so do NOT assert literal visual specifics you can't verify: do not state how many "
|
||||||
|
"figures are shown, their exact poses or actions, colors, or background details. Lean on "
|
||||||
|
"what's certain (title, tags, medium, date, artist, movement) and on feeling/significance. "
|
||||||
|
"If you don't recognize the exact work, stay general and contextual rather than inventing. "
|
||||||
|
"No preamble, no title repetition, no hype, no markdown — just the note."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _blurb(client, obj: dict) -> str | None:
|
||||||
|
"""A short 'museum guide' note for the piece, grounded in the Met catalogue metadata.
|
||||||
|
Best-effort + cached by the caller; returns None on any trouble."""
|
||||||
|
tags = ", ".join(t.get("term", "") for t in (obj.get("tags") or []) if t.get("term"))[:200]
|
||||||
|
facts = "\n".join(f"{k}: {v}" for k, v in (
|
||||||
|
("Title", obj.get("title")), ("Artist", obj.get("artistDisplayName")),
|
||||||
|
("Artist bio", obj.get("artistDisplayBio")), ("Date", obj.get("objectDate")),
|
||||||
|
("Medium", obj.get("medium")), ("Type", obj.get("objectName")),
|
||||||
|
("Classification", obj.get("classification")), ("Culture", obj.get("culture")),
|
||||||
|
("Period", obj.get("period")), ("Depicts", tags),
|
||||||
|
) if v)
|
||||||
|
user = f"Catalogue details:\n{facts}\n\nWrite the note."
|
||||||
|
try:
|
||||||
|
out = client.chat_text([{"role": "system", "content": _BLURB_SYSTEM},
|
||||||
|
{"role": "user", "content": user}]) or ""
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
out = " ".join(out.replace("*", "").replace("_", " ").split()).strip()[:600] # no stray markdown
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates(conn: sqlite3.Connection, art_date: str, source: str) -> list[int]:
|
||||||
|
"""The N least-recently-shown pool IDs, rotated deterministically by the date so the
|
||||||
|
same piece shows for everyone that day and pieces don't repeat soon."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT object_id FROM art_pool WHERE source=? AND blocked=0 "
|
||||||
|
"ORDER BY shown_at IS NOT NULL, shown_at, object_id LIMIT ?",
|
||||||
|
(source, _NO_REPEAT_POOL),
|
||||||
|
).fetchall()
|
||||||
|
ids = [r[0] for r in rows]
|
||||||
|
if not ids:
|
||||||
|
return ids
|
||||||
|
seed = int(hashlib.sha256(art_date.encode()).hexdigest(), 16) % len(ids)
|
||||||
|
return ids[seed:] + ids[:seed] # rotate so the daily choice is stable but varies
|
||||||
|
|
||||||
|
|
||||||
|
def pick_daily(conn: sqlite3.Connection, art_date: str | None = None, source: str = "met",
|
||||||
|
force: bool = False, client=None) -> dict | None:
|
||||||
|
"""Pick + cache the day's art. Idempotent (skips if today's already done unless force).
|
||||||
|
Tries successive candidates so a bad object/image never breaks the day; returns the
|
||||||
|
stored row, or None if nothing could be fetched (caller keeps the prior day's piece)."""
|
||||||
|
art_date = art_date or local_today()
|
||||||
|
existing = conn.execute("SELECT * FROM daily_art WHERE art_date=?", (art_date,)).fetchone()
|
||||||
|
if existing and not force:
|
||||||
|
return dict(existing)
|
||||||
|
for oid in _candidates(conn, art_date, source):
|
||||||
|
try:
|
||||||
|
obj = _object(oid)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
if not obj.get("isPublicDomain"):
|
||||||
|
continue
|
||||||
|
fname = _download_image(obj, oid)
|
||||||
|
if not fname:
|
||||||
|
continue
|
||||||
|
# All network/LLM/compute is done up front; only then a brief write txn + commit.
|
||||||
|
palette = json.dumps(_palette(cache_dir() / fname)) or None
|
||||||
|
blurb = _blurb(client, obj) if client else None
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO daily_art (art_date, source, object_id, title, artist, date_text, medium, "
|
||||||
|
"department, credit, source_url, image_file, image_url_full, is_public_domain, blurb, palette) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
||||||
|
"ON CONFLICT(art_date) DO UPDATE SET object_id=excluded.object_id, title=excluded.title, "
|
||||||
|
"artist=excluded.artist, date_text=excluded.date_text, medium=excluded.medium, "
|
||||||
|
"department=excluded.department, credit=excluded.credit, source_url=excluded.source_url, "
|
||||||
|
"image_file=excluded.image_file, image_url_full=excluded.image_url_full, "
|
||||||
|
"is_public_domain=excluded.is_public_domain, blurb=excluded.blurb, palette=excluded.palette",
|
||||||
|
(art_date, source, oid, obj.get("title") or "Untitled",
|
||||||
|
obj.get("artistDisplayName") or None, obj.get("objectDate") or None,
|
||||||
|
obj.get("medium") or None, obj.get("department") or None,
|
||||||
|
obj.get("creditLine") or None, obj.get("objectURL") or None, fname,
|
||||||
|
obj.get("primaryImage") or None, 1 if obj.get("isPublicDomain") else 0, blurb, palette),
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE art_pool SET shown_at=? WHERE source=? AND object_id=?",
|
||||||
|
(art_date, source, oid))
|
||||||
|
conn.commit()
|
||||||
|
return dict(conn.execute("SELECT * FROM daily_art WHERE art_date=?", (art_date,)).fetchone())
|
||||||
|
return None # nothing fetched today — get_today falls back to the latest piece
|
||||||
|
|
||||||
|
|
||||||
|
def get_today(conn: sqlite3.Connection, art_date: str | None = None) -> dict | None:
|
||||||
|
"""Today's piece if present, else the most recent one cached (room is never empty)."""
|
||||||
|
if art_date:
|
||||||
|
row = conn.execute("SELECT * FROM daily_art WHERE art_date=?", (art_date,)).fetchone()
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
row = conn.execute("SELECT * FROM daily_art ORDER BY art_date DESC LIMIT 1").fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def run_daily(conn: sqlite3.Connection, source: str = "met", client=None) -> dict:
|
||||||
|
"""Cycle entry point: ensure the pool exists, then ensure today has a piece. Bounded
|
||||||
|
and non-fatal — safe to call every cycle (it no-ops once the day is picked)."""
|
||||||
|
pool = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
|
||||||
|
harvested = None
|
||||||
|
if pool == 0:
|
||||||
|
harvested = harvest_pool(conn, source=source)
|
||||||
|
picked = pick_daily(conn, source=source, client=client)
|
||||||
|
return {"pool": conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0],
|
||||||
|
"harvested": harvested, "picked_object": picked.get("object_id") if picked else None}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""Bloom — the daily word wheel (Center Circle / Wild Bloom).
|
||||||
|
|
||||||
|
DESIGN and ACCEPTANCE are decoupled:
|
||||||
|
|
||||||
|
• DESIGN (wheel selection, tiers, pangram, the Full-Bloom target) uses the small
|
||||||
|
COMMON list only — deterministic, stored in daily_puzzles, and unaffected by
|
||||||
|
curation. Tiers are scored on COMMON so "Flourishing" is always reachable with
|
||||||
|
everyday vocabulary, and "Full Bloom" = finding the whole *designed* puzzle
|
||||||
|
(the broad bonus words are extra credit beyond it, never required).
|
||||||
|
|
||||||
|
• ACCEPTANCE is BROAD and DYNAMIC — every valid dictionary word buildable from
|
||||||
|
the wheel, computed at RESPONSE TIME as: broad dict ∪ {allow} − {block}, where
|
||||||
|
allow/block are runtime admin overrides (bloom_word_overrides). So a missed
|
||||||
|
word can be allowed (or a junk word blocked) with NO deploy or regeneration.
|
||||||
|
|
||||||
|
Accept words never sit in the network response: clients validate against salted
|
||||||
|
hashes and compute their own score/tier/pangram from the 7 letters.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sqlite3
|
||||||
|
from itertools import combinations
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_DATA = Path(__file__).parent / "data"
|
||||||
|
_W = json.loads((_DATA / "bloom_words.json").read_text())
|
||||||
|
ACCEPT: list[str] = _W["accept"] # broad: all valid dictionary words
|
||||||
|
_COMMON: set[str] = set(_W["common"]) # tight: design / tiers / pangrams only
|
||||||
|
_COMMON_LS: list[tuple[str, frozenset]] = [(w, frozenset(w)) for w in _COMMON]
|
||||||
|
_AVOID: set[str] = set(json.loads((_DATA / "bloom_avoid.json").read_text()))
|
||||||
|
|
||||||
|
# Broad accept words bucketed by distinct-letter set, so the accepted set for a
|
||||||
|
# 7-letter wheel is gathered by unioning its ≤127 letter-subsets (fast) — no scan
|
||||||
|
# of the whole ~68k list per request.
|
||||||
|
_BY_SET: dict[frozenset, list[str]] = {}
|
||||||
|
for _w in ACCEPT:
|
||||||
|
_BY_SET.setdefault(frozenset(_w), []).append(_w)
|
||||||
|
|
||||||
|
# Candidate wheels = letter-sets of 7-distinct-letter COMMON words (every wheel
|
||||||
|
# has ≥1 recognizable pangram). Sorted for deterministic order.
|
||||||
|
_PANGRAM_SETS: dict[frozenset, list[str]] = {}
|
||||||
|
for _w in _COMMON:
|
||||||
|
_s = frozenset(_w)
|
||||||
|
if len(_s) == 7:
|
||||||
|
_PANGRAM_SETS.setdefault(_s, []).append(_w)
|
||||||
|
_CANDIDATES: list[frozenset] = sorted(_PANGRAM_SETS, key=lambda s: "".join(sorted(s)))
|
||||||
|
|
||||||
|
MIN_COMMON_WORDS, MAX_COMMON_WORDS = 14, 45
|
||||||
|
PANGRAM_BONUS = 7
|
||||||
|
# 8 / 30 / 70 — Flourishing at 70% keeps Bloom from becoming a completionist
|
||||||
|
# grind. Do NOT raise Flourishing above 0.70 (Codex).
|
||||||
|
TIER_PCTS: tuple[tuple[str, float], ...] = (
|
||||||
|
("Sprouting", 0.0), ("Budding", 0.08), ("Blooming", 0.30), ("Flourishing", 0.70),
|
||||||
|
)
|
||||||
|
TOP_TIER_PCT = 0.70
|
||||||
|
|
||||||
|
|
||||||
|
def score_word(word: str) -> int:
|
||||||
|
"""4-letter word = 1 point; longer = its length. Pangram bonus added on top."""
|
||||||
|
return 1 if len(word) == 4 else len(word)
|
||||||
|
|
||||||
|
|
||||||
|
def score_words(payload: dict, words) -> int:
|
||||||
|
"""Score found words for a wheel (pangram = uses all 7 letters). Used for the
|
||||||
|
player's running score AND the Full-Bloom check (vs the design's max_score)."""
|
||||||
|
letters = frozenset(payload["center"]) | frozenset(payload["outer"])
|
||||||
|
total = 0
|
||||||
|
for w in words:
|
||||||
|
total += score_word(w)
|
||||||
|
if frozenset(w) == letters:
|
||||||
|
total += PANGRAM_BONUS
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
# --- DESIGN: common-only, deterministic, stored --------------------------------
|
||||||
|
|
||||||
|
def tiers_for(common_max: int) -> list[dict]:
|
||||||
|
return [{"name": n, "score": int(p * common_max)} for n, p in TIER_PCTS]
|
||||||
|
|
||||||
|
|
||||||
|
def _design(letters: frozenset, center: str):
|
||||||
|
"""Center-mode design from the COMMON list only."""
|
||||||
|
commons = [w for (w, s) in _COMMON_LS if center in w and s <= letters]
|
||||||
|
pangrams = [w for w in commons if frozenset(w) == letters]
|
||||||
|
common_max = sum(score_word(w) for w in commons) + PANGRAM_BONUS * len(pangrams)
|
||||||
|
display = sorted((p for p in pangrams if p not in _AVOID), key=lambda p: (len(p), p))
|
||||||
|
return commons, display, common_max
|
||||||
|
|
||||||
|
|
||||||
|
def _design_wild(letters: frozenset):
|
||||||
|
"""Wild design (no required center) from the COMMON list only."""
|
||||||
|
commons = [w for (w, s) in _COMMON_LS if s <= letters]
|
||||||
|
pangrams = [w for w in commons if frozenset(w) == letters]
|
||||||
|
common_max = sum(score_word(w) for w in commons) + PANGRAM_BONUS * len(pangrams)
|
||||||
|
display = sorted((p for p in pangrams if p not in _AVOID), key=lambda p: (len(p), p))
|
||||||
|
vowels = [c for c in sorted(letters) if c in "aeiou"]
|
||||||
|
return commons, display, common_max, (vowels[0] if vowels else sorted(letters)[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(letters: frozenset, center: str, display, common_max: int) -> dict:
|
||||||
|
return {
|
||||||
|
"center": center,
|
||||||
|
"outer": sorted(letters - {center}),
|
||||||
|
"pangram": display[0],
|
||||||
|
"tiers": tiers_for(common_max),
|
||||||
|
# Full Bloom = finding the whole designed (common) puzzle; broad bonus
|
||||||
|
# words push score past this but are never required.
|
||||||
|
"max_score": common_max,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _generate(seed_str: str, fmt: str) -> dict:
|
||||||
|
"""Deterministically pick a wheel design for a seed + format."""
|
||||||
|
rng = random.Random(int(hashlib.sha256(seed_str.encode()).hexdigest(), 16))
|
||||||
|
order = _CANDIDATES[:]
|
||||||
|
rng.shuffle(order)
|
||||||
|
for letters in order:
|
||||||
|
if fmt == "wild":
|
||||||
|
commons, display, cmax, center = _design_wild(letters)
|
||||||
|
if len(commons) >= MIN_COMMON_WORDS and display:
|
||||||
|
return _payload(letters, center, display, cmax)
|
||||||
|
else:
|
||||||
|
centers = sorted(letters)
|
||||||
|
rng.shuffle(centers)
|
||||||
|
for center in centers:
|
||||||
|
commons, display, cmax = _design(letters, center)
|
||||||
|
if MIN_COMMON_WORDS <= len(commons) <= MAX_COMMON_WORDS and display:
|
||||||
|
return _payload(letters, center, display, cmax)
|
||||||
|
raise RuntimeError("bloom: no valid wheel found") # impossible with the vendored dict
|
||||||
|
|
||||||
|
|
||||||
|
def build_puzzle(date: str) -> dict:
|
||||||
|
"""The day's shared Center Circle wheel design (deterministic by date)."""
|
||||||
|
return {"date": date, **_generate(f"bloom:{date}", "center")}
|
||||||
|
|
||||||
|
|
||||||
|
def build_free(seed: str, fmt: str = "center") -> dict:
|
||||||
|
"""A free-play wheel design (deterministic by seed) — Center Circle or Wild."""
|
||||||
|
fmt = "wild" if fmt == "wild" else "center"
|
||||||
|
return {"seed": seed, "format": fmt, **_generate(f"free:{fmt}:{seed}", fmt)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- ACCEPTANCE: broad + runtime overrides, computed at response time ----------
|
||||||
|
|
||||||
|
def overrides(conn: sqlite3.Connection) -> tuple[set, set]:
|
||||||
|
allow, block = set(), set()
|
||||||
|
for r in conn.execute("SELECT word, action FROM bloom_word_overrides"):
|
||||||
|
(allow if r["action"] == "allow" else block).add(r["word"])
|
||||||
|
return allow, block
|
||||||
|
|
||||||
|
|
||||||
|
def _broad_words_for(letters: frozenset) -> list[str]:
|
||||||
|
"""Every broad-dictionary word buildable from `letters` (distinct-set ⊆ letters)."""
|
||||||
|
ls = sorted(letters)
|
||||||
|
out = []
|
||||||
|
for r in range(1, len(ls) + 1):
|
||||||
|
for combo in combinations(ls, r):
|
||||||
|
out.extend(_BY_SET.get(frozenset(combo), ()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def accepted_words(conn: sqlite3.Connection, center: str, outer, require_center: bool) -> list[str]:
|
||||||
|
"""The wheel's accepted set RIGHT NOW: broad words buildable from the letters
|
||||||
|
(optionally requiring the center), plus allow-overrides, minus block-overrides."""
|
||||||
|
letters = frozenset(outer) | {center}
|
||||||
|
allow, block = overrides(conn)
|
||||||
|
seen, out = set(), []
|
||||||
|
for w in _broad_words_for(letters):
|
||||||
|
if w in seen or w in block:
|
||||||
|
continue
|
||||||
|
if require_center and center not in w:
|
||||||
|
continue
|
||||||
|
seen.add(w)
|
||||||
|
out.append(w)
|
||||||
|
for w in allow: # allow words that may not be in the broad dict
|
||||||
|
if w in seen or w in block or len(w) < 4 or "s" in w:
|
||||||
|
continue
|
||||||
|
if not (frozenset(w) <= letters) or (require_center and center not in w):
|
||||||
|
continue
|
||||||
|
seen.add(w)
|
||||||
|
out.append(w)
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
# --- daily_puzzles storage -----------------------------------------------------
|
||||||
|
|
||||||
|
def generate_bloom_puzzle(conn: sqlite3.Connection, date: str) -> dict:
|
||||||
|
"""Ensure the day's Bloom DESIGN exists in daily_puzzles. Idempotent, pure code."""
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
return json.loads(existing["payload_json"])
|
||||||
|
payload = build_puzzle(date)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'bloom', '', ?)",
|
||||||
|
(date, json.dumps(payload)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
|
||||||
|
).fetchone()
|
||||||
|
return json.loads(row["payload_json"])
|
||||||
|
|
||||||
|
|
||||||
|
def stored_payload(conn: sqlite3.Connection, date: str) -> dict | None:
|
||||||
|
"""The day's design IF it already exists — never generates (used by the state
|
||||||
|
sanitizer, which must not trigger generation)."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
|
||||||
|
).fetchone()
|
||||||
|
return json.loads(row["payload_json"]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def word_hash(salt: str, word: str) -> str:
|
||||||
|
return hashlib.sha256(f"{salt}:{word}".encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _response(salt: str, p: dict, words: list[str], extra: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"game": "bloom",
|
||||||
|
"center": p["center"],
|
||||||
|
"outer": p["outer"],
|
||||||
|
"accepted": [word_hash(salt, w) for w in words], # NO plaintext words leak
|
||||||
|
"max_score": p["max_score"], # Full Bloom = designed puzzle
|
||||||
|
"tiers": p["tiers"],
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bloom_response(conn: sqlite3.Connection, date: str) -> dict:
|
||||||
|
"""Daily Center Circle — accepted set computed live (broad + overrides)."""
|
||||||
|
p = generate_bloom_puzzle(conn, date)
|
||||||
|
words = accepted_words(conn, p["center"], p["outer"], require_center=True)
|
||||||
|
return _response(date, p, words, {"date": date})
|
||||||
|
|
||||||
|
|
||||||
|
def bloom_free_response(conn: sqlite3.Connection, seed: str, fmt: str) -> dict:
|
||||||
|
"""Free-play wheel keyed by `seed` (resumable). Accepted set computed live."""
|
||||||
|
p = build_free(seed, fmt)
|
||||||
|
words = accepted_words(conn, p["center"], p["outer"], require_center=p["format"] != "wild")
|
||||||
|
return _response(seed, p, words, {"mode": "free", "format": p["format"], "seed": p["seed"]})
|
||||||
|
|
||||||
|
|
||||||
|
# --- runtime curation: overrides + player reports ------------------------------
|
||||||
|
|
||||||
|
def set_override(conn: sqlite3.Connection, word: str, action: str, reason: str | None = None,
|
||||||
|
by: str | None = None) -> bool:
|
||||||
|
word = (word or "").strip().lower()
|
||||||
|
if not (word.isalpha() and action in ("allow", "block")):
|
||||||
|
return False
|
||||||
|
# An ALLOW that violates Bloom's hard rules (≥4 letters, no 'S') could never
|
||||||
|
# count — reject it rather than store an inert override. BLOCK stays permissive.
|
||||||
|
if action == "allow" and (len(word) < 4 or "s" in word):
|
||||||
|
return False
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO bloom_word_overrides (word, action, reason, created_by) VALUES (?,?,?,?) "
|
||||||
|
"ON CONFLICT(word) DO UPDATE SET action=excluded.action, reason=excluded.reason, "
|
||||||
|
"created_by=excluded.created_by, created_at=CURRENT_TIMESTAMP",
|
||||||
|
(word, action, reason, by),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def clear_override(conn: sqlite3.Connection, word: str) -> None:
|
||||||
|
conn.execute("DELETE FROM bloom_word_overrides WHERE word=?", ((word or "").strip().lower(),))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def list_overrides(conn: sqlite3.Connection) -> list[dict]:
|
||||||
|
return [dict(r) for r in conn.execute(
|
||||||
|
"SELECT word, action, reason, created_by, created_at FROM bloom_word_overrides ORDER BY created_at DESC")]
|
||||||
|
|
||||||
|
|
||||||
|
def add_report(conn: sqlite3.Connection, word: str, puzzle_date, mode, fmt, letters, reason) -> bool:
|
||||||
|
word = (word or "").strip().lower()
|
||||||
|
if not (word.isalpha() and 4 <= len(word) <= 24):
|
||||||
|
return False
|
||||||
|
# Don't pile up duplicate pending reports for the same word.
|
||||||
|
dup = conn.execute(
|
||||||
|
"SELECT 1 FROM bloom_word_reports WHERE word=? AND status='pending'", (word,)).fetchone()
|
||||||
|
if dup:
|
||||||
|
return True
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO bloom_word_reports (word, puzzle_date, mode, format, letters, reason) "
|
||||||
|
"VALUES (?,?,?,?,?,?)",
|
||||||
|
(word, str(puzzle_date or "")[:16], str(mode or "")[:8], str(fmt or "")[:8],
|
||||||
|
str(letters or "")[:16], str(reason or "")[:60]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_reports(conn: sqlite3.Connection, status: str = "pending", limit: int = 100) -> list[dict]:
|
||||||
|
return [dict(r) for r in conn.execute(
|
||||||
|
"SELECT id, word, puzzle_date, mode, format, letters, reason, status, created_at "
|
||||||
|
"FROM bloom_word_reports WHERE status=? ORDER BY created_at DESC LIMIT ?", (status, limit))]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_report(conn: sqlite3.Connection, report_id: int, action: str, by: str | None = None) -> bool:
|
||||||
|
"""action: 'approve' (→ allow override) | 'block' (→ block override) | 'dismiss'."""
|
||||||
|
status = {"approve": "approved", "block": "blocked", "dismiss": "dismissed"}.get(action)
|
||||||
|
row = conn.execute("SELECT word FROM bloom_word_reports WHERE id=?", (report_id,)).fetchone()
|
||||||
|
if not row or not status:
|
||||||
|
return False
|
||||||
|
if action == "approve":
|
||||||
|
if not set_override(conn, row["word"], "allow", reason="report", by=by):
|
||||||
|
return False # can't allow (hard rule) — leave pending; dismiss instead
|
||||||
|
elif action == "block":
|
||||||
|
set_override(conn, row["word"], "block", reason="report", by=by)
|
||||||
|
conn.execute("UPDATE bloom_word_reports SET status=? WHERE id=?", (status, report_id))
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
+13
-8
@@ -3,7 +3,8 @@ from __future__ import annotations
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from .localtime import local_today
|
from .localtime import local_today
|
||||||
from .paywall import is_paywalled
|
from .paywall import is_paywalled, is_paywalled_for_source
|
||||||
|
from .queries import paywalled_source_ids
|
||||||
|
|
||||||
|
|
||||||
def build_daily_brief(
|
def build_daily_brief(
|
||||||
@@ -15,11 +16,10 @@ def build_daily_brief(
|
|||||||
) -> int:
|
) -> int:
|
||||||
target_date = brief_date or local_today()
|
target_date = brief_date or local_today()
|
||||||
|
|
||||||
# Compose the selection first so we can tell whether anything actually
|
# Compose the selection first so we can tell whether anything actually changed.
|
||||||
# changed. A calm daily brief shouldn't repeatedly hand the reader a locked
|
# A calm daily brief never hands the reader a locked door: paywalled-source
|
||||||
# door: push paywalled candidates below readable ones (stable sort) first.
|
# candidates are excluded in _candidate_articles (before LIMIT) — no unreadable news.
|
||||||
rows = _candidate_articles(conn, target_date, window_days)
|
rows = _candidate_articles(conn, target_date, window_days)
|
||||||
rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
|
|
||||||
selected = _select_diverse(rows, limit)
|
selected = _select_diverse(rows, limit)
|
||||||
selected_ids = [row["id"] for row in selected]
|
selected_ids = [row["id"] for row in selected]
|
||||||
|
|
||||||
@@ -107,10 +107,13 @@ def _candidate_articles(
|
|||||||
`window_days` so the brief still fills on slow news days. Anything already
|
`window_days` so the brief still fills on slow news days. Anything already
|
||||||
featured in a brief within the last 7 days (other than this same date, which
|
featured in a brief within the last 7 days (other than this same date, which
|
||||||
is being rebuilt) is excluded so backfilled stories cannot linger across
|
is being rebuilt) is excluded so backfilled stories cannot linger across
|
||||||
consecutive days.
|
consecutive days. Paywalled sources are excluded here (before LIMIT) so they
|
||||||
|
can't consume candidate slots and leave an otherwise-full brief thin.
|
||||||
"""
|
"""
|
||||||
|
pwx = paywalled_source_ids(conn)
|
||||||
|
pw_clause = f"AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
a.id,
|
a.id,
|
||||||
a.title,
|
a.title,
|
||||||
@@ -121,6 +124,7 @@ def _candidate_articles(
|
|||||||
src.name AS source_name,
|
src.name AS source_name,
|
||||||
src.default_category,
|
src.default_category,
|
||||||
src.trust_score,
|
src.trust_score,
|
||||||
|
src.paywall_override AS paywall_override,
|
||||||
s.constructive_score,
|
s.constructive_score,
|
||||||
s.cortisol_score,
|
s.cortisol_score,
|
||||||
s.ragebait_score,
|
s.ragebait_score,
|
||||||
@@ -151,6 +155,7 @@ def _candidate_articles(
|
|||||||
AND b.brief_date <= date(?)
|
AND b.brief_date <= date(?)
|
||||||
AND b.brief_date > date(?, '-7 days')
|
AND b.brief_date > date(?, '-7 days')
|
||||||
)
|
)
|
||||||
|
{pw_clause}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
is_today DESC,
|
is_today DESC,
|
||||||
(s.constructive_score + s.agency_score + s.human_benefit_score + src.trust_score
|
(s.constructive_score + s.agency_score + s.human_benefit_score + src.trust_score
|
||||||
@@ -158,7 +163,7 @@ def _candidate_articles(
|
|||||||
COALESCE(a.published_at, a.discovered_at) DESC
|
COALESCE(a.published_at, a.discovered_at) DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
""",
|
""",
|
||||||
(target_date, target_date, target_date, window_days, target_date, target_date, target_date),
|
(target_date, target_date, target_date, window_days, target_date, target_date, target_date, *pwx),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+162
-17
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,8 +11,10 @@ from .db import connect, init_db
|
|||||||
from .digest import send_due_digests
|
from .digest import send_due_digests
|
||||||
from .games import generate_daily_puzzles
|
from .games import generate_daily_puzzles
|
||||||
from .localtime import local_today
|
from .localtime import local_today
|
||||||
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup
|
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, cluster_duplicates, dedup as run_dedup
|
||||||
from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
|
from .geo import tag_articles as tag_geo
|
||||||
|
from . import art, newsimg, onthisday, quote, wotd
|
||||||
|
from .enrich import enrich_brief_images, enrich_read_times, enrich_recent_images, enrich_summarized_images
|
||||||
from .summarize import generate_summary, get_summary
|
from .summarize import generate_summary, get_summary
|
||||||
from .feeds import (
|
from .feeds import (
|
||||||
fetch_feed,
|
fetch_feed,
|
||||||
@@ -39,9 +42,17 @@ DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
|
|||||||
DEFAULT_SOURCES = ROOT / "config" / "sources.toml"
|
DEFAULT_SOURCES = ROOT / "config" / "sources.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_db() -> Path:
|
||||||
|
# Honor GOODNEWS_DB like the rest of the app (db.connect) does, so `GOODNEWS_DB=… `
|
||||||
|
# actually targets that DB instead of being silently ignored — otherwise a copy-DB
|
||||||
|
# maintenance run (e.g. dedup --force-recluster) can land on production by surprise.
|
||||||
|
return Path(os.environ.get("GOODNEWS_DB") or DEFAULT_DB)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(prog="goodnews")
|
parser = argparse.ArgumentParser(prog="goodnews")
|
||||||
parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="SQLite database path")
|
parser.add_argument("--db", type=Path, default=_default_db(),
|
||||||
|
help="SQLite database path (defaults to $GOODNEWS_DB, else the bundled data/ DB)")
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
subparsers.add_parser("init-db", help="Create or update the SQLite schema")
|
subparsers.add_parser("init-db", help="Create or update the SQLite schema")
|
||||||
@@ -123,6 +134,10 @@ def main() -> None:
|
|||||||
cycle_parser.add_argument("--classify-limit", type=int, default=40)
|
cycle_parser.add_argument("--classify-limit", type=int, default=40)
|
||||||
cycle_parser.add_argument("--no-classify", action="store_true", help="Skip the LLM classify step")
|
cycle_parser.add_argument("--no-classify", action="store_true", help="Skip the LLM classify step")
|
||||||
cycle_parser.add_argument("--no-dedup", action="store_true", help="Skip the embedding dedup step")
|
cycle_parser.add_argument("--no-dedup", action="store_true", help="Skip the embedding dedup step")
|
||||||
|
cycle_parser.add_argument("--no-geo", action="store_true", help="Skip tagging article subject-geography")
|
||||||
|
cycle_parser.add_argument("--geo-limit", type=int, default=60, help="Max articles to geo-tag per cycle")
|
||||||
|
cycle_parser.add_argument("--no-art", action="store_true", help="Skip the Daily Art pick")
|
||||||
|
cycle_parser.add_argument("--no-joys", action="store_true", help="Skip the small-joys picks (On This Day, etc.)")
|
||||||
cycle_parser.add_argument("--no-brief", action="store_true", help="Skip rebuilding today's brief")
|
cycle_parser.add_argument("--no-brief", action="store_true", help="Skip rebuilding today's brief")
|
||||||
cycle_parser.add_argument("--no-review", action="store_true", help="Skip recomputing source review flags")
|
cycle_parser.add_argument("--no-review", action="store_true", help="Skip recomputing source review flags")
|
||||||
cycle_parser.add_argument("--no-digest", action="store_true", help="Skip sending due daily digests")
|
cycle_parser.add_argument("--no-digest", action="store_true", help="Skip sending due daily digests")
|
||||||
@@ -138,12 +153,25 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
enrich_images_parser.add_argument("--limit", type=int, default=50, help="Max articles to fetch this batch")
|
enrich_images_parser.add_argument("--limit", type=int, default=50, help="Max articles to fetch this batch")
|
||||||
|
|
||||||
|
art_parser = subparsers.add_parser("art", help="Daily Art: harvest the pool and/or pick today's cached piece")
|
||||||
|
art_parser.add_argument("--harvest", action="store_true", help="(Re)harvest the curated museum pool")
|
||||||
|
art_parser.add_argument("--force", action="store_true", help="Re-pick today's art even if already chosen")
|
||||||
|
|
||||||
|
geo_parser = subparsers.add_parser("geo", help="Tag article subject-geography (backfill / manual). Cycle-locked.")
|
||||||
|
geo_parser.add_argument("--limit", type=int, default=200, help="Max articles to tag this batch")
|
||||||
|
geo_parser.add_argument("--reclassify", action="store_true", help="Re-tag even rows already at the current geo version")
|
||||||
|
geo_parser.add_argument("--base-url", help="OpenAI-compatible base URL")
|
||||||
|
geo_parser.add_argument("--model", help="Local model name")
|
||||||
|
|
||||||
dedup_parser = subparsers.add_parser("dedup", help="Cluster near-duplicate stories via local embeddings")
|
dedup_parser = subparsers.add_parser("dedup", help="Cluster near-duplicate stories via local embeddings")
|
||||||
dedup_parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD, help="Cosine similarity cutoff")
|
dedup_parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD, help="Cosine similarity cutoff")
|
||||||
dedup_parser.add_argument("--window-days", type=int, default=DEFAULT_WINDOW_DAYS)
|
dedup_parser.add_argument("--window-days", type=int, default=DEFAULT_WINDOW_DAYS)
|
||||||
dedup_parser.add_argument("--embed-limit", type=int, help="Cap how many missing embeddings to compute")
|
dedup_parser.add_argument("--embed-limit", type=int, help="Cap how many missing embeddings to compute")
|
||||||
dedup_parser.add_argument("--base-url", help="OpenAI-compatible base URL")
|
dedup_parser.add_argument("--base-url", help="OpenAI-compatible base URL")
|
||||||
dedup_parser.add_argument("--model", help="Chat model name (unused for embeddings)")
|
dedup_parser.add_argument("--model", help="Chat model name (unused for embeddings)")
|
||||||
|
dedup_parser.add_argument("--force-recluster", action="store_true",
|
||||||
|
help="Re-cluster the EXISTING corpus even if no new embeddings "
|
||||||
|
"(re-applies representative policy; cycle-locked, no model needed)")
|
||||||
|
|
||||||
check_llm_parser = subparsers.add_parser("check-llm", help="Check local OpenAI-compatible model endpoint")
|
check_llm_parser = subparsers.add_parser("check-llm", help="Check local OpenAI-compatible model endpoint")
|
||||||
check_llm_parser.add_argument("--base-url", help="OpenAI-compatible base URL, e.g. http://127.0.0.1:1234/v1")
|
check_llm_parser.add_argument("--base-url", help="OpenAI-compatible base URL, e.g. http://127.0.0.1:1234/v1")
|
||||||
@@ -221,7 +249,9 @@ def main() -> None:
|
|||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
p = _json.loads(r["preview_json"])
|
p = _json.loads(r["preview_json"])
|
||||||
line += f" (accept {round(p.get('acceptance_rate', 0) * 100)}%, sampled {p.get('sampled', 0)})"
|
_rate = p.get("acceptance_rate")
|
||||||
|
_rate_str = f"{round(_rate * 100)}%" if _rate is not None else "—"
|
||||||
|
line += f" (accept {_rate_str}, sampled {p.get('sampled', 0)})"
|
||||||
print(line)
|
print(line)
|
||||||
elif args.command == "promote-candidate":
|
elif args.command == "promote-candidate":
|
||||||
init_db(conn)
|
init_db(conn)
|
||||||
@@ -284,8 +314,44 @@ def main() -> None:
|
|||||||
elif args.command == "enrich-images":
|
elif args.command == "enrich-images":
|
||||||
found = enrich_summarized_images(conn, limit=args.limit)
|
found = enrich_summarized_images(conn, limit=args.limit)
|
||||||
print(f"enrich-images: {found} new image(s) for summarized articles")
|
print(f"enrich-images: {found} new image(s) for summarized articles")
|
||||||
|
elif args.command == "art":
|
||||||
|
init_db(conn)
|
||||||
|
if args.harvest:
|
||||||
|
h = art.harvest_pool(conn)
|
||||||
|
print(f"art harvest: found={h['found']} added={h['added']} pool={h['pool']} errors={h['errors']}")
|
||||||
|
picked = art.pick_daily(conn, force=args.force, client=LocalModelClient.from_env())
|
||||||
|
if picked:
|
||||||
|
print(f"art pick: {picked['art_date']} -> #{picked['object_id']} "
|
||||||
|
f"\"{picked['title']}\" — {picked['artist'] or 'Unknown'}")
|
||||||
|
else:
|
||||||
|
print("art pick: nothing fetched (kept the last piece)")
|
||||||
|
elif args.command == "geo":
|
||||||
|
init_db(conn)
|
||||||
|
# Cycle-locked so a manual backfill can't contend with the scheduled cycle.
|
||||||
|
with cycle_lock(args.db) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
print("geo: a cycle is already running; try again after it finishes")
|
||||||
|
return
|
||||||
|
g = tag_geo(conn, llm_client_from_args(args), limit=args.limit, reclassify=args.reclassify)
|
||||||
|
print(f"geo: tagged={g['tagged']} errors={g['errors']} (of {g['candidates']} candidates)")
|
||||||
elif args.command == "dedup":
|
elif args.command == "dedup":
|
||||||
init_db(conn)
|
init_db(conn)
|
||||||
|
if args.force_recluster:
|
||||||
|
# Re-apply representative policy to the EXISTING corpus. The normal path
|
||||||
|
# fast-skips when no new embeddings exist, so it would NOT pick up a policy
|
||||||
|
# change. Cycle-locked so it can't overlap the scheduled timer; no model
|
||||||
|
# needed (pure re-cluster over stored embeddings).
|
||||||
|
with cycle_lock(args.db) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
print("dedup: a cycle is already running; re-run --force-recluster after it finishes")
|
||||||
|
return
|
||||||
|
stats = cluster_duplicates(conn, threshold=args.threshold, window_days=args.window_days)
|
||||||
|
print(
|
||||||
|
f"dedup (forced recluster): articles={stats['articles']} "
|
||||||
|
f"clusters={stats['clusters']} duplicate_clusters={stats['duplicate_clusters']} "
|
||||||
|
f"duplicates_hidden={stats['duplicates']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
client = llm_client_from_args(args)
|
client = llm_client_from_args(args)
|
||||||
stats = run_dedup(
|
stats = run_dedup(
|
||||||
conn, client, threshold=args.threshold, window_days=args.window_days, embed_limit=args.embed_limit
|
conn, client, threshold=args.threshold, window_days=args.window_days, embed_limit=args.embed_limit
|
||||||
@@ -368,7 +434,9 @@ def list_recent(conn: sqlite3.Connection, limit: int, accepted_only: bool) -> No
|
|||||||
def print_preview(p: dict) -> None:
|
def print_preview(p: dict) -> None:
|
||||||
mode = "model" if p["classified"] else "heuristic"
|
mode = "model" if p["classified"] else "heuristic"
|
||||||
print(f"Preview of {p['url']} ({mode})")
|
print(f"Preview of {p['url']} ({mode})")
|
||||||
print(f" sampled={p['sampled']} accepted={p['accepted']} ({p['acceptance_rate']*100:.0f}%)")
|
rate = p.get("acceptance_rate")
|
||||||
|
rate_str = f"{rate * 100:.0f}%" if rate is not None else "— (all held)"
|
||||||
|
print(f" sampled={p['sampled']} accepted={p['accepted']} ({rate_str})")
|
||||||
print(f" freshness: newest={p['newest_published'] or 'unknown'} in_last_7d={p['recent_7d']}")
|
print(f" freshness: newest={p['newest_published'] or 'unknown'} in_last_7d={p['recent_7d']}")
|
||||||
print(f" averages: cortisol={p['avg_cortisol']} ragebait={p['avg_ragebait']} pr_risk={p['avg_pr_risk']}")
|
print(f" averages: cortisol={p['avg_cortisol']} ragebait={p['avg_ragebait']} pr_risk={p['avg_pr_risk']}")
|
||||||
if p["topic_mix"]:
|
if p["topic_mix"]:
|
||||||
@@ -398,6 +466,28 @@ def check_feeds(conn: sqlite3.Connection, include_inactive: bool = False) -> Non
|
|||||||
print(f"--- {ok}/{len(rows)} feeds healthy ---")
|
print(f"--- {ok}/{len(rows)} feeds healthy ---")
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def cycle_lock(db_path):
|
||||||
|
"""Exclusive, non-blocking lock shared by the scheduled cycle and any manual job
|
||||||
|
that mutates the corpus (e.g. a forced dedup re-cluster), so they can never overlap
|
||||||
|
and contend on the database/model. Yields True if acquired, False if already held."""
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
lock_path = Path(db_path).parent / ".goodnews-cycle.lock"
|
||||||
|
lock_file = open(lock_path, "w")
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
lock_file.close()
|
||||||
|
yield False
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
yield True
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||||
|
lock_file.close()
|
||||||
|
|
||||||
|
|
||||||
def run_cycle(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
|
def run_cycle(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
|
||||||
"""One end-to-end pass for a scheduler: poll due sources, classify the new
|
"""One end-to-end pass for a scheduler: poll due sources, classify the new
|
||||||
arrivals, dedup, rebuild today's brief. Each step is independent and
|
arrivals, dedup, rebuild today's brief. Each step is independent and
|
||||||
@@ -406,21 +496,11 @@ def run_cycle(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
|
|||||||
Holds an exclusive lock so a manual run and the systemd timer (or two timer
|
Holds an exclusive lock so a manual run and the systemd timer (or two timer
|
||||||
ticks) can never overlap and contend on the database and model.
|
ticks) can never overlap and contend on the database and model.
|
||||||
"""
|
"""
|
||||||
import fcntl
|
with cycle_lock(args.db) as acquired:
|
||||||
|
if not acquired:
|
||||||
lock_path = Path(args.db).parent / ".goodnews-cycle.lock"
|
|
||||||
lock_file = open(lock_path, "w")
|
|
||||||
try:
|
|
||||||
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
except OSError:
|
|
||||||
print("cycle: another cycle is already running; skipping")
|
print("cycle: another cycle is already running; skipping")
|
||||||
lock_file.close()
|
|
||||||
return
|
return
|
||||||
try:
|
|
||||||
_run_cycle_locked(conn, args)
|
_run_cycle_locked(conn, args)
|
||||||
finally:
|
|
||||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
|
||||||
lock_file.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
|
def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
|
||||||
@@ -462,6 +542,45 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"dedup: skipped ({exc})")
|
print(f"dedup: skipped ({exc})")
|
||||||
|
|
||||||
|
# Geo: tag newly-accepted, non-duplicate articles with subject geography (its own
|
||||||
|
# LLM pass, decoupled from scoring). Bounded per cycle; idempotent (skips rows
|
||||||
|
# already at the current GEO_VERSION). Non-fatal like every other step.
|
||||||
|
if not args.no_geo:
|
||||||
|
try:
|
||||||
|
g = tag_geo(conn, llm_client_from_args(args), limit=args.geo_limit)
|
||||||
|
print(f"geo: tagged={g['tagged']} errors={g['errors']} (of {g['candidates']} untagged)")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"geo: skipped ({exc})")
|
||||||
|
|
||||||
|
# Daily Art: ensure the pool exists, then ensure today has a cached piece. No-ops
|
||||||
|
# once the day is picked; non-fatal like every other step.
|
||||||
|
if not args.no_art:
|
||||||
|
try:
|
||||||
|
a = art.run_daily(conn, client=LocalModelClient.from_env()) # client → the guide blurb
|
||||||
|
print(f"art: pool={a['pool']} picked={a['picked_object']}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"art: skipped ({exc})")
|
||||||
|
|
||||||
|
# Small joys: On This Day (history), Quote of the Day, Word of the Day. Each is
|
||||||
|
# bounded + non-fatal; one shared LLM client for tone/explainer/word proposals.
|
||||||
|
if not args.no_joys:
|
||||||
|
joy_client = LocalModelClient.from_env()
|
||||||
|
try:
|
||||||
|
o = onthisday.run_daily(conn, client=joy_client)
|
||||||
|
print(f"onthisday: md={o['md']} picked={'yes' if o['picked'] else 'no'}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"onthisday: skipped ({exc})")
|
||||||
|
try:
|
||||||
|
q = quote.run_daily(conn, client=joy_client)
|
||||||
|
print(f"quote: pool={q['pool']} picked={q['picked']}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"quote: skipped ({exc})")
|
||||||
|
try:
|
||||||
|
w = wotd.run_daily(conn, client=joy_client)
|
||||||
|
print(f"word: pool={w['pool']} picked={w['picked']}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"word: skipped ({exc})")
|
||||||
|
|
||||||
if not args.no_brief:
|
if not args.no_brief:
|
||||||
today = local_today()
|
today = local_today()
|
||||||
try:
|
try:
|
||||||
@@ -480,6 +599,26 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"recent images: skipped ({exc})")
|
print(f"recent images: skipped ({exc})")
|
||||||
|
|
||||||
|
# Cache downscaled display copies of those images on our own origin (so the
|
||||||
|
# hub/feed serve local files, not flaky third-party hotlinks), then enforce the
|
||||||
|
# size ceiling with LRU eviction. Both bounded + non-fatal.
|
||||||
|
try:
|
||||||
|
warmed = newsimg.warm(conn)
|
||||||
|
pr = newsimg.prune()
|
||||||
|
print(f"images: cached={warmed} size={pr['after'] // 1048576}MB/"
|
||||||
|
f"{pr['cap'] // 1048576}MB evicted={pr['removed']}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"images: skipped ({exc})")
|
||||||
|
|
||||||
|
# Full-article read-times: count words for recent accepted articles so the
|
||||||
|
# front door can show "Full story · ~N min" next to our gist (bounded per cycle).
|
||||||
|
try:
|
||||||
|
reads = enrich_read_times(conn)
|
||||||
|
if reads:
|
||||||
|
print(f"read-times: {reads} counted")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"read-times: skipped ({exc})")
|
||||||
|
|
||||||
# Pre-warm summaries for today's brief so Today reads as a calm briefing.
|
# Pre-warm summaries for today's brief so Today reads as a calm briefing.
|
||||||
# Idempotent: cached items are skipped, so this only hits the LLM for new ones.
|
# Idempotent: cached items are skipped, so this only hits the LLM for new ones.
|
||||||
try:
|
try:
|
||||||
@@ -505,6 +644,12 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"review: skipped ({exc})")
|
print(f"review: skipped ({exc})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .queries import reindex_search
|
||||||
|
print(f"search: indexed {reindex_search(conn)} articles")
|
||||||
|
except Exception as exc: # noqa: BLE001 — search index is non-critical
|
||||||
|
print(f"search: skipped ({exc})")
|
||||||
|
|
||||||
if not args.no_digest:
|
if not args.no_digest:
|
||||||
try:
|
try:
|
||||||
sent = send_due_digests(conn) # morning-gated + deduped internally
|
sent = send_due_digests(conn) # morning-gated + deduped internally
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Shared helpers for the daily "small joys" features — On This Day, Word of the Day,
|
||||||
|
Quote of the Day (and whatever calm little delights come next).
|
||||||
|
|
||||||
|
Each joy keeps its own pool + daily table, but they all share this skeleton:
|
||||||
|
harvest -> pool → deterministic daily pick (date-seeded, least-recently-shown) →
|
||||||
|
cache row in a daily_* table → API → page.
|
||||||
|
|
||||||
|
This module holds only the genuinely shared bits (network + the deterministic pick), so a
|
||||||
|
new joy is a small self-contained module, not a copy-paste of plumbing. Network calls go
|
||||||
|
through http_json so tests can monkeypatch them.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
|
||||||
|
|
||||||
|
|
||||||
|
def http_json(url: str, timeout: int = 20) -> dict:
|
||||||
|
req = urllib.request.Request(url, headers=_UA)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def seeded_order(ids: list, date_str: str) -> list:
|
||||||
|
"""Rotate a list deterministically by the date, so the day's pick is the same for
|
||||||
|
everyone and varies day to day (the same trick Daily Art uses)."""
|
||||||
|
if not ids:
|
||||||
|
return ids
|
||||||
|
seed = int(hashlib.sha256(date_str.encode()).hexdigest(), 16) % len(ids)
|
||||||
|
return ids[seed:] + ids[:seed]
|
||||||
|
|
||||||
|
|
||||||
|
def freshest(rows: list) -> list:
|
||||||
|
"""The cohort to feature today, from pool rows carrying `id` + `shown_at`: every
|
||||||
|
NEVER-shown item (shown_at NULL) while any remain, else every item tied for the
|
||||||
|
OLDEST shown_at. Guarantees each pool item is featured ONCE before any repeat, then
|
||||||
|
cycles oldest-first. Pick deterministically *within* this cohort (seeded_order) —
|
||||||
|
NEVER across the whole pool, which re-feeds recent items (the QOTD/WOTD repeat bug)."""
|
||||||
|
never = [r["id"] for r in rows if r["shown_at"] is None]
|
||||||
|
if never:
|
||||||
|
return sorted(never)
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
oldest = min(r["shown_at"] for r in rows)
|
||||||
|
return sorted(r["id"] for r in rows if r["shown_at"] == oldest)
|
||||||
|
|
||||||
|
|
||||||
|
def content_key(*parts) -> str:
|
||||||
|
"""A stable dedup key for a pool item (so re-harvesting never duplicates a row)."""
|
||||||
|
raw = "|".join("" if p is None else str(p) for p in parts)
|
||||||
|
return hashlib.sha256(raw.encode()).hexdigest()[:24]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
["vagina", "vulva", "nipple", "rectum", "anal", "fecal", "ejaculation", "eunuch", "nude", "nudity", "butt"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
["death","dying","died","killed","killing","murder","murdered","corpse","coffin","funeral","grave","buried","burial","weapon","gunshot","warfare","violent","violence","deadly","lethal","poison","poisoned","suicide","slaughter","victim","bleeding","wound","wounded","vomit","vomiting","vomited","diarrhea","disease","diseased","cancer","tumor","illness","infection","infected","plague","disabled","lucifer","satan","demon","demonic","devil","damned","hatred","hateful","terror","terrorize","hostage","kidnap","kidnapped","abuse","abused","assault","trauma","traumatic","anxiety","depression","depressed","divorce","divorced","bankrupt","eviction","evicted","layoff","drowned","drowning","choking","suffocate","starving","famine","poverty","despair","misery","miserable","tragic","tragedy","horror","horrible","nightmare","panic","dread","grief","grieving","mourning","rotting","decay","decayed","maggot","vermin","filth","sewage","manure"]
|
||||||
File diff suppressed because one or more lines are too long
+268
-1
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS sources (
|
|||||||
retry_after_at TEXT,
|
retry_after_at TEXT,
|
||||||
review_flag INTEGER NOT NULL DEFAULT 0,
|
review_flag INTEGER NOT NULL DEFAULT 0,
|
||||||
review_reason TEXT,
|
review_reason TEXT,
|
||||||
|
x_handle TEXT, -- the source's own verified X handle, if known
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -48,6 +49,8 @@ CREATE TABLE IF NOT EXISTS articles (
|
|||||||
title_hash TEXT,
|
title_hash TEXT,
|
||||||
duplicate_of INTEGER REFERENCES articles(id) ON DELETE SET NULL,
|
duplicate_of INTEGER REFERENCES articles(id) ON DELETE SET NULL,
|
||||||
image_checked_at TEXT,
|
image_checked_at TEXT,
|
||||||
|
source_words INTEGER, -- full-article word count (metadata only; never the body)
|
||||||
|
read_checked_at TEXT, -- when we last tried to count words (retry guard)
|
||||||
FOREIGN KEY (source_id) REFERENCES sources(id)
|
FOREIGN KEY (source_id) REFERENCES sources(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ CREATE TABLE IF NOT EXISTS article_scores (
|
|||||||
reason_text TEXT,
|
reason_text TEXT,
|
||||||
topic TEXT,
|
topic TEXT,
|
||||||
flavor TEXT,
|
flavor TEXT,
|
||||||
|
language TEXT,
|
||||||
model_name TEXT,
|
model_name TEXT,
|
||||||
scored_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
scored_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -215,6 +219,146 @@ CREATE TABLE IF NOT EXISTS article_summaries (
|
|||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Where a story is ABOUT (subject geography), kept SEPARATE from article_scores so
|
||||||
|
-- durable geography isn't coupled to volatile scoring/acceptance. "local" is never
|
||||||
|
-- stored here — it's relative to the reader; the UI computes "Near you" by comparing
|
||||||
|
-- these places to the visitor's chosen home. geo_version lets us re-backfill cleanly
|
||||||
|
-- when the prompt/taxonomy changes. 'global' is a real category, not a failure.
|
||||||
|
CREATE TABLE IF NOT EXISTS article_geo (
|
||||||
|
article_id INTEGER PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
breadth TEXT NOT NULL DEFAULT 'unknown', -- locality|regional|national|multinational|global|unknown
|
||||||
|
confidence TEXT NOT NULL DEFAULT 'low', -- high|medium|low
|
||||||
|
rationale TEXT,
|
||||||
|
geo_version TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
-- 0..N normalized places per article (a story can span regions). Codes are ISO
|
||||||
|
-- (country = alpha-2, state = US 2-letter / ISO-3166-2 subdivision), normalized in
|
||||||
|
-- code — never trusting the model's free text.
|
||||||
|
CREATE TABLE IF NOT EXISTS article_places (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
country_code TEXT,
|
||||||
|
state_code TEXT,
|
||||||
|
locality TEXT,
|
||||||
|
ord INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_places_article ON article_places(article_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_places_country ON article_places(country_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_article_geo_breadth ON article_geo(breadth);
|
||||||
|
|
||||||
|
-- Daily Art (the /art room). art_pool = a curated set of public-domain, highlighted
|
||||||
|
-- museum object IDs (so the daily pick never hits a potsherd). daily_art = one cached
|
||||||
|
-- piece per day (metadata + a locally-cached image), so the homepage never waits on or
|
||||||
|
-- hotlinks the museum. shown_at lets us avoid repeating a piece too soon.
|
||||||
|
CREATE TABLE IF NOT EXISTS art_pool (
|
||||||
|
object_id INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'met',
|
||||||
|
shown_at TEXT,
|
||||||
|
blocked INTEGER NOT NULL DEFAULT 0, -- manual lever: skip an odd/unsuitable piece
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (source, object_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_art (
|
||||||
|
art_date TEXT PRIMARY KEY,
|
||||||
|
source TEXT NOT NULL DEFAULT 'met',
|
||||||
|
object_id INTEGER NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
artist TEXT,
|
||||||
|
date_text TEXT,
|
||||||
|
medium TEXT,
|
||||||
|
department TEXT,
|
||||||
|
credit TEXT, -- museum credit line
|
||||||
|
source_url TEXT, -- canonical museum object page
|
||||||
|
image_file TEXT, -- our cached (web-large) image
|
||||||
|
image_url_full TEXT, -- source full-res URL, for a later richer /art view
|
||||||
|
is_public_domain INTEGER, -- license marker (CC0/public domain), stored for citizenship
|
||||||
|
blurb TEXT, -- LLM "museum guide" note: what you're looking at (cached)
|
||||||
|
palette TEXT, -- JSON array of hex colors extracted from the image
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "Small joys" daily features. On This Day: a good/neutral thing that happened on
|
||||||
|
-- today's calendar date, harvested + tone-filtered into a pool, then one picked per day.
|
||||||
|
CREATE TABLE IF NOT EXISTS onthisday_pool (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'wikimedia', -- multi-source ready (wikimedia | admin | ...)
|
||||||
|
md TEXT NOT NULL, -- 'MM-DD'
|
||||||
|
year INTEGER,
|
||||||
|
ckey TEXT NOT NULL UNIQUE, -- dedup hash so re-harvest never duplicates
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
page_url TEXT,
|
||||||
|
shown_at TEXT, -- last date this was the pick (no-soon-repeat)
|
||||||
|
blocked INTEGER NOT NULL DEFAULT 0, -- admin lever: never pick this
|
||||||
|
featured INTEGER NOT NULL DEFAULT 0, -- admin lever: prefer this for its date
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_onthisday (
|
||||||
|
feature_date TEXT PRIMARY KEY, -- 'YYYY-MM-DD'
|
||||||
|
pool_id INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'wikimedia',
|
||||||
|
md TEXT NOT NULL,
|
||||||
|
year INTEGER,
|
||||||
|
text TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
page_url TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Quote of the Day: curated public-domain quotes; one picked per day. `meaning` is an
|
||||||
|
-- AI explainer of the (real) quote, filled lazily the first time it's shown.
|
||||||
|
CREATE TABLE IF NOT EXISTS quote_pool (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'curated',
|
||||||
|
ckey TEXT NOT NULL UNIQUE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
author TEXT,
|
||||||
|
work TEXT,
|
||||||
|
year TEXT,
|
||||||
|
meaning TEXT,
|
||||||
|
shown_at TEXT,
|
||||||
|
blocked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
featured INTEGER NOT NULL DEFAULT 0,
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_quote (
|
||||||
|
feature_date TEXT PRIMARY KEY,
|
||||||
|
pool_id INTEGER NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'curated',
|
||||||
|
text TEXT, author TEXT, work TEXT, year TEXT, meaning TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Word of the Day: LLM-proposed positive words, validated/enriched against a real
|
||||||
|
-- dictionary (definition, IPA, examples, cached audio clip); one picked per day.
|
||||||
|
CREATE TABLE IF NOT EXISTS wotd_pool (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'llm',
|
||||||
|
word TEXT NOT NULL UNIQUE,
|
||||||
|
part_of_speech TEXT,
|
||||||
|
phonetic TEXT, -- IPA
|
||||||
|
audio_file TEXT, -- our cached pronunciation clip (or null → browser TTS)
|
||||||
|
audio_url TEXT, -- source clip URL
|
||||||
|
definition TEXT NOT NULL, -- raw dictionary gloss (anchor / ground truth)
|
||||||
|
examples TEXT, -- JSON array of raw dictionary example sentences (anchor)
|
||||||
|
gloss TEXT, -- LLM plain-language rewrite of the definition (for display)
|
||||||
|
usage TEXT, -- JSON array of LLM everyday example sentences (for display)
|
||||||
|
shown_at TEXT,
|
||||||
|
blocked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
featured INTEGER NOT NULL DEFAULT 0,
|
||||||
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_wotd (
|
||||||
|
feature_date TEXT PRIMARY KEY,
|
||||||
|
pool_id INTEGER NOT NULL,
|
||||||
|
word TEXT, part_of_speech TEXT, phonetic TEXT, audio_file TEXT,
|
||||||
|
definition TEXT, examples TEXT, gloss TEXT, usage TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
-- Privacy-respecting, first-party analytics. NO IP / user-agent / referrer / raw
|
-- Privacy-respecting, first-party analytics. NO IP / user-agent / referrer / raw
|
||||||
-- URL. visitor_hash is a hash of a random localStorage token (never email/IP).
|
-- URL. visitor_hash is a hash of a random localStorage token (never email/IP).
|
||||||
-- The UNIQUE key dedups to one row per (kind, article, visitor, day) — that both
|
-- The UNIQUE key dedups to one row per (kind, article, visitor, day) — that both
|
||||||
@@ -300,6 +444,47 @@ CREATE TABLE IF NOT EXISTS daily_puzzles (
|
|||||||
UNIQUE (puzzle_date, game, variant)
|
UNIQUE (puzzle_date, game, variant)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Full-text search over the PUBLIC article corpus (title/description/source/tags).
|
||||||
|
-- Standalone FTS5 (not external-content) since the searchable text spans tables;
|
||||||
|
-- rebuilt from the accepted, non-duplicate set on each ingest cycle (+ lazily).
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS article_search USING fts5(
|
||||||
|
article_id UNINDEXED, title, body, source_name, tags
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS game_state (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
game TEXT NOT NULL, -- 'word' | 'wordsearch'
|
||||||
|
variant TEXT NOT NULL, -- '5'|'6' | 'small'|'med'|'large'
|
||||||
|
puzzle_date TEXT NOT NULL,
|
||||||
|
state_json TEXT NOT NULL, -- per-puzzle progress; merged server-side on save
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, game, variant, puzzle_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Bloom runtime word curation (no deploy needed). The accepted set is computed
|
||||||
|
-- live as: broad dictionary ∪ {allow} − {block}. Admin-managed; one row per word.
|
||||||
|
CREATE TABLE IF NOT EXISTS bloom_word_overrides (
|
||||||
|
word TEXT PRIMARY KEY, -- lowercase
|
||||||
|
action TEXT NOT NULL, -- 'allow' | 'block'
|
||||||
|
reason TEXT,
|
||||||
|
created_by TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Player "this should count" reports → admin queue (approve→allow / block / dismiss).
|
||||||
|
CREATE TABLE IF NOT EXISTS bloom_word_reports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
word TEXT NOT NULL, -- lowercase
|
||||||
|
puzzle_date TEXT,
|
||||||
|
mode TEXT, -- 'daily' | 'free'
|
||||||
|
format TEXT, -- 'center' | 'wild'
|
||||||
|
letters TEXT, -- the wheel's 7 letters (for context)
|
||||||
|
reason TEXT, -- why it was rejected (e.g. 'not in the word list')
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'blocked' | 'dismissed'
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bloom_reports_status ON bloom_word_reports(status, created_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_follows (
|
CREATE TABLE IF NOT EXISTS user_follows (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -317,6 +502,49 @@ CREATE TABLE IF NOT EXISTS digest_sends (
|
|||||||
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE (user_id, brief_date)
|
UNIQUE (user_id, brief_date)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Publishing Desk: a platform-NEUTRAL outbound-share record (X first; Bluesky /
|
||||||
|
-- Threads / newsletter later reuse this). One row per (article, platform); the
|
||||||
|
-- queue tops up without ever overwriting saved text/handles. opened != posted —
|
||||||
|
-- Web Intents can't confirm a post, so the human confirms the terminal state.
|
||||||
|
CREATE TABLE IF NOT EXISTS outbound_shares (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'x',
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued', -- queued|drafting|opened|posted|skipped|snoozed
|
||||||
|
social_score INTEGER, -- LLM "stop-scrolling" interest (0-10)
|
||||||
|
rationale TEXT, -- why someone would stop scrolling
|
||||||
|
talking_points TEXT, -- JSON array of factual points
|
||||||
|
angle TEXT, -- a suggested conversational angle
|
||||||
|
entities TEXT, -- JSON array of raw named entities (LLM-extracted)
|
||||||
|
suggested_handles TEXT, -- JSON array of {handle, profile_url, via}
|
||||||
|
draft_text TEXT, -- autosaved in-progress blurb (the human writes it)
|
||||||
|
final_text TEXT, -- what was actually posted (teaches voice later)
|
||||||
|
share_url TEXT, -- the exact /a/{id}?utm... link used
|
||||||
|
post_url TEXT, -- the resulting tweet URL, if captured
|
||||||
|
snooze_until TEXT, -- 'not right now' (re-eligible after this)
|
||||||
|
opened_at TEXT,
|
||||||
|
posted_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (article_id, platform)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_outbound_shares_status ON outbound_shares(platform, status);
|
||||||
|
|
||||||
|
-- Verified handle directory — the LLM only ever proposes NAMES; the @handle comes
|
||||||
|
-- only from here (or a source's own x_handle). Aliases resolve consistently by each
|
||||||
|
-- having its own row pointing at the same handle (e.g. "Johns Hopkins University"
|
||||||
|
-- and "Johns Hopkins").
|
||||||
|
CREATE TABLE IF NOT EXISTS entity_handles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_name TEXT NOT NULL, -- display name as entered
|
||||||
|
normalized_name TEXT NOT NULL, -- lowercased/stripped match key
|
||||||
|
platform TEXT NOT NULL DEFAULT 'x',
|
||||||
|
handle TEXT NOT NULL, -- e.g. @AnthropicAI
|
||||||
|
profile_url TEXT,
|
||||||
|
verified_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (normalized_name, platform)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -349,7 +577,7 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
need an explicit, idempotent ALTER guarded by the current column set.
|
need an explicit, idempotent ALTER guarded by the current column set.
|
||||||
"""
|
"""
|
||||||
score_cols = {row["name"] for row in conn.execute("PRAGMA table_info(article_scores)")}
|
score_cols = {row["name"] for row in conn.execute("PRAGMA table_info(article_scores)")}
|
||||||
for column in ("topic", "flavor"):
|
for column in ("topic", "flavor", "language"):
|
||||||
if column not in score_cols:
|
if column not in score_cols:
|
||||||
conn.execute(f"ALTER TABLE article_scores ADD COLUMN {column} TEXT")
|
conn.execute(f"ALTER TABLE article_scores ADD COLUMN {column} TEXT")
|
||||||
|
|
||||||
@@ -371,6 +599,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
if "image_checked_at" not in article_cols:
|
if "image_checked_at" not in article_cols:
|
||||||
conn.execute("ALTER TABLE articles ADD COLUMN image_checked_at TEXT")
|
conn.execute("ALTER TABLE articles ADD COLUMN image_checked_at TEXT")
|
||||||
|
if "source_words" not in article_cols: # full-article read-time (count only, no body)
|
||||||
|
conn.execute("ALTER TABLE articles ADD COLUMN source_words INTEGER")
|
||||||
|
if "read_checked_at" not in article_cols:
|
||||||
|
conn.execute("ALTER TABLE articles ADD COLUMN read_checked_at TEXT")
|
||||||
# Created here (not in SCHEMA) so it runs after the column exists on upgrades.
|
# Created here (not in SCHEMA) so it runs after the column exists on upgrades.
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_duplicate_of ON articles(duplicate_of)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_duplicate_of ON articles(duplicate_of)")
|
||||||
|
|
||||||
@@ -382,10 +614,14 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
"consecutive_failures": "INTEGER NOT NULL DEFAULT 0",
|
"consecutive_failures": "INTEGER NOT NULL DEFAULT 0",
|
||||||
"review_flag": "INTEGER NOT NULL DEFAULT 0",
|
"review_flag": "INTEGER NOT NULL DEFAULT 0",
|
||||||
"review_reason": "TEXT",
|
"review_reason": "TEXT",
|
||||||
|
"paywall_override": "TEXT", # NULL = use domain rule · 'free' · 'paywalled'
|
||||||
}
|
}
|
||||||
for column, decl in health_columns.items():
|
for column, decl in health_columns.items():
|
||||||
if column not in source_cols:
|
if column not in source_cols:
|
||||||
conn.execute(f"ALTER TABLE sources ADD COLUMN {column} {decl}")
|
conn.execute(f"ALTER TABLE sources ADD COLUMN {column} {decl}")
|
||||||
|
# Publishing Desk: the source's own verified X handle (suggested when sharing).
|
||||||
|
if "x_handle" not in source_cols:
|
||||||
|
conn.execute("ALTER TABLE sources ADD COLUMN x_handle TEXT")
|
||||||
|
|
||||||
# Lifecycle: status (active/paused/retired) + content_visible. `active` is
|
# Lifecycle: status (active/paused/retired) + content_visible. `active` is
|
||||||
# kept as a synced mirror so legacy code (scheduler/CLI) keeps working.
|
# kept as a synced mirror so legacy code (scheduler/CLI) keeps working.
|
||||||
@@ -396,12 +632,35 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute("ALTER TABLE sources ADD COLUMN content_visible INTEGER NOT NULL DEFAULT 1")
|
conn.execute("ALTER TABLE sources ADD COLUMN content_visible INTEGER NOT NULL DEFAULT 1")
|
||||||
if "retry_after_at" not in source_cols:
|
if "retry_after_at" not in source_cols:
|
||||||
conn.execute("ALTER TABLE sources ADD COLUMN retry_after_at TEXT")
|
conn.execute("ALTER TABLE sources ADD COLUMN retry_after_at TEXT")
|
||||||
|
# Image rights policy per source: 'cache' (cleared to re-host a downscaled copy),
|
||||||
|
# 'remote' (hotlink the publisher's image — the conservative DEFAULT), 'none' (no
|
||||||
|
# image). Caching is opt-in; unknown/new sources are never re-hosted.
|
||||||
|
if "image_policy" not in source_cols:
|
||||||
|
conn.execute("ALTER TABLE sources ADD COLUMN image_policy TEXT NOT NULL DEFAULT 'remote'")
|
||||||
|
|
||||||
|
# Daily Art columns added after the tables first shipped.
|
||||||
|
pool_cols = {row["name"] for row in conn.execute("PRAGMA table_info(art_pool)")}
|
||||||
|
if pool_cols and "blocked" not in pool_cols:
|
||||||
|
conn.execute("ALTER TABLE art_pool ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0")
|
||||||
|
art_cols = {row["name"] for row in conn.execute("PRAGMA table_info(daily_art)")}
|
||||||
|
if art_cols and "image_url_full" not in art_cols:
|
||||||
|
conn.execute("ALTER TABLE daily_art ADD COLUMN image_url_full TEXT")
|
||||||
|
if art_cols and "is_public_domain" not in art_cols:
|
||||||
|
conn.execute("ALTER TABLE daily_art ADD COLUMN is_public_domain INTEGER")
|
||||||
|
for column in ("blurb", "palette"): # richer /art page: guide note + extracted colors
|
||||||
|
if art_cols and column not in art_cols:
|
||||||
|
conn.execute(f"ALTER TABLE daily_art ADD COLUMN {column} TEXT")
|
||||||
|
|
||||||
# feedback.read_at (admin inbox read/unread) added later.
|
# feedback.read_at (admin inbox read/unread) added later.
|
||||||
fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")}
|
fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")}
|
||||||
if fb_cols and "read_at" not in fb_cols:
|
if fb_cols and "read_at" not in fb_cols:
|
||||||
conn.execute("ALTER TABLE feedback ADD COLUMN read_at TEXT")
|
conn.execute("ALTER TABLE feedback ADD COLUMN read_at TEXT")
|
||||||
|
|
||||||
|
# client_errors.read_at — admin triages load-error beacons (default view = unread).
|
||||||
|
ce_cols = {row["name"] for row in conn.execute("PRAGMA table_info(client_errors)")}
|
||||||
|
if ce_cols and "read_at" not in ce_cols:
|
||||||
|
conn.execute("ALTER TABLE client_errors ADD COLUMN read_at TEXT")
|
||||||
|
|
||||||
# feedback_replies.message_html (rendered Markdown subset) added later.
|
# feedback_replies.message_html (rendered Markdown subset) added later.
|
||||||
rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")}
|
rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")}
|
||||||
if rep_cols and "message_html" not in rep_cols:
|
if rep_cols and "message_html" not in rep_cols:
|
||||||
@@ -412,3 +671,11 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
for column in ("what_happened", "why_matters", "why_belongs"):
|
for column in ("what_happened", "why_matters", "why_belongs"):
|
||||||
if sum_cols and column not in sum_cols:
|
if sum_cols and column not in sum_cols:
|
||||||
conn.execute(f"ALTER TABLE article_summaries ADD COLUMN {column} TEXT")
|
conn.execute(f"ALTER TABLE article_summaries ADD COLUMN {column} TEXT")
|
||||||
|
|
||||||
|
# WOTD display polish: LLM plain-language gloss + everyday example sentences, kept
|
||||||
|
# alongside the raw dictionary def/examples (which stay the anchor / ground truth).
|
||||||
|
for tbl in ("wotd_pool", "daily_wotd"):
|
||||||
|
cols = {row["name"] for row in conn.execute(f"PRAGMA table_info({tbl})")}
|
||||||
|
for column in ("gloss", "usage"):
|
||||||
|
if cols and column not in cols:
|
||||||
|
conn.execute(f"ALTER TABLE {tbl} ADD COLUMN {column} TEXT")
|
||||||
|
|||||||
+35
-3
@@ -102,7 +102,8 @@ def cluster_duplicates(
|
|||||||
(COALESCE(s.constructive_score,0) + COALESCE(s.agency_score,0)
|
(COALESCE(s.constructive_score,0) + COALESCE(s.agency_score,0)
|
||||||
+ COALESCE(s.human_benefit_score,0) + src.trust_score
|
+ COALESCE(s.human_benefit_score,0) + src.trust_score
|
||||||
- COALESCE(s.cortisol_score,0) - COALESCE(s.ragebait_score,0)
|
- COALESCE(s.cortisol_score,0) - COALESCE(s.ragebait_score,0)
|
||||||
- COALESCE(s.pr_risk_score,0)) AS rank_score
|
- COALESCE(s.pr_risk_score,0)) AS rank_score,
|
||||||
|
COALESCE(s.accepted, 0) AS accepted
|
||||||
FROM articles a
|
FROM articles a
|
||||||
JOIN article_embeddings e ON e.article_id = a.id
|
JOIN article_embeddings e ON e.article_id = a.id
|
||||||
JOIN sources src ON src.id = a.source_id
|
JOIN sources src ON src.id = a.source_id
|
||||||
@@ -114,7 +115,8 @@ def cluster_duplicates(
|
|||||||
items = []
|
items = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
vec = _unit(array("f", r["vector"]).tolist())
|
vec = _unit(array("f", r["vector"]).tolist())
|
||||||
items.append({"id": r["id"], "ord": _day_ordinal(r["dt"]), "vec": vec, "score": r["rank_score"]})
|
items.append({"id": r["id"], "ord": _day_ordinal(r["dt"]), "vec": vec,
|
||||||
|
"score": r["rank_score"], "accepted": bool(r["accepted"])})
|
||||||
|
|
||||||
clusters: list[dict] = [] # {anchor_vec, anchor_ord, members:[item]}
|
clusters: list[dict] = [] # {anchor_vec, anchor_ord, members:[item]}
|
||||||
for it in items:
|
for it in items:
|
||||||
@@ -130,6 +132,14 @@ def cluster_duplicates(
|
|||||||
if not placed:
|
if not placed:
|
||||||
clusters.append({"anchor_vec": it["vec"], "anchor_ord": it["ord"], "members": [it]})
|
clusters.append({"anchor_vec": it["vec"], "anchor_ord": it["ord"], "members": [it]})
|
||||||
|
|
||||||
|
# Which articles are CURRENTLY a representative (something points at them)? Captured
|
||||||
|
# BEFORE we reset, so we can keep an established canonical stable across runs.
|
||||||
|
prior_reps = {
|
||||||
|
row[0] for row in conn.execute(
|
||||||
|
"SELECT DISTINCT duplicate_of FROM articles WHERE duplicate_of IS NOT NULL"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
# Reset prior decisions for everything we considered, then re-apply.
|
# Reset prior decisions for everything we considered, then re-apply.
|
||||||
considered = [it["id"] for it in items]
|
considered = [it["id"] for it in items]
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
@@ -142,7 +152,19 @@ def cluster_duplicates(
|
|||||||
if len(cl["members"]) < 2:
|
if len(cl["members"]) < 2:
|
||||||
continue
|
continue
|
||||||
dup_clusters += 1
|
dup_clusters += 1
|
||||||
rep = max(cl["members"], key=lambda m: (m["score"], -m["id"]))
|
# Representative priority (highest wins), in order:
|
||||||
|
# 1. accepted/serveable — an accepted page must never be retired to a REJECTED
|
||||||
|
# rep (that page would 404 with nothing to redirect to).
|
||||||
|
# 2. established rep — if a member is already the cluster's canonical, keep it,
|
||||||
|
# so an indexed URL doesn't churn when a newer twin arrives.
|
||||||
|
# 3. quality score — decides genuinely-new clusters.
|
||||||
|
# 4. -id — deterministic final tiebreak (older wins).
|
||||||
|
rep = max(cl["members"], key=lambda m: (
|
||||||
|
1 if m["accepted"] else 0,
|
||||||
|
1 if m["id"] in prior_reps else 0,
|
||||||
|
m["score"],
|
||||||
|
-m["id"],
|
||||||
|
))
|
||||||
for m in cl["members"]:
|
for m in cl["members"]:
|
||||||
if m["id"] != rep["id"]:
|
if m["id"] != rep["id"]:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -166,6 +188,16 @@ def dedup(
|
|||||||
embed_limit: int | None = None,
|
embed_limit: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
embedded = ensure_embeddings(conn, client, limit=embed_limit)
|
embedded = ensure_embeddings(conn, client, limit=embed_limit)
|
||||||
|
if embedded == 0:
|
||||||
|
# Nothing new entered the corpus → the clusters and duplicate_of links are
|
||||||
|
# unchanged, so skip the full re-cluster. It was re-running an O(n²) cosine
|
||||||
|
# pass over EVERY article and rewriting duplicate_of for all ~3.7k of them
|
||||||
|
# every cycle (~53s + a large WAL commit), which starved live API reads
|
||||||
|
# (/api/brief 2-7s). Most cycles find no new articles, so this makes the
|
||||||
|
# cycle near-instant and keeps reads fast. A real new article re-runs it.
|
||||||
|
dups = conn.execute("SELECT COUNT(*) FROM articles WHERE duplicate_of IS NOT NULL").fetchone()[0]
|
||||||
|
return {"embedded": 0, "articles": 0, "clusters": 0, "duplicate_clusters": 0,
|
||||||
|
"duplicates": dups, "skipped": True}
|
||||||
stats = cluster_duplicates(conn, threshold=threshold, window_days=window_days)
|
stats = cluster_duplicates(conn, threshold=threshold, window_days=window_days)
|
||||||
stats["embedded"] = embedded
|
stats["embedded"] = embedded
|
||||||
return stats
|
return stats
|
||||||
|
|||||||
+21
-16
@@ -16,7 +16,8 @@ from html import escape
|
|||||||
|
|
||||||
from . import email_send
|
from . import email_send
|
||||||
from .localtime import local_now, local_today
|
from .localtime import local_now, local_today
|
||||||
from .paywall import is_paywalled
|
from .paywall import is_paywalled, is_paywalled_for_source
|
||||||
|
from .queries import paywalled_source_ids
|
||||||
|
|
||||||
DIGEST_HOUR = int(os.environ.get("GOODNEWS_DIGEST_HOUR", "7"))
|
DIGEST_HOUR = int(os.environ.get("GOODNEWS_DIGEST_HOUR", "7"))
|
||||||
DIGEST_WINDOW_HOURS = 4 # send between DIGEST_HOUR and +4h, site-local
|
DIGEST_WINDOW_HOURS = 4 # send between DIGEST_HOUR and +4h, site-local
|
||||||
@@ -29,25 +30,27 @@ def _base_url() -> str:
|
|||||||
|
|
||||||
def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> list[dict]:
|
def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> list[dict]:
|
||||||
"""The brief's items with the bits a calm email needs (visible sources only)."""
|
"""The brief's items with the bits a calm email needs (visible sources only)."""
|
||||||
|
pwx = paywalled_source_ids(conn)
|
||||||
|
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
f"""
|
||||||
SELECT a.id, a.title, a.canonical_url, s.name AS source, sc.reason_text,
|
SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, sc.reason_text,
|
||||||
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
|
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
|
||||||
FROM daily_briefs b
|
FROM daily_briefs b
|
||||||
JOIN daily_brief_items bi ON bi.brief_id = b.id
|
JOIN daily_brief_items bi ON bi.brief_id = b.id
|
||||||
JOIN articles a ON a.id = bi.article_id
|
JOIN articles a ON a.id = bi.article_id
|
||||||
JOIN sources s ON s.id = a.source_id
|
JOIN sources s ON s.id = a.source_id
|
||||||
LEFT JOIN article_scores sc ON sc.article_id = a.id
|
LEFT JOIN article_scores sc ON sc.article_id = a.id
|
||||||
WHERE b.brief_date = ? AND s.content_visible = 1
|
WHERE b.brief_date = ? AND s.content_visible = 1{pw_clause}
|
||||||
ORDER BY bi.rank
|
ORDER BY bi.rank
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(brief_date, limit),
|
(brief_date, *pwx, limit),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
items = []
|
items = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = dict(r)
|
d = dict(r)
|
||||||
d["paywalled"] = is_paywalled(d["canonical_url"])
|
d["paywalled"] = is_paywalled_for_source(d["canonical_url"], d.get("paywall_override"))
|
||||||
items.append(d)
|
items.append(d)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@@ -72,19 +75,21 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
|
|||||||
f"AND at.tag IN ({','.join('?' * len(ftags))}))"
|
f"AND at.tag IN ({','.join('?' * len(ftags))}))"
|
||||||
)
|
)
|
||||||
params += ftags
|
params += ftags
|
||||||
|
pwx = paywalled_source_ids(conn)
|
||||||
|
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT a.id, a.title, a.canonical_url, s.name AS source, a.source_id, sc.reason_text,
|
SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, a.source_id, sc.reason_text,
|
||||||
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
|
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
|
||||||
FROM articles a
|
FROM articles a
|
||||||
JOIN sources s ON s.id = a.source_id
|
JOIN sources s ON s.id = a.source_id
|
||||||
JOIN article_scores sc ON sc.article_id = a.id
|
JOIN article_scores sc ON sc.article_id = a.id
|
||||||
WHERE sc.accepted = 1 AND a.duplicate_of IS NULL AND s.content_visible = 1
|
WHERE sc.accepted = 1 AND a.duplicate_of IS NULL AND s.content_visible = 1{pw_clause}
|
||||||
AND ({' OR '.join(ors)})
|
AND ({' OR '.join(ors)})
|
||||||
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
|
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
|
||||||
LIMIT 30
|
LIMIT 30
|
||||||
""",
|
""",
|
||||||
params,
|
[*pwx, *params],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
exclude, per_source, out = set(exclude_ids), {}, []
|
exclude, per_source, out = set(exclude_ids), {}, []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -92,7 +97,7 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
|
|||||||
if d["id"] in exclude or per_source.get(d["source_id"], 0) >= 1:
|
if d["id"] in exclude or per_source.get(d["source_id"], 0) >= 1:
|
||||||
continue
|
continue
|
||||||
per_source[d["source_id"]] = 1
|
per_source[d["source_id"]] = 1
|
||||||
d["paywalled"] = is_paywalled(d["canonical_url"])
|
d["paywalled"] = is_paywalled_for_source(d["canonical_url"], d.get("paywall_override"))
|
||||||
out.append(d)
|
out.append(d)
|
||||||
if len(out) >= limit:
|
if len(out) >= limit:
|
||||||
break
|
break
|
||||||
@@ -127,7 +132,7 @@ def _item_html(it: dict, base: str) -> str:
|
|||||||
f'<div style="color:#5d6b78;font-size:13px;margin:3px 0 8px">{escape(it["source"])}</div>'
|
f'<div style="color:#5d6b78;font-size:13px;margin:3px 0 8px">{escape(it["source"])}</div>'
|
||||||
f'{summary}{why}'
|
f'{summary}{why}'
|
||||||
'<div style="margin-top:10px;font-size:14px">'
|
'<div style="margin-top:10px;font-size:14px">'
|
||||||
f'<a href="{base}/a/{it["id"]}" style="color:#0083ad;text-decoration:none">Read on Upbeat Bytes</a>'
|
f'<a href="{base}/a/{it["id"]}" style="color:#0083ad;text-decoration:none">Read on upbeatBytes</a>'
|
||||||
f' · <a href="{escape(it["canonical_url"])}" style="color:#5d6b78;text-decoration:none">Full story at source{lock}</a>'
|
f' · <a href="{escape(it["canonical_url"])}" style="color:#5d6b78;text-decoration:none">Full story at source{lock}</a>'
|
||||||
'</div></div>'
|
'</div></div>'
|
||||||
)
|
)
|
||||||
@@ -145,12 +150,12 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
|
|||||||
followed = followed or []
|
followed = followed or []
|
||||||
n = len(items)
|
n = len(items)
|
||||||
weekday = _weekday(brief_date)
|
weekday = _weekday(brief_date)
|
||||||
subject = f"{weekday}'s Upbeat Bytes · {n} calm read{'' if n == 1 else 's'}"
|
subject = f"{weekday}'s upbeatBytes · {n} calm read{'' if n == 1 else 's'}"
|
||||||
if weekday == "today":
|
if weekday == "today":
|
||||||
subject = f"Today's Upbeat Bytes · {n} calm reads"
|
subject = f"Today's upbeatBytes · {n} calm reads"
|
||||||
|
|
||||||
text_lines = [
|
text_lines = [
|
||||||
f"Upbeat Bytes — Daily Highlights",
|
f"upbeatBytes — Daily Highlights",
|
||||||
f"{n} calm read{'' if n == 1 else 's'} for {weekday}.\n",
|
f"{n} calm read{'' if n == 1 else 's'} for {weekday}.\n",
|
||||||
"Good morning. A small, hopeful handful of what's going right — and there's",
|
"Good morning. A small, hopeful handful of what's going right — and there's",
|
||||||
"always more waiting on the site whenever you want it.\n",
|
"always more waiting on the site whenever you want it.\n",
|
||||||
@@ -181,7 +186,7 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
|
|||||||
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
|
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
|
||||||
# The real logo as a small hosted PNG (SVG isn't email-safe); alt text
|
# The real logo as a small hosted PNG (SVG isn't email-safe); alt text
|
||||||
# keeps the brand when a client blocks remote images.
|
# keeps the brand when a client blocks remote images.
|
||||||
f'<img src="{base}/logo-email.png" alt="Upbeat Bytes" width="180" '
|
f'<img src="{base}/logo-email.png" alt="upbeatBytes" width="180" '
|
||||||
'style="display:block;border:0;outline:none;text-decoration:none;height:auto;margin:0 0 2px">'
|
'style="display:block;border:0;outline:none;text-decoration:none;height:auto;margin:0 0 2px">'
|
||||||
'<div style="font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:#0083ad;'
|
'<div style="font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:#0083ad;'
|
||||||
f'margin:5px 0 0">Daily Highlights · {escape(weekday)}</div>'
|
f'margin:5px 0 0">Daily Highlights · {escape(weekday)}</div>'
|
||||||
@@ -192,7 +197,7 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
|
|||||||
+ main_blocks
|
+ main_blocks
|
||||||
+ followed_html
|
+ followed_html
|
||||||
+ '<p style="font-size:15px;color:#3f7048;margin:8px 0 0">That’s today’s highlights — more good news is '
|
+ '<p style="font-size:15px;color:#3f7048;margin:8px 0 0">That’s today’s highlights — more good news is '
|
||||||
f'always <a href="{base}" style="color:#3f7048">waiting on Upbeat Bytes</a>. See you tomorrow.</p>'
|
f'always <a href="{base}" style="color:#3f7048">waiting on upbeatBytes</a>. See you tomorrow.</p>'
|
||||||
f'<p style="font-size:12px;color:#9aa6b2;margin-top:24px">You’re getting this because you turned on '
|
f'<p style="font-size:12px;color:#9aa6b2;margin-top:24px">You’re getting this because you turned on '
|
||||||
f'the daily digest. <a href="{unsub_url}" style="color:#9aa6b2">Unsubscribe</a>.</p>'
|
f'the daily digest. <a href="{unsub_url}" style="color:#9aa6b2">Unsubscribe</a>.</p>'
|
||||||
'</div>'
|
'</div>'
|
||||||
|
|||||||
+10
-10
@@ -24,7 +24,7 @@ def _cfg() -> dict:
|
|||||||
"port": int(os.environ.get("GOODNEWS_SMTP_PORT", "587")),
|
"port": int(os.environ.get("GOODNEWS_SMTP_PORT", "587")),
|
||||||
"user": os.environ.get("GOODNEWS_SMTP_USER", ""),
|
"user": os.environ.get("GOODNEWS_SMTP_USER", ""),
|
||||||
"password": os.environ.get("GOODNEWS_SMTP_PASSWORD", ""),
|
"password": os.environ.get("GOODNEWS_SMTP_PASSWORD", ""),
|
||||||
"sender": os.environ.get("GOODNEWS_SMTP_FROM", "Upbeat Bytes <hello@upbeatbytes.com>"),
|
"sender": os.environ.get("GOODNEWS_SMTP_FROM", "upbeatBytes <hello@upbeatbytes.com>"),
|
||||||
# Where a reader's reply should land; falls back to the From address.
|
# Where a reader's reply should land; falls back to the From address.
|
||||||
"reply_to": os.environ.get("GOODNEWS_REPLY_TO_EMAIL", ""),
|
"reply_to": os.environ.get("GOODNEWS_REPLY_TO_EMAIL", ""),
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ def send_email(to: str, subject: str, text: str, html: str | None = None, reply_
|
|||||||
|
|
||||||
def send_feedback(to: str, category: str, message: str, contact: str | None, who: str) -> None:
|
def send_feedback(to: str, category: str, message: str, contact: str | None, who: str) -> None:
|
||||||
"""Notify the admin of new user feedback (plain text is plenty here)."""
|
"""Notify the admin of new user feedback (plain text is plenty here)."""
|
||||||
subject = f"Upbeat Bytes feedback · {category}"
|
subject = f"upbeatBytes feedback · {category}"
|
||||||
reply = contact or "(none given)"
|
reply = contact or "(none given)"
|
||||||
text = (
|
text = (
|
||||||
f"New feedback ({category})\n"
|
f"New feedback ({category})\n"
|
||||||
@@ -74,14 +74,14 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin
|
|||||||
"""Reply to a reader's feedback from the admin inbox. Sends multipart
|
"""Reply to a reader's feedback from the admin inbox. Sends multipart
|
||||||
text/plain + text/html (the HTML is the pre-sanitized Markdown render). Quotes
|
text/plain + text/html (the HTML is the pre-sanitized Markdown render). Quotes
|
||||||
their original note for context; exposes no analytics/account details."""
|
their original note for context; exposes no analytics/account details."""
|
||||||
subject = "Re: Your Upbeat Bytes feedback"
|
subject = "Re: Your upbeatBytes feedback"
|
||||||
quoted = "\n".join("> " + line for line in (original_message or "").splitlines())
|
quoted = "\n".join("> " + line for line in (original_message or "").splitlines())
|
||||||
text = (
|
text = (
|
||||||
f"{reply_text}\n\n"
|
f"{reply_text}\n\n"
|
||||||
"—\n"
|
"—\n"
|
||||||
"In reply to your note to Upbeat Bytes:\n"
|
"In reply to your note to upbeatBytes:\n"
|
||||||
f"{quoted}\n\n"
|
f"{quoted}\n\n"
|
||||||
"Thanks for reaching out.\n— Upbeat Bytes\n"
|
"Thanks for reaching out.\n— upbeatBytes\n"
|
||||||
)
|
)
|
||||||
body_html = None
|
body_html = None
|
||||||
if reply_html:
|
if reply_html:
|
||||||
@@ -90,9 +90,9 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin
|
|||||||
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
||||||
'color:#16263a;font-size:15px;line-height:1.5">'
|
'color:#16263a;font-size:15px;line-height:1.5">'
|
||||||
f"{reply_html}"
|
f"{reply_html}"
|
||||||
'<p style="color:#5d6b78;font-size:13px;margin-top:20px">In reply to your note to Upbeat Bytes:</p>'
|
'<p style="color:#5d6b78;font-size:13px;margin-top:20px">In reply to your note to upbeatBytes:</p>'
|
||||||
f'<blockquote style="color:#5d6b78;border-left:3px solid #e8e3d8;margin:0;padding-left:12px">{oq}</blockquote>'
|
f'<blockquote style="color:#5d6b78;border-left:3px solid #e8e3d8;margin:0;padding-left:12px">{oq}</blockquote>'
|
||||||
"<p>Thanks for reaching out.<br>— Upbeat Bytes</p></div>"
|
"<p>Thanks for reaching out.<br>— upbeatBytes</p></div>"
|
||||||
)
|
)
|
||||||
# Route the reader's reply to our chosen inbox (never back to the reader).
|
# Route the reader's reply to our chosen inbox (never back to the reader).
|
||||||
cfg = _cfg()
|
cfg = _cfg()
|
||||||
@@ -102,9 +102,9 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin
|
|||||||
|
|
||||||
def send_magic_link(to: str, link: str) -> None:
|
def send_magic_link(to: str, link: str) -> None:
|
||||||
"""Send a calm, single-purpose sign-in email."""
|
"""Send a calm, single-purpose sign-in email."""
|
||||||
subject = "Your Upbeat Bytes sign-in link"
|
subject = "Your upbeatBytes sign-in link"
|
||||||
text = (
|
text = (
|
||||||
"Welcome back to Upbeat Bytes.\n\n"
|
"Welcome back to upbeatBytes.\n\n"
|
||||||
f"Tap to sign in:\n{link}\n\n"
|
f"Tap to sign in:\n{link}\n\n"
|
||||||
"This link works once and expires in 15 minutes.\n"
|
"This link works once and expires in 15 minutes.\n"
|
||||||
"If you didn't request it, you can safely ignore this email."
|
"If you didn't request it, you can safely ignore this email."
|
||||||
@@ -113,7 +113,7 @@ def send_magic_link(to: str, link: str) -> None:
|
|||||||
html = (
|
html = (
|
||||||
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
||||||
'color:#16263a;line-height:1.6">'
|
'color:#16263a;line-height:1.6">'
|
||||||
"<p>Welcome back to <strong>Upbeat Bytes</strong>.</p>"
|
"<p>Welcome back to <strong>upbeatBytes</strong>.</p>"
|
||||||
f'<p><a href="{safe}" style="display:inline-block;background:#0083ad;color:#fff;'
|
f'<p><a href="{safe}" style="display:inline-block;background:#0083ad;color:#fff;'
|
||||||
'text-decoration:none;padding:11px 20px;border-radius:999px;font-weight:600">'
|
'text-decoration:none;padding:11px 20px;border-radius:999px;font-weight:600">'
|
||||||
"Sign in</a></p>"
|
"Sign in</a></p>"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user