9 Commits

Author SHA1 Message Date
prosolis
8e0d6aff3e Adventure news: fix posting gates, digest counts, and section gating
- Don't post adventure beats when posting.enabled=false (PostNow ignores the flag)
- Keep the adventure channel out of the round-robin rotation so it can't steal bulletins from the digest
- no_push now retires a fact against the digest, not just the live post
- Default a missing occurred_at to now instead of the Unix epoch
- Keep protocol-relative image URLs on the guarded thumbnailer
- Make digest_hour=0 (midnight UTC) settable
- Quote the true window total in the digest, not the truncated slice
- Hide the Adventure section entirely when it's disabled
2026-07-11 08:07:40 -07:00
prosolis
9bf56cbb4e Adventure news: add zone_clear event type
gogobee now splits the realm's first-ever clear (zone_first, priority)
from a later repeat (zone_clear, bulletin) so a repeat is never filed as
a first-ever. Handle both:

- renderAdventure: zone_first keeps the "cleared for the very first
  time" headline; zone_clear gets the personal "{subject} clears {zone}"
  headline, sharing the lede. A tier fallback still handles a legacy
  zone_first that predates the split.
- advEventMeta: zone_clear -> "Zone cleared" (distinct from zone_first's
  "First clear"), so a repeat's permalink page and emblem aren't stamped
  "First clear".

The GUID contract is otherwise unchanged: prefix == event_type, so the
permalink label derives correctly. Test: TestRenderZoneTaxonomy.

Deploy this before the matching gogobee change — a zone_clear fact hits
an un-updated Pete as an unknown event_type (400).
2026-07-11 07:44:15 -07:00
prosolis
4c671fb410 Adventure section: ingest, permalink, digest, emblems, noindex
Pete's side of the Adventure news feed. Receives structured game-event
facts from gogobee, templates them in Pete's warm-reporter voice, and
publishes to a new /adventure section + live Matrix posts.

- adventure.go: bearer ingest + fact-guard + 13 event templates;
  /adventure/{guid} permalink (story.html); per-event SVG emblems at
  /adventure/art/{type}.svg (card image + og:image); NoPush suppresses
  the live Matrix post (cold-start backfill).
- adventure_digest.go: daily BULLETIN roundup at DigestHour (UTC);
  unposted-in-48h = bulletins; marks them digested; per-day ?digest= URL
  avoids canonical dedup.
- config AdventureConfig (enabled/ingest_token/channel/digest_hour);
  web.New takes the seam + a priority poster; started in main.
- adventure theme colors; thumbURL passes through local emblem paths;
  adventure pages are noindex (player-named; gap #5).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-11 00:53:59 -07:00
prosolis
0a723418ff Redo moon and clouds as procedural shaders in the GL engine
The baked atlas sprites (blurred-circle clouds, gradient moon with flat
maria blobs) read as cheap. Clouds are now built per-instance in the
particle fragment shader from fbm density with a flattened base and lit
tops; the moon renders in the sky shader with sphere shading, emboss
relief, maria and a halo. Stars skip the moon disc since the sky pass
draws first.
2026-07-08 00:19:55 -07:00
prosolis
9b20040b49 Rebuild weather effects on WebGL2 with new variants and a live demo page
The background weather is now GPU-rendered: one instanced-quad draw call
over a baked sprite atlas plus a fullscreen sky shader (fog, Saharan
haze, aurora, sun rays, storm gloom and lightning flash). The old
Canvas2D renderer stays as weather-2d.js and kicks in automatically
when WebGL2 is missing; weather.js is now a thin controller that owns
the toggle, prefs and the PeteWeather API.

Effect upgrades: shared wind with gust pulses leans the whole scene
together, rain gets depth, splash pops and velocity-aligned streaks,
storms grow procedural branched lightning bolts, snow mixes soft motes
with spinning six-arm crystals, clouds drift in two parallax layers,
clear nights get a moon with maria, twinkling stars and the occasional
shooting star, blossoms and leaves tumble with a faked 3D flip.

New variants: haze (Saharan calima), wind (autumn gusts with streak
lines), hail (bouncing stones with drizzle) and aurora. The /weather
demo page switches variant, intensity and phase in place without a
reload and shows the active renderer plus an FPS meter.
2026-07-07 23:53:45 -07:00
prosolis
e91b423b1a Focus reader scroll region on open so keys scroll the story
The reader overlay opened without moving focus, so Arrow-Up/Down and
PageUp/Down scrolled the page behind it until the user clicked into the
text. Make the scroll container focusable (tabindex=-1) and focus it on
open, and drop its focus outline.
2026-07-07 23:14:05 -07:00
prosolis
dbcb459908 Add server-side Piper read-aloud with voice picker
Reader read-aloud now streams neural WAV audio from a new POST /api/tts
endpoint that shells out to Piper, instead of the browser's Web Speech
voice. Each paragraph is synthesized on demand with the next one
prefetched during playback, keeping the existing highlight/scroll sync.

Voices are configured under [web.tts] (piper binary + voices_dir + a
labelled voice list) and exposed to the client as window.PETE_TTS; the
reader gets a Voice selector in the Aa menu, persisted per-device. Still
a signed-in-only perk and gated on auth.
2026-07-07 23:00:27 -07:00
prosolis
fceeb12ad5 Fix reader prefs popover ignoring the hidden attribute
The .pete-reader-typemenu class sets display:flex, which outranks the
UA [hidden]{display:none} rule, so toggling typeMenu.hidden never hid
the popover. Add an explicit [hidden] guard.
2026-07-07 22:48:06 -07:00
prosolis
74aa578a2d Precompute content_chars to drop per-render body scans
The N-min-read chip derived reading time via LENGTH(content) over the
full article-body TEXT column on every listing render. LENGTH can't use
an index, so SQLite read each row's whole body per request on the hottest
path. Cache the character count in a content_chars column filled at insert
time (backfilled for existing rows), and point StoryContentLengths at it.
2026-07-07 22:41:41 -07:00
27 changed files with 3193 additions and 552 deletions

View File

@@ -77,6 +77,25 @@ interval_minutes = 360
# item doesn't ping everyone.
min_stories = 3
# Server-side neural read-aloud (Piper, https://github.com/rhasspy/piper).
# When enabled, the reader's "Listen" button streams real Piper voices instead
# of the browser's robotic Web Speech voice. Signed-in only, so it needs
# [web.auth] on too. Install the piper binary and one or more voice models
# (<id>.onnx + <id>.onnx.json) into voices_dir first.
[web.tts]
enabled = false
piper_bin = "/opt/piper/piper" # path to the piper executable
voices_dir = "/opt/piper/voices" # dir holding <id>.onnx (+ .onnx.json) models
default = "en_US-amy-medium" # voice id selected until the reader picks another
# List the voices to offer, in menu order. Omit the whole [[web.tts.voices]]
# list to auto-discover every *.onnx in voices_dir (labelled by filename).
[[web.tts.voices]]
id = "en_US-amy-medium"
label = "Amy (US, female)"
[[web.tts.voices]]
id = "en_US-ryan-high"
label = "Ryan (US, male, HQ)"
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
# There is no automatic classification — Pete posts each story to its configured channel.
# Optional: language = "en" drops feed items whose per-item <language> tag is

View File

@@ -13,13 +13,44 @@ import (
var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)
type Config struct {
Matrix MatrixConfig `toml:"matrix"`
Posting PostingConfig `toml:"posting"`
Storage StorageConfig `toml:"storage"`
Web WebConfig `toml:"web"`
Sources []SourceConfig `toml:"sources"`
Matrix MatrixConfig `toml:"matrix"`
Posting PostingConfig `toml:"posting"`
Storage StorageConfig `toml:"storage"`
Web WebConfig `toml:"web"`
Adventure AdventureConfig `toml:"adventure"`
Sources []SourceConfig `toml:"sources"`
}
// AdventureConfig wires the gogobee adventure-news seam: gogobee POSTs
// game-event facts to Pete's ingest endpoint, Pete templates them into stories
// on the /adventure section and posts PRIORITY beats live to Matrix. Disabled by
// default — the endpoint 404s and no adventure channel appears until enabled.
type AdventureConfig struct {
Enabled bool `toml:"enabled"`
// IngestToken is the shared bearer secret gogobee presents on
// POST /api/ingest/adventure. Required when enabled. Use ${ENV_VAR}.
IngestToken string `toml:"ingest_token"`
// Channel is the Matrix channel name (a key in [matrix.channels]) that
// PRIORITY adventure beats post to live. If it isn't a configured Matrix
// channel, adventure runs website-only (stories still appear on /adventure).
Channel string `toml:"channel"`
// DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
// "unset" and doesn't get silently rewritten to the default.
DigestHour *int `toml:"digest_hour"`
}
// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
// unset case. Safe on a zero-value AdventureConfig.
func (a AdventureConfig) DigestHourOrDefault() int {
if a.DigestHour == nil {
return defaultDigestHour
}
return *a.DigestHour
}
const defaultDigestHour = 17
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
type WebConfig struct {
Enabled bool `toml:"enabled"`
@@ -28,6 +59,7 @@ type WebConfig struct {
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
// AdminSubs is the allowlist of OIDC subjects allowed to view the
// owner-facing source-health dashboard at /status. Empty means the page is
// inaccessible to everyone (returns 404). Requires auth to be enabled.
@@ -53,6 +85,29 @@ type PushConfig struct {
MinStories int `toml:"min_stories"`
}
// TTSConfig wires server-side neural read-aloud (Piper). When enabled,
// signed-in users get the reader's "Listen" button backed by real Piper voices
// instead of the browser's robotic Web Speech voice. Read-aloud is a signed-in
// perk, so this does nothing unless auth is also enabled.
type TTSConfig struct {
Enabled bool `toml:"enabled"`
PiperBin string `toml:"piper_bin"` // path to the piper executable
VoicesDir string `toml:"voices_dir"` // directory holding <id>.onnx (+ .onnx.json) models
// Voices lists the voices to offer, in menu order. Each id is a model
// filename stem, so id "en_US-ryan-high" maps to <voices_dir>/en_US-ryan-high.onnx.
// Leave empty to auto-discover every *.onnx in voices_dir.
Voices []VoiceConfig `toml:"voices"`
// Default is the voice id selected until the reader picks another. Empty
// falls back to the first available voice.
Default string `toml:"default"`
}
// VoiceConfig is one selectable Piper voice.
type VoiceConfig struct {
ID string `toml:"id"` // model filename stem, e.g. "en_US-ryan-high"
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
}
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
// When enabled, signed-in users get their preferences stored server-side keyed
// by the OIDC subject; anonymous visitors keep using browser localStorage.
@@ -204,6 +259,21 @@ func (c *Config) validate() error {
}
}
if c.Adventure.Enabled {
if c.Adventure.IngestToken == "" {
return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)")
}
if h := c.Adventure.DigestHourOrDefault(); h < 0 || h > 23 {
return fmt.Errorf("adventure.digest_hour must be 0-23")
}
if c.Adventure.Channel != "" {
if _, ok := c.Matrix.Channels[c.Adventure.Channel]; !ok {
slog.Warn("adventure.channel is not a configured Matrix channel — adventure runs website-only",
"channel", c.Adventure.Channel)
}
}
}
for i, s := range c.Sources {
if s.Name == "" {
return fmt.Errorf("sources[%d].name is required", i)

View File

@@ -85,6 +85,11 @@ func runMigrations(d *sql.DB) error {
// else the body scraped during paywall detection) for reader mode. Stories
// ingested before this column existed simply have NULL and fall back to lede.
addColumnIfMissing(d, "stories", "content", "TEXT")
// content_chars caches the character count of content so the "N min read"
// chip never has to LENGTH() the full body on the hot listing path. Filled at
// insert time; the backfill below populates rows that predate the column.
addColumnIfMissing(d, "stories", "content_chars", "INTEGER NOT NULL DEFAULT 0")
backfillContentChars(d)
addColumnIfMissing(d, "stories", "published_at", "INTEGER")
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
@@ -154,6 +159,20 @@ func exec(label, query string, args ...any) {
}
}
// backfillContentChars populates content_chars for rows carrying a body but a
// zero count — i.e. stories ingested before the column existed. LENGTH() counts
// characters (code points) for TEXT, matching the utf8.RuneCountInString done at
// insert. After the first run this matches no rows (bodied stories are set,
// bodyless ones stay 0 and are filtered by content IS NOT NULL), so it's a cheap
// startup no-op thereafter.
func backfillContentChars(d *sql.DB) {
if _, err := d.Exec(
`UPDATE stories SET content_chars = LENGTH(content)
WHERE content_chars = 0 AND content IS NOT NULL AND content <> ''`); err != nil {
slog.Error("backfill content_chars failed", "err", err)
}
}
func addColumnIfMissing(d *sql.DB, table, column, columnType string) {
q := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType)
if _, err := d.Exec(q); err != nil {

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"strings"
"time"
"unicode/utf8"
)
func nowUnix() int64 {
@@ -38,9 +39,9 @@ func InsertStory(s *Story) error {
publishedAt = s.PublishedAt
}
_, err := Get().Exec(
`INSERT INTO stories (guid, headline, lede, content, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt,
`INSERT INTO stories (guid, headline, lede, content, content_chars, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), utf8.RuneCountInString(s.Content), s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt,
)
return err
}
@@ -131,10 +132,10 @@ func MarkClassified(guid, channel, platforms string) {
// GetStoryByGUID returns the full story record for a GUID, or nil if not found.
func GetStoryByGUID(guid string) (*Story, error) {
row := Get().QueryRow(
`SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
`SELECT guid, headline, lede, COALESCE(content, ''), image_url, article_url, source, platforms, channel, seen_at
FROM stories WHERE guid = ?`, guid)
var s Story
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.Content, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
return nil, err
}
return &s, nil
@@ -251,6 +252,53 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
return &s, nil
}
// UnpostedAdventureSince returns adventure dispatches seen at or after `since`
// that have never been posted to Matrix (no post_log row). PRIORITY beats post
// live and so carry a post_log row; the ones left are exactly the BULLETINs the
// daily digest collects. Oldest first so the digest reads chronologically.
//
// At most `limit` rows come back, but total is how many match in all — the digest
// quotes it to readers, so it must count the window rather than the returned page.
func UnpostedAdventureSince(since int64, limit int) (stories []Story, total int, err error) {
const where = `WHERE classified = 1
AND channel = 'adventure'
AND seen_at >= ?
AND guid NOT IN (SELECT guid FROM post_log)`
if err := Get().QueryRow(`SELECT COUNT(*) FROM stories `+where, since).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := Get().Query(
`SELECT guid, headline, lede, article_url, seen_at
FROM stories `+where+`
ORDER BY seen_at ASC
LIMIT ?`, since, limit)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []Story
for rows.Next() {
var s Story
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ArticleURL, &s.SeenAt); err != nil {
return nil, 0, err
}
out = append(out, s)
}
return out, total, rows.Err()
}
// MarkAdventureDigested records each bulletin guid as posted (in a shared digest
// event) so the next daily digest doesn't re-collect it. Idempotent per guid via
// the post_log OR IGNORE. eventID is the digest's synthetic key (e.g.
// "adv-digest:2026-07-11") shared by every story that went out in that digest.
func MarkAdventureDigested(guids []string, eventID string) {
for _, g := range guids {
InsertPostLog(g, "adventure", eventID, "", false)
}
}
// ListClassifiedByChannel returns up to `limit` classified stories routed to a
// real channel, newest first, with optional offset for pagination. Sentinel
// channels (_discarded, _duplicate) are excluded.
@@ -414,8 +462,10 @@ func TrendingStories(limit int, sinceDay int64) ([]Story, error) {
// StoryContentLengths returns the captured article text length (in characters)
// for the given story ids, as a map keyed by id. Used to estimate a "N min read"
// chip without transferring the full body: only LENGTH(content) crosses the
// wire. Ids with no captured content are absent (treated as zero by callers).
// chip. It reads the precomputed content_chars column rather than LENGTH()-ing
// the full body, so the hot listing path never scans article text. Ids with no
// captured content have content_chars = 0 and are absent from the map (treated
// as zero by callers).
func StoryContentLengths(ids []int64) map[int64]int {
out := make(map[int64]int, len(ids))
if len(ids) == 0 {
@@ -423,8 +473,8 @@ func StoryContentLengths(ids []int64) map[int64]int {
}
ph, args := intInClause(ids)
rows, err := Get().Query(
`SELECT id, LENGTH(content) FROM stories
WHERE id IN (`+ph+`) AND content IS NOT NULL AND content <> ''`, args...)
`SELECT id, content_chars FROM stories
WHERE id IN (`+ph+`) AND content_chars > 0`, args...)
if err != nil {
slog.Error("story content lengths query failed", "err", err)
return out

View File

@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS stories (
headline TEXT NOT NULL,
lede TEXT,
content TEXT,
content_chars INTEGER NOT NULL DEFAULT 0,
image_url TEXT,
article_url TEXT NOT NULL,
url_canonical TEXT,

402
internal/web/adventure.go Normal file
View File

@@ -0,0 +1,402 @@
package web
import (
"crypto/subtle"
"encoding/json"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"pete/internal/storage"
)
// AdvFact is the game-event fact gogobee POSTs to Pete. It mirrors the contract
// in pete_adventure_news_voice.md. Names are character names only (never Matrix
// handles) and Actors is the allow-list of the only names permitted to appear in
// rendered output.
type AdvFact struct {
GUID string `json:"guid"`
EventType string `json:"event_type"`
Tier string `json:"tier"` // "priority" | "bulletin"
Actors []string `json:"actors"`
Subject string `json:"subject"`
Opponent string `json:"opponent"`
Boss string `json:"boss"`
Zone string `json:"zone"`
Region string `json:"region"`
Level int `json:"level"`
Count int `json:"count"`
Outcome string `json:"outcome"`
Stakes string `json:"stakes"`
ClassRace string `json:"class_race"`
Milestone string `json:"milestone"`
OccurredAt int64 `json:"occurred_at"`
NoPush bool `json:"no_push"`
}
// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
// web-local so the web package needs no dependency on internal/poster.
type AdvPost struct {
GUID string
Headline string
Lede string
ImageURL string
ArticleURL string
Source string
Channel string
}
// PriorityPoster posts a priority adventure item to Matrix immediately. main
// adapts *poster.Queue.PostNow to this; nil in web-only/local modes.
type PriorityPoster func(AdvPost)
const advSource = "Pete"
// advBackfillEvent is the synthetic post_log event id used to retire a no_push
// (cold-start backfill) dispatch against the daily digest. It never went to
// Matrix; the row exists only so the digest skips it.
const advBackfillEvent = "adv-backfill"
// handleAdventureIngest receives a game-event fact from gogobee, templates it
// into a deterministic story, publishes it to the /adventure section, and posts
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var f AdvFact
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&f); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if f.GUID == "" || f.EventType == "" {
http.Error(w, "guid and event_type are required", http.StatusBadRequest)
return
}
// Fact-guard: any player name we render must be in the actors allow-list.
// gogobee pre-sanitizes, but Pete never trusts the channel — this is the
// last line before a name reaches a public page.
if !factGuard(f) {
slog.Warn("adventure ingest: fact-guard rejected", "guid", f.GUID, "event_type", f.EventType)
http.Error(w, "fact-guard: subject/opponent not in actors", http.StatusBadRequest)
return
}
headline, lede, ok := renderAdventure(f)
if !ok {
http.Error(w, "unknown event_type", http.StatusBadRequest)
return
}
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
if storage.IsGUIDSeen(f.GUID) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("duplicate"))
return
}
// A fact with no occurred_at would otherwise be stored at the Unix epoch:
// dated 1970 on the permalink, pinned to the bottom of the section, and
// outside every digest window. Treat "missing" as "now".
occurredAt := f.OccurredAt
if occurredAt <= 0 {
occurredAt = time.Now().Unix()
}
articleURL := s.advPermalink(f.GUID)
imageURL := advArtURL(f.EventType)
if err := storage.InsertStory(&storage.Story{
GUID: f.GUID,
Headline: headline,
Lede: lede,
ImageURL: imageURL,
ArticleURL: articleURL,
Source: advSource,
Channel: "adventure",
Classified: true,
SeenAt: occurredAt,
PublishedAt: occurredAt,
}); err != nil {
slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
http.Error(w, "insert failed", http.StatusInternalServerError)
return
}
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
// the live post isn't enough: the digest collects adventure rows that carry no
// post_log entry, so a backfilled bulletin would still be swept into the next
// roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid
// against the digest up front instead.
if f.NoPush {
storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
return
}
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
// digest. Website section always gets the row above regardless of tier.
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
// media), and the link's og:image carries the preview instead.
s.advPost(AdvPost{
GUID: f.GUID,
Headline: headline,
Lede: lede,
ArticleURL: articleURL,
Source: advSource,
Channel: s.adv.Channel,
})
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
// advEventMeta maps an event_type to a short display label and emoji used by the
// permalink page (and, later, OG art selection). Unknown types fall back to a
// neutral dispatch label so a new gogobee event never renders blank.
func advEventMeta(eventType string) (label, emoji string) {
switch eventType {
case "siege_start", "siege_win", "siege_loss":
return "The Siege", "🏰"
case "boss_first", "boss_kill":
return "Boss down", "🐉"
case "zone_first":
return "First clear", "🗺️"
case "zone_clear":
return "Zone cleared", "🗺️"
case "death":
return "In memoriam", "🪦"
case "arrival":
return "New arrival", "👋"
case "standings", "rival_result":
return "The rival board", "⚔️"
case "pete_duel_loss", "pete_duel_win":
return "Pete's duels", "🤝"
case "milestone":
return "Milestone", "🏅"
}
return "Dispatch", "📣"
}
// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
// the external-image thumbnailer.
func advArtURL(eventType string) string {
return "/adventure/art/" + eventType + ".svg"
}
// handleAdventureArt renders the themed emblem for an event type — an adventure
// gradient with the event's emoji and label. Deterministic and dependency-free
// (no external asset), so every dispatch card has visual identity instead of the
// blank placeholder that made the section look broken next to RSS cards.
func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
eventType := strings.TrimSuffix(r.PathValue("type"), ".svg")
label, emoji := advEventMeta(eventType)
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label)))
}
// advArtSVG is the emblem template: %s = emoji, %s = label. 1200×630 (the OG
// card ratio) so the same image works as a link-preview image.
const advArtSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7c5ce8"/>
<stop offset="1" stop-color="#5836b8"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#g)"/>
<text x="600" y="300" font-size="260" text-anchor="middle" dominant-baseline="central">%s</text>
<text x="600" y="500" font-size="64" font-family="Fredoka, Nunito, system-ui, sans-serif" font-weight="700" fill="#ffffff" text-anchor="middle" letter-spacing="6" opacity="0.92">%s</text>
</svg>`
// advStoryPage is the per-story permalink view. It reuses the shared layout so a
// dispatch reads like the rest of the site, with an adventure-themed hero.
type advStoryPage struct {
pageData
EventLabel string
Emoji string
Headline string
Body string
Region string
When string
Permalink string
}
// handleAdventureStory serves the server-rendered permalink for one dispatch
// (the article_url every ingested story points at). Public and cacheable; 404s
// when the section is disabled or the guid is unknown. Character names in the
// stored headline/body already passed the ingest fact-guard, so nothing
// player-controlled reaches here unchecked.
func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
guid := r.PathValue("guid")
st, err := storage.GetStoryByGUID(guid)
if err != nil || st == nil || st.Channel != "adventure" {
http.NotFound(w, r)
return
}
s.track(r, "adventure")
// event_type is encoded in the guid prefix (e.g. "death:<hash>:<ts>"); fall
// back to the neutral dispatch meta when it isn't a known type.
eventType, _, _ := strings.Cut(guid, ":")
label, emoji := advEventMeta(eventType)
body := st.Content
if strings.TrimSpace(body) == "" {
body = st.Lede // template-only dispatches carry the write-up in the lede
}
base := s.base(r)
base.Active = "adventure"
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls
}
s.render(w, "story", advStoryPage{
pageData: base,
EventLabel: label,
Emoji: emoji,
Headline: st.Headline,
Body: body,
Region: "", // reserved: region isn't stored on the row yet
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
Permalink: s.advPermalink(guid),
})
}
// bearerOK checks the Authorization: Bearer header against the configured ingest
// token in constant time.
func (s *Server) bearerOK(r *http.Request) bool {
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, prefix) || s.adv.IngestToken == "" {
return false
}
got := strings.TrimPrefix(h, prefix)
return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1
}
// siteURL makes a root-relative path absolute against BaseURL. Links that go out
// to Matrix need the absolute form to survive safeHref; when BaseURL isn't
// configured the relative form is all we have, and it's still fine on-site.
func (s *Server) siteURL(path string) string {
return strings.TrimRight(s.cfg.BaseURL, "/") + path
}
// advPermalink builds the per-story Pete permalink used as article_url (the card
// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a
// stray "/" or "?" would otherwise produce a link that routes somewhere else.
func (s *Server) advPermalink(guid string) string {
return s.siteURL("/adventure/" + url.PathEscape(guid))
}
// factGuard verifies every player-name field we might render is present in the
// actors allow-list. Boss/zone/region/milestone are game-authored content, not
// player-controlled, so they are not guarded.
func factGuard(f AdvFact) bool {
allow := make(map[string]bool, len(f.Actors))
for _, a := range f.Actors {
if a != "" {
allow[a] = true
}
}
if f.Subject != "" && !allow[f.Subject] {
return false
}
if f.Opponent != "" && !allow[f.Opponent] {
return false
}
return true
}
// renderAdventure returns the deterministic headline + lede for a fact. Copied
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
atLevel := ""
if f.Level > 0 {
atLevel = fmt.Sprintf(", at level %d", f.Level)
}
switch f.EventType {
case "siege_start":
return fmt.Sprintf("Breaking: %s is marching on the town.", f.Boss),
fmt.Sprintf("Folks, this is the big one — %s has camped outside the gates and the whole community's needed to turn it back. You've got %s. Let's rally.", f.Boss, f.Stakes), true
case "siege_win":
return fmt.Sprintf("The town holds! %s turned back.", f.Boss),
fmt.Sprintf("What a turnout — %d defenders stood shoulder to shoulder and sent %s packing. Spoils are going out now. Proud of you all.", f.Count, f.Boss), true
case "siege_loss":
return fmt.Sprintf("Heavy news: %s broke through.", f.Boss),
fmt.Sprintf("We gave it everything, but %s got past the gates and took its tribute. We'll be ready next time — heads up, everyone.", f.Boss), true
case "boss_first":
return fmt.Sprintf("First ever: %s brings down %s.", f.Subject, f.Boss),
fmt.Sprintf("History in %s today — %s is the first anyone's seen clear %s. Nobody had done it before. Hats off.", f.Region, f.Subject, f.Boss), true
case "boss_kill":
return fmt.Sprintf("%s takes down %s again.", f.Subject, f.Boss),
fmt.Sprintf("Another clean run in %s today. Routine for %s by now — but still worth a nod.", f.Zone, f.Subject), true
case "zone_first", "zone_clear":
// gogobee splits the realm's first-ever clear (zone_first, priority) from a
// later repeat (zone_clear, bulletin); they share a lede but differ in
// headline. Fall back to the tier for a legacy zone_first that predates the
// split.
if f.EventType == "zone_first" || f.Tier == "priority" {
headline = fmt.Sprintf("%s cleared for the very first time.", f.Zone)
} else {
headline = fmt.Sprintf("%s clears %s.", f.Subject, f.Zone)
}
inRegion := ""
if f.Region != "" {
inRegion = " in " + f.Region
}
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
case "death":
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
case "arrival":
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
case "standings":
return "The rival board's been shaken up.",
fmt.Sprintf("%s is on the move — here's where the standings sit today.", f.Subject), true
case "rival_result":
return fmt.Sprintf("%s settles the score with %s.", f.Subject, f.Opponent),
fmt.Sprintf("Their duel went %s's way today, and the board reflects it. Good match, you two.", f.Subject), true
case "pete_duel_loss":
if f.Tier == "priority" {
headline = fmt.Sprintf("You got me, %s.", f.Subject)
} else {
headline = fmt.Sprintf("%s got the better of me again.", f.Subject)
}
return headline, fmt.Sprintf("Credit where it's due — %s beat me fair and square. Good duel. I'll want a rematch when you're ready.", f.Subject), true
case "pete_duel_win":
return fmt.Sprintf("Held my ground against %s today.", f.Subject),
fmt.Sprintf("Closer than the record will show, honestly — %s pushed me. Rematch whenever you like.", f.Subject), true
case "milestone":
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
}
return "", "", false
}

View File

@@ -0,0 +1,155 @@
package web
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"pete/internal/storage"
)
// The BULLETIN digest is the batched counterpart to the live PRIORITY beats.
// Once a day (Adventure.DigestHour, UTC) Pete collects the adventure dispatches
// that were seen since the last digest but never posted live — exactly the
// bulletins — and posts a single warm roundup to the adventure channel. The
// website already carries each one as its own card; the digest is the Matrix-only
// nudge so quiet-but-real activity surfaces without one ping per event.
const (
// digestWindow bounds how far back a digest looks. Wider than a day so a
// missed run (process down over a digest hour) still sweeps up the gap;
// re-collection is prevented by MarkAdventureDigested, not by the window.
digestWindow = 48 * time.Hour
// digestCap bounds a single digest. Far past the observed volume (a dormant
// community, per the voice spec); a runaway just truncates with a log line.
digestCap = 40
// digestPreview is how many headlines the roundup lede lists by name before
// collapsing the rest to "…and N more".
digestPreview = 4
)
// StartAdventureDigest launches the daily bulletin-digest loop. No-op unless the
// section is enabled AND there's a live Matrix poster AND a channel to post to —
// i.e. website-only and local modes never post a digest.
func (s *Server) StartAdventureDigest(ctx context.Context) {
if !s.adv.Enabled || s.advPost == nil || s.adv.Channel == "" {
return
}
go s.runAdventureDigest(ctx)
}
// runAdventureDigest sleeps until the next DigestHour, posts, then repeats every
// 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the
// digest at a predictable time of day across restarts.
func (s *Server) runAdventureDigest(ctx context.Context) {
hour := s.adv.DigestHourOrDefault()
slog.Info("web: adventure digest scheduler started", "digest_hour_utc", hour, "channel", s.adv.Channel)
for {
wait := durUntilNextHour(time.Now().UTC(), hour)
select {
case <-ctx.Done():
return
case <-time.After(wait):
s.postDailyDigest(time.Now().UTC())
}
}
}
// durUntilNextHour returns the duration from now to the next occurrence of the
// given UTC hour. If it's exactly the hour now, it targets tomorrow so a restart
// at the hour doesn't double-fire.
func durUntilNextHour(now time.Time, hour int) time.Duration {
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC)
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
return next.Sub(now)
}
// postDailyDigest collects the window's un-posted bulletins, posts one roundup,
// and marks them digested so they don't recur. Silent when there's nothing new —
// a dormant realm should stay quiet, not ship an empty digest.
func (s *Server) postDailyDigest(now time.Time) {
since := now.Add(-digestWindow).Unix()
bulletins, total, err := storage.UnpostedAdventureSince(since, digestCap)
if err != nil {
slog.Error("adventure digest: query failed", "err", err)
return
}
if len(bulletins) == 0 {
return
}
if total > len(bulletins) {
slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap, "total", total)
}
date := now.Format("2006-01-02")
eventID := "adv-digest:" + date
headline, lede := buildDigest(bulletins, total)
s.advPost(AdvPost{
GUID: eventID,
Headline: headline,
Lede: lede,
ArticleURL: s.digestURL(date),
Source: advSource,
Channel: s.adv.Channel,
})
guids := make([]string, len(bulletins))
for i, b := range bulletins {
guids[i] = b.GUID
}
storage.MarkAdventureDigested(guids, eventID)
slog.Info("adventure digest: posted", "date", date, "items", len(bulletins))
}
// buildDigest renders the roundup headline + lede in Pete's warm reporter voice.
// total is how many bulletins the window actually holds, which is larger than
// len(bulletins) when the cap truncated the fetch — the counts quoted to readers
// have to describe the realm, not the slice. Headlines come from stored,
// fact-guarded rows, so no player-controlled text is introduced here.
func buildDigest(bulletins []storage.Story, total int) (headline, lede string) {
if total == 1 {
return "Today in the realm: one dispatch.",
fmt.Sprintf("Quiet day out there, but one worth noting — %s Full story on the board.", trimHeadline(bulletins[0].Headline))
}
headline = fmt.Sprintf("Today in the realm: %d dispatches.", total)
shown := bulletins
if len(shown) > digestPreview {
shown = shown[:digestPreview]
}
parts := make([]string, len(shown))
for i, b := range shown {
parts[i] = trimHeadline(b.Headline)
}
list := strings.Join(parts, " ")
more := ""
if total > len(shown) {
more = fmt.Sprintf(" …and %d more.", total-len(shown))
}
return headline, fmt.Sprintf("Here's what came across the wire today. %s%s The full board's on the site.", list, more)
}
// trimHeadline ensures a headline ends with sentence punctuation so the joined
// lede reads as prose rather than a run-on.
func trimHeadline(h string) string {
h = strings.TrimSpace(h)
if h == "" {
return ""
}
if last := h[len(h)-1]; last != '.' && last != '!' && last != '?' {
h += "."
}
return h
}
// digestURL points the roundup at the section, with a per-day query param so the
// canonical-URL dedup treats each day's digest as distinct (it strips only
// tracking params, keeping ?digest=).
func (s *Server) digestURL(date string) string {
return s.siteURL("/adventure?digest=" + date)
}

View File

@@ -0,0 +1,313 @@
package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
// newAdvServer builds a web server with the adventure seam enabled and a
// capturing priority poster, backed by a fresh temp DB.
func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
t.Helper()
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
var posted []AdvPost
adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
// Mirror the production poster's side effect: queue.PostNow records a
// post_log row, which is exactly what marks a beat "posted" and thus
// excludes it from the bulletin digest.
poster := func(p AdvPost) {
posted = append(posted, p)
storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
nil, true, adv, poster)
if err != nil {
t.Fatal(err)
}
return s, &posted
}
func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(f)
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rw := httptest.NewRecorder()
s.handleAdventureIngest(rw, req)
return rw
}
// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
// bearer-accepted, templated, stored as an adventure story, and posted live.
func TestAdventureIngestEndToEnd(t *testing.T) {
const token = "s3cret-token"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
}
got, err := storage.GetStoryByGUID("death:abc:1000")
if err != nil || got == nil {
t.Fatalf("story not stored: %v", err)
}
if got.Channel != "adventure" || got.Source != advSource {
t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
}
if got.Headline != "We lost Brannigan in the Underforge." {
t.Errorf("headline = %q", got.Headline)
}
if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
t.Errorf("article_url = %q", got.ArticleURL)
}
if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
t.Fatalf("priority post not delivered: %+v", *posted)
}
// Idempotent re-delivery: no error, no second post.
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("dup ingest status = %d", rw.Code)
}
if len(*posted) != 1 {
t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
}
}
// TestAdventurePermalink renders the per-story page the article_url points at:
// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
// and body, and an unknown guid must 404.
func TestAdventurePermalink(t *testing.T) {
const token = "t"
s, _ := newAdvServer(t, token)
f := AdvFact{
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d", rw.Code)
}
req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
req.SetPathValue("guid", "death:abc:1000")
rw := httptest.NewRecorder()
s.handleAdventureStory(rw, req)
if rw.Code != 200 {
t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
}
body := rw.Body.String()
if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
t.Errorf("permalink missing headline; body=%s", body)
}
if !bytes.Contains([]byte(body), []byte("In memoriam")) {
t.Errorf("permalink missing event label; body=%s", body)
}
// Unknown guid 404s.
req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
req2.SetPathValue("guid", "nope:1")
rw2 := httptest.NewRecorder()
s.handleAdventureStory(rw2, req2)
if rw2.Code != 404 {
t.Errorf("unknown guid status = %d, want 404", rw2.Code)
}
}
// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
func TestDurUntilNextHour(t *testing.T) {
// 14:30 UTC, targeting 17:00 → 2h30m today.
now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
t.Errorf("before hour: got %v", got)
}
// 17:00 exactly → tomorrow's 17:00 (24h), not zero.
now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 24*time.Hour {
t.Errorf("at hour: got %v", got)
}
// 20:00, targeting 17:00 → tomorrow, 21h.
now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 21*time.Hour {
t.Errorf("after hour: got %v", got)
}
}
// TestAdventureDigest covers the batched path: bulletins (no live post) are
// collected into one roundup, priority beats are excluded (they already posted),
// digested bulletins don't recur, and an empty window stays silent.
func TestAdventureDigest(t *testing.T) {
const token = "t"
s, posted := newAdvServer(t, token)
now := time.Now()
// Two bulletins + one priority (which posts live and must be excluded).
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
if len(*posted) != 1 {
t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
}
s.postDailyDigest(now.UTC())
if len(*posted) != 2 {
t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
}
dg := (*posted)[1]
if !strings.Contains(dg.Headline, "2 dispatches") {
t.Errorf("digest headline = %q", dg.Headline)
}
if !strings.HasPrefix(dg.GUID, "adv-digest:") {
t.Errorf("digest guid = %q", dg.GUID)
}
// Re-running finds nothing new (bulletins marked digested).
s.postDailyDigest(now.UTC())
if len(*posted) != 2 {
t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
}
}
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
// returns a themed SVG, ingested cards carry its local path, and the permalink
// page is noindex with an og:image.
// TestRenderZoneTaxonomy: a realm-first (zone_first) reads as first-ever; a
// repeat (zone_clear) reads as a personal clear, not a mis-labeled "first".
func TestRenderZoneTaxonomy(t *testing.T) {
first := AdvFact{EventType: "zone_first", Tier: "priority", Subject: "Brannigan",
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
hl, _, ok := renderAdventure(first)
if !ok || !strings.Contains(hl, "very first time") {
t.Errorf("zone_first headline = %q (ok=%v)", hl, ok)
}
repeat := AdvFact{EventType: "zone_clear", Tier: "bulletin", Subject: "Brannigan",
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
hl2, _, ok := renderAdventure(repeat)
if !ok || !strings.Contains(hl2, "Brannigan clears") || strings.Contains(hl2, "first") {
t.Errorf("zone_clear headline = %q (ok=%v)", hl2, ok)
}
// The permalink label distinguishes the two, so a repeat's page isn't stamped
// "First clear".
if lbl, _ := advEventMeta("zone_clear"); lbl == "First clear" {
t.Errorf("zone_clear meta label = %q, want distinct from first clear", lbl)
}
}
func TestAdventureArtAndMeta(t *testing.T) {
const token = "t"
s, _ := newAdvServer(t, token)
// Emblem endpoint returns SVG with the event's emoji.
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
areq.SetPathValue("type", "death.svg")
arw := httptest.NewRecorder()
s.handleAdventureArt(arw, areq)
if arw.Code != 200 {
t.Fatalf("art status = %d", arw.Code)
}
if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
t.Errorf("art content-type = %q", ct)
}
if b := arw.Body.String(); !strings.Contains(b, "🪦") || !strings.Contains(b, "<svg") {
t.Errorf("art body missing emblem: %s", b)
}
// Ingest sets the card image to the local emblem path.
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d", rw.Code)
}
got, _ := storage.GetStoryByGUID("death:abc:1000")
if got == nil || got.ImageURL != "/adventure/art/death.svg" {
t.Errorf("story image = %q", got.ImageURL)
}
// Permalink page is noindex with an og:image.
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
preq.SetPathValue("guid", "death:abc:1000")
prw := httptest.NewRecorder()
s.handleAdventureStory(prw, preq)
body := prw.Body.String()
if !strings.Contains(body, `name="robots" content="noindex"`) {
t.Error("permalink not noindex")
}
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
t.Errorf("permalink missing og:image; body=%s", body)
}
}
// TestAdventureIngestBearer rejects missing/wrong tokens.
func TestAdventureIngestBearer(t *testing.T) {
s, _ := newAdvServer(t, "right")
f := AdvFact{GUID: "arrival:x:1", EventType: "arrival", Tier: "bulletin", OccurredAt: 1}
if rw := postFact(t, s, "", f); rw.Code != 401 {
t.Errorf("no token: status = %d, want 401", rw.Code)
}
if rw := postFact(t, s, "wrong", f); rw.Code != 401 {
t.Errorf("wrong token: status = %d, want 401", rw.Code)
}
}
// TestAdventureFactGuard rejects a subject not present in the actors allow-list,
// so a name that slipped the source can't reach a public page.
func TestAdventureFactGuard(t *testing.T) {
const token = "t"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "death:evil:1", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Kif", // Kif not in actors
Zone: "the Underforge", Level: 3, OccurredAt: 1,
}
if rw := postFact(t, s, token, f); rw.Code != 400 {
t.Errorf("fact-guard: status = %d, want 400", rw.Code)
}
if storage.IsGUIDSeen("death:evil:1") {
t.Error("guarded fact was stored")
}
if len(*posted) != 0 {
t.Error("guarded fact was posted")
}
}
// TestAdventureDisabled 404s when the seam is off.
func TestAdventureDisabled(t *testing.T) {
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "off.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}
f := AdvFact{GUID: "x", EventType: "arrival", OccurredAt: 1}
if rw := postFact(t, s, "anything", f); rw.Code != 404 {
t.Errorf("disabled: status = %d, want 404", rw.Code)
}
}

View File

@@ -53,7 +53,7 @@ func TestFeeds(t *testing.T) {
}
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

View File

@@ -104,6 +104,9 @@ type pageData struct {
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
PushPublicKey string // VAPID public key handed to the client to subscribe
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
NoIndex bool // emit <meta name="robots" content="noindex"> — used by the adventure section
OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none
}
type channelPage struct {
@@ -157,7 +160,7 @@ func (s *Server) base(r *http.Request) pageData {
}
d := pageData{
SiteTitle: s.cfg.SiteTitle,
Channels: channels,
Channels: s.channels,
Weather: currentWeather(time.Now()),
AllSources: jsForScript(srcJSON),
AuthEnabled: s.auth != nil,
@@ -166,6 +169,10 @@ func (s *Server) base(r *http.Request) pageData {
PostingEnabled: s.postingEnabled,
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
TTS: template.JS("null"),
}
if s.tts != nil {
d.TTS = s.tts.clientConfig()
}
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
@@ -210,8 +217,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
dayStart := time.Now().Add(-24 * time.Hour).Unix()
stats := make([]channelStat, 0, len(channels))
for _, ch := range channels {
stats := make([]channelStat, 0, len(s.channels))
for _, ch := range s.channels {
total, _ := storage.CountClassifiedByChannel(ch.Slug)
lastTs := storage.GetLastPostTime(ch.Slug)
stat := channelStat{
@@ -322,6 +329,9 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
base := s.base(r)
base.Active = ch.Slug
// The adventure section names player characters; keep it out of search
// indexes to bound the cached-forever exposure (see plan gap #5).
base.NoIndex = ch.Slug == "adventure"
data := channelPage{
pageData: base,
Channel: ch,
@@ -396,7 +406,7 @@ func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
}
var (
demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"}
demoVariants = []string{"clear", "clouds", "rain", "storm", "hail", "snow", "fog", "haze", "wind", "aurora", "petals", "jacaranda", "motes", "leaves"}
demoIntensities = []string{"light", "medium", "heavy"}
demoPhases = []string{"day", "dawn", "dusk", "night"}
)
@@ -426,10 +436,12 @@ func seasonForVariant(v string) string {
return "winter"
case "petals", "jacaranda":
return "spring"
case "motes":
case "motes", "haze":
return "summer"
case "leaves":
case "leaves", "wind":
return "autumn"
case "hail":
return "winter"
}
return ""
}

View File

@@ -42,7 +42,7 @@ func TestReaderCardDataAndArticleAPI(t *testing.T) {
t.Fatal(err)
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

View File

@@ -31,6 +31,10 @@ type Channel struct {
}
// channels in display order. Add a new entry here to add a section.
//
// This is the full catalogue, used to resolve a story's channel slug to its
// display metadata. It is NOT the list of live sections: an entry can be gated
// off (adventure), so route registration and the nav read s.channels instead.
var channels = []Channel{
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
@@ -42,6 +46,7 @@ var channels = []Channel{
{Slug: "kids", Title: "Kids", Theme: "kids", Emoji: "🧒", Blurb: "World news written for younger readers — short, clear, and curious."},
{Slug: "finance", Title: "Finance", Theme: "finance", Emoji: "💰", Blurb: "Markets, money, and the business of the world. Read-only — these stories don't post to Matrix."},
{Slug: "lego", Title: "LEGO", Theme: "lego", Emoji: "🧱", Blurb: "Sets, releases, and the wider brick-building world."},
{Slug: "adventure", Title: "Adventure", Theme: "adventure", Emoji: "⚔️", Blurb: "Dispatches from the realm — deaths, first-clears, arrivals, and rivalries. Reporting from the field, this is Pete."},
}
// SourceInfo is the trimmed view of a configured feed for the settings panel.
@@ -58,8 +63,12 @@ type Server struct {
srv *http.Server
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
auth *Authenticator // nil when sign-in is disabled or unavailable
tts *ttsService // nil when server-side read-aloud is disabled
adminSubs map[string]bool // OIDC subjects allowed to view /status
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
adv config.AdventureConfig // gogobee adventure-news seam
advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only
channels []Channel // live sections: the catalogue minus anything gated off (adventure)
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
// Guarded by metricsMu; never persisted (see metrics.go).
@@ -71,8 +80,8 @@ type Server struct {
// New builds the server. Templates are parsed once at startup. Each page
// gets its own template set sharing layout.html + _card.html, which avoids
// `main` define collisions between pages.
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool) (*Server, error) {
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status"}
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}
tpls := make(map[string]*template.Template, len(pages))
for _, p := range pages {
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
@@ -98,7 +107,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
adminSubs[sub] = true
}
}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs}
// The adventure section only exists when it's enabled: with the seam off,
// there's no nav tab, no /adventure page and no /adventure feed, rather than
// an empty section nobody can fill.
live := make([]Channel, 0, len(channels))
for _, ch := range channels {
if ch.Slug == "adventure" && !adv.Enabled {
continue
}
live = append(live, ch)
}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live}
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
// provider is unreachable at boot we log and serve anonymously rather than
@@ -115,6 +135,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
}
}
// Optional server-side neural read-aloud (Piper). Signed-in only, so it is
// only useful alongside auth; newTTS logs and disables itself on any misconfig.
if s.auth != nil {
tts, err := newTTS(cfg.TTS)
if err != nil {
return nil, err
}
s.tts = tts
} else if cfg.TTS.Enabled {
slog.Warn("web: TTS enabled but auth is off; read-aloud is signed-in only, so it stays disabled")
}
mux := http.NewServeMux()
staticSub, err := fs.Sub(staticFS, "static")
@@ -134,7 +166,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("GET /api/related", s.handleRelated)
mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
mux.HandleFunc("GET /feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, "") })
for _, ch := range channels {
for _, ch := range s.channels {
ch := ch
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
s.handleChannel(w, r, ch)
@@ -152,6 +184,19 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
_, _ = w.Write([]byte("ok"))
})
// gogobee adventure-news ingest. Bearer-authed (not OIDC), so it lives
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
// Per-dispatch permalink (the article_url every ingested story points at).
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
// channel listing, registered in the channels loop above).
mux.HandleFunc("GET /adventure/{guid}", s.handleAdventureStory)
// Themed per-event emblem (card + og:image). A distinct path depth from
// /adventure/{guid}, so the two patterns never overlap.
mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)
if s.auth != nil {
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
@@ -167,6 +212,9 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
}
if s.tts != nil {
mux.HandleFunc("POST /api/tts", s.handleTTS)
}
}
s.srv = &http.Server{

View File

@@ -79,6 +79,7 @@ html[data-phase="night"] {
.bg-theme-kids { background-color: #14b8a6; }
.bg-theme-finance { background-color: #10b981; }
.bg-theme-lego { background-color: #d01012; }
.bg-theme-adventure { background-color: #6d4bd8; }
.text-theme-gaming { color: #2d8a5a; }
.text-theme-tech { color: #2f7fb8; }
@@ -90,6 +91,7 @@ html[data-phase="night"] {
.text-theme-kids { color: #0f766e; }
.text-theme-finance { color: #059669; }
.text-theme-lego { color: #b00d0e; }
.text-theme-adventure { color: #5836b8; }
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
@@ -101,6 +103,7 @@ html[data-phase="night"] {
.decoration-theme-kids { text-decoration-color: #14b8a6; }
.decoration-theme-finance { text-decoration-color: #10b981; }
.decoration-theme-lego { text-decoration-color: #d01012; }
.decoration-theme-adventure { text-decoration-color: #6d4bd8; }
.border-theme-gaming { border-color: #4caf7d; }
.border-theme-tech { border-color: #5aa9e6; }
@@ -112,6 +115,7 @@ html[data-phase="night"] {
.border-theme-kids { border-color: #14b8a6; }
.border-theme-finance { border-color: #10b981; }
.border-theme-lego { border-color: #d01012; }
.border-theme-adventure { border-color: #6d4bd8; }
/* "Posted to Matrix" glow — soft pulsing aura in the channel's theme color. */
.glow-theme-gaming { box-shadow: 0 0 0 2px rgba(76,175,125,0.35), 0 0 24px 4px rgba(76,175,125,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
@@ -124,6 +128,7 @@ html[data-phase="night"] {
.glow-theme-kids { box-shadow: 0 0 0 2px rgba(20,184,166,0.35), 0 0 24px 4px rgba(20,184,166,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-finance { box-shadow: 0 0 0 2px rgba(16,185,129,0.35), 0 0 24px 4px rgba(16,185,129,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-lego { box-shadow: 0 0 0 2px rgba(208,16,18,0.35), 0 0 24px 4px rgba(208,16,18,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-adventure { box-shadow: 0 0 0 2px rgba(109,75,216,0.35), 0 0 24px 4px rgba(109,75,216,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
@keyframes pete-glow-pulse {
0%, 100% { filter: brightness(1); }
@@ -242,6 +247,7 @@ html[data-phase="night"] {
/* Text-options popover, anchored under the Aa button in the reader bar. */
.pete-reader-type { position: relative; display: inline-flex; }
.pete-reader-typemenu[hidden] { display: none; }
.pete-reader-typemenu {
position: absolute;
top: calc(100% + 0.5rem);
@@ -282,6 +288,23 @@ html[data-phase="night"] {
color: #1c1305;
border-color: transparent;
}
.pete-reader-voicerow[hidden] { display: none; }
.pete-reader-voice {
font: inherit;
font-size: 0.8rem;
font-weight: 600;
padding: 0.25rem 0.4rem;
border-radius: 0.55rem;
border: 1px solid rgba(20, 14, 6, 0.18);
background: rgba(20, 14, 6, 0.06);
color: inherit;
max-width: 11rem;
cursor: pointer;
}
html[data-phase="night"] .pete-reader-voice {
border-color: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
}
.pete-reader-scroll {
flex: 1 1 auto;
@@ -289,6 +312,7 @@ html[data-phase="night"] {
-webkit-overflow-scrolling: touch;
border-radius: 1.75rem;
}
.pete-reader-scroll:focus { outline: none; }
.pete-reader-article {
background: var(--card);

File diff suppressed because one or more lines are too long

View File

@@ -25,6 +25,8 @@
var shareBtn = overlay.querySelector("[data-reader-share]");
var typeBtn = overlay.querySelector("[data-reader-type]");
var typeMenu = overlay.querySelector("[data-reader-typemenu]");
var voiceRow = overlay.querySelector("[data-reader-voicerow]");
var voiceSel = overlay.querySelector("[data-reader-voice]");
var relatedEl = overlay.querySelector("[data-reader-related]");
var relatedCache = {}; // id -> results array
@@ -34,10 +36,12 @@
var SIGNED_IN = !!(window.PETE_USER);
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
// Read-aloud is a signed-in-only perk and needs Web Speech support. Share is
// available to everyone (native sheet where present, clipboard copy otherwise).
var TTS_OK = SIGNED_IN && typeof window.speechSynthesis !== "undefined" &&
typeof window.SpeechSynthesisUtterance !== "undefined";
// Read-aloud is a signed-in-only perk, backed by server-side Piper voices
// (window.PETE_TTS) rather than the browser's robotic Web Speech voice. Share
// is available to everyone (native sheet where present, clipboard otherwise).
var TTS_CFG = (window.PETE_TTS && window.PETE_TTS.enabled &&
window.PETE_TTS.voices && window.PETE_TTS.voices.length) ? window.PETE_TTS : null;
var TTS_OK = SIGNED_IN && !!TTS_CFG;
var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
var index = 0;
@@ -484,12 +488,34 @@
window.open(url, "_blank", "noopener");
}
// ---- read aloud (signed-in only) ------------------------------------------
// ---- read aloud (signed-in only, server-side Piper) -----------------------
// Each paragraph is synthesized on demand by POSTing its text to /api/tts and
// playing the returned WAV. We fetch the next paragraph while the current one
// plays, so the model-load + synthesis latency is hidden behind playback.
// speakGen is bumped on every stop/start/voice-change; any async callback that
// fires afterwards checks it against its captured gen and no-ops if stale.
var VOICE_KEY = "pete.reader.voice.v1";
var speaking = false;
var speakGen = 0; // bumped on every stop/start; stale utterance callbacks that
// fire after speechSynthesis.cancel() check it and no-op
var speakParas = []; // <p> elements being read, for highlight
var speakQueue = []; // remaining {el, text} to utter
var speakGen = 0;
var speakParas = []; // <p> elements currently highlighted
var speakItems = []; // [{el, text}] for the whole article
var speakIdx = 0; // index into speakItems currently playing
var speakReq = null; // in-flight fetch for the current paragraph (has .ctrl)
var prefetch = null; // { idx, gen, promise, abort } for the next paragraph
var audioEl = null; // shared <audio> element
var currentURL = null; // object URL currently loaded into audioEl
// Device-local voice choice, like the type prefs (see loadType).
var ttsVoice = "";
(function loadVoice() {
if (!TTS_CFG) return;
var saved = "";
try { saved = localStorage.getItem(VOICE_KEY) || ""; } catch (e) {}
var ok = TTS_CFG.voices.some(function (v) { return v.id === saved; });
ttsVoice = ok ? saved : TTS_CFG.default;
})();
function saveVoice() { try { localStorage.setItem(VOICE_KEY, ttsVoice); } catch (e) {} }
function speakingText() {
if (!articleEl) return [];
var out = [];
@@ -505,30 +531,95 @@
speakParas.forEach(function (p) { p.classList.remove("is-speaking"); });
speakParas = [];
}
function stopSpeak() {
if (!TTS_OK) return;
speakGen++;
try { window.speechSynthesis.cancel(); } catch (e) {}
speaking = false;
speakQueue = [];
function highlight(item) {
clearSpeakHighlight();
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
}
function speakNext() {
if (!speaking) return;
var item = speakQueue.shift();
if (!item) { stopSpeak(); return; }
clearSpeakHighlight();
if (item.el) {
if (item && item.el) {
item.el.classList.add("is-speaking");
speakParas.push(item.el);
try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
}
}
// ttsFetch kicks off synthesis of one paragraph. Returns { ctrl, promise },
// where promise resolves to an object URL for the audio blob.
function ttsFetch(text) {
var ctrl = new AbortController();
var promise = fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ voice: ttsVoice, text: text }),
signal: ctrl.signal,
}).then(function (r) {
if (!r.ok) throw new Error("tts " + r.status);
return r.blob();
}).then(function (b) { return URL.createObjectURL(b); });
return { ctrl: ctrl, promise: promise };
}
function abortPrefetch() {
if (!prefetch) return;
try { prefetch.ctrl.abort(); } catch (e) {}
// If it already resolved to a URL, reclaim it.
prefetch.promise.then(function (u) { URL.revokeObjectURL(u); }, function () {});
prefetch = null;
}
function startPrefetch(i, gen) {
var item = speakItems[i];
if (!item) { prefetch = null; return; }
var req = ttsFetch(item.text);
prefetch = { idx: i, gen: gen, ctrl: req.ctrl, promise: req.promise };
}
function playURL(url, gen) {
if (!audioEl) audioEl = new Audio();
if (currentURL) URL.revokeObjectURL(currentURL);
currentURL = url;
audioEl.src = url;
audioEl.onended = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
audioEl.onerror = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
var pr = audioEl.play();
if (pr && pr.catch) pr.catch(function () {}); // ignore autoplay/abort rejections
}
function playIdx(i) {
if (!speaking) return;
var gen = speakGen;
var u = new SpeechSynthesisUtterance(item.text);
u.onend = function () { if (speaking && gen === speakGen) speakNext(); };
u.onerror = function () { if (speaking && gen === speakGen) speakNext(); };
try { window.speechSynthesis.speak(u); } catch (e) { stopSpeak(); }
var item = speakItems[i];
if (!item) { stopSpeak(); return; }
speakIdx = i;
highlight(item);
var urlP;
if (prefetch && prefetch.idx === i && prefetch.gen === gen) {
urlP = prefetch.promise;
prefetch = null; // hand off; do not abort/revoke this one
} else {
abortPrefetch();
var req = ttsFetch(item.text);
speakReq = req;
urlP = req.promise;
}
urlP.then(function (url) {
if (!speaking || gen !== speakGen) { URL.revokeObjectURL(url); return; }
speakReq = null;
startPrefetch(i + 1, gen); // synthesize the next paragraph during playback
playURL(url, gen);
}).catch(function () {
if (!speaking || gen !== speakGen) return;
speakReq = null;
playIdx(i + 1); // skip a paragraph that failed rather than stalling
});
}
function teardownAudio() {
if (speakReq) { try { speakReq.ctrl.abort(); } catch (e) {} speakReq = null; }
abortPrefetch();
if (audioEl) { try { audioEl.pause(); } catch (e) {} audioEl.onended = null; audioEl.onerror = null; audioEl.removeAttribute("src"); }
if (currentURL) { URL.revokeObjectURL(currentURL); currentURL = null; }
}
function stopSpeak() {
if (!TTS_OK) return;
speakGen++;
speaking = false;
teardownAudio();
speakItems = [];
clearSpeakHighlight();
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
}
function startSpeak() {
if (!TTS_OK) return;
@@ -536,16 +627,43 @@
var parts = speakingText();
if (!parts.length) { toast("Nothing to read yet"); return; }
speakGen++;
try { window.speechSynthesis.cancel(); } catch (e) {}
teardownAudio();
speaking = true;
speakQueue = parts.slice();
speakItems = parts;
speakIdx = 0;
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "⏹ Stop"; }
speakNext();
playIdx(0);
}
function toggleSpeak() {
if (!TTS_OK) return;
if (speaking) stopSpeak(); else startSpeak();
}
// Switching voice restarts the current paragraph with the new voice so the
// change is audible immediately, keeping our place in the article.
function changeVoice(id) {
if (!TTS_CFG) return;
ttsVoice = id;
saveVoice();
if (voiceSel) voiceSel.value = ttsVoice;
if (speaking) {
speakGen++;
teardownAudio();
playIdx(speakIdx);
}
}
function buildVoiceMenu() {
if (!voiceSel || !TTS_CFG) return;
voiceSel.innerHTML = "";
TTS_CFG.voices.forEach(function (v) {
var o = document.createElement("option");
o.value = v.id;
o.textContent = v.label;
voiceSel.appendChild(o);
});
voiceSel.value = ttsVoice;
voiceSel.addEventListener("change", function () { changeVoice(voiceSel.value); });
if (voiceRow) voiceRow.hidden = false;
}
// ---- open / close ---------------------------------------------------------
function openReader() {
@@ -556,6 +674,9 @@
document.body.classList.add("overflow-hidden");
applyType();
show(firstUnread());
// Move focus into the scroll region so Space / PageUp-Down / arrow keys
// scroll the story, not the page behind it, without a click first.
if (scrollEl) { try { scrollEl.focus({ preventScroll: true }); } catch (e) { scrollEl.focus(); } }
}
function closeReader() {
open = false;
@@ -587,9 +708,9 @@
if (it) setBookmark(it.id, !isBookmarked(it.id));
});
// Read-aloud: signed-in only, and only where Web Speech exists.
// Read-aloud: signed-in only, and only when server-side TTS is configured.
if (listenBtn) {
if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); }
if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); buildVoiceMenu(); }
else listenBtn.style.display = "none";
}
// Share: available to everyone.

View File

@@ -0,0 +1,487 @@
// Canvas2D weather engine — the fallback when WebGL2 isn't available. This is
// the original hand-drawn renderer wrapped as an engine factory; weather.js
// owns the toggle, storage and the PeteWeather API and just calls
// set/start/stop here. New GPU-only variants (hail, haze, wind, aurora) alias
// to their nearest 2D look so the fallback never renders a blank page.
(function () {
window.PeteWeatherEngines = window.PeteWeatherEngines || {};
window.PeteWeatherEngines.canvas2d = function (canvas) {
var ctx = canvas.getContext("2d");
if (!ctx) return null;
var root = document.documentElement;
var W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = canvas.clientWidth || window.innerWidth;
H = canvas.clientHeight || window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
resize();
window.addEventListener("resize", resize);
var aliases = { hail: "snow", haze: "motes", wind: "leaves", aurora: "clear" };
var counts = {
rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38,
snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200
};
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
function rand(a, b) { return a + Math.random() * (b - a); }
var variant = null; // current rendered variant
var intensity = "heavy"; // light | medium | heavy
var particles = [];
var flash = 0; // lightning flash decay (storm only)
var nextBolt = 2; // seconds until next lightning bolt (storm only)
function spawn(initial) {
var p = {};
p.x = rand(0, W);
p.y = initial ? rand(0, H) : rand(-40, -10);
p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha
if (variant === "rain" || variant === "storm") {
p.vy = rand(700, 1100) * p.z;
p.vx = -rand(60, 120);
p.len = rand(10, 18) * p.z;
p.alpha = rand(0.25, 0.55);
} else if (variant === "snow") {
p.vy = rand(35, 75) * p.z;
p.swayAmp = rand(12, 34);
p.swayFreq = rand(0.3, 0.8);
p.swayPhase = rand(0, Math.PI * 2);
p.size = rand(2, 4.6) * p.z;
p.alpha = rand(0.65, 0.95);
} else if (variant === "clouds") {
// Soft puffs drifting across the upper sky.
p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45);
p.x = initial ? rand(0, W) : -rand(120, 320);
p.vx = rand(8, 22);
p.size = rand(80, 170) * p.z;
p.alpha = rand(0.32, 0.55);
p.sprite = Math.floor(rand(0, 3));
} else if (variant === "fog") {
// Wide translucent bands creeping sideways.
p.y = rand(H * 0.1, H);
p.x = initial ? rand(0, W) : -rand(200, 500);
p.vx = rand(6, 16);
p.w = rand(260, 520);
p.h = rand(70, 150);
p.alpha = rand(0.05, 0.14);
} else if (variant === "petals" || variant === "jacaranda") {
p.vy = rand(60, 120);
p.swayAmp = rand(20, 50);
p.swayFreq = rand(0.4, 0.9);
p.swayPhase = rand(0, Math.PI * 2);
p.rot = rand(0, Math.PI * 2);
p.vrot = rand(-1.2, 1.2);
p.size = rand(8, 16) * p.z;
p.alpha = rand(0.75, 1.0);
if (variant === "jacaranda") {
p.hue = rand(265, 285); // blue-violet — jacaranda uses the petal silhouette in purple
p.sat = rand(55, 75);
p.light = rand(70, 82);
} else {
p.hue = rand(335, 355); // pink (almond/cherry)
p.sat = rand(60, 80);
p.light = rand(78, 88);
}
} else if (variant === "motes") {
p.vx = rand(-15, 25);
p.vy = rand(8, 25);
p.size = rand(1.2, 2.8) * p.z;
p.alpha = rand(0.35, 0.7);
p.twinklePhase = rand(0, Math.PI * 2);
p.twinkleFreq = rand(0.5, 1.5);
} else if (variant === "leaves") {
p.vy = rand(70, 130);
p.swayAmp = rand(30, 70);
p.swayFreq = rand(0.3, 0.6);
p.swayPhase = rand(0, Math.PI * 2);
p.rot = rand(0, Math.PI * 2);
p.vrot = rand(-2.0, 2.0);
p.size = rand(10, 18) * p.z;
p.alpha = rand(0.8, 1.0);
p.hue = rand(18, 42); // orange→amber→brown
p.light = rand(38, 55);
}
return p;
}
var night = root.dataset.phase === "night";
function refreshNight() { night = root.dataset.phase === "night"; }
function drawRain(p) {
// Day/dawn/dusk backgrounds are warm and pale, so the rain needs a darker,
// more saturated blue plus a slightly thicker line to stay visible. Night
// keeps a paler tone so streaks read against the dark palette.
if (night) {
ctx.strokeStyle = "rgba(200,215,240," + p.alpha + ")";
ctx.lineWidth = 1;
} else {
ctx.strokeStyle = "rgba(60,95,150," + Math.min(1, p.alpha + 0.25) + ")";
ctx.lineWidth = 1.3;
}
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.len * 0.12, p.y + p.len);
ctx.stroke();
}
function drawSnow(p) {
// Soft round flake with a faint glow so it reads on light and dark palettes.
var col = night ? "235,242,255" : "255,255,255";
ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
if (!night) {
// A thin cool outline keeps white flakes visible against cream backgrounds.
ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")";
ctx.lineWidth = 0.6;
ctx.stroke();
}
}
// Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each
// cloud reads as one fluffy mass. Built once per palette (day/night) onto
// offscreen canvases, then just drifted — cheap to animate.
var cloudSprites = null;
var cloudSpritesNight = null;
// Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a
// 0..1 box (x across, y down) with a flattish base around y≈0.66.
var cloudShapes = [
[[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18],
[0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]],
[[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16],
[0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]],
[[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16],
[0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]]
];
function makeCloudSprite(col, shape) {
var c = document.createElement("canvas");
var w = 320, h = 224;
c.width = w; c.height = h;
var cx = c.getContext("2d");
cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud
cx.fillStyle = "rgba(" + col + ",1)";
for (var i = 0; i < shape.length; i++) {
cx.beginPath();
cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2);
cx.fill();
}
return c;
}
function ensureCloudSprites() {
if (cloudSprites && cloudSpritesNight === night) return;
// Day sky is warm cream so clouds need a cool slate-grey; night uses a pale
// tone against the dark palette.
var col = night ? "206,216,236" : "150,164,190";
cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); });
cloudSpritesNight = night;
}
function drawCloud(p) {
ensureCloudSprites();
var spr = cloudSprites[p.sprite % cloudSprites.length];
var w = p.size * 2.6;
var h = w * (spr.height / spr.width);
ctx.globalAlpha = p.alpha;
ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h);
ctx.globalAlpha = 1;
}
function drawFog(p) {
var col = night ? "150,160,180" : "230,228,224";
var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0);
g.addColorStop(0, "rgba(" + col + ",0)");
g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")");
g.addColorStop(1, "rgba(" + col + ",0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2);
ctx.fill();
}
function drawMoon(cx, cy, mr) {
var TAU = Math.PI * 2;
ctx.save();
// Clip everything to the lunar disc so shading stays inside the sphere.
ctx.beginPath();
ctx.arc(cx, cy, mr, 0, TAU);
ctx.clip();
// Spherical body shading — light source upper-right, terminator lower-left.
var lx = cx + mr * 0.45, ly = cy - mr * 0.45;
var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35);
body.addColorStop(0, "rgba(249,251,255,0.97)");
body.addColorStop(0.55, "rgba(214,222,240,0.92)");
body.addColorStop(1, "rgba(140,151,180,0.85)");
ctx.fillStyle = body;
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
// Limb darkening — fade the edge so the disc reads as a ball, not a coin.
var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr);
limb.addColorStop(0, "rgba(18,24,46,0)");
limb.addColorStop(1, "rgba(18,24,46,0.4)");
ctx.fillStyle = limb;
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
ctx.restore();
// Soft outer halo, drawn unclipped around the disc.
var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4);
halo.addColorStop(0, "rgba(220,230,255,0.28)");
halo.addColorStop(1, "rgba(220,230,255,0)");
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(cx, cy, mr * 2.4, 0, TAU);
ctx.fill();
}
function drawClearGlow(t) {
// No particles — render a single calm sun (day) or moon (night) glow that
// breathes very slowly, plus a few stars at night.
var breathe = 0.5 + 0.5 * Math.sin(t * 0.25);
var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5;
if (night) {
// Ambient sky glow around the moon.
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")");
g.addColorStop(1, "rgba(220,230,255,0)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
drawMoon(cx, cy, r * 0.16);
} else {
var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")");
gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")");
gd.addColorStop(1, "rgba(255,214,120,0)");
ctx.fillStyle = gd;
ctx.fillRect(0, 0, W, H);
}
}
// Deterministic star field for clear nights (seeded so they don't jitter).
var stars = [];
function buildStars() {
stars = [];
var n = 60;
for (var i = 0; i < n; i++) {
// Cheap LCG-ish spread keyed by index — stable across frames.
var sx = ((i * 73 + 11) % 100) / 100;
var sy = ((i * 37 + 7) % 100) / 100;
stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) });
}
}
function drawStars(t) {
for (var i = 0; i < stars.length; i++) {
var s = stars[i];
var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph));
ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")";
ctx.beginPath();
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
ctx.fill();
}
}
function drawPetals(p) {
// Five-petal almond/cherry blossom viewed face-on, with a yellow center.
var s = p.size;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = "hsla(" + p.hue + "," + p.sat + "%," + p.light + "%," + p.alpha + ")";
for (var i = 0; i < 5; i++) {
var a = (i / 5) * Math.PI * 2 - Math.PI / 2;
var cx = Math.cos(a) * s * 0.32;
var cy = Math.sin(a) * s * 0.32;
ctx.beginPath();
ctx.ellipse(cx, cy, s * 0.38, s * 0.24, a, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "hsla(48,90%,68%," + p.alpha + ")";
ctx.beginPath();
ctx.arc(0, 0, s * 0.13, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawLeaf(p) {
var s = p.size;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
var g = ctx.createLinearGradient(-s * 0.5, 0, s * 0.5, 0);
g.addColorStop(0, "hsla(" + p.hue + ",70%," + (p.light - 10) + "%," + p.alpha + ")");
g.addColorStop(1, "hsla(" + p.hue + ",75%," + (p.light + 10) + "%," + p.alpha + ")");
ctx.fillStyle = g;
// Almond/lozenge leaf — pointed at both ends.
ctx.beginPath();
ctx.moveTo(-s * 0.55, 0);
ctx.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0);
ctx.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0);
ctx.closePath();
ctx.fill();
// Midrib vein.
ctx.strokeStyle = "hsla(" + p.hue + ",60%," + (p.light - 22) + "%," + (p.alpha * 0.7) + ")";
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(-s * 0.5, 0);
ctx.lineTo(s * 0.5, 0);
ctx.stroke();
// Small stem nub at the base.
ctx.strokeStyle = "hsla(" + p.hue + ",55%," + (p.light - 25) + "%," + (p.alpha * 0.7) + ")";
ctx.lineWidth = 1.0;
ctx.beginPath();
ctx.moveTo(-s * 0.55, 0);
ctx.lineTo(-s * 0.7, -s * 0.04);
ctx.stroke();
ctx.restore();
}
function drawMote(p, t) {
var twinkle = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
// Night keeps a pale glowing mote against the dark palette. Day/dawn/dusk
// use a deeper saturated honey so the motes stand out against warm cream
// and peach backgrounds; alpha also gets a bump.
var color, a;
if (night) {
color = "255,240,180";
a = p.alpha * (0.45 + 0.55 * twinkle);
} else {
color = "130,80,180";
a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * twinkle));
}
// Soft glow halo.
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 5);
g.addColorStop(0, "rgba(" + color + "," + (a * 0.55) + ")");
g.addColorStop(1, "rgba(" + color + ",0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 5, 0, Math.PI * 2);
ctx.fill();
// Bright core.
ctx.fillStyle = "rgba(" + color + "," + a + ")";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
var last = performance.now();
var raf = 0;
function step(now) {
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
var t = now / 1000;
ctx.clearRect(0, 0, W, H);
refreshNight();
if (variant === "clear") {
drawClearGlow(t);
if (night) drawStars(t);
raf = requestAnimationFrame(step);
return;
}
// Storm: drive the lightning flash before drawing rain over it.
if (variant === "storm") {
nextBolt -= dt;
if (nextBolt <= 0) {
flash = 1;
nextBolt = rand(2.5, 7);
}
if (flash > 0) {
ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")";
ctx.fillRect(0, 0, W, H);
flash = Math.max(0, flash - dt * 4); // ~0.25s decay
}
}
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
if (variant === "rain" || variant === "storm") {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > H + 20 || p.x < -40) {
particles[i] = spawn(false);
particles[i].x = rand(0, W + 60);
continue;
}
drawRain(p);
} else if (variant === "snow") {
p.y += p.vy * dt;
var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
if (p.y > H + 10) { particles[i] = spawn(false); continue; }
var ox = p.x; p.x = ox + sxOff;
drawSnow(p);
p.x = ox;
} else if (variant === "clouds") {
p.x += p.vx * dt;
if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; }
drawCloud(p);
} else if (variant === "fog") {
p.x += p.vx * dt;
if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; }
drawFog(p);
} else if (variant === "motes") {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > H + 10 || p.x < -10 || p.x > W + 10) {
particles[i] = spawn(false);
continue;
}
drawMote(p, t);
} else {
// Drifting variants: petals, jacaranda, leaves.
p.y += p.vy * dt;
p.rot += p.vrot * dt;
var sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
var origX = p.x;
p.x = origX + sway;
if (p.y > H + 30) {
particles[i] = spawn(false);
continue;
}
if (variant === "jacaranda" || variant === "petals") drawPetals(p);
else drawLeaf(p);
p.x = origX;
}
}
raf = requestAnimationFrame(step);
}
function start() {
if (raf) return;
last = performance.now();
raf = requestAnimationFrame(step);
}
function stop() {
if (raf) cancelAnimationFrame(raf);
raf = 0;
ctx.clearRect(0, 0, W, H);
}
// Build the particle pool for a variant. The controller decides whether to
// start rendering afterwards.
function set(v, inten) {
variant = aliases[v] || v || null;
intensity = inten || "heavy";
flash = 0; nextBolt = rand(1.5, 4);
particles = [];
if (variant === "clear") { buildStars(); }
if (!variant) { stop(); return; }
var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1));
for (var i = 0; i < N; i++) particles.push(spawn(true));
}
return { name: "canvas2d", set: set, start: start, stop: stop };
};
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
// Canvas-based weather rendered behind the page. Two drivers feed it:
// Weather controller. Two drivers feed the background canvas:
//
// 1. The server picks a *seasonal* variant + intensity from the Lisbon-local
// date and writes them to <html data-weather / data-intensity>. This is the
@@ -7,15 +7,21 @@
// forecast and calls PeteWeather.set(variant, intensity) to override the
// background with live conditions.
//
// Each variant has a hand-drawn shape so silhouettes are recognizable instead
// of a generic blob. Seasonal variants: rain, petals, jacaranda, motes, leaves.
// Live-weather variants add: clear, clouds, snow, fog, storm.
// Rendering is delegated to an engine: the WebGL2 one (weather-gl.js) when the
// browser supports it — GPU sprites, shader fog/aurora, real lightning — with
// the original Canvas2D renderer (weather-2d.js) as the fallback. This file
// only owns the toggle button, the on/off preference and the public API.
(function () {
var root = document.documentElement;
var canvas = document.getElementById("pete-weather");
if (!canvas) return;
var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var engines = window.PeteWeatherEngines || {};
var engine = (engines.webgl2 && engines.webgl2(canvas)) || null;
if (!engine && engines.canvas2d) engine = engines.canvas2d(canvas);
if (!engine) return;
var STORAGE_KEY = "pete-weather-off";
var toggleBtn = document.querySelector("[data-weather-toggle]");
var star = document.querySelector("[data-weather-star]");
@@ -41,504 +47,38 @@
}
}
var ctx = canvas.getContext("2d");
var W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = canvas.clientWidth || window.innerWidth;
H = canvas.clientHeight || window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
resize();
window.addEventListener("resize", resize);
var counts = {
rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38,
snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200
};
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
function rand(a, b) { return a + Math.random() * (b - a); }
var variant = null; // current rendered variant
var intensity = "heavy"; // light | medium | heavy
var particles = [];
var flash = 0; // lightning flash decay (storm only)
var nextBolt = 2; // seconds until next lightning bolt (storm only)
function spawn(initial) {
var p = {};
p.x = rand(0, W);
p.y = initial ? rand(0, H) : rand(-40, -10);
p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha
if (variant === "rain" || variant === "storm") {
p.vy = rand(700, 1100) * p.z;
p.vx = -rand(60, 120);
p.len = rand(10, 18) * p.z;
p.alpha = rand(0.25, 0.55);
} else if (variant === "snow") {
p.vy = rand(35, 75) * p.z;
p.swayAmp = rand(12, 34);
p.swayFreq = rand(0.3, 0.8);
p.swayPhase = rand(0, Math.PI * 2);
p.size = rand(2, 4.6) * p.z;
p.alpha = rand(0.65, 0.95);
} else if (variant === "clouds") {
// Soft puffs drifting across the upper sky.
p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45);
p.x = initial ? rand(0, W) : -rand(120, 320);
p.vx = rand(8, 22);
p.size = rand(80, 170) * p.z;
p.alpha = rand(0.32, 0.55);
p.sprite = Math.floor(rand(0, 3));
} else if (variant === "fog") {
// Wide translucent bands creeping sideways.
p.y = rand(H * 0.1, H);
p.x = initial ? rand(0, W) : -rand(200, 500);
p.vx = rand(6, 16);
p.w = rand(260, 520);
p.h = rand(70, 150);
p.alpha = rand(0.05, 0.14);
} else if (variant === "petals" || variant === "jacaranda") {
p.vy = rand(60, 120);
p.swayAmp = rand(20, 50);
p.swayFreq = rand(0.4, 0.9);
p.swayPhase = rand(0, Math.PI * 2);
p.rot = rand(0, Math.PI * 2);
p.vrot = rand(-1.2, 1.2);
p.size = rand(8, 16) * p.z;
p.alpha = rand(0.75, 1.0);
if (variant === "jacaranda") {
p.hue = rand(265, 285); // blue-violet — jacaranda uses the petal silhouette in purple
p.sat = rand(55, 75);
p.light = rand(70, 82);
} else {
p.hue = rand(335, 355); // pink (almond/cherry)
p.sat = rand(60, 80);
p.light = rand(78, 88);
}
} else if (variant === "motes") {
p.vx = rand(-15, 25);
p.vy = rand(8, 25);
p.size = rand(1.2, 2.8) * p.z;
p.alpha = rand(0.35, 0.7);
p.twinklePhase = rand(0, Math.PI * 2);
p.twinkleFreq = rand(0.5, 1.5);
} else if (variant === "leaves") {
p.vy = rand(70, 130);
p.swayAmp = rand(30, 70);
p.swayFreq = rand(0.3, 0.6);
p.swayPhase = rand(0, Math.PI * 2);
p.rot = rand(0, Math.PI * 2);
p.vrot = rand(-2.0, 2.0);
p.size = rand(10, 18) * p.z;
p.alpha = rand(0.8, 1.0);
p.hue = rand(18, 42); // orange→amber→brown
p.light = rand(38, 55);
}
return p;
}
var night = root.dataset.phase === "night";
function refreshNight() { night = root.dataset.phase === "night"; }
function drawRain(p) {
// Day/dawn/dusk backgrounds are warm and pale, so the rain needs a darker,
// more saturated blue plus a slightly thicker line to stay visible. Night
// keeps a paler tone so streaks read against the dark palette.
if (night) {
ctx.strokeStyle = "rgba(200,215,240," + p.alpha + ")";
ctx.lineWidth = 1;
} else {
ctx.strokeStyle = "rgba(60,95,150," + Math.min(1, p.alpha + 0.25) + ")";
ctx.lineWidth = 1.3;
}
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.len * 0.12, p.y + p.len);
ctx.stroke();
}
function drawSnow(p) {
// Soft round flake with a faint glow so it reads on light and dark palettes.
var col = night ? "235,242,255" : "255,255,255";
ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
if (!night) {
// A thin cool outline keeps white flakes visible against cream backgrounds.
ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")";
ctx.lineWidth = 0.6;
ctx.stroke();
}
}
// Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each
// cloud reads as one fluffy mass. Built once per palette (day/night) onto
// offscreen canvases, then just drifted — cheap to animate.
var cloudSprites = null;
var cloudSpritesNight = null;
// Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a
// 0..1 box (x across, y down) with a flattish base around y≈0.66.
var cloudShapes = [
[[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18],
[0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]],
[[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16],
[0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]],
[[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16],
[0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]]
];
function makeCloudSprite(col, shape) {
var c = document.createElement("canvas");
var w = 320, h = 224;
c.width = w; c.height = h;
var cx = c.getContext("2d");
cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud
cx.fillStyle = "rgba(" + col + ",1)";
for (var i = 0; i < shape.length; i++) {
cx.beginPath();
cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2);
cx.fill();
}
return c;
}
function ensureCloudSprites() {
if (cloudSprites && cloudSpritesNight === night) return;
// Day sky is warm cream so clouds need a cool slate-grey; night uses a pale
// tone against the dark palette.
var col = night ? "206,216,236" : "150,164,190";
cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); });
cloudSpritesNight = night;
}
function drawCloud(p) {
ensureCloudSprites();
var spr = cloudSprites[p.sprite % cloudSprites.length];
var w = p.size * 2.6;
var h = w * (spr.height / spr.width);
ctx.globalAlpha = p.alpha;
ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h);
ctx.globalAlpha = 1;
}
function drawFog(p) {
var col = night ? "150,160,180" : "230,228,224";
var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0);
g.addColorStop(0, "rgba(" + col + ",0)");
g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")");
g.addColorStop(1, "rgba(" + col + ",0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2);
ctx.fill();
}
function drawMoon(cx, cy, mr) {
var TAU = Math.PI * 2;
ctx.save();
// Clip everything to the lunar disc so shading stays inside the sphere.
ctx.beginPath();
ctx.arc(cx, cy, mr, 0, TAU);
ctx.clip();
// Spherical body shading — light source upper-right, terminator lower-left.
var lx = cx + mr * 0.45, ly = cy - mr * 0.45;
var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35);
body.addColorStop(0, "rgba(249,251,255,0.97)");
body.addColorStop(0.55, "rgba(214,222,240,0.92)");
body.addColorStop(1, "rgba(140,151,180,0.85)");
ctx.fillStyle = body;
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
// Limb darkening — fade the edge so the disc reads as a ball, not a coin.
var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr);
limb.addColorStop(0, "rgba(18,24,46,0)");
limb.addColorStop(1, "rgba(18,24,46,0.4)");
ctx.fillStyle = limb;
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
ctx.restore();
// Soft outer halo, drawn unclipped around the disc.
var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4);
halo.addColorStop(0, "rgba(220,230,255,0.28)");
halo.addColorStop(1, "rgba(220,230,255,0)");
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(cx, cy, mr * 2.4, 0, TAU);
ctx.fill();
}
function drawClearGlow(t) {
// No particles — render a single calm sun (day) or moon (night) glow that
// breathes very slowly, plus a few stars at night.
var breathe = 0.5 + 0.5 * Math.sin(t * 0.25);
var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5;
if (night) {
// Ambient sky glow around the moon.
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")");
g.addColorStop(1, "rgba(220,230,255,0)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
drawMoon(cx, cy, r * 0.16);
} else {
var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")");
gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")");
gd.addColorStop(1, "rgba(255,214,120,0)");
ctx.fillStyle = gd;
ctx.fillRect(0, 0, W, H);
}
}
// Deterministic star field for clear nights (seeded so they don't jitter).
var stars = [];
function buildStars() {
stars = [];
var n = 60;
for (var i = 0; i < n; i++) {
// Cheap LCG-ish spread keyed by index — stable across frames.
var sx = ((i * 73 + 11) % 100) / 100;
var sy = ((i * 37 + 7) % 100) / 100;
stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) });
}
}
function drawStars(t) {
for (var i = 0; i < stars.length; i++) {
var s = stars[i];
var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph));
ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")";
ctx.beginPath();
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
ctx.fill();
}
}
function drawPetals(p) {
// Five-petal almond/cherry blossom viewed face-on, with a yellow center.
var s = p.size;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = "hsla(" + p.hue + "," + p.sat + "%," + p.light + "%," + p.alpha + ")";
for (var i = 0; i < 5; i++) {
var a = (i / 5) * Math.PI * 2 - Math.PI / 2;
var cx = Math.cos(a) * s * 0.32;
var cy = Math.sin(a) * s * 0.32;
ctx.beginPath();
ctx.ellipse(cx, cy, s * 0.38, s * 0.24, a, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "hsla(48,90%,68%," + p.alpha + ")";
ctx.beginPath();
ctx.arc(0, 0, s * 0.13, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawLeaf(p) {
var s = p.size;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
var g = ctx.createLinearGradient(-s * 0.5, 0, s * 0.5, 0);
g.addColorStop(0, "hsla(" + p.hue + ",70%," + (p.light - 10) + "%," + p.alpha + ")");
g.addColorStop(1, "hsla(" + p.hue + ",75%," + (p.light + 10) + "%," + p.alpha + ")");
ctx.fillStyle = g;
// Almond/lozenge leaf — pointed at both ends.
ctx.beginPath();
ctx.moveTo(-s * 0.55, 0);
ctx.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0);
ctx.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0);
ctx.closePath();
ctx.fill();
// Midrib vein.
ctx.strokeStyle = "hsla(" + p.hue + ",60%," + (p.light - 22) + "%," + (p.alpha * 0.7) + ")";
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(-s * 0.5, 0);
ctx.lineTo(s * 0.5, 0);
ctx.stroke();
// Small stem nub at the base.
ctx.strokeStyle = "hsla(" + p.hue + ",55%," + (p.light - 25) + "%," + (p.alpha * 0.7) + ")";
ctx.lineWidth = 1.0;
ctx.beginPath();
ctx.moveTo(-s * 0.55, 0);
ctx.lineTo(-s * 0.7, -s * 0.04);
ctx.stroke();
ctx.restore();
}
function drawMote(p, t) {
var twinkle = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
// Night keeps a pale glowing mote against the dark palette. Day/dawn/dusk
// use a deeper saturated honey so the motes stand out against warm cream
// and peach backgrounds; alpha also gets a bump.
var color, a;
if (night) {
color = "255,240,180";
a = p.alpha * (0.45 + 0.55 * twinkle);
} else {
color = "130,80,180";
a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * twinkle));
}
// Soft glow halo.
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 5);
g.addColorStop(0, "rgba(" + color + "," + (a * 0.55) + ")");
g.addColorStop(1, "rgba(" + color + ",0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 5, 0, Math.PI * 2);
ctx.fill();
// Bright core.
ctx.fillStyle = "rgba(" + color + "," + a + ")";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
var last = performance.now();
var raf = 0;
function step(now) {
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
var t = now / 1000;
ctx.clearRect(0, 0, W, H);
refreshNight();
if (variant === "clear") {
drawClearGlow(t);
if (night) drawStars(t);
raf = requestAnimationFrame(step);
return;
}
// Storm: drive the lightning flash before drawing rain over it.
if (variant === "storm") {
nextBolt -= dt;
if (nextBolt <= 0) {
flash = 1;
nextBolt = rand(2.5, 7);
}
if (flash > 0) {
ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")";
ctx.fillRect(0, 0, W, H);
flash = Math.max(0, flash - dt * 4); // ~0.25s decay
}
}
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
if (variant === "rain" || variant === "storm") {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > H + 20 || p.x < -40) {
particles[i] = spawn(false);
particles[i].x = rand(0, W + 60);
continue;
}
drawRain(p);
} else if (variant === "snow") {
p.y += p.vy * dt;
var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
if (p.y > H + 10) { particles[i] = spawn(false); continue; }
var ox = p.x; p.x = ox + sxOff;
drawSnow(p);
p.x = ox;
} else if (variant === "clouds") {
p.x += p.vx * dt;
if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; }
drawCloud(p);
} else if (variant === "fog") {
p.x += p.vx * dt;
if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; }
drawFog(p);
} else if (variant === "motes") {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > H + 10 || p.x < -10 || p.x > W + 10) {
particles[i] = spawn(false);
continue;
}
drawMote(p, t);
} else {
// Drifting variants: petals, jacaranda, leaves.
p.y += p.vy * dt;
p.rot += p.vrot * dt;
var sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
var origX = p.x;
p.x = origX + sway;
if (p.y > H + 30) {
particles[i] = spawn(false);
continue;
}
if (variant === "jacaranda" || variant === "petals") drawPetals(p);
else drawLeaf(p);
p.x = origX;
}
}
raf = requestAnimationFrame(step);
}
function start() {
if (raf) return;
last = performance.now();
raf = requestAnimationFrame(step);
}
function stop() {
if (raf) cancelAnimationFrame(raf);
raf = 0;
ctx.clearRect(0, 0, W, H);
}
// Build the particle pool for a variant and (re)start rendering if enabled.
function configure(v, inten) {
variant = v || null;
intensity = inten || "heavy";
flash = 0; nextBolt = rand(1.5, 4);
particles = [];
if (variant === "clear") { buildStars(); }
if (!variant) { stop(); return; }
var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1));
for (var i = 0; i < N; i++) particles.push(spawn(true));
if (enabled) start();
}
var enabled = !userDisabled() && !reducedMotion;
// Public hook so weather-forecast.js can swap the seasonal default for live
// conditions. Passing a falsy variant clears the canvas.
// Public hook so weather-forecast.js (and the /weather demo) can swap the
// seasonal default for other conditions. Passing a falsy variant clears the
// canvas.
window.PeteWeather = {
set: function (v, inten) {
root.dataset.weather = v || "";
if (inten) root.dataset.intensity = inten;
configure(v, inten || intensity);
engine.set(v || null, inten || root.dataset.intensity || "heavy");
if (enabled && v) engine.start();
},
isEnabled: function () { return enabled; }
isEnabled: function () { return enabled; },
renderer: function () { return engine.name; }
};
syncBtn(enabled);
configure(root.dataset.weather, root.dataset.intensity);
engine.set(root.dataset.weather || null, root.dataset.intensity || "heavy");
if (enabled && root.dataset.weather) engine.start();
if (toggleBtn) {
toggleBtn.addEventListener("click", function () {
enabled = !enabled;
setDisabled(!enabled);
syncBtn(enabled);
if (enabled) start(); else stop();
if (enabled) engine.start(); else engine.stop();
});
}
document.addEventListener("visibilitychange", function () {
if (!enabled) return;
if (document.hidden) stop();
else start();
if (document.hidden) engine.stop();
else engine.start();
});
})();

View File

@@ -4,7 +4,7 @@
//
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
// drops every cache that doesn't match the current version.
var CACHE_VERSION = "v3";
var CACHE_VERSION = "v4";
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
@@ -13,6 +13,8 @@ var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
var SHELL_ASSETS = [
"/static/css/output.css",
"/static/js/prefs.js",
"/static/js/weather-2d.js",
"/static/js/weather-gl.js",
"/static/js/weather.js",
"/static/js/weather-forecast.js",
"/static/js/search.js",

View File

@@ -9,7 +9,7 @@ import (
)
func TestStatusTemplateExecutes(t *testing.T) {
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

View File

@@ -4,6 +4,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}{{.SiteTitle}}{{end}}</title>
{{if .NoIndex}}<meta name="robots" content="noindex">{{end}}
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}"><meta name="twitter:card" content="summary_large_image">{{end}}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
@@ -246,6 +248,10 @@
<button type="button" data-paper="sepia">Sepia</button>
</div>
</div>
<div class="pete-reader-typerow pete-reader-voicerow" data-reader-voicerow hidden>
<span class="pete-reader-typelabel">Voice</span>
<select class="pete-reader-voice" data-reader-voice aria-label="Read-aloud voice"></select>
</div>
</div>
</div>
<button type="button" data-reader-bookmark class="pete-reader-btn" style="display:none" title="Bookmark (b)" aria-label="Bookmark this story" aria-pressed="false">🔖 Save</button>
@@ -253,7 +259,7 @@
<button type="button" data-reader-close class="pete-reader-btn" title="Close (esc)" aria-label="Close reader">esc</button>
</div>
</div>
<div class="pete-reader-scroll" data-reader-scroll>
<div class="pete-reader-scroll" data-reader-scroll tabindex="-1">
<article class="pete-reader-article" data-reader-article></article>
<div class="pete-reader-related" data-reader-related hidden></div>
</div>
@@ -305,8 +311,11 @@
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
window.PETE_PREFS = {{.UserPrefs}};
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
window.PETE_TTS = {{.TTS}};
</script>
<script src="/static/js/prefs.js" defer></script>
<script src="/static/js/weather-2d.js" defer></script>
<script src="/static/js/weather-gl.js" defer></script>
<script src="/static/js/weather.js" defer></script>
<script src="/static/js/weather-forecast.js" defer></script>
<script src="/static/js/search.js" defer></script>

View File

@@ -0,0 +1,27 @@
{{define "title"}}{{.Headline}} — {{.SiteTitle}}{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto">
<nav class="mb-4">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<span aria-hidden="true"></span> All dispatches
</a>
</nav>
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{.Emoji}}</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">{{.Emoji}} {{.EventLabel}}</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Headline}}</h1>
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">Reported {{.When}}{{if .Region}} · {{.Region}}{{end}}</p>
</div>
</header>
<div class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Body}}</p>
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
Reporting from the realm, this is Pete.
</p>
</div>
</article>
{{end}}

View File

@@ -3,14 +3,21 @@
{{define "main"}}
<div class="space-y-8">
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<h1 class="font-display text-3xl font-bold mb-1">Weather demo</h1>
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. The canvas reloads on each click.</p>
<div class="flex flex-wrap items-start justify-between gap-3 mb-1">
<h1 class="font-display text-3xl font-bold">Weather demo</h1>
<div class="flex items-center gap-2 text-xs font-semibold">
<span data-demo-renderer class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 uppercase tracking-wider text-[color:var(--ink)]/60">renderer: …</span>
<span data-demo-fps class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 tabular-nums text-[color:var(--ink)]/60">— fps</span>
</div>
</div>
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. Switching is instant, no reload.</p>
<div class="space-y-3 text-sm">
<div class="flex flex-wrap items-center gap-2">
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Variant</span>
{{range $v := .Variants}}
<a href="?variant={{$v}}&amp;intensity={{$.Intensity}}&amp;phase={{$.PhaseSel}}"
data-demo-variant="{{$v}}"
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
{{if eq $.Variant $v}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$v}}</a>
{{end}}
@@ -19,6 +26,7 @@
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Intensity</span>
{{range $i := .Intensities}}
<a href="?variant={{$.Variant}}&amp;intensity={{$i}}&amp;phase={{$.PhaseSel}}"
data-demo-intensity="{{$i}}"
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
{{if eq $.Intensity $i}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$i}}</a>
{{end}}
@@ -27,6 +35,7 @@
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Phase</span>
{{range $p := .Phases}}
<a href="?variant={{$.Variant}}&amp;intensity={{$.Intensity}}&amp;phase={{$p}}"
data-demo-phase="{{$p}}"
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
{{if eq $.PhaseSel $p}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$p}}</a>
{{end}}
@@ -34,7 +43,8 @@
</div>
<div class="mt-5 text-xs text-[color:var(--ink)]/50">
Current: <code class="font-semibold">{{.Weather.Season}} / {{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
Current: <code data-demo-current class="font-semibold">{{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
<span class="mx-1">·</span> Tip: aurora and clear night want the night phase; wind and haze are the new autumn gusts and Saharan calima.
</div>
</section>
@@ -50,8 +60,76 @@
</div>
<div class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<p class="font-display">Card B</p>
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Move your window taller to give the particles more room.</p>
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Make your window taller to give the particles more room.</p>
</div>
</section>
</div>
<script>
// Instant switching: the pill links stay real URLs for no-JS visitors, but
// with JS we swap the effect in place via PeteWeather.set and keep the URL
// in sync with replaceState.
document.addEventListener("DOMContentLoaded", function () {
var root = document.documentElement;
var state = {
variant: {{.Variant}},
intensity: {{.Intensity}},
phase: {{.PhaseSel}}
};
function markActive(attr, value) {
document.querySelectorAll("[" + attr + "]").forEach(function (el) {
var on = el.getAttribute(attr) === value;
el.classList.toggle("bg-[color:var(--accent)]", on);
el.classList.toggle("text-white", on);
el.classList.toggle("font-semibold", on);
});
}
function apply() {
root.dataset.phase = state.phase;
if (window.PeteWeather) window.PeteWeather.set(state.variant, state.intensity);
markActive("data-demo-variant", state.variant);
markActive("data-demo-intensity", state.intensity);
markActive("data-demo-phase", state.phase);
var cur = document.querySelector("[data-demo-current]");
if (cur) cur.textContent = state.variant + " / " + state.intensity + " / " + state.phase;
var label = document.querySelector("[data-phase-label]");
if (label) label.textContent = state.phase;
history.replaceState(null, "", "?variant=" + state.variant + "&intensity=" + state.intensity + "&phase=" + state.phase);
}
document.addEventListener("click", function (e) {
var a = e.target.closest && e.target.closest("[data-demo-variant],[data-demo-intensity],[data-demo-phase]");
if (!a || !window.PeteWeather) return;
e.preventDefault();
if (a.hasAttribute("data-demo-variant")) state.variant = a.getAttribute("data-demo-variant");
if (a.hasAttribute("data-demo-intensity")) state.intensity = a.getAttribute("data-demo-intensity");
if (a.hasAttribute("data-demo-phase")) state.phase = a.getAttribute("data-demo-phase");
apply();
});
var rEl = document.querySelector("[data-demo-renderer]");
if (rEl && window.PeteWeather && window.PeteWeather.renderer) {
rEl.textContent = "renderer: " + window.PeteWeather.renderer();
}
// Lightweight FPS meter, demo page only. Counts frames on its own rAF and
// refreshes the pill once a second.
var fpsEl = document.querySelector("[data-demo-fps]");
if (fpsEl) {
var frames = 0, lastStamp = performance.now();
function tick(now) {
frames++;
if (now - lastStamp >= 1000) {
fpsEl.textContent = Math.round(frames * 1000 / (now - lastStamp)) + " fps";
frames = 0;
lastStamp = now;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
});
</script>
{{end}}

View File

@@ -67,6 +67,14 @@ func thumbURL(src string) string {
if src == "" {
return ""
}
// Root-relative sources are our own local assets (e.g. the adventure
// emblems) — serve them directly; the thumbnailer only handles remote
// http(s) images and would 404 a local path. "//host/x.jpg" is NOT local:
// it's a protocol-relative remote image, and it has to keep going through
// the guarded thumbnailer like any other third-party URL.
if strings.HasPrefix(src, "/") && !strings.HasPrefix(src, "//") {
return src
}
return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src)
}

View File

@@ -46,7 +46,7 @@ func TestIndexTrendingAndBadges(t *testing.T) {
storage.RecordStoryView(id)
storage.RecordStoryView(id)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

199
internal/web/tts.go Normal file
View File

@@ -0,0 +1,199 @@
package web
import (
"bytes"
"context"
"encoding/json"
"html/template"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"pete/internal/config"
)
const (
ttsMaxTextLen = 6000 // per-request cap; a single paragraph is ~1-2k chars
ttsTimeout = 60 * time.Second
ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box
)
// ttsVoice is one selectable voice, as sent to the client.
type ttsVoice struct {
ID string `json:"id"`
Label string `json:"label"`
}
// ttsService runs Piper (https://github.com/rhasspy/piper) as a subprocess to
// synthesize read-aloud audio server-side. It holds the validated voice
// registry and a concurrency semaphore; there is no long-lived process, each
// request spawns a short-lived piper that loads its model, synthesizes one
// chunk of text to a WAV on stdout, and exits (~0.5s for a paragraph).
type ttsService struct {
piperBin string
voices []ttsVoice // menu order, for the client selector
models map[string]string // voice id -> absolute .onnx path
def string // default voice id
sem chan struct{} // buffered to ttsMaxConcurrent
}
// newTTS validates the Piper install and builds the voice registry. It returns
// (nil, nil) when TTS is disabled. A configured voice whose model file is
// missing is skipped with a warning rather than failing startup; if that leaves
// no usable voices, TTS is disabled.
func newTTS(cfg config.TTSConfig) (*ttsService, error) {
if !cfg.Enabled {
return nil, nil
}
bin := cfg.PiperBin
if bin == "" {
bin = "piper"
}
if p, err := exec.LookPath(bin); err != nil {
slog.Error("web: TTS enabled but piper binary not found; read-aloud disabled", "piper_bin", bin, "err", err)
return nil, nil
} else {
bin = p
}
dir := cfg.VoicesDir
svc := &ttsService{piperBin: bin, models: make(map[string]string)}
want := cfg.Voices
if len(want) == 0 {
// Auto-discover every *.onnx in voices_dir, labelled by filename stem.
entries, err := os.ReadDir(dir)
if err != nil {
slog.Error("web: TTS voices_dir unreadable; read-aloud disabled", "voices_dir", dir, "err", err)
return nil, nil
}
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".onnx") {
continue
}
id := strings.TrimSuffix(name, ".onnx")
want = append(want, config.VoiceConfig{ID: id, Label: id})
}
sort.Slice(want, func(i, j int) bool { return want[i].ID < want[j].ID })
}
for _, v := range want {
if v.ID == "" {
continue
}
model := filepath.Join(dir, v.ID+".onnx")
if _, err := os.Stat(model); err != nil {
slog.Warn("web: TTS voice model missing; skipping", "voice", v.ID, "path", model)
continue
}
label := v.Label
if label == "" {
label = v.ID
}
svc.models[v.ID] = model
svc.voices = append(svc.voices, ttsVoice{ID: v.ID, Label: label})
}
if len(svc.voices) == 0 {
slog.Error("web: TTS enabled but no usable voices found; read-aloud disabled", "voices_dir", dir)
return nil, nil
}
svc.def = cfg.Default
if _, ok := svc.models[svc.def]; !ok {
svc.def = svc.voices[0].ID
}
svc.sem = make(chan struct{}, ttsMaxConcurrent)
slog.Info("web: server-side TTS enabled", "piper", bin, "voices", len(svc.voices), "default", svc.def)
return svc, nil
}
// clientConfig is the JSON handed to the page as window.PETE_TTS so the reader
// can build its voice selector and know TTS is available.
func (t *ttsService) clientConfig() template.JS {
payload := struct {
Enabled bool `json:"enabled"`
Default string `json:"default"`
Voices []ttsVoice `json:"voices"`
}{Enabled: true, Default: t.def, Voices: t.voices}
b, err := json.Marshal(payload)
if err != nil {
return template.JS("null")
}
return jsForScript(b)
}
// handleTTS synthesizes one chunk of text to WAV audio with the requested
// voice. It is registered only under the authenticated route group, so callers
// are already signed in (read-aloud is a signed-in perk). The request body is
// JSON {voice, text}; the response is audio/wav.
func (s *Server) handleTTS(w http.ResponseWriter, r *http.Request) {
if s.tts == nil {
http.Error(w, "tts disabled", http.StatusNotFound)
return
}
if s.auth == nil || s.auth.userFromRequest(r) == nil {
http.Error(w, "sign-in required", http.StatusUnauthorized)
return
}
var req struct {
Voice string `json:"voice"`
Text string `json:"text"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
text := strings.TrimSpace(req.Text)
if text == "" {
http.Error(w, "empty text", http.StatusBadRequest)
return
}
if len(text) > ttsMaxTextLen {
text = text[:ttsMaxTextLen]
}
model, ok := s.tts.models[req.Voice]
if !ok {
model = s.tts.models[s.tts.def] // unknown/blank voice -> default
}
// Bound concurrent piper processes; give up if the client leaves or we wait
// too long for a slot rather than queueing unboundedly.
slotCtx, cancelSlot := context.WithTimeout(r.Context(), ttsTimeout)
defer cancelSlot()
select {
case s.tts.sem <- struct{}{}:
defer func() { <-s.tts.sem }()
case <-slotCtx.Done():
http.Error(w, "tts busy", http.StatusServiceUnavailable)
return
}
ctx, cancel := context.WithTimeout(r.Context(), ttsTimeout)
defer cancel()
// piper -m <model> -f - : write a full WAV to stdout. Text is fed on stdin,
// so there is no shell and nothing user-controlled reaches the arg list
// besides the model path, which is looked up from a fixed allowlist above.
cmd := exec.CommandContext(ctx, s.tts.piperBin, "-m", model, "-f", "-")
cmd.Stdin = strings.NewReader(text)
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
slog.Error("web: piper synthesis failed", "err", err, "stderr", strings.TrimSpace(errb.String()))
http.Error(w, "synthesis failed", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "audio/wav")
w.Header().Set("Cache-Control", "private, max-age=3600")
w.Header().Set("Content-Length", strconv.Itoa(out.Len()))
_, _ = w.Write(out.Bytes())
}

35
main.go
View File

@@ -248,6 +248,13 @@ func main() {
if postingEnabled && roundRobinMode {
channelNames := make([]string, 0, len(cfg.Matrix.Channels))
for name := range cfg.Matrix.Channels {
// The adventure channel drives its own posting: priority beats go
// live at ingest, bulletins wait for the daily digest. Leaving it in
// the rotation would let the scheduler post bulletins one-by-one
// (they're "classified, not yet posted") and steal them from the digest.
if cfg.Adventure.Enabled && name == cfg.Adventure.Channel {
continue
}
channelNames = append(channelNames, name)
}
rr := scheduler.New(channelNames, cfg.Posting.RoundRobin.IntervalHours, queue)
@@ -258,14 +265,35 @@ func main() {
}
}
// Adapter: the web ingest handler posts priority adventure beats live to
// Matrix through the same queue everything else uses (bypasses pacing).
// PostNow doesn't consult posting.enabled, so gate here: with posting off,
// adventure stays website-only like every other channel (nil poster also
// keeps the digest loop from starting).
var advPost web.PriorityPoster
if postingEnabled {
advPost = func(p web.AdvPost) {
queue.PostNow(poster.QueueItem{
GUID: p.GUID,
Headline: p.Headline,
Lede: p.Lede,
ImageURL: p.ImageURL,
ArticleURL: p.ArticleURL,
Source: p.Source,
Channel: p.Channel,
})
}
}
// Start the read-only web UI alongside the Matrix bot.
if cfg.Web.Enabled {
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled)
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled, cfg.Adventure, advPost)
if err != nil {
slog.Error("web server init failed", "err", err)
} else {
go ws.Start(ctx)
ws.StartPushSender(ctx)
ws.StartAdventureDigest(ctx)
}
}
@@ -370,8 +398,9 @@ func runLocal(cfg *config.Config) {
poller.Start(ctx)
slog.Info("local: pollers started")
// Local mode never connects to Matrix, so it's always web-only.
ws, err := web.New(cfg.Web, cfg.Sources, false)
// Local mode never connects to Matrix, so it's always web-only: adventure
// stories still ingest and render, but nothing posts (nil priority poster).
ws, err := web.New(cfg.Web, cfg.Sources, false, cfg.Adventure, nil)
if err != nil {
slog.Error("local: web server init failed", "err", err)
os.Exit(1)