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>
373 lines
25 KiB
HTML
373 lines
25 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: Index of works</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=Newsreader:ital,opsz,wght@0,6..72,300..600;1,6..72,300..500&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root{
|
||
--paper:#EEEAE1; --paper-2:#E6E1D5; --ink:#1C1A16; --muted:#6E685C; --rule:#CFC8BA;
|
||
--rust:#B0431C; --serif:"Newsreader",Georgia,serif; --mono:"IBM Plex Mono",ui-monospace,monospace;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html{background:var(--paper)}
|
||
body{margin:0;color:var(--ink);font-family:var(--serif);font-size:17px;line-height:1.45;font-optical-sizing:auto}
|
||
a{color:inherit;text-decoration:none;border-bottom:1px solid var(--rule);transition:border-color .15s,color .15s}
|
||
a:hover{border-color:var(--rust);color:var(--rust)}
|
||
.ph{text-decoration:underline dotted var(--muted);text-underline-offset:3px;cursor:help}
|
||
.mono{font-family:var(--mono);font-size:12.5px;letter-spacing:.01em}
|
||
.wrap{max-width:1180px;margin:0 auto;padding:0 32px}
|
||
|
||
/* mock note */
|
||
.mock-note{position:fixed;top:10px;right:10px;z-index:9999;max-width:290px;background:#fff;color:#222;border:1px solid #aaa;padding:8px 30px 8px 10px;font:12px/1.4 system-ui,sans-serif}
|
||
.mock-note b{display:block}
|
||
.mock-note button{position:absolute;top:4px;right:6px;border:0;background:none;font-size:16px;cursor:pointer;color:#555}
|
||
|
||
/* masthead */
|
||
header.mast{padding:40px 0 0}
|
||
.mast-top{display:flex;justify-content:space-between;align-items:baseline;gap:24px;border-bottom:1px solid var(--ink);padding-bottom:10px}
|
||
.mast h1{font-weight:400;font-size:44px;line-height:1;margin:0;letter-spacing:-.01em}
|
||
.mast h1 i{font-weight:300;color:var(--muted)}
|
||
.mast-links{display:flex;gap:18px;flex-wrap:wrap}
|
||
.colophon{display:grid;grid-template-columns:1.2fr 1.2fr 1.3fr 1fr;border-bottom:1px solid var(--rule)}
|
||
.colophon > div{padding:14px 18px 16px 0}
|
||
.colophon > div + div{padding-left:18px;border-left:1px solid var(--rule)}
|
||
.colophon .k{display:block;color:var(--muted);margin-bottom:4px}
|
||
.colophon p{margin:0;font-size:16px}
|
||
.archive-line{display:flex;justify-content:space-between;gap:16px;padding:9px 0;border-bottom:1px solid var(--rule);color:var(--muted)}
|
||
.archive-line a{color:var(--ink)}
|
||
|
||
/* controls */
|
||
.controls{position:sticky;top:0;z-index:5;background:var(--paper);display:flex;flex-wrap:wrap;align-items:center;gap:10px 28px;padding:14px 0 12px;border-bottom:1px solid var(--ink)}
|
||
.search{display:flex;align-items:center;gap:8px;flex:1 1 220px}
|
||
.search input{flex:1;background:transparent;border:0;border-bottom:1px solid var(--rule);font:inherit;font-size:17px;padding:2px 0;color:var(--ink);outline:none}
|
||
.search input:focus{border-color:var(--rust)}
|
||
kbd{font-family:var(--mono);font-size:11px;border:1px solid var(--rule);border-bottom-width:2px;padding:0 4px;border-radius:2px;color:var(--muted);background:var(--paper-2)}
|
||
.ai-filter{display:flex;align-items:center;gap:4px;flex-wrap:wrap}
|
||
.ai-filter .lbl{color:var(--muted);margin-right:6px}
|
||
.ai-filter button{font:inherit;font-family:var(--mono);font-size:12.5px;background:none;border:0;padding:3px 7px;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent}
|
||
.ai-filter button:hover{color:var(--ink)}
|
||
.ai-filter button[aria-pressed="true"]{color:var(--ink);border-bottom-color:var(--rust)}
|
||
.ai-filter sup{font-size:9px;margin-left:2px}
|
||
.glyph{display:inline-block;width:11px;height:11px;border:1.3px solid currentColor;border-radius:50%;vertical-align:-1px;margin-right:4px}
|
||
.glyph.some{background:linear-gradient(90deg,currentColor 50%,transparent 50%)}
|
||
.glyph.full{background:currentColor}
|
||
|
||
/* table */
|
||
table{width:100%;border-collapse:collapse}
|
||
thead th{text-align:left;font-weight:400;padding:10px 10px 8px 0;border-bottom:1px solid var(--rule);white-space:nowrap}
|
||
thead button{font:inherit;font-family:var(--mono);font-size:11.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);background:none;border:0;padding:0;cursor:pointer}
|
||
thead button:hover,thead button.on{color:var(--ink)}
|
||
thead button.on::after{content:" ↓";color:var(--rust)}
|
||
thead button.on.asc::after{content:" ↑"}
|
||
tbody tr.row{cursor:pointer;transition:background .12s}
|
||
tbody tr.row td{padding:9px 10px 9px 0;border-bottom:1px solid var(--rule);vertical-align:baseline}
|
||
tbody tr.row:hover{background:var(--paper-2)}
|
||
tbody tr.row.cur{background:var(--paper-2);box-shadow:inset 3px 0 0 var(--rust)}
|
||
tbody tr.row.cur td:first-child{padding-left:10px}
|
||
td.no{width:44px;color:var(--muted)}
|
||
td.yr{width:64px}
|
||
td.title{font-size:19px}
|
||
td.title em{font-style:italic;color:var(--muted);font-size:15px;margin-left:6px}
|
||
td.kind{color:var(--muted);font-style:italic;width:120px}
|
||
td.stack{color:var(--muted)}
|
||
td.ai{width:120px;white-space:nowrap}
|
||
td.lk{width:150px;text-align:right;white-space:nowrap}
|
||
td.lk a{margin-left:10px}
|
||
tr.row[data-ai="full"] td.ai{color:var(--rust)}
|
||
tr.detail td{padding:0;border-bottom:1px solid var(--ink)}
|
||
.case{display:grid;grid-template-columns:44px 230px 1fr 1fr;gap:0 24px;padding:18px 0 22px}
|
||
.fig{grid-column:2;height:150px;border:1px solid var(--rule);position:relative;background:
|
||
repeating-linear-gradient(135deg,transparent 0 7px,rgba(28,26,22,.06) 7px 8px),var(--paper-2)}
|
||
.fig span{position:absolute;left:8px;bottom:6px;color:var(--muted)}
|
||
.figcap{grid-column:2;color:var(--muted);margin-top:6px}
|
||
.case h3{margin:0 0 6px;font-weight:400;font-size:22px}
|
||
.case p{margin:0 0 10px}
|
||
.case dl{margin:0;display:grid;grid-template-columns:110px 1fr;gap:4px 10px;font-size:15px}
|
||
.case dt{color:var(--muted);font-family:var(--mono);font-size:11.5px;text-transform:uppercase;letter-spacing:.05em;padding-top:3px}
|
||
.case dd{margin:0}
|
||
.split-bar{display:flex;height:8px;margin:4px 0 2px;border:1px solid var(--ink)}
|
||
.split-bar i{display:block;height:100%}
|
||
.split-bar .h{background:var(--ink)}
|
||
.split-bar .a{background:repeating-linear-gradient(90deg,var(--rust) 0 2px,transparent 2px 4px)}
|
||
.empty{padding:30px 0;color:var(--muted);font-style:italic;display:none}
|
||
.count{color:var(--muted)}
|
||
|
||
/* lower indexes */
|
||
.lower{display:grid;grid-template-columns:1.25fr 1fr;gap:56px;padding:56px 0 24px}
|
||
h2{font-weight:400;font-size:28px;margin:0 0 4px;letter-spacing:-.005em}
|
||
h2 + .mono{color:var(--muted);display:block;margin-bottom:14px}
|
||
.posts li,.cv li{list-style:none;display:grid;grid-template-columns:86px 1fr auto;gap:12px;padding:8px 0;border-bottom:1px solid var(--rule);align-items:baseline}
|
||
.posts,.cv{margin:0;padding:0;border-top:1px solid var(--ink)}
|
||
.posts .t{font-size:18px}
|
||
.posts .tag{color:var(--muted)}
|
||
.posts li:hover .t{color:var(--rust)}
|
||
.cv li{grid-template-columns:96px 1fr}
|
||
.cv .what{font-size:17px}
|
||
.cv .what small{display:block;color:var(--muted);font-size:14.5px}
|
||
.more{display:inline-block;margin-top:12px}
|
||
|
||
footer{padding:28px 0 60px;border-top:1px solid var(--ink);margin-top:28px;display:grid;grid-template-columns:1fr 1fr;gap:24px;color:var(--muted)}
|
||
footer p{margin:0 0 6px}
|
||
|
||
@media (max-width:820px){
|
||
.colophon{grid-template-columns:1fr 1fr}
|
||
.colophon > div:nth-child(3){border-left:0;padding-left:0}
|
||
.colophon > div:nth-child(n+3){border-top:1px solid var(--rule)}
|
||
td.kind,td.stack,thead .c-kind,thead .c-stack{display:none}
|
||
.case{grid-template-columns:1fr;gap:12px;padding:16px 0}
|
||
.fig,.figcap{grid-column:auto}
|
||
.lower,footer{grid-template-columns:1fr;gap:36px}
|
||
}
|
||
@media (max-width:520px){
|
||
.wrap{padding:0 16px}
|
||
.mast h1{font-size:32px}
|
||
.mast-top{flex-direction:column}
|
||
.colophon{grid-template-columns:1fr}
|
||
.colophon > div + div{border-left:0;padding-left:0;border-top:1px solid var(--rule)}
|
||
td.no,thead .c-no,td.lk,thead .c-lk{display:none}
|
||
td.ai{width:auto}
|
||
.archive-line{flex-direction:column;gap:2px}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="mock-note" id="mockNote"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>tech-1 · The catalogue</b>Every project in one dense, sortable index; rows open into short case studies. Keyboard-first, typeset like a printed catalogue. Borrows from beige.zone (year index, Fig. labels) and ntch.design (shortcuts, tagged list).</div>
|
||
|
||
<div class="wrap">
|
||
<header class="mast">
|
||
<div class="mast-top">
|
||
<h1>Lucas Winther <i>— index of works, 2016–2026</i></h1>
|
||
<nav class="mast-links mono">
|
||
<a href="#works">Works</a><a href="#writing">Writing</a><a href="#cv">CV</a><a href="https://github.com/StereotypicalCat">GitHub</a><a href="mailto:lucas@lucaswinther.info">Email</a>
|
||
</nav>
|
||
</div>
|
||
<div class="colophon">
|
||
<div><span class="k mono">Now</span><p><span class="ph" title="placeholder">Software engineer, platform team at Nordlys Systems</span>, since <span class="ph" title="placeholder">2023</span>.</p></div>
|
||
<div><span class="k mono">Education</span><p>BSc Computer Science, <span class="ph" title="placeholder">Aarhus University</span>, <span class="ph" title="placeholder">2022</span>. Thesis on P2P archival.</p></div>
|
||
<div><span class="k mono">Works with</span><p>TypeScript, Node.js, React, Ansible, Docker, Linux, C#/Unity. Agents for the boring half.</p></div>
|
||
<div><span class="k mono">Contact</span><p><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br><a href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a> · <a href="#" title="placeholder: CV PDF">CV (PDF)</a></p></div>
|
||
</div>
|
||
<div class="archive-line mono">
|
||
<span>Everything here is tagged by how much AI was involved: <span class="glyph"></span>none <span class="glyph some"></span>some <span class="glyph full"></span>fully AI-made.</span>
|
||
<span>Prefer work made purely by hand? <a href="https://old.lucaswinther.info">The 2021 site, frozen as it was →</a></span>
|
||
</div>
|
||
</header>
|
||
|
||
<section id="works">
|
||
<div class="controls">
|
||
<label class="search mono"><kbd>/</kbd><input id="q" type="search" placeholder="Search title, stack, kind…" autocomplete="off"></label>
|
||
<div class="ai-filter" role="group" aria-label="Filter by AI involvement">
|
||
<span class="lbl mono">AI</span>
|
||
<button data-ai="all" aria-pressed="true">All<sup class="n"></sup></button>
|
||
<button data-ai="none" aria-pressed="false"><span class="glyph"></span>None<sup class="n"></sup></button>
|
||
<button data-ai="some" aria-pressed="false"><span class="glyph some"></span>Some help<sup class="n"></sup></button>
|
||
<button data-ai="full" aria-pressed="false"><span class="glyph full"></span>Fully AI<sup class="n"></sup></button>
|
||
</div>
|
||
<span class="count mono" id="count"></span>
|
||
</div>
|
||
<table>
|
||
<thead><tr>
|
||
<th class="c-no"><button data-sort="no">No.</button></th>
|
||
<th><button data-sort="year" class="on">Year</button></th>
|
||
<th><button data-sort="title">Title</button></th>
|
||
<th class="c-kind"><button data-sort="kind">Kind</button></th>
|
||
<th class="c-stack"><button data-sort="stack">Stack</button></th>
|
||
<th><button data-sort="ai">AI</button></th>
|
||
<th class="c-lk" style="text-align:right"><span class="mono" style="font-size:11.5px;letter-spacing:.06em;color:var(--muted)">LINKS</span></th>
|
||
</tr></thead>
|
||
<tbody id="tbody"></tbody>
|
||
</table>
|
||
<p class="empty" id="empty">Nothing matches. Press <kbd>Esc</kbd> to clear.</p>
|
||
</section>
|
||
|
||
<div class="lower">
|
||
<section id="writing">
|
||
<h2>Writing</h2>
|
||
<span class="mono">Tech notes on AI tooling, devops and the homelab. Full archive at <a href="https://blog.lucaswinther.info">blog.lucaswinther.info</a></span>
|
||
<ul class="posts" id="posts"></ul>
|
||
<a class="more mono" href="https://blog.lucaswinther.info">All entries →</a>
|
||
</section>
|
||
<section id="cv">
|
||
<h2>Experience & education</h2>
|
||
<span class="mono">The short version. <a href="#" title="placeholder: CV PDF">Full CV as PDF</a></span>
|
||
<ul class="cv">
|
||
<li><span class="mono"><span class="ph" title="placeholder">2023–now</span></span><span class="what"><span class="ph" title="placeholder">Software engineer, Nordlys Systems</span><small>Platform team: CI, internal tooling, deployment automation.</small></span></li>
|
||
<li><span class="mono"><span class="ph" title="placeholder">2021–2022</span></span><span class="what"><span class="ph" title="placeholder">Student developer, university IT</span><small>Web tools for teaching staff, React and Node.js.</small></span></li>
|
||
<li><span class="mono"><span class="ph" title="placeholder">2019–2022</span></span><span class="what">BSc Computer Science, <span class="ph" title="placeholder">Aarhus University</span><small>Thesis: reputation systems in peer-to-peer archival (HyperCache).</small></span></li>
|
||
<li><span class="mono"><span class="ph" title="placeholder">2015–2018</span></span><span class="what"><span class="ph" title="placeholder">HTX, programming line</span><small>Exam projects: Censor Simulator (VR), BreakoutPython.</small></span></li>
|
||
</ul>
|
||
</section>
|
||
</div>
|
||
|
||
<footer class="mono">
|
||
<div>
|
||
<p><kbd>/</kbd> search · <kbd>j</kbd><kbd>k</kbd> move · <kbd>Enter</kbd> open · <kbd>Esc</kbd> close/clear · <kbd>0</kbd>–<kbd>3</kbd> AI filter</p>
|
||
<p>Set in Newsreader and IBM Plex Mono. <a href="https://old.lucaswinther.info">By-hand archive (2021)</a> · <a href="https://blog.lucaswinther.info">Blog</a> · <a href="https://github.com/StereotypicalCat">GitHub</a></p>
|
||
</div>
|
||
<div>
|
||
<p>Placeholders (dotted underline): current job and employer, university name, all years and dates, school, blog post titles, CV link, “this site” and “Homelab metrics” projects, process splits and case-study details.</p>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
|
||
<script>
|
||
const P = [
|
||
{id:1,year:2016,title:"Tribute to Norman Borlaug",kind:"Website",stack:"HTML, CSS",ai:"none",links:[["Live","#"]],
|
||
sum:"One of the first websites I made, a freeCodeCamp tribute page to the agronomist behind the Green Revolution.",
|
||
did:"Hand-written HTML and CSS, learning what a box model is.",broke:"Nothing, there wasn't much to break.",hand:100},
|
||
{id:2,year:2017,title:"BreakoutPython",kind:"Game",stack:"Python, Pygame",ai:"none",links:[["GitHub","#"]],
|
||
sum:"High-school exam project: Breakout with levels, power-ups and a high-score file.",
|
||
did:"Collision handling written from scratch; I learned why you separate update and draw.",broke:"The ball tunnelled through bricks at high speed until I sub-stepped the physics.",hand:100},
|
||
{id:3,year:2018,title:"Censor Simulator",kind:"VR",stack:"Unity, C#",ai:"none",links:[["Video","#"]],
|
||
sum:"High-school exam project in VR: you play the external examiner, grading a nervous virtual student.",
|
||
did:"Scripted dialogue tree and grading logic, built for an early Oculus headset.",broke:"Motion sickness. The fix was snapping rotation instead of smooth turning.",hand:100},
|
||
{id:4,year:2019,title:"LoL Winrate Discord Bot",kind:"Bot",stack:"Node.js, Discord API",ai:"none",links:[["GitHub","#"]],
|
||
sum:"A Discord bot that answers with champion win rates for the current League of Legends patch.",
|
||
did:"Scraper, caching layer and command parser, hand-written.",broke:"Rate limits on the source site; added a per-patch cache.",hand:100},
|
||
{id:5,year:2019,title:"VR-Classroom",kind:"Prototype",stack:"Node.js, WebXR",ai:"none",links:[["Report","#"],["Video","#"]],
|
||
sum:"A prototype shared classroom in VR, with a report on what remote teaching needs from presence.",
|
||
did:"Node.js sync server and a small WebXR client.",broke:"Avatar jitter over bad connections; interpolation helped.",hand:100},
|
||
{id:6,year:2020,title:"Mixed-Reality Rowing Game",kind:"Hardware",stack:"Unity, Arduino, C#",ai:"none",links:[["Report","#"],["Video","#"]],
|
||
sum:"Two real oars with sensors drive a boat in Unity. You row a race in mixed reality.",
|
||
did:"Arduino firmware reading the oar angles, serial bridge into Unity, the game itself.",broke:"Sensor drift every few minutes, so I added a calibration pose.",hand:100},
|
||
{id:7,year:2020,title:"Chessswap",kind:"Game",stack:"React",ai:"none",links:[["Demo","#"],["GitHub","#"]],
|
||
sum:"Chess where the board swaps sides every few turns. You have to plan for the position you will inherit.",
|
||
did:"Move validation and the swap rule, hand-written in React.",broke:"Castling rights after a swap. Still arguing with myself about it.",hand:100},
|
||
{id:8,year:2021,title:"Student Dashboard",kind:"Web · HCI",stack:"React, user studies",ai:"none",links:[["Report","#"],["Demo","#"]],
|
||
sum:"A dashboard for university students, designed from two rounds of user studies.",
|
||
did:"Interviews, paper prototypes, then a React build tested with students.",broke:"Our first design was what we wanted, not what they needed.",hand:100},
|
||
{id:9,year:2021,title:"Monthly Expenses Tracker",kind:"Web app",stack:"JavaScript",ai:"none",links:[["Demo","#"],["GitHub","#"]],
|
||
sum:"Tracks monthly spending in DKK, with categories and a running total against a budget.",
|
||
did:"Plain JavaScript, localStorage, a hand-drawn SVG chart.",broke:"Floating point and øre. Now everything is stored as integers.",hand:100},
|
||
{id:10,year:2021,title:"Portfolio",kind:"Website",stack:"React, Material UI",ai:"none",links:[["Archive","https://old.lucaswinther.info"]],
|
||
sum:"The previous lucaswinther.info, kept frozen as the by-hand archive.",
|
||
did:"Create React App, Material UI, a searchable grid fed by info.json.",broke:"Nothing, which is why it stayed up for five years.",hand:100},
|
||
{id:11,year:2022,title:"HyperCache",kind:"Research",stack:"Node.js, P2P",ai:"none",note:"Bachelor report",links:[["Thesis PDF","#"]],
|
||
sum:"Reputation systems for peer-to-peer archival: who do you trust to keep a copy of your data?",
|
||
did:"Simulation in Node.js comparing reputation schemes under churn and free-riding peers.",broke:"Simulations took hours until I stopped deep-copying the network every tick.",hand:100},
|
||
{id:12,year:2025,title:"Event Tracker for Gacha Games",kind:"Web app",stack:"JavaScript, scraping",ai:"full",links:[["Live","#"],["GitHub","#"]],
|
||
sum:"Scrapes event announcements from several gacha games into one calendar, so limited banners stop slipping by.",
|
||
did:"I wrote the spec and acceptance checks; an agent wrote the code. I reviewed every change.",broke:"Two scrapers broke when sites changed their markup; the agent fixed them from my failing tests.",hand:10},
|
||
{id:13,year:2025,title:"Config",kind:"Infra",stack:"Ansible, docker-compose",ai:"full",links:[["GitHub","#"]],
|
||
sum:"Ansible playbooks and compose files that rebuild my homelab from bare metal.",
|
||
did:"Inventory and the decisions are mine; the playbooks were generated and iterated with AI.",broke:"An idempotency bug restarted every container on each run.",hand:15},
|
||
{id:14,year:2026,title:"Homelab metrics",kind:"Infra",stack:"Prometheus, Grafana",ai:"some",ph:true,links:[["Write-up","#"]],
|
||
sum:"Dashboards and alerts for the homelab, with exporters for the UPS and the NAS.",
|
||
did:"Alert rules hand-written; AI drafted the Grafana JSON, I rewrote half of it.",broke:"Alert storms on reboot until I added inhibition rules.",hand:60},
|
||
{id:15,year:2026,title:"lucaswinther.info",kind:"Website",stack:"HTML, CSS, JS",ai:"some",ph:true,note:"this site",links:[["Source","#"]],
|
||
sum:"This catalogue. Design and typesetting by hand; the sort/filter plumbing pair-written with AI.",
|
||
did:"Layout, type and copy by hand. Keyboard handling drafted by AI, reviewed line by line.",broke:"Sticky header covered the focused row. Scroll margin fixed it.",hand:70}
|
||
];
|
||
const AI_LABEL={none:"None",some:"Some help",full:"Fully AI"};
|
||
const AI_RANK={none:0,some:1,full:2};
|
||
let state={ai:"all",q:"",sort:"year",asc:false,cur:-1,open:null};
|
||
const tbody=document.getElementById("tbody");
|
||
|
||
function visible(){
|
||
const q=state.q.toLowerCase();
|
||
let r=P.filter(p=>(state.ai==="all"||p.ai===state.ai)&&
|
||
(!q||(p.title+" "+p.stack+" "+p.kind+" "+(p.note||"")).toLowerCase().includes(q)));
|
||
const k=state.sort, d=state.asc?1:-1;
|
||
r.sort((a,b)=>{
|
||
let x=a[k],y=b[k];
|
||
if(k==="ai"){x=AI_RANK[a.ai];y=AI_RANK[b.ai];}
|
||
if(k==="no"){x=a.id;y=b.id;}
|
||
if(typeof x==="string")return x.localeCompare(y)*d;
|
||
return ((x-y)||(a.id-b.id))*d;
|
||
});
|
||
return r;
|
||
}
|
||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));}
|
||
function render(){
|
||
const rows=visible();
|
||
let h="";
|
||
rows.forEach((p,i)=>{
|
||
const t=esc(p.title)+(p.note?`<em>${esc(p.note)}</em>`:"");
|
||
h+=`<tr class="row${i===state.cur?" cur":""}" data-id="${p.id}" data-ai="${p.ai}" tabindex="-1" aria-expanded="${state.open===p.id}">
|
||
<td class="no mono">${String(p.id).padStart(2,"0")}</td>
|
||
<td class="yr mono">${p.year}</td>
|
||
<td class="title">${p.ph?`<span class="ph" title="placeholder project">${t}</span>`:t}</td>
|
||
<td class="kind">${esc(p.kind)}</td>
|
||
<td class="stack mono">${esc(p.stack)}</td>
|
||
<td class="ai mono"><span class="glyph ${p.ai}"></span>${AI_LABEL[p.ai]}</td>
|
||
<td class="lk mono">${p.links.map(l=>`<a href="${l[1]}">${l[0]}</a>`).join("")}</td></tr>`;
|
||
if(state.open===p.id){
|
||
h+=`<tr class="detail"><td colspan="7"><div class="case">
|
||
<div></div>
|
||
<div><div class="fig"><span class="mono">placeholder: screenshot</span></div><div class="figcap mono">Fig. ${String(p.id).padStart(2,"0")}: ${esc(p.title)}, ${p.year}</div></div>
|
||
<div><h3>${esc(p.title)}</h3><p>${esc(p.sum)}</p><p>${p.links.map(l=>`<a class="mono" href="${l[1]}">${l[0]} ↗</a>`).join(" ")}</p></div>
|
||
<dl>
|
||
<dt>What I did</dt><dd>${esc(p.did)}</dd>
|
||
<dt>What broke</dt><dd>${esc(p.broke)}</dd>
|
||
<dt>By hand / AI</dt><dd><div class="split-bar" title="${p.hand}% hand-written"><i class="h" style="width:${p.hand}%"></i><i class="a" style="width:${100-p.hand}%"></i></div><span class="mono" style="color:var(--muted)">${p.hand}% hand · ${100-p.hand}% AI <span class="ph" title="placeholder estimate">(est.)</span></span></dd>
|
||
</dl></div></td></tr>`;
|
||
}
|
||
});
|
||
tbody.innerHTML=h;
|
||
document.getElementById("empty").style.display=rows.length?"none":"block";
|
||
document.getElementById("count").textContent=rows.length+" of "+P.length;
|
||
document.querySelectorAll(".ai-filter button").forEach(b=>{
|
||
const a=b.dataset.ai;b.setAttribute("aria-pressed",a===state.ai);
|
||
b.querySelector(".n").textContent=a==="all"?P.length:P.filter(p=>p.ai===a).length;
|
||
});
|
||
document.querySelectorAll("thead button").forEach(b=>{
|
||
b.classList.toggle("on",b.dataset.sort===state.sort);b.classList.toggle("asc",state.asc);
|
||
});
|
||
}
|
||
function toggleOpen(id){state.open=state.open===id?null:id;render();}
|
||
tbody.addEventListener("click",e=>{
|
||
if(e.target.closest("a"))return;
|
||
const tr=e.target.closest("tr.row"); if(!tr)return;
|
||
const id=+tr.dataset.id;
|
||
state.cur=visible().findIndex(p=>p.id===id);
|
||
toggleOpen(id);
|
||
});
|
||
document.querySelectorAll(".ai-filter button").forEach(b=>b.addEventListener("click",()=>{state.ai=b.dataset.ai;state.cur=-1;render();}));
|
||
document.querySelectorAll("thead button").forEach(b=>b.addEventListener("click",()=>{
|
||
const k=b.dataset.sort;
|
||
if(state.sort===k)state.asc=!state.asc;else{state.sort=k;state.asc=(k==="title"||k==="kind"||k==="stack");}
|
||
render();
|
||
}));
|
||
const q=document.getElementById("q");
|
||
q.addEventListener("input",()=>{state.q=q.value;state.cur=-1;render();});
|
||
function moveCur(d){
|
||
const rows=visible(); if(!rows.length)return;
|
||
state.cur=Math.max(0,Math.min(rows.length-1,state.cur+d));
|
||
render();
|
||
const el=tbody.querySelector("tr.row.cur");
|
||
if(el){el.scrollIntoView({block:"nearest"});}
|
||
}
|
||
document.addEventListener("keydown",e=>{
|
||
if(e.metaKey||e.ctrlKey||e.altKey)return;
|
||
const typing=document.activeElement===q;
|
||
if(e.key==="/"&&!typing){e.preventDefault();q.focus();return;}
|
||
if(e.key==="Escape"){
|
||
if(state.open!==null){state.open=null;render();}
|
||
else{q.value="";state.q="";state.ai="all";render();}
|
||
q.blur();return;
|
||
}
|
||
if(typing){ if(e.key==="ArrowDown"||e.key==="Enter"){e.preventDefault();q.blur();moveCur(state.cur<0?1:0);} return; }
|
||
if(e.key==="j"||e.key==="ArrowDown"){e.preventDefault();moveCur(1);}
|
||
else if(e.key==="k"||e.key==="ArrowUp"){e.preventDefault();moveCur(-1);}
|
||
else if(e.key==="Enter"||e.key===" "){const p=visible()[state.cur];if(p){e.preventDefault();toggleOpen(p.id);}}
|
||
else if("0123".includes(e.key)&&e.key.length===1){state.ai=["all","none","some","full"][+e.key];state.cur=-1;render();}
|
||
});
|
||
render();
|
||
|
||
const POSTS=[
|
||
["2026-10","What I let the agent write, and what I didn't","AI"],
|
||
["2026-08","Rebuilding the homelab from one git repo","Devops"],
|
||
["2026-06","Three mini PCs, one k3s cluster, no regrets so far","Homelab"],
|
||
["2026-03","Specs are prompts with manners","AI"],
|
||
["2025-11","Backups that have actually been restored","Homelab"],
|
||
["2025-09","Ansible idempotency, the hard way","Devops"]
|
||
];
|
||
document.getElementById("posts").innerHTML=POSTS.map(p=>`<li><span class="mono"><span class="ph" title="placeholder">${p[0]}</span></span><a class="t" style="border:0" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">${p[1]}</span></a><span class="tag mono">${p[2]}</span></li>`).join("");
|
||
</script>
|
||
</body>
|
||
</html>
|