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

1575 lines
138 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">
<meta name="color-scheme" content="dark">
<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: #0e0d0b; --s1: #161513; --s2: #1c1a17; --s3: #24211d; --s4: #2c2924; --stage: #121110;
--line: rgba(255,236,214,.075); --line2: rgba(255,236,214,.12); --line3: rgba(255,236,214,.2);
--ink: #f0e9de; --ink2: #c9c1b5; --mute: #8f887d; --dim: #645e56;
--acc: #ee7a47; --acc2: #ff9a6a; --acc-s: rgba(238,122,71,.14); --ok: #aab78f;
--a-bg: #2a1a1f; --a-ink: #f1e6d6;
--sans: "Geist", system-ui, sans-serif; --mono: "Geist Mono", ui-monospace, monospace;
--hi: inset 0 1px 0 rgba(255,240,222,.065);
--sh1: 0 1px 2px rgba(0,0,0,.35), 0 10px 28px -14px rgba(0,0,0,.6);
--sh2: 0 1px 2px rgba(0,0,0,.4), 0 22px 44px -18px rgba(0,0,0,.72), 0 60px 110px -50px rgba(0,0,0,.7);
--ease: cubic-bezier(.16,1,.3,1);
--spring: linear(0, .09, .281, .494, .682, .828, .929, .992, 1.025, 1.037, 1.037, 1.032, 1.024, 1.016, 1.009, 1.004, 1.001, 1, .999, 1);
--edge: 42px;
}
* { box-sizing: border-box; }
html { scroll-padding-top: 80px; }
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; font-feature-settings: "ss01", "cv11"; }
body::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
background:
radial-gradient(1100px 560px at 18% -14%, rgba(238,122,71,.085), transparent 62%),
radial-gradient(800px 420px at 88% -6%, rgba(255,226,196,.04), transparent 60%),
linear-gradient(#100f0d, #0e0d0b 40%); }
body::after { content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; opacity: .05; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
a { color: inherit; }
button { font: inherit; color: inherit; }
::selection { background: var(--acc); color: #120f0c; }
:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; border-radius: 6px; }
.mono, .tnum { font-variant-numeric: tabular-nums; }
.mono { font-family: var(--mono); }
.ph { text-decoration: underline dotted rgba(240,233,222,.32); text-underline-offset: 3px; text-decoration-thickness: 1px; cursor: help; }
kbd { font: 500 10.5px/1 var(--mono); color: var(--ink2); background: linear-gradient(var(--s3), var(--s2)); border: 1px solid var(--line2); border-bottom-color: rgba(0,0,0,.5); border-radius: 5px; padding: 3px 5px; box-shadow: var(--hi); }
/* one-time arrival: ≤ 600 ms in total */
@keyframes rise { from { opacity: 0; transform: translateY(10px); filter: blur(3px); } to { opacity: 1; transform: none; filter: none; } }
.rise { animation: rise .48s var(--ease) both; animation-delay: calc(var(--d, 0) * 40ms); }
/* pointer light, shared by every raised panel */
.glow { position: relative; isolation: isolate; }
.glow::before, .glow::after { content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; opacity: 0; transition: opacity .35s var(--ease); z-index: 6; }
.glow::before { padding: 1px; background: radial-gradient(260px circle at var(--mx, -500px) var(--my, -500px), rgba(255,186,146,.55), rgba(255,186,146,.08) 45%, transparent 70%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; }
.glow::after { background: radial-gradient(520px circle at var(--mx, -500px) var(--my, -500px), rgba(255,206,170,.045), transparent 60%); }
.glow:hover::before, .glow:hover::after { opacity: 1; }
.raised { background: linear-gradient(180deg, var(--s2), var(--s1)); border: 1px solid var(--line); box-shadow: var(--hi), var(--sh1); }
/* mockup note */
.mk { position: fixed; top: 72px; right: calc(var(--edge) + 16px); z-index: 65; width: 320px; background: rgba(240,233,222,.94); color: #1b1916; font: 12px/1.45 var(--mono); padding: 11px 30px 11px 13px; border-radius: 12px; box-shadow: 0 20px 50px -20px rgba(0,0,0,.8); backdrop-filter: blur(10px); }
.mk b { display: block; font-family: var(--sans); font-size: 13px; margin-bottom: 3px; }
.mk button { position: absolute; top: 5px; right: 8px; background: none; border: 0; font-size: 16px; cursor: pointer; color: #1b1916; }
/* off the clock edge */
.edge { position: fixed; top: 0; right: 0; bottom: 0; width: var(--edge); z-index: 40; color: var(--a-ink); border-left: 1px solid #3c2a2f; display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; font: italic 300 16px "Fraunces", serif; text-decoration: none; transition: width .35s var(--spring), box-shadow .3s;
background: linear-gradient(180deg, #2f1d23, #241519); box-shadow: inset 1px 0 0 rgba(241,230,214,.06); }
.edge:hover { width: 56px; box-shadow: inset 1px 0 0 rgba(241,230,214,.12), -20px 0 60px -20px rgba(120,50,70,.55); }
/* top bar */
.bar { position: sticky; top: 0; z-index: 30; margin-right: var(--edge); background: rgba(14,13,11,.55); -webkit-backdrop-filter: blur(18px) saturate(1.5); backdrop-filter: blur(18px) saturate(1.5); border-bottom: 1px solid transparent; transition: border-color .25s, background .25s, box-shadow .25s; }
.bar.scrolled { border-bottom-color: var(--line); background: rgba(16,15,13,.72); box-shadow: 0 12px 30px -24px rgba(0,0,0,.9); }
.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; border: 1px solid var(--line2); border-radius: 8px; padding: 4px 9px; background: rgba(255,236,214,.02); transition: color .15s, border-color .15s, background .15s; }
.crumb a.back:hover { color: var(--ink); border-color: var(--line3); background: rgba(255,236,214,.05); }
.crumb .mark { width: 22px; height: 22px; border-radius: 7px; background: linear-gradient(145deg, #ff9a6a, #d4612f); box-shadow: inset 0 1px 0 rgba(255,255,255,.3), 0 4px 14px -4px rgba(238,122,71,.6); display: grid; place-items: center; font: 700 11px var(--sans); color: #1a0f09; letter-spacing: -.03em; }
.crumb b { font-weight: 600; letter-spacing: -.01em; }
.crumb span.sl { color: var(--dim); }
.nav { position: relative; display: flex; gap: 2px; margin-left: 4px; }
.nav a { position: relative; z-index: 1; text-decoration: none; color: var(--mute); font-size: 13.5px; padding: 6px 11px; border-radius: 8px; transition: color .15s; }
.nav a:hover, .nav a.on { color: var(--ink); }
.nav .ind { position: absolute; top: 0; bottom: 0; left: 0; width: 0; border-radius: 8px; background: rgba(255,236,214,.06); box-shadow: var(--hi); opacity: 0; transition: transform .45s var(--spring), width .45s var(--spring), opacity .2s; }
.kbtn { margin-left: auto; display: inline-flex; align-items: center; gap: 9px; height: 32px; padding: 0 6px 0 11px; min-width: 200px; border-radius: 9px; border: 1px solid var(--line2); background: rgba(255,236,214,.03); color: var(--dim); font-size: 13px; cursor: pointer; box-shadow: var(--hi); transition: border-color .15s, background .15s, color .15s; }
.kbtn:hover { border-color: var(--line3); background: rgba(255,236,214,.06); color: var(--ink2); }
.kbtn span { margin-right: auto; }
.pref { display: flex; align-items: center; gap: 10px; font: 12px var(--mono); color: var(--mute); }
/* segmented control with a sliding thumb */
.seg { position: relative; display: inline-flex; background: rgba(0,0,0,.28); border: 1px solid var(--line); border-radius: 10px; padding: 3px; box-shadow: inset 0 1px 2px rgba(0,0,0,.4); }
.seg button { position: relative; z-index: 1; border: 0; background: none; padding: 4px 10px; border-radius: 7px; font: 12px var(--mono); color: var(--mute); cursor: pointer; transition: color .2s; white-space: nowrap; }
.seg button:hover { color: var(--ink2); }
.seg button[aria-pressed="true"] { color: var(--ink); }
.seg button small { font-size: 10.5px; color: var(--dim); margin-left: 5px; }
.seg .thumb { position: absolute; top: 3px; bottom: 3px; left: 0; width: 0; border-radius: 7px; background: linear-gradient(180deg, var(--s4), var(--s3)); border: 1px solid var(--line2); box-shadow: var(--hi), 0 2px 6px -2px rgba(0,0,0,.6); transition: transform .45s var(--spring), width .45s var(--spring); }
.seg.acc .thumb { background: linear-gradient(180deg, rgba(238,122,71,.26), rgba(238,122,71,.14)); border-color: rgba(238,122,71,.4); box-shadow: inset 0 1px 0 rgba(255,200,170,.18), 0 0 18px -4px rgba(238,122,71,.45); }
main { margin-right: var(--edge); }
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 40px; }
/* intro */
.intro { padding: 40px 0 28px; display: grid; grid-template-columns: 1fr auto; gap: 48px; align-items: end; }
.eyebrow { display: inline-flex; align-items: center; gap: 9px; font: 12px var(--mono); color: var(--ink2); padding: 4px 11px 4px 9px; border-radius: 99px; border: 1px solid var(--line2); background: rgba(255,236,214,.03); box-shadow: var(--hi); }
.eyebrow i { width: 7px; height: 7px; border-radius: 50%; background: var(--acc); box-shadow: 0 0 0 3px var(--acc-s), 0 0 12px rgba(238,122,71,.7); }
.intro h1 { font-size: 50px; line-height: 1.04; letter-spacing: -.045em; font-weight: 600; margin: 18px 0 0; max-width: 21em; text-wrap: balance; }
.intro h1 em { font-style: normal; color: var(--mute); background: linear-gradient(90deg, #8f887d, #6d665d); -webkit-background-clip: text; background-clip: text; color: transparent; }
.intro p { margin: 16px 0 0; color: var(--ink2); max-width: 40em; font-size: 16px; }
.legend { display: grid; grid-template-columns: auto auto; gap: 8px; font: 12px var(--mono); color: var(--mute); padding: 14px 16px; border-radius: 14px; }
.legend h4 { grid-column: 1 / -1; margin: 0 0 2px; font: 500 10.5px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--dim); }
/* now strip */
.now { display: grid; grid-template-columns: 1fr 1.15fr 1.35fr; gap: 12px; margin-bottom: 56px; }
.now > div { padding: 16px 20px 16px; min-width: 0; border-radius: 16px; }
.now h3 { position: relative; z-index: 1; margin: 0 0 10px; font: 500 10.5px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--dim); display: flex; justify-content: space-between; gap: 8px; }
.now h3 a { text-decoration: none; color: var(--mute); text-transform: none; letter-spacing: 0; }
.now h3 a:hover { color: var(--ink); }
.now ul { margin: 0; padding: 0; list-style: none; font-size: 14px; }
.now li { padding: 2px 0; color: var(--ink2); display: flex; gap: 9px; }
.now li::before { content: ""; flex: 0 0 5px; height: 5px; margin-top: 9px; border-radius: 50%; background: var(--dim); }
.now li:first-child::before { background: var(--acc); box-shadow: 0 0 8px rgba(238,122,71,.8); }
.wl a { position: relative; z-index: 7; 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(--acc2); }
.wl a .d { font: 11px var(--mono); color: var(--dim); white-space: nowrap; padding-top: 2px; }
.wl a.lead .t { font-weight: 500; }
.wl .wlb { margin: 0 0 6px; font-size: 13px; color: var(--mute); line-height: 1.45; }
.cv10 { display: grid; grid-template-columns: 64px 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 { position: relative; z-index: 7; }
.cvlinks { position: relative; z-index: 7; 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); border: 1px solid var(--line2); border-radius: 8px; padding: 4px 10px; background: rgba(255,236,214,.025); cursor: pointer; box-shadow: var(--hi); transition: border-color .15s, color .15s, background .15s, transform .3s var(--spring); }
.btn:hover { color: var(--ink); border-color: var(--line3); background: rgba(255,236,214,.06); transform: translateY(-1px); }
.btn.solid { background: linear-gradient(180deg, #fff3e6, #e9dfd1); color: #15120f; border-color: transparent; box-shadow: inset 0 1px 0 #fff, 0 6px 16px -8px rgba(255,220,190,.5); }
.btn.solid:hover { background: linear-gradient(180deg, #ffa477, #ee7a47); box-shadow: inset 0 1px 0 rgba(255,255,255,.35), 0 8px 20px -8px rgba(238,122,71,.8); }
/* AI mark + chips */
.ai { display: inline-flex; align-items: center; gap: 6px; font: 500 11px var(--mono); color: var(--ink2); border: 1px solid var(--line2); border-radius: 99px; padding: 2px 9px 2px 7px; white-space: nowrap; line-height: 1.5; background: rgba(255,236,214,.02); }
.ai i { width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid currentColor; flex: none; }
.ai.full { color: var(--acc2); border-color: rgba(238,122,71,.4); background: rgba(238,122,71,.07); } .ai.full i { background: currentColor; box-shadow: 0 0 6px rgba(238,122,71,.7); }
.ai.some i { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
.ai.unk { color: var(--mute); border-style: dashed; } .ai.unk i { border-style: dashed; }
.ai.bare { border: 0; padding: 0; background: none; }
.chips { display: flex; gap: 5px; flex-wrap: wrap; }
.chip { font: 11.5px var(--mono); color: var(--ink2); background: rgba(255,236,214,.035); border: 1px solid var(--line); border-radius: 7px; padding: 2px 8px; white-space: nowrap; }
/* section heads */
.sh { display: flex; align-items: baseline; gap: 14px; margin: 0 0 20px; }
.sh h2 { margin: 0; font-size: 30px; letter-spacing: -.035em; font-weight: 600; line-height: 1.1; }
.sh .n { font: 12px var(--mono); color: var(--dim); }
.sh .r { margin-left: auto; font: 12px var(--mono); color: var(--mute); text-decoration: none; }
.sh a.r:hover { color: var(--ink); }
/* featured panels */
.feats { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.feat { border-radius: 22px; overflow: hidden; display: grid; cursor: pointer; transition: transform .5s var(--spring), box-shadow .4s var(--ease), border-color .3s; }
.feat:hover { transform: translateY(-3px); box-shadow: var(--hi), var(--sh2); border-color: var(--line2); }
.feat:active { transform: translateY(-1px) scale(.997); }
.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; border-right: 0; border-left: 1px solid var(--line); }
.feat.wide .stage { border-right: 1px solid var(--line); }
.feat:not(.wide) .stage { border-bottom: 1px solid var(--line); }
.stage { position: relative; overflow: hidden; min-height: 300px;
background:
radial-gradient(ellipse 70% 60% at 50% 45%, rgba(238,122,71,.06), transparent 70%),
radial-gradient(rgba(255,236,214,.06) 1px, transparent 1px) 0 0 / 18px 18px,
linear-gradient(180deg, #141311, #100f0d); }
.stage::after { content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 4; box-shadow: inset 0 0 60px 10px rgba(14,13,11,.7); }
.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: 12px; font: 11px var(--mono); color: var(--dim); z-index: 5; pointer-events: none; transition: opacity .2s; }
.stage:hover .hint { opacity: 0; }
.txt { position: relative; padding: 30px 32px 28px; display: flex; flex-direction: column; gap: 15px; min-width: 0; }
.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); }
.meta .dot { width: 3px; height: 3px; border-radius: 50%; background: var(--dim); }
.feat h3 { margin: 0; font-size: 36px; line-height: 1.02; letter-spacing: -.04em; font-weight: 600; }
.feat:not(.wide) h3 { font-size: 30px; }
.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: 12px; padding-top: 6px; flex-wrap: wrap; }
.open { position: relative; z-index: 7; display: inline-flex; align-items: center; gap: 8px; background: rgba(255,236,214,.04); border: 1px solid var(--line2); border-radius: 10px; padding: 7px 13px; font-size: 14px; cursor: pointer; box-shadow: var(--hi); transition: background .2s, border-color .2s, color .2s, box-shadow .2s; }
.open span { transition: transform .35s var(--spring); display: inline-block; }
.feat:hover .open, .open:hover { background: linear-gradient(180deg, #fff3e6, #e9dfd1); color: #15120f; border-color: transparent; box-shadow: inset 0 1px 0 #fff, 0 8px 20px -10px rgba(255,220,190,.6); }
.feat:hover .open span { transform: translateX(3px); }
.feat .filt { display: none; padding: 16px 22px; font: 13px var(--mono); color: var(--mute); align-items: center; gap: 12px; }
.feat.filtered { align-self: start; display: block; cursor: default; transform: none; box-shadow: none; background: transparent; border-style: dashed; }
.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; }
/* drawn windows in stages: shared depth */
.win, .ed, .boat, .chart, .term { background: linear-gradient(180deg, #211e1a, #1b1916); border: 1px solid var(--line2); box-shadow: inset 0 1px 0 rgba(255,240,222,.07), 0 2px 4px rgba(0,0,0,.35), 0 30px 60px -24px rgba(0,0,0,.85), 0 70px 120px -60px rgba(0,0,0,.8); }
.float { position: absolute; background: rgba(40,36,31,.72); -webkit-backdrop-filter: blur(14px) saturate(1.4); backdrop-filter: blur(14px) saturate(1.4); border: 1px solid var(--line3); border-radius: 12px; padding: 10px 12px; font-size: 12px; color: var(--ink2); box-shadow: inset 0 1px 0 rgba(255,240,222,.1), 0 24px 48px -18px rgba(0,0,0,.9); transition: transform .5s var(--spring); }
.float b { color: var(--ink); font-weight: 500; display: block; font-size: 12.5px; }
.float .mono { font-size: 10.5px; color: var(--mute); }
/* -- stage 1: compute platform (tilt) -- */
.s-compute { min-height: 470px; perspective: 1400px; }
.s-compute .bloom { position: absolute; left: 12%; right: 12%; bottom: 6%; height: 50%; background: radial-gradient(ellipse at 50% 100%, rgba(238,122,71,.2), transparent 70%); filter: blur(30px); pointer-events: none; }
.tiltw { position: absolute; inset: 38px 48px 50px 44px; transform-style: preserve-3d; transition: transform .6s var(--spring); transform: rotateX(calc(var(--ty, 0) * -3deg)) rotateY(calc(var(--tx, 0) * 4deg)); }
.win { position: absolute; inset: 0; border-radius: 14px; overflow: hidden; display: grid; grid-template-rows: 36px 1fr; }
.win .tb { display: flex; align-items: center; gap: 10px; padding: 0 12px; border-bottom: 1px solid var(--line); background: rgba(0,0,0,.18); }
.win .tb .dots { display: flex; gap: 6px; } .win .tb .dots i { width: 9px; height: 9px; border-radius: 50%; background: #3a3530; box-shadow: inset 0 1px 0 rgba(255,255,255,.06); }
.win .tb .url { flex: 1; max-width: 300px; margin: 0 auto; background: rgba(0,0,0,.25); border: 1px solid var(--line); border-radius: 7px; font: 11px var(--mono); color: var(--mute); text-align: center; padding: 2px 0; }
.app { display: grid; grid-template-columns: 150px 1fr; min-height: 0; }
.app .side { border-right: 1px solid var(--line); padding: 14px 10px; display: flex; flex-direction: column; gap: 2px; font-size: 12.5px; color: var(--mute); background: rgba(0,0,0,.1); }
.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::before { content: ""; width: 12px; height: 12px; border-radius: 4px; background: linear-gradient(145deg, var(--acc2), var(--acc)); }
.app .side .it { padding: 5px 8px; border-radius: 7px; }
.app .side .it.on { background: rgba(255,236,214,.07); color: var(--ink); box-shadow: var(--hi); }
.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: -.01em; }
.app .hd b small { font: 11px var(--mono); color: var(--dim); font-weight: 400; margin-left: 8px; }
.app .hd .nb { font-size: 12px; background: linear-gradient(180deg, #fff3e6, #e2d7c8); color: #15120f; border-radius: 7px; padding: 3px 10px; font-weight: 500; box-shadow: inset 0 1px 0 #fff; }
.jobs { font-size: 12px; border: 1px solid var(--line); border-radius: 9px; overflow: hidden; background: rgba(0,0,0,.12); }
.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; font-variant-numeric: tabular-nums; }
.jobs .jr:first-child { border-top: 0; font: 10.5px var(--mono); color: var(--dim); text-transform: uppercase; letter-spacing: .05em; }
.jobs .jr:not(:first-child):hover { background: rgba(255,236,214,.04); }
.jobs .jr .m { font-family: var(--mono); font-size: 11.5px; }
.st { font: 11px var(--mono); display: inline-flex; align-items: center; gap: 6px; }
.st i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.st.run { color: var(--ok); } .st.run i { box-shadow: 0 0 6px currentColor; }
.st.q { color: var(--acc2); } .st.done { color: var(--dim); }
.scale { border: 1px solid var(--line); border-radius: 9px; padding: 8px 10px 6px; background: rgba(0,0,0,.12); }
.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: linear-gradient(180deg, #4a443d, #34302b); border-radius: 3px 3px 1px 1px; min-height: 2px; }
.scale .bars i.hi { background: linear-gradient(180deg, #d9d0c3, #8f887d); } .scale .bars i.nw { background: linear-gradient(180deg, var(--acc2), var(--acc)); box-shadow: 0 0 12px rgba(238,122,71,.6); }
.f1 { top: -18px; right: -22px; width: 214px; transform: translate3d(calc(var(--tx,0) * 16px), calc(var(--ty,0) * 12px), 50px); }
.f2 { bottom: 74px; left: -30px; width: 196px; transform: translate3d(calc(var(--tx,0) * 24px), calc(var(--ty,0) * 16px), 70px); }
.f3 { bottom: -18px; right: 70px; width: 200px; transform: translate3d(calc(var(--tx,0) * 10px), calc(var(--ty,0) * 8px), 30px); display: flex; gap: 10px; align-items: center; }
.f3 .av { width: 26px; height: 26px; border-radius: 50%; flex: none; background: linear-gradient(145deg, #5a4d42, #3a322b); display: grid; place-items: center; font: 600 10px var(--sans); color: var(--ink); box-shadow: inset 0 1px 0 rgba(255,255,255,.12); }
.f1 .bar2 { height: 4px; background: rgba(0,0,0,.35); border-radius: 3px; margin-top: 8px; overflow: hidden; } .f1 .bar2 i { display: block; height: 100%; width: 38%; background: linear-gradient(90deg, var(--acc), var(--acc2)); border-radius: 3px; box-shadow: 0 0 10px rgba(238,122,71,.7); }
/* -- stage 2: config (spotlight) -- */
.s-config { min-height: 340px; }
.s-config::before { content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none; opacity: 0; transition: opacity .3s;
background: radial-gradient(200px circle at var(--mx, 50%) var(--my, 50%), rgba(238,122,71,.13), transparent 70%); }
.s-config:hover::before { opacity: 1; }
.ed { position: absolute; inset: 28px 70px 64px 26px; border-radius: 12px; overflow: hidden; display: grid; grid-template-columns: 140px 1fr; grid-template-rows: 30px 1fr; }
.ed .etabs { grid-column: 1 / -1; display: flex; align-items: center; gap: 2px; padding: 0 8px; border-bottom: 1px solid var(--line); background: rgba(0,0,0,.18); font: 11px var(--mono); color: var(--dim); }
.ed .etabs span { padding: 4px 10px; border-radius: 6px; }
.ed .etabs span.on { color: var(--ink); background: rgba(255,236,214,.06); box-shadow: var(--hi); }
.tree { border-right: 1px solid var(--line); padding: 10px 6px; font: 11.5px/1.75 var(--mono); color: var(--mute); background: rgba(0,0,0,.1); }
.tree div { padding-left: calc(var(--d, 0) * 12px + 6px); border-radius: 5px; white-space: nowrap; }
.tree .dir { color: var(--ink2); } .tree .on { background: rgba(238,122,71,.12); color: var(--ink); box-shadow: inset 2px 0 0 var(--acc); }
.code { font: 12px/1.7 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: var(--dim); }
.code .ln:hover { background: rgba(255,236,214,.05); }
.k1 { color: var(--ink); } .s1 { color: var(--ok); } .c1 { color: var(--dim); } .a1 { color: var(--acc2); }
.term { position: absolute; right: 22px; bottom: 30px; width: 300px; border-radius: 11px; overflow: hidden; z-index: 2; transition: transform .5s var(--spring); }
.s-config:hover .term { transform: translate(-4px, -4px); }
.term .th2 { font: 10.5px var(--mono); color: var(--dim); padding: 6px 10px; border-bottom: 1px solid var(--line); background: rgba(0,0,0,.2); display: flex; gap: 8px; }
.term .tb2 { font: 11px/1.65 var(--mono); color: var(--mute); padding: 7px 10px 9px; font-variant-numeric: tabular-nums; }
.term .tb2 b { color: var(--ink); font-weight: 500; } .term .ok { color: var(--ok); }
/* -- 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(255,236,214,.16); stroke-width: 1; transition: stroke .2s, opacity .2s; }
.s-net .e.bad { stroke-dasharray: 3 4; }
.s-net .p circle { fill: #1d1b18; stroke: var(--ink2); stroke-width: 1.4; transition: fill .2s, stroke .2s; }
.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: 10px var(--mono); fill: var(--ink); opacity: 0; transition: opacity .2s; }
.s-net.hot .e { opacity: .22; }
.s-net.hot .e.lit { opacity: 1; stroke: var(--ink2); }
.s-net.hot .e.lit.bad { stroke: var(--acc); }
.s-net .p.on circle { fill: var(--ink); stroke: var(--ink); filter: drop-shadow(0 0 8px rgba(255,230,200,.7)); }
.s-net .p.on.free circle { fill: var(--acc); stroke: var(--acc); filter: drop-shadow(0 0 10px rgba(238,122,71,.9)); }
.s-net .p.on text { fill: var(--ink); }
.s-net .w.lit { opacity: 1; }
.s-net .halo { fill: none; stroke: rgba(255,230,200,.22); stroke-width: 1; opacity: 0; transform-box: fill-box; transform-origin: center; transform: scale(.6); transition: opacity .2s, transform .45s var(--spring); }
.s-net .halo.bad { stroke: rgba(238,122,71,.45); }
.s-net .halo.on { opacity: 1; transform: none; }
.ro { position: absolute; left: 16px; top: 14px; z-index: 5; font: 11px/1.6 var(--mono); color: var(--mute); background: rgba(30,27,24,.66); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); border: 1px solid var(--line2); border-radius: 10px; padding: 8px 11px; min-width: 214px; box-shadow: inset 0 1px 0 rgba(255,240,222,.08), 0 16px 32px -16px rgba(0,0,0,.9); font-variant-numeric: tabular-nums; }
.ro b { color: var(--ink); font-weight: 500; }
.ro .bad { color: var(--acc2); }
/* -- stage 4: rowing (scrub) -- */
.s-row { min-height: 410px; }
.rowgrid { position: absolute; inset: 28px 30px 46px; display: grid; grid-template-columns: 210px 1fr; grid-template-rows: 1fr auto; gap: 14px 18px; }
.boat, .chart { border-radius: 12px; position: relative; overflow: hidden; }
.boat svg, .chart svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.boat .lbl, .chart .lbl { position: absolute; left: 12px; top: 9px; 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.8; }
.chart .area { fill: url(#cg); }
.chart .grid line { stroke: rgba(255,236,214,.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); filter: drop-shadow(0 0 6px rgba(238,122,71,.9)); }
.chart .rd { position: absolute; right: 12px; top: 8px; font: 10.5px var(--mono); color: var(--mute); z-index: 2; background: rgba(0,0,0,.25); border: 1px solid var(--line); border-radius: 7px; padding: 2px 8px; font-variant-numeric: tabular-nums; }
.chart .rd b { color: var(--acc2); font-weight: 500; }
.pipe { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; font: 11px var(--mono); color: var(--mute); }
.pipe span { border: 1px solid var(--line2); border-radius: 7px; padding: 3px 9px; background: linear-gradient(180deg, #221f1b, #1b1916); color: var(--ink2); box-shadow: var(--hi), 0 6px 14px -8px rgba(0,0,0,.8); }
.pipe em { font-style: normal; color: var(--dim); }
/* index */
.idx { margin-top: 88px; }
.tools { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.tabs button { font-family: var(--sans); font-size: 13px; padding: 4px 11px; }
.search { margin-left: auto; position: relative; }
.search input { background: rgba(0,0,0,.25); border: 1px solid var(--line2); border-radius: 10px; color: var(--ink); font: 13px var(--sans); padding: 7px 36px 7px 12px; width: 280px; outline: none; box-shadow: inset 0 1px 2px rgba(0,0,0,.4); transition: border-color .2s, box-shadow .2s; }
.search input:focus { border-color: rgba(238,122,71,.5); box-shadow: inset 0 1px 2px rgba(0,0,0,.4), 0 0 0 3px rgba(238,122,71,.14); }
.search input::placeholder { color: var(--dim); }
.search kbd { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); }
.list { border-radius: 18px; overflow: hidden; padding: 6px; }
.row { position: relative; z-index: 7; display: grid; grid-template-columns: 64px 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, box-shadow .2s; }
.row + .row::before { content: ""; position: absolute; top: 0; left: 84px; right: 12px; height: 1px; background: var(--line); transition: opacity .2s; }
.row:hover, .row:focus-visible { background: rgba(255,236,214,.045); box-shadow: var(--hi); }
.row:hover::before, .row:hover + .row::before { opacity: 0; }
.row .th { width: 64px; height: 39px; border: 1px solid var(--line2); border-radius: 8px; background: #1b1916; overflow: hidden; transition: transform .4s var(--spring), box-shadow .3s, border-color .2s; box-shadow: 0 4px 10px -6px rgba(0,0,0,.8); }
.row .th svg { width: 100%; height: 100%; display: block; }
.row:hover .th { transform: scale(1.06) rotate(-1deg); border-color: var(--line3); box-shadow: 0 10px 20px -8px rgba(0,0,0,.9); }
.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 .ai { justify-self: start; }
.row .yr { font: 12px var(--mono); color: var(--mute); font-variant-numeric: tabular-nums; }
.row .go { color: var(--dim); transition: transform .35s var(--spring), color .15s; }
.row:hover .go { color: var(--acc); transform: translateX(3px); }
.row.in { animation: rise .36s var(--ease) both; animation-delay: calc(var(--i, 0) * 22ms); }
.empty { padding: 18px 12px; font: 13px var(--mono); color: var(--mute); }
.hiddenby { font: 12px var(--mono); color: var(--mute); padding: 12px 8px; }
/* floating preview next to the pointer (list rows) */
.hp { position: fixed; left: 0; top: 0; z-index: 60; width: 330px; pointer-events: none; opacity: 0; transform: translate3d(var(--hx, 0), var(--hy, 0), 0) scale(.94); transform-origin: 0 0; transition: opacity .18s var(--ease), transform .45s var(--spring); border-radius: 12px; overflow: hidden; }
.hp.on { opacity: 1; transform: translate3d(var(--hx, 0), var(--hy, 0), 0) scale(1); }
/* drawn frame (r3-a drawings, re-lit for dark) */
.frame { border-radius: 12px; overflow: hidden; border: 1px solid var(--line2); background: #1a1815; box-shadow: inset 0 1px 0 rgba(255,240,222,.07), 0 2px 4px rgba(0,0,0,.35), 0 30px 60px -24px rgba(0,0,0,.9); }
.fbar { height: 28px; display: flex; align-items: center; gap: 10px; padding: 0 11px; border-bottom: 1px solid var(--line); background: rgba(0,0,0,.22); font: 10.5px var(--mono); color: var(--mute); }
.fbar .dotz { display: flex; gap: 5px; } .fbar .dotz i { width: 7px; height: 7px; border-radius: 50%; background: #3a3530; }
.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: #e9e1d5; } .k-2 { fill: #b3aa9d; } .k-3 { fill: #6f675d; } .k-4 { fill: #36322c; } .k-5 { fill: #1f1d1a; } .k-w { fill: #2a2722; } .k-acc { fill: var(--acc); } .k-at { fill: rgba(238,122,71,.18); } .nf { fill: none; } .pc { font-family: 'DejaVu Sans', 'Segoe UI Symbol', serif; }
.ln { stroke: #2f2b27; stroke-width: 1; fill: none; } .ln2 { stroke: #423d37; stroke-width: 1; fill: none; }
.sk { stroke: #d8cfc2; fill: none; stroke-width: 1.5; } .ska { stroke: var(--acc); fill: none; stroke-width: 1.5; }
.tx { font-size: 11px; fill: #9a9286; } .tb { font-size: 11px; fill: var(--ink); font-weight: 600; } .tm { font-family: var(--mono); }
.tw { fill: #16130f; font-size: 11px; font-weight: 500; } .ta { fill: var(--acc2); font-size: 11px; } .t3 { fill: #6f675d; font-size: 9px; letter-spacing: .06em; }
/* writing + history */
.lower { margin-top: 88px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
.card { border-radius: 20px; padding: 24px 26px; }
.card .sh h2 { font-size: 24px; }
.posts a { position: relative; z-index: 7; display: grid; grid-template-columns: 92px 1fr auto; gap: 14px; text-decoration: none; padding: 11px 10px; margin: 0 -10px; border-radius: 10px; align-items: baseline; transition: background .2s; }
.posts a + a { box-shadow: 0 -1px 0 var(--line); }
.posts a:hover { background: rgba(255,236,214,.04); box-shadow: var(--hi); }
.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); border: 1px solid var(--line); border-radius: 99px; padding: 0 8px; }
.posts a:hover .t { color: var(--acc2); }
.jobsx { margin: 0; padding: 0; list-style: none; position: relative; z-index: 7; }
.jobsx > li { display: grid; grid-template-columns: 110px 1fr; gap: 14px; padding: 13px 0; border-top: 1px solid var(--line); position: relative; }
.jobsx > li:first-child { border-top: 0; padding-top: 4px; }
.jobsx .when { font: 11.5px var(--mono); color: var(--dim); padding-top: 3px; font-variant-numeric: tabular-nums; }
.jobsx > li:first-child .when { color: var(--acc2); }
.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 ul li::marker { color: var(--dim); }
.jobsx .chips { margin-top: 9px; }
footer { margin-top: 64px; border-top: 1px solid 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; transition: color .15s; } footer a:hover { color: var(--acc2); }
footer .sp { margin-left: auto; color: var(--dim); }
/* mockup-only gate suggestion */
.gs { margin: 8px auto 48px; max-width: 1320px; padding: 0 40px; }
.gs .box { border: 1px dashed var(--line2); border-radius: 16px; padding: 18px 20px; 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: 12px; overflow: hidden; border: 1px solid var(--line2); box-shadow: var(--sh2); }
.mini .l { background: radial-gradient(300px circle at 70% 30%, rgba(238,122,71,.12), transparent 70%), linear-gradient(#141311, #0e0d0b); 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); font-weight: 500; }
.mini .l h5 { margin: 6px 0 4px; font: 600 38px/1 var(--sans); letter-spacing: -.05em; color: var(--ink); }
.mini .l .le { font-size: 11px; color: var(--ink2); max-width: 17em; }
.mini .l .rows { margin-top: 10px; width: 190px; padding: 4px 8px; border-radius: 8px; background: rgba(255,236,214,.03); border: 1px solid var(--line); }
.mini .l .rows div { display: flex; justify-content: space-between; gap: 8px; font-size: 10px; color: var(--ink2); padding: 4px 0; }
.mini .l .rows div + div { border-top: 1px solid var(--line); }
.mini .l .rows .ai { font-size: 9px; border: 0; padding: 0; background: none; }
.mini .l .rows .ai i { width: 7px; height: 7px; }
.mini .l .en { margin-top: 10px; font: 10px var(--mono); color: var(--ink); }
.mini .l .thumb { position: absolute; left: 14px; top: 50%; transform: translateY(-50%) rotate(-3deg); width: 104px; border-radius: 7px; overflow: hidden; border: 1px solid var(--line2); box-shadow: 0 16px 30px -12px rgba(0,0,0,.9); }
.mini .l .thumb svg { display: block; width: 100%; height: auto; }
.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 */
.scrim { position: fixed; inset: 0; z-index: 70; background: rgba(8,7,6,.5); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); opacity: 0; pointer-events: none; transition: opacity .3s var(--ease); }
.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: 20px; overflow: hidden;
background: linear-gradient(180deg, rgba(30,27,24,.86), rgba(22,20,18,.9)); -webkit-backdrop-filter: blur(28px) saturate(1.4); backdrop-filter: blur(28px) saturate(1.4);
border: 1px solid var(--line2); box-shadow: inset 0 1px 0 rgba(255,240,222,.09), 0 40px 100px -30px rgba(0,0,0,.9), -30px 0 80px -40px rgba(0,0,0,.7);
transform: translateX(calc(100% + 30px)); visibility: hidden; transition: transform .4s var(--ease), visibility 0s .4s; display: flex; flex-direction: column; }
.sheet.on { transform: none; visibility: visible; transition: transform .55s var(--spring), visibility 0s; }
.sheet .top { display: flex; align-items: center; gap: 6px; padding: 12px 14px 12px 20px; border-bottom: 1px solid var(--line); font: 12px var(--mono); color: var(--mute); background: rgba(0,0,0,.12); font-variant-numeric: tabular-nums; }
.sheet .top .pos { margin-right: auto; }
.ib { display: inline-flex; align-items: center; gap: 6px; background: rgba(255,236,214,.03); border: 1px solid var(--line2); border-radius: 8px; color: var(--ink2); font: 12px var(--mono); padding: 4px 9px; cursor: pointer; box-shadow: var(--hi); transition: color .15s, border-color .15s, background .15s; }
.ib:hover { color: var(--ink); border-color: var(--line3); background: rgba(255,236,214,.06); }
.ib:disabled { opacity: .35; cursor: default; }
.sbody { overflow-y: auto; padding: 22px 28px 44px; flex: 1; view-transition-name: sheetbody; scrollbar-width: thin; scrollbar-color: #3a3530 transparent; }
.sheet.entering .sbody > * { animation: rise .5s var(--ease) both; animation-delay: calc(var(--i, 0) * 35ms + 90ms); }
.sbody .vis { position: relative; padding: 26px 34px 34px; border-radius: 16px; border: 1px solid var(--line);
background: radial-gradient(ellipse 70% 70% at 50% 60%, rgba(238,122,71,.12), transparent 70%), radial-gradient(rgba(255,236,214,.06) 1px, transparent 1px) 0 0 / 16px 16px, #12110f; }
.sbody .vis .frame { transform: perspective(1200px) rotateX(4deg); transform-origin: 50% 100%; }
.sbody .vis .cap { position: absolute; left: 14px; bottom: 9px; font: 10.5px var(--mono); color: var(--dim); }
.sbody h2 { font-size: 38px; letter-spacing: -.045em; line-height: 1.02; margin: 24px 0 10px; font-weight: 600; }
.sbody .lead { font-size: 17px; color: var(--ink2); margin: 12px 0 20px; letter-spacing: -.01em; }
.sbody section { border-top: 1px solid var(--line); padding: 15px 0; display: grid; grid-template-columns: 116px 1fr; gap: 14px; }
.sbody section h4 { margin: 0; font: 500 10.5px var(--mono); color: var(--dim); text-transform: uppercase; letter-spacing: .08em; padding-top: 4px; }
.sbody section p { margin: 0; color: var(--ink2); font-size: 14.5px; }
.parts { list-style: none; margin: 0; padding: 0; }
.parts li { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px dashed var(--line2); 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: rgba(255,236,214,.035); border: 1px solid var(--line2); border-radius: 8px; padding: 4px 10px; font-size: 13px; color: var(--ink2); cursor: pointer; box-shadow: var(--hi); transition: border-color .15s, color .15s, transform .3s var(--spring); }
.rel button:hover { border-color: var(--line3); color: var(--ink); transform: translateY(-1px); }
.sposts a { display: block; text-decoration: none; font-size: 14px; color: var(--ink2); padding: 2px 0; }
.sposts a:hover { color: var(--acc2); }
::view-transition-old(sheetbody) { animation: vtout .14s var(--ease) both; }
::view-transition-new(sheetbody) { animation: vtin .3s var(--ease) both; }
@keyframes vtout { to { opacity: 0; transform: translateX(-12px); filter: blur(2px); } }
@keyframes vtin { from { opacity: 0; transform: translateX(14px); filter: blur(2px); } }
/* command palette */
dialog.pal { border: 0; padding: 0; background: transparent; color: var(--ink); width: min(820px, calc(100vw - 32px)); max-width: none; margin-top: 12vh; overflow: visible; }
dialog.pal::backdrop { background: rgba(8,7,6,.55); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
dialog.pal[open] .pbox { animation: palin .32s var(--spring) both; }
dialog.pal[open]::backdrop { animation: fadein .2s ease-out; }
@keyframes palin { from { opacity: 0; transform: translateY(-8px) scale(.975); filter: blur(4px); } to { opacity: 1; transform: none; filter: none; } }
@keyframes fadein { from { opacity: 0; } }
.pbox { border-radius: 16px; overflow: hidden; border: 1px solid var(--line2); background: linear-gradient(180deg, rgba(34,31,27,.88), rgba(24,22,19,.92)); -webkit-backdrop-filter: blur(30px) saturate(1.5); backdrop-filter: blur(30px) saturate(1.5); box-shadow: inset 0 1px 0 rgba(255,240,222,.1), 0 50px 120px -30px rgba(0,0,0,.95), 0 0 0 1px rgba(0,0,0,.4); }
.pal-in { display: flex; align-items: center; gap: 12px; padding: 0 18px; border-bottom: 1px solid var(--line); }
.pal-in svg { flex: none; color: var(--mute); }
.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-grid { display: grid; grid-template-columns: 1fr 300px; min-height: 360px; }
.pal ul { list-style: none; margin: 0; padding: 6px; max-height: 400px; overflow: auto; scrollbar-width: thin; scrollbar-color: #3a3530 transparent; }
.pal .g { font: 500 10.5px var(--mono); color: var(--dim); padding: 12px 10px 5px; letter-spacing: .06em; text-transform: uppercase; }
.pal .it { position: relative; display: flex; align-items: center; gap: 11px; padding: 7px 10px; border-radius: 9px; cursor: pointer; font-size: 14px; color: var(--ink2); }
.pal .it .k { font: 10.5px var(--mono); color: var(--mute); width: 24px; height: 22px; border-radius: 6px; display: grid; place-items: center; background: rgba(255,236,214,.04); border: 1px solid var(--line); flex: none; }
.pal .it .lbl { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pal .it .s { margin-left: auto; font-size: 12px; color: var(--dim); white-space: nowrap; }
.pal .it .ai { margin-left: auto; }
.pal .it.on { background: rgba(255,236,214,.07); color: var(--ink); box-shadow: var(--hi); }
.pal .it.on .k { color: var(--acc2); border-color: rgba(238,122,71,.4); background: rgba(238,122,71,.1); }
.pal .nores { padding: 18px 10px; color: var(--mute); font-size: 14px; }
.pv { border-left: 1px solid var(--line); padding: 16px; display: flex; flex-direction: column; gap: 12px; background: rgba(0,0,0,.12); min-width: 0; }
.pv .frame { transform: none; }
.pv h5 { margin: 0; font-size: 18px; letter-spacing: -.03em; font-weight: 600; line-height: 1.15; }
.pv p { margin: 0; font-size: 13px; color: var(--ink2); }
.pv .meta { font-size: 11px; }
.pv .big { font: 600 44px/1 var(--sans); letter-spacing: -.04em; color: var(--dim); }
.pv.swap > * { animation: rise .28s var(--ease) both; }
.pal-foot { display: flex; gap: 16px; align-items: center; padding: 9px 16px; border-top: 1px solid var(--line); font-size: 11.5px; color: var(--mute); background: rgba(0,0,0,.18); }
.pal-foot .sp { margin-left: auto; font-family: var(--mono); color: var(--dim); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0s !important; animation: none !important; }
.tiltw, .f1, .f2, .f3 { 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; border-left: 0; border-bottom: 1px solid var(--line); }
.feat.wide .stage { border-right: 0; border-bottom: 1px solid var(--line); }
.row { grid-template-columns: 64px 1fr 1.6fr 132px 20px; }
.row .stk, .row .yr { display: none; }
.gs .box { grid-template-columns: 1fr; }
.pal-grid { grid-template-columns: 1fr; } .pv { display: none; }
}
@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 > span { display: none; }
.intro { grid-template-columns: 1fr; padding-top: 28px; } .intro h1 { font-size: 36px; }
.now { grid-template-columns: 1fr; }
.feats { grid-template-columns: 1fr; }
.feat h3, .feat:not(.wide) h3 { font-size: 28px; }
.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, .f3 { display: none; }
.jobs .jr { grid-template-columns: 1.4fr 1.4fr .8fr; } .jobs .jr > :nth-child(3) { display: none; }
.ed { grid-template-columns: 1fr; inset: 22px 16px 70px; } .tree { display: none; } .term { width: 240px; right: 12px; }
.rowgrid { grid-template-columns: 1fr; grid-template-rows: 150px 1fr auto; } .s-row { min-height: 520px; } .pipe { flex-wrap: wrap; }
.row { grid-template-columns: 64px 1fr 20px; gap: 12px; } .row .one, .row .ai { display: none; } .row + .row::before { 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: 230px; } .stage .hint, .sh .r { display: none; }
.mini { grid-template-columns: 1fr; height: auto; } .mini .l .thumb { display: none; } .mini .l, .mini .r { min-height: 150px; }
.sbody { padding: 18px 18px 40px; } .sbody section { grid-template-columns: 1fr; gap: 6px; } .sbody .vis { padding: 14px; }
}
@media (hover: none) and (pointer: coarse) { .hp { display: none; } }
</style>
</head>
<body>
<div class="mk" id="mk"><button onclick="document.getElementById('mk').remove()" aria-label="close">×</button>
<b>tech-r4-a · Sleek, dark</b>r3-c's structure and showcases, taken to product quality: layered warm surfaces, a light that follows the pointer on every panel, a blurred bar and side sheet, ⌘K palette with live previews, and spring motion only when you open things. Linear / Raycast / Vercel craft level, in warm graphite with one orange.</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><span class="mark" aria-hidden="true">LW</span><b>Lucas Winther</b><span class="sl">/</span><span style="color:var(--mute)">work</span></div>
<nav class="nav" id="nav" aria-label="sections"><span class="ind" id="navind"></span><a href="#featured" data-sec="featured">Featured</a><a href="#all" data-sec="all">All projects</a><a href="#writing" data-sec="writing">Writing</a><a href="#cv" data-sec="cv">CV</a></nav>
<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 class="pref"><span>made</span>
<div class="seg acc" id="prefseg" role="group" aria-label="filter projects by how much AI was used">
<span class="thumb"></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>
</div>
</header>
<main>
<div class="wrap">
<section class="intro">
<div>
<div class="eyebrow rise"><i></i>IT Research Tech · Aarhus University</div>
<h1 class="rise" style="--d:1">I build and run research software. <em>On the side: small tools, games and infrastructure.</em></h1>
<p class="rise" style="--d:2">Four projects told properly, then everything else. Each is marked with how much of it AI made, and the filter up top hides what you'd rather not see. Press <kbd>⌘K</kbd> to jump anywhere.</p>
</div>
<div class="legend raised glow rise" style="--d:3" aria-label="legend">
<h4>AI marks</h4>
<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>not recorded</span>
</div>
</section>
<section class="now" aria-label="now, latest writing, cv">
<div class="raised glow rise" style="--d:3">
<h3><span>Now</span><span class="ph" title="placeholder">updated 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 class="raised glow rise" style="--d:4">
<h3><span>Latest writing</span><a href="#writing">all 5 ↓</a></h3>
<div class="wl" id="wl"></div>
</div>
<div class="raised glow rise" style="--d:5" 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 projects</span><span class="r">the drawings react to the pointer · click a panel for the full story</span></div>
<div class="feats">
<!-- 1 compute -->
<article class="feat wide raised glow" data-id="compute">
<div class="stage s-compute" data-fx="tilt" aria-hidden="true">
<div class="bloom"></div>
<div class="tiltw">
<div class="win">
<div class="tb"><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">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 active</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>+2 nodes</b><span class="mono">Oracle Cloud · booting, ~40 s</span><div class="bar2"><i></i></div></div>
<div class="float f2"><b>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 class="dot"></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. <button class="linkish" data-showanyway>show anyway</button></div>
</article>
<!-- 2 config -->
<article class="feat raised glow" data-id="config">
<div class="stage s-config" data-fx="spot" aria-hidden="true">
<div class="ed">
<div class="etabs"><span class="on">site.yml</span><span>hosts.yml</span><span>compose.yml.j2</span></div>
<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 class="term"><div class="th2"><span>zsh</span><span>~/config</span></div><div class="tb2">$ <b>ansible-playbook site.yml</b><br>PLAY RECAP<br><span title="placeholder">nas</span> &nbsp;ok=41 <span class="ok">changed=0</span> failed=0<br><span title="placeholder">pi</span> &nbsp;&nbsp;ok=17 <span class="ok">changed=0</span> failed=0</div></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="dot"></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">Hidden by your filter. <button class="linkish" data-showanyway>show anyway</button></div>
</article>
<!-- 3 hypercache -->
<article class="feat raised glow" 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="dot"></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 raised glow" 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="hull" x1="0" x2="1"><stop offset="0" stop-color="#2c2823"/><stop offset=".5" stop-color="#3a352f"/><stop offset="1" stop-color="#2c2823"/></linearGradient></defs>
<path d="M105 30 C 128 80, 128 220, 105 272 C 82 220, 82 80, 105 30 Z" fill="url(#hull)" stroke="#8f887d" stroke-width="1.2"/>
<rect x="94" y="150" width="22" height="14" rx="3" fill="none" stroke="#645e56"/>
<g class="oar" id="oarL" style="transform-origin:96px 140px"><line x1="96" y1="140" x2="18" y2="140" stroke="#d8cfc2" stroke-width="2"/><rect x="6" y="134" width="20" height="12" rx="3" fill="#d8cfc2"/></g>
<g class="oar" id="oarR" style="transform-origin:114px 140px"><line x1="114" y1="140" x2="192" y2="140" stroke="#d8cfc2" stroke-width="2"/><rect x="184" y="134" width="20" height="12" rx="3" fill="#d8cfc2"/></g>
<circle cx="96" cy="140" r="3" fill="#ee7a47"/><circle cx="114" cy="140" r="3" fill="#ee7a47"/>
<text x="105" y="292" text-anchor="middle" font-family="Geist Mono" font-size="10" fill="#645e56" 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>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="dot"></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 projects</span><span class="r">hover a row for a preview</span></div>
<div class="tools">
<div class="seg 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 raised glow" id="list"></div>
<div class="hiddenby" id="hiddenby"></div>
</section>
<!-- ================= writing + cv ================= -->
<div class="lower">
<section class="card raised glow" id="writing">
<div class="sh"><h2>Writing</h2><span class="n">AI, devops, homelab</span><a class="r" href="https://blog.lucaswinther.info" style="position:relative;z-index:7">blog.lucaswinther.info →</a></div>
<div class="posts" id="posts"></div>
</section>
<section class="card raised glow" id="cv">
<div class="sh"><h2>Where I've worked</h2><a class="r" href="#" onclick="return false" title="placeholder: CV PDF" style="position:relative;z-index:7">↓ 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="#" 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="sp">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 half goes warm graphite with the same faint orange bloom, Geist set big and tight. The three featured rows sit on a small raised panel with the AI marks; the pointer light from this page runs along its border.</p>
<p>The two halves then differ by hue and texture rather than light vs dark: lit graphite and crisp sans on the left, grainy plum and Fraunces on the right. On hover, a tilted drawn preview of the compute platform slides in where the grid paper used to be.</p>
<p>"Enter work" then needs no transition: the gate is already this page's colours.</p>
</div>
<div class="mini" aria-hidden="true">
<div class="l">
<div class="thumb" id="gthumb"></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="hp frame" id="hp" 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">←</button><button class="ib" id="snext" aria-label="next project">→</button><button class="ib" id="sclose" aria-label="close"><kbd>esc</kbd> close</button></div>
<div class="sbody" id="sbody"></div>
</aside>
<dialog class="pal" id="pal" aria-label="Jump to">
<div class="pbox">
<div class="pal-in"><svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7" cy="7" r="5"/><path d="M11 11l3.5 3.5"/></svg><input id="palq" placeholder="Jump to a project, post, section or filter…" autocomplete="off" spellcheck="false" aria-controls="pallist"><kbd>esc</kbd></div>
<div class="pal-grid">
<ul id="pallist" role="listbox"></ul>
<div class="pv" id="pv"></div>
</div>
<div class="pal-foot"><span><kbd>↑</kbd> <kbd>↓</kbd> move</span><span><kbd>↵</kbd> open</span><span><kbd>esc</kbd> close</span><span class="sp" id="palcount"></span></div>
</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="#34302b"');
['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="#ede7dd"');
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="#4a443d"') + 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="#3a3631"') + 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="#1e1c19" 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="#5a534b"') + (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="#3a3631"');
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="#ede7dd"') + 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="#4a443d" 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="#ea7444" 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="#ea7444"' : ' stroke="#3a3631"') + 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="#ede7dd"') + 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="#ede7dd"') + 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="#efe7da" stroke="#0e0d0b" stroke-width=".9"' : ' fill="#0e0d0b" stroke="#8e877c" stroke-width=".9"'); }
}
s += R(30, 30, 296, 296, 'nf', 0, ' stroke="#4a443d"');
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="#ede7dd"');
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="#c8c0b4"');
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="#1a1815"/><rect width="180" height="360" fill="#221f1b"/>' + '<circle cx="90" cy="80" r="34" fill="#47413a"/>' + T(90, 140, 'Lucas Winther', 'tw', 13, 'middle') + T(90, 160, 'Computer Scientist with', '', 9.5, 'middle', ' fill="#9a9286"') + T(90, 173, 'passion for development.', '', 9.5, 'middle', ' fill="#9a9286"');
for (var i = 0; i < 4; i++) s += '<circle cx="' + (54 + i * 24) + '" cy="204" r="8" fill="#3a3530"/>';
s += '<rect x="200" y="20" width="380" height="30" rx="4" fill="#2a2723"/>' + T(214, 39, 'Search projects…', '', 10.5, null, ' fill="#7d766c"');
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="#2a2723"/><rect x="' + x + '" y="' + y + '" width="120" height="62" rx="4" fill="#36312b"/><rect x="' + (x + 10) + '" y="' + (y + 76) + '" width="' + (70 + (j * 13) % 30) + '" height="7" rx="3" fill="#c8c0b4"/><rect x="' + (x + 10) + '" y="' + (y + 92) + '" width="96" height="5" rx="2.5" fill="#4f4942"/><rect x="' + (x + 10) + '" y="' + (y + 102) + '" width="70" height="5" rx="2.5" fill="#4f4942"/><rect x="' + (x + 10) + '" y="' + (y + 116) + '" width="34" height="10" rx="5" fill="#8e877c" 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="#7a7268"') + '<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="#ea7444" stroke-width="2"') + C(n[0], n[1], 5, 'k-acc') : C(n[0], n[1], 11, 'k-w', ' stroke="#8e877c" 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="#7a7268"') + (r[2] ? '<path d="M47 ' + (y + 18) + ' l3 3 l5 -6" stroke="#1e1c19" stroke-width="1.6" fill="none"/>' : '') + T(68, y + 17, r[0], 'tb', 11, null, r[2] ? ' text-decoration="line-through" fill="#7a7268"' : '') + 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="#8e877c" 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="#7a7268"');
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="#4a443d"') + 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="#8e877c" 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="#3a3631"') + 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="#7a7268" 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="#7a7268"');
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="#8e877c" stroke-width="1.5"/>';
s += R(140, 90, 320, 120, 'k-w', 6, ' stroke="#4a443d"') + 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="#8e877c"') + lines(142, 278, [110, 90, 100], 10) + '</g>';
s += '<g transform="rotate(6 400 292)">' + R(330, 266, 140, 52, 'k-w', 2, ' stroke="#8e877c"') + lines(342, 278, [110, 90, 100], 10) + R(352, 274, 96, 28, 'nf', 3, ' stroke="#ea7444" 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.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>';
}
function frame(id) { return '<div class="frame">' + frameHTML(id) + '</div>'; }
var REDUCE = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* ---------- segmented controls: sliding thumb ---------- */
function segThumb(seg, instant) {
var th = seg.querySelector('.thumb'), b = seg.querySelector('button[aria-pressed="true"]');
if (!th || !b) return;
if (instant) th.style.transition = 'none';
th.style.width = b.offsetWidth + 'px';
th.style.transform = 'translateX(' + b.offsetLeft + 'px)';
if (instant) { th.offsetWidth; th.style.transition = ''; }
}
function allSegs(instant) { document.querySelectorAll('.seg').forEach(function (s) { segThumb(s, instant); }); }
/* ---------- 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(animate) {
document.querySelectorAll('#prefseg button').forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.pref === PREF)); });
segThumb($('prefseg'));
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(animate);
}
function setPref(v) { PREF = v; SHOWANY = {}; applyPref(true); }
document.querySelectorAll('#prefseg button').forEach(function (b) { b.addEventListener('click', function () { setPref(b.dataset.pref); }); });
document.querySelectorAll('[data-showanyway]').forEach(function (b) {
b.addEventListener('click', function (e) { e.stopPropagation(); 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"></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)); });
segThumb($('tabs')); renderList(true);
});
});
}
var LISTIDS = [];
function renderList(animate) {
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, i) {
return '<button class="row' + (animate ? ' in' : '') + '" style="--i:' + i + '" 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 + ' projects';
$('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 () { openSheet(r.dataset.id, LISTIDS, r); }); });
}
$('q').addEventListener('input', function (e) { Q = e.target.value.trim().toLowerCase(); renderList(true); });
/* ---------- floating preview beside the pointer ---------- */
(function () {
var hp = $('hp'), cur = null;
function place(e) {
var w = 330, h = hp.offsetHeight || 230, x = e.clientX + 28, y = e.clientY - h / 2;
if (x + w > window.innerWidth - 60) x = e.clientX - w - 28;
y = Math.max(72, Math.min(window.innerHeight - h - 12, y));
hp.style.setProperty('--hx', x + 'px'); hp.style.setProperty('--hy', y + 'px');
}
$('list').addEventListener('mousemove', function (e) {
var r = e.target.closest('.row');
if (!r) { hp.classList.remove('on'); cur = null; return; }
if (r.dataset.id !== cur) {
var first = !hp.classList.contains('on');
cur = r.dataset.id; hp.innerHTML = frameHTML(cur);
if (first) { hp.style.transition = 'none'; place(e); hp.offsetWidth; hp.style.transition = ''; }
hp.classList.add('on');
}
place(e);
});
$('list').addEventListener('mouseleave', function () { hp.classList.remove('on'); cur = null; });
window.hideHP = function () { hp.classList.remove('on'); cur = null; };
})();
/* ---------- 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>' + (i === 0 ? '<p class="wlb">' + w.blurb + '</p>' : '');
}).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, enterT = 0;
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">' + frame(p.id) + '<span class="cap">drawn stand-in for a screenshot · contents invented</span></div>' +
'<h2 id="sTitle">' + p.name + '</h2>' +
'<div class="meta"><span>' + GROUPS[p.g] + '</span>' + (p.where ? '<span class="dot"></span><span>' + p.where + '</span>' : '') + '<span class="dot"></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) {
var p = P(id); CUR = id;
$('sbody').innerHTML = sheetHTML(p);
Array.prototype.forEach.call($('sbody').children, function (c, i) { c.style.setProperty('--i', Math.min(i, 10)); });
$('sbody').scrollTop = 0;
var i = CTX.indexOf(id);
$('spos').textContent = i >= 0 ? (i + 1) + ' / ' + CTX.length + (p.feat ? ' · featured' : ' · in list') : 'related project';
$('sprev').disabled = i <= 0; $('snext').disabled = i < 0 || i >= CTX.length - 1;
$('sbody').querySelectorAll('[data-rel]').forEach(function (b) { b.addEventListener('click', function () { swap(b.dataset.rel); }); });
history.replaceState(null, '', '#/' + id);
}
function swap(id) { $('sheet').classList.remove('entering'); if (document.startViewTransition && !REDUCE) document.startViewTransition(function () { fill(id); }); else fill(id); }
function openSheet(id, ctx, from) {
if ($('sheet').classList.contains('on')) { CTX = ctx || CTX; swap(id); return; }
CTX = ctx || []; RETURN = from || document.activeElement;
hideHP();
fill(id);
var sh = $('sheet');
sh.classList.add('entering', 'on'); $('scrim').classList.add('on'); sh.setAttribute('aria-hidden', 'false');
clearTimeout(enterT); enterT = setTimeout(function () { sh.classList.remove('entering'); }, 1000);
document.documentElement.style.overflow = 'hidden';
setTimeout(function () { $('sclose').focus({ preventScroll: true }); }, 40);
}
function closeSheet() {
if (!$('sheet').classList.contains('on')) return;
$('sheet').classList.remove('on', 'entering'); $('scrim').classList.remove('on'); $('sheet').setAttribute('aria-hidden', 'true');
document.documentElement.style.overflow = '';
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; }); }
document.querySelectorAll('[data-open]').forEach(function (b) {
b.addEventListener('click', function (e) { e.stopPropagation(); openSheet(b.dataset.open, P(b.dataset.open).feat ? featCtx() : [], b); });
});
document.querySelectorAll('.feat').forEach(function (f) {
f.addEventListener('click', function (e) {
if (f.classList.contains('filtered') || e.target.closest('a, button')) return;
openSheet(f.dataset.id, featCtx(), f.querySelector('.open'));
});
});
/* ---------- keyboard ---------- */
document.addEventListener('keydown', function (e) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { 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') {
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 && document.activeElement.tagName !== 'INPUT') { e.preventDefault(); $('q').scrollIntoView({ block: 'center', behavior: REDUCE ? 'auto' : 'smooth' }); $('q').focus({ preventScroll: true }); }
});
/* ---------- pointer light on panels ---------- */
document.addEventListener('pointermove', function (e) {
var g = e.target.closest && e.target.closest('.glow'); if (!g) return;
var r = g.getBoundingClientRect();
g.style.setProperty('--mx', (e.clientX - r.left) + 'px'); g.style.setProperty('--my', (e.clientY - r.top) + 'px');
}, { passive: true });
/* ---------- bar: scrolled state + section indicator ---------- */
(function () {
var bar = $('bar'), ind = $('navind'), links = document.querySelectorAll('#nav a'), last = null, ticking = false;
function upd() {
ticking = false;
bar.classList.toggle('scrolled', window.scrollY > 4);
var on = null;
['featured', 'all', 'writing'].forEach(function (id) { var el = $(id); if (el.getBoundingClientRect().top < 140) on = id; });
if (on === 'writing' && $('cv').getBoundingClientRect().top < 140 && (window.innerHeight + window.scrollY) >= document.body.scrollHeight - 300) on = 'cv';
if (on === last) return; last = on;
links.forEach(function (a) { a.classList.toggle('on', a.dataset.sec === on); });
var a = on && document.querySelector('#nav a[data-sec="' + on + '"]');
if (!a) { ind.style.opacity = 0; return; }
ind.style.opacity = 1; ind.style.width = a.offsetWidth + 'px'; ind.style.transform = 'translateX(' + a.offsetLeft + 'px)';
}
window.addEventListener('scroll', function () { if (!ticking) { ticking = true; requestAnimationFrame(upd); } }, { passive: true });
upd();
})();
/* ---------- 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 ? 'nw' : v >= 7 ? 'hi' : '') + '" style="height:' + (4 + v * 4) + 'px"></i>'; }).join('');
if (REDUCE) return;
var st = document.querySelector('[data-fx="tilt"]'), w = st.querySelector('.tiltw');
st.addEventListener('mousemove', function (e) {
var r = st.getBoundingClientRect();
w.style.setProperty('--tx', ((e.clientX - r.left) / r.width * 2 - 1).toFixed(3));
w.style.setProperty('--ty', ((e.clientY - r.top) / r.height * 2 - 1).toFixed(3));
});
st.addEventListener('mouseleave', function () { w.style.setProperty('--tx', 0); w.style.setProperty('--ty', 0); });
})();
/* ---------- stage 2: spotlight ---------- */
(function () {
var st = document.querySelector('[data-fx="spot"]');
st.addEventListener('mousemove', function (e) {
var r = st.getBoundingClientRect();
st.style.setProperty('--mx', (e.clientX - r.left) + 'px'); st.style.setProperty('--my', (e.clientY - r.top) + 'px');
});
})();
/* ---------- stage 3: peers ---------- */
(function () {
var N = [
[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 += '<circle class="halo' + (n[4] ? ' bad' : '') + '" data-i="' + i + '" cx="' + n[0] + '" cy="' + n[1] + '" r="17"/>'; });
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] + 13) + '" 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, .halo').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="cg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ee7a47" stop-opacity=".22"/><stop offset="1" stop-color="#ee7a47" 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"/></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); });
})();
/* ---------- command palette ---------- */
var pal = $('pal'), palq = $('palq'), pallist = $('pallist'), pv = $('pv'), palItems = [], palOn = 0;
var CMDS = [];
function jump(sec) { var el = $(sec); if (el) el.scrollIntoView({ behavior: REDUCE ? 'auto' : 'smooth' }); }
function projCtx(id) { return P(id).feat ? featCtx() : (LISTIDS.indexOf(id) >= 0 ? LISTIDS : []); }
PROJECTS.forEach(function (p, i) {
CMDS.push({ g: 'Projects', k: String(i + 1).padStart(2, '0'), label: strip(p.name), sub: strip(p.one), p: p, run: function () { openSheet(p.id, projCtx(p.id)); } });
});
[['featured', 'Featured projects', 'the four told properly', '◆'], ['all', 'All projects', 'filterable list of the other 13', '≡'], ['writing', 'Writing', 'AI, devops, homelab', '¶'], ['cv', 'Where I\'ve worked', 'full work history and degree', '§'], ['cv10', 'CV in ten seconds', 'job, degree, contact, PDF', '§']].forEach(function (s) {
CMDS.push({ g: 'Sections', k: s[3], label: s[1], sub: s[2], run: function () { closeSheet(); jump(s[0]); } });
});
POSTS.forEach(function (w) { CMDS.push({ g: 'Writing', k: '¶', label: w.t, sub: w.tag + ' · ' + w.d, w: w, run: function () { location.href = 'https://blog.lucaswinther.info'; } }); });
[['all', 'Show projects made any way'], ['nofull', 'Hide projects made fully by AI'], ['hand', 'Only projects made by hand']].forEach(function (f) {
CMDS.push({ g: 'Filter', k: '◐', label: f[1], sub: '', f: f[0], run: function () { setPref(f[0]); closeSheet(); jump('all'); } });
});
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'; } });
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'; } });
function score(c, q) {
if (!q) return 1; var hay = (c.label + ' ' + c.sub + ' ' + c.g + (c.p ? ' ' + c.p.stack.map(strip).join(' ') : '')).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 = [];
['Projects', 'Sections', 'Writing', 'Filter', 'Go'].forEach(function (g) {
var gi = items.filter(function (x) { return x.c.g === g; });
if (!gi.length) return; h += '<li class="g" role="presentation">' + g + '</li>';
gi.forEach(function (x) {
ordered.push(x);
h += '<li class="it" role="option" data-i="' + idx + '"><span class="k">' + x.c.k + '</span><span class="lbl">' + esc(x.c.label) + '</span>' + (x.c.p ? aiMark(x.c.p.ai) : x.c.f && x.c.f === PREF ? '<span class="s">current</span>' : x.c.sub && !x.c.w ? '<span class="s">' + esc(x.c.sub) + '</span>' : '') + '</li>';
idx++;
});
});
palItems = ordered; palOn = 0;
pallist.innerHTML = h || '<li class="nores">Nothing found for “' + esc(q) + '”.</li>';
$('palcount').textContent = ordered.length + ' results';
markPal();
}
function previewFor(c) {
if (c.p) { var p = c.p; return frame(p.id) + '<div class="meta"><span>' + GROUPS[p.g] + '</span><span class="dot"></span><span>' + p.yr + '</span>' + aiMark(p.ai) + '</div><h5>' + p.name + '</h5><p>' + p.one + '</p>' + (allowed(p) ? '' : '<p style="color:var(--mute);font-size:12px">hidden by your filter; opening it anyway is fine</p>'); }
if (c.w) return '<div class="big">¶</div><div class="meta"><span>' + c.w.tag + '</span><span class="dot"></span><span class="ph" title="placeholder">' + c.w.d + '</span></div><h5 class="ph" title="placeholder title">' + c.w.t + '</h5><p>' + c.w.blurb + '</p><p style="color:var(--dim);font-size:12px">opens blog.lucaswinther.info</p>';
if (c.f) { var n = PROJECTS.filter(function (p) { return c.f === 'nofull' ? p.ai !== 'full' : c.f === 'hand' ? p.ai === 'none' : true; }).length; return '<div class="big">' + n + '<span style="font-size:18px;letter-spacing:0"> / ' + PROJECTS.length + '</span></div><h5>' + c.label + '</h5><p>Projects left visible with this filter. Projects whose AI level isn\'t recorded yet only show with "any way".</p>'; }
return '<div class="big">' + c.k + '</div><h5>' + c.label + '</h5><p>' + c.sub + '</p>';
}
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' }); });
var x = palItems[palOn];
pv.innerHTML = x ? previewFor(x.c) : '';
pv.classList.remove('swap'); pv.offsetWidth; pv.classList.add('swap');
}
function openPal() { if (pal.open) { palq.select(); return; } hideHP(); 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);
/* ---------- init ---------- */
$('gthumb').innerHTML = DRAW.compute();
renderTabs(); applyPref(false);
allSegs(true);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { allSegs(true); });
window.addEventListener('resize', function () { allSegs(true); });
(function () { var m = location.hash.match(/^#\/(\w+)/); if (m && P(m[1])) openSheet(m[1], projCtx(m[1])); })();
</script>
</body>
</html>