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

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

786 lines
67 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Work · Lucas Winther</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&family=Fraunces:ital,opsz,wght@1,9..144,300&display=swap" rel="stylesheet">
<style>
:root {
--bg: #e8eae6; --ink: #1c2024; --ink2: #343a3e; --mute: #6b726f; --line: #cfd3cd; --acc: #d9532b; --paper: #f5f6f3;
--a-bg: #2a1a1f; --a-ink: #f1e6d6;
--mono: "IBM Plex Mono", ui-monospace, monospace; --sans: "IBM Plex Sans", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 64px; }
html, body { margin: 0; }
body { font-family: var(--sans); color: var(--ink); background: var(--bg);
background-image: linear-gradient(var(--line) 1px, transparent 1px), linear-gradient(90deg, var(--line) 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); }
button { font: inherit; color: inherit; }
.lbl { font-family: var(--mono); font-size: 10.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--mute); }
.lbl b { color: var(--acc); font-weight: 500; }
.ph { text-decoration: underline dotted var(--mute); text-underline-offset: 3px; cursor: help; }
:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; }
kbd { font-family: var(--mono); font-size: 10px; border: 1px solid currentColor; border-radius: 3px; padding: 0 4px; }
.ai { font-family: var(--mono); font-size: 11px; color: var(--mute); white-space: nowrap; }
.ai i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; margin-right: 5px; vertical-align: 0; }
.ai.full { color: var(--acc); } .ai.full i { background: currentColor; }
.ai.some i { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
.ai.unk i { border-style: dotted; }
/* ---------- frame ---------- */
.edge { position: fixed; top: 0; right: 0; bottom: 0; width: 44px; 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; text-decoration: none; z-index: 30; transition: width .2s; }
.edge:hover { width: 52px; color: #e8b04a; }
.bar { position: sticky; top: 0; z-index: 20; margin-right: 44px; background: rgba(232,234,230,.94); border-bottom: 1px solid var(--ink); backdrop-filter: none; }
.bar-in { max-width: 1180px; margin: 0 auto; padding: 0 40px; height: 48px; display: flex; align-items: center; gap: 28px; font-family: var(--mono); font-size: 12px; }
.bar .me { font-weight: 500; font-size: 13.5px; text-decoration: none; }
.bar .me span { color: var(--mute); font-weight: 400; }
.bar nav { display: flex; gap: 18px; }
.bar nav a { text-decoration: none; color: var(--ink2); padding: 14px 0; border-bottom: 2px solid transparent; }
.bar nav a:hover, .bar nav a.on { color: var(--ink); border-bottom-color: var(--acc); }
.bar nav a span { color: var(--mute); }
.bar .sp { flex: 1; }
.pref { display: flex; gap: 5px; align-items: center; }
.pref .why { color: var(--mute); font-size: 11px; margin-right: 3px; }
.pref button { border: 1px solid var(--line); background: var(--paper); font-family: var(--mono); font-size: 11px; padding: 2px 8px; border-radius: 99px; cursor: pointer; color: var(--ink2); }
.pref button:hover { border-color: var(--ink); }
.pref button[aria-pressed="true"] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.bar .gate { color: var(--mute); text-decoration: none; }
.bar .gate:hover { color: var(--acc); }
main { margin-right: 44px; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 40px; }
/* ---------- head ---------- */
.head { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 56px; padding: 44px 0 36px; }
h1 { font-family: var(--mono); font-weight: 500; font-size: clamp(40px, 5vw, 64px); letter-spacing: -.03em; line-height: 1; margin: 14px 0 0; }
.lede { font-size: 18px; line-height: 1.55; color: var(--ink2); margin: 16px 0 0; max-width: 31em; }
.toc { margin-top: 26px; border-top: 1px solid var(--ink); }
.toc a { display: grid; grid-template-columns: 34px 1fr auto; gap: 10px; align-items: baseline; padding: 7px 0; border-bottom: 1px solid var(--line); text-decoration: none; font-size: 15px; }
.toc a:hover { background: rgba(255,255,255,.5); }
.toc a:hover .t { color: var(--acc); }
.toc .no { font-family: var(--mono); font-size: 11px; color: var(--acc); }
.toc .sh { font-family: var(--mono); font-size: 11px; color: var(--mute); }
.toc a.all .no { color: var(--mute); }
.cv { background: var(--paper); border: 1px solid var(--ink); padding: 14px 18px 4px; align-self: start; position: relative; margin-top: 8px; }
.cv::before { content: "cv in ten seconds"; position: absolute; top: -9px; left: 12px; background: var(--bg); padding: 0 6px; font-family: var(--mono); font-size: 10.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--mute); }
.cv .row { display: grid; grid-template-columns: 78px 1fr; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14px; line-height: 1.45; }
.cv .k { font-family: var(--mono); font-size: 10.5px; color: var(--mute); text-transform: uppercase; letter-spacing: .06em; padding-top: 2px; }
.cv small { color: var(--mute); font-family: var(--mono); font-size: 11px; }
.cv .links { display: flex; gap: 14px; flex-wrap: wrap; padding: 10px 0; font-family: var(--mono); font-size: 12px; align-items: center; }
.pdf { display: inline-flex; gap: 6px; align-items: center; border: 1px solid var(--ink); padding: 3px 9px; text-decoration: none; background: var(--bg); }
.pdf:hover { background: var(--ink); color: var(--paper); }
.part { display: flex; align-items: baseline; gap: 14px; border-top: 2px solid var(--ink); padding: 10px 0 0; margin-top: 30px; }
.part h2 { font-family: var(--mono); font-weight: 500; font-size: 22px; letter-spacing: -.02em; margin: 0; }
.part .lbl { margin-left: auto; text-transform: none; letter-spacing: 0; font-size: 11.5px; }
/* ---------- features ---------- */
.feat { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 0 40px; padding: 40px 0 46px; border-bottom: 1px solid var(--ink); }
.feat.gone { display: none; }
.meta { font-size: 13px; }
.meta .big { font-family: var(--mono); font-size: 46px; font-weight: 500; line-height: 1; letter-spacing: -.04em; color: var(--acc); }
.meta .shape { font-family: var(--mono); font-size: 11px; color: var(--mute); margin-top: 6px; }
.meta dl { margin: 18px 0 0; border-top: 1px solid var(--ink); }
.meta dt { font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: .09em; color: var(--mute); margin-top: 10px; }
.meta dd { margin: 2px 0 0; line-height: 1.45; }
.meta .st { font-family: var(--mono); font-size: 11.5px; }
.meta .lk { font-family: var(--mono); font-size: 12px; display: block; }
.meta .lk.dead { color: var(--mute); font-style: italic; }
.feat h3 { font-family: var(--mono); font-weight: 500; font-size: 32px; letter-spacing: -.03em; margin: 0; line-height: 1.1; }
.feat .one { font-size: 18px; line-height: 1.5; color: var(--ink2); margin: 10px 0 0; max-width: 36em; }
.plate { margin-top: 22px; background: var(--paper); border: 1px solid var(--ink); position: relative; }
.plate .cap { display: flex; justify-content: space-between; gap: 12px; border-top: 1px solid var(--line); padding: 8px 14px; font-family: var(--mono); font-size: 11.5px; color: var(--mute); min-height: 34px; }
.plate .cap b { color: var(--ink); font-weight: 500; }
.plate .cap .live { color: var(--ink); }
.story3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 28px; margin-top: 22px; }
.story3 h4 { margin: 0 0 4px; font-family: var(--mono); font-weight: 500; font-size: 10.5px; text-transform: uppercase; letter-spacing: .09em; color: var(--mute); border-top: 1px solid var(--ink); padding-top: 8px; }
.story3 h4 b { color: var(--acc); font-weight: 500; }
.story3 p { margin: 0; font-size: 15px; line-height: 1.6; }
.hiddenby { display: none; padding: 14px 0; border-bottom: 1px solid var(--ink); font-family: var(--mono); font-size: 12px; color: var(--mute); }
.hiddenby.show { display: block; }
.hiddenby button { background: none; border: 0; padding: 0; text-decoration: underline; text-decoration-color: var(--acc); text-underline-offset: 3px; cursor: pointer; font-family: var(--mono); font-size: 12px; color: var(--ink); }
/* F1 architecture */
.arch svg { display: block; width: 100%; height: auto; }
.arch .n rect { fill: var(--paper); stroke: var(--ink); stroke-width: 1.3; transition: fill .15s, stroke .15s; }
.arch .n text { font-family: var(--mono); font-size: 12px; fill: var(--ink); }
.arch .n text.s { font-size: 10px; fill: var(--mute); }
.arch .n { cursor: default; }
.arch .n:hover rect, .arch .n.on rect { fill: #fff; stroke: var(--acc); }
.arch .n.ghost rect { stroke-dasharray: 4 3; stroke: var(--mute); fill: transparent; }
.arch .w { fill: none; stroke: var(--ink); stroke-width: 1.2; }
.arch .w.req { stroke: var(--acc); }
.arch .w.dash { stroke-dasharray: 3 3; }
.arch .zone { fill: none; stroke: var(--mute); stroke-dasharray: 5 4; }
.arch .zl { font-family: var(--mono); font-size: 11px; fill: var(--mute); }
/* F2 contact sheet */
.strip { background: var(--ink); padding: 18px 14px; display: grid; grid-template-columns: 1fr 1fr 1fr 2fr; gap: 10px; position: relative; }
.strip::before, .strip::after { content: ""; position: absolute; left: 10px; right: 10px; height: 6px; background: repeating-linear-gradient(90deg, var(--bg) 0 8px, transparent 8px 20px); opacity: .55; }
.strip::before { top: 6px; } .strip::after { bottom: 6px; }
.frame { background: var(--bg); aspect-ratio: 4 / 3; position: relative; overflow: hidden; border: 0; padding: 0; cursor: default; text-align: left; }
.frame.wide { aspect-ratio: auto; }
.frame .hatch { position: absolute; inset: 0; background: repeating-linear-gradient(135deg, rgba(28,32,36,.08) 0 1px, transparent 1px 8px); }
.frame .fl { position: absolute; left: 8px; bottom: 6px; right: 8px; font-family: var(--mono); font-size: 10px; color: var(--ink2); }
.frame .fn { position: absolute; left: 8px; top: 6px; font-family: var(--mono); font-size: 10px; color: var(--acc); }
.frame svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.frame:hover, .frame.on { outline: 2px solid var(--acc); outline-offset: -2px; }
.wire text { font-family: var(--mono); font-size: 11px; fill: var(--ink); }
.wire text.s { font-size: 9.5px; fill: var(--mute); }
/* F3 network */
.netwrap { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); }
.net svg { display: block; width: 100%; height: auto; }
.net .e { stroke: var(--line); stroke-width: 1.4; transition: stroke .15s; }
.net .e.on { stroke: var(--ink); }
.net .e.bad.on { stroke: var(--acc); stroke-dasharray: 4 3; }
.net .pe circle { fill: var(--paper); stroke: var(--ink); stroke-width: 1.4; transition: fill .15s; }
.net .pe.fr circle { stroke: var(--acc); stroke-dasharray: 3 2; }
.net .pe text { font-family: var(--mono); font-size: 10px; fill: var(--ink); text-anchor: middle; pointer-events: none; }
.net .pe { cursor: default; }
.net .pe:hover circle, .net .pe.on circle { fill: var(--ink); }
.net .pe:hover text, .net .pe.on text { fill: var(--paper); }
.readout { border-left: 1px solid var(--line); padding: 18px 20px; font-family: var(--mono); font-size: 12px; display: flex; flex-direction: column; }
.readout .scr { background: var(--bg); border: 1px solid var(--ink); padding: 12px 14px; line-height: 1.8; min-height: 150px; }
.readout .scr .k { color: var(--mute); display: inline-block; width: 92px; }
.readout .gauge { height: 8px; border: 1px solid var(--ink); margin: 4px 0 2px; position: relative; background: var(--paper); }
.readout .gauge i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--ink); transition: width .2s; }
.readout .gauge.low i { background: var(--acc); }
.readout .lg { margin-top: 14px; color: var(--mute); font-size: 11px; line-height: 1.7; }
/* F4 before / after */
.ba { display: grid; grid-template-columns: 1fr 40px 1fr; }
.ba .side { padding: 16px 18px 18px; }
.ba .side h5 { margin: 0 0 10px; font-family: var(--mono); font-size: 10.5px; font-weight: 500; text-transform: uppercase; letter-spacing: .09em; color: var(--mute); display: flex; justify-content: space-between; }
.ba .side h5 b { color: var(--ink); font-weight: 500; }
.ba .mid { border-left: 1px solid var(--line); border-right: 1px solid var(--line); display: flex; align-items: center; justify-content: center; font-family: var(--mono); color: var(--acc); font-size: 18px; background: repeating-linear-gradient(90deg, transparent 0 4px, rgba(28,32,36,.04) 4px 5px); }
.notes { list-style: none; margin: 0; padding: 0; font-family: var(--mono); font-size: 12px; line-height: 1.5; }
.notes li { display: grid; grid-template-columns: 70px 1fr; gap: 8px; padding: 3px 0; border-bottom: 1px dotted var(--line); }
.notes li span:first-child { color: var(--mute); }
.notes .x { color: var(--acc); }
.tree { font-family: var(--mono); font-size: 12px; line-height: 1.6; white-space: pre; margin: 0; color: var(--ink2); }
.tree b { color: var(--ink); font-weight: 500; }
.cmd { margin-top: 12px; border: 1px solid var(--ink); background: var(--bg); padding: 8px 10px; font-family: var(--mono); font-size: 12px; }
.cmd .r { display: block; color: var(--mute); margin-top: 4px; font-size: 11px; }
.cmd .r b { color: var(--ink); font-weight: 500; }
.who { display: flex; gap: 22px; flex-wrap: wrap; padding: 10px 14px; border-top: 1px solid var(--line); font-family: var(--mono); font-size: 11.5px; }
.meter { display: inline-grid; grid-template-columns: repeat(10, 5px); gap: 2px; vertical-align: -1px; margin-right: 6px; }
.meter i { height: 10px; border: 1px solid var(--ink); }
.meter i.f { background: var(--acc); border-color: var(--acc); }
.meter i.h { background: var(--ink); }
/* ---------- index ---------- */
.ix-tools { display: flex; gap: 16px; align-items: baseline; margin: 14px 0 6px; font-family: var(--mono); font-size: 11.5px; color: var(--mute); flex-wrap: wrap; }
.seg { display: inline-flex; border: 1px solid var(--ink); }
.seg button { border: 0; background: var(--paper); font-family: var(--mono); font-size: 11.5px; padding: 3px 10px; cursor: pointer; }
.seg button + button { border-left: 1px solid var(--ink); }
.seg button[aria-pressed="true"] { background: var(--ink); color: var(--paper); }
.ix-g { margin-top: 18px; }
.ix-gh { display: grid; grid-template-columns: 52px 1fr; align-items: end; border-bottom: 1px solid var(--ink); padding-bottom: 6px; }
.ix-gh .L { font-family: var(--mono); font-size: 34px; font-weight: 500; line-height: .9; color: var(--acc); }
.ix-gh .n { font-family: var(--mono); font-size: 14px; font-weight: 500; }
.ix-gh .n span { color: var(--mute); font-weight: 400; font-size: 11.5px; margin-left: 8px; }
.row { border-bottom: 1px solid var(--line); }
.row.off { display: none; }
.row > button { display: grid; grid-template-columns: 52px 92px minmax(0, 1fr) 210px 66px 82px 18px; gap: 14px; align-items: center; width: 100%; background: none; border: 0; padding: 9px 0; text-align: left; cursor: pointer; position: relative; }
.row > button:hover { background: rgba(255,255,255,.55); }
.row > button:hover .nm { color: var(--acc); }
.row .no { font-family: var(--mono); font-size: 11px; color: var(--mute); padding-left: 4px; }
.thumb { height: 36px; border: 1px solid var(--line); background: var(--bg); position: relative; transition: border-color .15s; }
.thumb svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.row > button:hover .thumb { border-color: var(--ink); }
.row .nm { font-size: 15.5px; font-weight: 500; }
.row .nm .fe { font-family: var(--mono); font-size: 10px; font-weight: 400; color: var(--acc); border: 1px solid var(--acc); padding: 0 4px; margin-left: 8px; vertical-align: 2px; }
.row .ds { display: block; font-size: 13.5px; color: var(--mute); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .stk { font-family: var(--mono); font-size: 11px; color: var(--ink2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .yr { font-family: var(--mono); font-size: 11.5px; }
.row .pm { font-family: var(--mono); color: var(--mute); transition: transform .2s; }
.row.open .pm { transform: rotate(45deg); color: var(--acc); }
.row.open > button { background: var(--paper); }
.more { display: none; grid-template-columns: 52px 92px 1fr; gap: 14px; padding: 4px 0 18px; background: var(--paper); }
.row.open .more { display: grid; }
.more .body { grid-column: 3; }
.more .s3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
.more h4 { margin: 0 0 3px; font-family: var(--mono); font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: .09em; color: var(--mute); }
.more p { margin: 0; font-size: 14px; line-height: 1.55; }
.more .ft { margin-top: 12px; font-family: var(--mono); font-size: 12px; display: flex; gap: 16px; flex-wrap: wrap; align-items: baseline; }
.more .ft .dead { color: var(--mute); font-style: italic; }
.ix-legend { margin-top: 12px; font-family: var(--mono); font-size: 11px; color: var(--mute); display: flex; gap: 16px; flex-wrap: wrap; }
/* ---------- writing ---------- */
.wr { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 40px; margin-top: 16px; }
.wr .lead { background: var(--paper); border: 1px solid var(--ink); padding: 18px 20px; text-decoration: none; display: block; }
.wr .lead .t { font-family: var(--mono); font-weight: 500; font-size: 21px; letter-spacing: -.02em; line-height: 1.25; display: block; margin-top: 8px; }
.wr .lead p { margin: 10px 0 0; color: var(--ink2); line-height: 1.55; }
.wr .lead:hover .t { color: var(--acc); }
.wr ul { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--ink); }
.wr li a { display: grid; grid-template-columns: 92px 1fr 70px; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); text-decoration: none; align-items: baseline; }
.wr .d, .wr .tg { font-family: var(--mono); font-size: 11px; color: var(--mute); }
.wr .tg { text-align: right; }
.wr li a:hover .t { color: var(--acc); }
.wr li .b { display: block; font-size: 13.5px; color: var(--mute); margin-top: 2px; }
footer { margin-top: 60px; border-top: 1px solid var(--ink); padding: 16px 0 40px; display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; font-family: var(--mono); font-size: 12px; color: var(--mute); }
footer a { color: var(--ink); }
.mk { position: fixed; top: 58px; right: 54px; z-index: 50; width: 310px; 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; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } * { transition: none !important; } }
@media (max-width: 1080px) {
.bar nav { display: none; }
.row > button { grid-template-columns: 44px 72px minmax(0, 1fr) 60px 70px 18px; }
.row .stk { display: none; }
.more { grid-template-columns: 44px 72px 1fr; }
}
@media (max-width: 760px) {
.edge { position: static; writing-mode: horizontal-tb; width: auto; height: 40px; }
.edge:hover { width: auto; }
.bar, main { margin-right: 0; }
.bar-in { padding: 0 14px; gap: 10px; height: auto; flex-wrap: wrap; padding-top: 8px; padding-bottom: 8px; }
.pref .why { display: none; }
.wrap { padding: 0 14px; }
.head, .feat, .story3, .netwrap, .ba, .wr, .more .s3 { grid-template-columns: 1fr; }
.head { gap: 30px; }
.feat { gap: 18px; }
.meta dl { display: none; }
.strip { grid-template-columns: 1fr 1fr; }
.frame.wide { grid-column: 1 / -1; aspect-ratio: 2 / 1; }
.readout { border-left: 0; border-top: 1px solid var(--line); }
.ba .mid { height: 30px; border: 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.row > button { grid-template-columns: 34px minmax(0, 1fr) 60px 18px; }
.row .thumb, .row .yr { display: none; }
.more { grid-template-columns: 1fr; padding: 4px 10px 14px; }
.more .body { grid-column: 1; }
.wr li a { grid-template-columns: 1fr; gap: 2px; }
.wr .tg { text-align: left; }
.mk { right: 10px; left: 10px; width: auto; }
}
</style>
</head>
<body>
<div class="mk" id="mk"><button onclick="document.getElementById('mk').remove()" aria-label="close">×</button>
<b>tech-r2-d · Featured, then everything</b>What "enter work" opens: a normal scrolling page. Four featured projects, each told in the shape that suits it (architecture, contact sheet, network, before/after), then a full index of all 17 with personality, then writing. Grid paper and Plex from nav-d's left gate; varied shapes from sankar.work, kept restrained; the index is T1 with character.</div>
<a class="edge" href="nav-d.html" title="cross over to the personal side">off the clock →</a>
<header class="bar">
<div class="bar-in">
<a class="me" href="#top">Lucas Winther <span>/ work</span></a>
<nav aria-label="On this page"><a href="#featured">featured <span>4</span></a><a href="#index">index <span>17</span></a><a href="#writing">writing</a><a href="#top">cv</a></nav>
<span class="sp"></span>
<span class="pref" role="group" aria-label="Filter projects by how they were made">
<span class="why">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>
</span>
<a class="gate" href="nav-d.html" title="back to the two gates">↖ gate</a>
</div>
</header>
<main id="top">
<div class="wrap">
<!-- ============ head ============ -->
<section class="head">
<div>
<div class="lbl"><b>●</b> 01 / work</div>
<h1>Work</h1>
<p class="lede">I build and run research software at Aarhus University, and make things on my own time, lately often with AI in the loop. Four projects are told properly below; everything else is in the index, each marked with how much of it an AI wrote.</p>
<div class="toc" id="toc"></div>
</div>
<div class="cv" id="cv">
<div class="row"><span class="k">now</span><span>IT-medarbejder, IT Research Tech, Aarhus University <small>2025–now</small><br><small>devops · containers · linux · observability · cloud · full-stack</small></span></div>
<div class="row"><span class="k">before</span><span>Intern, ScienceAtHome <small>2019</small><br>Student helper, Local Configuration Management, TERMA Group <small>2016–2018</small></span></div>
<div class="row"><span class="k">education</span><span>BSc Computer Science, <span class="ph" title="placeholder">Aarhus University</span> <small class="ph" title="placeholder">2022</small><br><small>thesis: HyperCache, reputation in P2P archival</small></span></div>
<div class="row"><span class="k">contact</span><span><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br><small><a href="https://github.com/StereotypicalCat">github/StereotypicalCat</a> · <a href="https://www.linkedin.com/in/lucas-winther/">linkedin/lucas-winther</a></small></span></div>
<div class="links"><a class="pdf" href="#" onclick="return false" title="placeholder: CV PDF">↓ CV (PDF)</a><a href="#" onclick="return false" title="placeholder">old site, 2021, by hand</a><a href="#" onclick="return false" title="placeholder">/simple</a></div>
</div>
</section>
<!-- ============ featured ============ -->
<div class="part" id="featured"><h2>Featured</h2><span class="lbl">four projects, each shown in the shape that explains it best</span></div>
<div id="features"></div>
<!-- ============ index ============ -->
<div class="part" id="index" style="margin-top:56px"><h2>Everything</h2><span class="lbl" id="ixCount">17 projects · click a row to open it</span></div>
<div class="ix-tools">
<span>arrange by</span>
<span class="seg" role="group" aria-label="Arrange index"><button aria-pressed="true" data-by="group">kind</button><button aria-pressed="false" data-by="year">year</button><button aria-pressed="false" data-by="ai">how it was made</button></span>
<span id="ixNote"></span>
</div>
<div id="ix"></div>
<div class="ix-legend"><span><span class="ai full"><i></i>fully AI</span></span><span><span class="ai some"><i></i>some AI</span></span><span><span class="ai none"><i></i>by hand</span></span><span><span class="ai unk"><i></i>not recorded yet</span></span><span>dotted underline = placeholder</span></div>
<!-- ============ writing ============ -->
<div class="part" id="writing" style="margin-top:56px"><h2>Latest writing</h2><span class="lbl"><a href="https://blog.lucaswinther.info">all of it at blog.lucaswinther.info →</a></span></div>
<div class="wr" id="wr"></div>
<footer>
<span><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a> · <a href="https://github.com/StereotypicalCat">GitHub</a> · <a href="https://www.linkedin.com/in/lucas-winther/">LinkedIn</a> · <a href="nav-d.html">↖ back to the gate</a></span>
<span>Placeholders: university &amp; year, most project years, story details, images, post titles, CV PDF, AI level for AU work.</span>
</footer>
</div>
</main>
<script>
/* ---------- shared content (same in tech-r2-c and tech-r2-d) ---------- */
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>'; }
var PREF = 'all';
function allowed(p) { if (PREF === 'nofull') return p.ai !== 'full'; if (PREF === 'hand') return p.ai === 'none'; return true; }
/* ---------- line-drawn figure placeholders, one motif per kind of project ---------- */
function figSVG(m) {
var s = '<svg viewBox="0 0 720 280" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><g fill="none" stroke="#1c2024" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">';
var A = '#d9532b';
function box(x, y, w, h, t, acc) { return '<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" fill="#f5f6f3"' + (acc ? ' stroke="' + A + '"' : '') + '/><text x="' + (x + w / 2) + '" y="' + (y + h / 2 + 4) + '" text-anchor="middle" fill="#1c2024" stroke="none" font-family="IBM Plex Mono" font-size="12">' + t + '</text>'; }
if (m === 'arch') {
s += '<rect x="372" y="34" width="320" height="214" stroke-dasharray="4 4" stroke="#6b726f"/><text x="384" y="52" fill="#6b726f" stroke="none" font-family="IBM Plex Mono" font-size="11">oracle cloud · scales up/down</text>';
s += box(28, 120, 92, 40, 'browser') + box(160, 120, 100, 40, 'web app', 1) + box(400, 120, 104, 40, 'slurm ctl') + box(560, 70, 100, 34, 'node') + box(560, 123, 100, 34, 'node') + box(560, 176, 100, 34, 'node ···');
s += box(160, 210, 100, 34, 'entra id') + box(160, 36, 100, 34, 'erda');
s += '<path d="M120 140H160M260 140H400" stroke="' + A + '"/><path d="M504 140H530M530 87V193M530 87H560M530 140H560M530 193H560"/><path d="M210 160V210M210 70V120" stroke-dasharray="3 3"/>';
} else if (m === 'infra') {
for (var i = 0; i < 3; i++) { var x = 90 + i * 200; s += '<rect x="' + x + '" y="70" width="140" height="150" fill="#f5f6f3"/>'; for (var j = 0; j < 4; j++) s += '<path d="M' + (x + 12) + ' ' + (92 + j * 32) + 'H' + (x + 100) + '"/><circle cx="' + (x + 120) + '" cy="' + (92 + j * 32) + '" r="3.5"' + (j === 1 && i === 1 ? ' fill="' + A + '" stroke="' + A + '"' : '') + '/>'; }
s += '<path d="M230 145H290M430 145H490" stroke-dasharray="3 3"/><text x="360" y="250" text-anchor="middle" fill="#6b726f" stroke="none" font-family="IBM Plex Mono" font-size="11">host-01 · host-02 · host-03</text>';
} else if (m === 'web') {
s += '<rect x="120" y="30" width="480" height="222" fill="#f5f6f3"/><path d="M120 54H600"/><circle cx="136" cy="42" r="3"/><circle cx="148" cy="42" r="3"/><circle cx="160" cy="42" r="3"/>';
s += '<rect x="144" y="74" width="120" height="156"/><path d="M290 84H570M290 102H520" /><rect x="290" y="124" width="280" height="40" stroke="' + A + '"/><rect x="290" y="176" width="130" height="54"/><rect x="440" y="176" width="130" height="54"/>';
} else if (m === 'doc') {
s += '<rect x="250" y="20" width="220" height="244" fill="#f5f6f3"/><path d="M276 50H420M276 70H444M276 84H444M276 98H400"/><rect x="276" y="116" width="168" height="70" stroke="' + A + '"/><path d="M276 204H444M276 218H444M276 232H380"/><rect x="216" y="36" width="20" height="200" stroke-dasharray="3 3" stroke="#6b726f"/>';
} else if (m === 'net') {
var n = [[120, 80], [200, 190], [290, 110], [370, 220], [400, 60], [480, 150], [560, 90], [600, 210], [250, 240], [160, 140]];
var e = [[0, 2], [0, 9], [9, 1], [1, 2], [2, 4], [2, 5], [4, 6], [5, 6], [5, 7], [3, 5], [3, 8], [1, 8], [6, 7]];
e.forEach(function (k) { s += '<path d="M' + n[k[0]][0] + ' ' + n[k[0]][1] + 'L' + n[k[1]][0] + ' ' + n[k[1]][1] + '"' + (k[0] === 2 && k[1] === 5 ? ' stroke="' + A + '"' : '') + '/>'; });
n.forEach(function (p, i) { s += '<circle cx="' + p[0] + '" cy="' + p[1] + '" r="' + (i === 7 ? 10 : 8) + '" fill="#f5f6f3"' + (i === 7 ? ' stroke="' + A + '" stroke-dasharray="3 2"' : '') + '/>'; });
} else if (m === 'game') {
s += '<path d="M40 150H680" stroke="#6b726f"/><path d="M60 180q20 -8 40 0t40 0t40 0M420 200q20 -8 40 0t40 0t40 0M200 230q20 -8 40 0t40 0" stroke="#6b726f"/>';
s += '<path d="M300 170L420 170L400 186L320 186Z" fill="#f5f6f3"/><path d="M330 172L250 222M390 172L470 222" stroke="' + A + '" stroke-width="2"/><rect x="242" y="216" width="18" height="10" transform="rotate(-30 251 221)"/><rect x="462" y="216" width="18" height="10" transform="rotate(30 471 221)"/>';
} else if (m === 'board') {
for (var r = 0; r < 6; r++) for (var c = 0; c < 6; c++) if ((r + c) % 2) s += '<rect x="' + (270 + c * 30) + '" y="' + (50 + r * 30) + '" width="30" height="30" fill="#cfd3cd" stroke="none"/>';
s += '<rect x="270" y="50" width="180" height="180"/><path d="M470 110q40 30 0 60" stroke="' + A + '"/><path d="M466 166l6 6l2 -8" stroke="' + A + '"/>';
} else if (m === 'bot') {
s += '<rect x="170" y="40" width="380" height="200" fill="#f5f6f3"/><rect x="194" y="64" width="210" height="30"/><rect x="290" y="110" width="236" height="56" stroke="' + A + '"/><path d="M306 128H480M306 146H430"/><rect x="194" y="182" width="160" height="30"/>';
} else if (m === 'vr') {
s += '<path d="M120 250L300 150H420L600 250M200 250L320 150M520 250L400 150M360 150V250M160 220H560M220 185H500"/>';
s += '<rect x="290" y="40" width="140" height="70" rx="14" fill="#f5f6f3"/><circle cx="330" cy="75" r="16" stroke="' + A + '"/><circle cx="390" cy="75" r="16" stroke="' + A + '"/>';
}
return s + '</g></svg>';
}
(function () {
var counts = {}; PROJECTS.forEach(function (p) { counts[p.g] = (counts[p.g] || 0) + 1; p.no = GROUPS[p.g].key + counts[p.g]; });
var FEAT = [
{ id: 'compute', shape: 'architecture', visual: archPlate },
{ id: 'rowing', shape: 'contact sheet + wiring', visual: rowPlate },
{ id: 'hypercache', shape: 'network', visual: netPlate },
{ id: 'config', shape: 'before / after', visual: baPlate }
];
FEAT.forEach(function (f, i) { P(f.id).feat = 'F' + (i + 1); });
function meter(level) { var n = { full: 10, some: 5, none: 0 }[level] || 0, h = ''; for (var i = 0; i < 10; i++) h += '<i class="' + (i < n ? 'f' : 'h') + '"></i>'; return '<span class="meter">' + h + '</span>'; }
// ---------- toc in the head ----------
document.getElementById('toc').innerHTML = FEAT.map(function (f, i) {
var p = P(f.id); return '<a href="#f-' + p.id + '"><span class="no">F' + (i + 1) + '</span><span class="t">' + p.name + '</span><span class="sh">' + f.shape + '</span></a>';
}).join('') + '<a class="all" href="#index"><span class="no">↓</span><span class="t">Everything else, 13 more</span><span class="sh">index</span></a>';
// ---------- F1 architecture ----------
function archPlate() {
var N = [
['br', 20, 150, 110, 46, 'researcher', 'browser', 'Researchers log in with their AU account and submit jobs from a form. No SSH, no batch scripts.'],
['web', 200, 110, 120, 46, 'web app', 'React', 'React front end: choose a job, choose data, follow its progress and log.'],
['api', 200, 196, 120, 46, 'api', 'Node.js', 'Node back end: keeps job state and talks to Slurm. Scales with load like the front end.'],
['ent', 20, 300, 110, 40, 'entra id', 'OAuth', 'Login through OAuth with Entra ID, so every AU account just works.'],
['erda', 200, 300, 120, 40, 'erda', 'AU storage', 'ERDA, AU\'s own storage: input data in, results back out, no copying by hand.'],
['slurm', 420, 196, 120, 46, 'slurm ctl', 'queue', 'Slurm queues the jobs and asks for more nodes when the queue grows.'],
['n1', 620, 96, 110, 38, 'node 01', 'running', 'A compute node on Oracle Cloud, started because jobs were waiting.'],
['n2', 620, 150, 110, 38, 'node 02', 'running', 'A compute node on Oracle Cloud, started because jobs were waiting.'],
['n3', 620, 204, 110, 38, 'node 03', 'idle → stop', 'Idle nodes are stopped again, so nothing is paid for while no one is computing.'],
['n4', 760, 96, 110, 38, 'node ··', 'on demand', 'Dashed nodes only exist while they are needed.', 1],
['n5', 760, 150, 110, 38, 'node ··', 'on demand', 'Dashed nodes only exist while they are needed.', 1]
];
var s = '<svg viewBox="0 0 900 370" role="img" aria-label="Architecture of the compute job platform">';
s += '<rect class="zone" x="180" y="70" width="705" height="190"/><text class="zl" x="192" y="88">oracle cloud · web stack and compute scale up and down</text>';
s += '<rect class="zone" x="8" y="282" width="330" height="76" style="stroke-dasharray:2 3"/><text class="zl" x="16" y="352" style="font-size:10px">aarhus university services</text>';
s += '<path class="w req" d="M130 173H165V133H200"/><path class="w req" d="M260 156V196"/><path class="w req" d="M320 219H420"/>';
s += '<path class="w" d="M540 219H580V115H620M580 169H620M580 223H620"/><path class="w dash" d="M730 115H760M730 169H760"/>';
s += '<path class="w dash" d="M260 242V300"/><path class="w dash" d="M75 196V300"/><path class="w dash" d="M320 320H480V242"/>';
N.forEach(function (n) {
s += '<g class="n' + (n[8] ? ' ghost' : '') + '" data-note="' + n[7].replace(/"/g, '&quot;') + '" data-name="' + n[5] + '" tabindex="0"><rect x="' + n[1] + '" y="' + n[2] + '" width="' + n[3] + '" height="' + n[4] + '"/>' +
'<text x="' + (n[1] + 10) + '" y="' + (n[2] + 18) + '">' + n[5] + '</text><text class="s" x="' + (n[1] + 10) + '" y="' + (n[2] + n[4] - 9) + '">' + n[6] + '</text></g>';
});
s += '<text class="zl" x="350" y="212">submit</text><text class="zl" x="350" y="314" style="font-size:10px">data in / out</text></svg>';
return '<div class="plate arch">' + s + '<div class="cap"><span class="live" id="archCap">Point at a box to see what it does. <span style="color:var(--mute)">Orange is the path a job takes.</span></span><span><b>fig. F1</b> · ' + PH('simplified') + '</span></div></div>';
}
// ---------- F2 contact sheet ----------
function rowPlate() {
var wire = '<svg class="wire" viewBox="0 0 400 150" preserveAspectRatio="xMidYMid meet"><g fill="none" stroke="#1c2024" stroke-width="1.2" stroke-linecap="round">' +
'<path d="M18 110L70 40" stroke="#d9532b" stroke-width="2.2"/><circle cx="58" cy="56" r="7" fill="#f5f6f3"/>' +
'<rect x="120" y="44" width="70" height="40" fill="#f5f6f3"/><path d="M65 56H120" stroke-dasharray="3 3"/>' +
'<rect x="230" y="44" width="70" height="40" fill="#f5f6f3"/><path d="M190 64H230"/><path d="M300 64H330"/>' +
'<rect x="330" y="44" width="58" height="40" fill="#f5f6f3"/></g>' +
'<text x="128" y="62">arduino</text><text class="s" x="128" y="76">reads both oars</text>' +
'<text x="238" y="62">serial</text><text class="s" x="238" y="76">usb, per stroke</text>' +
'<text x="337" y="62">unity</text><text class="s" x="337" y="76">C# · boat</text>' +
'<text class="s" x="40" y="30">oar + sensor<title>placeholder: sensor type</title></text><text class="s" x="80" y="112">× 2 (left, right)</text></svg>';
var F = [
['01', 'photo: the two oars on their rig', 'The rig: two real oars on pivots, one sensor each. ' + PH('Built from a workbench and scrap timber.')],
['02', 'photo: playtest', 'Playtesting. ' + PH('People rowed far harder than we had tuned for.')],
['03', 'screenshot: the lake in Unity', 'In the headset: the boat moves only as fast as you row.'],
['04', 'sketch: how the oars reach the game', 'Each oar\'s angle is read by the Arduino and sent over USB serial; Unity turns two angles into a stroke and moves the boat.', 1]
];
return '<div class="plate"><div class="strip">' + F.map(function (f) {
return '<button class="frame' + (f[3] ? ' wide' : '') + '" data-note="' + f[2].replace(/"/g, '&quot;') + '"><span class="hatch"' + (f[3] ? ' style="opacity:.35"' : '') + '></span>' + (f[3] ? wire : '') + '<span class="fn">' + f[0] + 'A</span><span class="fl">' + f[1] + '</span></button>';
}).join('') + '</div><div class="cap"><span class="live" id="rowCap">Four frames from the project. Point at one for its caption.</span><span><b>fig. F2</b> · ' + PH('photos to come') + '</span></div></div>';
}
// ---------- F3 network ----------
var PEERS = [
[90, 70, .91, 12, 12], [190, 170, .84, 11, 11], [70, 250, .77, 9, 9], [300, 80, .95, 14, 14], [320, 230, .62, 8, 9],
[430, 140, .88, 12, 12], [450, 280, .18, 3, 12, 1], [540, 70, .80, 10, 10], [580, 210, .72, 9, 9], [220, 300, .35, 4, 10, 1], [660, 130, .90, 13, 13]
];
var EDGES = [[0, 1], [0, 3], [1, 2], [1, 3], [1, 4], [2, 9], [3, 5], [3, 7], [4, 5], [4, 6], [4, 9], [5, 7], [5, 8], [6, 8], [7, 10], [8, 10], [5, 10], [6, 9]];
function netPlate() {
var s = '<svg viewBox="0 0 720 340" role="img" aria-label="Peer network with reputation scores">';
EDGES.forEach(function (e, i) { var a = PEERS[e[0]], b = PEERS[e[1]]; s += '<line class="e' + (a[5] || b[5] ? ' bad' : '') + '" data-a="' + e[0] + '" data-b="' + e[1] + '" x1="' + a[0] + '" y1="' + a[1] + '" x2="' + b[0] + '" y2="' + b[1] + '"/>'; });
PEERS.forEach(function (p, i) { var r = 12 + p[2] * 10; s += '<g class="pe' + (p[5] ? ' fr' : '') + '" data-i="' + i + '" tabindex="0"><circle cx="' + p[0] + '" cy="' + p[1] + '" r="' + r + '"/><text x="' + p[0] + '" y="' + (p[1] + 3.5) + '">' + String(i + 1).padStart(2, '0') + '</text></g>'; });
s += '</svg>';
return '<div class="plate"><div class="netwrap"><div class="net">' + s + '</div><div class="readout"><div class="lbl" style="margin-bottom:8px">peer readout</div><div class="scr" id="netScr">Point at a peer.<br><span style="color:var(--mute)">Circle size is its reputation. Dashed peers claim to store more than they do.</span></div>' +
'<div class="lg">Reputation rises when a peer can prove it still holds the chunks it promised, and falls when it can\'t. Numbers here are illustrative, ' + PH('not results from the thesis') + '.</div></div></div>' +
'<div class="cap"><span>Each peer stores pieces of an archive for the others. The question: who do you trust with yours?</span><span><b>fig. F3</b></span></div></div>';
}
function peerReadout(i) {
var p = PEERS[i], nb = EDGES.filter(function (e) { return e[0] === i || e[1] === i; }).length, low = p[2] < .5;
return '<span class="k">peer</span>' + String(i + 1).padStart(2, '0') + (p[5] ? ' <span style="color:var(--acc)">· freeloading</span>' : '') +
'<br><span class="k">reputation</span>' + p[2].toFixed(2) + '<div class="gauge' + (low ? ' low' : '') + '"><i style="width:' + Math.round(p[2] * 100) + '%"></i></div>' +
'<span class="k">holds</span>' + p[3] + ' of ' + p[4] + ' chunks' + (p[3] < p[4] ? ' <span style="color:var(--acc)">(claims ' + p[4] + ')</span>' : '') +
'<br><span class="k">neighbours</span>' + nb + '<br><span class="k">verdict</span>' + (low ? '<span style="color:var(--acc)">don\'t store here</span>' : 'safe to store here');
}
// ---------- F4 before / after ----------
function baPlate() {
return '<div class="plate"><div class="ba"><div class="side"><h5><b>before</b><span>' + PH('notes.txt') + '</span></h5><ul class="notes">' +
'<li><span>host-01</span><span>ssh in, git pull, docker compose up -d</span></li>' +
'<li><span>host-02</span><span>same, but the compose file there is older</span></li>' +
'<li><span>host-02</span><span class="x">forgot the .env, service down for a day</span></li>' +
'<li><span>host-03</span><span>"temporary" container started by hand in 2023</span></li>' +
'<li><span>new box</span><span class="x">which of these did I do last time?</span></li>' +
'</ul></div><div class="mid">→</div><div class="side"><h5><b>after</b><span>config/</span></h5>' +
'<pre class="tree"><b>inventory/</b>hosts.yml\n<b>roles/</b> base · docker · backups\n<b>stacks/</b> one compose file per service\n<b>site.yml</b></pre>' +
'<div class="cmd">$ ansible-playbook site.yml<span class="r">every host matches git · <b>' + PH('ok=48 changed=2 failed=0') + '</b></span></div></div></div>' +
'<div class="who"><span>who wrote what:</span><span>' + meter('none') + 'inventory, by hand</span><span>' + meter('full') + 'playbooks, AI</span><span>' + meter('full') + 'compose files, AI</span></div>' +
'<div class="cap"><span>The same three servers, before and after Config. ' + PH('Details illustrative.') + '</span><span><b>fig. F4</b></span></div></div>';
}
// ---------- render features ----------
document.getElementById('features').innerHTML = FEAT.map(function (f, i) {
var p = P(f.id);
return '<div class="hiddenby" data-for="' + p.id + '">F' + (i + 1) + ' ' + p.name + ' is hidden by your filter (' + AI[p.ai].long + '). <button data-show="' + p.id + '">show it anyway</button></div>' +
'<article class="feat" id="f-' + p.id + '" data-id="' + p.id + '"><div class="meta"><div class="big">F' + (i + 1) + '</div><div class="shape">shown as: ' + f.shape + '</div><dl>' +
'<dt>' + GROUPS[p.g].name + '</dt><dd>' + (p.where || (p.g === 'school' ? 'University project' : 'Own project')) + '</dd><dt>when</dt><dd>' + p.yr + '</dd><dt>made</dt><dd>' + aiMark(p.ai) + '</dd>' +
'<dt>stack</dt><dd class="st">' + p.stack.join(' · ') + '</dd><dt>links</dt><dd>' +
(p.links.length ? p.links.map(function (l) { return l[1] ? '<a class="lk" href="' + l[1] + '"' + (l[1] === '#' ? ' onclick="return false" title="placeholder link"' : '') + '>→ ' + l[0] + '</a>' : '<span class="lk dead">' + l[0] + '</span>'; }).join('') : '<span class="lk dead">none</span>') +
(p.posts.length ? '<a class="lk" href="https://blog.lucaswinther.info" title="placeholder title">✎ ' + POST(p.posts[0]).t.toLowerCase() + '</a>' : '') + '</dd></dl></div>' +
'<div><h3>' + p.name + '</h3><p class="one">' + p.one + '</p>' + f.visual() +
'<div class="story3"><div><h4><b>i.</b> problem</h4><p>' + p.problem + '</p></div><div><h4><b>ii.</b> approach</h4><p>' + p.approach + '</p></div><div><h4><b>iii.</b> what was interesting</h4><p>' + p.interesting + '</p></div></div>' +
'</div></article>';
}).join('');
// hover captions (only respond to the visitor)
function hoverCap(sel, capId, getText, idle) {
var cap = document.getElementById(capId);
document.querySelectorAll(sel).forEach(function (el) {
function on() { document.querySelectorAll(sel).forEach(function (x) { x.classList.toggle('on', x === el); }); cap.innerHTML = getText(el); }
el.addEventListener('mouseenter', on); el.addEventListener('focus', on);
el.addEventListener('mouseleave', function () { el.classList.remove('on'); cap.innerHTML = idle; });
});
}
hoverCap('.arch .n', 'archCap', function (el) { return '<b>' + el.dataset.name + '</b> · ' + el.dataset.note; }, document.getElementById('archCap').innerHTML);
hoverCap('.frame', 'rowCap', function (el) { return el.dataset.note; }, document.getElementById('rowCap').innerHTML);
var scr = document.getElementById('netScr'), scrIdle = scr.innerHTML;
document.querySelectorAll('.pe').forEach(function (g) {
var i = +g.dataset.i;
function on() {
document.querySelectorAll('.pe').forEach(function (x) { x.classList.toggle('on', x === g); });
document.querySelectorAll('.net .e').forEach(function (e) { e.classList.toggle('on', +e.dataset.a === i || +e.dataset.b === i); });
scr.innerHTML = peerReadout(i);
}
function off() { g.classList.remove('on'); document.querySelectorAll('.net .e').forEach(function (e) { e.classList.remove('on'); }); scr.innerHTML = scrIdle; }
g.addEventListener('mouseenter', on); g.addEventListener('focus', on); g.addEventListener('mouseleave', off); g.addEventListener('blur', off);
});
// ---------- index ----------
var BY = 'group', open = {};
function yearOf(p) { var m = strip(p.yr).match(/\d{4}/); return m ? +m[0] : 0; }
function rowHTML(p) {
return '<div class="row' + (open[p.id] ? ' open' : '') + '" data-row="' + p.id + '" id="p-' + p.id + '"><button aria-expanded="' + !!open[p.id] + '"><span class="no">' + p.no + '</span><span class="thumb">' + figSVG(p.motif) + '</span>' +
'<span><span class="nm">' + p.name + (p.feat ? '<span class="fe">' + p.feat + ' above</span>' : '') + '</span><span class="ds">' + strip(p.one) + '</span></span>' +
'<span class="stk">' + p.stack.map(strip).join(' · ') + '</span><span class="yr">' + p.yr + '</span>' + aiMark(p.ai) + '<span class="pm">+</span></button>' +
'<div class="more"><div class="body"><div class="s3"><div><h4>problem</h4><p>' + p.problem + '</p></div><div><h4>approach</h4><p>' + p.approach + '</p></div><div><h4>what was interesting</h4><p>' + p.interesting + '</p></div></div>' +
'<div class="ft">' + (p.links.length ? p.links.map(function (l) { return l[1] ? '<a href="' + l[1] + '"' + (l[1] === '#' ? ' onclick="return false" title="placeholder link"' : '') + '>→ ' + l[0] + '</a>' : '<span class="dead">' + l[0] + '</span>'; }).join('') : '<span class="dead">no public links</span>') +
(p.feat ? '<a href="#f-' + p.id + '">↑ read the full story (' + p.feat + ')</a>' : '') +
'<span style="color:var(--mute)">related: ' + p.rel.map(function (r) { return '<a href="#p-' + r + '" data-jump="' + r + '">' + P(r).name + '</a>'; }).join(', ') + '</span></div></div></div></div>';
}
function renderIndex() {
var groups;
if (BY === 'group') groups = Object.keys(GROUPS).map(function (g) { return [GROUPS[g].key, GROUPS[g].name, GROUPS[g].sub, PROJECTS.filter(function (p) { return p.g === g; })]; });
else if (BY === 'year') {
var ps = PROJECTS.slice().sort(function (a, b) { return yearOf(b) - yearOf(a); });
groups = [['↓', 'Newest first', 'years with a dotted line are guesses', ps]];
} else groups = [['●', 'Made with AI', 'fully AI', PROJECTS.filter(function (p) { return p.ai === 'full'; })], ['?', 'Not recorded yet', 'AU work, AI share to be filled in', PROJECTS.filter(function (p) { return p.ai === 'unk'; })], ['○', 'By hand', 'no AI at all', PROJECTS.filter(function (p) { return p.ai === 'none'; })]];
document.getElementById('ix').innerHTML = groups.map(function (g) {
return '<div class="ix-g"><div class="ix-gh"><span class="L">' + g[0] + '</span><span class="n">' + g[1] + '<span>' + g[2] + ' · ' + g[3].length + '</span></span></div>' + g[3].map(rowHTML).join('') + '</div>';
}).join('');
applyPref();
}
document.getElementById('ix').addEventListener('click', function (e) {
var j = e.target.closest('[data-jump]');
if (j) { open[j.dataset.jump] = true; renderIndex(); return; }
var b = e.target.closest('.row > button'); if (!b) return;
var r = b.parentNode, id = r.dataset.row; open[id] = !open[id];
r.classList.toggle('open', open[id]); b.setAttribute('aria-expanded', open[id]);
});
document.querySelectorAll('[data-by]').forEach(function (b) {
b.addEventListener('click', function () { BY = b.dataset.by; document.querySelectorAll('[data-by]').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); }); renderIndex(); });
});
// ---------- AI preference ----------
var shown = {};
function applyPref() {
var hidden = 0;
document.querySelectorAll('.row').forEach(function (r) { var ok = allowed(P(r.dataset.row)); r.classList.toggle('off', !ok); if (!ok) hidden++; });
FEAT.forEach(function (f) {
var ok = allowed(P(f.id)) || shown[f.id];
document.getElementById('f-' + f.id).classList.toggle('gone', !ok);
document.querySelector('.hiddenby[data-for="' + f.id + '"]').classList.toggle('show', !ok);
});
document.getElementById('ixNote').textContent = hidden ? hidden + ' hidden by "' + { nofull: 'not fully by AI', hand: 'by hand only' }[PREF] + '"' : '';
document.getElementById('ixCount').textContent = (PROJECTS.length - hidden) + ' projects · click a row to open it';
}
document.querySelectorAll('[data-pref]').forEach(function (b) {
b.addEventListener('click', function () {
PREF = b.dataset.pref; shown = {};
document.querySelectorAll('[data-pref]').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); });
applyPref();
});
});
document.getElementById('features').addEventListener('click', function (e) {
var s = e.target.closest('[data-show]'); if (s) { shown[s.dataset.show] = true; applyPref(); }
});
// ---------- writing ----------
var w0 = POSTS[0];
document.getElementById('wr').innerHTML = '<a class="lead" href="https://blog.lucaswinther.info"><span class="lbl"><span class="ph" title="placeholder">' + w0.d + '</span> · ' + w0.tag + ' · newest</span><span class="t ph" title="placeholder title">' + w0.t + '</span><p>' + w0.blurb + ' Related: <u>Event Tracker for Gacha Games</u>.</p></a>' +
'<ul>' + POSTS.slice(1).map(function (w) { return '<li><a href="https://blog.lucaswinther.info"><span class="d ph" title="placeholder">' + w.d + '</span><span><span class="t ph" title="placeholder title">' + w.t + '</span><span class="b">' + w.blurb + '</span></span><span class="tg">' + w.tag + '</span></a></li>'; }).join('') + '</ul>';
// ---------- section marker in the bar (passive, follows the visitor's scroll) ----------
var navs = document.querySelectorAll('.bar nav a');
var secs = ['featured', 'index', 'writing'].map(function (id) { return document.getElementById(id); });
window.addEventListener('scroll', function () {
var y = window.scrollY + 120, cur = null;
secs.forEach(function (s) { if (s.offsetTop <= y) cur = s.id; });
navs.forEach(function (a) { a.classList.toggle('on', cur && a.getAttribute('href') === '#' + cur); });
}, { passive: true });
// deep links: #p-config opens that row
if (/^#p-/.test(location.hash)) { open[location.hash.slice(3)] = true; }
renderIndex();
if (/^#p-/.test(location.hash)) { var t = document.getElementById(location.hash.slice(1)); if (t) t.scrollIntoView(); }
})();
</script>
<!-- Placeholders: university and graduation year, project years (except AU, ScienceAtHome, 2021 portfolio), ScienceAtHome project name and engine, rowing sensor type and photos, HyperCache numbers, Config before/after details, story details marked with dotted underline, all images, blog post titles/dates/excerpts, CV PDF, AI level for AU work. -->
</body>
</html>