The site could only reach somebody who was already looking at it. Push existed and adventure used none of it, so the one communal event in the game -- the Siege -- was invisible to anyone not sitting in Matrix, and a player whose adventurer died found out whenever they next opened a tab. Four opt-in categories, every one of them off until asked for: the Siege (realm-wide, begins and ends), your expedition ending, your adventurer wandering off, and a contract landing on you. Turning on news notifications is not consent to be told about the game, so nothing here enrolls anybody automatically. No new wire. Every trigger is a dispatch already landing in adventure_events, so this is Pete-side only and gogobee is untouched. Two things it needed from storage. push_subscriptions now keeps the Matrix localpart alongside the OIDC subject, because every ownership join in the schema is keyed on the localpart and the sender runs on a ticker with no session to read one from -- without it there is no way to answer "whose adventurer is this". And the alerts carry their own watermark, kept apart from the digest's: the two run on different clocks and one column would let each consume the other's backlog. The ownership join is re-read on every pass rather than trusted from the subscription row, so an opt-out or a removal closes the channel at once. It fails closed in both directions, and an unresolved owner can never fall through to a broadcast -- a game alert naming somebody's adventurer, delivered to the wrong phone, is a privacy leak dressed as a feature. An existing subscription carries watermark 0, which read literally means "has never been told anything" and would page every subscriber for the whole history of the realm on the first tick after deploy. Those rows are stamped to now and start from the next dispatch. Verified against a running Pete with a real push service, real P-256 client keys and real encryption: the right person is notified, the wrong one is not, a second pass is silent, and dropping the player from the board takes the channel with it. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
357 lines
24 KiB
HTML
357 lines
24 KiB
HTML
{{define "layout"}}<!doctype html>
|
|
<html lang="en" data-phase="{{if .Phase}}{{.Phase}}{{else}}day{{end}}"{{if .Phase}} data-phase-lock{{end}} data-season="{{.Weather.Season}}" data-weather="{{.Weather.Variant}}" data-intensity="{{.Weather.Intensity}}">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{{block "title" .}}{{.SiteTitle}}{{end}}</title>
|
|
{{if .NoIndex}}<meta name="robots" content="noindex">{{end}}
|
|
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}"><meta name="twitter:card" content="summary_large_image">{{end}}
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" href="/static/css/output.css">
|
|
<link rel="icon" href="/static/img/pete.avif" type="image/avif">
|
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
<meta name="theme-color" content="#fbf3e3">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
<meta name="apple-mobile-web-app-title" content="Pete">
|
|
<link rel="apple-touch-icon" href="/static/img/apple-touch-icon.png">
|
|
<link rel="alternate" type="application/rss+xml" title="{{.SiteTitle}} — all stories" href="/feed.xml">
|
|
<link rel="alternate" type="application/feed+json" title="{{.SiteTitle}} — all stories" href="/feed.json">
|
|
{{if .Active}}{{if ne .Active "bookmarks"}}
|
|
<link rel="alternate" type="application/rss+xml" title="{{.SiteTitle}} — {{.Active}}" href="/{{.Active}}/feed.xml">
|
|
{{end}}{{end}}
|
|
<script>
|
|
// Pick a palette phase from the browser clock and update it each minute.
|
|
(function () {
|
|
function phase(h) {
|
|
if (h >= 5 && h < 8) return "dawn";
|
|
if (h >= 8 && h < 17) return "day";
|
|
if (h >= 17 && h < 20) return "dusk";
|
|
return "night";
|
|
}
|
|
function apply() {
|
|
if (document.documentElement.hasAttribute("data-phase-lock")) return;
|
|
document.documentElement.dataset.phase = phase(new Date().getHours());
|
|
}
|
|
apply();
|
|
setInterval(apply, 60000);
|
|
})();
|
|
</script>
|
|
</head>
|
|
<body class="min-h-screen bg-[color:var(--bg)] text-[color:var(--ink)] transition-colors duration-1000">
|
|
<div class="pointer-events-none fixed inset-0 -z-10 bg-[color:var(--bg-grad)] transition-colors duration-1000"></div>
|
|
<canvas id="pete-weather" class="pointer-events-none fixed inset-0 -z-[5] h-full w-full" aria-hidden="true"></canvas>
|
|
|
|
<header class="mx-auto max-w-6xl px-4 pt-8 pb-4 sm:pt-12">
|
|
<div class="flex items-center justify-between gap-3 sm:gap-4">
|
|
<a href="/" class="flex items-center gap-3 group min-w-0">
|
|
<img src="/static/img/pete.avif" alt="Pete" width="48" height="48"
|
|
class="h-12 w-12 shrink-0 rounded-2xl bg-[color:var(--card)] shadow-pete border-2 border-[color:var(--ink)]/10 object-cover group-hover:-rotate-6 transition-transform">
|
|
<span class="font-display text-3xl font-bold tracking-tight">{{.SiteTitle}}</span>
|
|
<span class="hidden sm:inline rounded-full bg-[color:var(--accent)]/20 px-3 py-1 text-xs font-semibold uppercase tracking-wider text-[color:var(--ink)]/70" data-phase-label>day</span>
|
|
</a>
|
|
{{if .AuthEnabled}}
|
|
{{if .User}}
|
|
<a href="/auth/logout" data-account
|
|
title="Signed in as {{.User.Display}}{{if .User.Email}} · {{.User.Email}}{{end}} — sign out"
|
|
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<span class="grid h-6 w-6 place-items-center rounded-full bg-[color:var(--accent)] text-white text-xs font-bold">{{.User.Initial}}</span>
|
|
<span class="hidden sm:inline max-w-[7rem] truncate">{{.User.Display}}</span>
|
|
</a>
|
|
{{else}}
|
|
<a href="/auth/login?next={{.Path}}" data-signin
|
|
title="Sign in to sync your preferences"
|
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-[color:var(--ink)]/60">
|
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
|
|
<polyline points="10 17 15 12 10 7"></polyline>
|
|
<line x1="15" y1="12" x2="3" y2="12"></line>
|
|
</svg>
|
|
<span class="hidden sm:inline">Sign in</span>
|
|
</a>
|
|
{{end}}
|
|
{{end}}
|
|
</div>
|
|
<div class="mt-3 flex flex-wrap items-center gap-2 sm:gap-3">
|
|
<button type="button" data-settings-trigger
|
|
title="Feed settings"
|
|
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
|
<circle cx="12" cy="12" r="3"></circle>
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
|
</svg>
|
|
<span class="sr-only">Feed settings</span>
|
|
</button>
|
|
<button type="button" data-reader-open
|
|
title="Feed mode — read one story at a time (press f)"
|
|
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
|
<path d="M2 4h6a3 3 0 0 1 3 3v13a2.5 2.5 0 0 0-2.5-2.5H2z"></path>
|
|
<path d="M22 4h-6a3 3 0 0 0-3 3v13a2.5 2.5 0 0 1 2.5-2.5H22z"></path>
|
|
</svg>
|
|
<span class="sr-only">Feed mode</span>
|
|
</button>
|
|
{{if .Weather.Variant}}
|
|
<button type="button" data-weather-toggle aria-pressed="true"
|
|
title="Toggle weather animation"
|
|
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" class="h-5 w-5 transition-colors"
|
|
data-weather-star fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
|
|
<polygon points="12,2.7 14.85,9.27 21.95,10.05 16.6,14.83 18.18,21.8 12,18.13 5.82,21.8 7.4,14.83 2.05,10.05 9.15,9.27"/>
|
|
</svg>
|
|
<span class="sr-only">Toggle weather animation</span>
|
|
</button>
|
|
{{end}}
|
|
<button type="button" data-weather-loc
|
|
title="Set your location"
|
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<span data-weather-chip class="tabular-nums">📍 Weather</span>
|
|
</button>
|
|
<span data-aqi-chip
|
|
class="hidden shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 tabular-nums"></span>
|
|
<a href="/status" data-status-link
|
|
title="Source health"
|
|
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition{{if eq .Active "status"}} bg-[color:var(--accent)]/20{{end}}">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
|
<path d="M3 12h4l2 6 4-14 2 8h6"></path>
|
|
</svg>
|
|
<span class="sr-only">Source health</span>
|
|
</a>
|
|
{{if .User}}
|
|
<a href="/bookmarks" data-bookmarks-link
|
|
title="Your bookmarks"
|
|
class="inline-flex shrink-0 items-center justify-center rounded-full bg-[color:var(--card)] p-2 shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition{{if eq .Active "bookmarks"}} bg-[color:var(--accent)]/20{{end}}">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
|
<path d="M6 2h12a1 1 0 0 1 1 1v18l-7-4-7 4V3a1 1 0 0 1 1-1z"></path>
|
|
</svg>
|
|
<span class="sr-only">Bookmarks</span>
|
|
</a>
|
|
{{end}}
|
|
<div class="flex basis-full items-center gap-2 min-w-0 max-w-full sm:basis-auto sm:ml-auto">
|
|
<button type="button" data-search-trigger title="Search (⌘K)"
|
|
class="flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-[color:var(--ink)]/60">
|
|
<circle cx="11" cy="11" r="7"></circle>
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
</svg>
|
|
<span class="hidden sm:inline text-[color:var(--ink)]/60 font-semibold">Search</span>
|
|
<kbd class="hidden sm:inline-flex items-center rounded-md bg-[color:var(--ink)]/5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-[color:var(--ink)]/60">⌘K</kbd>
|
|
</button>
|
|
<nav class="pete-channel-nav flex min-w-0 items-center gap-1 sm:gap-2 overflow-x-auto rounded-full bg-[color:var(--card)] p-1 shadow-pete border-2 border-[color:var(--ink)]/10">
|
|
{{$active := .Active}}
|
|
{{if .User}}
|
|
<a href="/for-you"
|
|
title="Your personalized feed"
|
|
class="shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition
|
|
{{if eq $active "for-you"}}bg-[color:var(--accent)] text-[#1c1305] shadow-sm{{else}}hover:bg-[color:var(--ink)]/5{{end}}">
|
|
<span aria-hidden="true" class="mr-1">✨</span><span class="hidden sm:inline">For you</span>
|
|
</a>
|
|
{{end}}
|
|
{{range .Channels}}
|
|
<a href="/{{.Slug}}"
|
|
class="shrink-0 rounded-full px-3 py-2 text-sm font-semibold transition
|
|
{{if eq $active .Slug}}bg-theme-{{.Theme}} text-white shadow-sm{{else}}hover:bg-[color:var(--ink)]/5{{end}}">
|
|
<span aria-hidden="true" class="mr-1">{{.Emoji}}</span><span class="hidden sm:inline">{{.Title}}</span>
|
|
</a>
|
|
{{end}}
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div id="pete-search" class="hidden fixed inset-0 z-50 bg-[color:var(--ink)]/40 backdrop-blur-sm flex items-start justify-center pt-[10vh] px-4" aria-modal="true" role="dialog">
|
|
<div class="w-full max-w-2xl rounded-3xl bg-[color:var(--card)] shadow-pete-lg border-2 border-[color:var(--ink)]/10 overflow-hidden">
|
|
<div class="flex items-center gap-3 px-5 py-4 border-b border-[color:var(--ink)]/10">
|
|
<kbd class="hidden sm:inline-flex items-center rounded-md bg-[color:var(--ink)]/5 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-[color:var(--ink)]/60">⌘K</kbd>
|
|
<input type="search" data-search-input autocomplete="off" spellcheck="false"
|
|
placeholder="Search headlines…"
|
|
class="flex-1 bg-transparent text-lg outline-none placeholder:text-[color:var(--ink)]/40">
|
|
</div>
|
|
<div data-search-list class="max-h-[60vh] overflow-y-auto p-3 space-y-1"></div>
|
|
<div class="px-5 py-3 border-t border-[color:var(--ink)]/10 flex items-center justify-between text-xs text-[color:var(--ink)]/50">
|
|
<span><kbd class="font-mono">↑↓</kbd> navigate · <kbd class="font-mono">⏎</kbd> open · <kbd class="font-mono">esc</kbd> close</span>
|
|
<span data-search-meta></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="pete-settings" class="hidden fixed inset-0 z-50 bg-[color:var(--ink)]/40 backdrop-blur-sm flex items-start justify-center pt-[10vh] px-4" aria-modal="true" role="dialog">
|
|
<div class="w-full max-w-md rounded-3xl bg-[color:var(--card)] shadow-pete-lg border-2 border-[color:var(--ink)]/10 overflow-hidden">
|
|
<div class="flex items-center justify-between gap-3 px-5 py-4 border-b border-[color:var(--ink)]/10">
|
|
<h2 class="font-display text-lg font-bold">Feed settings</h2>
|
|
<button type="button" data-settings-close class="rounded-full px-2 py-1 text-sm text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5">esc</button>
|
|
</div>
|
|
{{if .PushEnabled}}{{if .User}}
|
|
<div data-push-section hidden class="px-5 pt-4 pb-1">
|
|
<div class="flex items-center justify-between gap-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-3">
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-bold">🔔 Notifications</div>
|
|
<div data-push-note class="text-xs text-[color:var(--ink)]/60">Get a nudge when new stories land.</div>
|
|
</div>
|
|
<button type="button" data-push-toggle aria-pressed="false"
|
|
class="shrink-0 rounded-full bg-[color:var(--accent)] px-3 py-2 text-sm font-semibold text-[#1c1305] shadow-sm hover:brightness-105 transition disabled:opacity-50 disabled:cursor-not-allowed">
|
|
Turn on notifications
|
|
</button>
|
|
</div>
|
|
{{if .AdvEnabled}}
|
|
<!-- Adventure alerts. Revealed by pwa.js only once a subscription exists,
|
|
because a category toggle with no subscription behind it is a switch
|
|
wired to nothing. Every box starts unchecked: turning on news
|
|
notifications is not consent to be told about the game. -->
|
|
<div data-adv-push hidden class="mt-2 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-3">
|
|
<div class="text-sm font-bold">⚔️ Adventure alerts</div>
|
|
<div class="text-xs text-[color:var(--ink)]/60">Pick what's worth a buzz. Off unless you say so.</div>
|
|
<div class="mt-2 space-y-1.5">
|
|
<label class="flex items-start gap-2 text-xs cursor-pointer">
|
|
<input type="checkbox" data-adv-cat="siege" class="mt-0.5 accent-[color:var(--accent)]">
|
|
<span><span class="font-semibold">The Siege</span>: when a world boss camps outside town, and when it's settled.</span>
|
|
</label>
|
|
<label class="flex items-start gap-2 text-xs cursor-pointer">
|
|
<input type="checkbox" data-adv-cat="run" class="mt-0.5 accent-[color:var(--accent)]">
|
|
<span><span class="font-semibold">Your expeditions</span>: cleared, backed out, or worse.</span>
|
|
</label>
|
|
<label class="flex items-start gap-2 text-xs cursor-pointer">
|
|
<input type="checkbox" data-adv-cat="departure" class="mt-0.5 accent-[color:var(--accent)]">
|
|
<span><span class="font-semibold">Wandering off</span>: your adventurer got bored and left without you.</span>
|
|
</label>
|
|
<label class="flex items-start gap-2 text-xs cursor-pointer">
|
|
<input type="checkbox" data-adv-cat="contract" class="mt-0.5 accent-[color:var(--accent)]">
|
|
<span><span class="font-semibold">Contracts on you</span>: somebody paid to have something sent after you.</span>
|
|
</label>
|
|
</div>
|
|
<div data-adv-push-note class="mt-2 text-xs text-[color:var(--ink)]/50"></div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}{{end}}
|
|
<p class="px-5 pt-3 text-xs text-[color:var(--ink)]/60">Uncheck a feed to hide its stories. <span data-storage-note>Saved in this browser.</span></p>
|
|
<div data-settings-list class="max-h-[55vh] overflow-y-auto p-3 space-y-1"></div>
|
|
<div class="px-5 py-3 border-t border-[color:var(--ink)]/10 flex items-center justify-between text-xs">
|
|
<button type="button" data-settings-reset class="text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] font-semibold">Enable all</button>
|
|
<span data-settings-meta class="text-[color:var(--ink)]/50"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="pete-reader" class="hidden fixed inset-0 z-50" aria-modal="true" role="dialog" aria-label="Feed mode reader">
|
|
<div class="pete-reader-backdrop" data-reader-backdrop></div>
|
|
<div class="pete-reader-shell">
|
|
<div class="pete-reader-bar">
|
|
<span class="pete-reader-progress tabular-nums" data-reader-progress>—</span>
|
|
<div class="pete-reader-nav">
|
|
<button type="button" data-reader-prev class="pete-reader-btn" title="Previous (←)" aria-label="Previous article">←</button>
|
|
<button type="button" data-reader-next class="pete-reader-btn" title="Next (→)" aria-label="Next article">→</button>
|
|
<button type="button" data-reader-listen class="pete-reader-btn" style="display:none" title="Read aloud (r)" aria-label="Read this story aloud" aria-pressed="false">🔊 Listen</button>
|
|
<button type="button" data-reader-share class="pete-reader-btn" style="display:none" title="Share (s)" aria-label="Share this story">Share</button>
|
|
<div class="pete-reader-type">
|
|
<button type="button" data-reader-type class="pete-reader-btn" title="Text options (t)" aria-label="Text options" aria-expanded="false"><span style="font-size:1.05rem">A</span><span style="font-size:.8rem">a</span></button>
|
|
<div class="pete-reader-typemenu" data-reader-typemenu hidden>
|
|
<div class="pete-reader-typerow">
|
|
<span class="pete-reader-typelabel">Size</span>
|
|
<div class="pete-reader-typebtns" data-reader-size>
|
|
<button type="button" data-size="s" title="Small">S</button>
|
|
<button type="button" data-size="m" title="Medium">M</button>
|
|
<button type="button" data-size="l" title="Large">L</button>
|
|
<button type="button" data-size="xl" title="Extra large">XL</button>
|
|
</div>
|
|
</div>
|
|
<div class="pete-reader-typerow">
|
|
<span class="pete-reader-typelabel">Font</span>
|
|
<div class="pete-reader-typebtns" data-reader-font>
|
|
<button type="button" data-font="sans">Sans</button>
|
|
<button type="button" data-font="serif" style="font-family:Georgia,'Times New Roman',serif">Serif</button>
|
|
</div>
|
|
</div>
|
|
<div class="pete-reader-typerow">
|
|
<span class="pete-reader-typelabel">Paper</span>
|
|
<div class="pete-reader-typebtns" data-reader-paper>
|
|
<button type="button" data-paper="default">Default</button>
|
|
<button type="button" data-paper="sepia">Sepia</button>
|
|
</div>
|
|
</div>
|
|
<div class="pete-reader-typerow pete-reader-voicerow" data-reader-voicerow hidden>
|
|
<span class="pete-reader-typelabel">Voice</span>
|
|
<select class="pete-reader-voice" data-reader-voice aria-label="Read-aloud voice"></select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button type="button" data-reader-bookmark class="pete-reader-btn" style="display:none" title="Bookmark (b)" aria-label="Bookmark this story" aria-pressed="false">🔖 Save</button>
|
|
<a data-reader-link target="_blank" rel="noopener noreferrer" class="pete-reader-btn pete-reader-btn-open" title="Open original (o)">Open ↗</a>
|
|
<button type="button" data-reader-close class="pete-reader-btn" title="Close (esc)" aria-label="Close reader">esc</button>
|
|
</div>
|
|
</div>
|
|
<div class="pete-reader-scroll" data-reader-scroll tabindex="-1">
|
|
<article class="pete-reader-article" data-reader-article></article>
|
|
<div class="pete-reader-related" data-reader-related hidden></div>
|
|
</div>
|
|
<div class="pete-reader-hint">
|
|
<span><kbd>←</kbd> <kbd>→</kbd> navigate · <kbd>o</kbd> open · <kbd>s</kbd> share · <kbd>t</kbd> text · <kbd>u</kbd> unread · <kbd>esc</kbd> close</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<main class="mx-auto max-w-6xl px-4 pb-24">
|
|
{{block "main" .}}{{end}}
|
|
</main>
|
|
|
|
<footer class="mx-auto max-w-6xl px-4 pb-10 text-center text-sm text-[color:var(--ink)]/50">
|
|
<img src="/static/img/pete.avif" alt="" width="20" height="20"
|
|
class="inline-block h-5 w-5 rounded-full object-cover align-[-4px] mr-1">
|
|
served by <span class="font-semibold">Pete</span> · also a Matrix bot · <span data-clock>—</span>
|
|
</footer>
|
|
|
|
<script>
|
|
// Tiny clock + phase label updater for the header pill / footer, plus Pete's
|
|
// time-of-day greeting on the home hero (whichever of these exist).
|
|
(function () {
|
|
function greeting(h) {
|
|
if (h >= 5 && h < 8) return "Early start? Here's what came in overnight.";
|
|
if (h >= 8 && h < 12) return "Morning! Here's what's fresh.";
|
|
if (h >= 12 && h < 17) return "Afternoon. Here's what's new.";
|
|
if (h >= 17 && h < 21) return "Evening. Here's what you missed today.";
|
|
return "Late one? Here's the quiet-hours roundup.";
|
|
}
|
|
function tick() {
|
|
var now = new Date();
|
|
var hh = String(now.getHours()).padStart(2, "0");
|
|
var mm = String(now.getMinutes()).padStart(2, "0");
|
|
var el = document.querySelector("[data-clock]");
|
|
if (el) el.textContent = hh + ":" + mm;
|
|
var label = document.querySelector("[data-phase-label]");
|
|
if (label) label.textContent = document.documentElement.dataset.phase;
|
|
var greet = document.querySelector("[data-pete-greeting]");
|
|
if (greet) greet.textContent = greeting(now.getHours());
|
|
}
|
|
tick();
|
|
setInterval(tick, 30000);
|
|
})();
|
|
</script>
|
|
|
|
<script>
|
|
window.PETE_SOURCES = {{.AllSources}};
|
|
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
|
|
window.PETE_PREFS = {{.UserPrefs}};
|
|
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
|
|
window.PETE_TTS = {{.TTS}};
|
|
</script>
|
|
<script src="/static/js/prefs.js" defer></script>
|
|
<script src="/static/js/weather-2d.js" defer></script>
|
|
<script src="/static/js/weather-gl.js" defer></script>
|
|
<script src="/static/js/weather.js" defer></script>
|
|
<script src="/static/js/weather-forecast.js" defer></script>
|
|
<script src="/static/js/search.js" defer></script>
|
|
<script src="/static/js/settings.js" defer></script>
|
|
<script src="/static/js/reader.js" defer></script>
|
|
<script src="/static/js/pwa.js" defer></script>
|
|
{{block "scripts" .}}{{end}}
|
|
</body>
|
|
</html>{{end}}
|