Files
portfolio-next/mockups/tech-r2-c.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

681 lines
55 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>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, 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; }
.mono { font-family: var(--mono); }
.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; }
/* AI marks, same as nav-d */
.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 ---------- */
.shell { display: grid; grid-template-columns: 296px minmax(0, 1fr) 232px; margin-right: 44px; min-height: 100vh; }
.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: 10; transition: width .2s; }
.edge:hover { width: 52px; color: #e8b04a; }
/* ---------- left: the index ---------- */
.index { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; border-right: 1px solid var(--ink); background: rgba(232,234,230,.9); }
.ix-head { padding: 18px 18px 12px 22px; border-bottom: 1px solid var(--ink); }
.ix-top { display: flex; justify-content: space-between; align-items: baseline; font-family: var(--mono); font-size: 12px; }
.ix-top a { text-decoration: none; color: var(--mute); }
.ix-top a:hover { color: var(--acc); }
.ix-name { font-family: var(--mono); font-weight: 500; font-size: 15px; }
.ix-name span { color: var(--mute); font-weight: 400; }
.pref { margin-top: 14px; display: flex; flex-wrap: wrap; gap: 5px; align-items: center; }
.pref .lbl { width: 100%; margin-bottom: 2px; }
.pref button { background: none; border: 1px solid var(--line); background: var(--paper); font-family: var(--mono); font-size: 11px; padding: 2px 7px; 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); }
.pref-note { font-family: var(--mono); font-size: 10.5px; color: var(--mute); min-height: 14px; margin-top: 5px; }
.ix-list { flex: 1; overflow-y: auto; padding: 8px 0 18px; position: relative; scrollbar-width: thin; }
.ix-group { margin-top: 12px; }
.ix-gh { display: flex; align-items: baseline; gap: 8px; padding: 4px 18px 4px 22px; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .09em; color: var(--mute); }
.ix-gh b { color: var(--ink); font-weight: 500; }
.ix-gh::after { content: ""; flex: 1; border-bottom: 1px solid var(--line); transform: translateY(-3px); }
.it { position: relative; display: grid; grid-template-columns: 30px 1fr auto; gap: 0 8px; align-items: baseline; width: 100%; text-align: left; border: 0; background: none; padding: 6px 16px 6px 22px; cursor: pointer; }
.it::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 12px; background: repeating-linear-gradient(var(--mute) 0 1px, transparent 1px 6px); background-size: 6px 6px; background-repeat: repeat-y; opacity: .5; }
.it .no { font-family: var(--mono); font-size: 10.5px; color: var(--mute); }
.it .nm { font-size: 14.5px; line-height: 1.3; }
.it .sub { grid-column: 2 / 4; font-family: var(--mono); font-size: 10.5px; color: var(--mute); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.it .ai { font-size: 0; } .it .ai i { margin: 0; }
.it:hover { background: rgba(255,255,255,.55); }
.it[aria-current="true"] { background: var(--paper); box-shadow: inset 0 1px 0 var(--ink), inset 0 -1px 0 var(--ink); }
.it[aria-current="true"]::after { content: ""; position: absolute; left: 0; top: 50%; border: 6px solid transparent; border-left: 9px solid var(--acc); transform: translateY(-50%); }
.it[aria-current="true"] .nm { font-weight: 500; }
.it.off { display: none; }
.it.hl .nm { background: linear-gradient(transparent 62%, rgba(217,83,43,.25) 62%); }
.it.overview .nm { font-family: var(--mono); font-size: 13px; }
.ix-foot { padding: 10px 18px 12px 22px; border-top: 1px solid var(--line); font-family: var(--mono); font-size: 10.5px; color: var(--mute); display: flex; gap: 12px; flex-wrap: wrap; }
kbd { font-family: var(--mono); font-size: 10px; border: 1px solid currentColor; border-radius: 3px; padding: 0 4px; }
/* ---------- middle: the sheet ---------- */
.stage { padding: 22px 26px 60px; min-width: 0; }
.crumbs { display: flex; justify-content: space-between; gap: 16px; font-family: var(--mono); font-size: 11.5px; color: var(--mute); margin-bottom: 12px; }
.crumbs a { color: var(--mute); text-decoration: none; } .crumbs a:hover { color: var(--acc); }
.crumbs .here { color: var(--ink); }
.sheet { background: var(--paper); border: 1px solid var(--ink); padding: 34px 40px 40px; position: relative; }
.sheet::before, .sheet::after { content: ""; position: absolute; width: 14px; height: 14px; border-color: var(--acc); border-style: solid; }
.sheet::before { top: -6px; left: -6px; border-width: 1.5px 0 0 1.5px; }
.sheet::after { bottom: -6px; right: -6px; border-width: 0 1.5px 1.5px 0; }
.kick { display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: baseline; }
h1 { font-family: var(--mono); font-weight: 500; font-size: clamp(30px, 3.3vw, 44px); letter-spacing: -.03em; line-height: 1.05; margin: 12px 0 0; }
.one { font-size: 19px; line-height: 1.45; color: var(--ink2); max-width: 34em; margin: 12px 0 0; }
.fig { margin: 26px 0 0; }
.fig .box { border: 1px solid var(--ink); background: repeating-linear-gradient(135deg, rgba(28,32,36,.045) 0 1px, transparent 1px 9px), var(--bg); aspect-ratio: 720 / 280; position: relative; }
.fig svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.fig figcaption { margin-top: 6px; font-family: var(--mono); font-size: 11px; color: var(--mute); }
.fig figcaption b { color: var(--ink); font-weight: 500; }
.fig .ticks { position: absolute; left: 0; right: 0; top: -7px; height: 6px; background: repeating-linear-gradient(90deg, var(--ink) 0 1px, transparent 1px 24px); opacity: .5; }
.sec { margin-top: 36px; }
.sec > .lbl { display: flex; gap: 10px; align-items: baseline; border-bottom: 1px solid var(--ink); padding-bottom: 6px; margin-bottom: 4px; }
.sec > .lbl .r { margin-left: auto; text-transform: none; letter-spacing: 0; }
.story { display: grid; grid-template-columns: 150px 1fr; }
.story > div { padding: 12px 0; border-bottom: 1px solid var(--line); }
.story .k { font-family: var(--mono); font-size: 12px; color: var(--mute); padding-right: 16px; }
.story .k b { display: block; color: var(--acc); font-weight: 500; font-size: 10.5px; }
.story .v { font-size: 16px; line-height: 1.6; max-width: 40em; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 0 36px; }
.stack { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 10px; }
.tag { font-family: var(--mono); font-size: 12px; border: 1px solid var(--ink); padding: 2px 8px; background: var(--paper); cursor: pointer; }
.tag:hover, .tag.on { background: var(--ink); color: var(--paper); }
.links { list-style: none; margin: 0; padding: 8px 0 0; font-family: var(--mono); font-size: 13px; }
.links li { padding: 4px 0; }
.links .dead { color: var(--mute); font-style: italic; }
table.who { border-collapse: collapse; width: 100%; font-size: 14.5px; }
table.who td { padding: 9px 12px 9px 0; border-bottom: 1px solid var(--line); vertical-align: middle; }
table.who td:first-child { width: 38%; }
.meter { display: inline-grid; grid-template-columns: repeat(10, 7px); gap: 2px; vertical-align: middle; margin-right: 10px; }
.meter i { height: 12px; border: 1px solid var(--ink); }
.meter i.f { background: var(--acc); border-color: var(--acc); }
.meter i.h { background: var(--ink); }
.meter.unk i { border-style: dotted; border-color: var(--mute); }
.who-note { font-family: var(--mono); font-size: 11px; color: var(--mute); margin-top: 8px; }
.rel { display: grid; grid-template-columns: 1fr 1fr; gap: 0 36px; }
.rel ul { list-style: none; margin: 0; padding: 0; }
.rel li { border-bottom: 1px solid var(--line); }
.rel button, .rel a.post { display: grid; grid-template-columns: 30px 1fr auto; gap: 8px; width: 100%; text-align: left; background: none; border: 0; padding: 9px 0; cursor: pointer; font-size: 14.5px; text-decoration: none; align-items: baseline; }
.rel a.post { grid-template-columns: 82px 1fr; }
.rel button:hover .t, .rel a.post:hover .t { color: var(--acc); }
.rel .no, .rel .d { font-family: var(--mono); font-size: 10.5px; color: var(--mute); }
.rel .ai { font-size: 0; } .rel .ai i { margin: 0; }
.pn { display: flex; justify-content: space-between; margin-top: 40px; font-family: var(--mono); font-size: 12.5px; }
.pn button { background: none; border: 0; cursor: pointer; padding: 0; text-align: left; }
.pn button:last-child { text-align: right; }
.pn button:hover { color: var(--acc); }
.pn small { display: block; color: var(--mute); font-size: 10.5px; }
/* overview */
.intro { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 36px; }
.intro p { font-size: 17px; line-height: 1.6; color: var(--ink2); margin: 14px 0 0; max-width: 32em; }
.cv { border-top: 1px solid var(--ink); }
.cv .row { display: grid; grid-template-columns: 78px 1fr; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 14px; line-height: 1.45; }
.cv .row .k { font-family: var(--mono); font-size: 10.5px; color: var(--mute); text-transform: uppercase; letter-spacing: .06em; padding-top: 2px; }
.cv .row small { color: var(--mute); font-family: var(--mono); font-size: 11px; }
.cv .bar { display: flex; gap: 14px; flex-wrap: wrap; padding: 10px 0; font-family: var(--mono); font-size: 12px; }
.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); }
.starts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 14px; }
.start { text-align: left; background: none; border: 0; border-top: 1px solid var(--ink); padding: 10px 0 0; cursor: pointer; }
.start .mini { aspect-ratio: 720/280; border: 1px solid var(--line); background: var(--bg); position: relative; margin-bottom: 8px; transition: border-color .15s; }
.start .mini svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.start:hover .mini { border-color: var(--acc); }
.start .t { font-family: var(--mono); font-weight: 500; font-size: 14px; display: block; }
.start:hover .t { color: var(--acc); }
.start .s { font-size: 13.5px; color: var(--mute); line-height: 1.4; display: block; margin-top: 3px; }
.posts { list-style: none; margin: 0; padding: 0; }
.posts li { border-bottom: 1px solid var(--line); }
.posts a { display: grid; grid-template-columns: 92px 1fr 76px; gap: 12px; padding: 10px 0; text-decoration: none; align-items: baseline; }
.posts .d, .posts .tg { font-family: var(--mono); font-size: 11px; color: var(--mute); }
.posts .tg { text-align: right; }
.posts .t { font-size: 15.5px; }
.posts .b { display: block; font-size: 13.5px; color: var(--mute); margin-top: 2px; }
.posts a:hover .t { color: var(--acc); }
/* ---------- right: inspector strip ---------- */
.insp { position: sticky; top: 0; height: 100vh; overflow-y: auto; border-left: 1px solid var(--line); padding: 22px 18px 22px 18px; background: rgba(232,234,230,.82); font-size: 13.5px; }
.insp h3 { font-family: var(--mono); font-size: 10.5px; font-weight: 500; text-transform: uppercase; letter-spacing: .09em; color: var(--mute); margin: 22px 0 6px; padding-bottom: 4px; border-bottom: 1px solid var(--line); }
.insp h3:first-child { margin-top: 0; }
.insp > div + div { margin-top: 24px; }
.insp .head { font-family: var(--mono); font-size: 11px; display: flex; justify-content: space-between; color: var(--mute); }
.insp .head b { color: var(--acc); font-weight: 500; }
.sk { list-style: none; margin: 0; padding: 0; }
.sk button { display: flex; justify-content: space-between; width: 100%; background: none; border: 0; padding: 3px 0; cursor: pointer; font-family: var(--mono); font-size: 12px; text-align: left; }
.sk button span:last-child { color: var(--mute); }
.sk button .bar { flex: 1; margin: 0 8px; border-bottom: 1px dotted var(--line); transform: translateY(-4px); }
.sk button:hover, .sk button.on { color: var(--acc); }
.sk button.on span:last-child { color: var(--acc); }
.insp .hint { font-family: var(--mono); font-size: 10.5px; color: var(--mute); margin-top: 6px; line-height: 1.5; }
.insp ul.w { list-style: none; margin: 0; padding: 0; }
.insp ul.w li { padding: 5px 0; line-height: 1.35; }
.insp ul.w small { display: block; font-family: var(--mono); font-size: 10.5px; color: var(--mute); }
.share { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px; background: var(--paper); border: 1px solid var(--line); padding: 5px 6px; word-break: break-all; }
.share button { margin-left: auto; border: 1px solid var(--ink); background: none; font-family: var(--mono); font-size: 10px; padding: 1px 6px; cursor: pointer; flex: none; }
.share button:hover { background: var(--ink); color: var(--paper); }
/* mockup note */
.mk { position: fixed; top: 10px; 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; }
.sheet > * { animation: none; }
.sheet.swap > * { animation: in .18s ease-out; }
@keyframes in { from { opacity: .35; transform: translateY(3px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
@media (max-width: 1180px) {
.shell { grid-template-columns: 270px minmax(0, 1fr); }
.insp { position: static; height: auto; grid-column: 2; border-left: 0; border-top: 1px solid var(--line); margin: 0 26px 40px; padding: 18px 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0 28px; background: none; }
.insp h3:first-child, .insp h3 { margin-top: 12px; }
.insp > div { min-width: 0; }
}
@media (max-width: 760px) {
.shell { display: block; margin-right: 0; }
.edge { position: static; writing-mode: horizontal-tb; width: auto; height: 40px; }
.edge:hover { width: auto; }
.index { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--ink); }
.ix-list { max-height: 320px; }
.stage { padding: 16px 12px 30px; }
.sheet { padding: 24px 18px 28px; }
.story, .two, .rel, .intro, .starts { grid-template-columns: 1fr; }
.story .k { padding-top: 12px; border-bottom: 0; padding-bottom: 0; }
.posts a { grid-template-columns: 1fr; gap: 2px; }
.posts .tg { text-align: left; }
.insp { margin: 0 12px 30px; }
.mk { right: 10px; width: auto; left: 10px; }
}
</style>
</head>
<body>
<div class="mk" id="mk"><button onclick="document.getElementById('mk').remove()" aria-label="close">×</button>
<b>tech-r2-c · Project explorer</b>What "enter work" opens: every project in one list on the left, a big readable project sheet on the right that swaps instantly (↑/↓ or click; the URL hash changes so any project can be shared). A slim inspector shows skills and related writing. Grid paper, Plex Mono and accent from nav-d's left gate; list-plus-detail from ntch.design, the inspector from T4, "who wrote what" from T5.</div>
<a class="edge" href="nav-d.html" title="cross over to the personal side">off the clock →</a>
<div class="shell">
<!-- ============ the index ============ -->
<nav class="index" aria-label="Projects">
<div class="ix-head">
<div class="ix-top"><span class="ix-name">Lucas Winther <span>/ work</span></span><a href="nav-d.html" title="back to the two gates">↖ gate</a></div>
<div class="pref" role="group" aria-label="Filter projects by how they were made">
<span class="lbl">projects made</span>
<button aria-pressed="true" data-pref="all">any way</button>
<button aria-pressed="false" data-pref="nofull">not fully by AI</button>
<button aria-pressed="false" data-pref="hand">by hand only</button>
</div>
<div class="pref-note" id="prefNote"></div>
</div>
<div class="ix-list" id="list"></div>
<div class="ix-foot"><span><kbd>↑</kbd> <kbd>↓</kbd> move</span><span><kbd>esc</kbd> overview</span><span><span class="ai full"><i></i></span>AI <span class="ai some"><i></i></span>some <span class="ai none"><i></i></span>hand <span class="ai unk"><i></i></span>?</span></div>
</nav>
<!-- ============ the sheet ============ -->
<main class="stage">
<div class="crumbs"><span><a href="nav-d.html">lucaswinther.info</a> / <a href="#" data-go="">work</a> / <span class="here" id="here">overview</span></span><span><a href="#" data-cv>cv</a> · <a href="https://blog.lucaswinther.info">writing</a> · <a href="#" onclick="return false" title="placeholder: /simple business card">/simple</a></span></div>
<article class="sheet" id="sheet" aria-live="polite"></article>
</main>
<!-- ============ the inspector ============ -->
<aside class="insp" id="insp" aria-label="Context"></aside>
</div>
<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 list = document.getElementById('list'), sheet = document.getElementById('sheet'), insp = document.getElementById('insp'), here = document.getElementById('here');
var current = ''; // '' = overview
var skillOn = null;
// numbering: A1, A2, B1 ...
var counts = {}; PROJECTS.forEach(function (p) { counts[p.g] = (counts[p.g] || 0) + 1; p.no = GROUPS[p.g].key + counts[p.g]; });
// ---------- list ----------
function buildList() {
var h = '<button class="it overview" data-id=""><span class="no">00</span><span class="nm">overview &amp; cv</span><span></span><span class="sub">start here · latest writing</span></button>';
Object.keys(GROUPS).forEach(function (g) {
h += '<div class="ix-group"><div class="ix-gh"><b>' + GROUPS[g].key + '</b>' + GROUPS[g].name + ' · ' + GROUPS[g].sub + '</div>';
PROJECTS.filter(function (p) { return p.g === g; }).forEach(function (p) {
h += '<button class="it" data-id="' + p.id + '"><span class="no">' + p.no + '</span><span class="nm">' + p.name + '</span>' + aiMark(p.ai, false) +
'<span class="sub">' + strip(p.yr) + ' · ' + p.stack.slice(0, 3).map(strip).join(' · ') + '</span></button>';
});
h += '</div>';
});
list.innerHTML = h;
}
function items() { return Array.prototype.slice.call(list.querySelectorAll('.it:not(.off)')); }
function markList() {
list.querySelectorAll('.it').forEach(function (b) {
b.setAttribute('aria-current', b.dataset.id === current ? 'true' : 'false');
var p = P(b.dataset.id);
b.classList.toggle('off', !!p && !allowed(p) && p.id !== current);
b.classList.toggle('hl', !!p && !!skillOn && p.stack.map(strip).indexOf(skillOn) > -1);
});
}
// ---------- skills ----------
function skillCounts(ps) {
var c = {};
ps.forEach(function (p) { p.stack.forEach(function (s) { s = strip(s); c[s] = (c[s] || 0) + 1; }); });
return c;
}
var ALL = skillCounts(PROJECTS);
function skillList(names) {
return '<ul class="sk">' + names.map(function (s) {
return '<li><button data-skill="' + s + '" class="' + (s === skillOn ? 'on' : '') + '"><span>' + s + '</span><span class="bar"></span><span>' + ALL[s] + '</span></button></li>';
}).join('') + '</ul>';
}
// ---------- views ----------
function overview() {
var feat = ['compute', 'rowing', 'hypercache'].map(P);
return '' +
'<div class="kick"><span class="lbl"><b>●</b> 01 / work</span><span class="lbl">17 projects · 3 groups</span></div>' +
'<div class="intro"><div><h1>Work</h1>' +
'<p>I build and run research software at Aarhus University, and make things on my own time, lately often with AI in the loop. Every project here says what it is, why it exists, how it was built, and how much of it an AI wrote.</p>' +
'<p style="font-size:15px;color:var(--mute)">Pick anything on the left. <kbd>↑</kbd> <kbd>↓</kbd> walk through the list, and the address bar always points at what you are reading.</p></div>' +
'<div id="cv"><div class="lbl" style="margin-bottom:6px">cv in ten seconds</div><div class="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, ' + PH('Aarhus University') + ' <small>' + PH('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="bar"><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></div>' +
'</div></div></div>' +
'<div class="sec"><div class="lbl"><b>02</b> start with one of these <span class="r">or anything in the list</span></div><div class="starts">' +
feat.map(function (p) { return '<button class="start" data-id="' + p.id + '"><div class="mini">' + figSVG(p.motif) + '</div><span class="lbl">' + p.no + ' · ' + GROUPS[p.g].name + '</span><span class="t">' + p.name + '</span><span class="s">' + strip(p.one) + '</span></button>'; }).join('') +
'</div></div>' +
'<div class="sec"><div class="lbl"><b>03</b> latest writing <span class="r"><a href="https://blog.lucaswinther.info">blog.lucaswinther.info →</a></span></div><ul class="posts">' +
POSTS.slice(0, 4).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></div>';
}
function meter(level) {
var n = { full: 10, some: 5, none: 0, unk: 0 }[level], h = '';
for (var i = 0; i < 10; i++) h += '<i class="' + (level === 'unk' ? '' : i < n ? 'f' : 'h') + '"></i>';
return '<span class="meter' + (level === 'unk' ? ' unk' : '') + '">' + h + '</span>';
}
function project(p) {
var vis = items().filter(function (b) { return b.dataset.id; });
var idx = vis.findIndex(function (b) { return b.dataset.id === p.id; });
var prev = idx > 0 ? P(vis[idx - 1].dataset.id) : null, next = idx > -1 && idx < vis.length - 1 ? P(vis[idx + 1].dataset.id) : null;
var unk = p.parts.every(function (x) { return x[1] === 'unk'; });
return '' +
'<div class="kick"><span class="lbl"><b>' + p.no + '</b> · ' + GROUPS[p.g].name + '</span><span class="lbl">' + (p.where || 'own project') + '</span><span class="lbl">' + p.yr + '</span>' + aiMark(p.ai) + '</div>' +
'<h1>' + p.name + '</h1><p class="one">' + p.one + '</p>' +
'<figure class="fig"><div class="box"><span class="ticks"></span>' + figSVG(p.motif) + '</div><figcaption><b>fig. ' + p.no + '</b> · ' + p.fig + ' <span class="ph" title="placeholder image">(placeholder)</span></figcaption></figure>' +
'<div class="sec"><div class="lbl"><b>01</b> the story <span class="r ph" title="drafted from the one-line description; Lucas rewrites">draft text</span></div><div class="story">' +
'<div class="k"><b>i.</b>problem</div><div class="v">' + p.problem + '</div>' +
'<div class="k"><b>ii.</b>approach</div><div class="v">' + p.approach + '</div>' +
'<div class="k"><b>iii.</b>what was interesting</div><div class="v">' + p.interesting + '</div>' +
'</div></div>' +
'<div class="sec two"><div><div class="lbl" style="border-bottom:1px solid var(--ink);padding-bottom:6px"><b>02</b> stack</div><div class="stack">' +
p.stack.map(function (s) { return '<button class="tag' + (strip(s) === skillOn ? ' on' : '') + '" data-skill="' + strip(s) + '" title="mark every project that uses this">' + s + '</button>'; }).join('') +
'</div></div><div><div class="lbl" style="border-bottom:1px solid var(--ink);padding-bottom:6px"><b>03</b> links</div><ul class="links">' +
(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] + '</a></li>' : '<li class="dead">' + l[0] + '</li>'; }).join('') : '<li class="dead">no public links</li>') +
'</ul></div></div>' +
'<div class="sec"><div class="lbl"><b>04</b> who wrote what <span class="r">' + AI[p.ai].long + '</span></div><table class="who">' +
p.parts.map(function (x) { return '<tr><td>' + x[0] + '</td><td>' + meter(x[1]) + '<span class="ai ' + x[1] + '"><i></i>' + AI[x[1]].label + '</span></td></tr>'; }).join('') +
'</table><div class="who-note">' + (unk ? 'AI share for AU work is not recorded yet; the meter stays dotted until it is (placeholder).' : 'filled = written by AI · dark = written by hand') + '</div></div>' +
'<div class="sec"><div class="lbl"><b>05</b> related</div><div class="rel"><ul>' +
p.rel.map(P).map(function (r) { return '<li><button data-id="' + r.id + '"><span class="no">' + r.no + '</span><span class="t">' + r.name + '</span>' + aiMark(r.ai, false) + '</button></li>'; }).join('') +
'</ul><ul>' +
(p.posts.length ? p.posts.map(POST).map(function (w) { return '<li><a class="post" href="https://blog.lucaswinther.info"><span class="d">' + w.d + '</span><span class="t ph" title="placeholder title">' + w.t + '</span></a></li>'; }).join('') : '<li style="padding:9px 0;color:var(--mute);font-size:14px">no posts about this one yet</li>') +
'</ul></div></div>' +
'<div class="pn">' + (prev ? '<button data-id="' + prev.id + '"><small>↑ previous</small>' + prev.no + ' ' + prev.name + '</button>' : '<button data-id=""><small>↑ back to</small>overview</button>') +
(next ? '<button data-id="' + next.id + '"><small>next ↓</small>' + next.no + ' ' + next.name + '</button>' : '<span></span>') + '</div>';
}
function inspector(p) {
var url = 'lucaswinther.info/work' + (p ? '#' + p.id : '');
if (!p) {
var top = Object.keys(ALL).filter(function (s) { return ALL[s] > 1; }).sort(function (a, b) { return ALL[b] - ALL[a]; });
return '<div><h3>inspector</h3><div class="head"><span>everything</span><b>17</b></div><div class="hint">Context for whatever is open. Click a skill to mark the projects that use it.</div></div>' +
'<div><h3>skills across projects</h3>' + skillList(top) + '<div class="hint" id="skHint">' + skHint() + '</div></div>' +
'<div><h3>by how it was made</h3><ul class="w">' + ['full', 'none', 'unk'].map(function (a) { var n = PROJECTS.filter(function (x) { return x.ai === a; }).length; return '<li>' + aiMark(a) + ' <span style="float:right" class="mono">' + n + '</span></li>'; }).join('') + '</ul><div class="hint">No project is "some AI" yet; that mark is ready for the next one.</div></div>';
}
var others = p.stack.map(strip);
return '<div><h3>inspector · ' + p.no + '</h3><div class="head"><span>' + GROUPS[p.g].name.toLowerCase() + ' · ' + strip(p.yr) + '</span>' + aiMark(p.ai) + '</div>' +
'<div class="hint">' + (p.where || 'Own project, own time.') + '</div></div>' +
'<div><h3>skills used here</h3>' + skillList(others) + '<div class="hint" id="skHint">' + skHint() + '</div></div>' +
'<div><h3>related writing</h3>' + (p.posts.length ? '<ul class="w">' + p.posts.map(POST).map(function (w) { return '<li><a href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">' + w.t + '</span></a><small>' + w.d + ' · ' + w.tag + '</small></li>'; }).join('') + '</ul>' : '<div class="hint">Nothing written about this one yet.</div>') + '</div>' +
'<div><h3>share this project</h3><div class="share"><span id="url">' + url + '</span><button id="copy">copy</button></div></div>';
}
function skHint() {
if (!skillOn) return 'number = projects that use it';
var n = PROJECTS.filter(function (p) { return p.stack.map(strip).indexOf(skillOn) > -1; }).length;
return '<span style="color:var(--acc)">' + skillOn + '</span>: ' + n + ' project' + (n > 1 ? 's' : '') + ' marked in the list · click again to clear';
}
// ---------- routing ----------
function render(id, push) {
var p = id ? P(id) : null;
if (id && !p) { id = ''; }
current = id;
if (push === 'push') history.pushState(null, '', id ? '#' + id : location.pathname);
else if (push === 'replace') history.replaceState(null, '', id ? '#' + id : location.pathname);
markList();
sheet.innerHTML = p ? project(p) : overview();
sheet.classList.remove('swap'); void sheet.offsetWidth; sheet.classList.add('swap');
insp.innerHTML = inspector(p);
here.textContent = p ? p.no + ' ' + p.name.toLowerCase() : 'overview';
document.title = (p ? p.name + ' · ' : '') + 'Work · Lucas Winther';
var cur = list.querySelector('[aria-current="true"]');
if (cur) cur.scrollIntoView({ block: 'nearest' });
if (window.scrollY > 120) window.scrollTo(0, 0);
}
window.addEventListener('popstate', function () { render(location.hash.slice(1)); });
document.addEventListener('click', function (e) {
var t = e.target.closest('[data-id]');
if (t) { e.preventDefault(); render(t.dataset.id, 'push'); return; }
var go = e.target.closest('[data-go]');
if (go) { e.preventDefault(); render('', 'push'); return; }
if (e.target.closest('[data-cv]')) {
e.preventDefault(); if (current) render('', 'push');
var cv = document.getElementById('cv'); cv.scrollIntoView({ block: 'center' }); cv.style.outline = '2px solid #d9532b'; cv.style.outlineOffset = '6px'; setTimeout(function () { cv.style.outline = ''; }, 900); return;
}
var sk = e.target.closest('[data-skill]');
if (sk) {
skillOn = skillOn === sk.dataset.skill ? null : sk.dataset.skill;
markList();
document.querySelectorAll('[data-skill]').forEach(function (b) { b.classList.toggle('on', b.dataset.skill === skillOn); });
var hn = document.getElementById('skHint'); if (hn) hn.innerHTML = skHint();
return;
}
if (e.target.id === 'copy') {
var u = 'https://' + document.getElementById('url').textContent;
if (navigator.clipboard) navigator.clipboard.writeText(u).catch(function () {});
e.target.textContent = 'copied'; setTimeout(function () { e.target.textContent = 'copy'; }, 1200);
}
});
document.addEventListener('keydown', function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.target.closest && e.target.closest('input, textarea')) return;
var k = e.key;
if (k === 'ArrowDown' || k === 'ArrowUp' || k === 'j' || k === 'k') {
var its = items(), i = its.findIndex(function (b) { return b.dataset.id === current; });
var n = (k === 'ArrowDown' || k === 'j') ? Math.min(its.length - 1, i + 1) : Math.max(0, i - 1);
e.preventDefault();
if (its[n] && its[n].dataset.id !== current) { render(its[n].dataset.id, 'replace'); its[n].focus({ preventScroll: true }); }
} else if (k === 'Escape') {
if (skillOn) { skillOn = null; render(current); }
else if (current) render('', 'push');
}
});
// AI preference
var note = document.getElementById('prefNote');
document.querySelectorAll('[data-pref]').forEach(function (b) {
b.addEventListener('click', function () {
PREF = b.dataset.pref;
document.querySelectorAll('[data-pref]').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); });
var hidden = PROJECTS.filter(function (p) { return !allowed(p); }).length;
note.textContent = PREF === 'all' ? '' : PREF === 'nofull' ? 'hiding ' + hidden + ' made fully with AI' : 'hiding ' + hidden + ': AI-made, or AI level unknown';
markList();
});
});
buildList();
render(location.hash.slice(1));
})();
</script>
<!-- Placeholders: university and graduation year, project years (except AU, ScienceAtHome, 2021 portfolio), ScienceAtHome project name and engine, story details marked with dotted underline, all figure images, blog post titles/dates/excerpts, CV PDF, AI level for AU work. -->
</body>
</html>