Files
portfolio-next/mockups/tech-r3-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

928 lines
79 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</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&family=Geist+Mono:wght@400;500&family=Fraunces:ital,opsz,wght@1,9..144,300&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f7f7f5; --panel: #efefeb; --surface: #ffffff;
--ink: #141518; --ink2: #3b3e43; --mute: #74777d; --faint: #a6a8ac;
--line: #e3e3de; --line2: #d4d5cf;
--acc: #d9532b; --acc-tint: #f8e3da;
--a-bg: #2a1a1f; --a-ink: #f1e6d6;
--sans: "Geist", system-ui, sans-serif; --mono: "Geist Mono", ui-monospace, monospace;
}
* { box-sizing: border-box; }
html { scroll-padding-top: 76px; }
body { margin: 0; font-family: var(--sans); color: var(--ink); background: var(--bg); font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.mono { font-family: var(--mono); }
.ph { text-decoration: underline dotted var(--faint); text-underline-offset: 3px; text-decoration-thickness: 1px; cursor: help; }
:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; border-radius: 4px; }
.lbl { font-family: var(--mono); font-size: 11px; letter-spacing: .02em; color: var(--mute); }
kbd { font-family: var(--mono); font-size: 10.5px; border: 1px solid var(--line2); border-bottom-width: 2px; border-radius: 4px; padding: 0 4px; color: var(--ink2); background: var(--surface); }
/* AI mark: a small square, filled by how much AI wrote */
.ai { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px; color: var(--mute); white-space: nowrap; }
.ai i { width: 9px; height: 9px; border: 1.5px solid currentColor; border-radius: 2px; flex: none; }
.ai.full { color: var(--acc); } .ai.full i { background: currentColor; }
.ai.some i { background: linear-gradient(to top, currentColor 50%, transparent 50%); }
.ai.none { color: var(--ink2); }
.ai.unk i { border-style: dashed; border-color: var(--faint); }
/* frame */
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 92px 0 52px; }
.edge { position: fixed; top: 0; right: 0; bottom: 0; width: 40px; background: var(--a-bg); color: var(--a-ink); display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; font-family: "Fraunces", serif; font-style: italic; font-weight: 300; font-size: 16px; z-index: 30; transition: width .18s ease; }
.edge:hover { width: 48px; color: #e8b04a; }
/* top bar */
.bar { position: sticky; top: 0; z-index: 20; background: var(--bg); border-bottom: 1px solid transparent; transition: border-color .2s; }
.bar.scrolled { border-bottom-color: var(--line); }
.bar .wrap { display: flex; align-items: center; height: 56px; gap: 28px; }
.crumb { font-size: 14px; display: flex; gap: 8px; align-items: center; }
.crumb b { font-weight: 600; }
.crumb .sep { color: var(--faint); }
.crumb a:hover { color: var(--acc); }
.crumb .gate { font-family: var(--mono); font-size: 11.5px; color: var(--mute); border: 1px solid var(--line2); border-radius: 999px; padding: 2px 9px; margin-right: 4px; }
.crumb .gate:hover { border-color: var(--acc); color: var(--acc); }
.bar nav { margin-left: auto; display: flex; gap: 22px; font-size: 14px; color: var(--ink2); }
.bar nav a:hover { color: var(--acc); }
.kbtn { display: inline-flex; align-items: center; gap: 10px; border: 1px solid var(--line2); background: var(--surface); border-radius: 8px; padding: 5px 7px 5px 11px; font-size: 13px; color: var(--mute); min-width: 200px; transition: border-color .15s; }
.kbtn:hover { border-color: var(--ink2); color: var(--ink2); }
.kbtn span { margin-right: auto; }
/* intro */
.intro { padding: 30px 0 26px; display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 64px; align-items: end; }
.intro h1 { font-size: 33px; line-height: 1.14; letter-spacing: -0.025em; font-weight: 500; margin: 10px 0 0; max-width: 21em; }
.intro h1 em { font-style: normal; color: var(--mute); }
.intro .side { font-size: 14px; color: var(--ink2); line-height: 1.55; }
.intro .side p { margin: 0 0 10px; }
.cv { display: grid; grid-template-columns: 1.35fr 1.35fr 1fr 1.1fr; border-top: 1px solid var(--line2); border-bottom: 1px solid var(--line); margin-bottom: 40px; }
.cv > div { padding: 12px 18px 13px 0; font-size: 13.5px; line-height: 1.45; }
.cv > div + div { padding-left: 18px; border-left: 1px solid var(--line); }
.cv .lbl { display: block; margin-bottom: 3px; }
.cv small { color: var(--mute); font-size: 13px; }
.cv a { border-bottom: 1px solid var(--line2); }
.cv a:hover { color: var(--acc); border-color: var(--acc); }
.cv .pdf { font-weight: 500; }
/* stage */
.sec-head { display: flex; align-items: baseline; gap: 16px; margin-bottom: 16px; }
.sec-head h2 { font-size: 20px; font-weight: 600; letter-spacing: -0.01em; margin: 0; }
.sec-head .count { font-family: var(--mono); font-size: 12px; color: var(--mute); }
.filter { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.seg { display: inline-flex; background: var(--panel); border-radius: 8px; padding: 3px; }
.seg button { font-size: 12.5px; padding: 4px 11px; border-radius: 6px; color: var(--mute); transition: background .15s, color .15s; }
.seg button:hover { color: var(--ink); }
.seg button[aria-pressed="true"] { background: var(--surface); color: var(--ink); box-shadow: 0 0 0 1px var(--line2); }
.filter-note { font-family: var(--mono); font-size: 11px; color: var(--mute); min-width: 0; }
.stagewrap { display: grid; grid-template-columns: 390px minmax(0, 1fr); gap: 44px; align-items: start; }
.plist { list-style: none; margin: 0; padding: 0; }
.plist .grp { display: flex; justify-content: space-between; align-items: baseline; padding: 18px 0 7px; border-bottom: 1px solid var(--line2); }
.plist .grp:first-child { padding-top: 4px; }
.plist .grp b { font-size: 12.5px; font-weight: 600; }
.plist .grp span { font-family: var(--mono); font-size: 11px; color: var(--faint); }
.row { position: relative; display: grid; grid-template-columns: 26px minmax(0, 1fr) auto 16px; align-items: center; gap: 10px; padding: 9px 10px 9px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; transition: color .12s; }
.row::before { content: ""; position: absolute; left: -14px; top: 50%; width: 6px; height: 6px; margin-top: -3px; border-radius: 50%; background: var(--acc); opacity: 0; transform: scale(.4); transition: opacity .12s, transform .12s; }
.row .no { font-family: var(--mono); font-size: 11px; color: var(--faint); }
.row .nm { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .yr { font-family: var(--mono); font-size: 11px; color: var(--mute); }
.row .ai { justify-self: end; }
.row:hover, .row.on { color: var(--ink); }
.row.on .nm { font-weight: 500; }
.row.on::before { opacity: 1; transform: none; }
.row.on .no { color: var(--acc); }
.plist:hover .row:not(.on) .nm, .plist:focus-within .row:not(.on) .nm { color: var(--ink2); }
.plist li.hide { display: none; }
.plist .empty { padding: 16px 0; font-size: 13.5px; color: var(--mute); }
.stage { position: sticky; top: 76px; }
.stage-top { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; font-size: 13px; color: var(--mute); }
.stage-top .mono { font-size: 11px; color: var(--acc); }
.stage-top .ai { margin-left: auto; }
.frame { background: var(--surface); border: 1px solid var(--line2); border-radius: 10px; overflow: hidden; box-shadow: 0 1px 0 rgba(20,21,24,.03), 0 18px 40px -28px rgba(20,21,24,.25); }
.stage-frame { view-transition-name: hero; display: block; cursor: zoom-in; }
.fbar { height: 30px; display: flex; align-items: center; gap: 10px; padding: 0 12px; border-bottom: 1px solid var(--line); background: #fbfbfa; font-family: var(--mono); font-size: 11px; color: var(--mute); }
.fbar .dotz { display: flex; gap: 5px; } .fbar .dotz i { width: 7px; height: 7px; border-radius: 50%; background: var(--line2); }
.fbar .url { flex: 1; text-align: center; margin-right: 27px; }
.fbar.fig .url { text-align: left; margin: 0; }
.fbody svg { display: block; width: 100%; height: auto; }
.fbody.swap svg { animation: pin .16s ease-out; }
@keyframes pin { from { opacity: .25; } to { opacity: 1; } }
.stage-cap { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 24px; padding: 16px 2px 0; align-items: start; }
.stage-title { view-transition-name: ptitle; font-size: 22px; font-weight: 600; letter-spacing: -0.015em; margin: 0; line-height: 1.25; width: fit-content; }
.stage-cap p { grid-column: 1; margin: 2px 0 0; color: var(--ink2); font-size: 15px; max-width: 38em; }
.stage-cap .open { grid-column: 2; grid-row: 1 / span 2; align-self: center; display: inline-flex; align-items: center; gap: 8px; font-size: 13.5px; font-weight: 500; padding: 8px 12px 8px 14px; border-radius: 8px; background: var(--ink); color: #fff; transition: background .15s; }
.stage-cap .open:hover { background: var(--acc); }
.stage-cap .open kbd { background: transparent; color: #cfd0d2; border-color: #4a4d52; }
.stage-cap .stack { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.chip { font-family: var(--mono); font-size: 11px; color: var(--ink2); background: var(--panel); border-radius: 5px; padding: 2px 7px; }
.drawnote { font-family: var(--mono); font-size: 10.5px; color: var(--faint); margin-top: 12px; }
/* writing */
.writing { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: 44px; padding: 80px 0 30px; }
.writing .sec-head { display: block; }
.writing .sec-head p { color: var(--mute); font-size: 14px; margin: 8px 0 14px; }
.writing .sec-head a { font-size: 13.5px; border-bottom: 1px solid var(--line2); }
.writing .sec-head a:hover { color: var(--acc); border-color: var(--acc); }
.posts { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line2); }
.posts a { display: grid; grid-template-columns: 96px minmax(0, 1fr) 90px 20px; gap: 18px; align-items: baseline; padding: 14px 0 15px; border-bottom: 1px solid var(--line); }
.posts .d { font-family: var(--mono); font-size: 11.5px; color: var(--mute); }
.posts .t { font-size: 16px; font-weight: 500; letter-spacing: -0.005em; }
.posts .b { display: block; color: var(--mute); font-size: 13.5px; margin-top: 2px; }
.posts .tg { font-family: var(--mono); font-size: 11px; color: var(--mute); text-align: right; }
.posts .ar { color: var(--faint); transition: transform .15s, color .15s; }
.posts a:hover .t { color: var(--acc); }
.posts a:hover .ar { color: var(--acc); transform: translateX(3px); }
footer { border-top: 1px solid var(--line); margin-top: 60px; padding: 22px 0 40px; font-size: 13px; color: var(--mute); }
footer .wrap { display: flex; flex-wrap: wrap; gap: 8px 26px; }
footer a:hover { color: var(--acc); }
footer .phs { flex-basis: 100%; font-size: 12px; color: var(--faint); margin-top: 6px; }
/* detail */
#detail { display: none; padding-top: 8px; padding-bottom: 40px; }
body.in-detail #home { display: none; }
body.in-detail #detail { display: block; }
.dnav { display: flex; align-items: center; gap: 16px; padding: 14px 0 26px; font-size: 13.5px; }
.back { display: inline-flex; align-items: center; gap: 8px; color: var(--ink2); padding: 5px 10px 5px 8px; margin-left: -8px; border-radius: 7px; transition: background .15s, color .15s; }
.back:hover { background: var(--panel); color: var(--ink); }
.dnav .where { color: var(--mute); }
.dnav .pn { margin-left: auto; display: flex; align-items: center; gap: 4px; }
.dnav .pn a { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 7px; color: var(--ink2); }
.dnav .pn a:hover { background: var(--panel); color: var(--ink); }
.dnav .pn .of { font-family: var(--mono); font-size: 11px; color: var(--mute); padding: 0 6px; }
.dhead { margin-bottom: 26px; }
.dhero { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 56px; align-items: start; }
.d-title { view-transition-name: ptitle; font-size: 44px; line-height: 1.08; letter-spacing: -0.03em; font-weight: 600; margin: 8px 0 12px; width: fit-content; }
.dhead .pitch { font-size: 19px; line-height: 1.45; color: var(--ink2); margin: 0; max-width: 34em; }
.facts { margin: 0; font-size: 13.5px; }
.facts div { display: grid; grid-template-columns: 80px 1fr; gap: 12px; padding: 7px 0; border-top: 1px solid var(--line); }
.facts dt { font-family: var(--mono); font-size: 11px; color: var(--mute); padding-top: 2px; }
.facts dd { margin: 0; }
.d-frame { view-transition-name: hero; }
.dgrid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 56px; margin-top: 36px; }
.story section { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: 24px; padding: 18px 0; border-top: 1px solid var(--line); }
.story h3 { font-size: 13px; font-weight: 600; margin: 3px 0 0; }
.story h3 span { display: block; font-family: var(--mono); font-weight: 400; font-size: 11px; color: var(--mute); margin-top: 2px; }
.story p { margin: 0; font-size: 16px; line-height: 1.6; color: var(--ink2); max-width: 36em; }
.who { list-style: none; padding: 0; margin: 0; }
.who li { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
.who li:last-child { border-bottom: 0; }
.aside h4 { font-family: var(--mono); font-weight: 400; font-size: 11px; color: var(--mute); margin: 18px 0 6px; }
.aside h4:first-child { margin-top: 2px; }
.aside ul { list-style: none; margin: 0; padding: 0; }
.aside li a { display: flex; justify-content: space-between; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
.aside li a:hover { color: var(--acc); }
.aside li a small { font-family: var(--mono); font-size: 11px; color: var(--mute); white-space: nowrap; }
.aside .dead { padding: 7px 0; font-size: 13.5px; color: var(--mute); }
.aside .stack { display: flex; flex-wrap: wrap; gap: 6px; }
.pnbig { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 48px; }
.pnbig a { border: 1px solid var(--line2); border-radius: 10px; padding: 14px 18px 16px; background: var(--surface); transition: border-color .15s; }
.pnbig a:hover { border-color: var(--ink2); }
.pnbig a:last-child { text-align: right; }
.pnbig .lbl { display: block; margin-bottom: 4px; }
.pnbig b { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
/* command palette */
dialog.pal { border: 1px solid var(--line2); border-radius: 12px; padding: 0; width: min(600px, calc(100vw - 32px)); margin-top: 14vh; background: var(--surface); color: var(--ink); box-shadow: 0 30px 70px -30px rgba(20,21,24,.45); overflow: hidden; }
dialog.pal::backdrop { background: rgba(247,247,245,.72); }
dialog.pal[open] { animation: palin .14s ease-out; }
@keyframes palin { from { opacity: 0; transform: translateY(-4px) scale(.99); } to { opacity: 1; transform: none; } }
.pal-in { display: flex; align-items: center; gap: 10px; padding: 0 16px; border-bottom: 1px solid var(--line); }
.pal-in svg { flex: none; }
.pal input { flex: 1; border: 0; outline: 0; font: inherit; font-size: 16px; padding: 16px 0; background: none; color: var(--ink); }
.pal ul { list-style: none; margin: 0; padding: 6px; max-height: 380px; overflow: auto; }
.pal .g { font-family: var(--mono); font-size: 10.5px; color: var(--faint); padding: 10px 10px 4px; }
.pal .it { display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 7px; cursor: pointer; font-size: 14px; }
.pal .it .k { font-family: var(--mono); font-size: 10.5px; color: var(--faint); width: 22px; }
.pal .it .s { margin-left: auto; font-size: 12px; color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 45%; }
.pal .it.on { background: var(--panel); }
.pal .it.on .k { color: var(--acc); }
.pal .none { padding: 18px 10px; color: var(--mute); font-size: 14px; }
.pal-foot { display: flex; gap: 16px; padding: 8px 16px; border-top: 1px solid var(--line); font-size: 11.5px; color: var(--mute); background: #fbfbfa; }
/* svg drawing palette */
svg text { font-family: var(--sans); }
.k-ink { fill: var(--ink); } .k-2 { fill: #5d6066; } .k-3 { fill: #a6a8ac; } .k-4 { fill: #dcddd8; } .k-5 { fill: #f0f0ec; } .k-w { fill: #fff; } .k-acc { fill: var(--acc); } .k-at { fill: var(--acc-tint); } .nf { fill: none; } .pc { font-family: 'DejaVu Sans', 'Segoe UI Symbol', serif; }
.ln { stroke: #e3e3de; stroke-width: 1; fill: none; } .ln2 { stroke: #c9cac4; stroke-width: 1; fill: none; }
.sk { stroke: var(--ink); fill: none; stroke-width: 1.5; } .ska { stroke: var(--acc); fill: none; stroke-width: 1.5; }
.bd { stroke: #d4d5cf; stroke-width: 1; }
.tx { font-size: 11px; fill: #6b6e74; } .tb { font-size: 11px; fill: var(--ink); font-weight: 600; } .tm { font-family: var(--mono); }
.tw { fill: #fff; font-size: 11px; font-weight: 500; } .ta { fill: var(--acc); font-size: 11px; } .t3 { fill: #a6a8ac; font-size: 9px; letter-spacing: .06em; }
/* mockup note */
.mk { position: fixed; top: 10px; right: 52px; z-index: 60; width: 320px; background: #fff; color: #222; border: 1px solid #bbb; padding: 10px 26px 10px 12px; font: 12.5px/1.4 system-ui, sans-serif; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
.mk b { display: block; margin-bottom: 4px; }
.mk button { position: absolute; top: 4px; right: 6px; border: 0; background: none; font-size: 16px; cursor: pointer; color: #666; }
::view-transition-group(*) { animation-duration: .28s; animation-timing-function: cubic-bezier(.2, .7, .2, 1); }
::view-transition-old(root), ::view-transition-new(root) { animation-duration: .2s; }
@media (prefers-reduced-motion: reduce) { *, ::view-transition-group(*) { transition: none !important; animation: none !important; } }
@media (max-width: 1100px) {
.intro { grid-template-columns: 1fr; gap: 18px; }
.cv { grid-template-columns: 1fr 1fr; } .cv > div:nth-child(3) { border-left: 0; padding-left: 0; } .cv > div:nth-child(n+3) { border-top: 1px solid var(--line); }
.stagewrap { grid-template-columns: 320px minmax(0, 1fr); gap: 28px; }
.dhero, .dgrid { grid-template-columns: 1fr; gap: 24px; }
}
@media (max-width: 760px) {
.wrap { padding: 0 18px; }
.edge { position: static; writing-mode: horizontal-tb; width: auto; height: 40px; }
.edge:hover { width: auto; }
.bar nav, .kbtn span, .kbtn kbd { display: none; } .kbtn { min-width: 0; margin-left: auto; }
.intro h1 { font-size: 28px; }
.cv { grid-template-columns: 1fr; } .cv > div + div { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); }
.sec-head { flex-wrap: wrap; } .filter { margin-left: 0; flex-wrap: wrap; }
.stagewrap { grid-template-columns: 1fr; } .stage { display: none; }
.row { grid-template-columns: 26px minmax(0, 1fr) auto; } .row .yr { display: none; }
.writing { grid-template-columns: 1fr; gap: 10px; padding-top: 50px; }
.posts a { grid-template-columns: 1fr; gap: 2px; } .posts .tg, .posts .ar { display: none; }
.d-title { font-size: 32px; } .story section { grid-template-columns: 1fr; gap: 6px; }
.dnav .where { display: none; } .pnbig { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="mk" id="mk"><button aria-label="dismiss" onclick="this.parentNode.remove()">×</button><b>tech-r3-a · Project stage</b>Modern and light: projects are the stage. Hover or focus a project and the big preview swaps at once; click for the full project, with a View Transition, back and prev/next. Press <kbd>/</kbd> or <kbd>⌘K</kbd> to jump anywhere. Borrows the list-and-preview from ntch.design, the craft and command palette from rauno.me, and the reactive feel (not the colours) from sankar.work.</div>
<a class="edge" href="nav-d.html" title="cross over to the personal side">off the clock →</a>
<header class="bar" id="bar">
<div class="wrap">
<div class="crumb"><a class="gate" href="nav-d.html" title="back to the two gates">↖ gate</a><a href="#" data-home><b>Lucas Winther</b></a><span class="sep">/</span><span>work</span></div>
<nav><a href="#" data-jump="projects">Projects</a><a href="#" data-jump="writing">Writing</a><a href="#" data-jump="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>
</header>
<main id="home" class="wrap">
<section class="intro" id="intro">
<div>
<div class="lbl">Computer scientist · Aarhus, Denmark <span class="ph" title="placeholder: city">(city?)</span></div>
<h1>I build and run research software at Aarhus University. <em>On the side I make small tools, games and experiments, some by hand and some with AI.</em></h1>
</div>
<div class="side">
<p>Every project below says how it was made. Hover one to see it, open it for the why and the how.</p>
<p class="lbl" style="margin:0">Press <kbd>/</kbd> to jump to any project, post or section.</p>
</div>
</section>
<section class="cv" id="cv" aria-label="CV in short">
<div><span class="lbl">now</span>IT-medarbejder, IT Research Tech<br><small>Aarhus University · Jan 2025–now</small></div>
<div><span class="lbl">before</span>ScienceAtHome, intern <small>2019</small><br>TERMA Group, student helper <small>2016–18</small></div>
<div><span class="lbl">education</span>BSc Computer Science<br><small><span class="ph" title="placeholder: university and year">Aarhus University, 2022</span> · thesis: HyperCache</small></div>
<div><span class="lbl">contact</span><a href="mailto:lucas@lucaswinther.info">email</a> · <a href="https://github.com/StereotypicalCat">GitHub</a> · <a href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a><br><a class="pdf" href="#" onclick="return false" title="placeholder: CV PDF">↓ CV, PDF</a> <small class="ph" title="placeholder">(1 page)</small></div>
</section>
<section id="projects">
<div class="sec-head">
<h2>Projects</h2><span class="count" id="count">17</span>
<div class="filter">
<span class="filter-note" id="fnote"></span>
<div class="seg" role="group" aria-label="Filter by how it was made">
<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>
</div>
</div>
<div class="stagewrap">
<ul class="plist" id="plist"></ul>
<div class="stage" id="stage" aria-live="polite">
<div class="stage-top"><span class="mono" id="sNo">01</span><span id="sWhere"></span><span id="sAi"></span></div>
<a class="frame stage-frame" id="sFrame" href="#" aria-label="Open project"></a>
<div class="stage-cap">
<h3 class="stage-title" id="sTitle"></h3>
<p id="sPitch"></p>
<a class="open" id="sOpen" href="#">Open project <kbd>↵</kbd></a>
<div class="stack" id="sStack"></div>
</div>
<div class="drawnote">drawn stand-in for a screenshot · placeholder</div>
</div>
</div>
</section>
<section class="writing" id="writing">
<div class="sec-head"><h2>Latest writing</h2><p>On AI, devops and the homelab. Posted at blog.lucaswinther.info.</p><a href="https://blog.lucaswinther.info">All posts →</a></div>
<ul class="posts" id="posts"></ul>
</section>
</main>
<main id="detail" class="wrap" aria-live="polite"></main>
<footer>
<div class="wrap">
<a href="nav-d.html">↖ back to the gate</a>
<a href="nav-d.html">off the clock →</a>
<a href="#" onclick="return false" title="placeholder: the frozen 2021 site">old site, 2021, by hand</a>
<a href="#" onclick="return false" title="placeholder: /simple business card">/simple</a>
<span style="margin-left:auto">lucas@lucaswinther.info</span>
<div class="phs">Placeholders (dotted underline): city, university and year, most project years, story details, every drawn preview, blog post titles, dates and blurbs, CV PDF, AI level of AU work, numbers inside the drawings.</div>
</div>
</footer>
<dialog class="pal" id="pal" aria-label="Jump to">
<div class="pal-in"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="#74777d" stroke-width="1.6"><circle cx="7" cy="7" r="5"/><path d="M11 11l3.5 3.5"/></svg><input id="palq" placeholder="Jump to a project, post or section…" autocomplete="off" spellcheck="false" aria-controls="pallist"></div>
<ul id="pallist" role="listbox"></ul>
<div class="pal-foot"><span><kbd>↑</kbd> <kbd>↓</kbd> move</span><span><kbd>↵</kbd> open</span><span><kbd>esc</kbd> close</span></div>
</dialog>
<script>
var PH = function (s) { return '<span class="ph" title="placeholder">' + s + '</span>'; };
var GROUPS = {
work: { key: 'A', name: 'Work', sub: 'paid, for someone else' },
own: { key: 'B', name: 'Own', sub: 'built for myself' },
school: { key: 'C', name: 'School', sub: 'university and high 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: ?', long: 'AI level not recorded yet (placeholder)' }
};
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 PROJECTS = [
{ id: 'compute', g: 'work', 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'],
fig: 'diagram: web app → Slurm → cloud nodes',
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: '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'],
fig: 'screenshot: Overleaf project list',
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'],
fig: 'screenshot: Grafana overview board',
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'],
fig: 'screenshot: prototype, ' + PH('project name'),
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'],
fig: 'screenshot: the event timeline',
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', 'https://github.com/StereotypicalCat']], rel: ['config', 'expenses'], posts: ['agent'] },
{ id: 'config', g: 'own', 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'],
fig: 'screenshot: repo tree and a playbook run',
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', 'https://github.com/StereotypicalCat']], rel: ['ops', 'gacha'], posts: ['ansible'] },
{ 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'],
fig: 'screenshot: board mid-swap',
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', 'https://github.com/StereotypicalCat']], 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'],
fig: 'screenshot: month view',
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', 'https://github.com/StereotypicalCat']], 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'],
fig: 'screenshot: bot reply in a channel',
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', 'https://github.com/StereotypicalCat']], 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'],
fig: 'screenshot: the 2021 site',
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'],
fig: 'screenshot: tribute page',
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', 'https://github.com/StereotypicalCat']], rel: ['portfolio'], posts: [] },
{ id: 'hypercache', g: 'school', 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'],
fig: 'diagram: peers and trust',
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: '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'],
fig: 'screenshot: dashboard, second iteration',
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: 'rowing', g: 'school', name: 'Mixed-Reality Rowing Game', yr: PH('2020'), ai: 'none', motif: 'game',
one: 'Row a virtual boat with two real oars, wired up through an Arduino.',
stack: ['Unity', 'C#', 'Arduino'],
fig: 'photo: the two oars on their rig',
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: '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'],
fig: 'still from the video',
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'],
fig: 'screenshot: the censor\'s desk',
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'],
fig: 'screenshot: level one',
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', 'https://github.com/StereotypicalCat']], 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, withLabel) { return '<span class="ai ' + a + '" title="' + AI[a].long + '"><i></i>' + (withLabel === false ? '' : AI[a].label) + '</span>'; }
AI.full.label = 'made with AI'; AI.some.label = 'some AI'; AI.none.label = 'by hand'; AI.unk.label = 'AI: not noted';
function aiMark(a, withLabel) { return '<span class="ai ' + a + '" title="' + AI[a].long + '"><i></i>' + (withLabel === false ? '' : AI[a].label) + '</span>'; }
var PREF = 'all';
function allowed(p) { if (PREF === 'nofull') return p.ai !== 'full'; if (PREF === 'hand') return p.ai === 'none'; return true; }
function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;'); }
/* ---------- drawings: small UIs and diagrams standing in for screenshots ---------- */
function R(x, y, w, h, c, rx, extra) { return '<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" class="' + (c || 'k-4') + '"' + (rx ? ' rx="' + rx + '"' : '') + (extra || '') + '/>'; }
function T(x, y, s, c, fs, anchor, extra) { return '<text x="' + x + '" y="' + y + '" class="' + (c || 'tx') + '"' + (fs ? ' style="font-size:' + fs + 'px"' : '') + (anchor ? ' text-anchor="' + anchor + '"' : '') + (extra || '') + '>' + s + '</text>'; }
function L(x1, y1, x2, y2, c, extra) { return '<line x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2 + '" class="' + (c || 'ln') + '"' + (extra || '') + '/>'; }
function C(x, y, r, c, extra) { return '<circle cx="' + x + '" cy="' + y + '" r="' + r + '" class="' + (c || 'k-4') + '"' + (extra || '') + '/>'; }
function PL(pts, c, extra) { return '<polyline points="' + pts.join(' ') + '" class="' + c + '"' + (extra || '') + '/>'; }
function series(x, y, w, h, vals) { var n = vals.length, out = []; for (var i = 0; i < n; i++) out.push((x + i * w / (n - 1)).toFixed(1) + ',' + (y + h - vals[i] * h).toFixed(1)); return out; }
function lines(x, y, ws, gap, c) { var s = ''; for (var i = 0; i < ws.length; i++) s += R(x, y + i * gap, ws[i], 5, c || 'k-4', 2.5); return s; }
function svg(inner) { return '<svg viewBox="0 0 600 360" aria-hidden="true">' + inner + '</svg>'; }
var DRAW = {
compute: function () {
var s = R(0, 0, 132, 360, 'k-5') + T(16, 28, 'Research compute', 'tb', 12) + R(8, 46, 116, 26, 'k-w', 6, ' stroke="#e3e3de"');
['Jobs', 'New job', 'Data · ERDA', 'Nodes', 'Usage'].forEach(function (n, i) { s += T(20, 63 + i * 30, n, i ? 'tx' : 'tb', 11.5); });
s += C(24, 332, 9, 'k-4') + T(40, 329, 'signed in', 'tx', 9.5) + T(40, 342, 'AU · Entra ID', 'tb', 9.5);
s += T(152, 32, 'Jobs', 'tb', 17) + T(152, 50, '12 running · 4 queued', 'tx', 11) + R(498, 16, 86, 28, 'k-ink', 7) + T(541, 34, '+ New job', 'tw', 11.5, 'middle');
s += T(152, 78, 'NAME', 't3') + T(330, 78, 'STATUS', 't3') + T(424, 78, 'NODES', 't3') + T(500, 78, 'TIME', 't3') + L(152, 86, 584, 86, 'ln2');
[['protein-fold-03', 'running', '4', '01:12:40'], ['nlp-tokenise', 'running', '2', '00:41:05'], ['climate-ens-17', 'queued', '–', 'node booting'], ['mri-preproc', 'done', '1', '00:08:22'], ['thesis-sim-b', 'done', '8', '03:55:10']].forEach(function (r, i) {
var y = 108 + i * 28;
s += T(152, y, r[0], 'tm', 11, null, ' fill="#141518"');
s += r[1] === 'running' ? R(330, y - 12, 58, 17, 'k-at', 8.5) + T(359, y, 'running', 'ta', 10, 'middle') : r[1] === 'queued' ? R(330, y - 12, 58, 17, 'k-w', 8.5, ' stroke="#c9cac4"') + T(359, y, 'queued', 'tx', 10, 'middle') : R(330, y - 12, 58, 17, 'k-5', 8.5) + T(359, y, 'done', 'tx', 10, 'middle');
s += T(424, y, r[2], 'tx tm', 11) + T(500, y, r[3], 'tx tm', 10.5) + L(152, y + 9, 584, y + 9);
});
s += R(152, 254, 432, 92, 'k-w', 8, ' stroke="#dcddd8"') + T(166, 274, 'Cloud nodes, last 24 h', 'tb', 11) + T(570, 274, 'scaling 3 → 6', 'ta tm', 10.5, 'end');
s += PL(['166,332', '220,332', '220,320', '300,320', '300,304', '360,304', '360,326', '440,326', '440,312', '510,312'], 'sk') + PL(['510,312', '510,292', '570,292'], 'ska', ' stroke-dasharray="4 3"');
return svg(s);
},
docs: function () {
var s = R(0, 0, 600, 36, 'k-5') + T(14, 23, 'paper-draft.tex', 'tb tm', 11) + C(404, 18, 8, 'k-3') + C(416, 18, 8, 'k-at', ' stroke="#fff" stroke-width="2"') + T(430, 22, '2 editing', 'tx', 10) + R(498, 8, 88, 21, 'k-acc', 6) + T(542, 22, 'Recompile', 'tw', 10.5, 'middle');
var code = ['\\documentclass{article}', '\\usepackage{graphicx}', '', '\\title{Trust in P2P archives}', '\\begin{document}', '\\maketitle', '', '\\section{Method}', 'We simulate 400 peers that', 'store and audit each other.', '', '\\begin{figure}', ' \\includegraphics{trust}', '\\end{figure}', '\\end{document}'];
code.forEach(function (c, i) { var y = 60 + i * 19; s += T(16, y, i + 1, 'tx tm', 9.5, null, ' fill="#b8babd"') + (c ? T(40, y, esc(c), c.charAt(0) === '\\' || c.charAt(2) === '\\' ? 'ta tm' : 'tx tm', 10.5) : ''); });
s += R(286, 145, 2, 14, 'k-ink');
s += R(300, 36, 300, 324, 'k-5') + L(300, 36, 300, 360, 'ln2') + R(338, 54, 224, 306, 'k-w', 0, ' stroke="#dcddd8"');
s += T(450, 86, 'Trust in P2P archives', 'tb', 12, 'middle') + R(410, 94, 80, 4, 'k-4', 2) + T(352, 124, '1 Method', 'tb', 10) + lines(352, 134, [196, 190, 120], 11) + R(366, 176, 168, 82, 'k-5', 3) + PL(series(378, 188, 144, 58, [.1, .35, .3, .55, .62, .8, .78, .9]), 'ska') + T(450, 272, 'Figure 1: trust over time', 'tx', 9, 'middle') + lines(352, 288, [196, 184, 196, 90], 11);
return svg(s);
},
ops: function () {
var s = R(0, 0, 600, 360, 'k-5');
[['Uptime', '99.97%'], ['Containers', '42'], ['Vault', 'unsealed'], ['Alerts', '1 firing']].forEach(function (c, i) {
var x = 14 + i * 145; s += R(x, 14, 133, 58, 'k-w', 7) + T(x + 12, 34, c[0], 'tx', 10.5) + T(x + 12, 59, c[1], i === 3 ? 'ta' : 'tb', 18, null, i === 3 ? ' font-weight="600"' : '');
});
s += R(14, 84, 280, 136, 'k-w', 7) + T(26, 104, 'CPU · compute nodes', 'tb', 11) + L(26, 200, 282, 200, 'ln2');
s += PL(series(26, 116, 256, 80, [.3, .32, .45, .4, .62, .58, .7, .52, .48, .66, .8, .74, .6, .55]), 'sk') + PL(series(26, 116, 256, 80, [.1, .15, .12, .2, .18, .3, .25, .22, .28, .35, .3, .26, .2, .22]), 'ska');
s += R(306, 84, 280, 136, 'k-w', 7) + T(318, 104, 'Requests / min', 'tb', 11);
[.4, .5, .45, .62, .7, .55, .5, .66, .8, .72, .6, .58, .74, .9, .68, .6].forEach(function (v, i) { s += R(318 + i * 16, 200 - v * 80, 11, v * 80, i === 13 ? 'k-acc' : 'k-4', 2); });
s += R(14, 232, 280, 114, 'k-w', 7) + T(26, 252, 'Image updates', 'tb', 11);
[['overleaf', '5.2 → 5.3', 'staged'], ['openproject', '15.1', 'ok'], ['openbao', '2.1', 'ok'], ['grafana', '11.3', 'ok']].forEach(function (r, i) { var y = 276 + i * 20; s += T(26, y, r[0], 'tm', 10.5, null, ' fill="#141518"') + T(150, y, r[1], 'tx tm', 10.5) + T(282, y, r[2], r[2] === 'ok' ? 'tx' : 'ta', 10.5, 'end'); });
s += R(306, 232, 280, 114, 'k-w', 7) + T(318, 252, 'Disk', 'tb', 11);
[['/var/lib/docker', .62], ['/srv/erda-cache', .81], ['/backup', .38]].forEach(function (r, i) { var y = 270 + i * 25; s += T(318, y, r[0], 'tx tm', 10) + R(318, y + 6, 254, 6, 'k-5', 3) + R(318, y + 6, 254 * r[1], 6, r[1] > .8 ? 'k-acc' : 'k-ink', 3); });
return svg(s);
},
sah: function () {
var s = T(22, 32, 'Level 1', 'tb', 14) + T(578, 32, 'moves 12', 'tx tm', 11, 'end') + L(22, 46, 578, 46);
var pts = [], path = [[0, 0], [1, 0], [1, 1], [2, 1], [3, 1], [3, 2], [4, 2], [5, 2], [5, 3]];
for (var r = 0; r < 4; r++) for (var c = 0; c < 7; c++) pts.push([120 + c * 60, 92 + r * 66]);
s += PL(path.map(function (p) { return (120 + p[0] * 60) + ',' + (92 + p[1] * 66); }), 'sk', ' stroke-width="4" stroke-linecap="round" stroke-linejoin="round"');
pts.forEach(function (p, i) { s += C(p[0], p[1], 9, 'k-w', ' stroke="#c9cac4" stroke-width="1.5"'); });
path.forEach(function (p) { s += C(120 + p[0] * 60, 92 + p[1] * 66, 9, 'k-ink'); });
s += C(420, 290, 13, 'k-acc') + C(420, 290, 20, 'nf', ' stroke="#d9532b" 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="#d9532b"' : ' stroke="#dcddd8"') + T(x + 8, y + 24, b[2], b[3] ? 'ta' : 'tx', 10.5); });
});
var tx = 110 + 2.35 * 68; s += L(tx, 48, tx, 346, 'ska') + R(tx - 34, 330, 68, 18, 'k-acc', 9) + T(tx, 343, 'ends in 14h', 'tw', 9.5, 'middle');
return svg(s);
},
config: function () {
var s = R(0, 0, 176, 360, 'k-5') + T(16, 26, 'config', 'tb tm', 11);
var tree = [['inventory/', 1], ['hosts.yml', 2], ['roles/', 1], ['docker/', 2], ['caddy/', 2], ['backup/', 2], ['stacks/', 1], ['grafana.yml.j2', 2], ['overleaf.yml.j2', 2], ['site.yml', 1]];
tree.forEach(function (t, i) { var y = 52 + i * 22; if (t[0] === 'site.yml') s += R(8, y - 14, 160, 20, 'k-w', 4); s += T(14 + t[1] * 12, y, t[0], t[0].slice(-1) === '/' ? 'tb tm' : 'tx tm', 10.5); });
var out = [['$ ansible-playbook site.yml -l homelab', 'tb'], ['', ''], ['PLAY [homelab]', 'tb'], ['TASK [docker : install engine]', 'tx'], ['ok: [nas] ok: [pi]', 'k'], ['TASK [stacks : render compose]', 'tx'], ['ok: [nas]', 'k'], ['TASK [stacks : compose up]', 'tx'], ['ok: [nas]', 'k'], ['', ''], ['PLAY RECAP', 'tb']];
out.forEach(function (o, i) { if (o[0]) s += T(196, 34 + i * 21, o[0], (o[1] === 'k' ? 'tx' : o[1]) + ' tm', 10.5); });
s += T(196, 286, 'nas ok=24', 'tm', 10.5, null, ' fill="#141518"') + T(300, 286, 'changed=0', 'ta tm', 10.5) + T(380, 286, 'failed=0', 'tx tm', 10.5);
s += T(196, 307, 'pi ok=17', 'tm', 10.5, null, ' fill="#141518"') + T(300, 307, 'changed=0', 'ta tm', 10.5) + T(380, 307, 'failed=0', 'tx tm', 10.5);
s += R(196, 322, 8, 14, 'k-ink');
return svg(s);
},
chess: function () {
var pos = ['rnbqk..r', 'ppp..ppp', '..n..n..', '...pp...', '..B.P...', '.....N..', 'PPPP.PPP', 'RNBQK..R'], G = { k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟' }, s = '', sq = 37;
for (var r = 0; r < 8; r++) for (var c = 0; c < 8; c++) {
var x = 30 + c * sq, y = 30 + r * sq, last = (r === 5 && c === 5) || (r === 7 && c === 6);
s += R(x, y, sq, sq, last ? 'k-at' : ((r + c) % 2 ? 'k-4' : 'k-5'));
var ch = pos[r].charAt(c);
if (ch !== '.') { var white = ch === ch.toUpperCase(); s += T(x + sq / 2, y + 28, G[ch.toLowerCase()], 'pc', 27, 'middle', white ? ' fill="#fff" stroke="#141518" stroke-width=".9"' : ' fill="#141518"'); }
}
s += R(30, 30, 296, 296, 'nf', 0, ' stroke="#141518"');
s += T(360, 52, 'Chessswap', 'tb', 17) + T(360, 72, 'turn 7 · white to move', 'tx', 11);
s += T(360, 112, 'BOARD SWAPS IN', 't3');
for (var i = 0; i < 4; i++) s += R(360 + i * 26, 122, 20, 8, i < 2 ? 'k-acc' : 'k-4', 2);
s += T(470, 130, '2 turns', 'ta', 11);
s += '<path d="M372 168 h40 l-8 -7 M412 168 l-8 7" class="sk"/><path d="M412 186 h-40 l8 -7 M372 186 l8 7" class="sk"/>' + T(424, 182, 'you play the other side', 'tx', 11);
['1. e4 e5', '2. Nf3 Nc6', '3. Bc4 Nf6', '4. ⇄ swap', '5. d5 Bxd5', '6. Nf6 …'].forEach(function (m, i) { s += T(360, 230 + i * 19, m, m.indexOf('swap') > 0 ? 'ta tm' : 'tx tm', 11); });
return svg(s);
},
expenses: function () {
var s = T(20, 34, 'October 2026', 'tb', 17) + T(20, 52, 'by category', 'tx', 11) + T(580, 30, 'total', 'tx', 10.5, 'end') + T(580, 52, '12.480 kr.', 'tb tm', 18, 'end');
var cats = [['Rent', 6200], ['Food', 2940, 2600], ['Going out', 1320], ['Transport', 890], ['Other', 720], ['Subscriptions', 410]];
cats.forEach(function (c, i) {
var y = 86 + i * 34, w = c[1] / 6200 * 300;
s += T(20, y + 12, c[0], 'tb', 11.5, null, ' font-weight="500"') + R(140, y, 300, 16, 'k-5', 3) + R(140, y, w, 16, c[2] ? 'k-acc' : 'k-ink', 3) + T(580, y + 12, c[1].toLocaleString('da-DK') + ' kr.', 'tx tm', 11, 'end');
if (c[2]) { var bx = 140 + c[2] / 6200 * 300; s += L(bx, y - 4, bx, y + 20, 'sk') + T(bx + 6, y - 6, 'budget', 'ta', 9); }
});
s += L(20, 296, 580, 296);
'JFMAMJJASOND'.split('').forEach(function (m, i) { var v = [.6, .55, .62, .58, .7, .66, .8, .64, .6, .74, 0, 0][i], x = 20 + i * 47; s += R(x, 340 - v * 30, 30, v * 30, i === 9 ? 'k-acc' : (v ? 'k-4' : 'k-5'), 2) + T(x + 15, 354, m, 'tx', 9, 'middle'); });
return svg(s);
},
lolbot: function () {
var s = R(0, 0, 136, 360, 'k-5') + T(16, 28, 'friends', 'tb', 12);
['# general', '# draft', '# clips', '# memes'].forEach(function (c, i) { var y = 60 + i * 26; if (i === 1) s += R(8, y - 15, 120, 22, 'k-w', 5); s += T(18, y, c, i === 1 ? 'tb' : 'tx', 11.5); });
s += T(154, 28, '# draft', 'tb', 12) + L(136, 42, 600, 42);
s += C(170, 72, 13, 'k-4') + T(192, 68, 'mads', 'tb', 11.5) + T(226, 68, 'today 21:14', 'tx', 9.5) + T(192, 84, '!wr jinx', 'tm', 11.5, null, ' fill="#141518"');
s += C(170, 124, 13, 'k-ink') + T(170, 128, 'WR', 'tw', 8.5, 'middle') + T(192, 120, 'WinrateBot', 'tb', 11.5) + R(256, 110, 26, 13, 'k-acc', 3) + T(269, 120, 'BOT', 'tw', 8, 'middle');
s += R(192, 132, 330, 128, 'k-5', 6) + R(192, 132, 4, 128, 'k-acc', 2) + T(210, 156, 'Jinx · bottom lane', 'tb', 12.5);
[['Win rate', '51.8%'], ['Pick rate', '14.2%'], ['Games', '24.1k'], ['Patch', '14.21']].forEach(function (r, i) { var x = 210 + (i % 2) * 150, y = 184 + Math.floor(i / 2) * 40; s += T(x, y, r[0], 'tx', 10) + T(x, y + 18, r[1], 'tb tm', 14); });
s += C(170, 290, 13, 'k-4') + T(192, 286, 'sofie', 'tb', 11.5) + T(192, 302, 'ok jinx it is', 'tx', 11.5, null, ' fill="#3b3e43"');
s += R(150, 322, 434, 28, 'k-5', 7) + T(164, 340, 'Message #draft', 'tx', 11);
return svg(s);
},
portfolio: function () {
var s = '<rect width="600" height="360" fill="#1f2125"/><rect width="180" height="360" fill="#2a2d31"/>' + '<circle cx="90" cy="80" r="34" fill="#4a4e54"/>' + T(90, 140, 'Lucas Winther', 'tw', 13, 'middle') + T(90, 160, 'Computer Scientist with', '', 9.5, 'middle', ' fill="#9a9ea4"') + T(90, 173, 'passion for development.', '', 9.5, 'middle', ' fill="#9a9ea4"');
for (var i = 0; i < 4; i++) s += '<circle cx="' + (54 + i * 24) + '" cy="204" r="8" fill="#3c4046"/>';
s += '<rect x="200" y="20" width="380" height="30" rx="4" fill="#2f3237"/>' + T(214, 39, 'Search projects…', '', 10.5, null, ' fill="#7d8187"');
for (var j = 0; j < 6; j++) { var x = 200 + (j % 3) * 128, y = 66 + Math.floor(j / 3) * 144; s += '<rect x="' + x + '" y="' + y + '" width="120" height="134" rx="4" fill="#2f3237"/><rect x="' + x + '" y="' + y + '" width="120" height="62" rx="4" fill="#3a3e44"/><rect x="' + (x + 10) + '" y="' + (y + 76) + '" width="' + (70 + (j * 13) % 30) + '" height="7" rx="3" fill="#c8cbd0"/><rect x="' + (x + 10) + '" y="' + (y + 92) + '" width="96" height="5" rx="2.5" fill="#55595f"/><rect x="' + (x + 10) + '" y="' + (y + 102) + '" width="70" height="5" rx="2.5" fill="#55595f"/><rect x="' + (x + 10) + '" y="' + (y + 116) + '" width="34" height="10" rx="5" fill="#90caf9" opacity=".55"/>'; }
return svg(s);
},
borlaug: function () {
var s = T(300, 50, 'Dr. Norman Borlaug', 'tb', 20, 'middle') + T(300, 70, 'The man who saved a billion lives', 'tx', 11.5, 'middle') + R(200, 86, 200, 112, 'k-5', 2);
for (var i = 0; i < 9; i++) { var x = 222 + i * 20; s += L(x, 190, x + 4, 120 + (i % 3) * 8, 'sk', ' stroke="#a6a8ac"') + '<ellipse cx="' + (x + 4) + '" cy="' + (114 + (i % 3) * 8) + '" rx="3" ry="9" class="k-3"/>'; }
s += T(300, 214, 'photo: wheat field (alt text)', 'tx', 9.5, 'middle');
[['1914', 'Born in Cresco, Iowa'], ['1944', 'Starts wheat research in Mexico'], ['1970', 'Nobel Peace Prize'], ['2009', 'Dies, aged 95']].forEach(function (r, i) { var y = 248 + i * 24; s += C(200, y - 4, 3, 'k-ink') + T(214, y, r[0], 'tb tm', 11) + T(254, y, r[1], 'tx', 11.5); });
s += L(200, 244, 200, 316, 'ln2');
return svg(s);
},
hypercache: function () {
var N = [[90, 90], [200, 60], [320, 96], [440, 64], [530, 130], [120, 210], [250, 180], [370, 200], [500, 250], [80, 310], [210, 290], [340, 310], [450, 330]], E = [[0, 1, 3], [1, 2, 2], [2, 3, 3], [3, 4, 2], [0, 5, 1], [1, 6, 3], [2, 6, 2], [2, 7, 3], [3, 7, 1], [4, 8, 2], [5, 6, 2], [6, 7, 3], [7, 8, 2], [5, 9, 2], [5, 10, 3], [6, 10, 2], [10, 11, 3], [11, 12, 2], [8, 12, 3], [9, 10, 1]], bad = 7, s = '';
E.forEach(function (e) { var a = N[e[0]], b = N[e[1]], isBad = e[0] === bad || e[1] === bad; s += L(a[0], a[1], b[0], b[1], isBad ? 'ska' : 'sk', ' stroke-width="' + (isBad ? 1.2 : e[2] * .9) + '"' + (isBad ? ' stroke-dasharray="4 4"' : ' stroke-opacity=".55"')); });
N.forEach(function (n, i) { s += i === bad ? C(n[0], n[1], 14, 'k-w', ' stroke="#d9532b" stroke-width="2"') + C(n[0], n[1], 5, 'k-acc') : C(n[0], n[1], 11, 'k-w', ' stroke="#141518" stroke-width="1.5"') + R(n[0] - 4, n[1] - 4, 8, 8, i % 3 ? 'k-ink' : 'k-3', 1); });
s += T(386, 190, 'drops data', 'ta', 10.5) + T(386, 178, 'peer 7', 'tb', 10.5);
s += T(20, 28, 'fig. peers rating each other', 'tb', 11) + T(580, 28, 'line weight = trust · dashed = failed audit', 'tx', 10, 'end');
return svg(s);
},
dashboard: function () {
var s = T(20, 34, 'Good morning, Lucas', 'tb', 16) + T(580, 34, 'week 44', 'tx tm', 11, 'end');
s += R(20, 52, 260, 290, 'k-5', 8) + T(36, 76, 'Due this week', 'tb', 11.5) + T(264, 76, '3 left', 'ta', 10.5, 'end');
[['Report draft', 'HCI · Wed', 1], ['Quiz 4', 'Algorithms · Thu', 0], ['Lab hand-in', 'Databases · Fri', 0], ['Group meeting', 'Project · Fri', 0], ['Read ch. 7', 'HCI · Sun', 0]].forEach(function (r, i) {
var y = 98 + i * 46; s += R(32, y, 236, 38, 'k-w', 6) + R(44, y + 12, 13, 13, r[2] ? 'k-ink' : 'k-w', 3, r[2] ? '' : ' stroke="#a6a8ac"') + (r[2] ? '<path d="M47 ' + (y + 18) + ' l3 3 l5 -6" stroke="#fff" stroke-width="1.6" fill="none"/>' : '') + T(68, y + 17, r[0], 'tb', 11, null, r[2] ? ' text-decoration="line-through" fill="#a6a8ac"' : '') + T(68, y + 30, r[1], 'tx', 9.5);
});
s += T(300, 76, 'Schedule', 'tb', 11.5);
['Mon', 'Tue', 'Wed', 'Thu', 'Fri'].forEach(function (d, i) { var x = 300 + i * 56; s += T(x + 24, 98, d, 'tx', 10, 'middle') + R(x, 106, 48, 236, 'k-5', 5); });
[[0, 120, 50], [0, 200, 40], [1, 140, 70], [2, 116, 36], [2, 230, 60, 1], [3, 160, 46], [4, 124, 30], [4, 180, 80]].forEach(function (b) { s += R(304 + b[0] * 56, b[1], 40, b[2], b[3] ? 'k-acc' : 'k-4', 4); });
return svg(s);
},
rowing: function () {
var s = T(20, 28, 'fig. two real oars into a Unity scene', 'tb', 11);
s += '<path d="M30 70 L150 120 M30 170 L150 130" class="sk" stroke-width="3" stroke-linecap="round"/><path d="M20 62 l18 4 -6 14 -16 -6z M20 178 l18 -4 -6 -14 -16 6z" class="k-ink"/>';
s += R(140, 104, 60, 42, 'k-w', 6, ' stroke="#141518" stroke-width="1.5"') + T(170, 129, 'sensors', 'tx', 10, 'middle') + T(170, 164, 'rig + 2 encoders', 'tx', 9.5, 'middle');
s += L(200, 125, 250, 125, 'sk') + '<path d="M250 125 l-6 -4 v8z" class="k-ink"/>';
s += R(252, 100, 92, 50, 'k-ink', 6) + T(298, 122, 'Arduino', 'tw', 11.5, 'middle') + T(298, 138, 'reads strokes', '', 9.5, 'middle', ' fill="#a6a8ac"');
s += L(344, 125, 394, 125, 'sk', ' stroke-dasharray="4 3"') + '<path d="M394 125 l-6 -4 v8z" class="k-ink"/>' + T(369, 114, 'serial', 'ta', 9.5, 'middle');
s += R(396, 70, 184, 110, 'k-5', 6, ' stroke="#c9cac4"') + T(408, 88, 'Unity scene', 'tb', 10.5);
for (var i = 0; i < 4; i++) s += '<path d="M' + (410 + i * 8) + ' ' + (120 + i * 14) + ' q20 -6 40 0 t40 0 t40 0 t40 0" class="ln2"/>';
s += '<path d="M450 128 h70 l-10 12 h-50z" class="k-ink"/><path d="M470 128 l-20 -16 M500 128 l20 -16" class="sk"/>';
s += T(20, 222, 'stroke force over time', 'tb', 10.5) + L(20, 330, 580, 330, 'ln2');
var pts = []; for (var x = 0; x <= 560; x += 4) { var ph = (x % 140) / 140, v = ph < .45 ? Math.sin(ph / .45 * Math.PI) : 0; pts.push((20 + x) + ',' + (330 - v * 82).toFixed(1)); }
s += PL(pts, 'sk');
for (var k = 0; k < 4; k++) s += C(20 + k * 140, 330, 4, 'k-acc');
s += T(20, 348, 'catch', 'ta', 9.5) + T(160, 348, 'catch', 'ta', 9.5);
return svg(s);
},
vrclass: function () {
var s = R(30, 40, 350, 290, 'k-w', 4, ' stroke="#141518" stroke-width="1.5"') + R(120, 48, 170, 6, 'k-ink', 2) + T(205, 72, 'whiteboard', 'tx', 9.5, 'middle');
s += C(205, 98, 12, 'k-acc') + T(225, 102, 'teacher', 'ta', 10);
var sync = [[0, 0], [1, 1], [2, 2], [0, 2]];
for (var r = 0; r < 3; r++) for (var c = 0; c < 3; c++) {
var x = 70 + c * 110, y = 150 + r * 62; s += R(x, y, 70, 24, 'k-5', 3, ' stroke="#dcddd8"') + C(x + 20, y + 36, 8, 'k-ink') + C(x + 50, y + 36, 8, r === 1 && c === 2 ? 'k-w' : 'k-ink', r === 1 && c === 2 ? ' stroke="#a6a8ac" stroke-dasharray="2 2"' : '');
}
s += R(440, 150, 130, 58, 'k-ink', 7) + T(505, 175, 'Node.js server', 'tw', 11, 'middle') + T(505, 192, 'room state', '', 9.5, 'middle', ' fill="#a6a8ac"');
sync.forEach(function (p) { var x = 70 + p[1] * 110 + 50, y = 150 + p[0] * 62 + 36; s += L(x, y, 440, 179, 'ska', ' stroke-dasharray="3 4" stroke-width="1"'); });
s += L(217, 98, 440, 170, 'ska', ' stroke-dasharray="3 4" stroke-width="1"');
s += T(440, 232, 'positions, gaze and', 'tx', 10.5) + T(440, 247, 'who is talking, synced', 'tx', 10.5) + T(440, 262, 'to every headset', 'tx', 10.5);
s += T(30, 26, 'fig. one shared room, seen from above', 'tb', 11);
return svg(s);
},
censor: function () {
var s = R(0, 0, 600, 360, 'k-5') + '<path d="M0 230 L600 230" class="ln2"/><path d="M60 360 L210 230 M540 360 L390 230 M300 360 L300 230" class="ln"/>';
s += '<path d="M90 250 L510 250 L570 340 L30 340 Z" class="k-w" stroke="#141518" stroke-width="1.5"/>';
s += R(140, 90, 320, 120, 'k-w', 6, ' stroke="#c9cac4"') + T(160, 114, 'INCOMING · item 7 of 12', 't3') + lines(160, 128, [260, 240, 270, 180], 14) + R(160, 128 + 14, 120, 5, 'k-ink', 2.5) + T(160, 196, 'decide: approve or censor', 'tx', 10.5);
s += '<g transform="rotate(-8 190 290)">' + R(130, 266, 140, 52, 'k-w', 2, ' stroke="#141518"') + lines(142, 278, [110, 90, 100], 10) + '</g>';
s += '<g transform="rotate(6 400 292)">' + R(330, 266, 140, 52, 'k-w', 2, ' stroke="#141518"') + lines(342, 278, [110, 90, 100], 10) + R(352, 274, 96, 28, 'nf', 3, ' stroke="#d9532b" stroke-width="2"') + T(400, 293, 'CENSORED', 'ta', 12, 'middle', ' font-weight="600" letter-spacing="1"') + '</g>';
s += T(20, 30, 'Day 3', 'tb', 13) + T(580, 30, '5 left in tray', 'tx', 11, 'end');
return svg(s);
},
breakout: function () {
var s = R(0, 0, 600, 360, 'k-5') + T(20, 28, 'SCORE 0420', 'tb tm', 12) + T(580, 28, 'LIVES ● ● ○', 'tx tm', 11, 'end');
var cls = ['k-ink', 'k-2', 'k-acc', 'k-3', 'k-4'], gone = { '1-3': 1, '2-4': 1, '2-5': 1, '3-5': 1, '4-2': 1, '4-5': 1, '4-6': 1, '3-4': 1 };
for (var r = 0; r < 5; r++) for (var c = 0; c < 9; c++) if (!gone[r + '-' + c]) s += R(30 + c * 61, 50 + r * 22, 56, 16, cls[r], 2);
s += PL(['300,330', '360,250', '410,180', '360,120'], 'sk', ' stroke-dasharray="2 5" stroke-opacity=".6"') + C(392, 206, 6, 'k-ink') + R(250, 330, 100, 10, 'k-ink', 5);
return svg(s);
}
};
var FRAME = {
compute: ['url', 'compute.cloud.au.dk/jobs'], docs: ['url', 'overleaf.au.dk/project/…'], ops: ['url', 'grafana · research-ops'], sah: ['fig', 'prototype · name unknown'],
gacha: ['url', 'events.local/timeline'], config: ['url', '~/config — terminal'], chess: ['url', 'localhost:3000'], expenses: ['url', 'expenses · oktober'],
lolbot: ['url', 'discord · friends'], portfolio: ['url', 'lucaswinther.info, 2021'], borlaug: ['url', 'codepen · tribute page'],
hypercache: ['fig', 'diagram'], dashboard: ['url', 'dashboard.local/week'], rowing: ['fig', 'diagram'], vrclass: ['fig', 'diagram'], censor: ['fig', 'Unity · VR'], breakout: ['fig', 'python breakout.py']
};
function frameHTML(id) {
var f = FRAME[id], bar = f[0] === 'url' ? '<div class="fbar"><span class="dotz"><i></i><i></i><i></i></span><span class="url">' + f[1] + '</span></div>' : '<div class="fbar fig"><span class="url">' + f[1] + '</span></div>';
return bar + '<div class="fbody">' + DRAW[id]() + '</div>';
}
/* ---------- order, numbering ---------- */
var ORDER = PROJECTS.map(function (p) { return p.id; });
function num(id) { var n = ORDER.indexOf(id) + 1; return (n < 10 ? '0' : '') + n; }
function whereOf(p) { return p.where ? p.where : GROUPS[p.g].name + ' · ' + GROUPS[p.g].sub; }
function visible() { return PROJECTS.filter(allowed); }
/* ---------- list ---------- */
var plist = document.getElementById('plist');
(function buildList() {
var h = '';
['work', 'own', 'school'].forEach(function (g) {
var ps = PROJECTS.filter(function (p) { return p.g === g; });
h += '<li class="grp" data-g="' + g + '"><b>' + GROUPS[g].name + '</b><span>' + GROUPS[g].sub + '</span></li>';
ps.forEach(function (p) { h += '<li data-li="' + p.id + '"><a class="row" href="#p/' + p.id + '" data-id="' + p.id + '"><span class="no">' + num(p.id) + '</span><span class="nm">' + strip(p.name) + '</span><span class="yr">' + strip(p.yr).replace('2025–now', '2025–') + '</span>' + aiMark(p.ai, false) + '</a></li>'; });
});
h += '<li class="empty" id="empty" hidden>Nothing matches this filter.</li>';
plist.innerHTML = h;
})();
/* ---------- stage ---------- */
var stageId = null, sFrame = document.getElementById('sFrame');
function setStage(id, anim) {
if (id === stageId) return; stageId = id; var p = P(id);
sFrame.innerHTML = frameHTML(id); sFrame.href = '#p/' + id;
if (anim) sFrame.querySelector('.fbody').classList.add('swap');
document.getElementById('sNo').textContent = num(id);
document.getElementById('sWhere').innerHTML = whereOf(p) + ' · ' + p.yr;
document.getElementById('sAi').innerHTML = aiMark(p.ai);
document.getElementById('sTitle').innerHTML = p.name;
document.getElementById('sPitch').innerHTML = p.one;
document.getElementById('sOpen').href = '#p/' + id;
document.getElementById('sStack').innerHTML = p.stack.slice(0, 6).map(function (t) { return '<span class="chip">' + t + '</span>'; }).join('');
plist.querySelectorAll('.row').forEach(function (r) { r.classList.toggle('on', r.dataset.id === id); });
}
plist.addEventListener('mouseover', function (e) { var r = e.target.closest('.row'); if (r) setStage(r.dataset.id, true); });
plist.addEventListener('focusin', function (e) { var r = e.target.closest('.row'); if (r) setStage(r.dataset.id, true); });
plist.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
var rows = [].slice.call(plist.querySelectorAll('li:not(.hide) .row')), i = rows.indexOf(document.activeElement);
if (i < 0) return; e.preventDefault();
var n = rows[Math.max(0, Math.min(rows.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))]; n.focus();
});
/* ---------- filter ---------- */
function setPref(v) {
PREF = v;
document.querySelectorAll('[data-pref]').forEach(function (b) { b.setAttribute('aria-pressed', b.dataset.pref === v); });
var vis = 0;
PROJECTS.forEach(function (p) { var ok = allowed(p); if (ok) vis++; plist.querySelector('[data-li="' + p.id + '"]').classList.toggle('hide', !ok); });
plist.querySelectorAll('.grp').forEach(function (g) { g.classList.toggle('hide', !PROJECTS.some(function (p) { return p.g === g.dataset.g && allowed(p); })); });
document.getElementById('empty').hidden = vis > 0;
document.getElementById('count').textContent = vis === PROJECTS.length ? PROJECTS.length : vis + ' of ' + PROJECTS.length;
var hidden = PROJECTS.length - vis;
document.getElementById('fnote').textContent = v === 'all' ? '' : v === 'nofull' ? 'hiding ' + hidden + ' made fully with AI' : 'hiding ' + hidden + ': made with AI, or AI not noted';
if (!allowed(P(stageId)) && vis) setStage(visible()[0].id, true);
}
document.querySelectorAll('[data-pref]').forEach(function (b) { b.addEventListener('click', function () { setPref(b.dataset.pref); }); });
/* ---------- writing ---------- */
document.getElementById('posts').innerHTML = POSTS.map(function (w) {
return '<li><a href="https://blog.lucaswinther.info"><span class="d ph" title="placeholder date">' + 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><span class="ar">→</span></a></li>';
}).join('');
/* ---------- detail ---------- */
var detail = document.getElementById('detail'), current = null;
function neighbours(id) { var v = visible().map(function (p) { return p.id; }); if (v.indexOf(id) < 0) v = ORDER; var i = v.indexOf(id), n = v.length; return { prev: v[(i - 1 + n) % n], next: v[(i + 1) % n], i: i + 1, n: n }; }
function renderDetail(id) {
var p = P(id), nb = neighbours(id), pv = P(nb.prev), nx = P(nb.next), unk = p.ai === 'unk';
detail.innerHTML =
'<div class="dnav"><a class="back" href="#" data-back>← All projects <kbd>esc</kbd></a><span class="where">' + GROUPS[p.g].name + ' / ' + strip(p.name) + '</span>' +
'<span class="pn"><a href="#p/' + nb.prev + '" title="' + strip(pv.name) + '">← prev</a><span class="of">' + nb.i + ' / ' + nb.n + '</span><a href="#p/' + nb.next + '" title="' + strip(nx.name) + '">next →</a></span></div>' +
'<div class="dhead"><div><div class="lbl">' + num(id) + ' · ' + whereOf(p) + '</div><h1 class="d-title">' + p.name + '</h1><p class="pitch">' + p.one + '</p></div></div>' +
'<div class="dhero"><div class="frame d-frame">' + frameHTML(id) + '</div>' +
'<dl class="facts"><div><dt>when</dt><dd>' + p.yr + '</dd></div><div><dt>made</dt><dd>' + aiMark(p.ai) + (unk ? ' <span class="ph" title="placeholder">(to fill in)</span>' : '') + '</dd></div><div><dt>stack</dt><dd>' + p.stack.slice(0, 4).join(', ') + (p.stack.length > 4 ? ' +' + (p.stack.length - 4) : '') + '</dd></div></dl></div>' +
'<div class="dgrid"><div class="story">' +
'<section><h3>Why<span>the problem</span></h3><p>' + p.problem + '</p></section>' +
'<section><h3>How<span>the approach</span></h3><p>' + p.approach + '</p></section>' +
'<section><h3>The interesting part</h3><p>' + p.interesting + '</p></section>' +
'<section><h3>Who made what<span>' + (unk ? 'not noted yet' : 'me, or the AI') + '</span></h3><ul class="who">' + p.parts.map(function (pt) { return '<li><span>' + pt[0] + '</span>' + aiMark(pt[1]) + '</li>'; }).join('') + '</ul></section>' +
'</div><aside class="aside">' +
'<h4>links</h4><ul>' + (p.links.length ? p.links.map(function (l) { return l[1] ? '<li><a href="' + l[1] + '"' + (l[1] === '#' ? ' onclick="return false" title="placeholder link"' : '') + '>' + l[0] + '<small>↗</small></a></li>' : '<li class="dead">' + l[0] + '</li>'; }).join('') : '<li class="dead">no public links</li>') + '</ul>' +
'<h4>stack</h4><div class="stack">' + p.stack.map(function (t) { return '<span class="chip">' + t + '</span>'; }).join('') + '</div>' +
'<h4>writing about it</h4><ul>' + (p.posts.length ? p.posts.map(POST).map(function (w) { return '<li><a href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">' + w.t + '</span><small>' + w.d.slice(0, 7) + '</small></a></li>'; }).join('') : '<li class="dead">nothing written yet</li>') + '</ul>' +
'<h4>related</h4><ul>' + p.rel.map(function (r) { var q = P(r); return '<li><a href="#p/' + r + '">' + strip(q.name) + aiMark(q.ai, false) + '</a></li>'; }).join('') + '</ul>' +
'</aside></div>' +
'<div class="pnbig"><a href="#p/' + nb.prev + '"><span class="lbl">← previous</span><b>' + strip(pv.name) + '</b></a><a href="#p/' + nb.next + '"><span class="lbl">next →</span><b>' + strip(nx.name) + '</b></a></div>';
}
/* ---------- routing with view transitions ---------- */
var listY = 0, booted = false, enteredFromList = false;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
document.addEventListener('click', function (e) {
var a = e.target.closest('a[href^="#p/"]');
if (a && !document.body.classList.contains('in-detail')) { listY = window.scrollY; enteredFromList = true; }
if (e.target.closest('[data-back]')) { e.preventDefault(); goBack(); }
var j = e.target.closest('[data-jump]'); if (j) { e.preventDefault(); jump(j.dataset.jump); }
if (e.target.closest('[data-home]')) { e.preventDefault(); jump('intro'); }
});
function goBack() { if (enteredFromList && history.length > 1) history.back(); else location.hash = ''; }
function route() {
var m = location.hash.match(/^#p\/([\w-]+)/), id = m && P(m[1]) ? m[1] : null;
var apply = function () {
if (id) { renderDetail(id); document.body.classList.add('in-detail'); window.scrollTo(0, 0); document.title = strip(P(id).name) + ' · Lucas Winther'; current = id; }
else {
if (current) setStage(current); document.body.classList.remove('in-detail'); document.title = 'Lucas Winther / work';
var sec = location.hash.slice(1); if (sec && document.getElementById(sec)) document.getElementById(sec).scrollIntoView(); else window.scrollTo(0, listY);
if (current) { var r = plist.querySelector('[data-id="' + current + '"]'); if (r) r.focus({ preventScroll: true }); }
current = null;
}
};
if (booted && document.startViewTransition && !reduce) document.startViewTransition(apply); else apply();
}
window.addEventListener('hashchange', route);
function jump(sec) {
var go = function () { var el = document.getElementById(sec); if (el) el.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth' }); };
if (document.body.classList.contains('in-detail')) { enteredFromList = false; listY = 0; history.pushState(null, '', location.pathname); route(); setTimeout(go, 30); } else go();
}
/* ---------- keyboard ---------- */
document.addEventListener('keydown', function (e) {
var typing = /INPUT|TEXTAREA/.test(document.activeElement.tagName);
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !typing)) { e.preventDefault(); openPal(); return; }
if (typing || pal.open) return;
if (document.body.classList.contains('in-detail')) {
if (e.key === 'Escape') { e.preventDefault(); goBack(); }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { var nb = neighbours(current); location.hash = 'p/' + (e.key === 'ArrowLeft' ? nb.prev : nb.next); }
}
});
/* ---------- command palette ---------- */
var pal = document.getElementById('pal'), palq = document.getElementById('palq'), pallist = document.getElementById('pallist'), palItems = [], palOn = 0;
var CMDS = [];
PROJECTS.forEach(function (p) { CMDS.push({ g: 'Projects', k: num(p.id), label: strip(p.name), sub: strip(p.one), run: function () { if (!document.body.classList.contains('in-detail')) { listY = window.scrollY; enteredFromList = true; } location.hash = 'p/' + p.id; } }); });
POSTS.forEach(function (w) { CMDS.push({ g: 'Writing', k: '¶', label: w.t, sub: w.tag + ' · ' + w.d, run: function () { location.href = 'https://blog.lucaswinther.info'; } }); });
[['intro', 'Intro', 'top of the page'], ['cv', 'CV in short', 'job, education, contact'], ['projects', 'All projects', 'the list'], ['writing', 'Latest writing', 'blog']].forEach(function (s) { CMDS.push({ g: 'Sections', k: '§', label: s[1], sub: s[2], run: function () { jump(s[0]); } }); });
[['all', 'Show projects made any way'], ['nofull', 'Hide projects made fully by AI'], ['hand', 'Show only projects made by hand']].forEach(function (f) { CMDS.push({ g: 'Filter', k: '◧', label: f[1], sub: '', run: function () { setPref(f[0]); jump('projects'); } }); });
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'; } });
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'; } });
function score(c, q) {
if (!q) return 1; var hay = (c.label + ' ' + c.sub + ' ' + c.g).toLowerCase(), words = q.toLowerCase().split(/\s+/).filter(Boolean);
for (var i = 0; i < words.length; i++) if (hay.indexOf(words[i]) < 0) return 0;
var lab = c.label.toLowerCase(); return lab.indexOf(words[0]) === 0 ? 4 : (' ' + lab).indexOf(' ' + words[0]) >= 0 ? 3 : lab.indexOf(words[0]) >= 0 ? 2 : 1;
}
function renderPal() {
var q = palq.value.trim();
palItems = CMDS.map(function (c) { return { c: c, s: score(c, q) }; }).filter(function (x) { return x.s > 0; });
if (q) palItems.sort(function (a, b) { return b.s - a.s; });
var groups = q ? ['Projects', 'Sections', 'Writing', 'Filter', 'Go'] : ['Projects', 'Sections', 'Writing', 'Filter', 'Go'], h = '', idx = 0, ordered = [];
groups.forEach(function (g) {
var items = palItems.filter(function (x) { return x.c.g === g; }); if (!q && g === 'Projects') items = items.slice(0, 17);
if (!items.length) return; h += '<li class="g">' + g + '</li>';
items.forEach(function (x) { ordered.push(x); h += '<li class="it" role="option" data-i="' + idx + '"><span class="k">' + x.c.k + '</span><span>' + esc(x.c.label) + '</span><span class="s">' + esc(x.c.sub) + '</span></li>'; idx++; });
});
palItems = ordered; palOn = 0;
pallist.innerHTML = h || '<li class="none">Nothing found for “' + esc(q) + '”.</li>';
markPal();
}
function markPal() { pallist.querySelectorAll('.it').forEach(function (li) { var on = +li.dataset.i === palOn; li.classList.toggle('on', on); li.setAttribute('aria-selected', on); if (on) li.scrollIntoView({ block: 'nearest' }); }); }
function openPal() { if (pal.open) { palq.select(); return; } 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(); });
document.getElementById('kbtn').addEventListener('click', openPal);
/* ---------- boot ---------- */
var bar = document.getElementById('bar');
window.addEventListener('scroll', function () { bar.classList.toggle('scrolled', window.scrollY > 4); }, { passive: true });
setStage('compute');
route();
booted = true;
</script>
</body>
</html>