Files
portfolio-next/mockups/personal-r2-b.html
T
Lucas WintherandClaude Opus 5.5 843b6d32bc chore: bootstrap agent-ready docs, rules and design-phase artefacts
Add AGENTS.md as the single source of truth (with CLAUDE.md and GEMINI.md
as pointers), permissions for Claude Code, Codex and Gemini, the docs tree
(architecture, decisions, TODO, permissions, specs, plans), PRODUCT.md and
DESIGN.md, plus the design-phase record: the inspiration log, all mockup
rounds with briefs and screenshots, and the owner's work history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:47:38 +01:00

740 lines
68 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>Off the clock · Lucas Winther</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:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,300;1,9..144,400&family=Caveat:wght@500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f1e6d6; --card: #f8f1e6; --card2: #ebdcc6; --line: #dccbb2; --line2: #c9b597;
--ink: #2a1a1f; --ink2: #4a3238; --mute: #7d6a5c; --dim: #a8947f;
--plum: #2a1a1f; --acc: #e8b04a; --rose: #c9706b; --rose-d: #9a4a45; --sage: #6f7f45;
--serif: "Fraunces", Georgia, serif; --hand: "Caveat", cursive; --mono: "IBM Plex Mono", ui-monospace, monospace;
--ease: cubic-bezier(.2,.8,.2,1);
--edge: 42px;
}
* { box-sizing: border-box; }
html { scroll-padding-top: 120px; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--ink); font-family: var(--serif); font-size: 16px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body::before { content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0; opacity: .5; mix-blend-mode: multiply;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .55 0 0 0 0 .45 0 0 0 0 .38 0 0 0 .16 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>"); }
a { color: inherit; }
button { font: inherit; color: inherit; }
::selection { background: var(--acc); color: var(--ink); }
:focus-visible { outline: 2px solid var(--rose-d); outline-offset: 3px; }
.ph { text-decoration: underline dotted rgba(42,26,31,.35); text-underline-offset: 3px; text-decoration-thickness: 1px; cursor: help; }
.dark .ph { text-decoration-color: rgba(241,230,214,.4); }
.mk { position: fixed; bottom: 14px; right: 14px; z-index: 95; width: 310px; background: #fff; color: #222; font: 12px/1.45 system-ui, sans-serif; padding: 10px 28px 10px 12px; border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,.2); }
.mk b { display: block; margin-bottom: 3px; }
.mk button { position: absolute; top: 4px; right: 6px; background: none; border: 0; font-size: 16px; cursor: pointer; color: #555; }
.edge { position: fixed; top: 0; left: 0; bottom: 0; width: var(--edge); z-index: 40; background: #131210; color: #ede7dd; display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; font: 500 12px "IBM Plex Mono", monospace; letter-spacing: .12em; text-transform: uppercase; text-decoration: none; transition: width .2s var(--ease); }
.edge span { transform: rotate(180deg); }
.edge:hover { width: 54px; color: #ea7444; }
.bar { position: sticky; top: 0; z-index: 30; margin-left: var(--edge); background: rgba(241,230,214,.9); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); }
.bar .in { max-width: 1320px; margin: 0 auto; padding: 0 40px; height: 54px; display: flex; align-items: center; gap: 22px; }
.crumb { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.crumb a.back { font: 12px var(--mono); color: var(--mute); text-decoration: none; border: 1px solid var(--line2); border-radius: 6px; padding: 3px 8px; }
.crumb a.back:hover { color: var(--ink); border-color: var(--ink2); }
.crumb b { font-weight: 500; font-size: 15px; }
.crumb i { color: var(--mute); font-weight: 300; }
.chips { display: flex; gap: 6px; margin-left: auto; overflow-x: auto; scrollbar-width: none; }
.chip { border: 1px solid var(--line2); background: transparent; border-radius: 99px; padding: 4px 12px; font-size: 14px; cursor: pointer; white-space: nowrap; color: var(--ink2); transition: background .15s, color .15s, border-color .15s; }
.chip sup { font: 10px var(--mono); color: var(--dim); margin-left: 3px; }
.chip:hover { border-color: var(--ink2); }
.chip[aria-pressed="true"] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.chip[aria-pressed="true"] sup { color: var(--acc); }
main { margin-left: var(--edge); position: relative; z-index: 1; }
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 40px; }
/* header + now */
.head { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 2fr); gap: 44px; padding: 38px 0 30px; align-items: end; }
.head .lab { font: 600 23px var(--hand); color: var(--rose-d); transform: rotate(-2deg); display: inline-block; }
.head h1 { margin: 2px 0 0; font-weight: 300; font-size: 60px; line-height: .98; letter-spacing: -.03em; }
.head h1 i { font-weight: 300; }
.head p { margin: 14px 0 0; color: var(--ink2); font-weight: 300; font-size: 16.5px; max-width: 26em; }
.now { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 2px solid var(--ink); }
.now > div { padding: 12px 16px 4px 0; min-width: 0; }
.now > div + div { padding-left: 16px; border-left: 1px solid var(--line); }
.now h4 { margin: 0 0 10px; display: flex; justify-content: space-between; align-items: baseline; gap: 6px; font: 500 10.5px var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--mute); }
.now h4 a { text-transform: none; letter-spacing: 0; color: var(--sage); text-decoration: none; white-space: nowrap; }
.now h4 a:hover { text-decoration: underline; }
.now .x { display: grid; grid-template-columns: 48px 1fr; gap: 10px; align-items: center; }
.now .x .c { width: 48px; height: 48px; border-radius: 3px; overflow: hidden; position: relative; box-shadow: 0 3px 8px rgba(42,26,31,.18); }
.now .x .c.tall { height: 66px; }
.now .x .c svg { display: block; width: 100%; height: 100%; }
.now .x b { display: block; font-weight: 500; font-size: 15px; line-height: 1.15; }
.now .x small { display: block; font: 11px var(--mono); color: var(--mute); margin-top: 2px; }
.now .pg { height: 3px; background: var(--line); border-radius: 2px; margin-top: 7px; }
.now .pg i { display: block; height: 100%; background: var(--sage); border-radius: 2px; }
.now .c .pp { position: absolute; inset: 0; margin: auto; width: 26px; height: 26px; border-radius: 50%; border: 0; background: rgba(42,26,31,.82); color: var(--bg); font-size: 9px; cursor: pointer; }
/* wall */
.wall-top { display: flex; align-items: baseline; gap: 14px; border-top: 1px solid var(--line); padding: 16px 0 18px; }
.wall-top h2 { margin: 0; font: 600 26px var(--hand); color: var(--rose-d); }
.wall-top .s { font: 12px var(--mono); color: var(--mute); }
.wall-top .s b { color: var(--ink); font-weight: 500; }
.wall { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); grid-auto-rows: 6px; column-gap: 22px; grid-auto-flow: row dense; }
.yr { grid-column: 1 / -1; display: flex; align-items: center; gap: 14px; font: 600 26px var(--hand); color: var(--rose-d); padding-top: 14px; }
.yr::after { content: ""; flex: 1; border-top: 1px dashed var(--line2); }
.yr small { font: 12px var(--mono); color: var(--mute); }
.card { grid-column: span var(--span, 4); align-self: start; position: relative; margin-bottom: 22px; background: var(--card); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; cursor: pointer; transition: transform .22s var(--ease), box-shadow .22s var(--ease), border-color .2s; }
.card:hover { transform: translateY(-3px); box-shadow: 0 14px 30px -12px rgba(42,26,31,.3); border-color: var(--line2); }
.card.open { grid-column: 1 / -1; cursor: default; transform: none; box-shadow: 0 20px 50px -20px rgba(42,26,31,.35); border-color: var(--line2); }
.card.hide { display: none; }
.card .meta { font: 10.5px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--mute); display: flex; gap: 8px; align-items: center; }
.card .meta .new { color: var(--bg); background: var(--rose-d); padding: 0 5px; border-radius: 3px; letter-spacing: .05em; }
.card h3 { margin: 6px 0 0; font-weight: 400; font-size: 22px; line-height: 1.12; letter-spacing: -.01em; }
.card .said { font: 500 21px/1.15 var(--hand); color: var(--rose-d); margin-top: 8px; }
.card .pad { padding: 16px 18px 18px; }
.gcv svg { width: 100%; height: 100%; }
.card svg { display: block; }
.card .open-hint { font: 11px var(--mono); color: var(--mute); margin-top: 10px; opacity: 0; transition: opacity .2s; }
.card:hover .open-hint { opacity: 1; }
.card .full { display: none; }
.card.open .full { display: block; }
.card.open .teaser { display: none; }
.card .x-close { display: none; position: absolute; top: 12px; right: 12px; z-index: 3; border: 1px solid var(--line2); background: var(--card); border-radius: 6px; padding: 3px 9px; font: 12px var(--mono); color: var(--ink2); cursor: pointer; }
.card.open .x-close { display: block; }
.dark.card .x-close { background: #3a252c; border-color: #5c4148; color: #f1e6d6; }
/* comic */
.panels { display: grid; grid-template-columns: 1.3fr 1fr; grid-template-rows: 1fr 1fr; gap: 6px; padding: 10px; background: var(--pp); height: var(--ph, 250px); }
.panels > div { border: 2px solid var(--pi); overflow: hidden; }
.panels > div:first-child { grid-row: span 2; }
.panels.three { grid-template-columns: 1.2fr 1fr 1fr; grid-template-rows: 1fr; }
.panels.three > div:first-child { grid-row: auto; }
.panels svg { width: 100%; height: 100%; }
.card.comic:hover .panels > div { transform: none; }
.reader { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 34px; padding: 22px; }
.reader .spread { display: flex; gap: 10px; }
.reader .pgbig { width: 240px; height: 320px; box-shadow: 0 10px 24px rgba(42,26,31,.25); background: #fff; }
.reader .ctl { display: flex; align-items: center; gap: 12px; margin-top: 12px; font: 12px var(--mono); color: var(--mute); }
.reader .ctl button, .btn { background: none; border: 1px solid var(--line2); border-radius: 6px; padding: 4px 10px; cursor: pointer; font: 12px var(--mono); color: var(--ink2); }
.reader .ctl button:hover, .btn:hover { background: var(--card2); }
.reader .ctl button:disabled { opacity: .35; cursor: default; }
.prose p { font-size: 17px; line-height: 1.65; margin: 12px 0 0; max-width: 36em; color: var(--ink2); }
.prose .src { display: inline-block; margin-top: 16px; font: 12px var(--mono); color: var(--ink2); }
/* song */
.dark { background: var(--plum); color: #f1e6d6; border-color: #3e2a30; }
.dark .meta { color: #b8a592; }
.dark .said { color: var(--acc); }
.dark .prose p { color: #e3d4c1; }
.sleeve { position: relative; aspect-ratio: 1; margin: 12px 12px 0; }
.sleeve .slv { position: absolute; inset: 0; z-index: 1; border-radius: 3px; overflow: hidden; box-shadow: 0 10px 24px rgba(0,0,0,.4); }
.sleeve .slv svg { width: 100%; height: 100%; }
.sleeve .rec { position: absolute; top: 4%; bottom: 4%; left: 4%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle, var(--c) 0 17%, #141012 17.5% 19%, #1b1517 19% 100%); transition: transform .35s var(--ease); }
.sleeve .rec::after { content: ""; position: absolute; inset: 10%; border-radius: 50%; background: repeating-radial-gradient(circle, transparent 0 3px, rgba(255,255,255,.05) 3px 4px); }
.card:hover .sleeve .rec { transform: translateX(10%); }
.sleeve.playing .rec, .card:hover .sleeve.playing .rec { transform: translateX(22%); animation: spin 2.4s linear infinite; }
.song .sleeve { margin: 14px 22% 0 14px; }
@keyframes spin { from { rotate: 0deg; } to { rotate: 360deg; } }
.sleeve .bigpp { position: absolute; z-index: 2; left: 12px; bottom: 12px; width: 48px; height: 48px; border-radius: 50%; border: 0; background: var(--acc); color: var(--plum); font-size: 14px; cursor: pointer; box-shadow: 0 4px 10px rgba(0,0,0,.35); transition: transform .15s; }
.sleeve .bigpp:hover { transform: scale(1.07); }
.mini { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.mini .bar2 { flex: 1; height: 3px; background: rgba(241,230,214,.2); border-radius: 2px; position: relative; cursor: pointer; }
.mini .bar2 i { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--acc); border-radius: 2px; }
.mini .tm { font: 11px var(--mono); color: #b8a592; }
.card:not(.dark) .mini .bar2 { background: var(--line); }
.card:not(.dark) .mini .bar2 i { background: var(--rose); }
.card:not(.dark) .mini .tm { color: var(--mute); }
.mini .pp { width: 30px; height: 30px; border-radius: 50%; border: 0; background: var(--acc); color: var(--plum); font-size: 10px; cursor: pointer; flex: 0 0 auto; }
/* game */
.game-grid { display: grid; grid-template-columns: 42% 1fr; }
.card.open .game-grid { grid-template-columns: 380px 1fr; }
.game-grid .cv { position: relative; min-height: 270px; }
.game-grid .cv svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.game-grid .cv .t { position: absolute; left: 14px; top: 12px; font: italic 400 24px var(--serif); color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.4); }
.game-grid .cv .stamp { position: absolute; right: 10px; bottom: 12px; font: 600 20px var(--hand); background: var(--acc); color: var(--plum); padding: 0 8px; border-radius: 3px; transform: rotate(-5deg); }
.quote { font: italic 300 24px/1.22 var(--serif); margin: 10px 0 0; }
.quote::before { content: "“"; color: var(--acc); font-size: 1.6em; line-height: 0; vertical-align: -.35em; margin-right: 2px; }
.facts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.facts > span { font: 11px var(--mono); border: 1px solid var(--line2); border-radius: 99px; padding: 2px 9px; color: var(--ink2); }
/* essay */
.card.essay { background: #fbf6ee; }
.card.essay .pad { padding: 24px 26px 24px; }
.card.essay h3 { font-size: 32px; font-weight: 300; font-style: italic; line-height: 1.05; letter-spacing: -.02em; margin-top: 12px; }
.card.essay .ex { margin-top: 14px; font-size: 15.5px; line-height: 1.6; color: var(--ink2); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
.card.essay .ex::first-letter { font-size: 2.6em; float: left; line-height: .85; padding: 4px 6px 0 0; color: var(--rose-d); font-weight: 500; }
.card.essay .rd { margin-top: 14px; font: 11px var(--mono); color: var(--mute); display: flex; justify-content: space-between; border-top: 1px solid var(--line); padding-top: 10px; }
.card.essay.life { background: #e7ecdf; }
.card.essay.meaning { background: #f4e2d6; }
.essay-full { max-width: 680px; margin: 0 auto; padding: 8px 0 18px; }
.essay-full p { font-size: 18.5px; line-height: 1.72; color: var(--ink2); margin: 14px 0 0; }
.essay-full .end { font: 600 24px var(--hand); color: var(--rose-d); margin-top: 22px; }
/* art */
.card.art { background: transparent; border: 0; }
.card.art .im { border-radius: 6px; overflow: hidden; box-shadow: 0 10px 24px -10px rgba(42,26,31,.35); }
.card.art .im svg { width: 100%; height: auto; }
.card.art .pad { padding: 12px 2px 4px; }
.card.art.open { background: var(--card); border: 1px solid var(--line2); }
.card.art.open .pad { padding: 16px 18px; }
/* film */
.film-grid { display: grid; grid-template-columns: 40% 1fr; gap: 0; }
.film-grid .po { position: relative; aspect-ratio: 2/3; }
.film-grid .po svg { width: 100%; height: 100%; }
.film-grid .po span { position: absolute; left: 0; right: 0; bottom: 10px; text-align: center; font: 600 12px var(--serif); letter-spacing: .14em; text-transform: uppercase; color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.5); }
.rating { font: 12px var(--mono); color: var(--rose-d); margin-top: 10px; }
/* tape / video */
.video { aspect-ratio: 16/9; background: repeating-linear-gradient(135deg, #3a252c 0 10px, #33212a 10px 20px); position: relative; display: grid; place-items: center; color: #e3d4c1; font: 11px var(--mono); }
.video .vp { width: 56px; height: 56px; border-radius: 50%; border: 0; background: var(--acc); color: var(--plum); font-size: 16px; cursor: pointer; }
.video span { position: absolute; left: 12px; bottom: 10px; }
.setlist { list-style: none; padding: 0; margin: 12px 0 0; font-size: 14px; }
.setlist li { display: flex; justify-content: space-between; padding: 5px 2px; border-top: 1px solid #3e2a30; cursor: pointer; color: #e3d4c1; }
.setlist li:hover { color: #fff; }
.setlist li.on { color: var(--acc); }
.setlist li span:last-child { font: 11px var(--mono); color: #b8a592; }
/* live cards */
.card.live { background: #eee5d3; }
.live-src { display: inline-flex; align-items: center; gap: 6px; font: 11px var(--mono); color: var(--sage); }
.live-src i { width: 6px; height: 6px; border-radius: 50%; background: var(--sage); box-shadow: 0 0 0 3px rgba(111,127,69,.18); }
.covers { display: flex; gap: 8px; align-items: flex-end; margin-top: 14px; }
.covers .bk { flex: 1; aspect-ratio: 2/3; border-radius: 2px 4px 4px 2px; position: relative; padding: 8px 7px; color: #fff; font: italic 400 12px/1.1 var(--serif); box-shadow: 0 6px 12px -4px rgba(42,26,31,.35); overflow: hidden; transition: transform .2s var(--ease); }
.covers .bk::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: rgba(0,0,0,.18); }
.covers .bk.light { color: var(--ink); }
.covers .bk:hover { transform: translateY(-5px) rotate(-2deg); }
.covers .bk small { position: absolute; left: 7px; bottom: 6px; font: 9px var(--mono); font-style: normal; opacity: .85; }
.rlist { list-style: none; margin: 12px 0 0; padding: 0; font-size: 14.5px; }
.rlist li { display: flex; justify-content: space-between; gap: 10px; padding: 6px 0; border-top: 1px solid var(--line); }
.rlist li span:last-child { font: 12px var(--mono); color: var(--rose-d); white-space: nowrap; }
.rlist li small { font: 10.5px var(--mono); color: var(--mute); margin-left: 6px; }
.out { display: inline-block; margin-top: 12px; font: 11.5px var(--mono); color: var(--ink2); }
/* older */
.older { margin: 0 0 26px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; border-top: 1px solid var(--line); padding-top: 16px; font: 12px var(--mono); color: var(--mute); }
.older button { border: 1px solid var(--line2); background: var(--card); border-radius: 99px; padding: 6px 14px; cursor: pointer; font: 400 15px var(--serif); color: var(--ink); }
.older button:hover { background: var(--card2); }
.card.arc .th { height: 120px; }
.card.arc .th svg { width: 100%; height: 100%; }
.card.arc h3 { font-size: 17px; }
.empty { grid-column: 1 / -1; padding: 30px 0; font: 600 24px var(--hand); color: var(--mute); }
footer { border-top: 1px solid var(--line); padding: 22px 0 40px; display: flex; justify-content: space-between; gap: 24px; font: 12px/1.6 var(--mono); color: var(--mute); }
footer a { color: var(--ink2); text-underline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
@media (max-width: 1100px) {
.head { grid-template-columns: 1fr; gap: 22px; }
.bar .in { flex-wrap: wrap; height: auto; padding-top: 8px; padding-bottom: 8px; }
.chips { margin-left: 0; width: 100%; }
}
@media (max-width: 900px) {
.wall { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.card { grid-column: span min(6, calc(var(--span) * 2)); }
.now { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.now > div:nth-child(3) { border-left: 0; padding-left: 0; }
.reader { grid-template-columns: 1fr; }
}
@media (max-width: 620px) {
:root { --edge: 24px; }
.edge { font-size: 9px; }
.wrap, .bar .in { padding-left: 16px; padding-right: 16px; }
.card { grid-column: 1 / -1; }
.head h1 { font-size: 42px; }
.now { grid-template-columns: 1fr; }
.now > div + div { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); }
.game-grid, .film-grid { grid-template-columns: 1fr; }
.reader .pgbig { width: 150px; height: 200px; }
.reader .spread .pgbig:nth-child(2) { display: none; }
footer { flex-direction: column; }
}
</style>
</head>
<body>
<div class="mk" id="mk"><button onclick="document.getElementById('mk').remove()" aria-label="close">×</button>
<b>personal-r2-b · One big, rich wall</b>A calm editorial wall where every card is shaped by what it is (comic panels, a sleeve that plays, a cover with a pull-quote, a typeset essay, the drawing itself). "Now" across the top pulls in Hardcover and Trakt; chips filter by interest; a card opens in place to full width. New things get big cards, older ones settle smaller.</div>
<a class="edge" href="tech-r3-c.html" title="cross over to the work side"><span>work →</span></a>
<header class="bar">
<div class="in">
<div class="crumb"><a class="back" href="nav-d.html" title="back to the start page">← gate</a><b>Lucas Winther</b><i>/ off the clock</i></div>
<nav class="chips" id="chips" aria-label="filter by interest"></nav>
</div>
</header>
<main>
<div class="wrap">
<section class="head">
<div>
<span class="lab">02 · off the clock</span>
<h1>Things I love <i>&amp; things I think</i></h1>
<p>A wall of what's caught me lately: comic pages, songs, games, the odd drawing, and some slower writing. Open anything.</p>
</div>
<div class="now" id="now" aria-label="right now"></div>
</section>
<div class="wall-top"><h2 id="wtitle">everything, newest first</h2><span class="s" id="wsum"></span></div>
<div class="wall" id="wall"></div>
<div class="wall" id="wall2"></div>
<div id="olderHost"></div>
<footer>
<span>Placeholders (dotted underline): every game, film, song, book, essay, drawing, commission, the band and its gig, and all Trakt/Hardcover data. Real: Now Recharging, Junkwraith, Cupid 13, the villainess trope and the pumpkin (from Bluesky). Pictures are drawn stand-ins.</span>
<span style="white-space:nowrap"><a href="https://bsky.app/profile/stereotypicalcat.bsky.social">Bluesky</a> · <a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a> · <a href="tech-r3-c.html">work</a></span>
</footer>
</div>
</main>
<script>
/* ===== shared content + drawn placeholders (same data in r2-a and r2-b) ===== */
const TODAY = new Date('2026-11-09');
const PH = s => '<span class="ph" title="placeholder">' + s + '</span>';
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fdate(d) { const x = new Date(d); return x.getDate() + ' ' + MONTHS[x.getMonth()] + ' ' + x.getFullYear(); }
function fshort(d) { const x = new Date(d); return x.getDate() + ' ' + MONTHS[x.getMonth()]; }
function ago(d) {
const days = Math.round((TODAY - new Date(d)) / 864e5);
if (days < 1) return 'today'; if (days === 1) return 'yesterday';
if (days < 14) return days + ' days ago'; if (days < 60) return Math.round(days / 7) + ' weeks ago';
return Math.round(days / 30) + ' months ago';
}
function mmss(s) { s = Math.max(0, Math.floor(s)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }
const SHELVES = [
{ id: 'comics', name: 'Comics', hand: 'pages that got me', total: 11 },
{ id: 'games', name: 'Games', hand: 'played, loved, backed', total: 7 },
{ id: 'music', name: 'Music', hand: 'on repeat, plus the band', total: 9 },
{ id: 'film', name: 'Film & TV', hand: 'what I watch', total: 6, live: 'Trakt' },
{ id: 'books', name: 'Books', hand: 'what I read', total: 23, live: 'Hardcover' },
{ id: 'made', name: 'Made by me', hand: 'drawings, commissions, a pumpkin', total: 5 },
{ id: 'writing', name: 'Writing', hand: 'slower thoughts', total: 4 },
];
/* art specs: bg / fg / acc + a motif the cover drawer understands */
const ITEMS = [
{ id: 'mitski', shelf: 'music', kind: 'song', date: '2026-11-04', ph: 1,
title: 'My Love Mine All Mine', by: 'Mitski', len: 138,
line: "been on repeat all week. it's so small and so tender 🥹",
art: { bg: '#c9706b', fg: '#2a1a1f', acc: '#f1e6d6', motif: 'moon' },
body: ["I keep putting this on at night when the flat is quiet. It's barely two minutes and it feels like it's being sung to the moon specifically, which, fair.", "The line about the only thing you get to keep is the love you gave away has been living in my head rent free. Press play, it's short, I promise."] },
{ id: 'pumpkin', shelf: 'made', kind: 'photo', date: '2026-10-31', ph: 0,
title: 'This year’s pumpkin', line: "carved a pumpkin this year! 🎃 he's a little lopsided but he's mine",
art: { draw: 'pumpkin' }, label: 'photo: the pumpkin, lit, on the windowsill',
body: ["First pumpkin in years. The left eye went a bit wrong so I decided he's winking. Took about an hour and most of a spoon.", "He lasted four days before going soft. Rest well, little guy."] },
{ id: 'nowrecharging', shelf: 'comics', kind: 'comic', date: '2026-10-22', ph: 0,
title: 'Now Recharging', sub: 'webcomic · nowrecharging.com', url: 'https://nowrecharging.com',
line: 'i cried so much in this chapter – death has always been something that hits me emotionally hard',
pal: 'nr', pages: 4,
body: ["I've been following this for a while and this chapter just flattened me. It handles death so gently, nobody gets a big speech, it just sits with you.", "Go read it from the start, it's free on the site. Then come back and cry with me."] },
{ id: 'lanternfall', shelf: 'games', kind: 'kickstarter', date: '2026-10-12', ph: 1,
title: 'Lanternfall', sub: 'Kickstarter · backed Mar 2024 · Switch', hours: 14,
line: 'backed it two and a half years ago, it finally arrived and I played 14 hours in a week',
quote: 'It feels like someone made a game just for the version of me that stays up too late.',
art: { bg: '#2f3a3a', fg: '#e8b04a', acc: '#f1e6d6', motif: 'lantern' },
body: ["I backed this at the 180 DKK tier because the trailer had a fox carrying a lantern and that was enough. Then the delays. Then more delays. I'd honestly forgotten.", "It's a small cozy-ish metroidvania where light is your map: you hang lanterns and the world remembers where you've been. The music is gorgeous. The last boss made me put the controller down and walk around the flat.", "My name is in the credits under 'lantern keepers', which I will be telling everyone about."] },
{ id: 'crying', shelf: 'writing', kind: 'essay', date: '2026-10-03', ph: 1,
title: 'Why I let stories make me cry', mins: 6, tag: 'essay',
line: 'On comics, death, and why a sad chapter can be the best part of my week.',
body: ["Death has always hit me hard. Not in an abstract way: in the way where a character I've known for forty pages dies and I have to stop reading and look at the wall for a bit.", "For a long time I thought that was something to grow out of. It isn't. It's the point. A story that can make me grieve someone who never existed is a story that's practising something with me, quietly, so that the real thing is a little less foreign when it comes.", "Comics are especially good at this because they control time. A whole page can be one panel of silence. Now Recharging does this better than almost anything I've read lately.", "So: I let them. I read the sad chapter on a Sunday, with tea, and I let it land."] },
{ id: 'junkwraith', shelf: 'comics', kind: 'comic', date: '2026-09-18', ph: 0,
title: 'Junkwraith', sub: 'new comic',
line: 'a new comic with an interesting art style… feels like a coming-of-age story with a unique world',
pal: 'jw', pages: 3,
body: ["Picked this up on a whim and the art style grabbed me straight away: all scrap metal and soft light.", "It's early days but it already feels like a coming-of-age story set somewhere that's never been drawn before. Keeping an eye on this one."] },
{ id: 'caferetro', shelf: 'music', kind: 'tape', date: '2026-09-07', ph: 1,
title: 'Live at Café Retro, side A', by: 'Lav Sol (our band)', len: 1209,
line: 'handcam from the bar. eleven people and a dog, best gig we’ve had',
set: [['Grå morgen', 252], ['Kitchen light', 208], ['Slow ferry', 301], ['Halvvejs', 196], ['Paper boats', 252]],
body: ["A friend filmed this on her phone from the bar, so you get the espresso machine in Halvvejs for free.", "The band is a Thursday-night thing: four of us, a rehearsal room that smells of old carpet, and a gig every couple of months."] },
{ id: 'perfectdays', shelf: 'film', kind: 'film', date: '2026-08-30', ph: 1,
title: 'Perfect Days', sub: 'Wim Wenders · 2023', rating: 10,
line: 'a man cleans toilets in Tokyo and it’s the most hopeful film I’ve seen in years',
art: { bg: '#d9c39a', fg: '#2f4b46', acc: '#c9706b', motif: 'trees' },
body: ["Nothing happens and everything happens. He wakes up, waters his little trees, listens to cassettes in the van, takes a photo of the light through the leaves.", "I came out of it wanting to own fewer things and notice more. That feeling lasted about three days, which is longer than most films manage."] },
{ id: 'cupid13', shelf: 'comics', kind: 'comic', date: '2026-08-14', ph: 0,
title: 'Cupid 13', sub: 'MangaPlus one-shot',
line: 'a one-shot about a forsaken sniper who regains faith. so much in so few pages',
pal: 'c13', pages: 3,
body: ["A one-shot on MangaPlus about a sniper everyone gave up on, and who gave up on himself, finding his faith again.", "One-shots that land in under sixty pages are a magic trick to me. This one does."] },
{ id: 'knightcat', shelf: 'made', kind: 'commission', date: '2026-07-21', ph: 1,
title: 'Pixel as a tiny knight', by: 'commission by @placeholder.artist',
line: 'commissioned a drawing of my cat as a knight and I will never recover',
art: { draw: 'knightcat' },
body: ["I commissioned this from an artist I've followed for ages. The brief was just 'my cat, but brave'. She nailed it.", "It's framed above my desk now, guarding me from bugs."] },
{ id: 'outerwilds', shelf: 'games', kind: 'game', date: '2026-07-02', ph: 1,
title: 'Outer Wilds', sub: 'Mobius Digital · 2019 · PC', hours: 31,
line: 'the best museum of a universe I’ve ever wandered through',
quote: 'The only progression is what you know. I’d give anything to play it for the first time again.',
art: { bg: '#1f2a3a', fg: '#e8b04a', acc: '#c9706b', motif: 'planet' },
body: ["You have 22 minutes before the sun goes supernova, and then you wake up again by the campfire. Nothing in your inventory changes. Only you do.", "I won't say more because the whole game is the not-knowing. Play it with a notebook. Don't look anything up."] },
{ id: 'villainess', shelf: 'comics', kind: 'trope', date: '2026-06-20', ph: 0,
title: 'The villainess, reborn', sub: 'a trope I can’t quit',
line: 'villainess reborn as a good person, but everyone around her is psychopathic and possessive. I am weak for this',
pal: 'vl', pages: 3,
body: ["She wakes up as the villain of a novel she's read, decides to just be nice this time, and somehow everyone around her becomes deeply, alarmingly obsessed with her.", "I know. I KNOW. I've read too many of these. A few favourites are in the 'see all' shelf, titles marked as placeholders until I dig up the real list."] },
{ id: 'frog', shelf: 'made', kind: 'drawing', date: '2026-06-08', ph: 1,
title: 'Frog with umbrella', line: 'a little doodle from a rainy lunch break 🐸',
art: { draw: 'frog' },
body: ["Drawn on the back of a receipt in about four minutes. He's not keeping the rain off, he's just enjoying having an umbrella."] },
{ id: 'lifesofar', shelf: 'writing', kind: 'notebook', date: '2026-04-18', ph: 1,
title: 'Life so far, part one', mins: 11, tag: 'life',
line: 'Growing up, the rowing machine in the basement, and the first website I ever made.',
body: ["I was the kid who took the family PC apart to see where the internet lived. It was not in there.", "Part one is the early years: Lego, a tribute page to Norman Borlaug for freeCodeCamp that I am still weirdly proud of, and the summer I learned that soldering irons are hot on both ends.", "Part two, whenever I write it, is university, the band, and moving out."] },
{ id: 'hiatus', shelf: 'music', kind: 'song', date: '2026-04-02', ph: 1,
title: 'Building a Ladder', by: 'Hiatus Kaiyote', len: 245,
line: 'the chord at 2:40. that’s it, that’s the post',
art: { bg: '#3e5a47', fg: '#e8b04a', acc: '#f1e6d6', motif: 'stairs' },
body: ["I've tried to learn this on guitar three times. I can get to the chorus. The chord at 2:40 is beyond me and possibly beyond physics."] },
{ id: 'arcane', shelf: 'film', kind: 'film', date: '2026-03-11', ph: 1,
title: 'Arcane', sub: 'Season 2 · Netflix', rating: 9,
line: 'every frame looks like a painting someone was scared to finish',
art: { bg: '#2c2440', fg: '#e8b04a', acc: '#c9706b', motif: 'city' },
body: ["I don't even play League any more but this got me. The animation is unreal, and the sisters' story is the heart of it."] },
{ id: 'meaning', shelf: 'writing', kind: 'card', date: '2026-02-14', ph: 1,
title: 'What I think the point is (draft 3)', mins: 4, tag: 'meaning of life',
line: 'Short answer: other people, and making small things for them.',
body: ["Draft three, because drafts one and two were too sure of themselves.", "What I keep coming back to: the point is other people, and making small things for them. A pumpkin, a song shared at the right moment, a bug fixed before anyone notices.", "I'll probably disagree with this by draft four. That's also the point."] },
{ id: 'celeste', shelf: 'games', kind: 'game', date: '2026-01-19', ph: 1,
title: 'Celeste', sub: 'Maddy Makes Games · 2018', hours: 22,
line: 'replayed it in January. still the kindest hard game',
quote: 'It’s a game about climbing a mountain and also about being nice to yourself while you do.',
art: { bg: '#3a2733', fg: '#f1e6d6', acc: '#c9706b', motif: 'mountain' },
body: ["Replayed it over the holidays and got all the strawberries this time. The B-sides still make me sweat."] },
];
/* pulled from the services: shaped like their APIs, all placeholder */
const TRAKT = {
synced: '2 h ago', user: 'stereotypicalcat',
watching: { show: 'Frieren: Beyond Journey’s End', ep: 'S2 · E07', title: 'A Small Detour', progress: 0.58, art: { bg: '#3a4a5a', fg: '#f1e6d6', acc: '#e8b04a', motif: 'sparkle' } },
ratings: [ ['Perfect Days', 'film', 10, '30 Aug'], ['The Bear', 'S4', 7, '12 Aug'], ['Dune: Part Two', 'film', 8, '2 Aug'], ['Arcane', 'S2', 9, '11 Mar'] ],
stats: '41 films and 12 shows this year'
};
const HARDCOVER = {
synced: '5 h ago', user: 'stereotypicalcat',
reading: { title: 'Piranesi', by: 'Susanna Clarke', pct: 62, page: 152, of: 245, c: '#4f6a75' },
read: [
{ title: 'Tomorrow, and Tomorrow, and Tomorrow', by: 'Gabrielle Zevin', stars: 5, c: '#c9706b', h: 188, w: 40, d: 'Oct' },
{ title: 'Project Hail Mary', by: 'Andy Weir', stars: 4.5, c: '#e8b04a', h: 196, w: 42, d: 'Sep' },
{ title: 'The House in the Cerulean Sea', by: 'TJ Klune', stars: 4, c: '#6b8f9a', h: 172, w: 34, d: 'Aug' },
{ title: 'Klara and the Sun', by: 'Kazuo Ishiguro', stars: 4, c: '#d9c39a', h: 180, w: 30, d: 'Jul' },
{ title: 'Spinning Silver', by: 'Naomi Novik', stars: 3.5, c: '#5d6e4f', h: 200, w: 46, d: 'Jun' },
],
stats: '19 of 30 books toward the 2026 goal'
};
/* older things for the "see all" views (all placeholder) */
const ARCHIVE = {
comics: [['Lore of the Lighthouse', '2025'], ['The Duchess Will Not Be Moved', '2025'], ['Beneath the Rainline', '2025'], ['A Sky Full of Moths', '2025'], ['Her Majesty’s Very Polite Knights', '2024'], ['Small Hours', '2024'], ['Return of the Iron Countess', '2024']],
games: [['Hollow Knight', '2025'], ['Tunic', '2025'], ['A Short Hike', '2024'], ['Stardew Valley', '2024']],
music: [['Phoebe Bridgers, Motion Sickness', '2025'], ['Lav Sol, basement gig', '2025'], ['Nujabes, Aruarian Dance', '2025'], ['Japanese Breakfast, Be Sweet', '2024'], ['Lav Sol, kitchen demos', '2024'], ['Bon Iver, Holocene', '2024']],
film: [['Spirited Away', '2025'], ['Past Lives', '2025'], ['Blue Eye Samurai', '2024'], ['Paddington 2', '2024']],
books: [['The Left Hand of Darkness', '2025'], ['Never Let Me Go', '2025'], ['A Psalm for the Wild-Built', '2025'], ['The Remains of the Day', '2024'], ['Babel', '2024'], ['Legends & Lattes', '2024']],
made: [['Sketchbook: trains', '2025'], ['Birthday card for mum', '2024']],
writing: [['On being bad at guitar, on purpose', '2025']],
};
const ARC_COLORS = ['#7a4f4a', '#4f6a75', '#5d6e4f', '#8a6a3a', '#6b4a63', '#3e5a47', '#9a5a4a', '#4a4f6b'];
/* ---------- drawn placeholders ---------- */
function coverSVG(a, w, h, extra) {
w = w || 100; h = h || 100;
const { bg, fg, acc } = a; let m = '';
const cx = w / 2;
switch (a.motif) {
case 'moon': m = `<circle cx="${w * .62}" cy="${h * .38}" r="${w * .2}" fill="${acc}"/><circle cx="${w * .7}" cy="${h * .33}" r="${w * .18}" fill="${bg}"/><path d="M0 ${h * .78} Q ${w * .3} ${h * .7} ${w * .55} ${h * .8} T ${w} ${h * .74} V ${h} H0Z" fill="${fg}" opacity=".85"/><circle cx="${w * .22}" cy="${h * .2}" r="1.2" fill="${acc}"/><circle cx="${w * .34}" cy="${h * .44}" r="1" fill="${acc}"/><circle cx="${w * .85}" cy="${h * .6}" r="1" fill="${acc}"/>`; break;
case 'stairs': for (let i = 0; i < 6; i++) m += `<rect x="${w * .1 + i * w * .13}" y="${h * .82 - i * h * .11}" width="${w * .14}" height="${h}" fill="${i % 2 ? fg : acc}" opacity="${.55 + i * .07}"/>`; m += `<circle cx="${w * .8}" cy="${h * .2}" r="${w * .08}" fill="${fg}"/>`; break;
case 'lantern': m = `<rect x="0" y="${h * .72}" width="${w}" height="${h * .28}" fill="#00000033"/><path d="M${w * .2} ${h * .72} q ${w * .1} -${h * .25} ${w * .25} -${h * .2}" stroke="${acc}" stroke-width="1.2" fill="none" opacity=".6"/><line x1="${cx}" y1="0" x2="${cx}" y2="${h * .3}" stroke="${acc}" stroke-width="1"/><circle cx="${cx}" cy="${h * .45}" r="${w * .28}" fill="${fg}" opacity=".12"/><circle cx="${cx}" cy="${h * .45}" r="${w * .17}" fill="${fg}" opacity=".22"/><rect x="${cx - w * .08}" y="${h * .32}" width="${w * .16}" height="${h * .24}" rx="3" fill="${fg}"/><rect x="${cx - w * .1}" y="${h * .3}" width="${w * .2}" height="${h * .03}" fill="${acc}"/><path d="M${w * .66} ${h * .72} l ${w * .06} -${h * .08} l ${w * .03} ${h * .04} l ${w * .08} -${h * .02} l -${w * .02} ${h * .06}z" fill="${acc}"/>`; break;
case 'planet': m = `<circle cx="${w * .42}" cy="${h * .5}" r="${w * .22}" fill="${acc}"/><ellipse cx="${w * .42}" cy="${h * .5}" rx="${w * .36}" ry="${h * .07}" fill="none" stroke="${fg}" stroke-width="1.6" transform="rotate(-14 ${w * .42} ${h * .5})"/><circle cx="${w * .8}" cy="${h * .2}" r="${w * .06}" fill="${fg}"/>` + [[.15, .2], [.7, .75], [.88, .5], [.2, .85], [.55, .12]].map(p => `<circle cx="${w * p[0]}" cy="${h * p[1]}" r="1" fill="${fg}"/>`).join(''); break;
case 'mountain': m = `<path d="M0 ${h} L ${w * .38} ${h * .3} L ${w * .52} ${h * .5} L ${w * .7} ${h * .22} L ${w} ${h * .7} V ${h}Z" fill="${acc}" opacity=".9"/><path d="M${w * .7} ${h * .22} l -${w * .07} ${h * .12} l ${w * .05} -${h * .02} l ${w * .03} ${h * .05} l ${w * .04} -${h * .06}z" fill="${fg}"/><circle cx="${w * .88}" cy="${h * .16}" r="${w * .05}" fill="${fg}"/>`; break;
case 'trees': m = `<rect x="0" y="${h * .66}" width="${w}" height="${h * .34}" fill="${fg}" opacity=".18"/>` + [.18, .36, .6, .8].map((x, i) => `<circle cx="${w * x}" cy="${h * (.42 + (i % 2) * .08)}" r="${w * (.13 - i * .01)}" fill="${fg}" opacity="${.5 + i * .12}"/><rect x="${w * x - 1}" y="${h * (.5 + (i % 2) * .08)}" width="2" height="${h * .2}" fill="${fg}"/>`).join('') + `<circle cx="${w * .82}" cy="${h * .16}" r="${w * .06}" fill="${acc}"/>`; break;
case 'city': for (let i = 0; i < 8; i++) { const bh = h * (.25 + ((i * 37) % 40) / 100); m += `<rect x="${i * w / 8}" y="${h - bh}" width="${w / 8 - 1}" height="${bh}" fill="${i % 3 ? fg : acc}" opacity="${.35 + (i % 4) * .15}"/>`; } m += `<circle cx="${w * .3}" cy="${h * .25}" r="${w * .1}" fill="${acc}" opacity=".9"/>`; break;
case 'sparkle': m = `<path d="M${w * .5} ${h * .15} L ${w * .55} ${h * .45} L ${w * .85} ${h * .5} L ${w * .55} ${h * .55} L ${w * .5} ${h * .85} L ${w * .45} ${h * .55} L ${w * .15} ${h * .5} L ${w * .45} ${h * .45}Z" fill="${acc}" opacity=".9"/><circle cx="${w * .2}" cy="${h * .2}" r="1.5" fill="${fg}"/><circle cx="${w * .82}" cy="${h * .8}" r="1.5" fill="${fg}"/>`; break;
default: m = `<circle cx="${cx}" cy="${h / 2}" r="${w * .25}" fill="${acc}"/>`;
}
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="${w}" height="${h}" fill="${bg}"/>${m}${extra || ''}</svg>`;
}
const PAL = {
nr: { paper: '#f6efe3', ink: '#3a3f4a', tint: '#cfdde0', acc: '#e7a1a0', tone: '#9fb4bb' },
jw: { paper: '#ece6d4', ink: '#2f3a2c', tint: '#c9cfae', acc: '#b4623e', tone: '#8a9470' },
c13: { paper: '#f5f3ee', ink: '#1d1b1a', tint: '#e2dfd8', acc: '#1d1b1a', tone: '#9c9790' },
vl: { paper: '#faf0ee', ink: '#4a2a36', tint: '#f1d3d6', acc: '#c9706b', tone: '#d9a5ae' },
};
function panelArt(kind, p, uid) {
switch (kind) {
case 'fig': return `<rect width="100" height="100" fill="${p.tint}"/><circle cx="50" cy="44" r="16" fill="${p.ink}"/><path d="M18 100 Q 22 66 50 64 Q 78 66 82 100Z" fill="${p.ink}"/><circle cx="56" cy="42" r="2.5" fill="${p.paper}"/>`;
case 'bubble': return `<rect width="100" height="100" fill="${p.paper}"/><ellipse cx="52" cy="40" rx="38" ry="22" fill="#fff" stroke="${p.ink}" stroke-width="2"/><path d="M40 58 L 30 76 L 52 60" fill="#fff" stroke="${p.ink}" stroke-width="2"/><line x1="30" y1="34" x2="74" y2="34" stroke="${p.tone}" stroke-width="3"/><line x1="30" y1="44" x2="64" y2="44" stroke="${p.tone}" stroke-width="3"/><circle cx="76" cy="88" r="10" fill="${p.ink}"/>`;
case 'land': return `<rect width="100" height="100" fill="${p.tint}"/><circle cx="72" cy="30" r="13" fill="${p.acc}"/><path d="M0 70 Q 30 56 55 68 T 100 62 V100 H0Z" fill="${p.tone}"/><path d="M0 84 Q 40 74 100 86 V100 H0Z" fill="${p.ink}"/>`;
case 'eye': return `<rect width="100" height="100" fill="${p.paper}"/><path d="M8 52 Q 50 14 92 52 Q 50 86 8 52Z" fill="#fff" stroke="${p.ink}" stroke-width="3"/><circle cx="50" cy="51" r="16" fill="${p.ink}"/><circle cx="55" cy="46" r="4" fill="#fff"/><path d="M60 70 q 3 10 0 18" stroke="${p.acc}" stroke-width="3" fill="none"/>`;
case 'tone': return `<defs><pattern id="d${uid}" width="5" height="5" patternUnits="userSpaceOnUse"><circle cx="2.5" cy="2.5" r="1.1" fill="${p.tone}"/></pattern></defs><rect width="100" height="100" fill="${p.paper}"/><rect width="100" height="100" fill="url(#d${uid})"/>` + Array.from({ length: 9 }, (_, i) => `<line x1="50" y1="50" x2="${50 + 80 * Math.cos(i * .7)}" y2="${50 + 80 * Math.sin(i * .7)}" stroke="${p.ink}" stroke-width="1.2" opacity=".5"/>`).join('') + `<circle cx="50" cy="50" r="10" fill="${p.ink}"/>`;
case 'cross': return `<rect width="100" height="100" fill="${p.tint}"/><circle cx="50" cy="50" r="30" fill="none" stroke="${p.ink}" stroke-width="2.5"/><line x1="50" y1="8" x2="50" y2="92" stroke="${p.ink}" stroke-width="1.5"/><line x1="8" y1="50" x2="92" y2="50" stroke="${p.ink}" stroke-width="1.5"/><circle cx="50" cy="50" r="3" fill="${p.acc === p.ink ? '#b33' : p.acc}"/>`;
case 'rose': return `<rect width="100" height="100" fill="${p.tint}"/>` + [[25, 30], [70, 24], [50, 70], [18, 78], [84, 72]].map(c => `<circle cx="${c[0]}" cy="${c[1]}" r="11" fill="${p.acc}"/><circle cx="${c[0]}" cy="${c[1]}" r="5" fill="${p.tone}"/>`).join('') + `<path d="M50 20 l3 8 8 3 -8 3 -3 8 -3 -8 -8 -3 8 -3z" fill="#fff"/>`;
case 'scrap': return `<rect width="100" height="100" fill="${p.tint}"/><path d="M0 100 L 10 60 L 30 72 L 42 40 L 60 66 L 74 48 L 100 78 V 100Z" fill="${p.tone}"/><path d="M0 100 L 20 80 L 46 90 L 70 74 L 100 92 V100Z" fill="${p.ink}"/><circle cx="62" cy="26" r="9" fill="${p.acc}"/>`;
default: return `<rect width="100" height="100" fill="${p.tint}"/>`;
}
}
const LAYOUTS = [
[[4, 4, 112, 52], [4, 60, 54, 46], [62, 60, 54, 46], [4, 110, 112, 46]],
[[4, 4, 54, 102], [62, 4, 54, 49], [62, 57, 54, 49], [4, 110, 112, 46]],
[[4, 4, 112, 36], [4, 44, 74, 64], [82, 44, 34, 64], [4, 112, 112, 44]],
];
const KINDS = { nr: ['land', 'fig', 'bubble', 'eye'], jw: ['scrap', 'fig', 'land', 'bubble'], c13: ['cross', 'eye', 'tone', 'fig'], vl: ['rose', 'fig', 'bubble', 'eye'] };
let UID = 0;
function comicPageSVG(pal, n) {
const p = PAL[pal], L = LAYOUTS[n % 3], K = KINDS[pal];
let s = `<svg viewBox="0 0 120 160" aria-hidden="true"><rect width="120" height="160" fill="${p.paper}"/>`;
L.forEach((r, i) => {
const k = K[(i + n) % K.length]; const id = ++UID;
s += `<svg x="${r[0]}" y="${r[1]}" width="${r[2]}" height="${r[3]}" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice">${panelArt(k, p, id)}</svg><rect x="${r[0]}" y="${r[1]}" width="${r[2]}" height="${r[3]}" fill="none" stroke="${p.ink}" stroke-width="1.4"/>`;
});
return s + '</svg>';
}
function drawingSVG(kind) {
if (kind === 'pumpkin') return `<svg viewBox="0 0 160 120" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="160" height="120" fill="#1e1416"/><rect y="88" width="160" height="32" fill="#2f2124"/><rect x="0" y="0" width="160" height="88" fill="#241a20"/><line x1="0" y1="88" x2="160" y2="88" stroke="#3d2b2f"/><circle cx="80" cy="66" r="44" fill="#e8b04a" opacity=".08"/><ellipse cx="66" cy="70" rx="20" ry="22" fill="#c86a2c"/><ellipse cx="94" cy="70" rx="20" ry="22" fill="#c86a2c"/><ellipse cx="80" cy="70" rx="20" ry="24" fill="#d97a34"/><path d="M78 47 q 2 -10 9 -12" stroke="#5d6e4f" stroke-width="4" fill="none" stroke-linecap="round"/><path d="M66 62 l6 -8 6 8z" fill="#ffd27a"/><path d="M86 61 q 4 -3 9 0" stroke="#ffd27a" stroke-width="2.5" fill="none" stroke-linecap="round"/><path d="M64 76 q 16 12 32 0 l -4 2 -3 -3 -4 4 -4 -4 -4 4 -4 -4 -3 3z" fill="#ffd27a"/></svg>`;
if (kind === 'knightcat') return `<svg viewBox="0 0 120 150" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="120" height="150" fill="#e9d6c4"/><circle cx="60" cy="64" r="44" fill="#c9706b" opacity=".35"/><path d="M28 140 Q 30 96 60 94 Q 90 96 92 140Z" fill="#7d8a96"/><path d="M60 98 v 40" stroke="#5b6670" stroke-width="2"/><circle cx="60" cy="70" r="26" fill="#3a2a2a"/><path d="M38 56 l 4 -22 12 14z M82 56 l -4 -22 -12 14z" fill="#3a2a2a"/><path d="M36 62 Q 60 42 84 62 V 70 H 36Z" fill="#9aa5ae"/><rect x="36" y="66" width="48" height="5" fill="#5b6670"/><circle cx="51" cy="78" r="3" fill="#e8b04a"/><circle cx="69" cy="78" r="3" fill="#e8b04a"/><path d="M57 86 l3 3 3 -3" stroke="#e9d6c4" stroke-width="1.5" fill="none"/><path d="M98 50 L 98 128" stroke="#cfd6db" stroke-width="4" stroke-linecap="round"/><path d="M90 116 h 16" stroke="#8a6a3a" stroke-width="4" stroke-linecap="round"/><path d="M60 40 q 6 -14 14 -8" stroke="#c9706b" stroke-width="3" fill="none"/></svg>`;
if (kind === 'frog') return `<svg viewBox="0 0 140 120" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="140" height="120" fill="#f6efe2"/><g fill="none" stroke="#2a1a1f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M40 52 Q 70 14 100 52 Z" fill="#c9706b" fill-opacity=".55"/><path d="M70 52 V 84 q 0 6 -6 6"/><ellipse cx="72" cy="94" rx="22" ry="12" fill="#a7b86c" fill-opacity=".6"/><circle cx="62" cy="82" r="5" fill="#fff"/><circle cx="82" cy="82" r="5" fill="#fff"/><circle cx="63" cy="82" r="1.5" fill="#2a1a1f"/><circle cx="81" cy="82" r="1.5" fill="#2a1a1f"/><path d="M66 98 q 6 4 12 0"/><path d="M30 30 l -2 6 M24 50 l -2 6 M112 34 l -2 6 M118 62 l -2 6 M34 72 l -2 6"/></g></svg>`;
return '';
}
/* one shared fake audio clock: advances only after the visitor presses play */
const PLAYER = { id: null, t: 0, timer: null, subs: [] };
function playerToggle(id, len) {
if (PLAYER.id === id && PLAYER.timer) { clearInterval(PLAYER.timer); PLAYER.timer = null; }
else {
if (PLAYER.id !== id) PLAYER.t = 0;
PLAYER.id = id; clearInterval(PLAYER.timer);
PLAYER.timer = setInterval(() => { PLAYER.t += .25; if (PLAYER.t >= len) { PLAYER.t = 0; clearInterval(PLAYER.timer); PLAYER.timer = null; } playerEmit(); }, 250);
}
playerEmit();
}
function playerEmit() { PLAYER.subs.forEach(f => f(PLAYER)); }
function playerSeek(id, len, frac) { if (PLAYER.id !== id) { PLAYER.id = id; } PLAYER.t = frac * len; playerEmit(); }
const byId = id => ITEMS.find(i => i.id === id);
/* ===================== r2-b: the wall ===================== */
const title = it => it.ph ? PH(it.title) : it.title;
const sorted = ITEMS.slice().sort((a, b) => new Date(b.date) - new Date(a.date));
const INTERESTS = [
['all', 'everything'], ['comics', 'comics'], ['games', 'games'], ['music', 'music'], ['film', 'film & tv'],
['books', 'books'], ['made', 'made by me'], ['writing', 'writing'], ['life', 'life'],
];
const tagsOf = it => { const t = [it.shelf]; if (it.tag === 'life' || it.tag === 'meaning of life') t.push('life'); return t; };
/* size in 12ths: newer + richer things get more room */
const SPAN = { mitski: 3, pumpkin: 4, nowrecharging: 6, lanternfall: 5, crying: 3, junkwraith: 4, caferetro: 5, perfectdays: 5, cupid13: 3, knightcat: 3, outerwilds: 6, villainess: 3, frog: 3, lifesofar: 4, hiatus: 4, arcane: 4, meaning: 4, celeste: 4, _hardcover: 5, _trakt: 3 };
function playerBox(it) {
return `<div class="mini" data-mini="${it.id}" data-len="${it.len}"><button class="pp" aria-label="play">▶</button><div class="bar2"><i></i></div><span class="tm">0:00 / ${mmss(it.len)}</span></div>`;
}
function metaLine(it, label) {
const fresh = (TODAY - new Date(it.date)) / 864e5 < 21;
return `<div class="meta">${fresh ? '<span class="new">new</span>' : ''}<span>${label}</span><span>·</span><span>${fshort(it.date)}</span></div>`;
}
function panelsHTML(it, three, h) {
const p = PAL[it.pal], K = KINDS[it.pal];
const n = three ? 3 : 3;
return `<div class="panels${three ? ' three' : ''}" style="--pp:${p.paper};--pi:${p.ink};--ph:${h}px">${[0, 1, 2].slice(0, n).map(i => `<div><svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice">${panelArt(K[i], p, ++UID)}</svg></div>`).join('')}</div>`;
}
const paras = it => it.body.map(p => `<p>${it.ph ? PH(p) : p}</p>`).join('');
function cardHTML(it) {
const sp = SPAN[it.id] || 3; const said = `<div class="said">“${it.line}”</div>`;
const tags = tagsOf(it).join(' ');
const open = `<div class="open-hint">open ↘</div>`;
const X = `<button class="x-close" data-close>esc · close</button>`;
switch (it.kind) {
case 'comic': case 'trope': {
const wide = sp >= 6;
return `<article class="card comic" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="teaser">${panelsHTML(it, wide, wide ? 230 : 210)}<div class="pad">${metaLine(it, it.kind === 'trope' ? 'comics · a trope' : 'comic')}<h3>${it.title}</h3>${said}${open}</div></div>
<div class="full"><div class="reader"><div><div class="spread" data-spread></div><div class="ctl"><button data-prev>← prev</button><span data-pos></span><button data-next>next →</button><span style="margin-left:auto">← → keys</span></div></div>
<div class="prose">${metaLine(it, 'comic · ' + it.sub)}<h3 style="font-size:36px;margin-top:8px">${it.title}</h3>${said}${paras(it)}${it.url ? `<a class="src" href="${it.url}">read it at nowrecharging.com ↗</a>` : `<span class="src">${PH('where to read it ↗')}</span>`}<div class="facts" style="margin-top:14px"><span>drawn stand-ins for the shared pages</span></div></div></div></div></article>`;
}
case 'song':
return `<article class="card dark song" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="sleeve" data-sleeve="${it.id}" style="--c:${it.art.bg}"><div class="rec"></div><div class="slv">${coverSVG(it.art, 200, 200)}</div><button class="bigpp" data-mini-btn="${it.id}" data-len="${it.len}" aria-label="play">▶</button></div>
<div class="pad">${metaLine(it, 'song')}<h3>${title(it)}</h3><div class="meta" style="text-transform:none;letter-spacing:0;margin-top:2px;font-size:12px">${PH(it.by)}</div>${playerBox(it)}
<div class="teaser">${said}</div><div class="full prose">${said}${paras(it)}<span class="src">${PH('Bandcamp ↗ · Spotify ↗')}</span></div></div></article>`;
case 'tape':
return `<article class="card dark tape" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="video"><button class="vp" data-mini-btn="${it.id}" data-len="${it.len}" aria-label="play">▶</button><span>handcam video: Café Retro, from the bar</span></div>
<div class="pad">${metaLine(it, 'the band · live')}<h3>${PH(it.title)}</h3>${playerBox(it)}<div class="teaser">${said}</div>
<div class="full"><div style="display:grid;grid-template-columns:1fr 1fr;gap:30px"><div class="prose">${said}${paras(it)}</div><ul class="setlist" data-setlist>${it.set.map((x, i) => `<li data-at="${it.set.slice(0, i).reduce((n, y) => n + y[1], 0)}"><span>${i + 1}. ${PH(x[0])}</span><span>${mmss(x[1])}</span></li>`).join('')}</ul></div></div></div></article>`;
case 'game': case 'kickstarter':
if (sp >= 5) return `<article class="card game" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="game-grid"><div class="cv">${coverSVG(it.art, 260, 300)}<span class="t">${it.title}</span>${it.kind === 'kickstarter' ? '<span class="stamp">backed!</span>' : ''}</div>
<div class="pad">${metaLine(it, it.kind === 'kickstarter' ? 'game · kickstarter' : 'game')}<p class="quote">${PH(it.quote)}</p><h3 style="font-size:18px;margin-top:12px">${title(it)} <span style="color:var(--mute);font-weight:300;font-size:14px">· ${PH(it.hours + ' h')}</span></h3>
<div class="full prose">${paras(it)}<div class="facts"><span>${PH(it.sub)}</span>${it.kind === 'kickstarter' ? `<span>${PH('180 DKK tier · name in the credits')}</span>` : ''}</div></div>${open}</div></div></article>`;
return `<article class="card game" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="gcv" style="position:relative;height:180px">${coverSVG(it.art, 300, 180)}<span style="position:absolute;left:14px;top:10px;font:italic 400 24px var(--serif);color:#fff">${it.title}</span></div>
<div class="pad">${metaLine(it, 'game')}<p class="quote" style="font-size:20px">${PH(it.quote)}</p><div class="full prose">${paras(it)}<div class="facts"><span>${PH(it.sub)}</span><span>${PH(it.hours + ' h played')}</span></div></div>${open}</div></article>`;
case 'film':
return `<article class="card film" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="film-grid"><div class="po">${coverSVG(it.art, 200, 300)}<span>${it.title}</span></div><div class="pad">${metaLine(it, 'film & tv')}<h3>${title(it)}</h3><div class="meta" style="text-transform:none;letter-spacing:0;margin-top:2px;font-size:12px">${PH(it.sub)}</div>${said}<div class="rating">♥ ${PH(it.rating + '/10 on Trakt')}</div>
<div class="full prose">${paras(it)}</div>${open}</div></div></article>`;
case 'photo': case 'commission': case 'drawing':
return `<article class="card art" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="teaser"><div class="im">${drawingSVG(it.art.draw)}</div><div class="pad">${metaLine(it, it.kind === 'commission' ? 'commissioned' : it.kind === 'photo' ? 'made by me · photo' : 'made by me · doodle')}<h3 style="font-size:19px">${title(it)}</h3></div></div>
<div class="full"><div style="display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:32px;padding:18px"><div class="im" style="border-radius:6px;overflow:hidden">${drawingSVG(it.art.draw)}</div>
<div class="prose">${metaLine(it, 'made by me')}<h3 style="font-size:34px;margin-top:8px">${title(it)}</h3>${it.by ? `<div class="meta" style="text-transform:none;margin-top:4px">${PH(it.by)}</div>` : ''}<div class="said">“${it.line}”</div>${paras(it)}<div class="facts"><span>${it.label || PH('drawn stand-in for the real picture')}</span></div></div></div></div></article>`;
default: {
const cls = it.tag === 'life' ? 'life' : it.tag === 'meaning of life' ? 'meaning' : '';
return `<article class="card essay ${cls}" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="teaser pad">${metaLine(it, it.tag)}<h3>${PH(it.title)}</h3><div class="ex">${PH(it.body[0])}</div><div class="rd"><span>${it.mins} min read</span><span>read ↘</span></div></div>
<div class="full pad"><div class="essay-full">${metaLine(it, it.tag + ' · ' + it.mins + ' min read')}<h3 style="font-size:48px;margin-top:12px">${PH(it.title)}</h3>${paras(it).replace(/<p>/g, '<p>')}<div class="end">– Lucas</div></div></div></article>`;
}
}
}
function hardcoverCard() {
const r = HARDCOVER.reading;
return `<article class="card live" data-id="_hardcover" data-tags="books" style="--span:${SPAN._hardcover}" data-date="2026-11-08"><div class="pad">
<div class="meta"><span class="live-src"><i></i>Hardcover · ${HARDCOVER.synced}</span></div><h3>Books lately</h3>
<div class="covers">${[{ title: r.title, c: r.c, pct: r.pct }].concat(HARDCOVER.read.slice(0, 3)).map(b => `<div class="bk${b.c === '#e8b04a' || b.c === '#d9c39a' ? ' light' : ''}" style="background:${b.c}" title="${b.title}">${b.title}<small>${b.pct ? b.pct + '% in' : b.stars + '★'}</small></div>`).join('')}</div>
<ul class="rlist"><li><span>${PH(r.title)}<small>reading</small></span><span>p. ${r.page}/${r.of}</span></li>${HARDCOVER.read.slice(0, 3).map(b => `<li><span>${PH(b.title.length > 28 ? b.title.slice(0, 26) + '…' : b.title)}</span><span>${'★'.repeat(Math.floor(b.stars))}${b.stars % 1 ? '½' : ''}</span></li>`).join('')}</ul>
<a class="out" href="#" onclick="return false">${PH(HARDCOVER.stats)} · hardcover.app ↗</a></div></article>`;
}
function traktCard() {
return `<article class="card live" data-id="_trakt" data-tags="film" style="--span:${SPAN._trakt}" data-date="2026-08-29"><div class="pad">
<div class="meta"><span class="live-src"><i></i>Trakt · ${TRAKT.synced}</span></div><h3>Recently rated</h3>
<ul class="rlist">${TRAKT.ratings.map(r => `<li><span>${PH(r[0])}<small>${r[1]}</small></span><span>♥ ${r[2]}</span></li>`).join('')}</ul>
<a class="out" href="#" onclick="return false">${PH(TRAKT.stats)} ↗</a></div></article>`;
}
/* ---------- now header ---------- */
function renderNow() {
const song = byId('mitski'), w = TRAKT.watching, r = HARDCOVER.reading, g = byId('lanternfall');
document.getElementById('now').innerHTML = `
<div><h4><span>Listening</span><a href="#" data-jump="mitski">why ↘</a></h4><div class="x"><div class="c">${coverSVG(song.art, 48, 48)}<button class="pp" data-mini-btn="mitski" data-len="${song.len}" aria-label="play">▶</button></div><div><b>${PH(song.title)}</b><small>${PH(song.by)} · on repeat</small></div></div></div>
<div><h4><span>Reading</span><a href="#" onclick="return false" title="pulled from Hardcover">Hardcover ↗</a></h4><div class="x"><div class="c tall" style="background:${r.c};padding:5px;color:#f1e6d6;font:italic 400 10px/1 var(--serif)">${r.title}</div><div><b>${PH(r.title)}</b><small>${PH(r.by)}</small><div class="pg"><i style="width:${r.pct}%"></i></div><small>${r.pct}% · synced ${HARDCOVER.synced}</small></div></div></div>
<div><h4><span>Watching</span><a href="#" onclick="return false" title="pulled from Trakt">Trakt ↗</a></h4><div class="x"><div class="c tall">${coverSVG(w.art, 48, 66)}</div><div><b>${PH(w.show)}</b><small>${w.ep}</small><div class="pg"><i style="width:${w.progress * 100}%"></i></div><small>synced ${TRAKT.synced}</small></div></div></div>
<div><h4><span>Playing</span><a href="#" data-jump="lanternfall">notes ↘</a></h4><div class="x"><div class="c">${coverSVG(g.art, 48, 48)}</div><div><b>${PH(g.title)}</b><small>${PH('Switch · ' + g.hours + ' h so far')}</small><small style="color:var(--dim)">updated by hand</small></div></div></div>`;
}
/* ---------- wall ---------- */
const wall = document.getElementById('wall');
let filter = 'all', olderShown = false;
function render() {
const entries = sorted.map(it => ({ d: it.date, html: cardHTML(it) }))
.concat([{ d: '2026-10-25', html: hardcoverCard() }, { d: '2026-08-29', html: traktCard() }])
.sort((a, b) => new Date(b.d) - new Date(a.d));
let h = entries.map(e => e.html).join('');
document.getElementById('olderHost').innerHTML = `<div class="older" id="older"><span>2026 so far. Older things settle into smaller cards:</span><button data-older="2025">2025 · 24 things ↓</button><button data-older="2024" disabled style="opacity:.5">2024 · 23</button></div>`;
wall.innerHTML = h;
applyFilter();
}
function olderCards() {
const picks = [['comics', 'Lore of the Lighthouse', 'nr'], ['music', 'Phoebe Bridgers, Motion Sickness', null, 'moon'], ['games', 'Hollow Knight', null, 'planet'], ['books', 'The Left Hand of Darkness', null, 'mountain'], ['comics', 'The Duchess Will Not Be Moved', 'vl'], ['film', 'Past Lives', null, 'trees'], ['writing', 'On being bad at guitar, on purpose'], ['made', 'Sketchbook: trains', null, 'city']];
let h = `<div class="yr">2025 <small>24 things · showing 8</small></div>`;
picks.forEach((p, k) => {
const c = ARC_COLORS[k % ARC_COLORS.length];
const th = p[2] ? comicPageSVG(p[2], k).replace('<svg ', '<svg preserveAspectRatio="xMidYMid slice" ') : p[3] ? coverSVG({ bg: c, fg: '#f1e6d6', acc: '#e8b04a', motif: p[3] }, 200, 120) : '';
h += `<article class="card arc" data-tags="${p[0]}" style="--span:3" title="placeholder archive item">${th ? `<div class="th">${th}</div>` : ''}<div class="pad"><div class="meta"><span>${INTERESTS.find(i => i[0] === p[0])[1]}</span><span>· 2025</span></div><h3${th ? '' : ' style="font-size:24px;font-style:italic;font-weight:300"'}>${PH(p[1])}</h3></div></article>`;
});
return h;
}
function applyFilter() {
let n = 0;
document.querySelectorAll('.wall .card').forEach(c => { const on = filter === 'all' || c.dataset.tags.split(' ').includes(filter); c.classList.toggle('hide', !on); if (on) n++; });
wall.querySelectorAll('.empty').forEach(e => e.remove());
// filtered views are short: give them an even rhythm instead of the curated mix
document.querySelectorAll('.wall .card').forEach(c => { if (!c.dataset.span0) c.dataset.span0 = c.style.getPropertyValue('--span'); c.style.setProperty('--span', filter === 'all' ? c.dataset.span0 : (n <= 4 ? 6 : 4)); });
if (!n) wall.insertAdjacentHTML('afterbegin', '<div class="empty">nothing here yet</div>');
const lab = INTERESTS.find(i => i[0] === filter)[1];
document.getElementById('wtitle').textContent = filter === 'all' ? 'everything, newest first' : lab + ', newest first';
document.getElementById('wsum').innerHTML = filter === 'all' ? '<b>' + ITEMS.length + '</b> things this year · 1–3 new a month · 65 since 2024' : '<b>' + n + '</b> on the wall · more in older years';
document.querySelectorAll('.chip').forEach(c => c.setAttribute('aria-pressed', c.dataset.f === filter));
layout();
}
function layout() {
const rowH = 6, gap = 0;
document.querySelectorAll('.wall > :not(.hide)').forEach(c => {
c.style.gridRowEnd = 'auto';
const h = c.getBoundingClientRect().height + 22;
c.style.gridRowEnd = 'span ' + Math.ceil((h + gap) / (rowH + gap));
});
}
function renderChips() {
const count = f => f === 'all' ? ITEMS.length + 2 : ITEMS.filter(it => tagsOf(it).includes(f)).length + (f === 'books' || f === 'film' ? 1 : 0);
document.getElementById('chips').innerHTML = INTERESTS.map(([f, l]) => `<button class="chip" data-f="${f}" aria-pressed="${f === filter}">${l}<sup>${f === 'books' ? 'live' : count(f)}</sup></button>`).join('');
}
/* ---------- open in place ---------- */
let openCard = null, reader = null;
function openC(card) {
if (openCard === card) return;
closeC(true);
openCard = card; card.classList.add('open');
const it = byId(card.dataset.id);
if (it && it.pal) { reader = { it, p: 0, card }; drawReader(); }
const sl = card.querySelector('[data-setlist]');
if (sl) sl.querySelectorAll('li').forEach(li => li.onclick = e => { e.stopPropagation(); PLAYER.id = it.id; PLAYER.t = +li.dataset.at; if (!PLAYER.timer) playerToggle(it.id, it.len); else playerEmit(); });
layout();
requestAnimationFrame(() => card.scrollIntoView({ block: 'start', behavior: 'smooth' }));
}
function closeC(silent) {
if (!openCard) return;
const c = openCard; openCard = null; reader = null; c.classList.remove('open'); layout();
if (!silent) c.focus({ preventScroll: true });
}
function drawReader() {
const { it, p, card } = reader;
card.querySelector('[data-spread]').innerHTML = [p, p + 1].filter(i => i < it.pages).map(i => `<div class="pgbig">${comicPageSVG(it.pal, i)}</div>`).join('');
card.querySelector('[data-pos]').textContent = 'pages ' + (p + 1) + (p + 1 < it.pages ? '–' + (p + 2) : '') + ' of ' + it.pages;
card.querySelector('[data-prev]').disabled = p === 0;
card.querySelector('[data-next]').disabled = p + 2 >= it.pages;
}
function turn(d) { if (!reader) return; const np = reader.p + d * 2; if (np < 0 || np >= reader.it.pages) return; reader.p = np; drawReader(); }
/* ---------- player ---------- */
PLAYER.subs.push(st => {
document.querySelectorAll('[data-mini]').forEach(m => {
const on = m.dataset.mini === st.id, len = +m.dataset.len, t = on ? st.t : 0, playing = on && !!st.timer;
m.querySelector('.pp').textContent = playing ? '❚❚' : '▶';
m.querySelector('.bar2 i').style.width = (t / len * 100) + '%';
m.querySelector('.tm').textContent = mmss(t) + ' / ' + mmss(len);
});
document.querySelectorAll('[data-mini-btn]').forEach(b => { const p = b.dataset.miniBtn === st.id && !!st.timer; b.textContent = p ? '❚❚' : '▶'; b.setAttribute('aria-label', p ? 'pause' : 'play'); });
document.querySelectorAll('[data-sleeve]').forEach(s => s.classList.toggle('playing', s.dataset.sleeve === st.id && !!st.timer));
const it = byId(st.id);
document.querySelectorAll('[data-setlist]').forEach(sl => { if (!it || !it.set) return; let acc = 0; sl.querySelectorAll('li').forEach((li, i) => { const a = acc; acc += it.set[i][1]; li.classList.toggle('on', st.t >= a && st.t < acc && (st.t > 0 || !!st.timer)); }); });
});
/* ---------- wire up ---------- */
renderNow(); renderChips(); render();
document.addEventListener('click', e => {
const t = e.target;
const chip = t.closest('.chip'); if (chip) { filter = chip.dataset.f; closeC(true); applyFilter(); return; }
const mb = t.closest('[data-mini-btn]'); if (mb) { e.stopPropagation(); playerToggle(mb.dataset.miniBtn, +mb.dataset.len); return; }
const mini = t.closest('[data-mini]');
if (mini) { const id = mini.dataset.mini, len = +mini.dataset.len;
if (t.closest('.pp')) playerToggle(id, len);
else if (t.closest('.bar2')) { const r = mini.querySelector('.bar2').getBoundingClientRect(); playerSeek(id, len, (e.clientX - r.left) / r.width); }
return; }
if (t.closest('[data-close]')) { closeC(); return; }
if (t.closest('[data-prev]')) { turn(-1); return; }
if (t.closest('[data-next]')) { turn(1); return; }
const j = t.closest('[data-jump]'); if (j) { e.preventDefault(); filter = 'all'; applyFilter(); openC(wall.querySelector(`[data-id="${j.dataset.jump}"]`)); return; }
const ob = t.closest('[data-older]'); if (ob && !olderShown) { olderShown = true; const o = document.getElementById('older'); document.getElementById('wall2').innerHTML = olderCards(); ob.disabled = true; ob.style.opacity = .5; o.querySelector('span').textContent = '2025 loaded. Older years keep stacking below, each behind its own button:'; o.querySelectorAll('button')[1].disabled = false; o.querySelectorAll('button')[1].style.opacity = 1; applyFilter(); return; }
const card = t.closest('.card[data-id]'); if (card && !card.classList.contains('open') && !card.dataset.id.startsWith('_') && !t.closest('a')) openC(card);
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeC(); return; }
if (reader && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) { turn(e.key === 'ArrowRight' ? 1 : -1); return; }
if (e.key === 'Enter' && e.target.matches('.card[data-id]')) openC(e.target);
});
window.addEventListener('resize', layout);
if (document.fonts) document.fonts.ready.then(layout);
window.addEventListener('load', layout);
(function () { const m = location.hash.replace('&noscroll', '').match(/^#(open|filter)=(.+)$/); const ns = location.hash.includes('noscroll'); if (!m) return; if (m[1] === 'filter') { filter = m[2]; applyFilter(); } else { const c = wall.querySelector(`[data-id="${m[2]}"]`); if (c) { c.classList.add('open'); openCard = c; const it = byId(m[2]); if (it.pal) { reader = { it, p: 0, card: c }; drawReader(); } layout(); if (!ns) setTimeout(() => c.scrollIntoView({ block: 'start' }), 50); } } })();
</script>
</body>
</html>