Compare commits
97 Commits
cebbed58ab
..
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 |
@@ -10,3 +10,5 @@ data/geo_audit*.json
|
||||
|
||||
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.
|
||||
(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.
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -46,6 +46,22 @@ upbeatbytes.com {
|
||||
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
|
||||
@@ -60,7 +76,8 @@ upbeatbytes.com {
|
||||
# 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.
|
||||
header @hidden X-Robots-Tag "noindex, nofollow"
|
||||
@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/*
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
#!/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.
|
||||
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 \
|
||||
|| 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
|
||||
# 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
|
||||
# `rsync --delete` can briefly serve a NEW index.html that points at chunks not
|
||||
# synced yet (→ failed load), or delete old chunks an in-flight client still
|
||||
# needs. And even once synced, a new shell can point at chunks that aren't warm
|
||||
# at the CDN edge yet, so the unlucky first visitor fetches them cold from the
|
||||
# (residential) origin — the post-deploy slow-load window. So the order is:
|
||||
# 1. new hashed chunks first, and DON'T prune old ones (grace window)
|
||||
# 2. WARM those chunks at the edge BEFORE the shell goes live, so a new shell
|
||||
# never references cold chunks
|
||||
# 3. other static assets (version.json, icons…), pruning removed files
|
||||
# 4. the shell HTML — only once its chunks exist AND are warm
|
||||
# 5. the service worker last — a returning client adopts it only after the rest
|
||||
# 6. warm the shell + key routes after publish
|
||||
# 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.
|
||||
# blank screens. rsync isn't atomic, so a naïve `rsync --delete` can briefly serve
|
||||
# a NEW index.html that points at chunks not synced yet (→ failed load), or delete
|
||||
# old chunks an in-flight client still needs. So the order is:
|
||||
# 1. new hashed chunks first, and DON'T prune old ones (14-day grace window)
|
||||
# 2. other static assets (version.json, icons…), pruning removed files
|
||||
# 3. the shell HTML — only once its chunks exist
|
||||
# 4. the service worker last — a returning client adopts it only after the rest
|
||||
# Old immutable chunks are pruned after the grace window to bound disk growth.
|
||||
#
|
||||
# NOTE: there is intentionally NO edge cache-warming. Cloudflare is DNS-only
|
||||
# (grey-cloud) for upbeatbytes.com — no proxy/CDN/POP — so warming would just GET
|
||||
# every asset from the residential origin (already µs-fast static): a no-op that
|
||||
# generated lots of internal request volume. Removed 2026-06-29.
|
||||
set -euo pipefail
|
||||
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).
|
||||
rsync -a "$src/_app/immutable/" "$site/_app/immutable/"
|
||||
|
||||
# 2. Warm the chunks BEFORE publishing the shell, so the new shell never points
|
||||
# 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.
|
||||
# 2. Other static assets (prune removed files), then 3. the shell, then 4. the SW.
|
||||
rsync -a --delete \
|
||||
--exclude='_app/immutable/***' --exclude='index.html' --exclude='service-worker.js' \
|
||||
"$src/" "$site/"
|
||||
rsync -a "$src/index.html" "$site/index.html"
|
||||
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.
|
||||
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.
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev --host",
|
||||
"build": "vite build",
|
||||
"postbuild": "node scripts/patch-play-head.mjs",
|
||||
"postbuild": "node scripts/patch-static-heads.mjs",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Post-build: give build/play.html its 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 link previews as the news homepage. Client
|
||||
// svelte:head can't fix that for non-JS social scrapers (Twitter/Slack/iMessage/etc.).
|
||||
// So we rewrite the static head of play.html here, at build time. Deep-linked variants
|
||||
// (/play?game=…) are served the same file, so they inherit this games-hub preview.
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
const FILE = new URL('../build/play.html', import.meta.url);
|
||||
const URL_PLAY = 'https://upbeatbytes.com/play';
|
||||
const TITLE = 'Play · Upbeat Bytes — calm daily games';
|
||||
const DESC =
|
||||
'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
|
||||
'A friendly little break from the doomscroll.';
|
||||
|
||||
const subs = [
|
||||
[/<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_PLAY}" />`],
|
||||
[/<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_PLAY}" />`],
|
||||
[/<meta name="twitter:title" content="[^"]*"\s*\/>/, `<meta name="twitter:title" content="${TITLE}" />`],
|
||||
[/<meta name="twitter:description" content="[^"]*"\s*\/>/, `<meta name="twitter:description" content="${DESC}" />`],
|
||||
];
|
||||
|
||||
let html = await readFile(FILE, 'utf8');
|
||||
const missed = [];
|
||||
for (const [re, repl] of subs) {
|
||||
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 /play preview again.
|
||||
if (missed.length) {
|
||||
console.error('patch-play-head: these head tags were not found (app.html changed?):\n ' + missed.join('\n '));
|
||||
process.exit(1);
|
||||
}
|
||||
await writeFile(FILE, html);
|
||||
console.log('patch-play-head: rewrote build/play.html head → /play metadata');
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* Upbeat Bytes calm design system.
|
||||
/* upbeatBytes calm design system.
|
||||
Sand, sea, and sun: warm paper surfaces, a vivid-azure accent, gold highlight,
|
||||
a serif voice for headlines, strong readable contrast, generous space.
|
||||
No urgency colors (no red). Built around the logo's #0083ad azure. */
|
||||
@@ -34,7 +34,10 @@
|
||||
}
|
||||
|
||||
* { 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 {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
@@ -72,5 +75,5 @@ button { font-family: inherit; cursor: pointer; }
|
||||
/play and always removed on navigation via effect cleanup. Mobile only. */
|
||||
@media (max-width: 720px) {
|
||||
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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0083ad" />
|
||||
<meta name="description" content="Calm, constructive news worth your attention — and nothing that isn't." />
|
||||
<title>Upbeat Bytes — calm, constructive news</title>
|
||||
<meta name="description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
|
||||
<title>upbeatBytes — a calmer, brighter corner of the internet</title>
|
||||
<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:title" content="Upbeat Bytes — calm, constructive news" />
|
||||
<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:title" content="upbeatBytes — a calmer, brighter corner of the internet" />
|
||||
<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 name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Upbeat Bytes — calm, constructive news" />
|
||||
<meta name="twitter:description" content="Calm, constructive news, summarized — get the gist, go deeper only if you want." />
|
||||
<meta name="twitter:title" content="upbeatBytes — a calmer, brighter corner of the internet" />
|
||||
<meta name="twitter:description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
|
||||
%sveltekit.head%
|
||||
<style>
|
||||
#boot-fallback {
|
||||
@@ -128,9 +128,9 @@
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
<div id="boot-fallback" role="alert" aria-live="polite">
|
||||
<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>
|
||||
<button type="button" onclick="location.reload()">Refresh Upbeat Bytes</button>
|
||||
<button type="button" onclick="location.reload()">Refresh upbeatBytes</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// admin dashboard tell "new vs returning" in aggregate. Server hashes it.
|
||||
const VISITOR_KEY = 'goodnews:visitor';
|
||||
const VISITDAY_KEY = 'goodnews:visitday';
|
||||
const ENGAGEDDAY_KEY = 'goodnews:engagedday';
|
||||
|
||||
export function visitorId() {
|
||||
try {
|
||||
@@ -63,3 +64,38 @@ export function trackVisit() {
|
||||
/* 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);
|
||||
}
|
||||
|
||||
// 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 copied = $state('');
|
||||
const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
|
||||
@@ -100,12 +100,13 @@
|
||||
>
|
||||
{#if showImage}
|
||||
<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"
|
||||
onerror={() => { failed = true; onimageerror?.(); }} />
|
||||
</a>
|
||||
{:else if usePlaceholder}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
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 = `Upbeat Bytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\n${gameShareUrl('bloom', bloomV)}`;
|
||||
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); });
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<div class="container bar">
|
||||
<a class="brand" href="/" aria-label="Upbeat Bytes — home">
|
||||
<img class="logo" src="/logo.svg" alt="Upbeat Bytes" width="586" height="196" />
|
||||
<a class="brand" href="/" aria-label="upbeatBytes — home">
|
||||
<img class="logo" src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
||||
</a>
|
||||
|
||||
<nav class="utils" aria-label="Your controls">
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
<script>
|
||||
// Shared editorial top bar for the hub (/home3) and its detail pages (/word, /quote,
|
||||
// /onthisday). Full horizontal nav on wide screens; a hamburger + drop panel on phones
|
||||
// so the bar stays clean. `active` highlights the current section.
|
||||
let { active = '' } = $props();
|
||||
// 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(() => {
|
||||
@@ -14,18 +23,18 @@
|
||||
return () => mq.removeEventListener('change', sync);
|
||||
});
|
||||
|
||||
const LINKS = [
|
||||
{ key: 'home', href: '/home3', label: 'Home' },
|
||||
{ key: 'news', href: '/', label: 'News' },
|
||||
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="/home3" aria-label="upbeatBytes home">
|
||||
<a class="brand" href="/" aria-label="upbeatBytes home">
|
||||
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
||||
</a>
|
||||
|
||||
@@ -37,10 +46,14 @@
|
||||
<span class="nav-soon">Entertainment</span>
|
||||
</nav>
|
||||
|
||||
<a class="acct" href="/account" aria-label="Your account">
|
||||
<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"
|
||||
@@ -62,24 +75,31 @@
|
||||
{/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: var(--body, #6b6256); text-decoration: none; }
|
||||
.nav a { color: #6b6256; text-decoration: none; }
|
||||
.nav a.on { color: #23201b; }
|
||||
.nav a:hover { color: var(--teal, #0083ad); }
|
||||
.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 */
|
||||
@@ -95,18 +115,18 @@
|
||||
.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); }
|
||||
.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 var(--news-border, #f2e7d3);
|
||||
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: var(--body, #6b6256); border-top: 1px solid #f3ece0;
|
||||
color: #6b6256; border-top: 1px solid #f3ece0;
|
||||
}
|
||||
.menu a:first-child { border-top: none; }
|
||||
.menu a.on { color: #23201b; }
|
||||
.menu a:hover { background: var(--canvas, #FFF9EF); color: var(--teal, #0083ad); }
|
||||
.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; }
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 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
|
||||
@@ -14,7 +15,7 @@
|
||||
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('/home3', { replaceState: true });
|
||||
else goto('/', { replaceState: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -33,7 +34,7 @@
|
||||
{@render children?.()}
|
||||
</main>
|
||||
|
||||
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -65,10 +66,4 @@
|
||||
.back:hover { color: var(--teal); }
|
||||
.back svg { transition: transform 0.15s ease; }
|
||||
.back:hover svg { transform: translateX(-2px); }
|
||||
|
||||
.foot {
|
||||
text-align: center; max-width: 1180px; width: 100%; margin: 14px auto 0; box-sizing: border-box;
|
||||
padding: 20px clamp(18px, 5vw, 44px) 30px; font-size: 13px; color: var(--muted);
|
||||
border-top: 1px solid var(--news-border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
function share() {
|
||||
const label = `${TIER_LABEL[tier] || tier} · ${format === 'colors' ? 'colors' : 'icons'}`;
|
||||
const when = isFree ? 'Free play' : date;
|
||||
const text = `Upbeat Bytes · Memory Match (${label}) ${when}\nCleared in ${moves} moves\n${gameShareUrl('match', `${mode}-${format}-${tier}`)}`;
|
||||
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); });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@
|
||||
</p>
|
||||
<button class="primary" onclick={onclose}>Done</button>
|
||||
{:else}
|
||||
<h2>Sign in to Upbeat Bytes</h2>
|
||||
<h2>Sign in to upbeatBytes</h2>
|
||||
<p class="sub">
|
||||
Save articles and keep your history across devices.
|
||||
</p>
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
const label = variant === '6' ? 'Long Word' : 'Daily Word';
|
||||
const score = status === 'won' ? guesses.length : 'X';
|
||||
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
|
||||
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
|
||||
const text = `upbeatBytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
|
||||
// Count a share only once it actually happens (sheet completed / clipboard wrote),
|
||||
// never on a cancelled share sheet or denied clipboard.
|
||||
if (navigator.share) navigator.share({ text }).then(() => trackGame('word', 'shared')).catch(() => {});
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
|
||||
function share() {
|
||||
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
|
||||
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
|
||||
const text = `upbeatBytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
|
||||
if (navigator.share) navigator.share({ text }).then(() => trackGame('wordsearch', 'shared')).catch(() => {});
|
||||
else navigator.clipboard?.writeText(text).then(() => { trackGame('wordsearch', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -15,25 +15,28 @@
|
||||
// /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-split.glb';
|
||||
const MODEL_URL = '/models/ub-angelfish.glb';
|
||||
|
||||
// Converged Phase-A starting point (Claude + Codex): a hair off strict profile so
|
||||
// the mouth/chin reads naturally; tail OPAQUE alpha-tested (one coherent tail, no
|
||||
// blend bleed); fins translucent + single-sided (kills the double-sided ghosting
|
||||
// that read as an off-center dorsal). All live-tunable at /zen?debug=1.
|
||||
// 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 = {
|
||||
yaw: Math.PI / 2 + 0.10, // ub.rotation.y — slight 3/4 view
|
||||
pitch: 0, // ub.rotation.x — nose up/down
|
||||
tailTranslucent: false, // false = opaque alpha-tested (coherent); true = blended
|
||||
tailSide: 'front', // front | back | double
|
||||
tailAlphaTest: 0.035,
|
||||
tailOpacity: 1.0, // only when translucent
|
||||
finSide: 'front', // front | back | double
|
||||
finOpacity: 0.75,
|
||||
finAlphaTest: 0.02,
|
||||
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,
|
||||
frame: 0, // 0..1 scrub position when paused
|
||||
};
|
||||
|
||||
const SIDE = { front: THREE.FrontSide, back: THREE.BackSide, double: THREE.DoubleSide };
|
||||
@@ -76,58 +79,70 @@ export async function createAquarium(canvas, initial = {}) {
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
ub.position.sub(center);
|
||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
ub.scale.setScalar(2.6 / maxDim);
|
||||
const baseScale = 2.6 / maxDim; // auto-fit; params.scale fine-tunes it live
|
||||
|
||||
// Collect the three named materials/meshes so applyMaterials can retarget them
|
||||
// live without re-traversing semantics.
|
||||
// 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) part[o.material.name] = o; });
|
||||
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.UB_Body, fins = part.UB_Fins, tail = part.UB_Tail;
|
||||
if (body) {
|
||||
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) {
|
||||
if (fins) { // fins (+ tail): opaque alpha-tested by default
|
||||
const m = fins.material;
|
||||
m.transparent = true; m.opacity = params.finOpacity; m.alphaTest = params.finAlphaTest;
|
||||
m.alphaToCoverage = true; m.depthWrite = false; m.depthTest = true;
|
||||
m.side = SIDE[params.finSide] ?? THREE.FrontSide;
|
||||
fins.renderOrder = 3; m.needsUpdate = true;
|
||||
}
|
||||
if (tail) {
|
||||
const m = tail.material;
|
||||
m.alphaTest = params.tailAlphaTest; m.alphaToCoverage = true; m.depthTest = true;
|
||||
m.side = SIDE[params.tailSide] ?? THREE.FrontSide;
|
||||
if (params.tailTranslucent) {
|
||||
m.transparent = true; m.opacity = params.tailOpacity; m.depthWrite = false;
|
||||
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; // opaque, coherent
|
||||
m.transparent = false; m.opacity = 1; m.depthWrite = true; // clean, coherent
|
||||
}
|
||||
tail.renderOrder = 2; m.needsUpdate = true;
|
||||
fins.renderOrder = 2; m.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function applyTransform() {
|
||||
ub.rotation.set(params.pitch, params.yaw, 0);
|
||||
}
|
||||
|
||||
applyMaterials();
|
||||
applyTransform();
|
||||
scene.add(ub);
|
||||
ub.scale.setScalar(baseScale * (params.scale ?? 1));
|
||||
|
||||
// Animation — keep every action by name so Phase B can crossfade (Idle_swim →
|
||||
// Eat_Up / Roll). Idle_swim is the base loop.
|
||||
// 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);
|
||||
const baseClip = (actions.Idle_swim ? actions.Idle_swim : mixer.clipAction(gltf.animations[0]));
|
||||
baseClip.play();
|
||||
const baseDuration = baseClip.getClip().duration || 1;
|
||||
mixer.timeScale = reduced ? 0.6 : 1; // calmer when reduced-motion
|
||||
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
|
||||
@@ -140,22 +155,37 @@ export async function createAquarium(canvas, initial = {}) {
|
||||
const clock = new THREE.Clock();
|
||||
renderer.setAnimationLoop(() => {
|
||||
const dt = clock.getDelta();
|
||||
if (params.paused) {
|
||||
mixer.setTime(params.frame * baseDuration); // scrub to a frozen frame
|
||||
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 {
|
||||
mixer.update(dt);
|
||||
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 Phase B tuning + the /zen?debug=1 panel
|
||||
ub, actions, mixer, scene, camera, params, baseDuration,
|
||||
// live setter: merge new values, re-apply materials + transform
|
||||
// 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();
|
||||
applyTransform();
|
||||
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 }; },
|
||||
|
||||
@@ -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,8 +2,8 @@
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
|
||||
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
|
||||
import { trackVisit } from '$lib/analytics.js';
|
||||
import { fb, closeFeedback } from '$lib/feedback.svelte.js';
|
||||
import { trackVisit, armEngaged } from '$lib/analytics.js';
|
||||
let { children } = $props();
|
||||
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
|
||||
// recovery card + timeout as soon as the shell hydrates.
|
||||
@@ -12,6 +12,8 @@
|
||||
// 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>
|
||||
|
||||
@@ -19,27 +21,5 @@
|
||||
|
||||
{#if fb.open}<FeedbackModal onclose={closeFeedback} />{/if}
|
||||
|
||||
<footer class="site">
|
||||
<div class="container">
|
||||
<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>
|
||||
<!-- Footer is per-surface now (shared $lib/components/Footer.svelte) so each section
|
||||
can add its own extras; FeedbackModal stays global, triggered from any footer. -->
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+534
-1004
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
||||
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
||||
import { track } from '$lib/analytics.js';
|
||||
import { openFeedback } from '$lib/feedback.svelte.js';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import AccountPanel from '$lib/components/AccountPanel.svelte';
|
||||
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
|
||||
import LanePicker from '$lib/components/LanePicker.svelte';
|
||||
@@ -100,7 +101,7 @@
|
||||
|
||||
<header class="bar">
|
||||
<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">
|
||||
<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>
|
||||
@@ -193,7 +194,7 @@
|
||||
<p class="gate">Sign in, then follow sources and topics to build your own calm lane.</p>
|
||||
{:else}
|
||||
<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}
|
||||
<ul class="follows">
|
||||
{#each follows as f (f.kind + ':' + f.value)}
|
||||
@@ -228,6 +229,8 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
<style>
|
||||
.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; }
|
||||
|
||||
@@ -22,6 +22,22 @@
|
||||
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 () => {
|
||||
if (!auth.ready) await refresh();
|
||||
if (!auth.user || !auth.user.is_admin) {
|
||||
@@ -495,6 +511,16 @@
|
||||
} 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 ---
|
||||
let candidates = $state([]);
|
||||
let newFeedUrl = $state('');
|
||||
@@ -675,7 +701,7 @@
|
||||
|
||||
<header class="bar">
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
@@ -718,34 +744,49 @@
|
||||
|
||||
<h2>Pulse</h2>
|
||||
<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.d7}</span><span class="l">Visitors (7d)</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.engaged_today}</span><span class="l">Engaged today</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.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.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">{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>
|
||||
{#if stats.client_errors}
|
||||
<div class="stat" class:alert={stats.client_errors.today > 0}>
|
||||
<span class="n">{stats.client_errors.today}</span><span class="l">Load errors today</span>
|
||||
<div class="stat" class:alert={stats.client_errors.unread > 0}>
|
||||
<span class="n">{stats.client_errors.unread}</span><span class="l">Unread load errors</span>
|
||||
</div>
|
||||
{/if}
|
||||
</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}
|
||||
<h2>Recent load errors <span class="count">(last {clientErrors.length} · newest {ago(clientErrors[0]?.created_at)})</span></h2>
|
||||
<ul class="cerrs">
|
||||
{#each clientErrors as e (e.created_at + e.reason)}
|
||||
{#each clientErrors as e (e.id)}
|
||||
{@const t = errType(e)}
|
||||
<li class:bot={e.bot}>
|
||||
<li class:bot={e.bot} class:isread={e.read}>
|
||||
<span class="ce-when">{fdate(e.created_at)}</span>
|
||||
<span class="ce-reason"><span class="ce-tag {t.k}">{t.label}</span>{e.reason || '—'}</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>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="ce-empty">{ceFilter === 'unread' ? 'All clear — no unread load errors. ✨' : 'Nothing to show.'}</p>
|
||||
{/if}
|
||||
|
||||
{:else if section === 'content'}
|
||||
@@ -995,6 +1036,14 @@
|
||||
<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)}
|
||||
@@ -1041,7 +1090,16 @@
|
||||
{: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>
|
||||
<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="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>
|
||||
@@ -1879,9 +1937,21 @@
|
||||
|
||||
.stat.alert { background: #f3e0e0; }
|
||||
.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 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; }
|
||||
.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-reason { font-family: var(--label); color: #9a3b3b; }
|
||||
/* Layer tag — html-slow is the only incident-grade one, so it reads loudest. */
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<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 = [
|
||||
@@ -19,16 +22,77 @@
|
||||
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' && zoom) zoom = false;
|
||||
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(); });
|
||||
|
||||
let who = $derived(
|
||||
art ? [art.artist || 'Unknown artist', art.date_text].filter(Boolean) : []
|
||||
);
|
||||
// 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));
|
||||
|
||||
@@ -55,6 +119,32 @@
|
||||
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} />
|
||||
@@ -72,61 +162,69 @@
|
||||
{/snippet}
|
||||
|
||||
<div class="room">
|
||||
<header class="bar">
|
||||
<a class="brand" href="/" aria-label="upbeatBytes home">
|
||||
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="/">News</a>
|
||||
<a href="/play">Games</a>
|
||||
<a href="/art" aria-current="page">Art</a>
|
||||
<a class="acct" href="/account" aria-label="Your account">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="3.3" />
|
||||
<path d="M5.5 19.2a6.5 6.5 0 0 1 13 0" />
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
<HubBar active="art" />
|
||||
|
||||
<main class="gallery">
|
||||
<div class="intro">
|
||||
<h1>Daily Art</h1>
|
||||
<p>A masterwork a day, from the world's open collections.</p>
|
||||
</div>
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
|
||||
<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'}
|
||||
<figure class="piece">
|
||||
<button class="frame frame--{frame}" style="--frame-scale:{thickness}"
|
||||
<!-- "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} />
|
||||
<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>
|
||||
Click to expand
|
||||
Expand
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<figcaption class="placard">
|
||||
<h2 class="title">{art.title}</h2>
|
||||
<p class="who">
|
||||
{#each who as part, i}{#if i > 0}<span class="sep">·</span>{/if}{part}{/each}
|
||||
</p>
|
||||
{#if art.medium}<p class="medium">{art.medium}</p>{/if}
|
||||
<p class="credit">
|
||||
from {art.museum}{#if art.license}<span class="sep">·</span>{art.license}{/if}
|
||||
{#if art.source_url}<a class="more" href={art.source_url} target="_blank" rel="noopener">View at {art.museum} →</a>{/if}
|
||||
</p>
|
||||
|
||||
<div class="frames">
|
||||
<span class="frames-label">Frame</span>
|
||||
<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}>
|
||||
@@ -134,15 +232,32 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if frame !== 'none'}
|
||||
<div class="thickness">
|
||||
<span class="frames-label">Thickness</span>
|
||||
<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}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
{#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}
|
||||
@@ -150,75 +265,166 @@
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
{#if zoom && art}
|
||||
<button class="lightbox" bind:this={lightboxEl} onclick={() => (zoom = false)} aria-label="Close artwork">
|
||||
<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>
|
||||
/* --- look-overhaul testbed: bright, modern, calm. Scoped to /art for now. --- */
|
||||
@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 cream, not brown */
|
||||
--surface: #ffffff;
|
||||
--ink: #232a31; /* dark slate */
|
||||
--muted: #707b86;
|
||||
--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: #0a93c0; /* upbeatBytes blue, a touch brighter */
|
||||
--accent-deep: #066c8e;
|
||||
--accent: #8857C2; /* Daily-Art purple (matches the home tile) */
|
||||
--accent-deep: #6f42a8;
|
||||
min-height: 100vh;
|
||||
background: var(--canvas);
|
||||
color: var(--ink);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
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 */
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px clamp(16px, 5vw, 56px);
|
||||
max-width: 1100px; width: 100%; margin: 0 auto; box-sizing: border-box;
|
||||
}
|
||||
.brand { display: block; line-height: 0; }
|
||||
.brand img { height: 34px; width: auto; display: block; }
|
||||
.nav { display: flex; align-items: center; gap: clamp(12px, 2.5vw, 26px); }
|
||||
.nav a { color: var(--muted); text-decoration: none; font-weight: 600; font-size: 0.95rem; }
|
||||
.nav a:hover { color: var(--ink); }
|
||||
.nav a[aria-current="page"] { color: var(--accent); }
|
||||
.acct {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 38px; height: 38px; border-radius: 50%; color: var(--muted);
|
||||
}
|
||||
.acct:hover { color: var(--accent); background: #eef6f9; }
|
||||
|
||||
/* /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: 1100px; margin: 0 auto;
|
||||
padding: clamp(6px, 1.5vw, 16px) clamp(20px, 5vw, 56px) clamp(20px, 5vw, 56px);
|
||||
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; align-items: center;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
}
|
||||
.intro { text-align: center; margin-bottom: clamp(18px, 3.5vw, 30px); }
|
||||
.intro h1 {
|
||||
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
||||
font-size: clamp(2rem, 5vw, 3rem); margin: 0; letter-spacing: -0.01em;
|
||||
/* 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;
|
||||
}
|
||||
.intro p { color: var(--muted); margin: 10px 0 0; font-size: 1.05rem; }
|
||||
.back:hover { color: var(--accent); }
|
||||
.back svg { transition: transform 0.15s ease; }
|
||||
.back:hover svg { transform: translateX(-2px); }
|
||||
|
||||
/* A quiet hairline between the title and the artwork, fading at both ends. */
|
||||
.divider {
|
||||
width: min(440px, 76%); height: 1px; margin: 0 auto clamp(24px, 4.5vw, 44px);
|
||||
background: linear-gradient(90deg, transparent, rgba(112, 123, 134, 0.30) 22%,
|
||||
rgba(112, 123, 134, 0.30) 78%, transparent);
|
||||
/* 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); }
|
||||
|
||||
.piece { margin: 0; display: flex; flex-direction: column; align-items: center; max-width: 860px; }
|
||||
/* 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
|
||||
@@ -341,20 +547,8 @@
|
||||
}
|
||||
.frame:hover .hint, .frame:focus-visible .hint { background: rgba(20, 26, 33, 0.72); }
|
||||
|
||||
.placard { text-align: center; margin-top: clamp(22px, 4vw, 36px); max-width: 640px; }
|
||||
.title {
|
||||
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
||||
font-size: clamp(1.4rem, 3.5vw, 2rem); margin: 0; line-height: 1.2;
|
||||
}
|
||||
.who { margin: 8px 0 0; font-size: 1.05rem; color: var(--ink); }
|
||||
.medium { margin: 4px 0 0; color: var(--muted); font-size: 0.95rem; font-style: italic; }
|
||||
.credit { margin: 16px 0 0; color: var(--muted); font-size: 0.88rem; }
|
||||
.sep { display: inline-block; margin: 0 0.5em; color: var(--muted); }
|
||||
.more { display: inline-block; margin-left: 8px; color: var(--accent); font-weight: 600; text-decoration: none; }
|
||||
.more:hover { color: var(--accent-deep); }
|
||||
.sep { display: inline-block; margin: 0 0.5em; color: var(--muted); } /* used in the lightbox caption */
|
||||
|
||||
.frames { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 11px; margin-top: 24px; }
|
||||
.frames-label { font-size: 0.74rem; font-weight: 600; color: var(--muted); margin-right: 4px; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
/* 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;
|
||||
@@ -374,29 +568,71 @@
|
||||
.swatch--none { background: linear-gradient(150deg, #ffffff, #ece6da); }
|
||||
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
|
||||
.thickness { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 16px; }
|
||||
.thickness input[type="range"] { width: min(220px, 60vw); accent-color: var(--accent); cursor: pointer; }
|
||||
|
||||
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
|
||||
|
||||
.foot {
|
||||
text-align: center; color: var(--muted); font-size: 0.84rem;
|
||||
padding: 28px 16px 36px; border-top: 1px solid var(--line); margin-top: 24px;
|
||||
}
|
||||
|
||||
.lightbox {
|
||||
position: fixed; inset: 0; z-index: 50; border: none; cursor: zoom-out;
|
||||
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; gap: 12px; padding: 2.5vmin;
|
||||
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 {
|
||||
cursor: zoom-out; max-width: 92vw;
|
||||
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));
|
||||
}
|
||||
@@ -405,4 +641,30 @@
|
||||
.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}
|
||||
<h1>Couldn't sign you in</h1>
|
||||
<p class="muted">{error}</p>
|
||||
<a class="back" href="/">← Back to Upbeat Bytes</a>
|
||||
<a class="back" href="/">← Back to upbeatBytes</a>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { getJSON } from '$lib/api.js';
|
||||
import { ROOMS } from '$lib/rooms.js';
|
||||
import RoomCard from '$lib/components/RoomCard.svelte';
|
||||
|
||||
// Hidden prototype of the new homepage hub (/home2). Iterate the look here, then
|
||||
// promote to / and remove this clone — same approach we used for /art.
|
||||
let artImg = $state(null);
|
||||
let newsHeadline = $state('');
|
||||
let newsImg = $state(null);
|
||||
|
||||
onMount(async () => {
|
||||
try { artImg = (await getJSON('/api/art/today'))?.image_url ?? null; } catch { /* card falls back to blurb */ }
|
||||
// Respect the reader's saved Closer-to-Home filter so the headline matches their Brief.
|
||||
let homeq = '';
|
||||
try {
|
||||
const hv = localStorage.getItem('goodnews:home') || '';
|
||||
const hs = localStorage.getItem('goodnews:homeScope') || 'nearby';
|
||||
if (hv && hs !== 'world') homeq = `&home=${encodeURIComponent(hv)}&scope=${hs}`;
|
||||
} catch { /* default global brief */ }
|
||||
try {
|
||||
const it = (await getJSON(`/api/brief?limit=1${homeq}`))?.items?.[0];
|
||||
newsHeadline = it?.title ?? '';
|
||||
newsImg = it?.image_url ?? null;
|
||||
} catch { /* card falls back to blurb */ }
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>upbeatBytes — a calmer corner of the internet</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="description" content="A calmer corner of the internet: good news, daily art, small games, and little resets." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="room">
|
||||
<header class="bar">
|
||||
<a class="brand" href="/home2" aria-label="upbeatBytes home">
|
||||
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="/">News</a>
|
||||
<a href="/play">Games</a>
|
||||
<a href="/art">Art</a>
|
||||
<a class="acct" href="/account" aria-label="Your account">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="3.3" />
|
||||
<path d="M5.5 19.2a6.5 6.5 0 0 1 13 0" />
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="hub">
|
||||
<div class="intro">
|
||||
<h1>A calmer corner of the internet.</h1>
|
||||
<p>Good news, daily art, small games, and little resets.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
{#each ROOMS as r (r.id)}
|
||||
<RoomCard room={r} {artImg} {newsImg} {newsHeadline} />
|
||||
{/each}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Self-hosted modern sans (OFL) — privacy-friendly, no Google hotlink. */
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
src: url('/fonts/manrope-var.woff2') format('woff2');
|
||||
font-weight: 200 800; font-style: normal; font-display: swap;
|
||||
}
|
||||
|
||||
.room {
|
||||
--canvas: #faf6ee; --surface: #ffffff; --ink: #232a31; --muted: #707b86;
|
||||
--line: #ece5d8; --accent: #0a93c0; --accent-deep: #066c8e;
|
||||
--sans: 'Manrope', ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
min-height: 100vh; background: var(--canvas); color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
/* Bigger, more present top bar — logo + lighter, modern nav labels. */
|
||||
.bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px clamp(18px, 5vw, 64px);
|
||||
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
|
||||
}
|
||||
.brand { display: block; line-height: 0; }
|
||||
.brand img { height: 58px; width: auto; display: block; }
|
||||
.nav { display: flex; align-items: center; gap: clamp(18px, 3vw, 40px); }
|
||||
.nav a {
|
||||
font-family: var(--sans); color: #4a525c; text-decoration: none;
|
||||
font-weight: 500; font-size: 1.1rem; letter-spacing: 0.01em;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.nav a:hover { color: var(--accent); }
|
||||
.acct {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 44px; height: 44px; border-radius: 50%; color: var(--muted);
|
||||
}
|
||||
.acct:hover { color: var(--accent); background: #eef6f9; }
|
||||
|
||||
.hub {
|
||||
flex: 1; width: 100%; max-width: 1180px; margin: 0 auto; box-sizing: border-box;
|
||||
padding: clamp(12px, 2.4vw, 30px) clamp(18px, 5vw, 64px) clamp(40px, 6vw, 72px);
|
||||
}
|
||||
.intro { text-align: center; margin: clamp(6px, 1.2vw, 14px) 0 clamp(40px, 6vw, 64px); }
|
||||
.intro h1 {
|
||||
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
||||
font-size: clamp(1.9rem, 4.5vw, 3rem); margin: 0; letter-spacing: -0.015em; line-height: 1.08;
|
||||
}
|
||||
.intro p { color: var(--muted); margin: 12px 0 0; font-size: clamp(1rem, 2vw, 1.2rem); }
|
||||
|
||||
.grid {
|
||||
display: grid; gap: clamp(14px, 1.8vw, 22px);
|
||||
grid-template-columns: repeat(4, 1fr); grid-auto-flow: dense;
|
||||
}
|
||||
/* size variants → grid spans (RoomCard owns min-heights) */
|
||||
:global(.grid .card--large) { grid-column: span 2; grid-row: span 2; }
|
||||
:global(.grid .card--wide) { grid-column: span 2; }
|
||||
:global(.grid .card--tall) { grid-row: span 2; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.grid { grid-template-columns: repeat(2, 1fr); }
|
||||
:global(.grid .card--large), :global(.grid .card--wide) { grid-column: span 2; }
|
||||
:global(.grid .card--large), :global(.grid .card--tall) { grid-row: auto; }
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
:global(.grid .card--large), :global(.grid .card--wide), :global(.grid .card--tall) { grid-column: span 1; }
|
||||
}
|
||||
|
||||
.foot {
|
||||
text-align: center; color: var(--muted); font-size: 0.86rem;
|
||||
padding: 28px 16px 40px; border-top: 1px solid var(--line); margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,475 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { getJSON } from '$lib/api.js';
|
||||
import HubBar from '$lib/components/HubBar.svelte';
|
||||
|
||||
// /home3 — the Claude Design "Frame A" direction (editorial, with colour), rebuilt in
|
||||
// our codebase with our real logo + self-hosted fonts, wired to live data. Hidden
|
||||
// prototype (noindex), alongside /home2 so we can compare.
|
||||
let news = $state(null); // {id, title, summary, image}
|
||||
let art = $state(null); // {title, artist, year, image}
|
||||
let newsFit = $state('cover'); // 'cover' = full-bleed photo; 'contain' = framed-plate figure
|
||||
let word = $state(null); // /api/word/today
|
||||
let quote = $state(null); // /api/quote/today
|
||||
let fact = $state(null); // /api/onthisday/today
|
||||
|
||||
// small-joys display helpers
|
||||
const POS = { noun: 'n.', adjective: 'adj.', verb: 'v.', adverb: 'adv.', pronoun: 'pron.',
|
||||
preposition: 'prep.', conjunction: 'conj.', interjection: 'interj.' };
|
||||
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '');
|
||||
const clip = (s, n) => {
|
||||
if (!s || s.length <= n) return s || '';
|
||||
const cut = s.slice(0, n), i = cut.lastIndexOf(' ');
|
||||
return (i > 0 ? cut.slice(0, i) : cut).replace(/[\s,;:.]+$/, '') + '…';
|
||||
};
|
||||
|
||||
// truncation handled by CSS (-webkit-line-clamp:2) — breaks on whole words, fills 2 full lines
|
||||
let headline = $derived(news?.title ?? 'What went right this week: the good news that actually matters');
|
||||
|
||||
// small-joys shelf: 3 cells shown two at a time, rotated by the reader (no auto-motion)
|
||||
const JOY_ACCENTS = ['#4f7da8', '#b06a86', '#b06a45'];
|
||||
let joyIdx = $state(0);
|
||||
const prevJoy = () => (joyIdx = (joyIdx + 2) % 3);
|
||||
const nextJoy = () => (joyIdx = (joyIdx + 1) % 3);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const a = await getJSON('/api/art/today');
|
||||
if (a) art = { title: a.title, artist: a.artist, year: a.date_text, image: a.image_url };
|
||||
} catch { /* fall back to gradient swatch */ }
|
||||
|
||||
let homeq = '';
|
||||
try {
|
||||
const hv = localStorage.getItem('goodnews:home') || '';
|
||||
const hs = localStorage.getItem('goodnews:homeScope') || 'nearby';
|
||||
if (hv && hs !== 'world') homeq = `&home=${encodeURIComponent(hv)}&scope=${hs}`;
|
||||
} catch { /* global brief */ }
|
||||
try {
|
||||
const it = (await getJSON(`/api/brief?limit=1${homeq}`))?.items?.[0];
|
||||
if (it) news = { id: it.id, title: it.title, summary: it.summary || it.description || '', image: it.image_url || null };
|
||||
// Photos display full (cover); only wide/tall figures (diagrams) get the framed plate.
|
||||
if (news?.image) {
|
||||
const probe = new Image();
|
||||
probe.onload = () => {
|
||||
const a = probe.naturalWidth / probe.naturalHeight;
|
||||
newsFit = a >= 0.85 && a <= 1.9 ? 'cover' : 'contain';
|
||||
};
|
||||
probe.src = news.image;
|
||||
}
|
||||
} catch { /* fall back to design copy */ }
|
||||
|
||||
// small joys (each falls back to its placeholder if the engine has nothing yet)
|
||||
try { word = await getJSON('/api/word/today'); } catch { /* placeholder */ }
|
||||
try { quote = await getJSON('/api/quote/today'); } catch { /* placeholder */ }
|
||||
try { fact = await getJSON('/api/onthisday/today'); } catch { /* placeholder */ }
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>upbeatBytes — a calmer, brighter corner of the internet</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
<meta name="description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
|
||||
</svelte:head>
|
||||
|
||||
{#snippet joyCard(i)}
|
||||
{#if i === 0}
|
||||
<a class="joy joy-word" href="/word">
|
||||
<span class="wm" aria-hidden="true">{word ? cap(word.word)[0] : 'S'}</span>
|
||||
<div class="joy-in">
|
||||
<div class="tag"><span class="rule"></span><span class="tag-label">Word of the day</span></div>
|
||||
<p class="word-line"><span class="word">{word ? cap(word.word) : 'Serene'}</span> <span class="word-pos">{word ? (POS[word.part_of_speech] ?? word.part_of_speech ?? '') : 'adj.'}</span></p>
|
||||
<p class="word-pron">{word?.phonetic ?? '/səˈriːn/'}</p>
|
||||
<p class="def">{word ? clip(word.definition, 78) : 'Calm, peaceful, and untroubled. The quiet after a storm passes.'}</p>
|
||||
</div>
|
||||
</a>
|
||||
{:else if i === 1}
|
||||
<a class="joy joy-quote" href="/quote">
|
||||
<span class="wm wm-q" aria-hidden="true">“</span>
|
||||
<div class="joy-in">
|
||||
<div class="tag"><span class="rule"></span><span class="tag-label">Quote of the day</span></div>
|
||||
<p class="quote">{quote ? clip(quote.text, 92) : 'Very little is needed to make a happy life.'}</p>
|
||||
<div class="attrib"><span class="attrib-rule"></span><span class="attrib-by">{quote?.author ?? 'Marcus Aurelius'}</span></div>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<a class="joy joy-fact" href="/onthisday">
|
||||
<div class="joy-in">
|
||||
<div class="tag"><span class="rule"></span><span class="tag-label">A good thing today</span></div>
|
||||
<p class="fact-hero"><span class="year">{fact?.year ?? '1928'}</span> <span class="onthis">ON THIS DAY</span></p>
|
||||
<p class="fact">{fact ? clip(fact.text, 96) : 'Penicillin was discovered by a happy accident.'}</p>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="page">
|
||||
<HubBar active="home" />
|
||||
|
||||
<section class="hero">
|
||||
<h1>A <span class="t">calmer</span>, <span class="b">brighter</span> corner of the internet.</h1>
|
||||
<p class="sub">Good news, daily art, small games, and little resets.</p>
|
||||
</section>
|
||||
|
||||
<main class="bento">
|
||||
<!-- Good News (tall) — a card with TWO links, so it's a div, not a single anchor -->
|
||||
<div class="card news">
|
||||
<a class="news-photo-a" href={news?.id ? `/a/${news.id}` : '/'} aria-label="Read this article">
|
||||
<div class="news-photo {newsFit}">
|
||||
<div class="news-plate" style={news?.image ? `background-image:url(${news.image})` : ''}></div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="news-body">
|
||||
<span class="label" style="color:#0083ad">GOOD NEWS</span>
|
||||
<a class="headline-a" href={news?.id ? `/a/${news.id}` : '/'}><h2>{headline}</h2></a>
|
||||
<a class="summary-a" href={news?.id ? `/a/${news.id}` : '/'}>
|
||||
<p class="summary">{news?.summary || "We read the week so you don't have to doomscroll it. Five quietly hopeful stories, summarised to the gist."}</p>
|
||||
</a>
|
||||
<div class="news-foot">
|
||||
<span class="meta">3 min read</span>
|
||||
</div>
|
||||
<hr class="news-div" />
|
||||
<a class="news-more" href="/">Read more good news →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rightcol">
|
||||
<!-- Daily Art (wide) -->
|
||||
<a class="card art" href="/art">
|
||||
<div class="art-body">
|
||||
<span class="label" style="color:#8857C2">DAILY ART</span>
|
||||
<h3>A masterwork a day</h3>
|
||||
<p class="art-today">
|
||||
{#if art}Today: <span class="ital">{art.title}</span>{#if art.artist} — {art.artist}{/if}{#if art.year}, {art.year}{/if}.
|
||||
{:else}Today: <span class="ital">Among the Sierra Nevada</span> — Bierstadt, 1868.{/if}
|
||||
</p>
|
||||
<span class="link art-link">View today</span>
|
||||
</div>
|
||||
<div class="art-swatch" style={art?.image ? `--art:url(${art.image})` : ''}></div>
|
||||
</a>
|
||||
|
||||
<!-- bottom pair — vertically centered in the space beneath the pinned Art card -->
|
||||
<div class="pair-wrap">
|
||||
<div class="pair">
|
||||
<a class="card play" href="/play">
|
||||
<div class="play-top">
|
||||
<span class="label" style="color:#A8650F">PLAY</span>
|
||||
<h3>A little daily puzzle</h3>
|
||||
</div>
|
||||
|
||||
<!-- "bleeding boards": three game motifs clipping at the card edges (decorative) -->
|
||||
<div class="play-band" aria-hidden="true">
|
||||
<div class="wb">
|
||||
<div class="wb-row"><span class="wb-t wb-a">E</span><span class="wb-t wb-n">A</span><span class="wb-t wb-a">T</span></div>
|
||||
<div class="wb-row"><span class="wb-t wb-g">Y</span><span class="wb-t wb-g">T</span><span class="wb-t wb-g">E</span></div>
|
||||
<div class="wb-row"><span class="wb-t wb-e"></span><span class="wb-t wb-e"></span><span class="wb-t wb-d"></span></div>
|
||||
</div>
|
||||
<div class="ws">
|
||||
<span>K</span><span>R</span><span>O</span><span>A</span><span>E</span><span>S</span>
|
||||
<span class="hl">B</span><span class="hl">Y</span><span class="hl">T</span><span class="hl">E</span><span class="hl">S</span><span>W</span>
|
||||
<span>T</span><span>I</span><span>M</span><span>U</span><span>H</span><span>P</span>
|
||||
<span>G</span><span>E</span><span>B</span><span>O</span><span>R</span><span>L</span>
|
||||
<span>F</span><span>I</span><span>N</span><span>D</span><span>C</span><span>A</span>
|
||||
<span>Z</span><span>O</span><span>S</span><span>E</span><span>K</span><span>Y</span>
|
||||
</div>
|
||||
<div class="mm">
|
||||
<span class="mm-a"></span>
|
||||
<span class="mm-w"><span class="mm-dot" style="background:#6bbf8c"></span></span>
|
||||
<span class="mm-a"></span>
|
||||
<span class="mm-w"><span class="mm-dot" style="background:#6bbf8c"></span></span>
|
||||
<span class="mm-a"></span>
|
||||
<span class="mm-a"></span>
|
||||
<span class="mm-a"></span>
|
||||
<span class="mm-w"><span class="mm-dot" style="background:#D2861B"></span></span>
|
||||
<span class="mm-a"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="play-foot"><span class="link play-link">Enter</span></div>
|
||||
</a>
|
||||
<div class="card moment">
|
||||
<div class="moment-top">
|
||||
<span class="label" style="color:#3F9A66">ENTERTAINMENT</span>
|
||||
<span class="soon">SOON</span>
|
||||
</div>
|
||||
<div class="moment-mid">
|
||||
<div class="ent-icons" aria-hidden="true">
|
||||
<span class="ent-icon">
|
||||
<svg viewBox="0 0 24 24"><circle cx="8" cy="9" r="3" fill="#e3a24c" /><circle cx="16" cy="9" r="3" fill="#5aa0c8" /><circle cx="12" cy="15.5" r="3" fill="#5bbf86" /></svg>
|
||||
</span>
|
||||
<span class="ent-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#3F9A66" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9" /><path d="M8.5 14a4.5 4.5 0 0 0 7 0" /><circle cx="9" cy="10" r="1" fill="#3F9A66" stroke="none" /><circle cx="15" cy="10" r="1" fill="#3F9A66" stroke="none" /></svg>
|
||||
</span>
|
||||
<span class="ent-icon">
|
||||
<svg viewBox="0 0 24 24" fill="#3F9A66"><path d="M12 3l1.6 7.4L21 12l-7.4 1.6L12 21l-1.6-7.4L3 12l7.4-1.6z" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<p class="moment-line">A little something to enjoy.</p>
|
||||
</div>
|
||||
<span class="moment-meta">Coloring, characters, and curiosities. Coming soon.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- "small joys" — two jewels at a time, rotated by the reader (3 cells total) -->
|
||||
<div class="joys-shelf">
|
||||
<div class="joys-head">
|
||||
<div class="joys-title"><span class="jt-label">Small joys for today</span><span class="jt-count">· {joyIdx + 1} of 3</span></div>
|
||||
<div class="joys-nav">
|
||||
<div class="joys-dots" aria-hidden="true">
|
||||
{#each [0, 1, 2] as d}
|
||||
<span class="dot" class:on={d === joyIdx} style={d === joyIdx ? `background:${JOY_ACCENTS[joyIdx]}` : ''}></span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="joys-arrows">
|
||||
<button class="arrow" onclick={prevJoy} aria-label="Previous small joys">‹</button>
|
||||
<button class="arrow" onclick={nextJoy} aria-label="More small joys">›</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="joys">
|
||||
{@render joyCard(joyIdx)}
|
||||
{@render joyCard((joyIdx + 1) % 3)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</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; }
|
||||
|
||||
/* Hero — spacing tuned per the /home2 pass: pulled up a touch, more air before cards */
|
||||
.hero { text-align: center; max-width: 1180px; width: 100%; margin: 0 auto; padding: clamp(24px, 4vw, 34px) clamp(18px, 5vw, 44px) clamp(38px, 5vw, 48px); }
|
||||
.hero h1 {
|
||||
font-family: 'Newsreader', Georgia, serif; font-weight: 500;
|
||||
font-size: clamp(2.1rem, 5vw, 50px); line-height: 1.04; letter-spacing: -0.015em; margin: 0; color: var(--ink);
|
||||
}
|
||||
.hero h1 .t { color: #0083ad; }
|
||||
.hero h1 .b { color: #E0852C; }
|
||||
.hero .sub { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: clamp(1rem, 2vw, 19px); color: #857b6c; margin: 14px 0 0; }
|
||||
|
||||
/* Bento grid */
|
||||
.bento {
|
||||
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
|
||||
padding: 0 clamp(18px, 5vw, 44px) 16px;
|
||||
display: grid; grid-template-columns: minmax(0, 1.18fr) minmax(0, 1.82fr); gap: 16px;
|
||||
}
|
||||
/* right column matches the News height; Art stays pinned to the TOP and the Play/Moment
|
||||
pair to the BOTTOM, with the extra space distributed BETWEEN them (FIX1). The cards
|
||||
themselves keep their natural size and never stretch. */
|
||||
.rightcol { display: flex; flex-direction: column; gap: 16px; }
|
||||
.rightcol .art { flex: none; } /* Art pinned to the top */
|
||||
.pair-wrap { flex: 1; display: flex; align-items: center; } /* fill the rest; pair vertically centered */
|
||||
.card {
|
||||
border-radius: 18px; overflow: hidden; text-decoration: none; color: inherit;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
a.card:hover { transform: translateY(-2px); }
|
||||
.label { font-size: 11px; font-weight: 700; letter-spacing: 0.16em; }
|
||||
.link { font-size: 14px; font-weight: 600; padding-bottom: 2px; align-self: flex-start; }
|
||||
/* card titles a touch larger + bolder so they jump on hover/scan */
|
||||
h2, h3 { font-family: 'Newsreader', Georgia, serif; font-weight: 600; letter-spacing: -0.01em; color: var(--ink); }
|
||||
|
||||
/* Good News — tall, photo on top */
|
||||
.news {
|
||||
background: #fff; border: 1px solid var(--news-border);
|
||||
display: flex; flex-direction: column; box-shadow: 0 6px 20px -14px rgba(0, 131, 173, 0.4);
|
||||
}
|
||||
/* photo + headline both link to the article (clickable, not just the text links) */
|
||||
.news-photo-a { display: block; }
|
||||
.news-photo-a:hover { filter: brightness(0.97); }
|
||||
.headline-a { display: block; text-decoration: none; color: inherit; }
|
||||
.headline-a:hover h2 { color: var(--teal); }
|
||||
/* Photos fill edge-to-edge (cover, no box). Only figures/diagrams (detected by their
|
||||
wide/tall shape) get the soft tinted matte + white framed plate, so labels stay whole. */
|
||||
.news-photo { aspect-ratio: 5/4; }
|
||||
.news-plate { background-position: center; background-repeat: no-repeat; }
|
||||
.news-photo.cover .news-plate { width: 100%; height: 100%; background-size: cover; }
|
||||
.news-photo.contain {
|
||||
/* silvery at top, fading down into the card's white so the matte isn't a hard band */
|
||||
background: linear-gradient(180deg, #e6edef 0%, #f3f6f5 55%, #ffffff 100%);
|
||||
display: flex; align-items: center; justify-content: center; padding: 20px;
|
||||
}
|
||||
.news-photo.contain .news-plate {
|
||||
width: 100%; height: 100%; box-sizing: border-box; padding: 12px;
|
||||
background-color: #fff; border: 1px solid #e7edee; border-radius: 8px;
|
||||
box-shadow: 0 6px 18px -10px rgba(30, 60, 70, 0.28);
|
||||
background-size: contain; background-origin: content-box;
|
||||
}
|
||||
.news-body { padding: 24px 26px; flex: 1; display: flex; flex-direction: column; }
|
||||
.news h2 {
|
||||
font-size: clamp(1.55rem, 2.6vw, 30px); line-height: 1.14; margin: 12px 0 0;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
|
||||
}
|
||||
/* the gist FILLS the room available (the card runs tall), fading only at the very bottom;
|
||||
it's also a link to the article (whole content block clickable) */
|
||||
.summary-a {
|
||||
flex: 1 1 auto; min-height: 0; overflow: hidden; display: block; text-decoration: none; color: inherit;
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 2.1em), transparent);
|
||||
mask-image: linear-gradient(to bottom, #000 calc(100% - 2.1em), transparent);
|
||||
}
|
||||
.summary { font-size: 14.5px; line-height: 1.55; color: var(--body); margin: 12px 0 0; }
|
||||
.news-foot { display: flex; align-items: center; justify-content: flex-end; padding-top: 18px; }
|
||||
.meta { font-size: 12px; color: var(--muted); }
|
||||
/* divider sets the secondary "feed" link apart as its own thing */
|
||||
.news-div { border: none; border-top: 1px solid #e6d9bf; margin: 14px 0 12px; }
|
||||
.news-more { display: inline-block; font-size: 13px; font-weight: 600; color: var(--teal); text-decoration: none; }
|
||||
.news-more:hover { text-decoration: underline; }
|
||||
.news-more:hover { color: var(--teal); }
|
||||
|
||||
/* Daily Art — wide, text left + artwork swatch right */
|
||||
.art { background: #F3EEF9; border: 1px solid #e4d8f1; display: flex; min-height: 188px; }
|
||||
.art-body { flex: 1; padding: 24px 26px; display: flex; flex-direction: column; }
|
||||
.art h3 { font-size: clamp(1.35rem, 2.1vw, 25px); line-height: 1.16; margin: 10px 0 0; color: #2a1c3d; }
|
||||
.art-today { font-size: 13.5px; line-height: 1.5; color: #6f6280; margin: 9px 0 0; }
|
||||
.ital { font-style: italic; font-family: 'Newsreader', Georgia, serif; }
|
||||
.art-link { margin-top: auto; color: #8857C2; border-bottom: 2px solid #c9aef0; }
|
||||
/* swatch crops a few px off every edge (::after inset) so scanned paintings don't show
|
||||
their ragged/black canvas edge at the top */
|
||||
.art-swatch {
|
||||
width: 46%; min-width: 130px; position: relative; overflow: hidden;
|
||||
background: linear-gradient(170deg, #bfe0f0 0%, #a9cf9a 50%, #d89a4e 100%);
|
||||
}
|
||||
.art-swatch::after {
|
||||
content: ""; position: absolute; inset: -6px;
|
||||
background-image: var(--art); background-size: cover; background-position: center;
|
||||
}
|
||||
|
||||
/* bottom pair */
|
||||
.pair { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.play { background: #FFF3DC; border: 1px solid #f6e2b8; display: flex; flex-direction: column; }
|
||||
.play-top { padding: 22px 24px 0; }
|
||||
.play h3 { font-size: clamp(1.25rem, 1.9vw, 23px); margin: 14px 0 0; color: #5c3d0c; }
|
||||
.play-foot { margin-top: auto; padding: 16px 24px 22px; }
|
||||
.play-link { color: #A8650F; border-bottom: 2px solid #e0a94f; }
|
||||
|
||||
/* "bleeding boards" — three game motifs clipping at the card edges (decorative) */
|
||||
/* Word search is the centred highlight; the two side games are the SAME size and each
|
||||
bleeds ~half a column off its edge (consistent both sides) to imply "more under the hood". */
|
||||
.play-band { position: relative; height: 124px; margin-top: 24px; overflow: hidden; }
|
||||
.wb { position: absolute; top: 50%; left: -12px; transform: translateY(-50%); display: flex; flex-direction: column; gap: 4px; }
|
||||
.wb-row { display: flex; gap: 4px; }
|
||||
.wb-t { width: 24px; height: 24px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; color: #fff; }
|
||||
.wb-g { background: #6bbf8c; } .wb-a { background: #E6A02C; } .wb-n { background: #d9c39a; }
|
||||
.wb-e { background: #fff; border: 1.5px solid #ecca84; } .wb-d { background: #fff; border: 1.5px dashed #e0bb6f; }
|
||||
.ws {
|
||||
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
display: grid; grid-template-columns: repeat(6, 14px); gap: 3px;
|
||||
font-weight: 600; font-size: 10.5px; line-height: 14px; color: #d4b576; text-align: center;
|
||||
background: #fff; border: 1.5px solid #f0d597; border-radius: 10px; padding: 9px;
|
||||
box-shadow: 0 5px 16px -8px rgba(210, 134, 27, 0.55);
|
||||
}
|
||||
.ws .hl { color: #B5701A; font-weight: 800; }
|
||||
.mm { position: absolute; top: 50%; right: -12px; transform: translateY(-50%); display: grid; grid-template-columns: repeat(3, 24px); grid-auto-rows: 24px; gap: 4px; }
|
||||
.mm > span { border-radius: 6px; }
|
||||
.mm-a { background: #E6A02C; }
|
||||
.mm-w { background: #fff; border: 1.5px solid #f0d597; display: flex; align-items: center; justify-content: center; }
|
||||
.mm-dot { width: 7px; height: 7px; border-radius: 50%; display: block; }
|
||||
|
||||
.moment { background: #E6F3E9; border: 1px solid #cee6d3; padding: 22px 24px; display: flex; flex-direction: column; }
|
||||
.moment-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.soon { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; color: #3F9A66; background: #fff; border-radius: 999px; padding: 3px 8px; }
|
||||
/* centered motif: three small enjoy-icons + tagline fill the middle, caption at the foot */
|
||||
.moment-mid { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; text-align: center; }
|
||||
.ent-icons { display: flex; gap: 10px; }
|
||||
.ent-icon { width: 46px; height: 46px; border-radius: 13px; background: #fff; border: 1px solid #d3e4d8; box-shadow: 0 4px 12px -8px rgba(40, 90, 60, 0.4); display: flex; align-items: center; justify-content: center; }
|
||||
.ent-icon svg { width: 24px; height: 24px; display: block; }
|
||||
.moment-line { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 18px; line-height: 1.3; color: #214a35; margin: 0; }
|
||||
.moment-meta { margin-top: 14px; font-size: 13px; color: #6f9683; text-align: center; }
|
||||
|
||||
/* "small joys" rail — little jewels: one big focal point per card, a faint oversized
|
||||
watermark glyph, an accent-tag label, soft diagonal gradient + long low shadow. */
|
||||
.joys-shelf { flex: none; }
|
||||
.joys-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.joys-title { display: flex; align-items: baseline; gap: 8px; }
|
||||
.jt-label { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 17px; color: #7a6f5b; }
|
||||
.jt-count { font-size: 12px; color: #b3a890; }
|
||||
.joys-nav { display: flex; align-items: center; gap: 14px; }
|
||||
.joys-dots { display: flex; align-items: center; gap: 6px; }
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; background: #d9cdb8; transition: width 0.2s ease, background 0.2s ease; }
|
||||
.dot.on { width: 18px; border-radius: 3px; }
|
||||
.joys-arrows { display: flex; gap: 8px; }
|
||||
.arrow {
|
||||
width: 30px; height: 30px; border-radius: 50%; border: 1px solid #e0d3b8; background: transparent;
|
||||
color: #b09a6e; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
padding: 0; line-height: 1; transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.arrow:hover { background: #fff; color: #9a7b3e; }
|
||||
.joys { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
/* all cells share one compact height (tight, not crowded) so every rotation matches */
|
||||
/* clamp every card to the WORD card's natural height (its tallest) so rotations never jump */
|
||||
.joy {
|
||||
position: relative; overflow: hidden; border-radius: 20px; padding: 18px 22px; min-height: 170px;
|
||||
box-sizing: border-box; display: block; text-decoration: none; color: inherit;
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
.joy:hover { transform: translateY(-2px); }
|
||||
.joy-in { position: relative; } /* content sits above the watermark */
|
||||
.wm { position: absolute; font-family: 'Newsreader', Georgia, serif; line-height: 1; pointer-events: none; }
|
||||
|
||||
/* a fresh trio, distinct from the doors above (teal/plum/amber/green): sky · rose · clay */
|
||||
.joy-word { background: linear-gradient(165deg, #EAF2F9, #DBE8F4); border: 1px solid #d2e1f0; box-shadow: 0 10px 30px -22px rgba(60, 100, 145, 0.55); --accent: #4f7da8; --rule: #4f7da8; }
|
||||
.joy-word .wm { right: -14px; bottom: -30px; font-size: 150px; font-weight: 400; color: rgba(79, 125, 168, 0.13); }
|
||||
.joy-quote { background: linear-gradient(165deg, #F9EDF1, #F1DEE6); border: 1px solid #eed6df; box-shadow: 0 10px 30px -22px rgba(150, 85, 115, 0.5); --accent: #b06a86; --rule: #b06a86; }
|
||||
.joy-quote .wm { left: 14px; top: -26px; font-size: 120px; color: rgba(176, 106, 134, 0.16); }
|
||||
.joy-fact { background: linear-gradient(165deg, #F7EAE1, #EFDACB); border: 1px solid #ecd5c4; box-shadow: 0 10px 30px -22px rgba(150, 90, 55, 0.5); --accent: #b06a45; --rule: #b06a45; }
|
||||
|
||||
.tag { display: flex; align-items: center; gap: 8px; }
|
||||
.tag .rule { width: 18px; height: 2px; border-radius: 2px; background: var(--rule); }
|
||||
.tag-label { font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--accent); }
|
||||
|
||||
.word-line { margin: 8px 0 0; display: flex; align-items: baseline; gap: 9px; }
|
||||
.joy .word { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: 32px; line-height: 1; letter-spacing: -0.01em; color: #2c3a48; }
|
||||
.word-pos { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 13px; color: #7d93a8; }
|
||||
.word-pron { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 13px; color: #5f7791; margin: 2px 0 0; }
|
||||
.joy .def { font-size: 13.5px; color: #45535d; margin: 8px 0 0; line-height: 1.45; }
|
||||
|
||||
.joy .quote { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 21px; line-height: 1.3; color: #3e2c36; margin: 16px 0 0; }
|
||||
.attrib { display: flex; align-items: center; gap: 9px; margin-top: 12px; }
|
||||
.attrib-rule { width: 22px; height: 1px; background: #d8afc1; }
|
||||
.attrib-by { font-family: 'Newsreader', Georgia, serif; font-size: 13px; color: #97667f; }
|
||||
|
||||
.fact-hero { display: flex; align-items: baseline; gap: 8px; margin: 12px 0 0; }
|
||||
.year { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: 30px; color: #7a4a30; line-height: 0.9; }
|
||||
.onthis { font-size: 11px; color: #9e7a64; letter-spacing: 0.04em; }
|
||||
.joy .fact { font-family: 'Newsreader', Georgia, serif; font-size: 16px; color: #5e4636; margin: 8px 0 0; line-height: 1.34; }
|
||||
|
||||
.foot {
|
||||
text-align: center; max-width: 1180px; width: 100%; margin: 14px auto 0; box-sizing: border-box;
|
||||
padding: 20px clamp(18px, 5vw, 44px) 30px; font-size: 13px; color: var(--muted);
|
||||
border-top: 1px solid var(--news-border);
|
||||
}
|
||||
|
||||
/* responsive — collapse the bento on narrow screens */
|
||||
@media (max-width: 860px) {
|
||||
.bento { grid-template-columns: 1fr; }
|
||||
.news { grid-row: auto; }
|
||||
/* single column = natural card height, so the gist is never truncated; drop the
|
||||
bottom fade (it would otherwise dim the final line for no reason) */
|
||||
.summary-a { -webkit-mask-image: none; mask-image: none; flex: 0 1 auto; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
/* Art becomes an image-first card: the painting on top in a proper landscape frame
|
||||
(aspect-ratio, not a stubby fixed-height band that crop the work to a sliver),
|
||||
caption beneath. */
|
||||
.art { flex-direction: column; min-height: 0; }
|
||||
.art-swatch { width: 100%; min-width: 0; order: -1; aspect-ratio: 3 / 2; }
|
||||
.pair { grid-template-columns: 1fr; }
|
||||
.joys { grid-template-columns: 1fr; }
|
||||
/* tighten the joys header so the title + dots/arrows never collide on a phone */
|
||||
.joys-head { flex-wrap: wrap; gap: 8px 12px; }
|
||||
}
|
||||
</style>
|
||||
@@ -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" />
|
||||
@@ -8,10 +8,30 @@
|
||||
|
||||
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
function dayLabel(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const [, m, d] = dateStr.split('-').map(Number);
|
||||
return `${MONTHS[m - 1]} ${d}`;
|
||||
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 () => {
|
||||
@@ -32,25 +52,49 @@
|
||||
<HubShell active="">
|
||||
<article class="otd-page">
|
||||
{#if state === 'ready'}
|
||||
<p class="eyebrow">A good thing today</p>
|
||||
<h1 class="date">{dayLabel(f.date)}</h1>
|
||||
<!-- 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="event">
|
||||
<p class="year-line"><span class="year">{f.year}</span><span class="ago">on this day in history</span></p>
|
||||
<p class="fact">{f.text}</p>
|
||||
<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="photo" style="background-image:url({f.image_url})"></div>
|
||||
<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}
|
||||
<p class="summary">{f.summary}</p>
|
||||
<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}
|
||||
<a class="source" href={f.source_url} target="_blank" rel="noopener">Read more on Wikipedia →</a>
|
||||
<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}
|
||||
@@ -60,40 +104,76 @@
|
||||
</HubShell>
|
||||
|
||||
<style>
|
||||
.otd-page { max-width: 680px; margin: 0 auto; text-align: center; }
|
||||
.eyebrow {
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
|
||||
color: #3f9a66; margin: clamp(8px, 3vw, 28px) 0 0;
|
||||
@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);
|
||||
}
|
||||
.date {
|
||||
font-family: 'Newsreader', Georgia, serif; font-weight: 500; letter-spacing: -0.015em;
|
||||
font-size: clamp(2.4rem, 7vw, 4rem); line-height: 1; margin: 12px 0 0; color: var(--ink);
|
||||
}
|
||||
.event { margin-top: clamp(22px, 4vw, 34px); }
|
||||
.year-line { display: flex; align-items: baseline; justify-content: center; gap: 12px; margin: 0; }
|
||||
.year { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: clamp(1.8rem, 4vw, 2.6rem); color: #1e5b3b; line-height: 1; }
|
||||
.ago { font-size: 0.9rem; letter-spacing: 0.04em; color: #6f9683; text-transform: uppercase; }
|
||||
.fact {
|
||||
font-family: 'Newsreader', Georgia, serif; font-size: clamp(1.3rem, 3vw, 1.7rem); line-height: 1.34;
|
||||
color: #214a35; margin: 14px 0 0;
|
||||
.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;
|
||||
}
|
||||
|
||||
.photo {
|
||||
margin: clamp(26px, 5vw, 40px) auto 0; max-width: 460px; aspect-ratio: 3/2;
|
||||
background-size: cover; background-position: center; border-radius: 16px;
|
||||
border: 1px solid #d8e6da; box-shadow: 0 10px 30px -16px rgba(40, 120, 75, 0.4);
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: clamp(26px, 5vw, 38px) auto 0; max-width: 600px; text-align: left;
|
||||
font-size: 1.05rem; line-height: 1.65; color: #4a5a50;
|
||||
.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;
|
||||
}
|
||||
|
||||
.source {
|
||||
display: inline-block; margin-top: clamp(24px, 4vw, 34px);
|
||||
font-size: 0.95rem; font-weight: 600; color: #3f9a66; text-decoration: none;
|
||||
.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;
|
||||
}
|
||||
.source:hover { text-decoration: underline; }
|
||||
.cta:hover { background: #854c2f; }
|
||||
|
||||
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
|
||||
</style>
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { goto, afterNavigate } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
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 { ritualState } from '$lib/ritual.js';
|
||||
import { prefs, initPrefs } from '$lib/prefs.svelte.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';
|
||||
@@ -40,9 +41,11 @@
|
||||
let date = $state('');
|
||||
let wordStatus = $state({ 5: null, 6: null });
|
||||
let wsStatus = $state(null);
|
||||
// Daily Ritual ("today's calm set") — Brief · Daily Word · Word Search, keyed
|
||||
// on the server puzzle date; the Brief tick is set on the home end-cap, read here.
|
||||
let ritual = $state({ items: [], count: 0, total: 0 });
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
@@ -104,7 +107,6 @@
|
||||
wsStatus = ws;
|
||||
bloomStatus = readBloom();
|
||||
matchStatus = readMatch();
|
||||
if (date) ritual = ritualState(date, prefs.data.ritual);
|
||||
}
|
||||
function fmtMs(ms) {
|
||||
const s = Math.round(ms / 1000);
|
||||
@@ -206,6 +208,11 @@
|
||||
// (no in-app history) it navigates to the parent screen instead of leaving the
|
||||
// site. Device Back stays browser-native either way.
|
||||
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 pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); }
|
||||
function back() {
|
||||
@@ -259,94 +266,125 @@
|
||||
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).
|
||||
afterNavigate(() => refreshStatus());
|
||||
afterNavigate(({ from }) => { if (from) cameFromApp = true; refreshStatus(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<!-- Canonical/OG/description for /play are baked into the static play.html at build
|
||||
time (scripts/patch-play-head.mjs) so non-JS social scrapers get them; we keep
|
||||
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 · Upbeat Bytes — calm daily games</title>
|
||||
<title>Play · upbeatBytes — calm daily games</title>
|
||||
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
|
||||
</svelte:head>
|
||||
|
||||
<header class="bar">
|
||||
<div class="container inner">
|
||||
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
||||
{#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>
|
||||
<HubBar active="games" />
|
||||
|
||||
<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'}
|
||||
<h1>Play</h1>
|
||||
<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>
|
||||
{#if date && ritual.total}
|
||||
<div class="calmset">
|
||||
<p class="cs-head">Today's calm set</p>
|
||||
<ul class="cs-items">
|
||||
{#each ritual.items as it (it.key)}
|
||||
<li class="cs-item" class:done={it.done}>
|
||||
<span class="cs-mark" aria-hidden="true"></span>{#if it.done}{it.label}{:else}<a href={it.href}>{it.label}</a>{/if}
|
||||
</li>
|
||||
<div class="arcade-head">
|
||||
<div class="head-left">
|
||||
<span class="eyelash" aria-hidden="true"></span>
|
||||
<h1 class="play-title">Play</h1>
|
||||
<span class="head-div" aria-hidden="true"></span>
|
||||
<p class="head-sub">A few little games, fresh every morning. Pick one and dive in.</p>
|
||||
</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>
|
||||
</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}
|
||||
</ul>
|
||||
<p class="cs-foot">{ritual.count === ritual.total ? `All ${ritual.total} enjoyed today` : `${ritual.count} of ${ritual.total} enjoyed today`} · fresh set tomorrow · <a class="cs-edit" href="/account?section=calmset">make it yours</a></p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="cards">
|
||||
<button class="gamecard" onclick={() => openGame('word')}>
|
||||
<div class="gc-icon">◧</div>
|
||||
<div class="gc-body">
|
||||
<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>
|
||||
</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 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>
|
||||
|
||||
<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="gamecard" onclick={() => openGame('bloom')}>
|
||||
<div class="gc-icon">✿</div>
|
||||
<div class="gc-body">
|
||||
<h2>Bloom{#if isDevGated('bloom')}<span class="devtag">dev</span>{/if}</h2>
|
||||
<p class="gc-sub">Make words from today’s letters</p>
|
||||
<p class="gc-status" class:played={bloomStatus && bloomStatus.count > 0}>{bloomHubLabel()}</p>
|
||||
</div>
|
||||
<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="gamecard" onclick={() => openGame('match')}>
|
||||
<div class="gc-icon">⧉</div>
|
||||
<div class="gc-body">
|
||||
<h2>Memory Match{#if isDevGated('match')}<span class="devtag">dev</span>{/if}</h2>
|
||||
<p class="gc-sub">Find the pairs — icons or colors</p>
|
||||
<p class="gc-status" class:played={matchStatus}>{matchHubLabel()}</p>
|
||||
</div>
|
||||
<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}
|
||||
{#if !blockedForViewer('zen', auth.user, $page.url)}
|
||||
<a class="gamecard zencard" href="/zen">
|
||||
<div class="gc-icon">🐟</div>
|
||||
<div class="gc-body">
|
||||
<h2>Zen Den{#if isDevGated('zen')}<span class="devtag">dev</span>{/if}</h2>
|
||||
<p class="gc-sub">A calm corner — drop in with UB</p>
|
||||
<p class="gc-status zen">Visit · no scores, just quiet</p>
|
||||
|
||||
<!-- 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>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if view === 'select'}
|
||||
@@ -427,14 +465,18 @@
|
||||
{/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;
|
||||
background: none; border: none; font-family: inherit; cursor: pointer; }
|
||||
.back svg { width: 17px; height: 17px; display: block; }
|
||||
.page { padding: 22px 20px 70px; }
|
||||
/* in-game step-back, below the shared HubBar (matches the hub Back affordance) */
|
||||
.gameback { color: var(--accent-deep); font-size: 0.9rem; font-weight: 600; display: inline-flex;
|
||||
align-items: center; gap: 6px; background: none; border: none; font-family: inherit;
|
||||
cursor: pointer; padding: 6px 10px 6px 0; margin-bottom: 4px;
|
||||
-webkit-tap-highlight-color: transparent; }
|
||||
.gameback svg { width: 16px; height: 16px; display: block; transition: transform 0.15s ease; }
|
||||
.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; }
|
||||
.seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); }
|
||||
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
|
||||
@@ -452,51 +494,87 @@
|
||||
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; }
|
||||
|
||||
/* Daily Ritual — "today's calm set". Gentle, non-instrumental. */
|
||||
.calmset {
|
||||
max-width: 460px; margin: 0 0 24px; padding: 14px 18px;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow);
|
||||
}
|
||||
.cs-head {
|
||||
margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.13em;
|
||||
font-family: var(--label); font-size: 0.64rem; font-weight: 600; color: var(--accent-deep);
|
||||
}
|
||||
.cs-items { list-style: none; margin: 0; padding: 0; display: flex; gap: 18px; flex-wrap: wrap; }
|
||||
.cs-item { display: inline-flex; align-items: center; gap: 7px; font-size: 0.9rem; color: var(--muted); }
|
||||
.cs-item a { color: inherit; text-decoration: none; }
|
||||
.cs-item a:hover { color: var(--accent-deep); }
|
||||
.cs-item.done { color: var(--ink); }
|
||||
.cs-mark {
|
||||
width: 16px; height: 16px; border-radius: 50%; border: 1.5px solid var(--line);
|
||||
flex-shrink: 0; transition: background 0.16s ease, border-color 0.16s ease;
|
||||
}
|
||||
.cs-item.done .cs-mark {
|
||||
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: 13px; background-repeat: no-repeat; background-position: center;
|
||||
}
|
||||
.cs-foot { margin: 12px 0 0; font-family: var(--label); font-size: 0.82rem; color: var(--muted); }
|
||||
.cs-edit { color: var(--accent-deep); text-decoration: underline; white-space: nowrap; }
|
||||
/* ===== Arcade hub (CD "Play - Arcade"): colored game tiles, whole-card tap, no
|
||||
dashboard/streaks/XP — just games. Fonts match the rest of the hub. ===== */
|
||||
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 800; 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: 'Space Mono'; src: url('/fonts/space-mono-latin.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
|
||||
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
|
||||
.gamecard {
|
||||
display: flex; gap: 14px; align-items: center; text-align: left;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 16px;
|
||||
padding: 18px 20px; cursor: pointer; font: inherit; color: var(--ink);
|
||||
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); }
|
||||
/* The Zen Den isn't a game — give it a soft aqua identity so it reads as a calm corner. */
|
||||
.zencard { text-decoration: none; background: linear-gradient(180deg, #f2fbfc, var(--surface)); }
|
||||
.zencard:hover { border-color: #7cc3cc; }
|
||||
.zencard .gc-status.zen { color: #2b8c98; font-weight: 600; }
|
||||
.gc-icon { font-size: 2rem; color: var(--accent); line-height: 1; flex-shrink: 0; }
|
||||
.gc-body h2 { font-size: 1.2rem; margin: 0 0 3px; }
|
||||
.devtag { margin-left: 8px; font-size: 0.6rem; font-family: var(--label); font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.08em; color: #fff; background: #c2569b;
|
||||
border-radius: 5px; padding: 2px 6px; vertical-align: middle; }
|
||||
.gc-sub { color: var(--muted); font-size: 0.86rem; margin: 0 0 8px; }
|
||||
.gc-status { font-size: 0.84rem; color: var(--accent-deep); font-weight: 600; margin: 0; }
|
||||
.tile:hover { transform: translateY(-5px); }
|
||||
.tile.soon { cursor: default; }
|
||||
.tile.soon:hover { transform: none; }
|
||||
@media (prefers-reduced-motion: reduce) { .tile { transition: none; } .tile:hover { transform: none; } }
|
||||
.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 */
|
||||
.opts { display: flex; flex-direction: column; gap: 12px; max-width: 460px; }
|
||||
|
||||
@@ -6,6 +6,34 @@
|
||||
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');
|
||||
@@ -24,22 +52,52 @@
|
||||
<HubShell active="">
|
||||
<article class="quote-page">
|
||||
{#if state === 'ready'}
|
||||
<p class="eyebrow">Quote of the day</p>
|
||||
<blockquote class="quote">
|
||||
<span class="glyph" aria-hidden="true">“</span>
|
||||
<p class="text">{q.text}</p>
|
||||
<footer class="attrib">
|
||||
<span class="dash"></span>
|
||||
<span class="by">{q.author}{#if q.work}, <span class="work">{q.work}</span>{/if}</span>
|
||||
</footer>
|
||||
</blockquote>
|
||||
<!-- 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}
|
||||
<section class="meaning">
|
||||
<h2>What it means</h2>
|
||||
<p>{q.meaning}</p>
|
||||
</section>
|
||||
<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}
|
||||
@@ -49,35 +107,82 @@
|
||||
</HubShell>
|
||||
|
||||
<style>
|
||||
.quote-page { max-width: 720px; margin: 0 auto; }
|
||||
.eyebrow {
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
|
||||
color: #b06a86; margin: clamp(8px, 3vw, 28px) 0 0; text-align: center;
|
||||
@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);
|
||||
}
|
||||
.quote { margin: clamp(18px, 4vw, 34px) 0 0; position: relative; text-align: center; }
|
||||
.glyph {
|
||||
font-family: 'Newsreader', Georgia, serif; font-size: clamp(4rem, 11vw, 7rem); line-height: 0.6;
|
||||
color: rgba(176, 106, 134, 0.22); display: block; height: clamp(2rem, 5vw, 3rem);
|
||||
.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;
|
||||
}
|
||||
.quote .text {
|
||||
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-weight: 500;
|
||||
font-size: clamp(1.6rem, 4.5vw, 2.6rem); line-height: 1.28; letter-spacing: -0.01em;
|
||||
color: #2f2240; margin: 8px 0 0;
|
||||
/* 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;
|
||||
}
|
||||
.attrib { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: clamp(22px, 4vw, 32px); }
|
||||
.dash { width: 30px; height: 1px; background: #c9a6ba; }
|
||||
.by { font-family: 'Newsreader', Georgia, serif; font-size: 1.1rem; color: #7c64a0; }
|
||||
|
||||
/* 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 {
|
||||
margin: clamp(36px, 6vw, 56px) auto 0; max-width: 600px;
|
||||
border-top: 1px solid #ecdce4; padding-top: 26px;
|
||||
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;
|
||||
}
|
||||
.meaning h2 {
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: var(--muted); margin: 0 0 12px; text-align: center;
|
||||
|
||||
.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;
|
||||
}
|
||||
.meaning p { font-size: clamp(1.05rem, 2vw, 1.2rem); line-height: 1.6; color: #4a4255; margin: 0; text-align: center; }
|
||||
/* 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>
|
||||
|
||||
@@ -101,8 +101,9 @@
|
||||
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(8px, 3vw, 40px);
|
||||
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;
|
||||
@@ -115,7 +116,7 @@
|
||||
.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: 11px; font-weight: 700; letter-spacing: 0.3em; text-transform: uppercase; color: #7fa0bd;
|
||||
font-size: 13px; font-weight: 700; letter-spacing: 0.3em; text-transform: uppercase; color: #5f86a6;
|
||||
}
|
||||
.whead-main { min-width: 0; }
|
||||
.word {
|
||||
@@ -123,14 +124,19 @@
|
||||
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: 1.15rem; color: #8a8478; }
|
||||
.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: #3E6E97;
|
||||
background: transparent; border: 1px solid #3E6E97; border-radius: 999px; padding: 7px 15px;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
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;
|
||||
}
|
||||
.listen:hover { background: #3E6E97; color: #fdf7ec; }
|
||||
@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;
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { auth } from '$lib/auth.svelte.js';
|
||||
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);
|
||||
@@ -14,6 +15,8 @@
|
||||
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() {
|
||||
@@ -24,12 +27,17 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Dev-gated while UB is being ironed out: non-admins (no preview token) bounce.
|
||||
if (blockedForViewer('zen', auth.user, $page.url)) { goto('/play'); return; }
|
||||
debug = $page.url.searchParams.get('debug') === '1';
|
||||
let h;
|
||||
let cancelled = false; // guard the async load against an early unmount
|
||||
(async () => {
|
||||
// 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');
|
||||
@@ -38,25 +46,31 @@
|
||||
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();
|
||||
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; h?.dispose(); };
|
||||
return () => { cancelled = true; clearInterval(statTimer); h?.dispose(); };
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>The Zen Den · Upbeat Bytes</title>
|
||||
<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="Upbeat Bytes" /></a>
|
||||
<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>
|
||||
@@ -83,42 +97,51 @@
|
||||
<div class="panel">
|
||||
<div class="prow"><strong>UB render tuner</strong><button class="copy" onclick={copyValues}>{copied ? 'copied ✓' : 'copy values'}</button></div>
|
||||
|
||||
<label>yaw <span>{dbg.yaw.toFixed(2)}</span>
|
||||
<input type="range" min="-3.15" max="3.15" step="0.01" bind:value={dbg.yaw} oninput={apply} /></label>
|
||||
<label>pitch <span>{dbg.pitch.toFixed(2)}</span>
|
||||
<input type="range" min="-0.6" max="0.6" step="0.01" bind:value={dbg.pitch} oninput={apply} /></label>
|
||||
<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">Tail</div>
|
||||
<label class="chk"><input type="checkbox" bind:checked={dbg.tailTranslucent} onchange={apply} /> translucent (off = opaque, coherent)</label>
|
||||
<label>side
|
||||
<select bind:value={dbg.tailSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
|
||||
<label>alphaTest <span>{dbg.tailAlphaTest.toFixed(3)}</span>
|
||||
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.tailAlphaTest} oninput={apply} /></label>
|
||||
{#if dbg.tailTranslucent}
|
||||
<label>opacity <span>{dbg.tailOpacity.toFixed(2)}</span>
|
||||
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.tailOpacity} oninput={apply} /></label>
|
||||
<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</div>
|
||||
<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>
|
||||
<label>alphaTest <span>{dbg.finAlphaTest.toFixed(3)}</span>
|
||||
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.finAlphaTest} oninput={apply} /></label>
|
||||
{/if}
|
||||
|
||||
<hr />
|
||||
<label class="chk"><input type="checkbox" bind:checked={dbg.paused} onchange={apply} /> freeze frame</label>
|
||||
{#if dbg.paused}
|
||||
<label>frame <span>{dbg.frame.toFixed(2)}</span>
|
||||
<input type="range" min="0" max="1" step="0.01" bind:value={dbg.frame} oninput={apply} /></label>
|
||||
{/if}
|
||||
<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; }
|
||||
@@ -152,6 +175,9 @@
|
||||
.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; }
|
||||
|
||||
@@ -5,3 +5,9 @@ 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.
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Upbeat Bytes",
|
||||
"short_name": "Upbeat Bytes",
|
||||
"description": "Calm, constructive news worth your attention — and nothing that isn't.",
|
||||
"name": "upbeatBytes",
|
||||
"short_name": "upbeatBytes",
|
||||
"description": "A calmer, brighter corner of the internet: good news, daily art, small games, and little resets.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+113
-23
@@ -36,7 +36,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import art, auth, bloom, daily, email_send, feeds, games, oauth_google, onthisday, publishing, queries, quote, share, sources, summarize, wotd
|
||||
from . import art, auth, bloom, daily, email_send, feeds, games, newsimg, oauth_google, onthisday, publishing, queries, quote, readtime, share, sources, summarize, wotd
|
||||
from .localtime import local_today
|
||||
from .markup import reply_html_to_text, sanitize_reply_html
|
||||
from .db import connect
|
||||
@@ -322,6 +322,7 @@ class Article(BaseModel):
|
||||
paywalled: bool = False
|
||||
tags: list[str] = []
|
||||
summary: str | None = None # our own cached summary (present on the brief)
|
||||
source_read_minutes: int | None = None # ~minutes to read the FULL source article (null = unknown)
|
||||
# Subject geography (present on feed rows; absent/empty on the brief). breadth is
|
||||
# locality|regional|national|multinational|global|unknown; places are ISO codes.
|
||||
geo_breadth: str | None = None
|
||||
@@ -345,11 +346,14 @@ class Article(BaseModel):
|
||||
geo_confidence=row.get("geo_confidence"),
|
||||
geo_places=places,
|
||||
summary=row.get("summary"),
|
||||
source_read_minutes=readtime.source_read_minutes(row.get("source_words")),
|
||||
id=row["id"],
|
||||
title=row["title"],
|
||||
description=row.get("description"),
|
||||
url=row["canonical_url"],
|
||||
image_url=row.get("image_url"),
|
||||
# Resolve per the source's image policy: our cached copy, the publisher's URL
|
||||
# (hotlink), or none — so we never re-host an image we haven't cleared.
|
||||
image_url=newsimg.display_url(row["id"], row.get("image_policy"), row.get("image_url")),
|
||||
published_at=row.get("published_at"),
|
||||
source=row["source_name"],
|
||||
source_id=row.get("source_id"),
|
||||
@@ -589,6 +593,10 @@ class SourcePaywallBody(BaseModel):
|
||||
override: str | None = None # None = use domain rule · 'free' · 'paywalled'
|
||||
|
||||
|
||||
class SourceImagePolicyBody(BaseModel):
|
||||
policy: str = "remote" # 'cache' · 'remote' (default) · 'none'
|
||||
|
||||
|
||||
class CandidateSuggestBody(BaseModel):
|
||||
feed_url: str = ""
|
||||
name: str | None = None
|
||||
@@ -636,6 +644,7 @@ _EVENT_KINDS = {
|
||||
"share_ub", "copy_source", "native_share",
|
||||
"not_today", "less_like_this", "hide_topic",
|
||||
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
|
||||
"engaged", # genuine engagement: ~8s visible + a real gesture (vs. a raw visit)
|
||||
"client_error", # boot-failure seatbelt beacon (blank-screen risk signal)
|
||||
} | _GAME_EVENT_KINDS
|
||||
|
||||
@@ -765,7 +774,7 @@ def create_app() -> FastAPI:
|
||||
ok = _do_unsubscribe(u, t)
|
||||
msg = (
|
||||
"You’re unsubscribed from the daily digest. No hard feelings — "
|
||||
"Upbeat Bytes is always here when you want it."
|
||||
"upbeatBytes is always here when you want it."
|
||||
if ok else
|
||||
"That unsubscribe link looks invalid or expired. You can manage the "
|
||||
"digest from your account settings."
|
||||
@@ -774,9 +783,9 @@ def create_app() -> FastAPI:
|
||||
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
'<div style="max-width:520px;margin:12vh auto;padding:0 24px;text-align:center;'
|
||||
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
|
||||
'<h1 style="font-size:22px">Upbeat Bytes</h1>'
|
||||
'<h1 style="font-size:22px">upbeatBytes</h1>'
|
||||
f'<p style="font-size:16px;line-height:1.5;color:#3b4754">{msg}</p>'
|
||||
'<p><a href="/" style="color:#0083ad;text-decoration:none">← Back to Upbeat Bytes</a></p></div>'
|
||||
'<p><a href="/" style="color:#0083ad;text-decoration:none">← Back to upbeatBytes</a></p></div>'
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
|
||||
@@ -1109,7 +1118,7 @@ def create_app() -> FastAPI:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT a.id, a.title, a.description, a.image_url, a.canonical_url, "
|
||||
"a.duplicate_of, a.source_id, src.name AS source_name, s.reason_text, s.accepted, "
|
||||
"a.duplicate_of, a.source_id, src.name AS source_name, src.image_policy, s.reason_text, s.accepted, "
|
||||
"(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags "
|
||||
"FROM articles a JOIN sources src ON src.id = a.source_id "
|
||||
"LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
|
||||
@@ -1158,8 +1167,13 @@ def create_app() -> FastAPI:
|
||||
# --- Privacy-respecting first-party analytics -------------------------
|
||||
|
||||
@app.post("/api/events")
|
||||
def record_event(body: EventBody) -> dict:
|
||||
if body.kind in _EVENT_KINDS:
|
||||
def record_event(body: EventBody, request: Request) -> dict:
|
||||
# Don't let crawlers inflate visitor/funnel counts. Many modern bots run JS and
|
||||
# DO fire this beacon, so filter by User-Agent (same check the load-error beacon
|
||||
# uses) — catches honest bot UAs (GPTBot, AhrefsBot, headless Chrome, …). The
|
||||
# response is identical either way, so a bot can't tell it was dropped.
|
||||
ua = request.headers.get("user-agent", "")
|
||||
if body.kind in _EVENT_KINDS and not queries.is_bot_ua(ua):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO events (kind, article_id, visitor_hash, day) "
|
||||
@@ -1183,15 +1197,37 @@ def create_app() -> FastAPI:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/admin/client-errors")
|
||||
def admin_client_errors(request: Request) -> list[dict]:
|
||||
def admin_client_errors(request: Request,
|
||||
show: str = Query("unread", pattern="^(unread|read|all)$")) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
where = {"unread": "WHERE read_at IS NULL", "read": "WHERE read_at IS NOT NULL", "all": ""}[show]
|
||||
rows = conn.execute(
|
||||
"SELECT reason, path, user_agent, app_version, created_at FROM client_errors ORDER BY id DESC LIMIT 20"
|
||||
f"SELECT id, reason, path, user_agent, app_version, created_at, read_at "
|
||||
f"FROM client_errors {where} ORDER BY id DESC LIMIT 50"
|
||||
).fetchall()
|
||||
# Bots stay visible in the list (tagged) but are excluded from the
|
||||
# headline counts — see queries.admin_stats.
|
||||
return [{**dict(r), "bot": queries.is_bot_ua(r["user_agent"])} for r in rows]
|
||||
# Bots stay visible (tagged) but are excluded from the headline count — see admin_stats.
|
||||
return [{**dict(r), "read": r["read_at"] is not None,
|
||||
"bot": queries.is_bot_ua(r["user_agent"])} for r in rows]
|
||||
|
||||
@app.post("/api/admin/client-errors/read-all")
|
||||
def admin_client_errors_read_all(request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
cur = conn.execute("UPDATE client_errors SET read_at = CURRENT_TIMESTAMP WHERE read_at IS NULL")
|
||||
conn.commit()
|
||||
return {"ok": True, "marked": cur.rowcount}
|
||||
|
||||
@app.post("/api/admin/client-errors/{eid}/read")
|
||||
def admin_client_error_read(eid: int, body: FeedbackReadBody, request: Request) -> dict:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
ts = "CURRENT_TIMESTAMP" if body.read else "NULL"
|
||||
cur = conn.execute(f"UPDATE client_errors SET read_at = {ts} WHERE id = ?", (eid,))
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="load error not found")
|
||||
conn.commit()
|
||||
return {"ok": True, "read": body.read}
|
||||
|
||||
@app.post("/api/feedback")
|
||||
def submit_feedback(body: FeedbackBody, request: Request, background_tasks: BackgroundTasks) -> dict:
|
||||
@@ -1387,6 +1423,26 @@ def create_app() -> FastAPI:
|
||||
conn.commit()
|
||||
return {"ok": True, "override": ov}
|
||||
|
||||
@app.post("/api/admin/sources/{sid}/image-policy")
|
||||
def admin_source_image_policy(sid: int, body: SourceImagePolicyBody, request: Request) -> dict:
|
||||
# Image rights policy: 'cache' (re-host a downscaled copy — only for sources we've
|
||||
# cleared: open license / permission / public-domain), 'remote' (hotlink the
|
||||
# publisher's image), 'none' (no image). Default is the conservative 'remote'.
|
||||
pol = body.policy
|
||||
if pol not in ("cache", "remote", "none"):
|
||||
raise HTTPException(status_code=422, detail="policy must be 'cache', 'remote', or 'none'")
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
cur = conn.execute("UPDATE sources SET image_policy = ? WHERE id = ?", (pol, sid))
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="source not found")
|
||||
conn.commit()
|
||||
# Leaving 'cache' (e.g. permission revoked) → take the re-hosted copies down now,
|
||||
# not just make them inaccessible. Setting TO 'cache' just flips the flag; the
|
||||
# cycle warms it.
|
||||
purged = newsimg.purge_source(conn, sid) if pol != "cache" else 0
|
||||
return {"ok": True, "policy": pol, "purged": purged}
|
||||
|
||||
# --- Source candidates (supervised add-a-source pipeline) ----------------
|
||||
|
||||
def _candidate_dict(row) -> dict:
|
||||
@@ -1693,19 +1749,32 @@ def create_app() -> FastAPI:
|
||||
return HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404)
|
||||
return HTMLResponse(share.render_digest(items, PUBLIC_BASE_URL, b.get("brief_date")))
|
||||
|
||||
@app.get("/sitemap.xml")
|
||||
@app.api_route("/sitemap.xml", methods=["GET", "HEAD"])
|
||||
def sitemap() -> Response:
|
||||
with get_conn() as conn:
|
||||
pwx = queries.paywalled_source_ids(conn)
|
||||
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
|
||||
# Only articles with a real summary (the page has substance), paywalled excluded.
|
||||
# Cap near the 50k protocol ceiling so older canonical pages aren't dropped.
|
||||
rows = conn.execute(
|
||||
"SELECT a.id, COALESCE(a.published_at, a.discovered_at) AS lm "
|
||||
"FROM articles a JOIN article_scores s ON s.article_id = a.id "
|
||||
"WHERE s.accepted = 1 AND a.duplicate_of IS NULL "
|
||||
"ORDER BY lm DESC LIMIT 5000"
|
||||
"WHERE s.accepted = 1 AND a.duplicate_of IS NULL" + pw_clause + " "
|
||||
"AND EXISTS (SELECT 1 FROM article_summaries asum WHERE asum.article_id = a.id) "
|
||||
"ORDER BY lm DESC LIMIT 50000",
|
||||
pwx,
|
||||
).fetchall()
|
||||
base = PUBLIC_BASE_URL
|
||||
# Hub + the public sections, then every summarized article page.
|
||||
urls = [
|
||||
f"<url><loc>{base}/</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>",
|
||||
f"<url><loc>{base}/news</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>",
|
||||
f"<url><loc>{base}/today</loc><changefreq>daily</changefreq><priority>0.9</priority></url>",
|
||||
f"<url><loc>{base}/art</loc><changefreq>daily</changefreq><priority>0.6</priority></url>",
|
||||
f"<url><loc>{base}/play</loc><changefreq>weekly</changefreq><priority>0.5</priority></url>",
|
||||
f"<url><loc>{base}/word</loc><changefreq>daily</changefreq><priority>0.5</priority></url>",
|
||||
f"<url><loc>{base}/quote</loc><changefreq>daily</changefreq><priority>0.5</priority></url>",
|
||||
f"<url><loc>{base}/onthisday</loc><changefreq>daily</changefreq><priority>0.5</priority></url>",
|
||||
]
|
||||
for r in rows:
|
||||
lm = (r["lm"] or "")[:10]
|
||||
@@ -1907,13 +1976,9 @@ def create_app() -> FastAPI:
|
||||
else:
|
||||
rows = _fetch(None, limit, offset)
|
||||
next_offset = offset + len(rows) if len(rows) == limit else None
|
||||
# Paywalled below readable WITHIN each section (so tiers stay grouped); non-home
|
||||
# rows all share section rank 0, preserving the original global behavior.
|
||||
# Section grouping only — paywalled-source stories are excluded upstream now.
|
||||
_SEC = {"near": 0, "country": 1, "world": 2}
|
||||
rows = sorted(rows, key=lambda r: (
|
||||
_SEC.get(r.get("__section"), 0),
|
||||
is_paywalled_for_source(r["canonical_url"], r["paywall_override"]),
|
||||
))
|
||||
rows = sorted(rows, key=lambda r: _SEC.get(r.get("__section"), 0))
|
||||
return FeedResponse(
|
||||
topic=topic,
|
||||
flavor=flavor,
|
||||
@@ -2233,7 +2298,8 @@ def create_app() -> FastAPI:
|
||||
pool = data["items"]
|
||||
# Drop dismissed (replaced-away) items and anything the reader's
|
||||
# boundaries hide; avoid-terms take precedence over curation.
|
||||
items = [a for a in pool if a["id"] not in excl]
|
||||
items = [a for a in pool if a["id"] not in excl
|
||||
and not is_paywalled_for_source(a.get("canonical_url"), a.get("paywall_override"))]
|
||||
if not fp.is_empty():
|
||||
items = filter_articles(items, fp, now)
|
||||
items = items[:limit] # home mode over-fetches to survive filtering; cap here
|
||||
@@ -2292,10 +2358,34 @@ def create_app() -> FastAPI:
|
||||
"source": a["source"], "museum": museums.get(a["source"], a["source"]),
|
||||
"is_public_domain": bool(a["is_public_domain"]),
|
||||
"license": "Public Domain (CC0)" if a["is_public_domain"] else None,
|
||||
"blurb": a.get("blurb"),
|
||||
"palette": json.loads(a["palette"]) if a.get("palette") else [],
|
||||
"image_url": f"/api/art/image/{a['object_id']}",
|
||||
"image_url_large": f"/api/art/image/{a['object_id']}?size=full",
|
||||
}
|
||||
|
||||
@app.api_route("/api/img/{article_id}", methods=["GET", "HEAD"])
|
||||
def article_image(article_id: int) -> FileResponse:
|
||||
"""Serve the locally-cached, downscaled WebP for an accepted, canonical article.
|
||||
Cache HITS ONLY — this never fetches (the cycle owns all fetching via newsimg.warm),
|
||||
so the public endpoint has no SSRF/worker-exhaustion surface. A miss 404s; the
|
||||
frontend handles that (retry, then the typographic cover)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT a.image_url FROM articles a JOIN article_scores s ON s.article_id = a.id "
|
||||
"JOIN sources src ON src.id = a.source_id "
|
||||
"WHERE a.id = ? AND s.accepted = 1 AND a.duplicate_of IS NULL AND src.image_policy = 'cache'",
|
||||
(article_id,),
|
||||
).fetchone()
|
||||
url = row["image_url"] if row else None
|
||||
path = newsimg.path_for(url) if url else None
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail="image not cached")
|
||||
# NOT immutable: a re-enriched article can change image_url, so the bytes behind a
|
||||
# given id can change. A day's browser cache is plenty (we're direct-origin, no CDN).
|
||||
return FileResponse(str(path), media_type="image/webp",
|
||||
headers={"Cache-Control": "public, max-age=86400"})
|
||||
|
||||
@app.api_route("/api/art/image/{object_id}", methods=["GET", "HEAD"])
|
||||
def art_image(object_id: int, size: str = Query("")) -> FileResponse:
|
||||
cdir = art.cache_dir()
|
||||
|
||||
+73
-8
@@ -132,6 +132,69 @@ def _download_image(obj: dict, object_id: int) -> str | None:
|
||||
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."""
|
||||
@@ -148,7 +211,7 @@ def _candidates(conn: sqlite3.Connection, art_date: str, source: str) -> list[in
|
||||
|
||||
|
||||
def pick_daily(conn: sqlite3.Connection, art_date: str | None = None, source: str = "met",
|
||||
force: bool = False) -> dict | None:
|
||||
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)."""
|
||||
@@ -166,21 +229,23 @@ def pick_daily(conn: sqlite3.Connection, art_date: str | None = None, source: st
|
||||
fname = _download_image(obj, oid)
|
||||
if not fname:
|
||||
continue
|
||||
# All network work is done above; only now do we open a brief write txn + commit.
|
||||
# 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) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
||||
"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",
|
||||
"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),
|
||||
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))
|
||||
@@ -199,13 +264,13 @@ def get_today(conn: sqlite3.Connection, art_date: str | None = None) -> dict | N
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def run_daily(conn: sqlite3.Connection, source: str = "met") -> dict:
|
||||
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)
|
||||
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}
|
||||
|
||||
+11
-7
@@ -4,6 +4,7 @@ import sqlite3
|
||||
|
||||
from .localtime import local_today
|
||||
from .paywall import is_paywalled, is_paywalled_for_source
|
||||
from .queries import paywalled_source_ids
|
||||
|
||||
|
||||
def build_daily_brief(
|
||||
@@ -15,11 +16,10 @@ def build_daily_brief(
|
||||
) -> int:
|
||||
target_date = brief_date or local_today()
|
||||
|
||||
# Compose the selection first so we can tell whether anything actually
|
||||
# changed. A calm daily brief shouldn't repeatedly hand the reader a locked
|
||||
# door: push paywalled candidates below readable ones (stable sort) first.
|
||||
# Compose the selection first so we can tell whether anything actually changed.
|
||||
# A calm daily brief never hands the reader a locked door: paywalled-source
|
||||
# candidates are excluded in _candidate_articles (before LIMIT) — no unreadable news.
|
||||
rows = _candidate_articles(conn, target_date, window_days)
|
||||
rows = sorted(rows, key=lambda r: is_paywalled_for_source(r["canonical_url"], r["paywall_override"]))
|
||||
selected = _select_diverse(rows, limit)
|
||||
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
|
||||
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
|
||||
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(
|
||||
"""
|
||||
f"""
|
||||
SELECT
|
||||
a.id,
|
||||
a.title,
|
||||
@@ -152,6 +155,7 @@ def _candidate_articles(
|
||||
AND b.brief_date <= date(?)
|
||||
AND b.brief_date > date(?, '-7 days')
|
||||
)
|
||||
{pw_clause}
|
||||
ORDER BY
|
||||
is_today DESC,
|
||||
(s.constructive_score + s.agency_score + s.human_benefit_score + src.trust_score
|
||||
@@ -159,7 +163,7 @@ def _candidate_articles(
|
||||
COALESCE(a.published_at, a.discovered_at) DESC
|
||||
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()
|
||||
|
||||
|
||||
|
||||
+24
-4
@@ -13,8 +13,8 @@ from .games import generate_daily_puzzles
|
||||
from .localtime import local_today
|
||||
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, cluster_duplicates, dedup as run_dedup
|
||||
from .geo import tag_articles as tag_geo
|
||||
from . import art, onthisday, quote, wotd
|
||||
from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
|
||||
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 .feeds import (
|
||||
fetch_feed,
|
||||
@@ -319,7 +319,7 @@ def main() -> None:
|
||||
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)
|
||||
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'}")
|
||||
@@ -556,7 +556,7 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
||||
# once the day is picked; non-fatal like every other step.
|
||||
if not args.no_art:
|
||||
try:
|
||||
a = art.run_daily(conn)
|
||||
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})")
|
||||
@@ -599,6 +599,26 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
||||
except Exception as 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.
|
||||
# Idempotent: cached items are skipped, so this only hits the LLM for new ones.
|
||||
try:
|
||||
|
||||
@@ -33,6 +33,21 @@ def seeded_order(ids: list, date_str: str) -> list:
|
||||
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)
|
||||
|
||||
@@ -49,6 +49,8 @@ CREATE TABLE IF NOT EXISTS articles (
|
||||
title_hash TEXT,
|
||||
duplicate_of INTEGER REFERENCES articles(id) ON DELETE SET NULL,
|
||||
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)
|
||||
);
|
||||
|
||||
@@ -271,6 +273,8 @@ CREATE TABLE IF NOT EXISTS daily_art (
|
||||
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
|
||||
);
|
||||
|
||||
@@ -595,6 +599,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
if "image_checked_at" not in article_cols:
|
||||
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.
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_duplicate_of ON articles(duplicate_of)")
|
||||
|
||||
@@ -624,6 +632,11 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("ALTER TABLE sources ADD COLUMN content_visible INTEGER NOT NULL DEFAULT 1")
|
||||
if "retry_after_at" not in source_cols:
|
||||
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)")}
|
||||
@@ -634,12 +647,20 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
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.
|
||||
fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")}
|
||||
if fb_cols and "read_at" not in fb_cols:
|
||||
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.
|
||||
rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")}
|
||||
if rep_cols and "message_html" not in rep_cols:
|
||||
|
||||
+10
-5
@@ -17,6 +17,7 @@ from html import escape
|
||||
from . import email_send
|
||||
from .localtime import local_now, local_today
|
||||
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_WINDOW_HOURS = 4 # send between DIGEST_HOUR and +4h, site-local
|
||||
@@ -29,8 +30,10 @@ def _base_url() -> str:
|
||||
|
||||
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)."""
|
||||
pwx = paywalled_source_ids(conn)
|
||||
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
f"""
|
||||
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
|
||||
FROM daily_briefs b
|
||||
@@ -38,11 +41,11 @@ def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> l
|
||||
JOIN articles a ON a.id = bi.article_id
|
||||
JOIN sources s ON s.id = a.source_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
|
||||
LIMIT ?
|
||||
""",
|
||||
(brief_date, limit),
|
||||
(brief_date, *pwx, limit),
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
@@ -72,6 +75,8 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
|
||||
f"AND at.tag IN ({','.join('?' * len(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(
|
||||
f"""
|
||||
SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, a.source_id, sc.reason_text,
|
||||
@@ -79,12 +84,12 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
|
||||
FROM articles a
|
||||
JOIN sources s ON s.id = a.source_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)})
|
||||
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
|
||||
LIMIT 30
|
||||
""",
|
||||
params,
|
||||
[*pwx, *params],
|
||||
).fetchall()
|
||||
exclude, per_source, out = set(exclude_ids), {}, []
|
||||
for r in rows:
|
||||
|
||||
@@ -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:
|
||||
"""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)"
|
||||
text = (
|
||||
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
|
||||
text/plain + text/html (the HTML is the pre-sanitized Markdown render). Quotes
|
||||
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())
|
||||
text = (
|
||||
f"{reply_text}\n\n"
|
||||
"—\n"
|
||||
"In reply to your note to Upbeat Bytes:\n"
|
||||
"In reply to your note to upbeatBytes:\n"
|
||||
f"{quoted}\n\n"
|
||||
"Thanks for reaching out.\n— Upbeat Bytes\n"
|
||||
"Thanks for reaching out.\n— upbeatBytes\n"
|
||||
)
|
||||
body_html = None
|
||||
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;'
|
||||
'color:#16263a;font-size:15px;line-height:1.5">'
|
||||
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>'
|
||||
"<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).
|
||||
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:
|
||||
"""Send a calm, single-purpose sign-in email."""
|
||||
subject = "Your Upbeat Bytes sign-in link"
|
||||
subject = "Your upbeatBytes sign-in link"
|
||||
text = (
|
||||
"Welcome back to Upbeat Bytes.\n\n"
|
||||
"Welcome back to upbeatBytes.\n\n"
|
||||
f"Tap to sign in:\n{link}\n\n"
|
||||
"This link works once and expires in 15 minutes.\n"
|
||||
"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 = (
|
||||
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
||||
'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;'
|
||||
'text-decoration:none;padding:11px 20px;border-radius:999px;font-weight:600">'
|
||||
"Sign in</a></p>"
|
||||
|
||||
@@ -174,6 +174,47 @@ def fetch_og_image(url: str | None) -> str | None:
|
||||
return None # too many redirects
|
||||
|
||||
|
||||
# Word counting reads more of the body than image metadata (which only needs <head>).
|
||||
_READ_MAX_BYTES = 900_000
|
||||
|
||||
|
||||
def fetch_source_words(url: str | None) -> int | None:
|
||||
"""Fetch a page and return its full-article word count (furniture stripped), or
|
||||
None on any failure or a too-thin extraction (JS/video/paywall pages). Same SSRF
|
||||
safety as fetch_og_image; we read the count only, never store the body."""
|
||||
from .readtime import source_read_minutes, word_count_from_html
|
||||
opener = urllib.request.build_opener(_NoRedirect)
|
||||
for _ in range(MAX_REDIRECTS + 1):
|
||||
if not url:
|
||||
return None
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme not in ("http", "https") or not _host_is_public(parts.hostname):
|
||||
return None
|
||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html"})
|
||||
try:
|
||||
response = opener.open(request, timeout=TIMEOUT)
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
return None
|
||||
status = getattr(response, "status", 200) or 200
|
||||
if status in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("Location")
|
||||
response.close()
|
||||
if not location:
|
||||
return None
|
||||
url = urljoin(url, location)
|
||||
continue
|
||||
if "html" not in response.headers.get("Content-Type", "").lower():
|
||||
response.close()
|
||||
return None
|
||||
try:
|
||||
body = response.read(_READ_MAX_BYTES)
|
||||
finally:
|
||||
response.close()
|
||||
words = word_count_from_html(body)
|
||||
return words if source_read_minutes(words) is not None else None
|
||||
return None # too many redirects
|
||||
|
||||
|
||||
def _image_dimensions(data: bytes) -> "tuple[int, int] | None":
|
||||
"""Best-effort (width, height) from an image file's header bytes — PNG, GIF,
|
||||
JPEG, WebP. Returns None for formats we can't cheaply measure (e.g. SVG)."""
|
||||
@@ -411,3 +452,42 @@ def enrich_summarized_images(
|
||||
if enrich_article_image(conn, row["id"], fetch=fetch, retry_days=retry_days):
|
||||
found += 1
|
||||
return found
|
||||
|
||||
|
||||
def enrich_read_times(
|
||||
conn: sqlite3.Connection, fetch=fetch_source_words, limit: int = 40, retry_days: int = 14
|
||||
) -> int:
|
||||
"""Give recent accepted articles a full-article word count, so the front door can
|
||||
show "Full story · ~N min" next to our one-minute gist. Bounded per run (mirrors
|
||||
the image enrichers); fetches each article once, retrying a failed/too-thin
|
||||
extraction only after `retry_days`. Returns how many real counts were stored."""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT a.id, a.canonical_url FROM articles a
|
||||
JOIN article_scores s ON s.article_id = a.id
|
||||
WHERE s.accepted = 1 AND a.duplicate_of IS NULL
|
||||
AND a.source_words IS NULL
|
||||
AND (a.read_checked_at IS NULL OR a.read_checked_at < datetime('now', ?))
|
||||
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(f"-{retry_days} days", limit),
|
||||
).fetchall()
|
||||
found = 0
|
||||
for row in rows:
|
||||
try:
|
||||
words = fetch(row["canonical_url"])
|
||||
except Exception:
|
||||
words = None
|
||||
# Only ever write a REAL count; never overwrite a good value with null/zero.
|
||||
# Always stamp the check time so failed/thin pages aren't re-fetched until retry.
|
||||
if words:
|
||||
conn.execute(
|
||||
"UPDATE articles SET source_words = ?, read_checked_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(words, row["id"]),
|
||||
)
|
||||
found += 1
|
||||
else:
|
||||
conn.execute("UPDATE articles SET read_checked_at = CURRENT_TIMESTAMP WHERE id = ?", (row["id"],))
|
||||
conn.commit()
|
||||
return found
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ _RESPONSE_FORMATS = (
|
||||
)
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You classify article metadata for Upbeat Bytes, a calm news digest.
|
||||
SYSTEM_PROMPT = """You classify article metadata for upbeatBytes, a calm news digest.
|
||||
|
||||
The bar is NOT "is this happy?" — it is "will a reader finish this calm or a little better, never worse?" ACCEPT stories that are calm, neutral, insightful, or uplifting: they inform, teach, delight, or show progress or benefit. Neutral-but-absorbing is welcome — a discovery, a clear explainer, a clever build or gadget, a fascinating bit of science, space, nature, design, or culture, a genuinely useful insight — even when it isn't "feel-good."
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Local image cache + downscale for news article images.
|
||||
|
||||
Article images used to be hotlinked from the source, so a slow/flaky third-party CDN
|
||||
left a blank graphic until a refresh. Instead the CYCLE fetches a downscaled WebP copy
|
||||
to data/img_cache/ (beside the DB, mounted into the API container, mirrors art_cache),
|
||||
and the API serves only cache HITS — it never fetches, so the public endpoint has no
|
||||
SSRF or worker-exhaustion surface. The cache is bounded by a hard size ceiling with LRU
|
||||
eviction, so it can't grow without limit no matter the ingest rate.
|
||||
|
||||
Security posture (the fetch runs only in the trusted cycle, but feed image URLs are
|
||||
still externally supplied, so we treat them as untrusted):
|
||||
* SSRF-safe fetch reuses enrich._host_is_public + bounded redirect re-validation
|
||||
(same path as feeds.safe_fetch_feed) — no private/loopback/link-local targets,
|
||||
http(s) only, every redirect hop re-checked.
|
||||
* Only successfully-decoded RASTER images are re-encoded to WebP and stored; SVG and
|
||||
anything undecodable is REJECTED (never retained as a same-origin file).
|
||||
* Decompression-bomb + dimension guards.
|
||||
* Definitive failures are negative-cached (a .fail marker) so a bad URL isn't refetched
|
||||
every cycle; transient network errors are not, so they retry.
|
||||
Concurrency: all fetching happens inside the cycle, which holds an exclusive lock, so no
|
||||
two fetches race; writes are atomic (temp + rename) regardless.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
from .enrich import MAX_REDIRECTS, _NoRedirect, _host_is_public
|
||||
|
||||
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
|
||||
_MIN_IMAGE_BYTES = 500
|
||||
_MAX_FETCH_BYTES = 20 * 1024 * 1024 # never pull an absurd original into memory
|
||||
_MAX_PIXELS = 50_000_000 # decompression-bomb ceiling (≈50 MP)
|
||||
_MAX_DIM = 12000 # reject pathological single-axis dimensions
|
||||
DISPLAY_WIDTH = 800 # cards / feed never show wider than this
|
||||
WEBP_QUALITY = 80
|
||||
DEFAULT_CAP_BYTES = 1024 * 1024 * 1024 # 1 GB hard ceiling (override via env)
|
||||
_FAIL_TTL_S = 3 * 24 * 3600 # don't refetch a definitively-bad URL for 3 days
|
||||
|
||||
|
||||
def cache_dir() -> Path:
|
||||
override = os.environ.get("GOODNEWS_IMG_CACHE")
|
||||
db = Path(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
|
||||
d = Path(override) if override else db.parent / "img_cache"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def cap_bytes() -> int:
|
||||
try:
|
||||
return int(os.environ.get("GOODNEWS_IMG_CACHE_CAP", DEFAULT_CAP_BYTES))
|
||||
except ValueError:
|
||||
return DEFAULT_CAP_BYTES
|
||||
|
||||
|
||||
def _key(url: str) -> str:
|
||||
return hashlib.sha1(url.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def display_url(article_id: int, image_policy: str | None, raw_url: str | None) -> str | None:
|
||||
"""The image URL the frontend should use, honoring the SOURCE's image policy:
|
||||
'cache' → our locally-cached copy (/api/img/<id>) — only for sources we've cleared
|
||||
to re-host (open license / explicit permission / public-domain).
|
||||
'remote' → the publisher's own URL (hotlinked + the frontend's graceful retry). The
|
||||
conservative DEFAULT: we display but never re-host.
|
||||
'none' → no image (typographic cover).
|
||||
Returns None when there's no image or the policy is 'none'."""
|
||||
if not raw_url:
|
||||
return None
|
||||
if image_policy == "cache":
|
||||
return f"/api/img/{article_id}"
|
||||
if image_policy == "none":
|
||||
return None
|
||||
return raw_url # 'remote' (default) — hotlink, never re-hosted
|
||||
|
||||
|
||||
class _FetchError(Exception):
|
||||
"""permanent=True → negative-cache (won't retry soon); False → transient, retry."""
|
||||
def __init__(self, msg: str, permanent: bool):
|
||||
super().__init__(msg)
|
||||
self.permanent = permanent
|
||||
|
||||
|
||||
def _safe_fetch(url: str, timeout: int = 12) -> tuple[bytes, str]:
|
||||
"""SSRF-safe fetch of an untrusted image URL: http(s) only, every redirect hop
|
||||
re-validated against public IPs, bounded redirects, body capped. Raises _FetchError
|
||||
(permanent for policy refusals, transient for network errors)."""
|
||||
opener = urllib.request.build_opener(_NoRedirect)
|
||||
current = url
|
||||
for _ in range(MAX_REDIRECTS + 1):
|
||||
parts = urlsplit(current)
|
||||
if parts.scheme not in ("http", "https") or not _host_is_public(parts.hostname):
|
||||
raise _FetchError(f"non-public or non-http(s): {current}", permanent=True)
|
||||
req = urllib.request.Request(current, headers=_UA)
|
||||
try:
|
||||
resp = opener.open(req, timeout=timeout)
|
||||
except urllib.error.HTTPError as exc:
|
||||
# _NoRedirect makes urllib RAISE on 3xx (rather than return a response), so
|
||||
# redirects arrive here. Re-validate the destination on the next loop. 4xx
|
||||
# (except 429) is a permanent miss → negative-cache; 429/5xx → transient.
|
||||
if exc.code in (301, 302, 303, 307, 308):
|
||||
loc = exc.headers.get("Location")
|
||||
exc.close()
|
||||
if not loc:
|
||||
raise _FetchError("redirect without location", permanent=True) from exc
|
||||
current = urljoin(current, loc)
|
||||
continue
|
||||
permanent = 400 <= exc.code < 500 and exc.code != 429
|
||||
raise _FetchError(f"http {exc.code}", permanent=permanent) from exc
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
raise _FetchError(f"fetch failed: {exc}", permanent=False) from exc
|
||||
try:
|
||||
return resp.read(_MAX_FETCH_BYTES + 1), (resp.headers.get("Content-Type") or "")
|
||||
finally:
|
||||
resp.close()
|
||||
raise _FetchError("too many redirects", permanent=True)
|
||||
|
||||
|
||||
def _encode(data: bytes) -> bytes | None:
|
||||
"""Downscale a decoded RASTER image to DISPLAY_WIDTH and re-encode as WebP. None if
|
||||
it isn't a decodable raster (e.g. SVG), is a decompression bomb, or has pathological
|
||||
dimensions — the caller then REJECTS it (never stores arbitrary bytes)."""
|
||||
try:
|
||||
from PIL import Image
|
||||
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS # backstop; Pillow only WARNS at this, raises ~2x
|
||||
im = Image.open(io.BytesIO(data)) # lazy: header (size) read without decoding pixels
|
||||
# Enforce the pixel/dimension ceiling BEFORE load() so a decompression bomb is never
|
||||
# actually decoded (Pillow's own MAX_IMAGE_PIXELS only warns at the threshold).
|
||||
if (im.width * im.height > _MAX_PIXELS or im.width > _MAX_DIM or im.height > _MAX_DIM
|
||||
or im.width < 1 or im.height < 1):
|
||||
return None
|
||||
im.load() # decode now (also catches truncated data)
|
||||
if im.mode not in ("RGB", "RGBA"):
|
||||
im = im.convert("RGBA" if ("A" in im.mode or im.mode == "P") else "RGB")
|
||||
if im.width > DISPLAY_WIDTH:
|
||||
h = max(1, round(im.height * DISPLAY_WIDTH / im.width))
|
||||
im = im.resize((DISPLAY_WIDTH, h), Image.LANCZOS)
|
||||
out = io.BytesIO()
|
||||
im.save(out, format="WEBP", quality=WEBP_QUALITY, method=4)
|
||||
return out.getvalue()
|
||||
except Exception: # noqa: BLE001 — UnidentifiedImageError, DecompressionBombError, SVG, truncated …
|
||||
return None
|
||||
|
||||
|
||||
def _fail_path(url: str) -> Path:
|
||||
return cache_dir() / f"{_key(url)}.fail"
|
||||
|
||||
|
||||
def _mark_failed(url: str) -> None:
|
||||
try:
|
||||
_fail_path(url).touch()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _failed_recently(url: str) -> bool:
|
||||
try:
|
||||
return (time.time() - _fail_path(url).stat().st_mtime) < _FAIL_TTL_S
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def path_for(url: str) -> Path | None:
|
||||
"""The cached WebP for this URL if present (and bump its mtime, the LRU marker).
|
||||
A pure cache lookup — never fetches."""
|
||||
if not url:
|
||||
return None
|
||||
p = cache_dir() / f"{_key(url)}.webp"
|
||||
if p.exists():
|
||||
try:
|
||||
os.utime(p, None) # touch → last-used time for LRU eviction
|
||||
except OSError:
|
||||
pass
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def fetch_and_cache(url: str | None) -> Path | None:
|
||||
"""Fetch (SSRF-safe), downscale to WebP, and cache atomically. CYCLE-ONLY — the API
|
||||
endpoint never calls this. None on any failure; definitive failures are negative-cached
|
||||
so they aren't retried every cycle."""
|
||||
if not url or not url.startswith(("http://", "https://")):
|
||||
return None
|
||||
try:
|
||||
data, _ctype = _safe_fetch(url)
|
||||
except _FetchError as exc:
|
||||
if exc.permanent:
|
||||
_mark_failed(url)
|
||||
return None
|
||||
if not (_MIN_IMAGE_BYTES <= len(data) <= _MAX_FETCH_BYTES):
|
||||
_mark_failed(url)
|
||||
return None
|
||||
blob = _encode(data)
|
||||
if blob is None: # SVG / undecodable / bomb / bad dimensions
|
||||
_mark_failed(url)
|
||||
return None
|
||||
cdir = cache_dir()
|
||||
key = _key(url)
|
||||
tmp = cdir / f".{key}.tmp"
|
||||
dest = cdir / f"{key}.webp"
|
||||
try:
|
||||
tmp.write_bytes(blob)
|
||||
os.replace(tmp, dest) # atomic
|
||||
except OSError:
|
||||
try:
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
return dest
|
||||
|
||||
|
||||
def purge_source(conn, source_id: int) -> int:
|
||||
"""Delete every cached file for a source's article image URLs. Called when a source
|
||||
leaves 'cache' policy (revoked permission / re-classified), so the re-hosted copies
|
||||
come down immediately rather than lingering inaccessible on disk. Returns webp count."""
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT image_url FROM articles WHERE source_id = ? "
|
||||
"AND image_url IS NOT NULL AND image_url != ''",
|
||||
(source_id,),
|
||||
).fetchall()
|
||||
cdir = cache_dir()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
key = _key(r[0])
|
||||
for suffix in (".webp", ".fail"):
|
||||
p = cdir / f"{key}{suffix}"
|
||||
try:
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
if suffix == ".webp":
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
def warm(conn, limit: int = 200) -> int:
|
||||
"""Pre-fetch display copies for the newest ACCEPTED, CANONICAL articles whose SOURCE
|
||||
is cleared to cache (image_policy='cache'), so the API only ever serves cache hits.
|
||||
Bounded; skips already-cached and recently-failed URLs. Returns how many it newly
|
||||
cached. Sources default to 'remote' (hotlink, never re-hosted), so this caches
|
||||
nothing until a source is explicitly set to 'cache'."""
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT a.image_url FROM article_scores s JOIN articles a ON a.id = s.article_id "
|
||||
"JOIN sources src ON src.id = a.source_id "
|
||||
"WHERE s.accepted=1 AND a.duplicate_of IS NULL AND src.image_policy='cache' "
|
||||
"AND a.image_url IS NOT NULL AND a.image_url != '' ORDER BY a.id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
made = 0
|
||||
for r in rows:
|
||||
url = r[0]
|
||||
if path_for(url) or _failed_recently(url):
|
||||
continue
|
||||
if fetch_and_cache(url):
|
||||
made += 1
|
||||
return made
|
||||
|
||||
|
||||
def prune(cap: int | None = None) -> dict:
|
||||
"""Enforce the size ceiling: delete least-recently-used WebPs (oldest mtime first)
|
||||
until under the cap; also sweep stale .fail markers. Returns {before, after, removed, cap}."""
|
||||
if cap is None:
|
||||
cap = cap_bytes()
|
||||
now = time.time()
|
||||
files, total = [], 0
|
||||
for p in cache_dir().iterdir():
|
||||
if p.name.startswith("."):
|
||||
continue
|
||||
if p.suffix == ".fail":
|
||||
try:
|
||||
if now - p.stat().st_mtime >= _FAIL_TTL_S:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if p.suffix != ".webp" or not p.is_file():
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
except OSError:
|
||||
continue
|
||||
files.append((st.st_mtime, st.st_size, p))
|
||||
total += st.st_size
|
||||
before, removed = total, 0
|
||||
if total > cap:
|
||||
files.sort() # oldest mtime first = least recently used
|
||||
for _mtime, size, p in files:
|
||||
if total <= cap:
|
||||
break
|
||||
try:
|
||||
p.unlink()
|
||||
total -= size
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
return {"before": before, "after": total, "removed": removed, "cap": cap}
|
||||
+13
-1
@@ -31,6 +31,18 @@ _NEG = (
|
||||
)
|
||||
|
||||
|
||||
# Wikimedia's feed hands us a 330px `thumbnail`, which upscales (blurry) in our hero. It also
|
||||
# gives `originalimage` — a sharp, full-size URL that's always valid. We can't just request a
|
||||
# bigger thumbnail width: for very large source images Wikimedia only serves pre-generated
|
||||
# bucket sizes and 400s on arbitrary widths (e.g. 500px ok, 800/1024px fail, 1280px ok). So
|
||||
# prefer the originalimage (reliably sharp), falling back to the thumbnail.
|
||||
def _best_image(page: dict) -> str | None:
|
||||
"""The sharpest reliably-served image URL: originalimage, else the 330px thumbnail."""
|
||||
orig = (page.get("originalimage") or {}).get("source")
|
||||
thumb = (page.get("thumbnail") or {}).get("source")
|
||||
return orig or thumb or None
|
||||
|
||||
|
||||
def _fetch_events(md: str) -> list[dict]:
|
||||
"""All events for a MM-DD from Wikimedia, normalized to our candidate shape."""
|
||||
mm, dd = md.split("-")
|
||||
@@ -46,7 +58,7 @@ def _fetch_events(md: str) -> list[dict]:
|
||||
"year": e.get("year"),
|
||||
"text": text,
|
||||
"summary": (page.get("extract") or "").strip() or None,
|
||||
"image_url": ((page.get("thumbnail") or {}).get("source")) or None,
|
||||
"image_url": _best_image(page),
|
||||
"page_url": (((page.get("content_urls") or {}).get("desktop") or {}).get("page")) or None,
|
||||
})
|
||||
return out
|
||||
|
||||
+57
-2
@@ -20,6 +20,19 @@ from .paywall import is_paywalled, is_paywalled_for_source
|
||||
BOT_UA_MARKS = ("headlesschrome", "bot", "spider", "crawl", "python", "curl", "wget", "phantomjs")
|
||||
_NOT_BOT_SQL = " AND ".join(f"instr(lower(user_agent), '{m}')=0" for m in BOT_UA_MARKS)
|
||||
|
||||
# "Engaged reader" = a distinct visitor-day with DELIBERATE activity, as opposed to a raw
|
||||
# visit (which a JS-capable bot can trip). Counts the gesture-gated 'engaged' beacon OR a
|
||||
# genuine deliberate action. Deliberately EXCLUDES auto-fired/passive kinds (visit,
|
||||
# summary_viewed, open), replace_none, and game *_arrival (a share-loop landing, not engagement).
|
||||
_ENGAGED_GAMES = ("word", "wordsearch", "bloom", "match")
|
||||
ENGAGED_EVENT_KINDS = (
|
||||
"engaged", "full_story", "source_click",
|
||||
"share_ub", "copy_source", "native_share",
|
||||
"replace_used", "paywall_replace", "paywalled_source_open",
|
||||
"not_today", "less_like_this", "hide_topic",
|
||||
*(f"{g}_{e}" for g in _ENGAGED_GAMES for e in ("started", "completed", "shared")),
|
||||
)
|
||||
|
||||
|
||||
def is_bot_ua(ua: str | None) -> bool:
|
||||
low = (ua or "").lower()
|
||||
@@ -55,11 +68,26 @@ _ARTICLE_COLUMNS = f"""
|
||||
s.reason_text,
|
||||
s.model_name,
|
||||
src.paywall_override AS paywall_override,
|
||||
src.image_policy AS image_policy,
|
||||
a.source_words,
|
||||
(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags,
|
||||
{RANK_SCORE_SQL} AS rank_score
|
||||
"""
|
||||
|
||||
|
||||
def paywalled_source_ids(conn: sqlite3.Connection) -> list[int]:
|
||||
"""Source ids whose stories are paywalled — the domain rule (PAYWALL_DOMAINS),
|
||||
overridable per source in admin. Computed live so an admin flag takes effect at
|
||||
once. Small set (a handful of sources), so the lookup is cheap."""
|
||||
rows = conn.execute(
|
||||
"SELECT id, homepage_url, feed_url, paywall_override FROM sources"
|
||||
).fetchall()
|
||||
return [
|
||||
r["id"] for r in rows
|
||||
if is_paywalled_for_source((r["homepage_url"] or r["feed_url"]), r["paywall_override"])
|
||||
]
|
||||
|
||||
|
||||
def feed(
|
||||
conn: sqlite3.Connection,
|
||||
topic: str | None = None,
|
||||
@@ -83,6 +111,7 @@ def feed(
|
||||
home_country: str | None = None,
|
||||
home_state: str | None = None,
|
||||
geo_scope: str | None = None, # 'near' | 'country' | 'world' relative to the reader's home
|
||||
include_paywalled: bool = False, # default: hide paywalled-source stories (no unreadable news in the feed)
|
||||
) -> list[dict]:
|
||||
"""Return articles with categorical filters applied in SQL.
|
||||
|
||||
@@ -105,6 +134,13 @@ def feed(
|
||||
fts_join = "JOIN article_search ON article_search.article_id = a.id"
|
||||
clauses.append("article_search MATCH ?")
|
||||
params.append(match)
|
||||
# Hard-exclude paywalled sources (admin-overridable). Added after MATCH so the FTS
|
||||
# bound param keeps leading; the NOT IN list (a handful of ids) follows.
|
||||
if not include_paywalled:
|
||||
pwx = paywalled_source_ids(conn)
|
||||
if pwx:
|
||||
clauses.append("a.source_id NOT IN (%s)" % ",".join("?" * len(pwx)))
|
||||
params.extend(pwx)
|
||||
if accepted_only:
|
||||
clauses.append("s.accepted = 1")
|
||||
if topic:
|
||||
@@ -352,6 +388,8 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
|
||||
if not header:
|
||||
return {"brief_date": target_date, "title": None, "created_at": None, "items": []}
|
||||
|
||||
pwx = paywalled_source_ids(conn)
|
||||
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT bi.rank, bi.selection_reason, {_ARTICLE_COLUMNS},
|
||||
@@ -361,11 +399,11 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
|
||||
JOIN articles a ON a.id = bi.article_id
|
||||
JOIN sources src ON src.id = a.source_id
|
||||
LEFT JOIN article_scores s ON s.article_id = a.id
|
||||
WHERE b.brief_date = ? AND src.content_visible = 1
|
||||
WHERE b.brief_date = ? AND src.content_visible = 1{pw_clause}
|
||||
ORDER BY bi.rank
|
||||
LIMIT ?
|
||||
""",
|
||||
(target_date, limit),
|
||||
(target_date, *pwx, limit),
|
||||
).fetchall()
|
||||
return {
|
||||
"brief_date": header["brief_date"],
|
||||
@@ -501,6 +539,7 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
|
||||
s.id, s.name, s.feed_url, s.homepage_url, s.default_category AS category, s.active,
|
||||
s.status, s.content_visible, s.retry_after_at,
|
||||
s.consecutive_failures AS failures, s.review_flag, s.review_reason, s.paywall_override,
|
||||
s.image_policy,
|
||||
s.poll_interval_minutes AS interval_minutes,
|
||||
s.last_success_at, s.last_error_at, substr(s.last_error, 1, 160) AS last_error,
|
||||
(SELECT MAX(r.finished_at) FROM ingest_runs r
|
||||
@@ -720,13 +759,25 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
def scalar(sql, params=()):
|
||||
return conn.execute(sql, params).fetchone()[0] or 0
|
||||
|
||||
eng_ph = ",".join("?" * len(ENGAGED_EVENT_KINDS))
|
||||
visitors = {
|
||||
# Recorded visits — the raw/noisy count (one daily 'visit' beacon per device).
|
||||
"today": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||
"WHERE kind='visit' AND visitor_hash!='' AND day=date('now')"),
|
||||
"d7": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||
"WHERE kind='visit' AND visitor_hash!='' AND day>=date('now','-7 days')"),
|
||||
"d30": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||
"WHERE kind='visit' AND visitor_hash!='' AND day>=date('now',?)", (since,)),
|
||||
# Engaged readers — distinct visitor-day with deliberate activity (the honest number).
|
||||
"engaged_today": scalar(f"SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||
f"WHERE kind IN ({eng_ph}) AND visitor_hash!='' AND day=date('now')",
|
||||
ENGAGED_EVENT_KINDS),
|
||||
"engaged_d7": scalar(f"SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||
f"WHERE kind IN ({eng_ph}) AND visitor_hash!='' AND day>=date('now','-7 days')",
|
||||
ENGAGED_EVENT_KINDS),
|
||||
"engaged_d30": scalar(f"SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||
f"WHERE kind IN ({eng_ph}) AND visitor_hash!='' AND day>=date('now',?)",
|
||||
(*ENGAGED_EVENT_KINDS, since)),
|
||||
}
|
||||
|
||||
# Returning (seen on ≥2 distinct days) vs one-and-done, over the window.
|
||||
@@ -862,6 +913,10 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
f"SELECT COUNT(*) FROM client_errors WHERE created_at>=date('now',?) AND {_NOT_BOT_SQL}",
|
||||
(since,),
|
||||
),
|
||||
# Drives the headline now: how many still need a look (clears as you mark them read).
|
||||
"unread": scalar(
|
||||
f"SELECT COUNT(*) FROM client_errors WHERE read_at IS NULL AND {_NOT_BOT_SQL}",
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+4
-7
@@ -14,8 +14,6 @@ import sqlite3
|
||||
from . import daily
|
||||
from .localtime import local_today
|
||||
|
||||
_NO_REPEAT_POOL = 60
|
||||
|
||||
# Public-domain (ancient / author died well over a century ago), uplifting. Admin curates.
|
||||
SEED = [
|
||||
("Very little is needed to make a happy life; it is all within yourself, in your way of thinking.", "Marcus Aurelius", "Meditations"),
|
||||
@@ -65,11 +63,10 @@ def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int]
|
||||
if featured:
|
||||
ids = [r[0] for r in featured]
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM quote_pool WHERE blocked=0 ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
|
||||
(_NO_REPEAT_POOL,),
|
||||
).fetchall()
|
||||
ids = [r[0] for r in rows]
|
||||
# The freshest cohort only (never-shown, else the oldest-shown group) — picking
|
||||
# across the whole pool is what re-fed recent quotes day to day.
|
||||
rows = conn.execute("SELECT id, shown_at FROM quote_pool WHERE blocked=0").fetchall()
|
||||
ids = daily.freshest(rows)
|
||||
if avoid is not None:
|
||||
ids = [i for i in ids if i != avoid] or ids
|
||||
return ids
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Estimate a SOURCE article's full read time from its fetched HTML.
|
||||
|
||||
We never store the publisher's body — only a word COUNT (metadata) — to derive a
|
||||
"Full story · ~N min" hint that contrasts with our one-minute gist. That tiny
|
||||
detail sells the value: the calm summary now, the deep dive only if you want it.
|
||||
|
||||
Extraction is deliberately light (no readability parser yet): drop the obvious
|
||||
non-article furniture (scripts, styles, nav, header, footer, forms, buttons,
|
||||
asides), strip tags, count words. ~225 wpm offsets the boilerplate that still
|
||||
slips through. Below a floor we assume failed/blocked extraction and return None
|
||||
so the UI shows NO badge rather than a misleading "1 min".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_WPM = 225
|
||||
_MIN_WORDS = 200 # below this → assume failed/too-thin extraction → no badge
|
||||
|
||||
# Blocks whose CONTENT is furniture, removed wholesale before counting.
|
||||
_FURNITURE = re.compile(
|
||||
rb"<(script|style|noscript|template|svg|nav|header|footer|form|button|aside|select|option)\b[^>]*>.*?</\1>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_TAGS = re.compile(rb"<[^>]+>")
|
||||
_WS = re.compile(r"\s+")
|
||||
|
||||
|
||||
def word_count_from_html(raw: bytes | None) -> int:
|
||||
"""Rough article word count from raw HTML bytes, furniture stripped."""
|
||||
if not raw:
|
||||
return 0
|
||||
cleaned = _FURNITURE.sub(b" ", raw)
|
||||
text = _TAGS.sub(b" ", cleaned).decode("utf-8", "replace")
|
||||
return len(_WS.sub(" ", text).split())
|
||||
|
||||
|
||||
def source_read_minutes(words: int | None) -> int | None:
|
||||
"""Whole-minute estimate for the FULL article, or None when the count looks
|
||||
failed/too thin (so callers omit the badge instead of showing a wrong number)."""
|
||||
if not words or words < _MIN_WORDS:
|
||||
return None
|
||||
return max(2, round(words / _WPM))
|
||||
+162
-41
@@ -10,6 +10,8 @@ from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
|
||||
from .newsimg import display_url
|
||||
|
||||
|
||||
def _tag(name: str, content: str | None, attr: str = "property") -> str:
|
||||
if not content:
|
||||
@@ -17,43 +19,170 @@ def _tag(name: str, content: str | None, attr: str = "property") -> str:
|
||||
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
|
||||
|
||||
|
||||
# --- Shared top bar -----------------------------------------------------------------
|
||||
# A static replica of the SPA's HubBar so server-rendered share pages (/a/<id>, the
|
||||
# digest) carry the SAME toolbar as the rest of the site. These pages can't run the
|
||||
# Svelte component, so this is kept in sync with frontend/src/lib/components/HubBar.svelte
|
||||
# (+ HubShell's borderless Back) BY HAND — change both together. `active` highlights a
|
||||
# section ('' = none, as on an article). The account glyph is the signed-out state;
|
||||
# _TOP_BAR_JS swaps in the cached avatar for signed-in readers, just like HubBar.
|
||||
_TOP_NAV = (("/", "Home", "home"), ("/news", "News", "news"),
|
||||
("/play", "Games", "games"), ("/art", "Art", "art"))
|
||||
|
||||
|
||||
def _nav_links(active: str) -> str:
|
||||
return "".join(f'<a class="{"on" if k == active else ""}" href="{href}">{label}</a>'
|
||||
for href, label, k in _TOP_NAV)
|
||||
|
||||
|
||||
def _top_bar_html(active: str = "") -> str:
|
||||
return (
|
||||
'<header class="bar">'
|
||||
'<a class="brand" href="/" aria-label="upbeatBytes home"><img src="/logo.svg" alt="upbeatBytes"></a>'
|
||||
'<div class="bar-end">'
|
||||
f'<nav class="nav">{_nav_links(active)}<span class="nav-soon">Entertainment</span></nav>'
|
||||
'<a class="acct" href="/account" aria-label="Your account">'
|
||||
'<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></a>'
|
||||
'<button class="burger" type="button" aria-label="Menu" aria-expanded="false" data-burger>'
|
||||
'<span></span><span></span><span></span></button>'
|
||||
'</div></header>'
|
||||
f'<div class="menu-wrap" data-menu hidden><nav class="menu">{_nav_links(active)}'
|
||||
'<span class="menu-soon">Entertainment <em>soon</em></span></nav></div>'
|
||||
)
|
||||
|
||||
|
||||
def _back_link_html(label: str = "Back") -> str:
|
||||
return ('<button class="hb-back" type="button" data-back 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">'
|
||||
f'<path d="M15 18l-6-6 6-6"/></svg>{label}</button>')
|
||||
|
||||
|
||||
# Ported verbatim from HubBar.svelte's <style> (+ HubShell's .back), scoped to the same
|
||||
# class names so the bar looks pixel-identical to the SPA regardless of page palette.
|
||||
_TOP_BAR_CSS = """
|
||||
/* reserve the scrollbar gutter so the bar doesn't jump left-right between pages */
|
||||
html { scrollbar-gutter: stable; }
|
||||
@font-face { font-family:'Hanken Grotesk'; src:url('/fonts/hanken-var.woff2') format('woff2'); font-weight:400 700; font-style:normal; font-display:swap; }
|
||||
header.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; background:none; border:none; }
|
||||
header.bar .brand { display:block; line-height:0; }
|
||||
header.bar .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; text-decoration:none; }
|
||||
.acct.hasimg { background:none; overflow:hidden; padding:0; }
|
||||
.acct:hover { background:#fbe6c4; }
|
||||
.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 .2s ease, opacity .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); }
|
||||
.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,.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:.08em; text-transform:uppercase; color:#c3b69c; }
|
||||
.hb-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:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; font-size:14px; font-weight:600; color:#6b6256; transition:color .15s ease; }
|
||||
.hb-back:hover { color:#0083ad; }
|
||||
.hb-back svg { transition:transform .15s ease; }
|
||||
.hb-back:hover svg { transform:translateX(-2px); }
|
||||
@media (max-width:720px) { .nav { display:none; } .burger { display:flex; } }
|
||||
@media (min-width:721px) { .menu-wrap { display:none !important; } }
|
||||
"""
|
||||
|
||||
# Burger toggle + signed-in avatar (read from the SPA's localStorage cache, same as HubBar).
|
||||
# Parity with HubBar: Escape closes the menu, and crossing back to desktop width resets it.
|
||||
_TOP_BAR_JS = """<script>
|
||||
(function(){
|
||||
var b=document.querySelector('[data-burger]'), m=document.querySelector('[data-menu]');
|
||||
function close(){ if(m) m.setAttribute('hidden',''); if(b){ b.classList.remove('open'); b.setAttribute('aria-expanded','false'); } }
|
||||
if(b&&m){ b.addEventListener('click',function(){
|
||||
if(m.hasAttribute('hidden')){ m.removeAttribute('hidden'); b.classList.add('open'); b.setAttribute('aria-expanded','true'); }
|
||||
else { close(); }
|
||||
}); }
|
||||
document.addEventListener('keydown',function(e){ if(e.key==='Escape') close(); });
|
||||
if(window.matchMedia){ var mq=window.matchMedia('(min-width:721px)'); if(mq.addEventListener) mq.addEventListener('change',function(e){ if(e.matches) close(); }); }
|
||||
try{
|
||||
var u=JSON.parse(localStorage.getItem('goodnews:auth_user')||'null');
|
||||
if(u&&u.avatar_url){
|
||||
var a=document.querySelector('.acct');
|
||||
if(a){ var img=document.createElement('img'); img.src=u.avatar_url; img.alt=''; img.referrerPolicy='no-referrer';
|
||||
img.style.cssText='width:32px;height:32px;border-radius:999px;object-fit:cover';
|
||||
a.classList.add('hasimg'); a.textContent=''; a.appendChild(img); }
|
||||
}
|
||||
}catch(e){}
|
||||
})();
|
||||
</script>"""
|
||||
|
||||
# Single-history Back (mirrors HubShell): go back ONLY when we arrived from our own origin,
|
||||
# else go home — never bounce the reader off to an external referrer.
|
||||
_BACK_JS = """<script>
|
||||
(function(){
|
||||
var b=document.querySelector('[data-back]'); if(!b) return;
|
||||
b.addEventListener('click',function(){
|
||||
var same=false; try{ same=!!document.referrer && new URL(document.referrer).origin===location.origin; }catch(e){}
|
||||
if(same && history.length>1){ history.back(); } else { location.href='/'; }
|
||||
});
|
||||
})();
|
||||
</script>"""
|
||||
|
||||
|
||||
def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
||||
explanation: dict | None = None) -> str:
|
||||
aid = article["id"]
|
||||
title = (article.get("title") or "Upbeat Bytes").strip()
|
||||
title = (article.get("title") or "upbeatBytes").strip()
|
||||
why = (article.get("reason_text") or article.get("description")
|
||||
or "A calm, constructive story worth your attention.").strip()
|
||||
source = (article.get("source_name") or "the source").strip()
|
||||
source_id = article.get("source_id")
|
||||
# Link the source name into the app's publication feed for that source.
|
||||
source_html = (
|
||||
f'<a class="src srclink" href="/?source={source_id}">{escape(source)}</a>'
|
||||
f'<a class="src srclink" href="/news?source={source_id}">{escape(source)}</a>'
|
||||
if source_id else f'<div class="src">{escape(source)}</div>'
|
||||
)
|
||||
src_url = article.get("canonical_url") or base_url
|
||||
image = article.get("image_url")
|
||||
policy = article.get("image_policy")
|
||||
# What WE show, honoring the source's image policy (cache → our copy; remote → the
|
||||
# publisher's URL; none → nothing). og/twitter reference the publisher's own image
|
||||
# (a link, not re-hosting) whenever we'd show anything; 'none' omits it entirely.
|
||||
display = display_url(aid, policy, image)
|
||||
og_image = image if (image and policy != "none") else None
|
||||
page_url = f"{base_url}/a/{aid}"
|
||||
# With an image: a large-image card. Without: a clean text unfurl (title +
|
||||
# why + brand) — tidy, not a broken/muddy preview. (A branded fallback PNG
|
||||
# can replace this later.)
|
||||
twitter_card = "summary_large_image" if image else "summary"
|
||||
twitter_card = "summary_large_image" if display else "summary"
|
||||
|
||||
meta = "\n".join(filter(None, [
|
||||
_tag("og:site_name", "Upbeat Bytes"),
|
||||
_tag("og:site_name", "upbeatBytes"),
|
||||
_tag("og:type", "article"),
|
||||
_tag("og:title", title),
|
||||
_tag("og:description", why),
|
||||
_tag("og:url", page_url),
|
||||
_tag("og:image", image),
|
||||
_tag("og:image", og_image),
|
||||
_tag("twitter:card", twitter_card, attr="name"),
|
||||
_tag("twitter:title", title, attr="name"),
|
||||
_tag("twitter:description", why, attr="name"),
|
||||
_tag("twitter:image", image, attr="name"),
|
||||
_tag("twitter:image", og_image, attr="name"),
|
||||
]))
|
||||
|
||||
# The visible image is whatever the policy resolved to (our cached copy for 'cache'
|
||||
# sources, else the publisher's URL for 'remote'). If it isn't cached yet / fails to
|
||||
# load, drop the element so the page degrades to the clean text unfurl, not a broken icon.
|
||||
media = (
|
||||
f'<img class="media" src="{escape(image)}" alt="" referrerpolicy="no-referrer">'
|
||||
if image else ""
|
||||
f'<img class="media" src="{escape(display)}" alt="" referrerpolicy="no-referrer" '
|
||||
f'onerror="this.remove()">'
|
||||
if display else ""
|
||||
)
|
||||
|
||||
raw_tags = (article.get("tags") or "")
|
||||
@@ -138,7 +267,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{escape(title)} · Upbeat Bytes</title>
|
||||
<title>{escape(title)} · upbeatBytes</title>
|
||||
<meta name="description" content="{escape(why)}">
|
||||
<link rel="canonical" href="{escape(page_url)}">
|
||||
<link rel="icon" href="/favicon.svg">
|
||||
@@ -150,17 +279,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
||||
body {{ margin:0; background:var(--bg); color:var(--ink);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
line-height:1.6; }}
|
||||
.bar {{ background:var(--surface); border-bottom:1px solid var(--line); }}
|
||||
.bar .inner {{ max-width:680px; margin:0 auto; padding:12px 20px;
|
||||
display:flex; align-items:center; justify-content:space-between; gap:12px; }}
|
||||
.bar img {{ height:40px; display:block; }}
|
||||
.back {{ display:inline-flex; align-items:center; gap:7px;
|
||||
background:none; border:1px solid var(--line); color:var(--accent-deep);
|
||||
border-radius:999px; padding:8px 17px 8px 14px; font-size:.92rem; font-weight:600;
|
||||
font-family:inherit; cursor:pointer; line-height:1; white-space:nowrap;
|
||||
transition:border-color .14s ease, background .14s ease; }}
|
||||
.back svg {{ width:19px; height:19px; display:block; }}
|
||||
.back:hover {{ border-color:var(--accent); background:var(--bg); }}
|
||||
{_TOP_BAR_CSS}
|
||||
.wrap {{ max-width:680px; margin:0 auto; padding:24px 20px 60px; }}
|
||||
.card {{ background:var(--surface); border:1px solid var(--line); border-radius:16px;
|
||||
overflow:hidden; box-shadow:0 10px 30px rgba(40,38,28,.06); }}
|
||||
@@ -194,8 +313,9 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="Upbeat Bytes"></a><button class="back" type="button" data-back aria-label="Go 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>Back</button></div></div>
|
||||
{_top_bar_html()}
|
||||
<main class="wrap">
|
||||
{_back_link_html()}
|
||||
<article class="card">
|
||||
{media}
|
||||
<div class="body">
|
||||
@@ -207,9 +327,9 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
||||
<div class="actions">
|
||||
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
|
||||
<button class="secondary" type="button" data-share>Copy link</button>
|
||||
<a class="secondary" href="/">Explore Upbeat Bytes →</a>
|
||||
<a class="secondary" href="/news">Explore upbeatBytes →</a>
|
||||
</div>
|
||||
<p class="note">Upbeat Bytes summarizes in its own words and links to the original publisher — it doesn't host the article.</p>
|
||||
<p class="note">upbeatBytes summarizes in its own words and links to the original publisher — it doesn't host the article.</p>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
@@ -224,6 +344,14 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
||||
// visit isn't recorded for a /a/ landing — count it here, once per day per device.
|
||||
var t=new Date().toISOString().slice(0,10);
|
||||
if(localStorage.getItem('goodnews:visitday')!==t){{localStorage.setItem('goodnews:visitday',t);beacon({{kind:'visit',article_id:0,visitor:v}});}}
|
||||
// Engaged-reader signal (mirrors the SPA's armEngaged): ~8s visible + a real gesture, once/day.
|
||||
var eng=false,gest=false,secs=0;
|
||||
function fireEng(){{
|
||||
if(eng||!gest||secs<8) return; eng=true;
|
||||
try{{ if(localStorage.getItem('goodnews:engagedday')!==t){{localStorage.setItem('goodnews:engagedday',t);beacon({{kind:'engaged',article_id:0,visitor:v}});}} }}catch(e){{}}
|
||||
}}
|
||||
var iv=setInterval(function(){{ if(document.visibilityState==='visible'){{secs++;fireEng();}} if(eng) clearInterval(iv); }},1000);
|
||||
['scroll','pointerdown','keydown','touchstart'].forEach(function(e){{window.addEventListener(e,function(){{gest=true;fireEng();}},{{passive:true}});}});
|
||||
}}catch(e){{}}
|
||||
}})();
|
||||
</script>
|
||||
@@ -238,14 +366,8 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
||||
}});
|
||||
}})();
|
||||
</script>
|
||||
<script>
|
||||
(function(){{
|
||||
var b=document.querySelector('[data-back]'); if(!b) return;
|
||||
b.addEventListener('click',function(){{
|
||||
if(document.referrer && history.length>1){{ history.back(); }} else {{ location.href='/'; }}
|
||||
}});
|
||||
}})();
|
||||
</script>
|
||||
{_BACK_JS}
|
||||
{_TOP_BAR_JS}
|
||||
{poll}
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -275,7 +397,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
|
||||
)
|
||||
|
||||
meta = "\n".join(filter(None, [
|
||||
_tag("og:site_name", "Upbeat Bytes"),
|
||||
_tag("og:site_name", "upbeatBytes"),
|
||||
_tag("og:type", "website"),
|
||||
_tag("og:title", "Today's good news, summarized"),
|
||||
_tag("og:description", intro),
|
||||
@@ -292,7 +414,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Today's good news, summarized · Upbeat Bytes</title>
|
||||
<title>Today's good news, summarized · upbeatBytes</title>
|
||||
<meta name="description" content="{escape(intro)}">
|
||||
<link rel="canonical" href="{page_url}">
|
||||
<link rel="icon" href="/favicon.svg">
|
||||
@@ -303,9 +425,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:var(--bg); color:var(--ink);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; line-height:1.6; }}
|
||||
.bar {{ background:var(--surface); border-bottom:1px solid var(--line); }}
|
||||
.bar .inner {{ max-width:720px; margin:0 auto; padding:12px 20px; }}
|
||||
.bar img {{ height:40px; display:block; }}
|
||||
{_TOP_BAR_CSS}
|
||||
.wrap {{ max-width:720px; margin:0 auto; padding:26px 20px 60px; }}
|
||||
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:2rem; margin:0 0 4px; }}
|
||||
.lede {{ color:var(--muted); margin:0 0 26px; }}
|
||||
@@ -324,13 +444,14 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="Upbeat Bytes"></a></div></div>
|
||||
{_top_bar_html()}
|
||||
<main class="wrap">
|
||||
<h1>Today's good news</h1>
|
||||
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
|
||||
{cards}
|
||||
<p class="more"><a href="/">Browse more on Upbeat Bytes →</a></p>
|
||||
<p class="more"><a href="/news">Browse more on upbeatBytes →</a></p>
|
||||
</main>
|
||||
{_TOP_BAR_JS}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@@ -339,7 +460,7 @@ def render_not_found(base_url: str) -> str:
|
||||
return f"""<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Story not found · Upbeat Bytes</title><link rel="icon" href="/favicon.svg">
|
||||
<title>Story not found · upbeatBytes</title><link rel="icon" href="/favicon.svg">
|
||||
<style>
|
||||
body {{ margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
|
||||
justify-content:center; gap:14px; text-align:center; padding:40px;
|
||||
@@ -348,8 +469,8 @@ def render_not_found(base_url: str) -> str:
|
||||
a {{ color:#006b8e; }}
|
||||
</style></head>
|
||||
<body>
|
||||
<img src="/logo.svg" alt="Upbeat Bytes" style="height:44px">
|
||||
<img src="/logo.svg" alt="upbeatBytes" style="height:44px">
|
||||
<h1 style="font-family:Georgia,serif;font-weight:600">That story isn't here</h1>
|
||||
<p style="color:#5d6b78">It may have moved on — the good news refreshes often.</p>
|
||||
<a href="/">← Back to Upbeat Bytes</a>
|
||||
<a href="/">← Back to upbeatBytes</a>
|
||||
</body></html>"""
|
||||
|
||||
+8
-7
@@ -25,7 +25,6 @@ from .localtime import local_today
|
||||
|
||||
DICT_BASE = "https://api.dictionaryapi.dev/api/v2/entries/en"
|
||||
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
|
||||
_NO_REPEAT_POOL = 60
|
||||
_TARGET_POOL = 30 # keep harvesting (a batch/day) until the pool reaches this
|
||||
_HARVEST_BATCH = 12
|
||||
_MIN_AUDIO_BYTES = 500
|
||||
@@ -95,7 +94,10 @@ def _polish(client, word: str, part_of_speech: str | None, definition: str) -> d
|
||||
return None
|
||||
gloss = " ".join(str(data.get("gloss") or "").split()).strip()
|
||||
examples = [" ".join(str(e).split()).strip() for e in (data.get("examples") or []) if str(e).strip()]
|
||||
if not gloss:
|
||||
# Enforce the contract: keep only sentences that actually use the word, and require at
|
||||
# least one. A gloss with no usable examples falls back to the raw dictionary data.
|
||||
examples = [e for e in examples if word.lower() in e.lower()]
|
||||
if not gloss or not examples:
|
||||
return None
|
||||
return {"gloss": gloss, "examples": examples[:2]}
|
||||
|
||||
@@ -219,11 +221,10 @@ def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int]
|
||||
if featured:
|
||||
ids = [r[0] for r in featured]
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM wotd_pool WHERE blocked=0 ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
|
||||
(_NO_REPEAT_POOL,),
|
||||
).fetchall()
|
||||
ids = [r[0] for r in rows]
|
||||
# The freshest cohort only (never-shown, else the oldest-shown group) — picking
|
||||
# across the whole pool is what re-fed recent words day to day.
|
||||
rows = conn.execute("SELECT id, shown_at FROM wotd_pool WHERE blocked=0").fetchall()
|
||||
ids = daily.freshest(rows)
|
||||
if avoid is not None:
|
||||
ids = [i for i in ids if i != avoid] or ids
|
||||
return ids
|
||||
|
||||
@@ -15,6 +15,7 @@ dependencies = []
|
||||
web = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.29",
|
||||
"Pillow>=10", # downscale/re-encode cached article images (goodnews/newsimg.py)
|
||||
]
|
||||
test = [
|
||||
"pytest>=8",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""One-off: migrate daily_art (blurb/palette) and backfill the most recent picks in place
|
||||
— re-fetch Met metadata for the guide blurb, extract the palette from the cached image —
|
||||
WITHOUT re-picking (keeps each day's existing piece). Run on the host with LLM env sourced."""
|
||||
import json
|
||||
import os
|
||||
|
||||
from goodnews import art
|
||||
from goodnews.db import connect, init_db
|
||||
from goodnews.llm import LocalModelClient
|
||||
|
||||
conn = connect(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
|
||||
init_db(conn) # idempotent migration: adds blurb/palette
|
||||
client = LocalModelClient.from_env()
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT art_date, object_id, image_file, title FROM daily_art "
|
||||
"WHERE blurb IS NULL OR palette IS NULL ORDER BY art_date DESC LIMIT 8"
|
||||
).fetchall()
|
||||
print(f"rows to backfill: {len(rows)}")
|
||||
for r in rows:
|
||||
img = art.cache_dir() / r["image_file"] if r["image_file"] else None
|
||||
palette = json.dumps(art._palette(img)) if (img and img.exists()) else "[]"
|
||||
blurb = None
|
||||
try:
|
||||
blurb = art._blurb(client, art._object(r["object_id"]))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" blurb fetch failed for {r['object_id']}: {exc}")
|
||||
conn.execute("UPDATE daily_art SET blurb=COALESCE(?, blurb), palette=? WHERE art_date=?",
|
||||
(blurb, palette, r["art_date"]))
|
||||
conn.commit()
|
||||
print(f"\n{r['art_date']} · #{r['object_id']} · {r['title']}")
|
||||
print(f" palette: {palette}")
|
||||
print(f" blurb: {blurb}")
|
||||
conn.close()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""One-off: upgrade stored On This Day images from the blurry 330px thumbnail to a sharp,
|
||||
size-capped URL. Re-fetches the Wikimedia events for each stored MM-DD, matches by page_url,
|
||||
and rewrites image_url in onthisday_pool + daily_onthisday in place (no re-picking). Idempotent.
|
||||
|
||||
Run on the host: python -m scripts.otd_image_upsize_backfill
|
||||
"""
|
||||
import os
|
||||
|
||||
from goodnews import daily, onthisday
|
||||
from goodnews.db import connect
|
||||
|
||||
conn = connect(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
|
||||
|
||||
# distinct dates we've harvested
|
||||
mds = [r[0] for r in conn.execute(
|
||||
"SELECT DISTINCT md FROM onthisday_pool WHERE image_url LIKE '%/thumb/%px-%'").fetchall()]
|
||||
print(f"dates to refresh: {mds}")
|
||||
|
||||
# page_url -> sharp image_url, from a fresh feed fetch per date
|
||||
best: dict[str, str] = {}
|
||||
for md in mds:
|
||||
mm, dd = md.split("-")
|
||||
data = daily.http_json(f"{onthisday.WIKI_BASE}/{mm}/{dd}")
|
||||
for e in (data.get("events") or []):
|
||||
page = (e.get("pages") or [{}])[0] or {}
|
||||
page_url = (((page.get("content_urls") or {}).get("desktop") or {}).get("page")) or None
|
||||
img = onthisday._best_image(page)
|
||||
if page_url and img:
|
||||
best[page_url] = img
|
||||
print(f" {md}: {len(data.get('events') or [])} events fetched")
|
||||
|
||||
updated_pool = updated_daily = 0
|
||||
for table in ("onthisday_pool", "daily_onthisday"):
|
||||
for page_url, new in best.items():
|
||||
cur = conn.execute(
|
||||
f"UPDATE {table} SET image_url=? "
|
||||
f"WHERE page_url=? AND image_url LIKE '%/thumb/%px-%' AND image_url<>?",
|
||||
(new, page_url, new))
|
||||
if table == "onthisday_pool":
|
||||
updated_pool += cur.rowcount
|
||||
else:
|
||||
updated_daily += cur.rowcount
|
||||
conn.commit()
|
||||
print(f"updated: onthisday_pool={updated_pool}, daily_onthisday={updated_daily}")
|
||||
|
||||
# show a few results
|
||||
for r in conn.execute("SELECT md, year, image_url FROM onthisday_pool "
|
||||
"WHERE image_url IS NOT NULL ORDER BY md LIMIT 6").fetchall():
|
||||
print(f" {r['md']} {r['year']}: {r['image_url']}")
|
||||
conn.close()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""One-off: migrate the WOTD tables (gloss/usage) and backfill LLM polish for every
|
||||
pooled word that lacks it, then refresh today's pick from the now-polished pool.
|
||||
Run on the host with the LLM env sourced. Idempotent — safe to re-run."""
|
||||
import json
|
||||
import os
|
||||
|
||||
from goodnews import wotd
|
||||
from goodnews.db import connect, init_db
|
||||
from goodnews.llm import LocalModelClient
|
||||
|
||||
conn = connect(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
|
||||
init_db(conn) # idempotent migration: adds gloss/usage columns
|
||||
client = LocalModelClient.from_env()
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, word, part_of_speech, definition FROM wotd_pool WHERE gloss IS NULL OR gloss=''"
|
||||
).fetchall()
|
||||
print(f"pool words to polish: {len(rows)}")
|
||||
for r in rows:
|
||||
p = wotd._polish(client, r["word"], r["part_of_speech"], r["definition"])
|
||||
if p:
|
||||
conn.execute("UPDATE wotd_pool SET gloss=?, usage=? WHERE id=?",
|
||||
(p["gloss"], json.dumps(p["examples"]), r["id"]))
|
||||
print(f" ✓ {r['word']}: {p['gloss']}")
|
||||
else:
|
||||
print(f" – {r['word']}: polish unavailable, raw kept")
|
||||
conn.commit()
|
||||
|
||||
picked = wotd.pick_daily(conn, force=True, client=client) # today, from the polished pool
|
||||
print(f"\ntoday → {picked.get('word')}")
|
||||
print(f" gloss: {picked.get('gloss')}")
|
||||
print(f" usage: {picked.get('usage')}")
|
||||
conn.close()
|
||||
+60
-7
@@ -484,6 +484,37 @@ def test_client_error_telemetry(tmp_path, monkeypatch):
|
||||
assert stats["today"] == 1 and stats["window"] == 1 # bot excluded from both
|
||||
|
||||
|
||||
def test_client_error_read_unread(tmp_path, monkeypatch):
|
||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||
anon = TestClient(app)
|
||||
for r in ("boot-timeout", "preloadError", "boot-slow"):
|
||||
anon.post("/api/client-error", json={"reason": r, "path": "/"})
|
||||
tc = _signin(app, api, "boss@x.com")
|
||||
# Default view is unread; all three start unread and drive the headline count.
|
||||
unread = tc.get("/api/admin/client-errors").json()
|
||||
assert len(unread) == 3 and all(not e["read"] for e in unread)
|
||||
assert tc.get("/api/admin/stats").json()["client_errors"]["unread"] == 3
|
||||
# Mark one read → it leaves the unread view, appears under read, count drops.
|
||||
eid = unread[0]["id"]
|
||||
assert tc.post(f"/api/admin/client-errors/{eid}/read", json={"read": True}).json()["read"] is True
|
||||
assert len(tc.get("/api/admin/client-errors?show=unread").json()) == 2
|
||||
rd = tc.get("/api/admin/client-errors?show=read").json()
|
||||
assert len(rd) == 1 and rd[0]["id"] == eid and rd[0]["read"] is True
|
||||
assert len(tc.get("/api/admin/client-errors?show=all").json()) == 3
|
||||
assert tc.get("/api/admin/stats").json()["client_errors"]["unread"] == 2
|
||||
# Toggling back restores it to unread.
|
||||
assert tc.post(f"/api/admin/client-errors/{eid}/read", json={"read": False}).json()["read"] is False
|
||||
assert len(tc.get("/api/admin/client-errors?show=unread").json()) == 3
|
||||
# Mark-all clears the unread view in one go.
|
||||
assert tc.post("/api/admin/client-errors/read-all").json()["marked"] == 3
|
||||
assert tc.get("/api/admin/client-errors?show=unread").json() == []
|
||||
assert tc.get("/api/admin/stats").json()["client_errors"]["unread"] == 0
|
||||
# Unknown id 404s; both new routes are admin-gated.
|
||||
assert tc.post("/api/admin/client-errors/99999/read", json={"read": True}).status_code == 404
|
||||
assert anon.post("/api/admin/client-errors/read-all").status_code == 401
|
||||
assert anon.post(f"/api/admin/client-errors/{eid}/read", json={"read": True}).status_code == 401
|
||||
|
||||
|
||||
def test_wordsearch_theme_admin(tmp_path, monkeypatch):
|
||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||
assert TestClient(app).get("/api/admin/wordsearch/themes").status_code == 401 # gated
|
||||
@@ -532,22 +563,44 @@ def test_source_paywall_override(tmp_path, monkeypatch):
|
||||
c.commit(); c.close()
|
||||
tc = _signin(app, api, "boss@x.com")
|
||||
|
||||
def feed_badge():
|
||||
return next(a for a in tc.get("/api/feed?source_id=2").json()["items"] if a["id"] == 2)["paywalled"]
|
||||
def in_feed():
|
||||
return any(a["id"] == 2 for a in tc.get("/api/feed?source_id=2").json()["items"])
|
||||
|
||||
# domain rule: nytimes.com → paywalled in table, inspector, AND feed badge (all agree)
|
||||
# domain rule: nytimes.com → paywalled in the source table + inspector, and HARD-EXCLUDED
|
||||
# from the public feed (we don't surface stories you can't read for free)
|
||||
assert _src(tc, 2)["paywalled"] is True
|
||||
assert tc.get("/api/admin/sources/2/articles").json()["summary"]["paywalled"] is True
|
||||
assert feed_badge() is True
|
||||
# override 'free' (the NYT Learning fix) → effective OFF everywhere
|
||||
assert in_feed() is False
|
||||
# override 'free' (the NYT Learning fix) → effective OFF: it returns to the feed, no badge
|
||||
assert tc.post("/api/admin/sources/2/paywall", json={"override": "free"}).json()["override"] == "free"
|
||||
assert _src(tc, 2)["paywalled"] is False
|
||||
summ = tc.get("/api/admin/sources/2/articles").json()["summary"]
|
||||
assert summ["paywalled"] is False and summ["paywall_domain"] is True and summ["paywall_override"] == "free"
|
||||
assert feed_badge() is False # ranking/badge now agree it's free
|
||||
# back to domain rule, and the 'paywalled' override
|
||||
assert in_feed() is True
|
||||
assert next(a for a in tc.get("/api/feed?source_id=2").json()["items"] if a["id"] == 2)["paywalled"] is False
|
||||
# back to domain rule → excluded again
|
||||
assert tc.post("/api/admin/sources/2/paywall", json={"override": None}).json()["override"] is None
|
||||
assert _src(tc, 2)["paywalled"] is True
|
||||
assert in_feed() is False
|
||||
# validation + 404
|
||||
assert tc.post("/api/admin/sources/2/paywall", json={"override": "bogus"}).status_code == 422
|
||||
assert tc.post("/api/admin/sources/999/paywall", json={"override": "free"}).status_code == 404
|
||||
|
||||
|
||||
def test_source_image_policy(tmp_path, monkeypatch):
|
||||
import sqlite3, os
|
||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
|
||||
c.execute("INSERT INTO sources (id,name,feed_url) VALUES (2,'Gov','http://g/f')")
|
||||
c.commit(); c.close()
|
||||
anon = TestClient(app)
|
||||
assert anon.post("/api/admin/sources/2/image-policy", json={"policy": "cache"}).status_code == 401 # gated
|
||||
tc = _signin(app, api, "boss@x.com")
|
||||
assert tc.post("/api/admin/sources/2/image-policy", json={"policy": "cache"}).json()["policy"] == "cache"
|
||||
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
|
||||
assert c.execute("SELECT image_policy FROM sources WHERE id=2").fetchone()[0] == "cache"
|
||||
c.close()
|
||||
r = tc.post("/api/admin/sources/2/image-policy", json={"policy": "remote"}).json()
|
||||
assert r["policy"] == "remote" and r["purged"] == 0 # leaving cache purges (no files here)
|
||||
assert tc.post("/api/admin/sources/2/image-policy", json={"policy": "bogus"}).status_code == 422
|
||||
assert tc.post("/api/admin/sources/999/image-policy", json={"policy": "cache"}).status_code == 404
|
||||
|
||||
@@ -44,6 +44,58 @@ def test_harvest_dedupes_into_pool(conn):
|
||||
assert art.harvest_pool(conn)["added"] == 0 # idempotent
|
||||
|
||||
|
||||
def test_palette_extracts_hex_colors(tmp_path):
|
||||
from PIL import Image
|
||||
p = tmp_path / "img.png"
|
||||
im = Image.new("RGB", (60, 60), (200, 30, 30)) # mostly red...
|
||||
for x in range(60):
|
||||
for y in range(30):
|
||||
im.putpixel((x, y), (30, 150, 70)) # ...top half green
|
||||
im.save(p)
|
||||
cols = art._palette(p, n=3)
|
||||
assert 1 <= len(cols) <= 3
|
||||
assert all(c.startswith("#") and len(c) == 7 for c in cols)
|
||||
|
||||
|
||||
def test_palette_bad_image_is_empty(tmp_path):
|
||||
p = tmp_path / "bad.jpg"
|
||||
p.write_bytes(b"\xff\xd8\xff" + b"x" * 500) # not a decodable image
|
||||
assert art._palette(p) == []
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, text="A quiet wheat field at dusk."):
|
||||
self.text, self.seen = text, None
|
||||
def chat_text(self, messages):
|
||||
self.seen = messages
|
||||
return self.text
|
||||
|
||||
|
||||
def test_blurb_grounds_in_metadata_and_cleans():
|
||||
c = _FakeClient(" A returning soldier in a golden field. \n")
|
||||
out = art._blurb(c, {"title": "The Veteran", "artistDisplayName": "Homer",
|
||||
"medium": "Oil on canvas", "tags": [{"term": "wheat"}, {"term": "scythe"}]})
|
||||
assert out == "A returning soldier in a golden field."
|
||||
user = c.seen[-1]["content"]
|
||||
assert "Homer" in user and "Oil on canvas" in user and "wheat" in user # catalogue facts fed in
|
||||
|
||||
|
||||
def test_blurb_none_on_error_or_empty():
|
||||
class Bad:
|
||||
def chat_text(self, m): raise RuntimeError("down")
|
||||
assert art._blurb(Bad(), {"title": "X"}) is None
|
||||
assert art._blurb(_FakeClient(" "), {"title": "X"}) is None
|
||||
|
||||
|
||||
def test_pick_stores_blurb_and_palette(conn):
|
||||
art.harvest_pool(conn)
|
||||
a = art.pick_daily(conn, art_date="2026-06-21", client=_FakeClient("A quiet masterwork."))
|
||||
assert a["blurb"] == "A quiet masterwork."
|
||||
assert a["palette"] == "[]" # fixture image isn't decodable → empty palette, stored as JSON
|
||||
b = art.pick_daily(conn, art_date="2026-06-22") # no client → no blurb, pick still succeeds
|
||||
assert b["blurb"] is None
|
||||
|
||||
|
||||
def test_pick_caches_image_metadata_and_marks_shown(conn):
|
||||
art.harvest_pool(conn)
|
||||
a = art.pick_daily(conn, art_date="2026-06-21")
|
||||
|
||||
@@ -23,6 +23,8 @@ def client(tmp_path, monkeypatch):
|
||||
"VALUES ('2026-06-21','met',10154,'Lander''s Peak','Bierstadt','1863','Oil','Paintings',"
|
||||
"'Gift','https://met/10154','10154.jpg','https://met/full.jpg',1)"
|
||||
)
|
||||
c.execute("UPDATE daily_art SET blurb=?, palette=? WHERE object_id=10154",
|
||||
("A luminous western vista.", '["#7fb4cf", "#c79a3c"]'))
|
||||
c.commit(); c.close()
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
(cache / "10154.jpg").write_bytes(b"\xff\xd8\xff" + b"x" * 5000) # web-large display copy
|
||||
@@ -43,3 +45,5 @@ def test_today_exposes_full_res_url(client):
|
||||
assert a["image_url"] == "/api/art/image/10154"
|
||||
assert a["image_url_large"] == "/api/art/image/10154?size=full"
|
||||
assert a["license"] == "Public Domain (CC0)" and a["museum"] == "The Met"
|
||||
assert a["blurb"] == "A luminous western vista."
|
||||
assert a["palette"] == ["#7fb4cf", "#c79a3c"] # parsed from stored JSON
|
||||
|
||||
@@ -8,9 +8,12 @@ from goodnews.paywall import is_paywalled
|
||||
def test_brief_prefers_readable_over_higher_scored_paywalled():
|
||||
c = connect(":memory:"); init_db(c)
|
||||
today = date.today().isoformat()
|
||||
# Sources 1-2 are paywalled by their homepage domain (matches production: a paywalled
|
||||
# source has a paywalled site); 3-7 are free.
|
||||
homes = {1: "https://www.newscientist.com/", 2: "https://www.nature.com/"}
|
||||
for sid in range(1, 8):
|
||||
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (?,?,?,5)",
|
||||
(sid, f"S{sid}", f"http://s{sid}/f"))
|
||||
c.execute("INSERT INTO sources (id,name,feed_url,homepage_url,trust_score) VALUES (?,?,?,?,5)",
|
||||
(sid, f"S{sid}", f"http://s{sid}/f", homes.get(sid)))
|
||||
|
||||
def add(aid, sid, url, score):
|
||||
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,published_at,url_hash) "
|
||||
@@ -19,7 +22,7 @@ def test_brief_prefers_readable_over_higher_scored_paywalled():
|
||||
"cortisol_score,ragebait_score,pr_risk_score,accepted,topic,flavor) "
|
||||
"VALUES (?,?,?,?,0,0,2,1,'science','discovery')", (aid, score, score, score))
|
||||
|
||||
# Paywalled ones are scored HIGHER — readability must still win for the five.
|
||||
# Paywalled sources are scored HIGHER — they must still be EXCLUDED, leaving the five readable.
|
||||
add(1, 1, "https://www.newscientist.com/a", 9)
|
||||
add(2, 2, "https://www.nature.com/b", 9)
|
||||
add(3, 3, "https://phys.org/c", 4)
|
||||
@@ -33,4 +36,4 @@ def test_brief_prefers_readable_over_higher_scored_paywalled():
|
||||
urls = [r["canonical_url"] for r in show_brief(c, brief_date=today, limit=10)]
|
||||
c.close()
|
||||
assert len(urls) == 5
|
||||
assert not any(is_paywalled(u) for u in urls) # five readable chosen over paywalled
|
||||
assert not any(is_paywalled(u) for u in urls) # paywalled sources excluded; five readable fill
|
||||
|
||||
@@ -43,6 +43,6 @@ def test_reply_to_uses_env_inbox(monkeypatch):
|
||||
def test_reply_to_falls_back_to_from(monkeypatch):
|
||||
_arm(monkeypatch)
|
||||
monkeypatch.delenv("GOODNEWS_REPLY_TO_EMAIL", raising=False)
|
||||
monkeypatch.setenv("GOODNEWS_SMTP_FROM", "Upbeat Bytes <hello@upbeatbytes.com>")
|
||||
monkeypatch.setenv("GOODNEWS_SMTP_FROM", "upbeatBytes <hello@upbeatbytes.com>")
|
||||
es.send_feedback_reply("reader@x.com", "hi", None, "o")
|
||||
assert _FakeSMTP.last["Reply-To"] == "Upbeat Bytes <hello@upbeatbytes.com>"
|
||||
assert _FakeSMTP.last["Reply-To"] == "upbeatBytes <hello@upbeatbytes.com>"
|
||||
|
||||
+43
-5
@@ -1,6 +1,12 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# The events beacon now drops known-bot User-Agents (queries.is_bot_ua), and the test
|
||||
# client's default UA contains "python" → would be filtered. Send a real browser UA so
|
||||
# these record like a genuine visitor; the bot case is covered explicitly below.
|
||||
_BROWSER = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_db(tmp_path, monkeypatch):
|
||||
@@ -28,7 +34,7 @@ def _count(db, **where):
|
||||
|
||||
def test_event_recorded_and_deduped(app_db):
|
||||
app, db = app_db
|
||||
tc = TestClient(app)
|
||||
tc = TestClient(app, headers=_BROWSER)
|
||||
for _ in range(3): # same (kind, article, visitor, day) → one row
|
||||
assert tc.post("/api/events", json={"kind": "open", "article_id": 5, "visitor": "tok"}).json() == {"ok": True}
|
||||
assert _count(db, kind="open", article_id=5) == 1
|
||||
@@ -39,7 +45,7 @@ def test_event_recorded_and_deduped(app_db):
|
||||
|
||||
def test_visitor_token_is_hashed_not_stored_raw(app_db):
|
||||
app, db = app_db
|
||||
TestClient(app).post("/api/events", json={"kind": "visit", "visitor": "secret-token"})
|
||||
TestClient(app, headers=_BROWSER).post("/api/events", json={"kind": "visit", "visitor": "secret-token"})
|
||||
from goodnews.db import connect
|
||||
c = connect(str(db))
|
||||
vh = c.execute("SELECT visitor_hash FROM events").fetchone()[0]
|
||||
@@ -49,13 +55,28 @@ def test_visitor_token_is_hashed_not_stored_raw(app_db):
|
||||
|
||||
def test_unknown_kind_is_ignored(app_db):
|
||||
app, db = app_db
|
||||
assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
|
||||
assert TestClient(app, headers=_BROWSER).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
|
||||
assert _count(db) == 0
|
||||
|
||||
|
||||
def test_bot_user_agents_are_not_counted(app_db):
|
||||
"""JS-capable crawlers fire this beacon too; honest bot UAs must not inflate counts.
|
||||
Response stays {ok:true} so a bot can't tell it was dropped."""
|
||||
app, db = app_db
|
||||
for bot_ua in ("Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)",
|
||||
"Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) HeadlessChrome/120.0 Safari/537.36"):
|
||||
tc = TestClient(app, headers={"user-agent": bot_ua})
|
||||
assert tc.post("/api/events", json={"kind": "visit", "visitor": "b"}).json() == {"ok": True}
|
||||
assert _count(db, kind="visit") == 0 # none recorded
|
||||
# a real browser on the same beacon IS counted
|
||||
TestClient(app, headers=_BROWSER).post("/api/events", json={"kind": "visit", "visitor": "human"})
|
||||
assert _count(db, kind="visit") == 1
|
||||
|
||||
|
||||
def test_game_event_kinds_are_allowed(app_db):
|
||||
app, db = app_db
|
||||
tc = TestClient(app)
|
||||
tc = TestClient(app, headers=_BROWSER)
|
||||
# the per-game funnel kinds (incl. the share-loop arrival) pass the allowlist
|
||||
for kind in ("word_started", "word_completed", "word_shared", "word_arrival", "match_arrival"):
|
||||
assert tc.post("/api/events", json={"kind": kind, "article_id": 0, "visitor": "t"}).json() == {"ok": True}
|
||||
@@ -67,7 +88,7 @@ def test_game_event_kinds_are_allowed(app_db):
|
||||
|
||||
def test_admin_stats_games_funnel_aggregates(app_db):
|
||||
app, db = app_db
|
||||
tc = TestClient(app)
|
||||
tc = TestClient(app, headers=_BROWSER)
|
||||
# two visitors arrive at Daily Word via a shared link; one engages + shares; a Match completes
|
||||
for v in ("a", "b"):
|
||||
tc.post("/api/events", json={"kind": "word_arrival", "article_id": 0, "visitor": v})
|
||||
@@ -82,3 +103,20 @@ def test_admin_stats_games_funnel_aggregates(app_db):
|
||||
assert games["by_game"]["word"] == {"arrival": 2, "started": 1, "completed": 0, "shared": 1}
|
||||
assert games["by_game"]["match"]["completed"] == 1
|
||||
assert games["totals"]["arrival"] == 2 and games["totals"]["shared"] == 1
|
||||
|
||||
|
||||
def test_engaged_readers_metric(app_db):
|
||||
"""Engaged readers counts the gesture-gated 'engaged' beacon + deliberate actions,
|
||||
NOT auto-fired visit/summary_viewed or a game-share arrival."""
|
||||
app, db = app_db
|
||||
tc = TestClient(app, headers=_BROWSER)
|
||||
tc.post("/api/events", json={"kind": "engaged", "visitor": "a"}) # gesture beacon
|
||||
tc.post("/api/events", json={"kind": "source_click", "article_id": 5, "visitor": "b"}) # deliberate
|
||||
tc.post("/api/events", json={"kind": "visit", "visitor": "c"}) # raw visit only
|
||||
tc.post("/api/events", json={"kind": "summary_viewed", "article_id": 5, "visitor": "c"}) # auto-fired
|
||||
tc.post("/api/events", json={"kind": "word_arrival", "visitor": "d"}) # share-loop landing
|
||||
from goodnews.db import connect
|
||||
from goodnews import queries
|
||||
cn = connect(str(db)); v = queries.admin_stats(cn, days=30)["visitors"]; cn.close()
|
||||
assert v["engaged_today"] == 2 # a (engaged) + b (source_click)
|
||||
assert v["today"] == 1 # only c fired a raw visit
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""News image cache (hardened): SSRF-safe cycle-only fetch + downscale to WebP, reject
|
||||
SVG/non-raster/private-host/redirect-to-private, negative-cache failures, LRU eviction,
|
||||
and the cache-HITS-ONLY /api/img endpoint (restricted to accepted, canonical articles)."""
|
||||
import io
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from goodnews import newsimg
|
||||
from goodnews.db import connect, init_db
|
||||
|
||||
# The real SSRF-safe fetch, captured before any fixture stubs it — the SSRF tests below
|
||||
# restore it so they exercise the genuine host/redirect validation.
|
||||
_REAL_SAFE_FETCH = newsimg._safe_fetch
|
||||
|
||||
|
||||
class _Resp:
|
||||
"""Minimal non-redirect (2xx) response for a fake opener."""
|
||||
def __init__(self, headers, body=b""):
|
||||
self.status, self.headers, self._b = 200, headers, body
|
||||
def read(self, n=-1):
|
||||
return self._b
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _http_error(url, code, location=None):
|
||||
"""A real urllib HTTPError, as _NoRedirect raises for 3xx (and urllib for 4xx/5xx)."""
|
||||
import urllib.error
|
||||
hdrs = {"Location": location} if location else {}
|
||||
return urllib.error.HTTPError(url, code, "x", hdrs, io.BytesIO(b""))
|
||||
|
||||
|
||||
def _fake_opener(handler):
|
||||
class _Op:
|
||||
def open(self, req, timeout=None):
|
||||
return handler(req.full_url)
|
||||
return lambda *a, **k: _Op()
|
||||
|
||||
|
||||
def _png(w=1600, h=1000, color=(40, 130, 173)):
|
||||
out = io.BytesIO()
|
||||
Image.new("RGB", (w, h), color).save(out, format="PNG")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("GOODNEWS_IMG_CACHE", str(tmp_path / "img_cache"))
|
||||
# default: treat hosts as public + a happy PNG fetch; individual tests override.
|
||||
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: True)
|
||||
monkeypatch.setattr(newsimg, "_safe_fetch", lambda url, timeout=12: (_png(), "image/png"))
|
||||
return tmp_path / "img_cache"
|
||||
|
||||
|
||||
def test_fetch_and_cache_downscales_to_webp(cache):
|
||||
p = newsimg.fetch_and_cache("https://example.com/big.png")
|
||||
assert p and p.exists() and p.suffix == ".webp"
|
||||
with Image.open(p) as im:
|
||||
assert im.width == newsimg.DISPLAY_WIDTH and im.format == "WEBP"
|
||||
assert newsimg.path_for("https://example.com/big.png") == p # pure cache lookup
|
||||
|
||||
|
||||
def test_bad_scheme_and_empty_are_rejected(cache):
|
||||
assert newsimg.fetch_and_cache(None) is None
|
||||
assert newsimg.fetch_and_cache("ftp://example.com/x.png") is None
|
||||
assert not list(cache.glob("*.webp"))
|
||||
|
||||
|
||||
def test_svg_and_nonimage_rejected_and_negative_cached(cache, monkeypatch):
|
||||
monkeypatch.setattr(newsimg, "_safe_fetch",
|
||||
lambda url, timeout=12: (b"<svg xmlns='http://www.w3.org/2000/svg'></svg>" + b" " * 600,
|
||||
"image/svg+xml"))
|
||||
url = "https://example.com/evil.svg"
|
||||
assert newsimg.fetch_and_cache(url) is None # decoded-raster only → SVG refused
|
||||
assert not list(cache.glob("*.webp")) # nothing stored
|
||||
assert newsimg._failed_recently(url) # negative-cached (won't refetch)
|
||||
|
||||
|
||||
def test_private_host_refused_and_negative_cached(cache, monkeypatch):
|
||||
monkeypatch.setattr(newsimg, "_safe_fetch", _REAL_SAFE_FETCH) # exercise real validation
|
||||
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: False)
|
||||
url = "http://169.254.169.254/latest/meta-data/"
|
||||
assert newsimg.fetch_and_cache(url) is None
|
||||
assert newsimg._failed_recently(url) # SSRF target → permanent fail
|
||||
|
||||
|
||||
def test_redirect_to_private_is_refused(cache, monkeypatch):
|
||||
monkeypatch.setattr(newsimg, "_safe_fetch", _REAL_SAFE_FETCH)
|
||||
hosts = {"good.example": True, "evil.internal": False}
|
||||
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: hosts.get(h, False))
|
||||
|
||||
def handle(url): # real 302 (HTTPError) → private dest
|
||||
if "good.example" in url:
|
||||
raise _http_error(url, 302, "http://evil.internal/x.png")
|
||||
return _Resp({"Content-Type": "image/png"}, _png()) # must never be reached
|
||||
monkeypatch.setattr(newsimg.urllib.request, "build_opener", _fake_opener(handle))
|
||||
|
||||
url = "http://good.example/p.png"
|
||||
assert newsimg.fetch_and_cache(url) is None # redirect hop re-validated → refused
|
||||
assert newsimg._failed_recently(url)
|
||||
|
||||
|
||||
def test_real_redirect_followed_to_public(cache, monkeypatch):
|
||||
"""Regression for the _NoRedirect bug: 3xx arrive as HTTPError, must be followed."""
|
||||
monkeypatch.setattr(newsimg, "_safe_fetch", _REAL_SAFE_FETCH)
|
||||
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: True)
|
||||
seen = []
|
||||
|
||||
def handle(url):
|
||||
seen.append(url)
|
||||
if url == "http://a.example/p.png":
|
||||
raise _http_error(url, 302, "http://b.example/final.png")
|
||||
return _Resp({"Content-Type": "image/png"}, _png())
|
||||
monkeypatch.setattr(newsimg.urllib.request, "build_opener", _fake_opener(handle))
|
||||
|
||||
p = newsimg.fetch_and_cache("http://a.example/p.png")
|
||||
assert p and p.suffix == ".webp" # followed the redirect, cached the dest
|
||||
assert seen == ["http://a.example/p.png", "http://b.example/final.png"]
|
||||
|
||||
|
||||
def test_404_is_permanent_but_5xx_is_transient(cache, monkeypatch):
|
||||
monkeypatch.setattr(newsimg, "_safe_fetch", _REAL_SAFE_FETCH)
|
||||
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: True)
|
||||
monkeypatch.setattr(newsimg.urllib.request, "build_opener",
|
||||
_fake_opener(lambda url: (_ for _ in ()).throw(_http_error(url, 404))))
|
||||
gone = "http://x.example/missing.png"
|
||||
assert newsimg.fetch_and_cache(gone) is None
|
||||
assert newsimg._failed_recently(gone) # 404 → permanent, negative-cached
|
||||
|
||||
monkeypatch.setattr(newsimg.urllib.request, "build_opener",
|
||||
_fake_opener(lambda url: (_ for _ in ()).throw(_http_error(url, 503))))
|
||||
down = "http://x.example/down.png"
|
||||
assert newsimg.fetch_and_cache(down) is None
|
||||
assert not newsimg._failed_recently(down) # 5xx → transient, retried next cycle
|
||||
|
||||
|
||||
def test_megapixel_ceiling_enforced_before_decode(cache, monkeypatch):
|
||||
monkeypatch.setattr(newsimg, "_MAX_PIXELS", 1000) # a 1600x1000 image (1.6M px) exceeds this
|
||||
monkeypatch.setattr(newsimg, "_safe_fetch", lambda url, timeout=12: (_png(1600, 1000), "image/png"))
|
||||
url = "https://x.example/huge.png"
|
||||
assert newsimg.fetch_and_cache(url) is None # rejected by the explicit w*h check
|
||||
assert not list(cache.glob("*.webp"))
|
||||
assert newsimg._failed_recently(url)
|
||||
|
||||
|
||||
def test_transient_failure_is_not_negative_cached(cache, monkeypatch):
|
||||
def boom(url, timeout=12):
|
||||
raise newsimg._FetchError("network down", permanent=False)
|
||||
monkeypatch.setattr(newsimg, "_safe_fetch", boom)
|
||||
url = "https://example.com/x.jpg"
|
||||
assert newsimg.fetch_and_cache(url) is None
|
||||
assert not newsimg._failed_recently(url) # transient → retried next cycle
|
||||
|
||||
|
||||
def test_prune_evicts_least_recently_used_over_cap(cache):
|
||||
paths = [newsimg.fetch_and_cache(f"https://example.com/{i}.png") for i in range(5)]
|
||||
for i, p in enumerate(paths): # 0 = oldest/LRU, 4 = newest
|
||||
os.utime(p, (1000 + i, 1000 + i))
|
||||
sizes = [p.stat().st_size for p in paths]
|
||||
cap = sum(sizes) - sizes[0] - sizes[1] + 1 # room for the 3 newest only
|
||||
r = newsimg.prune(cap)
|
||||
assert r["removed"] == 2 and r["after"] <= cap
|
||||
assert not paths[0].exists() and not paths[1].exists()
|
||||
assert paths[2].exists() and paths[4].exists()
|
||||
|
||||
|
||||
def test_display_url_resolves_per_policy():
|
||||
assert newsimg.display_url(7, "cache", "https://x/p.jpg") == "/api/img/7" # our copy
|
||||
assert newsimg.display_url(7, "remote", "https://x/p.jpg") == "https://x/p.jpg" # hotlink
|
||||
assert newsimg.display_url(7, "none", "https://x/p.jpg") is None # typographic cover
|
||||
assert newsimg.display_url(7, None, "https://x/p.jpg") == "https://x/p.jpg" # default = remote
|
||||
assert newsimg.display_url(7, "cache", None) is None # no image
|
||||
|
||||
|
||||
def test_warm_only_caches_cache_policy_sources(cache, monkeypatch):
|
||||
conn = connect(":memory:"); init_db(conn)
|
||||
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (1,'C','http://c/f','cache')")
|
||||
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (2,'R','http://r/f','remote')")
|
||||
# source 1 (cache): two accepted+canonical+image; plus not-accepted + duplicate (skipped)
|
||||
# source 2 (remote): accepted+image but must NOT be cached (re-hosting not cleared)
|
||||
rows = ((1, 1, "https://x/1.jpg", 1, None), (2, 1, "https://x/2.jpg", 1, None),
|
||||
(3, 1, "https://x/3.jpg", 0, None), (4, 1, "https://x/4.jpg", 1, 1),
|
||||
(5, 2, "https://x/5.jpg", 1, None))
|
||||
for aid, sid, img, acc, dup in rows:
|
||||
conn.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url,duplicate_of) "
|
||||
"VALUES (?,?,?,?,?,?,?)", (aid, sid, f"http://s/{aid}", f"t{aid}", f"h{aid}", img, dup))
|
||||
conn.execute("INSERT INTO article_scores (article_id, accepted) VALUES (?,?)", (aid, acc))
|
||||
conn.commit()
|
||||
assert newsimg.warm(conn) == 2 # only source 1's accepted+canonical+image
|
||||
assert newsimg.warm(conn) == 0 # idempotent — already cached
|
||||
|
||||
|
||||
def test_purge_source_removes_cached_copies(cache, monkeypatch):
|
||||
conn = connect(":memory:"); init_db(conn)
|
||||
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (1,'C','http://c/f','cache')")
|
||||
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (2,'O','http://o/f','cache')")
|
||||
for aid, sid, img in ((1, 1, "https://x/1.jpg"), (2, 1, "https://x/2.jpg"), (3, 2, "https://x/3.jpg")):
|
||||
conn.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url) "
|
||||
"VALUES (?,?,?,?,?,?)", (aid, sid, f"http://s/{aid}", f"t{aid}", f"h{aid}", img))
|
||||
conn.execute("INSERT INTO article_scores (article_id, accepted) VALUES (?,1)", (aid,))
|
||||
conn.commit()
|
||||
assert newsimg.warm(conn) == 3 # all three cached (both sources 'cache')
|
||||
removed = newsimg.purge_source(conn, 1) # source 1 leaves cache → its 2 copies go
|
||||
assert removed == 2
|
||||
assert newsimg.path_for("https://x/1.jpg") is None and newsimg.path_for("https://x/2.jpg") is None
|
||||
assert newsimg.path_for("https://x/3.jpg") is not None # source 2 untouched
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("GOODNEWS_DB", str(tmp_path / "t.sqlite3"))
|
||||
monkeypatch.setenv("GOODNEWS_IMG_CACHE", str(tmp_path / "img_cache"))
|
||||
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: True)
|
||||
monkeypatch.setattr(newsimg, "_safe_fetch", lambda url, timeout=12: (_png(), "image/png"))
|
||||
conn = connect(tmp_path / "t.sqlite3"); init_db(conn)
|
||||
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (1,'C','http://c/f','cache')")
|
||||
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (2,'R','http://r/f','remote')")
|
||||
# src1 (cache): 1=accepted+image, 2=no image, 3=NOT accepted, 4=duplicate.
|
||||
# src2 (remote): 5=accepted+image but NOT cleared to cache → endpoint must 404.
|
||||
rows = ((1, 1, "https://x/pic.jpg", 1, None), (2, 1, "", 1, None),
|
||||
(3, 1, "https://x/p3.jpg", 0, None), (4, 1, "https://x/p4.jpg", 1, 1),
|
||||
(5, 2, "https://x/p5.jpg", 1, None))
|
||||
for aid, sid, img, acc, dup in rows:
|
||||
conn.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url,duplicate_of) "
|
||||
"VALUES (?,?,?,?,?,?,?)", (aid, sid, f"http://s/{aid}", f"t{aid}", f"h{aid}", img, dup))
|
||||
conn.execute("INSERT INTO article_scores (article_id, accepted) VALUES (?,?)", (aid, acc))
|
||||
conn.commit()
|
||||
# the cycle owns fetching — pre-warm so the endpoint has a hit to serve
|
||||
newsimg.warm(conn)
|
||||
conn.close()
|
||||
from goodnews.api import create_app
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
def test_img_endpoint_serves_cached_and_restricts(client):
|
||||
r = client.get("/api/img/1")
|
||||
assert r.status_code == 200 and r.headers["content-type"] == "image/webp"
|
||||
assert "immutable" not in r.headers.get("cache-control", "") # no 1-year immutable
|
||||
assert client.get("/api/img/2").status_code == 404 # no image
|
||||
assert client.get("/api/img/3").status_code == 404 # not accepted
|
||||
assert client.get("/api/img/4").status_code == 404 # duplicate (non-canonical)
|
||||
assert client.get("/api/img/5").status_code == 404 # remote-policy source (not cleared to cache)
|
||||
assert client.get("/api/img/999").status_code == 404 # unknown id
|
||||
|
||||
|
||||
def test_img_endpoint_never_fetches(client, monkeypatch):
|
||||
# An accepted article whose image was never warmed must 404, not trigger a fetch.
|
||||
called = {"n": 0}
|
||||
monkeypatch.setattr(newsimg, "fetch_and_cache", lambda *a, **k: called.__setitem__("n", called["n"] + 1))
|
||||
# /api/img/1 is cached (warmed in fixture) → still served without any fetch
|
||||
assert client.get("/api/img/1").status_code == 200
|
||||
assert called["n"] == 0
|
||||
@@ -76,3 +76,15 @@ def test_tone_filter_llm_narrows(conn):
|
||||
kept = onthisday._tone_filter([dict(e) for e in FAKE], client=FakeClient())
|
||||
# keyword floor drops the invasion (3 remain), then the LLM narrows to 1
|
||||
assert len(kept) == 1 and kept[0]["year"] == 1611
|
||||
|
||||
|
||||
def test_best_image_prefers_original_over_thumbnail():
|
||||
# the 330px thumbnail upscales (blurry); originalimage is reliably sharp → prefer it
|
||||
page = {
|
||||
"thumbnail": {"source": "https://x/thumb/Foo.jpg/330px-Foo.jpg", "width": 330},
|
||||
"originalimage": {"source": "https://x/thumb/Foo.jpg/3840px-Foo.jpg", "width": 7000},
|
||||
}
|
||||
assert onthisday._best_image(page) == "https://x/thumb/Foo.jpg/3840px-Foo.jpg"
|
||||
# falls back to the thumbnail when there's no originalimage, and to None when neither exists
|
||||
assert onthisday._best_image({"thumbnail": {"source": "https://x/330px-Foo.jpg"}}) == "https://x/330px-Foo.jpg"
|
||||
assert onthisday._best_image({}) is None
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""The no-paywall promise across every public discovery path: paywalled sources are
|
||||
excluded from brief generation, stored-brief retrieval (/today + /api/brief), and both
|
||||
digest queries — while Saved keeps anything the reader saved, and a 'free' override
|
||||
restores eligibility."""
|
||||
from datetime import date
|
||||
|
||||
from goodnews.db import connect, init_db
|
||||
from goodnews import briefs, digest, queries
|
||||
|
||||
|
||||
def _setup(c, pay_override="paywalled"):
|
||||
"""Source 1 = paywalled (via override, so the test doesn't depend on the domain list),
|
||||
source 2 = free. One recent accepted article each, both in a stored brief."""
|
||||
today = date.today().isoformat()
|
||||
c.execute("INSERT INTO sources (id,name,feed_url,content_visible,paywall_override) VALUES (1,'Pay','http://p/f',1,?)",
|
||||
(pay_override,))
|
||||
c.execute("INSERT INTO sources (id,name,feed_url,content_visible) VALUES (2,'Free','http://f/f',1)")
|
||||
for aid, sid in [(1, 1), (2, 2)]:
|
||||
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,published_at,url_hash) VALUES (?,?,?,?,?,?)",
|
||||
(aid, sid, f"http://x/{aid}", f"t{aid}", today + "T12:00:00+00:00", f"h{aid}"))
|
||||
c.execute("INSERT INTO article_scores (article_id,accepted,topic,flavor) VALUES (?,1,'science','discovery')", (aid,))
|
||||
c.execute("INSERT INTO article_summaries (article_id,summary) VALUES (?,?)", (aid, f"s{aid}"))
|
||||
c.commit()
|
||||
return today
|
||||
|
||||
|
||||
def _store_brief(c, today, ids=(1, 2)):
|
||||
bid = c.execute("INSERT INTO daily_briefs (brief_date,title) VALUES (?,'t')", (today,)).lastrowid
|
||||
for rank, aid in enumerate(ids, start=1):
|
||||
c.execute("INSERT INTO daily_brief_items (brief_id,article_id,rank) VALUES (?,?,?)", (bid, aid, rank))
|
||||
c.commit()
|
||||
|
||||
|
||||
def test_paywalled_never_stored_in_a_new_brief():
|
||||
c = connect(":memory:"); init_db(c)
|
||||
today = _setup(c)
|
||||
briefs.build_daily_brief(c, brief_date=today, limit=5, replace=True)
|
||||
stored = [r["article_id"] for r in c.execute("SELECT article_id FROM daily_brief_items")]
|
||||
assert stored == [2] # paywalled candidate excluded, never written
|
||||
|
||||
|
||||
def test_stored_brief_retrieval_and_digest_omit_paywalled():
|
||||
c = connect(":memory:"); init_db(c)
|
||||
today = _setup(c)
|
||||
_store_brief(c, today) # both stored directly → retrieval/digest must still filter
|
||||
assert [r["id"] for r in queries.brief(c)["items"]] == [2] # /today + /api/brief
|
||||
assert [d["id"] for d in digest.digest_items(c, today)] == [2] # morning email
|
||||
|
||||
|
||||
def test_followed_source_email_omits_paywalled():
|
||||
c = connect(":memory:"); init_db(c)
|
||||
today = _setup(c)
|
||||
c.execute("INSERT INTO users (id,email) VALUES (1,'r@x.com')")
|
||||
c.execute("INSERT INTO user_follows (user_id,kind,value) VALUES (1,'source','1')") # follow the paywalled one
|
||||
c.execute("INSERT INTO user_follows (user_id,kind,value) VALUES (1,'source','2')")
|
||||
c.commit()
|
||||
ids = [d["id"] for d in digest.followed_digest_items(c, 1, exclude_ids=[])]
|
||||
assert ids == [2] # even a followed paywalled source is omitted from the email
|
||||
|
||||
|
||||
def test_saved_retains_paywalled():
|
||||
c = connect(":memory:"); init_db(c)
|
||||
_setup(c)
|
||||
c.execute("INSERT INTO users (id,email) VALUES (1,'r@x.com')")
|
||||
c.execute("INSERT INTO saved_articles (user_id,article_id,saved_at) VALUES (1,1,'2026-06-28T00:00:00')")
|
||||
c.commit()
|
||||
assert 1 in [r["id"] for r in queries.saved(c, 1)] # you keep what you saved
|
||||
|
||||
|
||||
def test_history_retains_paywalled():
|
||||
c = connect(":memory:"); init_db(c)
|
||||
_setup(c)
|
||||
c.execute("INSERT INTO users (id,email) VALUES (1,'r@x.com')")
|
||||
c.execute("INSERT INTO user_history (user_id,article_id,at) VALUES (1,1,'2026-06-28T00:00:00')")
|
||||
c.commit()
|
||||
assert 1 in [r["id"] for r in queries.history(c, 1)] # a viewed paywalled article stays in history
|
||||
|
||||
|
||||
def test_free_override_restores_eligibility():
|
||||
c = connect(":memory:"); init_db(c)
|
||||
today = _setup(c, pay_override="free") # same domain-less source, but marked free
|
||||
briefs.build_daily_brief(c, brief_date=today, limit=5, replace=True)
|
||||
stored = [r["article_id"] for r in c.execute("SELECT article_id FROM daily_brief_items")]
|
||||
assert 1 in stored and 2 in stored
|
||||
assert {r["id"] for r in queries.brief(c)["items"]} == {1, 2}
|
||||
@@ -56,3 +56,28 @@ def test_get_today_never_empty(conn):
|
||||
def test_run_daily_seeds_then_picks(conn):
|
||||
r = quote.run_daily(conn)
|
||||
assert r["pool"] == len(quote.SEED) and r["picked"]
|
||||
|
||||
|
||||
def test_no_repeat_until_pool_exhausted(conn):
|
||||
"""Every quote is featured exactly once before ANY repeat; then the oldest-shown
|
||||
repeats first. (Regression for the rotate-the-whole-pool selector bug.)"""
|
||||
import datetime
|
||||
quote.seed(conn)
|
||||
n = len(quote.SEED)
|
||||
d0 = datetime.date(2026, 1, 1)
|
||||
picks = [quote.pick_daily(conn, feature_date=(d0 + datetime.timedelta(days=i)).isoformat())["pool_id"]
|
||||
for i in range(n)]
|
||||
assert len(set(picks)) == n # full coverage, no repeat within the pool
|
||||
nxt = (d0 + datetime.timedelta(days=n)).isoformat()
|
||||
assert quote.pick_daily(conn, feature_date=nxt)["pool_id"] == picks[0] # oldest repeats first
|
||||
|
||||
|
||||
def test_freshest_cohort():
|
||||
from goodnews import daily
|
||||
# never-shown win outright (the oldest shown item is ignored while any never-shown remain)
|
||||
assert daily.freshest([{"id": 1, "shown_at": "2026-01-02"},
|
||||
{"id": 2, "shown_at": None}, {"id": 3, "shown_at": None}]) == [2, 3]
|
||||
# all shown → only the oldest-shown cohort
|
||||
assert daily.freshest([{"id": 1, "shown_at": "2026-01-03"},
|
||||
{"id": 2, "shown_at": "2026-01-01"}, {"id": 3, "shown_at": "2026-01-01"}]) == [2, 3]
|
||||
assert daily.freshest([]) == []
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Full-article read-time: word counting strips furniture, threshold/None handling,
|
||||
and the bounded enrich pass is idempotent + never overwrites a good count with zero."""
|
||||
import pytest
|
||||
|
||||
from goodnews import readtime
|
||||
from goodnews.db import connect, init_db
|
||||
from goodnews.enrich import enrich_read_times
|
||||
|
||||
|
||||
def test_word_count_strips_furniture():
|
||||
html = (b"<html><head><style>.x{color:red}</style></head><body>"
|
||||
b"<nav>Home About Contact Subscribe Login Search Menu</nav>"
|
||||
b"<header>Site Name Sections Newsletter</header>"
|
||||
b"<article><p>" + b"word " * 300 + b"</p></article>"
|
||||
b"<footer>Copyright cookie consent terms privacy policy</footer>"
|
||||
b"<script>var a = 1; trackEverything(); analytics();</script></body></html>")
|
||||
n = readtime.word_count_from_html(html)
|
||||
# ~300 article words; nav/header/footer/script/style excluded → only a small overcount
|
||||
assert 300 <= n <= 320
|
||||
|
||||
|
||||
def test_read_minutes_threshold_and_rounding():
|
||||
assert readtime.source_read_minutes(None) is None
|
||||
assert readtime.source_read_minutes(0) is None
|
||||
assert readtime.source_read_minutes(150) is None # below the 200-word floor
|
||||
assert readtime.source_read_minutes(220) == 2 # clamped to a 2-min minimum
|
||||
assert readtime.source_read_minutes(450) == 2 # 450/225 = 2
|
||||
assert readtime.source_read_minutes(2250) == 10
|
||||
|
||||
|
||||
def test_word_count_empty_or_none():
|
||||
assert readtime.word_count_from_html(None) == 0
|
||||
assert readtime.word_count_from_html(b"") == 0
|
||||
assert readtime.source_read_minutes(readtime.word_count_from_html(b"<html></html>")) is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn(tmp_path):
|
||||
c = connect(str(tmp_path / "t.sqlite3")); init_db(c)
|
||||
c.execute("INSERT INTO sources (id, name, feed_url) VALUES (1, 'S', 'http://s/f')")
|
||||
for i in (1, 2):
|
||||
c.execute("INSERT INTO articles (id, source_id, canonical_url, title, url_hash) VALUES (?,1,?,?,?)",
|
||||
(i, f"https://ex.com/{i}", f"T{i}", f"h{i}"))
|
||||
c.execute("INSERT INTO article_scores (article_id, accepted) VALUES (?, 1)", (i,))
|
||||
c.commit()
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
def test_enrich_stores_then_skips_already_counted(conn):
|
||||
calls = []
|
||||
def fake(url):
|
||||
calls.append(url)
|
||||
return 900 # ~4 min
|
||||
assert enrich_read_times(conn, fetch=fake) == 2
|
||||
assert conn.execute("SELECT source_words FROM articles WHERE id=1").fetchone()[0] == 900
|
||||
calls.clear()
|
||||
assert enrich_read_times(conn, fetch=fake) == 0 # both counted → re-fetches nothing
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_enrich_failure_stamps_but_never_overwrites(conn):
|
||||
enrich_read_times(conn, fetch=lambda u: 600) # both get a good count
|
||||
assert conn.execute("SELECT source_words FROM articles WHERE id=1").fetchone()[0] == 600
|
||||
# a fresh article whose extraction fails: stamped (so we don't hammer it), left null
|
||||
conn.execute("INSERT INTO articles (id, source_id, canonical_url, title, url_hash) "
|
||||
"VALUES (3, 1, 'https://ex.com/3', 'T3', 'h3')")
|
||||
conn.execute("INSERT INTO article_scores (article_id, accepted) VALUES (3, 1)")
|
||||
conn.commit()
|
||||
assert enrich_read_times(conn, fetch=lambda u: None) == 0
|
||||
row = conn.execute("SELECT source_words, read_checked_at FROM articles WHERE id=3").fetchone()
|
||||
assert row[0] is None and row[1] is not None
|
||||
# the good counts are untouched by a later failing pass
|
||||
assert conn.execute("SELECT source_words FROM articles WHERE id=1").fetchone()[0] == 600
|
||||
|
||||
|
||||
def test_api_exposes_source_read_minutes_only_when_known():
|
||||
from goodnews.api import Article
|
||||
base = {"id": 1, "title": "T", "canonical_url": "https://ex.com/a", "source_name": "S", "accepted": 1}
|
||||
assert Article.from_row({**base, "source_words": 2000}).source_read_minutes == 9
|
||||
assert Article.from_row({**base, "source_words": 120}).source_read_minutes is None # too thin
|
||||
assert Article.from_row({**base}).source_read_minutes is None # absent → null
|
||||
@@ -43,3 +43,29 @@ def test_sitemap(client):
|
||||
assert "https://upbeatbytes.com/a/1" in xml
|
||||
assert "https://upbeatbytes.com/today" in xml
|
||||
assert "<lastmod>2026-06-05</lastmod>" in xml
|
||||
|
||||
|
||||
def test_sitemap_excludes_paywalled_and_restores_on_free(tmp_path, monkeypatch):
|
||||
db = tmp_path / "sm.sqlite3"
|
||||
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
||||
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com")
|
||||
import importlib
|
||||
import goodnews.api as api
|
||||
importlib.reload(api)
|
||||
from goodnews.db import connect, init_db
|
||||
c = connect(str(db)); init_db(c)
|
||||
c.execute("INSERT INTO sources (id,name,feed_url,trust_score,content_visible) VALUES (1,'Free','http://f',5,1)")
|
||||
c.execute("INSERT INTO sources (id,name,feed_url,trust_score,content_visible,paywall_override) "
|
||||
"VALUES (2,'Pay','http://p',5,1,'paywalled')")
|
||||
for aid, sid in [(1, 1), (2, 2)]:
|
||||
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,published_at) VALUES (?,?,?,?,?,?)",
|
||||
(aid, sid, f"http://x/{aid}", f"t{aid}", f"h{aid}", "2026-06-05T08:00:00"))
|
||||
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (?,1)", (aid,))
|
||||
c.execute("INSERT INTO article_summaries (article_id,summary) VALUES (?,?)", (aid, f"s{aid}")) # sitemap requires a summary
|
||||
c.commit()
|
||||
tc = TestClient(api.create_app())
|
||||
xml = tc.get("/sitemap.xml").text
|
||||
assert "/a/1</loc>" in xml and "/a/2</loc>" not in xml # paywalled source omitted
|
||||
c.execute("UPDATE sources SET paywall_override='free' WHERE id=2"); c.commit()
|
||||
assert "/a/2</loc>" in tc.get("/sitemap.xml").text # restored under 'free'
|
||||
c.close()
|
||||
|
||||
@@ -105,3 +105,15 @@ def test_complete_page_is_cached_and_served_from_cache(app_complete):
|
||||
assert 1 in api._SHARE_CACHE # finished page cached
|
||||
r2 = tc.get("/a/1") # second hit served from cache
|
||||
assert r2.status_code == 200 and r2.text == r1.text
|
||||
|
||||
|
||||
def test_share_chrome_parity_and_fallbacks():
|
||||
"""HubBar-replica parity Codex asked for: Back only follows a SAME-ORIGIN referrer,
|
||||
the menu honors Escape, and a failed image removes itself (clean text unfurl)."""
|
||||
from goodnews import share
|
||||
html = share.render_share_page(
|
||||
{"id": 7, "title": "T", "image_url": "https://x/p.jpg", "canonical_url": "https://s/x",
|
||||
"source_name": "S"}, "https://upbeatbytes.com")
|
||||
assert "origin===location.origin" in html # Back requires same-origin referrer
|
||||
assert "'Escape'" in html or "Escape" in html # menu closes on Escape (parity)
|
||||
assert 'onerror="this.remove()"' in html # broken image degrades to text unfurl
|
||||
|
||||
+43
-3
@@ -108,13 +108,53 @@ def test_pick_lazy_polishes_older_words(conn, monkeypatch):
|
||||
def test_polish_trims_and_caps_two_examples():
|
||||
class C:
|
||||
def chat_text(self, m):
|
||||
return 'sure: {"gloss": " Calm and peaceful. ", "examples": ["One.", "Two.", "Three."]} done'
|
||||
return ('sure: {"gloss": " Calm and peaceful. ", "examples": '
|
||||
'["A serene lake.", "The serene night.", "A serene mood."]} done')
|
||||
out = wotd._polish(C(), "serene", "adjective", "x")
|
||||
assert out["gloss"] == "Calm and peaceful." and out["examples"] == ["One.", "Two."]
|
||||
assert out["gloss"] == "Calm and peaceful." and out["examples"] == ["A serene lake.", "The serene night."]
|
||||
|
||||
|
||||
def test_polish_returns_none_without_a_gloss():
|
||||
class C:
|
||||
def chat_text(self, m):
|
||||
return '{"examples": ["x"]}'
|
||||
return '{"examples": ["A serene lake."]}'
|
||||
assert wotd._polish(C(), "serene", None, "x") is None
|
||||
|
||||
|
||||
def test_polish_drops_examples_that_dont_use_the_word():
|
||||
# the word must appear (case-insensitive) — example here keeps only the matching one
|
||||
class C:
|
||||
def chat_text(self, m):
|
||||
return '{"gloss": "Calm.", "examples": ["It was quiet.", "A SERENE harbor."]}'
|
||||
out = wotd._polish(C(), "serene", "adjective", "x")
|
||||
assert out["examples"] == ["A SERENE harbor."]
|
||||
|
||||
|
||||
def test_polish_returns_none_when_no_example_uses_the_word():
|
||||
class C:
|
||||
def chat_text(self, m):
|
||||
return '{"gloss": "A warm clear gloss.", "examples": ["Totally unrelated.", "Still nothing."]}'
|
||||
assert wotd._polish(C(), "serene", "adjective", "x") is None
|
||||
|
||||
|
||||
def test_polish_returns_none_with_empty_examples():
|
||||
class C:
|
||||
def chat_text(self, m):
|
||||
return '{"gloss": "A warm clear gloss.", "examples": []}'
|
||||
assert wotd._polish(C(), "serene", "adjective", "x") is None
|
||||
|
||||
|
||||
def test_no_repeat_until_pool_exhausted(conn):
|
||||
"""Same freshness guarantee as QOTD: every word featured once before any repeat,
|
||||
then the oldest-shown repeats first. (Regression for 'harmony' repeating after 3 days.)"""
|
||||
import datetime
|
||||
for w in ["alpha", "bravo", "charlie", "delta"]:
|
||||
conn.execute("INSERT INTO wotd_pool (word, definition) VALUES (?, 'a definition')", (w,))
|
||||
conn.commit()
|
||||
n = 4
|
||||
d0 = datetime.date(2026, 2, 1)
|
||||
picks = [wotd.pick_daily(conn, feature_date=(d0 + datetime.timedelta(days=i)).isoformat())["pool_id"]
|
||||
for i in range(n)]
|
||||
assert len(set(picks)) == n
|
||||
nxt = (d0 + datetime.timedelta(days=n)).isoformat()
|
||||
assert wotd.pick_daily(conn, feature_date=nxt)["pool_id"] == picks[0]
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
{
|
||||
"note": "Angelfish Animated Pack 10 (CGTrader). One baked 75.67s/2270-frame Take at 30fps in each GLB. GLB frame = Maya frame + 110. Slice with THREE.AnimationUtils.subclip(clip,name,startFrame,endFrame,30).",
|
||||
"fps": 30,
|
||||
"total_frames": 2270,
|
||||
"clips": {
|
||||
"Swim1_norm": {
|
||||
"glb_frames": [
|
||||
110,
|
||||
185
|
||||
],
|
||||
"seconds": [
|
||||
3.667,
|
||||
6.167
|
||||
],
|
||||
"maya_frames": [
|
||||
0,
|
||||
75
|
||||
]
|
||||
},
|
||||
"Turn_R_in": {
|
||||
"glb_frames": [
|
||||
186,
|
||||
255
|
||||
],
|
||||
"seconds": [
|
||||
6.2,
|
||||
8.5
|
||||
],
|
||||
"maya_frames": [
|
||||
76,
|
||||
145
|
||||
]
|
||||
},
|
||||
"Turn_R_loop": {
|
||||
"glb_frames": [
|
||||
256,
|
||||
330
|
||||
],
|
||||
"seconds": [
|
||||
8.533,
|
||||
11.0
|
||||
],
|
||||
"maya_frames": [
|
||||
146,
|
||||
220
|
||||
]
|
||||
},
|
||||
"Turn_R_out": {
|
||||
"glb_frames": [
|
||||
331,
|
||||
409
|
||||
],
|
||||
"seconds": [
|
||||
11.033,
|
||||
13.633
|
||||
],
|
||||
"maya_frames": [
|
||||
221,
|
||||
299
|
||||
]
|
||||
},
|
||||
"Swim2_Fast": {
|
||||
"glb_frames": [
|
||||
410,
|
||||
450
|
||||
],
|
||||
"seconds": [
|
||||
13.667,
|
||||
15.0
|
||||
],
|
||||
"maya_frames": [
|
||||
300,
|
||||
340
|
||||
]
|
||||
},
|
||||
"Turn_L_in": {
|
||||
"glb_frames": [
|
||||
486,
|
||||
535
|
||||
],
|
||||
"seconds": [
|
||||
16.2,
|
||||
17.833
|
||||
],
|
||||
"maya_frames": [
|
||||
376,
|
||||
425
|
||||
]
|
||||
},
|
||||
"Turn_L_loop": {
|
||||
"glb_frames": [
|
||||
536,
|
||||
610
|
||||
],
|
||||
"seconds": [
|
||||
17.867,
|
||||
20.333
|
||||
],
|
||||
"maya_frames": [
|
||||
426,
|
||||
500
|
||||
]
|
||||
},
|
||||
"Turn_L_out": {
|
||||
"glb_frames": [
|
||||
611,
|
||||
710
|
||||
],
|
||||
"seconds": [
|
||||
20.367,
|
||||
23.667
|
||||
],
|
||||
"maya_frames": [
|
||||
501,
|
||||
600
|
||||
]
|
||||
},
|
||||
"Attack": {
|
||||
"glb_frames": [
|
||||
711,
|
||||
751
|
||||
],
|
||||
"seconds": [
|
||||
23.7,
|
||||
25.033
|
||||
],
|
||||
"maya_frames": [
|
||||
601,
|
||||
641
|
||||
]
|
||||
},
|
||||
"Eat_Swim": {
|
||||
"glb_frames": [
|
||||
752,
|
||||
811
|
||||
],
|
||||
"seconds": [
|
||||
25.067,
|
||||
27.033
|
||||
],
|
||||
"maya_frames": [
|
||||
642,
|
||||
701
|
||||
]
|
||||
},
|
||||
"Eat_Ground": {
|
||||
"glb_frames": [
|
||||
812,
|
||||
852
|
||||
],
|
||||
"seconds": [
|
||||
27.067,
|
||||
28.4
|
||||
],
|
||||
"maya_frames": [
|
||||
702,
|
||||
742
|
||||
]
|
||||
},
|
||||
"Eat_Wall": {
|
||||
"glb_frames": [
|
||||
853,
|
||||
893
|
||||
],
|
||||
"seconds": [
|
||||
28.433,
|
||||
29.767
|
||||
],
|
||||
"maya_frames": [
|
||||
743,
|
||||
783
|
||||
]
|
||||
},
|
||||
"Turn_L_Fast": {
|
||||
"glb_frames": [
|
||||
894,
|
||||
923
|
||||
],
|
||||
"seconds": [
|
||||
29.8,
|
||||
30.767
|
||||
],
|
||||
"maya_frames": [
|
||||
784,
|
||||
813
|
||||
]
|
||||
},
|
||||
"Turn_R_Fast": {
|
||||
"glb_frames": [
|
||||
924,
|
||||
953
|
||||
],
|
||||
"seconds": [
|
||||
30.8,
|
||||
31.767
|
||||
],
|
||||
"maya_frames": [
|
||||
814,
|
||||
843
|
||||
]
|
||||
},
|
||||
"Swim3_Long_Wide": {
|
||||
"glb_frames": [
|
||||
954,
|
||||
1401
|
||||
],
|
||||
"seconds": [
|
||||
31.8,
|
||||
46.7
|
||||
],
|
||||
"maya_frames": [
|
||||
844,
|
||||
1291
|
||||
]
|
||||
},
|
||||
"Swim4_Long_Near": {
|
||||
"glb_frames": [
|
||||
1402,
|
||||
1852
|
||||
],
|
||||
"seconds": [
|
||||
46.733,
|
||||
61.733
|
||||
],
|
||||
"maya_frames": [
|
||||
1292,
|
||||
1742
|
||||
]
|
||||
},
|
||||
"Death1 Sink Start": {
|
||||
"glb_frames": [
|
||||
1853,
|
||||
1930
|
||||
],
|
||||
"seconds": [
|
||||
61.767,
|
||||
64.333
|
||||
],
|
||||
"maya_frames": [
|
||||
1743,
|
||||
1820
|
||||
]
|
||||
},
|
||||
"Death1 Sink Loop": {
|
||||
"glb_frames": [
|
||||
1930,
|
||||
1987
|
||||
],
|
||||
"seconds": [
|
||||
64.333,
|
||||
66.233
|
||||
],
|
||||
"maya_frames": [
|
||||
1820,
|
||||
1877
|
||||
]
|
||||
},
|
||||
"Death2 Float Start": {
|
||||
"glb_frames": [
|
||||
1988,
|
||||
2120
|
||||
],
|
||||
"seconds": [
|
||||
66.267,
|
||||
70.667
|
||||
],
|
||||
"maya_frames": [
|
||||
1878,
|
||||
2010
|
||||
]
|
||||
},
|
||||
"Death2 Float Loop": {
|
||||
"glb_frames": [
|
||||
2121,
|
||||
2210
|
||||
],
|
||||
"seconds": [
|
||||
70.7,
|
||||
73.667
|
||||
],
|
||||
"maya_frames": [
|
||||
2011,
|
||||
2100
|
||||
]
|
||||
},
|
||||
"Jump": {
|
||||
"glb_frames": [
|
||||
2211,
|
||||
2270
|
||||
],
|
||||
"seconds": [
|
||||
73.7,
|
||||
75.667
|
||||
],
|
||||
"maya_frames": [
|
||||
2101,
|
||||
2160
|
||||
]
|
||||
},
|
||||
"Idle": {
|
||||
"glb_frames": [
|
||||
0,
|
||||
109
|
||||
],
|
||||
"seconds": [
|
||||
0.0,
|
||||
3.633
|
||||
],
|
||||
"maya_frames": [
|
||||
-110,
|
||||
-1
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Build a multi-clip GLB from the pack's single baked Take: extract a chosen set of
|
||||
// clips (by frame range from angelfish-clips.json) into SEPARATE named animations, each
|
||||
// rebased to start at 0, then drop the original 75s Take. This is what the Zen Den's
|
||||
// behavior engine crossfades between. Usage: node build-clips.mjs in.glb out.glb
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||
import { prune } from '@gltf-transform/functions';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const [, , inPath, outPath] = process.argv;
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const doc = await io.read(inPath);
|
||||
const root = doc.getRoot();
|
||||
const buffer = root.listBuffers()[0];
|
||||
const meta = JSON.parse(fs.readFileSync(new URL('./angelfish-clips.json', import.meta.url))).clips;
|
||||
|
||||
// output-clip-name -> source clip in the pack. Full turn SEQUENCES (in→loop→out) so the
|
||||
// controller can play a real turn; swim intensities for cadence/zoomies; eats for forage.
|
||||
const MAP = {
|
||||
idle: 'Idle',
|
||||
cruise: 'Swim1_norm',
|
||||
fast: 'Swim2_Fast',
|
||||
turnLin: 'Turn_L_in', turnLloop: 'Turn_L_loop', turnLout: 'Turn_L_out',
|
||||
turnRin: 'Turn_R_in', turnRloop: 'Turn_R_loop', turnRout: 'Turn_R_out',
|
||||
eatswim: 'Eat_Swim', eatground: 'Eat_Ground',
|
||||
};
|
||||
|
||||
const src = root.listAnimations()[0];
|
||||
const srcChannels = src.listChannels();
|
||||
const EPS = 1e-4;
|
||||
// The original Take's time/value accessors — captured now, disposed after we've copied
|
||||
// the windows we need (new clips create their own fresh accessors, so these become dead).
|
||||
const oldAccessors = new Set();
|
||||
for (const sm of src.listSamplers()) { oldAccessors.add(sm.getInput()); oldAccessors.add(sm.getOutput()); }
|
||||
|
||||
for (const [outName, srcName] of Object.entries(MAP)) {
|
||||
const [s0, s1] = meta[srcName].seconds;
|
||||
const anim = doc.createAnimation(outName);
|
||||
for (const ch of srcChannels) {
|
||||
const sm = ch.getSampler();
|
||||
const times = sm.getInput().getArray();
|
||||
let a = 0; while (a < times.length - 1 && times[a] < s0 - EPS) a++;
|
||||
let b = times.length - 1; while (b > a && times[b] > s1 + EPS) b--;
|
||||
const base = times[a];
|
||||
const nTimes = Float32Array.from(times.slice(a, b + 1), (x) => x - base);
|
||||
const out = sm.getOutput();
|
||||
const c = out.getElementSize();
|
||||
const nOut = out.getArray().slice(a * c, (b + 1) * c);
|
||||
const inAcc = doc.createAccessor().setType('SCALAR').setArray(nTimes).setBuffer(buffer);
|
||||
const outAcc = doc.createAccessor().setType(out.getType()).setArray(nOut).setBuffer(buffer);
|
||||
const ns = doc.createAnimationSampler().setInput(inAcc).setOutput(outAcc).setInterpolation(sm.getInterpolation());
|
||||
anim.addSampler(ns);
|
||||
anim.addChannel(doc.createAnimationChannel().setTargetNode(ch.getTargetNode()).setTargetPath(ch.getTargetPath()).setSampler(ns));
|
||||
}
|
||||
}
|
||||
src.dispose(); // remove the original 75s Take
|
||||
for (const acc of oldAccessors) acc.dispose(); // free its ~10MB of keyframe data
|
||||
await doc.transform(prune());
|
||||
await io.write(outPath, doc);
|
||||
console.log('clips:', root.listAnimations().map((a) => `${a.getName()}(${a.listChannels().length}ch)`).join(', '));
|
||||
@@ -0,0 +1,71 @@
|
||||
// Generic GLB structural audit (species-agnostic). For each file: mesh/poly counts,
|
||||
// materials with alphaMode + doubleSided (the koi's failure was one BLEND double-sided
|
||||
// mesh), textures, skin/joint count, animation clip names (must carry Idle/Swim in the
|
||||
// GLB, not just the Maya source), bbox proportions, and a crude "two-tail" check —
|
||||
// histogram of the rear-slice vertices along the thin (left/right) axis: a single peak
|
||||
// at centre = one tail; two side peaks with a centre gap = a forked / double tail.
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const path = process.argv[2];
|
||||
const doc = await io.read(path);
|
||||
const root = doc.getRoot();
|
||||
const base = path.split('/').pop();
|
||||
|
||||
const meshes = root.listMeshes();
|
||||
let prims = 0, tris = 0, verts = 0;
|
||||
const bb = { min: [1e9, 1e9, 1e9], max: [-1e9, -1e9, -1e9] };
|
||||
const allPos = [];
|
||||
for (const m of meshes) for (const p of m.listPrimitives()) {
|
||||
prims++;
|
||||
const pos = p.getAttribute('POSITION');
|
||||
const idx = p.getIndices();
|
||||
tris += idx ? idx.getCount() / 3 : pos.getCount() / 3;
|
||||
verts += pos.getCount();
|
||||
const v = [0, 0, 0];
|
||||
for (let i = 0; i < pos.getCount(); i++) {
|
||||
pos.getElement(i, v);
|
||||
allPos.push(v[0], v[1], v[2]);
|
||||
for (let d = 0; d < 3; d++) { bb.min[d] = Math.min(bb.min[d], v[d]); bb.max[d] = Math.max(bb.max[d], v[d]); }
|
||||
}
|
||||
}
|
||||
const size = bb.max.map((m, d) => m - bb.min[d]);
|
||||
const mats = root.listMaterials();
|
||||
const texs = root.listTextures();
|
||||
const skin = root.listSkins()[0];
|
||||
const joints = skin ? skin.listJoints() : [];
|
||||
const anims = root.listAnimations();
|
||||
|
||||
// axes: width = thinnest (fish are laterally compressed); length = longest of the other two.
|
||||
const widthAxis = size.indexOf(Math.min(...size));
|
||||
const other = [0, 1, 2].filter((d) => d !== widthAxis);
|
||||
const lenAxis = other[0] === undefined ? 0 : (size[other[0]] >= size[other[1]] ? other[0] : other[1]);
|
||||
// rear slice = last 22% along the length axis (either end — pick the end with fewer verts = the tail, not the body)
|
||||
const loEnd = bb.min[lenAxis] + 0.22 * size[lenAxis];
|
||||
const hiEnd = bb.max[lenAxis] - 0.22 * size[lenAxis];
|
||||
let loN = 0, hiN = 0;
|
||||
for (let i = 0; i < allPos.length; i += 3) { const L = allPos[i + lenAxis]; if (L <= loEnd) loN++; else if (L >= hiEnd) hiN++; }
|
||||
const tailIsLo = loN <= hiN;
|
||||
const wc = (bb.min[widthAxis] + bb.max[widthAxis]) / 2, wHalf = size[widthAxis] / 2 || 1;
|
||||
const bins = new Array(11).fill(0);
|
||||
let tailN = 0;
|
||||
for (let i = 0; i < allPos.length; i += 3) {
|
||||
const L = allPos[i + lenAxis];
|
||||
if (tailIsLo ? L > loEnd : L < hiEnd) continue;
|
||||
tailN++;
|
||||
const t = (allPos[i + widthAxis] - wc) / wHalf; // -1..1 across width
|
||||
bins[Math.max(0, Math.min(10, Math.round((t + 1) * 5)))]++;
|
||||
}
|
||||
const centreFrac = tailN ? (bins[4] + bins[5] + bins[6]) / tailN : 0; // fraction near centre-plane
|
||||
|
||||
const AX = ['X', 'Y', 'Z'];
|
||||
console.log(`\n== ${base} ==`);
|
||||
console.log(` meshes ${meshes.length} prims ${prims} tris ${Math.round(tris)} verts ${verts}`);
|
||||
console.log(` materials ${mats.length}: ${mats.map((m) => `${m.getName() || '—'}[${m.getAlphaMode()}${m.getDoubleSided() ? ',2SIDED' : ''}]`).join(' ')}`);
|
||||
console.log(` textures ${texs.length} skin joints ${joints.length}`);
|
||||
console.log(` anims ${anims.length}: ${anims.map((a) => a.getName()).join(', ') || '(none in GLB!)'}`);
|
||||
console.log(` bbox size ${AX.map((a, d) => `${a}=${size[d].toFixed(2)}`).join(' ')} (len axis ${AX[lenAxis]}, width axis ${AX[widthAxis]})`);
|
||||
console.log(` tail slice: ${tailN} verts, ${(centreFrac * 100).toFixed(0)}% near centre-plane → ${centreFrac > 0.5 ? 'SINGLE tail (centred)' : 'possible fork/side-lobes — eyeball it'}`);
|
||||
const tj = joints.filter((j) => /tail|caudal|fork|fin/i.test(j.getName())).map((j) => j.getName());
|
||||
if (tj.length) console.log(` fin/tail joints: ${tj.join(', ')}`);
|
||||
Generated
+600
-1
@@ -7,7 +7,17 @@
|
||||
"name": "glb-split",
|
||||
"dependencies": {
|
||||
"@gltf-transform/core": "^4.4.0",
|
||||
"@gltf-transform/extensions": "^4.4.0"
|
||||
"@gltf-transform/extensions": "^4.4.0",
|
||||
"@gltf-transform/functions": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@gltf-transform/core": {
|
||||
@@ -33,15 +43,604 @@
|
||||
"url": "https://github.com/sponsors/donmccurdy"
|
||||
}
|
||||
},
|
||||
"node_modules/@gltf-transform/functions": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@gltf-transform/functions/-/functions-4.4.0.tgz",
|
||||
"integrity": "sha512-CaSTAVAd2NXNWsxdgvq090rKHqy7AQlcNWV4ec7xtQyS8WEv3S3gVN27ikWmdB8nWEsXUbOIDhtPMLbXI6xDJg==",
|
||||
"dependencies": {
|
||||
"@gltf-transform/core": "^4.4.0",
|
||||
"@gltf-transform/extensions": "^4.4.0",
|
||||
"ktx-parse": "^1.1.0",
|
||||
"ndarray": "^1.0.19",
|
||||
"ndarray-lanczos": "^0.3.0",
|
||||
"ndarray-pixels": "^5.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/donmccurdy"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ndarray": {
|
||||
"version": "1.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/ndarray/-/ndarray-1.0.14.tgz",
|
||||
"integrity": "sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg=="
|
||||
},
|
||||
"node_modules/cwise-compiler": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz",
|
||||
"integrity": "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==",
|
||||
"dependencies": {
|
||||
"uniq": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/iota-array": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz",
|
||||
"integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="
|
||||
},
|
||||
"node_modules/is-buffer": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
|
||||
},
|
||||
"node_modules/ktx-parse": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz",
|
||||
"integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="
|
||||
},
|
||||
"node_modules/ndarray": {
|
||||
"version": "1.0.19",
|
||||
"resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz",
|
||||
"integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==",
|
||||
"dependencies": {
|
||||
"iota-array": "^1.0.0",
|
||||
"is-buffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ndarray-lanczos": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ndarray-lanczos/-/ndarray-lanczos-0.3.0.tgz",
|
||||
"integrity": "sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg==",
|
||||
"dependencies": {
|
||||
"@types/ndarray": "^1.0.11",
|
||||
"ndarray": "^1.0.19"
|
||||
}
|
||||
},
|
||||
"node_modules/ndarray-ops": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ndarray-ops/-/ndarray-ops-1.2.2.tgz",
|
||||
"integrity": "sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw==",
|
||||
"dependencies": {
|
||||
"cwise-compiler": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ndarray-pixels": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ndarray-pixels/-/ndarray-pixels-5.0.1.tgz",
|
||||
"integrity": "sha512-IBtrpefpqlI8SPDCGjXk4v5NV5z7r3JSuCbfuEEXaM0vrOJtNGgYUa4C3Lt5H+qWdYF4BCPVFsnXhNC7QvZwkw==",
|
||||
"dependencies": {
|
||||
"@types/ndarray": "^1.0.14",
|
||||
"ndarray": "^1.0.19",
|
||||
"ndarray-ops": "^1.2.2",
|
||||
"sharp": "^0.34.0"
|
||||
}
|
||||
},
|
||||
"node_modules/property-graph": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-graph/-/property-graph-4.1.0.tgz",
|
||||
"integrity": "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/uniq": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",
|
||||
"integrity": "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"name":"glb-split","private":true,"type":"module","dependencies":{"@gltf-transform/core":"^4.4.0","@gltf-transform/extensions":"^4.4.0"}}
|
||||
{"name":"glb-split","private":true,"type":"module","dependencies":{"@gltf-transform/core":"^4.4.0","@gltf-transform/extensions":"^4.4.0","@gltf-transform/functions":"^4.4.0"}}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Extract ONE clip [tStart,tEnd] (seconds) from a GLB's baked Take, rebased to start at 0,
|
||||
// and report the root joint's net translation over the clip (so we know if a swim cycle
|
||||
// drifts the fish through space vs. swims in place). Usage:
|
||||
// node trim-clip.mjs in.glb out.glb 3.667 6.167
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||
import { prune } from '@gltf-transform/functions';
|
||||
|
||||
const [, , inPath, outPath, s0, s1] = process.argv;
|
||||
const tStart = parseFloat(s0), tEnd = parseFloat(s1), EPS = 1e-4;
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const doc = await io.read(inPath);
|
||||
const root = doc.getRoot();
|
||||
|
||||
// Pass 1: per unique input, find the kept index window + rebase times to 0.
|
||||
const win = new Map(); // input -> {a,b}
|
||||
for (const anim of root.listAnimations())
|
||||
for (const sm of anim.listSamplers()) {
|
||||
const input = sm.getInput();
|
||||
if (win.has(input)) continue;
|
||||
const t = input.getArray();
|
||||
let a = 0; while (a < t.length - 1 && t[a] < tStart - EPS) a++;
|
||||
let b = t.length - 1; while (b > a && t[b] > tEnd + EPS) b--;
|
||||
win.set(input, { a, b });
|
||||
const base = t[a];
|
||||
input.setArray(t.slice(a, b + 1).map((x) => x - base));
|
||||
}
|
||||
// Pass 2: slice each output to its input's window.
|
||||
for (const anim of root.listAnimations())
|
||||
for (const sm of anim.listSamplers()) {
|
||||
const { a, b } = win.get(sm.getInput());
|
||||
const out = sm.getOutput();
|
||||
const c = out.getElementSize();
|
||||
out.setArray(out.getArray().slice(a * c, (b + 1) * c));
|
||||
}
|
||||
|
||||
// Report root-joint translation drift over the clip (first joint's translation track).
|
||||
const skin = root.listSkins()[0];
|
||||
const rootJoint = skin?.listJoints()[0];
|
||||
let drift = 'n/a';
|
||||
if (rootJoint) {
|
||||
for (const anim of root.listAnimations())
|
||||
for (const ch of anim.listChannels()) {
|
||||
if (ch.getTargetNode() === rootJoint && ch.getTargetPath() === 'translation') {
|
||||
const o = ch.getSampler().getOutput().getArray();
|
||||
const n = o.length / 3;
|
||||
const dx = o[(n - 1) * 3] - o[0], dy = o[(n - 1) * 3 + 1] - o[1], dz = o[(n - 1) * 3 + 2] - o[2];
|
||||
drift = `Δ[${dx.toFixed(4)}, ${dy.toFixed(4)}, ${dz.toFixed(4)}] (model units)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
await doc.transform(prune());
|
||||
await io.write(outPath, doc);
|
||||
const a = root.listAnimations()[0];
|
||||
let tmax = 0; for (const ch of a.listChannels()) { const arr = ch.getSampler().getInput().getArray(); tmax = Math.max(tmax, arr[arr.length - 1]); }
|
||||
console.log(`wrote ${outPath} | clip duration ${tmax.toFixed(2)}s | root drift ${drift}`);
|
||||
@@ -0,0 +1,39 @@
|
||||
// Trim a GLB's baked animation down to a [0, tEnd] window (seconds) — for the angelfish
|
||||
// pack that's the Idle clip (GLB frames 0–109 ≈ 3.633s), which drops ~95% of the animation
|
||||
// data (the full Take is 75.67s). Geometry/skin/textures are untouched. Shared input
|
||||
// accessors are sliced once; outputs per-sampler. Usage: node trim-idle.mjs in.glb out.glb 3.633
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||
import { prune } from '@gltf-transform/functions';
|
||||
|
||||
const [, , inPath, outPath, tEndStr] = process.argv;
|
||||
const tEnd = parseFloat(tEndStr);
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const doc = await io.read(inPath);
|
||||
const root = doc.getRoot();
|
||||
|
||||
// Pass 1: per UNIQUE input time-track, compute how many keyframes fall in [0, tEnd]
|
||||
// (BEFORE mutating — all channels share one input), then slice the input once.
|
||||
const keepByInput = new Map();
|
||||
for (const anim of root.listAnimations())
|
||||
for (const s of anim.listSamplers()) {
|
||||
const input = s.getInput();
|
||||
if (keepByInput.has(input)) continue;
|
||||
const times = input.getArray();
|
||||
let last = 0;
|
||||
for (let i = 0; i < times.length; i++) { if (times[i] <= tEnd + 1e-6) last = i; else break; }
|
||||
const keep = last + 1;
|
||||
keepByInput.set(input, keep);
|
||||
if (keep >= 2 && keep < times.length) input.setArray(times.slice(0, keep));
|
||||
}
|
||||
// Pass 2: slice every sampler's OUTPUT to its input's keep count.
|
||||
for (const anim of root.listAnimations())
|
||||
for (const s of anim.listSamplers()) {
|
||||
const keep = keepByInput.get(s.getInput());
|
||||
const output = s.getOutput();
|
||||
const comps = output.getElementSize();
|
||||
if (keep * comps < output.getArray().length) output.setArray(output.getArray().slice(0, keep * comps));
|
||||
}
|
||||
await doc.transform(prune()); // repack buffers + drop the orphaned (trimmed-away) data
|
||||
await io.write(outPath, doc);
|
||||
console.log('wrote', outPath);
|
||||
Reference in New Issue
Block a user