Files
upbeatBytes/goodnews/share.py
T
thejayman77 f416e13700 analytics: honest engagement metric — Engaged readers vs Recorded visits (Codex)
Admin now shows two numbers:
- Recorded visits: the existing raw count (one daily 'visit' beacon; still includes
  UA-spoofing bots that slip past the UA filter).
- Engaged readers: distinct visitor-day with DELIBERATE activity — either the new
  gesture-gated 'engaged' beacon (fires once/day only after ~8s visible AND a real
  scroll/pointer/key/touch) or a deliberate action (source_click, full_story, share,
  replace_used, paywall_replace, not_today/less_like_this/hide_topic, game start/
  complete/share). Explicitly EXCLUDES auto-fired visit/summary_viewed/open, replace_none,
  and game *_arrival (a share-loop landing, not engagement).

armEngaged() in analytics.js (wired in the global layout) + a mirrored vanilla-JS beacon
on the server-rendered /a/<id> share pages. 'engaged' added to the event allowlist and
fired with article_id=0 so the uniqueness constraint dedups it per day. queries.admin_stats
gains engaged_today/d7/d30. Bots are doubly excluded (UA filter at the beacon + the
gesture gate). Tests cover the metric (engaged + deliberate counted; visit/summary/arrival
not). 447 backend + 36 frontend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:07:24 -04:00

477 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Server-rendered share/landing page for /a/<id>.
A *pointer* page (never a republished article): the story's own title, "why it's
uplifting" note, and image, wrapped with rich OpenGraph/Twitter meta and a
prominent link to the original source. Social scrapers don't run JS, so this is
plain server-rendered HTML. All dynamic values are HTML-escaped.
"""
from __future__ import annotations
from html import escape
from .newsimg import display_url
def _tag(name: str, content: str | None, attr: str = "property") -> str:
if not content:
return ""
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
# --- Shared top bar -----------------------------------------------------------------
# A static replica of the SPA's HubBar so server-rendered share pages (/a/<id>, the
# digest) carry the SAME toolbar as the rest of the site. These pages can't run the
# Svelte component, so this is kept in sync with frontend/src/lib/components/HubBar.svelte
# (+ HubShell's borderless Back) BY HAND — change both together. `active` highlights a
# section ('' = none, as on an article). The account glyph is the signed-out state;
# _TOP_BAR_JS swaps in the cached avatar for signed-in readers, just like HubBar.
_TOP_NAV = (("/", "Home", "home"), ("/news", "News", "news"),
("/play", "Games", "games"), ("/art", "Art", "art"))
def _nav_links(active: str) -> str:
return "".join(f'<a class="{"on" if k == active else ""}" href="{href}">{label}</a>'
for href, label, k in _TOP_NAV)
def _top_bar_html(active: str = "") -> str:
return (
'<header class="bar">'
'<a class="brand" href="/" aria-label="upbeatBytes home"><img src="/logo.svg" alt="upbeatBytes"></a>'
'<div class="bar-end">'
f'<nav class="nav">{_nav_links(active)}<span class="nav-soon">Entertainment</span></nav>'
'<a class="acct" href="/account" aria-label="Your account">'
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#C98A2E" stroke-width="2" aria-hidden="true">'
'<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/></svg></a>'
'<button class="burger" type="button" aria-label="Menu" aria-expanded="false" data-burger>'
'<span></span><span></span><span></span></button>'
'</div></header>'
f'<div class="menu-wrap" data-menu hidden><nav class="menu">{_nav_links(active)}'
'<span class="menu-soon">Entertainment <em>soon</em></span></nav></div>'
)
def _back_link_html(label: str = "Back") -> str:
return ('<button class="hb-back" type="button" data-back aria-label="Go back">'
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" '
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
f'<path d="M15 18l-6-6 6-6"/></svg>{label}</button>')
# Ported verbatim from HubBar.svelte's <style> (+ HubShell's .back), scoped to the same
# class names so the bar looks pixel-identical to the SPA regardless of page palette.
_TOP_BAR_CSS = """
/* reserve the scrollbar gutter so the bar doesn't jump left-right between pages */
html { scrollbar-gutter: stable; }
@font-face { font-family:'Hanken Grotesk'; src:url('/fonts/hanken-var.woff2') format('woff2'); font-weight:400 700; font-style:normal; font-display:swap; }
header.bar { display:flex; align-items:center; justify-content:space-between; max-width:1180px; width:100%; margin:0 auto; box-sizing:border-box; padding:26px clamp(18px,5vw,44px) 0; font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; background:none; border:none; }
header.bar .brand { display:block; line-height:0; }
header.bar .brand img { height:48px; width:auto; display:block; }
.bar-end { display:flex; align-items:center; gap:clamp(16px,2.4vw,32px); }
.nav { display:flex; align-items:center; gap:clamp(16px,2.4vw,32px); font-size:16.5px; font-weight:500; }
.nav a { color:#6b6256; text-decoration:none; }
.nav a.on { color:#23201b; }
.nav a:hover { color:#0083ad; }
.nav-soon { color:#b3a890; }
.acct { width:32px; height:32px; border-radius:50%; border:1.5px solid #e6c9a0; background:#FCEFD7; display:flex; align-items:center; justify-content:center; flex:none; text-decoration:none; }
.acct.hasimg { background:none; overflow:hidden; padding:0; }
.acct:hover { background:#fbe6c4; }
.burger { display:none; flex-direction:column; align-items:center; justify-content:center; gap:4px; width:40px; height:40px; border-radius:11px; border:1.5px solid #e6c9a0; background:#FCEFD7; cursor:pointer; padding:0; flex:none; }
.burger:hover { background:#fbe6c4; }
.burger span { width:18px; height:2px; border-radius:2px; background:#7a6a52; transition:transform .2s ease, opacity .15s ease; }
.burger.open span:nth-child(1) { transform:translateY(6px) rotate(45deg); }
.burger.open span:nth-child(2) { opacity:0; }
.burger.open span:nth-child(3) { transform:translateY(-6px) rotate(-45deg); }
.menu-wrap { max-width:1180px; width:100%; margin:10px auto 0; box-sizing:border-box; padding:0 clamp(18px,5vw,44px); font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; }
.menu { display:flex; flex-direction:column; background:#fff; border:1px solid #f2e7d3; border-radius:14px; overflow:hidden; box-shadow:0 14px 34px -20px rgba(60,50,30,.4); }
.menu a, .menu .menu-soon { padding:14px 18px; font-size:16px; font-weight:500; text-decoration:none; color:#6b6256; border-top:1px solid #f3ece0; }
.menu a:first-child { border-top:none; }
.menu a.on { color:#23201b; }
.menu a:hover { background:#FFF9EF; color:#0083ad; }
.menu-soon { display:flex; align-items:center; justify-content:space-between; color:#b3a890; }
.menu-soon em { font-style:normal; font-size:10px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#c3b69c; }
.hb-back { display:inline-flex; align-items:center; gap:6px; margin:0 0 clamp(14px,3vw,24px); background:none; border:none; cursor:pointer; padding:6px 10px 6px 0; font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; font-size:14px; font-weight:600; color:#6b6256; transition:color .15s ease; }
.hb-back:hover { color:#0083ad; }
.hb-back svg { transition:transform .15s ease; }
.hb-back:hover svg { transform:translateX(-2px); }
@media (max-width:720px) { .nav { display:none; } .burger { display:flex; } }
@media (min-width:721px) { .menu-wrap { display:none !important; } }
"""
# Burger toggle + signed-in avatar (read from the SPA's localStorage cache, same as HubBar).
# Parity with HubBar: Escape closes the menu, and crossing back to desktop width resets it.
_TOP_BAR_JS = """<script>
(function(){
var b=document.querySelector('[data-burger]'), m=document.querySelector('[data-menu]');
function close(){ if(m) m.setAttribute('hidden',''); if(b){ b.classList.remove('open'); b.setAttribute('aria-expanded','false'); } }
if(b&&m){ b.addEventListener('click',function(){
if(m.hasAttribute('hidden')){ m.removeAttribute('hidden'); b.classList.add('open'); b.setAttribute('aria-expanded','true'); }
else { close(); }
}); }
document.addEventListener('keydown',function(e){ if(e.key==='Escape') close(); });
if(window.matchMedia){ var mq=window.matchMedia('(min-width:721px)'); if(mq.addEventListener) mq.addEventListener('change',function(e){ if(e.matches) close(); }); }
try{
var u=JSON.parse(localStorage.getItem('goodnews:auth_user')||'null');
if(u&&u.avatar_url){
var a=document.querySelector('.acct');
if(a){ var img=document.createElement('img'); img.src=u.avatar_url; img.alt=''; img.referrerPolicy='no-referrer';
img.style.cssText='width:32px;height:32px;border-radius:999px;object-fit:cover';
a.classList.add('hasimg'); a.textContent=''; a.appendChild(img); }
}
}catch(e){}
})();
</script>"""
# Single-history Back (mirrors HubShell): go back ONLY when we arrived from our own origin,
# else go home — never bounce the reader off to an external referrer.
_BACK_JS = """<script>
(function(){
var b=document.querySelector('[data-back]'); if(!b) return;
b.addEventListener('click',function(){
var same=false; try{ same=!!document.referrer && new URL(document.referrer).origin===location.origin; }catch(e){}
if(same && history.length>1){ history.back(); } else { location.href='/'; }
});
})();
</script>"""
def render_share_page(article: dict, base_url: str, summary: str | None = None,
explanation: dict | None = None) -> str:
aid = article["id"]
title = (article.get("title") or "upbeatBytes").strip()
why = (article.get("reason_text") or article.get("description")
or "A calm, constructive story worth your attention.").strip()
source = (article.get("source_name") or "the source").strip()
source_id = article.get("source_id")
# Link the source name into the app's publication feed for that source.
source_html = (
f'<a class="src srclink" href="/news?source={source_id}">{escape(source)}</a>'
if source_id else f'<div class="src">{escape(source)}</div>'
)
src_url = article.get("canonical_url") or base_url
image = article.get("image_url")
policy = article.get("image_policy")
# What WE show, honoring the source's image policy (cache → our copy; remote → the
# publisher's URL; none → nothing). og/twitter reference the publisher's own image
# (a link, not re-hosting) whenever we'd show anything; 'none' omits it entirely.
display = display_url(aid, policy, image)
og_image = image if (image and policy != "none") else None
page_url = f"{base_url}/a/{aid}"
# With an image: a large-image card. Without: a clean text unfurl (title +
# why + brand) — tidy, not a broken/muddy preview. (A branded fallback PNG
# can replace this later.)
twitter_card = "summary_large_image" if display else "summary"
meta = "\n".join(filter(None, [
_tag("og:site_name", "upbeatBytes"),
_tag("og:type", "article"),
_tag("og:title", title),
_tag("og:description", why),
_tag("og:url", page_url),
_tag("og:image", og_image),
_tag("twitter:card", twitter_card, attr="name"),
_tag("twitter:title", title, attr="name"),
_tag("twitter:description", why, attr="name"),
_tag("twitter:image", og_image, attr="name"),
]))
# The visible image is whatever the policy resolved to (our cached copy for 'cache'
# sources, else the publisher's URL for 'remote'). If it isn't cached yet / fails to
# load, drop the element so the page degrades to the clean text unfurl, not a broken icon.
media = (
f'<img class="media" src="{escape(display)}" alt="" referrerpolicy="no-referrer" '
f'onerror="this.remove()">'
if display else ""
)
raw_tags = (article.get("tags") or "")
chips = "".join(
f'<span class="chip">{escape(t.replace("-", " "))}</span>'
for t in raw_tags.split(",") if t
)
groupings = f'<div class="chips">{chips}</div>' if chips else ""
if summary:
summary_block = f'<p class="summary" id="summary">{escape(summary)}</p>'
else:
pending = (
f"✦ Were putting together a quick summary — in the meantime, "
f"read the full story at {escape(source)} below."
)
summary_block = f'<p class="summary pending" id="summary">{pending}</p>'
# The structured "Why it belongs" editorial section — only when the LLM gave a
# clean three-part read; otherwise the single reason line is the calm fallback.
def _why_row(lbl: str, text: str) -> str:
return f'<div class="why-row"><span class="lbl">{lbl}</span><p>{escape(text)}</p></div>'
if explanation:
why_block = (
'<div class="why" id="why">'
+ _why_row("What happened", explanation["what_happened"])
+ _why_row("Why it matters", explanation["why_matters"])
+ _why_row("Why it belongs here", explanation["why_belongs"])
+ '</div>'
)
else:
why_block = f'<div class="why" id="why"><div class="why-row"><span class="lbl">Why its here</span><p>{escape(why)}</p></div></div>'
# Quietly poll until BOTH the summary and the structured read are cached, then
# swap each in (older summaries get topped up with the section on first view).
poll = ""
if not summary or not explanation:
poll = f"""<script>
(function(){{
var id={aid}, box=document.getElementById('summary'), n=0;
function row(l,t){{
var d=document.createElement('div'); d.className='why-row';
var s=document.createElement('span'); s.className='lbl'; s.textContent=l;
var p=document.createElement('p'); p.textContent=t;
d.appendChild(s); d.appendChild(p); return d;
}}
function go(){{
n++;
fetch('/api/summary/'+id).then(function(r){{return r.json();}}).then(function(d){{
var done=true;
if(d&&d.summary){{ box.textContent=d.summary; box.className='summary'; }} else {{ done=false; }}
if(d&&d.explanation){{
var w=document.getElementById('why');
if(w){{ w.innerHTML=''; w.appendChild(row('What happened',d.explanation.what_happened));
w.appendChild(row('Why it matters',d.explanation.why_matters));
w.appendChild(row('Why it belongs here',d.explanation.why_belongs)); }}
}} else {{ done=false; }}
if(!done && n<12){{ setTimeout(go,2500); }}
}}).catch(function(){{ if(n<12) setTimeout(go,3000); }});
}}
go();
}})();
</script>"""
# Fire a privacy-respecting 'source_click' when the reader heads to the source
# (reuses the SPA's anonymous visitor token from same-origin localStorage).
src_click = f"""<script>
(function(){{
var b=document.querySelector('[data-src-click]'); if(!b) return;
b.addEventListener('click',function(){{
try{{
var v=localStorage.getItem('goodnews:visitor')||'';
var body=JSON.stringify({{kind:'source_click',article_id:{aid},visitor:v}});
if(navigator.sendBeacon) navigator.sendBeacon('/api/events',new Blob([body],{{type:'application/json'}}));
}}catch(e){{}}
}});
}})();
</script>"""
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(title)} · upbeatBytes</title>
<meta name="description" content="{escape(why)}">
<link rel="canonical" href="{escape(page_url)}">
<link rel="icon" href="/favicon.svg">
{meta}
<style>
:root {{ --accent:#0083ad; --accent-deep:#006b8e; --ink:#16263a; --muted:#5d6b78;
--bg:#f7f4ec; --surface:#fffdf8; --line:#e8e3d8; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:var(--bg); color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
line-height:1.6; }}
{_TOP_BAR_CSS}
.wrap {{ max-width:680px; margin:0 auto; padding:24px 20px 60px; }}
.card {{ background:var(--surface); border:1px solid var(--line); border-radius:16px;
overflow:hidden; box-shadow:0 10px 30px rgba(40,38,28,.06); }}
.media {{ width:100%; height:auto; display:block; max-height:380px; object-fit:cover; }}
.body {{ padding:24px 26px 28px; }}
.src {{ color:var(--muted); font-size:.82rem; text-transform:uppercase; letter-spacing:.08em; }}
a.srclink {{ text-decoration:none; cursor:pointer; border-bottom:1px dotted transparent; }}
a.srclink:hover {{ color:var(--accent-deep); border-bottom-color:var(--line); }}
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600;
font-size:1.7rem; line-height:1.2; margin:6px 0 14px; }}
.summary {{ font-size:1.05rem; color:var(--ink); margin:0 0 18px; }}
.summary.pending {{ color:var(--muted); font-style:italic; }}
.why {{ color:#3b4754; font-size:.92rem; margin:0 0 22px;
border-left:2px solid var(--accent); padding-left:14px; }}
.why-row {{ margin:0 0 12px; }}
.why-row:last-child {{ margin-bottom:0; }}
.why-row p {{ margin:0; }}
.why .lbl {{ display:block; text-transform:uppercase; letter-spacing:.08em; font-size:.7rem;
color:var(--accent-deep); font-weight:600; margin-bottom:2px; }}
.chips {{ display:flex; flex-wrap:wrap; gap:7px; margin:0 0 22px; }}
.chip {{ background:#e0eef3; color:var(--accent-deep); border-radius:999px; padding:3px 11px;
font-size:.7rem; text-transform:uppercase; letter-spacing:.06em; }}
.actions {{ display:flex; flex-wrap:wrap; gap:12px; align-items:center; }}
.primary {{ background:var(--accent); color:#fff; text-decoration:none; font-weight:600;
padding:12px 20px; border-radius:999px; display:inline-block; }}
.primary:hover {{ background:var(--accent-deep); }}
.secondary {{ color:var(--accent-deep); text-decoration:none; font-size:.92rem;
background:none; border:none; cursor:pointer; font-family:inherit; padding:0; }}
.secondary:hover {{ text-decoration:underline; }}
.note {{ color:var(--muted); font-size:.8rem; margin-top:22px; }}
</style>
</head>
<body>
{_top_bar_html()}
<main class="wrap">
{_back_link_html()}
<article class="card">
{media}
<div class="body">
{source_html}
<h1>{escape(title)}</h1>
{summary_block}
{why_block}
{groupings}
<div class="actions">
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
<button class="secondary" type="button" data-share>Copy link</button>
<a class="secondary" href="/news">Explore upbeatBytes →</a>
</div>
<p class="note">upbeatBytes summarizes in its own words and links to the original publisher — it doesn't host the article.</p>
</div>
</article>
</main>
<script>
(function(){{
try{{
var v=localStorage.getItem('goodnews:visitor');
if(!v){{v=crypto.randomUUID?crypto.randomUUID():String(Math.random()).slice(2)+Date.now();localStorage.setItem('goodnews:visitor',v);}}
function beacon(o){{var b=JSON.stringify(o);if(navigator.sendBeacon)navigator.sendBeacon('/api/events',new Blob([b],{{type:'application/json'}}));}}
beacon({{kind:'summary_viewed',article_id:{aid},visitor:v}});
// This page is server-rendered (outside the Svelte app), so the SPA's daily
// visit isn't recorded for a /a/ landing — count it here, once per day per device.
var t=new Date().toISOString().slice(0,10);
if(localStorage.getItem('goodnews:visitday')!==t){{localStorage.setItem('goodnews:visitday',t);beacon({{kind:'visit',article_id:0,visitor:v}});}}
// Engaged-reader signal (mirrors the SPA's armEngaged): ~8s visible + a real gesture, once/day.
var eng=false,gest=false,secs=0;
function fireEng(){{
if(eng||!gest||secs<8) return; eng=true;
try{{ if(localStorage.getItem('goodnews:engagedday')!==t){{localStorage.setItem('goodnews:engagedday',t);beacon({{kind:'engaged',article_id:0,visitor:v}});}} }}catch(e){{}}
}}
var iv=setInterval(function(){{ if(document.visibilityState==='visible'){{secs++;fireEng();}} if(eng) clearInterval(iv); }},1000);
['scroll','pointerdown','keydown','touchstart'].forEach(function(e){{window.addEventListener(e,function(){{gest=true;fireEng();}},{{passive:true}});}});
}}catch(e){{}}
}})();
</script>
{src_click}
<script>
(function(){{
var s=document.querySelector('[data-share]'); if(!s) return;
s.addEventListener('click',function(){{
var url=location.href;
if(navigator.share){{ navigator.share({{title:document.title,url:url}}).catch(function(){{}}); }}
else if(navigator.clipboard){{ navigator.clipboard.writeText(url).then(function(){{ s.textContent='Copied!'; setTimeout(function(){{s.textContent='Copy link';}},1500); }}); }}
}});
}})();
</script>
{_BACK_JS}
{_TOP_BAR_JS}
{poll}
</body>
</html>"""
def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> str:
"""A shareable, indexable 'today's good news, summarized' page."""
lead_img = next((i.get("image_url") for i in items if i.get("image_url")), None)
intro = "The day's calm, constructive news — summarized in plain English, so you can get the gist and go deeper only if you want."
page_url = f"{base_url}/today"
cards = ""
for it in items:
aid = it["id"]
title = escape((it.get("title") or "").strip())
source = escape((it.get("source_name") or "the source").strip())
src_url = escape(it.get("canonical_url") or base_url)
gist = escape((it.get("summary") or it.get("reason_text") or it.get("description") or "").strip())
cards += (
'<article class="item">'
f'<a class="t" href="/a/{aid}"><h2>{title}</h2></a>'
f'<div class="src">{source}</div>'
f'<p class="gist">{gist}</p>'
f'<div class="links"><a href="/a/{aid}">Read the summary</a>'
f'<a href="{src_url}" rel="noopener">Full story at {source} →</a></div>'
"</article>"
)
meta = "\n".join(filter(None, [
_tag("og:site_name", "upbeatBytes"),
_tag("og:type", "website"),
_tag("og:title", "Today's good news, summarized"),
_tag("og:description", intro),
_tag("og:url", page_url),
_tag("og:image", lead_img),
_tag("twitter:card", "summary_large_image" if lead_img else "summary", attr="name"),
_tag("twitter:title", "Today's good news, summarized", attr="name"),
_tag("twitter:description", intro, attr="name"),
_tag("twitter:image", lead_img, attr="name"),
]))
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Today's good news, summarized · upbeatBytes</title>
<meta name="description" content="{escape(intro)}">
<link rel="canonical" href="{page_url}">
<link rel="icon" href="/favicon.svg">
{meta}
<style>
:root {{ --accent:#0083ad; --accent-deep:#006b8e; --ink:#16263a; --muted:#5d6b78;
--bg:#f7f4ec; --surface:#fffdf8; --line:#e8e3d8; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:var(--bg); color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; line-height:1.6; }}
{_TOP_BAR_CSS}
.wrap {{ max-width:720px; margin:0 auto; padding:26px 20px 60px; }}
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:2rem; margin:0 0 4px; }}
.lede {{ color:var(--muted); margin:0 0 26px; }}
.item {{ border-top:1px solid var(--line); padding:20px 0; }}
.item .t {{ text-decoration:none; color:var(--ink); }}
.item h2 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:1.3rem; margin:0 0 4px; }}
.item .t:hover h2 {{ color:var(--accent-deep); }}
.item .src {{ color:var(--muted); font-size:.78rem; text-transform:uppercase; letter-spacing:.07em; margin-bottom:8px; }}
.item .gist {{ margin:0 0 10px; }}
.item .links {{ display:flex; flex-wrap:wrap; gap:16px; font-size:.9rem; }}
.item .links a {{ color:var(--accent-deep); text-decoration:none; }}
.item .links a:hover {{ text-decoration:underline; }}
.more {{ margin-top:28px; }}
.more a {{ display:inline-block; background:var(--accent); color:#fff; text-decoration:none;
padding:11px 20px; border-radius:999px; font-weight:600; }}
</style>
</head>
<body>
{_top_bar_html()}
<main class="wrap">
<h1>Today's good news</h1>
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
{cards}
<p class="more"><a href="/news">Browse more on upbeatBytes →</a></p>
</main>
{_TOP_BAR_JS}
</body>
</html>"""
def render_not_found(base_url: str) -> str:
return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Story not found · upbeatBytes</title><link rel="icon" href="/favicon.svg">
<style>
body {{ margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
justify-content:center; gap:14px; text-align:center; padding:40px;
background:#f7f4ec; color:#16263a;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }}
a {{ color:#006b8e; }}
</style></head>
<body>
<img src="/logo.svg" alt="upbeatBytes" style="height:44px">
<h1 style="font-family:Georgia,serif;font-weight:600">That story isn't here</h1>
<p style="color:#5d6b78">It may have moved on — the good news refreshes often.</p>
<a href="/">← Back to upbeatBytes</a>
</body></html>"""