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

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

852 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther / work: the notebook</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=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Serif:ital,wght@0,300;0,400;0,500;1,300;1,400&display=swap" rel="stylesheet">
<style>
:root{
--bg:#e8eae6; --grid:rgba(160,168,162,.32); --paper:#f4f4f0; --ink:#1c2024; --soft:#3a4044; --mute:#6b726f; --line:#cfd3cd;
--acc:#d9532b; --hl:#efd96a; --hl-soft:rgba(239,217,106,.5); --art:#2a1a1f; --art-ink:#f1e6d6;
--serif:"IBM Plex Serif",Georgia,serif; --sans:"IBM Plex Sans",system-ui,sans-serif; --mono:"IBM Plex Mono",ui-monospace,monospace;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--serif);font-size:15.5px;line-height:1.55;
background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px);background-size:24px 24px;background-position:-1px -1px}
a{color:inherit;text-decoration-color:var(--acc);text-underline-offset:3px}
a:hover{color:var(--acc)}
.ph{text-decoration:underline dotted var(--mute);text-underline-offset:3px;cursor:help}
.mono{font-family:var(--mono)}
.k{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
.k b{color:var(--acc);font-weight:500}
.mock-note{position:fixed;top:10px;right:56px;z-index:99;max-width:300px;background:#fff;color:#222;border:1px solid #aaa;padding:8px 30px 8px 10px;font:12px/1.4 system-ui,sans-serif}
.mock-note b{display:block}
.mock-note button{position:absolute;top:4px;right:6px;border:0;background:none;font-size:16px;cursor:pointer;color:#555}
/* edge to the other gate, as in nav-d's opened state */
.edge{position:fixed;top:0;right:0;bottom:0;width:40px;background:var(--art);color:var(--art-ink);z-index:20;display:flex;align-items:center;justify-content:center;text-decoration:none;writing-mode:vertical-rl;font:italic 15px/1 Georgia,serif;letter-spacing:.03em;transition:width .2s}
.edge:hover{width:52px;color:#e8b04a}
.wrap{max-width:1340px;margin:0 auto;padding:0 64px 0 24px;display:grid;grid-template-columns:232px minmax(0,1fr);gap:0 40px}
/* ---------- rail ---------- */
.rail{position:sticky;top:0;height:100vh;overflow:auto;padding:22px 0 24px;font-family:var(--sans);font-size:13.5px;scrollbar-width:thin}
.rail .back{font-family:var(--mono);font-size:12px;text-decoration:none;display:inline-flex;gap:8px;align-items:center;color:var(--soft)}
.rail .back:hover{color:var(--acc)}
.rail .back kbd,.kbd{font-family:var(--mono);font-size:10px;border:1px solid currentColor;border-radius:3px;padding:0 4px;opacity:.7}
.rail h2{font-family:var(--mono);font-weight:500;font-size:13px;letter-spacing:.06em;text-transform:uppercase;margin:22px 0 8px;display:flex;justify-content:space-between;align-items:baseline}
.rail h2 span{font-size:10px;color:var(--mute);letter-spacing:.04em;text-transform:none;font-weight:400}
.filter{border:1px solid var(--ink);background:var(--paper);padding:8px 10px 9px}
.filter .k{display:block;margin-bottom:5px}
.filter button{display:flex;width:100%;align-items:center;gap:8px;background:none;border:0;font:13px var(--sans);color:var(--soft);padding:3px 0;cursor:pointer;text-align:left}
.filter button::before{content:"";width:9px;height:9px;border:1.5px solid var(--ink);border-radius:50%;flex:none}
.filter button[aria-pressed="true"]{color:var(--ink);font-weight:500}
.filter button[aria-pressed="true"]::before{background:var(--acc);border-color:var(--acc)}
.filter button:hover{color:var(--acc)}
.filter .note{font-family:var(--mono);font-size:10.5px;color:var(--mute);margin-top:4px;min-height:14px}
.grp{margin:16px 0 0}
.grp .k{display:block;padding:0 0 4px;border-bottom:1px solid var(--ink);margin-bottom:2px}
.toc{list-style:none;margin:0;padding:0}
.toc a{position:relative;display:grid;grid-template-columns:24px 1fr 12px;gap:6px;align-items:baseline;padding:3px 6px 3px 0;text-decoration:none;color:var(--soft);border-bottom:1px dotted var(--line);transition:color .15s,background .15s}
.toc a .pg{font-family:var(--mono);font-size:10px;color:var(--mute)}
.toc a:hover{color:var(--ink);background:rgba(255,255,255,.55)}
.toc a.on{color:var(--ink);font-weight:500;background:var(--paper)}
.toc a.on::before{content:"";position:absolute;left:-14px;top:2px;bottom:2px;width:8px;background:var(--acc)}
.toc a.feat .t::after{content:" ✦";display:none}
.toc li.off a{opacity:.32;text-decoration:line-through;text-decoration-color:var(--mute)}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;border:1.5px solid var(--ink);align-self:center}
.dot.ai{background:var(--acc);border-color:var(--acc)}
.dot.some{background:linear-gradient(90deg,var(--ink) 50%,transparent 50%)}
.dot.unk{border-style:dashed;border-color:var(--mute)}
.legend{margin-top:14px;font-family:var(--mono);font-size:10px;color:var(--mute);display:grid;grid-template-columns:1fr 1fr;gap:3px 8px}
.legend span{display:flex;gap:6px;align-items:center}
.rail .also{margin-top:16px;padding-top:10px;border-top:1px solid var(--ink);font-family:var(--mono);font-size:11.5px;display:flex;flex-direction:column;gap:4px}
.rail .also a{text-decoration:none;color:var(--soft)}
.rail .also a:hover{color:var(--acc)}
.railtoggle{display:none;width:100%;margin:0 0 10px;background:var(--paper);border:1px solid var(--ink);font:12px var(--mono);padding:6px 8px;text-align:left;cursor:pointer}
.rail .keys{font-family:var(--mono);font-size:10px;color:var(--mute);margin-top:10px}
/* ---------- main ---------- */
main{padding:22px 0 60px;min-width:0}
.crumb{display:flex;justify-content:space-between;font-family:var(--mono);font-size:11.5px;color:var(--mute);border-bottom:1px solid var(--ink);padding-bottom:8px}
.crumb a{text-decoration:none}
.row{display:grid;grid-template-columns:minmax(0,1fr) 210px;gap:0 28px}
.row > .notes{padding-top:8px}
/* CV block */
.cv{margin-top:20px}
.cv h1{font-family:var(--mono);font-weight:500;font-size:34px;letter-spacing:-.03em;line-height:1.05;margin:0}
.cv h1 i{font-family:var(--serif);font-weight:300;font-size:19px;letter-spacing:0;color:var(--soft);display:block;margin-top:6px}
.card{margin-top:14px;border:1.5px solid var(--ink);background:var(--paper);display:grid;grid-template-columns:1.35fr 1fr 1fr .9fr;font-family:var(--sans);font-size:13.5px;line-height:1.45}
.card > div{padding:10px 14px 12px}
.card > div + div{border-left:1px solid var(--line)}
.card .k{display:block;margin-bottom:4px}
.card p{margin:0 0 4px}
.card .sub{color:var(--mute);font-size:12.5px}
.card ul{margin:2px 0 0;padding:0;list-style:none}
.card li{padding-left:12px;position:relative}
.card li::before{content:"–";position:absolute;left:0;color:var(--mute)}
.card .cvpdf{display:inline-block;margin-top:6px;font-family:var(--mono);font-size:12px;border:1px solid var(--ink);padding:2px 8px;text-decoration:none}
.card .cvpdf:hover{background:var(--ink);color:var(--paper)}
.card .wide{grid-column:1 / -1;border-left:0;border-top:1px solid var(--line);padding:6px 14px;font-family:var(--mono);font-size:11.5px;color:var(--soft);display:flex;gap:6px 16px;flex-wrap:wrap}
/* group heading */
.ghead{margin:46px 0 10px;display:flex;flex-wrap:wrap;align-items:baseline;gap:14px;border-bottom:1px solid var(--ink);padding-bottom:6px}
.ghead h2{font-family:var(--mono);font-weight:500;font-size:22px;letter-spacing:-.02em;margin:0}
.ghead p{margin:0;color:var(--mute);font-style:italic;font-size:15px}
/* spread */
.proj{scroll-margin-top:16px;margin:0 0 26px}
.proj.hide,.notes.hide{display:none}
.spread{background:var(--paper);border:1px solid #b9beb8;box-shadow:0 1px 0 #b9beb8,0 3px 0 -1px var(--paper),0 3px 0 0 #b9beb8;position:relative}
.shead{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;padding:12px 20px 10px;border-bottom:1px solid var(--line)}
.shead .k{flex:none}
.shead h3{margin:0;font-family:var(--serif);font-weight:400;font-size:27px;line-height:1.1;letter-spacing:-.005em}
.shead .meta{font-family:var(--mono);font-size:11.5px;color:var(--mute)}
.shead .stamp{margin-left:auto}
.stamp{font-family:var(--mono);font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;border:1.5px solid currentColor;padding:2px 7px 1px;transform:rotate(-2deg);display:inline-block;white-space:nowrap}
.stamp.hand{color:var(--ink)}
.stamp.ai{color:var(--acc)}
.stamp.some{color:#7a6410;background:var(--hl-soft)}
.stamp.unk{color:var(--mute);border-style:dashed}
.pages{display:grid;grid-template-columns:1fr 1fr;position:relative}
.pages::before{content:"";position:absolute;top:0;bottom:0;left:50%;width:22px;margin-left:-11px;background:linear-gradient(90deg,transparent,rgba(28,32,36,.06) 45%,rgba(28,32,36,.1) 50%,rgba(28,32,36,.06) 55%,transparent);pointer-events:none}
.page{padding:14px 26px 18px 20px;min-width:0}
.page + .page{padding:14px 20px 18px 30px}
.page.single{grid-column:1 / -1;max-width:46em}
.single-pages{display:grid;grid-template-columns:1.25fr 1fr;gap:0 28px;padding:14px 20px 16px}
h4{font-family:var(--mono);font-size:10.5px;font-weight:500;text-transform:uppercase;letter-spacing:.1em;color:var(--mute);margin:14px 0 5px;display:flex;align-items:center;gap:8px}
h4:first-child{margin-top:2px}
h4::after{content:"";flex:1;border-bottom:1px solid var(--line)}
.page p,.single-pages p{margin:0 0 8px}
.draft{font-family:var(--mono);font-size:9.5px;letter-spacing:.06em;color:var(--mute);border:1px dashed var(--mute);padding:0 4px;text-transform:uppercase;font-weight:400}
figure{margin:6px 0 4px;position:relative}
figure svg{display:block;width:100%;height:auto}
figcaption{font-family:var(--mono);font-size:10.5px;color:var(--mute);margin-top:2px}
.anno{font-family:var(--mono);font-style:italic;fill:var(--acc);font-size:10px}
.lbl{font-family:var(--mono);font-size:10.5px;fill:var(--ink)}
.lbl-s{font-family:var(--mono);font-size:9.5px;fill:var(--mute)}
table.who{border-collapse:collapse;width:100%;font-family:var(--sans);font-size:13px;line-height:1.35}
table.who th{font-family:var(--mono);font-weight:500;font-size:9.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--mute);text-align:left;padding:3px 8px 3px 0;border-bottom:1px solid var(--ink)}
table.who td{padding:5px 8px 5px 0;border-bottom:1px solid var(--line);vertical-align:top}
table.who td.w{font-family:var(--mono);font-size:11px;white-space:nowrap;width:78px}
.w .h{border-bottom:1.5px solid var(--ink)}
.w .a{background:var(--hl);padding:0 3px}
.w .m{background:linear-gradient(transparent 55%,var(--hl) 55%);padding:0 3px}
.w .u{color:var(--mute);border-bottom:1px dashed var(--mute)}
.share{display:flex;height:8px;border:1px solid var(--ink);margin:8px 0 2px;background:var(--paper)}
.share i{display:block}
.share .h{background:repeating-linear-gradient(90deg,var(--ink) 0 1px,transparent 1px 3px)}
.share .a{background:var(--hl)}
.share .u{background:repeating-linear-gradient(135deg,var(--line) 0 3px,transparent 3px 6px)}
.share-l{font-family:var(--mono);font-size:10px;color:var(--mute)}
ol.dec{margin:0;padding-left:18px;font-size:15px}
ol.dec li{margin:0 0 4px}
ul.log{margin:0;padding:0;list-style:none;font-size:14.5px}
ul.log li{position:relative;padding:2px 0 2px 20px}
ul.log li::before{content:"✕";position:absolute;left:0;top:5px;font-family:var(--mono);font-size:11px;color:var(--acc)}
ul.log li.fix::before{content:"✓";color:var(--ink)}
.links{display:flex;gap:6px 14px;flex-wrap:wrap;font-family:var(--mono);font-size:12px;margin-top:6px}
.links a::after{content:" ↗";color:var(--mute)}
.sfoot{display:flex;justify-content:space-between;gap:12px;border-top:1px solid var(--line);padding:7px 20px;font-family:var(--mono);font-size:11px;color:var(--mute)}
.sfoot a{text-decoration:none;color:var(--soft)}
.sfoot a:hover{color:var(--acc)}
/* compact page entries */
.mini{background:var(--paper);border:1px solid #b9beb8;border-left:3px solid var(--ink);display:grid;grid-template-columns:150px minmax(0,1fr) auto;gap:4px 18px;padding:10px 16px 11px;align-items:baseline}
.mini h3{margin:0;font-weight:400;font-size:19px;line-height:1.2}
.mini .meta{font-family:var(--mono);font-size:11px;color:var(--mute)}
.mini p{margin:0;font-size:15px;grid-column:2 / 3}
.mini .left{grid-row:span 2}
.mini .links{grid-column:2 / -1;margin-top:2px}
.mini .stamp{justify-self:end}
.proj.small{margin-bottom:10px}
/* margin notes = writing */
.mnote{position:relative;font-family:var(--serif);font-size:14px;line-height:1.4;padding:6px 0 10px 12px;border-left:2px solid var(--hl);margin-bottom:16px}
.mnote .k{display:block;margin-bottom:2px}
.mnote a.t{font-style:italic;font-size:15.5px;text-decoration:none;display:block;color:var(--ink)}
.mnote a.t:hover{color:var(--acc);text-decoration:underline;text-decoration-color:var(--acc)}
.mnote p{margin:3px 0 0;color:var(--soft)}
.mnote .rel{font-family:var(--mono);font-size:10px;color:var(--mute);margin-top:4px;display:block}
.notes h5{font-family:var(--mono);font-weight:500;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;margin:0 0 10px;color:var(--ink);display:flex;justify-content:space-between}
.notes h5 a{color:var(--mute);text-decoration:none;font-weight:400}
.notes h5 a:hover{color:var(--acc)}
main > footer{margin-top:50px;border-top:1px solid var(--ink);padding-top:12px;font-family:var(--sans);font-size:13px;color:var(--mute);max-width:900px}
main > footer p{margin:0 0 6px}
@media (max-width:1180px){
.row{grid-template-columns:1fr}
.row > .notes{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0 20px;padding-top:0}
.notes h5{grid-column:1 / -1}
}
@media (max-width:860px){
.wrap{grid-template-columns:1fr;padding:0 52px 0 16px}
.rail{position:relative;height:auto;border-bottom:1px solid var(--ink)}
.rail .grps,.rail .legend,.rail .keys{display:none}
.rail.open .grps{display:grid}
.rail.open .legend{display:grid}
.railtoggle{display:block!important}
.rail .grps{grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0 18px}
.toc a.on::before{display:none}
.card{grid-template-columns:1fr 1fr}
.card > div:nth-child(3){border-left:0}
.card > div:nth-child(n+3){border-top:1px solid var(--line)}
.pages,.single-pages{grid-template-columns:1fr}
.pages::before{display:none}
.page + .page{padding:4px 20px 18px 20px}
.mini{grid-template-columns:1fr auto}
.mini p,.mini .links{grid-column:1 / -1}
.mini .left{grid-row:auto}
}
@media (max-width:480px){
.card{grid-template-columns:1fr}
.card > div + div{border-left:0;border-top:1px solid var(--line)}
.cv h1{font-size:27px}
.shead h3{font-size:22px}
.edge{width:30px;font-size:13px}
.wrap{padding-right:40px}
}
</style>
</head>
<body>
<div class="mock-note"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>tech-r2-a · The notebook, project-first</b>T5's lab notebook, moved into the left gate's grid paper and Plex type. Every project is a notebook spread (what and why, a sketch of how it works, who wrote what, decisions, what broke); a page-tab rail lists them all. Writing lives in the margins. Borrows ntch.design's tagged list and beige.zone's archival labels.</div>
<a class="edge" href="nav-d.html" title="Cross to the personal side">off the clock →</a>
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="rough" x="-5%" y="-5%" width="110%" height="110%"><feTurbulence type="fractalNoise" baseFrequency=".018" numOctaves="1" seed="4"/><feDisplacementMap in="SourceGraphic" scale="2.2"/></filter>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 1 L9 5 L0 9" fill="none" stroke="#1c2024" stroke-width="1.4"/></marker>
<marker id="arr-r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 1 L9 5 L0 9" fill="none" stroke="#d9532b" stroke-width="1.4"/></marker>
</defs>
</svg>
<div class="wrap">
<!-- =============== RAIL =============== -->
<nav class="rail" aria-label="Contents">
<a class="back" href="nav-d.html"><span>←</span> the gate <kbd>Esc</kbd></a>
<h2>Contents <span id="count">18 projects</span></h2>
<button class="railtoggle" id="railtoggle" aria-expanded="false">show all projects ▾</button>
<div class="filter" role="group" aria-label="Filter projects by how they were made">
<span class="k">projects made</span>
<button aria-pressed="true" data-pref="all">any way</button>
<button aria-pressed="false" data-pref="nofull">not fully by AI</button>
<button aria-pressed="false" data-pref="hand">by hand only</button>
<div class="note" id="fnote"></div>
</div>
<div class="grps" id="grps"><!-- built from the pages below --></div>
<div class="legend">
<span><i class="dot"></i>by hand</span><span><i class="dot some"></i>some AI</span>
<span><i class="dot ai"></i>AI-made</span><span><i class="dot unk"></i>not marked yet</span>
</div>
<div class="also">
<a href="#cv">↑ CV and contact</a>
<a href="https://blog.lucaswinther.info">all writing ↗</a>
<a href="https://old.lucaswinther.info">the 2021 site (by hand) ↗</a>
<a href="nav-d.html">off the clock →</a>
</div>
<div class="keys"><span class="kbd">[</span> <span class="kbd">]</span> previous / next project</div>
</nav>
<!-- =============== MAIN =============== -->
<main>
<div class="crumb"><span><a href="nav-d.html">lucaswinther.info</a> / work / notebook</span><span>vol. II · <span class="ph" title="placeholder">updated Nov 2026</span></span></div>
<!-- CV, compact, at the very top -->
<section class="row cv" id="cv">
<div>
<h1>Lucas Winther<i>A notebook of things I've built: what they are, how they work, and who (or what) wrote each part.</i></h1>
<div class="card">
<div>
<span class="k"><b>●</b> now</span>
<p><b>IT-medarbejder</b>, IT Research Tech, Aarhus University <span class="sub">· Jan 2025–now</span></p>
<p class="sub">Builds and runs research software: a Slurm compute platform, document services, and the secure, monitored infrastructure under them.</p>
</div>
<div>
<span class="k">before</span>
<ul>
<li>ScienceAtHome, intern <span class="sub">· Jul 2019</span></li>
<li>TERMA Group, student helper, local configuration management <span class="sub">· 2016–18</span></li>
</ul>
</div>
<div>
<span class="k">education</span>
<p>BSc Computer Science, <span class="ph" title="placeholder: university">Aarhus University</span></p>
<p class="sub">Thesis: <a href="#p-hypercache">HyperCache</a>, reputation in P2P archival</p>
</div>
<div>
<span class="k">reach</span>
<p><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br><a href="https://github.com/StereotypicalCat">GitHub</a> · <a href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a></p>
<a class="cvpdf" href="#" title="placeholder: CV PDF">CV (PDF) ↓</a>
</div>
<div class="wide"><span>DevOps</span><span>Docker</span><span>Linux admin</span><span>Observability</span><span>Cloud (OCI)</span><span>Slurm</span><span>React / Node</span><span>Ansible</span><span>Unity / C#</span><span>Leadership &amp; organisation</span></div>
</div>
</div>
<aside class="notes" aria-label="Latest writing">
<h5>Latest writing <a href="https://blog.lucaswinther.info">blog ↗</a></h5>
<div class="mnote">
<span class="k"><span class="ph" title="placeholder">28 Oct 2026</span> · AI</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">What I let the agent write, and what I didn't</span></a>
<p>Going back through two projects line by line and marking every part by origin.</p>
<span class="rel">→ see <a href="#p-gacha">Gacha tracker</a>, <a href="#p-config">Config</a></span>
</div>
<div class="mnote">
<span class="k"><span class="ph" title="placeholder">Sep 2026</span> · devops</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">A Slurm cluster that scales to zero</span></a>
<span class="rel">→ see <a href="#p-compute">AU compute platform</a></span>
</div>
</aside>
</section>
<!-- ================= AT WORK ================= -->
<div class="ghead" id="g-work"><h2>At work</h2><p>Aarhus University, IT Research Tech, 2025–now; earlier internships.</p></div>
<section class="row">
<article class="proj" id="p-compute" data-group="at work" data-title="AU compute platform" data-ai="unk" data-feat>
<div class="spread">
<header class="shead">
<span class="k">pp. 2–3</span>
<h3>AU compute platform</h3>
<span class="meta">Slurm · React · Node · Oracle Cloud · Entra ID · ERDA · 2025–now</span>
<span class="stamp unk" title="AI level not marked yet (placeholder)">AI: not marked</span>
</header>
<div class="pages">
<div class="page">
<h4>What and why</h4>
<p>A compute job system for researchers at Aarhus University. Slurm is powerful but unfriendly; this puts a web app in front of it, so submitting, watching and collecting a job doesn't need a terminal.</p>
<p>Both halves scale: the web stack and the compute nodes grow and shrink on Oracle Cloud with the queue, so the university isn't paying for idle machines.</p>
<h4>How it works</h4>
<figure>
<svg viewBox="0 0 400 268" role="img" aria-label="Sketch: browser to React app and Node API, login through Entra ID, jobs go to the Slurm controller, which scales compute nodes on Oracle Cloud; data in ERDA">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<rect x="8" y="22" width="86" height="40"/>
<rect x="138" y="22" width="110" height="40"/>
<rect x="292" y="10" width="100" height="34" stroke-dasharray="4 3"/>
<rect x="138" y="112" width="110" height="40"/>
<rect x="138" y="204" width="110" height="40"/>
<path d="M270 180 q-8 -22 18 -24 q6 -20 34 -14 q20 -14 40 4 q30 0 26 24 q14 18 -6 30 q-4 22 -30 18 q-18 16 -42 4 q-28 6 -32 -14 q-20 -8 -8 -28z" stroke="#6b726f" stroke-dasharray="5 4"/>
<rect x="296" y="178" width="28" height="22"/><rect x="330" y="178" width="28" height="22"/><rect x="296" y="206" width="28" height="22" stroke-dasharray="3 3"/><rect x="330" y="206" width="28" height="22" stroke-dasharray="3 3"/>
<rect x="8" y="204" width="86" height="40"/>
<path d="M94 42 H136" marker-end="url(#arr)"/>
<path d="M248 36 C268 32 274 28 290 27" marker-end="url(#arr)" stroke-dasharray="4 3"/>
<path d="M193 62 V110" marker-end="url(#arr)"/>
<path d="M193 152 V202" marker-end="url(#arr)"/>
<path d="M248 224 C262 222 270 214 284 206" marker-end="url(#arr)"/>
<path d="M290 222 C230 262 130 262 96 232" marker-end="url(#arr)"/>
<path d="M94 214 C114 180 128 150 136 136" marker-end="url(#arr)" stroke-dasharray="4 3"/>
</g>
<g>
<text class="lbl" x="18" y="46">browser</text>
<text class="lbl" x="148" y="40">React web app</text><text class="lbl-s" x="148" y="54">+ Node API</text>
<text class="lbl" x="302" y="25">Entra ID</text><text class="lbl-s" x="302" y="38">OAuth</text>
<text class="lbl" x="148" y="130">job service</text><text class="lbl-s" x="148" y="144">queue · status</text>
<text class="lbl" x="148" y="222">Slurm</text><text class="lbl-s" x="148" y="236">controller</text>
<text class="lbl-s" x="300" y="170">compute nodes</text>
<text class="lbl" x="18" y="222">ERDA</text><text class="lbl-s" x="18" y="236">AU storage</text>
<text class="anno" x="300" y="60">log in with</text><text class="anno" x="300" y="72">the AU account</text>
<text class="anno" x="262" y="100">nodes appear when</text><text class="anno" x="262" y="112">the queue grows, and</text><text class="anno" x="262" y="124">go away when it's empty</text>
<text class="anno" x="12" y="186">inputs in,</text><text class="anno" x="12" y="198">results out</text>
</g>
</svg>
<figcaption>sketch 1 · request path, top to bottom. Dashed = external or elastic.</figcaption>
</figure>
</div>
<div class="page">
<h4>Who wrote what <span class="draft" title="placeholder">not marked yet</span></h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>React front end</td><td class="w"><span class="u">?</span></td><td>Job form, queue view, results.</td></tr>
<tr><td>Node API &amp; job service</td><td class="w"><span class="u">?</span></td><td>Talks to Slurm on the user's behalf.</td></tr>
<tr><td>OCI autoscaling</td><td class="w"><span class="u">?</span></td><td>Web stack and compute, separately.</td></tr>
<tr><td>OAuth / Entra ID login</td><td class="w"><span class="u">?</span></td><td>University accounts only.</td></tr>
<tr><td>ERDA integration</td><td class="w"><span class="u">?</span></td><td>Pick inputs, deliver results.</td></tr>
</table>
<div class="share" title="not marked yet"><i class="u" style="width:100%"></i></div>
<span class="share-l">share by hand vs. AI: to be filled in</span>
<h4>Decisions <span class="draft" title="placeholder">placeholder</span></h4>
<ol class="dec">
<li>Keep Slurm, wrap it. Researchers' existing scripts keep working.</li>
<li>Scale the web stack and the compute pool independently: a busy week of uploads isn't a busy week of jobs.</li>
<li>Store nothing long-term in the platform; ERDA is the source of truth.</li>
</ol>
<h4>What broke <span class="draft" title="placeholder">placeholder</span></h4>
<ul class="log">
<li>New nodes joined the cluster before their mounts were ready, and the first jobs on them failed.</li>
<li class="fix">Nodes now report healthy only after a mount check; Slurm waits for that.</li>
</ul>
<div class="links"><span class="mono" style="color:var(--mute)">internal tool, no public link</span><a href="https://blog.lucaswinther.info">write-up</a></div>
</div>
</div>
<footer class="sfoot"><span>at work · 1 of 4</span><span><a href="#p-docs">next: document services →</a></span></footer>
</div>
</article>
<aside class="notes">
<div class="mnote">
<span class="k">margin · related</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">Entra ID, OAuth and the redirect URI that ate a week</span></a>
<p>Notes from wiring university login into a small web app.</p>
</div>
<div class="mnote">
<span class="k">margin · skills here</span>
<p style="margin:0;font-family:var(--mono);font-size:11.5px;line-height:1.6">Slurm · OCI · React · Node · OAuth · Linux · cloud cost</p>
</div>
</aside>
</section>
<section class="row">
<article class="proj" id="p-docs" data-group="at work" data-title="Document services" data-ai="unk">
<div class="spread">
<header class="shead">
<span class="k">p. 4</span>
<h3>Document &amp; information services</h3>
<span class="meta">Overleaf · OpenProject · integrations · 2025–now</span>
<span class="stamp unk" title="placeholder">AI: not marked</span>
</header>
<div class="single-pages">
<div>
<h4>What and why</h4>
<p>Self-hosted writing and planning tools for research groups: Overleaf for papers, OpenProject for the projects behind them, wired into university login and each other so a group gets one place to work instead of five accounts.</p>
<h4>How it works</h4>
<figure>
<svg viewBox="0 0 360 96" role="img" aria-label="Sketch: AU login in front of Overleaf and OpenProject, both on shared containers">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<rect x="6" y="30" width="80" height="34"/><rect x="140" y="8" width="96" height="32"/><rect x="140" y="56" width="96" height="32"/>
<rect x="290" y="30" width="64" height="34" stroke-dasharray="4 3"/>
<path d="M86 42 L138 26" marker-end="url(#arr)"/><path d="M86 52 L138 70" marker-end="url(#arr)"/>
<path d="M236 24 L288 40" marker-end="url(#arr)" stroke-dasharray="4 3"/><path d="M236 72 L288 56" marker-end="url(#arr)" stroke-dasharray="4 3"/>
</g>
<text class="lbl" x="14" y="51">AU login</text><text class="lbl" x="150" y="28">Overleaf</text><text class="lbl" x="150" y="76">OpenProject</text><text class="lbl-s" x="298" y="51">backups</text>
</svg>
</figure>
</div>
<div>
<h4>Who wrote what <span class="draft">not marked yet</span></h4>
<table class="who"><tr><td>Deployment &amp; config</td><td class="w"><span class="u">?</span></td></tr><tr><td>Login integration</td><td class="w"><span class="u">?</span></td></tr><tr><td>Glue between the two</td><td class="w"><span class="u">?</span></td></tr></table>
<h4>What broke <span class="draft">placeholder</span></h4>
<ul class="log"><li>An upgrade changed the Overleaf compile image, and old projects stopped building.</li><li class="fix">Pinned images per project; upgrades are now opt-in.</li></ul>
</div>
</div>
<footer class="sfoot"><span><a href="#p-compute">← compute platform</a></span><span><a href="#p-ops">next: kept secure, kept running →</a></span></footer>
</div>
</article>
<aside class="notes"></aside>
</section>
<section class="row">
<article class="proj" id="p-ops" data-group="at work" data-title="Kept secure, kept running" data-ai="unk">
<div class="spread">
<header class="shead">
<span class="k">p. 5</span>
<h3>Kept secure, kept running</h3>
<span class="meta">OpenBao / Vault · Grafana · Docker · Ansible</span>
<span class="stamp unk" title="placeholder">AI: not marked</span>
</header>
<div class="single-pages">
<div>
<h4>What and why</h4>
<p>Trying out newer versions and new kinds of software for researchers, without it becoming a risk. Everything runs isolated in containers, secrets live in OpenBao (Vault), machines are configured with Ansible, and Grafana watches all of it.</p>
<p>Less a single project than a way of working, but the part I'd most like to show someone.</p>
</div>
<div>
<h4>The loop</h4>
<figure>
<svg viewBox="0 0 300 120" role="img" aria-label="Sketch: a loop of try, isolate, update, monitor">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<ellipse cx="150" cy="60" rx="110" ry="44"/>
<path d="M258 52 l2 10 l-9 -4" />
</g>
<text class="lbl" x="124" y="12">try it</text><text class="lbl" x="250" y="88">isolate</text><text class="lbl" x="118" y="118">update</text><text class="lbl" x="2" y="88">monitor</text>
<text class="lbl-s" x="96" y="58">Docker · Ansible</text><text class="lbl-s" x="96" y="70">OpenBao · Grafana</text>
</svg>
</figure>
<h4>Who wrote what <span class="draft">not marked yet</span></h4>
<p class="mono" style="font-size:11.5px;color:var(--mute)">Playbooks, dashboards, policies: to be filled in.</p>
</div>
</div>
<footer class="sfoot"><span><a href="#p-docs">← document services</a></span><span><a href="#p-sah">next: ScienceAtHome prototype →</a></span></footer>
</div>
</article>
<aside class="notes">
<div class="mnote">
<span class="k">margin · related</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">Four alerts, not forty</span></a>
<p>What I'd get up at night for, and what can wait for a dashboard.</p>
</div>
</aside>
</section>
<section class="row">
<article class="proj small" id="p-sah" data-group="at work" data-title="ScienceAtHome prototype" data-ai="hand">
<div class="mini">
<div class="left"><span class="k">p. 6 · 2019</span><br><span class="meta">internship</span></div>
<h3>Early prototype, ScienceAtHome</h3>
<span class="stamp hand">by hand</span>
<p>A month on a new project: ideation and planning with the team, then an early prototype of the base mechanics so the people it was for had something to react to. Pair-programmed with experienced developers to start the real thing. <span class="ph" title="placeholder">Project name and stack unknown.</span></p>
</div>
</article>
<aside class="notes"></aside>
</section>
<!-- ================= ON MY OWN ================= -->
<div class="ghead" id="g-own"><h2>On my own</h2><p>Side projects: the homelab, small tools, games.</p></div>
<section class="row">
<article class="proj" id="p-gacha" data-group="on my own" data-title="Gacha event tracker" data-ai="ai" data-feat>
<div class="spread">
<header class="shead">
<span class="k">pp. 7–8</span>
<h3>Event Tracker for Gacha Games</h3>
<span class="meta">JavaScript · scraping · web app · <span class="ph" title="placeholder">2025</span></span>
<span class="stamp ai">AI-made</span>
</header>
<div class="pages">
<div class="page">
<h4>What and why</h4>
<p>One calendar for the limited-time events in several gacha games, so banners stop slipping past unnoticed. Also a deliberate test: how far can an agent take a small, real project if I only write the spec and the checks?</p>
<h4>How it works</h4>
<figure>
<svg viewBox="0 0 400 110" role="img" aria-label="Sketch: scrapers feed a normaliser which feeds a calendar UI">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<rect x="6" y="14" width="78" height="22"/><rect x="10" y="40" width="78" height="22"/><rect x="14" y="66" width="78" height="22"/>
<rect x="150" y="34" width="96" height="40"/><rect x="306" y="26" width="88" height="56"/>
<path d="M306 42 H394 M336 26 V82 M366 26 V82" stroke-width=".8"/>
<path d="M94 52 H148" marker-end="url(#arr)"/><path d="M246 54 H304" marker-end="url(#arr)"/>
</g>
<text class="lbl-s" x="14" y="29">game A</text><text class="lbl-s" x="18" y="55">game B</text><text class="lbl-s" x="22" y="81">game C…</text>
<text class="lbl" x="160" y="52">normalise</text><text class="lbl-s" x="160" y="66">dedupe · UTC</text>
<text class="lbl-s" x="310" y="38">calendar</text>
<text class="anno" x="140" y="100">the part I rejected twice</text>
</svg>
</figure>
<h4>Decisions</h4>
<ol class="dec"><li>Scrape the announcement pages, not the games.</li><li>Everything stored in UTC, shown in the visitor's zone.</li><li>The agent writes code; it never writes the tests' expectations.</li></ol>
</div>
<div class="page">
<h4>Who wrote what <span class="draft" title="placeholder">detail is placeholder</span></h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>Spec &amp; tests</td><td class="w"><span class="h">by hand</span></td><td>One page of requirements, one failing test per game.</td></tr>
<tr><td>Scrapers</td><td class="w"><span class="a">AI</span></td><td>Written against my fixtures.</td></tr>
<tr><td>Normaliser</td><td class="w"><span class="a">AI</span></td><td>First version guessed time zones. Rejected.</td></tr>
<tr><td>Calendar UI</td><td class="w"><span class="a">AI</span></td><td>Plain JS, no framework, on request.</td></tr>
<tr><td>Review</td><td class="w"><span class="h">by hand</span></td><td>Every change read before merge.</td></tr>
</table>
<div class="share" title="about 10% by hand"><i class="h" style="width:10%"></i><i class="a" style="width:90%"></i></div>
<span class="share-l">≈ 10% of lines by hand, 90% AI · highlighter = AI</span>
<h4>What broke <span class="draft" title="placeholder">placeholder</span></h4>
<ul class="log"><li>Two scrapers died when a site changed its markup.</li><li class="fix">Fixed by the agent in one pass from the failing fixtures.</li><li>Duplicate events after a daylight-saving change.</li></ul>
<div class="links"><a href="#">live demo</a><a href="https://github.com/StereotypicalCat">GitHub</a></div>
</div>
</div>
<footer class="sfoot"><span>on my own · 1 of 7</span><span><a href="#p-config">next: Config →</a></span></footer>
</div>
</article>
<aside class="notes">
<div class="mnote">
<span class="k">margin · related</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">What I let the agent write, and what I didn't</span></a>
<p>I thought I wrote most of the tests. I wrote their names.</p>
</div>
</aside>
</section>
<section class="row">
<article class="proj" id="p-config" data-group="on my own" data-title="Config" data-ai="ai" data-feat>
<div class="spread">
<header class="shead">
<span class="k">pp. 9–10</span>
<h3>Config</h3>
<span class="meta">Ansible · docker-compose · IaC · homelab</span>
<span class="stamp ai">AI-made</span>
</header>
<div class="pages">
<div class="page">
<h4>What and why</h4>
<p>Every machine in the homelab, rebuildable from bare metal with one command, with git as the documentation. Deployment management for a growing pile of docker-compose services.</p>
<h4>How it works</h4>
<figure>
<svg viewBox="0 0 400 130" role="img" aria-label="Sketch: git repo, ansible-playbook, three hosts each running compose stacks">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<path d="M8 30 h70 v70 h-70z M8 46 h70"/>
<rect x="132" y="48" width="90" height="34"/>
<rect x="290" y="6" width="104" height="32"/><rect x="290" y="49" width="104" height="32"/><rect x="290" y="92" width="104" height="32"/>
<path d="M78 65 H130" marker-end="url(#arr)"/>
<path d="M222 58 L288 24" marker-end="url(#arr)"/><path d="M222 65 H288" marker-end="url(#arr)"/><path d="M222 72 L288 106" marker-end="url(#arr)"/>
</g>
<text class="lbl" x="14" y="42">repo</text><text class="lbl-s" x="14" y="62">inventory</text><text class="lbl-s" x="14" y="74">roles/</text><text class="lbl-s" x="14" y="86">compose/</text>
<text class="lbl" x="140" y="69">ansible</text>
<text class="lbl-s" x="298" y="26">nas · media, backup</text><text class="lbl-s" x="298" y="69">mini-1 · services</text><text class="lbl-s" x="298" y="112">mini-2 · monitoring</text>
<text class="anno" x="96" y="112">one command, any host</text>
</svg>
<figcaption>sketch · <span class="ph" title="placeholder">host names placeholder</span></figcaption>
</figure>
</div>
<div class="page">
<h4>Who wrote what <span class="draft" title="placeholder">detail is placeholder</span></h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>Inventory &amp; layout</td><td class="w"><span class="h">by hand</span></td><td>Which service lives where, and why.</td></tr>
<tr><td>Playbooks &amp; roles</td><td class="w"><span class="a">AI</span></td><td>Generated, then iterated for a month.</td></tr>
<tr><td>Compose files</td><td class="w"><span class="a">AI</span></td><td>Templated from one pattern I wrote.</td></tr>
</table>
<div class="share"><i class="h" style="width:15%"></i><i class="a" style="width:85%"></i></div>
<span class="share-l">≈ 15% by hand</span>
<h4>Decisions</h4>
<ol class="dec"><li>Compose, not Kubernetes: the lab is three machines.</li><li>Secrets in an encrypted vault file, never in the repo history.</li></ol>
<h4>What broke <span class="draft" title="placeholder">placeholder</span></h4>
<ul class="log"><li>Every run restarted every container: a template wrote a fresh timestamp on each render.</li><li class="fix">Found by hand with <code>--diff</code>. The agent kept suggesting handlers.</li></ul>
<div class="links"><a href="https://github.com/StereotypicalCat">GitHub</a></div>
</div>
</div>
<footer class="sfoot"><span><a href="#p-gacha">← gacha tracker</a></span><span><a href="#p-chess">next: Chessswap →</a></span></footer>
</div>
</article>
<aside class="notes">
<div class="mnote">
<span class="k">margin · related</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">Ansible idempotency, the hard way</span></a>
<p>The write-up of Config's worst week.</p>
</div>
<div class="mnote">
<span class="k">margin · related</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">Three mini PCs, one k3s cluster</span></a>
<p>…and why it went back to compose a week later.</p>
</div>
</aside>
</section>
<div id="own-minis"></div>
<!-- ================= SCHOOL ================= -->
<div class="ghead" id="g-school"><h2>School</h2><p>University and high school. All of it by hand.</p></div>
<section class="row">
<article class="proj" id="p-hypercache" data-group="school" data-title="HyperCache (thesis)" data-ai="hand" data-feat>
<div class="spread">
<header class="shead">
<span class="k">pp. 16–17</span>
<h3>HyperCache, bachelor thesis</h3>
<span class="meta">Node.js · simulation · P2P · <span class="ph" title="placeholder">2022</span></span>
<span class="stamp hand">by hand</span>
</header>
<div class="pages">
<div class="page">
<h4>What and why</h4>
<p>Peer-to-peer archives only work if peers actually keep what they promise to keep. The thesis looks at reputation systems for P2P archival: how peers can rate each other so that free-riders and bad actors are found and routed around.</p>
<h4>How it works</h4>
<figure>
<svg viewBox="0 0 400 192" role="img" aria-label="Sketch: a network of peers with reputation scores; one free-riding peer is crossed out and routed around">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<path d="M60 40 L170 30 L280 52 L340 120 L220 140 L90 120 Z M60 40 L90 120 M170 30 L220 140 M170 30 L90 120 M280 52 L220 140"/>
<circle cx="60" cy="40" r="16" fill="#f4f4f0"/><circle cx="170" cy="30" r="16" fill="#f4f4f0"/><circle cx="280" cy="52" r="16" fill="#f4f4f0"/>
<circle cx="340" cy="120" r="16" fill="#f4f4f0"/><circle cx="220" cy="140" r="16" fill="#f4f4f0"/><circle cx="90" cy="120" r="16" fill="#f4f4f0"/>
<circle cx="150" cy="92" r="14" fill="#f4f4f0" stroke-dasharray="3 3" stroke="#d9532b"/>
<path d="M141 83 l18 18 M159 83 l-18 18" stroke="#d9532b"/>
</g>
<g class="lbl-s"><text x="52" y="44">.92</text><text x="162" y="34">.88</text><text x="272" y="56">.71</text><text x="332" y="124">.95</text><text x="212" y="144">.64</text><text x="82" y="124">.90</text></g>
<path d="M150 108 V160" fill="none" stroke="#d9532b" stroke-width="1" stroke-dasharray="2 2"/><text class="anno" x="110" y="174">free-rider: claims to store,</text><text class="anno" x="110" y="186">fails the spot checks</text>
<text class="anno" x="214" y="12">score = past audits</text>
</svg>
<figcaption>sketch · peers audit each other's stored chunks; scores decide who gets data</figcaption>
</figure>
</div>
<div class="page">
<h4>Who wrote what</h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>Simulator</td><td class="w"><span class="h">by hand</span></td><td>Node.js, peers as processes in one event loop.</td></tr>
<tr><td>Reputation models</td><td class="w"><span class="h">by hand</span></td><td>Compared against a no-reputation baseline.</td></tr>
<tr><td>Report</td><td class="w"><span class="h">by hand</span></td><td>Written in 2022, before any of this was AI.</td></tr>
</table>
<div class="share"><i class="h" style="width:100%"></i></div>
<span class="share-l">100% by hand</span>
<h4>Decisions <span class="draft" title="placeholder">placeholder</span></h4>
<ol class="dec"><li>Simulate instead of deploy: thousands of peers on one laptop, reproducible runs.</li><li>Model churn explicitly; honest peers going offline shouldn't look like cheating.</li></ol>
<h4>What broke <span class="draft" title="placeholder">placeholder</span></h4>
<ul class="log"><li>Early runs punished honest peers on slow links harder than free-riders.</li><li class="fix">Audits got a grace window, and the result became a chapter.</li></ul>
<div class="links"><a href="#">thesis (PDF)</a><a href="https://github.com/StereotypicalCat">code</a></div>
</div>
</div>
<footer class="sfoot"><span>school · 1 of 6</span><span><a href="#p-rowing">next: rowing game →</a></span></footer>
</div>
</article>
<aside class="notes">
<div class="mnote">
<span class="k">margin · related</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">Reading a thesis I wrote by hand, three years later</span></a>
<p>What holds up, and what an agent would have got wrong.</p>
</div>
</aside>
</section>
<section class="row">
<article class="proj" id="p-rowing" data-group="school" data-title="Mixed-reality rowing game" data-ai="hand" data-feat>
<div class="spread">
<header class="shead">
<span class="k">pp. 18–19</span>
<h3>Mixed-Reality Rowing Game</h3>
<span class="meta">Unity · C# · Arduino · two real oars · <span class="ph" title="placeholder">2020</span></span>
<span class="stamp hand">by hand</span>
</header>
<div class="pages">
<div class="page">
<h4>What and why</h4>
<p>A rowing game you play with two real oars. Sensors on the oars feed an Arduino, the Arduino feeds Unity, and the boat in the headset moves the way your arms do. The question: does real resistance make a virtual space feel more real?</p>
<h4>How it works</h4>
<figure>
<svg viewBox="0 0 400 150" role="img" aria-label="Sketch: two oars with angle sensors wired to an Arduino, over USB serial to Unity, rendered to a headset">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<path d="M10 30 L90 58 M10 120 L90 92"/><path d="M4 22 q-4 10 10 14 l-6 -18z M4 128 q-4 -10 10 -14 l-6 18z"/>
<circle cx="96" cy="60" r="7"/><circle cx="96" cy="90" r="7"/>
<rect x="150" y="56" width="70" height="38"/>
<rect x="270" y="50" width="70" height="50"/>
<path d="M350 66 h34 q8 0 8 8 v6 q0 8 -8 8 h-10 l-6 -6 h-6 l-6 6 h-6 q-8 0 -8 -8 v-6 q0 -8 8 -8z"/>
<path d="M103 60 C124 60 130 68 148 70" marker-end="url(#arr)"/><path d="M103 90 C124 90 130 82 148 80" marker-end="url(#arr)"/>
<path d="M220 75 H268" marker-end="url(#arr)"/>
</g>
<text class="lbl" x="158" y="79">Arduino</text><text class="lbl" x="282" y="79">Unity</text><text class="lbl-s" x="282" y="92">C#</text>
<text class="lbl-s" x="226" y="68">serial</text>
<text class="anno" x="40" y="146">angle sensor at each oarlock</text>
<text class="anno" x="262" y="26">mixed reality: you</text><text class="anno" x="262" y="38">see your own hands</text>
</svg>
</figure>
</div>
<div class="page">
<h4>Who wrote what</h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>Arduino firmware</td><td class="w"><span class="h">by hand</span></td><td>Reads two sensors, streams angles.</td></tr>
<tr><td>Unity game &amp; physics</td><td class="w"><span class="h">by hand</span></td><td>C#, group project.</td></tr>
<tr><td>Oar rig</td><td class="w"><span class="h">by hand</span></td><td>Literally, with tape.</td></tr>
</table>
<div class="share"><i class="h" style="width:100%"></i></div>
<span class="share-l">100% by hand</span>
<h4>Decisions <span class="draft" title="placeholder">placeholder</span></h4>
<ol class="dec"><li>Measure oar angle, not force: cheaper sensors, and it's what the boat needs.</li><li>Smooth on the Unity side so the firmware stays dumb.</li></ol>
<h4>What broke <span class="draft" title="placeholder">placeholder</span></h4>
<ul class="log"><li>Serial lag made the boat feel drunk.</li><li class="fix">Smaller packets, higher baud rate, and the latency went away.</li></ul>
<div class="links"><a href="#">video</a><a href="https://github.com/StereotypicalCat">GitHub</a></div>
</div>
</div>
<footer class="sfoot"><span><a href="#p-hypercache">← HyperCache</a></span><span><a href="#p-dashboard">next: Student Dashboard →</a></span></footer>
</div>
</article>
<aside class="notes"></aside>
</section>
<div id="school-minis"></div>
<footer>
<p>Set in IBM Plex Serif, Sans and Mono, on the same grid paper as the gate. Highlighter means AI; an underline means by hand; a dashed ring means not marked yet.</p>
<p><b>Placeholders</b> (dotted underline or "placeholder" tag): university name, project years, all blog titles and dates, the CV PDF link, every "decisions" and "what broke" entry, the per-part AI breakdowns and percentages, the AU projects' AI level, ScienceAtHome project name, Config host names, live demo links.</p>
<p><a href="nav-d.html">← back to the gate</a> · <a href="nav-d.html">off the clock →</a> · <a href="https://old.lucaswinther.info">the 2021 site</a></p>
</footer>
</main>
</div>
<script>
(function(){
// compact pages for the smaller projects
var MINI = {
"own-minis": [
["p-chess","Chessswap","on my own","hand","p. 11","React · game","Chess where the board swaps every few turns. A small rules engine and a lot of edge cases: what happens to a pinned piece when its side of the board leaves?",[["GitHub","https://github.com/StereotypicalCat"]],"2020"],
["p-expenses","Monthly Expenses Tracker","on my own","hand","p. 12","JS · web app · DKK","Where the money went each month, in kroner. Amounts stored in øre so floating point never touches them.",[["GitHub","https://github.com/StereotypicalCat"]],"2021"],
["p-lol","LoL Winrate Discord Bot","on my own","hand","p. 13","Node.js · Discord","Ask the bot in chat, get a champion's current win rate back. Built for one friend group.",[["GitHub","https://github.com/StereotypicalCat"]],"2019"],
["p-portfolio","Portfolio, 2021","on my own","hand","p. 14","React · Material UI","The previous version of this site, frozen as it was. Searchable project grid, written entirely by hand.",[["old.lucaswinther.info","https://old.lucaswinther.info"]],"2021"],
["p-borlaug","Tribute to Norman Borlaug","on my own","hand","p. 15","HTML · CSS · freeCodeCamp","One of my first websites. Kept for honesty.",[],"2016"]
],
"school-minis": [
["p-dashboard","Student Dashboard","school","hand","p. 20","React · user studies","A dashboard for students, designed from two rounds of user studies; the report is as much the project as the code.",[["report (PDF)","#"],["demo","#"]],"2021"],
["p-vr","VR-Classroom","school","hand","p. 21","Node.js · prototype","A prototype of a shared virtual classroom: a Node.js server keeping everyone's view in sync.",[["report","#"],["video","#"]],"2019"],
["p-censor","Censor Simulator","school","hand","p. 22","Unity · VR · high school","High-school exam project: a VR game about being the censor.",[],"2018"],
["p-breakout","BreakoutPython","school","hand","p. 23","Python · high school","High-school exam project. Breakout, in Python, from scratch.",[["GitHub","https://github.com/StereotypicalCat"]],"2017"]
]
};
var AI_LABEL = {hand:"by hand", ai:"AI-made", some:"some AI", unk:"AI: not marked"};
Object.keys(MINI).forEach(function(id){
var host = document.getElementById(id);
host.outerHTML = MINI[id].map(function(p){
var links = p[7].map(function(l){return '<a href="'+l[1]+'">'+l[0]+'</a>';}).join("");
return '<section class="row"><article class="proj small" id="'+p[0]+'" data-group="'+p[2]+'" data-title="'+p[1]+'" data-ai="'+p[3]+'">'+
'<div class="mini"><div class="left"><span class="k">'+p[4]+' · <span class="ph" title="placeholder year">'+p[8]+'</span></span><br><span class="meta">'+p[5]+'</span></div>'+
'<h3>'+p[1]+'</h3><span class="stamp '+p[3]+'">'+AI_LABEL[p[3]]+'</span><p>'+p[6]+'</p>'+(links?'<div class="links">'+links+'</div>':'')+'</div></article><aside class="notes"></aside></section>';
}).join("");
});
// build the rail from the pages
var projs = Array.prototype.slice.call(document.querySelectorAll(".proj"));
var groups = {};
projs.forEach(function(p){ (groups[p.dataset.group] = groups[p.dataset.group] || []).push(p); });
var grps = document.getElementById("grps");
grps.innerHTML = Object.keys(groups).map(function(g){
return '<div class="grp"><span class="k">'+g+' · '+groups[g].length+'</span><ul class="toc">'+groups[g].map(function(p){
var pg = (p.querySelector(".shead .k, .mini .k")||{}).textContent || "";
pg = pg.replace(/^pp?\.\s*/,"").split(/[–·\s]/)[0];
return '<li data-for="'+p.id+'"><a href="#'+p.id+'"'+(p.hasAttribute("data-feat")?' class="feat"':'')+'><span class="pg">'+pg+'</span><span class="t">'+p.dataset.title+'</span><i class="dot '+(p.dataset.ai==="hand"?"":p.dataset.ai)+'"></i></a></li>';
}).join("")+'</ul></div>';
}).join("");
document.getElementById("count").textContent = projs.length + " projects";
// highlight the page you're on (no motion, just a tab)
var links = {};
grps.querySelectorAll("a").forEach(function(a){ links[a.getAttribute("href").slice(1)] = a; });
var current = null;
function setOn(id){ if(current===id) return; current=id; Object.keys(links).forEach(function(k){ links[k].classList.toggle("on", k===id); }); }
if ("IntersectionObserver" in window){
var io = new IntersectionObserver(function(entries){
entries.forEach(function(e){ if(e.isIntersecting) setOn(e.target.id); });
},{rootMargin:"-30% 0px -60% 0px"});
projs.forEach(function(p){ io.observe(p); });
}
// AI filter (same wording as the gate's footer)
var fnote = document.getElementById("fnote");
function applyPref(pref){
var hidden = 0;
projs.forEach(function(p){
var a = p.dataset.ai;
var hide = (pref==="nofull" && a==="ai") || (pref==="hand" && a!=="hand");
p.classList.toggle("hide", hide);
var sec = p.parentNode; var aside = sec.querySelector(".notes");
if (aside && !sec.classList.contains("cv")) aside.classList.toggle("hide", hide);
var li = grps.querySelector('[data-for="'+p.id+'"]'); if (li) li.classList.toggle("off", hide);
if (hide) hidden++;
});
fnote.textContent = hidden ? hidden + " hidden" + (pref==="hand" ? " (incl. unmarked work)" : "") : "";
document.querySelectorAll("[data-pref]").forEach(function(b){ b.setAttribute("aria-pressed", b.dataset.pref===pref ? "true" : "false"); });
}
document.querySelectorAll("[data-pref]").forEach(function(b){ b.addEventListener("click", function(){ applyPref(b.dataset.pref); }); });
var rail=document.querySelector(".rail"), rt=document.getElementById("railtoggle");
rt.addEventListener("click",function(){var o=rail.classList.toggle("open");rt.setAttribute("aria-expanded",o);rt.textContent=o?"hide project list ▴":"show all projects ▾";});
grps.addEventListener("click",function(){ if(window.innerWidth<=860){rail.classList.remove("open");rt.setAttribute("aria-expanded","false");rt.textContent="show all projects ▾";} });
// [ and ] jump between projects; Esc goes back to the gate
document.addEventListener("keydown", function(e){
if (e.target.closest && e.target.closest("input,textarea")) return;
if (e.key === "Escape") { location.href = "nav-d.html"; return; }
if (e.key !== "[" && e.key !== "]") return;
var vis = projs.filter(function(p){ return !p.classList.contains("hide"); });
var i = vis.findIndex(function(p){ return p.id === current; });
var n = e.key === "]" ? Math.min(vis.length-1, i+1) : Math.max(0, i-1);
if (vis[n]) { vis[n].scrollIntoView(); setOn(vis[n].id); history.replaceState(null,"","#"+vis[n].id); }
});
})();
</script>
</body>
</html>