search for standard sites pub-search.waow.tech
search zig blog atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

wrapped: factoids as a frosted telemetry readout with scramble-decode

scattered fixed-position captions overlapped on narrow viewports and bare
text fought the star field for contrast. now one glass panel in normal
flow beneath the entry form (adapts to any width, can never overlap),
facts decode in character-by-character — resolved head, churning glyph
frontier — like a transmission resolving out of the noise. ~35 lines of
vanilla js, no library; reduced-motion gets a plain swap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

+107 -100
+107 -100
site/wrapped.html
··· 125 125 line-height: 1.4; 126 126 } 127 127 .freshness .dot { color: var(--accent); } 128 - /* ephemeral corpus factoids — faint captions drifting in the star field 129 - itself (entry screen only). several are visible at once at scattered 130 - positions, each fading in and out on its own clock, like reading 131 - plaques hung on the galaxy. */ 132 - #factoid-layer { 133 - position: fixed; inset: 0; z-index: 0; pointer-events: none; 128 + /* ephemeral corpus factoids — a single frosted readout beneath the entry 129 + form (entry screen only). facts decode in character-by-character like a 130 + transmission resolving out of the star field. in normal flow, so it 131 + adapts to any viewport and can never overlap anything. */ 132 + .factoid-panel { 133 + margin-top: 2.5rem; 134 + border: 1px solid var(--border); 135 + border-radius: 12px; 136 + background: color-mix(in srgb, var(--bg-subtle) 55%, transparent); 137 + backdrop-filter: blur(14px) saturate(140%); 138 + -webkit-backdrop-filter: blur(14px) saturate(140%); 139 + box-shadow: inset 0 1px 0 0 color-mix(in srgb, var(--text-bright) 6%, transparent); 140 + padding: 0.85rem 1rem 0.9rem; 141 + opacity: 0; transition: opacity 0.8s ease; 142 + } 143 + .factoid-panel.on { opacity: 1; } 144 + .factoid-head { 145 + display: flex; align-items: center; gap: 0.5rem; 146 + font-size: 10px; letter-spacing: 0.09em; text-transform: uppercase; 147 + color: var(--text-muted); margin-bottom: 0.5rem; user-select: none; 134 148 } 135 - .factoid { 136 - position: absolute; max-width: 270px; 137 - font-size: 12px; line-height: 1.5; 138 - opacity: 0; transition: opacity 1.1s ease; 139 - pointer-events: auto; 149 + .factoid-head .pip { 150 + width: 6px; height: 6px; border-radius: 50%; background: var(--accent); 151 + animation: fact-pip 2.4s ease-in-out infinite; 140 152 } 141 - .factoid.show { opacity: 0.9; } 142 - /* halo in the page bg so text stays legible over the stars 143 - (same trick as the canvas neighborhood labels) */ 144 - .factoid .fact-text, .factoid .fact-detail { 145 - text-shadow: 146 - 0 0 4px var(--bg), 0 0 8px var(--bg), 0 0 12px var(--bg), 147 - 0 1px 2px var(--bg); 153 + @keyframes fact-pip { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.9; } } 154 + .fact-text { 155 + display: block; color: var(--text); font-size: 13px; line-height: 1.55; 156 + min-height: 3.1em; /* ~2 lines reserved; longer facts grow the panel, 157 + which sits at the end of the flow so nothing shifts */ 158 + text-decoration: none; overflow-wrap: break-word; 148 159 } 149 - .factoid .fact-text { color: var(--text); } 150 - .factoid a.fact-text:hover { color: var(--text-bright); } 151 - .factoid .fact-detail { 152 - display: block; margin-top: 0.2rem; 153 - color: var(--text-dim); font-size: 11px; 160 + a.fact-text:hover { color: var(--text-bright); } 161 + .fact-text .scramble { color: var(--text-muted); } 162 + .fact-detail { 163 + display: block; margin-top: 0.35rem; 164 + color: var(--text-dim); font-size: 11px; line-height: 1.45; 165 + min-height: 1.45em; 154 166 } 155 167 @media (prefers-reduced-motion: reduce) { 156 - .factoid { transition: none; } 168 + .factoid-panel { transition: none; } 169 + .factoid-head .pip { animation: none; opacity: 0.6; } 157 170 } 158 171 159 172 .entry form { display: flex; gap: 0.5rem; } ··· 483 496 '</div>' + 484 497 '<button type="submit">go</button>' + 485 498 '</form>' + 499 + '<div class="factoid-panel" id="factoid-panel" aria-live="polite">' + 500 + '<div class="factoid-head"><span class="pip"></span>corpus telemetry</div>' + 501 + '<span class="fact-text" id="fact-text"></span>' + 502 + '<span class="fact-detail" id="fact-detail"></span>' + 503 + '</div>' + 486 504 '</div>'; 487 505 startFactoids(); 488 506 var form = document.getElementById('entry-form'); ··· 573 591 // ----- ephemeral corpus factoids (entry screen only) ----------------- 574 592 // facts.json is precomputed by scripts/build-facts (regenerated on the 575 593 // prefect server alongside the atlas) so the fun-but-expensive queries 576 - // never touch the live database. Several captions float in the star 577 - // field at once, scattered around the content column, each on its own 578 - // fade clock — more of the corpus on screen without rushing the reader. 594 + // never touch the live database. One frosted readout beneath the form; 595 + // each fact decodes in character-by-character, like a transmission 596 + // resolving out of the noise, holds long enough to read, then the next 597 + // one decodes over it. 579 598 var FACTS = null; 580 - // slot anchors as viewport fractions [x, y, align]; chosen to ring the 581 - // 600px content column rather than sit under it 582 - var FACT_SLOTS_WIDE = [ 583 - [0.06, 0.44, 'left'], [0.94, 0.40, 'right'], 584 - [0.08, 0.72, 'left'], [0.92, 0.68, 'right'], 585 - [0.50, 0.88, 'center'], [0.28, 0.58, 'left'] 586 - ]; 587 - var FACT_SLOTS_NARROW = [[0.50, 0.55, 'center'], [0.50, 0.80, 'center']]; 599 + var SCRAMBLE_GLYPHS = '!<>-_\\/[]{}—=+*^?#·:;abcdefghijklmnopqrstuvwxyz0123456789'; 588 600 589 601 function startFactoids() { 590 - if (FACTS) { scatterFactoids(); return; } 602 + if (FACTS) { runFactoids(); return; } 591 603 fetch('facts.json') 592 604 .then(function(r) { return r.ok ? r.json() : null; }) 593 605 .then(function(d) { ··· 598 610 var j = Math.floor(Math.random() * (i + 1)); 599 611 var t = FACTS[i]; FACTS[i] = FACTS[j]; FACTS[j] = t; 600 612 } 601 - scatterFactoids(); 613 + runFactoids(); 602 614 }) 603 615 .catch(function() {}); 604 616 } 605 617 606 - function factoidLayer() { 607 - // the layer lives only while the entry screen does 608 - if (!document.getElementById('entry-form')) return null; 609 - var layer = document.getElementById('factoid-layer'); 610 - if (!layer) { 611 - layer = document.createElement('div'); 612 - layer.id = 'factoid-layer'; 613 - document.body.appendChild(layer); 614 - } 615 - return layer; 616 - } 617 - function removeFactoidLayer() { 618 - var layer = document.getElementById('factoid-layer'); 619 - if (layer) layer.remove(); 618 + // scramble-decode `text` into `el`: characters resolve left to right, 619 + // the still-encoded tail churns through random glyphs. ~35 lines, no lib. 620 + function decodeInto(el, text, done) { 621 + var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; 622 + if (reduce) { el.textContent = text; if (done) done(); return; } 623 + var len = text.length; 624 + var resolved = 0; 625 + var TICK_MS = 24; 626 + // resolve rate scales with length so long and short facts decode in 627 + // roughly the same ~1.6s 628 + var perTick = Math.max(1, Math.ceil(len / (1600 / TICK_MS))); 629 + var timer = setInterval(function() { 630 + if (!el.isConnected) { clearInterval(timer); return; } 631 + resolved = Math.min(len, resolved + perTick); 632 + var head = text.slice(0, resolved); 633 + var tailLen = Math.min(12, len - resolved); // churn only a short frontier 634 + var tail = ''; 635 + for (var i = 0; i < tailLen; i++) { 636 + var src = text[resolved + i]; 637 + tail += (src === ' ') ? ' ' 638 + : SCRAMBLE_GLYPHS[Math.floor(Math.random() * SCRAMBLE_GLYPHS.length)]; 639 + } 640 + el.textContent = ''; 641 + el.appendChild(document.createTextNode(head)); 642 + if (tailLen) { 643 + var s = document.createElement('span'); 644 + s.className = 'scramble'; 645 + s.textContent = tail; 646 + el.appendChild(s); 647 + } 648 + if (resolved >= len) { 649 + clearInterval(timer); 650 + if (done) done(); 651 + } 652 + }, TICK_MS); 620 653 } 621 654 622 - function scatterFactoids() { 623 - if (!factoidLayer()) return; 624 - var narrow = matchMedia('(max-width: 720px)').matches; 625 - var slots = narrow ? FACT_SLOTS_NARROW : FACT_SLOTS_WIDE; 626 - var concurrent = narrow ? 1 : 3; 655 + function runFactoids() { 627 656 var next = 0; 628 - var busy = slots.map(function() { return false; }); 629 - var HOLD_MS = 11000, FADE_MS = 1150; 630 - 631 - function freeSlot() { 632 - var open = []; 633 - for (var i = 0; i < slots.length; i++) if (!busy[i]) open.push(i); 634 - return open.length ? open[Math.floor(Math.random() * open.length)] : -1; 635 - } 636 - 637 - function place() { 638 - var layer = factoidLayer(); 639 - if (!layer) return; // left the entry screen — rotation ends 640 - var si = freeSlot(); 641 - if (si < 0) { setTimeout(place, 2000); return; } 657 + var HOLD_MS = 8000; 658 + function show() { 659 + var panel = document.getElementById('factoid-panel'); 660 + var textEl = document.getElementById('fact-text'); 661 + var detailEl = document.getElementById('fact-detail'); 662 + if (!panel || !textEl) return; // left the entry screen — rotation ends 663 + panel.classList.add('on'); 642 664 var f = FACTS[next]; next = (next + 1) % FACTS.length; 643 - var s = slots[si]; 644 - busy[si] = true; 645 - 646 - var el = document.createElement('div'); 647 - el.className = 'factoid'; 648 - var inner = f.url 649 - ? '<a class="fact-text" href="' + esc(f.url) + '" target="_blank" rel="noopener">' + esc(f.text) + '</a>' 650 - : '<span class="fact-text">' + esc(f.text) + '</span>'; 651 - if (f.detail) inner += '<span class="fact-detail">' + esc(f.detail) + '</span>'; 652 - el.innerHTML = inner; 653 - el.style.textAlign = s[2]; 654 - if (s[2] === 'right') { el.style.right = ((1 - s[0]) * 100) + '%'; } 655 - else if (s[2] === 'center') { el.style.left = (s[0] * 100) + '%'; el.style.transform = 'translateX(-50%)'; } 656 - else { el.style.left = (s[0] * 100) + '%'; } 657 - el.style.top = (s[1] * 100) + '%'; 658 - layer.appendChild(el); 659 - requestAnimationFrame(function() { requestAnimationFrame(function() { el.classList.add('show'); }); }); 660 - 661 - setTimeout(function() { 662 - el.classList.remove('show'); 663 - setTimeout(function() { el.remove(); busy[si] = false; }, FADE_MS); 664 - }, HOLD_MS); 665 - // each chain replaces its own caption when it dies, so exactly 666 - // `concurrent` are ever in flight, offset by the initial stagger 667 - setTimeout(place, HOLD_MS + FADE_MS + Math.random() * 1500); 665 + // the whole readout links out when the fact points at a document 666 + if (f.url) { 667 + textEl.style.cursor = 'pointer'; 668 + textEl.onclick = function() { window.open(f.url, '_blank', 'noopener'); }; 669 + } else { 670 + textEl.style.cursor = ''; 671 + textEl.onclick = null; 672 + } 673 + detailEl.textContent = ''; 674 + decodeInto(textEl, f.text, function() { 675 + if (f.detail && detailEl.isConnected) decodeInto(detailEl, f.detail); 676 + }); 677 + setTimeout(show, HOLD_MS); 668 678 } 669 - 670 - // stagger the first wave in 671 - for (var k = 0; k < concurrent; k++) setTimeout(place, 400 + k * 3500); 679 + show(); 672 680 } 673 681 674 682 // ----- rank badge --------------------------------------------------- ··· 1205 1213 // gentle parallax drift; mouseOn gates the near-cursor glow. 1206 1214 var mtx = 0, mty = 0, mx = 0, my = 0, mouseOn = false, overContent = false; 1207 1215 // foreground blocks the atlas labels should never draw over (text/cards/form) 1208 - var CONTENT_SEL = '.lead, .sub, #entry-form, .factoid, .who, .card, .lf-typeahead-dropdown'; 1216 + var CONTENT_SEL = '.lead, .sub, #entry-form, .factoid-panel, .who, .card, .lf-typeahead-dropdown'; 1209 1217 var clusterCentroids = []; // [{cid,label,x,y,n}] in screen space, rebuilt on build() 1210 1218 1211 1219 var bg = '#050505', pal = GALAXY_DARK; ··· 1383 1391 1384 1392 var key = did ? 'did' : 'handle'; 1385 1393 var val = did || handle; 1386 - removeFactoidLayer(); 1387 1394 root.innerHTML = '<div class="status">reading the graph…</div>'; 1388 1395 1389 1396 fetch(API_BASE + '/wrapped?' + key + '=' + encodeURIComponent(val))