Files
Seeds/app.html
T
2026-09-09 13:09:19 -04:00

344 lines
18 KiB
HTML
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.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<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=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
/* ===== THE DETERMINER =====
--font-floor is the comfortable-readable body atom. Everything derives UP from it.
floor 15px = comfortable-readable minimum (physiological)
ceiling 21px = anti-absurd on huge screens (convention)
viewport term = growth rate (convention) — ~16-17px around a 1440px viewport
--measure 62ch = optimal line length (physiological ~60ch) */
--font-floor: clamp(15px, 0.9rem + 0.35vw, 21px);
--measure: 62ch;
/* derived UP from the determiner (ratios, not magic numbers) */
--f-body: var(--font-floor);
--f-head: calc(var(--font-floor) * 1.7);
--f-eye: calc(var(--font-floor) * 0.72);
--card-min: 22rem; /* ~ a readable measure at floor size; derived target */
--bg:#ffffff; --ink:#2b2b3a; --muted:#6b6b7b; --line:#e3e3ec;
--bar-h:52px; --radius:20px; --gap:14px; --fade:.28s;
}
*{ box-sizing:border-box; }
html,body{ margin:0; height:100%; background:var(--bg); color:var(--ink);
font-family:"Inter",system-ui,sans-serif; font-size:var(--f-body);
-webkit-font-smoothing:antialiased; }
#bar{ position:sticky; top:0; height:var(--bar-h); display:flex; align-items:center; gap:8px;
padding:0 16px; background:rgba(255,255,255,.85); backdrop-filter:blur(8px);
border-bottom:1px solid var(--line); z-index:100; }
.navtag{ font:500 15px/1 "Inter",sans-serif; color:var(--muted); background:none;
border:1px solid var(--line); border-radius:999px; padding:8px 16px; cursor:pointer;
transition:color .2s, border-color .2s, background .2s; }
.navtag:hover{ color:var(--ink); border-color:var(--ink); }
.navtag.active{ color:var(--bg); background:var(--ink); border-color:var(--ink); }
/* ===== n×n GRID: wraps, mobile-safe, no media query =====
minmax(min(--card-min,100%),1fr): as many columns as fit at >=card-min,
but never wider than the container (so phones become one full-width column). */
#grid{ display:grid; gap:var(--gap); padding:var(--gap);
grid-template-columns:repeat(auto-fill, minmax(min(var(--card-min), 100%), 1fr));
align-content:start; }
.card{ position:relative; border:1px solid var(--line); border-radius:var(--radius);
padding:26px 24px; min-height:24vh; background:var(--bg); cursor:pointer; overflow:hidden;
display:flex; flex-direction:column; justify-content:flex-start;
box-shadow:0 1px 2px rgba(43,43,58,.04);
transition:box-shadow .2s ease, transform .12s ease; } /* opacity owned by WAAPI, not CSS */
.card:hover{ box-shadow:0 6px 22px rgba(43,43,58,.08); }
.card:active{ transform:scale(.995); }
.card{ --accent:var(--ink); }
.card::before{ content:""; position:absolute; left:0; top:0; bottom:0; width:4px; background:var(--accent); }
.card h2{ margin:0 0 6px; font-family:"Fraunces",Georgia,serif; font-weight:600;
font-size:var(--f-head); line-height:1.05; letter-spacing:-.01em; color:var(--ink); overflow-wrap:anywhere; }
.card .k{ display:block; font-size:var(--f-eye); letter-spacing:.14em; text-transform:uppercase;
color:var(--accent); font-weight:500; }
.card p{ margin:10px 0 0; color:var(--muted); font-size:var(--f-body); line-height:1.5; max-width:var(--measure); }
.card pre{ margin:12px 0 0; padding:12px 14px; background:#f5f5f9; border:1px solid var(--line);
border-radius:10px; overflow:auto; font:0.85em/1.5 ui-monospace,"SF Mono",Menlo,monospace; color:var(--ink); }
.card blockquote{ margin:12px 0 0; padding:2px 0 2px 14px; border-left:3px solid var(--accent);
color:var(--ink); font-style:italic; font-size:var(--f-body); }
.card .invite{ margin:14px 0 0; align-self:flex-start; padding:7px 14px; border-radius:999px;
border:1px solid var(--accent); color:var(--accent); font-size:0.9em; font-weight:500; }
.card .tags{ margin-top:auto; padding-top:16px; display:flex; flex-wrap:wrap; gap:6px; }
.card .tag{ font:500 0.72rem/1 "Inter",sans-serif; color:var(--muted); cursor:pointer;
border:1px solid var(--line); border-radius:999px; padding:4px 10px; transition:color .2s, border-color .2s; }
.card .tag:hover{ color:var(--ink); border-color:var(--ink); }
/* tags view = ONE virtual card holding a weighted cloud (normalized band sizing).
tag look (Fraunces + accent) carried from the old tag render for consistency. */
.card.cloud{ cursor:default; display:block; }
.card.cloud .cloudtag{ display:inline-block; margin:0 .55em .35em 0; line-height:1.1;
font-family:"Fraunces",Georgia,serif; color:var(--accent); cursor:pointer;
transition:opacity .2s; }
.card.cloud .cloudtag:hover{ opacity:.55; }
.placeholder{ visibility:hidden; }
/* the ?card render: one card, inset by the same --gap gutter as the grid,
fills the remaining width, content-height, scrolls past the viewport.
ONLY functional overrides here — corners/border/shadow/padding inherit from .card. */
.card.solo{ position:fixed; z-index:50;
top:calc(var(--bar-h) + var(--gap)); left:var(--gap); right:var(--gap); width:auto;
height:auto; min-height:0; max-height:calc(100vh - var(--bar-h) - 2*var(--gap));
overflow:auto; }
@media (prefers-reduced-motion:reduce){ .navtag{ transition:none; } }
</style>
</head>
<body>
<nav id="bar"></nav>
<main id="grid"></main>
<script>
/*__DATA__*/ /* worker injects window.__CARDS__ [{slug,md}] + window.__TAGS__ [str] */
/* ---------- parser ---------- */
function parse(md){
const idm = md.match(/<!--\s*id:\s*([0-9a-f-]{36})\s*-->/i); // minted by gen.mjs
const id = idm ? idm[1].toLowerCase() : "";
md = md.replace(/<!--\s*id:\s*[0-9a-f-]{36}\s*-->/i, ""); // strip so it never renders
const fm = md.match(/^---\n([\s\S]*?)\n---\n?/);
const front = fm ? fm[1] : "";
const body = fm ? md.slice(fm[0].length) : md;
const tags = (front.match(/tags:\s*\[([^\]]*)\]/)?.[1] || "")
.split(",").map(s=>s.trim()).filter(Boolean);
const L = body.split("\n"); const blocks = [];
for(let i=0;i<L.length;i++){
const l=L[i]; if(!l.trim()) continue;
if(/^\^\s+/.test(l)){ blocks.push({type:"eyebrow",text:l.replace(/^\^\s+/,"").trim()}); continue; }
if(/^#\s+/.test(l)) { blocks.push({type:"heading",text:l.replace(/^#\s+/,"").trim()}); continue; }
if(/^```/.test(l)) { const b=[]; i++; while(i<L.length&&!/^```/.test(L[i])){b.push(L[i]);i++;}
blocks.push({type:"code",text:b.join("\n")}); continue; }
if(/^>\s?/.test(l)) { const b=[l.replace(/^>\s?/,"")];
while(i+1<L.length&&/^>\s?/.test(L[i+1])){i++;b.push(L[i].replace(/^>\s?/,""));}
blocks.push({type:"quote",text:b.join(" ")}); continue; }
if(/^=>\s+/.test(l)){ blocks.push({type:"invite",text:l.replace(/^=>\s+/,"").trim()}); continue; }
const b=[l.trim()];
while(i+1<L.length&&L[i+1].trim()&&!/^(\^|#|```|>|=>)/.test(L[i+1])){i++;b.push(L[i].trim());}
blocks.push({type:"prose",text:b.join(" ")});
}
const pick=t=>blocks.find(b=>b.type===t)?.text||"";
return { id, tags, eyebrow:pick("eyebrow"), heading:pick("heading"), blocks };
}
const hue = t => { let h=0; for(const c of t) h=(h*31+c.charCodeAt(0))>>>0; return h%360; };
const accentOf = tag => `hsl(${hue(tag)} 52% 42%)`;
function renderBlock(b){
let el;
if(b.type==="prose"){ el=document.createElement("p"); }
else if(b.type==="code"){ el=document.createElement("pre"); const c=document.createElement("code");
c.textContent=b.text; el.append(c); return el; }
else if(b.type==="quote"){ el=document.createElement("blockquote"); }
else if(b.type==="invite"){ el=document.createElement("div"); el.className="invite"; }
else { el=document.createElement("div"); }
el.textContent=b.text;
return el;
}
function renderCard(c){
const primary = c.tags[0] || "";
const el = document.createElement("article");
el.className = "card"; el.dataset.k = primary; el.dataset.slug = c.slug || ""; el.dataset.id = c.id || "";
el.style.setProperty("--accent", accentOf(primary));
const eb=document.createElement("span"); eb.className="k"; eb.textContent=c.eyebrow;
const h =document.createElement("h2"); h.textContent=c.heading;
el.append(eb,h);
for(const b of c.blocks){
if(b.type==="eyebrow"||b.type==="heading") continue;
el.append(renderBlock(b));
}
if(c.tags.length){
const row=document.createElement("div"); row.className="tags";
for(const t of c.tags){
const s=document.createElement("span"); s.className="tag"; s.textContent=t;
s.addEventListener("click",e=>{ e.stopPropagation(); setState({view:'surface', tag:t, card:null}); });
row.append(s);
}
el.append(row);
}
return el;
}
/* the tags view: one virtual card holding a weighted cloud.
weight = card-count per tag (free from ALL). normalized band → size, derived on --font-floor. */
function renderTagCloud(){
const count = new Map();
for(const e of ALL) for(const t of e.tags) count.set(t, (count.get(t)||0)+1);
const counts = TAGS.map(t => count.get(t)||0);
const lo = Math.min(...counts), hi = Math.max(...counts);
const MIN = 1.0, MAX = 2.2; // font-floor multiples: least→most frequent
const mul = c => hi===lo ? (MIN+MAX)/2 : MIN + (MAX-MIN)*(c-lo)/(hi-lo);
const el = document.createElement("article");
el.className = "card cloud"; el.dataset.slug = "cloud:tags"; // non-navigable (see click guard)
for(const t of TAGS){
const s = document.createElement("span");
s.className = "cloudtag"; s.textContent = t;
s.style.fontSize = `calc(var(--font-floor) * ${mul(count.get(t)||0).toFixed(3)})`;
s.style.setProperty("--accent", accentOf(t));
s.addEventListener("click", e => { e.stopPropagation(); setState({view:'surface', tag:t, card:null}); });
el.append(s);
}
return el;
}
/* ---------- source boundary: bundled CARDS + TAGS (injected) ---------- */
const RAW = (window.__CARDS__ || []);
const TAGS = (window.__TAGS__ || []);
const RAWMAP = new Map(RAW.map(c=>[c.slug, c.md]));
const BYID = new Map(RAW.map(c=>[c.id, c])); // id → raw card (identity lookup for ?card=)
const CACHE = new Map();
function cardFor(slug){
if(CACHE.has(slug)) return CACHE.get(slug);
const c = parse(RAWMAP.get(slug)); c.slug = slug; CACHE.set(slug, c); return c;
}
const ALL = RAW.map(c=>({ slug:c.slug, id:c.id, tags:parse(c.md).tags })); // index, file-order (name-sorted)
const grid = document.getElementById("grid");
const bar = document.getElementById("bar");
/* ---------- toolbar: Cards + Tags (intrinsic views) ---------- */
const NAV = [
{ label:"Cards", act:()=> setState({view:'cards', tag:null, card:null}) },
{ label:"Tags", act:()=> setState({view:'tags', tag:null, card:null}) },
];
const navEls = NAV.map(n=>{ const b=document.createElement("button"); b.className="navtag";
b.textContent=n.label; b.addEventListener("click", n.act); bar.append(b); return b; });
/* ================= THE AXIOM =================
ONE state. setState → 'state' event → render(S). Activators only setState.
URL is a peer expression of state (push only) and a peer activator (popstate). */
let S = { view:'cards', tag:null, card:null }; // one query, always. no 'open'. 'card' holds a v7 id (identity); name gives order
/* state <-> URL (push only; NEVER replaceState — browser history is the user's) */
/* URL is query-shaped: every state IS a query, flat, no hierarchy.
all cards → ?cards ( bare / normalizes here on boot — educational: home is a query too )
tags → ?tags
surface → ?tag=NAME
card → ?card=ID
No 'open': a single card is just ?card=ID. Back button walks the query path. */
function stateToURL(S){
if(S.view==='tags') return '?tags';
if(S.view==='surface' && S.tag) return '?tag=' + encodeURIComponent(S.tag);
if(S.view==='card' && S.card) return '?card=' + encodeURIComponent(S.card);
return '?cards'; // canonical all-cards ( / normalizes here on boot )
}
function urlToState(){
const p = new URLSearchParams(location.search);
if(p.has('tags')) return { view:'tags', tag:null, card:null };
if(p.has('tag')) return { view:'surface', tag:p.get('tag'), card:null };
if(p.has('card')) return { view:'card', tag:null, card:p.get('card') };
return { view:'cards', tag:null, card:null }; // ?cards or bare / → all cards
}
const bus = new EventTarget();
let queue = Promise.resolve();
function setState(patch){
S = { ...S, ...patch };
history.pushState(S, '', stateToURL(S)); // PUSH only — appends, never replaces
queue = queue.then(()=> render(S));
}
/* ===== TRANSITIONS: Web Animations API. Completion = Animation.finished
(promise-based, event-driven, guaranteed to SETTLE — resolves on real
completion, rejects on cancel). No transitionend (conditional-firing),
no timers (trust assumptions). A transition is a named render; swap the
keyframes and nothing else changes. t=0 / reduced-motion = instant. ===== */
const DUR = 750; // one duration source (ms) — app-wide floor
const reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
// run an animation, resolve when it FINISHES; a cancelled anim (element
// pulled mid-flight) rejects → we swallow it and treat as done. Never hangs.
const play = (el, frames, opts={}) => {
if(reduce || !el.animate) return Promise.resolve(); // instant path
const a = el.animate(frames, { duration: DUR, easing:'ease', fill:'both', ...opts });
return a.finished.then(()=>a.cancel(), ()=>{}); // commit then clear; ignore cancel-reject
};
const fadeOut = els => Promise.all(els.map(el =>
play(el, [{opacity:1},{opacity:0}]))); // each card fades; all .finished
const fadeIn = els => Promise.all(els.map(el =>
play(el, [{opacity:0},{opacity:1}])));
/* the group for a state = a query result */
function groupFor(S){
if(S.view==='cards') return ALL.map(e=>e.slug).map(cardFor).map(renderCard);
if(S.view==='tags') return [renderTagCloud()];
if(S.view==='card') { const c = BYID.get(S.card); return c ? [cardFor(c.slug)].map(renderCard) : []; }
// surface: cards carrying the tag
return ALL.filter(e=>e.tags.includes(S.tag)).map(e=>e.slug).map(cardFor).map(renderCard);
}
/* grow: animate the BOX geometry from slot rect (first) to full rect (last).
geometry-space, not transform — content reflows to fill at normal size, never scales.
first keyframe = slot with fill:'both', so the full-size state never paints (no flash).
.solo rests at full w/h, so the cancel-on-finished holds the end with no jump. */
function grow(el, first){
el.classList.add('solo');
const last = el.getBoundingClientRect(); // full rect (from .solo)
const box = r => ({ top:r.top+'px', left:r.left+'px', width:r.width+'px', height:r.height+'px' });
return play(el, [ box(first), box(last) ]);
}
async function swap(S){
const cur = [...grid.querySelectorAll('.card')];
const target = (S.view==='card') ? cur.find(c => c.dataset.id === S.card) : null;
if(target){ // a visible card was clicked → grow sequence
const first = target.getBoundingClientRect(); // slot rect, up front
const others = cur.filter(c => c !== target);
await fadeOut(others); // PHASE 1: the way clears — .finished = broadcast "cleared"
others.forEach(c => c.remove());
navEls.forEach(n => n.classList.remove('active'));
await grow(target, first); // PHASE 2: target heard "cleared", grows — .finished = broadcast "filled"
return;
}
// generic transition: old fades out → new group → new fades in
await fadeOut(cur);
const els = groupFor(S);
grid.replaceChildren(...els);
navEls[0].classList.toggle('active', S.view==='cards');
navEls[1].classList.toggle('active', S.view==='tags');
if(S.view==='card') els.forEach(el => el.classList.add('solo')); // direct ?card= (no source card): appear solo, no grow
await fadeIn(els);
}
/* ---------- the ONE response: DOM := f(state) ---------- */
function key(S){ return S.view+'|'+(S.tag||'')+'|'+(S.card||''); } // what defines "which group"
async function render(S){
if(key(S) !== grid.dataset.key){
grid.dataset.key = key(S);
await swap(S);
}
}
/* ---------- activators ---------- */
grid.addEventListener('click', e => {
const card = e.target.closest('.card'); if(!card || card.classList.contains('cloud')) return;
setState({ view:'card', card: card.dataset.id, tag:null }); // click a card = navigate to ?card=<id>
});
window.addEventListener('popstate', ()=>{ // back/forward = peer activator (no push)
S = urlToState(); queue = queue.then(()=> render(S));
});
function boot(){
S = urlToState();
history.replaceState(S, '', stateToURL(S)); // seed initial entry only (the one allowed replace: first load has no prior)
queue = queue.then(()=> render(S));
}
boot();
</script>
</body>
</html>