Six rounds of mockups with the owner on 2026-11-12; K v2 picked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1061 lines
87 KiB
HTML
1061 lines
87 KiB
HTML
<!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> 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 & 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 & 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 & 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 & website · <span class="ph" title="placeholder">since ?</span> · ongoing</div>
|
||
<div>Lystrup Genbrugser</div><div class="r">IT & 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 & 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 & 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, '<') + '"' : '') + '.</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>
|