This commit is contained in:
Paul B. Hartzog
2026-09-09 13:09:19 -04:00
commit a4f43d526f
8 changed files with 518 additions and 0 deletions
+343
View File
@@ -0,0 +1,343 @@
<!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>