Files
portfolio-next/mockups/personal-r2-a.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

859 lines
80 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=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #2a1a1f; --bg2: #321f25; --bg3: #3a252c; --line: #4a3238; --line2: #5c4148;
--ink: #f1e6d6; --ink2: #e3d4c1; --mute: #b8a592; --dim: #8a7768;
--acc: #e8b04a; --rose: #c9706b; --sage: #a7b86c;
--paper: #f1e6d6; --pink: #2a1a1f;
--serif: "Fraunces", Georgia, serif; --hand: "Caveat", cursive; --mono: "Geist Mono", ui-monospace, monospace;
--ease: cubic-bezier(.2,.8,.2,1);
--edge: 42px;
}
* { box-sizing: border-box; }
html { scroll-padding-top: 70px; }
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: .13;
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='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 .9 0 0 0 0 .8 0 0 0 .55 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(--bg); }
:focus-visible { outline: 2px solid var(--acc); outline-offset: 3px; }
.ph { text-decoration: underline dotted rgba(241,230,214,.4); text-underline-offset: 3px; text-decoration-thickness: 1px; cursor: help; }
.mono { font-family: var(--mono); }
/* mockup note */
.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,.25); }
.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; }
/* work edge (mirror of tech-r3-c's off-the-clock edge) */
.edge { position: fixed; top: 0; left: 0; bottom: 0; width: var(--edge); z-index: 40; background: #131210; color: #ede7dd; border-right: 1px solid #2b2824; display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; transform-origin: center; font: 500 12px "Geist 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; }
/* top bar */
.bar { position: sticky; top: 0; z-index: 30; margin-left: var(--edge); background: rgba(42,26,31,.92); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border-bottom: 1px solid var(--line); }
.bar .in { max-width: 1320px; margin: 0 auto; padding: 0 40px; height: 56px; display: flex; align-items: center; gap: 24px; }
.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(--mute); }
.crumb b { font-weight: 500; font-size: 15px; }
.crumb i { color: var(--mute); font-weight: 300; }
.snav { display: flex; gap: 2px; margin-left: 4px; overflow-x: auto; scrollbar-width: none; }
.snav a { text-decoration: none; color: var(--mute); font-size: 14px; padding: 5px 9px; border-radius: 6px; white-space: nowrap; }
.snav a:hover { color: var(--ink); background: var(--bg3); }
.snav a sup { font: 10px var(--mono); color: var(--dim); margin-left: 2px; }
.bar .meta { margin-left: auto; font: 12px var(--mono); color: var(--dim); white-space: nowrap; }
main { margin-left: var(--edge); position: relative; z-index: 1; }
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 40px; }
/* intro + new lately */
.intro { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr); gap: 48px; align-items: end; padding: 40px 0 34px; }
.intro .lab { font: 600 23px var(--hand); color: var(--acc); transform: rotate(-2deg); display: inline-block; }
.intro h1 { font-weight: 300; font-style: italic; font-size: 50px; line-height: 1.02; letter-spacing: -.02em; margin: 6px 0 0; }
.intro p { color: var(--ink2); font-weight: 300; font-size: 17px; margin: 14px 0 0; max-width: 30em; }
.lately { border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(180deg, rgba(255,255,255,.035), rgba(255,255,255,.01)); padding: 14px 16px 16px; }
.lately h3 { margin: 0 0 10px; display: flex; justify-content: space-between; align-items: baseline; font: 500 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--mute); }
.lately h3 em { font: 600 20px var(--hand); text-transform: none; letter-spacing: 0; color: var(--acc); font-style: normal; }
.lately .row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.lt { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: 64px 1fr; gap: 12px; align-items: center; padding: 8px; border-radius: 10px; transition: background .18s; }
.lt:hover, .lt:focus-visible { background: var(--bg3); }
.lt .th { width: 64px; height: 64px; border-radius: 4px; overflow: hidden; box-shadow: 0 6px 14px rgba(0,0,0,.35); transition: transform .2s var(--ease); }
.lt:hover .th { transform: rotate(-3deg) scale(1.05); }
.lt .th svg { width: 100%; height: 100%; display: block; }
.lt small { display: block; font: 11px var(--mono); color: var(--acc); }
.lt b { display: block; font-weight: 500; font-size: 15px; line-height: 1.2; margin: 2px 0; }
.lt span { font-size: 13px; color: var(--mute); }
/* shelves */
.shelf { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 32px; padding: 34px 0 30px; border-top: 1px solid var(--line); }
.sh-head { position: relative; padding-top: 4px; }
.sh-head .n { font: 600 20px var(--hand); color: var(--dim); }
.sh-head h2 { margin: 0; font-weight: 400; font-style: italic; font-size: 32px; letter-spacing: -.015em; line-height: 1.05; }
.sh-head .hd { font: 500 21px var(--hand); color: var(--acc); line-height: 1.1; margin-top: 4px; transform: rotate(-1.5deg); transform-origin: left; }
.sh-head .live { margin-top: 12px; display: inline-flex; align-items: center; gap: 7px; font: 11px var(--mono); color: var(--sage); border: 1px solid #4f5a3a; border-radius: 99px; padding: 3px 9px 3px 8px; }
.sh-head .live i { width: 6px; height: 6px; border-radius: 50%; background: var(--sage); box-shadow: 0 0 0 3px rgba(167,184,108,.18); }
.sh-head .cnt { font: 12px var(--mono); color: var(--dim); margin-top: 14px; }
.sh-head .all { margin-top: 6px; background: none; border: 1px solid var(--line2); border-radius: 99px; padding: 5px 12px; font-size: 14px; cursor: pointer; color: var(--ink2); transition: background .15s, border-color .15s; }
.sh-head .all:hover { background: var(--bg3); border-color: var(--mute); }
.sh-head .all span { display: inline-block; transition: transform .2s var(--ease); }
.sh-head .all:hover span { transform: translateX(3px); }
.row-objs { position: relative; display: flex; gap: 34px; align-items: flex-start; padding: 0 8px; }
.row-objs::before { /* the ledge */ content: ""; position: absolute; left: -8px; right: -8px; top: 214px; height: 9px; border-radius: 2px;
background: linear-gradient(180deg, #5b3f3f 0, #4a3134 3px, #3a262a 9px); box-shadow: 0 10px 18px -6px rgba(0,0,0,.6); }
.it { all: unset; box-sizing: border-box; cursor: pointer; display: flex; flex-direction: column; width: var(--w, 170px); flex: 0 0 auto; position: relative; }
.it .stage { height: 214px; display: flex; align-items: flex-end; justify-content: center; position: relative; }
.it .cap { padding-top: 22px; text-align: left; }
.it .cap .d { font: 11px var(--mono); color: var(--dim); }
.it .cap .d em { font-style: normal; color: var(--acc); }
.it .cap b { display: block; font-weight: 500; font-size: 16px; line-height: 1.2; margin: 3px 0 4px; transition: color .15s; }
.it .cap .ln { font-size: 13.5px; color: var(--mute); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.it:hover .cap b, .it:focus-visible .cap b { color: var(--acc); }
.it:focus-visible { outline: none; }
.it:focus-visible .stage { outline: 2px solid var(--acc); outline-offset: 6px; border-radius: 4px; }
.obj { transition: transform .25s var(--ease); transform-origin: bottom center; }
.it:hover .obj, .it:focus-visible .obj { transform: translateY(-7px); }
.obj svg { display: block; width: 100%; height: 100%; }
/* comic: fanned pages */
.o-comic { width: 190px; height: 200px; position: relative; }
.o-comic .pg { position: absolute; bottom: 0; left: 50%; width: 112px; height: 150px; margin-left: -56px; box-shadow: 0 6px 14px rgba(0,0,0,.4); transform-origin: 50% 110%; transition: transform .28s var(--ease); background: #fff; }
.o-comic .pg:nth-child(1) { transform: rotate(-11deg) translateX(-16px); }
.o-comic .pg:nth-child(2) { transform: rotate(-1deg); }
.o-comic .pg:nth-child(3) { transform: rotate(10deg) translateX(16px); }
.it:hover .o-comic .pg:nth-child(1) { transform: rotate(-19deg) translateX(-26px); }
.it:hover .o-comic .pg:nth-child(2) { transform: rotate(-2deg) translateY(-6px); }
.it:hover .o-comic .pg:nth-child(3) { transform: rotate(17deg) translateX(26px); }
.it:hover .o-comic { transform: none; }
.o-comic .clip { position: absolute; top: 40px; left: 50%; margin-left: -12px; width: 24px; height: 10px; background: var(--acc); border-radius: 2px; z-index: 3; transform: rotate(-3deg); opacity: .9; }
/* game box */
.o-box { width: 140px; height: 186px; position: relative; display: flex; }
.o-box .spine { width: 16px; background: color-mix(in srgb, var(--c) 70%, #000); color: rgba(241,230,214,.8); writing-mode: vertical-rl; font: 600 9px var(--mono); letter-spacing: .1em; text-transform: uppercase; display: flex; align-items: center; justify-content: center; transform: skewY(-12deg); transform-origin: right top; margin-top: 3px; }
.o-box .front { flex: 1; position: relative; overflow: hidden; box-shadow: 0 8px 18px rgba(0,0,0,.45); border-radius: 1px 3px 3px 1px; }
.o-box .front .t { position: absolute; left: 10px; right: 10px; top: 10px; font: 600 15px var(--serif); color: #fff; letter-spacing: .02em; text-shadow: 0 1px 2px rgba(0,0,0,.4); line-height: 1.05; }
.o-box .front .band { position: absolute; left: 0; right: 0; bottom: 0; height: 16px; background: rgba(0,0,0,.45); font: 8px var(--mono); color: #ddd; display: flex; align-items: center; padding: 0 8px; letter-spacing: .1em; }
/* cartridge */
.o-cart { width: 150px; height: 172px; position: relative; background: #8c8a86; border-radius: 6px 6px 3px 3px; box-shadow: inset 0 -6px 0 #6e6c68, 0 8px 18px rgba(0,0,0,.45); padding: 26px 13px 18px; }
.o-cart::before { content: ""; position: absolute; top: 8px; left: 14px; right: 14px; height: 10px; background: repeating-linear-gradient(90deg, #6e6c68 0 3px, transparent 3px 7px); border-radius: 2px; }
.o-cart .lbl { height: 100%; border-radius: 3px; overflow: hidden; position: relative; }
.o-cart .lbl .t { position: absolute; left: 8px; bottom: 7px; font: italic 500 16px var(--serif); color: #f1e6d6; }
.o-cart .stamp { position: absolute; right: -14px; top: 36px; transform: rotate(12deg); font: 600 19px var(--hand); color: var(--bg); background: var(--acc); padding: 0 8px; border-radius: 3px; box-shadow: 0 2px 0 rgba(0,0,0,.3); }
/* record sleeve */
.o-sleeve { width: 168px; height: 168px; position: relative; }
.o-sleeve .rec { position: absolute; top: 6px; left: 6px; width: 156px; height: 156px; border-radius: 50%; background: radial-gradient(circle, var(--c, #c9706b) 0 17%, #141012 17.5% 19%, #1b1517 19% 100%); box-shadow: inset 0 0 0 1px #000; transition: transform .35s var(--ease); }
.o-sleeve .rec::after { content: ""; position: absolute; inset: 10%; border-radius: 50%; background: repeating-radial-gradient(circle, transparent 0 3px, rgba(255,255,255,.04) 3px 4px); }
.o-sleeve .slv { position: absolute; inset: 0; overflow: hidden; box-shadow: 0 8px 18px rgba(0,0,0,.45); border-radius: 2px; }
.o-sleeve .slv .t { position: absolute; left: 10px; bottom: 8px; font: italic 400 13px var(--serif); color: rgba(255,255,255,.9); }
.it:hover .o-sleeve .rec { transform: translateX(30px); }
.o-sleeve.playing .rec { transform: translateX(58px); animation: spin 2.4s linear infinite; }
.it:hover .o-sleeve.playing .rec { transform: translateX(58px); }
@keyframes spin { from { rotate: 0deg; } to { rotate: 360deg; } }
.mini { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.mini .pp { width: 28px; height: 28px; border-radius: 50%; border: 0; background: var(--acc); color: var(--bg); cursor: pointer; font-size: 10px; display: grid; place-items: center; flex: 0 0 auto; transition: transform .15s; }
.mini .pp:hover { transform: scale(1.08); }
.mini .bar2 { flex: 1; height: 3px; background: var(--line2); border-radius: 2px; position: relative; cursor: pointer; }
.mini .bar2 i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--acc); border-radius: 2px; width: 0; }
.mini .tm { font: 11px var(--mono); color: var(--dim); min-width: 64px; text-align: right; }
/* cassette (band handcam) */
.o-tape { width: 190px; height: 122px; background: #e9dcc6; border-radius: 7px; padding: 10px 12px; box-shadow: 0 8px 18px rgba(0,0,0,.45); color: var(--bg); position: relative; }
.o-tape .l { background: #f6efe2; border-bottom: 3px solid var(--rose); padding: 3px 8px; font: 600 18px/1.1 var(--hand); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.o-tape .win { margin: 12px auto 0; width: 124px; height: 38px; background: var(--bg); border-radius: 19px; display: flex; justify-content: space-between; align-items: center; padding: 0 10px; }
.o-tape .reel { width: 24px; height: 24px; border-radius: 50%; border: 3px dotted #e9dcc6; }
.o-tape.playing .reel { animation: spin 2.4s linear infinite; }
.o-tape .vid { position: absolute; right: 10px; bottom: 8px; font: 9px var(--mono); letter-spacing: .08em; opacity: .6; }
/* poster */
.o-poster { width: 128px; height: 192px; position: relative; overflow: hidden; box-shadow: 0 8px 18px rgba(0,0,0,.45); border-radius: 2px; }
.o-poster .t { position: absolute; left: 0; right: 0; bottom: 12px; text-align: center; font: 600 15px var(--serif); letter-spacing: .14em; text-transform: uppercase; color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.5); }
.o-poster .s { position: absolute; left: 0; right: 0; bottom: 4px; text-align: center; font: 7px var(--mono); color: rgba(255,255,255,.75); letter-spacing: .12em; }
/* trakt "now watching" */
.live-card { width: 280px; height: 206px; margin-bottom: -4px; border: 1px solid var(--line2); border-radius: 12px; background: var(--bg2); padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; font-size: 13px; text-align: left; }
.live-card .top { display: flex; justify-content: space-between; font: 10.5px var(--mono); color: var(--dim); letter-spacing: .04em; }
.live-card .top b { color: var(--sage); font-weight: 500; }
.live-card .w { display: grid; grid-template-columns: 46px 1fr; gap: 10px; align-items: center; }
.live-card .w .c { width: 46px; height: 64px; border-radius: 3px; overflow: hidden; }
.live-card .w b { display: block; font-weight: 500; font-size: 14px; line-height: 1.15; }
.live-card .w small { font: 11px var(--mono); color: var(--mute); }
.live-card .prog { height: 3px; background: var(--line2); border-radius: 2px; margin-top: 6px; }
.live-card .prog i { display: block; height: 100%; background: var(--sage); border-radius: 2px; }
.live-card ul { list-style: none; margin: 4px 0 0; padding: 6px 0 0; border-top: 1px dashed var(--line2); }
.live-card li { display: flex; justify-content: space-between; gap: 8px; color: var(--ink2); padding: 1px 0; }
.live-card li span:last-child { font: 11px var(--mono); color: var(--acc); }
.live-card li small { color: var(--dim); font: 10px var(--mono); margin-left: 4px; }
.live-card a.out { font: 11px var(--mono); color: var(--mute); margin-top: auto; text-decoration: none; }
.live-card a.out:hover { color: var(--ink); }
/* books: spines on the shelf */
.books { display: flex; align-items: flex-end; gap: 3px; height: 214px; padding-left: 4px; }
.spine-b { all: unset; box-sizing: border-box; cursor: pointer; width: var(--w); height: var(--h); background: var(--c); border-radius: 2px 2px 0 0; position: relative; color: rgba(255,255,255,.92); writing-mode: vertical-rl; transform: rotate(180deg); display: flex; align-items: center; justify-content: space-between; padding: 10px 0; font: 500 11px var(--serif); box-shadow: inset -3px 0 0 rgba(0,0,0,.12), inset 3px 0 0 rgba(255,255,255,.08); transition: transform .22s var(--ease); }
.spine-b:hover, .spine-b:focus-visible { transform: rotate(180deg) translateY(10px); }
.spine-b .st { font: 9px var(--mono); opacity: .8; }
.spine-b.light { color: #2a1a1f; }
.lean { width: 128px; height: 184px; margin: 0 22px 0 26px; position: relative; transform: rotate(-8deg); transform-origin: bottom left; }
.lean .cv { position: absolute; inset: 0; background: var(--c); border-radius: 2px 4px 4px 2px; box-shadow: 0 8px 18px rgba(0,0,0,.45); padding: 16px 12px; color: #f1e6d6; overflow: hidden; }
.lean .cv::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 7px; background: rgba(0,0,0,.18); }
.lean .cv b { display: block; font: italic 400 21px/1 var(--serif); }
.lean .cv small { display: block; font: 10px var(--mono); margin-top: 6px; opacity: .8; }
.lean .cv svg { position: absolute; left: 10px; right: 10px; bottom: 14px; height: 70px; width: calc(100% - 20px); opacity: .7; }
.lean .bm { position: absolute; top: -14px; right: 22px; width: 14px; height: 52px; background: var(--acc); clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 82%, 0 100%); }
.lean .tag { position: absolute; left: 6px; top: -34px; transform: rotate(4deg); font: 600 19px/1 var(--hand); color: var(--acc); white-space: nowrap; }
.booksinfo { margin-left: 30px; width: 250px; align-self: flex-start; }
/* made by me */
.o-polaroid { width: 168px; background: #f4ece0; padding: 9px 9px 30px; box-shadow: 0 8px 18px rgba(0,0,0,.45); transform: rotate(-3deg); position: relative; }
.o-polaroid .im { height: 132px; overflow: hidden; }
.o-polaroid .cp { position: absolute; left: 0; right: 0; bottom: 5px; text-align: center; font: 600 18px var(--hand); color: var(--bg); }
.it:hover .o-polaroid { transform: rotate(-1deg) translateY(-7px); }
.o-frame { width: 136px; height: 172px; padding: 11px; background: #5a3d32; box-shadow: inset 0 0 0 2px #6e4b3e, 0 8px 18px rgba(0,0,0,.45); }
.o-frame .mat { width: 100%; height: 100%; background: #f4ece0; padding: 9px; }
.o-frame .mat > div { width: 100%; height: 100%; overflow: hidden; }
.o-taped { width: 170px; height: 146px; background: #f6efe2; padding: 6px; box-shadow: 0 6px 14px rgba(0,0,0,.4); transform: rotate(2.5deg); position: relative; }
.o-taped::before, .o-taped::after { content: ""; position: absolute; top: -8px; width: 44px; height: 16px; background: rgba(232,176,74,.55); }
.o-taped::before { left: 12px; transform: rotate(-8deg); } .o-taped::after { right: 12px; transform: rotate(7deg); }
.o-taped > div { width: 100%; height: 100%; overflow: hidden; }
.it:hover .o-taped { transform: rotate(0) translateY(-7px); }
/* writing objects */
.o-paper { width: 150px; height: 194px; background: #f4ece0; color: var(--bg); padding: 18px 16px; box-shadow: 0 8px 18px rgba(0,0,0,.4), 4px 4px 0 -1px #e2d4bf, 4px 4px 0 0 rgba(0,0,0,.15); transform: rotate(-1.5deg); position: relative; text-align: left; }
.o-paper .k { font: 9px var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--rose); }
.o-paper b { display: block; font: 500 17px/1.12 var(--serif); margin: 6px 0 10px; }
.o-paper .lines { height: 70px; background: repeating-linear-gradient(transparent 0 9px, #cdbfa9 9px 10px); opacity: .8; }
.o-paper .pc { position: absolute; top: -10px; right: 22px; width: 12px; height: 38px; border: 2px solid #9aa0a6; border-radius: 6px; }
.o-note { width: 150px; height: 186px; background: #3e5a47; border-radius: 4px 8px 8px 4px; padding: 20px 16px; color: #f1e6d6; box-shadow: 0 8px 18px rgba(0,0,0,.45); position: relative; text-align: left; }
.o-note::after { content: ""; position: absolute; top: 0; bottom: 0; right: 26px; width: 7px; background: #2a1a1f; opacity: .55; }
.o-note .k { font: 9px var(--mono); letter-spacing: .1em; text-transform: uppercase; opacity: .7; }
.o-note b { display: block; font: italic 400 19px/1.1 var(--serif); margin-top: 8px; padding-right: 20px; }
.o-card { width: 176px; height: 118px; background: #fbf6ec; color: var(--bg); padding: 12px 14px; box-shadow: 0 6px 14px rgba(0,0,0,.4); transform: rotate(2deg); text-align: left; border-top: 18px solid #fbf6ec; background-image: linear-gradient(var(--rose), var(--rose)), repeating-linear-gradient(transparent 0 15px, #c9d6dd 15px 16px); background-size: 100% 1px, 100% 100%; background-repeat: no-repeat, repeat; background-position: 0 0, 0 6px; }
.o-card .k { font: 9px var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--rose); }
.o-card b { display: block; font: 600 22px/1.05 var(--hand); margin-top: 4px; }
.it.more { margin-left: auto; }
.stack { width: 110px; height: 150px; position: relative; }
.stack span { position: absolute; inset: 0; border-radius: 3px; transform: translate(calc(var(--k) * 9px), calc(var(--k) * -9px)) rotate(calc(var(--k) * 3deg - 3deg)); box-shadow: 0 6px 14px rgba(0,0,0,.4); opacity: .55; transition: transform .25s var(--ease), opacity .2s; }
.it.more:hover .stack span { opacity: .85; transform: translate(calc(var(--k) * 14px), calc(var(--k) * -12px)) rotate(calc(var(--k) * 5deg - 5deg)); }
.stack b { position: absolute; left: 0; right: 0; top: 50%; text-align: center; font: 600 34px var(--hand); color: var(--ink); transform: translate(9px, -24px); z-index: 2; text-shadow: 0 1px 6px rgba(0,0,0,.5); }
.shelf .more-hint { align-self: center; margin-left: auto; padding-left: 10px; }
.ghost { width: 120px; height: 160px; border: 1.5px dashed var(--line2); border-radius: 6px; display: grid; place-items: center; text-align: center; color: var(--dim); font: 500 20px/1.1 var(--hand); padding: 10px; transition: border-color .15s, color .15s; }
.it:hover .ghost { border-color: var(--acc); color: var(--acc); }
/* footer */
.zine { margin: 10px 0 0; border-top: 1px solid var(--line); padding: 30px 0; display: grid; grid-template-columns: 220px 1fr auto; gap: 32px; align-items: center; }
.zine .hd { font: 600 20px var(--hand); color: var(--dim); }
.zine p { margin: 0; color: var(--ink2); font-weight: 300; }
.zine p b { font-weight: 500; font-style: italic; }
.zine a { font: 13px var(--mono); color: var(--acc); }
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(--dim); }
footer a { color: var(--ink2); text-decoration-color: var(--line2); text-underline-offset: 3px; }
/* overlay */
.scrim { position: fixed; inset: 0; z-index: 70; background: rgba(18,10,13,.72); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); opacity: 0; pointer-events: none; transition: opacity .22s; }
.scrim.on { opacity: 1; pointer-events: auto; }
.modal { position: fixed; z-index: 80; left: 50%; top: 50%; width: min(1040px, calc(100vw - 48px)); max-height: calc(100vh - 64px); transform: translate(-50%, -48%) scale(.98); opacity: 0; visibility: hidden; transition: transform .24s var(--ease), opacity .2s, visibility .24s; background: var(--paper); color: var(--bg); border-radius: 14px; box-shadow: 0 30px 80px rgba(0,0,0,.5); display: flex; flex-direction: column; overflow: hidden; }
.modal.on { transform: translate(-50%, -50%); opacity: 1; visibility: visible; }
.modal .mtop { display: flex; align-items: center; gap: 10px; padding: 11px 18px; border-bottom: 1px solid #ddcdb6; font: 12px var(--mono); color: #7d6a5c; }
.modal .mtop .path { margin-right: auto; }
.modal .mtop button { background: none; border: 1px solid #d3c1a8; border-radius: 6px; padding: 3px 9px; cursor: pointer; font: 12px var(--mono); color: #5a4637; }
.modal .mtop button:hover { background: #e9dcc8; }
.mbody { overflow-y: auto; padding: 28px 34px 36px; }
.mbody .ph { text-decoration-color: rgba(42,26,31,.35); }
.mbody h2 { font-weight: 400; font-size: 38px; letter-spacing: -.02em; line-height: 1.05; margin: 0; }
.mbody .sub { font: 12px var(--mono); color: #7d6a5c; margin-top: 8px; }
.mbody .said { font: 500 27px/1.15 var(--hand); color: #8f3f3a; margin: 18px 0 6px; }
.mbody p { font-size: 17px; line-height: 1.6; margin: 12px 0 0; max-width: 36em; color: #3d2a2f; }
.mbody .src { display: inline-block; margin-top: 18px; font: 12px var(--mono); color: #5a4637; }
.two { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: 36px; align-items: start; }
.reader { background: #2a1a1f; border-radius: 10px; padding: 18px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.reader .spread { display: flex; gap: 10px; justify-content: center; }
.reader .pgbig { width: 220px; height: 293px; box-shadow: 0 10px 24px rgba(0,0,0,.5); background: #fff; }
.reader .ctl { display: flex; align-items: center; gap: 14px; color: #e3d4c1; font: 12px var(--mono); }
.reader .ctl button { background: none; border: 1px solid #5c4148; color: #f1e6d6; border-radius: 6px; padding: 4px 10px; cursor: pointer; font: 12px var(--mono); }
.reader .ctl button:hover { background: #3a252c; }
.reader .ctl button:disabled { opacity: .35; cursor: default; }
.reader .note { font: 10px var(--mono); color: #8a7768; }
.bigsleeve { width: 300px; height: 300px; position: relative; margin: 0 auto; }
.bigsleeve .slv { position: absolute; inset: 0; box-shadow: 0 14px 30px rgba(0,0,0,.35); overflow: hidden; border-radius: 3px; }
.bigsleeve .rec { position: absolute; inset: 10px; border-radius: 50%; background: radial-gradient(circle, var(--c) 0 17%, #141012 17.5% 19%, #1b1517 19% 100%); transition: transform .35s var(--ease); }
.bigsleeve.playing .rec { transform: translateX(110px); animation: spin 2.4s linear infinite; }
.bplayer { margin-top: 22px; display: flex; align-items: center; gap: 12px; }
.bplayer .pp { width: 44px; height: 44px; border-radius: 50%; border: 0; background: #2a1a1f; color: var(--paper); cursor: pointer; font-size: 13px; }
.bplayer .bar2 { flex: 1; height: 4px; background: #d9c8b0; border-radius: 2px; position: relative; cursor: pointer; }
.bplayer .bar2 i { position: absolute; left: 0; top: 0; bottom: 0; background: #c9706b; border-radius: 2px; width: 0; }
.bplayer .tm { font: 12px var(--mono); color: #7d6a5c; min-width: 84px; text-align: right; }
.video { aspect-ratio: 16/9; background: repeating-linear-gradient(135deg, #3a252c 0 10px, #33212a 10px 20px); border-radius: 10px; position: relative; display: grid; place-items: center; color: #e3d4c1; font: 11px var(--mono); }
.video button { width: 64px; height: 64px; border-radius: 50%; border: 0; background: var(--acc); color: #2a1a1f; font-size: 18px; cursor: pointer; }
.video span { position: absolute; left: 12px; bottom: 10px; }
.setlist { list-style: none; padding: 0; margin: 14px 0 0; border-top: 1px solid #ddcdb6; }
.setlist li { display: flex; justify-content: space-between; padding: 7px 4px; border-bottom: 1px solid #e6d8c4; cursor: pointer; font-size: 15px; }
.setlist li:hover { background: #ebdfcc; }
.setlist li.on { color: #8f3f3a; font-weight: 500; }
.setlist li span:last-child { font: 12px var(--mono); color: #7d6a5c; }
.pull { font: italic 300 30px/1.2 var(--serif); color: #2a1a1f; margin: 20px 0 6px; padding-left: 18px; border-left: 3px solid #e8b04a; max-width: 22em; }
.facts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.facts > span { font: 11.5px var(--mono); border: 1px solid #d3c1a8; border-radius: 99px; padding: 3px 10px; color: #5a4637; }
.bigart { border-radius: 6px; overflow: hidden; box-shadow: 0 14px 30px rgba(0,0,0,.25); }
.bigart svg { display: block; width: 100%; height: auto; }
.essay { max-width: 640px; margin: 0 auto; padding: 10px 0 20px; }
.essay .k { font: 11px var(--mono); letter-spacing: .1em; text-transform: uppercase; color: #c9706b; }
.essay h2 { font-size: 46px; font-weight: 300; font-style: italic; margin-top: 10px; }
.essay p { font-size: 18.5px; line-height: 1.7; }
.essay p:first-of-type::first-letter { font-size: 3.2em; float: left; line-height: .85; padding: 6px 8px 0 0; font-weight: 500; color: #8f3f3a; }
.essay .end { margin-top: 26px; font: 600 24px var(--hand); color: #8f3f3a; }
.archive h2 { font-style: italic; font-weight: 400; }
.archive .yr { font: 600 24px var(--hand); color: #8f3f3a; margin: 26px 0 10px; display: flex; align-items: center; gap: 12px; }
.archive .yr::after { content: ""; flex: 1; border-top: 1px dashed #d3c1a8; }
.agrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 18px 16px; }
.ai { all: unset; box-sizing: border-box; cursor: pointer; display: block; }
.ai .th { aspect-ratio: 1; border-radius: 4px; overflow: hidden; box-shadow: 0 6px 14px rgba(0,0,0,.18); transition: transform .2s var(--ease); background: #ddd; position: relative; }
.ai:hover .th, .ai:focus-visible .th { transform: translateY(-4px) rotate(-1deg); }
.ai .th svg { display: block; width: 100%; height: 100%; }
.ai .th .lbl { position: absolute; left: 8px; right: 8px; bottom: 8px; font: italic 500 14px/1.1 var(--serif); color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.5); }
.ai b { display: block; font-weight: 500; font-size: 14.5px; line-height: 1.25; margin-top: 8px; }
.ai small { font: 11px var(--mono); color: #7d6a5c; }
.archive .scale { margin-top: 26px; font: 12px var(--mono); color: #7d6a5c; border-top: 1px solid #ddcdb6; padding-top: 12px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
@media (max-width: 980px) {
.intro { grid-template-columns: 1fr; gap: 24px; }
.shelf { grid-template-columns: 1fr; gap: 14px; }
.row-objs { overflow-x: auto; padding-bottom: 8px; scrollbar-width: thin; }
.row-objs::before { width: 1400px; right: auto; }
.zine { grid-template-columns: 1fr; gap: 10px; }
.two { grid-template-columns: 1fr; }
.bar .meta { display: none; }
}
@media (max-width: 620px) {
:root { --edge: 24px; }
.edge { font-size: 9px; }
.wrap, .bar .in { padding: 0 18px; }
.snav { display: none; }
.intro h1 { font-size: 36px; }
.lately .row { grid-template-columns: 1fr; }
.mbody { padding: 20px 18px 28px; }
.reader .pgbig { width: 140px; height: 187px; }
.reader .spread .pgbig:nth-child(2) { display: none; }
.bigsleeve { width: 220px; height: 220px; }
.modal { width: calc(100vw - 20px); max-height: calc(100vh - 20px); }
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-a · Shelves of things I love</b>One shelf per interest, each holding its 1–4 latest things as fitting objects (fanned comic pages, a cartridge, a sleeve that plays, posters, spines pulled from Hardcover). "New lately" up top; click anything to open it, "see all" opens the shelf's archive. P2's tactile desk, rebuilt as rows so a new interest is just one more shelf.</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="snav" id="snav" aria-label="shelves"></nav>
<div class="meta" id="meta"></div>
</div>
</header>
<main>
<div class="wrap">
<section class="intro">
<div>
<span class="lab">02 · off the clock</span>
<h1>Things I love, and a few things I think.</h1>
<p>Comics that made me cry, games I backed years ago, songs I can't stop playing, and the odd drawing. A shelf for each. Pick anything up.</p>
</div>
<div class="lately" aria-label="new lately">
<h3><span>New lately · across all shelves</span><em>fresh off the pile</em></h3>
<div class="row" id="lately"></div>
</div>
</section>
<div id="shelves"></div>
<section class="zine">
<div class="hd">every season</div>
<p>Once a season the shelves get bound into a little zine: <b>Autumn 2026</b> has Now Recharging, the pumpkin, Lanternfall and an essay about crying at comics.</p>
<a href="#" onclick="return false"><span class="ph" title="placeholder: the P5 zine as a seasonal roundup">read the autumn roundup →</span></a>
</section>
<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>
<div class="scrim" id="scrim"></div>
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="mpath">
<div class="mtop"><span class="path" id="mpath"></span><button id="mclose">esc · close</button></div>
<div class="mbody" id="mbody"></div>
</div>
<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-a: shelves ===================== */
const T = s => s; // title helper
const title = it => it.ph ? PH(it.title) : it.title;
const shelfOf = id => SHELVES.find(s => s.id === id);
const sorted = ITEMS.slice().sort((a, b) => new Date(b.date) - new Date(a.date));
function objHTML(it) {
switch (it.kind) {
case 'comic': case 'trope':
return `<div class="obj o-comic">${[0, 1, 2].map(i => `<div class="pg">${comicPageSVG(it.pal, i)}</div>`).join('')}</div>`;
case 'kickstarter':
return `<div class="obj o-cart"><div class="lbl">${coverSVG(it.art, 124, 128)}<span class="t">${it.title}</span></div><span class="stamp">backed!</span></div>`;
case 'game':
return `<div class="obj o-box" style="--c:${it.art.bg}"><div class="spine">${it.title}</div><div class="front">${coverSVG(it.art, 124, 186)}<span class="t">${it.title}</span><span class="band">${it.sub.split('·').pop().trim()}</span></div></div>`;
case 'song':
return `<div class="obj o-sleeve" data-sleeve="${it.id}" style="--c:${it.art.bg}"><div class="rec"></div><div class="slv">${coverSVG(it.art, 168, 168)}<span class="t">${it.by}</span></div></div>`;
case 'tape':
return `<div class="obj o-tape" data-sleeve="${it.id}"><div class="l">${PH('Lav Sol')} · Café Retro</div><div class="win"><span class="reel"></span><span class="reel"></span></div><span class="vid">HANDCAM · VIDEO</span></div>`;
case 'film':
return `<div class="obj o-poster">${coverSVG(it.art, 128, 192)}<span class="t">${it.title}</span><span class="s">${it.sub.toUpperCase()}</span></div>`;
case 'photo':
return `<div class="obj o-polaroid"><div class="im">${drawingSVG(it.art.draw)}</div><span class="cp">lopsided, mine 🎃</span></div>`;
case 'commission':
return `<div class="obj o-frame"><div class="mat"><div>${drawingSVG(it.art.draw)}</div></div></div>`;
case 'drawing':
return `<div class="obj o-taped"><div>${drawingSVG(it.art.draw)}</div></div>`;
case 'essay':
return `<div class="obj o-paper"><span class="pc"></span><span class="k">essay · ${it.mins} min</span><b>${it.title}</b><div class="lines"></div></div>`;
case 'notebook':
return `<div class="obj o-note"><span class="k">${it.tag} · ${it.mins} min</span><b>${it.title}</b></div>`;
case 'card':
return `<div class="obj o-card"><span class="k">${it.tag}</span><b>${it.title}</b></div>`;
}
return '';
}
const WIDTH = { comic: 190, trope: 190, kickstarter: 160, game: 150, song: 176, tape: 196, film: 136, photo: 176, commission: 146, drawing: 176, essay: 160, notebook: 160, card: 186 };
function miniPlayer(it) {
return `<div class="mini" data-mini="${it.id}" data-len="${it.len}"><button class="pp" aria-label="play ${it.title}">▶</button><div class="bar2" aria-label="seek"><i></i></div><span class="tm">0:00 / ${mmss(it.len)}</span></div>`;
}
function itemHTML(it) {
const fresh = (TODAY - new Date(it.date)) / 864e5 < 21;
return `<div class="it" role="button" tabindex="0" data-open="${it.id}" style="--w:${WIDTH[it.kind]}px">
<div class="stage">${objHTML(it)}</div>
<div class="cap"><span class="d">${fdate(it.date)}${fresh ? ' · <em>new</em>' : ''}</span><b>${title(it)}${it.by && it.kind === 'song' ? ' <span style="color:var(--mute);font-weight:300">· ' + (it.ph ? PH(it.by) : it.by) + '</span>' : ''}</b>
${it.kind === 'song' || it.kind === 'tape' ? miniPlayer(it) : `<span class="ln">${it.line}</span>`}</div>
</div>`;
}
function headHTML(s, shown) {
const live = s.live ? `<div class="live"><i></i>live · ${s.live} · ${s.live === 'Trakt' ? TRAKT.synced : HARDCOVER.synced}</div>` : '';
return `<div class="sh-head"><div class="n">${String(SHELVES.indexOf(s) + 1).padStart(2, '0')}</div><h2>${s.name}</h2><div class="hd">${s.hand}</div>${live}
<div class="cnt">${shown} of ${s.total}${s.live ? ' · ' + (s.live === 'Trakt' ? 'films & shows' : 'books') : ''}</div><button class="all" data-all="${s.id}">see all <span>→</span></button></div>`;
}
function traktCard() {
const w = TRAKT.watching;
return `<div class="it" style="--w:280px;cursor:default"><div class="stage"><div class="live-card">
<div class="top"><span>TRAKT · watching now</span><b>● ${TRAKT.synced}</b></div>
<div class="w"><div class="c">${coverSVG(w.art, 46, 64)}</div><div><b>${PH(w.show)}</b><small>${w.ep} · “${PH(w.title)}”</small><div class="prog"><i style="width:${w.progress * 100}%"></i></div></div></div>
<ul>${TRAKT.ratings.slice(1, 4).map(r => `<li><span>${PH(r[0])}<small>${r[1]}</small></span><span>${'♥'.repeat(1)} ${r[2]}/10</span></li>`).join('')}</ul>
<a class="out" href="#" onclick="return false">trakt.tv/users/${TRAKT.user} ↗</a></div></div>
<div class="cap"><span class="d">pulled from Trakt</span><span class="ln">${PH(TRAKT.stats)}. Ratings come in by themselves; the posters are the ones I wrote about.</span></div></div>`;
}
function moreTile(s, shown) {
const rest = s.total - shown; if (rest <= 0) return '';
const arc = (ARCHIVE[s.id] || []).slice(0, 3);
return `<div class="it more" role="button" tabindex="0" data-all="${s.id}" style="--w:150px"><div class="stage"><div class="obj stack">${arc.map((a, k) => `<span style="--k:${k};background:${ARC_COLORS[(k + s.id.length) % ARC_COLORS.length]}"></span>`).join('')}<b>+${rest}</b></div></div>
<div class="cap"><span class="d">older</span><b>see all ${s.total} →</b><span class="ln">${arc.map(a => PH(a[0])).slice(0, 2).join(', ')}…</span></div></div>`;
}
function booksShelf(s) {
const r = HARDCOVER.reading;
const spines = HARDCOVER.read.map((b, i) => `<button class="spine-b${b.c === '#d9c39a' || b.c === '#e8b04a' ? ' light' : ''}" style="--c:${b.c};--h:${b.h}px;--w:${b.w}px" data-book="${i}" title="${b.title}, ${b.by} · ${b.stars}★ on Hardcover"><span>${b.title.length > 24 ? b.title.slice(0, 22) + '…' : b.title}</span><span class="st">${'★'.repeat(Math.floor(b.stars))}${b.stars % 1 ? '½' : ''}</span></button>`).join('');
return `<section class="shelf" id="s-${s.id}">${headHTML(s, 6)}
<div class="row-objs">
<div class="it" role="button" tabindex="0" data-book="r" style="--w:auto">
<div class="stage" style="justify-content:flex-start">
<div class="lean" style="--c:${r.c}"><div class="cv"><b>${PH(r.title)}</b><small>${PH(r.by)}</small>${coverSVG({ bg: r.c, fg: '#f1e6d6', acc: '#d9c39a', motif: 'stairs' }, 108, 70)}</div><span class="bm"></span><span class="tag">reading · ${r.pct}%</span></div>
<div class="books">${spines}</div>
</div>
<div class="cap"><span class="d">from Hardcover · ${HARDCOVER.synced}</span><b>Reading ${PH(r.title)}, page ${r.page} of ${r.of}</b><span class="ln">Spines are the last five finished, with my stars. Hover to pull one out. ${PH(HARDCOVER.stats)}.</span></div>
</div>
${moreTile(s, 6)}
</div></section>`;
}
function renderShelves() {
const host = document.getElementById('shelves'); let h = '';
SHELVES.forEach(s => {
if (s.id === 'books') { h += booksShelf(s); return; }
const its = sorted.filter(i => i.shelf === s.id).slice(0, 4);
h += `<section class="shelf" id="s-${s.id}">${headHTML(s, its.length)}<div class="row-objs">${its.map(itemHTML).join('')}${s.id === 'film' ? traktCard() : ''}${moreTile(s, its.length)}</div></section>`;
});
host.innerHTML = h;
document.getElementById('snav').innerHTML = SHELVES.map(s => `<a href="#s-${s.id}">${s.name}<sup>${s.total}</sup></a>`).join('');
document.getElementById('meta').textContent = SHELVES.reduce((n, s) => n + s.total, 0) + ' things · since 2024';
}
function thumb(it) {
if (it.pal) return comicPageSVG(it.pal, 1);
if (it.art && it.art.draw) return drawingSVG(it.art.draw);
if (it.art) return coverSVG(it.art, 64, 64);
return '';
}
function renderLately() {
document.getElementById('lately').innerHTML = sorted.slice(0, 3).map(it =>
`<button class="lt" data-open="${it.id}"><div class="th">${thumb(it)}</div><div><small>${ago(it.date)}</small><b>${title(it)}</b><span>on ${shelfOf(it.shelf).name}</span></div></button>`).join('');
}
/* ---------- overlay ---------- */
const scrim = document.getElementById('scrim'), modal = document.getElementById('modal'), mbody = document.getElementById('mbody'), mpath = document.getElementById('mpath');
let lastFocus = null, reader = null;
function openModal(path, html) {
lastFocus = document.activeElement;
mpath.innerHTML = path; mbody.innerHTML = html; mbody.scrollTop = 0;
scrim.classList.add('on'); modal.classList.add('on');
document.getElementById('mclose').focus({ preventScroll: true });
bindPlayers(mbody);
}
function closeModal() {
scrim.classList.remove('on'); modal.classList.remove('on'); reader = null;
if (lastFocus) lastFocus.focus({ preventScroll: true });
}
scrim.onclick = closeModal; document.getElementById('mclose').onclick = closeModal;
function openItem(id) {
const it = byId(id); const s = shelfOf(it.shelf);
const path = `off the clock / ${s.name.toLowerCase()} / ${it.title.toLowerCase()}`;
const paras = it.body.map(p => `<p>${it.ph ? PH(p) : p}</p>`).join('');
const said = `<div class="said">“${it.line}”</div>`;
let h = '';
if (it.pal) {
reader = { it, p: 0 };
h = `<div class="two"><div class="reader"><div class="spread" id="spread"></div>
<div class="ctl"><button id="rprev" aria-label="previous pages">← prev</button><span id="rpos"></span><button id="rnext" aria-label="next pages">next →</button></div>
<div class="note">drawn stand-ins for the shared pages · ← → keys turn</div></div>
<div><h2>${it.title}</h2><div class="sub">${it.sub} · shared ${fdate(it.date)}</div>${said}${paras}
${it.url ? `<a class="src" href="${it.url}">read it at nowrecharging.com ↗</a>` : `<span class="src">${PH('link to where you can read it ↗')}</span>`}</div></div>`;
} else if (it.kind === 'song') {
h = `<div class="two"><div><div class="bigsleeve" data-sleeve="${it.id}" style="--c:${it.art.bg}"><div class="rec"></div><div class="slv">${coverSVG(it.art, 300, 300)}</div></div>
<div class="bplayer" 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></div>
<div><div class="sub" style="margin-top:0">a song I want to share · ${fdate(it.date)}</div><h2 style="margin-top:8px">${title(it)}</h2><div class="sub">${PH(it.by)}</div>${said}${paras}<span class="src">${PH('also on Bandcamp ↗ · Spotify ↗')}</span></div></div>`;
} else if (it.kind === 'tape') {
h = `<div class="two"><div><div class="video"><button aria-label="play video" data-vid>▶</button><span>handcam video: Café Retro, from the bar</span></div>
<div class="bplayer" 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>
<ul class="setlist" id="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 class="sub" style="margin-top:0">the band · one topic among many</div><h2 style="margin-top:8px">${PH(it.title)}</h2><div class="sub">${PH(it.by)} · ${fdate(it.date)}</div>${said}${paras}</div></div>`;
} else if (it.kind === 'game' || it.kind === 'kickstarter') {
h = `<div class="two" style="grid-template-columns:300px 1fr"><div class="bigart">${coverSVG(it.art, 300, 400, `<text x="20" y="44" font-family="Fraunces" font-size="30" font-style="italic" fill="#fff">${it.title}</text>`)}</div>
<div><div class="sub" style="margin-top:0">${it.kind === 'kickstarter' ? 'a Kickstarter I backed, now playing' : 'a game I love'} · ${fdate(it.date)}</div><h2 style="margin-top:8px">${title(it)}</h2>
<div class="pull">${PH(it.quote)}</div>${paras}
<div class="facts"><span>${PH(it.sub)}</span><span>${PH(it.hours + ' h played')}</span>${it.kind === 'kickstarter' ? `<span>${PH('180 DKK tier · name in the credits')}</span>` : ''}</div></div></div>`;
} else if (it.kind === 'film') {
h = `<div class="two" style="grid-template-columns:280px 1fr"><div class="bigart">${coverSVG(it.art, 280, 420, `<text x="140" y="390" text-anchor="middle" font-family="Fraunces" font-size="24" letter-spacing="4" fill="#fff">${it.title.toUpperCase()}</text>`)}</div>
<div><div class="sub" style="margin-top:0">film & tv · ${fdate(it.date)}</div><h2 style="margin-top:8px">${title(it)}</h2><div class="sub">${PH(it.sub)}</div>${said}${paras}
<div class="facts"><span>my Trakt rating · ${PH(it.rating + '/10')}</span><span>${PH('watched twice')}</span></div><a class="src" href="#" onclick="return false">${PH('see it on Trakt ↗')}</a></div></div>`;
} else if (it.art && it.art.draw) {
h = `<div class="two"><div class="bigart">${drawingSVG(it.art.draw)}</div>
<div><div class="sub" style="margin-top:0">made by me${it.kind === 'commission' ? ' (well, for me)' : ''} · ${fdate(it.date)}</div><h2 style="margin-top:8px">${title(it)}</h2>${it.by ? `<div class="sub">${PH(it.by)}</div>` : ''}${said}${paras}
${it.label ? `<div class="facts"><span>${it.label}</span></div>` : `<div class="facts"><span>${PH('drawn stand-in for the real picture')}</span></div>`}</div></div>`;
} else {
h = `<article class="essay"><div class="k">${it.tag} · ${it.mins} min read · ${fdate(it.date)}</div><h2>${PH(it.title)}</h2>${paras}<div class="end">– Lucas</div></article>`;
}
openModal(path, h);
if (reader) { drawReader(); document.getElementById('rprev').onclick = () => turn(-1); document.getElementById('rnext').onclick = () => turn(1); }
const sl = document.getElementById('setlist');
if (sl) sl.querySelectorAll('li').forEach(li => li.onclick = () => { PLAYER.id = it.id; PLAYER.t = +li.dataset.at; if (!PLAYER.timer) playerToggle(it.id, it.len); else playerEmit(); });
const v = mbody.querySelector('[data-vid]'); if (v) v.onclick = () => mbody.querySelector('.bplayer .pp').click();
}
function drawReader() {
const { it, p } = reader; const two = window.innerWidth > 620;
document.getElementById('spread').innerHTML = [p, p + 1].filter(i => i < it.pages).map(i => `<div class="pgbig">${comicPageSVG(it.pal, i)}</div>`).join('');
document.getElementById('rpos').textContent = 'pages ' + (p + 1) + (p + 1 < it.pages ? '–' + (p + 2) : '') + ' of ' + it.pages;
document.getElementById('rprev').disabled = p === 0;
document.getElementById('rnext').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(); }
function openAll(sid) {
const s = shelfOf(sid);
let entries = sorted.filter(i => i.shelf === sid).map(i => ({ it: i, y: i.date.slice(0, 4) }));
if (sid === 'books') entries = [{ book: HARDCOVER.reading, y: '2026', r: 1 }].concat(HARDCOVER.read.map(b => ({ book: b, y: '2026' })));
if (sid === 'film') entries = entries.concat(TRAKT.ratings.filter(r => !ITEMS.some(i => i.title === r[0])).map(r => ({ trakt: r, y: '2026' })));
(ARCHIVE[sid] || []).forEach((a, k) => entries.push({ arc: a, y: a[1], k }));
const years = [...new Set(entries.map(e => e.y))];
const tile = e => {
if (e.it) return `<button class="ai" data-open="${e.it.id}"><div class="th">${thumb(e.it)}</div><b>${title(e.it)}</b><small>${fdate(e.it.date)}</small></button>`;
if (e.book) return `<div class="ai" style="cursor:default"><div class="th" style="background:${e.book.c}"><span class="lbl">${e.book.title}</span></div><b>${PH(e.book.title)}</b><small>${e.r ? 'reading · ' + e.book.pct + '%' : e.book.stars + '★ · Hardcover'}</small></div>`;
if (e.trakt) return `<div class="ai" style="cursor:default"><div class="th" style="background:#4a4f6b"><span class="lbl">${e.trakt[0]}</span></div><b>${PH(e.trakt[0])}</b><small>${e.trakt[2]}/10 · Trakt</small></div>`;
const c = ARC_COLORS[(e.k + sid.length) % ARC_COLORS.length];
const art = sid === 'comics' ? comicPageSVG(['nr', 'jw', 'c13', 'vl'][e.k % 4], e.k) : sid === 'books' ? '' : coverSVG({ bg: c, fg: '#f1e6d6', acc: '#e8b04a', motif: ['moon', 'planet', 'mountain', 'trees', 'stairs', 'city'][e.k % 6] }, 100, 100);
return `<div class="ai" style="cursor:default" title="placeholder archive item"><div class="th" style="background:${c}">${art}${sid === 'books' ? `<span class="lbl">${e.arc[0]}</span>` : ''}</div><b>${PH(e.arc[0])}</b><small>${e.arc[1]}</small></div>`;
};
const h = `<div class="archive"><div class="sub" style="margin-top:0">${s.hand}</div><h2 style="margin-top:6px">${s.name} <span style="font:400 14px var(--mono);color:#7d6a5c;font-style:normal">· everything, newest first</span></h2>
${years.map(y => `<div class="yr">${y}</div><div class="agrid">${entries.filter(e => e.y === y).map(tile).join('')}</div>`).join('')}
<div class="scale">This is how a shelf grows: the shelf on the page always shows the newest four; this view groups the rest by year. At 100+ things it gains a year jump list and a search box, nothing else changes.</div></div>`;
openModal(`off the clock / ${s.name.toLowerCase()} / all`, h);
}
/* ---------- players ---------- */
function bindPlayers(root) {
root.querySelectorAll('[data-mini]').forEach(m => {
const id = m.dataset.mini, len = +m.dataset.len;
m.querySelector('.pp').onclick = e => { e.stopPropagation(); playerToggle(id, len); };
const bar = m.querySelector('.bar2');
bar.onclick = e => { e.stopPropagation(); const r = bar.getBoundingClientRect(); playerSeek(id, len, (e.clientX - r.left) / r.width); };
m.onclick = e => e.stopPropagation();
m.onkeydown = e => e.stopPropagation();
});
}
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('.pp').setAttribute('aria-label', playing ? 'pause' : 'play');
m.querySelector('.bar2 i').style.width = (t / len * 100) + '%';
m.querySelector('.tm').textContent = mmss(t) + ' / ' + mmss(len);
});
document.querySelectorAll('[data-sleeve]').forEach(s => s.classList.toggle('playing', s.dataset.sleeve === st.id && !!st.timer));
const it = byId(st.id); const sl = document.getElementById('setlist');
if (sl && it && it.set) { 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 ---------- */
renderLately(); renderShelves(); bindPlayers(document);
// deep links: #open=<id> or #all=<shelf> (what a real router would do)
(function () { const m = location.hash.match(/^#(open|all)=(.+)$/); if (!m) return; if (m[1] === 'open' && byId(m[2])) openItem(m[2]); if (m[1] === 'all') openAll(m[2]); })();
document.addEventListener('click', e => {
const all = e.target.closest('[data-all]'); if (all) { openAll(all.dataset.all); return; }
const o = e.target.closest('[data-open]'); if (o) { openItem(o.dataset.open); return; }
const b = e.target.closest('[data-book]'); if (b && !modal.contains(b)) openAll('books');
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modal.classList.contains('on')) { closeModal(); return; }
if (modal.classList.contains('on') && reader) { if (e.key === 'ArrowRight') turn(1); if (e.key === 'ArrowLeft') turn(-1); return; }
if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('.it[data-open], .it[data-book], .it[data-all]')) { e.preventDefault(); e.target.click(); }
});
</script>
</body>
</html>