344 lines
18 KiB
HTML
344 lines
18 KiB
HTML
<!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>
|