From 55aa167151c023e49f49bcd69430d2935cbd6c9f Mon Sep 17 00:00:00 2001
From: prosolis <5590409+prosolis@users.noreply.github.com>
Date: Mon, 6 Jul 2026 22:46:14 -0700
Subject: [PATCH] Add feed/reader mode with full-article capture at ingest
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reader mode presents the stories on a page one at a time in a focused
overlay, marking each read as it's shown. Left/right arrows (or the header
book button / `f`) page through them; read stories dim on the grid. Read
state is device-local in localStorage.
Backing this required actually capturing article bodies, which Pete wasn't
doing — it kept only the RSS ]+src=["']([^"']+)["']`)
+
+ // Used by extractContentText to preserve paragraph structure when turning
+ // content:encoded HTML into plain text.
+ blockCloseRe = regexp.MustCompile(`(?i)(p|div|li|h[1-6]|blockquote|article|section|figure|figcaption|ul|ol|table|tr|pre)>`)
+ brRe = regexp.MustCompile(`(?i)
`)
+ hspaceRe = regexp.MustCompile(`[ \t\f\r]+`)
+ manyNewlineRe = regexp.MustCompile(`\n{3,}`)
)
+// maxContentChars bounds the stored article text. Generous enough for any real
+// article while keeping row size and the reader payload sane.
+const maxContentChars = 20000
+
// FetchFeed fetches and parses an RSS/Atom feed, returning items. ua overrides
// the User-Agent header; pass "" to use Pete's honest default bot UA.
func FetchFeed(feedURL, ua string) ([]FeedItem, error) {
@@ -79,6 +91,7 @@ func FetchFeed(feedURL, ua string) ([]FeedItem, error) {
GUID: itemGUID(item),
Headline: strings.TrimSpace(item.Title),
Lede: extractLede(item.Description),
+ Content: extractContentText(item.Content),
ImageURL: NormalizeImageURL(extractImageURL(item)),
ArticleURL: strings.TrimSpace(item.Link),
Language: itemLanguage(item),
@@ -142,6 +155,31 @@ func extractLede(desc string) string {
return strings.TrimSpace(text)
}
+// extractContentText converts a feed's content:encoded HTML into plain text
+// with paragraph breaks preserved, for reader mode. Block-level boundaries
+// (
First paragraph with a link & entity.
` + + `Second line one.
line two.
" + strings.Repeat("x", maxContentChars+500) + "
" + if n := len(extractContentText(long)); n > maxContentChars { + t.Errorf("capped length = %d, want <= %d", n, maxContentChars) + } +} diff --git a/internal/ingestion/poller.go b/internal/ingestion/poller.go index f511974..8056f83 100644 --- a/internal/ingestion/poller.go +++ b/internal/ingestion/poller.go @@ -160,6 +160,10 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e "guid", items[i].GUID, "url", originalURL, "source", src.Name) continue } + // bodyText is the article text we may store for reader mode. It starts + // as the body scraped during the fetch above and is upgraded to the + // snapshot's body if we end up swapping in an archive snapshot. + bodyText := meta.BodyText gated := meta.Gated() || (meta.Fetched && meta.BodyChars < PaywallBodyThreshold) // paywalled tracks whether the link the user will click is still // gated — i.e. gating was detected AND no archive workaround worked. @@ -175,6 +179,9 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e if items[i].ImageURL == "" && snapMeta.ImageURL != "" { items[i].ImageURL = snapMeta.ImageURL } + if snapMeta.BodyText != "" { + bodyText = snapMeta.BodyText + } workedAround = true slog.Info("paywall detected, using archive snapshot", "guid", items[i].GUID, "original", originalURL, @@ -209,10 +216,18 @@ func (p *Poller) pollOnceWithErr(ctx context.Context, src config.SourceConfig) e if publishedAt > seenAt { publishedAt = seenAt } + // Reader-mode body: prefer whichever source gave us more complete text. + // Feeds that ship full content:encoded usually win; feeds that only + // syndicate a snippet fall back to the scraped article body. + content := items[i].Content + if len(bodyText) > len(content) { + content = bodyText + } if err := storage.InsertStory(&storage.Story{ GUID: items[i].GUID, Headline: items[i].Headline, Lede: items[i].Lede, + Content: content, ImageURL: items[i].ImageURL, ArticleURL: items[i].ArticleURL, URLCanonical: canonical, diff --git a/internal/storage/db.go b/internal/storage/db.go index 5040344..10a9302 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -81,6 +81,10 @@ func runMigrations(d *sql.DB) error { addColumnIfMissing(d, "stories", "url_canonical", "TEXT") addColumnIfMissing(d, "stories", "headline_norm", "TEXT") addColumnIfMissing(d, "stories", "paywalled", "INTEGER NOT NULL DEFAULT 0") + // content holds the full article text (feed content:encoded when present, + // else the body scraped during paywall detection) for reader mode. Stories + // ingested before this column existed simply have NULL and fall back to lede. + addColumnIfMissing(d, "stories", "content", "TEXT") addColumnIfMissing(d, "stories", "published_at", "INTEGER") addColumnIfMissing(d, "post_log", "url_canonical", "TEXT") addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0") diff --git a/internal/storage/queries.go b/internal/storage/queries.go index ffd1194..e9f58e3 100644 --- a/internal/storage/queries.go +++ b/internal/storage/queries.go @@ -38,13 +38,28 @@ func InsertStory(s *Story) error { publishedAt = s.PublishedAt } _, err := Get().Exec( - `INSERT INTO stories (guid, headline, lede, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - s.GUID, s.Headline, s.Lede, s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt, + `INSERT INTO stories (guid, headline, lede, content, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt, ) return err } +// GetStoryReaderText returns the stored full article text and lede for a single +// story, for reader mode. found is false when no story has that id. +func GetStoryReaderText(id int64) (content, lede string, found bool, err error) { + var c sql.NullString + var l sql.NullString + row := Get().QueryRow(`SELECT content, lede FROM stories WHERE id = ?`, id) + switch err = row.Scan(&c, &l); { + case errors.Is(err, sql.ErrNoRows): + return "", "", false, nil + case err != nil: + return "", "", false, err + } + return c.String, l.String, true, nil +} + // nullIfEmpty returns a SQL NULL for empty strings so partial unique indexes // (WHERE col IS NOT NULL) can hold multiple "unknown" rows without conflict. func nullIfEmpty(s string) any { @@ -236,7 +251,7 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) { // channels (_discarded, _duplicate) are excluded. func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) { rows, err := Get().Query( - `SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at, + `SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at, EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted FROM stories s WHERE s.classified = 1 @@ -250,7 +265,7 @@ func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) var out []Story for rows.Next() { var s Story - if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil { + if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil { return nil, err } out = append(out, s) @@ -262,7 +277,7 @@ func ListClassifiedByChannel(channel string, limit, offset int) ([]Story, error) // channels, newest first. Sentinel channels are excluded. func ListAllClassified(limit, offset int) ([]Story, error) { rows, err := Get().Query( - `SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at, + `SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at, EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted FROM stories s WHERE s.classified = 1 @@ -277,7 +292,7 @@ func ListAllClassified(limit, offset int) ([]Story, error) { var out []Story for rows.Next() { var s Story - if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil { + if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil { return nil, err } out = append(out, s) @@ -289,7 +304,7 @@ func ListAllClassified(limit, offset int) ([]Story, error) { // Matrix, ordered by post time (newest first). Posted is always true. func ListRecentlyPosted(limit int) ([]Story, error) { rows, err := Get().Query( - `SELECT s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at + `SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at FROM stories s JOIN post_log p ON p.guid = s.guid GROUP BY s.guid @@ -302,7 +317,7 @@ func ListRecentlyPosted(limit int) ([]Story, error) { var out []Story for rows.Next() { var s Story - if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt); err != nil { + if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt); err != nil { return nil, err } s.Posted = true diff --git a/internal/storage/schema.go b/internal/storage/schema.go index 5c5463f..525e13e 100644 --- a/internal/storage/schema.go +++ b/internal/storage/schema.go @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS stories ( guid TEXT UNIQUE NOT NULL, headline TEXT NOT NULL, lede TEXT, + content TEXT, image_url TEXT, article_url TEXT NOT NULL, url_canonical TEXT, diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index e70f12b..9a21852 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -61,6 +61,67 @@ func TestInsertStoryAndGUIDSeen(t *testing.T) { } } +func TestGetStoryReaderText(t *testing.T) { + setupTestDB(t) + + s := &Story{ + GUID: "reader-guid", + Headline: "Reader Headline", + Lede: "Short lede.", + Content: "First paragraph.\n\nSecond paragraph.", + ArticleURL: "https://example.com/reader", + Source: "Src", + SeenAt: 1700000000, + } + if err := InsertStory(s); err != nil { + t.Fatal(err) + } + var id int64 + if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, s.GUID).Scan(&id); err != nil { + t.Fatal(err) + } + + content, lede, found, err := GetStoryReaderText(id) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("expected story to be found") + } + if content != s.Content { + t.Errorf("content = %q, want %q", content, s.Content) + } + if lede != s.Lede { + t.Errorf("lede = %q, want %q", lede, s.Lede) + } + + // A story ingested before content capture (no content) is still found, with + // an empty content string so the reader falls back to the lede. + bare := &Story{GUID: "bare-guid", Headline: "H", Lede: "Only a lede.", ArticleURL: "https://a.com", Source: "S", SeenAt: 1} + if err := InsertStory(bare); err != nil { + t.Fatal(err) + } + var bareID int64 + if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, bare.GUID).Scan(&bareID); err != nil { + t.Fatal(err) + } + content, lede, found, err = GetStoryReaderText(bareID) + if err != nil || !found { + t.Fatalf("bare story: found=%v err=%v", found, err) + } + if content != "" { + t.Errorf("bare content = %q, want empty", content) + } + if lede != "Only a lede." { + t.Errorf("bare lede = %q", lede) + } + + // Unknown id reports not-found rather than erroring. + if _, _, found, err = GetStoryReaderText(999999); err != nil || found { + t.Errorf("unknown id: found=%v err=%v, want found=false err=nil", found, err) + } +} + func TestInsertStoryDuplicateGUID(t *testing.T) { setupTestDB(t) diff --git a/internal/storage/types.go b/internal/storage/types.go index d81fab7..cb34acc 100644 --- a/internal/storage/types.go +++ b/internal/storage/types.go @@ -6,6 +6,7 @@ type Story struct { GUID string Headline string Lede string + Content string // full article text for reader mode (feed content:encoded or scraped body); "" when unavailable ImageURL string ArticleURL string URLCanonical string diff --git a/internal/web/handlers.go b/internal/web/handlers.go index e111369..e4b3bf7 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -17,6 +17,7 @@ const pageSize = 24 // StoryView is the trimmed-down record used in templates. type StoryView struct { + ID int64 Headline string Lede string ImageURL string @@ -30,6 +31,7 @@ type StoryView struct { func toView(s storage.Story) StoryView { return StoryView{ + ID: s.ID, Headline: s.Headline, Lede: s.Lede, ImageURL: s.ImageURL, @@ -334,6 +336,31 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"results": results}) } +// handleArticle serves the stored full text of a single story for reader mode. +// The client already has headline/image/source/time from the card's data +// attributes, so this returns just the body text (and the lede as a fallback +// for stories ingested before content was captured). +func (s *Server) handleArticle(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64) + if err != nil || id <= 0 { + http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest) + return + } + content, lede, found, err := storage.GetStoryReaderText(id) + if err != nil { + slog.Error("web: article read failed", "id", id, "err", err) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + if !found { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"content": content, "lede": lede}) +} + func shortTimeAgo(t time.Time) string { d := time.Since(t) switch { diff --git a/internal/web/reader_test.go b/internal/web/reader_test.go new file mode 100644 index 0000000..df5d1d1 --- /dev/null +++ b/internal/web/reader_test.go @@ -0,0 +1,95 @@ +package web + +import ( + "encoding/json" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "pete/internal/config" + "pete/internal/storage" +) + +// TestReaderCardDataAndArticleAPI exercises the reader-mode path end to end: a +// classified story renders a card carrying its id + headline data attributes, +// and /api/article returns the stored full text for that id. +func TestReaderCardDataAndArticleAPI(t *testing.T) { + storage.Close() + if err := storage.Init(filepath.Join(t.TempDir(), "reader.db")); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { storage.Close() }) + + story := &storage.Story{ + GUID: "reader-e2e", + Headline: "A Distinctive Reader Headline", + Lede: "The lede.", + Content: "Opening paragraph of the piece.\n\nA second paragraph with more detail.", + ArticleURL: "https://example.com/story", + Source: "Example Wire", + Channel: "tech", + Classified: true, + SeenAt: time.Now().Unix(), + } + if err := storage.InsertStory(story); err != nil { + t.Fatal(err) + } + var id int64 + if err := storage.Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, story.GUID).Scan(&id); err != nil { + t.Fatal(err) + } + + s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil) + if err != nil { + t.Fatal(err) + } + + // Index page renders the card with the reader data attributes. + rw := httptest.NewRecorder() + s.handleIndex(rw, httptest.NewRequest("GET", "/", nil)) + body := rw.Body.String() + if rw.Code != 200 { + t.Fatalf("index status = %d", rw.Code) + } + for _, want := range []string{ + `data-id="` + strconv.FormatInt(id, 10) + `"`, + `data-headline="A Distinctive Reader Headline"`, + `data-story-card`, + } { + if !strings.Contains(body, want) { + t.Errorf("index HTML missing %q", want) + } + } + + // The article endpoint returns the stored content for that id. + rw2 := httptest.NewRecorder() + s.handleArticle(rw2, httptest.NewRequest("GET", "/api/article?id="+strconv.FormatInt(id, 10), nil)) + if rw2.Code != 200 { + t.Fatalf("article status = %d body=%s", rw2.Code, rw2.Body.String()) + } + var got struct { + Content string `json:"content"` + Lede string `json:"lede"` + } + if err := json.Unmarshal(rw2.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Content != story.Content { + t.Errorf("content = %q, want %q", got.Content, story.Content) + } + + // A bad id is a 400, an unknown id is a 404. + for _, tc := range []struct { + q string + code int + }{{"id=0", 400}, {"id=abc", 400}, {"id=999999", 404}} { + w := httptest.NewRecorder() + s.handleArticle(w, httptest.NewRequest("GET", "/api/article?"+tc.q, nil)) + if w.Code != tc.code { + t.Errorf("article?%s status = %d, want %d", tc.q, w.Code, tc.code) + } + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 4439daf..bfc1bd1 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -118,6 +118,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) { mux.HandleFunc("GET /{$}", s.handleIndex) mux.HandleFunc("GET /weather", s.handleWeatherDemo) mux.HandleFunc("GET /api/search", s.handleSearch) + mux.HandleFunc("GET /api/article", s.handleArticle) for _, ch := range channels { ch := ch mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/static/css/input.css b/internal/web/static/css/input.css index 43292d4..b98270b 100644 --- a/internal/web/static/css/input.css +++ b/internal/web/static/css/input.css @@ -169,3 +169,185 @@ html[data-phase="night"] { filter: contrast(1.05); } } + +/* ---------------------------------------------------------------------------- + Feed / reader mode. A focused, one-story-at-a-time overlay driven by + reader.js. Left/right arrows page through the stories currently on screen, + marking each read as it's shown. Styling leans on the phase palette vars so + it recolours with day/night like everything else. +---------------------------------------------------------------------------- */ +@layer components { + .pete-reader-backdrop { + position: absolute; + inset: 0; + background: rgba(20, 14, 6, 0.55); + backdrop-filter: blur(6px); + } + html[data-phase="night"] .pete-reader-backdrop { background: rgba(6, 8, 20, 0.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), 0.75rem) 1rem 0.75rem; + gap: 0.75rem; + } + + .pete-reader-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + color: #fff; + } + .pete-reader-progress { + font-family: "Fredoka", "Nunito", system-ui, sans-serif; + font-weight: 600; + font-size: 0.85rem; + letter-spacing: 0.02em; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + } + .pete-reader-nav { display: flex; align-items: center; gap: 0.4rem; } + .pete-reader-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.25rem; + height: 2.25rem; + padding: 0 0.7rem; + border-radius: 9999px; + background: rgba(255, 255, 255, 0.14); + color: #fff; + font-weight: 700; + font-size: 0.95rem; + border: 2px solid rgba(255, 255, 255, 0.18); + cursor: pointer; + transition: background 0.15s ease, transform 0.15s ease, opacity 0.15s ease; + } + .pete-reader-btn:hover { background: rgba(255, 255, 255, 0.28); transform: translateY(-1px); } + .pete-reader-btn:disabled { opacity: 0.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-scroll { + flex: 1 1 auto; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + border-radius: 1.75rem; + } + + .pete-reader-article { + background: var(--card); + color: var(--ink); + border-radius: 1.75rem; + border: 2px solid rgba(0, 0, 0, 0.06); + box-shadow: 0 6px 0 rgba(60, 40, 20, 0.12), 0 16px 32px rgba(60, 40, 20, 0.18); + padding: clamp(1.25rem, 4vw, 2.5rem); + } + html[data-phase="night"] .pete-reader-article { border-color: rgba(255, 255, 255, 0.08); } + + .pete-reader-eyebrow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + font-size: 0.75rem; + margin-bottom: 0.85rem; + } + .pete-reader-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border-radius: 9999px; + padding: 0.15rem 0.65rem; + color: #fff; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + } + .pete-reader-source { font-weight: 700; opacity: 0.75; } + .pete-reader-time { opacity: 0.55; } + + .pete-reader-title { + font-family: "Fredoka", "Nunito", system-ui, sans-serif; + font-weight: 700; + line-height: 1.15; + letter-spacing: -0.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, 0.05); + } + + .pete-reader-body { font-size: 1.08rem; line-height: 1.75; } + .pete-reader-body p { margin-bottom: 1.05em; } + .pete-reader-body p:last-child { margin-bottom: 0; } + + .pete-reader-note { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid rgba(0, 0, 0, 0.1); + font-size: 0.85rem; + opacity: 0.7; + } + html[data-phase="night"] .pete-reader-note { border-color: rgba(255, 255, 255, 0.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: 0.75rem; + padding: clamp(2rem, 8vw, 4rem) 1rem; + color: var(--ink); + } + .pete-reader-empty-emoji { font-size: 2.5rem; } + + .pete-reader-hint { + text-align: center; + color: rgba(255, 255, 255, 0.85); + font-size: 0.75rem; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + } + .pete-reader-hint kbd { + font-family: ui-monospace, monospace; + background: rgba(255, 255, 255, 0.16); + border-radius: 0.3rem; + padding: 0.05rem 0.3rem; + } + + @media (max-width: 640px) { + .pete-reader-hint { display: none; } + } + + /* Grid treatment for stories already read in feed mode: dimmed, with a small + corner check. Hovering restores full opacity so nothing feels lost. */ + [data-story-card][data-read="1"] { opacity: 0.5; transition: opacity 0.2s ease; } + [data-story-card][data-read="1"]:hover { opacity: 1; } + [data-story-card][data-read="1"]::after { + content: "✓"; + position: absolute; + top: 0.6rem; + right: 0.6rem; + z-index: 6; + display: grid; + place-items: center; + height: 1.5rem; + width: 1.5rem; + border-radius: 9999px; + background: rgba(20, 14, 6, 0.72); + color: #fff; + font-size: 0.8rem; + font-weight: 700; + } +} diff --git a/internal/web/static/css/output.css b/internal/web/static/css/output.css index 98e26c8..b417a7b 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}.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}.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}.mx-auto{margin-left:auto;margin-right:auto}.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}.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-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-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}.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%}.shrink-0{flex-shrink:0}.-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-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;white-space:nowrap}.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-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-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}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-24{padding-bottom:6rem}.pb-4{padding-bottom:1rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-center{text-align:center}.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-\[color\:var\(--ink\)\]{color:var(--ink)}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/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-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%)}.filter{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);-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}.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\: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\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.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))}} \ 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-scroll{flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;border-radius:1.75rem}.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-body{font-size:1.08rem;line-height:1.75}.pete-reader-body p{margin-bottom:1.05em}.pete-reader-body p:last-child{margin-bottom:0}.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-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}}[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}.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}.mx-auto{margin-left:auto;margin-right:auto}.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}.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\.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\.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}.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%}.shrink-0{flex-shrink:0}.-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-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;white-space:nowrap}.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-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-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}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-24{padding-bottom:6rem}.pb-4{padding-bottom:1rem}.pt-1{padding-top:.25rem}.pt-3{padding-top:.75rem}.pt-8{padding-top:2rem}.pt-\[10vh\]{padding-top:10vh}.pt-\[12vh\]{padding-top:12vh}.text-center{text-align:center}.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-\[color\:var\(--ink\)\]{color:var(--ink)}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/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-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%)}.filter{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}.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\: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\:block{display:block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:gap-2{gap:.5rem}.sm\:gap-4{gap:1rem}.sm\:p-10{padding:2.5rem}.sm\:pt-12{padding-top:3rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.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))}} \ No newline at end of file diff --git a/internal/web/static/js/reader.js b/internal/web/static/js/reader.js new file mode 100644 index 0000000..3bec300 --- /dev/null +++ b/internal/web/static/js/reader.js @@ -0,0 +1,279 @@ +// Feed / reader mode. Presents the stories currently on the page one at a +// time in a focused overlay, marking each read as it's shown. Left/right arrow +// keys (or the on-screen buttons) page through them; the full article text is +// whatever Pete captured at ingest (content:encoded or the scraped body), +// fetched on demand from /api/article and cached for the session. +// +// Read state lives in localStorage under pete.read.v1. It's deliberately +// device-local — unlike the small prefs blob, the read set grows unbounded, so +// it isn't mirrored through PetePrefs to the account. +(function () { + var overlay = document.getElementById("pete-reader"); + if (!overlay) return; + + var READ_KEY = "pete.read.v1"; + var articleEl = overlay.querySelector("[data-reader-article]"); + var scrollEl = overlay.querySelector("[data-reader-scroll]"); + var progressEl = overlay.querySelector("[data-reader-progress]"); + var linkEl = overlay.querySelector("[data-reader-link]"); + var prevBtn = overlay.querySelector("[data-reader-prev]"); + var nextBtn = overlay.querySelector("[data-reader-next]"); + var closeBtn = overlay.querySelector("[data-reader-close]"); + var backdrop = overlay.querySelector("[data-reader-backdrop]"); + + var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled} + var index = 0; + var open = false; + var contentCache = {}; // id -> {content, lede} + var inflight = null; + + // ---- read-state store ----------------------------------------------------- + function loadRead() { + try { + var raw = localStorage.getItem(READ_KEY); + var obj = raw ? JSON.parse(raw) : {}; + return obj && typeof obj === "object" ? obj : {}; + } catch (e) { return {}; } + } + function saveRead(set) { + try { localStorage.setItem(READ_KEY, JSON.stringify(set)); } catch (e) {} + } + var readSet = loadRead(); + + function isRead(id) { return !!readSet[id]; } + function setRead(id, on) { + if (on) readSet[id] = 1; else delete readSet[id]; + saveRead(readSet); + paintCard(id, on); + } + // Reflect read state onto every matching card on the page (a story can appear + // in more than one section on the home page). + function paintCard(id, on) { + document.querySelectorAll('[data-story-card][data-id="' + cssEsc(id) + '"]').forEach(function (c) { + if (on) c.setAttribute("data-read", "1"); + else c.removeAttribute("data-read"); + }); + } + function cssEsc(s) { + return String(s).replace(/["\\]/g, "\\$&"); + } + + // ---- collecting the page's stories ---------------------------------------- + function isVisible(el) { + return window.getComputedStyle(el).display !== "none"; + } + function collect() { + var seen = Object.create(null); + var out = []; + document.querySelectorAll("[data-story-card]").forEach(function (c) { + var id = c.getAttribute("data-id"); + if (!id || seen[id] || !isVisible(c)) return; + var url = c.getAttribute("data-url") || c.getAttribute("href") || ""; + if (!/^https?:\/\//i.test(url)) url = ""; + seen[id] = true; + out.push({ + id: id, + url: url, + headline: c.getAttribute("data-headline") || "", + lede: c.getAttribute("data-lede") || "", + image: c.getAttribute("data-image") || "", + time: c.getAttribute("data-time") || "", + source: c.getAttribute("data-source") || "", + chTitle: c.getAttribute("data-ch-title") || "", + chEmoji: c.getAttribute("data-ch-emoji") || "", + chTheme: c.getAttribute("data-ch-theme") || "", + paywalled: c.getAttribute("data-paywalled") === "1" + }); + }); + return out; + } + + // ---- rendering ------------------------------------------------------------ + function escapeHTML(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); + } + function safeURL(s) { + var raw = String(s == null ? "" : s).trim(); + return /^https?:\/\//i.test(raw) ? raw : ""; + } + // Turn stored plain text (paragraphs separated by blank lines) into escaped + //blocks. Single newlines inside a paragraph become spaces. + function paragraphs(text) { + var blocks = String(text || "").split(/\n{2,}/); + var html = ""; + for (var i = 0; i < blocks.length; i++) { + var t = blocks[i].replace(/\s*\n\s*/g, " ").trim(); + if (t) html += "
" + escapeHTML(t) + "
"; + } + return html; + } + + function eyebrow(it) { + var chip = it.chTheme + ? '' + + (it.chEmoji ? '" : "") + + escapeHTML(it.chTitle) + "" + : ""; + var src = it.source ? '' + escapeHTML(it.source) + "" : ""; + var time = it.time ? '' + escapeHTML(it.time) + "" : ""; + return '' + + (it.paywalled ? "This source is paywalled — the text above may be partial. " : "") + + (href ? 'Read it at the source: ' + + escapeHTML(it.source || "original article") + " ↗." : "") + + "
"; + articleEl.innerHTML = + eyebrow(it) + + 'Loading the full story…
'); + } + + function fillContent(it, data) { + var text = (data && data.content) ? data.content : ((data && data.lede) || it.lede); + var html = paragraphs(text); + if (!html) { + html = 'No article text was captured for this story. Open the original to read it.
'; + } + renderBody(it, html); + } + + function fetchContent(it) { + if (contentCache[it.id]) { fillContent(it, contentCache[it.id]); return; } + loadingBody(it); + if (inflight) inflight.abort(); + var ctrl = new AbortController(); + inflight = ctrl; + var reqId = it.id; + fetch("/api/article?id=" + encodeURIComponent(it.id), { signal: ctrl.signal, credentials: "same-origin" }) + .then(function (r) { if (!r.ok) throw new Error("status " + r.status); return r.json(); }) + .then(function (data) { + contentCache[reqId] = data; + if (ctrl !== inflight) return; // superseded by a newer navigation + if (items[index] && items[index].id === reqId) fillContent(it, data); + }) + .catch(function (err) { + if (err.name === "AbortError") return; + if (items[index] && items[index].id === reqId) fillContent(it, null); // fall back to lede + }) + .finally(function () { if (ctrl === inflight) inflight = null; }); + } + + // ---- navigation ----------------------------------------------------------- + function show(i) { + index = i; + if (index >= items.length) { renderDone(); return; } + var it = items[index]; + progressEl.textContent = (index + 1) + " / " + items.length; + var href = safeURL(it.url); + if (href) { linkEl.href = href; linkEl.style.display = ""; } + else linkEl.style.display = "none"; + prevBtn.disabled = index === 0; + nextBtn.disabled = false; + nextBtn.textContent = index === items.length - 1 ? "done ✓" : "→"; + if (scrollEl) scrollEl.scrollTop = 0; + fetchContent(it); + setRead(it.id, true); // presenting a story marks it read + } + + function renderDone() { + progressEl.textContent = items.length + " / " + items.length; + linkEl.style.display = "none"; + prevBtn.disabled = items.length === 0; + nextBtn.disabled = true; + nextBtn.textContent = "→"; + if (scrollEl) scrollEl.scrollTop = 0; + articleEl.innerHTML = + 'That\'s every story on this page. ' + + "Head back to browse more, or press ← to revisit the last one.
" + + "