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>
948 lines
73 KiB
HTML
948 lines
73 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Lucas Winther · Work</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&family=Fraunces:ital,opsz,wght@1,9..144,300&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg: #f4f4f1; --paper: #ffffff; --ink: #111111; --ink2: #3b3b39; --mute: #85857f; --line: #e0e0db; --line2: #c9c9c3; --hov: #ebebe7;
|
||
--acc: #f0461a; --acc-ink: #c4350f;
|
||
--a-bg: #2a1a1f; --a-ink: #f1e6d6;
|
||
--sans: "Geist", system-ui, sans-serif; --mono: "Geist Mono", ui-monospace, monospace;
|
||
--edge: 36px; --pad: 48px; --ease: cubic-bezier(.2, .7, .2, 1);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html { scroll-padding-top: 72px; }
|
||
body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased; font-feature-settings: "ss01"; }
|
||
a { color: inherit; text-decoration: underline; text-decoration-color: var(--line2); text-underline-offset: 3px; text-decoration-thickness: 1px; }
|
||
a:hover { text-decoration-color: var(--acc); color: var(--ink); }
|
||
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
|
||
:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; }
|
||
.mono { font-family: var(--mono); }
|
||
.lbl { font-family: var(--mono); font-size: 11px; letter-spacing: .02em; color: var(--mute); }
|
||
.ph { text-decoration: underline dotted var(--mute); text-decoration-thickness: 1px; text-underline-offset: 3px; cursor: help; }
|
||
.wrap { max-width: 1360px; margin: 0 auto; padding: 0 var(--pad); }
|
||
.page { margin-right: var(--edge); }
|
||
|
||
/* mockup note */
|
||
.mk { position: fixed; top: 64px; right: calc(var(--edge) + 12px); z-index: 90; width: 300px; background: var(--ink); color: #ddd; font: 12px/1.45 var(--mono); padding: 10px 30px 11px 12px; border-radius: 6px; }
|
||
.mk b { color: #fff; display: block; font-weight: 500; margin-bottom: 3px; }
|
||
.mk button { position: absolute; top: 5px; right: 9px; color: #aaa; font-size: 16px; }
|
||
|
||
/* edge to the personal side */
|
||
.edge { position: fixed; top: 0; right: 0; bottom: 0; width: var(--edge); 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: 15px; text-decoration: none; z-index: 60; transition: width .2s var(--ease); }
|
||
.edge:hover { width: 48px; color: var(--a-ink); }
|
||
|
||
/* ---------- header ---------- */
|
||
.bar { position: sticky; top: 0; z-index: 50; background: var(--bg); border-bottom: 1px solid var(--line); }
|
||
.bar .wrap { height: 52px; display: flex; align-items: center; gap: 28px; font-size: 13px; }
|
||
.me { font-weight: 600; letter-spacing: -.01em; text-decoration: none; white-space: nowrap; }
|
||
.me span { color: var(--mute); font-weight: 400; }
|
||
.bar nav { display: flex; gap: 20px; }
|
||
.bar nav a { text-decoration: none; color: var(--ink2); }
|
||
.bar nav a sup { font-family: var(--mono); font-size: 10px; color: var(--mute); margin-left: 2px; }
|
||
.bar nav a:hover { color: var(--ink); }
|
||
.bar .sp { flex: 1; }
|
||
.seg { display: inline-flex; align-items: center; gap: 2px; background: var(--hov); border-radius: 8px; padding: 2px; font-size: 12.5px; }
|
||
.seg .why { padding: 0 8px 0 8px; color: var(--mute); font-family: var(--mono); font-size: 11px; }
|
||
.seg button { padding: 4px 10px; border-radius: 6px; color: var(--ink2); white-space: nowrap; transition: background .15s, color .15s; }
|
||
.seg button:hover { color: var(--ink); }
|
||
.seg button[aria-pressed="true"] { background: var(--paper); color: var(--ink); box-shadow: 0 0 0 1px var(--line2); }
|
||
.hbtn { font-size: 13px; padding: 5px 10px; border-radius: 6px; text-decoration: none; white-space: nowrap; }
|
||
.hbtn:hover { background: var(--hov); }
|
||
.hbtn[aria-expanded="true"] { background: var(--ink); color: var(--bg); }
|
||
.hbtn.gate { color: var(--mute); }
|
||
.hbtn.gate:hover { color: var(--ink); }
|
||
|
||
/* CV sheet */
|
||
.cv { position: fixed; top: 60px; right: calc(var(--edge) + var(--pad) - 10px); z-index: 70; width: 400px; background: var(--paper); border: 1px solid var(--ink); border-radius: 10px; padding: 18px 20px 16px; font-size: 13.5px; opacity: 0; transform: translateY(-6px); pointer-events: none; transition: opacity .16s, transform .16s var(--ease); }
|
||
.cv.on { opacity: 1; transform: none; pointer-events: auto; }
|
||
.cv h3 { margin: 0 0 12px; font-size: 13px; font-weight: 500; display: flex; justify-content: space-between; }
|
||
.cv dl { margin: 0; display: grid; grid-template-columns: 82px 1fr; gap: 9px 12px; }
|
||
.cv dt { font-family: var(--mono); font-size: 11px; color: var(--mute); padding-top: 2px; }
|
||
.cv dd { margin: 0; }
|
||
.cv dd small { color: var(--mute); font-size: 12px; display: block; }
|
||
.cv .pdf { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||
.cv .pdf a.b { background: var(--ink); color: var(--bg); text-decoration: none; padding: 6px 12px; border-radius: 6px; font-size: 13px; }
|
||
.cv .pdf a.b:hover { background: var(--acc); }
|
||
|
||
/* ---------- intro ---------- */
|
||
.intro { display: grid; grid-template-columns: repeat(12, 1fr); gap: 0 24px; padding: 56px 0 52px; }
|
||
.intro .stmt { grid-column: 1 / span 8; }
|
||
.intro .lbl { margin-bottom: 18px; display: block; }
|
||
.intro h1 { margin: 0; font-size: 52px; line-height: 1.04; font-weight: 500; letter-spacing: -.036em; max-width: 15em; }
|
||
.intro h1 em { font-style: normal; color: var(--mute); }
|
||
.intro .side { grid-column: 9 / span 4; align-self: end; font-size: 13.5px; }
|
||
.hist { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--ink); }
|
||
.hist li { display: grid; grid-template-columns: 78px 1fr; gap: 12px; padding: 9px 0 10px; border-bottom: 1px solid var(--line); }
|
||
.hist .y { font-family: var(--mono); font-size: 11.5px; color: var(--mute); padding-top: 1px; }
|
||
.hist b { font-weight: 500; display: block; }
|
||
.hist span.r { color: var(--ink2); }
|
||
.hist a { font-family: var(--mono); font-size: 11px; color: var(--mute); text-decoration: none; margin-right: 8px; }
|
||
.hist a:hover { color: var(--acc-ink); }
|
||
.side .lks { margin-top: 12px; font-family: var(--mono); font-size: 11.5px; color: var(--mute); display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
||
.side .lks a { color: var(--ink2); }
|
||
|
||
/* ---------- index ---------- */
|
||
.ihead { display: flex; align-items: baseline; justify-content: space-between; border-bottom: 1px solid var(--ink); padding-bottom: 10px; gap: 24px; flex-wrap: wrap; }
|
||
.ihead h2, .whead h2 { margin: 0; font-size: 15px; font-weight: 500; }
|
||
.ihead h2 span, .whead h2 span { color: var(--mute); font-weight: 400; margin-left: 6px; font-family: var(--mono); font-size: 12px; }
|
||
.legend { display: flex; gap: 18px; font-family: var(--mono); font-size: 11.5px; color: var(--ink2); align-items: center; flex-wrap: wrap; }
|
||
.legend .hid { color: var(--acc-ink); }
|
||
.cols, .rh { display: grid; grid-template-columns: 76px minmax(0, 1fr) 230px 92px 118px 28px; gap: 0 20px; align-items: baseline; }
|
||
.cols { padding: 10px 0 8px; border-bottom: 1px solid var(--line); }
|
||
|
||
/* AI mark: a dot filled by how much AI did */
|
||
.ai { font-family: var(--mono); font-size: 11.5px; color: var(--ink2); white-space: nowrap; display: inline-flex; align-items: center; gap: 7px; }
|
||
.ai i { width: 9px; height: 9px; border-radius: 50%; border: 1.25px solid currentColor; flex: none; }
|
||
.ai.full i { background: currentColor; }
|
||
.ai.some i { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
|
||
.ai.unk i { border-style: dashed; }
|
||
.ai.unk { color: var(--mute); }
|
||
|
||
.row { border-bottom: 1px solid var(--line); transition: border-color .2s; }
|
||
.row[hidden] { display: none; }
|
||
.rh { width: 100%; text-align: left; padding: 18px 0 17px; position: relative; }
|
||
.rh .no { font-size: 40px; line-height: 1; font-weight: 400; color: var(--line2); letter-spacing: -.03em; font-variant-numeric: tabular-nums; transition: color .15s; }
|
||
.rh .nm { font-size: 40px; line-height: 1.05; font-weight: 500; letter-spacing: -.032em; transition: transform .22s var(--ease); }
|
||
.rh .wh, .rh .yr { font-family: var(--mono); font-size: 11.5px; color: var(--ink2); }
|
||
.rh .wh { color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.rh .pm { justify-self: end; width: 18px; height: 18px; position: relative; align-self: center; }
|
||
.rh .pm::before, .rh .pm::after { content: ""; position: absolute; left: 2px; right: 2px; top: 8px; height: 1.5px; background: var(--ink); transition: transform .22s var(--ease), background .15s; }
|
||
.rh .pm::after { transform: rotate(90deg); }
|
||
.rh:hover .no, .rh:focus-visible .no { color: var(--acc); }
|
||
.rh:hover .nm { transform: translateX(6px); }
|
||
.row.open { border-bottom-color: var(--ink); }
|
||
.row.open .rh .no { color: var(--acc); }
|
||
.row.open .rh .nm { transform: none; }
|
||
.row.open .rh .pm::after { transform: rotate(0deg); }
|
||
.row.open .rh .pm::before { background: var(--acc); }
|
||
.row.open .rh .pm::after { background: var(--acc); }
|
||
|
||
/* expanding detail: 0fr -> 1fr */
|
||
.rd { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .28s var(--ease); }
|
||
.row.open .rd { grid-template-rows: 1fr; }
|
||
.rd > .in { overflow: hidden; min-height: 0; }
|
||
.instant .rd, .instant .rd .det { transition: none !important; }
|
||
.det { display: grid; grid-template-columns: 76px repeat(12, minmax(0, 1fr)); gap: 0 20px; padding: 4px 0 40px; opacity: 0; transform: translateY(-4px); transition: opacity .2s .06s, transform .28s var(--ease); }
|
||
.row.open .det { opacity: 1; transform: none; }
|
||
.det .story { grid-column: 2 / span 5; }
|
||
.det .lead { font-size: 23px; line-height: 1.28; letter-spacing: -.018em; margin: 0 0 22px; font-weight: 450; max-width: 22em; }
|
||
.det .where { font-family: var(--mono); font-size: 11.5px; color: var(--mute); margin-bottom: 12px; display: block; }
|
||
.det .st { display: grid; grid-template-columns: 96px 1fr; gap: 12px; padding: 11px 0; border-top: 1px solid var(--line); font-size: 14.5px; color: var(--ink2); }
|
||
.det .st .k { font-family: var(--mono); font-size: 11px; color: var(--mute); padding-top: 3px; }
|
||
.det .st p { margin: 0; max-width: 34em; }
|
||
.det .figw { grid-column: 7 / span 7; }
|
||
.fig { background: var(--paper); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; position: relative; }
|
||
.fig .cv-in { display: flex; justify-content: center; align-items: center; padding: 22px; min-height: 300px; }
|
||
.fig .cv-in > svg { width: 100%; height: auto; display: block; }
|
||
.fig.small .cv-in > svg { max-width: 470px; }
|
||
.cap { display: flex; justify-content: space-between; gap: 16px; padding: 9px 14px; border-top: 1px solid var(--line); font-family: var(--mono); font-size: 11.5px; color: var(--mute); }
|
||
.cap .live { color: var(--ink2); min-height: 1.5em; }
|
||
.cap .live b { color: var(--ink); font-weight: 500; }
|
||
.facts { grid-column: 2 / span 12; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0 20px; margin-top: 28px; border-top: 1px solid var(--ink); }
|
||
.facts > div { padding-top: 12px; font-size: 13.5px; }
|
||
.facts h4 { margin: 0 0 9px; font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--mute); }
|
||
.facts ul { list-style: none; margin: 0; padding: 0; }
|
||
.facts li { padding: 2px 0; }
|
||
.stack { display: flex; flex-wrap: wrap; gap: 5px; }
|
||
.stack span { border: 1px solid var(--line2); border-radius: 5px; padding: 1px 7px; font-size: 12.5px; }
|
||
.parts li { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; padding: 4px 0; border-bottom: 1px dashed var(--line); }
|
||
.parts li:last-child { border-bottom: 0; }
|
||
.facts a.go { text-decoration: none; display: inline-flex; gap: 6px; }
|
||
.facts a.go:hover { color: var(--acc-ink); }
|
||
.facts .none { color: var(--mute); }
|
||
.nextp { grid-column: 2 / span 12; display: flex; justify-content: space-between; margin-top: 22px; font-family: var(--mono); font-size: 11.5px; color: var(--mute); }
|
||
.nextp button:hover, .nextp a:hover { color: var(--ink); }
|
||
.nextp a { text-decoration: none; }
|
||
.fnote { padding: 14px 0 0; font-family: var(--mono); font-size: 11.5px; color: var(--mute); min-height: 30px; }
|
||
|
||
/* floating hover preview */
|
||
.pv { position: fixed; left: 0; top: 0; z-index: 40; width: 300px; background: var(--paper); border: 1px solid var(--ink); border-radius: 10px; overflow: hidden; pointer-events: none; opacity: 0; transform: translate3d(var(--x, 0), var(--y, 0), 0) scale(.96); transform-origin: 0 50%; transition: opacity .14s, transform .14s var(--ease); box-shadow: 0 10px 30px -12px rgba(0,0,0,.25); }
|
||
.pv.on { opacity: 1; transform: translate3d(var(--x, 0), var(--y, 0), 0) scale(1); }
|
||
.pv .pvf { padding: 10px; }
|
||
.pv svg { width: 100%; height: auto; display: block; }
|
||
.pv .pvc span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.pv .pvc span:last-child { white-space: nowrap; }
|
||
.pv .pvc { display: flex; gap: 10px; justify-content: space-between; padding: 7px 12px 8px; border-top: 1px solid var(--line); font-family: var(--mono); font-size: 10.5px; color: var(--mute); }
|
||
|
||
/* figure drawing language */
|
||
.fg text { font-family: var(--mono); font-size: 8.5px; fill: var(--ink); }
|
||
.fg text.m { fill: var(--mute); }
|
||
.fg text.w { fill: #f4f4f1; }
|
||
.fg text.a { fill: var(--acc); }
|
||
.fg text.big { font-family: var(--sans); font-weight: 500; letter-spacing: -.02em; }
|
||
.fg .s { fill: none; stroke: var(--ink); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||
.fg .sl { fill: none; stroke: var(--line2); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||
.fg .b { fill: var(--paper); stroke: var(--ink); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||
.fg .g { fill: #ececea; }
|
||
.fg .gg { fill: #dcdcd6; }
|
||
.fg .k { fill: var(--ink); }
|
||
.fg .a { fill: var(--acc); }
|
||
.fg .as { fill: none; stroke: var(--acc); stroke-width: 1.6; vector-effect: non-scaling-stroke; }
|
||
.fg .d { stroke-dasharray: 3 3; }
|
||
.fg .hit { cursor: pointer; }
|
||
.fg .hit:hover rect, .fg .hit:focus rect, .fg .hit.on rect { stroke: var(--acc); stroke-width: 2; }
|
||
.fg .hit:focus { outline: none; }
|
||
.fg .peer circle { fill: var(--paper); stroke: var(--ink); vector-effect: non-scaling-stroke; transition: fill .12s; }
|
||
.fg .peer.bad circle { stroke: var(--acc); stroke-dasharray: 3 2; }
|
||
.fg .peer:hover circle, .fg .peer.on circle { fill: var(--ink); }
|
||
.fg .peer:hover text, .fg .peer.on text { fill: var(--bg); }
|
||
.fg .peer text { text-anchor: middle; }
|
||
.fg line.e { stroke: var(--line2); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||
.fg line.e.on { stroke: var(--ink); stroke-width: 1.6; }
|
||
|
||
/* config before / after (HTML figure) */
|
||
.ba { display: grid; grid-template-columns: 1fr 1fr; gap: 0; width: 100%; font-family: var(--mono); font-size: 12px; align-self: stretch; }
|
||
.ba > div { padding: 4px 18px; }
|
||
.ba > div + div { border-left: 1px solid var(--line); }
|
||
.ba h5 { margin: 0 0 10px; font-weight: 500; font-size: 11px; color: var(--mute); }
|
||
.ba h5 b { color: var(--ink); font-weight: 500; }
|
||
.ba ul { list-style: none; margin: 0; padding: 0; }
|
||
.ba li { display: grid; grid-template-columns: 62px 1fr; gap: 8px; padding: 5px 0; border-bottom: 1px dashed var(--line); color: var(--ink2); }
|
||
.ba li span:first-child { color: var(--mute); }
|
||
.ba li .x { color: var(--acc-ink); }
|
||
.ba pre { margin: 0; font: inherit; line-height: 1.75; color: var(--ink2); }
|
||
.ba pre b { color: var(--ink); font-weight: 500; }
|
||
.ba .term { margin-top: 12px; background: var(--ink); color: #d6d6d0; border-radius: 6px; padding: 9px 11px; line-height: 1.6; font-size: 11.5px; }
|
||
.ba .term .ok { color: #9fd49b; } .ba .term .ch { color: var(--acc); }
|
||
|
||
/* ---------- writing ---------- */
|
||
.writing { margin-top: 88px; }
|
||
.whead { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 1px solid var(--ink); padding-bottom: 10px; }
|
||
.whead a { font-family: var(--mono); font-size: 11.5px; color: var(--ink2); }
|
||
.wl { list-style: none; margin: 0; padding: 0; }
|
||
.wl li a { display: grid; grid-template-columns: 76px minmax(0, 1fr) 230px 92px 118px 28px; gap: 0 20px; align-items: baseline; padding: 16px 0 15px; border-bottom: 1px solid var(--line); text-decoration: none; }
|
||
.wl .d { font-family: var(--mono); font-size: 11.5px; color: var(--mute); }
|
||
.wl .t { font-size: 22px; font-weight: 500; letter-spacing: -.02em; line-height: 1.2; }
|
||
.wl .bl { display: block; font-size: 13.5px; color: var(--mute); font-weight: 400; letter-spacing: 0; margin-top: 4px; }
|
||
.wl .rel { font-family: var(--mono); font-size: 11.5px; color: var(--mute); }
|
||
.wl .tg { font-family: var(--mono); font-size: 11.5px; color: var(--ink2); }
|
||
.wl .ar { justify-self: end; transition: transform .2s var(--ease); }
|
||
.wl li a:hover .t { color: var(--ink); }
|
||
.wl li a:hover .ar { transform: translate(3px, -3px); color: var(--acc); }
|
||
.wl li a:hover .d { color: var(--acc-ink); }
|
||
|
||
footer { margin-top: 96px; border-top: 1px solid var(--ink); padding: 18px 0 40px; display: grid; grid-template-columns: 76px 1fr 1fr; gap: 0 20px; font-size: 13px; }
|
||
footer .big { font-size: 28px; letter-spacing: -.03em; font-weight: 500; line-height: 1.15; grid-column: 2; }
|
||
footer .big a { text-decoration-color: var(--acc); }
|
||
footer .r { font-family: var(--mono); font-size: 11.5px; color: var(--mute); display: flex; flex-direction: column; gap: 4px; align-items: flex-end; text-align: right; }
|
||
footer .r a { color: var(--ink2); }
|
||
footer .phs { grid-column: 2 / span 2; margin-top: 28px; font-family: var(--mono); font-size: 11px; color: var(--mute); max-width: 90ch; }
|
||
|
||
/* ---------- small screens ---------- */
|
||
@media (max-width: 1100px) {
|
||
.bar nav { display: none; }
|
||
.cols, .rh, .wl li a { grid-template-columns: 52px minmax(0, 1fr) 92px 28px; }
|
||
.cols .c-wh, .cols .c-yr, .rh .wh, .rh .yr, .wl .rel, .wl .tg { display: none; }
|
||
.rh .no, .rh .nm { font-size: 30px; }
|
||
.det { grid-template-columns: 52px repeat(12, minmax(0, 1fr)); }
|
||
.det .story, .det .figw { grid-column: 2 / span 12; }
|
||
.det .figw { margin-top: 20px; }
|
||
.facts { grid-template-columns: 1fr 1fr; gap: 16px 20px; }
|
||
.intro .stmt, .intro .side { grid-column: 1 / -1; }
|
||
.intro .side { margin-top: 36px; }
|
||
}
|
||
@media (max-width: 720px) {
|
||
:root { --pad: 18px; --edge: 0px; }
|
||
.edge { position: static; writing-mode: horizontal-tb; width: auto; height: 40px; }
|
||
.edge:hover { width: auto; }
|
||
.bar .wrap { height: auto; flex-wrap: wrap; gap: 8px 14px; padding-top: 8px; padding-bottom: 8px; }
|
||
.bar .sp { display: none; }
|
||
.seg .why { display: none; }
|
||
.intro { padding: 36px 0; }
|
||
.intro h1 { font-size: 36px; }
|
||
.cols, .rh, .wl li a { grid-template-columns: 34px minmax(0, 1fr) 24px; }
|
||
.cols .c-ai, .rh .aiw { display: none; }
|
||
.rh .no { font-size: 15px; padding-top: 6px; }
|
||
.rh .nm { font-size: 24px; }
|
||
.det { grid-template-columns: 0 repeat(12, minmax(0, 1fr)); gap: 0 8px; }
|
||
.det .st { grid-template-columns: 1fr; gap: 2px; }
|
||
.facts { grid-template-columns: 1fr; }
|
||
.ba { grid-template-columns: 1fr; }
|
||
.ba > div + div { border-left: 0; border-top: 1px solid var(--line); padding-top: 12px; margin-top: 8px; }
|
||
.wl .t { font-size: 18px; }
|
||
.wl .e { display: none; }
|
||
footer { grid-template-columns: 1fr; }
|
||
footer .big, footer .phs { grid-column: 1; }
|
||
footer .r { align-items: flex-start; text-align: left; margin-top: 16px; }
|
||
.mk { top: auto; bottom: 12px; right: 12px; }
|
||
.cv { right: 12px; left: 12px; width: auto; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0s !important; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="mk" id="mk"><button onclick="document.getElementById('mk').remove()" aria-label="close">×</button>
|
||
<b>tech-r3-b · Swiss index, modern</b>Every project is one big numbered row. Point at a row for a small drawn preview by the cursor; click to open it in place (one at a time, the URL follows). Strict grid, Geist, near-white with one orange. Borrows rauno.me's restraint and sankar.work's hover previews, minus the noise.</div>
|
||
|
||
<a class="edge" href="nav-d.html" title="cross over to the personal side">off the clock →</a>
|
||
|
||
<div class="page">
|
||
<header class="bar">
|
||
<div class="wrap">
|
||
<a class="me" href="#top">Lucas Winther <span>/ work</span></a>
|
||
<nav aria-label="On this page"><a href="#index">Index<sup id="navN">17</sup></a><a href="#cvsec">Work history</a><a href="#writing">Writing<sup>5</sup></a></nav>
|
||
<span class="sp"></span>
|
||
<div class="seg" role="group" aria-label="Filter projects by how they were made">
|
||
<span class="why">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>
|
||
<button class="hbtn" id="cvBtn" aria-expanded="false" aria-controls="cv">CV</button>
|
||
<a class="hbtn gate" href="nav-d.html" title="back to the two gates">↖ gate</a>
|
||
</div>
|
||
</header>
|
||
|
||
<aside class="cv" id="cv" aria-label="CV in ten seconds">
|
||
<h3><span>CV, in ten seconds</span><span class="lbl">esc</span></h3>
|
||
<dl>
|
||
<dt>now</dt><dd>IT-medarbejder, IT Research Tech<small>Aarhus University · Jan 2025 – now</small></dd>
|
||
<dt>before</dt><dd>Intern, ScienceAtHome <small>July 2019</small>Student helper, Local Configuration Management, TERMA Group<small>May 2016 – Sep 2018</small></dd>
|
||
<dt>education</dt><dd>BSc Computer Science<small><span class="ph" title="placeholder">Aarhus University, 2022</span> · thesis: HyperCache</small></dd>
|
||
<dt>skills</dt><dd>DevOps, Docker, Linux administration, observability, cloud, full-stack, leadership</dd>
|
||
<dt>contact</dt><dd><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><small><a href="https://github.com/StereotypicalCat">GitHub</a> · <a href="https://www.linkedin.com/in/lucas-winther/">LinkedIn</a></small></dd>
|
||
</dl>
|
||
<div class="pdf"><span class="lbl"><span class="ph" title="placeholder">2 pages · updated Nov 2026</span></span><a class="b" href="#" title="placeholder: CV PDF">Download CV (PDF)</a></div>
|
||
</aside>
|
||
|
||
<main class="wrap" id="top">
|
||
<section class="intro" id="cvsec">
|
||
<div class="stmt">
|
||
<span class="lbl">lucaswinther.info / work · computer scientist</span>
|
||
<h1>I build and run research software at Aarhus University, <em>and make things on the side. Each project says how much AI was involved.</em></h1>
|
||
</div>
|
||
<div class="side">
|
||
<ul class="hist">
|
||
<li><span class="y">2025 – now</span><span><b>Aarhus University</b><span class="r">IT-medarbejder, IT Research Tech</span><br><a href="#compute">01 compute</a><a href="#docs">02 docs</a><a href="#ops">03 ops</a></span></li>
|
||
<li><span class="y">2019</span><span><b>ScienceAtHome</b><span class="r">Internship, one month</span><br><a href="#sah">04 prototype</a></span></li>
|
||
<li><span class="y">2016 – 18</span><span><b>TERMA Group</b><span class="r">Student helper, Local Configuration Mgmt.</span></span></li>
|
||
<li><span class="y ph" title="placeholder">2022</span><span><b>BSc Computer Science</b><span class="r"><span class="ph" title="placeholder">Aarhus University</span></span><br><a href="#hypercache">12 thesis</a></span></li>
|
||
</ul>
|
||
<div class="lks"><a href="mailto:lucas@lucaswinther.info">email</a><a href="https://github.com/StereotypicalCat">github</a><a href="https://www.linkedin.com/in/lucas-winther/">linkedin</a><a href="#" title="placeholder: CV PDF">cv.pdf</a></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="index" aria-label="Projects">
|
||
<div class="ihead">
|
||
<h2>Index<span id="idxN">17 projects</span></h2>
|
||
<div class="legend" id="legend"></div>
|
||
</div>
|
||
<div class="cols lbl" aria-hidden="true"><span>no.</span><span>project</span><span class="c-wh">where</span><span class="c-yr">year</span><span class="c-ai">made</span><span></span></div>
|
||
<div id="rows"></div>
|
||
<div class="fnote" id="fnote"></div>
|
||
</section>
|
||
|
||
<section class="writing" id="writing">
|
||
<div class="whead"><h2>Writing<span>blog.lucaswinther.info</span></h2><a href="https://blog.lucaswinther.info">all posts ↗</a></div>
|
||
<ul class="wl" id="wl"></ul>
|
||
</section>
|
||
|
||
<footer>
|
||
<span></span>
|
||
<div class="big">Write to <a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br>or find the code on <a href="https://github.com/StereotypicalCat">GitHub</a>.</div>
|
||
<div class="r"><a href="nav-d.html">↖ back to the gate</a><a href="nav-d.html">off the clock →</a><a href="#" title="placeholder: old site URL">the 2021 site, by hand ↗</a></div>
|
||
<p class="phs">Placeholders (dotted underline): university and year, most project years, AI level of the AU work, ScienceAtHome engine, story details, all figures (drawn stand-ins for screenshots), HyperCache numbers, blog titles and dates, CV PDF.</p>
|
||
</footer>
|
||
</main>
|
||
</div>
|
||
|
||
<div class="pv" id="pv" aria-hidden="true"><div class="pvf" id="pvf"></div><div class="pvc"><span id="pvc1"></span><span id="pvc2"></span></div></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; }
|
||
|
||
|
||
/* ================= tech-r3-b ================= */
|
||
(function () {
|
||
PROJECTS.forEach(function (p, i) {
|
||
p.no = String(i + 1).padStart(2, '0');
|
||
if (!p.where) p.where = p.g === 'own' ? 'Own project' : 'University project';
|
||
});
|
||
var AILONG = { full: 'made with AI', some: 'some AI help', none: 'by hand', unk: 'not recorded' };
|
||
function aiMk(a) { return '<span class="ai ' + a + '" title="' + AI[a].long + '"><i></i>' + AILONG[a] + '</span>'; }
|
||
function esc(s) { return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<'); }
|
||
|
||
/* ---------- drawing helpers (mini figures are 300 x 190) ---------- */
|
||
function R(x, y, w, h, c, rx) { return '<rect class="' + (c || 'b') + '" x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '"' + (rx ? ' rx="' + rx + '"' : '') + '/>'; }
|
||
function T(x, y, s, c, extra) { return '<text' + (c ? ' class="' + c + '"' : '') + ' x="' + x + '" y="' + y + '"' + (extra || '') + '>' + s + '</text>'; }
|
||
function Lp(d, c) { return '<path class="' + (c || 's') + '" d="' + d + '"/>'; }
|
||
function C(x, y, r, c) { return '<circle class="' + (c || 'b') + '" cx="' + x + '" cy="' + y + '" r="' + r + '"/>'; }
|
||
function lines(x, y, ws, gap, c) { var s = ''; ws.forEach(function (w, i) { s += R(x, y + i * gap, w, 3, c || 'gg', 1.5); }); return s; }
|
||
function win(x, y, w, h, title) { return R(x, y, w, h, 'b', 6) + Lp('M' + x + ' ' + (y + 16) + 'H' + (x + w), 'sl') + C(x + 9, y + 8, 2.2, 'gg') + C(x + 16, y + 8, 2.2, 'gg') + C(x + 23, y + 8, 2.2, 'gg') + (title ? T(x + 34, y + 11, title, 'm', ' style="font-size:7px"') : ''); }
|
||
function svg(inner, vb, label) { return '<svg class="fg" viewBox="' + (vb || '0 0 300 190') + '" role="img" aria-label="' + esc(label || '') + '">' + inner + '</svg>'; }
|
||
|
||
var MINI = {
|
||
compute: function () {
|
||
var s = R(88, 22, 204, 132, 'sl d', 6) + T(96, 34, 'oracle cloud', 'm');
|
||
s += R(8, 74, 56, 32, 'b', 4) + T(15, 88, 'browser') + T(15, 99, 'AU login', 'm');
|
||
s += R(98, 48, 62, 28, 'b', 4) + T(105, 60, 'react') + T(105, 70, 'web app', 'm');
|
||
s += R(98, 100, 62, 28, 'b', 4) + T(105, 112, 'node') + T(105, 122, 'api', 'm');
|
||
s += R(186, 100, 50, 28, 'b', 4) + T(193, 112, 'slurm') + T(193, 122, 'queue', 'm');
|
||
s += R(250, 46, 34, 18, 'k', 3) + R(250, 70, 34, 18, 'k', 3) + R(250, 94, 34, 18, 'b', 3) + R(250, 118, 34, 18, 'sl d', 3);
|
||
s += T(256, 58, 'n01', 'w') + T(256, 82, 'n02', 'w') + T(256, 106, 'n03') + T(256, 130, '··', 'm');
|
||
s += Lp('M64 90H80V62H98', 'as') + Lp('M129 76V100', 'as') + Lp('M160 114H186', 'as') + Lp('M236 114H243V55H250M243 79H250M243 103H250', 's');
|
||
s += R(98, 160, 62, 20, 'b', 4) + T(105, 173, 'erda') + R(8, 160, 56, 20, 'b', 4) + T(15, 173, 'entra id') + Lp('M129 128V160M36 106V160', 's d');
|
||
return svg(s, 0, 'web app in front of Slurm, nodes on Oracle Cloud');
|
||
},
|
||
docs: function () {
|
||
var s = win(10, 10, 280, 170, 'overleaf · thesis.tex');
|
||
s += R(10, 26, 54, 154, 'g') + lines(18, 36, [32, 26, 36, 22, 30], 12);
|
||
s += T(74, 42, '\\section{Method}', 'm') + lines(74, 52, [96, 104, 80, 100, 60], 10) + T(74, 112, '\\begin{figure}', 'm') + lines(74, 122, [90, 70], 10);
|
||
s += R(190, 34, 90, 138, 'b', 2) + lines(200, 46, [50], 0, 'k') + lines(200, 58, [70, 66, 70, 52], 7) + R(200, 92, 70, 34, 'g') + lines(200, 134, [70, 62, 68, 40], 7);
|
||
s += R(122, 160, 56, 14, 'a', 7) + T(130, 170, '2 editing', 'w', ' style="font-size:7px"');
|
||
return svg(s, 0, 'shared editor with a page preview');
|
||
},
|
||
ops: function () {
|
||
var s = win(10, 10, 280, 170, 'grafana · research-services');
|
||
s += R(20, 34, 124, 62, 'b', 4) + T(28, 46, 'cpu, 24h', 'm') + Lp('M28 84L44 78L58 82L72 66L86 72L100 58L114 64L136 52', 's') + Lp('M28 60H136', 'sl d');
|
||
s += R(156, 34, 124, 62, 'b', 4) + T(164, 46, 'uptime', 'm') + T(164, 80, '99.97%', 'big', ' style="font-size:22px"');
|
||
s += R(20, 104, 124, 66, 'b', 4) + T(28, 116, 'containers', 'm');
|
||
[22, 30, 18, 34, 26, 30, 38, 20].forEach(function (h, i) { s += R(30 + i * 13, 162 - h, 8, h, 'gg'); });
|
||
s += R(156, 104, 124, 66, 'b', 4) + T(164, 116, 'checks', 'm');
|
||
['vault', 'overleaf', 'openproject', 'backup'].forEach(function (n, i) { s += C(168, 128 + i * 11, 2.6, i === 3 ? 'a' : 'k') + T(176, 131 + i * 11, n + (i === 3 ? ' late' : '')); });
|
||
return svg(s, 0, 'monitoring dashboard');
|
||
},
|
||
sah: function () {
|
||
var s = win(10, 10, 280, 170, 'prototype v0');
|
||
var pts = [[60, 70], [110, 50], [160, 80], [210, 56], [250, 100], [190, 130], [120, 120], [70, 140]];
|
||
[[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7], [7, 0], [2, 6], [2, 5]].forEach(function (e) { s += Lp('M' + pts[e[0]] + 'L' + pts[e[1]], e[0] === 2 && e[1] === 5 ? 'as' : 'sl'); });
|
||
pts.forEach(function (p, i) { s += C(p[0], p[1], i === 5 ? 9 : 7, i === 2 ? 'k' : i === 5 ? 'a' : 'b'); });
|
||
s += T(22, 172, 'base mechanic only · no art yet', 'm');
|
||
return svg(s, 0, 'early game prototype');
|
||
},
|
||
gacha: function () {
|
||
var s = win(10, 10, 280, 170, 'events · this week');
|
||
['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'].forEach(function (d, i) { s += T(74 + i * 30, 36, d, 'm'); s += Lp('M' + (70 + i * 30) + ' 42V172', 'sl'); });
|
||
var rows = [['game a', 70, 120, 0], ['game b', 100, 180, 1], ['game c', 70, 90, 0], ['game d', 160, 120, 0], ['game e', 130, 60, 0]];
|
||
rows.forEach(function (r, i) { var y = 50 + i * 24; s += T(20, y + 10, r[0], 'm') + R(r[1], y, r[2], 14, r[3] ? 'a' : (i % 2 ? 'k' : 'gg'), 3); });
|
||
s += Lp('M148 40V174', 'as') + T(151, 48, 'now', 'a', ' style="font-size:7px"');
|
||
s += T(184, 84, 'ends in 2d', 'w', ' style="font-size:7px"');
|
||
return svg(s, 0, 'event timeline for several games');
|
||
},
|
||
config: function () {
|
||
var s = R(10, 10, 280, 170, 'k', 6);
|
||
var L = [['$ ansible-playbook site.yml', 'w'], ['PLAY [all] ******************', 'm'], ['ok: [host-01]', 'w'], ['ok: [host-02]', 'w'], ['changed: [host-03]', 'a'], ['', ''], ['PLAY RECAP *****************', 'm'], ['host-01 ok=24 changed=0', 'w'], ['host-02 ok=24 changed=0', 'w'], ['host-03 ok=24 changed=1', 'a']];
|
||
L.forEach(function (l, i) { if (l[0]) s += T(22, 30 + i * 14.5, l[0], l[1]); });
|
||
s += R(22, 172, 6, 2, 'a');
|
||
return svg(s, 0, 'a playbook run in a terminal');
|
||
},
|
||
chess: function () {
|
||
var s = '', x0 = 22, y0 = 15, q = 20;
|
||
for (var r = 0; r < 8; r++) for (var c = 0; c < 8; c++) s += R(x0 + c * q, y0 + r * q, q, q, (r + c) % 2 ? 'gg' : 'b');
|
||
s += R(x0, y0, 160, 160, 's');
|
||
[[1, 6], [2, 6], [5, 6], [4, 7], [3, 4]].forEach(function (p) { s += C(x0 + p[0] * q + 10, y0 + p[1] * q + 10, 6, 'k'); });
|
||
[[2, 1], [5, 1], [6, 1], [4, 0], [4, 3]].forEach(function (p) { s += C(x0 + p[0] * q + 10, y0 + p[1] * q + 10, 6, 'b'); });
|
||
s += Lp('M210 70H270M262 64L270 70L262 76', 'as') + Lp('M270 110H210M218 104L210 110L218 116', 'as');
|
||
s += T(206, 54, 'board swaps', 'm') + T(206, 136, 'in 2 turns', 'a');
|
||
return svg(s, 0, 'chess board that swaps sides');
|
||
},
|
||
expenses: function () {
|
||
var s = win(10, 10, 280, 170, 'october');
|
||
s += T(22, 50, 'kr 8.420', 'big', ' style="font-size:22px"') + T(22, 62, 'spent this month', 'm');
|
||
var cats = [['rent', 120], ['food', 64], ['transport', 30], ['fun', 22], ['other', 14]];
|
||
cats.forEach(function (c, i) { var y = 78 + i * 18; s += T(22, y + 9, c[0], 'm') + R(80, y, c[1], 10, i === 1 ? 'a' : 'gg', 2); });
|
||
[40, 52, 46, 60, 50, 56].forEach(function (h, i) { s += R(222 + i * 10, 166 - h, 6, h, i === 5 ? 'k' : 'gg'); });
|
||
s += T(222, 54, 'by month', 'm');
|
||
return svg(s, 0, 'monthly expenses by category');
|
||
},
|
||
lolbot: function () {
|
||
var s = R(10, 10, 280, 170, 'b', 6) + R(10, 10, 56, 170, 'g', 0) + T(18, 28, '# draft', 'm') + T(18, 42, '# general', 'm');
|
||
s += C(84, 44, 8, 'gg') + T(98, 42, 'mads') + T(98, 54, '!wr ahri mid', 'm');
|
||
s += C(84, 82, 8, 'k') + T(98, 80, 'winrate-bot') + R(98, 88, 172, 58, 'b', 4) + R(98, 88, 3, 58, 'a');
|
||
s += T(108, 102, 'Ahri · mid · plat+') + T(108, 124, '51.3%', 'big', ' style="font-size:18px"') + T(160, 124, 'win rate', 'm') + T(108, 138, 'patch 14.x · 22k games', 'm');
|
||
s += R(76, 158, 200, 14, 'g', 4) + T(84, 168, 'message #draft', 'm');
|
||
return svg(s, 0, 'discord bot reply');
|
||
},
|
||
portfolio: function () {
|
||
var s = R(10, 10, 280, 170, 'k', 6) + C(46, 50, 14, 'gg') + lines(26, 74, [40, 32], 8) + lines(26, 98, [30, 30, 30, 30], 9);
|
||
for (var i = 0; i < 6; i++) { var x = 92 + (i % 3) * 64, y = 26 + Math.floor(i / 3) * 76; s += R(x, y, 56, 68, 'b', 3) + R(x + 4, y + 4, 48, 30, 'gg') + lines(x + 4, y + 42, [40, 30], 8); }
|
||
return svg(s, 0, 'the 2021 site: dark sidebar and project grid');
|
||
},
|
||
borlaug: function () {
|
||
var s = win(10, 10, 280, 170, 'tribute page');
|
||
s += T(150, 44, 'Dr. Norman Borlaug', 'big', ' text-anchor="middle" style="font-size:13px"') + T(150, 56, 'the man who saved a billion lives', 'm', ' text-anchor="middle"');
|
||
s += R(100, 64, 100, 56, 'g') + Lp('M100 64L200 120M200 64L100 120', 'sl');
|
||
s += lines(90, 130, [120, 110, 120, 90], 9);
|
||
return svg(s, 0, 'a simple tribute web page');
|
||
},
|
||
hypercache: function () {
|
||
var P2 = [[40, 50, .9], [100, 30, .8], [160, 60, .95], [230, 40, .7], [270, 100, .85], [200, 120, .2, 1], [130, 140, .75], [60, 130, .6], [110, 90, .9]];
|
||
var E2 = [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7], [7, 0], [8, 0], [8, 2], [8, 6], [2, 5], [8, 7]];
|
||
var s = '';
|
||
E2.forEach(function (e) { var a = P2[e[0]], b = P2[e[1]]; s += Lp('M' + a[0] + ' ' + a[1] + 'L' + b[0] + ' ' + b[1], a[3] || b[3] ? 'as d' : 'sl'); });
|
||
P2.forEach(function (p, i) { s += C(p[0], p[1], 6 + p[2] * 8, p[3] ? 'b' : i === 8 ? 'k' : 'b'); });
|
||
s += Lp('M200 120m-8 0a8 8 0 1 0 16 0a8 8 0 1 0 -16 0', 'as d');
|
||
s += T(212, 146, 'claims 12 chunks,', 'a') + T(212, 157, 'holds 3', 'a') + T(20, 178, 'size = reputation', 'm');
|
||
return svg(s, 0, 'peer network with reputation');
|
||
},
|
||
dashboard: function () {
|
||
var s = win(10, 10, 280, 170, 'student dashboard');
|
||
s += R(20, 34, 130, 136, 'b', 4) + T(28, 48, 'due this week', 'm');
|
||
['report draft', 'quiz 3', 'lab hand-in', 'reading'].forEach(function (t, i) { var y = 62 + i * 24; s += R(28, y - 7, 8, 8, i === 0 ? 'a' : 'b', 2) + T(42, y, t) + T(110, y, ['mon', 'tue', 'thu', 'fri'][i], 'm'); });
|
||
s += R(160, 34, 120, 70, 'b', 4) + T(168, 48, 'week 44', 'm');
|
||
for (var d = 0; d < 7; d++) s += R(168 + d * 15, 58, 11, 36, d === 1 ? 'k' : 'g', 2);
|
||
s += R(160, 112, 120, 58, 'b', 4) + T(168, 126, 'credits', 'm') + R(168, 140, 104, 8, 'g', 4) + R(168, 140, 70, 8, 'k', 4) + T(168, 162, '120 / 180 ECTS', 'm');
|
||
return svg(s, 0, 'dashboard with deadlines and calendar');
|
||
},
|
||
rowing: function () {
|
||
var s = '<ellipse class="b" cx="150" cy="95" rx="22" ry="70"/>' + Lp('M150 30V160', 'sl');
|
||
s += Lp('M130 92L40 60', 'as') + Lp('M170 92L260 60', 'as') + C(130, 92, 4, 'k') + C(170, 92, 4, 'k');
|
||
s += Lp('M40 60Q30 96 44 128', 's d') + Lp('M260 60Q270 96 256 128', 's d');
|
||
s += T(12, 50, 'left oar', 'm') + T(236, 50, 'right oar', 'm') + T(12, 150, 'angle → arduino', 'm') + T(200, 150, 'serial → unity', 'm');
|
||
s += R(118, 170, 64, 14, 'k', 3) + T(124, 180, 'stroke 0.92', 'w', ' style="font-size:7.5px"');
|
||
return svg(s, 0, 'two real oars on a boat');
|
||
},
|
||
vrclass: function () {
|
||
var s = R(10, 10, 280, 170, 'b', 6);
|
||
s += Lp('M10 120L120 70H180L290 120', 'sl') + Lp('M120 70V10M180 70V10', 'sl');
|
||
for (var i = 0; i < 6; i++) s += Lp('M' + (120 + i * 12) + ' 70L' + (10 + i * 56) + ' 180', 'sl');
|
||
s += R(128, 34, 44, 26, 'g') + T(134, 50, 'slides', 'm');
|
||
[[60, 140], [110, 140], [190, 140], [240, 140], [95, 110], [150, 110], [205, 110]].forEach(function (p, i) { s += R(p[0] - 14, p[1], 28, 8, 'gg', 1) + C(p[0], p[1] - 7, 5, i === 5 ? 'a' : 'b'); });
|
||
s += C(150, 76, 6, 'k') + T(160, 80, 'teacher', 'm');
|
||
return svg(s, 0, 'a shared virtual classroom');
|
||
},
|
||
censor: function () {
|
||
var s = R(10, 120, 280, 60, 'g') + Lp('M10 120H290', 's');
|
||
s += '<g transform="rotate(-4 90 80)">' + R(40, 40, 100, 120, 'b', 2) + lines(52, 56, [70, 60, 76, 50, 70, 66, 40], 10) + R(52, 76, 60, 7, 'k') + R(52, 106, 44, 7, 'k') + '</g>';
|
||
s += '<g transform="rotate(5 210 80)">' + R(160, 36, 100, 120, 'b', 2) + lines(172, 52, [70, 62, 74, 56, 70], 10) + R(176, 112, 70, 22, 'as') + T(186, 127, 'APPROVED?', 'a') + '</g>';
|
||
return svg(s, 0, 'documents on a censor\'s desk');
|
||
},
|
||
breakout: function () {
|
||
var s = R(10, 10, 280, 170, 'k', 6);
|
||
for (var r = 0; r < 4; r++) for (var c = 0; c < 9; c++) if (!(r === 3 && (c === 4 || c === 5)) && !(r === 2 && c === 5)) s += R(22 + c * 29, 24 + r * 12, 25, 8, r === 0 ? 'a' : 'gg', 1);
|
||
s += Lp('M150 160L196 110L170 76', 's d') + '<path d="M150 160L196 110L170 76" style="stroke:#f4f4f1;stroke-dasharray:3 3;fill:none"/>' + C(170, 76, 4, 'b') + R(124, 164, 52, 6, 'b', 3);
|
||
s += T(22, 150, 'score 1420', 'w') + T(232, 150, 'lives 2', 'w');
|
||
return svg(s, 0, 'breakout game');
|
||
}
|
||
};
|
||
|
||
/* ---------- big figures for a few projects (660 x 380) ---------- */
|
||
var ARCH = [
|
||
['br', 16, 160, 112, 46, 'researcher', 'browser', 'Researchers log in with their AU account and submit jobs from a form. No SSH, no batch scripts.'],
|
||
['web', 190, 110, 120, 46, 'web app', 'React', 'React front end: choose a job, choose data, follow its progress and log.'],
|
||
['api', 190, 200, 120, 46, 'api', 'Node.js', 'Node back end: keeps job state and talks to Slurm. Scales with load, like the front end.'],
|
||
['ent', 16, 310, 112, 42, 'entra id', 'OAuth', 'Login through OAuth with Entra ID, so every AU account just works.'],
|
||
['erda', 190, 310, 120, 42, 'erda', 'AU storage', 'ERDA, AU\'s own storage: input data in, results back out, no copying by hand.'],
|
||
['slurm', 360, 200, 110, 46, 'slurm ctl', 'queue', 'Slurm queues the jobs and asks for more nodes when the queue grows.'],
|
||
['n1', 520, 92, 112, 40, 'node 01', 'running', 'A compute node on Oracle Cloud, started because jobs were waiting.'],
|
||
['n2', 520, 143, 112, 40, 'node 02', 'running', 'A compute node on Oracle Cloud, started because jobs were waiting.'],
|
||
['n3', 520, 194, 112, 40, 'node 03', 'idle → stop', 'Idle nodes are stopped again, so nothing is paid for while no one is computing.'],
|
||
['n4', 520, 245, 112, 40, 'node ··', 'on demand', 'Dashed nodes only exist while they are needed.', 1]
|
||
];
|
||
var PEERS = [[90, 70, .91, 12, 12], [190, 170, .84, 11, 11], [70, 260, .77, 9, 9], [300, 80, .95, 14, 14], [320, 240, .62, 8, 9], [430, 150, .88, 12, 12], [450, 290, .18, 3, 12, 1], [540, 70, .80, 10, 10], [580, 220, .72, 9, 9], [220, 310, .35, 4, 10, 1]];
|
||
var EDGES = [[0, 1], [0, 3], [1, 2], [1, 3], [1, 4], [2, 9], [3, 5], [3, 7], [4, 5], [4, 6], [4, 9], [5, 7], [5, 8], [6, 8], [7, 8], [6, 9]];
|
||
var BIG = {
|
||
compute: function () {
|
||
var s = R(170, 64, 476, 236, 'sl d', 8) + T(184, 84, 'oracle cloud · web stack and compute scale up and down', 'm', ' style="font-size:10.5px"');
|
||
s += Lp('M128 183H158V133H190', 'as') + Lp('M250 156V200', 'as') + Lp('M310 223H360', 'as') + Lp('M470 223H495V112H520M495 163H520M495 214H520', 's') + Lp('M495 265H520', 's d');
|
||
s += Lp('M250 246V310', 's d') + Lp('M72 206V310', 's d');
|
||
ARCH.forEach(function (n) {
|
||
s += '<g class="hit" tabindex="0" data-note="' + esc(n[7]) + '" data-name="' + n[5] + '">' + R(n[1], n[2], n[3], n[4], n[8] ? 'b d' : 'b', 5) +
|
||
'<text x="' + (n[1] + 11) + '" y="' + (n[2] + 17) + '" style="font-size:11.5px">' + n[5] + '</text><text class="m" x="' + (n[1] + 11) + '" y="' + (n[2] + n[4] - 8) + '" style="font-size:10px">' + n[6] + '</text></g>';
|
||
});
|
||
s += T(318, 214, 'submit', 'm', ' style="font-size:10px"') + T(16, 370, 'aarhus university services', 'm', ' style="font-size:10px"');
|
||
return { svg: svg(s, '0 0 660 380', 'Architecture of the compute job platform'), cap: 'Point at a box. Orange is the path a job takes.', tag: 'fig. 01 · ' + PH('simplified') };
|
||
},
|
||
hypercache: function () {
|
||
var s = '';
|
||
EDGES.forEach(function (e) { var a = PEERS[e[0]], b = PEERS[e[1]]; s += '<line class="e" data-a="' + e[0] + '" data-b="' + e[1] + '" x1="' + a[0] + '" y1="' + a[1] + '" x2="' + b[0] + '" y2="' + b[1] + '"/>'; });
|
||
PEERS.forEach(function (p, i) { s += '<g class="peer' + (p[5] ? ' bad' : '') + '" tabindex="0" data-i="' + i + '"><circle cx="' + p[0] + '" cy="' + p[1] + '" r="' + (12 + p[2] * 12) + '"/><text x="' + p[0] + '" y="' + (p[1] + 4) + '" style="font-size:11px">' + String(i + 1).padStart(2, '0') + '</text></g>'; });
|
||
s += T(16, 368, 'circle size = reputation · dashed = claims more than it holds', 'm', ' style="font-size:10.5px"');
|
||
return { svg: svg(s, '0 0 660 380', 'Peer network with reputation scores'), cap: 'Point at a peer to see whether you should trust it.', tag: 'fig. 12 · ' + PH('illustrative numbers') };
|
||
},
|
||
gacha: function () {
|
||
var s = R(4, 4, 652, 372, 'b', 10) + Lp('M4 40H656', 'sl') + T(20, 27, 'Event Tracker', 'big', ' style="font-size:14px"') + T(130, 27, 'all games · week 45 · times in your zone', 'm', ' style="font-size:10px"');
|
||
var days = ['mon 3', 'tue 4', 'wed 5', 'thu 6', 'fri 7', 'sat 8', 'sun 9'];
|
||
days.forEach(function (d, i) { s += T(146 + i * 72, 62, d, 'm', ' style="font-size:10px"') + Lp('M' + (140 + i * 72) + ' 70V350', 'sl'); });
|
||
var ev = [['Game A', 'Lantern rite', 140, 200, 'gg'], ['Game A', 'Weapon banner', 284, 280, 'k'], ['Game B', 'Summer event', 140, 330, 'a'], ['Game C', 'Login bonus', 212, 144, 'gg'], ['Game D', 'Limited raid', 356, 130, 'k'], ['Game E', 'Anniversary', 428, 214, 'gg'], ['Game E', 'Shop reset', 140, 60, 'gg']];
|
||
ev.forEach(function (e, i) { var y = 82 + i * 38; s += T(20, y + 17, e[0], '', ' style="font-size:11px"') + R(e[2], y, e[3], 26, e[4], 4) + T(e[2] + 10, y + 17, e[1], e[4] === 'gg' ? '' : 'w', ' style="font-size:10.5px"'); });
|
||
s += Lp('M318 66V356', 'as') + R(296, 352, 44, 16, 'a', 3) + T(305, 363, 'now', 'w', ' style="font-size:9.5px"');
|
||
s += T(384, 175, 'ends in 2d 4h', 'w', ' style="font-size:10px;opacity:.8"');
|
||
return { svg: svg(s, '0 0 660 380', 'Event timeline screen'), cap: 'Five games, one week, one time zone.', tag: 'fig. 05 · ' + PH('drawn, not a screenshot') };
|
||
},
|
||
rowing: function () {
|
||
var s = '<ellipse class="b" cx="120" cy="190" rx="34" ry="130"/>' + Lp('M120 64V316', 'sl') + Lp('M94 186L14 120', 'as') + Lp('M146 186L226 120', 'as') + C(94, 186, 5, 'k') + C(146, 186, 5, 'k');
|
||
s += T(20, 104, 'oar + sensor', 'm', ' style="font-size:10px"') + T(180, 104, '× 2', 'm', ' style="font-size:10px"');
|
||
var box = function (x, y, a, b) { return R(x, y, 110, 52, 'b', 5) + T(x + 12, y + 22, a, '', ' style="font-size:12px"') + T(x + 12, y + 40, b, 'm', ' style="font-size:10px"'); };
|
||
s += box(280, 164, 'arduino', 'reads both oars') + box(420, 164, 'serial', 'usb, per stroke') + box(540, 164, 'unity', 'C# · boat');
|
||
s += Lp('M226 160Q250 190 280 190', 's d') + Lp('M390 190H420M530 190H540', 's');
|
||
s += R(280, 260, 370, 80, 'g', 5) + T(294, 282, 'stroke', 'm', ' style="font-size:10px"');
|
||
s += Lp('M294 326C320 326 330 292 360 292S400 326 430 326S470 292 500 292S540 326 570 326S610 292 636 292', 'as');
|
||
s += T(280, 140, 'sensor type: to confirm<title>placeholder</title>', 'm', ' style="font-size:10px;text-decoration:underline dotted"');
|
||
return { svg: svg(s, '0 0 660 380', 'Wiring from oars to Unity'), cap: 'Two angles in, one stroke out, the boat moves as hard as you row.', tag: 'fig. 14 · ' + PH('photos to come') };
|
||
},
|
||
config: function () {
|
||
var h = '<div class="ba"><div><h5><b>before</b> · ' + PH('notes.txt') + '</h5><ul>' +
|
||
'<li><span>host-01</span><span>ssh in, git pull, docker compose up -d</span></li>' +
|
||
'<li><span>host-02</span><span>same, but its compose file is older</span></li>' +
|
||
'<li><span>host-02</span><span class="x">forgot the .env, down for a day</span></li>' +
|
||
'<li><span>host-03</span><span>"temporary" container from 2023</span></li>' +
|
||
'<li><span>new box</span><span class="x">which of these did I do last time?</span></li></ul></div>' +
|
||
'<div><h5><b>after</b> · config/</h5><pre><b>inventory/</b> hosts.yml\n<b>roles/</b> base · docker · backups\n<b>stacks/</b> one compose file per service\n<b>site.yml</b></pre>' +
|
||
'<div class="term">$ ansible-playbook site.yml<br>host-01 <span class="ok">ok=24</span> changed=0<br>host-02 <span class="ok">ok=24</span> changed=0<br>host-03 <span class="ok">ok=23</span> <span class="ch">changed=1</span></div></div></div>';
|
||
return { html: h, cap: 'One command brings every box to the state git describes.', tag: 'fig. 06 · ' + PH('details') };
|
||
}
|
||
};
|
||
|
||
function figureFor(p) {
|
||
var b = BIG[p.id] && BIG[p.id]();
|
||
if (b) return '<div class="fig" data-fig="' + p.id + '"><div class="cv-in">' + (b.svg || b.html) + '</div><div class="cap"><span class="live">' + b.cap + '</span><span>' + b.tag + '</span></div></div>';
|
||
return '<div class="fig small"><div class="cv-in">' + MINI[p.id]() + '</div><div class="cap"><span class="live">' + p.fig + '</span><span>fig. ' + p.no + ' · ' + PH('drawn stand-in') + '</span></div></div>';
|
||
}
|
||
|
||
/* ---------- rows ---------- */
|
||
var rowsEl = document.getElementById('rows');
|
||
rowsEl.innerHTML = PROJECTS.map(function (p) {
|
||
return '<article class="row" id="p-' + p.id + '" data-id="' + p.id + '">' +
|
||
'<button class="rh" aria-expanded="false" aria-controls="d-' + p.id + '"><span class="no">' + p.no + '</span><span class="nm">' + p.name + '</span><span class="wh">' + strip(p.where).split(' · ')[0] + '</span><span class="yr">' + p.yr + '</span><span class="aiw">' + aiMk(p.ai) + '</span><span class="pm" aria-hidden="true"></span></button>' +
|
||
'<div class="rd" id="d-' + p.id + '"><div class="in"></div></div></article>';
|
||
}).join('');
|
||
|
||
function detailHTML(p, idx) {
|
||
var rel = p.rel.map(P).filter(Boolean);
|
||
var nxt = PROJECTS[(idx + 1) % PROJECTS.length];
|
||
var s = '<div class="det">';
|
||
s += '<div class="story"><span class="where">' + p.where + ' · ' + p.yr + '</span><p class="lead">' + p.one + '</p>' +
|
||
'<div class="st"><span class="k">why</span><p>' + p.problem + '</p></div>' +
|
||
'<div class="st"><span class="k">how</span><p>' + p.approach + '</p></div>' +
|
||
'<div class="st"><span class="k">interesting</span><p>' + p.interesting + '</p></div></div>';
|
||
s += '<div class="figw">' + figureFor(p) + '</div>';
|
||
s += '<div class="facts">' +
|
||
'<div><h4>stack</h4><div class="stack">' + p.stack.map(function (t) { return '<span>' + t + '</span>'; }).join('') + '</div></div>' +
|
||
'<div><h4>how it was made</h4><ul class="parts">' + p.parts.map(function (x) { return '<li><span>' + x[0] + '</span>' + aiMk(x[1]) + '</li>'; }).join('') + '</ul></div>' +
|
||
'<div><h4>links</h4><ul>' + (p.links.length ? p.links.map(function (l) { return l[1] ? '<li><a class="go" href="' + l[1] + '"' + (l[1] === '#' ? ' title="placeholder link"' : '') + '>' + l[0] + ' <span>↗</span></a></li>' : '<li class="none">' + l[0] + '</li>'; }).join('') : '<li class="none">nothing public</li>') + '</ul></div>' +
|
||
'<div><h4>related</h4><ul>' + p.posts.map(function (id) { var w = POST(id); return '<li><a class="go" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">' + w.t + '</span></a></li>'; }).join('') +
|
||
rel.map(function (r) { return '<li><a class="go" href="#' + r.id + '"><span class="lbl">' + r.no + '</span> ' + r.name + '</a></li>'; }).join('') + '</ul></div>' +
|
||
'</div>';
|
||
s += '<div class="nextp"><button data-close>close ×</button><a href="#' + nxt.id + '">next: ' + nxt.no + ' ' + nxt.name + ' →</a></div>';
|
||
return s + '</div>';
|
||
}
|
||
|
||
/* ---------- open / close, one at a time, hash follows ---------- */
|
||
var openId = null;
|
||
function rowOf(id) { return document.getElementById('p-' + id); }
|
||
function headerH() { return document.querySelector('.bar').offsetHeight; }
|
||
function closeRow(id, instant) {
|
||
var r = rowOf(id); if (!r) return;
|
||
if (instant) r.classList.add('instant');
|
||
r.classList.remove('open'); r.querySelector('.rh').setAttribute('aria-expanded', 'false');
|
||
if (instant) { r.offsetHeight; r.classList.remove('instant'); }
|
||
}
|
||
function open(id, opts) {
|
||
opts = opts || {};
|
||
var r = rowOf(id); if (!r || r.hidden) return;
|
||
if (openId === id) { if (opts.toggle) { closeRow(id); openId = null; setHash(''); } return; }
|
||
hidePv();
|
||
if (openId) {
|
||
var old = rowOf(openId), oldAbove = old.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_FOLLOWING;
|
||
var before = old.offsetHeight;
|
||
closeRow(openId, true);
|
||
if (oldAbove) window.scrollBy(0, old.offsetHeight - before);
|
||
}
|
||
var inEl = r.querySelector('.in');
|
||
if (!inEl.firstChild) { inEl.innerHTML = detailHTML(P(id), PROJECTS.indexOf(P(id))); wireFig(r); }
|
||
r.offsetHeight;
|
||
r.classList.add('open'); r.querySelector('.rh').setAttribute('aria-expanded', 'true');
|
||
openId = id; setHash(id);
|
||
var top = r.getBoundingClientRect().top - headerH();
|
||
if (opts.scroll || top < 0 || top > window.innerHeight * .45) window.scrollTo({ top: window.scrollY + top - 8, behavior: opts.jump ? 'auto' : 'smooth' });
|
||
}
|
||
function setHash(id) { history.replaceState(null, '', id ? '#' + id : location.pathname + location.search); }
|
||
rowsEl.addEventListener('click', function (e) {
|
||
var h = e.target.closest('.rh'); if (h) { open(h.parentNode.dataset.id, { toggle: true }); return; }
|
||
if (e.target.closest('[data-close]')) { var id = openId; closeRow(id); openId = null; setHash(''); var r = rowOf(id); if (r.getBoundingClientRect().top < headerH()) window.scrollTo({ top: window.scrollY + r.getBoundingClientRect().top - headerH() - 8, behavior: 'smooth' }); r.querySelector('.rh').focus({ preventScroll: true }); }
|
||
});
|
||
// any in-page link to a project id opens that row
|
||
document.addEventListener('click', function (e) {
|
||
var a = e.target.closest('a[href^="#"]'); if (!a) return;
|
||
var id = a.getAttribute('href').slice(1);
|
||
if (P(id)) { e.preventDefault(); if (rowOf(id).hidden) setPref('all'); open(id, { scroll: true }); }
|
||
});
|
||
window.addEventListener('hashchange', function () { var id = location.hash.slice(1); if (P(id)) open(id, { scroll: true }); });
|
||
|
||
/* ---------- figure interactions ---------- */
|
||
function wireFig(r) {
|
||
var f = r.querySelector('.fig'); if (!f) return;
|
||
var cap = f.querySelector('.live'), base = cap.innerHTML;
|
||
if (f.dataset.fig === 'compute') {
|
||
f.querySelectorAll('.hit').forEach(function (g) {
|
||
var on = function () { cap.innerHTML = '<b>' + g.dataset.name + '</b> · ' + g.dataset.note; };
|
||
g.addEventListener('mouseenter', on); g.addEventListener('focus', on);
|
||
g.addEventListener('mouseleave', function () { cap.innerHTML = base; });
|
||
});
|
||
}
|
||
if (f.dataset.fig === 'hypercache') {
|
||
f.querySelectorAll('.peer').forEach(function (g) {
|
||
var i = +g.dataset.i, p = PEERS[i];
|
||
var on = function () {
|
||
f.querySelectorAll('line.e').forEach(function (l) { l.classList.toggle('on', +l.dataset.a === i || +l.dataset.b === i); });
|
||
cap.innerHTML = '<b>peer ' + String(i + 1).padStart(2, '0') + '</b> · reputation ' + p[2].toFixed(2) + ' · holds ' + p[3] + ' of ' + p[4] + ' chunks · ' + (p[2] < .5 ? '<span style="color:var(--acc-ink)">don\'t store here</span>' : 'safe to store here');
|
||
};
|
||
g.addEventListener('mouseenter', on); g.addEventListener('focus', on);
|
||
g.addEventListener('mouseleave', function () { cap.innerHTML = base; f.querySelectorAll('line.e.on').forEach(function (l) { l.classList.remove('on'); }); });
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ---------- floating hover preview ---------- */
|
||
var pv = document.getElementById('pv'), pvf = document.getElementById('pvf'), pvId = null;
|
||
function hidePv() { pv.classList.remove('on'); pvId = null; }
|
||
function placePv(e) {
|
||
var w = 302, h = pv.offsetHeight || 240, x = e.clientX + 28, y = e.clientY - h / 2;
|
||
if (x + w > window.innerWidth - 60) x = e.clientX - w - 28;
|
||
y = Math.max(headerH() + 8, Math.min(y, window.innerHeight - h - 12));
|
||
pv.style.setProperty('--x', x + 'px'); pv.style.setProperty('--y', y + 'px');
|
||
}
|
||
{
|
||
rowsEl.addEventListener('pointermove', function (e) {
|
||
if (e.pointerType !== 'mouse') return;
|
||
var h = e.target.closest('.rh');
|
||
if (!h || h.parentNode.classList.contains('open')) { if (pvId) hidePv(); return; }
|
||
var id = h.parentNode.dataset.id;
|
||
if (id !== pvId) {
|
||
var p = P(id); pvf.innerHTML = MINI[id]();
|
||
document.getElementById('pvc1').textContent = p.no + ' · ' + strip(p.fig).replace(/^(screenshot|photo|diagram|still from the video|sketch): ?/, '').replace(/^still from the video$/, 'video still');
|
||
document.getElementById('pvc2').textContent = AILONG[p.ai];
|
||
var first = !pv.classList.contains('on');
|
||
if (first) { pv.style.transition = 'none'; placePv(e); pv.offsetHeight; pv.style.transition = ''; }
|
||
pvId = id; pv.classList.add('on');
|
||
}
|
||
placePv(e);
|
||
});
|
||
rowsEl.addEventListener('pointerleave', hidePv);
|
||
window.addEventListener('scroll', function () { if (pvId) hidePv(); }, { passive: true });
|
||
}
|
||
|
||
/* ---------- AI filter ---------- */
|
||
var PREFNAME = { nofull: 'not fully by AI', hand: 'by hand only' };
|
||
function setPref(v) {
|
||
PREF = v;
|
||
document.querySelectorAll('.seg button').forEach(function (b) { b.setAttribute('aria-pressed', b.dataset.pref === v ? 'true' : 'false'); });
|
||
var shown = 0;
|
||
PROJECTS.forEach(function (p) { var r = rowOf(p.id), ok = allowed(p); r.hidden = !ok; if (ok) shown++; if (!ok && openId === p.id) { closeRow(p.id, true); openId = null; setHash(''); } });
|
||
var hidden = PROJECTS.length - shown;
|
||
document.getElementById('idxN').textContent = shown + (hidden ? ' of ' + PROJECTS.length : '') + ' projects';
|
||
document.getElementById('navN').textContent = shown;
|
||
document.getElementById('fnote').innerHTML = hidden ? hidden + ' hidden by "' + PREFNAME[v] + '". The numbers keep their gaps so nothing quietly moves. <button style="text-decoration:underline" onclick="void 0" data-showall>show all</button>' : '';
|
||
legend();
|
||
}
|
||
document.querySelectorAll('.seg button').forEach(function (b) { b.addEventListener('click', function () { setPref(b.dataset.pref); }); });
|
||
document.getElementById('fnote').addEventListener('click', function (e) { if (e.target.closest('[data-showall]')) setPref('all'); });
|
||
function legend() {
|
||
var n = { none: 0, some: 0, full: 0, unk: 0 }; PROJECTS.forEach(function (p) { n[p.ai]++; });
|
||
document.getElementById('legend').innerHTML = ['none', 'some', 'full', 'unk'].filter(function (k) { return n[k]; }).map(function (k) { return aiMk(k) + '<span style="color:var(--mute);margin-left:-12px">' + n[k] + '</span>'; }).join('');
|
||
}
|
||
|
||
/* ---------- writing ---------- */
|
||
var REL = { agent: 'gacha', slurm: 'compute', vault: 'ops', ansible: 'config', thesis: 'hypercache' };
|
||
document.getElementById('wl').innerHTML = POSTS.map(function (w) {
|
||
var p = P(REL[w.id]);
|
||
return '<li><a href="https://blog.lucaswinther.info"><span class="d ph" title="placeholder">' + w.d.slice(0, 7).replace('-', '·') + '</span><span class="t"><span class="ph" title="placeholder title">' + w.t + '</span><span class="bl">' + w.blurb + '</span></span><span class="rel">on ' + p.no + ' ' + p.name.toLowerCase() + '</span><span class="tg">' + w.tag + '</span><span class="e"></span><span class="ar">↗</span></a></li>';
|
||
}).join('');
|
||
|
||
/* ---------- CV sheet ---------- */
|
||
var cvBtn = document.getElementById('cvBtn'), cv = document.getElementById('cv');
|
||
function cvSet(on) { cv.classList.toggle('on', on); cvBtn.setAttribute('aria-expanded', on ? 'true' : 'false'); }
|
||
cvBtn.addEventListener('click', function (e) { e.stopPropagation(); cvSet(!cv.classList.contains('on')); });
|
||
document.addEventListener('click', function (e) { if (cv.classList.contains('on') && !e.target.closest('#cv')) cvSet(false); });
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key !== 'Escape') return;
|
||
if (cv.classList.contains('on')) { cvSet(false); cvBtn.focus(); return; }
|
||
if (openId) { var r = rowOf(openId); closeRow(openId); openId = null; setHash(''); r.querySelector('.rh').focus({ preventScroll: true }); }
|
||
});
|
||
|
||
setPref('all');
|
||
var h0 = location.hash.slice(1); if (P(h0)) open(h0, { scroll: true, jump: true });
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|