Files
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

1499 lines
131 KiB
HTML
Raw Permalink 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>Work · 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=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&family=Fraunces:ital,opsz,wght@1,9..144,300&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f5f4f0; --bg2: #efede8; --surface: #ffffff; --raise: #fbfaf7; --well: #f1efea;
--ink: #1a1915; --ink2: #47443e; --mute: #7a766e; --dim: #a7a399;
--line: rgba(40,34,24,.08); --line2: rgba(40,34,24,.13); --line3: rgba(40,34,24,.22);
--acc: #e0592b; --acc-ink: #c4471c; --acc-s: rgba(224,89,43,.10); --acc-tint: #fbe6dc;
--ok: #4c8a5c; --ok-s: rgba(76,138,92,.11);
--a-bg: #2a1a1f; --a-ink: #f1e6d6;
--sans: "Geist", system-ui, sans-serif; --mono: "Geist Mono", ui-monospace, monospace;
--sh1: 0 0 0 1px rgba(40,34,24,.07), 0 1px 2px rgba(40,34,24,.05);
--sh2: 0 0 0 1px rgba(40,34,24,.065), 0 1px 2px rgba(40,34,24,.04), 0 6px 14px -6px rgba(40,34,24,.08), 0 24px 44px -26px rgba(40,34,24,.16);
--sh3: 0 0 0 1px rgba(40,34,24,.07), 0 2px 4px rgba(40,34,24,.04), 0 14px 28px -10px rgba(40,34,24,.14), 0 44px 90px -34px rgba(40,34,24,.30);
--hi: inset 0 1px 0 rgba(255,255,255,.95);
--spring: cubic-bezier(.32,.72,0,1); --ease: cubic-bezier(.2,.8,.2,1);
--edge: 42px;
}
* { box-sizing: border-box; }
html { scroll-padding-top: 76px; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.55; -webkit-font-smoothing: antialiased;
background-image: radial-gradient(1100px 520px at 18% -160px, rgba(224,89,43,.075), transparent 62%), radial-gradient(900px 600px at 100% 0, rgba(255,255,255,.9), transparent 70%); background-repeat: no-repeat; }
a { color: inherit; }
button { font: inherit; color: inherit; }
::selection { background: var(--acc); color: #fff; }
:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; }
.mono, .tnum { font-variant-numeric: tabular-nums; }
.mono { font-family: var(--mono); }
.ph { text-decoration: underline dotted rgba(40,34,24,.35); text-underline-offset: 3px; text-decoration-thickness: 1px; cursor: help; }
kbd { font: 500 10.5px var(--mono); color: var(--mute); background: var(--surface); border-radius: 5px; padding: 1px 5px; box-shadow: 0 0 0 1px var(--line2), 0 1px 0 var(--line2); }
/* mockup note */
.mk { position: fixed; top: 70px; right: calc(var(--edge) + 14px); z-index: 65; width: 290px; background: #1a1915; color: #ece7de; font: 11.5px/1.45 var(--mono); padding: 10px 30px 10px 12px; border-radius: 12px; box-shadow: var(--sh3); }
.mk b { display: block; font-family: var(--sans); font-size: 13px; margin-bottom: 3px; color: #fff; }
.mk button { position: absolute; top: 5px; right: 7px; background: none; border: 0; font-size: 16px; cursor: pointer; color: #ece7de; }
/* off the clock edge */
.edge { position: fixed; top: 0; right: 0; bottom: 0; width: var(--edge); z-index: 40; background: linear-gradient(180deg, #33212a, #24161b); color: var(--a-ink); box-shadow: inset 1px 0 0 rgba(255,255,255,.08), -10px 0 30px -18px rgba(42,26,31,.5); display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; font: italic 300 16px "Fraunces", serif; text-decoration: none; transition: width .3s var(--spring); }
.edge:hover { width: 56px; }
/* top bar: frosted */
.bar { position: sticky; top: 0; z-index: 30; margin-right: var(--edge); background: rgba(245,244,240,.7); -webkit-backdrop-filter: saturate(1.6) blur(18px); backdrop-filter: saturate(1.6) blur(18px); box-shadow: 0 1px 0 transparent; transition: box-shadow .25s, background .25s; }
.bar.scrolled { box-shadow: 0 1px 0 var(--line), 0 10px 30px -22px rgba(40,34,24,.25); background: rgba(248,247,244,.74); }
.bar .in { max-width: 1320px; margin: 0 auto; padding: 0 40px; height: 60px; display: flex; align-items: center; gap: 22px; }
.crumb { display: flex; align-items: center; gap: 10px; font-size: 14px; white-space: nowrap; }
.crumb a.back { font: 12px var(--mono); color: var(--mute); text-decoration: none; background: var(--surface); box-shadow: var(--sh1); border-radius: 99px; padding: 3px 10px; transition: color .15s, box-shadow .2s; }
.crumb a.back:hover { color: var(--ink); box-shadow: 0 0 0 1px var(--line3), 0 2px 6px -2px rgba(40,34,24,.15); }
.crumb b { font-weight: 600; letter-spacing: -.01em; }
.crumb span { color: var(--mute); }
.nav { display: flex; gap: 2px; margin-left: 6px; }
.nav a { text-decoration: none; color: var(--mute); font-size: 14px; padding: 6px 11px; border-radius: 8px; transition: color .15s, background .15s; }
.nav a:hover { color: var(--ink); background: rgba(40,34,24,.05); }
.pref { margin-left: auto; display: flex; align-items: center; gap: 10px; font: 12px var(--mono); color: var(--mute); }
.seg { position: relative; display: inline-flex; background: rgba(40,34,24,.055); border-radius: 10px; padding: 3px; box-shadow: inset 0 1px 2px rgba(40,34,24,.06); }
.seg .thumb { position: absolute; top: 3px; bottom: 3px; left: 0; width: 0; border-radius: 7px; background: var(--surface); box-shadow: 0 0 0 1px rgba(40,34,24,.06), 0 1px 2px rgba(40,34,24,.08), 0 3px 8px -3px rgba(40,34,24,.14); transition: transform .38s var(--spring), width .38s var(--spring); }
.seg button { position: relative; z-index: 1; border: 0; background: none; padding: 4px 11px; border-radius: 7px; font: 12px var(--mono); color: var(--mute); cursor: pointer; transition: color .2s; }
.seg button:hover { color: var(--ink); }
.seg button[aria-pressed="true"] { color: var(--ink); }
.kbtn { display: inline-flex; align-items: center; gap: 9px; background: var(--surface); border: 0; border-radius: 10px; padding: 6px 7px 6px 11px; font-size: 13px; color: var(--mute); cursor: pointer; min-width: 170px; box-shadow: var(--sh1); transition: box-shadow .2s, color .15s; }
.kbtn:hover { color: var(--ink); box-shadow: 0 0 0 1px var(--line3), 0 4px 12px -4px rgba(40,34,24,.18); }
.kbtn span { margin-right: auto; }
main { margin-right: var(--edge); }
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 40px; }
/* spotlight surface: pointer-following light + border glint */
.sp { position: relative; isolation: isolate; }
.sp::before { content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px; pointer-events: none; z-index: 8; opacity: 0; transition: opacity .35s;
background: radial-gradient(260px circle at var(--mx, 50%) var(--my, 50%), rgba(224,89,43,.55), rgba(224,89,43,.12) 40%, transparent 70%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); }
.sp::after { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: 7; opacity: 0; transition: opacity .35s;
background: radial-gradient(520px circle at var(--mx, 50%) var(--my, 50%), rgba(224,89,43,.045), transparent 55%); }
.sp:hover::before, .sp:hover::after { opacity: 1; }
/* intro */
.intro { padding: 40px 0 26px; display: grid; grid-template-columns: 1fr auto; gap: 48px; align-items: end; }
.eyebrow { display: inline-flex; align-items: center; gap: 8px; font: 12px var(--mono); color: var(--ink2); background: var(--surface); border-radius: 99px; padding: 4px 12px 4px 9px; box-shadow: var(--sh1); }
.eyebrow i { width: 7px; height: 7px; border-radius: 50%; background: var(--acc); box-shadow: 0 0 0 3px var(--acc-s); }
.intro h1 { font-size: 46px; line-height: 1.04; letter-spacing: -.042em; font-weight: 600; margin: 18px 0 0; max-width: 19em; text-wrap: balance; }
.intro h1 em { font-style: normal; color: var(--dim); }
.intro p { margin: 18px 0 0; color: var(--ink2); max-width: 40em; font-size: 16px; }
.legend { display: grid; grid-template-columns: auto auto; gap: 8px; padding: 12px; background: rgba(255,255,255,.55); border-radius: 14px; box-shadow: var(--sh1); margin-bottom: 4px; }
.now { display: grid; grid-template-columns: 1fr 1.15fr 1.35fr; background: var(--surface); border-radius: 18px; box-shadow: var(--sh2); margin-bottom: 44px; }
.now > div { padding: 18px 22px 18px; min-width: 0; position: relative; z-index: 9; }
.now > div + div { box-shadow: -1px 0 0 var(--line); }
.now h3 { margin: 0 0 10px; font: 500 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--mute); display: flex; justify-content: space-between; gap: 8px; }
.now h3 a { text-decoration: none; color: var(--mute); letter-spacing: 0; text-transform: none; } .now h3 a:hover { color: var(--acc-ink); }
.now ul { margin: 0; padding: 0; list-style: none; font-size: 14px; }
.now li { padding: 3px 0; color: var(--ink2); display: flex; gap: 10px; }
.now li::before { content: ""; flex: 0 0 6px; height: 6px; margin-top: 8px; border-radius: 50%; background: #d8d4cb; }
.now li:first-child::before { background: var(--acc); box-shadow: 0 0 0 3px var(--acc-s); }
.wl a { display: flex; justify-content: space-between; gap: 10px; text-decoration: none; padding: 3px 0; }
.wl a .t { color: var(--ink); font-size: 14px; transition: color .15s; }
.wl a:hover .t { color: var(--acc-ink); }
.wl a .d { font: 11px var(--mono); color: var(--dim); white-space: nowrap; padding-top: 2px; }
.wl a.lead .t { font-weight: 500; }
.cv10 { display: grid; grid-template-columns: 62px 1fr; gap: 4px 12px; font-size: 13.5px; color: var(--ink2); }
.cv10 .k { font: 11px var(--mono); color: var(--dim); padding-top: 2px; }
.cv10 .v b { color: var(--ink); font-weight: 500; }
.cv10 a { text-decoration-color: var(--line3); text-underline-offset: 3px; }
.cvlinks { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
.btn { display: inline-flex; align-items: center; gap: 6px; font: 12px var(--mono); text-decoration: none; color: var(--ink2); background: var(--surface); border: 0; border-radius: 8px; padding: 4px 10px; cursor: pointer; box-shadow: var(--sh1); transition: box-shadow .2s, color .15s, transform .2s var(--spring); }
.btn:hover { color: var(--ink); box-shadow: 0 0 0 1px var(--line3), 0 3px 8px -3px rgba(40,34,24,.18); transform: translateY(-1px); }
.btn.solid { background: linear-gradient(180deg, #2a2823, #1a1915); color: #fff; box-shadow: inset 0 1px 0 rgba(255,255,255,.14), 0 0 0 1px #1a1915, 0 2px 6px -2px rgba(26,25,21,.4); }
.btn.solid:hover { background: linear-gradient(180deg, #ea6a3d, var(--acc)); box-shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 0 0 1px var(--acc-ink), 0 4px 12px -3px rgba(224,89,43,.5); }
/* AI mark + chips */
.ai { display: inline-flex; align-items: center; gap: 6px; font: 500 11px var(--mono); color: var(--ink2); background: var(--surface); box-shadow: 0 0 0 1px var(--line2); border-radius: 99px; padding: 2px 9px 2px 7px; white-space: nowrap; line-height: 1.5; }
.ai i { width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid currentColor; flex: none; }
.ai.full { color: var(--acc-ink); background: var(--acc-tint); box-shadow: 0 0 0 1px rgba(224,89,43,.28); } .ai.full i { background: currentColor; }
.ai.some i { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
.ai.unk { color: var(--mute); box-shadow: none; outline: 1px dashed var(--line3); outline-offset: -1px; } .ai.unk i { border-style: dashed; }
.ai.bare { box-shadow: none; padding: 0; background: none; outline: 0; }
.chips { display: flex; gap: 5px; flex-wrap: wrap; }
.chip { font: 11.5px var(--mono); color: var(--ink2); background: var(--raise); box-shadow: 0 0 0 1px var(--line); border-radius: 6px; padding: 1px 7px; white-space: nowrap; }
/* section heads */
.sh { display: flex; align-items: baseline; gap: 12px; margin: 0 0 18px; }
.sh h2 { margin: 0; font-size: 26px; letter-spacing: -.035em; font-weight: 600; }
.sh .n { font: 11.5px var(--mono); color: var(--mute); background: rgba(40,34,24,.05); border-radius: 99px; padding: 1px 8px; }
.sh .r { margin-left: auto; font: 12px var(--mono); color: var(--mute); text-decoration: none; }
a.r:hover { color: var(--acc-ink); }
/* featured panels */
.feats { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.feat { background: var(--surface); border-radius: 22px; box-shadow: var(--sh2); display: grid; padding: 8px; transition: transform .45s var(--spring), box-shadow .45s var(--spring); }
.feat:hover { transform: translateY(-3px); box-shadow: var(--sh3); }
.feat.wide { grid-column: 1 / -1; grid-template-columns: 1.42fr 1fr; }
.feat.wide.rev { grid-template-columns: 1fr 1.42fr; }
.feat.wide.rev .stage { order: 2; }
.feat:not(.wide) { grid-template-rows: auto 1fr; }
.stage { position: relative; overflow: hidden; min-height: 300px; border-radius: 15px; z-index: 1;
background: radial-gradient(rgba(40,34,24,.075) 1px, transparent 1.2px) 0 0 / 18px 18px, radial-gradient(500px 300px at 85% 0%, rgba(224,89,43,.07), transparent 70%), linear-gradient(180deg, #f6f4f0, #eeebe5);
box-shadow: inset 0 0 0 1px var(--line), inset 0 2px 4px rgba(40,34,24,.03); }
.stage .cap { position: absolute; left: 16px; bottom: 12px; font: 11px var(--mono); color: var(--dim); z-index: 5; pointer-events: none; }
.stage .hint { position: absolute; right: 16px; bottom: 11px; font: 11px var(--mono); color: var(--mute); z-index: 5; pointer-events: none; transition: opacity .25s; background: rgba(255,255,255,.7); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); border-radius: 99px; padding: 2px 9px; box-shadow: var(--sh1); }
.stage:hover .hint { opacity: 0; }
.txt { padding: 26px 26px 20px; display: flex; flex-direction: column; gap: 14px; min-width: 0; position: relative; z-index: 9; }
.meta { display: flex; gap: 10px; align-items: center; font: 12px var(--mono); color: var(--mute); flex-wrap: wrap; font-variant-numeric: tabular-nums; }
.meta .num { color: var(--acc-ink); background: var(--acc-tint); border-radius: 6px; padding: 0 6px; }
.feat h3 { margin: 0; font-size: 34px; line-height: 1.02; letter-spacing: -.04em; font-weight: 600; }
.feat:not(.wide) h3 { font-size: 28px; }
.pitch { margin: 0; color: var(--ink2); font-size: 15.5px; max-width: 34em; }
.facts { display: grid; grid-template-columns: 92px 1fr; gap: 7px 14px; font-size: 13.5px; color: var(--ink2); margin: 2px 0 0; padding: 14px 0 0; border-top: 1px solid var(--line); }
.facts dt { font: 11px var(--mono); color: var(--dim); padding-top: 2px; }
.facts dd { margin: 0; }
.ft { margin-top: auto; display: flex; align-items: center; gap: 10px; padding-top: 8px; flex-wrap: wrap; }
.open { display: inline-flex; align-items: center; gap: 8px; background: linear-gradient(180deg, #2a2823, #1a1915); color: #fff; border: 0; border-radius: 10px; padding: 8px 14px; font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,.14), 0 0 0 1px #1a1915, 0 3px 8px -3px rgba(26,25,21,.45); transition: background .2s, box-shadow .2s, transform .3s var(--spring); }
.open span { transition: transform .3s var(--spring); display: inline-block; }
.open:hover { background: linear-gradient(180deg, #ea6a3d, var(--acc)); box-shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 0 0 1px var(--acc-ink), 0 6px 16px -4px rgba(224,89,43,.55); }
.open:hover span { transform: translateX(3px); }
.open:active { transform: scale(.97); }
.feat .filt { display: none; padding: 14px 18px; font: 13px var(--mono); color: var(--mute); align-items: center; gap: 12px; }
.feat.filtered { display: block; padding: 0; box-shadow: 0 0 0 1px var(--line2); background: transparent; }
.feat.filtered:hover { transform: none; }
.feat.filtered > .stage, .feat.filtered > .txt { display: none; }
.feat.filtered .filt { display: flex; }
.linkish { background: none; border: 0; padding: 0; color: var(--ink); text-decoration: underline; text-decoration-color: var(--acc); text-underline-offset: 3px; cursor: pointer; font: inherit; }
/* shared window chrome */
.win { background: var(--surface); border-radius: 12px; overflow: hidden; display: grid; grid-template-rows: 32px 1fr; box-shadow: var(--sh3); }
.wtb { display: flex; align-items: center; gap: 10px; padding: 0 12px; background: linear-gradient(180deg, #fdfcfa, #f7f5f1); box-shadow: inset 0 -1px 0 var(--line); }
.wtb .dots { display: flex; gap: 5px; } .wtb .dots i { width: 9px; height: 9px; border-radius: 50%; background: #e4e0d8; box-shadow: inset 0 0 0 .5px rgba(40,34,24,.1); }
.wtb .url { flex: 1; max-width: 300px; margin: 0 auto; background: rgba(40,34,24,.045); border-radius: 6px; font: 11px var(--mono); color: var(--mute); text-align: center; padding: 2px 0; }
.wtb .ttl { font: 11px var(--mono); color: var(--mute); margin: 0 auto; transform: translateX(-20px); }
/* -- stage 1: compute platform (tilt + floating layers) -- */
.s-compute { min-height: 468px; perspective: 1400px; }
.tiltw { position: absolute; inset: 38px 48px 50px 40px; transform-style: preserve-3d; transition: transform .5s var(--spring); transform: rotateX(calc(var(--ty, 0) * -3deg)) rotateY(calc(var(--tx, 0) * 4deg)); }
.s-compute .win { position: absolute; inset: 0; }
.ghostwin { position: absolute; inset: -18px 36px auto 36px; height: 60px; background: rgba(255,255,255,.6); border-radius: 12px; box-shadow: var(--sh1); transform: translateZ(-30px); }
.app { display: grid; grid-template-columns: 150px 1fr; min-height: 0; }
.app .side { background: var(--raise); box-shadow: inset -1px 0 0 var(--line); padding: 14px 10px; display: flex; flex-direction: column; gap: 2px; font-size: 12.5px; color: var(--mute); }
.app .side .logo { font-weight: 600; color: var(--ink); font-size: 13px; padding: 0 8px 12px; letter-spacing: -.01em; display: flex; align-items: center; gap: 7px; }
.app .side .logo i { width: 14px; height: 14px; border-radius: 4px; background: linear-gradient(135deg, #ef7a4c, var(--acc-ink)); box-shadow: inset 0 1px 0 rgba(255,255,255,.3); }
.app .side .it { padding: 5px 8px; border-radius: 6px; }
.app .side .it.on { background: var(--surface); color: var(--ink); box-shadow: var(--sh1); }
.app .side .who { margin-top: auto; font: 10.5px var(--mono); color: var(--dim); padding: 8px; border-top: 1px solid var(--line); }
.app .main { padding: 14px 16px; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.app .hd { display: flex; align-items: center; justify-content: space-between; }
.app .hd b { font-size: 15px; font-weight: 600; letter-spacing: -.015em; }
.app .hd b small { font: 400 11px var(--mono); color: var(--mute); margin-left: 8px; letter-spacing: 0; }
.app .hd .nb { font-size: 12px; background: var(--ink); color: #fff; border-radius: 7px; padding: 4px 10px; font-weight: 500; box-shadow: inset 0 1px 0 rgba(255,255,255,.15); }
.jobs { font-size: 12px; border-radius: 9px; overflow: hidden; box-shadow: 0 0 0 1px var(--line); }
.jobs .jr { display: grid; grid-template-columns: 1.5fr 1.6fr .6fr .8fr; gap: 8px; padding: 7px 10px; border-top: 1px solid var(--line); align-items: center; color: var(--ink2); transition: background .12s; }
.jobs .jr:first-child { border-top: 0; font: 10.5px var(--mono); color: var(--dim); text-transform: uppercase; letter-spacing: .05em; background: var(--raise); }
.jobs .jr:not(:first-child):hover { background: #faf8f4; }
.jobs .jr .m { font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.st { font: 11px var(--mono); display: inline-flex; align-items: center; gap: 6px; border-radius: 99px; padding: 1px 8px 1px 7px; width: fit-content; }
.st i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.st.run { color: var(--ok); background: var(--ok-s); } .st.q { color: var(--acc-ink); background: var(--acc-s); } .st.done { color: var(--mute); background: rgba(40,34,24,.05); }
.scale { border-radius: 9px; padding: 9px 10px 6px; box-shadow: 0 0 0 1px var(--line); }
.scale .l { display: flex; justify-content: space-between; font: 10.5px var(--mono); color: var(--dim); }
.scale .bars { display: flex; align-items: flex-end; gap: 3px; height: 46px; margin-top: 6px; }
.scale .bars i { flex: 1; background: #e8e5de; border-radius: 3px 3px 1px 1px; min-height: 2px; }
.scale .bars i.hi { background: #bdb8ad; } .scale .bars i.cur { background: linear-gradient(180deg, #ef7a4c, var(--acc)); }
.float { position: absolute; background: rgba(255,255,255,.88); -webkit-backdrop-filter: blur(12px) saturate(1.4); backdrop-filter: blur(12px) saturate(1.4); border-radius: 12px; padding: 11px 13px; font-size: 12px; color: var(--ink2); box-shadow: var(--sh3), var(--hi); transition: transform .5s var(--spring); }
.float b { color: var(--ink); font-weight: 600; display: flex; align-items: center; gap: 7px; font-size: 12.5px; letter-spacing: -.01em; }
.float b i { width: 7px; height: 7px; border-radius: 50%; background: var(--acc); box-shadow: 0 0 0 3px var(--acc-s); }
.float b i.g { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-s); }
.float .mono { font-size: 10.5px; color: var(--mute); }
.f1 { top: -20px; right: -22px; width: 216px; transform: translate3d(calc(var(--tx,0) * 16px), calc(var(--ty,0) * 11px), 40px); }
.f2 { bottom: 64px; left: -28px; width: 198px; transform: translate3d(calc(var(--tx,0) * 22px), calc(var(--ty,0) * 15px), 60px); }
.f1 .bar2 { height: 4px; background: #ece9e3; border-radius: 2px; margin-top: 8px; overflow: hidden; } .f1 .bar2 i { display: block; height: 100%; width: 38%; background: linear-gradient(90deg, #ef7a4c, var(--acc)); border-radius: 2px; }
/* -- stage 2: config (editor + terminal, parallax) -- */
.s-config { min-height: 350px; }
.s-config .win.ed { position: absolute; inset: 24px 74px 78px 22px; grid-template-rows: 32px 1fr auto; transition: transform .5s var(--spring); transform: translate3d(calc(var(--tx,0) * -6px), calc(var(--ty,0) * -4px), 0); }
.ed .body { display: grid; grid-template-columns: 132px 1fr; min-height: 0; }
.tree { background: var(--raise); box-shadow: inset -1px 0 0 var(--line); padding: 10px 6px; font: 11.5px/1.75 var(--mono); color: var(--mute); }
.tree div { padding-left: calc(var(--d, 0) * 12px + 6px); border-radius: 5px; white-space: nowrap; }
.tree .dir { color: var(--ink2); } .tree .on { background: var(--surface); color: var(--ink); box-shadow: var(--sh1); }
.code { font: 12px/1.72 var(--mono); padding: 10px 0; color: var(--ink2); counter-reset: ln; min-width: 0; overflow: hidden; }
.code .ln { padding: 0 12px 0 0; white-space: pre; display: flex; transition: background .12s; }
.code .ln::before { counter-increment: ln; content: counter(ln); width: 30px; flex: none; text-align: right; padding-right: 12px; color: #c9c5bc; }
.code .ln:hover { background: rgba(224,89,43,.06); }
.k1 { color: var(--ink); font-weight: 500; } .s1 { color: #4f7d48; } .c1 { color: var(--dim); } .a1 { color: var(--acc-ink); }
.term { position: absolute; right: 20px; bottom: 22px; width: 298px; background: linear-gradient(180deg, #24221e, #1b1a17); color: #d8d2c6; border-radius: 12px; font: 11px/1.7 var(--mono); padding: 0 0 10px; z-index: 3;
box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 0 0 1px rgba(0,0,0,.25), 0 18px 40px -12px rgba(26,25,21,.45), 0 50px 80px -40px rgba(26,25,21,.5); transition: transform .5s var(--spring); transform: translate3d(calc(var(--tx,0) * 14px), calc(var(--ty,0) * 10px), 0); }
.term .tt { display: flex; align-items: center; gap: 5px; padding: 8px 12px 6px; color: #7d776c; }
.term .tt i { width: 8px; height: 8px; border-radius: 50%; background: #3b3832; }
.term .tt span { margin-left: 8px; }
.term p { margin: 0; padding: 0 14px; white-space: nowrap; overflow: hidden; }
.term .d { color: #7d776c; } .term .g { color: #9cc79f; } .term .o { color: #f08c62; } .term .w { color: #fff; }
/* -- stage 3: hypercache (peer highlight) -- */
.s-net { min-height: 340px; cursor: crosshair; }
.s-net svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.s-net .e { stroke: rgba(40,34,24,.2); stroke-width: 1.2; transition: stroke .2s, opacity .2s; }
.s-net .e.bad { stroke-dasharray: 3 4; }
.s-net .p circle { fill: #fff; stroke: #4a463f; stroke-width: 1.4; transition: fill .2s, stroke .2s; filter: drop-shadow(0 2px 3px rgba(40,34,24,.18)); }
.s-net .p.free circle { stroke: var(--acc); stroke-dasharray: 2.5 2.5; }
.s-net .p text { font: 10px var(--mono); fill: var(--dim); }
.s-net .w { font: 500 10px var(--mono); fill: var(--ink); opacity: 0; transition: opacity .2s; paint-order: stroke; stroke: #f3f1ec; stroke-width: 4px; }
.s-net.hot .e { opacity: .25; }
.s-net.hot .e.lit { opacity: 1; stroke: var(--ink); }
.s-net.hot .e.lit.bad { stroke: var(--acc); }
.s-net .p.on circle { fill: var(--ink); stroke: var(--ink); }
.s-net .p.on.free circle { fill: var(--acc); stroke: var(--acc); }
.s-net .p.on text { fill: var(--ink); }
.s-net .w.lit { opacity: 1; }
.ro { position: absolute; left: 14px; top: 14px; z-index: 4; font: 11px/1.6 var(--mono); color: var(--mute); background: rgba(255,255,255,.8); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border-radius: 10px; padding: 8px 11px; min-width: 214px; box-shadow: var(--sh2), var(--hi); }
.ro b { color: var(--ink); font-weight: 500; }
.ro .bad { color: var(--acc-ink); }
/* -- stage 4: rowing (scrub) -- */
.s-row { min-height: 412px; }
.rowgrid { position: absolute; inset: 26px 28px 46px; display: grid; grid-template-columns: 210px 1fr; grid-template-rows: 1fr auto; gap: 16px 18px; }
.boat, .chart { background: var(--surface); border-radius: 13px; position: relative; overflow: hidden; box-shadow: var(--sh2); }
.boat svg, .chart svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.boat .lbl, .chart .lbl { position: absolute; left: 12px; top: 10px; font: 10.5px var(--mono); color: var(--dim); z-index: 2; }
.oar { transition: transform .12s linear; }
.chart { cursor: ew-resize; }
.chart .curve { fill: none; stroke: var(--ink); stroke-width: 1.7; }
.chart .area { fill: url(#areaG); }
.chart .grid line { stroke: rgba(40,34,24,.07); stroke-width: 1; }
.chart .ph-lbl { font: 10px var(--mono); fill: var(--dim); }
.chart .scr { opacity: 0; transition: opacity .15s; }
.chart:hover .scr { opacity: 1; }
.chart .scr line { stroke: var(--acc); stroke-width: 1; }
.chart .scr circle { fill: var(--acc); stroke: #fff; stroke-width: 2; }
.chart .rd { position: absolute; right: 12px; top: 9px; font: 10.5px var(--mono); color: var(--mute); z-index: 2; font-variant-numeric: tabular-nums; }
.chart .rd b { color: var(--acc-ink); font-weight: 500; }
.pipe { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; font: 11px var(--mono); color: var(--mute); flex-wrap: wrap; }
.pipe span { border-radius: 99px; padding: 3px 10px; background: var(--surface); color: var(--ink2); box-shadow: var(--sh1); }
.pipe span.hot { background: var(--ink); color: #fff; box-shadow: none; }
.pipe em { font-style: normal; color: var(--dim); }
/* index */
.idx { margin-top: 80px; }
.tools { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.tabs { position: relative; display: inline-flex; gap: 2px; background: rgba(40,34,24,.055); border-radius: 10px; padding: 3px; box-shadow: inset 0 1px 2px rgba(40,34,24,.06); }
.tabs .thumb { position: absolute; top: 3px; bottom: 3px; left: 0; width: 0; border-radius: 7px; background: var(--surface); box-shadow: 0 0 0 1px rgba(40,34,24,.06), 0 1px 2px rgba(40,34,24,.08), 0 3px 8px -3px rgba(40,34,24,.14); transition: transform .38s var(--spring), width .38s var(--spring); }
.tabs button { position: relative; z-index: 1; background: none; border: 0; border-radius: 7px; padding: 4px 11px; font-size: 13.5px; color: var(--mute); cursor: pointer; transition: color .2s; }
.tabs button:hover, .tabs button[aria-pressed="true"] { color: var(--ink); }
.tabs button small { font: 11px var(--mono); color: var(--dim); margin-left: 5px; }
.search { margin-left: auto; position: relative; }
.search input { background: var(--surface); border: 0; border-radius: 10px; color: var(--ink); font: 13.5px var(--sans); padding: 8px 36px 8px 13px; width: 280px; outline: none; box-shadow: var(--sh1); transition: box-shadow .2s; }
.search input:focus { box-shadow: 0 0 0 1px var(--acc), 0 0 0 4px var(--acc-s); }
.search input::placeholder { color: var(--dim); }
.search kbd { position: absolute; right: 9px; top: 50%; transform: translateY(-50%); }
.list { background: var(--surface); border-radius: 18px; box-shadow: var(--sh2); padding: 6px; }
.row { position: relative; z-index: 9; display: grid; grid-template-columns: 76px minmax(180px, 1.1fr) 2fr 1fr 64px 132px 20px; gap: 18px; align-items: center; width: 100%; text-align: left; background: none; border: 0; padding: 9px 12px 9px 8px; cursor: pointer; border-radius: 12px; transition: background .2s; }
.row + .row::after { content: ""; position: absolute; left: 100px; right: 12px; top: 0; height: 1px; background: var(--line); }
.row:hover, .row:focus-visible { background: #f8f6f2; }
.row:hover::after, .row:hover + .row::after { opacity: 0; }
.row .th { width: 76px; height: 46px; border-radius: 7px; background: #fff; overflow: hidden; box-shadow: 0 0 0 1px var(--line2), 0 2px 5px -2px rgba(40,34,24,.15); transition: transform .4s var(--spring), box-shadow .3s; }
.row:hover .th { transform: translateY(-1px) scale(1.04); box-shadow: 0 0 0 1px var(--line2), 0 8px 16px -6px rgba(40,34,24,.25); }
.row .th svg { width: 100%; height: 100%; display: block; }
.row .nm { font-weight: 500; font-size: 15px; letter-spacing: -.01em; }
.row .nm small { display: block; font: 11px var(--mono); color: var(--dim); font-weight: 400; margin-top: 1px; letter-spacing: 0; }
.row .one { color: var(--ink2); font-size: 14px; }
.row .stk { font: 11.5px var(--mono); color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .yr { font: 12px var(--mono); color: var(--mute); font-variant-numeric: tabular-nums; }
.row > .ai { justify-self: start; }
.row .go { color: var(--dim); transition: transform .3s var(--spring), color .15s; }
.row:hover .go { color: var(--acc); transform: translateX(3px); }
.empty { padding: 18px 14px; font: 13px var(--mono); color: var(--mute); }
.hiddenby { font: 12px var(--mono); color: var(--mute); padding: 12px 8px 0; }
/* hover peek for list rows */
.peek { position: fixed; z-index: 50; left: 0; top: 0; width: 340px; padding: 7px; border-radius: 16px; pointer-events: none; opacity: 0; transform: translate3d(var(--px, 0), var(--py, 0), 0) scale(.96); transform-origin: 0 50%;
background: rgba(255,255,255,.78); -webkit-backdrop-filter: blur(16px) saturate(1.5); backdrop-filter: blur(16px) saturate(1.5); box-shadow: var(--sh3), var(--hi); transition: opacity .18s, transform .3s var(--spring); }
.peek.on { opacity: 1; transform: translate3d(var(--px, 0), var(--py, 0), 0) scale(1); }
.peek .frame { border-radius: 10px; overflow: hidden; box-shadow: 0 0 0 1px var(--line); background: #fff; }
.peek .pc { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px 6px 2px; font: 11px var(--mono); color: var(--mute); }
/* drawings (from r3-a) */
.fbar { height: 28px; display: flex; align-items: center; gap: 10px; padding: 0 12px; background: linear-gradient(180deg, #fdfcfa, #f7f5f1); box-shadow: inset 0 -1px 0 var(--line); font: 11px var(--mono); color: var(--mute); }
.fbar .dotz { display: flex; gap: 5px; } .fbar .dotz i { width: 8px; height: 8px; border-radius: 50%; background: #e4e0d8; }
.fbar .url { flex: 1; text-align: center; margin-right: 27px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fbar.fig .url { text-align: left; margin: 0; }
.fbody svg { display: block; width: 100%; height: auto; }
svg text { font-family: var(--sans); }
.k-ink { fill: var(--ink); } .k-2 { fill: #5d5a54; } .k-3 { fill: #a9a59c; } .k-4 { fill: #e0ddd5; } .k-5 { fill: #f3f1ec; } .k-w { fill: #fff; } .k-acc { fill: var(--acc); } .k-at { fill: var(--acc-tint); } .nf { fill: none; } .pc { font-family: 'DejaVu Sans', 'Segoe UI Symbol', serif; }
.ln { stroke: #e6e3dc; stroke-width: 1; fill: none; } .ln2 { stroke: #cfcbc2; stroke-width: 1; fill: none; }
.sk { stroke: var(--ink); fill: none; stroke-width: 1.5; } .ska { stroke: var(--acc); fill: none; stroke-width: 1.5; }
.bd { stroke: #d6d2c9; stroke-width: 1; }
.tx { font-size: 11px; fill: #6e6a62; } .tb { font-size: 11px; fill: var(--ink); font-weight: 600; } .tm { font-family: var(--mono); }
.tw { fill: #fff; font-size: 11px; font-weight: 500; } .ta { fill: var(--acc-ink); font-size: 11px; } .t3 { fill: #a9a59c; font-size: 9px; letter-spacing: .06em; }
/* writing + history */
.lower { margin-top: 80px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
.card { background: var(--surface); border-radius: 20px; box-shadow: var(--sh2); padding: 24px 26px; }
.card > * { position: relative; z-index: 9; }
.posts a { display: grid; grid-template-columns: 88px 1fr auto; gap: 14px; text-decoration: none; padding: 11px 10px; margin: 0 -10px; border-radius: 10px; align-items: baseline; transition: background .2s; position: relative; }
.posts a + a::before { content: ""; position: absolute; left: 10px; right: 10px; top: 0; height: 1px; background: var(--line); }
.posts a:hover { background: #f8f6f2; } .posts a:hover::before, .posts a:hover + a::before { opacity: 0; }
.posts .d { font: 11.5px var(--mono); color: var(--dim); font-variant-numeric: tabular-nums; }
.posts .t { font-weight: 500; letter-spacing: -.01em; transition: color .15s; }
.posts .b { display: block; color: var(--mute); font-size: 13.5px; margin-top: 2px; }
.posts .tg { font: 11px var(--mono); color: var(--mute); background: rgba(40,34,24,.05); border-radius: 99px; padding: 0 7px; }
.posts a:hover .t { color: var(--acc-ink); }
.jobsx { margin: 0; padding: 0; list-style: none; position: relative; }
.jobsx::before { content: ""; position: absolute; left: 5px; top: 10px; bottom: 10px; width: 1px; background: linear-gradient(180deg, var(--acc), var(--line2) 30%, var(--line)); }
.jobsx > li { display: grid; grid-template-columns: 96px 1fr; gap: 14px; padding: 12px 0 12px 22px; position: relative; }
.jobsx > li::before { content: ""; position: absolute; left: 1px; top: 18px; width: 9px; height: 9px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 1.5px var(--line3); }
.jobsx > li:first-child::before { background: var(--acc); box-shadow: 0 0 0 3px var(--acc-s); }
.jobsx .when { font: 11.5px var(--mono); color: var(--dim); padding-top: 3px; font-variant-numeric: tabular-nums; }
.jobsx b { font-weight: 600; letter-spacing: -.01em; }
.jobsx .role { color: var(--mute); font-size: 13.5px; }
.jobsx p { margin: 6px 0 0; color: var(--ink2); font-size: 14px; }
.jobsx ul { margin: 6px 0 0; padding-left: 16px; color: var(--ink2); font-size: 13.5px; }
.jobsx .chips { margin-top: 10px; }
footer { margin-top: 64px; box-shadow: 0 -1px 0 var(--line); }
footer .wrap { display: flex; gap: 20px; align-items: center; padding-top: 22px; padding-bottom: 30px; font: 12px var(--mono); color: var(--mute); flex-wrap: wrap; }
footer a { color: var(--ink2); text-decoration: none; } footer a:hover { color: var(--acc-ink); }
footer .sp2 { margin-left: auto; }
/* mockup-only gate suggestion */
.gs { margin: 8px auto 48px; max-width: 1320px; padding: 0 40px; }
.gs .box { border: 1px dashed var(--line3); border-radius: 18px; padding: 20px 22px; display: grid; grid-template-columns: 1fr 520px; gap: 26px; align-items: center; }
.gs h4 { margin: 0 0 6px; font: 500 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--mute); }
.gs p { margin: 0 0 8px; color: var(--ink2); font-size: 14px; }
.mini { height: 250px; display: grid; grid-template-columns: 1.15fr 1fr; border-radius: 14px; overflow: hidden; box-shadow: var(--sh2); }
.mini .l { background: radial-gradient(300px 200px at 30% 0, rgba(224,89,43,.08), transparent 70%), #f5f4f0; padding: 20px 18px; display: flex; flex-direction: column; justify-content: center; align-items: flex-end; text-align: right; position: relative; }
.mini .l .lab { font: 9px var(--mono); color: var(--dim); letter-spacing: .08em; text-transform: uppercase; }
.mini .l .lab b { color: var(--acc-ink); font-weight: 500; }
.mini .l h5 { margin: 6px 0 4px; font: 600 36px/1 var(--sans); letter-spacing: -.045em; color: var(--ink); }
.mini .l .le { font-size: 11px; color: var(--ink2); max-width: 17em; }
.mini .l .rows { margin-top: 10px; width: 196px; background: #fff; border-radius: 9px; box-shadow: var(--sh1); padding: 2px 8px; }
.mini .l .rows div { display: flex; justify-content: space-between; gap: 8px; font-size: 10px; color: var(--ink2); padding: 4px 0; border-bottom: 1px solid var(--line); }
.mini .l .rows div:last-child { border-bottom: 0; }
.mini .l .rows .ai { font-size: 9px; }
.mini .l .rows .ai i { width: 7px; height: 7px; }
.mini .l .en { margin-top: 10px; font: 10px var(--mono); color: #fff; background: var(--ink); border-radius: 6px; padding: 2px 7px; }
.mini .l .thumb { position: absolute; left: 14px; top: 50%; transform: translateY(-50%) rotate(-3deg); width: 96px; height: 70px; border-radius: 8px; background: #fff; padding: 6px; display: grid; grid-template-columns: 18px 1fr; gap: 4px; box-shadow: var(--sh3); }
.mini .l .thumb i { background: #ece9e3; border-radius: 2px; display: block; }
.mini .l .thumb span { display: flex; flex-direction: column; gap: 3px; } .mini .l .thumb span i { height: 6px; } .mini .l .thumb span i:nth-child(2) { background: var(--acc); width: 60%; }
.mini .r { background: var(--a-bg); color: var(--a-ink); padding: 20px 18px; display: flex; flex-direction: column; justify-content: center; position: relative;
background-image: radial-gradient(rgba(241,230,214,.06) 1px, transparent 1px); background-size: 3px 3px; }
.mini .r .lab { font: italic 300 11px "Fraunces", serif; color: #b8a592; }
.mini .r h5 { margin: 4px 0; font: italic 300 32px/1 "Fraunces", serif; }
.mini .r .le { font: italic 300 12px "Fraunces", serif; color: #b8a592; }
/* side sheet: floating, frosted */
.scrim { position: fixed; inset: 0; z-index: 70; background: rgba(238,235,229,.42); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); opacity: 0; pointer-events: none; transition: opacity .3s; }
.scrim.on { opacity: 1; pointer-events: auto; }
.sheet { position: fixed; top: 10px; right: 10px; bottom: 10px; z-index: 80; width: min(640px, calc(100vw - 20px)); border-radius: 22px; overflow: hidden;
background: rgba(252,251,249,.84); -webkit-backdrop-filter: blur(28px) saturate(1.6); backdrop-filter: blur(28px) saturate(1.6);
box-shadow: 0 0 0 1px rgba(40,34,24,.08), inset 0 1px 0 rgba(255,255,255,.9), 0 30px 80px -20px rgba(40,34,24,.35), 0 10px 24px -10px rgba(40,34,24,.18);
transform: translateX(calc(100% + 40px)); transition: transform .48s var(--spring), visibility 0s .48s; display: flex; flex-direction: column; visibility: hidden; }
.sheet.on { transform: none; visibility: visible; transition: transform .48s var(--spring), visibility 0s; }
.sheet .top { display: flex; align-items: center; gap: 6px; padding: 12px 16px 12px 20px; font: 12px var(--mono); color: var(--mute); box-shadow: 0 1px 0 var(--line); font-variant-numeric: tabular-nums; }
.sheet .top .pos { margin-right: auto; }
.ib { background: var(--surface); border: 0; border-radius: 8px; color: var(--ink2); font: 12px var(--mono); padding: 4px 10px; cursor: pointer; box-shadow: var(--sh1); transition: box-shadow .2s, color .15s; }
.ib:hover { color: var(--ink); box-shadow: 0 0 0 1px var(--line3), 0 3px 8px -3px rgba(40,34,24,.18); }
.ib:disabled { opacity: .4; cursor: default; box-shadow: var(--sh1); }
.sbody { overflow-y: auto; padding: 18px 26px 40px; flex: 1; view-transition-name: sheetbody; }
.vis { position: relative; height: 282px; border-radius: 16px; overflow: hidden;
background: radial-gradient(rgba(40,34,24,.075) 1px, transparent 1.2px) 0 0 / 18px 18px, radial-gradient(400px 260px at 80% 0, rgba(224,89,43,.08), transparent 70%), linear-gradient(180deg, #f4f2ee, #ebe8e1);
box-shadow: inset 0 0 0 1px var(--line); }
.vis .ghost { position: absolute; left: 12%; width: 76%; top: 16px; height: 300px; border-radius: 12px; background: rgba(255,255,255,.55); box-shadow: var(--sh1); }
.vis .front { position: absolute; left: 6%; width: 88%; top: 32px; border-radius: 12px; overflow: hidden; background: #fff; box-shadow: var(--sh3); }
.vis .cap { position: absolute; right: 12px; bottom: 10px; font: 10.5px var(--mono); color: var(--mute); background: rgba(255,255,255,.75); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); border-radius: 99px; padding: 2px 9px; box-shadow: var(--sh1); z-index: 2; }
.sbody h2 { font-size: 34px; letter-spacing: -.04em; line-height: 1.05; margin: 22px 0 10px; font-weight: 600; }
.sbody .lead { font-size: 17px; color: var(--ink2); margin: 12px 0 18px; letter-spacing: -.005em; }
.sbody section { border-top: 1px solid var(--line); padding: 14px 0; display: grid; grid-template-columns: 110px 1fr; gap: 14px; }
.sbody section h4 { margin: 0; font: 11px var(--mono); color: var(--dim); text-transform: uppercase; letter-spacing: .06em; padding-top: 3px; }
.sbody section p { margin: 0; color: var(--ink2); font-size: 14.5px; }
.sbody.anim > * { animation: rise .5s var(--spring) both; animation-delay: calc(var(--i, 0) * 35ms); }
.parts { list-style: none; margin: 0; padding: 4px 12px; background: var(--surface); border-radius: 10px; box-shadow: var(--sh1); }
.parts li { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14px; color: var(--ink2); }
.parts li:last-child { border-bottom: 0; }
.lks { display: flex; gap: 6px; flex-wrap: wrap; }
.lks .dead { font: 12px var(--mono); color: var(--dim); }
.rel { display: flex; gap: 6px; flex-wrap: wrap; }
.rel button { background: var(--surface); border: 0; border-radius: 8px; padding: 4px 10px; font-size: 13px; color: var(--ink2); cursor: pointer; box-shadow: var(--sh1); transition: box-shadow .2s, color .15s, transform .3s var(--spring); }
.rel button:hover { color: var(--ink); box-shadow: 0 0 0 1px var(--line3), 0 3px 8px -3px rgba(40,34,24,.18); transform: translateY(-1px); }
.sposts a { display: block; text-decoration: none; font-size: 14px; color: var(--ink2); padding: 2px 0; }
.sposts a:hover { color: var(--acc-ink); }
::view-transition-old(sheetbody) { animation: .18s var(--ease) both vt-out; }
::view-transition-new(sheetbody) { animation: .32s var(--spring) both vt-in; }
@keyframes vt-out { to { opacity: 0; transform: translateX(-12px); } }
@keyframes vt-in { from { opacity: 0; transform: translateX(16px); } }
/* command palette */
dialog.pal { border: 0; border-radius: 18px; padding: 0; width: min(620px, calc(100vw - 32px)); margin-top: 13vh; color: var(--ink); overflow: hidden;
background: rgba(252,251,249,.86); -webkit-backdrop-filter: blur(28px) saturate(1.6); backdrop-filter: blur(28px) saturate(1.6);
box-shadow: 0 0 0 1px rgba(40,34,24,.09), inset 0 1px 0 rgba(255,255,255,.9), 0 40px 100px -30px rgba(40,34,24,.45), 0 12px 30px -12px rgba(40,34,24,.2); }
dialog.pal::backdrop { background: rgba(238,235,229,.45); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
dialog.pal[open] { animation: palin .32s var(--spring); }
@keyframes palin { from { opacity: 0; transform: translateY(-8px) scale(.97); } to { opacity: 1; transform: none; } }
.pal-in { display: flex; align-items: center; gap: 10px; padding: 0 18px; box-shadow: 0 1px 0 var(--line); }
.pal-in svg { flex: none; }
.pal input { flex: 1; border: 0; outline: 0; font: inherit; font-size: 17px; letter-spacing: -.01em; padding: 18px 0; background: none; color: var(--ink); }
.pal input::placeholder { color: var(--dim); }
.pal ul { list-style: none; margin: 0; padding: 6px 8px 8px; max-height: 400px; overflow: auto; }
.pal .g { font: 10.5px var(--mono); color: var(--dim); padding: 10px 10px 4px; letter-spacing: .04em; }
.pal .it { display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 10px; cursor: pointer; font-size: 14px; }
.pal .it .k { font: 10.5px var(--mono); color: var(--mute); width: 24px; height: 22px; border-radius: 6px; background: var(--surface); box-shadow: var(--sh1); display: inline-flex; align-items: center; justify-content: center; flex: none; }
.pal .it .s { margin-left: auto; font-size: 12px; color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 48%; }
.pal .it.on { background: var(--surface); box-shadow: var(--sh1), 0 4px 10px -6px rgba(40,34,24,.2); }
.pal .it.on .k { color: #fff; background: var(--acc); box-shadow: none; }
.pal .none { padding: 18px 10px; color: var(--mute); font-size: 14px; }
.pal-foot { display: flex; gap: 16px; padding: 9px 18px; box-shadow: 0 -1px 0 var(--line); font-size: 11.5px; color: var(--mute); background: rgba(255,255,255,.5); }
.pal-foot span:last-child { margin-left: auto; }
/* one short fade-up of the first screen on load */
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.boot { animation: rise .48s var(--spring) both; }
.boot.d1 { animation-delay: .05s; } .boot.d2 { animation-delay: .1s; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0s !important; animation: none !important; }
.tiltw, .f1, .f2, .term, .s-config .win.ed { transform: none !important; }
::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
}
@media (max-width: 1180px) {
.nav { display: none; }
.kbtn { min-width: 0; } .kbtn span { display: none; }
}
@media (max-width: 1100px) {
.feat.wide, .feat.wide.rev { grid-template-columns: 1fr; }
.feat.wide.rev .stage { order: 0; }
.row { grid-template-columns: 76px 1fr 1.6fr 132px 20px; }
.row .stk, .row .yr { display: none; }
.gs .box { grid-template-columns: 1fr; }
.intro h1 { font-size: 44px; }
}
@media (max-width: 760px) {
:root { --edge: 26px; }
.edge { font-size: 13px; }
.bar .in, .wrap, .gs { padding-left: 16px; padding-right: 16px; }
.bar .in { gap: 10px; height: auto; padding-top: 8px; padding-bottom: 8px; flex-wrap: wrap; }
.pref { margin-left: 0; } .pref > span { display: none; }
.kbtn kbd { display: none; }
.intro { grid-template-columns: 1fr; padding-top: 30px; gap: 20px; } .legend { justify-self: start; }
.intro h1 { font-size: 34px; }
.now { grid-template-columns: 1fr; } .now > div + div { box-shadow: 0 -1px 0 var(--line); }
.feats { grid-template-columns: 1fr; }
.s-compute { min-height: 380px; } .tiltw { inset: 22px 14px 40px; } .app { grid-template-columns: 1fr; } .app .side { display: none; } .f1 { right: -4px; width: 170px; } .f2 { left: 10px; }
.jobs .jr { grid-template-columns: 1.4fr 1.4fr .8fr; } .jobs .jr > :nth-child(3) { display: none; }
.ed .body { grid-template-columns: 1fr; } .tree { display: none; } .s-config .win.ed { inset: 18px 14px 90px; } .term { width: 250px; right: 12px; bottom: 14px; }
.rowgrid { grid-template-columns: 1fr; grid-template-rows: 150px 1fr auto; } .s-row { min-height: 540px; }
.row { grid-template-columns: 64px 1fr 20px; gap: 12px; } .row .th { width: 64px; height: 40px; } .row .one, .row .ai { display: none; }
.row + .row::after { left: 12px; }
.search { margin-left: 0; width: 100%; } .search input { width: 100%; }
.lower { grid-template-columns: 1fr; }
.posts a { grid-template-columns: 1fr; gap: 2px; }
.mk { width: 240px; } .stage .hint, .sh .r, .f2, .peek { display: none; }
.mini { grid-template-columns: 1fr; height: auto; } .mini .l .thumb { display: none; } .mini .l, .mini .r { min-height: 150px; }
.sbody section { grid-template-columns: 1fr; gap: 6px; }
}
</style>
</head>
<body>
<div class="mk" id="mk"><button onclick="document.getElementById('mk').remove()" aria-label="close">×</button>
<b>tech-r4-b · Light and sleek</b>r3-c's structure and showcases as a light, layered product surface: floating panels, pointer light, frosted bar, sheet and ⌘K palette. Drawn previews from r3-a. Craft of Linear, Vercel, Raycast.</div>
<a class="edge" href="nav-d.html#art" title="cross over to the personal side">off the clock →</a>
<header class="bar" id="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><span>/ work</span></div>
<nav class="nav" aria-label="sections"><a href="#featured">Featured</a><a href="#all">All projects</a><a href="#writing">Writing</a><a href="#cv">CV</a></nav>
<div class="pref"><span>made</span>
<div class="seg" id="seg" role="group" aria-label="filter projects by how much AI was used">
<span class="thumb" aria-hidden="true"></span>
<button data-pref="all" aria-pressed="true">any way</button><button data-pref="nofull" aria-pressed="false">not fully by AI</button><button data-pref="hand" aria-pressed="false">by hand only</button>
</div>
</div>
<button class="kbtn" id="kbtn" aria-label="Open command palette"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="7" cy="7" r="5"/><path d="M11 11l3.5 3.5"/></svg><span>Jump to…</span><kbd>⌘K</kbd></button>
</div>
</header>
<main>
<div class="wrap">
<section class="intro boot" id="top">
<div>
<span class="eyebrow"><i></i>work · projects, writing, cv</span>
<h1>I build and run research software at Aarhus University. <em>On the side, small tools, games and infrastructure.</em></h1>
<p>Four projects told properly, then everything else. Each one says how much of it was made with AI, and the filter in the bar hides what you'd rather not see.</p>
</div>
<div class="legend" aria-label="legend">
<span class="ai full"><i></i>fully AI</span><span class="ai some"><i></i>some AI</span>
<span class="ai none"><i></i>by hand</span><span class="ai unk" title="placeholder: AI level not recorded yet"><i></i>AI: not recorded</span>
</div>
</section>
<section class="now sp boot d1" aria-label="now, latest writing, cv">
<div>
<h3><span>Now</span><span class="ph" title="placeholder">Nov 2026</span></h3>
<ul>
<li><span class="ph" title="placeholder">Moving the compute platform's job logs into Grafana Loki</span></li>
<li><span class="ph" title="placeholder">Rewriting Config so a fresh machine comes up in one run</span></li>
<li><span class="ph" title="placeholder">Reading: Designing Data-Intensive Applications, 2nd ed.</span></li>
</ul>
</div>
<div>
<h3><span>Latest writing</span><a href="#writing">all 5 ↓</a></h3>
<div class="wl" id="wl"></div>
</div>
<div id="cv10">
<h3><span>CV in ten seconds</span></h3>
<div class="cv10">
<span class="k">now</span><span class="v"><b>IT-medarbejder</b>, Aarhus University, IT Research Tech · 2025–now</span>
<span class="k">before</span><span class="v">ScienceAtHome intern 2019 · TERMA student helper 2016–18</span>
<span class="k">degree</span><span class="v">BSc Computer Science, <span class="ph" title="placeholder">Aarhus University</span> · HyperCache thesis</span>
<span class="k">contact</span><span class="v"><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a></span>
</div>
<div class="cvlinks"><a class="btn solid" href="#" onclick="return false" title="placeholder: CV PDF">↓ CV (PDF)</a><a class="btn" href="#cv">full history</a><a class="btn" href="https://github.com/StereotypicalCat">GitHub</a><a class="btn" href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a></div>
</div>
</section>
<!-- ================= featured ================= -->
<section id="featured">
<div class="sh"><h2>Featured</h2><span class="n">4</span><span class="r">move over the drawings · open any panel for the full story</span></div>
<div class="feats">
<!-- 1 compute -->
<article class="feat wide sp boot d2" data-id="compute">
<div class="stage s-compute" data-fx="tilt" aria-hidden="true">
<div class="tiltw">
<div class="ghostwin"></div>
<div class="win">
<div class="wtb"><span class="dots"><i></i><i></i><i></i></span><span class="url" title="placeholder">compute.au.dk/jobs</span></div>
<div class="app">
<div class="side">
<div class="logo"><i></i>AU Compute</div>
<div class="it on">Jobs</div><div class="it">New job</div><div class="it">Data · ERDA</div><div class="it">Usage</div><div class="it">Templates</div>
<div class="who">signed in with<br>AU ID · Entra ID</div>
</div>
<div class="main">
<div class="hd"><b>Your jobs<small>3 running · 1 waiting</small></b><span class="nb">+ New job</span></div>
<div class="jobs">
<div class="jr"><span>job</span><span>status</span><span>nodes</span><span>time</span></div>
<div class="jr"><span class="m">protein-fold-07</span><span class="st run"><i></i>running</span><span class="m">4</span><span class="m">01:12:44</span></div>
<div class="jr"><span class="m">thesis-sim-batch</span><span class="st q"><i></i>waiting for node</span><span class="m">2</span><span class="m">00:00:41</span></div>
<div class="jr"><span class="m">rna-align-22</span><span class="st run"><i></i>running</span><span class="m">1</span><span class="m">00:18:03</span></div>
<div class="jr"><span class="m">imgproc-2210</span><span class="st done"><i></i>done</span><span class="m">8</span><span class="m">03:40:12</span></div>
<div class="jr"><span class="m">mc-sweep-12</span><span class="st done"><i></i>done</span><span class="m">2</span><span class="m">00:52:30</span></div>
</div>
<div class="scale">
<div class="l"><span>cluster size, last 24 h</span><span>scales to zero overnight</span></div>
<div class="bars" id="bars"></div>
</div>
</div>
</div>
</div>
<div class="float f1"><b><i></i>+2 nodes</b><span class="mono">Oracle Cloud · booting, ~40 s</span><div class="bar2"><i></i></div></div>
<div class="float f2"><b><i class="g"></i>ERDA mounted</b><span class="mono">/projects/<span title="placeholder">bio-lab</span> · 412 GB · read-only</span></div>
</div>
<span class="cap">drawn stand-in · job names and numbers invented</span>
<span class="hint">move the pointer here</span>
</div>
<div class="txt">
<div class="meta"><span class="num">01</span><span>Work · Aarhus University, IT Research Tech</span><span>2025–now</span></div>
<h3>Compute job platform</h3>
<p class="pitch">Slurm jobs from a browser. Researchers sign in with their AU account, pick data from ERDA and submit; a React/Node app sits in front of a cluster that grows and shrinks itself on Oracle Cloud.</p>
<dl class="facts">
<dt>why</dt><dd>Heavy jobs without learning Slurm, SSH and batch scripts first. Use is bursty, so a fixed cluster is either idle or full.</dd>
<dt>interesting</dt><dd><span class="ph" title="placeholder">A queued job has to say why it's waiting while a node boots, instead of just looking stuck.</span></dd>
</dl>
<div class="chips"><span class="chip">Slurm</span><span class="chip">React</span><span class="chip">Node.js</span><span class="chip">Oracle Cloud</span><span class="chip">OAuth · Entra ID</span><span class="chip">ERDA</span><span class="chip">Docker</span></div>
<div class="ft"><button class="open" data-open="compute">Read the full story <span>→</span></button><span class="ai unk" title="placeholder: AI level not recorded yet"><i></i>AI: not recorded</span></div>
</div>
<div class="filt">Hidden by your filter (AI level not recorded). <button class="linkish" data-showanyway>show anyway</button></div>
</article>
<!-- 2 config -->
<article class="feat sp" data-id="config">
<div class="stage s-config" data-fx="par" aria-hidden="true">
<div class="win ed">
<div class="wtb"><span class="dots"><i></i><i></i><i></i></span><span class="ttl">config / site.yml</span></div>
<div class="body">
<div class="tree">
<div class="dir">config/</div>
<div style="--d:1">inventory/</div>
<div style="--d:2">hosts.yml</div>
<div style="--d:1" class="dir">roles/</div>
<div style="--d:2">docker</div>
<div style="--d:2">compose_stack</div>
<div style="--d:2" title="placeholder">backups</div>
<div style="--d:1">stacks/</div>
<div style="--d:1" class="on">site.yml</div>
</div>
<div class="code">
<div class="ln"><span class="c1"># one command: the machine matches git</span></div>
<div class="ln"><span class="k1">- hosts</span>: <span class="a1">homelab</span></div>
<div class="ln"> <span class="k1">become</span>: true</div>
<div class="ln"> <span class="k1">roles</span>:</div>
<div class="ln"> - docker</div>
<div class="ln"> - compose_stack</div>
<div class="ln"> <span class="k1">vars</span>:</div>
<div class="ln"> <span class="k1">stacks</span>: [<span class="s1">grafana</span>, <span class="s1">openbao</span>, <span class="s1" title="placeholder">gitea</span>]</div>
</div>
</div>
</div>
<div class="term">
<div class="tt"><i></i><i></i><i></i><span>zsh · ~/config</span></div>
<p><span class="d">$</span> <span class="w">ansible-playbook site.yml</span></p>
<p class="d">TASK [compose_stack : up] ………</p>
<p><span class="g">ok:</span> [<span title="placeholder">nas</span>] <span class="g">ok:</span> [<span title="placeholder">pi</span>]</p>
<p class="d">PLAY RECAP</p>
<p><span title="placeholder">nas</span> ok=41 <span class="o">changed=0</span> failed=0</p>
</div>
<span class="cap">drawn stand-in · hosts invented</span>
</div>
<div class="txt">
<div class="meta"><span class="num">02</span><span>Own</span><span class="ph" title="placeholder">2025</span></div>
<h3>Config</h3>
<p class="pitch">Every service on my servers, deployed and updated from one repo with Ansible and docker-compose. One command brings a machine to the state git describes.</p>
<div class="chips"><span class="chip">Ansible</span><span class="chip">IaC</span><span class="chip">docker-compose</span><span class="chip">Linux</span></div>
<div class="ft"><button class="open" data-open="config">Read more <span>→</span></button><span class="ai full" title="made with AI"><i></i>fully AI</span></div>
</div>
<div class="filt">Config is hidden by your filter (fully AI-made). <button class="linkish" data-showanyway>show anyway</button></div>
</article>
<!-- 3 hypercache -->
<article class="feat sp" data-id="hypercache">
<div class="stage s-net" data-fx="net" aria-hidden="true">
<svg id="netsvg" viewBox="0 0 600 350" preserveAspectRatio="xMidYMid meet"></svg>
<div class="ro" id="netro">hover a peer to see who trusts it</div>
<span class="cap">simulated network · numbers illustrative</span>
</div>
<div class="txt">
<div class="meta"><span class="num">03</span><span>School · bachelor thesis</span><span class="ph" title="placeholder">2022</span></div>
<h3>HyperCache</h3>
<p class="pitch">Reputation systems for peer-to-peer archival: who can you trust to keep your data? Peers rate each other on whether data is really kept, built and simulated in Node.js.</p>
<div class="chips"><span class="chip">Node.js</span><span class="chip">P2P</span><span class="chip">simulation</span><span class="chip">reputation</span></div>
<div class="ft"><button class="open" data-open="hypercache">Read more <span>→</span></button><span class="ai none" title="no AI, by hand"><i></i>by hand</span></div>
</div>
<div class="filt">Hidden by your filter. <button class="linkish" data-showanyway>show anyway</button></div>
</article>
<!-- 4 rowing -->
<article class="feat wide rev sp" data-id="rowing">
<div class="stage s-row" data-fx="row" aria-hidden="true">
<div class="rowgrid">
<div class="boat"><span class="lbl">top view</span>
<svg viewBox="0 0 210 300" preserveAspectRatio="xMidYMid meet">
<defs><linearGradient id="hullG" x1="0" x2="1"><stop offset="0" stop-color="#efece6"/><stop offset=".5" stop-color="#fff"/><stop offset="1" stop-color="#efece6"/></linearGradient></defs>
<path d="M105 30 C 128 80, 128 220, 105 272 C 82 220, 82 80, 105 30 Z" fill="url(#hullG)" stroke="#8a857b" stroke-width="1.3"/>
<rect x="94" y="150" width="22" height="14" rx="3" fill="none" stroke="#b5b0a6"/>
<g class="oar" id="oarL" style="transform-origin:96px 140px"><line x1="96" y1="140" x2="18" y2="140" stroke="#47443e" stroke-width="2"/><rect x="6" y="134" width="20" height="12" rx="3" fill="#47443e"/></g>
<g class="oar" id="oarR" style="transform-origin:114px 140px"><line x1="114" y1="140" x2="192" y2="140" stroke="#47443e" stroke-width="2"/><rect x="184" y="134" width="20" height="12" rx="3" fill="#47443e"/></g>
<circle cx="96" cy="140" r="3.5" fill="#e0592b"/><circle cx="114" cy="140" r="3.5" fill="#e0592b"/>
<text x="105" y="292" text-anchor="middle" font-family="Geist Mono" font-size="10" fill="#a7a399" id="oarDeg">oar angle 0°</text>
</svg>
</div>
<div class="chart" id="chart"><span class="lbl">one stroke · force at the handle</span><span class="rd" id="rd">scrub the curve</span>
<svg id="chartsvg" viewBox="0 0 560 260" preserveAspectRatio="none"></svg>
</div>
<div class="pipe"><span>oar × 2</span><em>→</em><span title="placeholder: sensor type">rotary sensor</span><em>→</em><span class="hot">Arduino</span><em>→ serial →</em><span>Unity · C#</span><em>→</em><span>boat moves</span></div>
</div>
<span class="cap">drawn stand-in · curve shape illustrative, not measured</span>
</div>
<div class="txt">
<div class="meta"><span class="num">04</span><span>School · course project</span><span class="ph" title="placeholder">2020</span></div>
<h3>Mixed-Reality Rowing Game</h3>
<p class="pitch">Row a virtual boat with two real oars. Sensors on each oar are read by an Arduino, and every stroke goes over serial into a Unity scene.</p>
<dl class="facts">
<dt>why</dt><dd>Rowing machines are boring, and VR controllers don't feel like oars.</dd>
<dt>interesting</dt><dd><span class="ph" title="placeholder">Turning noisy sensor readings into a stroke that feels right, with almost no lag.</span></dd>
</dl>
<div class="chips"><span class="chip">Unity</span><span class="chip">C#</span><span class="chip">Arduino</span><span class="chip">hardware</span></div>
<div class="ft"><button class="open" data-open="rowing">Read the full story <span>→</span></button><span class="ai none" title="no AI, by hand"><i></i>by hand</span></div>
</div>
<div class="filt">Hidden by your filter. <button class="linkish" data-showanyway>show anyway</button></div>
</article>
</div>
</section>
<!-- ================= index ================= -->
<section class="idx" id="all">
<div class="sh"><h2>Everything else</h2><span class="n" id="cnt">13</span><span class="r">hover a row for a preview</span></div>
<div class="tools">
<div class="tabs" role="group" aria-label="filter by kind" id="tabs"></div>
<label class="search"><input id="q" type="search" placeholder="Search name, stack, idea" aria-label="search projects"><kbd>/</kbd></label>
</div>
<div class="list sp" id="list"></div>
<div class="hiddenby" id="hiddenby"></div>
</section>
<!-- ================= writing + cv ================= -->
<div class="lower">
<section class="card sp" id="writing">
<div class="sh"><h2>Writing</h2><span class="n">AI, devops, homelab</span><a class="r" href="https://blog.lucaswinther.info">blog.lucaswinther.info →</a></div>
<div class="posts" id="posts"></div>
</section>
<section class="card sp" id="cv">
<div class="sh"><h2>Where I've worked</h2><a class="r" href="#" onclick="return false" title="placeholder: CV PDF">↓ CV (PDF)</a></div>
<ul class="jobsx">
<li><span class="when">2025 – now</span><div><b>Aarhus University</b> <span class="role">· IT-medarbejder, IT Research Tech</span>
<p>Building and running software for researchers:</p>
<ul><li>a scalable, easy-to-use compute job system on Slurm, with a custom React/Node web app, scaling on Oracle Cloud, OAuth/Entra ID login and AU's ERDA storage</li>
<li>document and information services, integrating Overleaf and OpenProject</li>
<li>trying newer software while keeping it secure, containerised, updated and monitored: OpenBao/Vault, Grafana, Docker, Ansible</li></ul>
<div class="chips"><span class="chip">DevOps</span><span class="chip">Docker</span><span class="chip">Linux admin</span><span class="chip">observability</span><span class="chip">cloud</span><span class="chip">full-stack</span><span class="chip">leadership</span></div></div></li>
<li><span class="when">Jul 2019</span><div><b>ScienceAtHome</b> <span class="role">· internship</span>
<p>Joined ideation and planning on a new project, built an early prototype of its base mechanics to show the people it was for, and pair-programmed with experienced developers to start it.</p></div></li>
<li><span class="when">2016 – 2018</span><div><b>TERMA Group</b> <span class="role">· student helper, Local Configuration Management</span></div></li>
<li><span class="when">degree</span><div><b>BSc Computer Science</b> <span class="role">· <span class="ph" title="placeholder">Aarhus University, 2022</span></span>
<p>Thesis: HyperCache, reputation systems for peer-to-peer archival. <button class="linkish" data-open="hypercache">open project</button></p></div></li>
</ul>
</section>
</div>
</div>
<footer>
<div class="wrap">
<a href="nav-d.html">↖ back to the gate</a>
<a href="nav-d.html#art">off the clock →</a>
<a href="#" onclick="return false" title="placeholder link">old site (2021, by hand)</a>
<a href="#" onclick="return false" title="placeholder link">/simple</a>
<a href="https://github.com/StereotypicalCat">GitHub</a>
<a href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a>
<a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a>
<span class="sp2">dotted underline = placeholder · AU projects' AI level, years, "now", post titles, uni name, all drawn data</span>
</div>
</footer>
<!-- mockup-only: suggestion for the gate -->
<div class="gs">
<div class="box">
<div>
<h4>Mockup note, not part of the page · suggested left gate</h4>
<p>If this direction wins, the gate's left side drops the grid paper for this page's canvas: soft off-white (#f5f4f0) with a faint orange bloom, Geist, the same accent. "Work" in heavy, tight sans; the three featured rows sit on one floating white card with these AI marks.</p>
<p>The halves then differ by light and texture: a bright, layered product surface on the left, grainy plum with Fraunces on the right. On hover, a small drawn window of the compute platform lifts in on the left; the crosshair readout goes.</p>
<p>"Enter work" then needs no transition: the gate's colours are already this page's.</p>
</div>
<div class="mini" aria-hidden="true">
<div class="l">
<div class="thumb"><i></i><span><i></i><i></i><i></i><i></i></span></div>
<span class="lab"><b>01</b> · work</span>
<h5>Work</h5>
<span class="le">Research software at AU, and things on the side.</span>
<div class="rows"><div>Compute job platform<span class="ai unk bare"><i></i></span></div><div>HyperCache thesis<span class="ai none bare"><i></i>hand</span></div><div>Rowing game, two oars<span class="ai none bare"><i></i>hand</span></div></div>
<span class="en">← enter work</span>
</div>
<div class="r">
<span class="lab">02 · off the clock</span>
<h5>Off the clock</h5>
<span class="le">writing, music, the band (unchanged)</span>
</div>
</div>
</div>
</div>
</main>
<div class="peek" id="peek" aria-hidden="true"></div>
<div class="scrim" id="scrim"></div>
<aside class="sheet" id="sheet" role="dialog" aria-modal="true" aria-labelledby="sTitle" aria-hidden="true">
<div class="top"><span class="pos" id="spos"></span><button class="ib" id="sprev" aria-label="previous project">← prev</button><button class="ib" id="snext" aria-label="next project">next →</button><button class="ib" id="sclose" aria-label="close">esc ×</button></div>
<div class="sbody" id="sbody"></div>
</aside>
<dialog class="pal" id="pal" aria-label="Jump to">
<div class="pal-in"><svg width="17" height="17" viewBox="0 0 16 16" fill="none" stroke="#7a766e" stroke-width="1.6"><circle cx="7" cy="7" r="5"/><path d="M11 11l3.5 3.5"/></svg><input id="palq" placeholder="Jump to a project, post or section…" autocomplete="off" spellcheck="false" aria-controls="pallist"></div>
<ul id="pallist" role="listbox"></ul>
<div class="pal-foot"><span><kbd>↑</kbd> <kbd>↓</kbd> move</span><span><kbd>↵</kbd> open</span><span><kbd>esc</kbd> close</span><span>⌘K anywhere</span></div>
</dialog>
<script>
var PH = function (s) { return '<span class="ph" title="placeholder">' + s + '</span>'; };
var GROUPS = { work: 'Work', own: 'Own', school: 'School' };
var AI = {
full: { label: 'fully AI', long: 'made with AI' },
some: { label: 'some AI', long: 'some AI help' },
none: { label: 'by hand', long: 'no AI, by hand' },
unk: { label: 'AI: not recorded', long: 'placeholder: AI level not recorded yet' }
};
var POSTS = [
{ id: 'agent', t: "What I let the agent write, and what I didn't", d: '2026-10-14', tag: 'AI', blurb: 'Going back through the gacha tracker file by file and marking every part by who wrote it.' },
{ id: 'slurm', t: 'Slurm on a cloud that scales to zero', d: '2026-09-02', tag: 'devops', blurb: 'What it takes for a job queue to grow nodes on demand, and shrink again without killing anything.' },
{ id: 'vault', t: 'Moving secrets out of .env and into OpenBao', d: '2026-07-21', tag: 'homelab', blurb: 'The boring migration that made every other change less scary.' },
{ id: 'ansible', t: 'Ansible for three servers is not overkill', d: '2026-05-08', tag: 'devops', blurb: 'Why Config exists, and the week every run restarted every container.' },
{ id: 'thesis', t: 'Rereading my bachelor thesis four years later', d: '2026-03-30', tag: 'notes', blurb: 'What HyperCache got right about trust, and what I would simulate differently now.' }
];
var GH = 'https://github.com/StereotypicalCat';
var PROJECTS = [
{ id: 'compute', g: 'work', feat: 1, name: 'Compute job platform', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'arch',
one: 'Slurm jobs from a browser: a React/Node app in front of a cluster that scales itself on Oracle Cloud.',
stack: ['Slurm', 'React', 'Node.js', 'Oracle Cloud', 'OAuth / Entra ID', 'ERDA', 'Docker'],
problem: 'Researchers need to run heavy jobs without first learning Slurm, SSH and batch scripts. Use is bursty, so a fixed cluster either sits idle or runs out.',
approach: 'A full-stack React/Node web app in front of Slurm. People log in with their AU account (OAuth with Entra ID), pick their data from ERDA, AU\'s own storage, and submit. The web stack and the compute nodes both scale up and down on Oracle Cloud.',
interesting: 'Making "easy to use" and "scales to zero" live together: ' + PH('a queued job has to explain why it is waiting while a node boots, instead of just looking stuck.'),
parts: [['web app (React/Node)', 'unk'], ['Slurm + cloud scaling', 'unk'], ['Entra ID login, ERDA link', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['docs', 'ops', 'config'], posts: ['slurm', 'vault'] },
{ id: 'config', g: 'own', feat: 1, name: 'Config', yr: PH('2025'), ai: 'full', motif: 'infra',
one: 'Ansible and docker-compose: every service on my servers, deployed and updated from one repo.',
stack: ['Ansible', 'IaC', 'docker-compose', 'Docker', 'Linux'],
problem: 'Services were set up by hand over SSH, one machine at a time, and the only documentation was my memory.',
approach: 'An inventory of hosts, one role per kind of thing, and compose stacks templated from one pattern. One command brings a machine to the state git describes.',
interesting: PH('Idempotency: for a week every run restarted every container, because a template rendered a new timestamp each time.'),
parts: [['inventory & layout', 'none'], ['playbooks & roles', 'full'], ['compose files', 'full']],
links: [['GitHub', GH]], rel: ['ops', 'gacha'], posts: ['ansible'] },
{ id: 'hypercache', g: 'school', feat: 1, name: 'HyperCache', yr: PH('2022'), where: 'Bachelor thesis · ' + PH('Aarhus University'), ai: 'none', motif: 'net',
one: 'Reputation systems for peer-to-peer archival: who can you trust to keep your data?',
stack: ['Node.js', 'P2P', 'simulation', 'reputation systems'],
problem: 'In a peer-to-peer archive, data only survives if peers keep storing it. Some peers will freeload or quietly drop data.',
approach: 'A reputation system where peers rate each other on whether data is really kept, built and simulated in Node.js.',
interesting: PH('How fast trust recovers after a bad actor leaves, and how cheaply a newcomer can fake a good history.'),
parts: [['thesis & code', 'none']],
links: [['thesis (PDF)', '#']], rel: ['vrclass', 'compute'], posts: ['thesis'] },
{ id: 'rowing', g: 'school', feat: 1, name: 'Mixed-Reality Rowing Game', yr: PH('2020'), where: PH('course project'), ai: 'none', motif: 'boat',
one: 'Row a virtual boat with two real oars, wired up through an Arduino.',
stack: ['Unity', 'C#', 'Arduino'],
problem: 'Rowing machines are boring and VR controllers don\'t feel like oars.',
approach: 'Two real oars with sensors read by an Arduino, sending each stroke into a Unity scene over serial.',
interesting: PH('Turning noisy sensor readings into a stroke that feels right, with almost no lag.'),
parts: [['everything', 'none']],
links: [['video', '#']], rel: ['censor', 'vrclass', 'sah'], posts: [] },
{ id: 'docs', g: 'work', name: 'Document & information services', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'doc',
one: 'Overleaf and OpenProject, run by us for researchers and wired into the university\'s own systems.',
stack: ['Overleaf', 'OpenProject', 'Docker', 'Linux'],
problem: 'Research groups write papers together and plan projects, and want to do it on tools the university can run, secure and back up itself.',
approach: 'Self-hosted Overleaf and OpenProject, containerised, with integrations into the rest of AU\'s services. ' + PH('Which integrations exactly is still to be written up.'),
interesting: PH('Updating two large open-source products without breaking a group that is three days from a deadline.'),
parts: [['deployment', 'unk'], ['integrations', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['compute', 'ops'], posts: ['vault'] },
{ id: 'ops', g: 'work', name: 'Secure test & run setup', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'infra',
one: 'Trying newer versions of software while keeping everything isolated, patched and watched.',
stack: ['OpenBao / Vault', 'Grafana', 'Docker', 'Ansible', 'Linux'],
problem: 'A research IT team has to try new software and new versions early, without that becoming the weak spot in everything else.',
approach: 'Everything runs containerised and isolated, configured with Ansible, secrets in OpenBao / HashiCorp Vault, and watched in Grafana.',
interesting: PH('Deciding what deserves an alert. Most dashboards are for curiosity; only a few things should wake someone up.'),
parts: [['Ansible roles', 'unk'], ['dashboards & alerts', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['config', 'compute'], posts: ['vault', 'ansible'] },
{ id: 'sah', g: 'work', name: 'ScienceAtHome prototype', yr: '2019', where: 'ScienceAtHome · internship', ai: 'none', motif: 'game',
one: 'A month-long internship: an early prototype of a new project\'s base mechanics, to show the people it was for.',
stack: [PH('Unity'), 'prototyping', 'pair programming'],
problem: 'A new project needed its core idea tested before anyone built it properly, and something concrete to show the people it was made for.',
approach: 'Joined the ideation and planning, built a quick prototype of the base mechanics, then pair-programmed with experienced developers to start the real project.',
interesting: 'Seeing how a prototype changes the conversation: people react to something they can try very differently from a plan.',
parts: [['prototype', 'none']], links: [], rel: ['rowing', 'vrclass'], posts: [] },
{ id: 'gacha', g: 'own', name: 'Event Tracker for Gacha Games', yr: PH('2025'), ai: 'full', motif: 'web',
one: 'Scrapes event schedules from several games into one timeline, so limited events stop slipping by.',
stack: ['JavaScript', 'scraping', 'web app'],
problem: 'Each game announces its limited events somewhere different, in a different time zone. Keeping track meant checking five places.',
approach: 'Scrapers per game feed one normalised list of events, shown as a single timeline in a small web app.',
interesting: 'A test of building with AI: ' + PH('I wrote the spec and the fixtures, the agent wrote the scrapers, and every change was read before it went in.'),
parts: [['spec & fixtures', 'none'], ['scrapers', 'full'], ['timeline UI', 'full']],
links: [['live demo', '#'], ['GitHub', GH]], rel: ['config', 'expenses'], posts: ['agent'] },
{ id: 'chess', g: 'own', name: 'Chessswap', yr: PH('2020'), ai: 'none', motif: 'board',
one: 'Chess, except the board swaps every few turns.',
stack: ['React', 'JavaScript'],
problem: 'Chess openings are memorised; a twist that breaks memorisation makes it about thinking again.',
approach: 'A React board with normal chess rules, plus a swap every few turns that changes whose pieces you are playing.',
interesting: 'Rules that sound simple get strange at the edges: ' + PH('what happens to castling rights and check right at a swap?'),
parts: [['everything', 'none']], links: [['GitHub', GH]], rel: ['breakout', 'dashboard'], posts: [] },
{ id: 'expenses', g: 'own', name: 'Monthly Expenses Tracker', yr: PH('2021'), ai: 'none', motif: 'web',
one: 'A small web app for where the money goes each month, in DKK.',
stack: ['JavaScript', 'web app'],
problem: 'Bank apps show transactions, not the shape of a month.',
approach: 'A plain JavaScript web app: enter expenses, see each month by category, in kroner.',
interesting: PH('Storing amounts in øre to dodge floating-point surprises.'),
parts: [['everything', 'none']], links: [['GitHub', GH]], rel: ['gacha', 'dashboard'], posts: [] },
{ id: 'lolbot', g: 'own', name: 'LoL Winrate Discord Bot', yr: PH('2020'), ai: 'none', motif: 'bot',
one: 'Ask in Discord, get a League of Legends champion\'s current winrate.',
stack: ['Node.js', 'Discord API'],
problem: 'Mid-draft, nobody wants to alt-tab to a stats site.',
approach: 'A Node.js bot that answers a command with the champion\'s current winrate.',
interesting: PH('Rate limits and caching: a bot that is popular for ten minutes a night.'),
parts: [['everything', 'none']], links: [['GitHub', GH]], rel: ['gacha'], posts: [] },
{ id: 'portfolio', g: 'own', name: 'Portfolio (2021)', yr: '2021', ai: 'none', motif: 'web',
one: 'The old React + Material UI site, kept frozen as the by-hand archive.',
stack: ['React', 'Material UI'],
problem: 'A place to put projects, with a search.',
approach: 'Create React App and Material UI, projects read from one info.json.',
interesting: 'It is now the archive: everything on it was written by hand, before AI tools.',
parts: [['everything', 'none']], links: [['old site', '#']], rel: ['borlaug'], posts: [] },
{ id: 'borlaug', g: 'own', name: 'Tribute to Norman Borlaug', yr: PH('2016'), ai: 'none', motif: 'doc',
one: 'One of my first websites, a freeCodeCamp tribute page.',
stack: ['HTML', 'CSS'],
problem: 'Learning to make a web page at all.',
approach: 'One HTML page and a stylesheet, following the freeCodeCamp brief.',
interesting: 'Kept for honesty: this is where it started.',
parts: [['everything', 'none']], links: [['GitHub', GH]], rel: ['portfolio'], posts: [] },
{ id: 'dashboard', g: 'school', name: 'Student Dashboard', yr: PH('2021'), ai: 'none', motif: 'web',
one: 'A dashboard for students, shaped by user studies rather than guesses.',
stack: ['React', 'user studies'],
problem: 'Students juggle several university systems to answer simple questions like "what is due this week?".',
approach: 'User studies first, then a React dashboard, then studies again to see what actually helped.',
interesting: PH('The first interviews threw out half of the planned features.'),
parts: [['everything', 'none']], links: [['report (PDF)', '#'], ['demo', '#']], rel: ['vrclass', 'chess'], posts: [] },
{ id: 'vrclass', g: 'school', name: 'VR-Classroom', yr: PH('2020'), ai: 'none', motif: 'vr',
one: 'A Node.js prototype of a shared virtual classroom.',
stack: ['Node.js', 'VR', 'prototyping'],
problem: 'Remote teaching loses the feeling of being in the same room.',
approach: 'A Node.js prototype that keeps participants in a shared virtual room in sync.',
interesting: PH('Small things like where the teacher stands matter more than resolution.'),
parts: [['everything', 'none']], links: [['report (PDF)', '#'], ['video', '#']], rel: ['rowing', 'dashboard', 'censor'], posts: [] },
{ id: 'censor', g: 'school', name: 'Censor Simulator', yr: PH('2018'), where: 'High-school exam project', ai: 'none', motif: 'vr',
one: 'A VR exam project in Unity: you are the one deciding what gets through.',
stack: ['Unity', 'C#', 'VR'],
problem: 'An exam project that had to be both a game and an argument.',
approach: 'A Unity VR scene where you sort and censor incoming material.',
interesting: PH('First time building for VR: comfort and scale before anything else.'),
parts: [['everything', 'none']], links: [], rel: ['rowing', 'breakout'], posts: [] },
{ id: 'breakout', g: 'school', name: 'BreakoutPython', yr: PH('2017'), where: 'High-school exam project', ai: 'none', motif: 'board',
one: 'Breakout, written in Python for a high-school exam.',
stack: ['Python'],
problem: 'Learn to program a game loop from scratch.',
approach: 'Paddle, ball, bricks and collision, in Python.',
interesting: 'Collision is the whole game, and the first place bugs feel physical.',
parts: [['everything', 'none']], links: [['GitHub', GH]], rel: ['chess', 'censor'], posts: [] }
];
function P(id) { for (var i = 0; i < PROJECTS.length; i++) if (PROJECTS[i].id === id) return PROJECTS[i]; }
function POST(id) { for (var i = 0; i < POSTS.length; i++) if (POSTS[i].id === id) return POSTS[i]; }
function strip(s) { return String(s).replace(/<[^>]+>/g, ''); }
function aiMark(a) { return '<span class="ai ' + a + '" title="' + AI[a].long + '"><i></i>' + AI[a].label + '</span>'; }
function $(id) { return document.getElementById(id); }
function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;'); }
/* ---------- drawings: small UIs and diagrams standing in for screenshots ---------- */
function R(x, y, w, h, c, rx, extra) { return '<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" class="' + (c || 'k-4') + '"' + (rx ? ' rx="' + rx + '"' : '') + (extra || '') + '/>'; }
function T(x, y, s, c, fs, anchor, extra) { return '<text x="' + x + '" y="' + y + '" class="' + (c || 'tx') + '"' + (fs ? ' style="font-size:' + fs + 'px"' : '') + (anchor ? ' text-anchor="' + anchor + '"' : '') + (extra || '') + '>' + s + '</text>'; }
function L(x1, y1, x2, y2, c, extra) { return '<line x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2 + '" class="' + (c || 'ln') + '"' + (extra || '') + '/>'; }
function C(x, y, r, c, extra) { return '<circle cx="' + x + '" cy="' + y + '" r="' + r + '" class="' + (c || 'k-4') + '"' + (extra || '') + '/>'; }
function PL(pts, c, extra) { return '<polyline points="' + pts.join(' ') + '" class="' + c + '"' + (extra || '') + '/>'; }
function series(x, y, w, h, vals) { var n = vals.length, out = []; for (var i = 0; i < n; i++) out.push((x + i * w / (n - 1)).toFixed(1) + ',' + (y + h - vals[i] * h).toFixed(1)); return out; }
function lines(x, y, ws, gap, c) { var s = ''; for (var i = 0; i < ws.length; i++) s += R(x, y + i * gap, ws[i], 5, c || 'k-4', 2.5); return s; }
function svg(inner) { return '<svg viewBox="0 0 600 360" aria-hidden="true">' + inner + '</svg>'; }
var DRAW = {
compute: function () {
var s = R(0, 0, 132, 360, 'k-5') + T(16, 28, 'Research compute', 'tb', 12) + R(8, 46, 116, 26, 'k-w', 6, ' stroke="#e3e3de"');
['Jobs', 'New job', 'Data · ERDA', 'Nodes', 'Usage'].forEach(function (n, i) { s += T(20, 63 + i * 30, n, i ? 'tx' : 'tb', 11.5); });
s += C(24, 332, 9, 'k-4') + T(40, 329, 'signed in', 'tx', 9.5) + T(40, 342, 'AU · Entra ID', 'tb', 9.5);
s += T(152, 32, 'Jobs', 'tb', 17) + T(152, 50, '12 running · 4 queued', 'tx', 11) + R(498, 16, 86, 28, 'k-ink', 7) + T(541, 34, '+ New job', 'tw', 11.5, 'middle');
s += T(152, 78, 'NAME', 't3') + T(330, 78, 'STATUS', 't3') + T(424, 78, 'NODES', 't3') + T(500, 78, 'TIME', 't3') + L(152, 86, 584, 86, 'ln2');
[['protein-fold-03', 'running', '4', '01:12:40'], ['nlp-tokenise', 'running', '2', '00:41:05'], ['climate-ens-17', 'queued', '–', 'node booting'], ['mri-preproc', 'done', '1', '00:08:22'], ['thesis-sim-b', 'done', '8', '03:55:10']].forEach(function (r, i) {
var y = 108 + i * 28;
s += T(152, y, r[0], 'tm', 11, null, ' fill="#141518"');
s += r[1] === 'running' ? R(330, y - 12, 58, 17, 'k-at', 8.5) + T(359, y, 'running', 'ta', 10, 'middle') : r[1] === 'queued' ? R(330, y - 12, 58, 17, 'k-w', 8.5, ' stroke="#c9cac4"') + T(359, y, 'queued', 'tx', 10, 'middle') : R(330, y - 12, 58, 17, 'k-5', 8.5) + T(359, y, 'done', 'tx', 10, 'middle');
s += T(424, y, r[2], 'tx tm', 11) + T(500, y, r[3], 'tx tm', 10.5) + L(152, y + 9, 584, y + 9);
});
s += R(152, 254, 432, 92, 'k-w', 8, ' stroke="#dcddd8"') + T(166, 274, 'Cloud nodes, last 24 h', 'tb', 11) + T(570, 274, 'scaling 3 → 6', 'ta tm', 10.5, 'end');
s += PL(['166,332', '220,332', '220,320', '300,320', '300,304', '360,304', '360,326', '440,326', '440,312', '510,312'], 'sk') + PL(['510,312', '510,292', '570,292'], 'ska', ' stroke-dasharray="4 3"');
return svg(s);
},
docs: function () {
var s = R(0, 0, 600, 36, 'k-5') + T(14, 23, 'paper-draft.tex', 'tb tm', 11) + C(404, 18, 8, 'k-3') + C(416, 18, 8, 'k-at', ' stroke="#fff" stroke-width="2"') + T(430, 22, '2 editing', 'tx', 10) + R(498, 8, 88, 21, 'k-acc', 6) + T(542, 22, 'Recompile', 'tw', 10.5, 'middle');
var code = ['\\documentclass{article}', '\\usepackage{graphicx}', '', '\\title{Trust in P2P archives}', '\\begin{document}', '\\maketitle', '', '\\section{Method}', 'We simulate 400 peers that', 'store and audit each other.', '', '\\begin{figure}', ' \\includegraphics{trust}', '\\end{figure}', '\\end{document}'];
code.forEach(function (c, i) { var y = 60 + i * 19; s += T(16, y, i + 1, 'tx tm', 9.5, null, ' fill="#b8babd"') + (c ? T(40, y, esc(c), c.charAt(0) === '\\' || c.charAt(2) === '\\' ? 'ta tm' : 'tx tm', 10.5) : ''); });
s += R(286, 145, 2, 14, 'k-ink');
s += R(300, 36, 300, 324, 'k-5') + L(300, 36, 300, 360, 'ln2') + R(338, 54, 224, 306, 'k-w', 0, ' stroke="#dcddd8"');
s += T(450, 86, 'Trust in P2P archives', 'tb', 12, 'middle') + R(410, 94, 80, 4, 'k-4', 2) + T(352, 124, '1 Method', 'tb', 10) + lines(352, 134, [196, 190, 120], 11) + R(366, 176, 168, 82, 'k-5', 3) + PL(series(378, 188, 144, 58, [.1, .35, .3, .55, .62, .8, .78, .9]), 'ska') + T(450, 272, 'Figure 1: trust over time', 'tx', 9, 'middle') + lines(352, 288, [196, 184, 196, 90], 11);
return svg(s);
},
ops: function () {
var s = R(0, 0, 600, 360, 'k-5');
[['Uptime', '99.97%'], ['Containers', '42'], ['Vault', 'unsealed'], ['Alerts', '1 firing']].forEach(function (c, i) {
var x = 14 + i * 145; s += R(x, 14, 133, 58, 'k-w', 7) + T(x + 12, 34, c[0], 'tx', 10.5) + T(x + 12, 59, c[1], i === 3 ? 'ta' : 'tb', 18, null, i === 3 ? ' font-weight="600"' : '');
});
s += R(14, 84, 280, 136, 'k-w', 7) + T(26, 104, 'CPU · compute nodes', 'tb', 11) + L(26, 200, 282, 200, 'ln2');
s += PL(series(26, 116, 256, 80, [.3, .32, .45, .4, .62, .58, .7, .52, .48, .66, .8, .74, .6, .55]), 'sk') + PL(series(26, 116, 256, 80, [.1, .15, .12, .2, .18, .3, .25, .22, .28, .35, .3, .26, .2, .22]), 'ska');
s += R(306, 84, 280, 136, 'k-w', 7) + T(318, 104, 'Requests / min', 'tb', 11);
[.4, .5, .45, .62, .7, .55, .5, .66, .8, .72, .6, .58, .74, .9, .68, .6].forEach(function (v, i) { s += R(318 + i * 16, 200 - v * 80, 11, v * 80, i === 13 ? 'k-acc' : 'k-4', 2); });
s += R(14, 232, 280, 114, 'k-w', 7) + T(26, 252, 'Image updates', 'tb', 11);
[['overleaf', '5.2 → 5.3', 'staged'], ['openproject', '15.1', 'ok'], ['openbao', '2.1', 'ok'], ['grafana', '11.3', 'ok']].forEach(function (r, i) { var y = 276 + i * 20; s += T(26, y, r[0], 'tm', 10.5, null, ' fill="#141518"') + T(150, y, r[1], 'tx tm', 10.5) + T(282, y, r[2], r[2] === 'ok' ? 'tx' : 'ta', 10.5, 'end'); });
s += R(306, 232, 280, 114, 'k-w', 7) + T(318, 252, 'Disk', 'tb', 11);
[['/var/lib/docker', .62], ['/srv/erda-cache', .81], ['/backup', .38]].forEach(function (r, i) { var y = 270 + i * 25; s += T(318, y, r[0], 'tx tm', 10) + R(318, y + 6, 254, 6, 'k-5', 3) + R(318, y + 6, 254 * r[1], 6, r[1] > .8 ? 'k-acc' : 'k-ink', 3); });
return svg(s);
},
sah: function () {
var s = T(22, 32, 'Level 1', 'tb', 14) + T(578, 32, 'moves 12', 'tx tm', 11, 'end') + L(22, 46, 578, 46);
var pts = [], path = [[0, 0], [1, 0], [1, 1], [2, 1], [3, 1], [3, 2], [4, 2], [5, 2], [5, 3]];
for (var r = 0; r < 4; r++) for (var c = 0; c < 7; c++) pts.push([120 + c * 60, 92 + r * 66]);
s += PL(path.map(function (p) { return (120 + p[0] * 60) + ',' + (92 + p[1] * 66); }), 'sk', ' stroke-width="4" stroke-linecap="round" stroke-linejoin="round"');
pts.forEach(function (p, i) { s += C(p[0], p[1], 9, 'k-w', ' stroke="#c9cac4" stroke-width="1.5"'); });
path.forEach(function (p) { s += C(120 + p[0] * 60, 92 + p[1] * 66, 9, 'k-ink'); });
s += C(420, 290, 13, 'k-acc') + C(420, 290, 20, 'nf', ' stroke="#e0592b" stroke-dasharray="3 3"');
s += T(300, 344, 'base mechanics only, no art yet', 'tx', 10.5, 'middle');
return svg(s);
},
gacha: function () {
var s = T(18, 30, 'Upcoming events', 'tb', 15) + R(470, 14, 112, 24, 'k-5', 6) + R(473, 17, 54, 18, 'k-w', 4) + T(500, 30, 'Week', 'tb', 10.5, 'middle') + T(554, 30, 'Month', 'tx', 10.5, 'middle');
var days = ['Mon 9', 'Tue 10', 'Wed 11', 'Thu 12', 'Fri 13', 'Sat 14', 'Sun 15'];
days.forEach(function (d, i) { var x = 110 + i * 68; s += T(x + 6, 64, d, 'tx', 10) + L(x, 52, x, 346); });
var games = ['Game A', 'Game B', 'Game C', 'Game D', 'Game E'];
var bars = [[[0, 2.6, 'Limited banner', 'acc'], [3.2, 7, 'Login event']], [[0, 4.5, 'Story event'], [5, 7, 'Rerun']], [[1, 3, 'Double drops'], [3.4, 6.2, 'Limited banner']], [[0, 1.4, 'Shop reset'], [2.2, 7, 'Anniversary']], [[0.5, 5.5, 'Limited banner']]];
games.forEach(function (g, i) {
var y = 82 + i * 52; s += T(18, y + 22, g, 'tb', 11) + L(18, y + 44, 586, y + 44);
bars[i].forEach(function (b) { var x = 110 + b[0] * 68 + 2, w = (b[1] - b[0]) * 68 - 4; s += R(x, y + 8, w, 24, b[3] ? 'k-at' : 'k-5', 5, b[3] ? ' stroke="#e0592b"' : ' stroke="#dcddd8"') + T(x + 8, y + 24, b[2], b[3] ? 'ta' : 'tx', 10.5); });
});
var tx = 110 + 2.35 * 68; s += L(tx, 48, tx, 346, 'ska') + R(tx - 34, 330, 68, 18, 'k-acc', 9) + T(tx, 343, 'ends in 14h', 'tw', 9.5, 'middle');
return svg(s);
},
config: function () {
var s = R(0, 0, 176, 360, 'k-5') + T(16, 26, 'config', 'tb tm', 11);
var tree = [['inventory/', 1], ['hosts.yml', 2], ['roles/', 1], ['docker/', 2], ['caddy/', 2], ['backup/', 2], ['stacks/', 1], ['grafana.yml.j2', 2], ['overleaf.yml.j2', 2], ['site.yml', 1]];
tree.forEach(function (t, i) { var y = 52 + i * 22; if (t[0] === 'site.yml') s += R(8, y - 14, 160, 20, 'k-w', 4); s += T(14 + t[1] * 12, y, t[0], t[0].slice(-1) === '/' ? 'tb tm' : 'tx tm', 10.5); });
var out = [['$ ansible-playbook site.yml -l homelab', 'tb'], ['', ''], ['PLAY [homelab]', 'tb'], ['TASK [docker : install engine]', 'tx'], ['ok: [nas] ok: [pi]', 'k'], ['TASK [stacks : render compose]', 'tx'], ['ok: [nas]', 'k'], ['TASK [stacks : compose up]', 'tx'], ['ok: [nas]', 'k'], ['', ''], ['PLAY RECAP', 'tb']];
out.forEach(function (o, i) { if (o[0]) s += T(196, 34 + i * 21, o[0], (o[1] === 'k' ? 'tx' : o[1]) + ' tm', 10.5); });
s += T(196, 286, 'nas ok=24', 'tm', 10.5, null, ' fill="#141518"') + T(300, 286, 'changed=0', 'ta tm', 10.5) + T(380, 286, 'failed=0', 'tx tm', 10.5);
s += T(196, 307, 'pi ok=17', 'tm', 10.5, null, ' fill="#141518"') + T(300, 307, 'changed=0', 'ta tm', 10.5) + T(380, 307, 'failed=0', 'tx tm', 10.5);
s += R(196, 322, 8, 14, 'k-ink');
return svg(s);
},
chess: function () {
var pos = ['rnbqk..r', 'ppp..ppp', '..n..n..', '...pp...', '..B.P...', '.....N..', 'PPPP.PPP', 'RNBQK..R'], G = { k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟' }, s = '', sq = 37;
for (var r = 0; r < 8; r++) for (var c = 0; c < 8; c++) {
var x = 30 + c * sq, y = 30 + r * sq, last = (r === 5 && c === 5) || (r === 7 && c === 6);
s += R(x, y, sq, sq, last ? 'k-at' : ((r + c) % 2 ? 'k-4' : 'k-5'));
var ch = pos[r].charAt(c);
if (ch !== '.') { var white = ch === ch.toUpperCase(); s += T(x + sq / 2, y + 28, G[ch.toLowerCase()], 'pc', 27, 'middle', white ? ' fill="#fff" stroke="#141518" stroke-width=".9"' : ' fill="#141518"'); }
}
s += R(30, 30, 296, 296, 'nf', 0, ' stroke="#141518"');
s += T(360, 52, 'Chessswap', 'tb', 17) + T(360, 72, 'turn 7 · white to move', 'tx', 11);
s += T(360, 112, 'BOARD SWAPS IN', 't3');
for (var i = 0; i < 4; i++) s += R(360 + i * 26, 122, 20, 8, i < 2 ? 'k-acc' : 'k-4', 2);
s += T(470, 130, '2 turns', 'ta', 11);
s += '<path d="M372 168 h40 l-8 -7 M412 168 l-8 7" class="sk"/><path d="M412 186 h-40 l8 -7 M372 186 l8 7" class="sk"/>' + T(424, 182, 'you play the other side', 'tx', 11);
['1. e4 e5', '2. Nf3 Nc6', '3. Bc4 Nf6', '4. ⇄ swap', '5. d5 Bxd5', '6. Nf6 …'].forEach(function (m, i) { s += T(360, 230 + i * 19, m, m.indexOf('swap') > 0 ? 'ta tm' : 'tx tm', 11); });
return svg(s);
},
expenses: function () {
var s = T(20, 34, 'October 2026', 'tb', 17) + T(20, 52, 'by category', 'tx', 11) + T(580, 30, 'total', 'tx', 10.5, 'end') + T(580, 52, '12.480 kr.', 'tb tm', 18, 'end');
var cats = [['Rent', 6200], ['Food', 2940, 2600], ['Going out', 1320], ['Transport', 890], ['Other', 720], ['Subscriptions', 410]];
cats.forEach(function (c, i) {
var y = 86 + i * 34, w = c[1] / 6200 * 300;
s += T(20, y + 12, c[0], 'tb', 11.5, null, ' font-weight="500"') + R(140, y, 300, 16, 'k-5', 3) + R(140, y, w, 16, c[2] ? 'k-acc' : 'k-ink', 3) + T(580, y + 12, c[1].toLocaleString('da-DK') + ' kr.', 'tx tm', 11, 'end');
if (c[2]) { var bx = 140 + c[2] / 6200 * 300; s += L(bx, y - 4, bx, y + 20, 'sk') + T(bx + 6, y - 6, 'budget', 'ta', 9); }
});
s += L(20, 296, 580, 296);
'JFMAMJJASOND'.split('').forEach(function (m, i) { var v = [.6, .55, .62, .58, .7, .66, .8, .64, .6, .74, 0, 0][i], x = 20 + i * 47; s += R(x, 340 - v * 30, 30, v * 30, i === 9 ? 'k-acc' : (v ? 'k-4' : 'k-5'), 2) + T(x + 15, 354, m, 'tx', 9, 'middle'); });
return svg(s);
},
lolbot: function () {
var s = R(0, 0, 136, 360, 'k-5') + T(16, 28, 'friends', 'tb', 12);
['# general', '# draft', '# clips', '# memes'].forEach(function (c, i) { var y = 60 + i * 26; if (i === 1) s += R(8, y - 15, 120, 22, 'k-w', 5); s += T(18, y, c, i === 1 ? 'tb' : 'tx', 11.5); });
s += T(154, 28, '# draft', 'tb', 12) + L(136, 42, 600, 42);
s += C(170, 72, 13, 'k-4') + T(192, 68, 'mads', 'tb', 11.5) + T(226, 68, 'today 21:14', 'tx', 9.5) + T(192, 84, '!wr jinx', 'tm', 11.5, null, ' fill="#141518"');
s += C(170, 124, 13, 'k-ink') + T(170, 128, 'WR', 'tw', 8.5, 'middle') + T(192, 120, 'WinrateBot', 'tb', 11.5) + R(256, 110, 26, 13, 'k-acc', 3) + T(269, 120, 'BOT', 'tw', 8, 'middle');
s += R(192, 132, 330, 128, 'k-5', 6) + R(192, 132, 4, 128, 'k-acc', 2) + T(210, 156, 'Jinx · bottom lane', 'tb', 12.5);
[['Win rate', '51.8%'], ['Pick rate', '14.2%'], ['Games', '24.1k'], ['Patch', '14.21']].forEach(function (r, i) { var x = 210 + (i % 2) * 150, y = 184 + Math.floor(i / 2) * 40; s += T(x, y, r[0], 'tx', 10) + T(x, y + 18, r[1], 'tb tm', 14); });
s += C(170, 290, 13, 'k-4') + T(192, 286, 'sofie', 'tb', 11.5) + T(192, 302, 'ok jinx it is', 'tx', 11.5, null, ' fill="#3b3e43"');
s += R(150, 322, 434, 28, 'k-5', 7) + T(164, 340, 'Message #draft', 'tx', 11);
return svg(s);
},
portfolio: function () {
var s = '<rect width="600" height="360" fill="#1f2125"/><rect width="180" height="360" fill="#2a2d31"/>' + '<circle cx="90" cy="80" r="34" fill="#4a4e54"/>' + T(90, 140, 'Lucas Winther', 'tw', 13, 'middle') + T(90, 160, 'Computer Scientist with', '', 9.5, 'middle', ' fill="#9a9ea4"') + T(90, 173, 'passion for development.', '', 9.5, 'middle', ' fill="#9a9ea4"');
for (var i = 0; i < 4; i++) s += '<circle cx="' + (54 + i * 24) + '" cy="204" r="8" fill="#3c4046"/>';
s += '<rect x="200" y="20" width="380" height="30" rx="4" fill="#2f3237"/>' + T(214, 39, 'Search projects…', '', 10.5, null, ' fill="#7d8187"');
for (var j = 0; j < 6; j++) { var x = 200 + (j % 3) * 128, y = 66 + Math.floor(j / 3) * 144; s += '<rect x="' + x + '" y="' + y + '" width="120" height="134" rx="4" fill="#2f3237"/><rect x="' + x + '" y="' + y + '" width="120" height="62" rx="4" fill="#3a3e44"/><rect x="' + (x + 10) + '" y="' + (y + 76) + '" width="' + (70 + (j * 13) % 30) + '" height="7" rx="3" fill="#c8cbd0"/><rect x="' + (x + 10) + '" y="' + (y + 92) + '" width="96" height="5" rx="2.5" fill="#55595f"/><rect x="' + (x + 10) + '" y="' + (y + 102) + '" width="70" height="5" rx="2.5" fill="#55595f"/><rect x="' + (x + 10) + '" y="' + (y + 116) + '" width="34" height="10" rx="5" fill="#90caf9" opacity=".55"/>'; }
return svg(s);
},
borlaug: function () {
var s = T(300, 50, 'Dr. Norman Borlaug', 'tb', 20, 'middle') + T(300, 70, 'The man who saved a billion lives', 'tx', 11.5, 'middle') + R(200, 86, 200, 112, 'k-5', 2);
for (var i = 0; i < 9; i++) { var x = 222 + i * 20; s += L(x, 190, x + 4, 120 + (i % 3) * 8, 'sk', ' stroke="#a6a8ac"') + '<ellipse cx="' + (x + 4) + '" cy="' + (114 + (i % 3) * 8) + '" rx="3" ry="9" class="k-3"/>'; }
s += T(300, 214, 'photo: wheat field (alt text)', 'tx', 9.5, 'middle');
[['1914', 'Born in Cresco, Iowa'], ['1944', 'Starts wheat research in Mexico'], ['1970', 'Nobel Peace Prize'], ['2009', 'Dies, aged 95']].forEach(function (r, i) { var y = 248 + i * 24; s += C(200, y - 4, 3, 'k-ink') + T(214, y, r[0], 'tb tm', 11) + T(254, y, r[1], 'tx', 11.5); });
s += L(200, 244, 200, 316, 'ln2');
return svg(s);
},
hypercache: function () {
var N = [[90, 90], [200, 60], [320, 96], [440, 64], [530, 130], [120, 210], [250, 180], [370, 200], [500, 250], [80, 310], [210, 290], [340, 310], [450, 330]], E = [[0, 1, 3], [1, 2, 2], [2, 3, 3], [3, 4, 2], [0, 5, 1], [1, 6, 3], [2, 6, 2], [2, 7, 3], [3, 7, 1], [4, 8, 2], [5, 6, 2], [6, 7, 3], [7, 8, 2], [5, 9, 2], [5, 10, 3], [6, 10, 2], [10, 11, 3], [11, 12, 2], [8, 12, 3], [9, 10, 1]], bad = 7, s = '';
E.forEach(function (e) { var a = N[e[0]], b = N[e[1]], isBad = e[0] === bad || e[1] === bad; s += L(a[0], a[1], b[0], b[1], isBad ? 'ska' : 'sk', ' stroke-width="' + (isBad ? 1.2 : e[2] * .9) + '"' + (isBad ? ' stroke-dasharray="4 4"' : ' stroke-opacity=".55"')); });
N.forEach(function (n, i) { s += i === bad ? C(n[0], n[1], 14, 'k-w', ' stroke="#e0592b" stroke-width="2"') + C(n[0], n[1], 5, 'k-acc') : C(n[0], n[1], 11, 'k-w', ' stroke="#141518" stroke-width="1.5"') + R(n[0] - 4, n[1] - 4, 8, 8, i % 3 ? 'k-ink' : 'k-3', 1); });
s += T(386, 190, 'drops data', 'ta', 10.5) + T(386, 178, 'peer 7', 'tb', 10.5);
s += T(20, 28, 'fig. peers rating each other', 'tb', 11) + T(580, 28, 'line weight = trust · dashed = failed audit', 'tx', 10, 'end');
return svg(s);
},
dashboard: function () {
var s = T(20, 34, 'Good morning, Lucas', 'tb', 16) + T(580, 34, 'week 44', 'tx tm', 11, 'end');
s += R(20, 52, 260, 290, 'k-5', 8) + T(36, 76, 'Due this week', 'tb', 11.5) + T(264, 76, '3 left', 'ta', 10.5, 'end');
[['Report draft', 'HCI · Wed', 1], ['Quiz 4', 'Algorithms · Thu', 0], ['Lab hand-in', 'Databases · Fri', 0], ['Group meeting', 'Project · Fri', 0], ['Read ch. 7', 'HCI · Sun', 0]].forEach(function (r, i) {
var y = 98 + i * 46; s += R(32, y, 236, 38, 'k-w', 6) + R(44, y + 12, 13, 13, r[2] ? 'k-ink' : 'k-w', 3, r[2] ? '' : ' stroke="#a6a8ac"') + (r[2] ? '<path d="M47 ' + (y + 18) + ' l3 3 l5 -6" stroke="#fff" stroke-width="1.6" fill="none"/>' : '') + T(68, y + 17, r[0], 'tb', 11, null, r[2] ? ' text-decoration="line-through" fill="#a6a8ac"' : '') + T(68, y + 30, r[1], 'tx', 9.5);
});
s += T(300, 76, 'Schedule', 'tb', 11.5);
['Mon', 'Tue', 'Wed', 'Thu', 'Fri'].forEach(function (d, i) { var x = 300 + i * 56; s += T(x + 24, 98, d, 'tx', 10, 'middle') + R(x, 106, 48, 236, 'k-5', 5); });
[[0, 120, 50], [0, 200, 40], [1, 140, 70], [2, 116, 36], [2, 230, 60, 1], [3, 160, 46], [4, 124, 30], [4, 180, 80]].forEach(function (b) { s += R(304 + b[0] * 56, b[1], 40, b[2], b[3] ? 'k-acc' : 'k-4', 4); });
return svg(s);
},
rowing: function () {
var s = T(20, 28, 'fig. two real oars into a Unity scene', 'tb', 11);
s += '<path d="M30 70 L150 120 M30 170 L150 130" class="sk" stroke-width="3" stroke-linecap="round"/><path d="M20 62 l18 4 -6 14 -16 -6z M20 178 l18 -4 -6 -14 -16 6z" class="k-ink"/>';
s += R(140, 104, 60, 42, 'k-w', 6, ' stroke="#141518" stroke-width="1.5"') + T(170, 129, 'sensors', 'tx', 10, 'middle') + T(170, 164, 'rig + 2 encoders', 'tx', 9.5, 'middle');
s += L(200, 125, 250, 125, 'sk') + '<path d="M250 125 l-6 -4 v8z" class="k-ink"/>';
s += R(252, 100, 92, 50, 'k-ink', 6) + T(298, 122, 'Arduino', 'tw', 11.5, 'middle') + T(298, 138, 'reads strokes', '', 9.5, 'middle', ' fill="#a6a8ac"');
s += L(344, 125, 394, 125, 'sk', ' stroke-dasharray="4 3"') + '<path d="M394 125 l-6 -4 v8z" class="k-ink"/>' + T(369, 114, 'serial', 'ta', 9.5, 'middle');
s += R(396, 70, 184, 110, 'k-5', 6, ' stroke="#c9cac4"') + T(408, 88, 'Unity scene', 'tb', 10.5);
for (var i = 0; i < 4; i++) s += '<path d="M' + (410 + i * 8) + ' ' + (120 + i * 14) + ' q20 -6 40 0 t40 0 t40 0 t40 0" class="ln2"/>';
s += '<path d="M450 128 h70 l-10 12 h-50z" class="k-ink"/><path d="M470 128 l-20 -16 M500 128 l20 -16" class="sk"/>';
s += T(20, 222, 'stroke force over time', 'tb', 10.5) + L(20, 330, 580, 330, 'ln2');
var pts = []; for (var x = 0; x <= 560; x += 4) { var ph = (x % 140) / 140, v = ph < .45 ? Math.sin(ph / .45 * Math.PI) : 0; pts.push((20 + x) + ',' + (330 - v * 82).toFixed(1)); }
s += PL(pts, 'sk');
for (var k = 0; k < 4; k++) s += C(20 + k * 140, 330, 4, 'k-acc');
s += T(20, 348, 'catch', 'ta', 9.5) + T(160, 348, 'catch', 'ta', 9.5);
return svg(s);
},
vrclass: function () {
var s = R(30, 40, 350, 290, 'k-w', 4, ' stroke="#141518" stroke-width="1.5"') + R(120, 48, 170, 6, 'k-ink', 2) + T(205, 72, 'whiteboard', 'tx', 9.5, 'middle');
s += C(205, 98, 12, 'k-acc') + T(225, 102, 'teacher', 'ta', 10);
var sync = [[0, 0], [1, 1], [2, 2], [0, 2]];
for (var r = 0; r < 3; r++) for (var c = 0; c < 3; c++) {
var x = 70 + c * 110, y = 150 + r * 62; s += R(x, y, 70, 24, 'k-5', 3, ' stroke="#dcddd8"') + C(x + 20, y + 36, 8, 'k-ink') + C(x + 50, y + 36, 8, r === 1 && c === 2 ? 'k-w' : 'k-ink', r === 1 && c === 2 ? ' stroke="#a6a8ac" stroke-dasharray="2 2"' : '');
}
s += R(440, 150, 130, 58, 'k-ink', 7) + T(505, 175, 'Node.js server', 'tw', 11, 'middle') + T(505, 192, 'room state', '', 9.5, 'middle', ' fill="#a6a8ac"');
sync.forEach(function (p) { var x = 70 + p[1] * 110 + 50, y = 150 + p[0] * 62 + 36; s += L(x, y, 440, 179, 'ska', ' stroke-dasharray="3 4" stroke-width="1"'); });
s += L(217, 98, 440, 170, 'ska', ' stroke-dasharray="3 4" stroke-width="1"');
s += T(440, 232, 'positions, gaze and', 'tx', 10.5) + T(440, 247, 'who is talking, synced', 'tx', 10.5) + T(440, 262, 'to every headset', 'tx', 10.5);
s += T(30, 26, 'fig. one shared room, seen from above', 'tb', 11);
return svg(s);
},
censor: function () {
var s = R(0, 0, 600, 360, 'k-5') + '<path d="M0 230 L600 230" class="ln2"/><path d="M60 360 L210 230 M540 360 L390 230 M300 360 L300 230" class="ln"/>';
s += '<path d="M90 250 L510 250 L570 340 L30 340 Z" class="k-w" stroke="#141518" stroke-width="1.5"/>';
s += R(140, 90, 320, 120, 'k-w', 6, ' stroke="#c9cac4"') + T(160, 114, 'INCOMING · item 7 of 12', 't3') + lines(160, 128, [260, 240, 270, 180], 14) + R(160, 128 + 14, 120, 5, 'k-ink', 2.5) + T(160, 196, 'decide: approve or censor', 'tx', 10.5);
s += '<g transform="rotate(-8 190 290)">' + R(130, 266, 140, 52, 'k-w', 2, ' stroke="#141518"') + lines(142, 278, [110, 90, 100], 10) + '</g>';
s += '<g transform="rotate(6 400 292)">' + R(330, 266, 140, 52, 'k-w', 2, ' stroke="#141518"') + lines(342, 278, [110, 90, 100], 10) + R(352, 274, 96, 28, 'nf', 3, ' stroke="#e0592b" stroke-width="2"') + T(400, 293, 'CENSORED', 'ta', 12, 'middle', ' font-weight="600" letter-spacing="1"') + '</g>';
s += T(20, 30, 'Day 3', 'tb', 13) + T(580, 30, '5 left in tray', 'tx', 11, 'end');
return svg(s);
},
breakout: function () {
var s = R(0, 0, 600, 360, 'k-5') + T(20, 28, 'SCORE 0420', 'tb tm', 12) + T(580, 28, 'LIVES ● ● ○', 'tx tm', 11, 'end');
var cls = ['k-ink', 'k-2', 'k-acc', 'k-3', 'k-4'], gone = { '1-3': 1, '2-4': 1, '2-5': 1, '3-5': 1, '4-2': 1, '4-5': 1, '4-6': 1, '3-4': 1 };
for (var r = 0; r < 5; r++) for (var c = 0; c < 9; c++) if (!gone[r + '-' + c]) s += R(30 + c * 61, 50 + r * 22, 56, 16, cls[r], 2);
s += PL(['300,330', '360,250', '410,180', '360,120'], 'sk', ' stroke-dasharray="2 5" stroke-opacity=".6"') + C(392, 206, 6, 'k-ink') + R(250, 330, 100, 10, 'k-ink', 5);
return svg(s);
}
};
var FRAME = {
compute: ['url', 'compute.cloud.au.dk/jobs'], docs: ['url', 'overleaf.au.dk/project/…'], ops: ['url', 'grafana · research-ops'], sah: ['fig', 'prototype · name unknown'],
gacha: ['url', 'events.local/timeline'], config: ['url', '~/config — terminal'], chess: ['url', 'localhost:3000'], expenses: ['url', 'expenses · oktober'],
lolbot: ['url', 'discord · friends'], portfolio: ['url', 'lucaswinther.info, 2021'], borlaug: ['url', 'codepen · tribute page'],
hypercache: ['fig', 'diagram'], dashboard: ['url', 'dashboard.local/week'], rowing: ['fig', 'diagram'], vrclass: ['fig', 'diagram'], censor: ['fig', 'Unity · VR'], breakout: ['fig', 'python breakout.py']
};
function frameHTML(id) {
var f = FRAME[id], bar = f[0] === 'url' ? '<div class="fbar"><span class="dotz"><i></i><i></i><i></i></span><span class="url">' + f[1] + '</span></div>' : '<div class="fbar fig"><span class="url">' + f[1] + '</span></div>';
return bar + '<div class="fbody">' + DRAW[id]() + '</div>';
}
var REDUCE = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function vt(fn) { if (document.startViewTransition && !REDUCE) document.startViewTransition(fn); else fn(); }
/* ---------- segmented controls: sliding thumb ---------- */
function moveThumb(group) {
var t = group.querySelector('.thumb'), on = group.querySelector('button[aria-pressed="true"]');
if (!t || !on) return;
t.style.width = on.offsetWidth + 'px'; t.style.transform = 'translateX(' + on.offsetLeft + 'px)';
}
/* ---------- filter state ---------- */
var PREF = 'all', SHOWANY = {}, GROUP = 'all', Q = '';
function allowed(p) { if (SHOWANY[p.id]) return true; if (PREF === 'nofull') return p.ai !== 'full'; if (PREF === 'hand') return p.ai === 'none'; return true; }
var OTHERS = PROJECTS.filter(function (p) { return !p.feat; });
var FEAT = PROJECTS.filter(function (p) { return p.feat; });
function applyPref() {
document.querySelectorAll('.seg button').forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.pref === PREF)); });
moveThumb($('seg'));
document.querySelectorAll('.feat').forEach(function (el) {
var p = P(el.dataset.id);
el.classList.toggle('filtered', !allowed(p));
var f = el.querySelector('.filt');
if (f) f.firstChild.textContent = p.name + ' is hidden by your filter (' + (p.ai === 'full' ? 'fully AI-made' : p.ai === 'unk' ? 'AI level not recorded yet' : 'some AI') + '). ';
});
renderList();
}
function setPref(v) { PREF = v; SHOWANY = {}; vt(applyPref); }
document.querySelectorAll('.seg button').forEach(function (b) { b.addEventListener('click', function () { setPref(b.dataset.pref); }); });
document.querySelectorAll('[data-showanyway]').forEach(function (b) {
b.addEventListener('click', function () { SHOWANY[b.closest('.feat').dataset.id] = true; applyPref(); });
});
/* ---------- index list ---------- */
function renderTabs() {
var opts = [['all', 'All', OTHERS.length]].concat(Object.keys(GROUPS).map(function (g) { return [g, GROUPS[g], OTHERS.filter(function (p) { return p.g === g; }).length]; }));
$('tabs').innerHTML = '<span class="thumb" aria-hidden="true"></span>' + opts.map(function (o) { return '<button data-g="' + o[0] + '" aria-pressed="' + (GROUP === o[0]) + '">' + o[1] + '<small>' + o[2] + '</small></button>'; }).join('');
$('tabs').querySelectorAll('button').forEach(function (b) {
b.addEventListener('click', function () {
GROUP = b.dataset.g;
$('tabs').querySelectorAll('button').forEach(function (x) { x.setAttribute('aria-pressed', String(x === b)); });
moveThumb($('tabs')); renderList();
});
});
moveThumb($('tabs'));
}
var LISTIDS = [];
function renderList() {
var base = OTHERS.filter(function (p) {
if (GROUP !== 'all' && p.g !== GROUP) return false;
if (Q && strip(p.name + ' ' + p.one + ' ' + p.stack.join(' ')).toLowerCase().indexOf(Q) < 0) return false;
return true;
});
var shown = base.filter(allowed);
var hidden = base.length - shown.length;
LISTIDS = shown.map(function (p) { return p.id; });
$('list').innerHTML = shown.length ? shown.map(function (p) {
return '<button class="row" data-id="' + p.id + '"><span class="th">' + DRAW[p.id]() + '</span>' +
'<span class="nm">' + p.name + '<small>' + GROUPS[p.g] + (p.where ? ' · ' + strip(p.where).split(' · ')[0] : '') + '</small></span>' +
'<span class="one">' + p.one + '</span><span class="stk">' + p.stack.map(strip).join(' · ') + '</span>' +
'<span class="yr">' + p.yr + '</span>' + aiMark(p.ai) + '<span class="go">→</span></button>';
}).join('') : '<div class="empty">Nothing matches' + (Q ? ' "' + esc(Q) + '"' : '') + '.</div>';
$('cnt').textContent = shown.length + ' of ' + OTHERS.length;
$('hiddenby').innerHTML = hidden ? hidden + ' hidden by your AI filter (' + (PREF === 'hand' ? 'by hand only' : 'not fully by AI') + '). <button class="linkish" id="clearPref">show all</button>' : '';
if (hidden) $('clearPref').addEventListener('click', function () { setPref('all'); });
$('list').querySelectorAll('.row').forEach(function (r) { r.addEventListener('click', function () { hidePeek(); openSheet(r.dataset.id, LISTIDS, r); }); });
}
$('q').addEventListener('input', function (e) { Q = e.target.value.trim().toLowerCase(); renderList(); });
/* ---------- hover peek: the drawn preview follows the pointer over the list ---------- */
var peek = $('peek'), peekId = null, canHover = window.matchMedia('(min-width: 900px)').matches;
function hidePeek() { peek.classList.remove('on'); peekId = null; }
$('list').addEventListener('mousemove', function (e) {
if (!canHover) return;
var row = e.target.closest('.row');
if (!row) { hidePeek(); return; }
if (row.dataset.id !== peekId) {
peekId = row.dataset.id; var p = P(peekId);
peek.innerHTML = '<div class="frame">' + frameHTML(peekId) + '</div><div class="pc"><span>' + strip(p.name) + ' · drawn stand-in</span>' + aiMark(p.ai) + '</div>';
}
var x = e.clientX + 28, y = e.clientY - 120;
if (x + 350 > window.innerWidth - 50) x = e.clientX - 368;
y = Math.max(70, Math.min(window.innerHeight - 270, y));
peek.style.setProperty('--px', x + 'px'); peek.style.setProperty('--py', y + 'px');
peek.classList.add('on');
});
$('list').addEventListener('mouseleave', hidePeek);
window.addEventListener('scroll', hidePeek, { passive: true });
/* ---------- writing ---------- */
$('wl').innerHTML = POSTS.slice(0, 3).map(function (w, i) {
return '<a href="https://blog.lucaswinther.info"' + (i === 0 ? ' class="lead"' : '') + '><span class="t ph" title="placeholder title">' + w.t + '</span><span class="d">' + w.d.slice(5).replace('-', '/') + '</span></a>';
}).join('');
$('posts').innerHTML = POSTS.map(function (w) {
return '<a href="https://blog.lucaswinther.info"><span class="d ph" title="placeholder">' + w.d + '</span><span><span class="t ph" title="placeholder title">' + w.t + '</span><span class="b">' + w.blurb + '</span></span><span class="tg">' + w.tag + '</span></a>';
}).join('');
/* ---------- side sheet ---------- */
var CTX = [], CUR = null, RETURN = null;
function sheetHTML(p) {
var links = p.links.length ? p.links.map(function (l) { return l[1] ? '<a class="btn" href="' + l[1] + '"' + (l[1] === '#' ? ' onclick="return false" title="placeholder link"' : '') + '>→ ' + l[0] + '</a>' : '<span class="dead">' + l[0] + '</span>'; }).join('') : '<span class="dead">no public links</span>';
return '<div class="vis"><div class="ghost"></div><div class="front">' + frameHTML(p.id) + '</div><span class="cap">drawn stand-in · placeholder</span></div>' +
'<h2 id="sTitle">' + p.name + '</h2>' +
'<div class="meta"><span>' + GROUPS[p.g] + '</span>' + (p.where ? '<span>' + p.where + '</span>' : '') + '<span>' + p.yr + '</span>' + aiMark(p.ai) + '</div>' +
'<p class="lead">' + p.one + '</p>' +
'<section><h4>Why</h4><p>' + p.problem + '</p></section>' +
'<section><h4>How</h4><p>' + p.approach + '</p></section>' +
'<section><h4>Interesting</h4><p>' + p.interesting + '</p></section>' +
'<section><h4>Who made what</h4><ul class="parts">' + p.parts.map(function (x) { return '<li><span>' + x[0] + '</span>' + aiMark(x[1]) + '</li>'; }).join('') + '</ul></section>' +
'<section><h4>Stack</h4><div class="chips">' + p.stack.map(function (s) { return '<span class="chip">' + s + '</span>'; }).join('') + '</div></section>' +
'<section><h4>Links</h4><div class="lks">' + links + '</div></section>' +
(p.posts.length ? '<section><h4>Writing</h4><div class="sposts">' + p.posts.map(function (id) { var w = POST(id); return '<a href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">' + w.t + '</span> →</a>'; }).join('') + '</div></section>' : '') +
'<section><h4>Related</h4><div class="rel">' + p.rel.map(function (id) { return '<button data-rel="' + id + '">' + P(id).name + '</button>'; }).join('') + '</div></section>';
}
function fill(id, stagger) {
var p = P(id), b = $('sbody'); CUR = id;
b.innerHTML = sheetHTML(p);
b.scrollTop = 0;
Array.prototype.forEach.call(b.children, function (c, i) { c.style.setProperty('--i', Math.min(i, 10)); });
b.classList.remove('anim'); if (stagger) { void b.offsetWidth; b.classList.add('anim'); }
var i = CTX.indexOf(id);
$('spos').textContent = i >= 0 ? (i + 1) + ' / ' + CTX.length + (p.feat ? ' featured' : ' in list') : 'project';
$('sprev').disabled = i <= 0; $('snext').disabled = i < 0 || i >= CTX.length - 1;
b.querySelectorAll('[data-rel]').forEach(function (x) { x.addEventListener('click', function () { CTX = []; swap(x.dataset.rel); }); });
history.replaceState(null, '', '#/' + id);
}
function swap(id) { vt(function () { fill(id, false); }); }
function openSheet(id, ctx, from) {
CTX = ctx || []; RETURN = from || document.activeElement;
fill(id, true);
$('sheet').classList.add('on'); $('scrim').classList.add('on'); $('sheet').setAttribute('aria-hidden', 'false');
setTimeout(function () { $('sclose').focus({ preventScroll: true }); }, 30);
}
function closeSheet() {
if (!$('sheet').classList.contains('on')) return;
$('sheet').classList.remove('on'); $('scrim').classList.remove('on'); $('sheet').setAttribute('aria-hidden', 'true');
history.replaceState(null, '', location.pathname + location.search);
if (RETURN && RETURN.focus) RETURN.focus({ preventScroll: true });
}
function step(d) { var i = CTX.indexOf(CUR); if (i >= 0 && CTX[i + d]) swap(CTX[i + d]); }
$('sclose').addEventListener('click', closeSheet);
$('scrim').addEventListener('click', closeSheet);
$('sprev').addEventListener('click', function () { step(-1); });
$('snext').addEventListener('click', function () { step(1); });
function featCtx() { return FEAT.filter(allowed).map(function (p) { return p.id; }); }
function openAny(id, from) { var p = P(id); openSheet(id, p.feat ? featCtx() : (LISTIDS.indexOf(id) >= 0 ? LISTIDS : []), from); }
document.querySelectorAll('[data-open]').forEach(function (b) {
b.addEventListener('click', function (e) { e.stopPropagation(); openAny(b.dataset.open, b); });
});
document.querySelectorAll('.feat .txt h3').forEach(function (h) {
h.style.cursor = 'pointer';
h.addEventListener('click', function () { openAny(h.closest('.feat').dataset.id, h); });
});
/* ---------- command palette ---------- */
var pal = $('pal'), palq = $('palq'), pallist = $('pallist'), palItems = [], palOn = 0;
function jump(id) { closeSheet(); var el = $(id); if (el) el.scrollIntoView({ behavior: REDUCE ? 'auto' : 'smooth', block: 'start' }); }
var CMDS = [];
PROJECTS.forEach(function (p, i) { CMDS.push({ g: p.feat ? 'Featured' : 'Projects', k: (i < 9 ? '0' : '') + (i + 1), label: strip(p.name), sub: strip(p.one), run: function () { openAny(p.id); } }); });
[['top', 'Intro', 'who, now, CV in ten seconds'], ['featured', 'Featured projects', 'four, told properly'], ['all', 'All projects', 'the filterable list'], ['writing', 'Writing', 'latest posts'], ['cv', "Where I've worked", 'work history, degree']].forEach(function (s) { CMDS.push({ g: 'Sections', k: '§', label: s[1], sub: s[2], run: function () { jump(s[0]); } }); });
POSTS.forEach(function (w) { CMDS.push({ g: 'Writing', k: '¶', label: w.t, sub: w.tag + ' · ' + w.d, run: function () { location.href = 'https://blog.lucaswinther.info'; } }); });
[['all', 'Show projects made any way'], ['nofull', 'Hide projects made fully by AI'], ['hand', 'Show only projects made by hand']].forEach(function (f) { CMDS.push({ g: 'AI filter', k: '◐', label: f[1], sub: PREF === f[0] ? 'current' : '', run: function () { setPref(f[0]); } }); });
CMDS.push({ g: 'Go', k: '↓', label: 'Download CV (PDF)', sub: 'placeholder', run: function () {} });
CMDS.push({ g: 'Go', k: '@', label: 'Email Lucas', sub: 'lucas@lucaswinther.info', run: function () { location.href = 'mailto:lucas@lucaswinther.info'; } });
CMDS.push({ g: 'Go', k: 'gh', label: 'GitHub', sub: 'StereotypicalCat', run: function () { location.href = 'https://github.com/StereotypicalCat'; } });
CMDS.push({ g: 'Go', k: '↖', label: 'Back to the gate', sub: 'start page', run: function () { location.href = 'nav-d.html'; } });
CMDS.push({ g: 'Go', k: '→', label: 'Off the clock', sub: 'the personal side', run: function () { location.href = 'nav-d.html#art'; } });
function score(c, q) {
if (!q) return 1; var hay = (c.label + ' ' + c.sub + ' ' + c.g).toLowerCase(), words = q.toLowerCase().split(/\s+/).filter(Boolean);
for (var i = 0; i < words.length; i++) if (hay.indexOf(words[i]) < 0) return 0;
var lab = c.label.toLowerCase(); return lab.indexOf(words[0]) === 0 ? 4 : (' ' + lab).indexOf(' ' + words[0]) >= 0 ? 3 : lab.indexOf(words[0]) >= 0 ? 2 : 1;
}
function renderPal() {
var q = palq.value.trim();
var items = CMDS.map(function (c) { return { c: c, s: score(c, q) }; }).filter(function (x) { return x.s > 0; });
if (q) items.sort(function (a, b) { return b.s - a.s; });
var h = '', idx = 0, ordered = [];
['Featured', 'Projects', 'Sections', 'Writing', 'AI filter', 'Go'].forEach(function (g) {
var gi = items.filter(function (x) { return x.c.g === g; });
if (!q && g === 'Writing') gi = gi.slice(0, 2);
if (!gi.length) return; h += '<li class="g">' + g + '</li>';
gi.forEach(function (x) {
var sub = x.c.g === 'AI filter' ? (x.c.label.indexOf(PREF === 'all' ? 'any way' : PREF === 'nofull' ? 'Hide' : 'only') >= 0 ? 'current' : '') : x.c.sub;
ordered.push(x); h += '<li class="it" role="option" data-i="' + idx + '"><span class="k">' + x.c.k + '</span><span>' + esc(x.c.label) + '</span><span class="s">' + esc(sub) + '</span></li>'; idx++;
});
});
palItems = ordered; palOn = 0;
pallist.innerHTML = h || '<li class="none">Nothing found for “' + esc(q) + '”.</li>';
markPal();
}
function markPal() { pallist.querySelectorAll('.it').forEach(function (li) { var on = +li.dataset.i === palOn; li.classList.toggle('on', on); li.setAttribute('aria-selected', on); if (on) li.scrollIntoView({ block: 'nearest' }); }); }
function openPal() { if (pal.open) { palq.select(); return; } hidePeek(); palq.value = ''; renderPal(); pal.showModal(); palq.focus(); }
function runPal(i) { var x = palItems[i]; if (!x) return; pal.close(); x.c.run(); }
palq.addEventListener('input', renderPal);
palq.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { e.preventDefault(); palOn = Math.min(palItems.length - 1, palOn + 1); markPal(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); palOn = Math.max(0, palOn - 1); markPal(); }
else if (e.key === 'Enter') { e.preventDefault(); runPal(palOn); }
});
pallist.addEventListener('mousemove', function (e) { var li = e.target.closest('.it'); if (li && +li.dataset.i !== palOn) { palOn = +li.dataset.i; markPal(); } });
pallist.addEventListener('click', function (e) { var li = e.target.closest('.it'); if (li) runPal(+li.dataset.i); });
pal.addEventListener('click', function (e) { if (e.target === pal) pal.close(); });
$('kbtn').addEventListener('click', openPal);
/* ---------- keyboard ---------- */
document.addEventListener('keydown', function (e) {
if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); openPal(); return; }
if (pal.open) return;
var open = $('sheet').classList.contains('on');
if (e.key === 'Escape' && open) { e.preventDefault(); closeSheet(); return; }
if (open && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { e.preventDefault(); step(e.key === 'ArrowLeft' ? -1 : 1); return; }
if (open && e.key === 'Tab') { // keep focus inside the sheet
var f = $('sheet').querySelectorAll('button:not(:disabled), a[href]'); if (!f.length) return;
if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }
else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
return;
}
if (e.key === '/' && !open && !/INPUT|TEXTAREA/.test(document.activeElement.tagName)) { e.preventDefault(); $('q').scrollIntoView({ block: 'center', behavior: REDUCE ? 'auto' : 'smooth' }); $('q').focus({ preventScroll: true }); }
});
/* ---------- pointer light on surfaces ---------- */
document.querySelectorAll('.sp').forEach(function (el) {
el.addEventListener('pointermove', function (e) {
var r = el.getBoundingClientRect();
el.style.setProperty('--mx', (e.clientX - r.left) + 'px'); el.style.setProperty('--my', (e.clientY - r.top) + 'px');
});
});
/* ---------- frosted bar gets an edge once scrolled ---------- */
var bar = $('bar');
window.addEventListener('scroll', function () { bar.classList.toggle('scrolled', window.scrollY > 4); }, { passive: true });
/* ---------- stage 1: tilt ---------- */
(function () {
var bars = [0,0,0,0,0,0,1,2,4,6,8,8,7,9,10,8,6,6,5,4,3,2,1,2];
$('bars').innerHTML = bars.map(function (v, i) { return '<i class="' + (i === bars.length - 1 ? 'cur' : v >= 7 ? 'hi' : '') + '" style="height:' + (4 + v * 4) + 'px"></i>'; }).join('');
})();
document.querySelectorAll('[data-fx="tilt"], [data-fx="par"]').forEach(function (st) {
st.addEventListener('pointermove', function (e) {
var r = st.getBoundingClientRect();
st.style.setProperty('--tx', ((e.clientX - r.left) / r.width * 2 - 1).toFixed(3));
st.style.setProperty('--ty', ((e.clientY - r.top) / r.height * 2 - 1).toFixed(3));
});
st.addEventListener('pointerleave', function () { st.style.setProperty('--tx', 0); st.style.setProperty('--ty', 0); });
});
/* ---------- stage 3: peers ---------- */
(function () {
var N = [ // x, y, trust, kept, free
[70, 94, .91, '15/15'], [180, 70, .84, '14/15'], [300, 108, .88, '15/15'], [430, 76, .23, '3/15', 1], [540, 124, .79, '13/15'],
[110, 204, .86, '14/15'], [240, 194, .93, '15/15'], [370, 214, .81, '13/15'], [500, 234, .12, '1/15', 1],
[60, 304, .77, '12/15'], [190, 294, .89, '15/15'], [330, 304, .71, '11/15'], [460, 314, .85, '14/15']
];
var E = [[0,1],[1,2],[2,3],[3,4],[0,5],[1,6],[2,6],[2,7],[3,7],[4,8],[5,6],[6,7],[7,8],[5,9],[6,10],[7,11],[8,12],[9,10],[10,11],[11,12],[3,6],[8,7],[4,7],[1,5],[10,7]];
var svg = $('netsvg'), h = '';
E.forEach(function (e) {
var a = N[e[0]], b = N[e[1]], bad = a[4] || b[4];
var t = (bad ? (a[4] ? a[2] : b[2]) : (a[2] + b[2]) / 2).toFixed(2);
h += '<line class="e' + (bad ? ' bad' : '') + '" data-a="' + e[0] + '" data-b="' + e[1] + '" x1="' + a[0] + '" y1="' + a[1] + '" x2="' + b[0] + '" y2="' + b[1] + '"/>';
h += '<text class="w" data-a="' + e[0] + '" data-b="' + e[1] + '" x="' + ((a[0] + b[0]) / 2 + 4) + '" y="' + ((a[1] + b[1]) / 2 - 4) + '">' + t + '</text>';
});
N.forEach(function (n, i) { h += '<g class="p' + (n[4] ? ' free' : '') + '" data-i="' + i + '"><circle cx="' + n[0] + '" cy="' + n[1] + '" r="8"/><text x="' + (n[0] + 12) + '" y="' + (n[1] + 4) + '">' + String(i + 1).padStart(2, '0') + '</text></g>'; });
svg.innerHTML = h;
var st = svg.parentNode, ro = $('netro'), def = ro.innerHTML, last = -1;
st.addEventListener('mousemove', function (e) {
var r = svg.getBoundingClientRect(), s = Math.min(r.width / 600, r.height / 350);
var ox = (r.width - 600 * s) / 2, oy = (r.height - 350 * s) / 2;
var x = (e.clientX - r.left - ox) / s, y = (e.clientY - r.top - oy) / s, best = -1, bd = 1e9;
N.forEach(function (n, i) { var d = (n[0] - x) * (n[0] - x) + (n[1] - y) * (n[1] - y); if (d < bd) { bd = d; best = i; } });
if (bd > 90 * 90) best = -1;
if (best === last) return; last = best;
st.classList.toggle('hot', best >= 0);
svg.querySelectorAll('.p').forEach(function (g) { g.classList.toggle('on', +g.dataset.i === best); });
svg.querySelectorAll('.e, .w').forEach(function (l) { l.classList.toggle('lit', +l.dataset.a === best || +l.dataset.b === best); });
if (best < 0) { ro.innerHTML = def; return; }
var n = N[best], deg = E.filter(function (e) { return e[0] === best || e[1] === best; }).length;
ro.innerHTML = '<b>peer ' + String(best + 1).padStart(2, '0') + '</b> · ' + deg + ' neighbours<br>trust <b' + (n[4] ? ' class="bad"' : '') + '>' + n[2].toFixed(2) + '</b> · kept ' + n[3] + ' chunks' + (n[4] ? '<br><span class="bad">freeloader: drops data quietly</span>' : '');
});
st.addEventListener('mouseleave', function () { last = -1; st.classList.remove('hot'); svg.querySelectorAll('.on, .lit').forEach(function (x) { x.classList.remove('on', 'lit'); }); ro.innerHTML = def; });
})();
/* ---------- stage 4: rowing stroke ---------- */
(function () {
var W = 560, H = 260, L = 30, R = 20, T = 40, B = 34;
function force(t) { return t < 0.4 ? Math.pow(Math.sin(Math.PI * t / 0.4), 1.4) * (1 - 0.18 * t) : Math.max(0, 0.03 * Math.sin(t * 40) * (1 - t)); }
function angle(t) { return t < 0.4 ? -38 + 70 * (t / 0.4) : 32 - 70 * ((t - 0.4) / 0.6); }
function phase(t) { return t < 0.06 ? 'catch' : t < 0.34 ? 'drive' : t < 0.42 ? 'finish' : 'recovery'; }
function X(t) { return L + t * (W - L - R); } function Y(f) { return H - B - f * (H - T - B); }
var d = '', a = '';
for (var i = 0; i <= 200; i++) { var t = i / 200; d += (i ? 'L' : 'M') + X(t).toFixed(1) + ' ' + Y(force(t)).toFixed(1); }
a = d + 'L' + X(1) + ' ' + Y(0) + 'L' + X(0) + ' ' + Y(0) + 'Z';
var g = '<defs><linearGradient id="areaG" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e0592b" stop-opacity=".16"/><stop offset="1" stop-color="#e0592b" stop-opacity="0"/></linearGradient></defs><g class="grid">';
[0, .25, .5, .75, 1].forEach(function (f) { g += '<line x1="' + L + '" x2="' + (W - R) + '" y1="' + Y(f) + '" y2="' + Y(f) + '"/>'; });
g += '<line x1="' + X(.4) + '" x2="' + X(.4) + '" y1="' + T + '" y2="' + Y(0) + '" stroke-dasharray="3 4"/></g>';
var labels = '<text class="ph-lbl" x="' + X(.02) + '" y="' + (H - 12) + '">catch</text><text class="ph-lbl" x="' + X(.16) + '" y="' + (H - 12) + '">drive</text><text class="ph-lbl" x="' + X(.36) + '" y="' + (H - 12) + '">finish</text><text class="ph-lbl" x="' + X(.66) + '" y="' + (H - 12) + '">recovery</text>';
$('chartsvg').innerHTML = g + '<path class="area" d="' + a + '"/><path class="curve" d="' + d + '" vector-effect="non-scaling-stroke"/>' + labels +
'<g class="scr"><line id="sl" x1="0" x2="0" y1="' + T + '" y2="' + Y(0) + '" vector-effect="non-scaling-stroke"/><circle id="sd" r="4.5"/></g>';
var ch = $('chart'), sl = $('sl'), sd = $('sd'), rd = $('rd'), def = rd.innerHTML;
function setOars(deg) { $('oarL').style.transform = 'rotate(' + (-deg) + 'deg)'; $('oarR').style.transform = 'rotate(' + deg + 'deg)'; $('oarDeg').textContent = 'oar angle ' + Math.round(deg) + '°'; }
ch.addEventListener('mousemove', function (e) {
var r = ch.getBoundingClientRect(), vx = (e.clientX - r.left) / r.width * W;
var t = Math.min(1, Math.max(0, (vx - L) / (W - L - R))), f = force(t);
sl.setAttribute('x1', X(t)); sl.setAttribute('x2', X(t)); sd.setAttribute('cx', X(t)); sd.setAttribute('cy', Y(f));
rd.innerHTML = 't ' + (t * 2.2).toFixed(2) + ' s · <b>' + phase(t) + '</b> · ' + Math.round(f * 100) + '% of peak';
setOars(angle(t));
});
ch.addEventListener('mouseleave', function () { rd.innerHTML = def; setOars(0); });
})();
/* ---------- init ---------- */
renderTabs(); applyPref();
if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { moveThumb($('seg')); moveThumb($('tabs')); });
window.addEventListener('resize', function () { moveThumb($('seg')); moveThumb($('tabs')); });
(function () { var m = location.hash.match(/^#\/(\w+)/); if (m && P(m[1])) openAny(m[1]); })();
</script>
</body>
</html>