Files
portfolio-next/mockups/work-k2.html
T
Lucas WintherandClaude Opus 5.5 1041c8e5b8 docs(specs): 009 work page, CV first, with its plan and mockup rounds
Six rounds of mockups with the owner on 2026-11-12; K v2 picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:03:26 +01:00

1061 lines
87 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>
<!--
work-k2 (2026-11-12): the chosen /work/ design for spec 009, built from tech-r3-c.html.
Screenshots are referenced as /files/<name>.jpg (real project images, served only by the brainstorm
companion; not in this repo), so they show as empty panels when opened directly.
Truth for the layout and treatment: mockups/shots/work-k2-1440.png. List rows and writing posts are
tech-r3-c dummy text; dotted underlines are placeholders.
-->
<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=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&family=Fraunces:ital,opsz,wght@1,9..144,300&display=swap" rel="stylesheet">
<style>
:root {
--bg: #131210; --bg2: #171613; --panel: #1b1916; --panel2: #221f1b; --stage: #161411;
--line: #2b2824; --line2: #3a3631;
--ink: #ede7dd; --ink2: #c8c0b4; --mute: #8e877c; --dim: #625c54;
--acc: #ea7444; --acc-s: rgba(234,116,68,.13); --ok: #a8b48f;
--a-bg: #2a1a1f; --a-ink: #f1e6d6;
--sans: "Geist", system-ui, sans-serif; --mono: "Geist Mono", ui-monospace, monospace;
--ease: cubic-bezier(.2,.8,.2,1);
--edge: 42px;
}
* { box-sizing: border-box; }
html { scroll-padding-top: 72px; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.55; -webkit-font-smoothing: antialiased; }
a { color: inherit; }
button { font: inherit; color: inherit; }
::selection { background: var(--acc); color: #131210; }
:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; }
.mono { font-family: var(--mono); }
.ph { text-decoration: underline dotted rgba(237,231,221,.3); text-underline-offset: 3px; text-decoration-thickness: 1px; cursor: help; }
/* mockup note */
.mk { position: fixed; top: 64px; right: calc(var(--edge) + 12px); z-index: 65; width: 300px; background: #ede7dd; color: #1b1916; font: 12px/1.45 var(--mono); padding: 10px 28px 10px 12px; border-radius: 8px; }
.mk b { display: block; font-family: var(--sans); font-size: 13px; margin-bottom: 3px; }
.mk button { position: absolute; top: 4px; right: 6px; background: none; border: 0; font-size: 16px; cursor: pointer; color: #1b1916; }
/* off the clock edge */
.edge { position: fixed; top: 0; right: 0; bottom: 0; width: var(--edge); z-index: 40; background: var(--a-bg); color: var(--a-ink); border-left: 1px solid #3c2a2f; display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; font: italic 300 16px "Fraunces", serif; text-decoration: none; transition: width .2s var(--ease); }
.edge:hover { width: 54px; }
/* top bar */
.bar { position: sticky; top: 0; z-index: 30; margin-right: var(--edge); background: rgba(19,18,16,.94); border-bottom: 1px solid var(--line); }
.bar .in { max-width: 1320px; margin: 0 auto; padding: 0 40px; height: 56px; display: flex; align-items: center; gap: 28px; }
.crumb { display: flex; align-items: center; gap: 10px; font-size: 14px; white-space: nowrap; }
.crumb a.back { font: 12px var(--mono); color: var(--mute); text-decoration: none; border: 1px solid var(--line2); border-radius: 6px; padding: 3px 8px; }
.crumb a.back:hover { color: var(--ink); border-color: var(--mute); }
.crumb b { font-weight: 600; }
.crumb span { color: var(--mute); }
.nav { display: flex; gap: 4px; margin-left: 8px; }
.nav a { text-decoration: none; color: var(--mute); font-size: 14px; padding: 6px 10px; border-radius: 6px; }
.nav a:hover { color: var(--ink); background: var(--panel2); }
.pref { margin-left: auto; display: flex; align-items: center; gap: 10px; font: 12px var(--mono); color: var(--mute); }
.seg { display: inline-flex; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 2px; }
.seg button { border: 0; background: none; padding: 4px 10px; border-radius: 6px; font: 12px var(--mono); color: var(--mute); cursor: pointer; transition: background .15s, color .15s; }
.seg button:hover { color: var(--ink); }
.seg button[aria-pressed="true"] { background: var(--ink); color: var(--bg); }
main { margin-right: var(--edge); }
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 40px; }
/* intro + now strip */
.intro { padding: 32px 0 22px; display: grid; grid-template-columns: 1fr auto; gap: 40px; align-items: end; }
.eyebrow { font: 12px var(--mono); color: var(--mute); letter-spacing: .02em; }
.eyebrow b { color: var(--acc); font-weight: 500; }
.intro h1 { font-size: 34px; line-height: 1.08; letter-spacing: -.028em; font-weight: 600; margin: 12px 0 0; max-width: 20em; }
.intro h1 em { font-style: normal; color: var(--mute); }
.intro p { margin: 10px 0 0; color: var(--ink2); max-width: 44em; font-size: 16px; }
.legend { display: flex; flex-direction: column; gap: 7px; font: 12px var(--mono); color: var(--mute); padding-bottom: 4px; }
.legend > div { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.now { display: grid; grid-template-columns: 1fr 1.15fr 1.35fr; border: 1px solid var(--line); border-radius: 14px; background: var(--bg2); margin-bottom: 28px; }
.now > div { padding: 14px 20px 14px; min-width: 0; }
.now > div + div { border-left: 1px solid var(--line); }
.now h3 { margin: 0 0 8px; font: 500 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--mute); display: flex; justify-content: space-between; gap: 8px; }
.now h3 .pulse { color: var(--acc); }
.now ul { margin: 0; padding: 0; list-style: none; font-size: 14px; }
.now li { padding: 2px 0; color: var(--ink2); display: flex; gap: 8px; }
.now li::before { content: ""; flex: 0 0 5px; height: 5px; margin-top: 9px; border-radius: 50%; background: var(--dim); }
.now li:first-child::before { background: var(--acc); }
.wl a { display: block; text-decoration: none; padding: 2px 0; }
.wl a .t { color: var(--ink); font-size: 14px; }
.wl a:hover .t { color: var(--acc); }
.wl a .d { font: 11px var(--mono); color: var(--dim); margin-left: 6px; }
.wl a.lead .t { font-weight: 500; }
.cv10 { display: grid; grid-template-columns: 70px 1fr; gap: 3px 12px; font-size: 13.5px; color: var(--ink2); }
.cv10 .k { font: 11px var(--mono); color: var(--dim); padding-top: 2px; }
.cv10 .v b { color: var(--ink); font-weight: 500; }
.cvlinks { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.btn { display: inline-flex; align-items: center; gap: 6px; font: 12px var(--mono); text-decoration: none; color: var(--ink2); border: 1px solid var(--line2); border-radius: 6px; padding: 3px 9px; background: none; cursor: pointer; transition: border-color .15s, color .15s, background .15s; }
.btn:hover { color: var(--ink); border-color: var(--mute); }
.btn.solid { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.btn.solid:hover { background: var(--acc); border-color: var(--acc); color: #131210; }
/* AI mark + chips */
.ai { display: inline-flex; align-items: center; gap: 6px; font: 500 11px var(--mono); color: var(--ink2); border: 1px solid var(--line2); border-radius: 99px; padding: 2px 9px 2px 7px; white-space: nowrap; line-height: 1.5; }
.ai i { width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid currentColor; flex: none; }
.ai.full { color: var(--acc); border-color: rgba(234,116,68,.45); } .ai.full i { background: currentColor; }
.ai.some i { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
.ai.unk { color: var(--mute); border-style: dashed; } .ai.unk i { border-style: dashed; }
.ai.bare { border: 0; padding: 0; }
.chips { display: flex; gap: 5px; flex-wrap: wrap; }
.chip { font: 12px var(--mono); color: var(--ink2); background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 1px 7px; white-space: nowrap; }
/* section heads */
.sh { display: flex; align-items: baseline; gap: 14px; margin: 0 0 16px; }
.sh h2 { margin: 0; font-size: 22px; letter-spacing: -.02em; font-weight: 600; }
.sh .n { font: 12px var(--mono); color: var(--mute); }
.sh .r { margin-left: auto; font: 12px var(--mono); color: var(--mute); }
/* featured panels */
.feats { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.feat { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; overflow: hidden; display: grid; transition: border-color .2s; }
.feat:hover { border-color: var(--line2); }
.feat.wide { grid-column: 1 / -1; grid-template-columns: 1.42fr 1fr; }
.feat.wide.rev { grid-template-columns: 1fr 1.42fr; }
.feat.wide.rev .stage { order: 2; border-right: 0; border-left: 1px solid var(--line); }
.feat.wide .stage { border-right: 1px solid var(--line); }
.feat:not(.wide) .stage { border-bottom: 1px solid var(--line); }
.stage { position: relative; background: var(--stage); overflow: hidden; min-height: 300px;
background-image: radial-gradient(rgba(237,231,221,.055) 1px, transparent 1px); background-size: 18px 18px; }
.stage .cap { position: absolute; left: 16px; bottom: 12px; font: 11px var(--mono); color: var(--dim); z-index: 5; pointer-events: none; }
.stage .hint { position: absolute; right: 16px; bottom: 12px; font: 11px var(--mono); color: var(--dim); z-index: 5; pointer-events: none; transition: opacity .2s; }
.stage:hover .hint { opacity: 0; }
.txt { padding: 28px 30px 26px; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.meta { display: flex; gap: 10px; align-items: center; font: 12px var(--mono); color: var(--mute); flex-wrap: wrap; }
.meta .num { color: var(--acc); }
.feat h3 { margin: 0; font-size: 30px; line-height: 1.08; letter-spacing: -.025em; font-weight: 600; }
.feat:not(.wide) h3 { font-size: 26px; }
.pitch { margin: 0; color: var(--ink2); font-size: 15.5px; max-width: 34em; }
.facts { display: grid; grid-template-columns: 92px 1fr; gap: 6px 14px; font-size: 13.5px; color: var(--ink2); margin: 2px 0 0; padding: 12px 0 0; border-top: 1px solid var(--line); }
.facts dt { font: 11px var(--mono); color: var(--dim); padding-top: 2px; }
.facts dd { margin: 0; }
.ft { margin-top: auto; display: flex; align-items: center; gap: 10px; padding-top: 6px; flex-wrap: wrap; }
.open { display: inline-flex; align-items: center; gap: 8px; background: none; border: 1px solid var(--line2); border-radius: 8px; padding: 6px 12px; font-size: 14px; cursor: pointer; transition: background .15s, border-color .15s, color .15s; }
.open span { transition: transform .2s var(--ease); display: inline-block; }
.open:hover { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.open:hover span { transform: translateX(3px); }
.feat .filt { display: none; padding: 16px 22px; font: 13px var(--mono); color: var(--mute); align-items: center; gap: 12px; }
.feat.filtered { display: block; }
.feat.filtered > .stage, .feat.filtered > .txt { display: none; }
.feat.filtered .filt { display: flex; }
.linkish { background: none; border: 0; padding: 0; color: var(--ink); text-decoration: underline; text-decoration-color: var(--acc); text-underline-offset: 3px; cursor: pointer; font: inherit; }
/* -- stage 1: compute platform (tilt) -- */
.s-compute { min-height: 452px; perspective: 1400px; }
.tiltw { position: absolute; inset: 34px 44px 46px 40px; transform-style: preserve-3d; transition: transform .25s var(--ease); transform: rotateX(calc(var(--ty, 0) * -3deg)) rotateY(calc(var(--tx, 0) * 4deg)); }
.win { position: absolute; inset: 0; background: #1f1c18; border: 1px solid var(--line2); border-radius: 12px; overflow: hidden; display: grid; grid-template-rows: 34px 1fr; }
.win .tb { display: flex; align-items: center; gap: 10px; padding: 0 12px; border-bottom: 1px solid var(--line); background: #1a1815; }
.win .tb .dots { display: flex; gap: 5px; } .win .tb .dots i { width: 8px; height: 8px; border-radius: 50%; background: var(--line2); }
.win .tb .url { flex: 1; max-width: 300px; margin: 0 auto; background: var(--stage); border: 1px solid var(--line); border-radius: 6px; font: 11px var(--mono); color: var(--mute); text-align: center; padding: 2px 0; }
.app { display: grid; grid-template-columns: 150px 1fr; min-height: 0; }
.app .side { border-right: 1px solid var(--line); padding: 14px 10px; display: flex; flex-direction: column; gap: 2px; font-size: 12.5px; color: var(--mute); }
.app .side .logo { font-weight: 600; color: var(--ink); font-size: 13px; padding: 0 8px 12px; letter-spacing: -.01em; }
.app .side .it { padding: 5px 8px; border-radius: 6px; }
.app .side .it.on { background: var(--panel2); color: var(--ink); }
.app .side .who { margin-top: auto; font: 10.5px var(--mono); color: var(--dim); padding: 8px; border-top: 1px solid var(--line); }
.app .main { padding: 14px 16px; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.app .hd { display: flex; align-items: center; justify-content: space-between; }
.app .hd b { font-size: 15px; font-weight: 600; }
.app .hd .nb { font-size: 12px; background: var(--ink); color: var(--bg); border-radius: 6px; padding: 3px 10px; font-weight: 500; }
.jobs { font-size: 12px; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.jobs .jr { display: grid; grid-template-columns: 1.5fr 1.6fr .6fr .8fr; gap: 8px; padding: 7px 10px; border-top: 1px solid var(--line); align-items: center; color: var(--ink2); transition: background .12s; }
.jobs .jr:first-child { border-top: 0; font: 10.5px var(--mono); color: var(--dim); text-transform: uppercase; letter-spacing: .05em; }
.jobs .jr:not(:first-child):hover { background: var(--panel2); }
.jobs .jr .m { font-family: var(--mono); font-size: 11.5px; }
.st { font: 11px var(--mono); display: inline-flex; align-items: center; gap: 6px; }
.st i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.st.run { color: var(--ok); } .st.q { color: var(--acc); } .st.done { color: var(--dim); }
.scale { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px 6px; }
.scale .l { display: flex; justify-content: space-between; font: 10.5px var(--mono); color: var(--dim); }
.scale .bars { display: flex; align-items: flex-end; gap: 3px; height: 46px; margin-top: 6px; }
.scale .bars i { flex: 1; background: var(--line2); border-radius: 2px 2px 0 0; min-height: 2px; }
.scale .bars i.hi { background: var(--ink2); } .scale .bars i.now { background: var(--acc); }
.float { position: absolute; background: #26221e; border: 1px solid var(--line2); border-radius: 10px; padding: 10px 12px; font-size: 12px; color: var(--ink2); transition: transform .25s var(--ease); }
.float b { color: var(--ink); font-weight: 500; display: block; font-size: 12.5px; }
.float .mono { font-size: 10.5px; color: var(--mute); }
.f1 { top: -16px; right: -18px; width: 210px; transform: translate3d(calc(var(--tx,0) * 14px), calc(var(--ty,0) * 10px), 40px); }
.f2 { bottom: 70px; left: -26px; width: 190px; transform: translate3d(calc(var(--tx,0) * 20px), calc(var(--ty,0) * 14px), 60px); }
.f1 .bar2 { height: 3px; background: var(--line2); border-radius: 2px; margin-top: 7px; overflow: hidden; } .f1 .bar2 i { display: block; height: 100%; width: 38%; background: var(--acc); }
/* -- stage 2: config (spotlight) -- */
.s-config { min-height: 330px; }
.s-config::before { content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none; opacity: 0; transition: opacity .2s;
background: radial-gradient(180px circle at var(--mx, 50%) var(--my, 50%), rgba(234,116,68,.11), transparent 70%); }
.s-config:hover::before { opacity: 1; }
.ed { position: absolute; inset: 26px 26px 40px; background: #1f1c18; border: 1px solid var(--line2); border-radius: 10px; overflow: hidden; display: grid; grid-template-columns: 150px 1fr; grid-template-rows: 1fr auto; }
.tree { border-right: 1px solid var(--line); padding: 10px 6px; font: 11.5px/1.75 var(--mono); color: var(--mute); grid-row: 1 / 3; }
.tree div { padding-left: calc(var(--d, 0) * 12px + 6px); border-radius: 4px; white-space: nowrap; }
.tree .dir { color: var(--ink2); } .tree .on { background: var(--panel2); color: var(--ink); }
.code { font: 12px/1.7 var(--mono); padding: 10px 0; color: var(--ink2); counter-reset: ln; min-width: 0; overflow: hidden; }
.code .ln { padding: 0 12px 0 0; white-space: pre; display: flex; transition: background .12s; }
.code .ln::before { counter-increment: ln; content: counter(ln); width: 30px; flex: none; text-align: right; padding-right: 12px; color: var(--dim); }
.code .ln:hover { background: rgba(237,231,221,.05); }
.k1 { color: var(--ink); } .s1 { color: var(--ok); } .c1 { color: var(--dim); } .a1 { color: var(--acc); }
.recap { border-top: 1px solid var(--line); font: 11px/1.6 var(--mono); color: var(--mute); padding: 7px 12px; white-space: nowrap; overflow: hidden; }
.recap .ok { color: var(--ok); }
/* -- stage 3: hypercache (peer highlight) -- */
.s-net { min-height: 330px; cursor: crosshair; }
.s-net svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.s-net .e { stroke: var(--line2); stroke-width: 1; transition: stroke .15s, opacity .15s; }
.s-net .e.bad { stroke-dasharray: 3 4; }
.s-net .p circle { fill: var(--stage); stroke: var(--ink2); stroke-width: 1.4; transition: fill .15s, stroke .15s, r .15s; }
.s-net .p.free circle { stroke: var(--acc); stroke-dasharray: 2.5 2.5; }
.s-net .p text { font: 10px var(--mono); fill: var(--dim); }
.s-net .w { font: 10px var(--mono); fill: var(--ink); opacity: 0; transition: opacity .15s; }
.s-net.hot .e { opacity: .25; }
.s-net.hot .e.lit { opacity: 1; stroke: var(--ink2); }
.s-net.hot .e.lit.bad { stroke: var(--acc); }
.s-net .p.on circle { fill: var(--ink); stroke: var(--ink); }
.s-net .p.on.free circle { fill: var(--acc); stroke: var(--acc); }
.s-net .p.on text { fill: var(--ink); }
.s-net .w.lit { opacity: 1; }
.ro { position: absolute; left: 16px; top: 14px; z-index: 4; font: 11px/1.6 var(--mono); color: var(--mute); background: rgba(22,20,17,.9); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; min-width: 210px; }
.ro b { color: var(--ink); font-weight: 500; }
.ro .bad { color: var(--acc); }
/* -- stage 4: rowing (scrub) -- */
.s-row { min-height: 400px; }
.rowgrid { position: absolute; inset: 26px 28px 44px; display: grid; grid-template-columns: 210px 1fr; grid-template-rows: 1fr auto; gap: 14px 18px; }
.boat, .chart { background: #1f1c18; border: 1px solid var(--line2); border-radius: 10px; position: relative; overflow: hidden; }
.boat svg, .chart svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.boat .lbl, .chart .lbl { position: absolute; left: 12px; top: 9px; font: 10.5px var(--mono); color: var(--dim); z-index: 2; }
.oar { transition: transform .08s linear; }
.chart { cursor: ew-resize; }
.chart .curve { fill: none; stroke: var(--ink2); stroke-width: 1.6; }
.chart .area { fill: rgba(237,231,221,.05); }
.chart .grid line { stroke: var(--line); stroke-width: 1; }
.chart .ph-lbl { font: 10px var(--mono); fill: var(--dim); }
.chart .scr { opacity: 0; transition: opacity .12s; }
.chart:hover .scr { opacity: 1; }
.chart .scr line { stroke: var(--acc); stroke-width: 1; }
.chart .scr circle { fill: var(--acc); }
.chart .rd { position: absolute; right: 12px; top: 9px; font: 10.5px var(--mono); color: var(--mute); z-index: 2; }
.chart .rd b { color: var(--acc); font-weight: 500; }
.pipe { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; font: 11px var(--mono); color: var(--mute); }
.pipe span { border: 1px solid var(--line2); border-radius: 6px; padding: 3px 8px; background: #1f1c18; color: var(--ink2); }
.pipe em { font-style: normal; color: var(--dim); }
/* index */
.idx { margin-top: 64px; }
.tools { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.tabs { display: inline-flex; gap: 2px; }
.tabs button { background: none; border: 1px solid transparent; border-radius: 7px; padding: 4px 10px; font-size: 13.5px; color: var(--mute); cursor: pointer; }
.tabs button:hover { color: var(--ink); }
.tabs button[aria-pressed="true"] { border-color: var(--line2); color: var(--ink); background: var(--panel); }
.tabs button small { font: 11px var(--mono); color: var(--dim); margin-left: 4px; }
.search { margin-left: auto; position: relative; }
.search input { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; color: var(--ink); font: 13px var(--sans); padding: 6px 34px 6px 12px; width: 260px; outline: none; transition: border-color .15s; }
.search input:focus { border-color: var(--mute); }
.search input::placeholder { color: var(--dim); }
.search kbd { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font: 11px var(--mono); color: var(--dim); border: 1px solid var(--line2); border-radius: 4px; padding: 0 5px; }
.list { border-top: 1px solid var(--line); }
.row { display: grid; grid-template-columns: 56px minmax(180px, 1.1fr) 2fr 1fr 64px 132px 20px; gap: 18px; align-items: center; width: 100%; text-align: left; background: none; border: 0; border-bottom: 1px solid var(--line); padding: 11px 10px 11px 8px; cursor: pointer; transition: background .12s; border-radius: 0; }
.row:hover, .row:focus-visible { background: var(--panel); }
.row .th { width: 56px; height: 34px; border: 1px solid var(--line); border-radius: 6px; background: var(--stage); color: var(--mute); display: flex; align-items: center; justify-content: center; transition: color .15s, border-color .15s; }
.row:hover .th { color: var(--ink); border-color: var(--line2); }
.row .th svg { width: 44px; height: 26px; }
.row .nm { font-weight: 500; font-size: 15px; }
.row .nm small { display: block; font: 11px var(--mono); color: var(--dim); font-weight: 400; margin-top: 1px; }
.row .one { color: var(--ink2); font-size: 14px; }
.row .stk { font: 11.5px var(--mono); color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .yr { font: 12px var(--mono); color: var(--mute); }
.row .go { color: var(--dim); transition: transform .2s var(--ease), color .15s; }
.row:hover .go { color: var(--acc); transform: translateX(3px); }
.empty { padding: 18px 8px; font: 13px var(--mono); color: var(--mute); border-bottom: 1px solid var(--line); }
.hiddenby { font: 12px var(--mono); color: var(--mute); padding: 10px 8px; }
/* writing + history */
.lower { margin-top: 64px; display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.card { border: 1px solid var(--line); border-radius: 16px; background: var(--bg2); padding: 22px 24px; }
.posts a { display: grid; grid-template-columns: 88px 1fr auto; gap: 14px; text-decoration: none; padding: 10px 0; border-top: 1px solid var(--line); align-items: baseline; }
.posts a:first-child { border-top: 0; }
.posts .d { font: 11.5px var(--mono); color: var(--dim); }
.posts .t { font-weight: 500; }
.posts .b { display: block; color: var(--mute); font-size: 13.5px; margin-top: 2px; }
.posts .tg { font: 11px var(--mono); color: var(--mute); }
.posts a:hover .t { color: var(--acc); }
.jobsx { margin: 0; padding: 0; list-style: none; }
.jobsx > li { display: grid; grid-template-columns: 110px 1fr; gap: 14px; padding: 12px 0; border-top: 1px solid var(--line); }
.jobsx > li:first-child { border-top: 0; padding-top: 4px; }
.jobsx .when { font: 11.5px var(--mono); color: var(--dim); padding-top: 2px; }
.jobsx b { font-weight: 600; }
.jobsx .role { color: var(--mute); font-size: 13.5px; }
.jobsx p { margin: 6px 0 0; color: var(--ink2); font-size: 14px; }
.jobsx ul { margin: 6px 0 0; padding-left: 16px; color: var(--ink2); font-size: 13.5px; }
.jobsx .chips { margin-top: 8px; }
footer { margin-top: 56px; border-top: 1px solid var(--line); }
footer .wrap { display: flex; gap: 20px; align-items: center; padding-top: 20px; padding-bottom: 28px; font: 12px var(--mono); color: var(--mute); flex-wrap: wrap; }
footer a { color: var(--ink2); text-decoration: none; } footer a:hover { color: var(--acc); }
footer .sp { margin-left: auto; }
/* mockup-only gate suggestion */
.gs { margin: 8px auto 48px; max-width: 1320px; padding: 0 40px; }
.gs .box { border: 1px dashed var(--line2); border-radius: 14px; padding: 18px 20px; display: grid; grid-template-columns: 1fr 520px; gap: 26px; align-items: center; }
.gs h4 { margin: 0 0 6px; font: 500 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--mute); }
.gs p { margin: 0 0 8px; color: var(--ink2); font-size: 14px; }
.mini { height: 250px; display: grid; grid-template-columns: 1.15fr 1fr; border-radius: 10px; overflow: hidden; border: 1px solid var(--line2); }
.mini .l { background: #131210; padding: 20px 18px; display: flex; flex-direction: column; justify-content: center; align-items: flex-end; text-align: right; position: relative;
background-image: radial-gradient(260px circle at 70% 40%, rgba(234,116,68,.07), transparent 70%); }
.mini .l .lab { font: 9px var(--mono); color: var(--dim); letter-spacing: .08em; text-transform: uppercase; }
.mini .l .lab b { color: var(--acc); font-weight: 500; }
.mini .l h5 { margin: 6px 0 4px; font: 600 34px/1 var(--sans); letter-spacing: -.035em; color: var(--ink); }
.mini .l .le { font-size: 11px; color: var(--ink2); max-width: 17em; }
.mini .l .rows { margin-top: 10px; width: 190px; border-top: 1px solid var(--line2); }
.mini .l .rows div { display: flex; justify-content: space-between; gap: 8px; font-size: 10px; color: var(--ink2); padding: 4px 0; border-bottom: 1px solid var(--line); }
.mini .l .rows .ai { font-size: 9px; border: 0; padding: 0; }
.mini .l .rows .ai i { width: 7px; height: 7px; }
.mini .l .en { margin-top: 10px; font: 10px var(--mono); color: var(--ink); }
.mini .l .thumb { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 92px; height: 66px; border: 1px solid var(--line2); border-radius: 6px; background: #1f1c18; padding: 6px; display: grid; grid-template-columns: 18px 1fr; gap: 4px; }
.mini .l .thumb i { background: var(--line); border-radius: 2px; display: block; }
.mini .l .thumb span { display: flex; flex-direction: column; gap: 3px; } .mini .l .thumb span i { height: 6px; } .mini .l .thumb span i:nth-child(2) { background: var(--acc); width: 60%; }
.mini .r { background: var(--a-bg); color: var(--a-ink); padding: 20px 18px; display: flex; flex-direction: column; justify-content: center; position: relative;
background-image: radial-gradient(rgba(241,230,214,.06) 1px, transparent 1px); background-size: 3px 3px; }
.mini .r .lab { font: italic 300 11px "Fraunces", serif; color: #b8a592; }
.mini .r h5 { margin: 4px 0; font: italic 300 32px/1 "Fraunces", serif; }
.mini .r .le { font: italic 300 12px "Fraunces", serif; color: #b8a592; }
/* side sheet */
.scrim { position: fixed; inset: 0; z-index: 70; background: rgba(8,7,6,.6); opacity: 0; pointer-events: none; transition: opacity .22s; }
.scrim.on { opacity: 1; pointer-events: auto; }
.sheet { position: fixed; top: 0; right: 0; bottom: 0; z-index: 80; width: min(600px, 100vw); background: #191714; border-left: 1px solid var(--line2); transform: translateX(102%); transition: transform .26s var(--ease); display: flex; flex-direction: column; visibility: hidden; }
.sheet.on { transform: none; visibility: visible; }
.sheet .top { display: flex; align-items: center; gap: 8px; padding: 12px 18px; border-bottom: 1px solid var(--line); font: 12px var(--mono); color: var(--mute); }
.sheet .top .pos { margin-right: auto; }
.ib { background: none; border: 1px solid var(--line2); border-radius: 6px; color: var(--ink2); font: 12px var(--mono); padding: 3px 9px; cursor: pointer; }
.ib:hover { color: var(--ink); border-color: var(--mute); }
.ib:disabled { opacity: .35; cursor: default; }
.sbody { overflow-y: auto; padding: 22px 26px 40px; flex: 1; view-transition-name: sheetbody; }
.sbody .vis { height: 190px; border: 1px solid var(--line); border-radius: 12px; background: var(--stage); background-image: radial-gradient(rgba(237,231,221,.055) 1px, transparent 1px); background-size: 18px 18px; display: flex; align-items: center; justify-content: center; color: var(--ink2); position: relative; }
.sbody .vis svg { width: 260px; height: 156px; }
.sbody .vis .cap { position: absolute; left: 12px; bottom: 9px; font: 11px var(--mono); color: var(--dim); }
.sbody h2 { font-size: 30px; letter-spacing: -.025em; line-height: 1.1; margin: 20px 0 8px; font-weight: 600; }
.sbody .lead { font-size: 16px; color: var(--ink2); margin: 10px 0 18px; }
.sbody section { border-top: 1px solid var(--line); padding: 14px 0; display: grid; grid-template-columns: 110px 1fr; gap: 14px; }
.sbody section h4 { margin: 0; font: 11px var(--mono); color: var(--dim); text-transform: uppercase; letter-spacing: .06em; padding-top: 3px; }
.sbody section p { margin: 0; color: var(--ink2); font-size: 14.5px; }
.parts { list-style: none; margin: 0; padding: 0; }
.parts li { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 5px 0; border-bottom: 1px dashed var(--line); font-size: 14px; color: var(--ink2); }
.parts li:last-child { border-bottom: 0; }
.lks { display: flex; gap: 6px; flex-wrap: wrap; }
.lks .dead { font: 12px var(--mono); color: var(--dim); }
.rel { display: flex; gap: 6px; flex-wrap: wrap; }
.rel button { background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 3px 9px; font-size: 13px; color: var(--ink2); cursor: pointer; }
.rel button:hover { border-color: var(--mute); color: var(--ink); }
.sposts a { display: block; text-decoration: none; font-size: 14px; color: var(--ink2); padding: 2px 0; }
.sposts a:hover { color: var(--acc); }
::view-transition-old(sheetbody), ::view-transition-new(sheetbody) { animation-duration: .16s; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0s !important; }
.tiltw, .f1, .f2 { transform: none !important; }
}
@media (max-width: 1100px) {
.nav { display: none; }
.feat.wide, .feat.wide.rev { grid-template-columns: 1fr; }
.feat.wide.rev .stage { order: 0; border-left: 0; border-bottom: 1px solid var(--line); }
.feat.wide .stage { border-right: 0; border-bottom: 1px solid var(--line); }
.row { grid-template-columns: 56px 1fr 1.6fr 132px 20px; }
.row .stk, .row .yr { display: none; }
.gs .box { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
:root { --edge: 26px; }
.edge { font-size: 13px; }
.bar .in, .wrap, .gs { padding-left: 16px; padding-right: 16px; }
.bar .in { gap: 10px; height: auto; padding-top: 8px; padding-bottom: 8px; flex-wrap: wrap; }
.pref { margin-left: 0; } .pref > span { display: none; }
.intro { grid-template-columns: 1fr; } .legend > div { justify-content: flex-start; }
.intro h1 { font-size: 30px; }
.now { grid-template-columns: 1fr; } .now > div + div { border-left: 0; border-top: 1px solid var(--line); }
.feats { grid-template-columns: 1fr; }
.s-compute { min-height: 380px; } .tiltw { inset: 22px 14px 40px; } .app { grid-template-columns: 1fr; } .app .side { display: none; } .f1 { right: -4px; width: 170px; } .f2 { left: 10px; }
.jobs .jr { grid-template-columns: 1.4fr 1.4fr .8fr; } .jobs .jr > :nth-child(3) { display: none; }
.ed { grid-template-columns: 1fr; } .tree { display: none; }
.rowgrid { grid-template-columns: 1fr; grid-template-rows: 150px 1fr auto; } .s-row { min-height: 520px; }
.row { grid-template-columns: 56px 1fr 20px; gap: 12px; } .row .one, .row .ai { display: none; }
.search { margin-left: 0; width: 100%; } .search input { width: 100%; }
.lower { grid-template-columns: 1fr; }
.posts a { grid-template-columns: 1fr; gap: 2px; }
.mk { width: 230px; } .stage .hint, .sh .r, .f2 { display: none; }
.mini { grid-template-columns: 1fr; height: auto; } .mini .l .thumb { display: none; } .mini .l, .mini .r { min-height: 150px; }
}
/* --- K v2 additions --- */
.khero { display: grid; grid-template-columns: 1.35fr 1fr; border: 1px solid var(--line); border-radius: 18px; background: var(--panel); margin: 34px 0 10px; overflow: hidden; }
.khero .cv { padding: 32px 36px; }
.khero .side { border-left: 1px solid var(--line); padding: 28px 30px; background: var(--bg2); display: flex; flex-direction: column; gap: 24px; }
.khero h1 { font-size: 36px; line-height: 1.08; margin: 10px 0 6px; font-weight: 600; letter-spacing: -.02em; }
.khero .tg { color: var(--mute); margin: 0 0 24px; font-size: 16px; }
.lab { font: 500 11px var(--mono); letter-spacing: .09em; color: var(--mute); text-transform: uppercase; }
.lab b { color: var(--acc); }
.kv { display: grid; grid-template-columns: 78px 1fr; gap: 9px 16px; font-size: 15px; }
.kv .k { font: 11.5px var(--mono); color: var(--mute); padding-top: 3px; }
.kv .y { font: 12px var(--mono); color: var(--mute); }
.kbtns { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 22px; }
.hl { display: grid; grid-template-columns: 1fr auto; gap: 0 14px; font-size: 14.5px; }
.hl > div { padding: 9px 0; border-top: 1px solid var(--line); }
.hl .r { font: 12px var(--mono); color: var(--mute); text-align: right; white-space: nowrap; }
.hl .acc, .acc { color: var(--acc); }
.seal { display: inline-block; vertical-align: -2px; }
.bignum { font-size: 34px; font-weight: 600; line-height: 1; letter-spacing: -.02em; color: var(--acc); }
.lnk { font: 12px var(--mono); color: var(--mute); text-decoration: none; display: inline-block; margin-top: 10px; }
.lnk:hover { color: var(--ink); }
.s-shot { min-height: 300px; }
.feat.wide .s-shot { min-height: 380px; }
.s-shot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: top center; filter: saturate(.5) brightness(.8); transition: filter .3s var(--ease); }
.feat:hover .s-shot img { filter: none; }
.row .th img { width: 100%; height: 100%; object-fit: cover; border-radius: 5px; filter: saturate(.5) brightness(.8); transition: filter .2s; }
.row:hover .th img { filter: none; }
.row .th { overflow: hidden; }
.rec { display: grid; grid-template-columns: 1fr 1.15fr; gap: 18px; }
.rec .card { padding: 22px 24px; }
.ku { display: grid; grid-template-columns: 1fr auto auto auto; gap: 0 18px; font-size: 14px; align-items: center; }
.ku > div { padding: 9px 0; border-top: 1px solid var(--line); }
.ku .h { font: 500 10px var(--mono); letter-spacing: .08em; color: var(--mute); text-transform: uppercase; border-top: 0; padding-top: 0; }
.ku .c { font: 12px var(--mono); color: var(--mute); text-align: right; white-space: nowrap; }
.ku .ce { color: var(--ink); }
.sumline { font-size: 14px; color: var(--ink2); margin: 6px 0 14px; }
.sumline b { color: var(--ink); font-weight: 500; }
.subh { font: 500 10px var(--mono); letter-spacing: .08em; color: var(--mute); text-transform: uppercase; padding: 16px 0 4px; grid-column: 1 / -1; border-top: 0 !important; }
@media (max-width: 860px) { .khero, .rec { grid-template-columns: 1fr; } .khero .side { border-left: 0; border-top: 1px solid var(--line); } }
</style>
</head>
<body>
<a class="edge" href="personal-r2-b.html" title="cross over to the personal side">off the clock →</a>
<header class="bar">
<div class="in">
<div class="crumb"><a class="back" href="nav-d.html" title="back to the start page">← gate</a><b>Lucas Winther</b><span>/ work</span></div>
<nav class="nav" aria-label="sections"><a href="#featured">Projects</a><a href="#record">Record</a><a href="#writing">Writing</a><a href="#cv">CV</a></nav>
<div class="pref"><span>made</span>
<div class="seg" role="group" aria-label="filter projects by how much AI was used">
<button data-pref="all" aria-pressed="true">any way</button><button data-pref="nofull" aria-pressed="false">not fully by AI</button><button data-pref="hand" aria-pressed="false">by hand only</button>
</div>
</div>
</div>
</header>
<main>
<div class="wrap">
<section class="khero" id="top">
<div class="cv">
<div class="lab"><b>●</b>&nbsp; CV in ten seconds</div>
<h1>Lucas Winther</h1>
<p class="tg">Computer Scientist with passion for development.</p>
<div class="kv">
<span class="k">now</span><span>IT-medarbejder, IT Research Tech, Aarhus University <span class="y">2025–now</span></span>
<span class="k">before</span><span>Intern, ScienceAtHome <span class="y">2019</span><br>Student helper, Local Configuration Management, TERMA Group <span class="y">2016–2018</span></span>
<span class="k">degree</span><span>Bachelor in Computer Science</span>
<span class="k">contact</span><span><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a></span>
</div>
<div class="kbtns"><a class="btn" href="#cv">full history ↓</a><a class="btn" href="https://github.com/StereotypicalCat">GitHub</a><a class="btn" href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a></div>
</div>
<div class="side">
<div><div class="lab" style="margin-bottom:6px">Highlights</div>
<div class="hl">
<div class="acc">AUHackathon 2019</div><div class="r acc">winner · best AUHack</div>
<div>First Lego League</div><div class="r acc">won round 1, twice</div>
<div>Google Hash Code 2021</div><div class="r acc">top 12%</div>
</div><a class="lnk" href="#record">+ 3 more in the record ↓</a></div>
<div><div class="lab" style="margin-bottom:12px">Keeping up</div>
<div style="display:flex;align-items:baseline;gap:12px"><span class="bignum"><svg class="seal" width="24" height="24" viewBox="0 0 16 16"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg> 18</span><span>certificates</span></div>
<div class="mono" style="font-size:12px;color:var(--mute);margin-top:8px">23 courses &amp; hands-on labs · ~27 h since Feb 2025</div>
<div class="chips" style="margin-top:12px"><span class="chip">Ansible 5</span><span class="chip">Serverless / AWS 5</span><span class="chip">RAG &amp; LLMs 4</span><span class="chip">Security 2</span><span class="chip">Linux 1</span></div>
<a class="lnk" href="#record">all courses ↓</a></div>
</div>
</section>
<!-- ================= featured ================= -->
<section id="featured">
<div class="sh"><h2>Projects I'm proudest of</h2><span class="n">6 projects</span><span class="r">hover the drawings · click a panel for the full story</span></div>
<div class="feats">
<article class="feat wide" data-id="gacha">
<div class="stage s-shot" aria-hidden="true"><img src="/files/gacha-event-tracker.jpg" alt=""><span class="cap">screenshot · full colour on hover</span></div>
<div class="txt">
<div class="meta"><span class="num">01</span><span>Own · personal project</span></div>
<h3>Event Tracker for Gacha Games</h3>
<p class="pitch">Used to easily figure out which live service games events to prioritize.</p>
<dl class="facts">
<dt>why</dt><dd><span class="ph" title="placeholder">one line on the problem it solved</span></dd>
<dt>interesting</dt><dd><span class="ph" title="placeholder">one line on what was hard or surprising</span></dd>
</dl>
<div class="chips"><span class="chip">scraping</span><span class="chip">webapp</span><span class="chip">javascript</span></div>
<div class="ft"><button class="open" data-open="gacha">Read the full story <span>→</span></button><span class="ai full" title="made with AI"><i></i>fully AI</span></div>
</div>
<div class="filt">Hidden by your filter. <button class="linkish" data-showanyway>show anyway</button></div>
</article>
<article class="feat" data-id="config">
<div class="stage s-config" data-fx="spot" aria-hidden="true">
<div class="ed">
<div class="tree">
<div class="dir">config/</div>
<div style="--d:1">inventory/</div>
<div style="--d:2">hosts.yml</div>
<div style="--d:1" class="dir">roles/</div>
<div style="--d:2">docker</div>
<div style="--d:2">compose_stack</div>
<div style="--d:2" title="placeholder">backups</div>
<div style="--d:1">stacks/</div>
<div style="--d:1" class="on">site.yml</div>
</div>
<div class="code">
<div class="ln"><span class="c1"># one command: the machine matches git</span></div>
<div class="ln"><span class="k1">- hosts</span>: <span class="a1">homelab</span></div>
<div class="ln"> <span class="k1">become</span>: true</div>
<div class="ln"> <span class="k1">roles</span>:</div>
<div class="ln"> - docker</div>
<div class="ln"> - compose_stack</div>
<div class="ln"> <span class="k1">vars</span>:</div>
<div class="ln"> <span class="k1">stacks</span>: [<span class="s1">grafana</span>, <span class="s1">openbao</span>, <span class="s1" title="placeholder">gitea</span>]</div>
<div class="ln"> <span class="k1">pull_policy</span>: <span class="s1">missing</span></div>
</div>
<div class="recap">PLAY RECAP · <span title="placeholder">nas</span> : ok=41 <span class="ok">changed=0</span> unreachable=0 failed=0</div>
</div>
<span class="cap">drawn stand-in · hosts invented</span>
</div>
<div class="txt">
<div class="meta"><span class="num">02</span><span>Own · personal project</span></div>
<h3>Config</h3>
<p class="pitch">A repository to store, sync and manage docker-compose based deployment on servers.</p>
<dl class="facts">
<dt>why</dt><dd><span class="ph" title="placeholder">one line on the problem it solved</span></dd>
<dt>interesting</dt><dd><span class="ph" title="placeholder">one line on what was hard or surprising</span></dd>
</dl>
<div class="chips"><span class="chip">ansible</span><span class="chip">configuration management</span><span class="chip">infrastructure-as-code</span></div>
<div class="ft"><button class="open" data-open="config">Read more <span>→</span></button><span class="ai full" title="made with AI"><i></i>fully AI</span></div>
</div>
<div class="filt">Config is hidden by your filter (fully AI-made). <button class="linkish" data-showanyway>show anyway</button></div>
</article>
<article class="feat" data-id="hypercache">
<div class="stage s-net" data-fx="net" aria-hidden="true">
<svg id="netsvg" viewBox="0 0 600 350" preserveAspectRatio="xMidYMid meet"></svg>
<div class="ro" id="netro">hover a peer to see who trusts it</div>
<span class="cap">simulated network · numbers illustrative</span>
</div>
<div class="txt">
<div class="meta"><span class="num">03</span><span>School · bachelor thesis</span></div>
<h3>Bachelor Report</h3>
<p class="pitch">My bachelor thesis on using reputation systems in peer-to-peer archival.</p>
<dl class="facts">
<dt>why</dt><dd><span class="ph" title="placeholder">one line on the problem it solved</span></dd>
<dt>interesting</dt><dd><span class="ph" title="placeholder">one line on what was hard or surprising</span></dd>
</dl>
<div class="chips"><span class="chip">node.js</span><span class="chip">javascript</span><span class="chip">peer2peer</span></div>
<div class="ft"><button class="open" data-open="hypercache">Read more <span>→</span></button><span class="ai none" title="no AI, by hand"><i></i>by hand</span></div>
</div>
<div class="filt">Hidden by your filter. <button class="linkish" data-showanyway>show anyway</button></div>
</article>
<article class="feat wide rev" data-id="rowing">
<div class="stage s-row" data-fx="row" aria-hidden="true">
<div class="rowgrid">
<div class="boat"><span class="lbl">top view</span>
<svg viewBox="0 0 210 300" preserveAspectRatio="xMidYMid meet">
<path d="M105 30 C 128 80, 128 220, 105 272 C 82 220, 82 80, 105 30 Z" fill="#26221e" stroke="#8e877c" stroke-width="1.3"/>
<rect x="94" y="150" width="22" height="14" rx="3" fill="none" stroke="#625c54"/>
<g class="oar" id="oarL" style="transform-origin:96px 140px"><line x1="96" y1="140" x2="18" y2="140" stroke="#c8c0b4" stroke-width="2"/><rect x="6" y="134" width="20" height="12" rx="3" fill="#c8c0b4"/></g>
<g class="oar" id="oarR" style="transform-origin:114px 140px"><line x1="114" y1="140" x2="192" y2="140" stroke="#c8c0b4" stroke-width="2"/><rect x="184" y="134" width="20" height="12" rx="3" fill="#c8c0b4"/></g>
<circle cx="96" cy="140" r="3" fill="#ea7444"/><circle cx="114" cy="140" r="3" fill="#ea7444"/>
<text x="105" y="292" text-anchor="middle" font-family="Geist Mono" font-size="10" fill="#625c54" id="oarDeg">oar angle 0°</text>
</svg>
</div>
<div class="chart" id="chart"><span class="lbl">one stroke · force at the handle</span><span class="rd" id="rd">scrub the curve</span>
<svg id="chartsvg" viewBox="0 0 560 260" preserveAspectRatio="none"></svg>
</div>
<div class="pipe"><span>oar × 2</span><em>→</em><span title="placeholder: sensor type">rotary sensor</span><em>→</em><span>Arduino</span><em>→ serial →</em><span>Unity · C#</span><em>→</em><span>boat moves</span></div>
</div>
<span class="cap">drawn stand-in · curve shape illustrative, not measured</span>
</div>
<div class="txt">
<div class="meta"><span class="num">05</span><span>Own · personal project</span></div>
<h3>Mixed-Reality Rowing Game</h3>
<p class="pitch">A fun rowing game for two people, that uses an Arduino and two real oars to be played.</p>
<dl class="facts">
<dt>why</dt><dd><span class="ph" title="placeholder">one line on the problem it solved</span></dd>
<dt>interesting</dt><dd><span class="ph" title="placeholder">one line on what was hard or surprising</span></dd>
</dl>
<div class="chips"><span class="chip">unity</span><span class="chip">arduino</span><span class="chip">C#</span></div>
<div class="ft"><button class="open" data-open="rowing">Read the full story <span>→</span></button><span class="ai none" title="no AI, by hand"><i></i>by hand</span></div>
</div>
<div class="filt">Hidden by your filter. <button class="linkish" data-showanyway>show anyway</button></div>
</article>
<article class="feat" data-id="dashboard">
<div class="stage s-shot" aria-hidden="true"><img src="/files/student-dashboard.jpg" alt=""><span class="cap">screenshot · full colour on hover</span></div>
<div class="txt">
<div class="meta"><span class="num">04</span><span>School project</span></div>
<h3>Student Dashboard</h3>
<p class="pitch">A concept student dashboard web-app, built upon conducted user studies.</p>
<dl class="facts">
<dt>why</dt><dd><span class="ph" title="placeholder">one line on the problem it solved</span></dd>
<dt>interesting</dt><dd><span class="ph" title="placeholder">one line on what was hard or surprising</span></dd>
</dl>
<div class="chips"><span class="chip">react</span><span class="chip">webapp</span><span class="chip">dashboard</span></div>
<div class="ft"><button class="open" data-open="dashboard">Read more <span>→</span></button><span class="ai none" title="no AI, by hand"><i></i>by hand</span></div>
</div>
<div class="filt">Hidden by your filter. <button class="linkish" data-showanyway>show anyway</button></div>
</article>
<article class="feat" data-id="vrclass">
<div class="stage s-shot" aria-hidden="true"><img src="/files/vr-classroom.jpg" alt=""><span class="cap">screenshot · full colour on hover</span></div>
<div class="txt">
<div class="meta"><span class="num">06</span><span>School project</span></div>
<h3>VR-Classroom</h3>
<p class="pitch">A prototype website where you can register yourself, and then create and sign up for classes.</p>
<dl class="facts">
<dt>why</dt><dd><span class="ph" title="placeholder">one line on the problem it solved</span></dd>
<dt>interesting</dt><dd><span class="ph" title="placeholder">one line on what was hard or surprising</span></dd>
</dl>
<div class="chips"><span class="chip">javascript</span><span class="chip">node.js</span></div>
<div class="ft"><button class="open" data-open="vrclass">Read more <span>→</span></button><span class="ai none" title="no AI, by hand"><i></i>by hand</span></div>
</div>
<div class="filt">Hidden by your filter. <button class="linkish" data-showanyway>show anyway</button></div>
</article>
</div>
</section>
<!-- ================= index ================= -->
<section class="idx" id="all">
<div class="sh"><h2>Also made</h2><span class="n" id="cnt">13 projects</span></div>
<div class="tools">
<div class="tabs" role="group" aria-label="filter by kind" id="tabs"></div>
<label class="search"><input id="q" type="search" placeholder="Search name, stack, idea" aria-label="search projects"><kbd>/</kbd></label>
</div>
<div class="list" id="list"></div>
<div class="hiddenby" id="hiddenby"></div>
</section>
<section id="record" style="margin-top:70px">
<div class="sh"><h2>Record</h2><span class="n">the full version of the highlights</span></div>
<div class="rec">
<div class="card"><div class="lab">Recognition &amp; volunteering</div>
<div class="hl" style="margin-top:8px">
<div class="subh">Competitions</div>
<div class="acc">AUHackathon 2019</div><div class="r acc">winner · best AUHack</div>
<div>First Lego League <span class="mono" style="font-size:11px;color:var(--mute)">· four years, <span class="ph" title="placeholder">years?</span></span></div><div class="r acc">won round 1, two years running</div>
<div>Google Hash Code 2021</div><div class="r acc">top 12%</div>
<div>De Danske Cybermesterskaber 2024</div><div class="r">regional participant</div>
<div class="subh">Volunteering</div>
<div><a href="https://art-bubble.dk/">Art Bubble</a> <span class="mono" style="font-size:11px;color:var(--mute)">· art-bubble.dk ↗</span></div><div class="r">IT &amp; website · <span class="ph" title="placeholder">since ?</span> · ongoing</div>
<div>Lystrup Genbrugser</div><div class="r">IT &amp; website · <span class="ph" title="placeholder">years?</span> · site retired</div>
</div></div>
<div class="card"><div class="lab">Keeping up</div>
<p class="sumline"><span class="acc"><svg class="seal" width="14" height="14" viewBox="0 0 16 16"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg> <b class="acc">18 certificates</b></span> from 23 courses and hands-on labs · <b>~27 hours</b> of course time since Feb 2025</p>
<div class="ku"><div class="h">topic</div><div class="h" style="text-align:right">done</div><div class="h" style="text-align:right">certificates</div><div class="h" style="text-align:right">time</div><div>Ansible &amp; configuration</div><div class="c">2 courses · 3 labs</div><div class="c ce"><svg class="seal" width="12" height="12" viewBox="0 0 16 16"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg> 5</div><div class="c">9.4h</div><div>Linux administration</div><div class="c">1 course</div><div class="c ce"><svg class="seal" width="12" height="12" viewBox="0 0 16 16"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg> 1</div><div class="c">6.7h</div><div>Security <span class="mono" style="font-size:11px;color:var(--mute)">· JWT, ISO 27001, honeypots</span></div><div class="c">2 courses · 1 lab</div><div class="c ce"><svg class="seal" width="12" height="12" viewBox="0 0 16 16"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg> 2</div><div class="c">4.9h</div><div>Serverless on AWS</div><div class="c">5 courses · 3 labs</div><div class="c ce"><svg class="seal" width="12" height="12" viewBox="0 0 16 16"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg> 5</div><div class="c">3.9h</div><div>RAG &amp; LLM systems</div><div class="c">4 courses · 1 lab</div><div class="c ce"><svg class="seal" width="12" height="12" viewBox="0 0 16 16"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg> 4</div><div class="c">1.8h</div><div>Observability <span class="mono" style="font-size:11px;color:var(--mute)">· Grafana</span></div><div class="c">1 course</div><div class="c ce"><svg class="seal" width="12" height="12" viewBox="0 0 16 16"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg> 1</div><div class="c">0.4h</div></div>
<a class="lnk" href="#" onclick="return false" style="color:var(--acc)">All courses and certificates, searchable →</a></div>
</div>
</section>
<!-- ================= writing + cv ================= -->
<div class="lower">
<section class="card" id="writing">
<div class="sh"><h2>Writing</h2><span class="n">AI, devops, homelab</span><a class="r" href="https://blog.lucaswinther.info" style="text-decoration:none">blog.lucaswinther.info →</a></div>
<div class="posts" id="posts"></div>
</section>
<section class="card" id="cv">
<div class="sh"><h2>Where I've worked</h2><a class="r" href="#" onclick="return false" title="placeholder: CV PDF" style="text-decoration:none">↓ CV (PDF)</a></div>
<ul class="jobsx">
<li><span class="when">2025 – now</span><div><b>Aarhus University</b> <span class="role">· IT-medarbejder, IT Research Tech</span>
<p>Building and running software for researchers:</p>
<ul><li>a scalable, easy-to-use compute job system on Slurm, with a custom React/Node web app, scaling on Oracle Cloud, OAuth/Entra ID login and AU's ERDA storage</li>
<li>document and information services, integrating Overleaf and OpenProject</li>
<li>trying newer software while keeping it secure, containerised, updated and monitored: OpenBao/Vault, Grafana, Docker, Ansible</li></ul>
<div class="chips"><span class="chip">DevOps</span><span class="chip">Docker</span><span class="chip">Linux admin</span><span class="chip">observability</span><span class="chip">cloud</span><span class="chip">full-stack</span><span class="chip">leadership</span></div></div></li>
<li><span class="when">Jul 2019</span><div><b>ScienceAtHome</b> <span class="role">· internship</span>
<p>Joined ideation and planning on a new project, built an early prototype of its base mechanics to show the people it was for, and pair-programmed with experienced developers to start it.</p></div></li>
<li><span class="when">2016 – 2018</span><div><b>TERMA Group</b> <span class="role">· student helper, Local Configuration Management</span></div></li>
<li><span class="when">degree</span><div><b>BSc Computer Science</b> <span class="role">· <span class="ph" title="placeholder">Aarhus University, 2022</span></span>
<p>Thesis: HyperCache, reputation systems for peer-to-peer archival. <button class="linkish" data-open="hypercache">open project</button></p></div></li>
</ul>
</section>
</div>
</div>
<footer>
<div class="wrap">
<a href="nav-d.html">← back to the gate</a>
<a href="#" onclick="return false" title="placeholder link">/simple</a>
<a href="https://github.com/StereotypicalCat">GitHub</a>
<a href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a>
<a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a>
<span class="sp">dotted underline = a fact not written yet</span>
</div>
</footer>
</main>
<div class="scrim" id="scrim"></div>
<aside class="sheet" id="sheet" role="dialog" aria-modal="true" aria-labelledby="sTitle" aria-hidden="true">
<div class="top"><span class="pos" id="spos"></span><button class="ib" id="sprev" aria-label="previous project">← prev</button><button class="ib" id="snext" aria-label="next project">next →</button><button class="ib" id="sclose" aria-label="close">esc ×</button></div>
<div class="sbody" id="sbody"></div>
</aside>
<script>
/* ---------- content ---------- */
var PH = function (s) { return '<span class="ph" title="placeholder">' + s + '</span>'; };
var GROUPS = { own: 'Own', school: 'School' };
var AI = {
full: { label: 'fully AI', long: 'made with AI' },
some: { label: 'some AI', long: 'some AI help' },
none: { label: 'by hand', long: 'no AI, by hand' },
unk: { label: 'AI: not recorded', long: 'placeholder: AI level not recorded yet' }
};
var POSTS = [
{ id: 'agent', t: "What I let the agent write, and what I didn't", d: '2026-10-14', tag: 'AI', blurb: 'Going back through the gacha tracker file by file and marking every part by who wrote it.' },
{ id: 'slurm', t: 'Slurm on a cloud that scales to zero', d: '2026-09-02', tag: 'devops', blurb: 'What it takes for a job queue to grow nodes on demand, and shrink again without killing anything.' },
{ id: 'vault', t: 'Moving secrets out of .env and into OpenBao', d: '2026-07-21', tag: 'homelab', blurb: 'The boring migration that made every other change less scary.' },
{ id: 'ansible', t: 'Ansible for three servers is not overkill', d: '2026-05-08', tag: 'devops', blurb: 'Why Config exists, and the week every run restarted every container.' },
{ id: 'thesis', t: 'Rereading my bachelor thesis four years later', d: '2026-03-30', tag: 'notes', blurb: 'What HyperCache got right about trust, and what I would simulate differently now.' }
];
var GH = 'https://github.com/StereotypicalCat';
var PROJECTS = [
{ id: 'config', g: 'own', feat: 1, name: 'Config', yr: PH('2025'), ai: 'full', motif: 'infra',
one: 'Ansible and docker-compose: every service on my servers, deployed and updated from one repo.',
stack: ['Ansible', 'IaC', 'docker-compose', 'Docker', 'Linux'],
problem: 'Services were set up by hand over SSH, one machine at a time, and the only documentation was my memory.',
approach: 'An inventory of hosts, one role per kind of thing, and compose stacks templated from one pattern. One command brings a machine to the state git describes.',
interesting: PH('Idempotency: for a week every run restarted every container, because a template rendered a new timestamp each time.'),
parts: [['inventory & layout', 'none'], ['playbooks & roles', 'full'], ['compose files', 'full']],
links: [['GitHub', GH]], rel: ['ops', 'gacha'], posts: ['ansible'] },
{ id: 'hypercache', g: 'school', feat: 1, name: 'Bachelor Report', yr: PH('2022'), where: 'Bachelor thesis · ' + PH('Aarhus University'), ai: 'none', motif: 'net',
one: 'Reputation systems for peer-to-peer archival: who can you trust to keep your data?',
stack: ['Node.js', 'P2P', 'simulation', 'reputation systems'],
problem: 'In a peer-to-peer archive, data only survives if peers keep storing it. Some peers will freeload or quietly drop data.',
approach: 'A reputation system where peers rate each other on whether data is really kept, built and simulated in Node.js.',
interesting: PH('How fast trust recovers after a bad actor leaves, and how cheaply a newcomer can fake a good history.'),
parts: [['thesis & code', 'none']],
links: [['thesis (PDF)', '#']], rel: ['vrclass', 'compute'], posts: ['thesis'] },
{ id: 'rowing', g: 'own', feat: 1, name: 'Mixed-Reality Rowing Game', yr: PH('2020'), where: PH('course project'), ai: 'none', motif: 'boat',
one: 'Row a virtual boat with two real oars, wired up through an Arduino.',
stack: ['Unity', 'C#', 'Arduino'],
problem: 'Rowing machines are boring and VR controllers don\'t feel like oars.',
approach: 'Two real oars with sensors read by an Arduino, sending each stroke into a Unity scene over serial.',
interesting: PH('Turning noisy sensor readings into a stroke that feels right, with almost no lag.'),
parts: [['everything', 'none']],
links: [['video', '#']], rel: ['censor', 'vrclass', 'sah'], posts: [] },
{ id: 'gacha', g: 'own', feat: 1, name: 'Event Tracker for Gacha Games', yr: PH('2025'), ai: 'full', motif: 'web',
one: 'Scrapes event schedules from several games into one timeline, so limited events stop slipping by.',
stack: ['JavaScript', 'scraping', 'web app'],
problem: 'Each game announces its limited events somewhere different, in a different time zone. Keeping track meant checking five places.',
approach: 'Scrapers per game feed one normalised list of events, shown as a single timeline in a small web app.',
interesting: 'A test of building with AI: ' + PH('I wrote the spec and the fixtures, the agent wrote the scrapers, and every change was read before it went in.'),
parts: [['spec & fixtures', 'none'], ['scrapers', 'full'], ['timeline UI', 'full']],
links: [['live demo', '#'], ['GitHub', GH]], rel: ['config', 'expenses'], posts: ['agent'] },
{ id: 'chess', g: 'own', name: 'Chessswap', yr: PH('2020'), ai: 'none', motif: 'board',
one: 'Chess, except the board swaps every few turns.',
stack: ['React', 'JavaScript'],
problem: 'Chess openings are memorised; a twist that breaks memorisation makes it about thinking again.',
approach: 'A React board with normal chess rules, plus a swap every few turns that changes whose pieces you are playing.',
interesting: 'Rules that sound simple get strange at the edges: ' + PH('what happens to castling rights and check right at a swap?'),
parts: [['everything', 'none']], links: [['GitHub', GH]], rel: ['breakout', 'dashboard'], posts: [] },
{ id: 'expenses', g: 'own', name: 'Monthly Expenses Tracker', yr: PH('2021'), ai: 'none', motif: 'web',
one: 'A small web app for where the money goes each month, in DKK.',
stack: ['JavaScript', 'web app'],
problem: 'Bank apps show transactions, not the shape of a month.',
approach: 'A plain JavaScript web app: enter expenses, see each month by category, in kroner.',
interesting: PH('Storing amounts in øre to dodge floating-point surprises.'),
parts: [['everything', 'none']], links: [['GitHub', GH]], rel: ['gacha', 'dashboard'], posts: [] },
{ id: 'lolbot', g: 'own', name: 'LoL Winrate Discord Bot', yr: PH('2020'), ai: 'none', motif: 'bot',
one: 'Ask in Discord, get a League of Legends champion\'s current winrate.',
stack: ['Node.js', 'Discord API'],
problem: 'Mid-draft, nobody wants to alt-tab to a stats site.',
approach: 'A Node.js bot that answers a command with the champion\'s current winrate.',
interesting: PH('Rate limits and caching: a bot that is popular for ten minutes a night.'),
parts: [['everything', 'none']], links: [['GitHub', GH]], rel: ['gacha'], posts: [] },
{ id: 'portfolio', g: 'own', name: 'Portfolio (2021)', yr: '2021', ai: 'none', motif: 'web',
one: 'The old React + Material UI site, kept frozen as the by-hand archive.',
stack: ['React', 'Material UI'],
problem: 'A place to put projects, with a search.',
approach: 'Create React App and Material UI, projects read from one info.json.',
interesting: 'It is now the archive: everything on it was written by hand, before AI tools.',
parts: [['everything', 'none']], links: [['old site', '#']], rel: ['borlaug'], posts: [] },
{ id: 'borlaug', g: 'own', name: 'Tribute to Norman Borlaug', yr: PH('2016'), ai: 'none', motif: 'doc',
one: 'One of my first websites, a freeCodeCamp tribute page.',
stack: ['HTML', 'CSS'],
problem: 'Learning to make a web page at all.',
approach: 'One HTML page and a stylesheet, following the freeCodeCamp brief.',
interesting: 'Kept for honesty: this is where it started.',
parts: [['everything', 'none']], links: [['GitHub', GH]], rel: ['portfolio'], posts: [] },
{ id: 'dashboard', g: 'school', feat: 1, name: 'Student Dashboard', yr: PH('2021'), ai: 'none', motif: 'web',
one: 'A dashboard for students, shaped by user studies rather than guesses.',
stack: ['React', 'user studies'],
problem: 'Students juggle several university systems to answer simple questions like "what is due this week?".',
approach: 'User studies first, then a React dashboard, then studies again to see what actually helped.',
interesting: PH('The first interviews threw out half of the planned features.'),
parts: [['everything', 'none']], links: [['report (PDF)', '#'], ['demo', '#']], rel: ['vrclass', 'chess'], posts: [] },
{ id: 'vrclass', g: 'school', feat: 1, name: 'VR-Classroom', yr: PH('2020'), ai: 'none', motif: 'vr',
one: 'A Node.js prototype of a shared virtual classroom.',
stack: ['Node.js', 'VR', 'prototyping'],
problem: 'Remote teaching loses the feeling of being in the same room.',
approach: 'A Node.js prototype that keeps participants in a shared virtual room in sync.',
interesting: PH('Small things like where the teacher stands matter more than resolution.'),
parts: [['everything', 'none']], links: [['report (PDF)', '#'], ['video', '#']], rel: ['rowing', 'dashboard', 'censor'], posts: [] },
{ id: 'censor', g: 'school', name: 'Censor Simulator', yr: PH('2018'), where: 'High-school exam project', ai: 'none', motif: 'vr',
one: 'A VR exam project in Unity: you are the one deciding what gets through.',
stack: ['Unity', 'C#', 'VR'],
problem: 'An exam project that had to be both a game and an argument.',
approach: 'A Unity VR scene where you sort and censor incoming material.',
interesting: PH('First time building for VR: comfort and scale before anything else.'),
parts: [['everything', 'none']], links: [], rel: ['rowing', 'breakout'], posts: [] },
{ id: 'breakout', g: 'school', name: 'BreakoutPython', yr: PH('2017'), where: 'High-school exam project', ai: 'none', motif: 'board',
one: 'Breakout, written in Python for a high-school exam.',
stack: ['Python'],
problem: 'Learn to program a game loop from scratch.',
approach: 'Paddle, ball, bricks and collision, in Python.',
interesting: 'Collision is the whole game, and the first place bugs feel physical.',
parts: [['everything', 'none']], links: [['GitHub', GH]], rel: ['chess', 'censor'], posts: [] }
];
function P(id) { for (var i = 0; i < PROJECTS.length; i++) if (PROJECTS[i].id === id) return PROJECTS[i]; }
function POST(id) { for (var i = 0; i < POSTS.length; i++) if (POSTS[i].id === id) return POSTS[i]; }
function strip(s) { return String(s).replace(/<[^>]+>/g, ''); }
function aiMark(a) { return '<span class="ai ' + a + '" title="' + AI[a].long + '"><i></i>' + AI[a].label + '</span>'; }
function $(id) { return document.getElementById(id); }
PROJECTS.forEach(function (p) { p.rel = p.rel.filter(function (id) { return P(id); }); });
var IMG = { chess: 'chess-swap.jpg', expenses: 'budget.jpg', lolbot: 'lol-winrate-discord-bot.jpg', portfolio: 'portfolio.jpg', borlaug: 'tribute-to-bourlaug.jpg', censor: 'censor-simulator.jpg', breakout: 'breakout-python.jpg' };
/* ---------- small line drawings, one per kind of project ---------- */
function motif(kind) {
var s = '<svg viewBox="0 0 120 72" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">';
var m = {
arch: '<rect x="6" y="26" width="28" height="20" rx="3"/><rect x="46" y="26" width="28" height="20" rx="3"/><rect x="86" y="12" width="28" height="12" rx="2"/><rect x="86" y="30" width="28" height="12" rx="2"/><rect x="86" y="48" width="28" height="12" rx="2" stroke-dasharray="3 3"/><path d="M34 36h12M74 36l12-18M74 36h12M74 36l12 18"/>',
infra: '<rect x="30" y="8" width="60" height="14" rx="2"/><rect x="30" y="29" width="60" height="14" rx="2"/><rect x="30" y="50" width="60" height="14" rx="2"/><circle cx="38" cy="15" r="1.5" fill="currentColor"/><circle cx="38" cy="36" r="1.5" fill="currentColor"/><circle cx="38" cy="57" r="1.5" fill="currentColor"/><path d="M50 15h30M50 36h22M50 57h26"/>',
net: '<circle cx="20" cy="20" r="5"/><circle cx="60" cy="12" r="5"/><circle cx="100" cy="24" r="5"/><circle cx="38" cy="56" r="5"/><circle cx="84" cy="58" r="5" stroke-dasharray="2 2"/><path d="M25 19l30-6M65 13l30 10M23 24l12 28M43 55l36 2M96 28l-9 26M57 17l-16 34" />',
boat: '<path d="M60 8c10 14 10 42 0 56c-10-14-10-42 0-56z"/><path d="M56 32H12M64 32h44"/><rect x="6" y="28" width="10" height="8" rx="2"/><rect x="104" y="28" width="10" height="8" rx="2"/>',
doc: '<rect x="40" y="10" width="40" height="52" rx="3"/><path d="M34 16v50h38" opacity=".5"/><path d="M48 22h24M48 30h24M48 38h16M48 46h20"/>',
game: '<rect x="22" y="20" width="76" height="34" rx="12"/><path d="M40 32v10M35 37h10"/><circle cx="76" cy="33" r="2.5"/><circle cx="84" cy="41" r="2.5"/>',
web: '<rect x="12" y="10" width="96" height="52" rx="4"/><path d="M12 20h96"/><circle cx="19" cy="15" r="1.2" fill="currentColor"/><path d="M26 52V40M40 52V32M54 52V44M68 52V28M82 52V36M96 52V30"/>',
board: '<rect x="36" y="8" width="48" height="56"/><path d="M36 22h48M36 36h48M36 50h48M48 8v56M60 8v56M72 8v56"/><rect x="48" y="22" width="12" height="14" fill="currentColor" opacity=".35"/><rect x="60" y="36" width="12" height="14" fill="currentColor" opacity=".35"/>',
bot: '<rect x="14" y="12" width="56" height="20" rx="8"/><path d="M24 32l-4 8 10-8"/><rect x="50" y="40" width="56" height="20" rx="8"/><path d="M60 22h36M62 50h34" opacity=".0"/><path d="M24 22h36M62 50h32"/>',
vr: '<path d="M20 24h80a6 6 0 0 1 6 6v14a6 6 0 0 1-6 6H74l-6-6h-16l-6 6H20a6 6 0 0 1-6-6V30a6 6 0 0 1 6-6z"/><circle cx="42" cy="37" r="7"/><circle cx="78" cy="37" r="7"/>'
};
return s + (m[kind] || m.web) + '</svg>';
}
/* ---------- filter state ---------- */
var PREF = 'all', SHOWANY = {}, GROUP = 'all', Q = '';
function allowed(p) { if (SHOWANY[p.id]) return true; if (PREF === 'nofull') return p.ai !== 'full'; if (PREF === 'hand') return p.ai === 'none'; return true; }
var OTHERS = PROJECTS.filter(function (p) { return !p.feat; });
var FEAT = PROJECTS.filter(function (p) { return p.feat; });
function applyPref() {
document.querySelectorAll('.seg button').forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.pref === PREF)); });
document.querySelectorAll('.feat').forEach(function (el) {
var p = P(el.dataset.id);
el.classList.toggle('filtered', !allowed(p));
var f = el.querySelector('.filt');
if (f) f.firstChild.textContent = p.name + ' is hidden by your filter (' + (p.ai === 'full' ? 'fully AI-made' : p.ai === 'unk' ? 'AI level not recorded yet' : 'some AI') + '). ';
});
renderList();
}
document.querySelectorAll('.seg button').forEach(function (b) {
b.addEventListener('click', function () { PREF = b.dataset.pref; SHOWANY = {}; applyPref(); });
});
document.querySelectorAll('[data-showanyway]').forEach(function (b) {
b.addEventListener('click', function () { SHOWANY[b.closest('.feat').dataset.id] = true; applyPref(); });
});
/* ---------- index list ---------- */
function renderTabs() {
var opts = [['all', 'All', OTHERS.length]].concat(Object.keys(GROUPS).map(function (g) { return [g, GROUPS[g], OTHERS.filter(function (p) { return p.g === g; }).length]; }));
$('tabs').innerHTML = opts.map(function (o) { return '<button data-g="' + o[0] + '" aria-pressed="' + (GROUP === o[0]) + '">' + o[1] + '<small>' + o[2] + '</small></button>'; }).join('');
$('tabs').querySelectorAll('button').forEach(function (b) { b.addEventListener('click', function () { GROUP = b.dataset.g; renderTabs(); renderList(); }); });
}
var LISTIDS = [];
function renderList() {
var base = OTHERS.filter(function (p) {
if (GROUP !== 'all' && p.g !== GROUP) return false;
if (Q && strip(p.name + ' ' + p.one + ' ' + p.stack.join(' ')).toLowerCase().indexOf(Q) < 0) return false;
return true;
});
var shown = base.filter(allowed);
var hidden = base.length - shown.length;
LISTIDS = shown.map(function (p) { return p.id; });
$('list').innerHTML = shown.length ? shown.map(function (p) {
return '<button class="row" data-id="' + p.id + '"><span class="th">' + (IMG[p.id] ? '<img src="/files/' + IMG[p.id] + '" alt="">' : motif(p.motif)) + '</span>' +
'<span class="nm">' + p.name + '<small>' + GROUPS[p.g] + (p.where ? ' · ' + strip(p.where).split(' · ')[0] : '') + '</small></span>' +
'<span class="one">' + p.one + '</span><span class="stk">' + p.stack.map(strip).join(' · ') + '</span>' +
'<span class="yr">' + p.yr + '</span>' + aiMark(p.ai) + '<span class="go">→</span></button>';
}).join('') : '<div class="empty">Nothing matches' + (Q ? ' "' + Q.replace(/</g, '&lt;') + '"' : '') + '.</div>';
$('cnt').textContent = shown.length + ' of ' + OTHERS.length + ' projects';
$('hiddenby').innerHTML = hidden ? hidden + ' hidden by your AI filter (' + (PREF === 'hand' ? 'by hand only' : 'not fully by AI') + '). <button class="linkish" id="clearPref">show all</button>' : '';
if (hidden) $('clearPref').addEventListener('click', function () { PREF = 'all'; applyPref(); });
$('list').querySelectorAll('.row').forEach(function (r) { r.addEventListener('click', function () { openSheet(r.dataset.id, LISTIDS, r); }); });
}
$('q').addEventListener('input', function (e) { Q = e.target.value.trim().toLowerCase(); renderList(); });
/* ---------- writing ---------- */
if ($('wl')) $('wl').innerHTML = POSTS.slice(0, 3).map(function (w, i) {
return '<a href="https://blog.lucaswinther.info"' + (i === 0 ? ' class="lead"' : '') + '><span class="t ph" title="placeholder title">' + w.t + '</span><span class="d">' + w.d.slice(5).replace('-', '/') + '</span></a>';
}).join('');
$('posts').innerHTML = POSTS.map(function (w) {
return '<a href="https://blog.lucaswinther.info"><span class="d ph" title="placeholder">' + w.d + '</span><span><span class="t ph" title="placeholder title">' + w.t + '</span><span class="b">' + w.blurb + '</span></span><span class="tg">' + w.tag + '</span></a>';
}).join('');
/* ---------- side sheet ---------- */
var CTX = [], CUR = null, RETURN = null;
function sheetHTML(p) {
var links = p.links.length ? p.links.map(function (l) { return l[1] ? '<a class="btn" href="' + l[1] + '"' + (l[1] === '#' ? ' onclick="return false" title="placeholder link"' : '') + '>→ ' + l[0] + '</a>' : '<span class="dead">' + l[0] + '</span>'; }).join('') : '<span class="dead">no public links</span>';
return '<div class="vis">' + motif(p.motif) + '<span class="cap">' + (p.feat ? 'featured · drawn stand-in' : 'drawn stand-in for a screenshot') + '</span></div>' +
'<h2 id="sTitle">' + p.name + '</h2>' +
'<div class="meta"><span>' + GROUPS[p.g] + '</span>' + (p.where ? '<span>' + p.where + '</span>' : '') + '<span>' + p.yr + '</span>' + aiMark(p.ai) + '</div>' +
'<p class="lead">' + p.one + '</p>' +
'<section><h4>Why</h4><p>' + p.problem + '</p></section>' +
'<section><h4>How</h4><p>' + p.approach + '</p></section>' +
'<section><h4>Interesting</h4><p>' + p.interesting + '</p></section>' +
'<section><h4>Who made what</h4><ul class="parts">' + p.parts.map(function (x) { return '<li><span>' + x[0] + '</span>' + aiMark(x[1]) + '</li>'; }).join('') + '</ul></section>' +
'<section><h4>Stack</h4><div class="chips">' + p.stack.map(function (s) { return '<span class="chip">' + s + '</span>'; }).join('') + '</div></section>' +
'<section><h4>Links</h4><div class="lks">' + links + '</div></section>' +
(p.posts.length ? '<section><h4>Writing</h4><div class="sposts">' + p.posts.map(function (id) { var w = POST(id); return '<a href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">' + w.t + '</span> →</a>'; }).join('') + '</div></section>' : '') +
'<section><h4>Related</h4><div class="rel">' + p.rel.map(function (id) { return '<button data-rel="' + id + '">' + P(id).name + '</button>'; }).join('') + '</div></section>';
}
function fill(id) {
var p = P(id); CUR = id;
$('sbody').innerHTML = sheetHTML(p);
$('sbody').scrollTop = 0;
var i = CTX.indexOf(id);
$('spos').textContent = i >= 0 ? (i + 1) + ' / ' + CTX.length + (p.feat ? ' featured' : ' in list') : 'related project';
$('sprev').disabled = i <= 0; $('snext').disabled = i < 0 || i >= CTX.length - 1;
$('sbody').querySelectorAll('[data-rel]').forEach(function (b) { b.addEventListener('click', function () { swap(b.dataset.rel); }); });
history.replaceState(null, '', '#/' + id);
}
function swap(id) { if (document.startViewTransition) document.startViewTransition(function () { fill(id); }); else fill(id); }
function openSheet(id, ctx, from) {
CTX = ctx || []; RETURN = from || document.activeElement;
fill(id);
$('sheet').classList.add('on'); $('scrim').classList.add('on'); $('sheet').setAttribute('aria-hidden', 'false');
setTimeout(function () { $('sclose').focus(); }, 30);
}
function closeSheet() {
if (!$('sheet').classList.contains('on')) return;
$('sheet').classList.remove('on'); $('scrim').classList.remove('on'); $('sheet').setAttribute('aria-hidden', 'true');
history.replaceState(null, '', location.pathname + location.search);
if (RETURN && RETURN.focus) RETURN.focus();
}
function step(d) { var i = CTX.indexOf(CUR); if (i >= 0 && CTX[i + d]) swap(CTX[i + d]); }
$('sclose').addEventListener('click', closeSheet);
$('scrim').addEventListener('click', closeSheet);
$('sprev').addEventListener('click', function () { step(-1); });
$('snext').addEventListener('click', function () { step(1); });
function featCtx() { return FEAT.filter(allowed).map(function (p) { return p.id; }); }
document.querySelectorAll('[data-open]').forEach(function (b) {
b.addEventListener('click', function (e) { e.stopPropagation(); openSheet(b.dataset.open, P(b.dataset.open).feat ? featCtx() : [], b); });
});
document.querySelectorAll('.feat .txt h3').forEach(function (h) {
h.style.cursor = 'pointer';
h.addEventListener('click', function () { var id = h.closest('.feat').dataset.id; openSheet(id, featCtx(), h); });
});
document.addEventListener('keydown', function (e) {
var open = $('sheet').classList.contains('on');
if (e.key === 'Escape' && open) { e.preventDefault(); closeSheet(); return; }
if (open && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { e.preventDefault(); step(e.key === 'ArrowLeft' ? -1 : 1); return; }
if (open && e.key === 'Tab') { // keep focus inside the sheet
var f = $('sheet').querySelectorAll('button:not(:disabled), a[href]'); if (!f.length) return;
if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }
else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
return;
}
if (e.key === '/' && !open && document.activeElement.tagName !== 'INPUT') { e.preventDefault(); $('q').focus(); $('q').scrollIntoView({ block: 'center' }); }
});
/* ---------- stage 1: tilt ---------- */
(function () { if (!$('bars')) return;
var bars = [0,0,0,0,0,0,1,2,4,6,8,8,7,9,10,8,6,6,5,4,3,2,1,2];
$('bars').innerHTML = bars.map(function (v, i) { return '<i class="' + (i === bars.length - 1 ? 'now' : v >= 7 ? 'hi' : '') + '" style="height:' + (4 + v * 4) + 'px"></i>'; }).join('');
var st = document.querySelector('[data-fx="tilt"]'), w = st.querySelector('.tiltw');
st.addEventListener('mousemove', function (e) {
var r = st.getBoundingClientRect();
w.style.setProperty('--tx', ((e.clientX - r.left) / r.width * 2 - 1).toFixed(3));
w.style.setProperty('--ty', ((e.clientY - r.top) / r.height * 2 - 1).toFixed(3));
});
st.addEventListener('mouseleave', function () { w.style.setProperty('--tx', 0); w.style.setProperty('--ty', 0); });
})();
/* ---------- stage 2: spotlight ---------- */
(function () {
var st = document.querySelector('[data-fx="spot"]');
st.addEventListener('mousemove', function (e) {
var r = st.getBoundingClientRect();
st.style.setProperty('--mx', (e.clientX - r.left) + 'px'); st.style.setProperty('--my', (e.clientY - r.top) + 'px');
});
})();
/* ---------- stage 3: peers ---------- */
(function () {
var N = [ // x, y, trust, kept, free
[70, 94, .91, '15/15'], [180, 70, .84, '14/15'], [300, 108, .88, '15/15'], [430, 76, .23, '3/15', 1], [540, 124, .79, '13/15'],
[110, 204, .86, '14/15'], [240, 194, .93, '15/15'], [370, 214, .81, '13/15'], [500, 234, .12, '1/15', 1],
[60, 304, .77, '12/15'], [190, 294, .89, '15/15'], [330, 304, .71, '11/15'], [460, 314, .85, '14/15']
];
var E = [[0,1],[1,2],[2,3],[3,4],[0,5],[1,6],[2,6],[2,7],[3,7],[4,8],[5,6],[6,7],[7,8],[5,9],[6,10],[7,11],[8,12],[9,10],[10,11],[11,12],[3,6],[8,7],[4,7],[1,5],[10,7]];
var svg = $('netsvg'), h = '';
E.forEach(function (e, i) {
var a = N[e[0]], b = N[e[1]], bad = a[4] || b[4];
var t = (bad ? (a[4] ? a[2] : b[2]) : (a[2] + b[2]) / 2).toFixed(2);
h += '<line class="e' + (bad ? ' bad' : '') + '" data-a="' + e[0] + '" data-b="' + e[1] + '" x1="' + a[0] + '" y1="' + a[1] + '" x2="' + b[0] + '" y2="' + b[1] + '"/>';
h += '<text class="w" data-a="' + e[0] + '" data-b="' + e[1] + '" x="' + ((a[0] + b[0]) / 2 + 4) + '" y="' + ((a[1] + b[1]) / 2 - 4) + '">' + t + '</text>';
});
N.forEach(function (n, i) { h += '<g class="p' + (n[4] ? ' free' : '') + '" data-i="' + i + '"><circle cx="' + n[0] + '" cy="' + n[1] + '" r="8"/><text x="' + (n[0] + 12) + '" y="' + (n[1] + 4) + '">' + String(i + 1).padStart(2, '0') + '</text></g>'; });
svg.innerHTML = h;
var st = svg.parentNode, ro = $('netro'), def = ro.innerHTML, last = -1;
st.addEventListener('mousemove', function (e) {
var r = svg.getBoundingClientRect(), s = Math.min(r.width / 600, r.height / 350);
var ox = (r.width - 600 * s) / 2, oy = (r.height - 350 * s) / 2;
var x = (e.clientX - r.left - ox) / s, y = (e.clientY - r.top - oy) / s, best = -1, bd = 1e9;
N.forEach(function (n, i) { var d = (n[0] - x) * (n[0] - x) + (n[1] - y) * (n[1] - y); if (d < bd) { bd = d; best = i; } });
if (bd > 90 * 90) best = -1;
if (best === last) return; last = best;
st.classList.toggle('hot', best >= 0);
svg.querySelectorAll('.p').forEach(function (g) { g.classList.toggle('on', +g.dataset.i === best); });
svg.querySelectorAll('.e, .w').forEach(function (l) { l.classList.toggle('lit', +l.dataset.a === best || +l.dataset.b === best); });
if (best < 0) { ro.innerHTML = def; return; }
var n = N[best], deg = E.filter(function (e) { return e[0] === best || e[1] === best; }).length;
ro.innerHTML = '<b>peer ' + String(best + 1).padStart(2, '0') + '</b> · ' + deg + ' neighbours<br>trust <b' + (n[4] ? ' class="bad"' : '') + '>' + n[2].toFixed(2) + '</b> · kept ' + n[3] + ' chunks' + (n[4] ? '<br><span class="bad">freeloader: drops data quietly</span>' : '');
});
st.addEventListener('mouseleave', function () { last = -1; st.classList.remove('hot'); svg.querySelectorAll('.on, .lit').forEach(function (x) { x.classList.remove('on', 'lit'); }); ro.innerHTML = def; });
})();
/* ---------- stage 4: rowing stroke ---------- */
(function () {
var W = 560, H = 260, L = 30, R = 20, T = 40, B = 34;
function force(t) { return t < 0.4 ? Math.pow(Math.sin(Math.PI * t / 0.4), 1.4) * (1 - 0.18 * t) : Math.max(0, 0.03 * Math.sin(t * 40) * (1 - t)); }
function angle(t) { return t < 0.4 ? -38 + 70 * (t / 0.4) : 32 - 70 * ((t - 0.4) / 0.6); }
function phase(t) { return t < 0.06 ? 'catch' : t < 0.34 ? 'drive' : t < 0.42 ? 'finish' : 'recovery'; }
function X(t) { return L + t * (W - L - R); } function Y(f) { return H - B - f * (H - T - B); }
var d = '', a = '';
for (var i = 0; i <= 200; i++) { var t = i / 200; d += (i ? 'L' : 'M') + X(t).toFixed(1) + ' ' + Y(force(t)).toFixed(1); }
a = d + 'L' + X(1) + ' ' + Y(0) + 'L' + X(0) + ' ' + Y(0) + 'Z';
var g = '<g class="grid">';
[0, .25, .5, .75, 1].forEach(function (f) { g += '<line x1="' + L + '" x2="' + (W - R) + '" y1="' + Y(f) + '" y2="' + Y(f) + '"/>'; });
g += '<line x1="' + X(.4) + '" x2="' + X(.4) + '" y1="' + T + '" y2="' + Y(0) + '" stroke-dasharray="3 4"/></g>';
var labels = '<text class="ph-lbl" x="' + X(.02) + '" y="' + (H - 12) + '">catch</text><text class="ph-lbl" x="' + X(.16) + '" y="' + (H - 12) + '">drive</text><text class="ph-lbl" x="' + X(.36) + '" y="' + (H - 12) + '">finish</text><text class="ph-lbl" x="' + X(.66) + '" y="' + (H - 12) + '">recovery</text>';
$('chartsvg').innerHTML = g + '<path class="area" d="' + a + '"/><path class="curve" d="' + d + '" vector-effect="non-scaling-stroke"/>' + labels +
'<g class="scr"><line id="sl" x1="0" x2="0" y1="' + T + '" y2="' + Y(0) + '" vector-effect="non-scaling-stroke"/><circle id="sd" r="4"/></g>';
var ch = $('chart'), sl = $('sl'), sd = $('sd'), rd = $('rd'), def = rd.innerHTML;
function setOars(deg) { $('oarL').style.transform = 'rotate(' + (-deg) + 'deg)'; $('oarR').style.transform = 'rotate(' + deg + 'deg)'; $('oarDeg').textContent = 'oar angle ' + Math.round(deg) + '°'; }
ch.addEventListener('mousemove', function (e) {
var r = ch.getBoundingClientRect(), vx = (e.clientX - r.left) / r.width * W;
var t = Math.min(1, Math.max(0, (vx - L) / (W - L - R))), f = force(t);
sl.setAttribute('x1', X(t)); sl.setAttribute('x2', X(t)); sd.setAttribute('cx', X(t)); sd.setAttribute('cy', Y(f));
rd.innerHTML = 't ' + (t * 2.2).toFixed(2) + ' s · <b>' + phase(t) + '</b> · ' + Math.round(f * 100) + '% of peak';
setOars(angle(t));
});
ch.addEventListener('mouseleave', function () { rd.innerHTML = def; setOars(0); });
})();
/* ---------- init ---------- */
renderTabs(); applyPref();
(function () { var m = location.hash.match(/^#\/(\w+)/); if (m && P(m[1])) openSheet(m[1], P(m[1]).feat ? featCtx() : LISTIDS); })();
</script>
</body>
</html>