diff --git a/internal/config/config.go b/internal/config/config.go index daeeefc..0b7349a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` @@ -228,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) diff --git a/internal/storage/queries.go b/internal/storage/queries.go index cdd41d0..999b822 100644 --- a/internal/storage/queries.go +++ b/internal/storage/queries.go @@ -132,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 @@ -252,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. diff --git a/internal/web/adventure.go b/internal/web/adventure.go new file mode 100644 index 0000000..eb0aa3b --- /dev/null +++ b/internal/web/adventure.go @@ -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 = ` + + + + + + + + %s + %s +` + +// 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::"); 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 +} diff --git a/internal/web/adventure_digest.go b/internal/web/adventure_digest.go new file mode 100644 index 0000000..8352b75 --- /dev/null +++ b/internal/web/adventure_digest.go @@ -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) +} diff --git a/internal/web/adventure_test.go b/internal/web/adventure_test.go new file mode 100644 index 0000000..c55bcfc --- /dev/null +++ b/internal/web/adventure_test.go @@ -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, " — used by the adventure section + OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none } type channelPage struct { @@ -158,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, @@ -215,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{ @@ -327,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, diff --git a/internal/web/reader_test.go b/internal/web/reader_test.go index 18f30b8..7b23b32 100644 --- a/internal/web/reader_test.go +++ b/internal/web/reader_test.go @@ -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) } diff --git a/internal/web/server.go b/internal/web/server.go index 5e1d291..0ef0fc1 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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. @@ -61,6 +66,9 @@ type Server struct { 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). @@ -72,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, @@ -99,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 @@ -147,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) @@ -165,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) diff --git a/internal/web/static/css/input.css b/internal/web/static/css/input.css index 87bfa8a..ccc1287 100644 --- a/internal/web/static/css/input.css +++ b/internal/web/static/css/input.css @@ -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); } diff --git a/internal/web/static/css/output.css b/internal/web/static/css/output.css index ee49eeb..1c8b9e3 100644 --- a/internal/web/static/css/output.css +++ b/internal/web/static/css/output.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.pete-reader-backdrop{position:absolute;inset:0;background:rgba(20,14,6,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}html[data-phase=night] .pete-reader-backdrop{background:rgba(6,8,20,.65)}.pete-reader-shell{position:relative;height:100%;width:100%;max-width:44rem;margin:0 auto;display:flex;flex-direction:column;padding:max(env(safe-area-inset-top),.75rem) 1rem .75rem;gap:.75rem}.pete-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;color:#fff}.pete-reader-progress{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:600;font-size:.85rem;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-nav{display:flex;align-items:center;gap:.4rem}.pete-reader-btn{display:inline-flex;align-items:center;justify-content:center;min-width:2.25rem;height:2.25rem;padding:0 .7rem;border-radius:9999px;background:hsla(0,0%,100%,.14);color:#fff;font-weight:700;font-size:.95rem;border:2px solid hsla(0,0%,100%,.18);cursor:pointer;transition:background .15s ease,transform .15s ease,opacity .15s ease}.pete-reader-btn:hover{background:hsla(0,0%,100%,.28);transform:translateY(-1px)}.pete-reader-btn:disabled{opacity:.35;cursor:default;transform:none}.pete-reader-btn-open{background:var(--accent);border-color:transparent;color:#1c1305;text-decoration:none}.pete-reader-btn-open:hover{background:var(--accent);filter:brightness(1.08)}.pete-reader-btn[aria-expanded=true],.pete-reader-btn[aria-pressed=true]{background:var(--accent);border-color:transparent;color:#1c1305}.pete-reader-type{position:relative;display:inline-flex}.pete-reader-typemenu[hidden]{display:none}.pete-reader-typemenu{position:absolute;top:calc(100% + .5rem);right:0;z-index:10;width:15rem;padding:.75rem;border-radius:1rem;background:var(--card);color:var(--ink);border:2px solid rgba(0,0,0,.08);box-shadow:0 10px 30px rgba(20,14,6,.28);display:flex;flex-direction:column;gap:.6rem}html[data-phase=night] .pete-reader-typemenu{border-color:hsla(0,0%,100%,.12)}.pete-reader-typerow{display:flex;align-items:center;justify-content:space-between;gap:.6rem}.pete-reader-typelabel{font-size:.8rem;font-weight:700;opacity:.7}.pete-reader-typebtns{display:inline-flex;gap:.25rem}.pete-reader-typebtns button{min-width:2rem;height:2rem;padding:0 .55rem;border-radius:.6rem;background:rgba(20,14,6,.06);color:var(--ink);font-weight:700;font-size:.82rem;border:2px solid transparent;cursor:pointer;transition:background .15s ease}html[data-phase=night] .pete-reader-typebtns button{background:hsla(0,0%,100%,.08)}.pete-reader-typebtns button:hover{background:rgba(20,14,6,.12)}.pete-reader-typebtns button.is-active{background:var(--accent);color:#1c1305;border-color:transparent}.pete-reader-voicerow[hidden]{display:none}.pete-reader-voice{font:inherit;font-size:.8rem;font-weight:600;padding:.25rem .4rem;border-radius:.55rem;border:1px solid rgba(20,14,6,.18);background:rgba(20,14,6,.06);color:inherit;max-width:11rem;cursor:pointer}html[data-phase=night] .pete-reader-voice{border-color:hsla(0,0%,100%,.14);background:hsla(0,0%,100%,.08)}.pete-reader-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.pete-reader-scroll:focus{outline:none}.pete-reader-article{background:var(--card);color:var(--ink);border-radius:1.75rem;border:2px solid rgba(0,0,0,.06);box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.18);padding:clamp(1.25rem,4vw,2.5rem)}html[data-phase=night] .pete-reader-article{border-color:hsla(0,0%,100%,.08)}.pete-reader-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.75rem;margin-bottom:.85rem}.pete-reader-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:9999px;padding:.15rem .65rem;color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.pete-reader-source{font-weight:700;opacity:.75}.pete-reader-time{opacity:.55}.pete-reader-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;line-height:1.15;letter-spacing:-.01em;font-size:clamp(1.6rem,4.5vw,2.4rem);margin-bottom:1rem}.pete-reader-hero{width:100%;border-radius:1.1rem;margin-bottom:1.25rem;background:rgba(0,0,0,.05)}.pete-reader-scroll{--reader-fs:1.08rem}.pete-reader-body{font-size:var(--reader-fs);line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.pete-reader-scroll.is-size-s{--reader-fs:0.98rem}.pete-reader-scroll.is-size-m{--reader-fs:1.08rem}.pete-reader-scroll.is-size-l{--reader-fs:1.22rem}.pete-reader-scroll.is-size-xl{--reader-fs:1.4rem}.pete-reader-scroll.is-serif .pete-reader-body{font-family:Georgia,Iowan Old Style,Times New Roman,serif}.pete-reader-scroll.is-sepia .pete-reader-article{background:#f6ecd6;color:#4a3a28;border-color:rgba(74,58,40,.16)}.pete-reader-scroll.is-sepia .pete-reader-note{border-top-color:rgba(74,58,40,.18);opacity:.8}.pete-reader-scroll.is-sepia .pete-reader-hero{background:rgba(74,58,40,.08)}.pete-reader-body p.is-speaking{background:color-mix(in srgb,var(--accent) 32%,transparent);border-radius:.4rem;box-shadow:0 0 0 .35rem color-mix(in srgb,var(--accent) 32%,transparent)}.pete-reader-note{margin-top:1.25rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.1);font-size:.85rem;opacity:.7}html[data-phase=night] .pete-reader-note{border-color:hsla(0,0%,100%,.12)}.pete-reader-note a{color:var(--accent);font-weight:700}.pete-reader-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.75rem;padding:clamp(2rem,8vw,4rem) 1rem;color:var(--ink)}.pete-reader-empty-emoji{font-size:2.5rem}.pete-reader-toast{position:absolute;left:50%;bottom:4.5rem;transform:translate(-50%,.5rem);z-index:20;padding:.5rem .9rem;border-radius:9999px;background:rgba(20,14,6,.9);color:#fff;font-weight:700;font-size:.85rem;box-shadow:0 8px 24px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease}.pete-reader-toast.is-shown{opacity:1;transform:translate(-50%)}.pete-reader-hint{text-align:center;color:hsla(0,0%,100%,.85);font-size:.75rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-hint kbd{font-family:ui-monospace,monospace;background:hsla(0,0%,100%,.16);border-radius:.3rem;padding:.05rem .3rem}@media (max-width:640px){.pete-reader-hint{display:none}.pete-reader-bar{flex-wrap:wrap}.pete-reader-nav{flex-wrap:wrap;justify-content:flex-end;row-gap:.4rem}}.pete-reader-related{width:100%;margin:.85rem auto 0}.pete-reader-related-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;font-size:.95rem;color:#fff;margin:0 0 .6rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-related-grid{display:grid;gap:.6rem}.pete-reader-related-card{display:flex;align-items:center;gap:.75rem;background:var(--card);color:var(--ink);border-radius:1rem;border:2px solid rgba(0,0,0,.06);padding:.6rem;text-decoration:none;transition:transform .15s ease,box-shadow .15s ease}.pete-reader-related-card:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(60,40,20,.16)}html[data-phase=night] .pete-reader-related-card{border-color:hsla(0,0%,100%,.08)}.pete-reader-related-thumb{width:4.5rem;height:3.25rem;flex-shrink:0;-o-object-fit:cover;object-fit:cover;border-radius:.6rem;background:rgba(0,0,0,.06)}.pete-reader-related-meta{min-width:0;display:flex;flex-direction:column;gap:.25rem}.pete-reader-related-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.4rem;font-size:.7rem}.pete-reader-related-source{font-weight:700;opacity:.7}.pete-reader-related-headline{font-weight:700;line-height:1.25;font-size:.92rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}[data-story-card][data-read="1"]{opacity:.5;transition:opacity .2s ease}[data-story-card][data-read="1"]:hover{opacity:1}[data-story-card][data-read="1"]:after{content:"✓";position:absolute;top:.6rem;right:.6rem;z-index:6;display:grid;place-items:center;height:1.5rem;width:1.5rem;border-radius:9999px;background:rgba(20,14,6,.72);color:#fff;font-size:.8rem;font-weight:700}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.order-none{order:0}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[28rem\]{min-width:28rem}.min-w-\[52rem\]{min-width:52rem}.max-w-2xl{max-width:42rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.basis-full{flex-basis:100%}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/15{background-color:rgba(239,68,68,.15)}.bg-transparent{background-color:transparent}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#1c1305\]{--tw-text-opacity:1;color:rgb(28 19 5/var(--tw-text-opacity,1))}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/85{color:hsla(0,0%,100%,.85)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.bg-theme-kids{background-color:#14b8a6}.bg-theme-finance{background-color:#10b981}.bg-theme-lego{background-color:#d01012}.text-theme-gaming{color:#2d8a5a}.text-theme-tech{color:#2f7fb8}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.text-theme-kids{color:#0f766e}.text-theme-finance{color:#059669}.text-theme-lego{color:#b00d0e}.decoration-theme-gaming{text-decoration-color:#4caf7d}.decoration-theme-tech{text-decoration-color:#5aa9e6}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.decoration-theme-kids{text-decoration-color:#14b8a6}.decoration-theme-finance{text-decoration-color:#10b981}.decoration-theme-lego{text-decoration-color:#d01012}.border-theme-gaming{border-color:#4caf7d}.border-theme-tech{border-color:#5aa9e6}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}.last\:border-0:last-child{border-width:0}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:p-8{padding:2rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{font-family:Nunito,system-ui,sans-serif}html{scroll-behavior:smooth}body,html{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pete-channel-nav{scrollbar-width:none;-ms-overflow-style:none}.pete-channel-nav::-webkit-scrollbar{display:none}.pete-reader-backdrop{position:absolute;inset:0;background:rgba(20,14,6,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}html[data-phase=night] .pete-reader-backdrop{background:rgba(6,8,20,.65)}.pete-reader-shell{position:relative;height:100%;width:100%;max-width:44rem;margin:0 auto;display:flex;flex-direction:column;padding:max(env(safe-area-inset-top),.75rem) 1rem .75rem;gap:.75rem}.pete-reader-bar{display:flex;align-items:center;justify-content:space-between;gap:.75rem;color:#fff}.pete-reader-progress{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:600;font-size:.85rem;letter-spacing:.02em;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-nav{display:flex;align-items:center;gap:.4rem}.pete-reader-btn{display:inline-flex;align-items:center;justify-content:center;min-width:2.25rem;height:2.25rem;padding:0 .7rem;border-radius:9999px;background:hsla(0,0%,100%,.14);color:#fff;font-weight:700;font-size:.95rem;border:2px solid hsla(0,0%,100%,.18);cursor:pointer;transition:background .15s ease,transform .15s ease,opacity .15s ease}.pete-reader-btn:hover{background:hsla(0,0%,100%,.28);transform:translateY(-1px)}.pete-reader-btn:disabled{opacity:.35;cursor:default;transform:none}.pete-reader-btn-open{background:var(--accent);border-color:transparent;color:#1c1305;text-decoration:none}.pete-reader-btn-open:hover{background:var(--accent);filter:brightness(1.08)}.pete-reader-btn[aria-expanded=true],.pete-reader-btn[aria-pressed=true]{background:var(--accent);border-color:transparent;color:#1c1305}.pete-reader-type{position:relative;display:inline-flex}.pete-reader-typemenu[hidden]{display:none}.pete-reader-typemenu{position:absolute;top:calc(100% + .5rem);right:0;z-index:10;width:15rem;padding:.75rem;border-radius:1rem;background:var(--card);color:var(--ink);border:2px solid rgba(0,0,0,.08);box-shadow:0 10px 30px rgba(20,14,6,.28);display:flex;flex-direction:column;gap:.6rem}html[data-phase=night] .pete-reader-typemenu{border-color:hsla(0,0%,100%,.12)}.pete-reader-typerow{display:flex;align-items:center;justify-content:space-between;gap:.6rem}.pete-reader-typelabel{font-size:.8rem;font-weight:700;opacity:.7}.pete-reader-typebtns{display:inline-flex;gap:.25rem}.pete-reader-typebtns button{min-width:2rem;height:2rem;padding:0 .55rem;border-radius:.6rem;background:rgba(20,14,6,.06);color:var(--ink);font-weight:700;font-size:.82rem;border:2px solid transparent;cursor:pointer;transition:background .15s ease}html[data-phase=night] .pete-reader-typebtns button{background:hsla(0,0%,100%,.08)}.pete-reader-typebtns button:hover{background:rgba(20,14,6,.12)}.pete-reader-typebtns button.is-active{background:var(--accent);color:#1c1305;border-color:transparent}.pete-reader-voicerow[hidden]{display:none}.pete-reader-voice{font:inherit;font-size:.8rem;font-weight:600;padding:.25rem .4rem;border-radius:.55rem;border:1px solid rgba(20,14,6,.18);background:rgba(20,14,6,.06);color:inherit;max-width:11rem;cursor:pointer}html[data-phase=night] .pete-reader-voice{border-color:hsla(0,0%,100%,.14);background:hsla(0,0%,100%,.08)}.pete-reader-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.pete-reader-scroll:focus{outline:none}.pete-reader-article{background:var(--card);color:var(--ink);border-radius:1.75rem;border:2px solid rgba(0,0,0,.06);box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.18);padding:clamp(1.25rem,4vw,2.5rem)}html[data-phase=night] .pete-reader-article{border-color:hsla(0,0%,100%,.08)}.pete-reader-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.75rem;margin-bottom:.85rem}.pete-reader-chip{display:inline-flex;align-items:center;gap:.35rem;border-radius:9999px;padding:.15rem .65rem;color:#fff;font-weight:700;text-transform:uppercase;letter-spacing:.06em}.pete-reader-source{font-weight:700;opacity:.75}.pete-reader-time{opacity:.55}.pete-reader-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;line-height:1.15;letter-spacing:-.01em;font-size:clamp(1.6rem,4.5vw,2.4rem);margin-bottom:1rem}.pete-reader-hero{width:100%;border-radius:1.1rem;margin-bottom:1.25rem;background:rgba(0,0,0,.05)}.pete-reader-scroll{--reader-fs:1.08rem}.pete-reader-body{font-size:var(--reader-fs);line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.pete-reader-scroll.is-size-s{--reader-fs:0.98rem}.pete-reader-scroll.is-size-m{--reader-fs:1.08rem}.pete-reader-scroll.is-size-l{--reader-fs:1.22rem}.pete-reader-scroll.is-size-xl{--reader-fs:1.4rem}.pete-reader-scroll.is-serif .pete-reader-body{font-family:Georgia,Iowan Old Style,Times New Roman,serif}.pete-reader-scroll.is-sepia .pete-reader-article{background:#f6ecd6;color:#4a3a28;border-color:rgba(74,58,40,.16)}.pete-reader-scroll.is-sepia .pete-reader-note{border-top-color:rgba(74,58,40,.18);opacity:.8}.pete-reader-scroll.is-sepia .pete-reader-hero{background:rgba(74,58,40,.08)}.pete-reader-body p.is-speaking{background:color-mix(in srgb,var(--accent) 32%,transparent);border-radius:.4rem;box-shadow:0 0 0 .35rem color-mix(in srgb,var(--accent) 32%,transparent)}.pete-reader-note{margin-top:1.25rem;padding-top:1rem;border-top:1px solid rgba(0,0,0,.1);font-size:.85rem;opacity:.7}html[data-phase=night] .pete-reader-note{border-color:hsla(0,0%,100%,.12)}.pete-reader-note a{color:var(--accent);font-weight:700}.pete-reader-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:.75rem;padding:clamp(2rem,8vw,4rem) 1rem;color:var(--ink)}.pete-reader-empty-emoji{font-size:2.5rem}.pete-reader-toast{position:absolute;left:50%;bottom:4.5rem;transform:translate(-50%,.5rem);z-index:20;padding:.5rem .9rem;border-radius:9999px;background:rgba(20,14,6,.9);color:#fff;font-weight:700;font-size:.85rem;box-shadow:0 8px 24px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease}.pete-reader-toast.is-shown{opacity:1;transform:translate(-50%)}.pete-reader-hint{text-align:center;color:hsla(0,0%,100%,.85);font-size:.75rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-hint kbd{font-family:ui-monospace,monospace;background:hsla(0,0%,100%,.16);border-radius:.3rem;padding:.05rem .3rem}@media (max-width:640px){.pete-reader-hint{display:none}.pete-reader-bar{flex-wrap:wrap}.pete-reader-nav{flex-wrap:wrap;justify-content:flex-end;row-gap:.4rem}}.pete-reader-related{width:100%;margin:.85rem auto 0}.pete-reader-related-title{font-family:Fredoka,Nunito,system-ui,sans-serif;font-weight:700;font-size:.95rem;color:#fff;margin:0 0 .6rem;text-shadow:0 1px 2px rgba(0,0,0,.4)}.pete-reader-related-grid{display:grid;gap:.6rem}.pete-reader-related-card{display:flex;align-items:center;gap:.75rem;background:var(--card);color:var(--ink);border-radius:1rem;border:2px solid rgba(0,0,0,.06);padding:.6rem;text-decoration:none;transition:transform .15s ease,box-shadow .15s ease}.pete-reader-related-card:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(60,40,20,.16)}html[data-phase=night] .pete-reader-related-card{border-color:hsla(0,0%,100%,.08)}.pete-reader-related-thumb{width:4.5rem;height:3.25rem;flex-shrink:0;-o-object-fit:cover;object-fit:cover;border-radius:.6rem;background:rgba(0,0,0,.06)}.pete-reader-related-meta{min-width:0;display:flex;flex-direction:column;gap:.25rem}.pete-reader-related-eyebrow{display:flex;align-items:center;flex-wrap:wrap;gap:.4rem;font-size:.7rem}.pete-reader-related-source{font-weight:700;opacity:.7}.pete-reader-related-headline{font-weight:700;line-height:1.25;font-size:.92rem;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}[data-story-card][data-read="1"]{opacity:.5;transition:opacity .2s ease}[data-story-card][data-read="1"]:hover{opacity:1}[data-story-card][data-read="1"]:after{content:"✓";position:absolute;top:.6rem;right:.6rem;z-index:6;display:grid;place-items:center;height:1.5rem;width:1.5rem;border-radius:9999px;background:rgba(20,14,6,.72);color:#fff;font-size:.8rem;font-weight:700}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-6{right:-1.5rem}.-top-6{top:-1.5rem}.-z-10{z-index:-10}.-z-\[5\]{z-index:-5}.z-50{z-index:50}.order-none{order:0}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.-mt-1{margin-top:-.25rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.line-clamp-2{-webkit-line-clamp:2}.line-clamp-2,.line-clamp-3{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical}.line-clamp-3{-webkit-line-clamp:3}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.h-12{height:3rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-full{height:100%}.max-h-\[55vh\]{max-height:55vh}.max-h-\[60vh\]{max-height:60vh}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[28rem\]{min-width:28rem}.min-w-\[52rem\]{min-width:52rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-6xl{max-width:72rem}.max-w-\[7rem\]{max-width:7rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.basis-full{flex-basis:100%}.-translate-y-0{--tw-translate-y:-0px}.-translate-y-0,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-transparent{border-color:transparent}.bg-\[color\:var\(--accent\)\]{background-color:var(--accent)}.bg-\[color\:var\(--bg\)\]{background-color:var(--bg)}.bg-\[color\:var\(--bg-grad\)\]{background-color:var(--bg-grad)}.bg-\[color\:var\(--card\)\]{background-color:var(--card)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/15{background-color:rgba(239,68,68,.15)}.bg-transparent{background-color:transparent}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-\[-4px\]{vertical-align:-4px}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12rem\]{font-size:12rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#1c1305\]{--tw-text-opacity:1;color:rgb(28 19 5/var(--tw-text-opacity,1))}.text-\[color\:var\(--ink\)\]{color:var(--ink)}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/85{color:hsla(0,0%,100%,.85)}.underline{text-decoration-line:underline}.decoration-4{text-decoration-thickness:4px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.accent-\[color\:var\(--accent\)\]{accent-color:var(--accent)}.opacity-20{opacity:.2}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.sepia{--tw-sepia:sepia(100%)}.filter,.sepia{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-1000{transition-duration:1s}.duration-500{transition-duration:.5s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.font-display{font-family:Fredoka,Nunito,system-ui,sans-serif;letter-spacing:-.01em}.shadow-pete{box-shadow:0 4px 0 rgba(60,40,20,.1),0 8px 24px rgba(60,40,20,.08)}.shadow-pete-lg{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.bg-theme-gaming{background-color:#4caf7d}.bg-theme-tech{background-color:#5aa9e6}.bg-theme-politics{background-color:#e07a5f}.bg-theme-eu{background-color:#039}.bg-theme-music{background-color:#b079d6}.bg-theme-anime{background-color:#ec5e8a}.bg-theme-foss{background-color:#d97706}.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}.text-theme-politics{color:#b8523a}.text-theme-eu{color:#039}.text-theme-music{color:#8a4fb8}.text-theme-anime{color:#c33a6a}.text-theme-foss{color:#b8530a}.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}.decoration-theme-politics{text-decoration-color:#e07a5f}.decoration-theme-eu{text-decoration-color:#039}.decoration-theme-music{text-decoration-color:#b079d6}.decoration-theme-anime{text-decoration-color:#ec5e8a}.decoration-theme-foss{text-decoration-color:#d97706}.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}.border-theme-politics{border-color:#e07a5f}.border-theme-eu{border-color:#039}.border-theme-music{border-color:#b079d6}.border-theme-anime{border-color:#ec5e8a}.border-theme-foss{border-color:#d97706}.border-theme-kids{border-color:#14b8a6}.border-theme-finance{border-color:#10b981}.border-theme-lego{border-color:#d01012}.border-theme-adventure{border-color:#6d4bd8}.glow-theme-gaming{box-shadow:0 0 0 2px rgba(76,175,125,.35),0 0 24px 4px rgba(76,175,125,.45)}.glow-theme-gaming,.glow-theme-tech{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-tech{box-shadow:0 0 0 2px rgba(90,169,230,.35),0 0 24px 4px rgba(90,169,230,.45)}.glow-theme-politics{box-shadow:0 0 0 2px rgba(224,122,95,.35),0 0 24px 4px rgba(224,122,95,.45)}.glow-theme-eu,.glow-theme-politics{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-eu{box-shadow:0 0 0 2px rgba(0,51,153,.35),0 0 24px 4px rgba(0,51,153,.45)}.glow-theme-music{box-shadow:0 0 0 2px rgba(176,121,214,.35),0 0 24px 4px rgba(176,121,214,.45)}.glow-theme-anime,.glow-theme-music{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-anime{box-shadow:0 0 0 2px rgba(236,94,138,.35),0 0 24px 4px rgba(236,94,138,.45)}.glow-theme-foss{box-shadow:0 0 0 2px rgba(217,119,6,.35),0 0 24px 4px rgba(217,119,6,.45)}.glow-theme-foss,.glow-theme-kids{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-kids{box-shadow:0 0 0 2px rgba(20,184,166,.35),0 0 24px 4px rgba(20,184,166,.45)}.glow-theme-finance{box-shadow:0 0 0 2px rgba(16,185,129,.35),0 0 24px 4px rgba(16,185,129,.45)}.glow-theme-finance,.glow-theme-lego{animation:pete-glow-pulse 2.4s ease-in-out infinite}.glow-theme-lego{box-shadow:0 0 0 2px rgba(208,16,18,.35),0 0 24px 4px rgba(208,16,18,.45)}.glow-theme-adventure{box-shadow:0 0 0 2px rgba(109,75,216,.35),0 0 24px 4px rgba(109,75,216,.45);animation:pete-glow-pulse 2.4s ease-in-out infinite}@keyframes pete-glow-pulse{0%,to{filter:brightness(1)}50%{filter:brightness(1.08)}}.line-clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.paywall-stamp{position:absolute;inset:0;pointer-events:none;display:flex;align-items:center;justify-content:center;z-index:5;background:rgba(255,250,240,.18)}.paywall-stamp:before{content:"PAYWALLED";font-family:var(--font-display,ui-sans-serif),system-ui,sans-serif;font-weight:900;font-size:clamp(1rem,3.2vw,1.75rem);letter-spacing:.12em;color:rgba(180,30,30,.85);border:.28rem double rgba(180,30,30,.85);padding:.3rem .9rem;transform:rotate(-15deg);text-shadow:0 1px 0 hsla(0,0%,100%,.4);box-shadow:inset 0 0 0 2px hsla(0,0%,100%,.15);background:rgba(255,240,230,.55);border-radius:.25rem;filter:contrast(1.05)}:root,html[data-phase=day]{--bg:#fff7e4;--bg-grad:#ffeec2;--card:#fff;--ink:#3a2e1f;--accent:#f2a541}html[data-phase=dawn]{--bg:#ffe7d6;--bg-grad:#ffc9c9;--card:#fff4ea;--ink:#4a2e2a;--accent:#ff8a65}html[data-phase=dusk]{--bg:#ffd6a8;--bg-grad:#f7a07e;--card:#fff1de;--ink:#3d2417;--accent:#e6553a}html[data-phase=night]{--bg:#1a1f3a;--bg-grad:#2a3358;--card:#2d365a;--ink:#f1ecd8;--accent:#f9d976}.last\:border-0:last-child{border-width:0}.empty\:hidden:empty{display:none}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:text-\[color\:var\(--ink\)\]:hover{color:var(--ink)}.hover\:brightness-105:hover{--tw-brightness:brightness(1.05);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:shadow-pete-lg:hover{box-shadow:0 6px 0 rgba(60,40,20,.12),0 16px 32px rgba(60,40,20,.12)}.focus\:border-\[color\:var\(--accent\)\]:focus{border-color:var(--accent)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:-rotate-6{--tw-rotate:-6deg}.group:hover .group-hover\:-rotate-6,.group:hover .group-hover\:scale-105{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:underline{text-decoration-line:underline}@media (min-width:640px){.sm\:ml-auto{margin-left:auto}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:basis-auto{flex-basis:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:p-8{padding:2rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (prefers-color-scheme:dark){.dark\:text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dark\:text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.dark\:text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.dark\:text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}} \ No newline at end of file diff --git a/internal/web/status_render_test.go b/internal/web/status_render_test.go index 4ce6611..dba7cc2 100644 --- a/internal/web/status_render_test.go +++ b/internal/web/status_render_test.go @@ -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) } diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 23a83b4..5f8778f 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -4,6 +4,8 @@ {{block "title" .}}{{.SiteTitle}}{{end}} + {{if .NoIndex}}{{end}} + {{if .OGImage}}{{end}} diff --git a/internal/web/templates/story.html b/internal/web/templates/story.html new file mode 100644 index 0000000..6c88162 --- /dev/null +++ b/internal/web/templates/story.html @@ -0,0 +1,27 @@ +{{define "title"}}{{.Headline}} — {{.SiteTitle}}{{end}} + +{{define "main"}} +
+ + +
+ +
+

{{.Emoji}} {{.EventLabel}}

+

{{.Headline}}

+

Reported {{.When}}{{if .Region}} · {{.Region}}{{end}}

+
+
+ +
+

{{.Body}}

+

+ Reporting from the realm, this is Pete. +

+
+
+{{end}} diff --git a/internal/web/thumbs.go b/internal/web/thumbs.go index ecaf32c..60dade3 100644 --- a/internal/web/thumbs.go +++ b/internal/web/thumbs.go @@ -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) } diff --git a/internal/web/trending_test.go b/internal/web/trending_test.go index 989a7d6..a2d5f05 100644 --- a/internal/web/trending_test.go +++ b/internal/web/trending_test.go @@ -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) } diff --git a/main.go b/main.go index 22bd476..7202836 100644 --- a/main.go +++ b/main.go @@ -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)