chore: bootstrap agent-ready docs, rules and design-phase artefacts

Add AGENTS.md as the single source of truth (with CLAUDE.md and GEMINI.md
as pointers), permissions for Claude Code, Codex and Gemini, the docs tree
(architecture, decisions, TODO, permissions, specs, plans), PRODUCT.md and
DESIGN.md, plus the design-phase record: the inspiration log, all mockup
rounds with briefs and screenshots, and the owner's work history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-09 06:47:38 +01:00
commit 843b6d32bc
82 files changed
+17782

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
{
"permissions": {
"allow": [
"Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)",
"Bash(git branch --list:*)", "Bash(git rev-parse:*)",
"Bash(git add:*)", "Bash(git commit:*)"
],
"ask": [
"Bash(git push:*)", "Bash(git merge:*)", "Bash(git rebase:*)", "Bash(git switch:*)",
"Bash(git restore:*)", "Bash(git commit --amend:*)", "Bash(rm:*)",
"Bash(bun add:*)", "Bash(bun remove:*)", "Bash(bun update:*)", "Bash(bun upgrade:*)",
"Bash(bunx:*)", "Bash(npx:*)"
],
"deny": [
"Bash(git push --force:*)", "Bash(git push -f:*)", "Bash(git push --force-with-lease:*)",
"Bash(git reset --hard:*)", "Bash(git clean:*)", "Bash(git add -f:*)", "Bash(git add --force:*)",
"Bash(git commit --no-verify:*)", "Bash(git commit -n:*)",
"Bash(rm -r:*)", "Bash(rm -R:*)", "Bash(rm -rf:*)", "Bash(rm -fr:*)", "Bash(rm -Rf:*)",
"Bash(rm -fR:*)", "Bash(rm --recursive:*)", "Bash(sudo:*)",
"Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)"
]
}
}
+2
View File
@@ -0,0 +1,2 @@
sandbox_mode = "workspace-write"
approval_policy = "on-request"
+19
View File
@@ -0,0 +1,19 @@
# Rendered from docs/PERMISSIONS.md. Change both in one commit.
# Deny
prefix_rule(pattern = ["git", "push", ["--force", "-f", "--force-with-lease"]], decision = "forbidden",
justification = "Force-push rewrites shared history.")
prefix_rule(pattern = ["git", "reset", "--hard"], decision = "forbidden", justification = "Discards work.")
prefix_rule(pattern = ["git", "clean"], decision = "forbidden", justification = "Deletes untracked files.")
prefix_rule(pattern = ["git", "add", ["-f", "--force"]], decision = "forbidden",
justification = "Stages ignored files, e.g. secrets.")
prefix_rule(pattern = ["git", "commit", ["--no-verify", "-n"]], decision = "forbidden", justification = "Skips hooks.")
prefix_rule(pattern = ["rm", ["-r", "-R", "-rf", "-fr", "-Rf", "-fR", "--recursive"]], decision = "forbidden",
justification = "Recursive delete.")
prefix_rule(pattern = ["sudo"], decision = "forbidden", justification = "No privilege escalation.")
# Ask
prefix_rule(pattern = ["git", ["push", "merge", "rebase", "switch", "restore"]], decision = "prompt")
prefix_rule(pattern = ["git", "commit", "--amend"], decision = "prompt")
prefix_rule(pattern = ["rm"], decision = "prompt")
prefix_rule(pattern = ["bun", ["add", "remove", "update", "upgrade"]], decision = "prompt")
prefix_rule(pattern = ["bunx"], decision = "prompt")
prefix_rule(pattern = ["npx"], decision = "prompt")
+18
View File
@@ -0,0 +1,18 @@
{
"tools": {
"allowed": [
"run_shell_command(git status)", "run_shell_command(git diff)", "run_shell_command(git log)",
"run_shell_command(git show)", "run_shell_command(git branch --list)", "run_shell_command(git rev-parse)",
"run_shell_command(git add)", "run_shell_command(git commit)"
],
"exclude": [
"run_shell_command(git push --force)", "run_shell_command(git push -f)",
"run_shell_command(git push --force-with-lease)", "run_shell_command(git reset --hard)",
"run_shell_command(git clean)", "run_shell_command(git add -f)", "run_shell_command(git add --force)",
"run_shell_command(git commit --no-verify)", "run_shell_command(git commit -n)",
"run_shell_command(rm -r)", "run_shell_command(rm -R)", "run_shell_command(rm -rf)",
"run_shell_command(rm -fr)", "run_shell_command(rm -Rf)", "run_shell_command(rm -fR)",
"run_shell_command(rm --recursive)", "run_shell_command(sudo)"
]
}
}
+20
View File
@@ -0,0 +1,20 @@
# dependencies and build output
node_modules/
dist/
.astro/
*.tsbuildinfo
# secrets
.env
.env.*
!.env.example
# agent scratch and local-only files
.superpowers/
CLAUDE.local.md
.claude/settings.local.json
.worktrees/
# OS / editor
.DS_Store
*.log
+81
View File
@@ -0,0 +1,81 @@
# portfolio-next
The next lucaswinther.info: a two-gate personal site (work / off the clock). Pre-code: only docs and
HTML mockups exist. Planned stack: Bun 1.3, TypeScript, React, Astro (see `docs/DECISIONS.md`).
`AGENTS.md` is the single source of truth for agents here. `CLAUDE.md` and `GEMINI.md` only point here.
Deeper context on demand: `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TODO.md`.
## Commands
TODO(maintainer): confirm commands once tooling exists (no `package.json` yet).
Bun is the runtime and package manager, never Node, npm, pnpm or yarn:
`bun <file>` not `node`, `bun test` not jest/vitest, `bun install` / `bun run <script>`, `bunx` not `npx`.
Bun loads `.env` itself; don't add dotenv.
## Workflow
Required skills: `superpowers`, `impeccable`. Check at session start; install if missing.
Specs → `docs/specs/NNN-slug.md` (permanent). Plans → `docs/plans/NNN-slug.md` while in progress;
`git rm` them before opening a merge request — the spec is the record.
(Overrides the superpowers default paths, e.g. `docs/superpowers/specs/`.)
Anything beyond a trivial fix: brainstorm → spec (sign-off) → plan → TDD → review.
TDD means failing test first, watch it fail, minimal code, watch it pass, commit.
Tick plan checkboxes as you go so an interrupted session is resumable.
Trivial changes (typo, version bump, one-line config): say "trivial, skipping spec" and proceed.
Use impeccable for user-facing UI — `craft` for new, `audit`/`polish` on existing,
`harden` before shipping. Design context: `PRODUCT.md`, `DESIGN.md`.
## Rules
- The chosen mockups are the visual source of truth: `mockups/nav-d.html` (start page),
`mockups/tech-r3-c.html` (work), `mockups/personal-r2-b.html` (off the clock). Match them;
don't drift towards a generic template. Every other file in `mockups/` is a rejected or reference round.
- `docs/inspiration.md` records the owner's taste in his own words. Read its principles before
any visual change. Hard lines: no loading screens, nothing moves unless the visitor acts, no canvas/game feel,
nothing that sells, no purple/blue "AI" gradients.
- Every project carries an AI level: `ai-made` | `some-ai` | `by-hand` | unknown. Never guess one;
unknown stays unknown and is shown as such.
- Never invent facts about the owner (jobs, dates, life events, opinions). Use a visibly marked
placeholder and list it for him to fill in. Real work history: `extra_info.txt`.
## Architecture
`mockups/` -> throwaway HTML prototypes (one self-contained file each) + `shots/` screenshots
`docs/inspiration.md` -> taste log, mockup verdicts, decisions so far
`extra_info.txt` -> owner's real work history
Full picture: `docs/ARCHITECTURE.md` — read it before your first change in a session.
## Boundaries
Never touch: `.superpowers/` (local brainstorm sessions, gitignored), `extra_info.txt` (owner-maintained).
Never commit `.env`, secrets, or credentials.
Never force-push, rewrite pushed history, or commit directly to `main` once code exists.
Ask first: new runtime dependencies · deployment, containers or the owner's homelab config ·
anything destructive (`rm -rf`, `git reset --hard`) · work the approved plan does not cover.
If the task turns out much larger than the plan assumed: stop, report, re-plan.
## Docs duties
Same commit as the code, not "later":
- `docs/ARCHITECTURE.md` — when a module, boundary, or data flow changes.
- `docs/DECISIONS.md` — append-only; any choice a future maintainer would reverse-engineer.
- `docs/TODO.md` — anything noticed and deliberately not fixed. No `TODO` comments in source.
- `docs/PERMISSIONS.md` — together with `.claude/settings.json`, `.codex/`, `.gemini/settings.json`, in one commit.
## Git
Branches: TODO(maintainer): confirm `feat|fix|chore/<slug>`. Commits: TODO(maintainer): confirm Conventional Commits.
## Done means verified
Do not report success on work you have not run. Before saying done:
tests run *in this session* and seen passing · lint and typecheck clean · matches the approved spec ·
UI changes rendered and looked at (headless Chrome works: `google-chrome --headless=new --no-sandbox
--screenshot=out.png --window-size=1440,900 file://$PWD/page.html`) · docs duties complete ·
`git status` clean and the diff is only what you intended.
+9
View File
@@ -0,0 +1,9 @@
# CLAUDE.md
**`AGENTS.md` is the single source of truth for this repository.** Read it now and follow it exactly.
@AGENTS.md
This file exists only so Claude Code finds the rules, and for Claude-specific configuration that
AGENTS.md cannot express. It contains no project rules of its own — add those to `AGENTS.md`.
If anything here ever conflicts with `AGENTS.md`, `AGENTS.md` wins.
+39
View File
@@ -0,0 +1,39 @@
# Design
Derived from the three chosen mockups. They are the visual source of truth; this file summarises them.
When they disagree with this file, the mockup wins and this file gets fixed.
## Principles (owner's, see `docs/inspiration.md`)
- Calm until touched: nothing moves unless the visitor acts. No autoplay, no scroll-jacking, no loading screens.
- A space to explore: varied ways of showing information (drawn showcases, lists, side sheets), restrained.
- Ordinary, fast, scrolling HTML pages. No canvas/WebGL scenes or game feel.
- Restrained depth and glow are fine; the over-polished "sleek" round was rejected as too modern.
- Never purple/blue "AI" gradients, neon, emoji-as-icons, or generic bento grids.
- Honour `prefers-reduced-motion`. Pointer effects only behind `(hover: hover)`.
## Start page — `mockups/nav-d.html`
Two halves meeting at a seam; the name is split across it ("Lucas" mono left, "Winther" serif right).
Hovering a side widens it; clicking enters. A small dark pill on the seam opens "everything at once" (key G).
- Left/work: `#e8eae6` paper with a 24px grid, ink `#1c2024`, accent `#d9532b`; IBM Plex Mono + Plex Sans.
- Right/off the clock: plum `#2a1a1f` with grain, cream `#f1e6d6`, amber `#e8b04a`, rose `#c9706b`; Fraunces + Caveat.
- Footer strip `#121416`: contact links, `/simple`, the "projects made" AI preference.
## Work space — `mockups/tech-r3-c.html`
Warm dark. `--bg #131210`, `--panel #1b1916`, `--panel2 #221f1b`, `--line #2b2824`, `--ink #ede7dd`,
`--mute #8e877c`, single accent `--acc #ea7444`, status green `--ok #a8b48f` only inside drawn UIs.
Geist + Geist Mono. Structure: top bar (← gate, nav, AI filter) → intro → Now / Latest writing / CV strip →
four featured panels with interactive drawn showcases → filterable project list with a side sheet
(Esc closes, ←/→ steps, `#/id` deep links) → writing and work history. Plum "off the clock →" edge on the right.
## Personal space — `mockups/personal-r2-b.html`
Light cream. `--bg #f1e6d6`, `--card #f8f1e6`, `--line #dccbb2`, `--ink #2a1a1f`, `--mute #7d6a5c`,
`--acc #e8b04a`, `--rose #c9706b`, `--sage #6f7f45`. Fraunces (display and body), Caveat (handwritten
accents only), IBM Plex Mono (metadata). Structure: sticky interest chips → "Now" strip (listening,
Hardcover, Trakt, playing) → a curated masonry wall of cards shaped by type (comic panels, playable song
sleeve, game cover with pull-quote, typographic essay, art as itself) → older years as smaller cards.
Dark "work" edge on the left.
## Shared
- AI marks: filled = AI-made, half = some AI, outline = by hand, dashed = unknown/not recorded.
- Easing `cubic-bezier(.2,.8,.2,1)`, transitions 150–300ms.
- Placeholders: dotted underline + `title="placeholder"` until real content replaces them.
+7
View File
@@ -0,0 +1,7 @@
# GEMINI.md
**`AGENTS.md` is the single source of truth for this repository.** Read `./AGENTS.md` before doing
anything else and follow it exactly.
This file exists only so Gemini CLI finds the rules. It contains no project rules of its own — add
those to `AGENTS.md`. If anything here ever conflicts with `AGENTS.md`, `AGENTS.md` wins.
+25
View File
@@ -0,0 +1,25 @@
# Product
**What:** Lucas Winther's personal site (lucaswinther.info), replacing a 2021 React/Material UI portfolio.
**Why:** with AI tools everywhere, the gap between hand-made and AI-made work has narrowed. The site
should show that Lucas can reason and build by hand *and* use AI tools well, and be honest about which is which.
**Shape:** a start page with two gates.
- **Work** (left): projects (real work at Aarhus University IT Research Tech, personal and school
projects), CV in ten seconds, technical writing on AI, devops and homelab.
- **Off the clock** (right): things he loves and things he thinks: comics, games, films, songs,
drawings and commissioned art, essays, life story, music and his small band. Trakt and Hardcover pulled in.
Volume: 1–3 new items a month. A half-yearly zine-style roundup on top.
- A small "everything at once" link, a `/simple` one-page card, and the old 2021 site as an archive.
**Audiences:** recruiters and peers (want the CV and projects fast), friends and fellow artists or
musicians (want the person), and people who prefer work without AI (served by the AI filter:
any way / not fully by AI / by hand only).
**Voice:** warm, honest, curious. Never selling, never corporate, never jokey. Personal posts are casual and heartfelt.
**Anti-goals:** a template look; anything that shouts, sells, autoplays or needs a loading screen;
a game or canvas experience; a plain blog or news feed; guessing facts about Lucas.
Full taste record and every verdict: `docs/inspiration.md`.
+5
View File
@@ -0,0 +1,5 @@
# portfolio-next
The next version of [lucaswinther.info](https://lucaswinther.info). Currently in design: see `docs/inspiration.md` and `mockups/`.
Working on this repo (human or agent): start with [`AGENTS.md`](AGENTS.md).
+37
View File
@@ -0,0 +1,37 @@
# Architecture
How `portfolio-next` is built today. Update in the same commit as any structural change.
## Overview
There is no application code yet. The repo holds the design phase: a taste log, HTML mockups and
the owner's work history. The chosen mockups define the site: a start page with two gates that lead
into a technical space and a personal space. The planned implementation (Astro + React islands on
Bun, static output) is recorded in `docs/DECISIONS.md` and will be specified in `docs/specs/`.
## Map
| Path | Responsibility |
|---|---|
| `docs/inspiration.md` | Taste log: every reference site and mockup round with the owner's verdicts, principles, decisions so far |
| `mockups/*.html` | Self-contained HTML prototypes, one per file, no build step |
| `mockups/nav-d.html` | **Chosen** start page: two gates plus a small "everything at once" link |
| `mockups/tech-r3-c.html` | **Chosen** work space: dark showcase, featured panels, project list with side sheet |
| `mockups/personal-r2-b.html` | **Chosen** personal space: "now" strip and a curated wall of typed cards |
| `mockups/BRIEF*.md` | Briefs the mockup rounds were built from |
| `mockups/shots/` | Screenshots of every mockup |
| `extra_info.txt` | Owner-maintained real work history |
## Data flow
Mockups link to each other with relative links: `nav-d` → `tech-r3-c` / `personal-r2-b`; each space
links back to the gate and across to the other space. No data is fetched; all content is inline.
## External dependencies
Mockups load Google Fonts only (Geist, Geist Mono, IBM Plex, Fraunces, Caveat). Offline they fall
back to system fonts.
## Invariants
- Content about the owner is either real (from `extra_info.txt`, his Bluesky, or his statements in
`docs/inspiration.md`) or a visibly marked placeholder.
- Motion only on visitor action; no loading screens.
## Known rough edges
See `docs/TODO.md`.
+56
View File
@@ -0,0 +1,56 @@
# Decisions
Append-only. Never edit or delete an entry — supersede it with a new one that links back.
One entry per decision a future maintainer would otherwise have to reverse-engineer.
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number).
## 2026-11-09 — Stack: Bun + TypeScript + React, with Astro
**Status:** accepted
**Context:** The owner self-hosts and prefers Bun, TypeScript and React. The site must show content on
first paint (no loading screens) and be easy to post to (1–3 personal items a month).
**Decision:** Astro renders static HTML at build time; React is used only for interactive islands
(⌘K palette, AI filter, side sheet, players, comic reader, showcases). Content is Markdown/MDX with
frontmatter, posted by git push. Trakt and Hardcover are fetched at build time with scheduled
rebuilds. Output is static files in a small container, self-hosted.
**Alternatives:** A plain React SPA (blank first paint, against the no-loading principle). Bun's own
fullstack approach (`Bun.serve()` + HTML imports, which `bun init`'s CLAUDE.md recommends over Vite)
was not yet weighed against Astro, which builds on Vite. See TODO.
**Consequences:** Astro runs under Bun but uses Vite internally, so it diverges from `bun init`'s frontend advice.
## 2026-11-09 — Personal space is the "rich wall" with a half-yearly issue
**Status:** accepted
**Context:** The personal side hosts things the owner loves and thinks about (comics, games, films,
songs, drawings, essays, life story, Trakt/Hardcover), at 1–3 items a month.
**Decision:** `mockups/personal-r2-b.html`, plus a half-yearly zine/newspaper roundup inspired by `personal-r2-c`.
**Alternatives:** shelves (r2-a), seasonal zine as the front page (r2-c), personal homepage (r2-d),
and round 1 (P1–P5, too band-centric). Verdicts in `docs/inspiration.md`.
**Consequences:** Cards are typed by content kind; the design must look full with 10–20 items.
## 2026-11-09 — Work space is the dark showcase (tech-r3-c)
**Status:** accepted
**Context:** Four tech rounds. Paper metaphors felt boring; the sleek round 4 felt too modern.
**Decision:** `mockups/tech-r3-c.html` as it is. `tech-r4-a/b` are kept as reference only.
**Alternatives:** T1–T5, tech-r2-a…d, tech-r3-a/b, tech-r4-a/b.
**Consequences:** The start page's left gate (light grid paper) and tech-r3-c (dark) don't yet match; open question.
## 2026-11-09 — Two gates, not three spaces
**Status:** accepted
**Context:** An early reading split the site into "by hand before / with AI now / as a person".
**Decision:** Two gates, work and personal (`mockups/nav-d.html`), plus a small link to an
"everything at once" garden. The old 2021 site stays as an archive, reachable only via the
"by hand only" AI preference, not promoted. The AI level is a filter, not a section.
**Alternatives:** three doors (nav-a), a lens switch (nav-b), one garden as the home page (nav-c).
**Consequences:** Every project needs an AI level (`ai-made` / `some-ai` / `by-hand` / unknown).
## 2026-11-09 — Adopt AGENTS.md as single source of truth
**Status:** accepted
**Context:** Agents had no durable project context and no consistent workflow between sessions.
**Decision:** `AGENTS.md` is canonical; `CLAUDE.md`/`GEMINI.md` are pointers. Specs are permanent in
`docs/specs/`. Plans live in `docs/plans/` only while work is in progress and are removed before the
merge request. Both override the superpowers default paths. `bun init`'s generated CLAUDE.md rules
(use Bun over Node/npm/jest, `bunx`, no dotenv) were merged into `AGENTS.md`; its Bun.serve/HTML-import
frontend advice was not, pending the stack question above.
**Alternatives:** Per-tool rule files (drift, synchronisation debt); no rules file (agents re-explore
the repo every session — measurably slower and more tokens).
**Consequences:** Rule changes happen in one file; pointer files must stay thin. Plans are working
artefacts: kept after merge they go stale and contradict the code, so the spec is the record.
+42
View File
@@ -0,0 +1,42 @@
# Agent permissions
Source of truth for what AI coding agents may run here. Tool files are renderings of this page:
`.claude/settings.json`, `.codex/config.toml`, `.codex/rules/project.rules`, `.gemini/settings.json`.
Change this page and every tool file in the same commit.
## Verified commands
Run on 2026-11-09. Only `worked` rows may appear in a tier, a tool file, or `AGENTS.md` Commands.
| Command | Result |
|---|---|
| `bun --version` | worked (1.3.14) |
| `google-chrome --headless=new --no-sandbox --screenshot=out.png --window-size=1440,900 file://…` | worked |
| `bun install --frozen-lockfile` | not run: no `package.json` yet |
| `bun test`, `bun run build`, `bunx tsc --noEmit` | not run: no tooling yet |
TODO(maintainer): confirm every row above that is not worked, and add stack commands to the Allow tier once the Astro project exists.
## Tiers
| Tier | Commands | Why |
|---|---|---|
| Allow | `git status`, `git diff`, `git log`, `git show`, `git branch --list`, `git rev-parse`, `git add`, `git commit` | Inspection and committing; no prompt needed. |
| Ask | `git push`, `git merge`, `git rebase`, `git switch`, `git restore`, `git commit --amend`, `rm` (any), `bun add`, `bun remove`, `bun update`, `bun upgrade`, `bunx`, `npx` | Leaves the machine, rewrites work, changes dependencies, or downloads and runs code: a human decides. |
| Deny | `git push --force` / `-f` / `--force-with-lease`, `git reset --hard`, `git clean`, `git add -f` / `--force`, `git commit --no-verify` / `-n`, `rm -r` / `-R` / `-rf` / `-fr` / `-Rf` / `-fR` / `--recursive`, `sudo`; reading `.env`, `.env.*`, `secrets/**` | Destroys work, skips checks, or exposes secrets. |
## Known gaps
- Allowlists save prompts; they are not a security boundary. Use the tool's sandbox for isolation.
- Prefix rules are bypassable: `git -C dir push`, flags placed later (`git push origin main --force`),
`+refspec` pushes. Claude Code's `:*` matches at a word boundary, so each flag cluster must be
listed, which is why `git clean` is denied whole and `rm` always asks.
- Once they exist, allowed test, lint and build commands run code from the repo. An agent that can edit
`package.json` or the tests can run anything through them.
- Denying reads of `.env` does not stop a script that opens it itself, and git can print staged or
committed secrets (`git show HEAD:.env`). `git add -f` is denied so an agent cannot stage an ignored
secret; keep real secrets out of the working tree.
- Codex: no `allow` rules are written (in Codex `allow` means "run outside the sandbox"). In the
`workspace-write` sandbox `.git/` is read-only, so `git add`/`git commit` ask for escalation.
`approval_policy` in `.codex/config.toml` can loosen a stricter user setting. Codex rules cannot deny file reads.
- Gemini: `tools.exclude` is deprecated but is the only project-level deny; there is no Ask tier
(anything not allowed asks). Gemini cannot deny file reads. Ignored until the folder is trusted.
+32
View File
@@ -0,0 +1,32 @@
# TODO
Durable backlog. Agents append here instead of leaving `TODO` comments in source.
Small, specific, independently actionable. Remove only when actually done.
## Now
- [ ] Write the design spec (`docs/specs/001-…`) from `docs/inspiration.md` "Decisions so far".
- [ ] Decide Astro vs Bun's native fullstack (`Bun.serve()` + HTML imports) before scaffolding.
## Next
- [ ] Match the start page's left gate (light grid paper, `nav-d.html`) to the dark work space (`tech-r3-c.html`), or keep the jump on purpose.
- [ ] Mock up the half-yearly zine/newspaper issue for the personal space.
- [ ] Decide what the "everything at once" garden link opens in the real site.
- [ ] Decide the URL and hosting of the archived 2021 site (old.lucaswinther.info?).
- [ ] Decide whether blog.lucaswinther.info moves into the work space's writing section.
## Someday
## Recurring
- [ ] Maintain `AGENTS.md`:
- **Add a rule only in response to a real failure.** When an agent repeatedly gets something wrong,
that is a gap; add one line, ideally with a code example. Do not pre-empt hypothetical mistakes.
- **Update in the same PR as the convention change.** A stale rules file is worse than none — it
actively misleads.
- **Prune quarterly.** Delete rules whose convention has changed or whose mistake no longer occurs.
- **Optional, cheap validation:** run the same task twice in clean sessions, with and without the
file, and compare steps and token use.
## Known issues
- [ ] Placeholders in the mockups are inconsistent between rounds (band name, instrument, university, years). Resolve with real data when building.
- [ ] `mockups/tech-r3-c.html`: pointer effects (tilt, scrub, peer hover) were only checked by reading the code, never in a real browser.
- [ ] `mockups/tech-r4-b.html`: the hover preview is enabled by viewport width (≥900px), not `(hover: hover)`; don't copy that into the real build.
+194
View File
@@ -0,0 +1,194 @@
# Inspiration notes
Lucas's reactions to other portfolios while shaping the new lucaswinther.info.
Quotes are Lucas's own words, lightly tidied. "Takeaway" lines are Claude's reading, not Lucas's.
## Principles so far
- **Not a game engine.** The site shouldn't feel like it's rendered in a game engine or one big canvas.
- **No loading screen or loading bar.** Ever.
- **No epilepsy warnings.** Nothing flashy or strobing.
- **The visitor stays in control.** Nothing should happen outside the user's control: no scroll-jacking, nothing that jumps out at you, no autoplay spectacle.
- **Calm until touched.** "Life" comes from the site responding when you engage (hover details, things to discover, content that's lived-in and updated), not from motion pushed at you. *(Claude's framing; Lucas: "closer, yes".)*
- **A space to explore.** "It needs to be a space to explore, inviting you in and getting your curiosity, not a space that shouts in your face and tries to push you indoors."
- **Not selling anything.** No advertisement or landing-page feel, nothing corporate or jokey.
- **Not AI-generic.** Colours, layout and copy must not read as AI- or template-generated.
- **Fast.** Not laggy.
- **Varied ways of showing information.** Different presentations (timelines, graphs, tags, previews) make you want to dig deeper, but in moderation, not a feature for every section (from sankar.work).
- **Normal scrolling pages.** Scrolling with things to explore is good; scroll-jacking is not.
- **Compact screens.** Each screen shouldn't be huge or eat space (from david-estrada.dev).
- **Has an identity**, but a light one (from ntch.design, kigo.studio).
## The site's purpose (from conversation)
The site should show that Lucas has mastered both being a person who develops and reasons by hand, and using AI tools to make something great. Three parts:
**Two gates** (corrected 2026-11-09):
1. **Professional / technical:** portfolio, projects, education, skills, and a blog on AI, devops and homelab.
2. **Personal:** personal writing, creative work, the band and music.
The old 2021 site (React + Material UI) stays reachable as an archive, mainly for visitors who don't want AI-made work, but it isn't promoted as a space of its own.
Project-level AI filter: fully AI-made / some AI help / no AI. The existing `info.json` already tags two projects `made_with_ai`. Where the toggle lives is still open; it doesn't have to sit on top of the site.
Unsolved gap: none of the references recognise *why* someone is visiting (recruiter vs. friend) and show the right side of you.
## Round 1: well-known developer sites
| Site | Verdict | Notes |
|---|---|---|
| maggieappleton.com | 👍 Use ideas | Very nice digital garden. Takeaway: content sorted by type (essays/notes/small links) and maturity, technical and personal mixed. |
| brittanychiang.com | 👍 Base | Great, simple, responsive, reactive, has exactly what you need. Lacks life and discoverability: no blog, no differentiating between recruiter and friend. Takeaway: the cursor spotlight is the "responds only when you move" kind of life. |
| rauno.me | 🤔 | Good design idea, unsure it fits Lucas. |
| jhey.dev | 🤔 Idea for `/simple` | Interesting and themed, but too short and uninspiring, like a business card. Could be a shareable endpoint, e.g. `lucaswinther.info/simple`. The main site needs more life. |
| bruno-simon.com | 👎 | Very fun idea, but a project in itself, not a portfolio. |
| joshwcomeau.com | 👎 | Some good ideas, but weird, feels like it's selling something, too corporate, jokey and laggy. |
| emilkowal.ski | 👎 | Too boring, trying to sell something. |
| lynnandtonic.com | 👎 | Doesn't look good. (The yearly-archive idea isn't rejected, just this site.) |
| simonwillison.net | 👎 | Ugly and old school. |
| cassie.codes | — | No longer maintained. |
## Round 2: design-led portfolios
### Positive / closest
| Site | Verdict | Notes |
|---|---|---|
| sankar.work | 👍 Closest | Closer to what Lucas had in mind and generally good. Still feels a bit like an advertisement, and the colours feel way too AI-generated. **What's right:** "the page scrolls and has stuff to explore without being a game. It feels reactive, without taking away control. And it displays information in different ways, making you want to look deeper." **What's wrong:** the AI chatbot, the generic AI colour scheme, and "perhaps doing a bit too much" across many features. |
| ntch.design | 👍 | Good identity without being too heavy. (Keyboard shortcuts, tagged project list.) |
| beige.zone | 👍 Not bad | (Works grouped by year, tag filters, archival "Fig. 01" labels.) |
| adriangnzl.es | 👍 Not bad | |
| poulo.site | 👍 Not bad | |
| kigo.studio | 🤔 | Too disorganised, but mixes liveliness with site identity well. |
| marcomadonnadesign.com | 🤔 | Somewhat okay perhaps. |
| blue2purple.com | 🤔 | Not a bad idea, but not quite it. |
### Negative
| Site | Why |
|---|---|
| iamsunnywang.com, its-tomo.dev | Cool art identity, but way too heavy and game-feeling. |
| lcg.media, macmillan.studio | Too in your face; too much happening outside the user's control. |
| grubogrubo.com | Somewhat better, but too gimmicky. |
| worksruthi.com | Shouldn't need an epilepsy warning. |
| effepinnastudio.com, webdrift.be | Too much jumps out at you. |
| suho.studio, robertoattanasio.com | Too game-feeling. |
| jozue.fr, stevenatallah.com | Too "interactive". |
| danielebuser.com | Meh, too interactive. |
| juliamoresco.com.br | Cute, but too interactive and game-feeling. |
| vantra-studio.webflow.io | Meh. |
| gianlucaboccia.me | Has identity, but too gamey. |
| sebg.ch | Too interactive and game-feeling. |
| david-estrada.dev | Too much happening; each screen too big, takes too much space. |
| two26.studio | Too much happening. |
| nesilnoble.com, aat-architecten.be | Too interactive and game-feeling. |
| onlines.com.ar | Too much happening on scroll. |
| designbyency.com | Way too old school. |
| jithinrp.com | Too much happening, and has a loading bar. |
## Round 3: personal, music and blog sites
| Site | Verdict | Notes |
|---|---|---|
| marcschuster.com/blog | 🤔 | Better visual style and feel, but too uninteractive. |
| 8sided.blog, onetwoxu.de, 333sound.com, globalvariables.net, breakingmorewaves.blogspot.com, cardrossmaniac2.blogspot.com, chorus.fm | 👎 | Boring or too basic; feel like blogs or news feeds. |
| digidi.net/KALALUNA/page/bio | 🤔 Closer in some parts | Danish singer-songwriter on the DIGIDI artist platform. Clean two-column bio (portrait + text), simple top navigation (Bio, Videos, Backstage, Contact), restrained blue palette. |
Takeaway: the personal side can't be a plain chronological feed.
| mindofakira.com | 🤔 Cool elements | "Too interactive" overall, but cool elements: you can play music with a spinning record player and do a DJ scratch. |
| awwwards.com/sites/the-story-of-the-king-of-pop | 🤔 | Somewhat interesting aesthetic. |
| alexandracapitanescu.com, ty1official.com | 👎 | Too much happening; would need an epilepsy warning. |
| kalohane.com | 👎 | Too game-feeling. |
**What Lucas wants from the music corner:** "letting the user play some of the music, and then letting the user interact and explore the music." (The record player was about this, not the scratching itself.)
**Rejected idea:** a chronological logbook of gigs (date, venue, handcam video, photos). "A bit too boring. There's not enough for a chronological feed, and it cannot feel like just another blog."
**What the music actually is (Lucas):** not a well-known band with albums on Spotify or Apple Music. It's small projects and handcam-recorded concerts from his small band. Standard band-site patterns (streaming links, release pages, press kits) mostly don't apply. It needs the same explore-and-discover feel as the technical side.
## Still missing
- Positive references for the music side (band sites, musician sites).
- A clear "yes, that" for overall visual identity.
## Current site facts (recon, 2026-10-28)
- lucaswinther.info: Create React App + Material UI, dark theme. Fixed left panel with avatar, "Computer Scientist with passion for development", and links to LinkedIn, email, GitHub (StereotypicalCat) and the blog. Right side: a searchable grid of 13 projects from `/info.json`.
- Blog lives separately at blog.lucaswinther.info.
- Projects: Gacha Event Tracker (AI), Config/Ansible (AI), Bachelor thesis on P2P archival reputation systems, Student Dashboard, Chessswap, Monthly Expenses Tracker, Mixed-Reality Rowing Game (Arduino + real oars), VR-Classroom, LoL Winrate Discord Bot, Censor Simulator (VR), BreakoutPython, Tribute to Norman Borlaug, Portfolio.
## Mockup round 1 (2026-11-09)
13 throwaway mockups live in `mockups/` (brief: `mockups/BRIEF.md`): `nav-a/b/c` (three doors, switchable lens, one garden), `personal-1..5` (crate, desk, garden, almanac, zine) and `tech-1..5` (catalogue, two chapters, evolved split page, skills map, lab notebook). Lucas's reactions go here.
**Overall site:** A (three doors) is the favourite. B and C "take away too much reason to explore the other spaces".
**Correction to the structure:** the split is **two gates, professional/technical vs. personal**, not three spaces. The old by-hand site should *not* be promoted as a space of its own. The AI toggles are a filter, not a section. Next step: a new mockup based on A with just two gates, the left side technical and the right side art-y, more interesting and more clearly different from each other (`nav-d.html`).
**Garden as a third, small way in:** Lucas likes the garden (C) too, as a small third link under the two gates, not a gate of its own. Added to nav-d as a pill under the seam ("or wander through everything at once", key G).
**Decision (2026-11-09): nav-d is the starting page.** "Enter work" and "come in" should lead into the chosen tech and personal designs (still to be picked from T1–T5 and P1–P5), not the placeholder rooms.
**Tech mockups (T1–T5) feedback:** none are quite it; all are a bit too boring and/or hard to navigate.
- **T5 lab notebook:** the pick if forced. Cohesive style, presents things well. But the timeline view isn't it: wants a *more project-focused* view.
- **T4 skills map:** cool graphs, and the inspector is nice, but it takes away from actually learning about the projects. Latest writing is nice. Skills-over-time is not useful.
- **T3 split page:** a clean template, a bit boring. Could be the `/simple` view. "Pointing at" is not interesting.
- **T2 timeline:** not interesting or good.
- **T1 catalogue:** not a bad overview but no character. Could be useful somewhere (e.g. a full index).
Real work history is now in `extra_info.txt` (Aarhus University IT Research Tech 2025–now, ScienceAtHome internship 2019, TERMA student helper 2016–2018).
## Tech round 2 (2026-11-09)
Brief: `mockups/BRIEF-tech-r2.md`. `tech-r2-a` (notebook, project-first), `tech-r2-b` (spec sheets), `tech-r2-c` (project explorer), `tech-r2-d` (featured, then everything). All in the left gate's visual family, with real work history.
**Round 2 feedback (partial):** the notebook idea (tech-r2-a) is fun, but set aside for now. Wants to also explore something that feels **modern and alive**.
Correction: not "alive", just **modern**. Round 3 (`mockups/BRIEF-tech-r3.md`) drops the paper/print metaphors: `tech-r3-a` project stage (light, list + live preview, ⌘K), `tech-r3-b` Swiss index (big type, rows expand in place, hover preview), `tech-r3-c` dark showcase (featured panels, side-sheet detail). The gate's left side may change to match.
**Round 3 feedback:** the drawn project showcases in A and C are a really nice touch, so keep them in some form. C's structure is really good (Now / Writing / CV strip, featured panels, filterable list with side sheet). Overall it still feels "pretty basic and old-web-y", not as sleek or modern as Lucas wants.
No single reference for "sleek"; "take it a bit further", using tech-r3 as the baseline. **Principle update:** the round-1 ban on gradients, glass, shadows and glow is relaxed. Layered depth, cursor-responsive light, backdrop blur, tonal gradients and crisp user-triggered motion are allowed with restraint (Linear/Vercel/Raycast level). Still no purple/blue AI gradients, neon, autoplay loops, scroll-jacking or loading screens. Round 4 (`mockups/BRIEF-tech-r4.md`): `tech-r4-a` dark sleek, `tech-r4-b` light sleek, both evolved from tech-r3-c.
**Decision (2026-11-09): the tech space is `tech-r3-c` (dark showcase), as it is.** "It works well, and fits without being too modern." Round 4 (`tech-r4-a`, `tech-r4-b`) is kept as reference mockups only. nav-d's "enter work" now links to tech-r3-c, and tech-r3-c links back to the gate and across to `nav-d.html#art`.
## Personal mockups (P1–P5) feedback
- **P1 crate:** not my thing.
- **P2 desk:** closer, fun to look at, but might scale weirdly as more topics and interests get added.
- **P3 garden, P4 almanac:** don't fit how Lucas would actually use the site.
- **P5 zine:** an interesting idea, or at least an addition to how he'd use the site.
**The mockups focused too much on the band.** What the personal side should actually host:
- thoughts on a movie or game he really likes
- a song he wants to share right now
- pages from a comic he likes (as he does on Bluesky: bsky.app/profile/stereotypicalcat.bsky.social)
- links to Trakt (film/TV) and Hardcover (books)
- small random essays on things he cares about
- life history, and thoughts on the meaning of life
- a little drawing he made, commissioned art, a Kickstarter he backed and is now playing
- (plus music and the band, as one topic among many)
**Confirmed playback (personal side):** a place for things Lucas loves and things he thinks. Mostly small, visual shares (a game or movie and why, a song, a few comic pages with a line, a Kickstarter game, commissioned art, own doodles), some slower writing (essays, life story, meaning of life), and Trakt/Hardcover pulled in rather than logged twice. Browsable by interest and by what's new. The band is one topic among many. The zine could work as a seasonal roundup on top.
**Volume: only 1–3 things a month.** The design must look good sparse and grow slowly over years.
Real recent shares (Bluesky): comic pages from Now Recharging (nowrecharging.com, "i cried so much in this chapter"), Junkwraith (a coming-of-age comic with a unique world), the MangaPlus one-shot Cupid 13, a "villainess reborn" manga trope he's into, a Halloween pumpkin. Tone: warm, casual, heartfelt, the odd emoji.
## Personal round 2 (2026-11-09)
Brief: `mockups/BRIEF-personal-r2.md`. `personal-r2-a` (shelves of things I love), `-b` (one big rich wall), `-c` (the zine, grown up), `-d` (a personal homepage).
**Decision (2026-11-09): the personal space is `personal-r2-b` (one big, rich wall)**, plus **a half-yearly version of the zine/newspaper** (from personal-r2-c) as a roundup. nav-d's "come in" now links to personal-r2-b. tech-r3-c's "off the clock" edge links to personal-r2-b, and personal-r2-b's "work" edge links to tech-r3-c.
## Decisions so far (summary, 2026-11-09)
- **Start page:** `mockups/nav-d.html`, with two gates (work on the left, off the clock on the right) and a small "wander through everything at once" link to the garden (`nav-c.html` idea).
- **Tech space:** `mockups/tech-r3-c.html` (dark showcase), as it is. Round 4 (`tech-r4-a/b`) is kept as reference only.
- **Personal space:** `mockups/personal-r2-b.html` (one big, rich wall), plus a half-yearly zine/newspaper roundup (from `personal-r2-c`).
- **`/simple`:** a one-page card; `tech-3.html` (evolved split page) is the candidate.
- **Old 2021 site:** an archive, reachable but not promoted (via "by hand only").
- **AI filter:** any way / not fully by AI / by hand only, as a site-wide preference.
- **Stack (agreed):** Bun + TypeScript + React, with **Astro** as the framework. Static pages rendered at build time, React islands only for interactive parts (⌘K palette, AI filter, side sheet, players, comic reader, showcases). Content as Markdown/MDX files with frontmatter (type, interest, AI level, images), posted by git push. Trakt and Hardcover fetched at build time, with scheduled rebuilds. Self-hosted as static files in a small container, possibly deployed via Lucas's Ansible config repo.
Screenshots of every mockup are in `mockups/shots/`. The visual companion's index screens are in `.superpowers/brainstorm/`.
+11
View File
@@ -0,0 +1,11 @@
# Plans
Implementation plans from the superpowers writing-plans workflow. **Transient:** a plan lives only on
its feature branch and is removed with `git rm` before the merge request is opened. This README stays.
- Naming: `NNN-short-slug.md`, matching its spec number.
- Links back to its spec. Bite-sized checkbox tasks with exact file paths, test commands, and
commit checkpoints.
- Written for an engineer with zero context for this codebase — spell out the obvious.
- Tick checkboxes as you go so another agent can resume an interrupted session.
- Front matter: `status: draft | approved | in-progress`. There is no `done` — a finished plan is deleted.
+10
View File
@@ -0,0 +1,10 @@
# Specs
Approved specifications from the superpowers brainstorming workflow. Nothing non-trivial gets
implemented without one.
- Naming: `NNN-short-slug.md`, zero-padded, sequential.
- A spec states **what** and **why**, plus acceptance criteria. Not **how**.
- Front matter: `status: draft | approved | implemented | superseded`. No link to a plan — plans are
removed before merge.
- Never delete an implemented spec; it is the record of intent.
+34
View File
@@ -0,0 +1,34 @@
Aarhus Universitet
IT-medarbejder
January 2025 - Present (1 year 11 months)
Aarhus Municipality, Central Denmark Region, Denmark
My time working with IT Research Tech on Aarhus University is wonderfully
interesting. It's about building and running software solutions, such as
* A scaleable easy-to-use to use compute job systems using slurm with a
custom full-stack react-node web application, using the Oracle Cloud to scale
up and down both in terms of the web stack and the compute system, login
integrations using OAuth and Entra ID and integrations with AU's own ERDA
storage solution
* Different document and information services with integrations with
applications such as Overleaf and OpenProject
* And, of course, testing newer versions and frontiers of software all the while
keeping them secure, isolated/containerized, updated and monitored using lots
of different tools such as OpenBao/HashiCorp Vault, Grafana, Docker, Ansible
and more.
Core Skills Trained and Used: DevOps, Containerization (Docker), Linux
Adminstration, Observability, Cloud, Fullstack, Leadership and Organization
ScienceAtHome
Internship
July 2019 - July 2019 (1 month)
Greater Aarhus Area
At ScienceAtHome, I worked with the team to create ideas and follow the
workflow of planning out one of their projects. I helped by creating an early
prototype, to get a grip of the base mechanics and have something to show to
the people the project was created for, and pair programmed with experienced
programmers to startup the new project.
TERMA Group
Studenthelper, Local Configuration Management
May 2016 - September 2018 (2 years 5 months)
Central Denmark Region, Denmar
+30
View File
@@ -0,0 +1,30 @@
# Personal space, round 2 (addendum to BRIEF.md)
Read `BRIEF.md` (hard constraints, except as relaxed below) and **the end of `docs/inspiration.md`**: "Personal mockups (P1–P5) feedback" and "Confirmed playback". Open `nav-d.html` (the start page; the right half, "off the clock", is the door into your design), `personal-2.html` (liked: fun to look at, but it won't scale) and `personal-5.html` (liked as an idea or addition).
## What the personal side is
Not a band site. A place for **things Lucas loves and things he thinks**:
- **Shares (most posts):** a movie or game he loves and why; a song he wants to share right now; a few pages from a comic with a line about what got him; a Kickstarter game he backed and is now playing; commissioned art; his own small drawings; the band's handcam recordings (one topic among many).
- **Writing (occasional):** short essays on things he cares about; his life story; thoughts on the meaning of life.
- **Elsewhere, pulled in:** Trakt (film/TV) and Hardcover (books). Show "currently watching/reading" or recent ratings as if pulled from their APIs, with links out.
**Volume: 1–3 new things a month.** The design must look full and intentional with ~10–20 items, and still work after years (~100 items) without redesign. Visitors browse **by interest** (comics, games, music, film and TV, books, made by me, writing, life) and see **what's new**. It must not feel like a plain blog or news feed (rejected earlier), and must not be a gig logbook.
## Real content to use
From Lucas's Bluesky:
- **Now Recharging** (nowrecharging.com), a webcomic: "i cried so much in this chapter – death has always been something that hits me emotionally hard"
- **Junkwraith:** "a new comic with an interesting art style… feels like a coming-of-age story with a unique world"
- **Cupid 13:** a MangaPlus one-shot about a forsaken sniper who regains faith
- a fondness for the "villainess reborn as a good person, but everyone around her is psychopathic and possessive" manga trope
Tone: warm, casual, heartfelt, the odd emoji. Everything else (specific games, movies, songs, Kickstarter, drawings, essays, life events, Trakt/Hardcover data) is a placeholder: pick plausible items and mark them. Images are drawn placeholders: CSS/SVG comic-panel frames, a cover-art block, etc. Don't hotlink real images.
## Visual family
Continue the start page's right half: warm, grainy, plum #2a1a1f / cream #f1e6d6 / amber #e8b04a / rose #c9706b, Fraunces plus a handwritten accent (Caveat), paper-object charm in moderation. Light or dark inside is your call, as long as it reads as the same family. Polish like `tech-r3-c` (the chosen tech space): crisp and calm, not retro-clunky. Gentle depth and hover reactions are fine. Keep "← gate" and a "work →" edge (on the left side, mirroring tech-r3-c's right-side "off the clock" edge) linking to `tech-r3-c.html`.
## Output
As your task says, in `/home/user/portfolio-next/mockups/`. Render in headless Chrome at 1440×900 plus a full-page shot, look critically, iterate, then report briefly: idea, how it scales from 15 to 100+ items, interactions, placeholders.
+30
View File
@@ -0,0 +1,30 @@
# Tech space, round 2 (addendum to BRIEF.md)
Read `BRIEF.md` (hard constraints still apply), `docs/inspiration.md` (especially "Tech mockups (T1–T5) feedback" near the end), and `extra_info.txt`. Open `tech-5.html`, `tech-4.html` and `nav-d.html` to see what Lucas is reacting to.
## What changed
- **Start page is decided:** `nav-d.html`, two gates. The left gate ("Work") uses grid paper, IBM Plex Mono and Plex Sans, ink #1c2024 on #e8eae6, and accent #d9532b. Your mockups are what "enter work" opens, so they must feel like walking *into* that left gate: same visual family, but richer. Keep a small way back to the gate and across to "off the clock" (e.g. a thin edge or tab, as in nav-d's opened state).
- **Lucas's verdict on round 1:** too boring and/or hard to navigate. He wants:
- **Project-focused.** The point is learning about the projects he made: what each is, why, how, what's interesting about it. Not timelines, not skills-over-time.
- **Character.** T5's cohesive "lab notebook" style was the favourite. T1 had a useful overview but no character.
- **Easy to navigate.** It should be obvious how to get to any project and back.
- **Keep:** T4's inspector idea (contextual detail panel) and "latest writing" were liked, as long as they serve the projects rather than replace them.
- **Drop:** skills-over-time charts, "pointing at" panels, chapter timelines.
## Real content to use now
Work (from extra_info.txt, use it faithfully):
- **Aarhus University, IT Research Tech: IT-medarbejder, Jan 2025–now.** Builds and runs research software:
- A scalable, easy-to-use compute job system on Slurm with a custom full-stack React/Node web app, scaling web stack and compute on Oracle Cloud, OAuth + Entra ID login, integration with AU's ERDA storage.
- Document and information services integrating Overleaf and OpenProject.
- Testing new software while keeping it secure, isolated/containerised, updated and monitored: OpenBao/HashiCorp Vault, Grafana, Docker, Ansible.
- Skills: DevOps, Docker, Linux administration, observability, cloud, full-stack, leadership and organisation.
- **ScienceAtHome: internship, July 2019.** Ideation and planning on a project, built an early prototype of the base mechanics to show stakeholders, pair-programmed with experienced devs to start the project.
- **TERMA Group: student helper, Local Configuration Management, May 2016–Sep 2018.**
The AU work projects are real, so treat them as projects (the compute platform is a strong featured project). Their AI level is unknown: mark it as a placeholder. Education: a bachelor in computer science with the HyperCache thesis; the university name is unknown (placeholder). The personal projects list in BRIEF.md still applies. Don't invent extra projects beyond a clearly marked placeholder or two.
## Output
Write two mockups, as instructed in your task, named `tech-r2-*.html` in `/home/user/portfolio-next/mockups/`. Each must include: an AI filter (all / not fully AI / by hand only, matching nav-d's footer wording), a fast way to CV info (job, education, contact, CV PDF), latest writing (from blog.lucaswinther.info, placeholder titles), and the way back to the gate. Render each in headless Chrome (`google-chrome --headless=new --no-sandbox --screenshot=... --window-size=1440,900 file://...`), look at the screenshot, and fix what's off before reporting. Keep the final report short.
+22
View File
@@ -0,0 +1,22 @@
# Tech space, round 3: modern (addendum to BRIEF.md and BRIEF-tech-r2.md)
Read `BRIEF.md` (hard constraints still apply), `BRIEF-tech-r2.md` (real content and requirements still apply), and the end of `docs/inspiration.md`. Look at `nav-d.html` (the start page) and `tech-r2-c.html` / `tech-r2-d.html` (round 2).
## The new direction: modern
Lucas set the notebook idea aside. Every round-2 design leaned on a paper or print metaphor (notebook, engineering drawing, catalogue, grid paper). This round drops the metaphors: **crisp, contemporary, digital-native design.** Think of the craft level of rauno.me or a well-made product site, but personal and not selling anything. Lucas liked sankar.work's scrolling, reactive, varied-presentation feel, but not its AI-generic colours or how much it did.
What "modern" means here:
- Clean, confident type: a contemporary sans, e.g. Geist, Instrument Sans, Manrope, Hanken Grotesk or Space Grotesk; not Inter-on-white by default. Optionally pair it with a mono for metadata.
- Restrained colour: mostly neutrals with one considered accent. Light or dark is your call. No purple/blue gradients, no glassmorphism, no glow.
- Smooth, quick, user-triggered motion: hover previews, expanding detail, list-to-detail transitions (the View Transitions API is fine as progressive enhancement), and a command palette if it fits. Nothing on autoplay, no scroll-jacking, no loading screen.
- **Projects first.** Real-feeling project previews: draw small UI mockups or diagrams in HTML/CSS/SVG as stand-ins for screenshots, so it doesn't look like a wall of grey boxes.
- Easy to navigate, with an obvious path to any project and back.
You may **depart from the grid-paper look** of the start page's left gate. If your design needs it, include a small note in your report on how the gate's left side should change to match. Keep the way back to the gate and the "off the clock →" edge.
Still required: the AI filter (any way / not fully by AI / by hand only), CV in ten seconds, latest writing, real work history from extra_info.txt, placeholders marked.
## Output
One file, named as your task says, in `/home/user/portfolio-next/mockups/`. Render it in headless Chrome at 1440×900 (plus a tall full-page shot), look at it, fix what's off, and keep your report short: idea, type and colours, interactions, suggested gate changes, and placeholders.
+22
View File
@@ -0,0 +1,22 @@
# Tech space, round 4: sleek (addendum to BRIEF.md, -r2, -r3)
Start from `tech-r3-c.html` (dark showcase). Lucas likes **its structure** (top bar → intro → Now / Latest writing / CV strip → featured project panels → filterable list with side sheet → writing and work history) and **the drawn project showcases** (also good in `tech-r3-a.html`: the large drawn UI previews per project). Keep both. Copy `tech-r3-c.html` as your starting point and improve it; don't start from scratch.
His verdict on round 3: **"still feels pretty basic and old-web-y, not as sleek or modern as I'd like."** He has no single reference site. Take it further.
## Rules relaxed for this round
The round-1 ban on gradients, glass, shadows and glow overcorrected into flat 2012-looking boxes. Now allowed, **with restraint and taste**, at the level of Linear, Vercel, Raycast or rauno.me:
- **Layered depth:** surfaces at a few elevations, soft multi-layer shadows, inner highlights (1px top-edge light on panels), subtle borders that catch light.
- **Cursor-responsive light:** a spotlight or radial highlight that follows the pointer on panels and borders (e.g. a CSS variable-driven `radial-gradient` on hover), a faint glow under the active element.
- **Backdrop blur** on floating layers: the sticky top bar, the side sheet, the command palette, tooltips.
- **Subtle gradients:** tonal, within the palette (warm near-black to slightly lighter, a faint accent bloom). Still **no purple, indigo or blue "AI" gradients**, no rainbow, no neon.
- **Crisp, physical motion:** spring-like easing, 150–400ms, staggered reveals *when the visitor opens something*, hover lifts, smooth list-to-detail transitions (View Transitions API as an enhancement). A one-time, very short (≤600ms) fade-up of the first screen on load is OK. Still **no autoplay loops, no scroll-jacking, no loading screen**, and honour `prefers-reduced-motion`.
- Richer showcases: the drawn UIs can have real depth (floating cards, layered windows, shadows) and react more nicely to the pointer.
- Typography: modern and tight. Bigger contrast between display and body sizes, tight tracking on display text, tabular figures in data.
Still required: projects first, easy navigation, the AI filter (any way / not fully by AI / by hand only), CV in ten seconds, latest writing, real work history, placeholders marked, ↖ gate and the "off the clock →" edge, one self-contained file, fast.
## Output
As your task says. Render at 1440×900 plus a tall full-page shot, look critically (does it look like 2026 or 2012?), iterate at least once, then report briefly: what you changed and why, palette, interactions, placeholders.
+67
View File
@@ -0,0 +1,67 @@
# Mockup brief (shared by all mockup agents)
You're designing throwaway HTML mockups for Lucas Winther's new personal site (replacing lucaswinther.info). Lucas reacts to them in a browser. The goal is to find out what he likes, so **each mockup in your set must be a clearly different direction**: different layout idea, different navigation mechanic, different visual identity. Five variations on one theme would waste the round.
## Read first
`/home/user/portfolio-next/docs/inspiration.md` lists every site Lucas has reacted to, with his own words, plus the principles distilled from them. Treat the principles as hard constraints. Re-read the negative list before finishing.
## What the site is
The site proves Lucas can craft things by hand *and* use AI tools well. Three spaces:
1. **By hand, before:** the old 2021 React portfolio, frozen as an archive (old.lucaswinther.info or similar). Visitors who don't want AI work get pointed here.
2. **With AI, now:** the technical portfolio. Projects, education, skills, a blog on AI, devops and homelab.
3. **As a person:** personal writing, musings, findings, creative bits, and his small band (handcam concert recordings and small projects, *not* albums on Spotify).
Cross-cutting:
- **AI filter on projects:** fully AI-made / some AI help / no AI. Where and how it appears is open; it does not have to sit at the top of the site.
- **Different visitors want different sides:** a recruiter vs. a friend vs. a fellow musician.
- **`/simple`:** a separate one-page business card. Not your job unless your brief says so.
## Hard constraints
- **Ordinary HTML/CSS pages, scrolling normally.** No canvas or WebGL scenes, no game-engine feel, no 3D worlds.
- **No loading screens, preloaders or intro animations.** Content is visible on first paint.
- **Nothing moves unless the visitor does.** No autoplay, no scroll-jacking, no flashing, no big scroll-triggered spectacles. Hover, focus, click and expand are the "life". Small, quick transitions (≤300ms) are fine.
- **Calm until touched, a space to explore.** Things to discover, information shown in a few varied shapes (timelines, tag clouds, maps, lists that expand, previews on hover) that invite you to dig, but restrained: a handful of well-made ideas per page, not a feature in every section.
- **Not selling anything.** No CTAs like "Hire me!", no landing-page hero with three feature cards, no testimonials carousel, no corporate or jokey tone.
- **Must not look AI-generated.** Avoid purple/blue/indigo gradients, glassmorphism, emoji as icons, rounded-card grids with drop shadows, Inter-on-white defaults, "✨", neon-on-black dev-terminal clichés, and generic bento grids. Pick type and colour like a person with taste would: a real typeface pairing (Google Fonts via `<link>` is fine), a considered palette.
- **Compact.** Screens shouldn't be giant full-viewport panels with one sentence each.
- **Fast and light.** Vanilla HTML/CSS/JS in a single self-contained file. No frameworks, no CDNs except Google Fonts. No external images: use CSS shapes, inline SVG, solid colour blocks or simple patterns as image placeholders, labelled e.g. "photo: rehearsal room".
- **Responsive enough** that it doesn't break at about 400px wide, but design for desktop first.
## Content
Use real content where it exists. Lucas's real projects, from the old site's info.json:
- Event Tracker for Gacha Games: scraping, webapp, JS. Made with AI. Live demo plus GitHub.
- Config: Ansible, IaC, docker-compose deployment management. Made with AI.
- Bachelor Report (HyperCache): reputation systems in peer-to-peer archival. Node.js. Thesis PDF.
- Student Dashboard: React, built on user studies. Report PDF plus demo.
- Chessswap: chess where the board swaps every few turns. React.
- Monthly Expenses Tracker: JS webapp, DKK.
- Mixed-Reality Rowing Game: Unity, Arduino, C#, two real oars.
- VR-Classroom: Node.js prototype, report plus video.
- LoL Winrate Discord Bot: Node.js.
- Censor Simulator: VR high-school exam project, Unity.
- BreakoutPython: high-school exam project.
- Tribute to Norman Borlaug: one of his first websites (freecodecamp).
- Portfolio: the old React site itself.
Real links: GitHub `StereotypicalCat`, LinkedIn `lucas-winther`, email `lucas@lucaswinther.info`, blog `blog.lucaswinther.info`. Old tagline: "Computer Scientist with passion for development."
Anything else (band name, gig venues, blog titles, current job, city, education details beyond "bachelor in computer science") is unknown. Invent plausible placeholders and mark them visibly, e.g. with a subtle dotted underline and a `title="placeholder"`, or a small "placeholder" note in the footer listing what's invented. Lucas is Danish (prices in DKK, a Danish university), but don't assume a city.
Music: there are no real audio files. Mock the player UI convincingly (play/pause toggles state, a fake progress bar that only advances after the visitor presses play, song list, timestamps). Handcam concert video = a placeholder frame with a play affordance.
## Output format
- One standalone HTML file per mockup, full document (`<!DOCTYPE html>`), in `/home/user/portfolio-next/mockups/`, named as your brief says.
- At the very top of `<body>`, include a small fixed, dismissible note (top-right, unobtrusive, not part of the design) with the mockup's ID, a name, and 1–2 sentences on the idea and which reference sites it borrows from.
- Make it feel like a real page someone could scroll, with enough content to judge, but it's a mockup: one main page per mockup. Extra "pages" can be sections or expandable states.
- Don't start servers or install anything. Check that each file is well-formed (e.g. it parses, there are no JS errors you can spot by reading). If `node` is available you can sanity-check inline JS syntax.
## When done
Report back: for each file, its name, a one-line idea, the typeface and palette, and what's placeholder. Keep the report short.
+532
View File
@@ -0,0 +1,532 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>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=Archivo:wght@400;500;600;700&family=Libre+Caslon+Text:ital,wght@0,400;0,700;1,400&family=Roboto:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
--paper:#f1ece2; --ink:#1e1c18; --mute:#6f695e; --rule:#d6cfc0;
--d1:#2b2f36; --d1a:#90caf9;
--d2:#2f5d50; --d2bg:#eef1ec; --d2ink:#14231e;
--d3:#7a2e2a; --d3bg:#f4e9df; --d3ink:#2b1614;
}
*{box-sizing:border-box}
html{scroll-behavior:auto}
body{margin:0;background:var(--paper);color:var(--ink);font:16px/1.55 "Archivo",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
a{color:inherit}
button{font:inherit;color:inherit}
.ph{text-decoration:underline dotted;text-decoration-color:currentColor;text-underline-offset:3px;cursor:help}
.serif{font-family:"Libre Caslon Text",Georgia,serif}
.mono{font-family:"JetBrains Mono",ui-monospace,monospace}
/* mockup note */
.mnote{position:fixed;top:10px;right:10px;z-index:99;max-width:300px;background:#fff;border:1px solid #bbb;padding:10px 30px 10px 12px;font:12px/1.45 system-ui,sans-serif;color:#222;box-shadow:0 1px 0 rgba(0,0,0,.05)}
.mnote b{display:block;margin-bottom:2px}
.mnote button{position:absolute;top:4px;right:6px;border:0;background:none;cursor:pointer;font-size:16px;color:#666}
/* views */
.view{display:none}
.view.on{display:block;animation:fade .18s ease-out}
@keyframes fade{from{opacity:.4}to{opacity:1}}
/* ---------- HALL ---------- */
.hall{max-width:1180px;margin:0 auto;padding:44px 32px 40px}
.hall header{display:flex;justify-content:space-between;align-items:baseline;gap:24px;border-bottom:1px solid var(--rule);padding-bottom:14px}
.hall .name{font-family:"Libre Caslon Text",serif;font-size:22px;margin:0}
.hall .small{font-size:13px;color:var(--mute)}
.hall .lede{font-family:"Libre Caslon Text",serif;font-size:clamp(24px,3vw,34px);line-height:1.25;max-width:24ch;margin:34px 0 8px;font-weight:400}
.hall .lede i{color:var(--mute)}
.hall .sub{max-width:58ch;color:var(--mute);margin:0 0 26px}
.doors{display:flex;gap:10px;height:400px}
.door{flex:1 1 0;min-width:0;position:relative;display:flex;flex-direction:column;justify-content:space-between;padding:18px 18px 16px;text-decoration:none;border:1px solid transparent;transition:flex-grow .28s ease, background-color .2s;overflow:hidden;outline:none}
.door:hover,.door:focus-visible{flex-grow:2.3}
.door:focus-visible{outline:2px solid var(--ink);outline-offset:3px}
.door .num{font:500 12px "JetBrains Mono",monospace;letter-spacing:.06em;opacity:.8}
.door h2{font-family:"Libre Caslon Text",serif;font-weight:400;font-size:28px;line-height:1.1;margin:6px 0 4px}
.door .tag{font-size:14px;opacity:.85;max-width:30ch}
.door .enter{font-size:13px;opacity:.7;display:flex;justify-content:space-between;align-items:center}
.door .preview{flex:1;margin:14px 0;opacity:0;transform:translateY(6px);transition:opacity .22s, transform .22s;pointer-events:none;min-height:0;overflow:hidden}
.door:hover .preview,.door:focus-visible .preview{opacity:1;transform:none}
.door.d1{background:var(--d1);color:#e6e8eb}
.door.d2{background:var(--d2);color:#eef3ef}
.door.d3{background:var(--d3bg);color:var(--d3ink);border-color:#dcc6b6}
.door.d3 h2{font-style:italic;color:var(--d3)}
/* little arch at top of each door */
.door::before{content:"";position:absolute;top:0;left:50%;width:46px;height:23px;margin-left:-23px;border-radius:0 0 23px 23px;background:var(--paper);opacity:.95}
/* previews */
.pv1{display:grid;grid-template-columns:70px 1fr;gap:8px;height:100%;font-family:Roboto,sans-serif}
.pv1 .side{background:#1f2328;padding:8px;font-size:9px;color:#aab}
.pv1 .side .av{width:34px;height:34px;border-radius:50%;background:#556;margin-bottom:6px}
.pv1 .grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;align-content:start}
.pv1 .grid div{background:#353a42;height:44px;border-radius:3px;font-size:8.5px;padding:5px;color:#cfd3d8}
.pv1 .cap{grid-column:1/-1;font-size:11px;color:var(--d1a);font-family:"JetBrains Mono",monospace;margin-top:4px}
.pv2 ul{list-style:none;margin:0;padding:0;font-size:13px}
.pv2 li{display:flex;justify-content:space-between;gap:10px;border-top:1px solid rgba(255,255,255,.18);padding:6px 0}
.pv2 .ai{font:11px "JetBrains Mono",monospace;opacity:.8;white-space:nowrap}
.pv3{font-size:13px}
.pv3 .strip{display:flex;align-items:center;gap:10px;background:#ead8c9;padding:8px 10px;margin-bottom:10px}
.pv3 .strip .pl{width:22px;height:22px;border-radius:50%;background:var(--d3);position:relative;flex:none}
.pv3 .strip .pl::after{content:"";position:absolute;left:8px;top:6px;border-left:8px solid #f4e9df;border-top:5px solid transparent;border-bottom:5px solid transparent}
.pv3 .bars{flex:1;height:18px;background:repeating-linear-gradient(90deg,var(--d3) 0 2px,transparent 2px 5px);opacity:.35}
.pv3 p{margin:0 0 6px;font-family:"Libre Caslon Text",serif;font-style:italic}
.hall .below{display:grid;grid-template-columns:1fr 1fr 1fr;gap:24px;margin-top:22px;font-size:14px;color:var(--mute)}
.hall .below b{color:var(--ink);font-weight:600}
.hall .below a{color:var(--ink)}
kbd{font:11px "JetBrains Mono",monospace;border:1px solid currentColor;border-bottom-width:2px;padding:0 4px;border-radius:3px;opacity:.85}
.hall footer{margin-top:40px;border-top:1px solid var(--rule);padding-top:12px;font-size:12px;color:var(--mute);display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap}
/* ---------- CORRIDOR (shared bar inside rooms) ---------- */
.corridor{position:sticky;top:0;z-index:20;display:flex;align-items:stretch;font-size:13px;border-bottom:1px solid rgba(0,0,0,.12)}
.corridor a{text-decoration:none;display:flex;align-items:center;gap:8px;padding:10px 14px}
.corridor .home{background:var(--paper);color:var(--ink);font-family:"Libre Caslon Text",serif;font-size:15px;border-right:1px solid var(--rule)}
.corridor .home:hover{background:#e7e0d2}
.corridor .tabs{display:flex;flex:1}
.corridor .tabs a{flex:1;justify-content:flex-start;color:inherit;opacity:.7;border-right:1px solid rgba(0,0,0,.08)}
.corridor .tabs a:hover{opacity:1}
.corridor .tabs a[aria-current]{opacity:1;font-weight:600}
.corridor .chip{width:10px;height:10px;flex:none}
.corridor .tabs .t1{background:var(--d1);color:#e6e8eb}
.corridor .tabs .t2{background:var(--d2);color:#eef3ef}
.corridor .tabs .t3{background:var(--d3bg);color:var(--d3)}
.corridor .tabs a[aria-current]::after{content:"you are here";font:400 10px "JetBrains Mono",monospace;margin-left:auto;opacity:.7}
.corridor .keys{padding:10px 14px;background:var(--paper);color:var(--mute);white-space:nowrap}
.across{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:48px}
.across a{display:block;padding:16px 18px;text-decoration:none;min-height:96px}
.across small{display:block;font:11px "JetBrains Mono",monospace;opacity:.7;margin-bottom:6px}
.across span{font-family:"Libre Caslon Text",serif;font-size:22px}
.across .x1{background:var(--d1);color:#e6e8eb}
.across .x2{background:var(--d2);color:#eef3ef}
.across .x3{background:var(--d3bg);color:var(--d3);border:1px solid #dcc6b6}
/* ---------- ROOM 1 : archive antechamber (quotes old MUI look) ---------- */
#archive{background:#22262c;color:#e3e6ea;font-family:Roboto,sans-serif;min-height:100vh}
#archive .wrap{max-width:1180px;margin:0 auto;padding:28px 32px 48px}
.banner{display:flex;justify-content:space-between;gap:18px;align-items:center;border:1px dashed #5a6270;padding:12px 16px;margin-bottom:22px;font-size:14px;color:#c2c8d0}
.banner a{color:var(--d1a);white-space:nowrap}
.old{display:grid;grid-template-columns:240px 1fr;gap:22px}
.old aside{background:#1b1e23;padding:22px;text-align:center;border-radius:4px;align-self:start;position:sticky;top:60px}
.old aside .av{width:96px;height:96px;border-radius:50%;margin:0 auto 12px;background:repeating-linear-gradient(45deg,#3b414b 0 6px,#343a43 6px 12px);display:grid;place-items:center;font-size:10px;color:#8992a0}
.old aside h3{margin:0;font-weight:500;font-size:20px}
.old aside p{font-size:13px;color:#9aa3ae;margin:6px 0 14px}
.old aside a{display:block;color:var(--d1a);font-size:13px;text-decoration:none;padding:3px 0}
.old .search{background:#2c3138;border-radius:4px;padding:10px 14px;color:#9aa3ae;font-size:14px;margin-bottom:14px;display:flex;justify-content:space-between}
.old .search input{background:none;border:0;color:#e3e6ea;font:inherit;flex:1;outline:none}
.oldgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
.oc{background:#2c3138;border-radius:4px;padding:0 0 12px;font-size:13px;color:#c2c8d0}
.oc .img{height:84px;background:#3a404a;border-radius:4px 4px 0 0;display:grid;place-items:center;color:#7b8594;font-size:10px;margin-bottom:10px}
.oc h4{margin:0 12px 4px;font-weight:500;font-size:15px;color:#eef0f3}
.oc p{margin:0 12px 8px}
.oc .chips{margin:0 12px}
.oc .chips span{display:inline-block;border:1px solid #4d5562;border-radius:12px;padding:0 8px;font-size:11px;margin:0 4px 4px 0}
.oc .open{display:block;margin:6px 12px 0;color:var(--d1a);font-size:12px;text-transform:uppercase;letter-spacing:.06em;text-decoration:none}
#archive .across a{border:1px solid #3b414b}
#archive .corridor{border-bottom-color:#3b414b}
/* ---------- ROOM 2 : with AI, now ---------- */
#now{background:var(--d2bg);color:var(--d2ink);min-height:100vh}
#now .wrap{max-width:1040px;margin:0 auto;padding:34px 32px 56px}
#now h1{font:600 40px/1.05 "Archivo",sans-serif;letter-spacing:-.02em;margin:10px 0 10px;max-width:22ch}
#now .intro{display:grid;grid-template-columns:1.4fr 1fr;gap:40px;align-items:end;border-bottom:2px solid var(--d2);padding-bottom:22px}
#now .intro p{margin:0;color:#3f5650;max-width:56ch}
#now .facts{font:12px/1.8 "JetBrains Mono",monospace;color:#3f5650}
#now .facts b{color:var(--d2ink);font-weight:500}
#now h3{font:600 13px "JetBrains Mono",monospace;text-transform:uppercase;letter-spacing:.08em;margin:38px 0 10px;color:var(--d2)}
.plist{border-top:1px solid #b9c7c0}
.plhead{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:10px 0;border-bottom:1px solid #b9c7c0;flex-wrap:wrap}
.plhead .count{font:12px "JetBrains Mono",monospace;color:#3f5650}
.aif{display:flex;align-items:center;gap:0;font-size:13px}
.aif .lbl{font:12px "JetBrains Mono",monospace;color:#3f5650;margin-right:10px}
.aif button{background:none;border:1px solid var(--d2);border-left-width:0;padding:4px 10px;cursor:pointer;display:flex;gap:6px;align-items:center}
.aif button:first-of-type{border-left-width:1px}
.aif button[aria-pressed=true]{background:var(--d2);color:#eef3ef}
.aif button:hover:not([aria-pressed=true]){background:#dfe7e2}
.lvl{display:inline-block;width:18px;height:8px;border:1px solid currentColor;position:relative;flex:none}
.lvl::after{content:"";position:absolute;left:0;top:0;bottom:0;background:currentColor}
.lvl.full::after{width:100%}
.lvl.some::after{width:50%}
.lvl.none::after{width:0}
.prow{border-bottom:1px solid #cdd8d2}
.prow>button{width:100%;display:grid;grid-template-columns:1fr 210px 120px 18px;gap:16px;align-items:center;background:none;border:0;padding:12px 0;text-align:left;cursor:pointer}
.prow>button:hover{background:#e3eae6}
.prow .t{font-weight:600;font-size:16px}
.prow .k{font-size:13px;color:#3f5650}
.prow .a{font:12px "JetBrains Mono",monospace;display:flex;gap:8px;align-items:center;color:#3f5650}
.prow .pm{font:16px "JetBrains Mono",monospace;color:var(--d2);transition:transform .2s}
.prow[aria-expanded=true] .pm{transform:rotate(45deg)}
.prow .more{display:none;grid-template-columns:140px 1fr;gap:18px;padding:4px 0 18px}
.prow[aria-expanded=true] .more{display:grid}
.prow .more .thumb{height:90px;background:repeating-linear-gradient(135deg,#d3ddd8 0 8px,#c9d5cf 8px 16px);display:grid;place-items:center;font:10px "JetBrains Mono",monospace;color:#5b706a;text-align:center;padding:4px}
.prow .more p{margin:0 0 8px;max-width:62ch}
.prow .more .links a{margin-right:14px;font-size:14px;color:var(--d2)}
.prow .more .v1{font:12px "JetBrains Mono",monospace;color:#3f5650}
.prow.hide{display:none}
.empty{padding:18px 0;color:#3f5650;font-size:14px;display:none}
.two{display:grid;grid-template-columns:1fr 1fr;gap:40px}
.tl{list-style:none;margin:0;padding:0;border-left:2px solid var(--d2)}
.tl li{padding:0 0 14px 16px;position:relative;font-size:14px}
.tl li::before{content:"";position:absolute;left:-6px;top:6px;width:10px;height:10px;background:var(--d2bg);border:2px solid var(--d2)}
.tl b{display:block;font-size:15px}
.tl span{font:12px "JetBrains Mono",monospace;color:#3f5650}
.posts{list-style:none;margin:0;padding:0}
.posts li{display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid #cdd8d2;font-size:15px}
.posts li span{font:12px "JetBrains Mono",monospace;color:#3f5650;white-space:nowrap}
.skills{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:14px}
.skills span{border-bottom:1px solid #9fb3aa}
.skills span.big{font-size:18px;font-weight:600}
.handnote{margin-top:24px;padding:12px 14px;border-left:3px solid var(--d1);background:#e5ebe7;font-size:14px}
/* ---------- ROOM 3 : as a person ---------- */
#person{background:var(--d3bg);color:var(--d3ink);min-height:100vh;font-family:"Libre Caslon Text",serif}
#person .wrap{max-width:980px;margin:0 auto;padding:36px 32px 56px}
#person h1{font-weight:400;font-style:italic;font-size:44px;line-height:1.1;margin:8px 0 8px;color:var(--d3)}
#person .intro{max-width:56ch;font-size:17px}
#person h3{font:500 12px "JetBrains Mono",monospace;text-transform:uppercase;letter-spacing:.1em;color:var(--d3);margin:40px 0 12px;display:flex;gap:10px;align-items:center}
#person h3::after{content:"";flex:1;height:1px;background:#d9c2b1}
.musings{display:grid;grid-template-columns:1fr 1fr;gap:0 36px}
.musing{border-top:1px solid #d9c2b1;padding:10px 0}
.musing button{background:none;border:0;padding:0;text-align:left;cursor:pointer;width:100%;font-family:inherit}
.musing .ti{font-size:19px;display:block}
.musing button:hover .ti{color:var(--d3)}
.musing .me{font:11px "JetBrains Mono",monospace;color:#8a6a5e}
.musing .ex{display:none;font-size:15px;margin:8px 0 0;color:#4a2c28}
.musing.open .ex{display:block}
.band{display:grid;grid-template-columns:1.1fr 1fr;gap:28px;align-items:start}
.player{background:#2b1614;color:#f4e9df;padding:16px 18px;font-family:"Archivo",sans-serif}
.player .now{display:flex;gap:14px;align-items:center}
.player .pp{width:44px;height:44px;border-radius:50%;border:0;background:#f4e9df;cursor:pointer;flex:none;position:relative}
.player .pp::after{content:"";position:absolute;left:17px;top:13px;border-left:14px solid #2b1614;border-top:9px solid transparent;border-bottom:9px solid transparent}
.player.playing .pp::after{left:15px;top:14px;width:4px;height:16px;border:0;background:#2b1614;box-shadow:9px 0 0 #2b1614}
.player .ttl{font-weight:600}
.player .sub{font-size:12px;opacity:.7}
.player .bar{height:4px;background:#5a3a35;margin:14px 0 4px;cursor:pointer;position:relative}
.player .bar i{position:absolute;left:0;top:0;bottom:0;background:#e3a07c;width:0}
.player .times{display:flex;justify-content:space-between;font:11px "JetBrains Mono",monospace;opacity:.7}
.player ol{list-style:none;margin:12px 0 0;padding:0;font-size:14px}
.player li{display:flex;justify-content:space-between;padding:6px 4px;border-top:1px solid #4a2c28;cursor:pointer}
.player li:hover{background:#3a201d}
.player li.cur{color:#e3a07c}
.player li span{font:12px "JetBrains Mono",monospace;opacity:.7}
.vids{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.vid{aspect-ratio:4/3;background:#3a201d;color:#d9c2b1;position:relative;display:flex;align-items:flex-end;padding:8px;font:11px "JetBrains Mono",monospace;border:0;cursor:pointer;text-align:left}
.vid::before{content:"";position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(255,255,255,.03) 0 2px,transparent 2px 4px)}
.vid::after{content:"";position:absolute;left:50%;top:42%;margin:-14px 0 0 -10px;border-left:22px solid #f4e9df;border-top:14px solid transparent;border-bottom:14px solid transparent;opacity:.8}
.vid:hover::after{opacity:1}
.vid.wide{grid-column:1/-1;aspect-ratio:16/7}
.finds{display:flex;flex-wrap:wrap;gap:8px}
.finds a{font-size:15px;text-decoration:none;border:1px solid #d9c2b1;padding:4px 10px;background:#f8f0e8}
.finds a:hover{border-color:var(--d3);color:var(--d3)}
.phnote{font:11px/1.6 "JetBrains Mono",monospace;opacity:.65;margin-top:36px}
@media (max-width:760px){
.hall,.wrap{padding-left:16px!important;padding-right:16px!important}
.doors{flex-direction:column;height:auto}
.door{min-height:150px}
.door .preview{display:none}
.hall .below,.across,.two,.musings,.band,#now .intro,.old{grid-template-columns:1fr}
.old aside{position:static}
.prow>button{grid-template-columns:1fr 18px}
.prow .k,.prow .a{display:none}
.prow .more{grid-template-columns:1fr}
.corridor .tabs a span.long,.corridor .keys{display:none}
.corridor .tabs a[aria-current]::after{display:none}
}
</style>
</head>
<body>
<div class="mnote" id="mnote"><button aria-label="dismiss" onclick="this.parentNode.remove()">×</button><b>nav-a · Three doors</b>A calm hall that opens into three rooms, each with its own look; hover a door to peek in, keys 1/2/3 to enter, Esc back. AI filter lives inside the project list. Borrows Brittany Chiang's restraint and ntch.design's keyboard shortcuts.</div>
<!-- ================= HALL ================= -->
<main id="hall" class="view">
<div class="hall">
<header>
<p class="name">Lucas Winther</p>
<span class="small">Computer scientist · <span class="ph" title="placeholder">Denmark</span></span>
</header>
<h1 class="lede">I build things by hand, and lately with AI. <i>I also play in a small band.</i></h1>
<p class="sub">The site has three rooms. Pick the one you came for; the others are always one door away.</p>
<nav class="doors" aria-label="Three spaces">
<a class="door d1" href="#archive" data-key="1">
<div><span class="num">01 · 2018–2021</span><h2>By hand, before</h2><span class="tag">The old portfolio, frozen as it was. Nothing in there touched an AI.</span></div>
<div class="preview" aria-hidden="true">
<div class="pv1">
<div class="side"><div class="av"></div>Lucas Winther<br>Computer Scientist with passion for development.</div>
<div class="grid"><div>Bachelor Report</div><div>Chessswap</div><div>Rowing Game</div><div>VR-Classroom</div><div>Student Dashboard</div><div>Censor Simulator</div></div>
<div class="cap">React + Material UI, 2021 · 11 projects</div>
</div>
</div>
<div class="enter"><span>Enter the archive</span><kbd>1</kbd></div>
</a>
<a class="door d2" href="#now" data-key="2">
<div><span class="num">02 · now</span><h2>With AI, now</h2><span class="tag">Projects, education, skills, and notes on AI, devops and the homelab.</span></div>
<div class="preview pv2" aria-hidden="true">
<ul>
<li>Event Tracker for Gacha Games <span class="ai">made with AI</span></li>
<li>Config (Ansible, IaC) <span class="ai">made with AI</span></li>
<li><span class="ph" title="placeholder">Homelab on three mini PCs</span> <span class="ai">some help</span></li>
<li>Bachelor Report: HyperCache <span class="ai">by hand</span></li>
<li style="opacity:.7">+ 3 posts on the blog</li>
</ul>
</div>
<div class="enter"><span>Enter the workshop</span><kbd>2</kbd></div>
</a>
<a class="door d3" href="#person" data-key="3">
<div><span class="num">03 · whenever</span><h2>As a person</h2><span class="tag">Musings, small findings, and handcam recordings from the band.</span></div>
<div class="preview pv3" aria-hidden="true">
<div class="strip"><span class="pl"></span><span class="bars"></span><span class="mono" style="font-size:11px">3:41</span></div>
<p class="ph" title="placeholder">“Live at the basement, March”</p>
<p class="ph" title="placeholder">On owning a rack in a flat</p>
<p class="ph" title="placeholder">Things I found this month</p>
</div>
<div class="enter"><span>Come in</span><kbd>3</kbd></div>
</a>
</nav>
<div class="below">
<div><b>Here to hire?</b> The workshop (02) has the projects and education. If you'd rather see work made without AI, the archive (01) is all by hand.</div>
<div><b>Just need the card?</b> A one-screen version lives at <a href="#" onclick="return false" title="mockup: links to /simple">lucaswinther.info/simple</a>, for sharing.</div>
<div><b>Keyboard:</b> <kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> open a room, <kbd>Esc</kbd> returns here, <kbd>←</kbd> <kbd>→</kbd> walk between rooms.</div>
</div>
<footer>
<span><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> · <a href="https://blog.lucaswinther.info">blog</a></span>
<span>Old site: <a href="#" onclick="return false" title="mockup">old.lucaswinther.info</a> · <a href="#" onclick="return false" title="mockup">/simple</a></span>
</footer>
</div>
</main>
<!-- ================= ROOM 1: ARCHIVE ================= -->
<section id="archive" class="view" data-room="archive">
<nav class="corridor" aria-label="Rooms"></nav>
<div class="wrap">
<div class="banner">
<span>You're in the antechamber of the 2021 site. Everything below was built by hand, before AI tools. The full, working copy is frozen at its own address.</span>
<a href="#" onclick="return false" title="mockup: opens old.lucaswinther.info">Open old.lucaswinther.info ↗</a>
</div>
<div class="old">
<aside>
<div class="av">photo: 2021</div>
<h3>Lucas Winther</h3>
<p>Computer Scientist with passion for development.</p>
<a href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a>
<a href="mailto:lucas@lucaswinther.info">Email</a>
<a href="https://github.com/StereotypicalCat">GitHub</a>
<a href="https://blog.lucaswinther.info">Blog</a>
</aside>
<div>
<label class="search"><input id="oldq" placeholder="Search projects" aria-label="Search old projects"><span>⌕</span></label>
<div class="oldgrid" id="oldgrid"></div>
</div>
</div>
<div class="across" data-across></div>
</div>
</section>
<!-- ================= ROOM 2: NOW ================= -->
<section id="now" class="view" data-room="now">
<nav class="corridor" aria-label="Rooms"></nav>
<div class="wrap">
<div class="intro">
<div>
<span class="mono" style="font-size:12px;color:var(--d2)">02 · with AI, now</span>
<h1>Systems, tooling and the occasional webapp.</h1>
<p>I have a bachelor in computer science and work as a <span class="ph" title="placeholder">platform engineer</span>. These days I build with AI tools and write down what holds up. Every project says how much AI went into it.</p>
</div>
<div class="facts">
<b>Now</b> <span class="ph" title="placeholder">Platform engineer, Copenhagen-ish</span><br>
<b>Studied</b> BSc Computer Science, <span class="ph" title="placeholder">DTU</span><br>
<b>Writes at</b> <a href="https://blog.lucaswinther.info">blog.lucaswinther.info</a><br>
<b>Code</b> <a href="https://github.com/StereotypicalCat">github.com/StereotypicalCat</a>
</div>
</div>
<h3>Projects</h3>
<div class="plist">
<div class="plhead">
<span class="count" id="pcount">13 projects</span>
<div class="aif" role="group" aria-label="AI filter">
<span class="lbl">AI involved:</span>
<button aria-pressed="true" data-ai="all">Any</button>
<button aria-pressed="false" data-ai="full"><span class="lvl full"></span>Made with AI</button>
<button aria-pressed="false" data-ai="some"><span class="lvl some"></span>Some help</button>
<button aria-pressed="false" data-ai="none"><span class="lvl none"></span>By hand</button>
</div>
</div>
<div id="prows"></div>
<p class="empty" id="pempty">Nothing here at that level yet.</p>
</div>
<p class="handnote">Want only work made without AI? The <a href="#archive">by-hand archive</a> is the 2021 site exactly as it was, or set the filter above to <em>By hand</em>.</p>
<div class="two">
<div>
<h3>Education &amp; work</h3>
<ul class="tl">
<li><span class="ph" title="placeholder">2023 →</span><b class="ph" title="placeholder">Platform engineer, small SaaS company</b>Kubernetes, Terraform, on-call.</li>
<li><span class="ph" title="placeholder">2018–2021</span><b>BSc Computer Science, <span class="ph" title="placeholder">DTU</span></b>Thesis: reputation systems in peer-to-peer archival (HyperCache).</li>
<li><span class="ph" title="placeholder">2015–2018</span><b class="ph" title="placeholder">HTX, high-school exam projects</b>Censor Simulator (VR), BreakoutPython.</li>
</ul>
</div>
<div>
<h3>Writing on the blog</h3>
<ul class="posts">
<li><a href="#" onclick="return false" class="ph" title="placeholder">What I let an agent touch in my homelab</a><span>Oct</span></li>
<li><a href="#" onclick="return false" class="ph" title="placeholder">Ansible, but the AI writes the roles</a><span>Aug</span></li>
<li><a href="#" onclick="return false" class="ph" title="placeholder">Scraping gacha banners without getting banned</a><span>May</span></li>
<li><a href="#" onclick="return false" class="ph" title="placeholder">Backups I have actually restored</a><span>Mar</span></li>
</ul>
<h3>Tools I reach for</h3>
<div class="skills"><span class="big">Ansible</span><span>Docker</span><span class="big">Node.js</span><span>React</span><span>Unity</span><span>C#</span><span class="big">Linux</span><span>Python</span><span>Arduino</span><span>Claude Code</span><span>Proxmox</span></div>
</div>
</div>
<div class="across" data-across></div>
</div>
</section>
<!-- ================= ROOM 3: PERSON ================= -->
<section id="person" class="view" data-room="person">
<nav class="corridor" aria-label="Rooms"></nav>
<div class="wrap">
<span class="mono" style="font-size:12px;color:var(--d3)">03 · as a person</span>
<h1>Off the clock.</h1>
<p class="intro">Musings, things I found, and the band: <span class="ph" title="placeholder">Kældermændene</span>, four of us playing small rooms. No albums, just phone-in-hand recordings from the front row.</p>
<h3>Musings</h3>
<div class="musings">
<div class="musing"><button><span class="ti ph" title="placeholder">On owning a rack in a flat</span><span class="me">essay · 6 min</span></button><p class="ex">It started with one Raspberry Pi in a drawer. Now there is a shelf that hums, and a partner who has opinions about the hum…</p></div>
<div class="musing"><button><span class="ti ph" title="placeholder">Why I still write some code by hand</span><span class="me">note · 2 min</span></button><p class="ex">Not out of principle. Some things I only understand once my fingers have typed them wrong twice.</p></div>
<div class="musing"><button><span class="ti ph" title="placeholder">A bassline I can't stop playing</span><span class="me">note · 1 min</span></button><p class="ex">Three notes, a rest, and a slide. It has turned up in four of our songs and nobody has noticed yet.</p></div>
<div class="musing"><button><span class="ti ph" title="placeholder">Cycling to rehearsal in February</span><span class="me">essay · 4 min</span></button><p class="ex">Amp on the back, cable around the neck, rain sideways. Worth it every time, mostly.</p></div>
</div>
<h3>The band</h3>
<div class="band">
<div class="player" id="player">
<div class="now"><button class="pp" id="pp" aria-label="Play"></button><div><div class="ttl" id="ptitle">Basement Song</div><div class="sub" id="psub">live · <span class="ph" title="placeholder">Café Retro, March</span></div></div></div>
<div class="bar" id="pbar"><i id="pfill"></i></div>
<div class="times"><span id="pcur">0:00</span><span id="pdur">3:41</span></div>
<ol id="plist"></ol>
</div>
<div class="vids">
<button class="vid wide">handcam: full set, <span class="ph" title="placeholder">Spillestedet, May</span> · 24:10</button>
<button class="vid">handcam: soundcheck · 2:03</button>
<button class="vid">photo: rehearsal room</button>
</div>
</div>
<h3>Small findings</h3>
<div class="finds">
<a href="#" onclick="return false" class="ph" title="placeholder">a good tuner app</a><a href="#" onclick="return false" class="ph" title="placeholder">kitchen-table cable tester</a><a href="#" onclick="return false" class="ph" title="placeholder">the best rye bread in town</a><a href="#" onclick="return false" class="ph" title="placeholder">an old synth manual, scanned</a><a href="#" onclick="return false" class="ph" title="placeholder">a mechanical keyboard that's quiet</a>
</div>
<div class="across" data-across></div>
<p class="phnote">Placeholders (dotted underline): band name, venues, songs, musings, blog titles, job, university, city, timeline years.</p>
</div>
</section>
<script>
const PROJECTS = [
{id:"gacha",t:"Event Tracker for Gacha Games",k:"scraping · webapp · JS",ai:"full",d:"Scrapes upcoming banners and events across several gacha games and shows them on one calendar.",links:["Live demo","GitHub"]},
{id:"config",t:"Config",k:"Ansible · IaC · docker-compose",ai:"full",d:"The whole homelab as code: Ansible roles and docker-compose stacks, deployed from one repo.",links:["GitHub"]},
{id:"homelab",t:"Homelab on three mini PCs",k:"Proxmox · k3s · backups",ai:"some",ph:1,d:"Placeholder project: cluster, monitoring and restore drills. AI helped with the docs and some scripts.",links:["Write-up"]},
{id:"site",t:"This site",k:"HTML · CSS · a little JS",ai:"some",ph:1,d:"Placeholder: designed by hand, built with an AI pair. The design notes are on the blog.",links:["Source"]},
{id:"thesis",t:"Bachelor Report (HyperCache)",k:"Node.js · P2P · research",ai:"none",v1:1,d:"Reputation systems in peer-to-peer archival networks.",links:["Thesis PDF"]},
{id:"dash",t:"Student Dashboard",k:"React · user studies",ai:"none",v1:1,d:"A dashboard shaped by user studies with students.",links:["Report PDF","Demo"]},
{id:"chess",t:"Chessswap",k:"React · game",ai:"none",v1:1,d:"Chess where the board swaps every few turns.",links:["Play","GitHub"]},
{id:"exp",t:"Monthly Expenses Tracker",k:"JS · webapp",ai:"none",v1:1,d:"Keeps track of monthly spending in DKK.",links:["Demo"]},
{id:"row",t:"Mixed-Reality Rowing Game",k:"Unity · Arduino · C#",ai:"none",v1:1,d:"Row through a virtual world with two real oars wired to an Arduino.",links:["Video"]},
{id:"vrc",t:"VR-Classroom",k:"Node.js · prototype",ai:"none",v1:1,d:"A prototype classroom in VR.",links:["Report","Video"]},
{id:"lol",t:"LoL Winrate Discord Bot",k:"Node.js · bot",ai:"none",v1:1,d:"Posts League of Legends win rates into Discord.",links:["GitHub"]},
{id:"censor",t:"Censor Simulator",k:"Unity · VR · high school",ai:"none",v1:1,d:"High-school exam project: you are the censor, in VR.",links:["Video"]},
{id:"breakout",t:"BreakoutPython",k:"Python · high school",ai:"none",v1:1,d:"High-school exam project: Breakout in Python.",links:["GitHub"]},
{id:"borlaug",t:"Tribute to Norman Borlaug",k:"HTML · freecodecamp",ai:"none",v1:1,d:"One of my first websites.",links:["Open"]},
{id:"port",t:"Portfolio (2021)",k:"React · Material UI",ai:"none",v1:1,d:"The old site itself, now the archive.",links:["old.lucaswinther.info"]}
];
const AILABEL={full:"made with AI",some:"some help",none:"by hand"};
const esc=s=>s.replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
/* ---- routing ---- */
const ROOMS=[["archive","By hand, before","01","t1","x1"],["now","With AI, now","02","t2","x2"],["person","As a person","03","t3","x3"]];
document.querySelectorAll(".corridor").forEach(c=>{
const here=c.parentNode.dataset.room;
c.innerHTML='<a class="home" href="#">◂ Hall</a><div class="tabs">'+ROOMS.map(r=>
`<a class="${r[3]}" href="#${r[0]}" ${r[0]===here?'aria-current="page"':''}><span class="mono" style="font-size:11px">${r[2]}</span><span class="long">${r[1]}</span></a>`).join("")+
'</div><span class="keys"><kbd>←</kbd> <kbd>→</kbd> next door · <kbd>Esc</kbd> hall</span>';
});
document.querySelectorAll("[data-across]").forEach(box=>{
const here=box.closest("[data-room]").dataset.room;
const blurbs={archive:"the 2021 site, all by hand",now:"projects, education, the AI blog",person:"musings and the band"};
box.innerHTML=ROOMS.filter(r=>r[0]!==here).map(r=>`<a class="${r[4]}" href="#${r[0]}"><small>next door · ${r[2]}</small><span>${r[1]}</span><br><small style="margin:4px 0 0">${blurbs[r[0]]}</small></a>`).join("");
});
function route(){
const h=location.hash.slice(1);
const id=["archive","now","person"].includes(h)?h:"hall";
document.querySelectorAll(".view").forEach(v=>v.classList.toggle("on",v.id===id));
document.title=id==="hall"?"Lucas Winther":"Lucas Winther · "+ROOMS.find(r=>r[0]===id)[1];
window.scrollTo(0,0);
}
addEventListener("hashchange",route);route();
addEventListener("keydown",e=>{
if(e.target.matches("input,textarea")||e.metaKey||e.ctrlKey||e.altKey)return;
const order=["","archive","now","person"];
const cur=order.indexOf(location.hash.slice(1));
if(["1","2","3"].includes(e.key)){location.hash=order[+e.key];}
else if(e.key==="Escape"){location.hash="";}
else if(e.key==="ArrowRight"&&cur>0){location.hash=order[cur%3+1];}
else if(e.key==="ArrowLeft"&&cur>0){location.hash=order[(cur+1)%3+1];}
});
/* ---- archive grid (search works) ---- */
const oldgrid=document.getElementById("oldgrid");
function renderOld(q){
q=(q||"").toLowerCase();
const list=PROJECTS.filter(p=>p.v1&&(p.t+p.k+p.d).toLowerCase().includes(q));
oldgrid.innerHTML=list.map(p=>`<div class="oc"><div class="img">screenshot: ${esc(p.t.split(" ")[0].toLowerCase())}</div><h4>${esc(p.t)}</h4><p>${esc(p.d)}</p><div class="chips">${p.k.split(" · ").map(c=>`<span>${esc(c)}</span>`).join("")}</div><a class="open" href="#" onclick="return false">${esc(p.links[0])}</a></div>`).join("")||'<p style="color:#9aa3ae">No projects match.</p>';
}
renderOld("");
document.getElementById("oldq").addEventListener("input",e=>renderOld(e.target.value));
/* ---- now: project list with AI filter ---- */
const prows=document.getElementById("prows");
prows.innerHTML=PROJECTS.map(p=>`<div class="prow" data-ai="${p.ai}" aria-expanded="false">
<button aria-label="${esc(p.t)}"><span class="t ${p.ph?"ph":""}" ${p.ph?'title="placeholder"':""}>${esc(p.t)}</span><span class="k">${esc(p.k)}</span><span class="a"><span class="lvl ${p.ai}"></span>${AILABEL[p.ai]}</span><span class="pm">+</span></button>
<div class="more"><div class="thumb">image: ${esc(p.t.toLowerCase())}</div><div><p>${esc(p.d)}</p><div class="links">${p.links.map(l=>`<a href="#" onclick="return false">${esc(l)} ↗</a>`).join("")}</div>${p.v1?'<p class="v1">From the 2021 site · <a href="#archive">see it in the archive</a></p>':""}</div></div></div>`).join("");
prows.addEventListener("click",e=>{
const b=e.target.closest(".prow>button"); if(!b)return;
const row=b.parentNode; row.setAttribute("aria-expanded",row.getAttribute("aria-expanded")==="true"?"false":"true");
});
document.querySelectorAll(".aif button").forEach(btn=>btn.addEventListener("click",()=>{
document.querySelectorAll(".aif button").forEach(b=>b.setAttribute("aria-pressed",b===btn));
const v=btn.dataset.ai;let n=0;
document.querySelectorAll(".prow").forEach(r=>{const show=v==="all"||r.dataset.ai===v;r.classList.toggle("hide",!show);if(show)n++;});
document.getElementById("pcount").textContent=n+(n===1?" project":" projects")+(v==="all"?"":" · "+AILABEL[v]);
document.getElementById("pempty").style.display=n?"none":"block";
}));
/* ---- person: musings expand ---- */
document.querySelectorAll(".musing button").forEach(b=>b.addEventListener("click",()=>b.parentNode.classList.toggle("open")));
/* ---- mock player ---- */
const SONGS=[["Basement Song",221,"Café Retro, March"],["Slow Bicycle",187,"rehearsal room"],["Too Many Cables",254,"Spillestedet, May"],["Untitled in D",142,"kitchen demo"]];
let si=0,pos=0,timer=null;
const $=id=>document.getElementById(id);
const fmt=s=>Math.floor(s/60)+":"+String(Math.floor(s%60)).padStart(2,"0");
function drawList(){$("plist").innerHTML=SONGS.map((s,i)=>`<li class="${i===si?"cur":""}" data-i="${i}"><span class="ph" title="placeholder" style="font:inherit;opacity:1">${s[0]}</span><span>${fmt(s[1])}</span></li>`).join("");}
function drawNow(){const s=SONGS[si];$("ptitle").textContent=s[0];$("psub").innerHTML='live · <span class="ph" title="placeholder">'+s[2]+'</span>';$("pdur").textContent=fmt(s[1]);$("pcur").textContent=fmt(pos);$("pfill").style.width=(pos/s[1]*100)+"%";}
function play(on){
clearInterval(timer);timer=null;$("player").classList.toggle("playing",on);$("pp").setAttribute("aria-label",on?"Pause":"Play");
if(on)timer=setInterval(()=>{pos++;if(pos>=SONGS[si][1]){pos=0;si=(si+1)%SONGS.length;drawList();}drawNow();},1000);
}
$("pp").addEventListener("click",()=>play(!timer));
$("plist").addEventListener("click",e=>{const li=e.target.closest("li");if(!li)return;si=+li.dataset.i;pos=0;drawList();drawNow();play(true);});
$("pbar").addEventListener("click",e=>{const r=e.currentTarget.getBoundingClientRect();pos=Math.floor((e.clientX-r.left)/r.width*SONGS[si][1]);drawNow();});
drawList();drawNow();
</script>
</body>
</html>
+391
View File
@@ -0,0 +1,391 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>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=Schibsted+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&display=swap" rel="stylesheet">
<style>
:root{--bg:#e4e6df;--bg2:#d8dbd2;--ink:#16201c;--mute:#5f6862;--rule:#bcc1b8;--acc:#d9472b;--paper:#f2f2ed}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 "Schibsted Grotesk",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
a{color:inherit;text-decoration-color:var(--rule);text-underline-offset:3px}
a:hover{text-decoration-color:var(--acc)}
button{font:inherit;color:inherit}
.ph{text-decoration:underline dotted;text-underline-offset:3px;cursor:help}
.serif{font-family:"Newsreader",Georgia,serif}
.mnote{position:fixed;top:10px;right:10px;z-index:99;max-width:300px;background:#fff;border:1px solid #bbb;padding:10px 30px 10px 12px;font:12px/1.45 system-ui,sans-serif;color:#222}
.mnote b{display:block;margin-bottom:2px}
.mnote button{position:absolute;top:4px;right:6px;border:0;background:none;cursor:pointer;font-size:16px;color:#666}
.page{max-width:1200px;margin:0 auto;display:grid;grid-template-columns:360px 1fr;gap:64px;padding:0 40px}
/* ---------- rail ---------- */
.rail{position:sticky;top:0;align-self:start;height:100vh;padding:56px 0 28px;display:flex;flex-direction:column}
.rail h1{font:600 30px/1.1 "Schibsted Grotesk",sans-serif;letter-spacing:-.02em;margin:0}
.rail .role{font-family:"Newsreader",serif;font-size:19px;margin:6px 0 30px;color:var(--mute)}
.lens{margin:0 0 22px}
.lens .q{font-family:"Newsreader",serif;font-style:italic;font-size:17px;color:var(--mute);margin:0 0 8px}
.dial{position:relative;display:flex;flex-direction:column;border-left:1px solid var(--rule);padding-left:0}
.dial button{background:none;border:0;text-align:left;padding:5px 0 5px 18px;cursor:pointer;font-size:22px;font-weight:500;letter-spacing:-.01em;color:#8c948e;transition:color .2s;display:flex;align-items:baseline;gap:10px}
.dial button small{font-size:12px;font-weight:400;color:var(--mute);opacity:0;transition:opacity .2s}
.dial button:hover{color:var(--ink)}
.dial button:hover small,.dial button[aria-checked=true] small{opacity:1}
.dial button[aria-checked=true]{color:var(--ink)}
.dial .mark{position:absolute;left:-2px;width:3px;background:var(--acc);transition:top .25s ease,height .25s ease}
.lens .hint{font-size:12px;color:var(--mute);margin-top:8px}
.aifilter{border-top:1px solid var(--rule);padding-top:14px;margin-bottom:20px}
.aifilter .q{font-size:13px;color:var(--mute);margin:0 0 8px}
.aiopts{display:flex;gap:4px;flex-wrap:wrap}
.aiopts button{border:1px solid var(--rule);background:transparent;padding:3px 9px;font-size:13px;cursor:pointer;display:inline-flex;gap:6px;align-items:center}
.aiopts button[aria-pressed=true]{background:var(--ink);color:var(--paper);border-color:var(--ink)}
.aiopts button:hover:not([aria-pressed=true]){border-color:var(--ink)}
.dots{display:inline-flex;gap:2px}
.dots i{width:6px;height:6px;border-radius:50%;border:1px solid currentColor}
.dots i.f{background:currentColor}
.aifilter.muted{opacity:.4}
.toc{list-style:none;margin:0;padding:0;font-size:14px;flex:1;overflow:auto}
.toc li{display:flex;align-items:center;gap:10px;padding:3px 0;color:var(--mute);transition:opacity .2s}
.toc li a{text-decoration:none}
.toc li a:hover{color:var(--ink)}
.toc li .n{font-variant-numeric:tabular-nums;font-size:11px;width:18px}
.toc li.cur{color:var(--ink);font-weight:500}
.toc li.cur::after{content:"";flex:1;height:1px;background:var(--ink);max-width:40px}
.toc li.off{opacity:.35;text-decoration:line-through;text-decoration-color:var(--rule)}
.rail .foot{font-size:13px;color:var(--mute);display:flex;flex-direction:column;gap:2px;border-top:1px solid var(--rule);padding-top:12px}
.rail .foot .links a{margin-right:12px}
/* ---------- main ---------- */
main{padding:56px 0 80px;display:flex;flex-direction:column;min-width:0}
section{padding:0 0 46px;scroll-margin-top:24px}
section.hidden{display:none}
section h2{font:600 12px "Schibsted Grotesk",sans-serif;text-transform:uppercase;letter-spacing:.12em;color:var(--mute);margin:0 0 14px;display:flex;gap:10px;align-items:center}
section h2 .why{font-weight:400;text-transform:none;letter-spacing:0;font-family:"Newsreader",serif;font-style:italic;font-size:14px}
.about p{font-family:"Newsreader",serif;font-size:23px;line-height:1.45;margin:0 0 12px;max-width:34em}
.about p.small{font-size:17px;color:var(--mute)}
.about em{color:var(--acc);font-style:normal}
/* projects table */
.ptable{border-top:1px solid var(--ink)}
.prow{border-bottom:1px solid var(--rule)}
.prow>button{width:100%;background:none;border:0;padding:10px 0;display:grid;grid-template-columns:52px 1fr 1fr 76px;gap:14px;text-align:left;cursor:pointer;align-items:baseline}
.prow>button:hover .t{color:var(--acc)}
.prow .y{font-size:13px;color:var(--mute);font-variant-numeric:tabular-nums}
.prow .t{font-weight:500;transition:color .15s}
.prow .k{font-size:13px;color:var(--mute)}
.prow .a{font-size:12px;color:var(--mute);display:flex;gap:6px;align-items:center;justify-content:flex-end}
.prow .more{display:none;padding:2px 0 16px 66px;grid-template-columns:1fr 180px;gap:20px}
.prow.open .more{display:grid}
.prow .more p{margin:0 0 8px;font-family:"Newsreader",serif;font-size:17px}
.prow .more .links a{margin-right:14px;font-size:14px}
.prow .more .fig{height:100px;background:var(--bg2);border:1px solid var(--rule);display:flex;align-items:flex-end;padding:6px;font-size:11px;color:var(--mute)}
.prow.f-out{display:none}
.pfoot{display:flex;justify-content:space-between;font-size:13px;color:var(--mute);padding-top:8px}
/* experience: horizontal timeline */
.htl{position:relative;height:120px;margin-top:6px}
.htl .axis{position:absolute;left:0;right:0;top:58px;height:1px;background:var(--ink)}
.htl .yr{position:absolute;top:64px;font-size:11px;color:var(--mute);transform:translateX(-50%)}
.htl .span{position:absolute;height:10px;top:53px;background:var(--ink);cursor:default}
.htl .span.alt{background:var(--acc)}
.htl .lab{position:absolute;top:0;font-size:13px;line-height:1.3;max-width:200px}
.htl .lab b{display:block;font-weight:500}
.htl .lab span{color:var(--mute);font-size:12px}
.skills{margin-top:18px;font-size:14px;color:var(--mute)}
.skills b{color:var(--ink);font-weight:500}
/* writing */
.wlist{list-style:none;margin:0;padding:0}
.wlist li{display:grid;grid-template-columns:60px 1fr auto;gap:14px;padding:9px 0;border-bottom:1px solid var(--rule);align-items:baseline}
.wlist .d{font-size:13px;color:var(--mute)}
.wlist a{font-family:"Newsreader",serif;font-size:19px;text-decoration:none}
.wlist a:hover{color:var(--acc)}
.wlist .tg{font-size:12px;color:var(--mute)}
/* music */
.music{display:grid;grid-template-columns:1fr 1fr;gap:26px}
.bars{display:flex;align-items:flex-end;gap:6px;height:130px;border-bottom:1px solid var(--ink);padding-bottom:0}
.bars button{flex:1;border:0;background:var(--bg2);cursor:pointer;position:relative;padding:0;transition:background .15s}
.bars button:hover{background:#c5cabf}
.bars button.cur{background:var(--ink)}
.bars button i{position:absolute;left:0;right:0;bottom:0;background:var(--acc);height:0}
.nowp{display:flex;align-items:center;gap:12px;margin-top:12px}
.pp{width:38px;height:38px;border-radius:50%;border:1px solid var(--ink);background:none;cursor:pointer;position:relative;flex:none}
.pp::after{content:"";position:absolute;left:14px;top:11px;border-left:12px solid var(--ink);border-top:7px solid transparent;border-bottom:7px solid transparent}
.pp.on::after{left:12px;top:12px;width:4px;height:12px;border:0;background:var(--ink);box-shadow:7px 0 0 var(--ink)}
.nowp .tt{font-weight:500}
.nowp .tm{font-size:12px;color:var(--mute);font-variant-numeric:tabular-nums}
.vid{aspect-ratio:16/10;background:#26302b;color:#c9cfc6;border:0;width:100%;cursor:pointer;position:relative;text-align:left;padding:10px;font-size:12px;display:flex;align-items:flex-end}
.vid::after{content:"";position:absolute;left:50%;top:45%;margin:-16px 0 0 -10px;border-left:26px solid var(--paper);border-top:16px solid transparent;border-bottom:16px solid transparent;opacity:.75}
.vid:hover::after{opacity:1}
.vid.small{aspect-ratio:auto;height:60px;margin-top:8px;font-size:11px}
.vid.small::after{transform:scale(.5);top:30%}
/* musings: index cards in a row */
.musings{display:grid;grid-template-columns:repeat(3,1fr);gap:0;border-top:1px solid var(--ink)}
.mus{padding:12px 14px 14px 0;border-right:1px solid var(--rule);margin-right:14px;cursor:pointer;background:none;border-top:0;border-bottom:0;border-left:0;text-align:left}
.mus:last-child{border-right:0}
.mus .tg{font-size:11px;color:var(--mute);text-transform:uppercase;letter-spacing:.08em}
.mus .ti{font-family:"Newsreader",serif;font-size:20px;line-height:1.25;display:block;margin:4px 0}
.mus .ex{font-size:14px;color:var(--mute);max-height:0;overflow:hidden;transition:max-height .25s}
.mus:hover .ex,.mus:focus .ex{max-height:120px}
.finds{font-family:"Newsreader",serif;font-size:19px;line-height:1.8}
.finds a{text-decoration:none;border-bottom:1px solid var(--rule)}
.finds a:hover{border-color:var(--acc)}
.finds span{color:var(--mute)}
/* archive + simple */
.doors2{display:grid;grid-template-columns:1.3fr 1fr;gap:12px}
.arch{background:#22262c;color:#dfe3e8;padding:18px;text-decoration:none;display:block;font-family:Roboto,system-ui,sans-serif}
.arch .tiny{display:grid;grid-template-columns:40px 1fr 1fr 1fr;gap:4px;margin:10px 0}
.arch .tiny i{height:26px;background:#353a42}
.arch .tiny i:first-child{grid-row:span 2;height:56px;background:#1b1e23}
.arch small{color:#90caf9}
.card{border:1px solid var(--ink);padding:18px;text-decoration:none;display:flex;flex-direction:column;justify-content:space-between;background:var(--paper)}
.card b{font-size:18px}
.card small{color:var(--mute)}
/* folded sections */
.folded{border:1px dashed var(--rule);padding:12px 14px;font-size:14px;color:var(--mute);margin-bottom:46px}
.folded:empty{display:none}
.folded button{background:none;border:0;border-bottom:1px solid var(--rule);padding:0;margin:0 6px;cursor:pointer;color:var(--ink)}
.folded button:hover{border-color:var(--acc)}
.contact p{font-family:"Newsreader",serif;font-size:21px;margin:0}
.phnote{font-size:12px;color:var(--mute);margin-top:10px}
@media (max-width:860px){
.page{grid-template-columns:1fr;gap:0;padding:0 18px}
.rail{position:static;height:auto;padding:36px 0 10px}
.toc{display:none}
.dial{flex-direction:row;flex-wrap:wrap;border-left:0;border-bottom:1px solid var(--rule)}
.dial button{padding:4px 14px 6px 0;font-size:18px}
.dial button small,.dial .mark{display:none}
.dial button[aria-checked=true]{box-shadow:inset 0 -3px 0 var(--acc)}
main{padding-top:20px}
.music,.doors2,.musings{grid-template-columns:1fr}
.mus{border-right:0;border-bottom:1px solid var(--rule)}
.prow>button{grid-template-columns:44px 1fr 60px}
.prow .k{display:none}
.prow .more{padding-left:0;grid-template-columns:1fr}
.htl .lab{max-width:120px;font-size:12px}
}
</style>
</head>
<body>
<div class="mnote"><button aria-label="dismiss" onclick="this.parentNode.remove()">×</button><b>nav-b · One page, switchable lens</b>One home page; the “I'm here for” switch reorders, folds and re-words sections for a recruiter, a friend, or everyone. AI filter sits right under it. Base from Brittany Chiang's sticky two-column layout, with beige.zone's tag filtering.</div>
<div class="page">
<aside class="rail">
<h1>Lucas Winther</h1>
<p class="role">Computer scientist, homelabber, bass player in a <span class="ph" title="placeholder">small band</span>.</p>
<div class="lens">
<p class="q" id="lensq">You're here for</p>
<div class="dial" role="radiogroup" aria-labelledby="lensq">
<span class="mark" aria-hidden="true"></span>
<button role="radio" data-lens="work" aria-checked="false">the work <small>projects, CV, tech writing</small></button>
<button role="radio" data-lens="all" aria-checked="true">all of it <small>the whole person</small></button>
<button role="radio" data-lens="life" aria-checked="false">life &amp; music <small>band, musings, findings</small></button>
</div>
<p class="hint">Press <b>L</b> to change. The lens stays in the link, so <span id="lenslink">lucaswinther.info/#for=all</span> opens like this.</p>
</div>
<div class="aifilter" id="aifilter">
<p class="q">Projects made</p>
<div class="aiopts" role="group" aria-label="AI filter">
<button aria-pressed="true" data-ai="all">any way</button>
<button aria-pressed="false" data-ai="full"><span class="dots"><i class="f"></i><i class="f"></i></span>with AI</button>
<button aria-pressed="false" data-ai="some"><span class="dots"><i class="f"></i><i></i></span>some AI</button>
<button aria-pressed="false" data-ai="none"><span class="dots"><i></i><i></i></span>by hand</button>
</div>
</div>
<ol class="toc" id="toc"></ol>
<div class="foot">
<span class="links"><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">Email</a><a href="https://blog.lucaswinther.info">Blog</a></span>
<span><a href="#archive">by hand, before ↓</a> · <a href="#" onclick="return false" title="mockup: /simple">/simple</a></span>
</div>
</aside>
<main id="main">
<section id="about" class="about" data-name="About" data-order='{"work":0,"all":0,"life":0}'>
<div data-copy="work"><p>I'm a computer scientist working as a <span class="ph" title="placeholder">platform engineer</span>. I automate infrastructure, build small webapps, and <em>use AI tools where they earn their place</em>, which every project below is honest about.</p><p class="small">Bachelor in computer science, <span class="ph" title="placeholder">DTU</span>. Most interested in devops, homelab and tooling roles.</p></div>
<div data-copy="all"><p>I build things by hand, and lately <em>with AI</em>. This is where both live, next to the band, the homelab and whatever I'm thinking about this month.</p><p class="small">Use the switch on the left if you only came for one side of it.</p></div>
<div data-copy="life"><p>Off the clock I play bass in <span class="ph" title="placeholder">Kældermændene</span>, four people in a basement making songs that mostly only exist as <em>handcam recordings</em>. I also write things down.</p><p class="small">The work is still here, folded away at the bottom.</p></div>
</section>
<section id="projects" data-name="Projects" data-order='{"work":1,"all":1,"life":-1}'>
<h2>Projects <span class="why" data-why="work">the ones worth asking about are first</span></h2>
<div class="ptable" id="ptable"></div>
<div class="pfoot"><span id="pcount"></span><span>Years marked <span class="ph" title="placeholder">dotted</span> are guesses</span></div>
</section>
<section id="experience" data-name="Experience" data-order='{"work":2,"all":3,"life":-1}'>
<h2>Experience &amp; education</h2>
<div class="htl" id="htl"></div>
<p class="skills"><b>Daily:</b> Ansible, Docker, Linux, Node.js, Claude Code · <b>Comfortable:</b> React, Python, Proxmox · <b>Once upon a time:</b> Unity, C#, Arduino</p>
</section>
<section id="techwriting" data-name="Writing on tech" data-order='{"work":3,"all":4,"life":-1}'>
<h2>Writing on tech <span class="why">from blog.lucaswinther.info</span></h2>
<ul class="wlist">
<li><span class="d">Oct</span><a href="#" onclick="return false" class="ph" title="placeholder">What I let an agent touch in my homelab</a><span class="tg">AI · homelab</span></li>
<li><span class="d">Aug</span><a href="#" onclick="return false" class="ph" title="placeholder">Ansible, but the AI writes the roles</a><span class="tg">devops</span></li>
<li><span class="d">May</span><a href="#" onclick="return false" class="ph" title="placeholder">Scraping gacha banners politely</a><span class="tg">scraping</span></li>
</ul>
</section>
<section id="music" data-name="Music" data-order='{"work":-1,"all":2,"life":1}'>
<h2>The band <span class="why">click a bar to play a song; height is length</span></h2>
<div class="music">
<div>
<div class="bars" id="bars"></div>
<div class="nowp"><button class="pp" id="pp" aria-label="Play"></button><div><div class="tt ph" title="placeholder" id="ptt"></div><div class="tm"><span id="pcur">0:00</span> / <span id="pdur"></span> · <span id="pwhere" class="ph" title="placeholder"></span></div></div></div>
</div>
<div>
<button class="vid">handcam: full set, <span class="ph" title="placeholder">&nbsp;Huset, May</span> · 24:10</button>
<button class="vid small">handcam: the encore nobody asked for · 3:02</button>
</div>
</div>
</section>
<section id="musings" data-name="Musings" data-order='{"work":-1,"all":5,"life":2}'>
<h2>Musings <span class="why">hover to read the start</span></h2>
<div class="musings">
<button class="mus"><span class="tg">essay</span><span class="ti ph" title="placeholder">On owning a rack in a flat</span><span class="ex">It started with one Raspberry Pi in a drawer. Now there's a shelf that hums.</span></button>
<button class="mus"><span class="tg">note</span><span class="ti ph" title="placeholder">Why I still write some code by hand</span><span class="ex">Some things I only understand once my fingers have typed them wrong twice.</span></button>
<button class="mus"><span class="tg">note</span><span class="ti ph" title="placeholder">Cycling to rehearsal in February</span><span class="ex">Amp on the back, cable around the neck, rain sideways.</span></button>
</div>
</section>
<section id="findings" data-name="Findings" data-order='{"work":-1,"all":6,"life":3}'>
<h2>Findings</h2>
<p class="finds ph" title="placeholder"><a href="#" onclick="return false">a quiet mechanical keyboard</a> <span>/</span> <a href="#" onclick="return false">an old synth manual, scanned</a> <span>/</span> <a href="#" onclick="return false">rye bread worth the detour</a> <span>/</span> <a href="#" onclick="return false">a tuner app with no ads</a> <span>/</span> <a href="#" onclick="return false">this talk on boring tech</a></p>
</section>
<section id="archive" data-name="By hand, before" data-order='{"work":4,"all":7,"life":4}'>
<h2>Elsewhere</h2>
<div class="doors2">
<a class="arch" href="#" onclick="return false" title="mockup: old.lucaswinther.info"><small>old.lucaswinther.info ↗</small><div class="tiny"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div><b>By hand, before.</b> The 2021 React site, frozen. Eleven projects, none of them touched by AI. Start here if that's what you'd rather see.</a>
<a class="card" href="#" onclick="return false" title="mockup: /simple"><small>lucaswinther.info/simple</small><b>The one-card version</b><small>Name, links, one line. For sharing in a chat.</small></a>
</div>
</section>
<div class="folded" id="folded"></div>
<section id="contact" class="contact" data-name="Contact" data-order='{"work":30,"all":30,"life":30}'>
<h2>Say hello</h2>
<p><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a></p>
<p class="phnote">Placeholders (dotted underline): band name, venues, songs, musings, blog titles, job, university, years in the timeline.</p>
</section>
</main>
</div>
<script>
const PROJECTS=[
{t:"Event Tracker for Gacha Games",k:"scraping, webapp, JS",y:"2024",ai:"full",w:1,d:"Scrapes upcoming banners and events for several gacha games onto one calendar.",l:["Live demo","GitHub"]},
{t:"Config",k:"Ansible, IaC, docker-compose",y:"2025",ai:"full",w:2,d:"The homelab as code: Ansible roles and compose stacks, deployed from one repo.",l:["GitHub"]},
{t:"Homelab on three mini PCs",k:"Proxmox, k3s",y:"2025",ai:"some",w:3,ph:1,d:"Placeholder: cluster, monitoring, restore drills. AI wrote the first drafts of the runbooks.",l:["Write-up"]},
{t:"Bachelor Report (HyperCache)",k:"Node.js, P2P, research",y:"2021",ai:"none",w:4,d:"Reputation systems in peer-to-peer archival networks.",l:["Thesis PDF"]},
{t:"Student Dashboard",k:"React, user studies",y:"2020",ai:"none",w:5,d:"A dashboard built from user studies with students.",l:["Report PDF","Demo"]},
{t:"Mixed-Reality Rowing Game",k:"Unity, Arduino, C#",y:"2020",ai:"none",w:6,d:"Row through a virtual lake with two real oars wired to an Arduino.",l:["Video"]},
{t:"VR-Classroom",k:"Node.js prototype",y:"2020",ai:"none",w:7,d:"A prototype classroom in VR.",l:["Report","Video"]},
{t:"Chessswap",k:"React",y:"2019",ai:"none",w:8,d:"Chess where the board swaps every few turns.",l:["Play"]},
{t:"Monthly Expenses Tracker",k:"JS webapp",y:"2019",ai:"none",w:9,d:"Tracks monthly spending in DKK.",l:["Demo"]},
{t:"LoL Winrate Discord Bot",k:"Node.js",y:"2019",ai:"none",w:10,d:"Posts League of Legends win rates into Discord.",l:["GitHub"]},
{t:"Censor Simulator",k:"Unity, VR, high school",y:"2017",ai:"none",w:11,d:"High-school exam project: be the exam censor, in VR.",l:["Video"]},
{t:"BreakoutPython",k:"Python, high school",y:"2016",ai:"none",w:12,d:"High-school exam project: Breakout.",l:["GitHub"]},
{t:"Tribute to Norman Borlaug",k:"HTML, freecodecamp",y:"2016",ai:"none",w:13,d:"One of my first websites.",l:["Open"]},
{t:"Portfolio",k:"React, Material UI",y:"2021",ai:"none",w:14,d:"The old site, now the by-hand archive.",l:["old.lucaswinther.info"]}
];
const DOTS={full:'<i class="f"></i><i class="f"></i>',some:'<i class="f"></i><i></i>',none:'<i></i><i></i>'};
const AIL={full:"with AI",some:"some AI",none:"by hand"};
const esc=s=>s.replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const $=id=>document.getElementById(id);
/* projects */
$("ptable").innerHTML=PROJECTS.map((p,i)=>`<div class="prow" data-ai="${p.ai}" data-i="${i}">
<button><span class="y ph" title="placeholder: year">${p.y}</span><span class="t ${p.ph?"ph":""}" ${p.ph?'title="placeholder"':""}>${esc(p.t)}</span><span class="k">${esc(p.k)}</span><span class="a"><span class="dots">${DOTS[p.ai]}</span>${AIL[p.ai]}</span></button>
<div class="more"><div><p>${esc(p.d)}</p><div class="links">${p.l.map(x=>`<a href="#" onclick="return false">${esc(x)} ↗</a>`).join("")}</div></div><div class="fig">fig. ${String(i+1).padStart(2,"0")} · screenshot</div></div></div>`).join("");
$("ptable").addEventListener("click",e=>{const b=e.target.closest(".prow>button");if(b)b.parentNode.classList.toggle("open");});
let aiSel="all";
function applyAI(){let n=0;document.querySelectorAll(".prow").forEach(r=>{const s=aiSel==="all"||r.dataset.ai===aiSel;r.classList.toggle("f-out",!s);if(s)n++;});
$("pcount").textContent=n+" of "+PROJECTS.length+" projects"+(aiSel==="all"?"":", made "+AIL[aiSel]);}
document.querySelectorAll(".aiopts button").forEach(b=>b.addEventListener("click",()=>{
aiSel=b.dataset.ai;document.querySelectorAll(".aiopts button").forEach(x=>x.setAttribute("aria-pressed",x===b));applyAI();
const sec=$("projects");if(sec.classList.contains("hidden")){reveal.add("projects");applyLens();}
}));
applyAI();
/* timeline */
(function(){const a=2015,b=2027,pct=y=>((y-a)/(b-a)*100)+"%";
const items=[[2015.6,2018.5,"HTX","exam projects in Unity & Python",0,1],[2018.6,2021.5,"BSc Computer Science","DTU · thesis on P2P archival",0,0],[2021.7,2023.2,"Junior developer","agency, mostly Node",1,1],[2023.2,2026.9,"Platform engineer","Kubernetes, Ansible, on-call",0,0]];
let h='<div class="axis"></div>';for(let y=2016;y<=2026;y+=2)h+=`<span class="yr" style="left:${pct(y)}">${y}</span>`;
items.forEach((it,i)=>{h+=`<span class="span ${it[4]?"alt":""}" style="left:${pct(it[0])};width:calc(${pct(it[1])} - ${pct(it[0])})" title="${it[2]}"></span><span class="lab ph" title="placeholder" style="left:${pct(it[0])};top:${i%2?"84px":"0"}"><b>${it[2]}</b><span>${it[3]}</span></span>`;});
$("htl").innerHTML=h;$("htl").style.height="130px";})();
/* lens */
const LENSES=["work","all","life"];
const sections=[...document.querySelectorAll("main section")];
const reveal=new Set();
let lens=(location.hash.match(/for=(\w+)/)||[])[1];if(!LENSES.includes(lens))lens="all";
function applyLens(){
document.querySelectorAll(".dial button").forEach(b=>b.setAttribute("aria-checked",b.dataset.lens===lens));
const cur=document.querySelector('.dial button[aria-checked=true]'),mark=document.querySelector(".dial .mark");
if(cur){mark.style.top=cur.offsetTop+"px";mark.style.height=cur.offsetHeight+"px";}
document.querySelectorAll("[data-copy]").forEach(c=>c.hidden=c.dataset.copy!==lens);
document.querySelectorAll("[data-why]").forEach(c=>c.hidden=c.dataset.why!==lens);
const folded=[];
sections.forEach(s=>{const o=JSON.parse(s.dataset.order)[lens];const hide=o<0&&!reveal.has(s.id);
s.classList.toggle("hidden",hide);s.style.order=o<0?20:o;if(hide)folded.push(s);});
$("folded").style.order=25;
$("folded").innerHTML=folded.length?`Folded away by this lens: ${folded.map(s=>`<button data-show="${s.id}">${s.dataset.name}</button>`).join("")}<span style="margin-left:6px">or <button data-lens-set="all">see all of it</button></span>`:"";
// project order: work lens sorts by relevance, others by year
const tb=$("ptable");
const ordered=[...tb.querySelectorAll(".prow")].map(el=>({p:PROJECTS[+el.dataset.i],el}));
ordered.sort((A,B)=>lens==="work"?A.p.w-B.p.w:(+B.p.y-+A.p.y));
ordered.forEach(o=>tb.appendChild(o.el));
$("aifilter").classList.toggle("muted",lens==="life"&&!reveal.has("projects"));
// toc
const vis=sections.filter(s=>!s.classList.contains("hidden")).sort((a,b)=>a.style.order-b.style.order);
const hid=sections.filter(s=>s.classList.contains("hidden"));
$("toc").innerHTML=vis.map((s,i)=>`<li data-id="${s.id}"><span class="n">${String(i+1).padStart(2,"0")}</span><a href="#${s.id}">${s.dataset.name}</a></li>`).join("")+hid.map(s=>`<li class="off"><span class="n">–</span>${s.dataset.name}</li>`).join("");
$("lenslink").textContent="lucaswinther.info/#for="+lens;
history.replaceState(null,"","#for="+lens);
spy();
}
// keep tbody rows tied to PROJECTS index
document.querySelectorAll(".prow").forEach((r,i)=>r.dataset.i=i);
document.querySelector(".dial").addEventListener("click",e=>{const b=e.target.closest("button");if(!b)return;lens=b.dataset.lens;reveal.clear();applyLens();window.scrollTo({top:0});});
$("folded").addEventListener("click",e=>{const b=e.target.closest("button");if(!b)return;
if(b.dataset.show){reveal.add(b.dataset.show);applyLens();$(b.dataset.show).scrollIntoView();}
else if(b.dataset.lensSet){lens="all";reveal.clear();applyLens();}});
addEventListener("keydown",e=>{if(e.metaKey||e.ctrlKey||e.altKey||e.target.matches("input,textarea"))return;
if(e.key==="l"||e.key==="L"){lens=LENSES[(LENSES.indexOf(lens)+1)%3];reveal.clear();applyLens();}});
/* scroll spy for toc */
function spy(){let cur=null;sections.forEach(s=>{if(!s.classList.contains("hidden")&&s.getBoundingClientRect().top<160)cur=cur&&cur.getBoundingClientRect().top>s.getBoundingClientRect().top?cur:s;});
document.querySelectorAll("#toc li").forEach(li=>li.classList.toggle("cur",cur&&li.dataset.id===cur.id));}
addEventListener("scroll",spy,{passive:true});
addEventListener("resize",applyLens);
/* music */
const SONGS=[["Basement Song",221,"Café Retro, March"],["Slow Bicycle",187,"rehearsal room"],["Too Many Cables",254,"Huset, May"],["Untitled in D",142,"kitchen demo"],["The Long One",338,"Huset, May"],["Encore",182,"Café Retro, March"]];
const max=Math.max(...SONGS.map(s=>s[1]));let si=0,pos=0,tm=null;
const fmt=s=>Math.floor(s/60)+":"+String(Math.floor(s%60)).padStart(2,"0");
$("bars").innerHTML=SONGS.map((s,i)=>`<button style="height:${s[1]/max*100}%" data-i="${i}" title="${s[0]} · ${fmt(s[1])}" aria-label="${s[0]}"><i></i></button>`).join("");
function draw(){const s=SONGS[si];$("ptt").textContent=s[0];$("pdur").textContent=fmt(s[1]);$("pcur").textContent=fmt(pos);$("pwhere").textContent=s[2];
document.querySelectorAll("#bars button").forEach((b,i)=>{b.classList.toggle("cur",i===si);b.firstChild.style.height=i===si?(pos/s[1]*100)+"%":"0";});}
function play(on){clearInterval(tm);tm=null;$("pp").classList.toggle("on",on);$("pp").setAttribute("aria-label",on?"Pause":"Play");
if(on)tm=setInterval(()=>{pos++;if(pos>=SONGS[si][1]){pos=0;si=(si+1)%SONGS.length;}draw();},1000);}
$("pp").onclick=()=>play(!tm);
$("bars").onclick=e=>{const b=e.target.closest("button");if(!b)return;si=+b.dataset.i;pos=0;draw();play(true);};
draw();
applyLens();
</script>
</body>
</html>
+372
View File
@@ -0,0 +1,372 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther · garden</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=Young+Serif&family=IBM+Plex+Sans:ital,wght@0,400;0,500;1,400&family=IBM+Plex+Mono:wght@400;500&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#1f231c;--bg2:#272c23;--bg3:#30362b;--ink:#e9e3d3;--mute:#9a9d8c;--rule:#3d4436;--moss:#a7b86c;--rust:#d4824f;--sky:#9bb7b0}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 "IBM Plex Sans",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
a{color:inherit}
button{font:inherit;color:inherit}
.mono{font-family:"IBM Plex Mono",ui-monospace,monospace}
.ph{text-decoration:underline dotted;text-decoration-color:var(--mute);text-underline-offset:3px;cursor:help}
.mnote{position:fixed;top:10px;right:10px;z-index:99;max-width:300px;background:#fff;border:1px solid #bbb;padding:10px 30px 10px 12px;font:12px/1.45 system-ui,sans-serif;color:#222}
.mnote b{display:block;margin-bottom:2px}
.mnote button{position:absolute;top:4px;right:6px;border:0;background:none;cursor:pointer;font-size:16px;color:#666}
.wrap{max-width:1240px;margin:0 auto;padding:0 32px}
header.top{display:flex;justify-content:space-between;align-items:center;gap:20px;padding:22px 0 18px;border-bottom:1px solid var(--rule)}
.brand{font-family:"Young Serif",serif;font-size:21px;text-decoration:none}
.brand small{font:12px "IBM Plex Mono",monospace;color:var(--mute);margin-left:10px}
.topr{display:flex;gap:16px;align-items:center;font-size:13px}
.topr a{text-decoration:none;color:var(--mute)}
.topr a:hover{color:var(--ink)}
.findbtn{display:flex;align-items:center;gap:10px;background:var(--bg2);border:1px solid var(--rule);padding:6px 10px;cursor:pointer;color:var(--mute);min-width:220px}
.findbtn:hover{border-color:var(--mute);color:var(--ink)}
kbd{font:11px "IBM Plex Mono",monospace;border:1px solid var(--rule);padding:1px 5px;color:var(--mute);background:var(--bg)}
.findbtn kbd{margin-left:auto}
.simple{border:1px solid var(--moss);color:var(--moss)!important;padding:3px 8px}
.intro{display:grid;grid-template-columns:1.3fr 1fr;gap:48px;padding:34px 0 24px;align-items:end}
.intro h1{font:400 34px/1.2 "Young Serif",serif;margin:0;max-width:22ch}
.intro h1 em{font-style:normal;color:var(--moss)}
.intro p{color:var(--mute);margin:12px 0 0;max-width:56ch}
.legend{font-size:13px;color:var(--mute);display:grid;grid-template-columns:auto 1fr;gap:6px 12px;align-items:center}
.legend b{color:var(--ink);font-weight:500}
.paths{display:flex;gap:8px;flex-wrap:wrap;align-items:center;font-size:13px;color:var(--mute);padding-bottom:14px}
.paths button{background:none;border:0;border-bottom:1px dashed var(--mute);padding:0 0 1px;cursor:pointer;color:var(--ink)}
.paths button:hover{border-color:var(--moss);color:var(--moss)}
.filters{position:sticky;top:0;z-index:10;background:var(--bg);border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);display:flex;gap:28px;flex-wrap:wrap;padding:10px 0;font-size:13px}
.fg{display:flex;gap:2px;align-items:center;flex-wrap:wrap}
.fg>span{font:11px "IBM Plex Mono",monospace;color:var(--mute);text-transform:uppercase;letter-spacing:.08em;margin-right:8px}
.fg button{background:none;border:1px solid transparent;padding:3px 8px;cursor:pointer;color:var(--mute);display:inline-flex;gap:6px;align-items:center}
.fg button:hover{color:var(--ink)}
.fg button[aria-pressed=true]{color:var(--bg);background:var(--ink)}
.fg button sup{font:10px "IBM Plex Mono",monospace;opacity:.7}
.fstate{margin-left:auto;font:12px "IBM Plex Mono",monospace;color:var(--mute);display:flex;gap:10px;align-items:center}
.fstate button{background:none;border:0;color:var(--rust);cursor:pointer;padding:0;text-decoration:underline}
/* marks */
.mat{width:16px;height:16px;flex:none;vertical-align:-3px}
.aim{display:inline-block;width:11px;height:11px;border-radius:50%;border:1.5px solid currentColor;flex:none;vertical-align:-1px}
.aim.full{background:currentColor}
.aim.some{background:linear-gradient(90deg,currentColor 50%,transparent 50%)}
/* garden */
.garden{columns:4 250px;column-gap:18px;padding:22px 0 30px}
.e{break-inside:avoid;margin:0 0 18px;display:block;text-decoration:none;position:relative;border-top:1px solid var(--rule);padding:10px 0 4px;outline:none}
.e:hover,.e:focus-visible{border-top-color:var(--ink)}
.e.sel{box-shadow:0 0 0 1px var(--moss);padding-left:8px;padding-right:8px}
.e .meta{display:flex;gap:8px;align-items:center;font:11px "IBM Plex Mono",monospace;color:var(--mute);margin-bottom:6px}
.e .meta .ty{text-transform:uppercase;letter-spacing:.08em}
.e .meta .r{margin-left:auto;display:flex;gap:6px;align-items:center}
.e h3{margin:0 0 4px;font-weight:500;font-size:16px;line-height:1.3}
.e p{margin:0;color:var(--mute);font-size:14px}
.e .stack{font:12px "IBM Plex Mono",monospace;color:var(--sky);margin-top:6px}
.e.project h3{font-family:"Young Serif",serif;font-weight:400;font-size:19px}
.e.essay h3{font-family:"Young Serif",serif;font-weight:400;font-size:22px}
.e.essay{padding-bottom:8px}
.e.note h3{font-size:15px}
.e.note{background:var(--bg2);border-top:0;padding:10px 12px}
.e.music{background:var(--bg3);border-top:0;padding:10px 12px 12px}
.e.music .wave{display:flex;align-items:center;gap:10px;margin-top:8px}
.e.music .wave svg{flex:1;height:26px}
.mp{width:28px;height:28px;border-radius:50%;border:1px solid var(--ink);background:none;cursor:pointer;position:relative;flex:none}
.mp::after{content:"";position:absolute;left:10px;top:7px;border-left:10px solid var(--ink);border-top:6px solid transparent;border-bottom:6px solid transparent}
.mp.on::after{left:9px;top:8px;width:3px;height:10px;border:0;background:var(--ink);box-shadow:6px 0 0 var(--ink)}
.e.music .tm{font:11px "IBM Plex Mono",monospace;color:var(--mute);width:72px;text-align:right}
.e.video .frame{aspect-ratio:16/9;background:#141711;margin-top:8px;position:relative;display:flex;align-items:flex-end;padding:6px;font:11px "IBM Plex Mono",monospace;color:var(--mute)}
.e.video .frame::after{content:"";position:absolute;left:50%;top:42%;margin:-12px 0 0 -8px;border-left:20px solid var(--ink);border-top:12px solid transparent;border-bottom:12px solid transparent;opacity:.7}
.e.v1{border:1px dashed #56604d;padding:10px 12px;font-family:Roboto,sans-serif}
.e.v1 h3{font-family:Roboto,sans-serif;font-weight:500;font-size:15px}
.e.v1 .stamp{display:inline-block;font:10px "IBM Plex Mono",monospace;color:var(--rust);border:1px solid var(--rust);padding:0 5px;transform:rotate(-2deg)}
.e.v1 .go{font:12px "IBM Plex Mono",monospace;color:var(--mute);margin-top:6px;display:block}
.e.v1:hover .go{color:var(--rust)}
.e.hide{display:none}
.none{display:none;padding:30px 0;color:var(--mute);font-family:"Young Serif",serif;font-size:20px}
/* hover preview */
.pv{position:fixed;z-index:30;width:300px;background:var(--ink);color:var(--bg);padding:12px;pointer-events:none;opacity:0;transition:opacity .12s;font-size:13px}
.pv.on{opacity:1}
.pv .img{height:120px;background:repeating-linear-gradient(135deg,#d9d2bf 0 7px,#cfc8b4 7px 14px);display:flex;align-items:flex-end;padding:6px;font:10px "IBM Plex Mono",monospace;color:#5c5a4d;margin-bottom:10px}
.pv b{font-family:"Young Serif",serif;font-weight:400;font-size:17px;display:block;margin-bottom:4px}
.pv p{margin:0 0 8px}
.pv .mm{font:11px "IBM Plex Mono",monospace;color:#5c5a4d;display:flex;justify-content:space-between}
/* quick find */
.qf{position:fixed;inset:0;z-index:50;background:rgba(15,17,13,.6);display:none;align-items:flex-start;justify-content:center;padding-top:12vh}
.qf.on{display:flex}
.qbox{width:min(620px,92vw);background:var(--ink);color:var(--bg);box-shadow:0 0 0 1px #000}
.qbox input{width:100%;border:0;border-bottom:1px solid #c9c2ae;background:none;padding:16px 18px;font:18px "IBM Plex Sans",sans-serif;outline:none;color:var(--bg)}
.qres{list-style:none;margin:0;padding:6px 0;max-height:52vh;overflow:auto}
.qres li{display:flex;gap:12px;align-items:center;padding:7px 18px;cursor:pointer}
.qres li .ty{font:10px "IBM Plex Mono",monospace;text-transform:uppercase;letter-spacing:.08em;color:#6b6958;width:64px;flex:none}
.qres li .sub{margin-left:auto;font:11px "IBM Plex Mono",monospace;color:#6b6958;white-space:nowrap}
.qres li.act{background:var(--bg);color:var(--ink)}
.qres li.act .ty,.qres li.act .sub{color:var(--mute)}
.qres li mark{background:none;color:inherit;text-decoration:underline;text-decoration-color:var(--rust);text-decoration-thickness:2px}
.qres .grp{font:10px "IBM Plex Mono",monospace;color:#6b6958;padding:8px 18px 2px;text-transform:uppercase;letter-spacing:.1em;cursor:default}
.qfoot{display:flex;gap:16px;padding:8px 18px;border-top:1px solid #c9c2ae;font:11px "IBM Plex Mono",monospace;color:#6b6958}
.qfoot kbd{background:none;color:#6b6958;border-color:#b5ae99}
footer{border-top:1px solid var(--rule);padding:22px 0 40px;display:grid;grid-template-columns:repeat(3,1fr);gap:28px;font-size:13px;color:var(--mute)}
footer b{display:block;color:var(--ink);font-weight:500;margin-bottom:4px;font-family:"Young Serif",serif;font-size:16px;font-weight:400}
footer a{color:var(--ink)}
@media (max-width:760px){
.wrap{padding:0 16px}
header.top{flex-wrap:wrap}
.findbtn{min-width:0;flex:1}
.intro,footer{grid-template-columns:1fr}
.filters{gap:10px;position:static}
.fstate{margin-left:0}
}
</style>
</head>
<body>
<div class="mnote"><button aria-label="dismiss" onclick="this.parentNode.remove()">×</button><b>nav-c · One garden, many filters</b>Projects, writing, music and old v1 work in one collection, filtered by type, side and AI level, with maturity marks and hover previews. Press / or Ctrl/⌘K to find anything. From Maggie Appleton's garden, ntch.design's shortcuts and beige.zone's tag filters.</div>
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="m-seed" viewBox="0 0 16 16"><path d="M8 15V8" stroke="currentColor" stroke-width="1.4" fill="none"/><path d="M8 9C8 6 6 4.5 3.5 4.5 3.5 7 5.5 9 8 9Z" fill="currentColor"/></symbol>
<symbol id="m-bud" viewBox="0 0 16 16"><path d="M8 15V7" stroke="currentColor" stroke-width="1.4" fill="none"/><path d="M8 10C8 7.5 6.2 6 3.8 6 3.8 8.4 5.6 10 8 10Z M8 8.5C8 6 9.8 4.5 12.2 4.5 12.2 7 10.4 8.5 8 8.5Z" fill="currentColor"/><circle cx="8" cy="4.5" r="2" fill="currentColor"/></symbol>
<symbol id="m-ever" viewBox="0 0 16 16"><path d="M8 1.5 13 9H10.5L13.5 13H2.5L5.5 9H3Z" fill="currentColor"/><path d="M8 13v2.5" stroke="currentColor" stroke-width="1.4"/></symbol>
</svg>
<div class="wrap">
<header class="top">
<a class="brand" href="#">Lucas Winther<small>garden · <span class="ph" title="placeholder">updated 3 Nov</span></small></a>
<div class="topr">
<button class="findbtn" id="openqf">⌕ Find anything <kbd>/</kbd><kbd id="modk">Ctrl K</kbd></button>
<a href="#" onclick="return false" title="mockup: old.lucaswinther.info">old site ↗</a>
<a class="simple" href="#" onclick="return false" title="mockup: one-card version">/simple</a>
</div>
</header>
<section class="intro">
<div>
<h1>Things I've made <em>by hand</em> and <em>with AI</em>, things I've written, and songs from a basement.</h1>
<p>Everything is in one bed. Nothing here is finished in the same way: seedlings are rough, evergreens are things I'd stand behind. Every project says how much AI was in it.</p>
</div>
<div class="legend">
<svg class="mat" style="color:var(--moss)"><use href="#m-seed"/></svg><span><b>Seedling</b> rough, just planted</span>
<svg class="mat" style="color:var(--moss)"><use href="#m-bud"/></svg><span><b>Budding</b> taking shape, still changing</span>
<svg class="mat" style="color:var(--moss)"><use href="#m-ever"/></svg><span><b>Evergreen</b> done, or done enough</span>
<span style="display:flex;gap:3px"><i class="aim full"></i><i class="aim some"></i><i class="aim none"></i></span><span><b>AI</b> made with AI · some help · none</span>
</div>
</section>
<div class="paths">Short paths:
<button data-path="hire">I'm hiring: projects and tech writing</button> ·
<button data-path="friend">I'm a friend: the person side</button> ·
<button data-path="music">I play too: the music</button> ·
<button data-path="hand">Show me only work done by hand</button>
</div>
<nav class="filters" aria-label="Filters">
<div class="fg" data-f="type"><span>Type</span></div>
<div class="fg" data-f="side"><span>Side</span></div>
<div class="fg" data-f="ai"><span>AI</span></div>
<div class="fstate"><span id="count"></span><button id="reset" hidden>clear</button></div>
</nav>
<main class="garden" id="garden"></main>
<p class="none" id="none">Nothing grows at that intersection yet. <a href="#" id="none-reset">Clear the filters</a>.</p>
<footer>
<div><b>Three sides, one bed</b>“Now” is the technical work, much of it with AI. “Person” is writing and the band. “v1” entries are from the 2021 site, built by hand, and open in the frozen archive.</div>
<div><b>Elsewhere</b><a href="#" onclick="return false">old.lucaswinther.info</a>, the by-hand site, frozen<br><a href="#" onclick="return false">lucaswinther.info/simple</a>, the one-card version<br><a href="https://blog.lucaswinther.info">blog.lucaswinther.info</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">email</a></div>
<div><b>Placeholders</b>Dotted underlines are invented: essay and note titles, band name, songs, venues, dates, maturity stages, and the homelab and this-site projects.</div>
</footer>
</div>
<div class="pv" id="pv"></div>
<div class="qf" id="qf" role="dialog" aria-modal="true" aria-label="Find">
<div class="qbox">
<input id="qin" placeholder="Find a project, essay, song, or type a command…" autocomplete="off" aria-label="Find">
<ul class="qres" id="qres"></ul>
<div class="qfoot"><span><kbd>↑</kbd> <kbd>↓</kbd> move</span><span><kbd>↵</kbd> open</span><span><kbd>Esc</kbd> close</span><span style="margin-left:auto">try “unity”, “by hand”, “band”</span></div>
</div>
</div>
<script>
/* type: project | essay | note | music | video ; side: now | person | v1 ; ai: full | some | none | - ; mat: seed | bud | ever */
const E=[
{id:"gacha",type:"project",side:"now",ai:"full",mat:"ever",t:"Event Tracker for Gacha Games",d:"Scrapes upcoming banners and events across gacha games onto one calendar.",s:"scraping · webapp · JS",when:"2024",links:"live demo · GitHub"},
{id:"config",type:"project",side:"now",ai:"full",mat:"bud",t:"Config",d:"The homelab as code: Ansible roles and docker-compose stacks from one repo.",s:"Ansible · IaC · docker-compose",when:"2025",links:"GitHub"},
{id:"homelab",type:"project",side:"now",ai:"some",mat:"seed",ph:1,t:"Homelab on three mini PCs",d:"Cluster, monitoring and restore drills. AI drafted the runbooks.",s:"Proxmox · k3s",when:"2025",links:"write-up"},
{id:"site",type:"project",side:"now",ai:"some",mat:"bud",ph:1,t:"This garden",d:"Designed by hand, built with an AI pair. Notes on the blog.",s:"HTML · CSS · JS",when:"2026",links:"source"},
{id:"e1",type:"essay",side:"now",ai:"-",mat:"bud",ph:1,t:"What I let an agent touch in my homelab",d:"Read access everywhere, write access almost nowhere, and the one time that went wrong.",when:"Oct 2026"},
{id:"e2",type:"essay",side:"now",ai:"-",mat:"ever",ph:1,t:"Ansible, but the AI writes the roles",d:"A year of letting a model draft the boring parts of infrastructure code.",when:"Aug 2026"},
{id:"n1",type:"note",side:"now",ai:"-",mat:"seed",ph:1,t:"Backups I have actually restored",d:"A short, embarrassing list.",when:"Mar 2026"},
{id:"n2",type:"note",side:"now",ai:"-",mat:"seed",ph:1,t:"Prompting for diffs, not files",d:"Smaller asks, smaller messes.",when:"Sep 2026"},
{id:"m1",type:"essay",side:"person",ai:"-",mat:"ever",ph:1,t:"On owning a rack in a flat",d:"It started with one Raspberry Pi in a drawer. Now there's a shelf that hums.",when:"Jun 2026"},
{id:"m2",type:"note",side:"person",ai:"-",mat:"bud",ph:1,t:"Why I still write some code by hand",d:"Some things I only understand once I've typed them wrong twice.",when:"Jul 2026"},
{id:"m3",type:"note",side:"person",ai:"-",mat:"seed",ph:1,t:"Things I found in October",d:"A quiet keyboard, a scanned synth manual, decent rye bread.",when:"Oct 2026"},
{id:"m4",type:"essay",side:"person",ai:"-",mat:"bud",ph:1,t:"Cycling to rehearsal in February",d:"Amp on the back, cable around the neck, rain sideways.",when:"Feb 2026"},
{id:"s1",type:"music",side:"person",ai:"none",mat:"ever",ph:1,t:"Basement Song",d:"Live, Café Retro, March. The one people hum.",len:221,when:"Mar 2026"},
{id:"s2",type:"music",side:"person",ai:"none",mat:"bud",ph:1,t:"Too Many Cables",d:"Live at Huset. Tempo drifts, on purpose this time.",len:254,when:"May 2026"},
{id:"s3",type:"music",side:"person",ai:"none",mat:"seed",ph:1,t:"Untitled in D",d:"Kitchen demo, phone on the fridge.",len:142,when:"Sep 2026"},
{id:"v1v",type:"video",side:"person",ai:"none",mat:"ever",ph:1,t:"Full set, Huset, May",d:"Handcam from the front row. 24 minutes, one dropped pick.",when:"May 2026"},
{id:"v2v",type:"video",side:"person",ai:"none",mat:"seed",ph:1,t:"Soundcheck, rehearsal room",d:"Two minutes of us arguing about the hi-hat.",when:"Apr 2026"},
{id:"thesis",type:"project",side:"v1",ai:"none",mat:"ever",t:"Bachelor Report (HyperCache)",d:"Reputation systems in peer-to-peer archival.",s:"Node.js · research",when:"2021",links:"thesis PDF"},
{id:"dash",type:"project",side:"v1",ai:"none",mat:"ever",t:"Student Dashboard",d:"React, built on user studies.",s:"React",when:"2020",links:"report · demo"},
{id:"row",type:"project",side:"v1",ai:"none",mat:"ever",t:"Mixed-Reality Rowing Game",d:"Unity and Arduino, with two real oars.",s:"Unity · Arduino · C#",when:"2020",links:"video"},
{id:"vrc",type:"project",side:"v1",ai:"none",mat:"ever",t:"VR-Classroom",d:"Node.js prototype, report and video.",s:"Node.js",when:"2020",links:"report · video"},
{id:"chess",type:"project",side:"v1",ai:"none",mat:"ever",t:"Chessswap",d:"Chess where the board swaps every few turns.",s:"React",when:"2019",links:"play"},
{id:"exp",type:"project",side:"v1",ai:"none",mat:"ever",t:"Monthly Expenses Tracker",d:"JS webapp, in DKK.",s:"JS",when:"2019",links:"demo"},
{id:"lol",type:"project",side:"v1",ai:"none",mat:"ever",t:"LoL Winrate Discord Bot",d:"Win rates, posted into Discord.",s:"Node.js",when:"2019",links:"GitHub"},
{id:"censor",type:"project",side:"v1",ai:"none",mat:"ever",t:"Censor Simulator",d:"VR high-school exam project.",s:"Unity · VR",when:"2017",links:"video"},
{id:"breakout",type:"project",side:"v1",ai:"none",mat:"ever",t:"BreakoutPython",d:"High-school exam project.",s:"Python",when:"2016",links:"GitHub"},
{id:"borlaug",type:"project",side:"v1",ai:"none",mat:"ever",t:"Tribute to Norman Borlaug",d:"One of my first websites, from freecodecamp.",s:"HTML · CSS",when:"2016",links:"open"},
{id:"portfolio",type:"project",side:"v1",ai:"none",mat:"ever",t:"Portfolio (2021)",d:"The old site itself, now the archive.",s:"React · Material UI",when:"2021",links:"old.lucaswinther.info"}
];
/* interleave so the garden reads mixed, not grouped */
const ORDER=["gacha","m1","s1","e1","thesis","config","n1","row","m2","v1v","homelab","e2","chess","s2","dash","m3","site","censor","n2","vrc","m4","s3","exp","lol","v2v","breakout","borlaug","portfolio"];
E.sort((a,b)=>ORDER.indexOf(a.id)-ORDER.indexOf(b.id));
const F={
type:[["all","All"],["project","Projects"],["essay","Essays"],["note","Notes"],["music","Music"],["video","Handcam"]],
side:[["all","Everything"],["now","With AI, now"],["person","As a person"],["v1","v1 · by hand"]],
ai:[["all","Any"],["full","Made with AI"],["some","Some help"],["none","No AI"]]
};
const state={type:"all",side:"all",ai:"all"};
const MAT={seed:"Seedling",bud:"Budding",ever:"Evergreen"};
const AIL={full:"made with AI",some:"some AI help",none:"no AI","-":""};
const TYL={project:"project",essay:"essay",note:"note",music:"song",video:"handcam"};
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const $=id=>document.getElementById(id);
const fmt=s=>Math.floor(s/60)+":"+String(Math.floor(s%60)).padStart(2,"0");
const matSvg=m=>`<svg class="mat" style="color:var(--moss)" aria-label="${MAT[m]}"><use href="#m-${m}"/></svg>`;
const aiDot=a=>a==="-"?"":`<i class="aim ${a}" title="${AIL[a]}"></i>`;
function wave(seed){let p="",x=0,r=seed*9301%233;for(let i=0;i<48;i++){r=(r*9301+49297)%233280;const h=3+(r/233280)*20;p+=`<rect x="${i*5}" y="${13-h/2}" width="3" height="${h}" fill="currentColor"/>`;}return `<svg viewBox="0 0 240 26" preserveAspectRatio="none" style="color:#6d7562">${p}</svg>`;}
/* render */
$("garden").innerHTML=E.map((e,i)=>{
const ph=e.ph?' class="ph" title="placeholder"':"";
const meta=`<div class="meta"><span class="ty">${e.side==="v1"?"":TYL[e.type]}</span>${e.side==="v1"?'<span class="stamp">v1 · by hand</span>':""}<span class="r">${aiDot(e.ai)}${matSvg(e.mat)}</span></div>`;
if(e.side==="v1") return `<a class="e v1" id="e-${e.id}" data-id="${e.id}" href="#" onclick="return false" title="mockup: opens old.lucaswinther.info/#${e.id}">${meta}<h3>${esc(e.t)}</h3><p>${esc(e.d)}</p><span class="go">open in the 2021 archive ↗</span></a>`;
if(e.type==="music") return `<div class="e music" id="e-${e.id}" data-id="${e.id}" tabindex="0">${meta}<h3${ph}>${esc(e.t)}</h3><p>${esc(e.d)}</p><div class="wave"><button class="mp" data-song="${e.id}" aria-label="Play ${esc(e.t)}"></button>${wave(i+3)}<span class="tm" data-tm="${e.id}">0:00 / ${fmt(e.len)}</span></div></div>`;
if(e.type==="video") return `<div class="e video" id="e-${e.id}" data-id="${e.id}" tabindex="0">${meta}<h3${ph}>${esc(e.t)}</h3><div class="frame">handcam frame</div></div>`;
return `<a class="e ${e.type}" id="e-${e.id}" data-id="${e.id}" href="#" onclick="return false">${meta}<h3${ph}>${esc(e.t)}</h3><p>${esc(e.d)}</p>${e.s?`<div class="stack">${esc(e.s)}</div>`:""}</a>`;
}).join("");
/* filters */
document.querySelectorAll(".fg").forEach(g=>{
const f=g.dataset.f;
g.insertAdjacentHTML("beforeend",F[f].map(([v,l])=>`<button data-v="${v}" aria-pressed="${v==="all"}">${f==="ai"&&v!=="all"?`<i class="aim ${v}"></i>`:""}${l}<sup></sup></button>`).join(""));
g.addEventListener("click",ev=>{const b=ev.target.closest("button");if(!b)return;state[f]=b.dataset.v;apply();});
});
function match(e,s){return (s.type==="all"||e.type===s.type)&&(s.side==="all"||e.side===s.side)&&(s.ai==="all"||e.ai===s.ai);}
function apply(){
let n=0;
E.forEach(e=>{const ok=match(e,state);$("e-"+e.id).classList.toggle("hide",!ok);if(ok)n++;});
document.querySelectorAll(".fg").forEach(g=>{const f=g.dataset.f;
g.querySelectorAll("button").forEach(b=>{b.setAttribute("aria-pressed",state[f]===b.dataset.v);
const c=E.filter(e=>match(e,{...state,[f]:b.dataset.v})).length;b.querySelector("sup").textContent=c;b.style.opacity=c?"":".4";});});
$("count").textContent=n+" of "+E.length+" things";
const dirty=Object.values(state).some(v=>v!=="all");
$("reset").hidden=!dirty;$("none").style.display=n?"none":"block";
}
function setState(s){Object.assign(state,{type:"all",side:"all",ai:"all"},s);apply();}
$("reset").onclick=()=>setState({});
$("none-reset").onclick=e=>{e.preventDefault();setState({});};
const PATHS={hire:{side:"now"},friend:{side:"person"},music:{side:"person",type:"music"},hand:{ai:"none"}};
document.querySelectorAll(".paths button").forEach(b=>b.onclick=()=>{setState(PATHS[b.dataset.path]);document.querySelector(".filters").scrollIntoView({block:"start"});});
apply();
/* hover preview */
const pv=$("pv");
$("garden").addEventListener("mouseover",ev=>{
const el=ev.target.closest(".e");if(!el||ev.target.closest(".mp")){pv.classList.remove("on");return;}
const e=E.find(x=>x.id===el.dataset.id);
const img=e.side==="v1"?"screenshot from the 2021 site":e.type==="project"?"screenshot: "+e.t.toLowerCase():e.type==="music"?"photo: the gig":e.type==="video"?"still: handcam":"first paragraph";
pv.innerHTML=`<div class="img">${esc(img)}</div><b>${esc(e.t)}</b><p>${esc(e.d)}</p>${e.links?`<p style="font-size:12px">${esc(e.links)}</p>`:""}<div class="mm"><span>${MAT[e.mat]}${e.ai!=="-"?" · "+AIL[e.ai]:""}</span><span>${e.side==="v1"?"planted ":"tended "}${esc(e.when)}</span></div>`;
pv.classList.add("on");
});
$("garden").addEventListener("mousemove",ev=>{
if(!pv.classList.contains("on"))return;
let x=ev.clientX+18,y=ev.clientY+18;
if(x+310>innerWidth)x=ev.clientX-318;
if(y+pv.offsetHeight+10>innerHeight)y=innerHeight-pv.offsetHeight-10;
pv.style.left=x+"px";pv.style.top=y+"px";
});
$("garden").addEventListener("mouseleave",()=>pv.classList.remove("on"));
/* mini players: only one plays; progress only after press */
const songPos={};let playing=null,tick=null;
function stop(){clearInterval(tick);tick=null;if(playing)document.querySelector(`[data-song="${playing}"]`).classList.remove("on");playing=null;}
$("garden").addEventListener("click",ev=>{
const b=ev.target.closest(".mp");if(!b)return;ev.preventDefault();
const id=b.dataset.song;
if(playing===id){stop();return;}
stop();playing=id;b.classList.add("on");
const e=E.find(x=>x.id===id),svg=b.nextElementSibling;
tick=setInterval(()=>{songPos[id]=((songPos[id]||0)+1)%e.len;
document.querySelector(`[data-tm="${id}"]`).textContent=fmt(songPos[id])+" / "+fmt(e.len);
const rects=svg.querySelectorAll("rect"),k=Math.floor(songPos[id]/e.len*rects.length);
rects.forEach((r,i)=>r.setAttribute("fill",i<=k?"#d4824f":"currentColor"));},1000);
});
/* quick find */
const CMDS=[
{cmd:1,t:"Open the old by-hand site",sub:"old.lucaswinther.info ↗",run:()=>note("mockup: would open old.lucaswinther.info")},
{cmd:1,t:"Open the one-card version",sub:"/simple",run:()=>note("mockup: would open /simple")},
{cmd:1,t:"Show only work done by hand",sub:"filter",run:()=>setState({ai:"none"})},
{cmd:1,t:"Show only things made with AI",sub:"filter",run:()=>setState({ai:"full"})},
{cmd:1,t:"Show the music",sub:"filter",run:()=>setState({type:"music"})},
{cmd:1,t:"Show v1 · by hand entries",sub:"filter",run:()=>setState({side:"v1"})},
{cmd:1,t:"Clear all filters",sub:"filter",run:()=>setState({})},
{cmd:1,t:"Email Lucas",sub:"lucas@lucaswinther.info",run:()=>{location.href="mailto:lucas@lucaswinther.info";}}
];
let qi=0,qitems=[];
function note(m){$("count").textContent=m;}
const isMac=/Mac|iPhone|iPad/.test(navigator.platform);$("modk").textContent=isMac?"⌘K":"Ctrl K";
function hl(t,q){if(!q)return esc(t);const i=t.toLowerCase().indexOf(q);return i<0?esc(t):esc(t.slice(0,i))+"<mark>"+esc(t.slice(i,i+q.length))+"</mark>"+esc(t.slice(i+q.length));}
function score(x,q){const t=x.t.toLowerCase(),h=(x.t+" "+(x.d||"")+" "+(x.s||"")+" "+(x.sub||"")+" "+(x.side==="v1"?"v1 by hand archive old":"")+" "+(x.type?TYL[x.type]+" "+x.type:"")+" "+(x.side==="person"?"band person life":"")+" "+(x.ai&&x.ai!=="-"?AIL[x.ai]:"")).toLowerCase();
if(!q)return 1;if(t.startsWith(q))return 4;if(t.includes(q))return 3;if(q.split(/\s+/).every(w=>h.includes(w)))return 2;return 0;}
function renderQ(){
const q=$("qin").value.trim().toLowerCase();
const things=E.map(x=>({x,s:score(x,q)})).filter(o=>o.s).sort((a,b)=>b.s-a.s).slice(0,q?9:6).map(o=>o.x);
const cmds=CMDS.filter(c=>score(c,q)).slice(0,q?4:3);
qitems=[...things,...cmds];qi=Math.min(qi,Math.max(0,qitems.length-1));
let h="";
if(things.length)h+='<li class="grp">In the garden</li>'+things.map((x,i)=>`<li data-i="${i}" class="${i===qi?"act":""}"><span class="ty">${x.side==="v1"?"v1":TYL[x.type]}</span><span>${hl(x.t,q)}</span><span class="sub">${x.ai!=="-"?AIL[x.ai]+" · ":""}${esc(x.when)}</span></li>`).join("");
if(cmds.length)h+='<li class="grp">Go / do</li>'+cmds.map((c,j)=>{const i=things.length+j;return `<li data-i="${i}" class="${i===qi?"act":""}"><span class="ty">→</span><span>${hl(c.t,q)}</span><span class="sub">${esc(c.sub)}</span></li>`;}).join("");
if(!qitems.length)h='<li class="grp" style="padding:14px 18px">Nothing by that name. Try a stack, like “react”.</li>';
$("qres").innerHTML=h;
const a=$("qres").querySelector(".act");if(a)a.scrollIntoView({block:"nearest"});
}
function openQ(){$("qf").classList.add("on");$("qin").value="";qi=0;renderQ();$("qin").focus();pv.classList.remove("on");}
function closeQ(){$("qf").classList.remove("on");}
function choose(i){const x=qitems[i];if(!x)return;closeQ();
if(x.cmd){x.run();return;}
if(!match(x,state))setState({});
document.querySelectorAll(".e.sel").forEach(e=>e.classList.remove("sel"));
const el=$("e-"+x.id);el.classList.add("sel");el.scrollIntoView({block:"center"});el.focus({preventScroll:true});}
$("openqf").onclick=openQ;
$("qin").addEventListener("input",()=>{qi=0;renderQ();});
$("qres").addEventListener("click",e=>{const li=e.target.closest("li[data-i]");if(li)choose(+li.dataset.i);});
$("qres").addEventListener("mousemove",e=>{const li=e.target.closest("li[data-i]");if(li&&+li.dataset.i!==qi){qi=+li.dataset.i;renderQ();}});
$("qf").addEventListener("click",e=>{if(e.target===$("qf"))closeQ();});
addEventListener("keydown",e=>{
const open=$("qf").classList.contains("on");
if((e.key==="k"||e.key==="K")&&(e.metaKey||e.ctrlKey)){e.preventDefault();open?closeQ():openQ();return;}
if(!open&&e.key==="/"&&!e.target.matches("input,textarea")){e.preventDefault();openQ();return;}
if(!open)return;
if(e.key==="Escape"){closeQ();$("openqf").focus();}
else if(e.key==="ArrowDown"){e.preventDefault();qi=Math.min(qi+1,qitems.length-1);renderQ();}
else if(e.key==="ArrowUp"){e.preventDefault();qi=Math.max(qi-1,0);renderQ();}
else if(e.key==="Enter"){e.preventDefault();choose(qi);}
});
document.addEventListener("click",e=>{if(!e.target.closest(".e,#qf"))document.querySelectorAll(".e.sel").forEach(x=>x.classList.remove("sel"));});
</script>
</body>
</html>
+377
View File
@@ -0,0 +1,377 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,500;1,9..144,300;1,9..144,400&family=Caveat:wght@500&display=swap" rel="stylesheet">
<style>
:root {
--t-bg: #e8eae6; --t-ink: #1c2024; --t-mute: #6b726f; --t-line: #cfd3cd; --t-acc: #d9532b;
--a-bg: #2a1a1f; --a-ink: #f1e6d6; --a-mute: #b8a592; --a-acc: #e8b04a; --a-rose: #c9706b;
--ease: cubic-bezier(.2,.7,.2,1);
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body { font-family: "IBM Plex Sans", system-ui, sans-serif; background: var(--t-bg); color: var(--t-ink); }
a { color: inherit; }
/* ---------- gate ---------- */
.gate { display: flex; min-height: 640px; height: calc(100vh - 56px); max-height: 860px; position: relative; overflow: hidden; }
.side { position: relative; flex: 1 1 0; min-width: 0; transition: flex-grow .35s var(--ease); cursor: pointer; overflow: hidden; outline: none; }
.gate.lean-tech .tech, .gate.lean-art .art { flex-grow: 1.45; }
.gate.open-tech .tech, .gate.open-art .art { flex-grow: 30; cursor: default; }
.gate.open-tech .art, .gate.open-art .tech { flex-grow: 1; flex-basis: 0; max-width: 64px; }
.inner { position: absolute; inset: 0; padding: 96px 48px 40px; display: flex; flex-direction: column; justify-content: center; width: max(50vw, 560px); }
.tech .inner { right: 0; left: auto; }
.art .inner { left: 0; }
.gate.open-tech .tech .inner, .gate.open-art .art .inner { width: 100%; justify-content: flex-start; padding-top: 40px; }
/* ---------- technical side ---------- */
.tech { background: var(--t-bg); color: var(--t-ink);
background-image: linear-gradient(var(--t-line) 1px, transparent 1px), linear-gradient(90deg, var(--t-line) 1px, transparent 1px);
background-size: 24px 24px; background-position: -1px -1px; }
.tech::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(232,234,230,.0) 0%, rgba(232,234,230,.75) 30%, rgba(232,234,230,.92) 100%); pointer-events: none; }
.tech .inner { z-index: 1; font-family: "IBM Plex Sans", sans-serif; text-align: right; align-items: flex-end; }
.mono { font-family: "IBM Plex Mono", monospace; }
.t-label { font-family: "IBM Plex Mono", monospace; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--t-mute); }
.t-label b { color: var(--t-acc); font-weight: 500; }
.tech h1 { font-family: "IBM Plex Mono", monospace; font-weight: 500; font-size: clamp(40px, 5vw, 68px); letter-spacing: -.03em; margin: 14px 0 0; line-height: 1; }
.tech .lede { font-size: 17px; line-height: 1.5; max-width: 26em; margin: 18px 0 0; color: #343a3e; }
.t-panel { margin-top: 28px; width: min(100%, 460px); border-top: 1px solid var(--t-ink); text-align: left; }
.t-row { display: grid; grid-template-columns: 92px 1fr auto; gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--t-line); font-size: 14px; align-items: baseline; transition: background .15s; }
.t-row:hover { background: rgba(255,255,255,.55); }
.t-row .k { font-family: "IBM Plex Mono", monospace; font-size: 11px; color: var(--t-mute); text-transform: uppercase; letter-spacing: .06em; }
.t-row .v { color: var(--t-ink); }
.ai { font-family: "IBM Plex Mono", monospace; font-size: 11px; color: var(--t-mute); white-space: nowrap; }
.ai i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; margin-right: 5px; vertical-align: 0; }
.ai.full { color: var(--t-acc); } .ai.full i { background: currentColor; }
.ai.some i { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
.t-more { max-height: 0; overflow: hidden; opacity: 0; transition: max-height .35s var(--ease), opacity .25s; }
.gate.lean-tech .t-more, .gate.open-tech .t-more { max-height: 200px; opacity: 1; }
.t-enter { margin-top: 22px; font-family: "IBM Plex Mono", monospace; font-size: 13px; display: inline-flex; gap: 10px; align-items: center; }
.t-enter kbd, .a-enter kbd, .foot kbd { font-family: "IBM Plex Mono", monospace; font-size: 10px; border: 1px solid currentColor; border-radius: 3px; padding: 0 4px; opacity: .7; }
.t-enter .arrow { display: inline-block; transition: transform .2s; }
.tech:hover .t-enter .arrow { transform: translateX(-4px); }
/* cursor crosshair readout: only when hovering */
.readout { position: absolute; bottom: 24px; left: 48px; z-index: 1; font-family: "IBM Plex Mono", monospace; font-size: 10px; color: var(--t-mute); opacity: 0; transition: opacity .2s; text-align: left; line-height: 1.6; }
.tech:hover .readout { opacity: 1; }
.xh { position: absolute; z-index: 0; pointer-events: none; opacity: 0; transition: opacity .2s; }
.tech:hover .xh { opacity: 1; }
.xh.h { left: 0; right: 0; height: 1px; background: rgba(217,83,43,.35); }
.xh.v { top: 0; bottom: 0; width: 1px; background: rgba(217,83,43,.35); }
/* ---------- art side ---------- */
.art { background: var(--a-bg); color: var(--a-ink); }
.art::before { content: ""; position: absolute; inset: 0; opacity: .18; pointer-events: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 .9 0 0 0 0 .8 0 0 0 .55 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>"); }
.art .inner { z-index: 1; font-family: "Fraunces", serif; }
.a-label { font-family: "Caveat", cursive; font-size: 22px; color: var(--a-acc); transform: rotate(-2deg); transform-origin: left; display: inline-block; }
.art h1 { font-family: "Fraunces", serif; font-weight: 300; font-style: italic; font-size: clamp(44px, 5.6vw, 76px); letter-spacing: -.02em; margin: 10px 0 0; line-height: 1; }
.art .lede { font-size: 18px; line-height: 1.5; max-width: 24em; margin: 18px 0 0; color: #e3d4c1; font-weight: 300; }
.collage { position: relative; margin-top: 26px; height: 210px; width: min(100%, 480px); }
.scrap { position: absolute; transition: transform .3s var(--ease), box-shadow .3s; }
.scrap:hover { z-index: 3; }
.tape { width: 190px; height: 118px; left: 0; top: 18px; background: #e9dcc6; border-radius: 6px; transform: rotate(-4deg); padding: 10px 12px; color: #2a1a1f; box-shadow: 0 2px 0 rgba(0,0,0,.25); }
.tape:hover { transform: rotate(-1deg) translateY(-6px); }
.tape .lbl { background: #f6efe2; border-bottom: 3px solid var(--a-rose); padding: 4px 8px; font-family: "Caveat", cursive; font-size: 19px; line-height: 1.1; }
.tape .win { margin: 10px auto 0; width: 120px; height: 36px; background: #2a1a1f; border-radius: 18px; display: flex; justify-content: space-between; align-items: center; padding: 0 10px; }
.reel { width: 22px; height: 22px; border-radius: 50%; border: 3px dotted #e9dcc6; }
.tape.playing .reel { animation: spin 2.4s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.play { position: absolute; right: -14px; bottom: -14px; width: 40px; height: 40px; border-radius: 50%; border: 0; background: var(--a-acc); color: #2a1a1f; font-size: 14px; cursor: pointer; box-shadow: 0 2px 0 rgba(0,0,0,.3); }
.polaroid { width: 128px; left: 214px; top: 0; background: #f4ece0; padding: 8px 8px 26px; transform: rotate(3deg); box-shadow: 0 2px 0 rgba(0,0,0,.25); }
.polaroid:hover { transform: rotate(1deg) translateY(-6px); }
.polaroid .ph { height: 104px; background: repeating-linear-gradient(135deg, #7a4f4a 0 6px, #6c4440 6px 12px); display: flex; align-items: flex-end; padding: 4px; font: 9px "IBM Plex Mono", monospace; color: #f4ece0; }
.polaroid .cap { font-family: "Caveat", cursive; font-size: 17px; color: #2a1a1f; text-align: center; margin-top: 4px; }
.note { width: 150px; left: 330px; top: 92px; background: #e8b04a; color: #2a1a1f; padding: 10px 12px; transform: rotate(-2deg); font-size: 14px; line-height: 1.35; box-shadow: 0 2px 0 rgba(0,0,0,.25); }
.note:hover { transform: rotate(0) translateY(-6px); }
.note small { display: block; font-family: "IBM Plex Mono", monospace; font-size: 9px; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 4px; opacity: .7; }
.a-more { max-height: 0; overflow: hidden; opacity: 0; transition: max-height .35s var(--ease), opacity .25s; font-size: 15px; color: var(--a-mute); margin-top: 18px; }
.a-more a { color: var(--a-ink); text-decoration-color: var(--a-rose); }
.gate.lean-art .a-more, .gate.open-art .a-more { max-height: 200px; opacity: 1; }
.a-enter { margin-top: 20px; font-family: "Fraunces", serif; font-style: italic; font-size: 17px; display: inline-flex; gap: 10px; align-items: center; }
.a-enter .arrow { display: inline-block; transition: transform .2s; }
.art:hover .a-enter .arrow { transform: translateX(4px); }
.squiggle { position: absolute; right: 36px; top: 34px; width: 120px; opacity: .55; }
.squiggle path { stroke-dasharray: 260; stroke-dashoffset: 260; transition: stroke-dashoffset .6s var(--ease); }
.art:hover .squiggle path { stroke-dashoffset: 0; }
/* ---------- name across the seam ---------- */
.name-l, .name-r { position: absolute; top: 34px; z-index: 2; font-size: 15px; white-space: nowrap; }
.name-l { right: 14px; font-family: "IBM Plex Mono", monospace; font-weight: 500; }
.name-r { left: 14px; font-family: "Fraunces", serif; font-style: italic; font-size: 18px; top: 31px; }
.seam { position: absolute; top: 0; bottom: 0; width: 1px; z-index: 3; pointer-events: none; }
/* ---------- third, small way in: the garden ---------- */
.garden-link { position: absolute; bottom: 26px; left: 50%; transform: translateX(-50%); z-index: 5; display: inline-flex; align-items: center; gap: 10px;
background: #121416; color: #e8eae6; border-radius: 99px; padding: 8px 16px 8px 12px; font-family: "IBM Plex Mono", monospace; font-size: 12px; text-decoration: none; white-space: nowrap;
box-shadow: 0 0 0 1px rgba(255,255,255,.08); transition: background .2s, padding .2s; }
.garden-link:hover { background: #000; padding-right: 20px; }
.garden-link .sprout { width: 14px; height: 14px; }
.garden-link em { font-family: "Fraunces", serif; font-size: 14px; color: #e8b04a; }
.garden-link kbd { font-family: "IBM Plex Mono", monospace; font-size: 10px; border: 1px solid #555; border-radius: 3px; padding: 0 4px; color: #9aa09b; }
.gate.open-tech .garden-link, .gate.open-art .garden-link { display: none; }
/* ---------- opened states ---------- */
.front { display: contents; }
.room { display: none; }
.gate.open-tech .tech .room, .gate.open-art .art .room { display: block; }
.gate.open-tech .tech .front, .gate.open-art .art .front { display: none; }
.gate.open-tech .tech .inner { text-align: left; align-items: stretch; }
.gate.open-tech .tech::after { background: rgba(232,234,230,.9); }
.edge { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; writing-mode: vertical-rl; font-size: 13px; letter-spacing: .1em; z-index: 4; cursor: pointer; }
.gate.open-tech .art .edge, .gate.open-art .tech .edge { display: flex; }
.gate.open-tech .art .inner, .gate.open-art .tech .inner { visibility: hidden; }
.gate.open-tech .name-l, .gate.open-tech .name-r, .gate.open-art .name-l, .gate.open-art .name-r { display: none; }
.tech .edge { font-family: "IBM Plex Mono", monospace; text-transform: uppercase; color: var(--t-ink); transform: rotate(180deg); }
.art .edge { font-family: "Fraunces", serif; font-style: italic; color: var(--a-ink); font-size: 16px; letter-spacing: .02em; }
.edge:hover { background: rgba(0,0,0,.06); }
.art .edge:hover { background: rgba(255,255,255,.06); }
.room-top { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 1px solid currentColor; padding-bottom: 10px; }
.room-top button { background: none; border: 0; color: inherit; font: inherit; cursor: pointer; padding: 0; text-decoration: underline; text-underline-offset: 3px; }
.room h2 { margin: 28px 0 6px; }
.tech .room h2 { font-family: "IBM Plex Mono", monospace; font-weight: 500; font-size: 30px; letter-spacing: -.02em; }
.art .room h2 { font-family: "Fraunces", serif; font-weight: 300; font-style: italic; font-size: 40px; }
.room-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; margin-top: 22px; max-width: 980px; }
.room-grid > div { border-top: 1px solid currentColor; padding-top: 8px; font-size: 14px; line-height: 1.5; opacity: .9; }
.room-grid h4 { margin: 0 0 6px; font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; font-family: "IBM Plex Mono", monospace; }
.room .hint { margin-top: 26px; font-size: 13px; opacity: .7; max-width: 46em; }
/* ---------- footer strip ---------- */
.foot { height: 56px; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 0 48px; background: #121416; color: #b9bdb7; font-family: "IBM Plex Mono", monospace; font-size: 12px; }
.foot a { color: #e8eae6; text-decoration-color: #555; text-underline-offset: 3px; }
.pref { display: flex; align-items: center; gap: 8px; }
.pref button { background: none; border: 1px solid #3b4044; color: #b9bdb7; font: inherit; font-size: 11px; padding: 3px 8px; border-radius: 99px; cursor: pointer; }
.pref button[aria-pressed="true"] { background: #e8eae6; color: #121416; border-color: #e8eae6; }
.pref .why { color: #7d837f; }
.ph-mark { text-decoration: underline dotted; text-underline-offset: 3px; }
/* ---------- mockup note ---------- */
.mk { position: fixed; top: 10px; right: 10px; z-index: 50; width: 300px; background: #fff; color: #222; border: 1px solid #bbb; padding: 10px 12px; font: 12.5px/1.4 system-ui, sans-serif; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
.mk b { display: block; margin-bottom: 4px; }
.mk button { position: absolute; top: 4px; right: 6px; border: 0; background: none; font-size: 16px; cursor: pointer; color: #666; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
@media (max-width: 820px) {
.gate { flex-direction: column; height: auto; max-height: none; }
.side { flex: none !important; max-width: none !important; }
.inner { position: relative; width: 100% !important; text-align: left !important; align-items: flex-start !important; min-height: 520px; padding: 70px 24px 32px; }
.name-l, .name-r { display: none; }
.garden-link { position: static; transform: none; margin: 18px 24px; align-self: flex-start; }
.t-more, .a-more { max-height: 200px; opacity: 1; }
.foot { flex-direction: column; height: auto; padding: 14px 24px; align-items: flex-start; }
.room-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="mk" id="mk"><button onclick="document.getElementById('mk').remove()" aria-label="close">×</button>
<b>nav-d · Two gates</b>A, reworked into two gates: technical on the left (grid paper, mono, precise) and personal on the right (warm, grainy, serif and hand-written). Hover a side to lean in, click or press ←/→ then Enter to open it, Esc to come back. Work now leads into tech-r3-c, and off the clock into personal-r2-b. Once you're in, the other side stays a thin edge you can cross to. Under the seam, a small third way in opens the garden (nav-c) with everything at once.</div>
<main class="gate" id="gate">
<!-- ============ TECHNICAL ============ -->
<section class="side tech" id="tech" tabindex="0" aria-label="Work: technical portfolio">
<div class="xh h" id="xhH"></div><div class="xh v" id="xhV"></div>
<div class="readout" id="readout">x 000 · y 000<br>grid 24px</div>
<div class="name-l">Lucas</div>
<div class="edge" data-go="tech">← Work</div>
<div class="inner">
<div class="front">
<div class="t-label"><b>●</b> 01 / work</div>
<h1>Work</h1>
<p class="lede">Software, infrastructure, and how I build with AI. Projects, a CV you can read in ten seconds, and notes on what worked.</p>
<div class="t-panel">
<div class="t-row"><span class="k">now</span><span class="v"><span class="ph-mark" title="placeholder">Platform engineer</span></span><span class="ai"></span></div>
<div class="t-row"><span class="k">latest</span><span class="v">Config: Ansible + compose deploys</span><span class="ai full"><i></i>AI-made</span></div>
<div class="t-row"><span class="k">writing</span><span class="v"><span class="ph-mark" title="placeholder">What I let the agent write, and what I didn't</span></span><span class="ai some"><i></i>some AI</span></div>
<div class="t-more">
<div class="t-row"><span class="k">thesis</span><span class="v">HyperCache: reputation in P2P archival</span><span class="ai"><i></i>by hand</span></div>
<div class="t-row"><span class="k">built</span><span class="v">Mixed-reality rowing game, two real oars</span><span class="ai"><i></i>by hand</span></div>
<div class="t-row"><span class="k">stack</span><span class="v mono" style="font-size:12px">TS · Node · React · Ansible · Docker · Unity</span><span class="ai"></span></div>
</div>
</div>
<div class="t-enter"><span class="arrow">←</span> enter work <kbd>←</kbd></div>
</div>
<div class="room">
<div class="room-top mono" style="font-size:12px"><span>lucaswinther.info / work</span><button data-back>esc · back to the gate</button></div>
<h2>Work</h2>
<p style="max-width:40em;color:#343a3e">This is where the technical space would begin: CV first, then projects with the AI filter, then writing. Any of the tech mockups (T1–T5) could live here.</p>
<div class="room-grid">
<div><h4>CV</h4><span class="ph-mark" title="placeholder">Platform engineer, 2023–now</span>. BSc Computer Science, <span class="ph-mark" title="placeholder">2022</span>. CV (PDF).</div>
<div><h4>Projects · 15</h4>Filter by how they were made: all · skip fully AI-made · by hand only.</div>
<div><h4>Writing</h4>AI, devops, homelab. From blog.lucaswinther.info.</div>
</div>
<p class="hint">The personal side waits on the right edge. One click crosses over.</p>
</div>
</div>
</section>
<!-- ============ PERSONAL / ART ============ -->
<section class="side art" id="art" tabindex="0" aria-label="Off the clock: personal space">
<div class="name-r">Winther</div>
<div class="edge" data-go="art">off the clock →</div>
<svg class="squiggle" viewBox="0 0 120 40" fill="none"><path d="M2 30 C 18 4, 30 4, 40 22 S 62 38, 74 18 S 100 2, 118 14" stroke="#e8b04a" stroke-width="2" stroke-linecap="round"/></svg>
<div class="inner">
<div class="front">
<div><span class="a-label">02 · off the clock</span></div>
<h1>Off the clock</h1>
<p class="lede">Songs from a <span class="ph-mark" title="placeholder">small band</span>, half-finished thoughts, things I found and wanted to keep.</p>
<div class="collage">
<div class="scrap tape" id="tape">
<div class="lbl"><span class="ph-mark" title="placeholder">Live at Café Retro</span> · side A</div>
<div class="win"><span class="reel"></span><span class="reel"></span></div>
<button class="play" id="play" aria-label="play">▶</button>
</div>
<div class="scrap polaroid"><div class="ph">photo: rehearsal room</div><div class="cap">Thursdays</div></div>
<div class="scrap note"><small>musing</small><span class="ph-mark" title="placeholder">Small rooms are harder than big ones.</span></div>
</div>
<div class="a-more">Latest find: <a href="#" onclick="return false"><span class="ph-mark" title="placeholder">every bus shelter in Denmark, photographed</span></a><br>Recorded on a phone from the third row, on purpose.</div>
<div class="a-enter">come in <span class="arrow">→</span> <kbd>→</kbd></div>
</div>
<div class="room">
<div class="room-top" style="font-size:14px"><span style="font-style:italic">lucaswinther.info / off-the-clock</span><button data-back style="font-family:'IBM Plex Mono',monospace;font-size:12px">esc · back to the gate</button></div>
<h2>Off the clock</h2>
<p style="max-width:36em;color:#e3d4c1;font-weight:300">This is where the personal space would begin: press play on a tape, then explore the songs, with musings and finds alongside. Any of the personal mockups (P1–P5) could live here.</p>
<div class="room-grid">
<div><h4>The tapes</h4>Handcam sets and small recordings. Pick one, press play, jump between songs.</div>
<div><h4>Musings</h4>Half-finished thoughts, some of which grow up.</div>
<div><h4>Found &amp; kept</h4>Links, tools, records and things worth passing on.</div>
</div>
<p class="hint">The work side waits on the left edge.</p>
</div>
</div>
</section>
<a class="garden-link" id="gardenLink" href="nav-c.html" target="_blank">
<svg class="sprout" viewBox="0 0 14 14" fill="none" stroke="#a7b86c" stroke-width="1.4" stroke-linecap="round"><path d="M7 13V6"/><path d="M7 8C7 5 5 3.5 2 3.5 2 6.5 4 8 7 8Z"/><path d="M7 6.5C7 4 8.6 2 12 2 12 5 10 6.5 7 6.5Z"/></svg>
or wander through <em>everything at once</em> <kbd>G</kbd>
</a>
</main>
<footer class="foot">
<span><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a> · <a href="https://github.com/StereotypicalCat">GitHub</a> · <a href="https://www.linkedin.com/in/lucas-winther/">LinkedIn</a> · <a href="#" onclick="return false">/simple</a></span>
<span class="pref" role="group" aria-label="AI preference">
<span class="why">projects made</span>
<button aria-pressed="true" data-pref="all">any way</button>
<button aria-pressed="false" data-pref="nofull">not fully by AI</button>
<button aria-pressed="false" data-pref="hand">by hand only</button>
<span class="why" id="prefNote"></span>
</span>
</footer>
<script>
(function () {
var gate = document.getElementById('gate');
var tech = document.getElementById('tech');
var art = document.getElementById('art');
var state = 'none'; // none | tech | art
var lean = null;
function setLean(s) {
if (state !== 'none') return;
lean = s;
gate.classList.toggle('lean-tech', s === 'tech');
gate.classList.toggle('lean-art', s === 'art');
}
function open(s) {
// the work gate leads into the chosen tech space (tech-r3-c)
if (s === 'tech') { location.href = 'tech-r3-c.html'; return; }
// the personal gate leads into the chosen personal space (personal-r2-b)
if (s === 'art') { location.href = 'personal-r2-b.html'; return; }
state = s;
gate.classList.remove('lean-tech', 'lean-art', 'open-tech', 'open-art');
if (s !== 'none') gate.classList.add('open-' + s);
if (s === 'tech') tech.focus({ preventScroll: true });
if (s === 'art') art.focus({ preventScroll: true });
}
[tech, art].forEach(function (el) {
var s = el === tech ? 'tech' : 'art';
el.addEventListener('mouseenter', function () { setLean(s); });
el.addEventListener('mouseleave', function () { setLean(null); });
el.addEventListener('focus', function () { setLean(s); });
el.addEventListener('click', function (e) {
if (e.target.closest('button, a')) return;
if (state === 'none') open(s);
});
});
document.querySelectorAll('.edge').forEach(function (e) {
e.addEventListener('click', function (ev) { ev.stopPropagation(); open(e.dataset.go); });
});
document.querySelectorAll('[data-back]').forEach(function (b) {
b.addEventListener('click', function (ev) { ev.stopPropagation(); open('none'); });
});
document.addEventListener('keydown', function (e) {
if (e.target.closest && e.target.closest('input, textarea')) return;
if (e.key === 'Escape') { open('none'); return; }
if (state === 'none') {
if (e.key === 'ArrowLeft') setLean('tech');
else if (e.key === 'ArrowRight') setLean('art');
else if (e.key === 'Enter' && lean) open(lean);
} else {
if (e.key === 'ArrowLeft' && state === 'art') open('tech');
if (e.key === 'ArrowRight' && state === 'tech') open('art');
}
});
// arriving from the tech space's "off the clock" edge
if (location.hash === '#art') open('art');
// crosshair readout on the technical side, only while the cursor is there
var xhH = document.getElementById('xhH'), xhV = document.getElementById('xhV'), ro = document.getElementById('readout');
tech.addEventListener('mousemove', function (e) {
var r = tech.getBoundingClientRect();
var x = Math.round(e.clientX - r.left), y = Math.round(e.clientY - r.top);
xhH.style.top = y + 'px'; xhV.style.left = x + 'px';
ro.innerHTML = 'x ' + String(x).padStart(3, '0') + ' · y ' + String(y).padStart(3, '0') + '<br>cell ' + Math.floor(x / 24) + ',' + Math.floor(y / 24);
});
// tape: spins only after the visitor presses play
var tape = document.getElementById('tape'), play = document.getElementById('play');
play.addEventListener('click', function (e) {
e.stopPropagation();
var on = tape.classList.toggle('playing');
play.textContent = on ? '❚❚' : '▶';
play.setAttribute('aria-label', on ? 'pause' : 'play');
});
// the garden link follows the seam as the sides lean
var gl = document.getElementById('gardenLink');
function placeGarden() { if (window.innerWidth > 820) gl.style.left = tech.offsetWidth + 'px'; else gl.style.left = ''; }
if (window.ResizeObserver) new ResizeObserver(placeGarden).observe(tech);
window.addEventListener('resize', placeGarden); placeGarden();
gl.addEventListener('mouseenter', function () { setLean(null); });
document.addEventListener('keydown', function (e) { if ((e.key === 'g' || e.key === 'G') && state === 'none') gl.click(); });
// AI preference: remembered across the site in the real thing
var note = document.getElementById('prefNote');
var notes = { all: '', nofull: 'hides 2 projects', hand: 'shows hand-made work, plus the 2021 site' };
document.querySelectorAll('[data-pref]').forEach(function (b) {
b.addEventListener('click', function () {
document.querySelectorAll('[data-pref]').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); });
note.textContent = notes[b.dataset.pref];
});
});
})();
</script>
<!-- Placeholders: job title, blog post title, band, venue, photo, musing, find. -->
</body>
</html>
+333
View File
@@ -0,0 +1,333 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther · as a person (P1 · The crate)</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=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,600&family=Courier+Prime:wght@400;700&display=swap" rel="stylesheet">
<style>
:root{
--wall:#f2ebdf; --wall2:#e9e0d0; --ink:#2b241d; --soft:#6d6255; --line:#d6cab6;
--wood:#8a5a36; --wood2:#6e4426;
--rust:#c0562f; --mustard:#d6a441; --olive:#6f7d4f; --pine:#2f4b45; --bone:#e6dccb; --ox:#8a3a3a;
--serif:"Fraunces", Georgia, serif; --mono:"Courier Prime", ui-monospace, monospace;
}
*{box-sizing:border-box}
html{scroll-behavior:auto}
body{margin:0;background:var(--wall);color:var(--ink);font-family:var(--serif);font-size:17px;line-height:1.55;font-variation-settings:"opsz" 14}
a{color:inherit}
.ph{text-decoration:underline dotted var(--soft);text-underline-offset:3px;cursor:help}
.mock-note{position:fixed;top:10px;right:10px;z-index:99;max-width:300px;background:#fffdf8;border:1px solid #bbb;padding:8px 30px 8px 10px;font:12px/1.4 system-ui,sans-serif;color:#333}
.mock-note b{display:block}
.mock-note button{position:absolute;top:4px;right:6px;border:0;background:none;font-size:16px;cursor:pointer;color:#666}
.wrap{max-width:1080px;margin:0 auto;padding:0 28px}
header.top{display:flex;align-items:baseline;justify-content:space-between;gap:20px;padding:26px 0 14px;border-bottom:1px solid var(--line)}
.name{font-size:20px;font-weight:600;letter-spacing:-.01em}
.name small{font-weight:300;color:var(--soft);font-size:15px;margin-left:8px;font-style:italic}
nav.spaces{font-family:var(--mono);font-size:13px;display:flex;gap:18px;flex-wrap:wrap}
nav.spaces a{text-decoration:none;color:var(--soft)}
nav.spaces a[aria-current]{color:var(--ink);border-bottom:1px solid var(--ink)}
nav.spaces a:hover{color:var(--ink)}
.intro{display:grid;grid-template-columns:1.3fr 1fr;gap:40px;padding:34px 0 10px}
.intro h1{font-weight:300;font-size:40px;line-height:1.1;margin:0;letter-spacing:-.02em;font-variation-settings:"opsz" 120}
.intro h1 em{font-style:italic;color:var(--rust)}
.intro p{margin:0;color:var(--soft);font-size:16px;align-self:end}
/* shelf */
.shelf-head{display:flex;justify-content:space-between;align-items:baseline;margin:30px 0 8px;font-family:var(--mono);font-size:12.5px;color:var(--soft)}
.shelf-head .filters button{font:inherit;background:none;border:0;color:var(--soft);cursor:pointer;padding:2px 6px}
.shelf-head .filters button[aria-pressed="true"]{color:var(--ink);text-decoration:underline}
.shelf{position:relative;padding:0 18px;display:flex;align-items:flex-end;gap:6px;height:196px;border-bottom:16px solid var(--wood);
background:linear-gradient(var(--wall2),var(--wall2)) bottom/100% 4px no-repeat}
.shelf::after{content:"";position:absolute;left:0;right:0;bottom:-22px;height:6px;background:var(--wood2);opacity:.5}
.spine{all:unset;cursor:pointer;position:relative;width:46px;height:var(--h,168px);background:var(--c);border-radius:2px 2px 0 0;
transition:transform .22s ease, opacity .2s;display:flex;align-items:center;justify-content:center;box-shadow:inset -3px 0 0 rgba(0,0,0,.12), inset 2px 0 0 rgba(255,255,255,.12)}
.spine .lbl{writing-mode:vertical-rl;transform:rotate(180deg);background:#f7f1e4;color:var(--ink);font-family:var(--mono);font-size:12px;padding:10px 4px;
width:28px;height:78%;display:flex;align-items:center;justify-content:space-between;white-space:nowrap;overflow:hidden}
.spine .lbl i{font-style:normal;color:var(--soft);font-size:10.5px}
.spine:hover,.spine:focus-visible{transform:translateY(-10px)}
.spine:focus-visible{outline:2px solid var(--ink);outline-offset:3px}
.spine.out{transform:translateY(-34px)}
.spine.dim{opacity:.28}
.spine.thin{width:30px}
.spine.thin .lbl{width:18px;font-size:10px}
.bookend{width:12px;height:120px;background:var(--wood2);margin-left:auto;border-radius:2px 2px 0 0}
.shelf .card{align-self:flex-end;margin-left:14px;font-family:var(--mono);font-size:11.5px;color:var(--soft);width:150px;line-height:1.4;padding-bottom:8px}
.peek{min-height:22px;margin:30px 0 0;font-family:var(--mono);font-size:13px;color:var(--soft)}
/* deck */
.deck{margin:12px 0 0;border:1px solid var(--line);background:#f7f1e6;display:grid;grid-template-columns:330px 1fr;min-height:260px}
.deck.empty{grid-template-columns:1fr}
.deck .hint{padding:46px 30px;text-align:center;color:var(--soft);font-style:italic}
.face{padding:22px;border-right:1px solid var(--line);display:flex;flex-direction:column;gap:14px}
.cass{position:relative;height:178px;border-radius:8px;background:var(--c,#333);padding:12px 14px}
.cass .paper{background:#f7f1e4;height:58px;padding:6px 10px;font-family:var(--mono);font-size:12px;line-height:1.3}
.cass .paper b{display:block;font-size:14px}
.cass .win{position:absolute;left:50%;transform:translateX(-50%);bottom:24px;width:180px;height:62px;background:#1f1a15;border-radius:30px;display:flex;justify-content:space-between;align-items:center;padding:0 14px}
.reel{width:38px;height:38px;border-radius:50%;background:repeating-conic-gradient(#efe6d6 0 18deg,#8d8273 18deg 60deg);border:5px solid #3a3129}
.reel.l{box-shadow:0 0 0 calc(var(--fl,10px)) #4a3a2c}
.reel.r{box-shadow:0 0 0 calc(var(--fr,3px)) #4a3a2c}
.playing .reel{animation:spin 2.2s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){.playing .reel{animation:none}}
.cass .holes{position:absolute;bottom:6px;left:50%;transform:translateX(-50%);width:110px;height:12px;background:rgba(0,0,0,.18);clip-path:polygon(8% 0,92% 0,100% 100%,0 100%)}
.controls{display:flex;align-items:center;gap:12px;font-family:var(--mono);font-size:13px}
.btn{all:unset;cursor:pointer;width:40px;height:40px;border:1.5px solid var(--ink);border-radius:50%;display:grid;place-items:center}
.btn:hover{background:var(--ink);color:var(--wall)}
.btn:focus-visible{outline:2px solid var(--rust);outline-offset:2px}
.btn.sm{width:30px;height:30px;border-width:1px}
.bar{flex:1;height:22px;position:relative;cursor:pointer}
.bar::before{content:"";position:absolute;left:0;right:0;top:10px;height:2px;background:var(--line)}
.bar .fill{position:absolute;left:0;top:10px;height:2px;background:var(--ink);width:0}
.bar .tick{position:absolute;top:6px;width:1px;height:10px;background:var(--soft)}
.time{min-width:96px;text-align:right;color:var(--soft)}
.meta{font-size:14px;color:var(--soft)}
.meta strong{color:var(--ink);font-weight:600}
.explore{display:grid;grid-template-columns:1fr 1.25fr;min-width:0}
.setlist{padding:18px 0;border-right:1px solid var(--line)}
.setlist h3,.behind h3{font-family:var(--mono);font-weight:400;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--soft);margin:0 22px 8px}
.setlist ol{list-style:none;margin:0;padding:0;counter-reset:s}
.setlist li button{all:unset;box-sizing:border-box;cursor:pointer;display:grid;grid-template-columns:22px 1fr auto;width:100%;padding:7px 22px;font-size:15.5px;gap:8px}
.setlist li button::before{counter-increment:s;content:counter(s,decimal-leading-zero);font-family:var(--mono);font-size:11px;color:var(--soft);padding-top:4px}
.setlist li button span{font-family:var(--mono);font-size:12px;color:var(--soft);padding-top:3px}
.setlist li button:hover{background:var(--wall2)}
.setlist li button:focus-visible{outline:2px solid var(--ink);outline-offset:-2px}
.setlist li.now button{background:var(--ink);color:var(--wall)}
.setlist li.now button::before,.setlist li.now button span{color:var(--bone)}
.behind{padding:18px 22px}
.behind h4{font-weight:400;font-size:26px;margin:0 0 4px;letter-spacing:-.01em;font-variation-settings:"opsz" 72}
.tabs{display:flex;flex-wrap:wrap;gap:2px;margin:10px 0 12px;font-family:var(--mono);font-size:12.5px}
.tabs button{all:unset;cursor:pointer;padding:4px 8px;white-space:nowrap;border:1px solid var(--line);color:var(--soft)}
.tabs button[aria-selected="true"]{border-color:var(--ink);color:var(--ink);background:#fffaf0}
.tabs button:focus-visible{outline:2px solid var(--rust)}
.pane{font-size:15.5px;min-height:150px}
.who{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0}
.who dt{font-family:var(--mono);font-size:13px;color:var(--soft);padding-top:2px}
.who dd{margin:0}
blockquote{margin:0 0 10px;padding-left:14px;border-left:2px solid var(--rust);font-style:italic}
.photo{height:150px;background:repeating-linear-gradient(135deg,#d9cdb8 0 10px,#d2c5ae 10px 20px);display:flex;align-items:flex-end;padding:8px;font-family:var(--mono);font-size:12px;color:#5d5246}
.versions{list-style:none;margin:0;padding:0}
.versions li{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 0;border-bottom:1px dashed var(--line)}
.versions li .sw{width:14px;height:14px;border-radius:2px;background:var(--c);display:inline-block;margin-right:8px;vertical-align:-2px}
.versions button{font:12px var(--mono);background:none;border:1px solid var(--ink);padding:3px 8px;cursor:pointer}
.versions button:hover{background:var(--ink);color:var(--wall)}
.versions .cur{font:12px var(--mono);color:var(--soft)}
.video{margin-top:12px;height:120px;background:#2d2620;color:#cfc3b0;display:flex;align-items:center;gap:14px;padding:0 18px;font-family:var(--mono);font-size:12.5px;cursor:pointer;border:0;width:100%;text-align:left}
.video .pl{width:38px;height:38px;border:1.5px solid #cfc3b0;border-radius:50%;display:grid;place-items:center;flex:none}
.video.on{background:#1b1612}
/* quiet section */
.quiet{margin:70px 0 0;padding-top:30px;border-top:1px solid var(--line);display:grid;grid-template-columns:1fr 1fr;gap:56px}
.quiet h2{font-family:var(--mono);font-weight:400;font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:var(--soft);margin:0 0 14px}
.writing details{border-bottom:1px solid var(--line);padding:10px 0}
.writing summary{cursor:pointer;list-style:none;display:flex;justify-content:space-between;gap:16px;font-size:18px}
.writing summary::-webkit-details-marker{display:none}
.writing summary span{font-family:var(--mono);font-size:12px;color:var(--soft);white-space:nowrap;padding-top:4px}
.writing summary:hover{color:var(--rust)}
.writing details p{margin:8px 0 2px;color:var(--soft);font-size:15.5px}
.finds{list-style:none;padding:0;margin:0}
.finds li{padding:9px 0;border-bottom:1px solid var(--line);display:grid;grid-template-columns:70px 1fr;gap:12px;font-size:15.5px}
.finds li i{font-style:normal;font-family:var(--mono);font-size:11.5px;color:var(--soft);padding-top:3px}
.finds a{text-decoration:none;border-bottom:1px solid var(--line)}
.finds a:hover{border-color:var(--ink)}
.finds small{display:block;color:var(--soft);font-size:14px}
footer{margin:60px 0 30px;padding-top:14px;border-top:1px solid var(--line);font-family:var(--mono);font-size:12px;color:var(--soft);display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
@media (max-width:820px){
.intro,.quiet{grid-template-columns:1fr;gap:20px}
.deck{grid-template-columns:1fr}
.face{border-right:0;border-bottom:1px solid var(--line)}
.explore{grid-template-columns:1fr}
.setlist{border-right:0;border-bottom:1px solid var(--line)}
.shelf{overflow-x:auto;gap:4px}
.shelf .card{display:none}
.intro h1{font-size:30px}
}
</style>
</head>
<body>
<div class="mock-note" role="note"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>P1 · The crate</b>Recordings as cassettes on a shelf: pull one out, it plays, and the setlist, behind-the-song notes and other versions open underneath. Borrows mindofakira's "choose to play, then explore"; writing stays quiet below, like marcschuster.</div>
<div class="wrap">
<header class="top">
<div class="name">Lucas Winther <small>as a person</small></div>
<nav class="spaces" aria-label="Spaces">
<a href="#">with AI, now</a>
<a href="#" aria-current="page">as a person</a>
<a href="#">by hand, before ↗</a>
</nav>
</header>
<section class="intro">
<h1>Mostly I write software. In the evenings I play guitar in a <em class="ph" title="placeholder">small band</em> and write things down.</h1>
<p>Everything we've recorded fits on one shelf. Pull a tape out to listen, then dig into it: who's playing, where it came from, how it sounded on other nights.</p>
</section>
<div class="shelf-head">
<span>the shelf · <span id="count"></span></span>
<span class="filters" role="group" aria-label="Filter tapes">
<button aria-pressed="true" data-f="all">all</button>
<button aria-pressed="false" data-f="live">live handcam</button>
<button aria-pressed="false" data-f="demo">demos &amp; projects</button>
</span>
</div>
<div class="shelf" id="shelf" role="list" aria-label="Recordings"></div>
<div class="peek" id="peek" aria-live="polite">Hover a tape to read the label. Click to pull it out.</div>
<section class="deck empty" id="deck" aria-label="Player">
<div class="hint">The deck is empty. Nothing plays until you pick a tape.</div>
</section>
<section class="quiet">
<div class="writing">
<h2>Writing, now and then</h2>
<details><summary><span class="ph" title="placeholder">On playing to eleven people</span><span>Oct 2026</span></summary><p>Small rooms are harder than big ones. You can see every face, and every face can see your left hand. Some notes after the October gig.</p></details>
<details><summary><span class="ph" title="placeholder">Why I still keep a paper notebook</span><span>Aug 2026</span></summary><p>Half of the band's lyrics started in the margin of a meeting. The other half started on the bus.</p></details>
<details><summary><span class="ph" title="placeholder">Handcam is the honest format</span><span>May 2026</span></summary><p>Shaky phone footage from the third row, with the bar chatter left in. I've stopped wanting anything cleaner.</p></details>
<details><summary><span class="ph" title="placeholder">A winter of learning to sing harmony</span><span>Feb 2026</span></summary><p>Short version: record yourself, listen once, never again.</p></details>
</div>
<div>
<h2>Findings</h2>
<ul class="finds">
<li><i>link</i><div><a href="#" class="ph" title="placeholder">A field guide to Danish bus shelters</a><small>Someone photographed all of them. All of them.</small></div></li>
<li><i>record</i><div><a href="#" class="ph" title="placeholder">Bill Evans · Sunday at the Village Vanguard</a><small>Listen for the glasses clinking. That's the handcam feeling, in 1961.</small></div></li>
<li><i>tool</i><div><a href="#" class="ph" title="placeholder">A free tuner that doesn't nag</a><small>Rare.</small></div></li>
<li><i>essay</i><div><a href="#" class="ph" title="placeholder">"The case for small software"</a><small>Read it twice. Applies to bands too.</small></div></li>
<li><i>place</i><div><a href="#" class="ph" title="placeholder">A rehearsal room that rents by the hour, 90 DKK</a><small>Thursday nights, if you need us.</small></div></li>
</ul>
</div>
</section>
<footer>
<span>Placeholders (dotted underline): band name <span class="ph" title="placeholder">Lav Sol</span>, members, songs, venues, dates, writing titles, findings, photos.</span>
<span><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a> · <a href="#">GitHub</a> · <a href="#">blog</a></span>
</footer>
</div>
<script>
const M = {lucas:"Lucas · guitar, backing vocals", signe:"Signe · vocals, keys", mads:"Mads · bass", jonas:"Jonas · drums"};
const tapes = [
{id:"retro", kind:"live", c:"var(--rust)", h:172, title:"Live at Café Retro", date:"14 Mar 2026", venue:"Café Retro (placeholder)", note:"Filmed by Signe's flatmate from the bar.",
set:[["Grå morgen",252],["Kitchen light",208],["Slow ferry",301],["Halvvejs",196],["Paper boats",244]]},
{id:"kaelder", kind:"live", c:"var(--pine)", h:160, title:"Basement, Studenterhuset", date:"27 Oct 2025", venue:"Studenterhuset basement (placeholder)", note:"Phone on a beer crate, front left.",
set:[["Paper boats",231],["Grå morgen",270],["New one (no name)",188],["Slow ferry",322]]},
{id:"have", kind:"live", c:"var(--mustard)", h:150, title:"Garden party, Ida's", date:"9 Aug 2025", venue:"a friend's garden (placeholder)", note:"Acoustic. Wasps were involved.",
set:[["Kitchen light",190],["Halvvejs",205],["Grå morgen",240]]},
{id:"demo1", kind:"demo", c:"var(--olive)", h:138, thin:true, title:"Kitchen demos", date:"winter 2024", venue:"Lucas's kitchen", note:"One mic, laptop, a lot of fridge hum.",
set:[["Kitchen light (first take)",174],["Slow ferry (sketch)",142]]},
{id:"loop", kind:"demo", c:"var(--ox)", h:146, thin:true, title:"Loop pedal project", date:"spring 2026", venue:"rehearsal room", note:"Ten loops, one rule: every loop under eight bars.",
set:[["Loop 03",96],["Loop 07",121],["Loop 10",88]]},
{id:"spil", kind:"live", c:"var(--bone)", h:166, title:"First gig ever", date:"2 Jun 2024", venue:"a bar's open mic (placeholder)", note:"Three songs, too fast, very nervous.",
set:[["Halvvejs",170],["Paper boats",215],["Kitchen light",180]]},
];
const songs = {
"Grå morgen":{who:{lucas:"lead guitar, the riff",signe:"vocals",mads:"bass",jonas:"drums, brushes in the intro"},
lyric:"\"Grey morning, the coffee knows before I do.\"", origin:"Started as a riff Lucas played to warm up. Signe wrote words over it in one rehearsal and we never changed them.", photo:"photo: Signe at the mic, red light"},
"Kitchen light":{who:{lucas:"acoustic, vocals on verse 2",signe:"vocals, Rhodes",mads:"bass",jonas:"shaker, then drums"},
lyric:"\"Leave the kitchen light on, I'll find my way.\"", origin:"Written in a kitchen at 2am. The demo is on the olive tape; the live versions got slower every year.", photo:"photo: the kitchen, one lamp"},
"Slow ferry":{who:{lucas:"guitar, delay pedal",signe:"keys, vocals",mads:"bass, the long notes",jonas:"drums"},
lyric:"\"Nothing to do but stand at the rail.\"", origin:"About the ferry to Bornholm (placeholder). It's the one song we let run long.", photo:"photo: Mads, eyes closed"},
"Halvvejs":{who:{lucas:"guitar",signe:"vocals",mads:"bass",jonas:"drums"},
lyric:"\"Halvvejs hjem, halvvejs væk.\"", origin:"Our only song in Danish. The title means \"halfway\".", photo:"photo: setlist taped to the floor"},
"Paper boats":{who:{lucas:"guitar, harmony",signe:"vocals",mads:"bass",jonas:"drums, count-in"},
lyric:"\"Fold it twice and let it go.\"", origin:"Usually the last song. Jonas counts in too fast, every time.", photo:"photo: crowd, blurry, happy"},
};
const base = t => t.replace(/ \(.*\)$/,"");
const fmt = s => (Math.floor(s/60))+":"+String(Math.floor(s%60)).padStart(2,"0");
const shelf = document.getElementById("shelf"), peek = document.getElementById("peek"), deck = document.getElementById("deck");
function renderShelf(){
shelf.innerHTML = tapes.map(t=>`<button class="spine${t.thin?" thin":""}" role="listitem" data-id="${t.id}" data-kind="${t.kind}" style="--c:${t.c};--h:${t.h}px" aria-label="${t.title}, ${t.date}"><span class="lbl">${t.title}<i>${t.date.slice(-4)}</i></span></button>`).join("")
+ `<div class="card">${tapes.length} tapes. Live ones are handcam; the thin ones are demos and side projects.</div><div class="bookend"></div>`;
document.getElementById("count").textContent = tapes.length+" tapes";
shelf.querySelectorAll(".spine").forEach(b=>{
const t = tapes.find(x=>x.id===b.dataset.id);
const show = ()=> peek.innerHTML = `<b>${t.title}</b> · ${t.date} · ${t.set.length} songs · ${fmt(t.set.reduce((a,s)=>a+s[1],0))} · <i>${t.note}</i>`;
b.addEventListener("mouseenter",show); b.addEventListener("focus",show);
b.addEventListener("click",()=>load(t.id,0,true));
});
}
document.querySelectorAll(".filters button").forEach(b=>b.addEventListener("click",()=>{
document.querySelectorAll(".filters button").forEach(x=>x.setAttribute("aria-pressed",x===b));
shelf.querySelectorAll(".spine").forEach(s=>s.classList.toggle("dim", b.dataset.f!=="all" && s.dataset.kind!==b.dataset.f));
}));
let cur=null, pos=0, playing=false, timer=null, tab="who", videoOn=false;
const starts = t => {let a=0; return t.set.map(s=>{const st=a; a+=s[1]; return st;});};
const total = t => t.set.reduce((a,s)=>a+s[1],0);
const idxAt = (t,p)=>{const st=starts(t); let i=0; st.forEach((v,k)=>{if(p>=v)i=k}); return i;};
function load(id, songIdx, autoplay){
cur = tapes.find(t=>t.id===id); pos = starts(cur)[songIdx]; videoOn=false;
shelf.querySelectorAll(".spine").forEach(s=>s.classList.toggle("out", s.dataset.id===id));
deck.classList.remove("empty");
const st = starts(cur), T = total(cur);
deck.innerHTML = `
<div class="face" id="face">
<div class="cass" style="--c:${cur.c}"><div class="paper"><b>${cur.title}</b>${cur.date} · side A</div>
<div class="win"><span class="reel l"></span><span class="reel r"></span></div><span class="holes"></span></div>
<div class="controls">
<button class="btn sm" id="prev" aria-label="Previous song">«</button>
<button class="btn" id="pp" aria-label="Play">▶</button>
<button class="btn sm" id="next" aria-label="Next song">»</button>
<span class="time" id="time">0:00 / ${fmt(T)}</span>
</div>
<div class="bar" id="bar" aria-label="Seek">${st.slice(1).map(s=>`<span class="tick" style="left:${s/T*100}%"></span>`).join("")}<span class="fill" id="fill"></span></div>
<div class="meta"><strong>${cur.venue}</strong><br>${cur.note}</div>
</div>
<div class="explore">
<div class="setlist"><h3>Setlist · jump anywhere</h3><ol id="sl">${cur.set.map((s,i)=>`<li><button data-i="${i}">${s[0]}<span>${fmt(st[i])}</span></button></li>`).join("")}</ol></div>
<div class="behind" id="behind"></div>
</div>`;
deck.querySelector("#pp").onclick = ()=> setPlay(!playing);
deck.querySelector("#prev").onclick = ()=> jump(Math.max(0, idxAt(cur,pos)-1));
deck.querySelector("#next").onclick = ()=> jump(Math.min(cur.set.length-1, idxAt(cur,pos)+1));
deck.querySelector("#bar").onclick = e => { const r=e.currentTarget.getBoundingClientRect(); pos = (e.clientX-r.left)/r.width*T; update(true); };
deck.querySelectorAll("#sl button").forEach(b=>b.onclick=()=>jump(+b.dataset.i));
setPlay(!!autoplay); update(true);
}
function jump(i){ pos = starts(cur)[i]; update(true); }
function setPlay(on){
playing = on; clearInterval(timer);
const pp = document.getElementById("pp"); if(!pp) return;
pp.textContent = on ? "❚❚" : "▶"; pp.setAttribute("aria-label", on?"Pause":"Play");
document.getElementById("face").classList.toggle("playing", on);
if(on) timer = setInterval(()=>{ pos+=1; if(pos>=total(cur)){pos=total(cur); setPlay(false);} update(false); },1000);
}
let lastIdx=-1;
function update(force){
const T=total(cur), i=idxAt(cur,pos);
document.getElementById("fill").style.width = (pos/T*100)+"%";
document.getElementById("time").textContent = fmt(pos)+" / "+fmt(T);
const face=document.querySelector(".cass"); face.style.setProperty("--fl",(14-pos/T*12)+"px"); face.style.setProperty("--fr",(2+pos/T*12)+"px");
document.querySelectorAll("#sl li").forEach((li,k)=>li.classList.toggle("now",k===i));
if(force || i!==lastIdx){ lastIdx=i; renderBehind(i); }
}
function renderBehind(i){
const title = cur.set[i][0], s = songs[base(title)];
const versions = tapes.filter(t=>t.set.some(x=>base(x[0])===base(title)));
let pane = "";
if(!s){ pane = `<p style="color:var(--soft)">No notes for this one yet. ${cur.kind==="demo"?"It's a sketch from a side project.":"We only played it once."}</p>`; }
else if(tab==="who"){ pane = `<dl class="who">${Object.entries(s.who).map(([k,v])=>`<dt>${M[k].split(" · ")[0]}</dt><dd>${v}</dd>`).join("")}</dl>`; }
else if(tab==="story"){ pane = `<blockquote>${s.lyric}</blockquote><p style="margin:0">${s.origin}</p>`; }
else if(tab==="photo"){ pane = `<div class="photo">${s.photo} · ${cur.date}</div>`; }
else { pane = `<ul class="versions">${versions.map(t=>{const j=t.set.findIndex(x=>base(x[0])===base(title));
return `<li><span><span class="sw" style="--c:${t.c}"></span>${t.title} <span style="color:var(--soft);font-size:13px">· ${t.date} · ${fmt(t.set[j][1])}</span></span>${t===cur?'<span class="cur">playing</span>':`<button data-t="${t.id}" data-j="${j}">play this one</button>`}</li>`;}).join("")}</ul>`; }
document.getElementById("behind").innerHTML = `
<h3>Behind the song</h3><h4>${title}</h4>
<div class="tabs" role="tablist">${[["who","who plays what"],["story","lyric & origin"],["photo","photo from the night"],["versions",`other nights (${versions.length})`]].map(([k,l])=>`<button role="tab" aria-selected="${tab===k}" data-k="${k}">${l}</button>`).join("")}</div>
<div class="pane">${pane}</div>
${cur.kind==="live"?`<button class="video${videoOn?" on":""}" id="vid"><span class="pl">${videoOn?"❚❚":"▶"}</span><span>handcam video · ${title} · ${cur.venue}<br><span style="opacity:.7">${videoOn?"playing (placeholder frame)":"placeholder frame, click to watch"}</span></span></button>`:""}`;
document.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{tab=b.dataset.k; renderBehind(idxAt(cur,pos));});
document.querySelectorAll(".versions button").forEach(b=>b.onclick=()=>load(b.dataset.t,+b.dataset.j,playing));
const v=document.getElementById("vid"); if(v) v.onclick=()=>{videoOn=!videoOn; renderBehind(idxAt(cur,pos));};
}
renderShelf();
</script>
</body>
</html>
+359
View File
@@ -0,0 +1,359 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther · as a person (P2 · The desk)</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,400;0,6..72,600;1,6..72,400&family=IBM+Plex+Mono:wght@400;500&family=Reenie+Beanie&display=swap" rel="stylesheet">
<style>
:root{
--desk:#ddd0b8; --desk2:#d3c4a8; --blotter:#a9b597; --paper:#fbf7ee; --kraft:#c9a678; --ink:#26231e; --soft:#6b6457; --line:#cdbf9f;
--red:#b8452f; --tape:rgba(240,214,120,.62); --green:#3e5a47;
--serif:"Newsreader", Georgia, serif; --mono:"IBM Plex Mono", ui-monospace, monospace; --hand:"Reenie Beanie", cursive;
}
*{box-sizing:border-box}
body{margin:0;background:var(--desk);color:var(--ink);font-family:var(--serif);font-size:17px;line-height:1.5;
background-image:repeating-linear-gradient(90deg,transparent 0 160px,rgba(0,0,0,.018) 160px 162px)}
a{color:inherit}
.ph{text-decoration:underline dotted var(--soft);text-underline-offset:3px;cursor:help}
.mock-note{position:fixed;top:10px;right:10px;z-index:99;max-width:300px;background:#fff;border:1px solid #bbb;padding:8px 30px 8px 10px;font:12px/1.4 system-ui,sans-serif;color:#333}
.mock-note b{display:block}
.mock-note button{position:absolute;top:4px;right:6px;border:0;background:none;font-size:16px;cursor:pointer;color:#666}
.wrap{max-width:1160px;margin:0 auto;padding:0 28px 40px}
header{display:grid;grid-template-columns:1fr auto;align-items:end;gap:24px;padding:28px 0 18px}
header h1{margin:0;font-weight:400;font-size:34px;letter-spacing:-.015em;line-height:1.1}
header h1 i{color:var(--soft)}
header p{margin:6px 0 0;color:var(--soft);max-width:560px;font-size:16px}
nav.spaces{font-family:var(--mono);font-size:12.5px;display:flex;gap:16px}
nav.spaces a{text-decoration:none;color:var(--soft)} nav.spaces a[aria-current]{color:var(--ink);border-bottom:1px solid}
.trays{display:flex;gap:6px;align-items:flex-end;margin:0 0 -1px 18px;position:relative;z-index:2;flex-wrap:wrap}
.trays button{font:12.5px var(--mono);border:1px solid var(--line);border-bottom:0;background:var(--paper);padding:6px 12px 5px;cursor:pointer;color:var(--soft)}
.trays button[aria-pressed="true"]{color:var(--ink);padding-bottom:9px;background:var(--blotter);border-color:#8e9a7c}
.trays .tip{font:13px var(--mono);color:var(--soft);margin-left:auto;align-self:center;padding-bottom:6px}
.blotter{background:var(--blotter);border:1px solid #8e9a7c;padding:30px 26px 34px;position:relative;
background-image:radial-gradient(rgba(0,0,0,.04) 1px,transparent 1px);background-size:5px 5px}
.grid{display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:auto;gap:26px 24px;align-items:start}
.zone{grid-column:1/-1;min-height:0;font-family:var(--hand);font-size:26px;color:#2f3d2e;line-height:1;margin:6px 0 -10px;display:flex;align-items:center;gap:12px}
.zone::after{content:"";flex:1;border-top:1px dashed rgba(47,61,46,.35)}
.item{all:unset;box-sizing:border-box;display:block;min-height:150px;cursor:pointer;position:relative;background:var(--paper);padding:16px 16px 14px;
transform:rotate(var(--r,0deg));transition:transform .2s ease, box-shadow .2s ease, opacity .2s;
box-shadow:0 1px 1px rgba(40,30,10,.18), 0 2px 6px rgba(40,30,10,.08)}
.item:hover,.item:focus-visible{transform:rotate(0) translateY(-6px);box-shadow:0 2px 2px rgba(40,30,10,.12), 0 14px 24px rgba(40,30,10,.18);z-index:3}
.item:focus-visible{outline:2px solid var(--ink);outline-offset:4px}
.item.dim{opacity:.25}
.item .k{font:11px var(--mono);letter-spacing:.06em;text-transform:uppercase;color:var(--soft);display:block;margin-bottom:6px}
.item h3{margin:0 0 6px;font-weight:600;font-size:19px;line-height:1.2}
.item p{margin:0;font-size:15px;color:#4a443a}
.item .more{display:none}
.item.open{grid-column:span 2;grid-row:span 2;transform:none;cursor:default;z-index:4;box-shadow:0 2px 2px rgba(40,30,10,.12), 0 18px 34px rgba(40,30,10,.2)}
.item.open .more{display:block}
.item.open .less{display:none}
.item .close{display:none;position:absolute;top:8px;right:10px;font:12px var(--mono);background:none;border:0;cursor:pointer;color:var(--soft)}
.item.open .close{display:block}
.tape{position:absolute;top:-10px;left:50%;width:70px;height:20px;margin-left:-35px;background:var(--tape);transform:rotate(-3deg)}
.note{background:#fbf3c9}
.lined{background:var(--paper) repeating-linear-gradient(transparent 0 25px,#d9e2ea 25px 26px);background-position:0 8px;line-height:26px}
.lined p{line-height:26px}
.polaroid{padding:10px 10px 12px;background:#fdfcf8}
.polaroid .ph-img{height:130px;background:var(--c,#7d8a74);display:flex;align-items:flex-end;padding:6px 8px;font:11px var(--mono);color:rgba(255,255,255,.85)}
.polaroid .cap{font-family:var(--hand);font-size:22px;line-height:1.1;margin-top:8px}
.item.open.polaroid .ph-img{height:300px}
.ticket{background:var(--kraft);padding:0;display:grid;grid-template-columns:1fr 56px;min-height:120px;
-webkit-mask:radial-gradient(circle at 0 50%,transparent 8px,#000 9px) left/51% 100% no-repeat, radial-gradient(circle at 100% 50%,transparent 8px,#000 9px) right/51% 100% no-repeat;
mask:radial-gradient(circle at 0 50%,transparent 8px,#000 9px) left/51% 100% no-repeat, radial-gradient(circle at 100% 50%,transparent 8px,#000 9px) right/51% 100% no-repeat}
.ticket .main{padding:14px 16px}
.ticket .stub{border-left:2px dashed rgba(38,35,30,.35);writing-mode:vertical-rl;display:flex;align-items:center;justify-content:center;font:11px var(--mono);letter-spacing:.1em}
.ticket h3{font-size:18px}
.ticket .k{color:#5b4a33}
.card{background:#fff;border-top:6px solid var(--red)}
.clip{background:#efe9dc;font-size:14px}
.clip h3{font-size:17px;font-style:italic;font-weight:400}
.receipt{background:#fff;font:12.5px/1.6 var(--mono);padding:14px 16px 20px;
-webkit-mask:conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 50%/12px 100%;mask:conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) 50%/12px 100%}
.receipt .row{display:flex;justify-content:space-between}
.receipt hr{border:0;border-top:1px dashed #999;margin:6px 0}
/* the recorder */
.recorder{background:#30362f;color:#e9e3d2;padding:16px;grid-column:span 2;grid-row:span 1}
.recorder .k{color:#b8bea9}
.recorder .body{display:grid;grid-template-columns:150px 1fr;gap:16px;align-items:center}
.rec-win{height:84px;background:#1d201c;border:2px solid #4b5246;display:flex;align-items:center;justify-content:space-around}
.rec-win span{width:40px;height:40px;border-radius:50%;background:repeating-conic-gradient(#d6cfbd 0 20deg,#5b5f55 20deg 60deg);border:4px solid #2a2d27}
.rec-keys{display:flex;gap:5px;margin-top:8px}
.rec-keys i{flex:1;height:14px;background:#596055;border-bottom:3px solid #23271f}
.rec-keys i:first-child{background:var(--red)}
.recorder h3{color:#fff}
.recorder p{color:#cfd3c3}
.recorder.open{grid-column:1/-1;grid-row:span 1;cursor:default}
.playing .rec-win span{animation:spin 2.4s linear infinite}
@keyframes spin{to{transform:rotate(1turn)}}
@media (prefers-reduced-motion:reduce){.playing .rec-win span{animation:none}}
.player{display:none;margin-top:16px;border-top:1px solid #4b5246;padding-top:16px;grid-template-columns:1fr 1fr 1.1fr;gap:22px;cursor:auto}
.recorder.open .player{display:grid}
.player h4{font:11px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:#b8bea9;margin:0 0 8px;font-weight:400}
.gigs{display:flex;flex-direction:column;gap:6px}
.gigs button,.songs button{all:unset;cursor:pointer;display:flex;justify-content:space-between;gap:10px;padding:6px 8px;font-size:15px;border:1px solid transparent}
.gigs button small,.songs button small{font:11.5px var(--mono);color:#b8bea9;padding-top:3px}
.gigs button:hover,.songs button:hover{border-color:#6c7462}
.gigs button[aria-current="true"],.songs button[aria-current="true"]{background:#e9e3d2;color:#26231e}
.gigs button[aria-current="true"] small,.songs button[aria-current="true"] small{color:#5c5547}
.gigs button:focus-visible,.songs button:focus-visible,.ctrl button:focus-visible{outline:2px solid #f0d678}
.songs{display:flex;flex-direction:column}
.ctrl{display:flex;align-items:center;gap:10px;margin-top:10px;font:12px var(--mono)}
.ctrl button{all:unset;cursor:pointer;border:1px solid #e9e3d2;width:34px;height:34px;display:grid;place-items:center;border-radius:50%}
.ctrl button.big{width:42px;height:42px;background:#e9e3d2;color:#26231e}
.prog{height:18px;position:relative;cursor:pointer;margin-top:8px}
.prog::before{content:"";position:absolute;left:0;right:0;top:8px;height:2px;background:#596055}
.prog i{position:absolute;left:0;top:8px;height:2px;background:#f0d678;width:0}
.bts{background:var(--paper);color:var(--ink);padding:14px 16px;position:relative}
.bts h5{margin:0;font-size:22px;font-weight:400}
.bts .tabs{display:flex;gap:12px;margin:8px 0 10px;font:12px var(--mono);flex-wrap:wrap}
.bts .tabs button{all:unset;cursor:pointer;color:var(--soft);border-bottom:1px solid transparent}
.bts .tabs button[aria-selected="true"]{color:var(--ink);border-color:var(--ink)}
.bts .tabs button:focus-visible{outline:2px solid var(--red)}
.bts dl{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;margin:0;font-size:15px}
.bts dt{font:12.5px var(--mono);color:var(--soft);padding-top:2px}
.bts dd{margin:0}
.bts .pic{height:130px;background:repeating-linear-gradient(45deg,#c4cbb6 0 8px,#bac2ab 8px 16px);font:11.5px var(--mono);color:#4f5747;display:flex;align-items:flex-end;padding:8px}
.stubs{display:flex;gap:8px;flex-wrap:wrap}
.stubs button{font:12px var(--mono);background:var(--kraft);border:0;padding:8px 10px;cursor:pointer;text-align:left}
.stubs button[disabled]{background:#e4dccd;cursor:default}
.stubs button:hover:not([disabled]){background:#d8b98d}
.hand{font-family:var(--hand);font-size:22px;line-height:1.1;color:var(--red)}
footer{margin-top:26px;font:12px var(--mono);color:var(--soft);display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
@media (max-width:900px){.grid{grid-template-columns:repeat(2,1fr)} .player{grid-template-columns:1fr} header{grid-template-columns:1fr}}
@media (max-width:480px){.grid{grid-template-columns:1fr} .item.open,.recorder{grid-column:auto} .recorder .body{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="mock-note" role="note"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>P2 · The desk</b>An ordered desk: notes, photos, ticket stubs and a tape recorder in labelled zones. Hover lifts a thing, click opens it in place. The recorder holds the live sets. Borrows kigo.studio's liveliness, kept tidy, plus mindofakira's "play, then explore".</div>
<div class="wrap">
<header>
<div>
<h1>Lucas Winther, <i>off the clock</i></h1>
<p>This is roughly what my desk looks like: a few things I've been thinking about, things I found, photos, and the recorder with our gigs on it. Pick anything up.</p>
</div>
<nav class="spaces" aria-label="Spaces"><a href="#">with AI, now</a><a href="#" aria-current="page">as a person</a><a href="#">by hand, before ↗</a></nav>
</header>
<div class="trays" role="group" aria-label="Show only">
<button aria-pressed="true" data-f="all">everything</button>
<button aria-pressed="false" data-f="musing">musings</button>
<button aria-pressed="false" data-f="found">findings</button>
<button aria-pressed="false" data-f="photo">photos</button>
<button aria-pressed="false" data-f="music">music</button>
<span class="tip">hover lifts · click opens · esc puts it back</span>
</div>
<main class="blotter">
<div class="grid" id="grid">
<div class="zone">the recorder &amp; gigs</div>
<div class="item recorder" data-t="music" tabindex="0" role="button" aria-expanded="false" id="rec">
<span class="k">tape recorder · <span class="ph" title="placeholder">Lav Sol</span>, live</span>
<div class="body">
<div><div class="rec-win"><span></span><span></span></div><div class="rec-keys"><i></i><i></i><i></i><i></i></div></div>
<div class="less"><h3>Our gigs, recorded on a phone</h3><p>Four nights, three bedroom projects. Open it to play a set, skip between songs and read what's behind each one.</p></div>
<div class="more"><h3 id="recTitle">Live at Café Retro</h3><p id="recSub"></p></div>
</div>
<div class="player" id="player">
<div>
<h4>Tapes</h4>
<div class="gigs" id="gigs"></div>
</div>
<div>
<h4>Setlist</h4>
<div class="songs" id="songs"></div>
<div class="prog" id="prog" aria-label="Seek"><i id="fill"></i></div>
<div class="ctrl"><button id="prev" aria-label="Previous">«</button><button class="big" id="pp" aria-label="Play">▶</button><button id="next" aria-label="Next">»</button><span id="time">0:00</span></div>
</div>
<div class="bts" id="bts"></div>
</div>
<button class="close" aria-label="Close">put back ×</button>
</div>
<button class="item ticket" data-t="music" data-gig="retro" style="--r:-1.5deg"><div class="main"><span class="k">ticket · 14.03.26</span><h3 class="ph" title="placeholder">Café Retro</h3><p>Five songs, 60 DKK at the door. Opens the recorder at this gig.</p></div><div class="stub">ADMIT ONE</div></button>
<button class="item ticket" data-t="music" data-gig="kaelder" style="--r:1.2deg"><div class="main"><span class="k">ticket · 27.10.25</span><h3 class="ph" title="placeholder">Studenterhuset, basement</h3><p>The loud one. Opens the recorder at this gig.</p></div><div class="stub">Nº 0041</div></button>
<div class="zone">musings</div>
<div class="item note" data-t="musing" style="--r:-1deg" tabindex="0" role="button" aria-expanded="false">
<span class="k">sticky note</span>
<h3 class="ph" title="placeholder">Songs I still haven't finished</h3>
<p class="less">1. the one about the ferry<br>2. "Halvvejs", second verse<br>3. …</p>
<div class="more"><p>1. the one about the ferry: it's done, I just don't like the bridge.<br>2. "Halvvejs", second verse. Signe says it's fine. It's not fine.<br>3. A song made only of things overheard on the 5A bus.<br>4. An instrumental for the rowing-game soundtrack I never made.</p><p class="hand">update: the ferry one is done-ish</p></div>
<button class="close" aria-label="Close">put back ×</button>
</div>
<div class="item lined" data-t="musing" style="--r:.8deg" tabindex="0" role="button" aria-expanded="false">
<span class="k">essay · Oct 2026</span>
<h3 class="ph" title="placeholder">On playing to eleven people</h3>
<p class="less">Small rooms are harder than big ones. You can see every face…</p>
<div class="more"><p>Small rooms are harder than big ones. You can see every face, and every face can see your left hand. At the October gig there were eleven people and a dog, and I counted them twice during the first song.</p><p>What helped: looking at Jonas instead of the room. What didn't: the third coffee. What I'd do again: tell the story behind the song before playing it. People listen differently when they know where a song came from. That's partly why the recorder above has notes on every track.</p></div>
<button class="close" aria-label="Close">put back ×</button>
</div>
<div class="item card" data-t="musing" style="--r:-.6deg" tabindex="0" role="button" aria-expanded="false">
<span class="k">index card</span>
<h3>Writing code vs. writing songs</h3>
<p class="less">Both are mostly deleting. One has a test suite.</p>
<div class="more"><p>Both are mostly deleting. One has a test suite.</p><p>Lately I've been working with AI tools all day (that's <a href="#">the other side of the site</a>). Songwriting is where I deliberately don't. It's the one place I want to be slow and a bit bad at something.</p></div>
<button class="close" aria-label="Close">put back ×</button>
</div>
<div class="item receipt" data-t="musing" style="--r:1.4deg" tabindex="0" role="button" aria-expanded="false">
<span class="k">receipt</span>
<div class="row"><span>strings ×2</span><span>178</span></div>
<div class="row"><span>room, 4 thu</span><span>360</span></div>
<div class="row"><span>capo (lost)</span><span>95</span></div>
<hr><div class="row"><b>band, Oct</b><b>633 DKK</b></div>
<div class="more"><hr><p style="font-size:12.5px">Tracked in the <a href="#">Monthly Expenses Tracker</a>, which I built for exactly this kind of thing. Third capo this year.</p></div>
<button class="close" aria-label="Close">put back ×</button>
</div>
<div class="zone">found &amp; kept</div>
<div class="item clip" data-t="found" style="--r:.6deg" tabindex="0" role="button" aria-expanded="false">
<span class="k">clipping · essay</span>
<h3 class="ph" title="placeholder">"The case for small software"</h3>
<p class="less">Read it twice. Applies to bands too.</p>
<div class="more"><p>Read it twice. The argument: things made for a few people can be made with more care. Applies to bands too. <a href="#">Read it →</a></p></div>
<button class="close" aria-label="Close">put back ×</button>
</div>
<div class="item card" data-t="found" style="--r:-1.2deg;border-top-color:var(--green)" tabindex="0" role="button" aria-expanded="false">
<span class="k">record</span>
<h3 class="ph" title="placeholder">Bill Evans, Sunday at the Village Vanguard</h3>
<p class="less">You can hear the glasses clink.</p>
<div class="more"><p>You can hear the glasses clink and someone laughing at the back. It's handcam, in 1961. This is the record that made me stop caring about clean recordings.</p></div>
<button class="close" aria-label="Close">put back ×</button>
</div>
<div class="item note" data-t="found" style="--r:.9deg;background:#f6e1d6" tabindex="0" role="button" aria-expanded="false">
<span class="k">link</span>
<h3 class="ph" title="placeholder">Every bus shelter in Denmark, photographed</h3>
<p class="less">Someone did it. All of them.</p>
<div class="more"><p>Someone did it. All of them. There's a map. I've spent an embarrassing amount of time on it. <a href="#">Have a look →</a></p></div>
<button class="close" aria-label="Close">put back ×</button>
</div>
<div class="item clip" data-t="found" style="--r:-.4deg" tabindex="0" role="button" aria-expanded="false">
<span class="k">tool</span>
<h3 class="ph" title="placeholder">A guitar tuner that doesn't nag</h3>
<p class="less">No account, no ads, no subscription.</p>
<div class="more"><p>No account, no ads, no subscription. Just a needle. <a href="#">Link →</a></p></div>
<button class="close" aria-label="Close">put back ×</button>
</div>
<div class="zone">photos</div>
<div class="item polaroid" data-t="photo" style="--r:-2deg" tabindex="0" role="button" aria-expanded="false"><span class="tape"></span>
<div class="ph-img" style="--c:#7b6a55">photo: rehearsal room, Thursday</div><div class="cap">the room, 90 kr/hour</div>
<div class="more"><p style="margin-top:8px;font-size:15px">Egg-box foam on one wall, a broken amp nobody will throw out. We've rehearsed here every Thursday since <span class="ph" title="placeholder">2023</span>.</p></div>
<button class="close" aria-label="Close">put back ×</button>
</div>
<div class="item polaroid" data-t="photo" style="--r:1.5deg" tabindex="0" role="button" aria-expanded="false"><span class="tape"></span>
<div class="ph-img" style="--c:#4e6158">photo: ferry deck, grey sea</div><div class="cap">where "Slow ferry" came from</div>
<div class="more"><p style="margin-top:8px;font-size:15px">Taken on the way to <span class="ph" title="placeholder">Bornholm</span>. I wrote the first verse on the back of the ticket.</p></div>
<button class="close" aria-label="Close">put back ×</button>
</div>
<div class="item polaroid" data-t="photo" style="--r:-.8deg" tabindex="0" role="button" aria-expanded="false"><span class="tape"></span>
<div class="ph-img" style="--c:#8f5b46">photo: setlist on the floor</div><div class="cap">gaffer tape &amp; hope</div>
<div class="more"><p style="margin-top:8px;font-size:15px">Café Retro, March. We skipped song four and nobody noticed.</p></div>
<button class="close" aria-label="Close">put back ×</button>
</div>
<div class="item polaroid" data-t="photo" style="--r:1deg" tabindex="0" role="button" aria-expanded="false"><span class="tape"></span>
<div class="ph-img" style="--c:#6b7b5a">photo: the oars, rowing game</div><div class="cap">old project, still in the hallway</div>
<div class="more"><p style="margin-top:8px;font-size:15px">Two real oars from the <a href="#">Mixed-Reality Rowing Game</a>. They live in the hallway now.</p></div>
<button class="close" aria-label="Close">put back ×</button>
</div>
</div>
</main>
<footer><span>Placeholders (dotted underline): band name, members, songs, venues, dates, essay and finding titles, photos.</span><span><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a> · <a href="#">GitHub</a> · <a href="#">blog</a></span></footer>
</div>
<script>
const gigs = [
{id:"retro", name:"Café Retro", date:"14 Mar 2026", note:"handcam from the bar", set:[["Grå morgen",252],["Kitchen light",208],["Slow ferry",301],["Halvvejs",196],["Paper boats",244]]},
{id:"kaelder", name:"Studenterhuset basement", date:"27 Oct 2025", note:"phone on a beer crate", set:[["Paper boats",231],["Grå morgen",270],["New one (no name)",188],["Slow ferry",322]]},
{id:"have", name:"Garden party at Ida's", date:"9 Aug 2025", note:"acoustic, wasps", set:[["Kitchen light",190],["Halvvejs",205],["Grå morgen",240]]},
{id:"kitchen", name:"Kitchen demos", date:"winter 2024", note:"one mic, fridge hum", set:[["Kitchen light",174],["Slow ferry",142]]},
];
const S = {
"Grå morgen":{who:[["Lucas","the riff, lead guitar"],["Signe","vocals"],["Mads","bass"],["Jonas","drums, brushes"]],story:"Started as a warm-up riff. Signe wrote words over it in one rehearsal.",lyric:"Grey morning, the coffee knows before I do.",pic:"photo: Signe, red stage light"},
"Kitchen light":{who:[["Lucas","acoustic, verse 2"],["Signe","vocals, Rhodes"],["Mads","bass"],["Jonas","shaker"]],story:"Written in a kitchen at 2am. Every live version is slower than the last.",lyric:"Leave the kitchen light on, I'll find my way.",pic:"photo: one lamp, a kitchen table"},
"Slow ferry":{who:[["Lucas","guitar, delay"],["Signe","keys"],["Mads","the long bass notes"],["Jonas","drums"]],story:"First verse written on the back of a ferry ticket. The one we let run long.",lyric:"Nothing to do but stand at the rail.",pic:"photo: Mads, eyes closed"},
"Halvvejs":{who:[["Lucas","guitar"],["Signe","vocals"],["Mads","bass"],["Jonas","drums"]],story:"Our only Danish song. The title means \"halfway\".",lyric:"Halvvejs hjem, halvvejs væk.",pic:"photo: the setlist on the floor"},
"Paper boats":{who:[["Lucas","guitar, harmony"],["Signe","vocals"],["Mads","bass"],["Jonas","count-in (too fast)"]],story:"Usually the closer.",lyric:"Fold it twice and let it go.",pic:"photo: blurry, happy crowd"},
};
const fmt=s=>Math.floor(s/60)+":"+String(Math.floor(s%60)).padStart(2,"0");
const grid=document.getElementById("grid"), rec=document.getElementById("rec");
function closeAll(except){ grid.querySelectorAll(".item.open").forEach(i=>{ if(i!==except){i.classList.remove("open"); i.setAttribute("aria-expanded","false"); if(i===rec && playing) setPlay(false);} }); }
grid.querySelectorAll(".item").forEach(it=>{
if(it.classList.contains("ticket")){ it.addEventListener("click",()=>{ openRec(it.dataset.gig); }); return; }
it.addEventListener("click",e=>{
if(e.target.closest(".close")){ if(it===rec && playing) setPlay(false); it.classList.remove("open"); it.setAttribute("aria-expanded","false"); e.stopPropagation(); return; }
if(it.classList.contains("open")) return;
if(it===rec){ openRec(cur?cur.id:"retro"); return; }
closeAll(it); it.classList.add("open"); it.setAttribute("aria-expanded","true");
});
it.addEventListener("keydown",e=>{ if((e.key==="Enter"||e.key===" ") && e.target===it){ e.preventDefault(); it.click(); } });
});
document.addEventListener("keydown",e=>{ if(e.key==="Escape") closeAll(); });
document.querySelectorAll(".trays button").forEach(b=>b.addEventListener("click",()=>{
document.querySelectorAll(".trays button").forEach(x=>x.setAttribute("aria-pressed",x===b));
grid.querySelectorAll(".item").forEach(i=>i.classList.toggle("dim", b.dataset.f!=="all" && i.dataset.t!==b.dataset.f));
}));
let cur=null, si=0, pos=0, playing=false, timer=null, tab="who";
function openRec(id, songIdx){
closeAll(rec); rec.classList.add("open"); rec.setAttribute("aria-expanded","true");
cur=gigs.find(g=>g.id===id); si=songIdx||0; pos=0;
document.getElementById("recTitle").textContent=cur.name;
document.getElementById("recSub").textContent=cur.date+" · "+cur.note+" · "+cur.set.length+" songs";
document.getElementById("gigs").innerHTML=gigs.map(g=>`<button data-id="${g.id}" aria-current="${g===cur}">${g.name}<small>${g.date.slice(-4)}</small></button>`).join("");
document.querySelectorAll("#gigs button").forEach(b=>b.onclick=e=>{e.stopPropagation(); openRec(b.dataset.id);});
renderSongs(); renderBts();
rec.scrollIntoView({block:"nearest"});
}
function renderSongs(){
document.getElementById("songs").innerHTML=cur.set.map((s,i)=>`<button data-i="${i}" aria-current="${i===si}">${s[0]}<small>${fmt(s[1])}</small></button>`).join("");
document.querySelectorAll("#songs button").forEach(b=>b.onclick=e=>{e.stopPropagation(); go(+b.dataset.i);});
update();
}
function go(i){ si=i; pos=0; renderSongs(); renderBts(); }
function renderBts(){
const name=cur.set[si][0], s=S[name];
const others=gigs.map(g=>({g, j:g.set.findIndex(x=>x[0]===name)})).filter(o=>o.j>-1);
let body="";
if(!s) body=`<p style="margin:0;font-size:15px;color:var(--soft)">No notes yet. We've only played it once and it doesn't have a name.</p>`;
else if(tab==="who") body=`<dl>${s.who.map(w=>`<dt>${w[0]}</dt><dd>${w[1]}</dd>`).join("")}</dl>`;
else if(tab==="story") body=`<p class="hand" style="margin:0 0 6px">"${s.lyric}"</p><p style="margin:0;font-size:15px">${s.story}</p>`;
else if(tab==="pic") body=`<div class="pic">${s.pic} · ${cur.date}</div>`;
else body=`<div class="stubs">${others.map(o=>`<button data-g="${o.g.id}" data-j="${o.j}" ${o.g===cur?"disabled":""}>${o.g.name}<br>${o.g.date} · ${fmt(o.g.set[o.j][1])}${o.g===cur?" · now":""}</button>`).join("")}</div>`;
const bts=document.getElementById("bts");
bts.innerHTML=`<h4 style="color:var(--soft)">Behind the song</h4><h5>${name}</h5>
<div class="tabs" role="tablist">${[["who","who plays what"],["story","lyric & origin"],["pic","photo"],["other",`same song, other nights (${others.length})`]].map(([k,l])=>`<button role="tab" data-k="${k}" aria-selected="${tab===k}">${l}</button>`).join("")}</div>${body}`;
bts.querySelectorAll(".tabs button").forEach(b=>b.onclick=e=>{e.stopPropagation(); tab=b.dataset.k; renderBts();});
bts.querySelectorAll(".stubs button").forEach(b=>b.onclick=e=>{e.stopPropagation(); openRec(b.dataset.g,+b.dataset.j);});
}
function setPlay(on){
playing=on; clearInterval(timer); rec.classList.toggle("playing",on);
const pp=document.getElementById("pp"); pp.textContent=on?"❚❚":"▶"; pp.setAttribute("aria-label",on?"Pause":"Play");
if(on) timer=setInterval(()=>{ pos++; if(pos>=cur.set[si][1]){ if(si<cur.set.length-1){go(si+1);} else {pos=cur.set[si][1]; setPlay(false);} } update(); },1000);
}
function update(){
const L=cur.set[si][1];
document.getElementById("fill").style.width=(pos/L*100)+"%";
document.getElementById("time").textContent=fmt(pos)+" / "+fmt(L);
}
document.getElementById("pp").onclick=e=>{e.stopPropagation(); setPlay(!playing);};
document.getElementById("prev").onclick=e=>{e.stopPropagation(); go(Math.max(0,si-1));};
document.getElementById("next").onclick=e=>{e.stopPropagation(); go(Math.min(cur.set.length-1,si+1));};
document.getElementById("prog").onclick=e=>{e.stopPropagation(); const r=e.currentTarget.getBoundingClientRect(); pos=Math.floor((e.clientX-r.left)/r.width*cur.set[si][1]); update();};
document.getElementById("player").addEventListener("click",e=>e.stopPropagation());
</script>
</body>
</html>
+339
View File
@@ -0,0 +1,339 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther · as a person (P3 · The garden)</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=Spectral:ital,wght@0,300;0,400;0,600;1,400&family=Martian+Mono:wght@300;400&display=swap" rel="stylesheet">
<style>
:root{
--bg:#eef0e6; --bg2:#e4e8da; --ink:#1f2a22; --soft:#5f6b5f; --line:#c3cbb5; --faint:#d6dcc9;
--moss:#4f6b3a; --terra:#c06a43; --ochre:#b4862b; --plum:#7a4f5f;
--serif:"Spectral", Georgia, serif; --mono:"Martian Mono", ui-monospace, monospace;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--serif);font-size:17.5px;line-height:1.6}
a{color:inherit}
.ph{text-decoration:underline dotted var(--soft);text-underline-offset:3px;cursor:help}
.mock-note{position:fixed;top:10px;right:10px;z-index:99;max-width:300px;background:#fff;border:1px solid #bbb;padding:8px 30px 8px 10px;font:12px/1.4 system-ui,sans-serif;color:#333}
.mock-note b{display:block}
.mock-note button{position:absolute;top:4px;right:6px;border:0;background:none;font-size:16px;cursor:pointer;color:#666}
.wrap{max-width:1140px;margin:0 auto;padding:0 30px}
header{display:flex;justify-content:space-between;align-items:baseline;padding:26px 0 16px;border-bottom:1px solid var(--line);gap:20px;flex-wrap:wrap}
header .name{font-size:19px;font-weight:600}
header .name span{font-weight:300;font-style:italic;color:var(--soft)}
nav.spaces{font:11.5px var(--mono);display:flex;gap:16px}
nav.spaces a{text-decoration:none;color:var(--soft)} nav.spaces a[aria-current]{color:var(--ink);border-bottom:1px solid}
.intro{display:grid;grid-template-columns:1fr 330px;gap:50px;padding:28px 0 18px;align-items:end}
.intro h1{font-weight:300;font-size:36px;line-height:1.15;margin:0 0 10px;letter-spacing:-.01em}
.intro p{margin:0;color:var(--soft);max-width:600px}
.legend{font:11px/1.9 var(--mono);color:var(--soft)}
.legend div{display:flex;align-items:center;gap:8px}
.legend svg{flex:none}
.layout{display:grid;grid-template-columns:1fr 400px;gap:44px;align-items:start;padding-bottom:30px}
.filters{display:flex;gap:18px;flex-wrap:wrap;font:11.5px var(--mono);padding:12px 0;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}
.filters span{color:var(--soft)}
.filters button{all:unset;cursor:pointer;color:var(--soft);padding:1px 0}
.filters button[aria-pressed="true"]{color:var(--ink);border-bottom:1px solid var(--ink)}
.filters button:focus-visible{outline:2px solid var(--terra)}
.entries{list-style:none;margin:0;padding:0}
.entry{border-bottom:1px solid var(--faint)}
.entry.hide{display:none}
.entry > button{all:unset;box-sizing:border-box;cursor:pointer;display:grid;grid-template-columns:22px 1fr auto;gap:12px;align-items:baseline;width:100%;padding:11px 8px}
.entry > button:hover,.entry.hl > button{background:var(--bg2)}
.entry > button:focus-visible{outline:2px solid var(--terra);outline-offset:-2px}
.entry .t{font-size:18.5px}
.entry .t small{display:block;font-size:14.5px;color:var(--soft);line-height:1.4}
.entry .m{font:10.5px var(--mono);color:var(--soft);text-align:right;white-space:nowrap}
.entry .g{align-self:center}
.entry .body{display:none;padding:2px 8px 20px 42px}
.entry.open .body{display:block}
.entry.open > button .t{font-weight:600}
.body p{margin:0 0 10px;max-width:60ch}
.links{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:12px;font:11px var(--mono);color:var(--soft);align-items:baseline}
.links button{all:unset;cursor:pointer;font-family:var(--serif);font-size:15px;color:var(--ink);border-bottom:1px solid var(--line)}
.links button:hover{border-color:var(--ink)}
.links button:focus-visible{outline:2px solid var(--terra)}
.rec{display:grid;grid-template-columns:auto 1fr;gap:14px;align-items:center;margin:4px 0 14px}
.playbtn{all:unset;cursor:pointer;width:42px;height:42px;border-radius:50%;background:var(--ochre);color:#fff;display:grid;place-items:center;font-size:14px}
.playbtn:hover{background:#9a7120}
.playbtn:focus-visible{outline:2px solid var(--ink);outline-offset:2px}
.vers{list-style:none;margin:6px 0 12px;padding:0;font-size:15.5px}
.vers li{display:grid;grid-template-columns:18px 1fr auto;gap:10px;padding:5px 0;border-bottom:1px dashed var(--line);align-items:baseline}
.vers button{all:unset;cursor:pointer;font:11px var(--mono);color:var(--ochre)}
.vers button:hover{text-decoration:underline}
.vers .on{font:11px var(--mono);color:var(--ink)}
.two{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:8px 0}
.credits{display:grid;grid-template-columns:auto 1fr;gap:2px 12px;font-size:15px;margin:0}
.credits dt{font:11px var(--mono);color:var(--soft);padding-top:3px}
.credits dd{margin:0}
.photo{height:118px;background:repeating-linear-gradient(-45deg,#d0d7c2 0 9px,#c8d0b9 9px 18px);font:10.5px var(--mono);color:#4e5a4b;display:flex;align-items:flex-end;padding:8px}
.lyric{font-style:italic;border-left:2px solid var(--ochre);padding-left:12px;margin:6px 0 10px}
.set{list-style:none;margin:6px 0 10px;padding:0;counter-reset:n}
.set li button{all:unset;box-sizing:border-box;cursor:pointer;display:grid;grid-template-columns:26px 1fr auto;width:100%;padding:5px 6px;font-size:15.5px}
.set li button::before{counter-increment:n;content:counter(n);font:10.5px var(--mono);color:var(--soft);padding-top:4px}
.set li button span{font:10.5px var(--mono);color:var(--soft)}
.set li button:hover{background:var(--bg2)}
.set li.now button{background:var(--ink);color:var(--bg)}
.set li.now button::before,.set li.now button span{color:var(--line)}
.handcam{height:150px;background:#2a3129;color:#c9d1bd;display:flex;align-items:center;justify-content:center;gap:12px;font:11px var(--mono);margin-bottom:10px}
.handcam i{width:40px;height:40px;border:1.5px solid #c9d1bd;border-radius:50%;display:grid;place-items:center;font-style:normal}
aside{position:sticky;top:16px}
.mapbox{border:1px solid var(--line);background:#f4f5ee}
.maphead{display:flex;justify-content:space-between;font:10.5px var(--mono);color:var(--soft);padding:8px 10px;border-bottom:1px solid var(--line)}
svg.map{display:block;width:100%;height:auto}
.edge{stroke:var(--line);stroke-width:1;transition:stroke .15s, opacity .15s}
.node{cursor:pointer}
.node text{font:9.5px var(--mono);fill:var(--soft);pointer-events:none;transition:fill .15s, opacity .15s;opacity:0}
.node.lab text,.node:hover text,.node:focus-visible text,.map.focus .node.hl text{opacity:1}
.map.focus .node.lab:not(.hl) text{opacity:.5}
.node .s{transition:opacity .15s}
.map.focus .edge{opacity:.25}
.map.focus .node{opacity:.35}
.map.focus .edge.hl{opacity:1;stroke:var(--ink);stroke-width:1.4}
.map.focus .node.hl{opacity:1}
.map.focus .node.hl text{fill:var(--ink)}
.node.open .ring{stroke:var(--ink);stroke-width:1.5;fill:none}
.node:focus-visible{outline:none}
.node:focus-visible .ring{stroke:var(--terra);stroke-width:2;fill:none}
.now{margin-top:12px;border:1px solid var(--line);background:#f4f5ee;padding:12px 14px;font-size:15px}
.now .row{display:flex;align-items:center;gap:12px}
.now .row .playbtn{width:34px;height:34px;font-size:12px}
.now .title{flex:1;line-height:1.3}
.now .title small{display:block;font:10.5px var(--mono);color:var(--soft)}
.now .skip{all:unset;cursor:pointer;font:12px var(--mono);color:var(--soft);padding:4px}
.now .skip:hover{color:var(--ink)}
.bar{height:16px;position:relative;cursor:pointer;margin-top:8px}
.bar::before{content:"";position:absolute;left:0;right:0;top:7px;height:2px;background:var(--line)}
.bar i{position:absolute;left:0;top:7px;height:2px;background:var(--ochre);width:0}
.now .tm{font:10.5px var(--mono);color:var(--soft);display:flex;justify-content:space-between}
.now.idle{color:var(--soft);font-style:italic}
footer{border-top:1px solid var(--line);padding:14px 0 30px;font:10.5px/1.7 var(--mono);color:var(--soft);display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
@media (max-width:900px){.layout,.intro{grid-template-columns:1fr} aside{position:static;order:-1} .two{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="mock-note" role="note"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>P3 · The garden</b>Notes, songs, gigs and findings as one tended garden with maturity stages and visible connections. Hover anything to see what it's linked to on the map; songs open with their versions across gigs. Borrows maggieappleton.com's garden and beige.zone's tag filters.</div>
<div class="wrap">
<header>
<div class="name">Lucas Winther <span>· the garden</span></div>
<nav class="spaces" aria-label="Spaces"><a href="#">with AI, now</a><a href="#" aria-current="page">as a person</a><a href="#">by hand, before ↗</a></nav>
</header>
<section class="intro">
<div>
<h1>Half-finished thoughts, songs from <span class="ph" title="placeholder">the band</span>, and things I found, all growing into each other.</h1>
<p>Nothing here is in date order. Some notes are seedlings, some have been rewritten for years. Songs connect to the nights we played them, and those nights connect to whatever I was thinking about at the time.</p>
</div>
<div class="legend" id="legend"></div>
</section>
<div class="layout">
<main>
<div class="filters" role="group" aria-label="Filter">
<span>show</span>
<button data-k="type" data-v="all" aria-pressed="true">everything</button>
<button data-k="type" data-v="note" aria-pressed="false">notes</button>
<button data-k="type" data-v="song" aria-pressed="false">songs</button>
<button data-k="type" data-v="gig" aria-pressed="false">gigs</button>
<button data-k="type" data-v="find" aria-pressed="false">findings</button>
<span style="margin-left:auto">stage</span>
<button data-k="stage" data-v="all" aria-pressed="true">any</button>
<button data-k="stage" data-v="seedling" aria-pressed="false">seedling</button>
<button data-k="stage" data-v="budding" aria-pressed="false">budding</button>
<button data-k="stage" data-v="evergreen" aria-pressed="false">evergreen</button>
</div>
<ul class="entries" id="entries"></ul>
</main>
<aside>
<div class="mapbox">
<div class="maphead"><span>map · hover a dot</span><span id="mapcount"></span></div>
<svg class="map" id="map" viewBox="0 0 400 430" role="img" aria-label="Map of connections between notes, songs, gigs and findings"></svg>
</div>
<div class="now idle" id="now">Nothing playing. Open a song or a gig to listen.</div>
</aside>
</div>
<footer><span>Placeholders (dotted underline, and all music/notes content): band name <span class="ph" title="placeholder">Lav Sol</span>, members, songs, gigs, venues, note and finding titles, photos.</span><span><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a> · <a href="#">GitHub</a> · <a href="#">blog</a></span></footer>
</div>
<script>
const N = [
{id:"n1",type:"note",stage:"evergreen",x:55,y:95,title:"Handcam is the honest format",sub:"Why our gigs are filmed on a phone from the third row.",tended:"tended Oct 2026",
body:["Shaky phone footage with the bar chatter left in. I used to want something cleaner, now I think it's the only format that tells the truth about a small gig.","The best live record I own (see the Bill Evans finding) is basically the same thing, in 1961."]},
{id:"n2",type:"note",stage:"budding",x:150,y:30,title:"On playing to eleven people",sub:"Small rooms are harder than big ones.",tended:"tended Oct 2026",
body:["You can see every face, and every face can see your left hand. At Café Retro there were eleven people and a dog.","What helped: telling the story behind a song before playing it."]},
{id:"n3",type:"note",stage:"evergreen",x:345,y:62,title:"Writing code vs. writing songs",sub:"Both are mostly deleting.",tended:"tended Sep 2026",
body:["Both are mostly deleting. One has a test suite.","At work I lean on AI tools (that's the other half of this site). Songwriting is where I deliberately don't."]},
{id:"n4",type:"note",stage:"seedling",x:60,y:250,title:"Learning to sing harmony",sub:"Record yourself, listen once, never again.",tended:"planted Feb 2026",
body:["A seedling. So far: harmonies on Grå morgen and Halvvejs, both shaky. A tuner that doesn't nag helped more than expected."]},
{id:"n5",type:"note",stage:"budding",x:345,y:180,title:"Why I keep a paper notebook",sub:"Half our lyrics started in a meeting margin.",tended:"tended Aug 2026",
body:["Half of the band's lyrics started in the margin of a meeting, the other half on the bus. Kitchen light was written on the back of a receipt."]},
{id:"n6",type:"note",stage:"seedling",x:120,y:400,title:"Things overheard on the 5A",sub:"A list that might become a song.",tended:"planted Jun 2026",
body:["\"He's not a bad person, he just owns a jet ski.\" More to come. Might turn into a verse for Slow ferry's quiet bit."]},
{id:"s1",type:"song",stage:"evergreen",x:140,y:175,title:"Grå morgen",sub:"Our opener. A warm-up riff that became a song.",tended:"played 3 times",
who:[["Lucas","the riff, lead guitar"],["Signe","vocals"],["Mads","bass"],["Jonas","drums, brushes"]],lyric:"Grey morning, the coffee knows before I do.",origin:"Signe wrote the words over Lucas's warm-up riff in one rehearsal. Never changed them.",photo:"photo: Signe, red light"},
{id:"s2",type:"song",stage:"evergreen",x:285,y:150,title:"Kitchen light",sub:"Written at 2am. Slower every year.",tended:"played 3 times + demo",
who:[["Lucas","acoustic, verse 2"],["Signe","vocals, Rhodes"],["Mads","bass"],["Jonas","shaker, then drums"]],lyric:"Leave the kitchen light on, I'll find my way.",origin:"Written on a receipt in a kitchen. Each live version is slower than the last.",photo:"photo: one lamp"},
{id:"s3",type:"song",stage:"budding",x:310,y:330,title:"Slow ferry",sub:"The long one. The bridge still isn't right.",tended:"played 2 times",
who:[["Lucas","guitar, delay"],["Signe","keys"],["Mads","long bass notes"],["Jonas","drums"]],lyric:"Nothing to do but stand at the rail.",origin:"First verse on the back of a ferry ticket.",photo:"photo: grey sea"},
{id:"s4",type:"song",stage:"budding",x:80,y:340,title:"Halvvejs",sub:"Our only Danish song.",tended:"played 3 times",
who:[["Lucas","guitar"],["Signe","vocals"],["Mads","bass"],["Jonas","drums"]],lyric:"Halvvejs hjem, halvvejs væk.",origin:"The title means \"halfway\". Second verse unfinished.",photo:"photo: setlist on the floor"},
{id:"s5",type:"song",stage:"evergreen",x:195,y:130,title:"Paper boats",sub:"Usually the closer.",tended:"played 3 times",
who:[["Lucas","guitar, harmony"],["Signe","vocals"],["Mads","bass"],["Jonas","count-in, too fast"]],lyric:"Fold it twice and let it go.",origin:"Jonas counts it in too fast, every single time.",photo:"photo: blurry crowd"},
{id:"g1",type:"gig",stage:"evergreen",x:190,y:250,title:"Café Retro, 14 Mar 2026",sub:"Five songs, handcam from the bar.",tended:"gig",set:[["s1",252],["s2",208],["s3",301],["s4",196],["s5",244]]},
{id:"g2",type:"gig",stage:"evergreen",x:230,y:205,title:"Studenterhuset basement, Oct 2025",sub:"The loud one. Phone on a beer crate.",tended:"gig",set:[["s5",231],["s1",270],["new",188],["s3",322]]},
{id:"g3",type:"gig",stage:"budding",x:165,y:300,title:"Garden party at Ida's, Aug 2025",sub:"Acoustic. Wasps.",tended:"gig",set:[["s2",190],["s4",205],["s1",240]]},
{id:"f1",type:"find",stage:"evergreen",x:30,y:150,title:"Bill Evans · Sunday at the Village Vanguard",sub:"You can hear the glasses clink.",tended:"found 2024",body:["Handcam in spirit. The record that made me stop caring about clean recordings."]},
{id:"f2",type:"find",stage:"evergreen",x:375,y:105,title:"\"The case for small software\"",sub:"An essay. Applies to bands too.",tended:"found 2025",body:["Things made for a few people can be made with more care."]},
{id:"f3",type:"find",stage:"seedling",x:350,y:395,title:"Every Danish bus shelter, photographed",sub:"Someone did all of them.",tended:"found 2026",body:["There's a map. I've spent too long on it."]},
{id:"f4",type:"find",stage:"budding",x:25,y:285,title:"A tuner that doesn't nag",sub:"No account, no ads.",tended:"found 2026",body:["Just a needle."]},
];
const E = [["n1","f1"],["n1","g1"],["n1","g2"],["n2","g1"],["n2","s5"],["n3","f2"],["n3","n5"],["n4","s1"],["n4","s4"],["n4","f4"],["n5","s2"],["n5","s3"],["n6","f3"],["n6","s3"],["s2","n3"]];
N.filter(n=>n.type==="gig").forEach(g=>g.set.forEach(([s])=>{ if(s!=="new") E.push([g.id,s]); }));
const byId = Object.fromEntries(N.map(n=>[n.id,n]));
const nb = id => E.filter(e=>e.includes(id)).map(e=>e[0]===id?e[1]:e[0]);
const fmt=s=>Math.floor(s/60)+":"+String(Math.floor(s%60)).padStart(2,"0");
const typeName={note:"note",song:"song",gig:"gig",find:"finding"};
const col={note:"var(--moss)",song:"var(--ochre)",gig:"var(--terra)",find:"var(--plum)"};
function glyph(stage,c,sz=14){
if(stage==="seedling") return `<svg width="${sz}" height="${sz}" viewBox="0 0 14 14"><circle cx="7" cy="7" r="3" fill="none" stroke="${c}" stroke-width="1.4"/></svg>`;
if(stage==="budding") return `<svg width="${sz}" height="${sz}" viewBox="0 0 14 14"><circle cx="7" cy="7" r="5" fill="none" stroke="${c}" stroke-width="1.4"/><path d="M7 2a5 5 0 0 1 0 10z" fill="${c}"/></svg>`;
return `<svg width="${sz}" height="${sz}" viewBox="0 0 14 14"><path d="M7 1C11 4 12 9 7 13C2 9 3 4 7 1z" fill="${c}"/></svg>`;
}
document.getElementById("legend").innerHTML =
["seedling","budding","evergreen"].map(s=>`<div>${glyph(s,"var(--ink)")} ${s}</div>`).join("") +
`<div style="margin-top:6px"><svg width="14" height="14"><circle cx="7" cy="7" r="5" fill="var(--moss)"/></svg> note <svg width="14" height="14"><circle cx="7" cy="7" r="5" fill="none" stroke="var(--ochre)" stroke-width="3"/></svg> song <svg width="14" height="14"><rect x="2" y="2" width="10" height="10" fill="var(--terra)"/></svg> gig <svg width="14" height="14"><path d="M7 1L13 7L7 13L1 7z" fill="var(--plum)"/></svg> find</div>`;
const ul=document.getElementById("entries");
ul.innerHTML = N.map(n=>`<li class="entry" id="e-${n.id}" data-id="${n.id}" data-type="${n.type}" data-stage="${n.stage}">
<button aria-expanded="false"><span class="g">${glyph(n.stage,col[n.type])}</span><span class="t">${n.title}<small>${n.sub}</small></span><span class="m">${typeName[n.type]}<br>${n.tended}</span></button>
<div class="body"></div></li>`).join("");
const svg=document.getElementById("map");
const r = n => n.stage==="seedling"?5:n.stage==="budding"?7:9;
function shape(n){
const R=r(n), c=col[n.type];
if(n.type==="gig") return `<rect class="s" x="${n.x-R}" y="${n.y-R}" width="${2*R}" height="${2*R}" fill="${c}"/><rect class="ring" x="${n.x-R-4}" y="${n.y-R-4}" width="${2*R+8}" height="${2*R+8}" fill="none" stroke="none"/>`;
if(n.type==="find") return `<path class="s" d="M${n.x} ${n.y-R-1}L${n.x+R+1} ${n.y}L${n.x} ${n.y+R+1}L${n.x-R-1} ${n.y}z" fill="${c}"/><circle class="ring" cx="${n.x}" cy="${n.y}" r="${R+5}" fill="none" stroke="none"/>`;
if(n.type==="song") return `<circle class="s" cx="${n.x}" cy="${n.y}" r="${R}" fill="var(--bg)" stroke="${c}" stroke-width="3"/><circle class="ring" cx="${n.x}" cy="${n.y}" r="${R+5}" fill="none" stroke="none"/>`;
return `<circle class="s" cx="${n.x}" cy="${n.y}" r="${R}" fill="${c}"/><circle class="ring" cx="${n.x}" cy="${n.y}" r="${R+5}" fill="none" stroke="none"/>`;
}
function short(t){ return t.length>22 ? t.slice(0,21).replace(/[ ,·"]+$/,"")+"…" : t; }
svg.innerHTML = E.map(([a,b])=>`<line class="edge" data-a="${a}" data-b="${b}" x1="${byId[a].x}" y1="${byId[a].y}" x2="${byId[b].x}" y2="${byId[b].y}"/>`).join("")
+ N.map(n=>{ const left = n.x>=260; return `<g class="node${n.type==="song"||n.type==="gig"?" lab":""}" tabindex="0" role="button" aria-label="${n.title}" data-id="${n.id}">${shape(n)}<text x="${left?n.x-r(n)-5:n.x+r(n)+5}" y="${n.y+3}" text-anchor="${left?"end":"start"}">${short(n.title.replace(/^"|"$/g,""))}</text></g>`; }).join("");
document.getElementById("mapcount").textContent = N.length+" things · "+E.length+" links";
function focus(id){
if(!id){ svg.classList.remove("focus"); document.querySelectorAll(".entry.hl").forEach(e=>e.classList.remove("hl")); return; }
const set=new Set([id,...nb(id)]);
svg.classList.add("focus");
svg.querySelectorAll(".edge").forEach(l=>l.classList.toggle("hl", l.dataset.a===id||l.dataset.b===id));
svg.querySelectorAll(".node").forEach(g=>g.classList.toggle("hl", set.has(g.dataset.id)));
document.querySelectorAll(".entry").forEach(e=>e.classList.toggle("hl", e.dataset.id===id));
}
svg.querySelectorAll(".node").forEach(g=>{
g.addEventListener("mouseenter",()=>focus(g.dataset.id)); g.addEventListener("focus",()=>focus(g.dataset.id));
g.addEventListener("mouseleave",()=>focus(openId)); g.addEventListener("blur",()=>focus(openId));
g.addEventListener("click",()=>open(g.dataset.id,true));
g.addEventListener("keydown",e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();open(g.dataset.id,true);}});
});
ul.querySelectorAll(".entry").forEach(li=>{
const b=li.querySelector("button");
li.addEventListener("mouseenter",()=>focus(li.dataset.id)); li.addEventListener("mouseleave",()=>focus(openId));
b.addEventListener("focus",()=>focus(li.dataset.id));
b.addEventListener("click",()=>open(li.dataset.id===openId?null:li.dataset.id,false));
});
let openId=null;
function open(id,scroll){
openId=id;
ul.querySelectorAll(".entry").forEach(li=>{ const on=li.dataset.id===id; li.classList.toggle("open",on); li.querySelector("button").setAttribute("aria-expanded",on); if(on) li.querySelector(".body").innerHTML=bodyFor(byId[id]); });
svg.querySelectorAll(".node").forEach(g=>g.classList.toggle("open",g.dataset.id===id));
focus(id);
if(id){
const li=document.getElementById("e-"+id);
if(li.classList.contains("hide")){ resetFilters(); }
wire(li);
if(scroll) li.scrollIntoView({block:"nearest"});
}
}
function linksHtml(n){
return `<div class="links"><span>connected to</span>${nb(n.id).map(i=>`<button data-go="${i}">${byId[i].title}</button>`).join("")}</div>`;
}
function bodyFor(n){
if(n.type==="song"){
const vers=N.filter(g=>g.type==="gig"&&g.set.some(s=>s[0]===n.id));
return `<p class="lyric">"${n.lyric}"</p>
<div class="rec"><button class="playbtn" data-play="${vers[0].id}:${vers[0].set.findIndex(s=>s[0]===n.id)}" aria-label="Play ${n.title}">▶</button><span style="font-size:15px">Play it from <b>${vers[0].title}</b>, or pick another night below.</span></div>
<div style="font:10.5px var(--mono);color:var(--soft)">the same song, different nights</div>
<ul class="vers">${vers.map(g=>{const j=g.set.findIndex(s=>s[0]===n.id); return `<li>${glyph(g.stage,col.gig,12)}<span>${g.title} <span style="color:var(--soft)">· ${fmt(g.set[j][1])}</span></span><button data-play="${g.id}:${j}">play this version</button></li>`;}).join("")}</ul>
<div class="two"><div><div style="font:10.5px var(--mono);color:var(--soft);margin-bottom:4px">who plays what</div><dl class="credits">${n.who.map(w=>`<dt>${w[0]}</dt><dd>${w[1]}</dd>`).join("")}</dl><p style="font-size:15px;margin-top:10px">${n.origin}</p></div>
<div class="photo">${n.photo}</div></div>${linksHtml(n)}`;
}
if(n.type==="gig"){
return `<div class="handcam"><i>▶</i>handcam video · ${n.title} · placeholder frame</div>
<div style="font:10.5px var(--mono);color:var(--soft)">setlist · click to jump</div>
<ol class="set">${n.set.map(([s,l],i)=>`<li data-k="${n.id}:${i}"><button data-play="${n.id}:${i}">${s==="new"?"New one (no name yet)":byId[s].title}<span>${fmt(l)}</span></button></li>`).join("")}</ol>${linksHtml(n)}`;
}
return n.body.map(p=>`<p>${p}</p>`).join("")+linksHtml(n);
}
function wire(li){
li.querySelectorAll("[data-go]").forEach(b=>b.onclick=()=>open(b.dataset.go,true));
li.querySelectorAll("[data-play]").forEach(b=>b.onclick=()=>{const [g,i]=b.dataset.play.split(":"); play(g,+i);});
markNow();
}
/* filters */
const F={type:"all",stage:"all"};
document.querySelectorAll(".filters button").forEach(b=>b.addEventListener("click",()=>{
F[b.dataset.k]=b.dataset.v;
document.querySelectorAll(`.filters button[data-k="${b.dataset.k}"]`).forEach(x=>x.setAttribute("aria-pressed",x===b));
applyFilters();
}));
function applyFilters(){
ul.querySelectorAll(".entry").forEach(li=>li.classList.toggle("hide",(F.type!=="all"&&li.dataset.type!==F.type)||(F.stage!=="all"&&li.dataset.stage!==F.stage)));
}
function resetFilters(){ F.type="all";F.stage="all"; document.querySelectorAll(".filters button").forEach(x=>x.setAttribute("aria-pressed",x.dataset.v==="all")); applyFilters(); }
/* player */
let P={g:null,i:0,pos:0,on:false,timer:null};
const now=document.getElementById("now");
function play(g,i){ P.g=byId[g]; P.i=i; P.pos=0; setOn(true); }
function setOn(on){
P.on=on; clearInterval(P.timer);
if(on) P.timer=setInterval(()=>{ P.pos++; if(P.pos>=P.g.set[P.i][1]){ if(P.i<P.g.set.length-1){P.i++;P.pos=0;} else {P.pos=P.g.set[P.i][1]; setOn(false); return;} } renderNow(); },1000);
renderNow();
}
function renderNow(){
if(!P.g) return;
const [s,l]=P.g.set[P.i], name=s==="new"?"New one (no name yet)":byId[s].title;
now.classList.remove("idle");
now.innerHTML=`<div class="row"><button class="playbtn" id="npp" aria-label="${P.on?"Pause":"Play"}">${P.on?"❚❚":"▶"}</button>
<div class="title">${s==="new"?name:`<a href="#" data-go="${s}">${name}</a>`}<small>${P.g.title} · song ${P.i+1}/${P.g.set.length}</small></div>
<button class="skip" id="nprev" aria-label="Previous song">«</button><button class="skip" id="nnext" aria-label="Next song">»</button></div>
<div class="bar" id="nbar"><i style="width:${P.pos/l*100}%"></i></div><div class="tm"><span>${fmt(P.pos)}</span><span>${fmt(l)}</span></div>`;
document.getElementById("npp").onclick=()=>setOn(!P.on);
document.getElementById("nprev").onclick=()=>{P.i=Math.max(0,P.i-1);P.pos=0;renderNow();};
document.getElementById("nnext").onclick=()=>{P.i=Math.min(P.g.set.length-1,P.i+1);P.pos=0;renderNow();};
document.getElementById("nbar").onclick=e=>{const r=e.currentTarget.getBoundingClientRect();P.pos=Math.floor((e.clientX-r.left)/r.width*l);renderNow();};
const a=now.querySelector("[data-go]"); if(a) a.onclick=e=>{e.preventDefault(); open(a.dataset.go,true);};
markNow();
}
function markNow(){ document.querySelectorAll(".set li").forEach(li=>li.classList.toggle("now", P.g && li.dataset.k===P.g.id+":"+P.i)); }
</script>
</body>
</html>
+336
View File
@@ -0,0 +1,336 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther · as a person (P4 · The almanac)</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=Cormorant+Garamond:ital,wght@0,500;0,600;1,500&family=Crimson+Pro:ital,wght@0,400;0,600;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
--paper:#efe6d3; --paper2:#e6dbc4; --ink:#2a2620; --soft:#6e6455; --line:#cdbfa3;
--winter:#6b7c88; --spring:#7a955a; --summer:#cf982e; --autumn:#b4532f;
--disp:"Cormorant Garamond", Georgia, serif; --serif:"Crimson Pro", Georgia, serif; --mono:"DM Mono", ui-monospace, monospace;
}
*{box-sizing:border-box}
body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--serif);font-size:18px;line-height:1.5}
a{color:inherit}
.ph{text-decoration:underline dotted var(--soft);text-underline-offset:3px;cursor:help}
.mock-note{position:fixed;top:10px;right:10px;z-index:99;max-width:300px;background:#fff;border:1px solid #bbb;padding:8px 30px 8px 10px;font:12px/1.4 system-ui,sans-serif;color:#333}
.mock-note b{display:block}
.mock-note button{position:absolute;top:4px;right:6px;border:0;background:none;font-size:16px;cursor:pointer;color:#666}
.wrap{max-width:1120px;margin:0 auto;padding:0 30px}
header{display:flex;justify-content:space-between;align-items:baseline;padding:24px 0 12px;gap:20px;flex-wrap:wrap}
header .name{font:600 22px var(--disp)}
nav.spaces{font:12px var(--mono);display:flex;gap:16px}
nav.spaces a{text-decoration:none;color:var(--soft)} nav.spaces a[aria-current]{color:var(--ink);border-bottom:1px solid}
.title{border-top:3px double var(--ink);border-bottom:1px solid var(--ink);padding:14px 0 12px;display:grid;grid-template-columns:auto 1fr auto;gap:30px;align-items:end}
.title h1{font:500 58px/0.95 var(--disp);margin:0;letter-spacing:-.01em}
.title h1 i{font-style:italic;color:var(--soft)}
.title p{margin:0;color:var(--soft);font-size:17px;max-width:520px}
.years{display:flex;gap:4px;font:13px var(--mono)}
.years button{all:unset;cursor:pointer;padding:4px 9px;border:1px solid transparent;color:var(--soft)}
.years button[aria-pressed="true"]{border-color:var(--ink);color:var(--ink)}
.years button:hover{color:var(--ink)}
.years button:focus-visible{outline:2px solid var(--autumn)}
.summary{display:flex;gap:26px;font:12px var(--mono);color:var(--soft);padding:10px 0;flex-wrap:wrap}
.summary b{color:var(--ink);font-weight:500}
.key{margin-left:auto;display:flex;gap:14px;align-items:center}
.key span{display:inline-flex;gap:5px;align-items:center}
/* strip */
.seasons{display:grid;grid-template-columns:2fr 3fr 3fr 3fr 1fr;font:11px var(--mono);letter-spacing:.08em;text-transform:uppercase;margin-top:12px}
.seasons div{padding:0 0 5px 6px;border-bottom:2px solid var(--c)}
.seasons div:last-child{padding-left:0}
.strip{display:grid;grid-template-columns:repeat(12,1fr);border-left:1px solid var(--line)}
.month{all:unset;box-sizing:border-box;cursor:pointer;position:relative;border-right:1px solid var(--line);height:176px;display:flex;flex-direction:column;padding:6px 6px 0;transition:background .15s}
.month:hover,.month:focus-visible{background:var(--paper2)}
.month:focus-visible{outline:2px solid var(--ink);outline-offset:-2px}
.month[aria-pressed="true"]{background:#f7f0e1;box-shadow:inset 0 -3px 0 var(--c)}
.month .mn{font:600 21px var(--disp);line-height:1}
.month .mn small{display:block;font:10.5px var(--mono);color:var(--soft);margin-top:3px;font-weight:400}
.month .days{position:relative;flex:1;margin-top:8px}
.month .days::before{content:"";position:absolute;left:50%;top:0;bottom:0;border-left:1px dotted var(--line)}
.mk{position:absolute;left:50%;transform:translate(-50%,-50%);width:13px;height:13px;background:var(--ink)}
.mk.post{border-radius:50%;background:var(--paper);border:1.5px solid var(--ink)}
.mk.gig{background:var(--c)}
.mk.find{transform:translate(-50%,-50%) rotate(45deg);width:10px;height:10px;background:var(--soft)}
.mk.proj{background:none;width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:12px solid var(--ink)}
.mk.off{left:calc(50% + 12px)}
.mk.off2{left:calc(50% - 12px)}
.month .q{font:italic 13px var(--serif);color:var(--soft);padding:0 0 8px;line-height:1.2;min-height:30px}
.month.future{cursor:default;color:#a99c86}
.month.future .days::before{border-color:#ddd2bc}
.today{position:absolute;top:0;bottom:0;border-left:1.5px solid var(--autumn)}
.today::after{content:"today";position:absolute;top:-2px;left:4px;font:10px var(--mono);color:var(--autumn)}
.peek{min-height:26px;font:13px var(--mono);color:var(--soft);padding:10px 0 0}
/* month page */
.page{margin-top:18px;border-top:1px solid var(--ink);display:grid;grid-template-columns:280px 1fr;gap:40px;padding:22px 0 10px}
.page .left h2{font:500 64px/0.9 var(--disp);margin:0}
.page .left h2 small{display:block;font:12px var(--mono);color:var(--soft);margin-top:10px;letter-spacing:.06em;text-transform:uppercase}
.alm{margin-top:18px;font-size:16px;border-top:1px solid var(--line)}
.alm div{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-bottom:1px solid var(--line)}
.alm span:first-child{font:11.5px var(--mono);color:var(--soft);padding-top:3px}
.alm span:last-child{text-align:right}
.weather{font-style:italic;color:var(--soft);margin-top:14px;font-size:17px}
.items{list-style:none;margin:0;padding:0}
.items > li{border-bottom:1px solid var(--line);padding:12px 0}
.items .row{all:unset;box-sizing:border-box;cursor:pointer;display:grid;grid-template-columns:46px 1fr auto;gap:14px;width:100%;align-items:baseline}
.items .row:focus-visible{outline:2px solid var(--autumn);outline-offset:3px}
.items .day{font:12px var(--mono);color:var(--soft)}
.items h3{margin:0;font-weight:600;font-size:21px;line-height:1.2}
.items .row:hover h3{color:var(--autumn)}
.items .row p{margin:3px 0 0;color:var(--soft);font-size:16.5px}
.items .kind{font:11px var(--mono);color:var(--soft);text-transform:uppercase;letter-spacing:.06em}
.items .x{display:none;padding:12px 0 4px 60px}
.items li.open .x{display:block}
.items .x p{margin:0 0 10px;max-width:62ch}
.quiet{font-style:italic;color:var(--soft);font-size:19px;padding:12px 0;max-width:52ch}
/* gig player */
.gigbox{display:grid;grid-template-columns:1fr 1.15fr;gap:22px}
.cam{height:160px;background:#2b2722;color:#d7ccb8;display:flex;flex-direction:column;justify-content:flex-end;padding:10px 12px;font:11px var(--mono);position:relative}
.cam .big{position:absolute;inset:0;margin:auto;width:46px;height:46px;border:1.5px solid #d7ccb8;border-radius:50%;display:grid;place-items:center;background:none;color:#d7ccb8;cursor:pointer;font-size:14px}
.cam .big:hover{background:#d7ccb8;color:#2b2722}
.bar{height:18px;position:relative;cursor:pointer;margin-top:8px}
.bar::before{content:"";position:absolute;left:0;right:0;top:8px;height:2px;background:var(--line)}
.bar i{position:absolute;left:0;top:8px;height:2px;background:var(--autumn);width:0}
.bar b{position:absolute;top:5px;width:1px;height:8px;background:var(--soft)}
.tm{display:flex;justify-content:space-between;font:11.5px var(--mono);color:var(--soft)}
.set{list-style:none;margin:10px 0 0;padding:0}
.set button{all:unset;box-sizing:border-box;cursor:pointer;display:flex;justify-content:space-between;width:100%;padding:4px 8px;font-size:16.5px}
.set button span{font:11.5px var(--mono);color:var(--soft);padding-top:3px}
.set button:hover{background:var(--paper2)}
.set li.now button{background:var(--ink);color:var(--paper)}
.set li.now button span{color:var(--line)}
.song{background:#f7f0e1;padding:14px 16px;border:1px solid var(--line)}
.song h4{font:500 30px/1 var(--disp);margin:0 0 8px}
.song .tabs{display:flex;gap:14px;font:11.5px var(--mono);margin-bottom:10px;flex-wrap:wrap}
.song .tabs button{all:unset;cursor:pointer;color:var(--soft);border-bottom:1px solid transparent}
.song .tabs button[aria-selected="true"]{color:var(--ink);border-color:var(--ink)}
.song .tabs button:focus-visible{outline:2px solid var(--autumn)}
.song dl{display:grid;grid-template-columns:auto 1fr;gap:3px 14px;margin:0;font-size:16.5px}
.song dt{font:11.5px var(--mono);color:var(--soft);padding-top:4px}
.song dd{margin:0}
.song .pic{height:130px;background:repeating-linear-gradient(90deg,#ddd1b9 0 2px,#e7dcc6 2px 9px);font:11px var(--mono);color:var(--soft);display:flex;align-items:flex-end;padding:8px}
.others{list-style:none;margin:0;padding:0;font-size:16.5px}
.others li{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-bottom:1px dashed var(--line)}
.others button{all:unset;cursor:pointer;font:11.5px var(--mono);color:var(--autumn)}
.others button:hover{text-decoration:underline}
.ctrls{display:flex;gap:8px;align-items:center;margin-top:8px}
.ctrls button{all:unset;cursor:pointer;width:32px;height:32px;border:1px solid var(--ink);border-radius:50%;display:grid;place-items:center;font-size:12px}
.ctrls button.pp{width:40px;height:40px;background:var(--ink);color:var(--paper)}
.ctrls button:focus-visible{outline:2px solid var(--autumn);outline-offset:2px}
footer{border-top:1px solid var(--line);margin-top:40px;padding:14px 0 30px;font:11.5px/1.7 var(--mono);color:var(--soft);display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
@media (max-width:860px){
.title{grid-template-columns:1fr;gap:12px} .title h1{font-size:44px}
.strip{overflow-x:auto;grid-template-columns:repeat(12,minmax(70px,1fr))} .seasons{display:none}
.page{grid-template-columns:1fr;gap:16px} .gigbox{grid-template-columns:1fr}
.items .x{padding-left:0}
}
</style>
</head>
<body>
<div class="mock-note" role="note"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>P4 · The almanac</b>One year at a time on a seasonal strip. Hover a month to peek, click it to open that month's page: a gig with its setlist and per-song notes, a post, a find, or just a line about the weather. Quiet months are part of the design. Borrows lynnandtonic's yearly archive (the idea, not the look) and beige.zone's archival labels.</div>
<div class="wrap">
<header>
<div class="name">Lucas Winther</div>
<nav class="spaces" aria-label="Spaces"><a href="#">with AI, now</a><a href="#" aria-current="page">as a person</a><a href="#">by hand, before ↗</a></nav>
</header>
<section class="title">
<h1>Almanac <i id="yr">2026</i></h1>
<p>What happened outside work, a month at a time: gigs with <span class="ph" title="placeholder">Lav Sol</span>, things I wrote, things I found. Some months are full. Most aren't, and that's fine.</p>
<div class="years" role="group" aria-label="Year" id="years"></div>
</section>
<div class="summary" id="summary"></div>
<div class="seasons" aria-hidden="true">
<div style="--c:var(--winter)">winter</div><div style="--c:var(--spring)">spring</div><div style="--c:var(--summer)">summer</div><div style="--c:var(--autumn)">autumn</div><div style="--c:var(--winter)"></div>
</div>
<div class="strip" id="strip" role="group" aria-label="Months"></div>
<div class="peek" id="peek" aria-live="polite">Hover a month to peek. Click to open its page.</div>
<section class="page" id="page" aria-live="polite"></section>
<footer><span>Placeholders: band name, members, songs, venues, gig dates, post and finding titles, the almanac lines, photos.</span><span><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a> · <a href="#">GitHub</a> · <a href="#">blog</a></span></footer>
</div>
<script>
const MON=["January","February","March","April","May","June","July","August","September","October","November","December"];
const season=m=>[0,1,11].includes(m)?"winter":m<5?"spring":m<8?"summer":"autumn";
const TODAY={y:2026,m:10,d:9};
const GIGS={
retro:{name:"Café Retro",date:"14 Mar 2026",y:2026,m:2,set:[["Grå morgen",252],["Kitchen light",208],["Slow ferry",301],["Halvvejs",196],["Paper boats",244]]},
rehearsal:{name:"Rehearsal room, open door",date:"24 Sep 2026",y:2026,m:8,set:[["Slow ferry",344],["Halvvejs",201]]},
kaelder:{name:"Studenterhuset basement",date:"27 Oct 2025",y:2025,m:9,set:[["Paper boats",231],["Grå morgen",270],["New one (no name)",188],["Slow ferry",322]]},
have:{name:"Garden party at Ida's",date:"9 Aug 2025",y:2025,m:7,set:[["Kitchen light",190],["Halvvejs",205],["Grå morgen",240]]},
first:{name:"Open mic, first gig ever",date:"2 Jun 2024",y:2024,m:5,set:[["Halvvejs",170],["Paper boats",215],["Kitchen light",180]]},
};
const SONGS={
"Grå morgen":{who:[["Lucas","the riff, lead guitar"],["Signe","vocals"],["Mads","bass"],["Jonas","drums, brushes"]],lyric:"Grey morning, the coffee knows before I do.",origin:"A warm-up riff Signe wrote words over in one rehearsal.",pic:"photo: Signe under a red bulb"},
"Kitchen light":{who:[["Lucas","acoustic, verse 2"],["Signe","vocals, Rhodes"],["Mads","bass"],["Jonas","shaker"]],lyric:"Leave the kitchen light on, I'll find my way.",origin:"Written at 2am on a receipt. Slower every year.",pic:"photo: a single kitchen lamp"},
"Slow ferry":{who:[["Lucas","guitar, delay"],["Signe","keys"],["Mads","long bass notes"],["Jonas","drums"]],lyric:"Nothing to do but stand at the rail.",origin:"First verse written on a ferry ticket. Gets longer every time.",pic:"photo: grey sea from the deck"},
"Halvvejs":{who:[["Lucas","guitar"],["Signe","vocals"],["Mads","bass"],["Jonas","drums"]],lyric:"Halvvejs hjem, halvvejs væk.",origin:"Our only Danish song: \"halfway\".",pic:"photo: setlist taped to the floor"},
"Paper boats":{who:[["Lucas","guitar, harmony"],["Signe","vocals"],["Mads","bass"],["Jonas","count-in, too fast"]],lyric:"Fold it twice and let it go.",origin:"Usually the closer.",pic:"photo: blurry crowd, someone waving"},
};
const ITEMS=[
{y:2026,m:1,d:12,t:"post",title:"A winter of learning to sing harmony",text:"Record yourself, listen once, never again.",more:["Harmonies on Grå morgen and Halvvejs. Both shaky. A tuner that doesn't nag helped."]},
{y:2026,m:1,d:20,t:"find",title:"A tuner that doesn't nag",text:"No account, no ads. Just a needle.",more:["<a href='#'>link</a>"]},
{y:2026,m:2,d:14,t:"gig",gig:"retro",title:"Live at Café Retro",text:"Five songs, eleven people and a dog. Handcam from the bar."},
{y:2026,m:4,d:3,t:"post",title:"Handcam is the honest format",text:"Shaky phone footage, bar chatter left in.",more:["I used to want cleaner recordings. Not anymore."]},
{y:2026,m:5,d:22,t:"find",title:"Every Danish bus shelter, photographed",text:"Someone did all of them. There's a map.",more:["<a href='#'>link</a>"]},
{y:2026,m:6,d:10,t:"proj",title:"Loop pedal project",text:"Ten loops, each under eight bars.",more:["Rehearsal-room experiments. Nothing finished, all fun."]},
{y:2026,m:7,d:15,t:"post",title:"Why I still keep a paper notebook",text:"Half our lyrics started in a meeting margin.",more:["The other half started on the bus."]},
{y:2026,m:8,d:24,t:"gig",gig:"rehearsal",title:"Rehearsal, door open",text:"Two songs, filmed by whoever walked past."},
{y:2026,m:9,d:18,t:"post",title:"On playing to eleven people",text:"Small rooms are harder than big ones.",more:["You can see every face, and every face can see your left hand."]},
{y:2026,m:9,d:29,t:"find",title:"\"The case for small software\"",text:"Read it twice. Applies to bands too.",more:["<a href='#'>link</a>"]},
{y:2025,m:2,d:4,t:"post",title:"Writing code vs. writing songs",text:"Both are mostly deleting.",more:["One has a test suite."]},
{y:2025,m:7,d:9,t:"gig",gig:"have",title:"Garden party at Ida's",text:"Acoustic, three songs. Wasps were involved."},
{y:2025,m:9,d:27,t:"gig",gig:"kaelder",title:"Studenterhuset basement",text:"The loud one. Phone on a beer crate, front left."},
{y:2025,m:10,d:2,t:"find",title:"Bill Evans · Sunday at the Village Vanguard",text:"You can hear the glasses clink.",more:["The record that made me stop caring about clean recordings."]},
{y:2024,m:0,d:20,t:"proj",title:"Kitchen demos",text:"One mic, a laptop, fridge hum.",more:["First takes of Kitchen light and Slow ferry."]},
{y:2024,m:5,d:2,t:"gig",gig:"first",title:"Open mic, first gig ever",text:"Three songs, too fast, very nervous."},
];
const ALM={
2026:["Dark at four. Learning harmonies.","Cold. Practised a lot, posted once.","First gig of the year.","Nothing recorded. New strings.","Light again. Wrote a bit.","Bus shelters, for some reason.","Loops in the rehearsal room.","Notebook season.","Played with the door open.","Small rooms, small software.","Rehearsals Thursdays.",""],
2025:["Quiet. Work, mostly.","Nothing much.","One essay, about deleting.","Rain. Rehearsals.","Wrote nothing. Played a lot.","A cold that lasted a month.","Summer off.","Garden gig, wasps.","Back to Thursdays.","The loud basement gig.","Found a 1961 record.","Christmas, no music."],
2024:["Kitchen demos.","","","","Booked our first gig.","First gig ever.","Rested.","","Found a rehearsal room.","","","Decided to keep going."],
};
const ROWS={ // small almanac table for each month page
sun:["08:30","07:40","06:30","06:15","05:10","04:30","04:40","05:30","06:30","07:30","07:40","08:35"],
set:["15:55","16:55","17:55","20:00","21:00","21:55","21:45","20:50","19:30","18:20","16:20","15:40"],
};
const fmt=s=>Math.floor(s/60)+":"+String(Math.floor(s%60)).padStart(2,"0");
const glyph=(t,m)=>`<span class="mk ${t}" style="position:static;transform:${t==="find"?"rotate(45deg)":"none"};display:inline-block;--c:var(--${season(m||0)})"></span>`;
let Y=2026, openM=null;
const yrs=document.getElementById("years");
yrs.innerHTML=[2024,2025,2026].map(y=>`<button data-y="${y}" aria-pressed="${y===Y}">${y}</button>`).join("");
yrs.querySelectorAll("button").forEach(b=>b.onclick=()=>setYear(+b.dataset.y));
function setYear(y,m){
Y=y; document.getElementById("yr").textContent=y;
yrs.querySelectorAll("button").forEach(b=>b.setAttribute("aria-pressed",+b.dataset.y===y));
const its=ITEMS.filter(i=>i.y===y), c=t=>its.filter(i=>i.t===t).length;
const quiet=12-new Set(its.map(i=>i.m)).size - (y===TODAY.y?11-TODAY.m:0);
document.getElementById("summary").innerHTML=`<span><b>${c("gig")}</b> gig${c("gig")===1?"":"s"}</span><span><b>${c("post")}</b> pieces of writing</span><span><b>${c("find")}</b> finds</span><span><b>${c("proj")}</b> small project${c("proj")===1?"":"s"}</span><span><b>${quiet}</b> quiet months</span>
<span class="key"><span>${glyph("gig",2)} gig</span><span>${glyph("post")} writing</span><span>${glyph("find")} find</span><span>${glyph("proj")} project</span></span>`;
const strip=document.getElementById("strip");
strip.innerHTML=MON.map((mn,m)=>{
const future = y===TODAY.y && m>TODAY.m;
const mi=its.filter(i=>i.m===m);
const marks=mi.map((i,k)=>`<span class="mk ${i.t} ${k%3===1?"off":k%3===2?"off2":""}" style="top:${(i.d/31)*100}%"></span>`).join("");
const todayLine = (y===TODAY.y && m===TODAY.m) ? `<span class="today" style="left:${TODAY.d/30*100}%"></span>`:"";
return `<button class="month${future?" future":""}" data-m="${m}" style="--c:var(--${season(m)})" aria-pressed="false" ${future?'aria-disabled="true" tabindex="-1"':""} aria-label="${mn} ${y}, ${mi.length} things">
<span class="mn">${mn.slice(0,3)}<small>${mi.length?mi.length+" thing"+(mi.length>1?"s":""):"quiet"}</small></span>
<span class="days">${marks}${todayLine}</span>
<span class="q">${future?"not yet":(ALM[y][m]||"")}</span></button>`;
}).join("");
const peek=document.getElementById("peek");
strip.querySelectorAll(".month").forEach(b=>{
const m=+b.dataset.m;
if(b.classList.contains("future")) return;
const show=()=>{ const mi=its.filter(i=>i.m===m); peek.innerHTML = `<b style="color:var(--ink)">${MON[m]} ${y}</b> · ` + (mi.length? mi.map(i=>i.title).join(" · ") : `<i>${ALM[y][m]||"a quiet month"}</i>`); };
b.addEventListener("mouseenter",show); b.addEventListener("focus",show);
b.addEventListener("click",()=>openMonth(m));
});
const first = m!=null ? m : (y===TODAY.y ? ITEMS.filter(i=>i.y===y).slice(-1)[0].m : its[its.length-1].m);
openMonth(first);
}
let G=null;
function openMonth(m, gigId, songIdx){
openM=m;
document.querySelectorAll(".month").forEach(b=>b.setAttribute("aria-pressed",+b.dataset.m===m));
const mi=ITEMS.filter(i=>i.y===Y&&i.m===m);
const page=document.getElementById("page");
page.style.setProperty("--c",`var(--${season(m)})`);
page.innerHTML=`<div class="left">
<h2>${MON[m]}<small>${season(m)} · ${Y}</small></h2>
<div class="alm"><div><span>sunrise, 1st</span><span>${ROWS.sun[m]}</span></div><div><span>sunset, 1st</span><span>${ROWS.set[m]}</span></div><div><span>things logged</span><span>${mi.length||"none"}</span></div></div>
<p class="weather">"${ALM[Y][m]||"Nothing to report."}"</p>
</div>
<div>${mi.length?`<ul class="items">${mi.map((i,k)=>`<li data-k="${k}"><button class="row" aria-expanded="false"><span class="day">${String(i.d).padStart(2,"0")} ${MON[m].slice(0,3).toLowerCase()}</span><span><h3 class="ph" title="placeholder">${i.title}</h3><p>${i.text}</p></span><span class="kind">${glyph(i.t,m)} ${({gig:"gig",post:"writing",find:"find",proj:"project"})[i.t]}</span></button><div class="x"></div></li>`).join("")}</ul>`
:`<p class="quiet">Nothing happened this month that made it onto the site. ${ALM[Y][m]?"From the notebook: "+ALM[Y][m].toLowerCase():""} Try ${MON[(m+11)%12]} or ${MON[(m+1)%12]}.</p>`}</div>`;
page.querySelectorAll(".items > li").forEach(li=>{
const it=mi[+li.dataset.k], btn=li.querySelector(".row");
btn.onclick=()=>{
const on=!li.classList.contains("open");
page.querySelectorAll(".items > li").forEach(o=>{o.classList.remove("open"); o.querySelector(".row").setAttribute("aria-expanded","false");});
if(on){ li.classList.add("open"); btn.setAttribute("aria-expanded","true"); fill(li,it); }
else if(it.t==="gig") stop();
};
if(gigId && it.gig===gigId){ li.classList.add("open"); btn.setAttribute("aria-expanded","true"); fill(li,it,songIdx); }
});
}
function fill(li,it,songIdx){
const x=li.querySelector(".x");
if(it.t!=="gig"){ stop(); x.innerHTML=(it.more||[]).map(p=>`<p>${p}</p>`).join(""); return; }
const keep = G && G.id===it.gig && songIdx==null;
if(!keep){ stop(); G={id:it.gig, g:GIGS[it.gig], pos:0, on:false, timer:null, tab:"who"}; }
const g=G.g, st=[]; let a=0; g.set.forEach(s=>{st.push(a);a+=s[1];}); G.st=st; G.T=a;
if(songIdx!=null) G.pos=st[songIdx];
x.innerHTML=`<div class="gigbox"><div>
<div class="cam"><button class="big" id="camb" aria-label="Play">▶</button><span>handcam · ${g.name} · ${g.date} (placeholder frame)</span></div>
<div class="bar" id="gbar">${st.slice(1).map(s=>`<b style="left:${s/a*100}%"></b>`).join("")}<i id="gfill"></i></div>
<div class="tm"><span id="gt">0:00</span><span>${fmt(a)}</span></div>
<div class="ctrls"><button id="gprev" aria-label="Previous song">«</button><button class="pp" id="gpp" aria-label="Play">▶</button><button id="gnext" aria-label="Next song">»</button><span style="font:11.5px var(--mono);color:var(--soft);margin-left:6px">the whole set, ${g.set.length} songs</span></div>
<ol class="set" id="gset">${g.set.map((s,i)=>`<li><button data-i="${i}">${s[0]}<span>${fmt(st[i])}</span></button></li>`).join("")}</ol>
</div><div class="song" id="gsong"></div></div>`;
x.querySelector("#gpp").onclick=x.querySelector("#camb").onclick=()=>setOn(!G.on);
x.querySelector("#gprev").onclick=()=>jump(Math.max(0,cur()-1));
x.querySelector("#gnext").onclick=()=>jump(Math.min(g.set.length-1,cur()+1));
x.querySelector("#gbar").onclick=e=>{const r=e.currentTarget.getBoundingClientRect(); G.pos=Math.floor((e.clientX-r.left)/r.width*G.T); upd(true);};
x.querySelectorAll("#gset button").forEach(b=>b.onclick=()=>jump(+b.dataset.i));
setOn(G.on); upd(true);
}
const cur=()=>{let i=0; G.st.forEach((v,k)=>{if(G.pos>=v)i=k}); return i;};
function jump(i){ G.pos=G.st[i]; upd(true); }
function stop(){ if(G){clearInterval(G.timer); G.on=false;} }
function setOn(on){
G.on=on; clearInterval(G.timer);
const label=on?"❚❚":"▶";
const pp=document.getElementById("gpp"), cb=document.getElementById("camb");
if(pp){pp.textContent=label; pp.setAttribute("aria-label",on?"Pause":"Play");} if(cb) cb.textContent=label;
if(on) G.timer=setInterval(()=>{G.pos++; if(G.pos>=G.T){G.pos=G.T; setOn(false);} upd(false);},1000);
}
let lastI=-1;
function upd(force){
const f=document.getElementById("gfill"); if(!f) return;
f.style.width=(G.pos/G.T*100)+"%"; document.getElementById("gt").textContent=fmt(G.pos);
const i=cur();
document.querySelectorAll("#gset li").forEach((li,k)=>li.classList.toggle("now",k===i));
if(force||i!==lastI){ lastI=i; song(i); }
}
function song(i){
const name=G.g.set[i][0], s=SONGS[name], box=document.getElementById("gsong");
const others=Object.entries(GIGS).map(([id,g])=>({id,g,j:g.set.findIndex(x=>x[0]===name)})).filter(o=>o.j>-1);
let body;
if(!s) body=`<p style="margin:0;color:var(--soft);font-style:italic">No name, no notes yet. We only played it once.</p>`;
else if(G.tab==="who") body=`<dl>${s.who.map(w=>`<dt>${w[0]}</dt><dd>${w[1]}</dd>`).join("")}</dl>`;
else if(G.tab==="story") body=`<p style="margin:0 0 8px;font-style:italic;font-size:19px">"${s.lyric}"</p><p style="margin:0">${s.origin}</p>`;
else if(G.tab==="pic") body=`<div class="pic">${s.pic} · ${G.g.date}</div>`;
else body=`<ul class="others">${others.map(o=>`<li><span>${o.g.name} <span style="color:var(--soft);font-size:14px">· ${o.g.date}</span></span>${o.id===G.id?`<span style="font:11.5px var(--mono);color:var(--soft)">this one</span>`:`<button data-g="${o.id}" data-j="${o.j}">go to ${MON[o.g.m].slice(0,3)} ${o.g.y} →</button>`}</li>`).join("")}</ul>`;
box.innerHTML=`<div style="font:11px var(--mono);color:var(--soft);text-transform:uppercase;letter-spacing:.06em">behind the song</div><h4>${name}</h4>
<div class="tabs" role="tablist">${[["who","who plays what"],["story","lyric & origin"],["pic","photo"],["other",`on other nights (${others.length})`]].map(([k,l])=>`<button role="tab" data-k="${k}" aria-selected="${G.tab===k}">${l}</button>`).join("")}</div>${body}`;
box.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{G.tab=b.dataset.k; song(cur());});
box.querySelectorAll(".others button").forEach(b=>b.onclick=()=>{
const g=GIGS[b.dataset.g], j=+b.dataset.j, wasOn=G.on, tab=G.tab;
stop(); G=null; if(g.y!==Y) setYear(g.y,g.m);
openMonth(g.m,b.dataset.g,j); if(G){ G.tab=tab; song(cur()); if(wasOn) setOn(true); }
});
}
setYear(2026);
</script>
</body>
</html>
+288
View File
@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther · as a person (P5 · The zine)</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=Instrument+Serif:ital@0;1&family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,700&display=swap" rel="stylesheet">
<style>
:root{
--paper:#f3f0e8; --ink:#151412; --soft:#66625a; --rule:#151412; --hair:#cfc9bc; --spot:#e3432b; --spot2:#f2c6b9;
--disp:"Instrument Serif", Georgia, serif; --sans:"Bricolage Grotesque", system-ui, sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--sans);font-size:16px;line-height:1.5;font-variation-settings:"opsz" 14}
a{color:inherit}
.ph{text-decoration:underline dotted var(--soft);text-underline-offset:3px;cursor:help}
.mock-note{position:fixed;top:10px;right:10px;z-index:99;max-width:300px;background:#fff;border:1px solid #bbb;padding:8px 30px 8px 10px;font:12px/1.4 system-ui,sans-serif;color:#333}
.mock-note b{display:block}
.mock-note button{position:absolute;top:4px;right:6px;border:0;background:none;font-size:16px;cursor:pointer;color:#666}
.wrap{max-width:1180px;margin:0 auto;padding:0 32px}
.topline{display:flex;justify-content:space-between;font-size:12px;padding:14px 0 8px;letter-spacing:.02em;gap:16px;flex-wrap:wrap}
.topline nav{display:flex;gap:16px}
.topline a{text-decoration:none;color:var(--soft)} .topline a[aria-current]{color:var(--ink);text-decoration:underline}
.mast{border-top:2px solid var(--rule);border-bottom:1px solid var(--rule);display:grid;grid-template-columns:1fr auto;align-items:end;gap:20px;padding:4px 0 8px}
.mast h1{font:400 128px/0.85 var(--disp);margin:0;letter-spacing:-.025em}
.mast h1 i{color:var(--spot)}
.issue{text-align:right;font-size:13px;padding-bottom:10px}
.issue .no{font:italic 40px/1 var(--disp);display:block}
.issues{display:flex;border-bottom:1px solid var(--rule);font-size:12.5px}
.issues button{all:unset;cursor:pointer;padding:7px 14px;border-right:1px solid var(--hair);color:var(--soft)}
.issues button[aria-pressed="true"]{background:var(--ink);color:var(--paper)}
.issues button:hover:not([aria-pressed="true"]){color:var(--ink)}
.issues button:focus-visible{outline:2px solid var(--spot);outline-offset:-2px}
.issues .by{margin-left:auto;padding:7px 0;color:var(--soft)}
.label{font-size:11px;letter-spacing:.14em;text-transform:uppercase;font-weight:500}
.spread{display:grid;grid-template-columns:repeat(12,1fr);gap:0 28px;padding:26px 0;border-bottom:1px solid var(--rule)}
/* contents */
.contents{grid-column:span 2;font-size:13.5px;border-right:1px solid var(--hair);padding-right:16px}
.contents ol{list-style:none;margin:8px 0 0;padding:0}
.contents li{display:grid;grid-template-columns:24px 1fr;padding:6px 0;border-bottom:1px solid var(--hair)}
.contents li span{font:italic 18px/1 var(--disp);color:var(--spot)}
.contents a{text-decoration:none}
.contents a:hover{text-decoration:underline}
/* cover */
.cover{grid-column:span 6}
.kicker{color:var(--spot)}
.cover h2{font:400 58px/0.98 var(--disp);margin:6px 0 12px;letter-spacing:-.015em}
.cover h2 i{color:var(--spot)}
.stand{font-size:19px;line-height:1.4;margin:0 0 16px;font-variation-settings:"opsz" 40}
.cols{columns:2;column-gap:24px;font-size:15.5px;column-rule:1px solid var(--hair)}
.cols p{margin:0 0 10px}
.cols p:first-child::first-letter{font:400 64px/0.8 var(--disp);float:left;margin:6px 8px 0 0;color:var(--spot)}
.byline{font-size:12px;color:var(--soft);margin-bottom:12px}
/* player: tracklist */
.side{grid-column:span 4;border-left:2px solid var(--rule);padding-left:20px;display:flex;flex-direction:column}
.sidesw{display:flex;gap:0;margin:8px 0 4px;font-size:12px}
.sidesw button{all:unset;cursor:pointer;padding:4px 10px;border:1px solid var(--ink);margin-right:-1px}
.sidesw button[aria-pressed="true"]{background:var(--ink);color:var(--paper)}
.sidesw button:focus-visible{outline:2px solid var(--spot)}
.sidehead{font:400 30px/1.05 var(--disp);margin:8px 0 2px}
.sidesub{font-size:12.5px;color:var(--soft);margin-bottom:6px}
.tracks{list-style:none;margin:0;padding:0}
.tracks li{border-top:1px solid var(--hair)}
.tracks li:last-child{border-bottom:1px solid var(--hair)}
.tracks button{all:unset;box-sizing:border-box;cursor:pointer;display:grid;grid-template-columns:64px 1fr auto;align-items:center;width:100%;padding:4px 0;position:relative}
.tracks .n{font:400 46px/1 var(--disp);color:var(--hair);transition:color .15s}
.tracks .tt{font-size:17px;font-weight:500;line-height:1.2}
.tracks .tt small{display:block;font-weight:400;font-size:12px;color:var(--soft)}
.tracks .d{font-size:12px;color:var(--soft);font-variant-numeric:tabular-nums}
.tracks button:hover .n{color:var(--ink)}
.tracks button:focus-visible{outline:2px solid var(--spot);outline-offset:-2px}
.tracks li.now .n{color:var(--spot)}
.tracks li.now button::after{content:"";position:absolute;left:64px;bottom:0;height:2px;width:calc(var(--p,0) * (100% - 64px));background:var(--spot)}
.deck{display:flex;align-items:center;gap:12px;margin-top:14px;padding-top:12px;border-top:2px solid var(--rule)}
.deck button{all:unset;cursor:pointer;font-size:13px;padding:3px 0}
.deck button:hover{color:var(--spot)}
.deck button:focus-visible{outline:2px solid var(--spot)}
.deck .pp{width:54px;height:54px;border-radius:50%;background:var(--spot);color:var(--paper);display:grid;place-items:center;font-size:17px;padding:0}
.deck .pp:hover{background:var(--ink);color:var(--paper)}
.deck .time{margin-left:auto;font:italic 26px/1 var(--disp);font-variant-numeric:tabular-nums}
.deck .state{font-size:12px;color:var(--soft)}
.seek{height:14px;position:relative;cursor:pointer;margin-top:10px}
.seek::before{content:"";position:absolute;left:0;right:0;top:6px;border-top:1px dotted var(--ink)}
.seek i{position:absolute;left:0;top:5px;height:3px;background:var(--spot);width:0}
.film{margin-top:14px;display:flex;gap:12px;align-items:center;font-size:12.5px;color:var(--soft);border:1px solid var(--hair);padding:8px;cursor:pointer;background:none;width:100%;text-align:left;font-family:var(--sans)}
.film .fr{width:84px;height:54px;flex:none;background:radial-gradient(var(--ink) 1px,transparent 1.4px) 0 0/5px 5px,#bdb6a8;display:grid;place-items:center;color:var(--paper);font-size:14px}
.film:hover{border-color:var(--ink);color:var(--ink)}
/* liner notes */
.liner{grid-column:1/-1;display:grid;grid-template-columns:repeat(12,1fr);gap:0 28px}
.liner .head{grid-column:span 12;display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px;flex-wrap:wrap;gap:10px}
.liner .head h3{font:400 40px/1 var(--disp);margin:0}
.liner .head h3 small{font-size:20px;color:var(--soft);font-style:italic}
.quote{grid-column:span 5;font:italic 400 46px/1.02 var(--disp);color:var(--spot);margin:0;letter-spacing:-.01em}
.quote + p{grid-column:span 5}
.origin{font-size:15.5px;margin:12px 0 0}
.credits{grid-column:span 3;font-size:14.5px;border-left:1px solid var(--hair);padding-left:18px}
.credits dl{margin:8px 0 0;display:grid;grid-template-columns:auto 1fr;gap:6px 12px}
.credits dt{font-weight:700}
.credits dd{margin:0;color:var(--soft)}
.halftone{grid-column:span 4;min-height:200px;background:radial-gradient(circle,var(--ink) 1.1px,transparent 1.6px) 0 0/6px 6px, linear-gradient(135deg,#d9d2c4,#a9a294);display:flex;align-items:flex-end;padding:0;position:relative}
.halftone span{background:var(--paper);font-size:11.5px;padding:4px 8px;margin:8px}
.pressings{grid-column:5 / span 8;margin-top:22px;border-top:1px solid var(--rule);padding-top:10px;font-size:14px}
.pressings ul{list-style:none;margin:6px 0 0;padding:0;display:flex;gap:0;flex-wrap:wrap}
.pressings li{flex:1 1 180px;border-right:1px solid var(--hair);padding:4px 14px 4px 0;margin-right:14px}
.pressings li:last-child{border-right:0}
.pressings b{display:block;font:400 22px/1.1 var(--disp)}
.pressings button{all:unset;cursor:pointer;font-size:12px;color:var(--spot);margin-top:4px;display:inline-block}
.pressings button:hover{text-decoration:underline}
.pressings .here{font-size:12px;color:var(--soft);margin-top:4px;display:inline-block}
.liner-empty{grid-column:span 12;font:italic 26px/1.3 var(--disp);color:var(--soft)}
.ln-left{grid-column:span 5}
/* back pages */
.musings{grid-column:span 7}
.musings .row{display:grid;grid-template-columns:repeat(3,1fr);gap:22px;margin-top:10px}
.musings article{border-top:1px solid var(--rule);padding-top:8px}
.musings h4{font:400 26px/1.05 var(--disp);margin:0 0 6px}
.musings p{margin:0;font-size:14.5px}
.musings details summary{cursor:pointer;font-size:12px;color:var(--spot);margin-top:6px;list-style:none}
.musings details summary::-webkit-details-marker{display:none}
.musings details p{margin-top:6px}
.classifieds{grid-column:span 5;border-left:2px solid var(--rule);padding-left:20px}
.classifieds .grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--rule);border-left:1px solid var(--hair);margin-top:10px}
.classifieds .ad{border-right:1px solid var(--hair);border-bottom:1px solid var(--hair);padding:8px 10px;font-size:13.5px;line-height:1.35}
.classifieds .ad b{display:block;font-size:11px;letter-spacing:.12em;text-transform:uppercase;margin-bottom:3px}
.classifieds .ad a{text-decoration:none;font-weight:500}
.classifieds .ad a:hover{color:var(--spot)}
.classifieds .ad.wide{grid-column:span 2;background:var(--spot2)}
.colophon{display:grid;grid-template-columns:2fr 1fr 1fr;gap:28px;padding:18px 0 34px;font-size:12px;color:var(--soft)}
.colophon b{color:var(--ink);font-weight:500}
@media (max-width:980px){
.mast h1{font-size:84px}
.contents{grid-column:1/-1;border-right:0;padding:0 0 16px} .contents ol{columns:2}
.cover{grid-column:span 12} .side{grid-column:span 12;border-left:0;padding:20px 0 0;border-top:2px solid var(--rule);margin-top:20px}
.quote,.ln-left,.credits,.halftone,.pressings,.musings,.classifieds{grid-column:1/-1}
.credits,.classifieds{border-left:0;padding-left:0;margin-top:16px}
.musings .row{grid-template-columns:1fr}
.colophon{grid-template-columns:1fr}
}
@media (max-width:520px){.mast h1{font-size:60px} .cols{columns:1} .cover h2{font-size:40px} .quote{font-size:34px} .classifieds .grid{grid-template-columns:1fr} .classifieds .ad.wide{grid-column:auto}}
</style>
</head>
<body>
<div class="mock-note" role="note"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>P5 · The zine</b>The personal space as a small seasonal magazine. Each issue mixes a cover story, a printed tracklist that plays, liner notes for the current song, musings and classified-style findings. Borrows marcschuster.com's editorial feel and beige.zone's archival typography.</div>
<div class="wrap">
<div class="topline">
<span>Lucas Winther · personal pages</span>
<nav aria-label="Spaces"><a href="#">with AI, now</a><a href="#" aria-current="page">as a person</a><a href="#">by hand, before ↗</a></nav>
</div>
<header class="mast">
<h1><span title="placeholder: zine name">Sidespor</span><i>.</i></h1>
<div class="issue"><span class="no" id="no">Nº 03</span><span id="season">Autumn 2026</span> · a zine about the things I do when I'm not working</div>
</header>
<div class="issues" role="group" aria-label="Issues" id="issues"></div>
<section class="spread" id="cover"></section>
<section class="spread"><div class="liner" id="liner"></div></section>
<section class="spread" id="back"></section>
<footer class="colophon">
<div><b>Colophon.</b> Written and put together by Lucas Winther, one issue per season when there's enough to say. Recordings are handcam phone videos from <span class="ph" title="placeholder">Lav Sol</span> gigs. Placeholders (dotted underline): zine name, band name, members, songs, venues, dates, all article and finding titles.</div>
<div><b>Elsewhere.</b><br><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br><a href="#">GitHub · StereotypicalCat</a><br><a href="#">blog.lucaswinther.info</a></div>
<div><b>Back issues</b> stay up as they were printed. Nothing gets rewritten after the season ends.</div>
</footer>
</div>
<script>
const GIGS={
retro:{name:"Café Retro",date:"14 March 2026",note:"Handcam from the bar, eleven people and a dog.",set:[["Grå morgen",252],["Kitchen light",208],["Slow ferry",301],["Halvvejs",196],["Paper boats",244]]},
rehearsal:{name:"Rehearsal, door open",date:"24 September 2026",note:"Filmed by whoever walked past.",set:[["Slow ferry",344],["Halvvejs",201],["New one (no name)",176]]},
kaelder:{name:"Studenterhuset basement",date:"27 October 2025",note:"Phone on a beer crate, front left.",set:[["Paper boats",231],["Grå morgen",270],["New one (no name)",188],["Slow ferry",322]]},
have:{name:"Garden party at Ida's",date:"9 August 2025",note:"Acoustic. Wasps.",set:[["Kitchen light",190],["Halvvejs",205],["Grå morgen",240]]},
loops:{name:"Loop pedal sketches",date:"summer 2026",note:"Ten loops, each under eight bars.",set:[["Loop 03",96],["Loop 07",121],["Loop 10",88]]},
};
const SONGS={
"Grå morgen":{who:[["Lucas","the riff, lead guitar"],["Signe","vocals"],["Mads","bass"],["Jonas","drums, brushes in the intro"]],lyric:"Grey morning, the coffee knows before I do.",origin:"A warm-up riff that Signe wrote words over in one rehearsal. We've never changed a word.",pic:"photo: Signe under a red bulb"},
"Kitchen light":{who:[["Lucas","acoustic, verse two"],["Signe","vocals, Rhodes"],["Mads","bass"],["Jonas","shaker, then drums"]],lyric:"Leave the kitchen light on, I'll find my way.",origin:"Written at 2am on the back of a receipt. Every live version is slower than the last.",pic:"photo: one lamp over a kitchen table"},
"Slow ferry":{who:[["Lucas","guitar, delay pedal"],["Signe","keys, vocals"],["Mads","the long bass notes"],["Jonas","drums"]],lyric:"Nothing to do but stand at the rail.",origin:"First verse written on a ferry ticket. The one song we let run as long as it wants.",pic:"photo: grey sea from the deck"},
"Halvvejs":{who:[["Lucas","guitar"],["Signe","vocals"],["Mads","bass"],["Jonas","drums"]],lyric:"Halvvejs hjem, halvvejs væk.",origin:"Our only song in Danish. It means \"halfway\". The second verse is still unfinished.",pic:"photo: setlist gaffer-taped to the floor"},
"Paper boats":{who:[["Lucas","guitar, harmony"],["Signe","vocals"],["Mads","bass"],["Jonas","count-in, always too fast"]],lyric:"Fold it twice and let it go.",origin:"Usually the closer. Usually counted in too fast.",pic:"photo: blurry crowd, someone waving"},
};
const ISSUES=[
{no:"01",season:"Spring 2026",sides:["retro","kaelder"],
kicker:"Cover story",title:"Eleven people <i>and a dog</i>",stand:"Our first gig of the year was in a café that seats thirty. Notes on small rooms, nerves, and why the handcam stays.",
body:["Small rooms are harder than big ones. You can see every face, and every face can see your left hand. At Café Retro there were eleven people and a dog, and I counted them twice during the first song.","What helped was telling the story behind each song before we played it. People listen differently when they know where a song came from. It's why every track in this issue has liner notes.","The recording is a phone propped on the bar. You can hear the espresso machine in Halvvejs. I wouldn't change it."],
contents:["Eleven people and a dog","Side A: Café Retro","Learning harmony","Found: a tuner"],
musings:[["Learning harmony","Record yourself, listen once, never again.","So far: harmonies on two songs, both shaky."],["Paper notebooks","Half our lyrics started in a meeting margin.","The other half on the bus."],["New strings","Every March. Small rituals.","89 DKK a set, if you're wondering."]],
finds:[["tool","A tuner that doesn't nag","No account, no ads."],["essay","On small rooms","A musician's notes on playing cafés."],["place","A rehearsal room at 90 DKK/h","Thursdays are ours."],["record","Nick Drake, Pink Moon","Twenty-eight minutes. Perfect."]]},
{no:"02",season:"Summer 2026",sides:["loops","have"],
kicker:"Cover story",title:"Ten loops, <i>eight bars each</i>",stand:"A summer project with one rule: no loop longer than eight bars. Most were bad. Three are on side A.",
body:["The loop pedal was a birthday present that sat in a drawer for a year. This summer I gave myself one rule: every loop under eight bars, one loop per rehearsal.","Loop 07 turned into the bridge of a new song. Loop 10 is just a nice noise. Both are on side A.","Side B is last summer's garden party, acoustic, with wasps. It felt right to put them next to each other."],
contents:["Ten loops, eight bars each","Side A: loop sketches","Bus shelters","Found: Pink Moon"],
musings:[["Bus shelters","Someone photographed all of them.","I've looked at most."],["Summer off","No gigs in July, on purpose.","Best decision of the year."],["Writing on the ferry","Where Slow ferry came from.","Wind, ticket, pen."]],
finds:[["link","Every Danish bus shelter","There's a map."],["record","Pink Moon, again","Still twenty-eight minutes."],["tool","A free loop-pedal manual","Nobody reads them. Read it."],["place","Ferry deck, any ferry","Best writing desk I know."]]},
{no:"03",season:"Autumn 2026",sides:["rehearsal","kaelder"],
kicker:"Cover story",title:"Playing with <i>the door open</i>",stand:"In September we rehearsed with the door to the corridor open, and someone filmed it. Then a year-old basement gig turned up on a friend's phone.",
body:["The rehearsal room gets hot in September, so we left the door open. People walking past stopped, and one of them filmed two songs on their phone and sent them to us the next day. That's side A.","Side B is older: the Studenterhuset basement gig from last October, which I thought nobody had recorded. A friend found it on her phone while clearing space. It's loud, it's crooked, and Slow ferry runs to five and a half minutes.","Put them side by side and you can hear a song change over a year. Click a track, then look at the liner notes below for the other nights it was played."],
contents:["Playing with the door open","Side A: rehearsal, side B: basement","Code vs. songs","Found: small software"],
musings:[["Code vs. songs","Both are mostly deleting. One has a test suite.","At work I use AI tools all day. Songwriting is where I deliberately don't."],["On eleven people","Small rooms, revisited.","Still counting the audience during song one."],["Thursday nights","Rehearsal is the only fixed thing in my week.","It's also the best thing."]],
finds:[["essay","\"The case for small software\"","Read it twice. Applies to bands too."],["record","Bill Evans · Sunday at the Village Vanguard","The glasses clink. Handcam in 1961."],["tool","A setlist app that's just a list","No cloud. Good."],["place","The ferry café","Coffee, 25 DKK, sea included."]]},
];
const fmt=s=>Math.floor(s/60)+":"+String(Math.floor(s%60)).padStart(2,"0");
let I=2, side=0, P={g:null,i:0,pos:0,on:false,timer:null}, tab="who";
const issuesEl=document.getElementById("issues");
function renderIssues(){
issuesEl.innerHTML=ISSUES.map((x,k)=>`<button data-k="${k}" aria-pressed="${k===I}">Nº ${x.no} · ${x.season}</button>`).join("")+`<span class="by">older issues stay as printed</span>`;
issuesEl.querySelectorAll("button").forEach(b=>b.onclick=()=>{I=+b.dataset.k; side=0; stop(); renderIssue();});
}
function renderIssue(){
const x=ISSUES[I]; renderIssues();
document.getElementById("no").textContent="Nº "+x.no; document.getElementById("season").textContent=x.season;
document.getElementById("cover").innerHTML=`
<aside class="contents"><div class="label">In this issue</div><ol>${x.contents.map((c,k)=>`<li><span>${k+1}</span><a href="#" class="ph" title="placeholder">${c}</a></li>`).join("")}</ol></aside>
<article class="cover"><div class="label kicker">${x.kicker}</div><h2 class="ph" title="placeholder">${x.title}</h2><p class="stand">${x.stand}</p><div class="byline">Words by Lucas Winther · ${x.season}</div><div class="cols">${x.body.map(p=>`<p>${p}</p>`).join("")}</div></article>
<div class="side" id="side"></div>`;
loadSide(side,false);
renderBack();
}
function loadSide(s,keepPlaying){
side=s; const x=ISSUES[I], g=GIGS[x.sides[s]];
if(!keepPlaying || P.g!==g){ stop(); P.g=g; P.i=0; P.pos=0; }
renderSide(); renderLiner();
}
function renderSide(){
const x=ISSUES[I], g=P.g, el=document.getElementById("side");
el.innerHTML=`<div class="label">The record in this issue</div>
<div class="sidesw" role="group" aria-label="Side">${x.sides.map((id,k)=>`<button data-s="${k}" aria-pressed="${GIGS[id]===g}">Side ${"AB"[k]}</button>`).join("")}</div>
<div class="sidehead ph" title="placeholder">${g.name}</div><div class="sidesub">${g.date} · ${g.note}</div>
<ol class="tracks">${g.set.map((t,k)=>`<li class="${k===P.i&&(P.on||P.pos>0)?"now":""}"><button data-i="${k}" aria-label="Play ${t[0]}"><span class="n">${String(k+1).padStart(2,"0")}</span><span class="tt">${t[0]}<small>${SONGS[t[0]]?SONGS[t[0]].who.length+" players · liner notes":"no notes yet"}</small></span><span class="d">${fmt(t[1])}</span></button></li>`).join("")}</ol>
<div class="deck"><button class="pp" id="pp" aria-label="${P.on?"Pause":"Play"}">${P.on?"❚❚":"▶"}</button><div><button id="prev">← prev</button> · <button id="next">next →</button><div class="state">${P.on?"playing":P.pos>0?"paused":"press play, nothing starts on its own"}</div></div><span class="time" id="time">${fmt(P.pos)}</span></div>
<div class="seek" id="seek"><i id="sfill" style="width:${P.pos/g.set[P.i][1]*100}%"></i></div>
<button class="film"><span class="fr">▶</span><span>Watch the handcam video of this song<br><i>placeholder frame · ${g.name}</i></span></button>`;
el.querySelectorAll(".sidesw button").forEach(b=>b.onclick=()=>loadSide(+b.dataset.s,false));
el.querySelectorAll(".tracks button").forEach(b=>b.onclick=()=>{ const k=+b.dataset.i; if(k===P.i && P.on){ setOn(false); return; } P.i=k; P.pos=0; setOn(true); renderLiner(); });
el.querySelector("#pp").onclick=()=>setOn(!P.on);
el.querySelector("#prev").onclick=()=>{P.i=Math.max(0,P.i-1);P.pos=0;renderSide();renderLiner();};
el.querySelector("#next").onclick=()=>{P.i=Math.min(g.set.length-1,P.i+1);P.pos=0;renderSide();renderLiner();};
el.querySelector("#seek").onclick=e=>{const r=e.currentTarget.getBoundingClientRect(); P.pos=Math.floor((e.clientX-r.left)/r.width*g.set[P.i][1]); tickUI();};
tickUI();
}
function stop(){ clearInterval(P.timer); P.on=false; }
function setOn(on){
clearInterval(P.timer); P.on=on;
if(on) P.timer=setInterval(()=>{ P.pos++; const L=P.g.set[P.i][1]; if(P.pos>=L){ if(P.i<P.g.set.length-1){P.i++;P.pos=0;renderSide();renderLiner();return;} P.pos=L; setOn(false); return; } tickUI(); },1000);
renderSide();
}
function tickUI(){
const L=P.g.set[P.i][1], t=document.getElementById("time"); if(!t) return;
t.textContent=fmt(P.pos); document.getElementById("sfill").style.width=(P.pos/L*100)+"%";
const li=document.querySelectorAll(".tracks li")[P.i]; if(li){ li.classList.add("now"); li.style.setProperty("--p",P.pos/L); }
}
function renderLiner(){
const name=P.g.set[P.i][0], s=SONGS[name], el=document.getElementById("liner");
const where=Object.entries(GIGS).map(([id,g])=>({id,g,j:g.set.findIndex(t=>t[0]===name)})).filter(o=>o.j>-1);
if(!s){ el.innerHTML=`<div class="head"><div class="label">Liner notes · track ${String(P.i+1).padStart(2,"0")}</div></div><h3 class="liner-empty">${name}: no liner notes yet. Some songs only exist for one night.</h3>`; return; }
el.innerHTML=`<div class="head"><div class="label">Liner notes · track ${String(P.i+1).padStart(2,"0")} of side ${"AB"[side]}</div><h3>${name} <small>· ${P.g.name}</small></h3></div>
<div class="ln-left"><p class="quote">“${s.lyric}”</p><p class="origin">${s.origin}</p></div>
<div class="credits"><div class="label">Who plays what</div><dl>${s.who.map(w=>`<dt class="ph" title="placeholder">${w[0]}</dt><dd>${w[1]}</dd>`).join("")}</dl></div>
<div class="halftone"><span>${s.pic} · ${P.g.date}</span></div>
<div class="pressings"><div class="label">The same song on other nights</div><ul>${where.map(o=>`<li><b>${o.g.name}</b>${o.g.date} · ${fmt(o.g.set[o.j][1])}<br>${o.g===P.g?`<span class="here">you're hearing this one</span>`:`<button data-g="${o.id}" data-j="${o.j}">play this version →</button>`}</li>`).join("")}</ul></div>`;
el.querySelectorAll(".pressings button").forEach(b=>b.onclick=()=>{
const id=b.dataset.g, x=ISSUES[I], k=x.sides.indexOf(id), wasOn=P.on;
stop(); P.g=GIGS[id]; P.i=+b.dataset.j; P.pos=0; if(k>-1) side=k;
renderSide(); renderLiner(); if(wasOn) setOn(true);
});
}
function renderBack(){
const x=ISSUES[I];
document.getElementById("back").innerHTML=`
<div class="musings"><div class="label">Musings, short</div><div class="row">${x.musings.map(m=>`<article><h4 class="ph" title="placeholder">${m[0]}</h4><p>${m[1]}</p><details><summary>read the rest +</summary><p>${m[2]}</p></details></article>`).join("")}</div></div>
<div class="classifieds"><div class="label">Found &amp; kept · classifieds</div><div class="grid">${x.finds.map((f,k)=>`<div class="ad${k===0?" wide":""}"><b>${f[0]}</b><a href="#" class="ph" title="placeholder">${f[1]}</a><br>${f[2]}</div>`).join("")}</div></div>`;
}
renderIssue();
</script>
</body>
</html>
+858
View File
@@ -0,0 +1,858 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Off the clock · 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=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,300;1,9..144,400&family=Caveat:wght@500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #2a1a1f; --bg2: #321f25; --bg3: #3a252c; --line: #4a3238; --line2: #5c4148;
--ink: #f1e6d6; --ink2: #e3d4c1; --mute: #b8a592; --dim: #8a7768;
--acc: #e8b04a; --rose: #c9706b; --sage: #a7b86c;
--paper: #f1e6d6; --pink: #2a1a1f;
--serif: "Fraunces", Georgia, serif; --hand: "Caveat", cursive; --mono: "Geist Mono", ui-monospace, monospace;
--ease: cubic-bezier(.2,.8,.2,1);
--edge: 42px;
}
* { box-sizing: border-box; }
html { scroll-padding-top: 70px; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--ink); font-family: var(--serif); font-size: 16px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body::before { content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0; opacity: .13;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 .9 0 0 0 0 .8 0 0 0 .55 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>"); }
a { color: inherit; }
button { font: inherit; color: inherit; }
::selection { background: var(--acc); color: var(--bg); }
:focus-visible { outline: 2px solid var(--acc); outline-offset: 3px; }
.ph { text-decoration: underline dotted rgba(241,230,214,.4); text-underline-offset: 3px; text-decoration-thickness: 1px; cursor: help; }
.mono { font-family: var(--mono); }
/* mockup note */
.mk { position: fixed; bottom: 14px; right: 14px; z-index: 95; width: 310px; background: #fff; color: #222; font: 12px/1.45 system-ui, sans-serif; padding: 10px 28px 10px 12px; border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,.25); }
.mk b { display: block; margin-bottom: 3px; }
.mk button { position: absolute; top: 4px; right: 6px; background: none; border: 0; font-size: 16px; cursor: pointer; color: #555; }
/* work edge (mirror of tech-r3-c's off-the-clock edge) */
.edge { position: fixed; top: 0; left: 0; bottom: 0; width: var(--edge); z-index: 40; background: #131210; color: #ede7dd; border-right: 1px solid #2b2824; display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; transform-origin: center; font: 500 12px "Geist Mono", monospace; letter-spacing: .12em; text-transform: uppercase; text-decoration: none; transition: width .2s var(--ease); }
.edge span { transform: rotate(180deg); }
.edge:hover { width: 54px; color: #ea7444; }
/* top bar */
.bar { position: sticky; top: 0; z-index: 30; margin-left: var(--edge); background: rgba(42,26,31,.92); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); 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: 24px; }
.crumb { display: flex; align-items: center; gap: 10px; 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: 500; font-size: 15px; }
.crumb i { color: var(--mute); font-weight: 300; }
.snav { display: flex; gap: 2px; margin-left: 4px; overflow-x: auto; scrollbar-width: none; }
.snav a { text-decoration: none; color: var(--mute); font-size: 14px; padding: 5px 9px; border-radius: 6px; white-space: nowrap; }
.snav a:hover { color: var(--ink); background: var(--bg3); }
.snav a sup { font: 10px var(--mono); color: var(--dim); margin-left: 2px; }
.bar .meta { margin-left: auto; font: 12px var(--mono); color: var(--dim); white-space: nowrap; }
main { margin-left: var(--edge); position: relative; z-index: 1; }
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 40px; }
/* intro + new lately */
.intro { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr); gap: 48px; align-items: end; padding: 40px 0 34px; }
.intro .lab { font: 600 23px var(--hand); color: var(--acc); transform: rotate(-2deg); display: inline-block; }
.intro h1 { font-weight: 300; font-style: italic; font-size: 50px; line-height: 1.02; letter-spacing: -.02em; margin: 6px 0 0; }
.intro p { color: var(--ink2); font-weight: 300; font-size: 17px; margin: 14px 0 0; max-width: 30em; }
.lately { border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(180deg, rgba(255,255,255,.035), rgba(255,255,255,.01)); padding: 14px 16px 16px; }
.lately h3 { margin: 0 0 10px; display: flex; justify-content: space-between; align-items: baseline; font: 500 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--mute); }
.lately h3 em { font: 600 20px var(--hand); text-transform: none; letter-spacing: 0; color: var(--acc); font-style: normal; }
.lately .row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.lt { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: 64px 1fr; gap: 12px; align-items: center; padding: 8px; border-radius: 10px; transition: background .18s; }
.lt:hover, .lt:focus-visible { background: var(--bg3); }
.lt .th { width: 64px; height: 64px; border-radius: 4px; overflow: hidden; box-shadow: 0 6px 14px rgba(0,0,0,.35); transition: transform .2s var(--ease); }
.lt:hover .th { transform: rotate(-3deg) scale(1.05); }
.lt .th svg { width: 100%; height: 100%; display: block; }
.lt small { display: block; font: 11px var(--mono); color: var(--acc); }
.lt b { display: block; font-weight: 500; font-size: 15px; line-height: 1.2; margin: 2px 0; }
.lt span { font-size: 13px; color: var(--mute); }
/* shelves */
.shelf { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 32px; padding: 34px 0 30px; border-top: 1px solid var(--line); }
.sh-head { position: relative; padding-top: 4px; }
.sh-head .n { font: 600 20px var(--hand); color: var(--dim); }
.sh-head h2 { margin: 0; font-weight: 400; font-style: italic; font-size: 32px; letter-spacing: -.015em; line-height: 1.05; }
.sh-head .hd { font: 500 21px var(--hand); color: var(--acc); line-height: 1.1; margin-top: 4px; transform: rotate(-1.5deg); transform-origin: left; }
.sh-head .live { margin-top: 12px; display: inline-flex; align-items: center; gap: 7px; font: 11px var(--mono); color: var(--sage); border: 1px solid #4f5a3a; border-radius: 99px; padding: 3px 9px 3px 8px; }
.sh-head .live i { width: 6px; height: 6px; border-radius: 50%; background: var(--sage); box-shadow: 0 0 0 3px rgba(167,184,108,.18); }
.sh-head .cnt { font: 12px var(--mono); color: var(--dim); margin-top: 14px; }
.sh-head .all { margin-top: 6px; background: none; border: 1px solid var(--line2); border-radius: 99px; padding: 5px 12px; font-size: 14px; cursor: pointer; color: var(--ink2); transition: background .15s, border-color .15s; }
.sh-head .all:hover { background: var(--bg3); border-color: var(--mute); }
.sh-head .all span { display: inline-block; transition: transform .2s var(--ease); }
.sh-head .all:hover span { transform: translateX(3px); }
.row-objs { position: relative; display: flex; gap: 34px; align-items: flex-start; padding: 0 8px; }
.row-objs::before { /* the ledge */ content: ""; position: absolute; left: -8px; right: -8px; top: 214px; height: 9px; border-radius: 2px;
background: linear-gradient(180deg, #5b3f3f 0, #4a3134 3px, #3a262a 9px); box-shadow: 0 10px 18px -6px rgba(0,0,0,.6); }
.it { all: unset; box-sizing: border-box; cursor: pointer; display: flex; flex-direction: column; width: var(--w, 170px); flex: 0 0 auto; position: relative; }
.it .stage { height: 214px; display: flex; align-items: flex-end; justify-content: center; position: relative; }
.it .cap { padding-top: 22px; text-align: left; }
.it .cap .d { font: 11px var(--mono); color: var(--dim); }
.it .cap .d em { font-style: normal; color: var(--acc); }
.it .cap b { display: block; font-weight: 500; font-size: 16px; line-height: 1.2; margin: 3px 0 4px; transition: color .15s; }
.it .cap .ln { font-size: 13.5px; color: var(--mute); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.it:hover .cap b, .it:focus-visible .cap b { color: var(--acc); }
.it:focus-visible { outline: none; }
.it:focus-visible .stage { outline: 2px solid var(--acc); outline-offset: 6px; border-radius: 4px; }
.obj { transition: transform .25s var(--ease); transform-origin: bottom center; }
.it:hover .obj, .it:focus-visible .obj { transform: translateY(-7px); }
.obj svg { display: block; width: 100%; height: 100%; }
/* comic: fanned pages */
.o-comic { width: 190px; height: 200px; position: relative; }
.o-comic .pg { position: absolute; bottom: 0; left: 50%; width: 112px; height: 150px; margin-left: -56px; box-shadow: 0 6px 14px rgba(0,0,0,.4); transform-origin: 50% 110%; transition: transform .28s var(--ease); background: #fff; }
.o-comic .pg:nth-child(1) { transform: rotate(-11deg) translateX(-16px); }
.o-comic .pg:nth-child(2) { transform: rotate(-1deg); }
.o-comic .pg:nth-child(3) { transform: rotate(10deg) translateX(16px); }
.it:hover .o-comic .pg:nth-child(1) { transform: rotate(-19deg) translateX(-26px); }
.it:hover .o-comic .pg:nth-child(2) { transform: rotate(-2deg) translateY(-6px); }
.it:hover .o-comic .pg:nth-child(3) { transform: rotate(17deg) translateX(26px); }
.it:hover .o-comic { transform: none; }
.o-comic .clip { position: absolute; top: 40px; left: 50%; margin-left: -12px; width: 24px; height: 10px; background: var(--acc); border-radius: 2px; z-index: 3; transform: rotate(-3deg); opacity: .9; }
/* game box */
.o-box { width: 140px; height: 186px; position: relative; display: flex; }
.o-box .spine { width: 16px; background: color-mix(in srgb, var(--c) 70%, #000); color: rgba(241,230,214,.8); writing-mode: vertical-rl; font: 600 9px var(--mono); letter-spacing: .1em; text-transform: uppercase; display: flex; align-items: center; justify-content: center; transform: skewY(-12deg); transform-origin: right top; margin-top: 3px; }
.o-box .front { flex: 1; position: relative; overflow: hidden; box-shadow: 0 8px 18px rgba(0,0,0,.45); border-radius: 1px 3px 3px 1px; }
.o-box .front .t { position: absolute; left: 10px; right: 10px; top: 10px; font: 600 15px var(--serif); color: #fff; letter-spacing: .02em; text-shadow: 0 1px 2px rgba(0,0,0,.4); line-height: 1.05; }
.o-box .front .band { position: absolute; left: 0; right: 0; bottom: 0; height: 16px; background: rgba(0,0,0,.45); font: 8px var(--mono); color: #ddd; display: flex; align-items: center; padding: 0 8px; letter-spacing: .1em; }
/* cartridge */
.o-cart { width: 150px; height: 172px; position: relative; background: #8c8a86; border-radius: 6px 6px 3px 3px; box-shadow: inset 0 -6px 0 #6e6c68, 0 8px 18px rgba(0,0,0,.45); padding: 26px 13px 18px; }
.o-cart::before { content: ""; position: absolute; top: 8px; left: 14px; right: 14px; height: 10px; background: repeating-linear-gradient(90deg, #6e6c68 0 3px, transparent 3px 7px); border-radius: 2px; }
.o-cart .lbl { height: 100%; border-radius: 3px; overflow: hidden; position: relative; }
.o-cart .lbl .t { position: absolute; left: 8px; bottom: 7px; font: italic 500 16px var(--serif); color: #f1e6d6; }
.o-cart .stamp { position: absolute; right: -14px; top: 36px; transform: rotate(12deg); font: 600 19px var(--hand); color: var(--bg); background: var(--acc); padding: 0 8px; border-radius: 3px; box-shadow: 0 2px 0 rgba(0,0,0,.3); }
/* record sleeve */
.o-sleeve { width: 168px; height: 168px; position: relative; }
.o-sleeve .rec { position: absolute; top: 6px; left: 6px; width: 156px; height: 156px; border-radius: 50%; background: radial-gradient(circle, var(--c, #c9706b) 0 17%, #141012 17.5% 19%, #1b1517 19% 100%); box-shadow: inset 0 0 0 1px #000; transition: transform .35s var(--ease); }
.o-sleeve .rec::after { content: ""; position: absolute; inset: 10%; border-radius: 50%; background: repeating-radial-gradient(circle, transparent 0 3px, rgba(255,255,255,.04) 3px 4px); }
.o-sleeve .slv { position: absolute; inset: 0; overflow: hidden; box-shadow: 0 8px 18px rgba(0,0,0,.45); border-radius: 2px; }
.o-sleeve .slv .t { position: absolute; left: 10px; bottom: 8px; font: italic 400 13px var(--serif); color: rgba(255,255,255,.9); }
.it:hover .o-sleeve .rec { transform: translateX(30px); }
.o-sleeve.playing .rec { transform: translateX(58px); animation: spin 2.4s linear infinite; }
.it:hover .o-sleeve.playing .rec { transform: translateX(58px); }
@keyframes spin { from { rotate: 0deg; } to { rotate: 360deg; } }
.mini { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.mini .pp { width: 28px; height: 28px; border-radius: 50%; border: 0; background: var(--acc); color: var(--bg); cursor: pointer; font-size: 10px; display: grid; place-items: center; flex: 0 0 auto; transition: transform .15s; }
.mini .pp:hover { transform: scale(1.08); }
.mini .bar2 { flex: 1; height: 3px; background: var(--line2); border-radius: 2px; position: relative; cursor: pointer; }
.mini .bar2 i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--acc); border-radius: 2px; width: 0; }
.mini .tm { font: 11px var(--mono); color: var(--dim); min-width: 64px; text-align: right; }
/* cassette (band handcam) */
.o-tape { width: 190px; height: 122px; background: #e9dcc6; border-radius: 7px; padding: 10px 12px; box-shadow: 0 8px 18px rgba(0,0,0,.45); color: var(--bg); position: relative; }
.o-tape .l { background: #f6efe2; border-bottom: 3px solid var(--rose); padding: 3px 8px; font: 600 18px/1.1 var(--hand); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.o-tape .win { margin: 12px auto 0; width: 124px; height: 38px; background: var(--bg); border-radius: 19px; display: flex; justify-content: space-between; align-items: center; padding: 0 10px; }
.o-tape .reel { width: 24px; height: 24px; border-radius: 50%; border: 3px dotted #e9dcc6; }
.o-tape.playing .reel { animation: spin 2.4s linear infinite; }
.o-tape .vid { position: absolute; right: 10px; bottom: 8px; font: 9px var(--mono); letter-spacing: .08em; opacity: .6; }
/* poster */
.o-poster { width: 128px; height: 192px; position: relative; overflow: hidden; box-shadow: 0 8px 18px rgba(0,0,0,.45); border-radius: 2px; }
.o-poster .t { position: absolute; left: 0; right: 0; bottom: 12px; text-align: center; font: 600 15px var(--serif); letter-spacing: .14em; text-transform: uppercase; color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.5); }
.o-poster .s { position: absolute; left: 0; right: 0; bottom: 4px; text-align: center; font: 7px var(--mono); color: rgba(255,255,255,.75); letter-spacing: .12em; }
/* trakt "now watching" */
.live-card { width: 280px; height: 206px; margin-bottom: -4px; border: 1px solid var(--line2); border-radius: 12px; background: var(--bg2); padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; font-size: 13px; text-align: left; }
.live-card .top { display: flex; justify-content: space-between; font: 10.5px var(--mono); color: var(--dim); letter-spacing: .04em; }
.live-card .top b { color: var(--sage); font-weight: 500; }
.live-card .w { display: grid; grid-template-columns: 46px 1fr; gap: 10px; align-items: center; }
.live-card .w .c { width: 46px; height: 64px; border-radius: 3px; overflow: hidden; }
.live-card .w b { display: block; font-weight: 500; font-size: 14px; line-height: 1.15; }
.live-card .w small { font: 11px var(--mono); color: var(--mute); }
.live-card .prog { height: 3px; background: var(--line2); border-radius: 2px; margin-top: 6px; }
.live-card .prog i { display: block; height: 100%; background: var(--sage); border-radius: 2px; }
.live-card ul { list-style: none; margin: 4px 0 0; padding: 6px 0 0; border-top: 1px dashed var(--line2); }
.live-card li { display: flex; justify-content: space-between; gap: 8px; color: var(--ink2); padding: 1px 0; }
.live-card li span:last-child { font: 11px var(--mono); color: var(--acc); }
.live-card li small { color: var(--dim); font: 10px var(--mono); margin-left: 4px; }
.live-card a.out { font: 11px var(--mono); color: var(--mute); margin-top: auto; text-decoration: none; }
.live-card a.out:hover { color: var(--ink); }
/* books: spines on the shelf */
.books { display: flex; align-items: flex-end; gap: 3px; height: 214px; padding-left: 4px; }
.spine-b { all: unset; box-sizing: border-box; cursor: pointer; width: var(--w); height: var(--h); background: var(--c); border-radius: 2px 2px 0 0; position: relative; color: rgba(255,255,255,.92); writing-mode: vertical-rl; transform: rotate(180deg); display: flex; align-items: center; justify-content: space-between; padding: 10px 0; font: 500 11px var(--serif); box-shadow: inset -3px 0 0 rgba(0,0,0,.12), inset 3px 0 0 rgba(255,255,255,.08); transition: transform .22s var(--ease); }
.spine-b:hover, .spine-b:focus-visible { transform: rotate(180deg) translateY(10px); }
.spine-b .st { font: 9px var(--mono); opacity: .8; }
.spine-b.light { color: #2a1a1f; }
.lean { width: 128px; height: 184px; margin: 0 22px 0 26px; position: relative; transform: rotate(-8deg); transform-origin: bottom left; }
.lean .cv { position: absolute; inset: 0; background: var(--c); border-radius: 2px 4px 4px 2px; box-shadow: 0 8px 18px rgba(0,0,0,.45); padding: 16px 12px; color: #f1e6d6; overflow: hidden; }
.lean .cv::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 7px; background: rgba(0,0,0,.18); }
.lean .cv b { display: block; font: italic 400 21px/1 var(--serif); }
.lean .cv small { display: block; font: 10px var(--mono); margin-top: 6px; opacity: .8; }
.lean .cv svg { position: absolute; left: 10px; right: 10px; bottom: 14px; height: 70px; width: calc(100% - 20px); opacity: .7; }
.lean .bm { position: absolute; top: -14px; right: 22px; width: 14px; height: 52px; background: var(--acc); clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 82%, 0 100%); }
.lean .tag { position: absolute; left: 6px; top: -34px; transform: rotate(4deg); font: 600 19px/1 var(--hand); color: var(--acc); white-space: nowrap; }
.booksinfo { margin-left: 30px; width: 250px; align-self: flex-start; }
/* made by me */
.o-polaroid { width: 168px; background: #f4ece0; padding: 9px 9px 30px; box-shadow: 0 8px 18px rgba(0,0,0,.45); transform: rotate(-3deg); position: relative; }
.o-polaroid .im { height: 132px; overflow: hidden; }
.o-polaroid .cp { position: absolute; left: 0; right: 0; bottom: 5px; text-align: center; font: 600 18px var(--hand); color: var(--bg); }
.it:hover .o-polaroid { transform: rotate(-1deg) translateY(-7px); }
.o-frame { width: 136px; height: 172px; padding: 11px; background: #5a3d32; box-shadow: inset 0 0 0 2px #6e4b3e, 0 8px 18px rgba(0,0,0,.45); }
.o-frame .mat { width: 100%; height: 100%; background: #f4ece0; padding: 9px; }
.o-frame .mat > div { width: 100%; height: 100%; overflow: hidden; }
.o-taped { width: 170px; height: 146px; background: #f6efe2; padding: 6px; box-shadow: 0 6px 14px rgba(0,0,0,.4); transform: rotate(2.5deg); position: relative; }
.o-taped::before, .o-taped::after { content: ""; position: absolute; top: -8px; width: 44px; height: 16px; background: rgba(232,176,74,.55); }
.o-taped::before { left: 12px; transform: rotate(-8deg); } .o-taped::after { right: 12px; transform: rotate(7deg); }
.o-taped > div { width: 100%; height: 100%; overflow: hidden; }
.it:hover .o-taped { transform: rotate(0) translateY(-7px); }
/* writing objects */
.o-paper { width: 150px; height: 194px; background: #f4ece0; color: var(--bg); padding: 18px 16px; box-shadow: 0 8px 18px rgba(0,0,0,.4), 4px 4px 0 -1px #e2d4bf, 4px 4px 0 0 rgba(0,0,0,.15); transform: rotate(-1.5deg); position: relative; text-align: left; }
.o-paper .k { font: 9px var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--rose); }
.o-paper b { display: block; font: 500 17px/1.12 var(--serif); margin: 6px 0 10px; }
.o-paper .lines { height: 70px; background: repeating-linear-gradient(transparent 0 9px, #cdbfa9 9px 10px); opacity: .8; }
.o-paper .pc { position: absolute; top: -10px; right: 22px; width: 12px; height: 38px; border: 2px solid #9aa0a6; border-radius: 6px; }
.o-note { width: 150px; height: 186px; background: #3e5a47; border-radius: 4px 8px 8px 4px; padding: 20px 16px; color: #f1e6d6; box-shadow: 0 8px 18px rgba(0,0,0,.45); position: relative; text-align: left; }
.o-note::after { content: ""; position: absolute; top: 0; bottom: 0; right: 26px; width: 7px; background: #2a1a1f; opacity: .55; }
.o-note .k { font: 9px var(--mono); letter-spacing: .1em; text-transform: uppercase; opacity: .7; }
.o-note b { display: block; font: italic 400 19px/1.1 var(--serif); margin-top: 8px; padding-right: 20px; }
.o-card { width: 176px; height: 118px; background: #fbf6ec; color: var(--bg); padding: 12px 14px; box-shadow: 0 6px 14px rgba(0,0,0,.4); transform: rotate(2deg); text-align: left; border-top: 18px solid #fbf6ec; background-image: linear-gradient(var(--rose), var(--rose)), repeating-linear-gradient(transparent 0 15px, #c9d6dd 15px 16px); background-size: 100% 1px, 100% 100%; background-repeat: no-repeat, repeat; background-position: 0 0, 0 6px; }
.o-card .k { font: 9px var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--rose); }
.o-card b { display: block; font: 600 22px/1.05 var(--hand); margin-top: 4px; }
.it.more { margin-left: auto; }
.stack { width: 110px; height: 150px; position: relative; }
.stack span { position: absolute; inset: 0; border-radius: 3px; transform: translate(calc(var(--k) * 9px), calc(var(--k) * -9px)) rotate(calc(var(--k) * 3deg - 3deg)); box-shadow: 0 6px 14px rgba(0,0,0,.4); opacity: .55; transition: transform .25s var(--ease), opacity .2s; }
.it.more:hover .stack span { opacity: .85; transform: translate(calc(var(--k) * 14px), calc(var(--k) * -12px)) rotate(calc(var(--k) * 5deg - 5deg)); }
.stack b { position: absolute; left: 0; right: 0; top: 50%; text-align: center; font: 600 34px var(--hand); color: var(--ink); transform: translate(9px, -24px); z-index: 2; text-shadow: 0 1px 6px rgba(0,0,0,.5); }
.shelf .more-hint { align-self: center; margin-left: auto; padding-left: 10px; }
.ghost { width: 120px; height: 160px; border: 1.5px dashed var(--line2); border-radius: 6px; display: grid; place-items: center; text-align: center; color: var(--dim); font: 500 20px/1.1 var(--hand); padding: 10px; transition: border-color .15s, color .15s; }
.it:hover .ghost { border-color: var(--acc); color: var(--acc); }
/* footer */
.zine { margin: 10px 0 0; border-top: 1px solid var(--line); padding: 30px 0; display: grid; grid-template-columns: 220px 1fr auto; gap: 32px; align-items: center; }
.zine .hd { font: 600 20px var(--hand); color: var(--dim); }
.zine p { margin: 0; color: var(--ink2); font-weight: 300; }
.zine p b { font-weight: 500; font-style: italic; }
.zine a { font: 13px var(--mono); color: var(--acc); }
footer { border-top: 1px solid var(--line); padding: 22px 0 40px; display: flex; justify-content: space-between; gap: 24px; font: 12px/1.6 var(--mono); color: var(--dim); }
footer a { color: var(--ink2); text-decoration-color: var(--line2); text-underline-offset: 3px; }
/* overlay */
.scrim { position: fixed; inset: 0; z-index: 70; background: rgba(18,10,13,.72); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); opacity: 0; pointer-events: none; transition: opacity .22s; }
.scrim.on { opacity: 1; pointer-events: auto; }
.modal { position: fixed; z-index: 80; left: 50%; top: 50%; width: min(1040px, calc(100vw - 48px)); max-height: calc(100vh - 64px); transform: translate(-50%, -48%) scale(.98); opacity: 0; visibility: hidden; transition: transform .24s var(--ease), opacity .2s, visibility .24s; background: var(--paper); color: var(--bg); border-radius: 14px; box-shadow: 0 30px 80px rgba(0,0,0,.5); display: flex; flex-direction: column; overflow: hidden; }
.modal.on { transform: translate(-50%, -50%); opacity: 1; visibility: visible; }
.modal .mtop { display: flex; align-items: center; gap: 10px; padding: 11px 18px; border-bottom: 1px solid #ddcdb6; font: 12px var(--mono); color: #7d6a5c; }
.modal .mtop .path { margin-right: auto; }
.modal .mtop button { background: none; border: 1px solid #d3c1a8; border-radius: 6px; padding: 3px 9px; cursor: pointer; font: 12px var(--mono); color: #5a4637; }
.modal .mtop button:hover { background: #e9dcc8; }
.mbody { overflow-y: auto; padding: 28px 34px 36px; }
.mbody .ph { text-decoration-color: rgba(42,26,31,.35); }
.mbody h2 { font-weight: 400; font-size: 38px; letter-spacing: -.02em; line-height: 1.05; margin: 0; }
.mbody .sub { font: 12px var(--mono); color: #7d6a5c; margin-top: 8px; }
.mbody .said { font: 500 27px/1.15 var(--hand); color: #8f3f3a; margin: 18px 0 6px; }
.mbody p { font-size: 17px; line-height: 1.6; margin: 12px 0 0; max-width: 36em; color: #3d2a2f; }
.mbody .src { display: inline-block; margin-top: 18px; font: 12px var(--mono); color: #5a4637; }
.two { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: 36px; align-items: start; }
.reader { background: #2a1a1f; border-radius: 10px; padding: 18px; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.reader .spread { display: flex; gap: 10px; justify-content: center; }
.reader .pgbig { width: 220px; height: 293px; box-shadow: 0 10px 24px rgba(0,0,0,.5); background: #fff; }
.reader .ctl { display: flex; align-items: center; gap: 14px; color: #e3d4c1; font: 12px var(--mono); }
.reader .ctl button { background: none; border: 1px solid #5c4148; color: #f1e6d6; border-radius: 6px; padding: 4px 10px; cursor: pointer; font: 12px var(--mono); }
.reader .ctl button:hover { background: #3a252c; }
.reader .ctl button:disabled { opacity: .35; cursor: default; }
.reader .note { font: 10px var(--mono); color: #8a7768; }
.bigsleeve { width: 300px; height: 300px; position: relative; margin: 0 auto; }
.bigsleeve .slv { position: absolute; inset: 0; box-shadow: 0 14px 30px rgba(0,0,0,.35); overflow: hidden; border-radius: 3px; }
.bigsleeve .rec { position: absolute; inset: 10px; border-radius: 50%; background: radial-gradient(circle, var(--c) 0 17%, #141012 17.5% 19%, #1b1517 19% 100%); transition: transform .35s var(--ease); }
.bigsleeve.playing .rec { transform: translateX(110px); animation: spin 2.4s linear infinite; }
.bplayer { margin-top: 22px; display: flex; align-items: center; gap: 12px; }
.bplayer .pp { width: 44px; height: 44px; border-radius: 50%; border: 0; background: #2a1a1f; color: var(--paper); cursor: pointer; font-size: 13px; }
.bplayer .bar2 { flex: 1; height: 4px; background: #d9c8b0; border-radius: 2px; position: relative; cursor: pointer; }
.bplayer .bar2 i { position: absolute; left: 0; top: 0; bottom: 0; background: #c9706b; border-radius: 2px; width: 0; }
.bplayer .tm { font: 12px var(--mono); color: #7d6a5c; min-width: 84px; text-align: right; }
.video { aspect-ratio: 16/9; background: repeating-linear-gradient(135deg, #3a252c 0 10px, #33212a 10px 20px); border-radius: 10px; position: relative; display: grid; place-items: center; color: #e3d4c1; font: 11px var(--mono); }
.video button { width: 64px; height: 64px; border-radius: 50%; border: 0; background: var(--acc); color: #2a1a1f; font-size: 18px; cursor: pointer; }
.video span { position: absolute; left: 12px; bottom: 10px; }
.setlist { list-style: none; padding: 0; margin: 14px 0 0; border-top: 1px solid #ddcdb6; }
.setlist li { display: flex; justify-content: space-between; padding: 7px 4px; border-bottom: 1px solid #e6d8c4; cursor: pointer; font-size: 15px; }
.setlist li:hover { background: #ebdfcc; }
.setlist li.on { color: #8f3f3a; font-weight: 500; }
.setlist li span:last-child { font: 12px var(--mono); color: #7d6a5c; }
.pull { font: italic 300 30px/1.2 var(--serif); color: #2a1a1f; margin: 20px 0 6px; padding-left: 18px; border-left: 3px solid #e8b04a; max-width: 22em; }
.facts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.facts > span { font: 11.5px var(--mono); border: 1px solid #d3c1a8; border-radius: 99px; padding: 3px 10px; color: #5a4637; }
.bigart { border-radius: 6px; overflow: hidden; box-shadow: 0 14px 30px rgba(0,0,0,.25); }
.bigart svg { display: block; width: 100%; height: auto; }
.essay { max-width: 640px; margin: 0 auto; padding: 10px 0 20px; }
.essay .k { font: 11px var(--mono); letter-spacing: .1em; text-transform: uppercase; color: #c9706b; }
.essay h2 { font-size: 46px; font-weight: 300; font-style: italic; margin-top: 10px; }
.essay p { font-size: 18.5px; line-height: 1.7; }
.essay p:first-of-type::first-letter { font-size: 3.2em; float: left; line-height: .85; padding: 6px 8px 0 0; font-weight: 500; color: #8f3f3a; }
.essay .end { margin-top: 26px; font: 600 24px var(--hand); color: #8f3f3a; }
.archive h2 { font-style: italic; font-weight: 400; }
.archive .yr { font: 600 24px var(--hand); color: #8f3f3a; margin: 26px 0 10px; display: flex; align-items: center; gap: 12px; }
.archive .yr::after { content: ""; flex: 1; border-top: 1px dashed #d3c1a8; }
.agrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 18px 16px; }
.ai { all: unset; box-sizing: border-box; cursor: pointer; display: block; }
.ai .th { aspect-ratio: 1; border-radius: 4px; overflow: hidden; box-shadow: 0 6px 14px rgba(0,0,0,.18); transition: transform .2s var(--ease); background: #ddd; position: relative; }
.ai:hover .th, .ai:focus-visible .th { transform: translateY(-4px) rotate(-1deg); }
.ai .th svg { display: block; width: 100%; height: 100%; }
.ai .th .lbl { position: absolute; left: 8px; right: 8px; bottom: 8px; font: italic 500 14px/1.1 var(--serif); color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.5); }
.ai b { display: block; font-weight: 500; font-size: 14.5px; line-height: 1.25; margin-top: 8px; }
.ai small { font: 11px var(--mono); color: #7d6a5c; }
.archive .scale { margin-top: 26px; font: 12px var(--mono); color: #7d6a5c; border-top: 1px solid #ddcdb6; padding-top: 12px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
@media (max-width: 980px) {
.intro { grid-template-columns: 1fr; gap: 24px; }
.shelf { grid-template-columns: 1fr; gap: 14px; }
.row-objs { overflow-x: auto; padding-bottom: 8px; scrollbar-width: thin; }
.row-objs::before { width: 1400px; right: auto; }
.zine { grid-template-columns: 1fr; gap: 10px; }
.two { grid-template-columns: 1fr; }
.bar .meta { display: none; }
}
@media (max-width: 620px) {
:root { --edge: 24px; }
.edge { font-size: 9px; }
.wrap, .bar .in { padding: 0 18px; }
.snav { display: none; }
.intro h1 { font-size: 36px; }
.lately .row { grid-template-columns: 1fr; }
.mbody { padding: 20px 18px 28px; }
.reader .pgbig { width: 140px; height: 187px; }
.reader .spread .pgbig:nth-child(2) { display: none; }
.bigsleeve { width: 220px; height: 220px; }
.modal { width: calc(100vw - 20px); max-height: calc(100vh - 20px); }
footer { flex-direction: column; }
}
</style>
</head>
<body>
<div class="mk" id="mk"><button onclick="document.getElementById('mk').remove()" aria-label="close">×</button>
<b>personal-r2-a · Shelves of things I love</b>One shelf per interest, each holding its 1–4 latest things as fitting objects (fanned comic pages, a cartridge, a sleeve that plays, posters, spines pulled from Hardcover). "New lately" up top; click anything to open it, "see all" opens the shelf's archive. P2's tactile desk, rebuilt as rows so a new interest is just one more shelf.</div>
<a class="edge" href="tech-r3-c.html" title="cross over to the work side"><span>work →</span></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><i>/ off the clock</i></div>
<nav class="snav" id="snav" aria-label="shelves"></nav>
<div class="meta" id="meta"></div>
</div>
</header>
<main>
<div class="wrap">
<section class="intro">
<div>
<span class="lab">02 · off the clock</span>
<h1>Things I love, and a few things I think.</h1>
<p>Comics that made me cry, games I backed years ago, songs I can't stop playing, and the odd drawing. A shelf for each. Pick anything up.</p>
</div>
<div class="lately" aria-label="new lately">
<h3><span>New lately · across all shelves</span><em>fresh off the pile</em></h3>
<div class="row" id="lately"></div>
</div>
</section>
<div id="shelves"></div>
<section class="zine">
<div class="hd">every season</div>
<p>Once a season the shelves get bound into a little zine: <b>Autumn 2026</b> has Now Recharging, the pumpkin, Lanternfall and an essay about crying at comics.</p>
<a href="#" onclick="return false"><span class="ph" title="placeholder: the P5 zine as a seasonal roundup">read the autumn roundup →</span></a>
</section>
<footer>
<span>Placeholders (dotted underline): every game, film, song, book, essay, drawing, commission, the band and its gig, and all Trakt/Hardcover data. Real: Now Recharging, Junkwraith, Cupid 13, the villainess trope and the pumpkin (from Bluesky). Pictures are drawn stand-ins.</span>
<span style="white-space:nowrap"><a href="https://bsky.app/profile/stereotypicalcat.bsky.social">Bluesky</a> · <a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a> · <a href="tech-r3-c.html">work</a></span>
</footer>
</div>
</main>
<div class="scrim" id="scrim"></div>
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="mpath">
<div class="mtop"><span class="path" id="mpath"></span><button id="mclose">esc · close</button></div>
<div class="mbody" id="mbody"></div>
</div>
<script>
/* ===== shared content + drawn placeholders (same data in r2-a and r2-b) ===== */
const TODAY = new Date('2026-11-09');
const PH = s => '<span class="ph" title="placeholder">' + s + '</span>';
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fdate(d) { const x = new Date(d); return x.getDate() + ' ' + MONTHS[x.getMonth()] + ' ' + x.getFullYear(); }
function fshort(d) { const x = new Date(d); return x.getDate() + ' ' + MONTHS[x.getMonth()]; }
function ago(d) {
const days = Math.round((TODAY - new Date(d)) / 864e5);
if (days < 1) return 'today'; if (days === 1) return 'yesterday';
if (days < 14) return days + ' days ago'; if (days < 60) return Math.round(days / 7) + ' weeks ago';
return Math.round(days / 30) + ' months ago';
}
function mmss(s) { s = Math.max(0, Math.floor(s)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }
const SHELVES = [
{ id: 'comics', name: 'Comics', hand: 'pages that got me', total: 11 },
{ id: 'games', name: 'Games', hand: 'played, loved, backed', total: 7 },
{ id: 'music', name: 'Music', hand: 'on repeat, plus the band', total: 9 },
{ id: 'film', name: 'Film & TV', hand: 'what I watch', total: 6, live: 'Trakt' },
{ id: 'books', name: 'Books', hand: 'what I read', total: 23, live: 'Hardcover' },
{ id: 'made', name: 'Made by me', hand: 'drawings, commissions, a pumpkin', total: 5 },
{ id: 'writing', name: 'Writing', hand: 'slower thoughts', total: 4 },
];
/* art specs: bg / fg / acc + a motif the cover drawer understands */
const ITEMS = [
{ id: 'mitski', shelf: 'music', kind: 'song', date: '2026-11-04', ph: 1,
title: 'My Love Mine All Mine', by: 'Mitski', len: 138,
line: "been on repeat all week. it's so small and so tender 🥹",
art: { bg: '#c9706b', fg: '#2a1a1f', acc: '#f1e6d6', motif: 'moon' },
body: ["I keep putting this on at night when the flat is quiet. It's barely two minutes and it feels like it's being sung to the moon specifically, which, fair.", "The line about the only thing you get to keep is the love you gave away has been living in my head rent free. Press play, it's short, I promise."] },
{ id: 'pumpkin', shelf: 'made', kind: 'photo', date: '2026-10-31', ph: 0,
title: 'This year’s pumpkin', line: "carved a pumpkin this year! 🎃 he's a little lopsided but he's mine",
art: { draw: 'pumpkin' }, label: 'photo: the pumpkin, lit, on the windowsill',
body: ["First pumpkin in years. The left eye went a bit wrong so I decided he's winking. Took about an hour and most of a spoon.", "He lasted four days before going soft. Rest well, little guy."] },
{ id: 'nowrecharging', shelf: 'comics', kind: 'comic', date: '2026-10-22', ph: 0,
title: 'Now Recharging', sub: 'webcomic · nowrecharging.com', url: 'https://nowrecharging.com',
line: 'i cried so much in this chapter – death has always been something that hits me emotionally hard',
pal: 'nr', pages: 4,
body: ["I've been following this for a while and this chapter just flattened me. It handles death so gently, nobody gets a big speech, it just sits with you.", "Go read it from the start, it's free on the site. Then come back and cry with me."] },
{ id: 'lanternfall', shelf: 'games', kind: 'kickstarter', date: '2026-10-12', ph: 1,
title: 'Lanternfall', sub: 'Kickstarter · backed Mar 2024 · Switch', hours: 14,
line: 'backed it two and a half years ago, it finally arrived and I played 14 hours in a week',
quote: 'It feels like someone made a game just for the version of me that stays up too late.',
art: { bg: '#2f3a3a', fg: '#e8b04a', acc: '#f1e6d6', motif: 'lantern' },
body: ["I backed this at the 180 DKK tier because the trailer had a fox carrying a lantern and that was enough. Then the delays. Then more delays. I'd honestly forgotten.", "It's a small cozy-ish metroidvania where light is your map: you hang lanterns and the world remembers where you've been. The music is gorgeous. The last boss made me put the controller down and walk around the flat.", "My name is in the credits under 'lantern keepers', which I will be telling everyone about."] },
{ id: 'crying', shelf: 'writing', kind: 'essay', date: '2026-10-03', ph: 1,
title: 'Why I let stories make me cry', mins: 6, tag: 'essay',
line: 'On comics, death, and why a sad chapter can be the best part of my week.',
body: ["Death has always hit me hard. Not in an abstract way: in the way where a character I've known for forty pages dies and I have to stop reading and look at the wall for a bit.", "For a long time I thought that was something to grow out of. It isn't. It's the point. A story that can make me grieve someone who never existed is a story that's practising something with me, quietly, so that the real thing is a little less foreign when it comes.", "Comics are especially good at this because they control time. A whole page can be one panel of silence. Now Recharging does this better than almost anything I've read lately.", "So: I let them. I read the sad chapter on a Sunday, with tea, and I let it land."] },
{ id: 'junkwraith', shelf: 'comics', kind: 'comic', date: '2026-09-18', ph: 0,
title: 'Junkwraith', sub: 'new comic',
line: 'a new comic with an interesting art style… feels like a coming-of-age story with a unique world',
pal: 'jw', pages: 3,
body: ["Picked this up on a whim and the art style grabbed me straight away: all scrap metal and soft light.", "It's early days but it already feels like a coming-of-age story set somewhere that's never been drawn before. Keeping an eye on this one."] },
{ id: 'caferetro', shelf: 'music', kind: 'tape', date: '2026-09-07', ph: 1,
title: 'Live at Café Retro, side A', by: 'Lav Sol (our band)', len: 1209,
line: 'handcam from the bar. eleven people and a dog, best gig we’ve had',
set: [['Grå morgen', 252], ['Kitchen light', 208], ['Slow ferry', 301], ['Halvvejs', 196], ['Paper boats', 252]],
body: ["A friend filmed this on her phone from the bar, so you get the espresso machine in Halvvejs for free.", "The band is a Thursday-night thing: four of us, a rehearsal room that smells of old carpet, and a gig every couple of months."] },
{ id: 'perfectdays', shelf: 'film', kind: 'film', date: '2026-08-30', ph: 1,
title: 'Perfect Days', sub: 'Wim Wenders · 2023', rating: 10,
line: 'a man cleans toilets in Tokyo and it’s the most hopeful film I’ve seen in years',
art: { bg: '#d9c39a', fg: '#2f4b46', acc: '#c9706b', motif: 'trees' },
body: ["Nothing happens and everything happens. He wakes up, waters his little trees, listens to cassettes in the van, takes a photo of the light through the leaves.", "I came out of it wanting to own fewer things and notice more. That feeling lasted about three days, which is longer than most films manage."] },
{ id: 'cupid13', shelf: 'comics', kind: 'comic', date: '2026-08-14', ph: 0,
title: 'Cupid 13', sub: 'MangaPlus one-shot',
line: 'a one-shot about a forsaken sniper who regains faith. so much in so few pages',
pal: 'c13', pages: 3,
body: ["A one-shot on MangaPlus about a sniper everyone gave up on, and who gave up on himself, finding his faith again.", "One-shots that land in under sixty pages are a magic trick to me. This one does."] },
{ id: 'knightcat', shelf: 'made', kind: 'commission', date: '2026-07-21', ph: 1,
title: 'Pixel as a tiny knight', by: 'commission by @placeholder.artist',
line: 'commissioned a drawing of my cat as a knight and I will never recover',
art: { draw: 'knightcat' },
body: ["I commissioned this from an artist I've followed for ages. The brief was just 'my cat, but brave'. She nailed it.", "It's framed above my desk now, guarding me from bugs."] },
{ id: 'outerwilds', shelf: 'games', kind: 'game', date: '2026-07-02', ph: 1,
title: 'Outer Wilds', sub: 'Mobius Digital · 2019 · PC', hours: 31,
line: 'the best museum of a universe I’ve ever wandered through',
quote: 'The only progression is what you know. I’d give anything to play it for the first time again.',
art: { bg: '#1f2a3a', fg: '#e8b04a', acc: '#c9706b', motif: 'planet' },
body: ["You have 22 minutes before the sun goes supernova, and then you wake up again by the campfire. Nothing in your inventory changes. Only you do.", "I won't say more because the whole game is the not-knowing. Play it with a notebook. Don't look anything up."] },
{ id: 'villainess', shelf: 'comics', kind: 'trope', date: '2026-06-20', ph: 0,
title: 'The villainess, reborn', sub: 'a trope I can’t quit',
line: 'villainess reborn as a good person, but everyone around her is psychopathic and possessive. I am weak for this',
pal: 'vl', pages: 3,
body: ["She wakes up as the villain of a novel she's read, decides to just be nice this time, and somehow everyone around her becomes deeply, alarmingly obsessed with her.", "I know. I KNOW. I've read too many of these. A few favourites are in the 'see all' shelf, titles marked as placeholders until I dig up the real list."] },
{ id: 'frog', shelf: 'made', kind: 'drawing', date: '2026-06-08', ph: 1,
title: 'Frog with umbrella', line: 'a little doodle from a rainy lunch break 🐸',
art: { draw: 'frog' },
body: ["Drawn on the back of a receipt in about four minutes. He's not keeping the rain off, he's just enjoying having an umbrella."] },
{ id: 'lifesofar', shelf: 'writing', kind: 'notebook', date: '2026-04-18', ph: 1,
title: 'Life so far, part one', mins: 11, tag: 'life',
line: 'Growing up, the rowing machine in the basement, and the first website I ever made.',
body: ["I was the kid who took the family PC apart to see where the internet lived. It was not in there.", "Part one is the early years: Lego, a tribute page to Norman Borlaug for freeCodeCamp that I am still weirdly proud of, and the summer I learned that soldering irons are hot on both ends.", "Part two, whenever I write it, is university, the band, and moving out."] },
{ id: 'hiatus', shelf: 'music', kind: 'song', date: '2026-04-02', ph: 1,
title: 'Building a Ladder', by: 'Hiatus Kaiyote', len: 245,
line: 'the chord at 2:40. that’s it, that’s the post',
art: { bg: '#3e5a47', fg: '#e8b04a', acc: '#f1e6d6', motif: 'stairs' },
body: ["I've tried to learn this on guitar three times. I can get to the chorus. The chord at 2:40 is beyond me and possibly beyond physics."] },
{ id: 'arcane', shelf: 'film', kind: 'film', date: '2026-03-11', ph: 1,
title: 'Arcane', sub: 'Season 2 · Netflix', rating: 9,
line: 'every frame looks like a painting someone was scared to finish',
art: { bg: '#2c2440', fg: '#e8b04a', acc: '#c9706b', motif: 'city' },
body: ["I don't even play League any more but this got me. The animation is unreal, and the sisters' story is the heart of it."] },
{ id: 'meaning', shelf: 'writing', kind: 'card', date: '2026-02-14', ph: 1,
title: 'What I think the point is (draft 3)', mins: 4, tag: 'meaning of life',
line: 'Short answer: other people, and making small things for them.',
body: ["Draft three, because drafts one and two were too sure of themselves.", "What I keep coming back to: the point is other people, and making small things for them. A pumpkin, a song shared at the right moment, a bug fixed before anyone notices.", "I'll probably disagree with this by draft four. That's also the point."] },
{ id: 'celeste', shelf: 'games', kind: 'game', date: '2026-01-19', ph: 1,
title: 'Celeste', sub: 'Maddy Makes Games · 2018', hours: 22,
line: 'replayed it in January. still the kindest hard game',
quote: 'It’s a game about climbing a mountain and also about being nice to yourself while you do.',
art: { bg: '#3a2733', fg: '#f1e6d6', acc: '#c9706b', motif: 'mountain' },
body: ["Replayed it over the holidays and got all the strawberries this time. The B-sides still make me sweat."] },
];
/* pulled from the services: shaped like their APIs, all placeholder */
const TRAKT = {
synced: '2 h ago', user: 'stereotypicalcat',
watching: { show: 'Frieren: Beyond Journey’s End', ep: 'S2 · E07', title: 'A Small Detour', progress: 0.58, art: { bg: '#3a4a5a', fg: '#f1e6d6', acc: '#e8b04a', motif: 'sparkle' } },
ratings: [ ['Perfect Days', 'film', 10, '30 Aug'], ['The Bear', 'S4', 7, '12 Aug'], ['Dune: Part Two', 'film', 8, '2 Aug'], ['Arcane', 'S2', 9, '11 Mar'] ],
stats: '41 films and 12 shows this year'
};
const HARDCOVER = {
synced: '5 h ago', user: 'stereotypicalcat',
reading: { title: 'Piranesi', by: 'Susanna Clarke', pct: 62, page: 152, of: 245, c: '#4f6a75' },
read: [
{ title: 'Tomorrow, and Tomorrow, and Tomorrow', by: 'Gabrielle Zevin', stars: 5, c: '#c9706b', h: 188, w: 40, d: 'Oct' },
{ title: 'Project Hail Mary', by: 'Andy Weir', stars: 4.5, c: '#e8b04a', h: 196, w: 42, d: 'Sep' },
{ title: 'The House in the Cerulean Sea', by: 'TJ Klune', stars: 4, c: '#6b8f9a', h: 172, w: 34, d: 'Aug' },
{ title: 'Klara and the Sun', by: 'Kazuo Ishiguro', stars: 4, c: '#d9c39a', h: 180, w: 30, d: 'Jul' },
{ title: 'Spinning Silver', by: 'Naomi Novik', stars: 3.5, c: '#5d6e4f', h: 200, w: 46, d: 'Jun' },
],
stats: '19 of 30 books toward the 2026 goal'
};
/* older things for the "see all" views (all placeholder) */
const ARCHIVE = {
comics: [['Lore of the Lighthouse', '2025'], ['The Duchess Will Not Be Moved', '2025'], ['Beneath the Rainline', '2025'], ['A Sky Full of Moths', '2025'], ['Her Majesty’s Very Polite Knights', '2024'], ['Small Hours', '2024'], ['Return of the Iron Countess', '2024']],
games: [['Hollow Knight', '2025'], ['Tunic', '2025'], ['A Short Hike', '2024'], ['Stardew Valley', '2024']],
music: [['Phoebe Bridgers, Motion Sickness', '2025'], ['Lav Sol, basement gig', '2025'], ['Nujabes, Aruarian Dance', '2025'], ['Japanese Breakfast, Be Sweet', '2024'], ['Lav Sol, kitchen demos', '2024'], ['Bon Iver, Holocene', '2024']],
film: [['Spirited Away', '2025'], ['Past Lives', '2025'], ['Blue Eye Samurai', '2024'], ['Paddington 2', '2024']],
books: [['The Left Hand of Darkness', '2025'], ['Never Let Me Go', '2025'], ['A Psalm for the Wild-Built', '2025'], ['The Remains of the Day', '2024'], ['Babel', '2024'], ['Legends & Lattes', '2024']],
made: [['Sketchbook: trains', '2025'], ['Birthday card for mum', '2024']],
writing: [['On being bad at guitar, on purpose', '2025']],
};
const ARC_COLORS = ['#7a4f4a', '#4f6a75', '#5d6e4f', '#8a6a3a', '#6b4a63', '#3e5a47', '#9a5a4a', '#4a4f6b'];
/* ---------- drawn placeholders ---------- */
function coverSVG(a, w, h, extra) {
w = w || 100; h = h || 100;
const { bg, fg, acc } = a; let m = '';
const cx = w / 2;
switch (a.motif) {
case 'moon': m = `<circle cx="${w * .62}" cy="${h * .38}" r="${w * .2}" fill="${acc}"/><circle cx="${w * .7}" cy="${h * .33}" r="${w * .18}" fill="${bg}"/><path d="M0 ${h * .78} Q ${w * .3} ${h * .7} ${w * .55} ${h * .8} T ${w} ${h * .74} V ${h} H0Z" fill="${fg}" opacity=".85"/><circle cx="${w * .22}" cy="${h * .2}" r="1.2" fill="${acc}"/><circle cx="${w * .34}" cy="${h * .44}" r="1" fill="${acc}"/><circle cx="${w * .85}" cy="${h * .6}" r="1" fill="${acc}"/>`; break;
case 'stairs': for (let i = 0; i < 6; i++) m += `<rect x="${w * .1 + i * w * .13}" y="${h * .82 - i * h * .11}" width="${w * .14}" height="${h}" fill="${i % 2 ? fg : acc}" opacity="${.55 + i * .07}"/>`; m += `<circle cx="${w * .8}" cy="${h * .2}" r="${w * .08}" fill="${fg}"/>`; break;
case 'lantern': m = `<rect x="0" y="${h * .72}" width="${w}" height="${h * .28}" fill="#00000033"/><path d="M${w * .2} ${h * .72} q ${w * .1} -${h * .25} ${w * .25} -${h * .2}" stroke="${acc}" stroke-width="1.2" fill="none" opacity=".6"/><line x1="${cx}" y1="0" x2="${cx}" y2="${h * .3}" stroke="${acc}" stroke-width="1"/><circle cx="${cx}" cy="${h * .45}" r="${w * .28}" fill="${fg}" opacity=".12"/><circle cx="${cx}" cy="${h * .45}" r="${w * .17}" fill="${fg}" opacity=".22"/><rect x="${cx - w * .08}" y="${h * .32}" width="${w * .16}" height="${h * .24}" rx="3" fill="${fg}"/><rect x="${cx - w * .1}" y="${h * .3}" width="${w * .2}" height="${h * .03}" fill="${acc}"/><path d="M${w * .66} ${h * .72} l ${w * .06} -${h * .08} l ${w * .03} ${h * .04} l ${w * .08} -${h * .02} l -${w * .02} ${h * .06}z" fill="${acc}"/>`; break;
case 'planet': m = `<circle cx="${w * .42}" cy="${h * .5}" r="${w * .22}" fill="${acc}"/><ellipse cx="${w * .42}" cy="${h * .5}" rx="${w * .36}" ry="${h * .07}" fill="none" stroke="${fg}" stroke-width="1.6" transform="rotate(-14 ${w * .42} ${h * .5})"/><circle cx="${w * .8}" cy="${h * .2}" r="${w * .06}" fill="${fg}"/>` + [[.15, .2], [.7, .75], [.88, .5], [.2, .85], [.55, .12]].map(p => `<circle cx="${w * p[0]}" cy="${h * p[1]}" r="1" fill="${fg}"/>`).join(''); break;
case 'mountain': m = `<path d="M0 ${h} L ${w * .38} ${h * .3} L ${w * .52} ${h * .5} L ${w * .7} ${h * .22} L ${w} ${h * .7} V ${h}Z" fill="${acc}" opacity=".9"/><path d="M${w * .7} ${h * .22} l -${w * .07} ${h * .12} l ${w * .05} -${h * .02} l ${w * .03} ${h * .05} l ${w * .04} -${h * .06}z" fill="${fg}"/><circle cx="${w * .88}" cy="${h * .16}" r="${w * .05}" fill="${fg}"/>`; break;
case 'trees': m = `<rect x="0" y="${h * .66}" width="${w}" height="${h * .34}" fill="${fg}" opacity=".18"/>` + [.18, .36, .6, .8].map((x, i) => `<circle cx="${w * x}" cy="${h * (.42 + (i % 2) * .08)}" r="${w * (.13 - i * .01)}" fill="${fg}" opacity="${.5 + i * .12}"/><rect x="${w * x - 1}" y="${h * (.5 + (i % 2) * .08)}" width="2" height="${h * .2}" fill="${fg}"/>`).join('') + `<circle cx="${w * .82}" cy="${h * .16}" r="${w * .06}" fill="${acc}"/>`; break;
case 'city': for (let i = 0; i < 8; i++) { const bh = h * (.25 + ((i * 37) % 40) / 100); m += `<rect x="${i * w / 8}" y="${h - bh}" width="${w / 8 - 1}" height="${bh}" fill="${i % 3 ? fg : acc}" opacity="${.35 + (i % 4) * .15}"/>`; } m += `<circle cx="${w * .3}" cy="${h * .25}" r="${w * .1}" fill="${acc}" opacity=".9"/>`; break;
case 'sparkle': m = `<path d="M${w * .5} ${h * .15} L ${w * .55} ${h * .45} L ${w * .85} ${h * .5} L ${w * .55} ${h * .55} L ${w * .5} ${h * .85} L ${w * .45} ${h * .55} L ${w * .15} ${h * .5} L ${w * .45} ${h * .45}Z" fill="${acc}" opacity=".9"/><circle cx="${w * .2}" cy="${h * .2}" r="1.5" fill="${fg}"/><circle cx="${w * .82}" cy="${h * .8}" r="1.5" fill="${fg}"/>`; break;
default: m = `<circle cx="${cx}" cy="${h / 2}" r="${w * .25}" fill="${acc}"/>`;
}
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="${w}" height="${h}" fill="${bg}"/>${m}${extra || ''}</svg>`;
}
const PAL = {
nr: { paper: '#f6efe3', ink: '#3a3f4a', tint: '#cfdde0', acc: '#e7a1a0', tone: '#9fb4bb' },
jw: { paper: '#ece6d4', ink: '#2f3a2c', tint: '#c9cfae', acc: '#b4623e', tone: '#8a9470' },
c13: { paper: '#f5f3ee', ink: '#1d1b1a', tint: '#e2dfd8', acc: '#1d1b1a', tone: '#9c9790' },
vl: { paper: '#faf0ee', ink: '#4a2a36', tint: '#f1d3d6', acc: '#c9706b', tone: '#d9a5ae' },
};
function panelArt(kind, p, uid) {
switch (kind) {
case 'fig': return `<rect width="100" height="100" fill="${p.tint}"/><circle cx="50" cy="44" r="16" fill="${p.ink}"/><path d="M18 100 Q 22 66 50 64 Q 78 66 82 100Z" fill="${p.ink}"/><circle cx="56" cy="42" r="2.5" fill="${p.paper}"/>`;
case 'bubble': return `<rect width="100" height="100" fill="${p.paper}"/><ellipse cx="52" cy="40" rx="38" ry="22" fill="#fff" stroke="${p.ink}" stroke-width="2"/><path d="M40 58 L 30 76 L 52 60" fill="#fff" stroke="${p.ink}" stroke-width="2"/><line x1="30" y1="34" x2="74" y2="34" stroke="${p.tone}" stroke-width="3"/><line x1="30" y1="44" x2="64" y2="44" stroke="${p.tone}" stroke-width="3"/><circle cx="76" cy="88" r="10" fill="${p.ink}"/>`;
case 'land': return `<rect width="100" height="100" fill="${p.tint}"/><circle cx="72" cy="30" r="13" fill="${p.acc}"/><path d="M0 70 Q 30 56 55 68 T 100 62 V100 H0Z" fill="${p.tone}"/><path d="M0 84 Q 40 74 100 86 V100 H0Z" fill="${p.ink}"/>`;
case 'eye': return `<rect width="100" height="100" fill="${p.paper}"/><path d="M8 52 Q 50 14 92 52 Q 50 86 8 52Z" fill="#fff" stroke="${p.ink}" stroke-width="3"/><circle cx="50" cy="51" r="16" fill="${p.ink}"/><circle cx="55" cy="46" r="4" fill="#fff"/><path d="M60 70 q 3 10 0 18" stroke="${p.acc}" stroke-width="3" fill="none"/>`;
case 'tone': return `<defs><pattern id="d${uid}" width="5" height="5" patternUnits="userSpaceOnUse"><circle cx="2.5" cy="2.5" r="1.1" fill="${p.tone}"/></pattern></defs><rect width="100" height="100" fill="${p.paper}"/><rect width="100" height="100" fill="url(#d${uid})"/>` + Array.from({ length: 9 }, (_, i) => `<line x1="50" y1="50" x2="${50 + 80 * Math.cos(i * .7)}" y2="${50 + 80 * Math.sin(i * .7)}" stroke="${p.ink}" stroke-width="1.2" opacity=".5"/>`).join('') + `<circle cx="50" cy="50" r="10" fill="${p.ink}"/>`;
case 'cross': return `<rect width="100" height="100" fill="${p.tint}"/><circle cx="50" cy="50" r="30" fill="none" stroke="${p.ink}" stroke-width="2.5"/><line x1="50" y1="8" x2="50" y2="92" stroke="${p.ink}" stroke-width="1.5"/><line x1="8" y1="50" x2="92" y2="50" stroke="${p.ink}" stroke-width="1.5"/><circle cx="50" cy="50" r="3" fill="${p.acc === p.ink ? '#b33' : p.acc}"/>`;
case 'rose': return `<rect width="100" height="100" fill="${p.tint}"/>` + [[25, 30], [70, 24], [50, 70], [18, 78], [84, 72]].map(c => `<circle cx="${c[0]}" cy="${c[1]}" r="11" fill="${p.acc}"/><circle cx="${c[0]}" cy="${c[1]}" r="5" fill="${p.tone}"/>`).join('') + `<path d="M50 20 l3 8 8 3 -8 3 -3 8 -3 -8 -8 -3 8 -3z" fill="#fff"/>`;
case 'scrap': return `<rect width="100" height="100" fill="${p.tint}"/><path d="M0 100 L 10 60 L 30 72 L 42 40 L 60 66 L 74 48 L 100 78 V 100Z" fill="${p.tone}"/><path d="M0 100 L 20 80 L 46 90 L 70 74 L 100 92 V100Z" fill="${p.ink}"/><circle cx="62" cy="26" r="9" fill="${p.acc}"/>`;
default: return `<rect width="100" height="100" fill="${p.tint}"/>`;
}
}
const LAYOUTS = [
[[4, 4, 112, 52], [4, 60, 54, 46], [62, 60, 54, 46], [4, 110, 112, 46]],
[[4, 4, 54, 102], [62, 4, 54, 49], [62, 57, 54, 49], [4, 110, 112, 46]],
[[4, 4, 112, 36], [4, 44, 74, 64], [82, 44, 34, 64], [4, 112, 112, 44]],
];
const KINDS = { nr: ['land', 'fig', 'bubble', 'eye'], jw: ['scrap', 'fig', 'land', 'bubble'], c13: ['cross', 'eye', 'tone', 'fig'], vl: ['rose', 'fig', 'bubble', 'eye'] };
let UID = 0;
function comicPageSVG(pal, n) {
const p = PAL[pal], L = LAYOUTS[n % 3], K = KINDS[pal];
let s = `<svg viewBox="0 0 120 160" aria-hidden="true"><rect width="120" height="160" fill="${p.paper}"/>`;
L.forEach((r, i) => {
const k = K[(i + n) % K.length]; const id = ++UID;
s += `<svg x="${r[0]}" y="${r[1]}" width="${r[2]}" height="${r[3]}" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice">${panelArt(k, p, id)}</svg><rect x="${r[0]}" y="${r[1]}" width="${r[2]}" height="${r[3]}" fill="none" stroke="${p.ink}" stroke-width="1.4"/>`;
});
return s + '</svg>';
}
function drawingSVG(kind) {
if (kind === 'pumpkin') return `<svg viewBox="0 0 160 120" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="160" height="120" fill="#1e1416"/><rect y="88" width="160" height="32" fill="#2f2124"/><rect x="0" y="0" width="160" height="88" fill="#241a20"/><line x1="0" y1="88" x2="160" y2="88" stroke="#3d2b2f"/><circle cx="80" cy="66" r="44" fill="#e8b04a" opacity=".08"/><ellipse cx="66" cy="70" rx="20" ry="22" fill="#c86a2c"/><ellipse cx="94" cy="70" rx="20" ry="22" fill="#c86a2c"/><ellipse cx="80" cy="70" rx="20" ry="24" fill="#d97a34"/><path d="M78 47 q 2 -10 9 -12" stroke="#5d6e4f" stroke-width="4" fill="none" stroke-linecap="round"/><path d="M66 62 l6 -8 6 8z" fill="#ffd27a"/><path d="M86 61 q 4 -3 9 0" stroke="#ffd27a" stroke-width="2.5" fill="none" stroke-linecap="round"/><path d="M64 76 q 16 12 32 0 l -4 2 -3 -3 -4 4 -4 -4 -4 4 -4 -4 -3 3z" fill="#ffd27a"/></svg>`;
if (kind === 'knightcat') return `<svg viewBox="0 0 120 150" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="120" height="150" fill="#e9d6c4"/><circle cx="60" cy="64" r="44" fill="#c9706b" opacity=".35"/><path d="M28 140 Q 30 96 60 94 Q 90 96 92 140Z" fill="#7d8a96"/><path d="M60 98 v 40" stroke="#5b6670" stroke-width="2"/><circle cx="60" cy="70" r="26" fill="#3a2a2a"/><path d="M38 56 l 4 -22 12 14z M82 56 l -4 -22 -12 14z" fill="#3a2a2a"/><path d="M36 62 Q 60 42 84 62 V 70 H 36Z" fill="#9aa5ae"/><rect x="36" y="66" width="48" height="5" fill="#5b6670"/><circle cx="51" cy="78" r="3" fill="#e8b04a"/><circle cx="69" cy="78" r="3" fill="#e8b04a"/><path d="M57 86 l3 3 3 -3" stroke="#e9d6c4" stroke-width="1.5" fill="none"/><path d="M98 50 L 98 128" stroke="#cfd6db" stroke-width="4" stroke-linecap="round"/><path d="M90 116 h 16" stroke="#8a6a3a" stroke-width="4" stroke-linecap="round"/><path d="M60 40 q 6 -14 14 -8" stroke="#c9706b" stroke-width="3" fill="none"/></svg>`;
if (kind === 'frog') return `<svg viewBox="0 0 140 120" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="140" height="120" fill="#f6efe2"/><g fill="none" stroke="#2a1a1f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M40 52 Q 70 14 100 52 Z" fill="#c9706b" fill-opacity=".55"/><path d="M70 52 V 84 q 0 6 -6 6"/><ellipse cx="72" cy="94" rx="22" ry="12" fill="#a7b86c" fill-opacity=".6"/><circle cx="62" cy="82" r="5" fill="#fff"/><circle cx="82" cy="82" r="5" fill="#fff"/><circle cx="63" cy="82" r="1.5" fill="#2a1a1f"/><circle cx="81" cy="82" r="1.5" fill="#2a1a1f"/><path d="M66 98 q 6 4 12 0"/><path d="M30 30 l -2 6 M24 50 l -2 6 M112 34 l -2 6 M118 62 l -2 6 M34 72 l -2 6"/></g></svg>`;
return '';
}
/* one shared fake audio clock: advances only after the visitor presses play */
const PLAYER = { id: null, t: 0, timer: null, subs: [] };
function playerToggle(id, len) {
if (PLAYER.id === id && PLAYER.timer) { clearInterval(PLAYER.timer); PLAYER.timer = null; }
else {
if (PLAYER.id !== id) PLAYER.t = 0;
PLAYER.id = id; clearInterval(PLAYER.timer);
PLAYER.timer = setInterval(() => { PLAYER.t += .25; if (PLAYER.t >= len) { PLAYER.t = 0; clearInterval(PLAYER.timer); PLAYER.timer = null; } playerEmit(); }, 250);
}
playerEmit();
}
function playerEmit() { PLAYER.subs.forEach(f => f(PLAYER)); }
function playerSeek(id, len, frac) { if (PLAYER.id !== id) { PLAYER.id = id; } PLAYER.t = frac * len; playerEmit(); }
const byId = id => ITEMS.find(i => i.id === id);
/* ===================== r2-a: shelves ===================== */
const T = s => s; // title helper
const title = it => it.ph ? PH(it.title) : it.title;
const shelfOf = id => SHELVES.find(s => s.id === id);
const sorted = ITEMS.slice().sort((a, b) => new Date(b.date) - new Date(a.date));
function objHTML(it) {
switch (it.kind) {
case 'comic': case 'trope':
return `<div class="obj o-comic">${[0, 1, 2].map(i => `<div class="pg">${comicPageSVG(it.pal, i)}</div>`).join('')}</div>`;
case 'kickstarter':
return `<div class="obj o-cart"><div class="lbl">${coverSVG(it.art, 124, 128)}<span class="t">${it.title}</span></div><span class="stamp">backed!</span></div>`;
case 'game':
return `<div class="obj o-box" style="--c:${it.art.bg}"><div class="spine">${it.title}</div><div class="front">${coverSVG(it.art, 124, 186)}<span class="t">${it.title}</span><span class="band">${it.sub.split('·').pop().trim()}</span></div></div>`;
case 'song':
return `<div class="obj o-sleeve" data-sleeve="${it.id}" style="--c:${it.art.bg}"><div class="rec"></div><div class="slv">${coverSVG(it.art, 168, 168)}<span class="t">${it.by}</span></div></div>`;
case 'tape':
return `<div class="obj o-tape" data-sleeve="${it.id}"><div class="l">${PH('Lav Sol')} · Café Retro</div><div class="win"><span class="reel"></span><span class="reel"></span></div><span class="vid">HANDCAM · VIDEO</span></div>`;
case 'film':
return `<div class="obj o-poster">${coverSVG(it.art, 128, 192)}<span class="t">${it.title}</span><span class="s">${it.sub.toUpperCase()}</span></div>`;
case 'photo':
return `<div class="obj o-polaroid"><div class="im">${drawingSVG(it.art.draw)}</div><span class="cp">lopsided, mine 🎃</span></div>`;
case 'commission':
return `<div class="obj o-frame"><div class="mat"><div>${drawingSVG(it.art.draw)}</div></div></div>`;
case 'drawing':
return `<div class="obj o-taped"><div>${drawingSVG(it.art.draw)}</div></div>`;
case 'essay':
return `<div class="obj o-paper"><span class="pc"></span><span class="k">essay · ${it.mins} min</span><b>${it.title}</b><div class="lines"></div></div>`;
case 'notebook':
return `<div class="obj o-note"><span class="k">${it.tag} · ${it.mins} min</span><b>${it.title}</b></div>`;
case 'card':
return `<div class="obj o-card"><span class="k">${it.tag}</span><b>${it.title}</b></div>`;
}
return '';
}
const WIDTH = { comic: 190, trope: 190, kickstarter: 160, game: 150, song: 176, tape: 196, film: 136, photo: 176, commission: 146, drawing: 176, essay: 160, notebook: 160, card: 186 };
function miniPlayer(it) {
return `<div class="mini" data-mini="${it.id}" data-len="${it.len}"><button class="pp" aria-label="play ${it.title}">▶</button><div class="bar2" aria-label="seek"><i></i></div><span class="tm">0:00 / ${mmss(it.len)}</span></div>`;
}
function itemHTML(it) {
const fresh = (TODAY - new Date(it.date)) / 864e5 < 21;
return `<div class="it" role="button" tabindex="0" data-open="${it.id}" style="--w:${WIDTH[it.kind]}px">
<div class="stage">${objHTML(it)}</div>
<div class="cap"><span class="d">${fdate(it.date)}${fresh ? ' · <em>new</em>' : ''}</span><b>${title(it)}${it.by && it.kind === 'song' ? ' <span style="color:var(--mute);font-weight:300">· ' + (it.ph ? PH(it.by) : it.by) + '</span>' : ''}</b>
${it.kind === 'song' || it.kind === 'tape' ? miniPlayer(it) : `<span class="ln">${it.line}</span>`}</div>
</div>`;
}
function headHTML(s, shown) {
const live = s.live ? `<div class="live"><i></i>live · ${s.live} · ${s.live === 'Trakt' ? TRAKT.synced : HARDCOVER.synced}</div>` : '';
return `<div class="sh-head"><div class="n">${String(SHELVES.indexOf(s) + 1).padStart(2, '0')}</div><h2>${s.name}</h2><div class="hd">${s.hand}</div>${live}
<div class="cnt">${shown} of ${s.total}${s.live ? ' · ' + (s.live === 'Trakt' ? 'films & shows' : 'books') : ''}</div><button class="all" data-all="${s.id}">see all <span>→</span></button></div>`;
}
function traktCard() {
const w = TRAKT.watching;
return `<div class="it" style="--w:280px;cursor:default"><div class="stage"><div class="live-card">
<div class="top"><span>TRAKT · watching now</span><b>● ${TRAKT.synced}</b></div>
<div class="w"><div class="c">${coverSVG(w.art, 46, 64)}</div><div><b>${PH(w.show)}</b><small>${w.ep} · “${PH(w.title)}”</small><div class="prog"><i style="width:${w.progress * 100}%"></i></div></div></div>
<ul>${TRAKT.ratings.slice(1, 4).map(r => `<li><span>${PH(r[0])}<small>${r[1]}</small></span><span>${'♥'.repeat(1)} ${r[2]}/10</span></li>`).join('')}</ul>
<a class="out" href="#" onclick="return false">trakt.tv/users/${TRAKT.user} ↗</a></div></div>
<div class="cap"><span class="d">pulled from Trakt</span><span class="ln">${PH(TRAKT.stats)}. Ratings come in by themselves; the posters are the ones I wrote about.</span></div></div>`;
}
function moreTile(s, shown) {
const rest = s.total - shown; if (rest <= 0) return '';
const arc = (ARCHIVE[s.id] || []).slice(0, 3);
return `<div class="it more" role="button" tabindex="0" data-all="${s.id}" style="--w:150px"><div class="stage"><div class="obj stack">${arc.map((a, k) => `<span style="--k:${k};background:${ARC_COLORS[(k + s.id.length) % ARC_COLORS.length]}"></span>`).join('')}<b>+${rest}</b></div></div>
<div class="cap"><span class="d">older</span><b>see all ${s.total} →</b><span class="ln">${arc.map(a => PH(a[0])).slice(0, 2).join(', ')}…</span></div></div>`;
}
function booksShelf(s) {
const r = HARDCOVER.reading;
const spines = HARDCOVER.read.map((b, i) => `<button class="spine-b${b.c === '#d9c39a' || b.c === '#e8b04a' ? ' light' : ''}" style="--c:${b.c};--h:${b.h}px;--w:${b.w}px" data-book="${i}" title="${b.title}, ${b.by} · ${b.stars}★ on Hardcover"><span>${b.title.length > 24 ? b.title.slice(0, 22) + '…' : b.title}</span><span class="st">${'★'.repeat(Math.floor(b.stars))}${b.stars % 1 ? '½' : ''}</span></button>`).join('');
return `<section class="shelf" id="s-${s.id}">${headHTML(s, 6)}
<div class="row-objs">
<div class="it" role="button" tabindex="0" data-book="r" style="--w:auto">
<div class="stage" style="justify-content:flex-start">
<div class="lean" style="--c:${r.c}"><div class="cv"><b>${PH(r.title)}</b><small>${PH(r.by)}</small>${coverSVG({ bg: r.c, fg: '#f1e6d6', acc: '#d9c39a', motif: 'stairs' }, 108, 70)}</div><span class="bm"></span><span class="tag">reading · ${r.pct}%</span></div>
<div class="books">${spines}</div>
</div>
<div class="cap"><span class="d">from Hardcover · ${HARDCOVER.synced}</span><b>Reading ${PH(r.title)}, page ${r.page} of ${r.of}</b><span class="ln">Spines are the last five finished, with my stars. Hover to pull one out. ${PH(HARDCOVER.stats)}.</span></div>
</div>
${moreTile(s, 6)}
</div></section>`;
}
function renderShelves() {
const host = document.getElementById('shelves'); let h = '';
SHELVES.forEach(s => {
if (s.id === 'books') { h += booksShelf(s); return; }
const its = sorted.filter(i => i.shelf === s.id).slice(0, 4);
h += `<section class="shelf" id="s-${s.id}">${headHTML(s, its.length)}<div class="row-objs">${its.map(itemHTML).join('')}${s.id === 'film' ? traktCard() : ''}${moreTile(s, its.length)}</div></section>`;
});
host.innerHTML = h;
document.getElementById('snav').innerHTML = SHELVES.map(s => `<a href="#s-${s.id}">${s.name}<sup>${s.total}</sup></a>`).join('');
document.getElementById('meta').textContent = SHELVES.reduce((n, s) => n + s.total, 0) + ' things · since 2024';
}
function thumb(it) {
if (it.pal) return comicPageSVG(it.pal, 1);
if (it.art && it.art.draw) return drawingSVG(it.art.draw);
if (it.art) return coverSVG(it.art, 64, 64);
return '';
}
function renderLately() {
document.getElementById('lately').innerHTML = sorted.slice(0, 3).map(it =>
`<button class="lt" data-open="${it.id}"><div class="th">${thumb(it)}</div><div><small>${ago(it.date)}</small><b>${title(it)}</b><span>on ${shelfOf(it.shelf).name}</span></div></button>`).join('');
}
/* ---------- overlay ---------- */
const scrim = document.getElementById('scrim'), modal = document.getElementById('modal'), mbody = document.getElementById('mbody'), mpath = document.getElementById('mpath');
let lastFocus = null, reader = null;
function openModal(path, html) {
lastFocus = document.activeElement;
mpath.innerHTML = path; mbody.innerHTML = html; mbody.scrollTop = 0;
scrim.classList.add('on'); modal.classList.add('on');
document.getElementById('mclose').focus({ preventScroll: true });
bindPlayers(mbody);
}
function closeModal() {
scrim.classList.remove('on'); modal.classList.remove('on'); reader = null;
if (lastFocus) lastFocus.focus({ preventScroll: true });
}
scrim.onclick = closeModal; document.getElementById('mclose').onclick = closeModal;
function openItem(id) {
const it = byId(id); const s = shelfOf(it.shelf);
const path = `off the clock / ${s.name.toLowerCase()} / ${it.title.toLowerCase()}`;
const paras = it.body.map(p => `<p>${it.ph ? PH(p) : p}</p>`).join('');
const said = `<div class="said">“${it.line}”</div>`;
let h = '';
if (it.pal) {
reader = { it, p: 0 };
h = `<div class="two"><div class="reader"><div class="spread" id="spread"></div>
<div class="ctl"><button id="rprev" aria-label="previous pages">← prev</button><span id="rpos"></span><button id="rnext" aria-label="next pages">next →</button></div>
<div class="note">drawn stand-ins for the shared pages · ← → keys turn</div></div>
<div><h2>${it.title}</h2><div class="sub">${it.sub} · shared ${fdate(it.date)}</div>${said}${paras}
${it.url ? `<a class="src" href="${it.url}">read it at nowrecharging.com ↗</a>` : `<span class="src">${PH('link to where you can read it ↗')}</span>`}</div></div>`;
} else if (it.kind === 'song') {
h = `<div class="two"><div><div class="bigsleeve" data-sleeve="${it.id}" style="--c:${it.art.bg}"><div class="rec"></div><div class="slv">${coverSVG(it.art, 300, 300)}</div></div>
<div class="bplayer" data-mini="${it.id}" data-len="${it.len}"><button class="pp" aria-label="play">▶</button><div class="bar2"><i></i></div><span class="tm">0:00 / ${mmss(it.len)}</span></div></div>
<div><div class="sub" style="margin-top:0">a song I want to share · ${fdate(it.date)}</div><h2 style="margin-top:8px">${title(it)}</h2><div class="sub">${PH(it.by)}</div>${said}${paras}<span class="src">${PH('also on Bandcamp ↗ · Spotify ↗')}</span></div></div>`;
} else if (it.kind === 'tape') {
h = `<div class="two"><div><div class="video"><button aria-label="play video" data-vid>▶</button><span>handcam video: Café Retro, from the bar</span></div>
<div class="bplayer" data-mini="${it.id}" data-len="${it.len}"><button class="pp" aria-label="play">▶</button><div class="bar2"><i></i></div><span class="tm">0:00 / ${mmss(it.len)}</span></div>
<ul class="setlist" id="setlist">${it.set.map((x, i) => `<li data-at="${it.set.slice(0, i).reduce((n, y) => n + y[1], 0)}"><span>${i + 1}. ${PH(x[0])}</span><span>${mmss(x[1])}</span></li>`).join('')}</ul></div>
<div><div class="sub" style="margin-top:0">the band · one topic among many</div><h2 style="margin-top:8px">${PH(it.title)}</h2><div class="sub">${PH(it.by)} · ${fdate(it.date)}</div>${said}${paras}</div></div>`;
} else if (it.kind === 'game' || it.kind === 'kickstarter') {
h = `<div class="two" style="grid-template-columns:300px 1fr"><div class="bigart">${coverSVG(it.art, 300, 400, `<text x="20" y="44" font-family="Fraunces" font-size="30" font-style="italic" fill="#fff">${it.title}</text>`)}</div>
<div><div class="sub" style="margin-top:0">${it.kind === 'kickstarter' ? 'a Kickstarter I backed, now playing' : 'a game I love'} · ${fdate(it.date)}</div><h2 style="margin-top:8px">${title(it)}</h2>
<div class="pull">${PH(it.quote)}</div>${paras}
<div class="facts"><span>${PH(it.sub)}</span><span>${PH(it.hours + ' h played')}</span>${it.kind === 'kickstarter' ? `<span>${PH('180 DKK tier · name in the credits')}</span>` : ''}</div></div></div>`;
} else if (it.kind === 'film') {
h = `<div class="two" style="grid-template-columns:280px 1fr"><div class="bigart">${coverSVG(it.art, 280, 420, `<text x="140" y="390" text-anchor="middle" font-family="Fraunces" font-size="24" letter-spacing="4" fill="#fff">${it.title.toUpperCase()}</text>`)}</div>
<div><div class="sub" style="margin-top:0">film & tv · ${fdate(it.date)}</div><h2 style="margin-top:8px">${title(it)}</h2><div class="sub">${PH(it.sub)}</div>${said}${paras}
<div class="facts"><span>my Trakt rating · ${PH(it.rating + '/10')}</span><span>${PH('watched twice')}</span></div><a class="src" href="#" onclick="return false">${PH('see it on Trakt ↗')}</a></div></div>`;
} else if (it.art && it.art.draw) {
h = `<div class="two"><div class="bigart">${drawingSVG(it.art.draw)}</div>
<div><div class="sub" style="margin-top:0">made by me${it.kind === 'commission' ? ' (well, for me)' : ''} · ${fdate(it.date)}</div><h2 style="margin-top:8px">${title(it)}</h2>${it.by ? `<div class="sub">${PH(it.by)}</div>` : ''}${said}${paras}
${it.label ? `<div class="facts"><span>${it.label}</span></div>` : `<div class="facts"><span>${PH('drawn stand-in for the real picture')}</span></div>`}</div></div>`;
} else {
h = `<article class="essay"><div class="k">${it.tag} · ${it.mins} min read · ${fdate(it.date)}</div><h2>${PH(it.title)}</h2>${paras}<div class="end">– Lucas</div></article>`;
}
openModal(path, h);
if (reader) { drawReader(); document.getElementById('rprev').onclick = () => turn(-1); document.getElementById('rnext').onclick = () => turn(1); }
const sl = document.getElementById('setlist');
if (sl) sl.querySelectorAll('li').forEach(li => li.onclick = () => { PLAYER.id = it.id; PLAYER.t = +li.dataset.at; if (!PLAYER.timer) playerToggle(it.id, it.len); else playerEmit(); });
const v = mbody.querySelector('[data-vid]'); if (v) v.onclick = () => mbody.querySelector('.bplayer .pp').click();
}
function drawReader() {
const { it, p } = reader; const two = window.innerWidth > 620;
document.getElementById('spread').innerHTML = [p, p + 1].filter(i => i < it.pages).map(i => `<div class="pgbig">${comicPageSVG(it.pal, i)}</div>`).join('');
document.getElementById('rpos').textContent = 'pages ' + (p + 1) + (p + 1 < it.pages ? '–' + (p + 2) : '') + ' of ' + it.pages;
document.getElementById('rprev').disabled = p === 0;
document.getElementById('rnext').disabled = p + 2 >= it.pages;
}
function turn(d) { if (!reader) return; const np = reader.p + d * 2; if (np < 0 || np >= reader.it.pages) return; reader.p = np; drawReader(); }
function openAll(sid) {
const s = shelfOf(sid);
let entries = sorted.filter(i => i.shelf === sid).map(i => ({ it: i, y: i.date.slice(0, 4) }));
if (sid === 'books') entries = [{ book: HARDCOVER.reading, y: '2026', r: 1 }].concat(HARDCOVER.read.map(b => ({ book: b, y: '2026' })));
if (sid === 'film') entries = entries.concat(TRAKT.ratings.filter(r => !ITEMS.some(i => i.title === r[0])).map(r => ({ trakt: r, y: '2026' })));
(ARCHIVE[sid] || []).forEach((a, k) => entries.push({ arc: a, y: a[1], k }));
const years = [...new Set(entries.map(e => e.y))];
const tile = e => {
if (e.it) return `<button class="ai" data-open="${e.it.id}"><div class="th">${thumb(e.it)}</div><b>${title(e.it)}</b><small>${fdate(e.it.date)}</small></button>`;
if (e.book) return `<div class="ai" style="cursor:default"><div class="th" style="background:${e.book.c}"><span class="lbl">${e.book.title}</span></div><b>${PH(e.book.title)}</b><small>${e.r ? 'reading · ' + e.book.pct + '%' : e.book.stars + '★ · Hardcover'}</small></div>`;
if (e.trakt) return `<div class="ai" style="cursor:default"><div class="th" style="background:#4a4f6b"><span class="lbl">${e.trakt[0]}</span></div><b>${PH(e.trakt[0])}</b><small>${e.trakt[2]}/10 · Trakt</small></div>`;
const c = ARC_COLORS[(e.k + sid.length) % ARC_COLORS.length];
const art = sid === 'comics' ? comicPageSVG(['nr', 'jw', 'c13', 'vl'][e.k % 4], e.k) : sid === 'books' ? '' : coverSVG({ bg: c, fg: '#f1e6d6', acc: '#e8b04a', motif: ['moon', 'planet', 'mountain', 'trees', 'stairs', 'city'][e.k % 6] }, 100, 100);
return `<div class="ai" style="cursor:default" title="placeholder archive item"><div class="th" style="background:${c}">${art}${sid === 'books' ? `<span class="lbl">${e.arc[0]}</span>` : ''}</div><b>${PH(e.arc[0])}</b><small>${e.arc[1]}</small></div>`;
};
const h = `<div class="archive"><div class="sub" style="margin-top:0">${s.hand}</div><h2 style="margin-top:6px">${s.name} <span style="font:400 14px var(--mono);color:#7d6a5c;font-style:normal">· everything, newest first</span></h2>
${years.map(y => `<div class="yr">${y}</div><div class="agrid">${entries.filter(e => e.y === y).map(tile).join('')}</div>`).join('')}
<div class="scale">This is how a shelf grows: the shelf on the page always shows the newest four; this view groups the rest by year. At 100+ things it gains a year jump list and a search box, nothing else changes.</div></div>`;
openModal(`off the clock / ${s.name.toLowerCase()} / all`, h);
}
/* ---------- players ---------- */
function bindPlayers(root) {
root.querySelectorAll('[data-mini]').forEach(m => {
const id = m.dataset.mini, len = +m.dataset.len;
m.querySelector('.pp').onclick = e => { e.stopPropagation(); playerToggle(id, len); };
const bar = m.querySelector('.bar2');
bar.onclick = e => { e.stopPropagation(); const r = bar.getBoundingClientRect(); playerSeek(id, len, (e.clientX - r.left) / r.width); };
m.onclick = e => e.stopPropagation();
m.onkeydown = e => e.stopPropagation();
});
}
PLAYER.subs.push(st => {
document.querySelectorAll('[data-mini]').forEach(m => {
const on = m.dataset.mini === st.id, len = +m.dataset.len, t = on ? st.t : 0, playing = on && !!st.timer;
m.querySelector('.pp').textContent = playing ? '❚❚' : '▶';
m.querySelector('.pp').setAttribute('aria-label', playing ? 'pause' : 'play');
m.querySelector('.bar2 i').style.width = (t / len * 100) + '%';
m.querySelector('.tm').textContent = mmss(t) + ' / ' + mmss(len);
});
document.querySelectorAll('[data-sleeve]').forEach(s => s.classList.toggle('playing', s.dataset.sleeve === st.id && !!st.timer));
const it = byId(st.id); const sl = document.getElementById('setlist');
if (sl && it && it.set) { let acc = 0; sl.querySelectorAll('li').forEach((li, i) => { const a = acc; acc += it.set[i][1]; li.classList.toggle('on', st.t >= a && st.t < acc && (st.t > 0 || !!st.timer)); }); }
});
/* ---------- wire up ---------- */
renderLately(); renderShelves(); bindPlayers(document);
// deep links: #open=<id> or #all=<shelf> (what a real router would do)
(function () { const m = location.hash.match(/^#(open|all)=(.+)$/); if (!m) return; if (m[1] === 'open' && byId(m[2])) openItem(m[2]); if (m[1] === 'all') openAll(m[2]); })();
document.addEventListener('click', e => {
const all = e.target.closest('[data-all]'); if (all) { openAll(all.dataset.all); return; }
const o = e.target.closest('[data-open]'); if (o) { openItem(o.dataset.open); return; }
const b = e.target.closest('[data-book]'); if (b && !modal.contains(b)) openAll('books');
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modal.classList.contains('on')) { closeModal(); return; }
if (modal.classList.contains('on') && reader) { if (e.key === 'ArrowRight') turn(1); if (e.key === 'ArrowLeft') turn(-1); return; }
if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('.it[data-open], .it[data-book], .it[data-all]')) { e.preventDefault(); e.target.click(); }
});
</script>
</body>
</html>
+739
View File
@@ -0,0 +1,739 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Off the clock · 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=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,300;1,9..144,400&family=Caveat:wght@500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f1e6d6; --card: #f8f1e6; --card2: #ebdcc6; --line: #dccbb2; --line2: #c9b597;
--ink: #2a1a1f; --ink2: #4a3238; --mute: #7d6a5c; --dim: #a8947f;
--plum: #2a1a1f; --acc: #e8b04a; --rose: #c9706b; --rose-d: #9a4a45; --sage: #6f7f45;
--serif: "Fraunces", Georgia, serif; --hand: "Caveat", cursive; --mono: "IBM Plex Mono", ui-monospace, monospace;
--ease: cubic-bezier(.2,.8,.2,1);
--edge: 42px;
}
* { box-sizing: border-box; }
html { scroll-padding-top: 120px; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--ink); font-family: var(--serif); font-size: 16px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
body::before { content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0; opacity: .5; mix-blend-mode: multiply;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .55 0 0 0 0 .45 0 0 0 0 .38 0 0 0 .16 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>"); }
a { color: inherit; }
button { font: inherit; color: inherit; }
::selection { background: var(--acc); color: var(--ink); }
:focus-visible { outline: 2px solid var(--rose-d); outline-offset: 3px; }
.ph { text-decoration: underline dotted rgba(42,26,31,.35); text-underline-offset: 3px; text-decoration-thickness: 1px; cursor: help; }
.dark .ph { text-decoration-color: rgba(241,230,214,.4); }
.mk { position: fixed; bottom: 14px; right: 14px; z-index: 95; width: 310px; background: #fff; color: #222; font: 12px/1.45 system-ui, sans-serif; padding: 10px 28px 10px 12px; border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,.2); }
.mk b { display: block; margin-bottom: 3px; }
.mk button { position: absolute; top: 4px; right: 6px; background: none; border: 0; font-size: 16px; cursor: pointer; color: #555; }
.edge { position: fixed; top: 0; left: 0; bottom: 0; width: var(--edge); z-index: 40; background: #131210; color: #ede7dd; display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; font: 500 12px "IBM Plex Mono", monospace; letter-spacing: .12em; text-transform: uppercase; text-decoration: none; transition: width .2s var(--ease); }
.edge span { transform: rotate(180deg); }
.edge:hover { width: 54px; color: #ea7444; }
.bar { position: sticky; top: 0; z-index: 30; margin-left: var(--edge); background: rgba(241,230,214,.9); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); }
.bar .in { max-width: 1320px; margin: 0 auto; padding: 0 40px; height: 54px; display: flex; align-items: center; gap: 22px; }
.crumb { display: flex; align-items: center; gap: 10px; 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(--ink2); }
.crumb b { font-weight: 500; font-size: 15px; }
.crumb i { color: var(--mute); font-weight: 300; }
.chips { display: flex; gap: 6px; margin-left: auto; overflow-x: auto; scrollbar-width: none; }
.chip { border: 1px solid var(--line2); background: transparent; border-radius: 99px; padding: 4px 12px; font-size: 14px; cursor: pointer; white-space: nowrap; color: var(--ink2); transition: background .15s, color .15s, border-color .15s; }
.chip sup { font: 10px var(--mono); color: var(--dim); margin-left: 3px; }
.chip:hover { border-color: var(--ink2); }
.chip[aria-pressed="true"] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.chip[aria-pressed="true"] sup { color: var(--acc); }
main { margin-left: var(--edge); position: relative; z-index: 1; }
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 40px; }
/* header + now */
.head { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 2fr); gap: 44px; padding: 38px 0 30px; align-items: end; }
.head .lab { font: 600 23px var(--hand); color: var(--rose-d); transform: rotate(-2deg); display: inline-block; }
.head h1 { margin: 2px 0 0; font-weight: 300; font-size: 60px; line-height: .98; letter-spacing: -.03em; }
.head h1 i { font-weight: 300; }
.head p { margin: 14px 0 0; color: var(--ink2); font-weight: 300; font-size: 16.5px; max-width: 26em; }
.now { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 2px solid var(--ink); }
.now > div { padding: 12px 16px 4px 0; min-width: 0; }
.now > div + div { padding-left: 16px; border-left: 1px solid var(--line); }
.now h4 { margin: 0 0 10px; display: flex; justify-content: space-between; align-items: baseline; gap: 6px; font: 500 10.5px var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--mute); }
.now h4 a { text-transform: none; letter-spacing: 0; color: var(--sage); text-decoration: none; white-space: nowrap; }
.now h4 a:hover { text-decoration: underline; }
.now .x { display: grid; grid-template-columns: 48px 1fr; gap: 10px; align-items: center; }
.now .x .c { width: 48px; height: 48px; border-radius: 3px; overflow: hidden; position: relative; box-shadow: 0 3px 8px rgba(42,26,31,.18); }
.now .x .c.tall { height: 66px; }
.now .x .c svg { display: block; width: 100%; height: 100%; }
.now .x b { display: block; font-weight: 500; font-size: 15px; line-height: 1.15; }
.now .x small { display: block; font: 11px var(--mono); color: var(--mute); margin-top: 2px; }
.now .pg { height: 3px; background: var(--line); border-radius: 2px; margin-top: 7px; }
.now .pg i { display: block; height: 100%; background: var(--sage); border-radius: 2px; }
.now .c .pp { position: absolute; inset: 0; margin: auto; width: 26px; height: 26px; border-radius: 50%; border: 0; background: rgba(42,26,31,.82); color: var(--bg); font-size: 9px; cursor: pointer; }
/* wall */
.wall-top { display: flex; align-items: baseline; gap: 14px; border-top: 1px solid var(--line); padding: 16px 0 18px; }
.wall-top h2 { margin: 0; font: 600 26px var(--hand); color: var(--rose-d); }
.wall-top .s { font: 12px var(--mono); color: var(--mute); }
.wall-top .s b { color: var(--ink); font-weight: 500; }
.wall { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); grid-auto-rows: 6px; column-gap: 22px; grid-auto-flow: row dense; }
.yr { grid-column: 1 / -1; display: flex; align-items: center; gap: 14px; font: 600 26px var(--hand); color: var(--rose-d); padding-top: 14px; }
.yr::after { content: ""; flex: 1; border-top: 1px dashed var(--line2); }
.yr small { font: 12px var(--mono); color: var(--mute); }
.card { grid-column: span var(--span, 4); align-self: start; position: relative; margin-bottom: 22px; background: var(--card); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; cursor: pointer; transition: transform .22s var(--ease), box-shadow .22s var(--ease), border-color .2s; }
.card:hover { transform: translateY(-3px); box-shadow: 0 14px 30px -12px rgba(42,26,31,.3); border-color: var(--line2); }
.card.open { grid-column: 1 / -1; cursor: default; transform: none; box-shadow: 0 20px 50px -20px rgba(42,26,31,.35); border-color: var(--line2); }
.card.hide { display: none; }
.card .meta { font: 10.5px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--mute); display: flex; gap: 8px; align-items: center; }
.card .meta .new { color: var(--bg); background: var(--rose-d); padding: 0 5px; border-radius: 3px; letter-spacing: .05em; }
.card h3 { margin: 6px 0 0; font-weight: 400; font-size: 22px; line-height: 1.12; letter-spacing: -.01em; }
.card .said { font: 500 21px/1.15 var(--hand); color: var(--rose-d); margin-top: 8px; }
.card .pad { padding: 16px 18px 18px; }
.gcv svg { width: 100%; height: 100%; }
.card svg { display: block; }
.card .open-hint { font: 11px var(--mono); color: var(--mute); margin-top: 10px; opacity: 0; transition: opacity .2s; }
.card:hover .open-hint { opacity: 1; }
.card .full { display: none; }
.card.open .full { display: block; }
.card.open .teaser { display: none; }
.card .x-close { display: none; position: absolute; top: 12px; right: 12px; z-index: 3; border: 1px solid var(--line2); background: var(--card); border-radius: 6px; padding: 3px 9px; font: 12px var(--mono); color: var(--ink2); cursor: pointer; }
.card.open .x-close { display: block; }
.dark.card .x-close { background: #3a252c; border-color: #5c4148; color: #f1e6d6; }
/* comic */
.panels { display: grid; grid-template-columns: 1.3fr 1fr; grid-template-rows: 1fr 1fr; gap: 6px; padding: 10px; background: var(--pp); height: var(--ph, 250px); }
.panels > div { border: 2px solid var(--pi); overflow: hidden; }
.panels > div:first-child { grid-row: span 2; }
.panels.three { grid-template-columns: 1.2fr 1fr 1fr; grid-template-rows: 1fr; }
.panels.three > div:first-child { grid-row: auto; }
.panels svg { width: 100%; height: 100%; }
.card.comic:hover .panels > div { transform: none; }
.reader { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 34px; padding: 22px; }
.reader .spread { display: flex; gap: 10px; }
.reader .pgbig { width: 240px; height: 320px; box-shadow: 0 10px 24px rgba(42,26,31,.25); background: #fff; }
.reader .ctl { display: flex; align-items: center; gap: 12px; margin-top: 12px; font: 12px var(--mono); color: var(--mute); }
.reader .ctl button, .btn { background: none; border: 1px solid var(--line2); border-radius: 6px; padding: 4px 10px; cursor: pointer; font: 12px var(--mono); color: var(--ink2); }
.reader .ctl button:hover, .btn:hover { background: var(--card2); }
.reader .ctl button:disabled { opacity: .35; cursor: default; }
.prose p { font-size: 17px; line-height: 1.65; margin: 12px 0 0; max-width: 36em; color: var(--ink2); }
.prose .src { display: inline-block; margin-top: 16px; font: 12px var(--mono); color: var(--ink2); }
/* song */
.dark { background: var(--plum); color: #f1e6d6; border-color: #3e2a30; }
.dark .meta { color: #b8a592; }
.dark .said { color: var(--acc); }
.dark .prose p { color: #e3d4c1; }
.sleeve { position: relative; aspect-ratio: 1; margin: 12px 12px 0; }
.sleeve .slv { position: absolute; inset: 0; z-index: 1; border-radius: 3px; overflow: hidden; box-shadow: 0 10px 24px rgba(0,0,0,.4); }
.sleeve .slv svg { width: 100%; height: 100%; }
.sleeve .rec { position: absolute; top: 4%; bottom: 4%; left: 4%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle, var(--c) 0 17%, #141012 17.5% 19%, #1b1517 19% 100%); transition: transform .35s var(--ease); }
.sleeve .rec::after { content: ""; position: absolute; inset: 10%; border-radius: 50%; background: repeating-radial-gradient(circle, transparent 0 3px, rgba(255,255,255,.05) 3px 4px); }
.card:hover .sleeve .rec { transform: translateX(10%); }
.sleeve.playing .rec, .card:hover .sleeve.playing .rec { transform: translateX(22%); animation: spin 2.4s linear infinite; }
.song .sleeve { margin: 14px 22% 0 14px; }
@keyframes spin { from { rotate: 0deg; } to { rotate: 360deg; } }
.sleeve .bigpp { position: absolute; z-index: 2; left: 12px; bottom: 12px; width: 48px; height: 48px; border-radius: 50%; border: 0; background: var(--acc); color: var(--plum); font-size: 14px; cursor: pointer; box-shadow: 0 4px 10px rgba(0,0,0,.35); transition: transform .15s; }
.sleeve .bigpp:hover { transform: scale(1.07); }
.mini { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.mini .bar2 { flex: 1; height: 3px; background: rgba(241,230,214,.2); border-radius: 2px; position: relative; cursor: pointer; }
.mini .bar2 i { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: var(--acc); border-radius: 2px; }
.mini .tm { font: 11px var(--mono); color: #b8a592; }
.card:not(.dark) .mini .bar2 { background: var(--line); }
.card:not(.dark) .mini .bar2 i { background: var(--rose); }
.card:not(.dark) .mini .tm { color: var(--mute); }
.mini .pp { width: 30px; height: 30px; border-radius: 50%; border: 0; background: var(--acc); color: var(--plum); font-size: 10px; cursor: pointer; flex: 0 0 auto; }
/* game */
.game-grid { display: grid; grid-template-columns: 42% 1fr; }
.card.open .game-grid { grid-template-columns: 380px 1fr; }
.game-grid .cv { position: relative; min-height: 270px; }
.game-grid .cv svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.game-grid .cv .t { position: absolute; left: 14px; top: 12px; font: italic 400 24px var(--serif); color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.4); }
.game-grid .cv .stamp { position: absolute; right: 10px; bottom: 12px; font: 600 20px var(--hand); background: var(--acc); color: var(--plum); padding: 0 8px; border-radius: 3px; transform: rotate(-5deg); }
.quote { font: italic 300 24px/1.22 var(--serif); margin: 10px 0 0; }
.quote::before { content: "“"; color: var(--acc); font-size: 1.6em; line-height: 0; vertical-align: -.35em; margin-right: 2px; }
.facts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.facts > span { font: 11px var(--mono); border: 1px solid var(--line2); border-radius: 99px; padding: 2px 9px; color: var(--ink2); }
/* essay */
.card.essay { background: #fbf6ee; }
.card.essay .pad { padding: 24px 26px 24px; }
.card.essay h3 { font-size: 32px; font-weight: 300; font-style: italic; line-height: 1.05; letter-spacing: -.02em; margin-top: 12px; }
.card.essay .ex { margin-top: 14px; font-size: 15.5px; line-height: 1.6; color: var(--ink2); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
.card.essay .ex::first-letter { font-size: 2.6em; float: left; line-height: .85; padding: 4px 6px 0 0; color: var(--rose-d); font-weight: 500; }
.card.essay .rd { margin-top: 14px; font: 11px var(--mono); color: var(--mute); display: flex; justify-content: space-between; border-top: 1px solid var(--line); padding-top: 10px; }
.card.essay.life { background: #e7ecdf; }
.card.essay.meaning { background: #f4e2d6; }
.essay-full { max-width: 680px; margin: 0 auto; padding: 8px 0 18px; }
.essay-full p { font-size: 18.5px; line-height: 1.72; color: var(--ink2); margin: 14px 0 0; }
.essay-full .end { font: 600 24px var(--hand); color: var(--rose-d); margin-top: 22px; }
/* art */
.card.art { background: transparent; border: 0; }
.card.art .im { border-radius: 6px; overflow: hidden; box-shadow: 0 10px 24px -10px rgba(42,26,31,.35); }
.card.art .im svg { width: 100%; height: auto; }
.card.art .pad { padding: 12px 2px 4px; }
.card.art.open { background: var(--card); border: 1px solid var(--line2); }
.card.art.open .pad { padding: 16px 18px; }
/* film */
.film-grid { display: grid; grid-template-columns: 40% 1fr; gap: 0; }
.film-grid .po { position: relative; aspect-ratio: 2/3; }
.film-grid .po svg { width: 100%; height: 100%; }
.film-grid .po span { position: absolute; left: 0; right: 0; bottom: 10px; text-align: center; font: 600 12px var(--serif); letter-spacing: .14em; text-transform: uppercase; color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.5); }
.rating { font: 12px var(--mono); color: var(--rose-d); margin-top: 10px; }
/* tape / video */
.video { aspect-ratio: 16/9; background: repeating-linear-gradient(135deg, #3a252c 0 10px, #33212a 10px 20px); position: relative; display: grid; place-items: center; color: #e3d4c1; font: 11px var(--mono); }
.video .vp { width: 56px; height: 56px; border-radius: 50%; border: 0; background: var(--acc); color: var(--plum); font-size: 16px; cursor: pointer; }
.video span { position: absolute; left: 12px; bottom: 10px; }
.setlist { list-style: none; padding: 0; margin: 12px 0 0; font-size: 14px; }
.setlist li { display: flex; justify-content: space-between; padding: 5px 2px; border-top: 1px solid #3e2a30; cursor: pointer; color: #e3d4c1; }
.setlist li:hover { color: #fff; }
.setlist li.on { color: var(--acc); }
.setlist li span:last-child { font: 11px var(--mono); color: #b8a592; }
/* live cards */
.card.live { background: #eee5d3; }
.live-src { display: inline-flex; align-items: center; gap: 6px; font: 11px var(--mono); color: var(--sage); }
.live-src i { width: 6px; height: 6px; border-radius: 50%; background: var(--sage); box-shadow: 0 0 0 3px rgba(111,127,69,.18); }
.covers { display: flex; gap: 8px; align-items: flex-end; margin-top: 14px; }
.covers .bk { flex: 1; aspect-ratio: 2/3; border-radius: 2px 4px 4px 2px; position: relative; padding: 8px 7px; color: #fff; font: italic 400 12px/1.1 var(--serif); box-shadow: 0 6px 12px -4px rgba(42,26,31,.35); overflow: hidden; transition: transform .2s var(--ease); }
.covers .bk::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: rgba(0,0,0,.18); }
.covers .bk.light { color: var(--ink); }
.covers .bk:hover { transform: translateY(-5px) rotate(-2deg); }
.covers .bk small { position: absolute; left: 7px; bottom: 6px; font: 9px var(--mono); font-style: normal; opacity: .85; }
.rlist { list-style: none; margin: 12px 0 0; padding: 0; font-size: 14.5px; }
.rlist li { display: flex; justify-content: space-between; gap: 10px; padding: 6px 0; border-top: 1px solid var(--line); }
.rlist li span:last-child { font: 12px var(--mono); color: var(--rose-d); white-space: nowrap; }
.rlist li small { font: 10.5px var(--mono); color: var(--mute); margin-left: 6px; }
.out { display: inline-block; margin-top: 12px; font: 11.5px var(--mono); color: var(--ink2); }
/* older */
.older { margin: 0 0 26px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; border-top: 1px solid var(--line); padding-top: 16px; font: 12px var(--mono); color: var(--mute); }
.older button { border: 1px solid var(--line2); background: var(--card); border-radius: 99px; padding: 6px 14px; cursor: pointer; font: 400 15px var(--serif); color: var(--ink); }
.older button:hover { background: var(--card2); }
.card.arc .th { height: 120px; }
.card.arc .th svg { width: 100%; height: 100%; }
.card.arc h3 { font-size: 17px; }
.empty { grid-column: 1 / -1; padding: 30px 0; font: 600 24px var(--hand); color: var(--mute); }
footer { border-top: 1px solid var(--line); padding: 22px 0 40px; display: flex; justify-content: space-between; gap: 24px; font: 12px/1.6 var(--mono); color: var(--mute); }
footer a { color: var(--ink2); text-underline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
@media (max-width: 1100px) {
.head { grid-template-columns: 1fr; gap: 22px; }
.bar .in { flex-wrap: wrap; height: auto; padding-top: 8px; padding-bottom: 8px; }
.chips { margin-left: 0; width: 100%; }
}
@media (max-width: 900px) {
.wall { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.card { grid-column: span min(6, calc(var(--span) * 2)); }
.now { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.now > div:nth-child(3) { border-left: 0; padding-left: 0; }
.reader { grid-template-columns: 1fr; }
}
@media (max-width: 620px) {
:root { --edge: 24px; }
.edge { font-size: 9px; }
.wrap, .bar .in { padding-left: 16px; padding-right: 16px; }
.card { grid-column: 1 / -1; }
.head h1 { font-size: 42px; }
.now { grid-template-columns: 1fr; }
.now > div + div { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); }
.game-grid, .film-grid { grid-template-columns: 1fr; }
.reader .pgbig { width: 150px; height: 200px; }
.reader .spread .pgbig:nth-child(2) { display: none; }
footer { flex-direction: column; }
}
</style>
</head>
<body>
<div class="mk" id="mk"><button onclick="document.getElementById('mk').remove()" aria-label="close">×</button>
<b>personal-r2-b · One big, rich wall</b>A calm editorial wall where every card is shaped by what it is (comic panels, a sleeve that plays, a cover with a pull-quote, a typeset essay, the drawing itself). "Now" across the top pulls in Hardcover and Trakt; chips filter by interest; a card opens in place to full width. New things get big cards, older ones settle smaller.</div>
<a class="edge" href="tech-r3-c.html" title="cross over to the work side"><span>work →</span></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><i>/ off the clock</i></div>
<nav class="chips" id="chips" aria-label="filter by interest"></nav>
</div>
</header>
<main>
<div class="wrap">
<section class="head">
<div>
<span class="lab">02 · off the clock</span>
<h1>Things I love <i>&amp; things I think</i></h1>
<p>A wall of what's caught me lately: comic pages, songs, games, the odd drawing, and some slower writing. Open anything.</p>
</div>
<div class="now" id="now" aria-label="right now"></div>
</section>
<div class="wall-top"><h2 id="wtitle">everything, newest first</h2><span class="s" id="wsum"></span></div>
<div class="wall" id="wall"></div>
<div class="wall" id="wall2"></div>
<div id="olderHost"></div>
<footer>
<span>Placeholders (dotted underline): every game, film, song, book, essay, drawing, commission, the band and its gig, and all Trakt/Hardcover data. Real: Now Recharging, Junkwraith, Cupid 13, the villainess trope and the pumpkin (from Bluesky). Pictures are drawn stand-ins.</span>
<span style="white-space:nowrap"><a href="https://bsky.app/profile/stereotypicalcat.bsky.social">Bluesky</a> · <a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a> · <a href="tech-r3-c.html">work</a></span>
</footer>
</div>
</main>
<script>
/* ===== shared content + drawn placeholders (same data in r2-a and r2-b) ===== */
const TODAY = new Date('2026-11-09');
const PH = s => '<span class="ph" title="placeholder">' + s + '</span>';
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fdate(d) { const x = new Date(d); return x.getDate() + ' ' + MONTHS[x.getMonth()] + ' ' + x.getFullYear(); }
function fshort(d) { const x = new Date(d); return x.getDate() + ' ' + MONTHS[x.getMonth()]; }
function ago(d) {
const days = Math.round((TODAY - new Date(d)) / 864e5);
if (days < 1) return 'today'; if (days === 1) return 'yesterday';
if (days < 14) return days + ' days ago'; if (days < 60) return Math.round(days / 7) + ' weeks ago';
return Math.round(days / 30) + ' months ago';
}
function mmss(s) { s = Math.max(0, Math.floor(s)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }
const SHELVES = [
{ id: 'comics', name: 'Comics', hand: 'pages that got me', total: 11 },
{ id: 'games', name: 'Games', hand: 'played, loved, backed', total: 7 },
{ id: 'music', name: 'Music', hand: 'on repeat, plus the band', total: 9 },
{ id: 'film', name: 'Film & TV', hand: 'what I watch', total: 6, live: 'Trakt' },
{ id: 'books', name: 'Books', hand: 'what I read', total: 23, live: 'Hardcover' },
{ id: 'made', name: 'Made by me', hand: 'drawings, commissions, a pumpkin', total: 5 },
{ id: 'writing', name: 'Writing', hand: 'slower thoughts', total: 4 },
];
/* art specs: bg / fg / acc + a motif the cover drawer understands */
const ITEMS = [
{ id: 'mitski', shelf: 'music', kind: 'song', date: '2026-11-04', ph: 1,
title: 'My Love Mine All Mine', by: 'Mitski', len: 138,
line: "been on repeat all week. it's so small and so tender 🥹",
art: { bg: '#c9706b', fg: '#2a1a1f', acc: '#f1e6d6', motif: 'moon' },
body: ["I keep putting this on at night when the flat is quiet. It's barely two minutes and it feels like it's being sung to the moon specifically, which, fair.", "The line about the only thing you get to keep is the love you gave away has been living in my head rent free. Press play, it's short, I promise."] },
{ id: 'pumpkin', shelf: 'made', kind: 'photo', date: '2026-10-31', ph: 0,
title: 'This year’s pumpkin', line: "carved a pumpkin this year! 🎃 he's a little lopsided but he's mine",
art: { draw: 'pumpkin' }, label: 'photo: the pumpkin, lit, on the windowsill',
body: ["First pumpkin in years. The left eye went a bit wrong so I decided he's winking. Took about an hour and most of a spoon.", "He lasted four days before going soft. Rest well, little guy."] },
{ id: 'nowrecharging', shelf: 'comics', kind: 'comic', date: '2026-10-22', ph: 0,
title: 'Now Recharging', sub: 'webcomic · nowrecharging.com', url: 'https://nowrecharging.com',
line: 'i cried so much in this chapter – death has always been something that hits me emotionally hard',
pal: 'nr', pages: 4,
body: ["I've been following this for a while and this chapter just flattened me. It handles death so gently, nobody gets a big speech, it just sits with you.", "Go read it from the start, it's free on the site. Then come back and cry with me."] },
{ id: 'lanternfall', shelf: 'games', kind: 'kickstarter', date: '2026-10-12', ph: 1,
title: 'Lanternfall', sub: 'Kickstarter · backed Mar 2024 · Switch', hours: 14,
line: 'backed it two and a half years ago, it finally arrived and I played 14 hours in a week',
quote: 'It feels like someone made a game just for the version of me that stays up too late.',
art: { bg: '#2f3a3a', fg: '#e8b04a', acc: '#f1e6d6', motif: 'lantern' },
body: ["I backed this at the 180 DKK tier because the trailer had a fox carrying a lantern and that was enough. Then the delays. Then more delays. I'd honestly forgotten.", "It's a small cozy-ish metroidvania where light is your map: you hang lanterns and the world remembers where you've been. The music is gorgeous. The last boss made me put the controller down and walk around the flat.", "My name is in the credits under 'lantern keepers', which I will be telling everyone about."] },
{ id: 'crying', shelf: 'writing', kind: 'essay', date: '2026-10-03', ph: 1,
title: 'Why I let stories make me cry', mins: 6, tag: 'essay',
line: 'On comics, death, and why a sad chapter can be the best part of my week.',
body: ["Death has always hit me hard. Not in an abstract way: in the way where a character I've known for forty pages dies and I have to stop reading and look at the wall for a bit.", "For a long time I thought that was something to grow out of. It isn't. It's the point. A story that can make me grieve someone who never existed is a story that's practising something with me, quietly, so that the real thing is a little less foreign when it comes.", "Comics are especially good at this because they control time. A whole page can be one panel of silence. Now Recharging does this better than almost anything I've read lately.", "So: I let them. I read the sad chapter on a Sunday, with tea, and I let it land."] },
{ id: 'junkwraith', shelf: 'comics', kind: 'comic', date: '2026-09-18', ph: 0,
title: 'Junkwraith', sub: 'new comic',
line: 'a new comic with an interesting art style… feels like a coming-of-age story with a unique world',
pal: 'jw', pages: 3,
body: ["Picked this up on a whim and the art style grabbed me straight away: all scrap metal and soft light.", "It's early days but it already feels like a coming-of-age story set somewhere that's never been drawn before. Keeping an eye on this one."] },
{ id: 'caferetro', shelf: 'music', kind: 'tape', date: '2026-09-07', ph: 1,
title: 'Live at Café Retro, side A', by: 'Lav Sol (our band)', len: 1209,
line: 'handcam from the bar. eleven people and a dog, best gig we’ve had',
set: [['Grå morgen', 252], ['Kitchen light', 208], ['Slow ferry', 301], ['Halvvejs', 196], ['Paper boats', 252]],
body: ["A friend filmed this on her phone from the bar, so you get the espresso machine in Halvvejs for free.", "The band is a Thursday-night thing: four of us, a rehearsal room that smells of old carpet, and a gig every couple of months."] },
{ id: 'perfectdays', shelf: 'film', kind: 'film', date: '2026-08-30', ph: 1,
title: 'Perfect Days', sub: 'Wim Wenders · 2023', rating: 10,
line: 'a man cleans toilets in Tokyo and it’s the most hopeful film I’ve seen in years',
art: { bg: '#d9c39a', fg: '#2f4b46', acc: '#c9706b', motif: 'trees' },
body: ["Nothing happens and everything happens. He wakes up, waters his little trees, listens to cassettes in the van, takes a photo of the light through the leaves.", "I came out of it wanting to own fewer things and notice more. That feeling lasted about three days, which is longer than most films manage."] },
{ id: 'cupid13', shelf: 'comics', kind: 'comic', date: '2026-08-14', ph: 0,
title: 'Cupid 13', sub: 'MangaPlus one-shot',
line: 'a one-shot about a forsaken sniper who regains faith. so much in so few pages',
pal: 'c13', pages: 3,
body: ["A one-shot on MangaPlus about a sniper everyone gave up on, and who gave up on himself, finding his faith again.", "One-shots that land in under sixty pages are a magic trick to me. This one does."] },
{ id: 'knightcat', shelf: 'made', kind: 'commission', date: '2026-07-21', ph: 1,
title: 'Pixel as a tiny knight', by: 'commission by @placeholder.artist',
line: 'commissioned a drawing of my cat as a knight and I will never recover',
art: { draw: 'knightcat' },
body: ["I commissioned this from an artist I've followed for ages. The brief was just 'my cat, but brave'. She nailed it.", "It's framed above my desk now, guarding me from bugs."] },
{ id: 'outerwilds', shelf: 'games', kind: 'game', date: '2026-07-02', ph: 1,
title: 'Outer Wilds', sub: 'Mobius Digital · 2019 · PC', hours: 31,
line: 'the best museum of a universe I’ve ever wandered through',
quote: 'The only progression is what you know. I’d give anything to play it for the first time again.',
art: { bg: '#1f2a3a', fg: '#e8b04a', acc: '#c9706b', motif: 'planet' },
body: ["You have 22 minutes before the sun goes supernova, and then you wake up again by the campfire. Nothing in your inventory changes. Only you do.", "I won't say more because the whole game is the not-knowing. Play it with a notebook. Don't look anything up."] },
{ id: 'villainess', shelf: 'comics', kind: 'trope', date: '2026-06-20', ph: 0,
title: 'The villainess, reborn', sub: 'a trope I can’t quit',
line: 'villainess reborn as a good person, but everyone around her is psychopathic and possessive. I am weak for this',
pal: 'vl', pages: 3,
body: ["She wakes up as the villain of a novel she's read, decides to just be nice this time, and somehow everyone around her becomes deeply, alarmingly obsessed with her.", "I know. I KNOW. I've read too many of these. A few favourites are in the 'see all' shelf, titles marked as placeholders until I dig up the real list."] },
{ id: 'frog', shelf: 'made', kind: 'drawing', date: '2026-06-08', ph: 1,
title: 'Frog with umbrella', line: 'a little doodle from a rainy lunch break 🐸',
art: { draw: 'frog' },
body: ["Drawn on the back of a receipt in about four minutes. He's not keeping the rain off, he's just enjoying having an umbrella."] },
{ id: 'lifesofar', shelf: 'writing', kind: 'notebook', date: '2026-04-18', ph: 1,
title: 'Life so far, part one', mins: 11, tag: 'life',
line: 'Growing up, the rowing machine in the basement, and the first website I ever made.',
body: ["I was the kid who took the family PC apart to see where the internet lived. It was not in there.", "Part one is the early years: Lego, a tribute page to Norman Borlaug for freeCodeCamp that I am still weirdly proud of, and the summer I learned that soldering irons are hot on both ends.", "Part two, whenever I write it, is university, the band, and moving out."] },
{ id: 'hiatus', shelf: 'music', kind: 'song', date: '2026-04-02', ph: 1,
title: 'Building a Ladder', by: 'Hiatus Kaiyote', len: 245,
line: 'the chord at 2:40. that’s it, that’s the post',
art: { bg: '#3e5a47', fg: '#e8b04a', acc: '#f1e6d6', motif: 'stairs' },
body: ["I've tried to learn this on guitar three times. I can get to the chorus. The chord at 2:40 is beyond me and possibly beyond physics."] },
{ id: 'arcane', shelf: 'film', kind: 'film', date: '2026-03-11', ph: 1,
title: 'Arcane', sub: 'Season 2 · Netflix', rating: 9,
line: 'every frame looks like a painting someone was scared to finish',
art: { bg: '#2c2440', fg: '#e8b04a', acc: '#c9706b', motif: 'city' },
body: ["I don't even play League any more but this got me. The animation is unreal, and the sisters' story is the heart of it."] },
{ id: 'meaning', shelf: 'writing', kind: 'card', date: '2026-02-14', ph: 1,
title: 'What I think the point is (draft 3)', mins: 4, tag: 'meaning of life',
line: 'Short answer: other people, and making small things for them.',
body: ["Draft three, because drafts one and two were too sure of themselves.", "What I keep coming back to: the point is other people, and making small things for them. A pumpkin, a song shared at the right moment, a bug fixed before anyone notices.", "I'll probably disagree with this by draft four. That's also the point."] },
{ id: 'celeste', shelf: 'games', kind: 'game', date: '2026-01-19', ph: 1,
title: 'Celeste', sub: 'Maddy Makes Games · 2018', hours: 22,
line: 'replayed it in January. still the kindest hard game',
quote: 'It’s a game about climbing a mountain and also about being nice to yourself while you do.',
art: { bg: '#3a2733', fg: '#f1e6d6', acc: '#c9706b', motif: 'mountain' },
body: ["Replayed it over the holidays and got all the strawberries this time. The B-sides still make me sweat."] },
];
/* pulled from the services: shaped like their APIs, all placeholder */
const TRAKT = {
synced: '2 h ago', user: 'stereotypicalcat',
watching: { show: 'Frieren: Beyond Journey’s End', ep: 'S2 · E07', title: 'A Small Detour', progress: 0.58, art: { bg: '#3a4a5a', fg: '#f1e6d6', acc: '#e8b04a', motif: 'sparkle' } },
ratings: [ ['Perfect Days', 'film', 10, '30 Aug'], ['The Bear', 'S4', 7, '12 Aug'], ['Dune: Part Two', 'film', 8, '2 Aug'], ['Arcane', 'S2', 9, '11 Mar'] ],
stats: '41 films and 12 shows this year'
};
const HARDCOVER = {
synced: '5 h ago', user: 'stereotypicalcat',
reading: { title: 'Piranesi', by: 'Susanna Clarke', pct: 62, page: 152, of: 245, c: '#4f6a75' },
read: [
{ title: 'Tomorrow, and Tomorrow, and Tomorrow', by: 'Gabrielle Zevin', stars: 5, c: '#c9706b', h: 188, w: 40, d: 'Oct' },
{ title: 'Project Hail Mary', by: 'Andy Weir', stars: 4.5, c: '#e8b04a', h: 196, w: 42, d: 'Sep' },
{ title: 'The House in the Cerulean Sea', by: 'TJ Klune', stars: 4, c: '#6b8f9a', h: 172, w: 34, d: 'Aug' },
{ title: 'Klara and the Sun', by: 'Kazuo Ishiguro', stars: 4, c: '#d9c39a', h: 180, w: 30, d: 'Jul' },
{ title: 'Spinning Silver', by: 'Naomi Novik', stars: 3.5, c: '#5d6e4f', h: 200, w: 46, d: 'Jun' },
],
stats: '19 of 30 books toward the 2026 goal'
};
/* older things for the "see all" views (all placeholder) */
const ARCHIVE = {
comics: [['Lore of the Lighthouse', '2025'], ['The Duchess Will Not Be Moved', '2025'], ['Beneath the Rainline', '2025'], ['A Sky Full of Moths', '2025'], ['Her Majesty’s Very Polite Knights', '2024'], ['Small Hours', '2024'], ['Return of the Iron Countess', '2024']],
games: [['Hollow Knight', '2025'], ['Tunic', '2025'], ['A Short Hike', '2024'], ['Stardew Valley', '2024']],
music: [['Phoebe Bridgers, Motion Sickness', '2025'], ['Lav Sol, basement gig', '2025'], ['Nujabes, Aruarian Dance', '2025'], ['Japanese Breakfast, Be Sweet', '2024'], ['Lav Sol, kitchen demos', '2024'], ['Bon Iver, Holocene', '2024']],
film: [['Spirited Away', '2025'], ['Past Lives', '2025'], ['Blue Eye Samurai', '2024'], ['Paddington 2', '2024']],
books: [['The Left Hand of Darkness', '2025'], ['Never Let Me Go', '2025'], ['A Psalm for the Wild-Built', '2025'], ['The Remains of the Day', '2024'], ['Babel', '2024'], ['Legends & Lattes', '2024']],
made: [['Sketchbook: trains', '2025'], ['Birthday card for mum', '2024']],
writing: [['On being bad at guitar, on purpose', '2025']],
};
const ARC_COLORS = ['#7a4f4a', '#4f6a75', '#5d6e4f', '#8a6a3a', '#6b4a63', '#3e5a47', '#9a5a4a', '#4a4f6b'];
/* ---------- drawn placeholders ---------- */
function coverSVG(a, w, h, extra) {
w = w || 100; h = h || 100;
const { bg, fg, acc } = a; let m = '';
const cx = w / 2;
switch (a.motif) {
case 'moon': m = `<circle cx="${w * .62}" cy="${h * .38}" r="${w * .2}" fill="${acc}"/><circle cx="${w * .7}" cy="${h * .33}" r="${w * .18}" fill="${bg}"/><path d="M0 ${h * .78} Q ${w * .3} ${h * .7} ${w * .55} ${h * .8} T ${w} ${h * .74} V ${h} H0Z" fill="${fg}" opacity=".85"/><circle cx="${w * .22}" cy="${h * .2}" r="1.2" fill="${acc}"/><circle cx="${w * .34}" cy="${h * .44}" r="1" fill="${acc}"/><circle cx="${w * .85}" cy="${h * .6}" r="1" fill="${acc}"/>`; break;
case 'stairs': for (let i = 0; i < 6; i++) m += `<rect x="${w * .1 + i * w * .13}" y="${h * .82 - i * h * .11}" width="${w * .14}" height="${h}" fill="${i % 2 ? fg : acc}" opacity="${.55 + i * .07}"/>`; m += `<circle cx="${w * .8}" cy="${h * .2}" r="${w * .08}" fill="${fg}"/>`; break;
case 'lantern': m = `<rect x="0" y="${h * .72}" width="${w}" height="${h * .28}" fill="#00000033"/><path d="M${w * .2} ${h * .72} q ${w * .1} -${h * .25} ${w * .25} -${h * .2}" stroke="${acc}" stroke-width="1.2" fill="none" opacity=".6"/><line x1="${cx}" y1="0" x2="${cx}" y2="${h * .3}" stroke="${acc}" stroke-width="1"/><circle cx="${cx}" cy="${h * .45}" r="${w * .28}" fill="${fg}" opacity=".12"/><circle cx="${cx}" cy="${h * .45}" r="${w * .17}" fill="${fg}" opacity=".22"/><rect x="${cx - w * .08}" y="${h * .32}" width="${w * .16}" height="${h * .24}" rx="3" fill="${fg}"/><rect x="${cx - w * .1}" y="${h * .3}" width="${w * .2}" height="${h * .03}" fill="${acc}"/><path d="M${w * .66} ${h * .72} l ${w * .06} -${h * .08} l ${w * .03} ${h * .04} l ${w * .08} -${h * .02} l -${w * .02} ${h * .06}z" fill="${acc}"/>`; break;
case 'planet': m = `<circle cx="${w * .42}" cy="${h * .5}" r="${w * .22}" fill="${acc}"/><ellipse cx="${w * .42}" cy="${h * .5}" rx="${w * .36}" ry="${h * .07}" fill="none" stroke="${fg}" stroke-width="1.6" transform="rotate(-14 ${w * .42} ${h * .5})"/><circle cx="${w * .8}" cy="${h * .2}" r="${w * .06}" fill="${fg}"/>` + [[.15, .2], [.7, .75], [.88, .5], [.2, .85], [.55, .12]].map(p => `<circle cx="${w * p[0]}" cy="${h * p[1]}" r="1" fill="${fg}"/>`).join(''); break;
case 'mountain': m = `<path d="M0 ${h} L ${w * .38} ${h * .3} L ${w * .52} ${h * .5} L ${w * .7} ${h * .22} L ${w} ${h * .7} V ${h}Z" fill="${acc}" opacity=".9"/><path d="M${w * .7} ${h * .22} l -${w * .07} ${h * .12} l ${w * .05} -${h * .02} l ${w * .03} ${h * .05} l ${w * .04} -${h * .06}z" fill="${fg}"/><circle cx="${w * .88}" cy="${h * .16}" r="${w * .05}" fill="${fg}"/>`; break;
case 'trees': m = `<rect x="0" y="${h * .66}" width="${w}" height="${h * .34}" fill="${fg}" opacity=".18"/>` + [.18, .36, .6, .8].map((x, i) => `<circle cx="${w * x}" cy="${h * (.42 + (i % 2) * .08)}" r="${w * (.13 - i * .01)}" fill="${fg}" opacity="${.5 + i * .12}"/><rect x="${w * x - 1}" y="${h * (.5 + (i % 2) * .08)}" width="2" height="${h * .2}" fill="${fg}"/>`).join('') + `<circle cx="${w * .82}" cy="${h * .16}" r="${w * .06}" fill="${acc}"/>`; break;
case 'city': for (let i = 0; i < 8; i++) { const bh = h * (.25 + ((i * 37) % 40) / 100); m += `<rect x="${i * w / 8}" y="${h - bh}" width="${w / 8 - 1}" height="${bh}" fill="${i % 3 ? fg : acc}" opacity="${.35 + (i % 4) * .15}"/>`; } m += `<circle cx="${w * .3}" cy="${h * .25}" r="${w * .1}" fill="${acc}" opacity=".9"/>`; break;
case 'sparkle': m = `<path d="M${w * .5} ${h * .15} L ${w * .55} ${h * .45} L ${w * .85} ${h * .5} L ${w * .55} ${h * .55} L ${w * .5} ${h * .85} L ${w * .45} ${h * .55} L ${w * .15} ${h * .5} L ${w * .45} ${h * .45}Z" fill="${acc}" opacity=".9"/><circle cx="${w * .2}" cy="${h * .2}" r="1.5" fill="${fg}"/><circle cx="${w * .82}" cy="${h * .8}" r="1.5" fill="${fg}"/>`; break;
default: m = `<circle cx="${cx}" cy="${h / 2}" r="${w * .25}" fill="${acc}"/>`;
}
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="${w}" height="${h}" fill="${bg}"/>${m}${extra || ''}</svg>`;
}
const PAL = {
nr: { paper: '#f6efe3', ink: '#3a3f4a', tint: '#cfdde0', acc: '#e7a1a0', tone: '#9fb4bb' },
jw: { paper: '#ece6d4', ink: '#2f3a2c', tint: '#c9cfae', acc: '#b4623e', tone: '#8a9470' },
c13: { paper: '#f5f3ee', ink: '#1d1b1a', tint: '#e2dfd8', acc: '#1d1b1a', tone: '#9c9790' },
vl: { paper: '#faf0ee', ink: '#4a2a36', tint: '#f1d3d6', acc: '#c9706b', tone: '#d9a5ae' },
};
function panelArt(kind, p, uid) {
switch (kind) {
case 'fig': return `<rect width="100" height="100" fill="${p.tint}"/><circle cx="50" cy="44" r="16" fill="${p.ink}"/><path d="M18 100 Q 22 66 50 64 Q 78 66 82 100Z" fill="${p.ink}"/><circle cx="56" cy="42" r="2.5" fill="${p.paper}"/>`;
case 'bubble': return `<rect width="100" height="100" fill="${p.paper}"/><ellipse cx="52" cy="40" rx="38" ry="22" fill="#fff" stroke="${p.ink}" stroke-width="2"/><path d="M40 58 L 30 76 L 52 60" fill="#fff" stroke="${p.ink}" stroke-width="2"/><line x1="30" y1="34" x2="74" y2="34" stroke="${p.tone}" stroke-width="3"/><line x1="30" y1="44" x2="64" y2="44" stroke="${p.tone}" stroke-width="3"/><circle cx="76" cy="88" r="10" fill="${p.ink}"/>`;
case 'land': return `<rect width="100" height="100" fill="${p.tint}"/><circle cx="72" cy="30" r="13" fill="${p.acc}"/><path d="M0 70 Q 30 56 55 68 T 100 62 V100 H0Z" fill="${p.tone}"/><path d="M0 84 Q 40 74 100 86 V100 H0Z" fill="${p.ink}"/>`;
case 'eye': return `<rect width="100" height="100" fill="${p.paper}"/><path d="M8 52 Q 50 14 92 52 Q 50 86 8 52Z" fill="#fff" stroke="${p.ink}" stroke-width="3"/><circle cx="50" cy="51" r="16" fill="${p.ink}"/><circle cx="55" cy="46" r="4" fill="#fff"/><path d="M60 70 q 3 10 0 18" stroke="${p.acc}" stroke-width="3" fill="none"/>`;
case 'tone': return `<defs><pattern id="d${uid}" width="5" height="5" patternUnits="userSpaceOnUse"><circle cx="2.5" cy="2.5" r="1.1" fill="${p.tone}"/></pattern></defs><rect width="100" height="100" fill="${p.paper}"/><rect width="100" height="100" fill="url(#d${uid})"/>` + Array.from({ length: 9 }, (_, i) => `<line x1="50" y1="50" x2="${50 + 80 * Math.cos(i * .7)}" y2="${50 + 80 * Math.sin(i * .7)}" stroke="${p.ink}" stroke-width="1.2" opacity=".5"/>`).join('') + `<circle cx="50" cy="50" r="10" fill="${p.ink}"/>`;
case 'cross': return `<rect width="100" height="100" fill="${p.tint}"/><circle cx="50" cy="50" r="30" fill="none" stroke="${p.ink}" stroke-width="2.5"/><line x1="50" y1="8" x2="50" y2="92" stroke="${p.ink}" stroke-width="1.5"/><line x1="8" y1="50" x2="92" y2="50" stroke="${p.ink}" stroke-width="1.5"/><circle cx="50" cy="50" r="3" fill="${p.acc === p.ink ? '#b33' : p.acc}"/>`;
case 'rose': return `<rect width="100" height="100" fill="${p.tint}"/>` + [[25, 30], [70, 24], [50, 70], [18, 78], [84, 72]].map(c => `<circle cx="${c[0]}" cy="${c[1]}" r="11" fill="${p.acc}"/><circle cx="${c[0]}" cy="${c[1]}" r="5" fill="${p.tone}"/>`).join('') + `<path d="M50 20 l3 8 8 3 -8 3 -3 8 -3 -8 -8 -3 8 -3z" fill="#fff"/>`;
case 'scrap': return `<rect width="100" height="100" fill="${p.tint}"/><path d="M0 100 L 10 60 L 30 72 L 42 40 L 60 66 L 74 48 L 100 78 V 100Z" fill="${p.tone}"/><path d="M0 100 L 20 80 L 46 90 L 70 74 L 100 92 V100Z" fill="${p.ink}"/><circle cx="62" cy="26" r="9" fill="${p.acc}"/>`;
default: return `<rect width="100" height="100" fill="${p.tint}"/>`;
}
}
const LAYOUTS = [
[[4, 4, 112, 52], [4, 60, 54, 46], [62, 60, 54, 46], [4, 110, 112, 46]],
[[4, 4, 54, 102], [62, 4, 54, 49], [62, 57, 54, 49], [4, 110, 112, 46]],
[[4, 4, 112, 36], [4, 44, 74, 64], [82, 44, 34, 64], [4, 112, 112, 44]],
];
const KINDS = { nr: ['land', 'fig', 'bubble', 'eye'], jw: ['scrap', 'fig', 'land', 'bubble'], c13: ['cross', 'eye', 'tone', 'fig'], vl: ['rose', 'fig', 'bubble', 'eye'] };
let UID = 0;
function comicPageSVG(pal, n) {
const p = PAL[pal], L = LAYOUTS[n % 3], K = KINDS[pal];
let s = `<svg viewBox="0 0 120 160" aria-hidden="true"><rect width="120" height="160" fill="${p.paper}"/>`;
L.forEach((r, i) => {
const k = K[(i + n) % K.length]; const id = ++UID;
s += `<svg x="${r[0]}" y="${r[1]}" width="${r[2]}" height="${r[3]}" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice">${panelArt(k, p, id)}</svg><rect x="${r[0]}" y="${r[1]}" width="${r[2]}" height="${r[3]}" fill="none" stroke="${p.ink}" stroke-width="1.4"/>`;
});
return s + '</svg>';
}
function drawingSVG(kind) {
if (kind === 'pumpkin') return `<svg viewBox="0 0 160 120" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="160" height="120" fill="#1e1416"/><rect y="88" width="160" height="32" fill="#2f2124"/><rect x="0" y="0" width="160" height="88" fill="#241a20"/><line x1="0" y1="88" x2="160" y2="88" stroke="#3d2b2f"/><circle cx="80" cy="66" r="44" fill="#e8b04a" opacity=".08"/><ellipse cx="66" cy="70" rx="20" ry="22" fill="#c86a2c"/><ellipse cx="94" cy="70" rx="20" ry="22" fill="#c86a2c"/><ellipse cx="80" cy="70" rx="20" ry="24" fill="#d97a34"/><path d="M78 47 q 2 -10 9 -12" stroke="#5d6e4f" stroke-width="4" fill="none" stroke-linecap="round"/><path d="M66 62 l6 -8 6 8z" fill="#ffd27a"/><path d="M86 61 q 4 -3 9 0" stroke="#ffd27a" stroke-width="2.5" fill="none" stroke-linecap="round"/><path d="M64 76 q 16 12 32 0 l -4 2 -3 -3 -4 4 -4 -4 -4 4 -4 -4 -3 3z" fill="#ffd27a"/></svg>`;
if (kind === 'knightcat') return `<svg viewBox="0 0 120 150" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="120" height="150" fill="#e9d6c4"/><circle cx="60" cy="64" r="44" fill="#c9706b" opacity=".35"/><path d="M28 140 Q 30 96 60 94 Q 90 96 92 140Z" fill="#7d8a96"/><path d="M60 98 v 40" stroke="#5b6670" stroke-width="2"/><circle cx="60" cy="70" r="26" fill="#3a2a2a"/><path d="M38 56 l 4 -22 12 14z M82 56 l -4 -22 -12 14z" fill="#3a2a2a"/><path d="M36 62 Q 60 42 84 62 V 70 H 36Z" fill="#9aa5ae"/><rect x="36" y="66" width="48" height="5" fill="#5b6670"/><circle cx="51" cy="78" r="3" fill="#e8b04a"/><circle cx="69" cy="78" r="3" fill="#e8b04a"/><path d="M57 86 l3 3 3 -3" stroke="#e9d6c4" stroke-width="1.5" fill="none"/><path d="M98 50 L 98 128" stroke="#cfd6db" stroke-width="4" stroke-linecap="round"/><path d="M90 116 h 16" stroke="#8a6a3a" stroke-width="4" stroke-linecap="round"/><path d="M60 40 q 6 -14 14 -8" stroke="#c9706b" stroke-width="3" fill="none"/></svg>`;
if (kind === 'frog') return `<svg viewBox="0 0 140 120" preserveAspectRatio="xMidYMid slice" aria-hidden="true"><rect width="140" height="120" fill="#f6efe2"/><g fill="none" stroke="#2a1a1f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M40 52 Q 70 14 100 52 Z" fill="#c9706b" fill-opacity=".55"/><path d="M70 52 V 84 q 0 6 -6 6"/><ellipse cx="72" cy="94" rx="22" ry="12" fill="#a7b86c" fill-opacity=".6"/><circle cx="62" cy="82" r="5" fill="#fff"/><circle cx="82" cy="82" r="5" fill="#fff"/><circle cx="63" cy="82" r="1.5" fill="#2a1a1f"/><circle cx="81" cy="82" r="1.5" fill="#2a1a1f"/><path d="M66 98 q 6 4 12 0"/><path d="M30 30 l -2 6 M24 50 l -2 6 M112 34 l -2 6 M118 62 l -2 6 M34 72 l -2 6"/></g></svg>`;
return '';
}
/* one shared fake audio clock: advances only after the visitor presses play */
const PLAYER = { id: null, t: 0, timer: null, subs: [] };
function playerToggle(id, len) {
if (PLAYER.id === id && PLAYER.timer) { clearInterval(PLAYER.timer); PLAYER.timer = null; }
else {
if (PLAYER.id !== id) PLAYER.t = 0;
PLAYER.id = id; clearInterval(PLAYER.timer);
PLAYER.timer = setInterval(() => { PLAYER.t += .25; if (PLAYER.t >= len) { PLAYER.t = 0; clearInterval(PLAYER.timer); PLAYER.timer = null; } playerEmit(); }, 250);
}
playerEmit();
}
function playerEmit() { PLAYER.subs.forEach(f => f(PLAYER)); }
function playerSeek(id, len, frac) { if (PLAYER.id !== id) { PLAYER.id = id; } PLAYER.t = frac * len; playerEmit(); }
const byId = id => ITEMS.find(i => i.id === id);
/* ===================== r2-b: the wall ===================== */
const title = it => it.ph ? PH(it.title) : it.title;
const sorted = ITEMS.slice().sort((a, b) => new Date(b.date) - new Date(a.date));
const INTERESTS = [
['all', 'everything'], ['comics', 'comics'], ['games', 'games'], ['music', 'music'], ['film', 'film & tv'],
['books', 'books'], ['made', 'made by me'], ['writing', 'writing'], ['life', 'life'],
];
const tagsOf = it => { const t = [it.shelf]; if (it.tag === 'life' || it.tag === 'meaning of life') t.push('life'); return t; };
/* size in 12ths: newer + richer things get more room */
const SPAN = { mitski: 3, pumpkin: 4, nowrecharging: 6, lanternfall: 5, crying: 3, junkwraith: 4, caferetro: 5, perfectdays: 5, cupid13: 3, knightcat: 3, outerwilds: 6, villainess: 3, frog: 3, lifesofar: 4, hiatus: 4, arcane: 4, meaning: 4, celeste: 4, _hardcover: 5, _trakt: 3 };
function playerBox(it) {
return `<div class="mini" data-mini="${it.id}" data-len="${it.len}"><button class="pp" aria-label="play">▶</button><div class="bar2"><i></i></div><span class="tm">0:00 / ${mmss(it.len)}</span></div>`;
}
function metaLine(it, label) {
const fresh = (TODAY - new Date(it.date)) / 864e5 < 21;
return `<div class="meta">${fresh ? '<span class="new">new</span>' : ''}<span>${label}</span><span>·</span><span>${fshort(it.date)}</span></div>`;
}
function panelsHTML(it, three, h) {
const p = PAL[it.pal], K = KINDS[it.pal];
const n = three ? 3 : 3;
return `<div class="panels${three ? ' three' : ''}" style="--pp:${p.paper};--pi:${p.ink};--ph:${h}px">${[0, 1, 2].slice(0, n).map(i => `<div><svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice">${panelArt(K[i], p, ++UID)}</svg></div>`).join('')}</div>`;
}
const paras = it => it.body.map(p => `<p>${it.ph ? PH(p) : p}</p>`).join('');
function cardHTML(it) {
const sp = SPAN[it.id] || 3; const said = `<div class="said">“${it.line}”</div>`;
const tags = tagsOf(it).join(' ');
const open = `<div class="open-hint">open ↘</div>`;
const X = `<button class="x-close" data-close>esc · close</button>`;
switch (it.kind) {
case 'comic': case 'trope': {
const wide = sp >= 6;
return `<article class="card comic" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="teaser">${panelsHTML(it, wide, wide ? 230 : 210)}<div class="pad">${metaLine(it, it.kind === 'trope' ? 'comics · a trope' : 'comic')}<h3>${it.title}</h3>${said}${open}</div></div>
<div class="full"><div class="reader"><div><div class="spread" data-spread></div><div class="ctl"><button data-prev>← prev</button><span data-pos></span><button data-next>next →</button><span style="margin-left:auto">← → keys</span></div></div>
<div class="prose">${metaLine(it, 'comic · ' + it.sub)}<h3 style="font-size:36px;margin-top:8px">${it.title}</h3>${said}${paras(it)}${it.url ? `<a class="src" href="${it.url}">read it at nowrecharging.com ↗</a>` : `<span class="src">${PH('where to read it ↗')}</span>`}<div class="facts" style="margin-top:14px"><span>drawn stand-ins for the shared pages</span></div></div></div></div></article>`;
}
case 'song':
return `<article class="card dark song" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="sleeve" data-sleeve="${it.id}" style="--c:${it.art.bg}"><div class="rec"></div><div class="slv">${coverSVG(it.art, 200, 200)}</div><button class="bigpp" data-mini-btn="${it.id}" data-len="${it.len}" aria-label="play">▶</button></div>
<div class="pad">${metaLine(it, 'song')}<h3>${title(it)}</h3><div class="meta" style="text-transform:none;letter-spacing:0;margin-top:2px;font-size:12px">${PH(it.by)}</div>${playerBox(it)}
<div class="teaser">${said}</div><div class="full prose">${said}${paras(it)}<span class="src">${PH('Bandcamp ↗ · Spotify ↗')}</span></div></div></article>`;
case 'tape':
return `<article class="card dark tape" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="video"><button class="vp" data-mini-btn="${it.id}" data-len="${it.len}" aria-label="play">▶</button><span>handcam video: Café Retro, from the bar</span></div>
<div class="pad">${metaLine(it, 'the band · live')}<h3>${PH(it.title)}</h3>${playerBox(it)}<div class="teaser">${said}</div>
<div class="full"><div style="display:grid;grid-template-columns:1fr 1fr;gap:30px"><div class="prose">${said}${paras(it)}</div><ul class="setlist" data-setlist>${it.set.map((x, i) => `<li data-at="${it.set.slice(0, i).reduce((n, y) => n + y[1], 0)}"><span>${i + 1}. ${PH(x[0])}</span><span>${mmss(x[1])}</span></li>`).join('')}</ul></div></div></div></article>`;
case 'game': case 'kickstarter':
if (sp >= 5) return `<article class="card game" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="game-grid"><div class="cv">${coverSVG(it.art, 260, 300)}<span class="t">${it.title}</span>${it.kind === 'kickstarter' ? '<span class="stamp">backed!</span>' : ''}</div>
<div class="pad">${metaLine(it, it.kind === 'kickstarter' ? 'game · kickstarter' : 'game')}<p class="quote">${PH(it.quote)}</p><h3 style="font-size:18px;margin-top:12px">${title(it)} <span style="color:var(--mute);font-weight:300;font-size:14px">· ${PH(it.hours + ' h')}</span></h3>
<div class="full prose">${paras(it)}<div class="facts"><span>${PH(it.sub)}</span>${it.kind === 'kickstarter' ? `<span>${PH('180 DKK tier · name in the credits')}</span>` : ''}</div></div>${open}</div></div></article>`;
return `<article class="card game" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="gcv" style="position:relative;height:180px">${coverSVG(it.art, 300, 180)}<span style="position:absolute;left:14px;top:10px;font:italic 400 24px var(--serif);color:#fff">${it.title}</span></div>
<div class="pad">${metaLine(it, 'game')}<p class="quote" style="font-size:20px">${PH(it.quote)}</p><div class="full prose">${paras(it)}<div class="facts"><span>${PH(it.sub)}</span><span>${PH(it.hours + ' h played')}</span></div></div>${open}</div></article>`;
case 'film':
return `<article class="card film" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="film-grid"><div class="po">${coverSVG(it.art, 200, 300)}<span>${it.title}</span></div><div class="pad">${metaLine(it, 'film & tv')}<h3>${title(it)}</h3><div class="meta" style="text-transform:none;letter-spacing:0;margin-top:2px;font-size:12px">${PH(it.sub)}</div>${said}<div class="rating">♥ ${PH(it.rating + '/10 on Trakt')}</div>
<div class="full prose">${paras(it)}</div>${open}</div></div></article>`;
case 'photo': case 'commission': case 'drawing':
return `<article class="card art" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="teaser"><div class="im">${drawingSVG(it.art.draw)}</div><div class="pad">${metaLine(it, it.kind === 'commission' ? 'commissioned' : it.kind === 'photo' ? 'made by me · photo' : 'made by me · doodle')}<h3 style="font-size:19px">${title(it)}</h3></div></div>
<div class="full"><div style="display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:32px;padding:18px"><div class="im" style="border-radius:6px;overflow:hidden">${drawingSVG(it.art.draw)}</div>
<div class="prose">${metaLine(it, 'made by me')}<h3 style="font-size:34px;margin-top:8px">${title(it)}</h3>${it.by ? `<div class="meta" style="text-transform:none;margin-top:4px">${PH(it.by)}</div>` : ''}<div class="said">“${it.line}”</div>${paras(it)}<div class="facts"><span>${it.label || PH('drawn stand-in for the real picture')}</span></div></div></div></div></article>`;
default: {
const cls = it.tag === 'life' ? 'life' : it.tag === 'meaning of life' ? 'meaning' : '';
return `<article class="card essay ${cls}" data-id="${it.id}" data-tags="${tags}" style="--span:${sp}" tabindex="0">${X}
<div class="teaser pad">${metaLine(it, it.tag)}<h3>${PH(it.title)}</h3><div class="ex">${PH(it.body[0])}</div><div class="rd"><span>${it.mins} min read</span><span>read ↘</span></div></div>
<div class="full pad"><div class="essay-full">${metaLine(it, it.tag + ' · ' + it.mins + ' min read')}<h3 style="font-size:48px;margin-top:12px">${PH(it.title)}</h3>${paras(it).replace(/<p>/g, '<p>')}<div class="end">– Lucas</div></div></div></article>`;
}
}
}
function hardcoverCard() {
const r = HARDCOVER.reading;
return `<article class="card live" data-id="_hardcover" data-tags="books" style="--span:${SPAN._hardcover}" data-date="2026-11-08"><div class="pad">
<div class="meta"><span class="live-src"><i></i>Hardcover · ${HARDCOVER.synced}</span></div><h3>Books lately</h3>
<div class="covers">${[{ title: r.title, c: r.c, pct: r.pct }].concat(HARDCOVER.read.slice(0, 3)).map(b => `<div class="bk${b.c === '#e8b04a' || b.c === '#d9c39a' ? ' light' : ''}" style="background:${b.c}" title="${b.title}">${b.title}<small>${b.pct ? b.pct + '% in' : b.stars + '★'}</small></div>`).join('')}</div>
<ul class="rlist"><li><span>${PH(r.title)}<small>reading</small></span><span>p. ${r.page}/${r.of}</span></li>${HARDCOVER.read.slice(0, 3).map(b => `<li><span>${PH(b.title.length > 28 ? b.title.slice(0, 26) + '…' : b.title)}</span><span>${'★'.repeat(Math.floor(b.stars))}${b.stars % 1 ? '½' : ''}</span></li>`).join('')}</ul>
<a class="out" href="#" onclick="return false">${PH(HARDCOVER.stats)} · hardcover.app ↗</a></div></article>`;
}
function traktCard() {
return `<article class="card live" data-id="_trakt" data-tags="film" style="--span:${SPAN._trakt}" data-date="2026-08-29"><div class="pad">
<div class="meta"><span class="live-src"><i></i>Trakt · ${TRAKT.synced}</span></div><h3>Recently rated</h3>
<ul class="rlist">${TRAKT.ratings.map(r => `<li><span>${PH(r[0])}<small>${r[1]}</small></span><span>♥ ${r[2]}</span></li>`).join('')}</ul>
<a class="out" href="#" onclick="return false">${PH(TRAKT.stats)} ↗</a></div></article>`;
}
/* ---------- now header ---------- */
function renderNow() {
const song = byId('mitski'), w = TRAKT.watching, r = HARDCOVER.reading, g = byId('lanternfall');
document.getElementById('now').innerHTML = `
<div><h4><span>Listening</span><a href="#" data-jump="mitski">why ↘</a></h4><div class="x"><div class="c">${coverSVG(song.art, 48, 48)}<button class="pp" data-mini-btn="mitski" data-len="${song.len}" aria-label="play">▶</button></div><div><b>${PH(song.title)}</b><small>${PH(song.by)} · on repeat</small></div></div></div>
<div><h4><span>Reading</span><a href="#" onclick="return false" title="pulled from Hardcover">Hardcover ↗</a></h4><div class="x"><div class="c tall" style="background:${r.c};padding:5px;color:#f1e6d6;font:italic 400 10px/1 var(--serif)">${r.title}</div><div><b>${PH(r.title)}</b><small>${PH(r.by)}</small><div class="pg"><i style="width:${r.pct}%"></i></div><small>${r.pct}% · synced ${HARDCOVER.synced}</small></div></div></div>
<div><h4><span>Watching</span><a href="#" onclick="return false" title="pulled from Trakt">Trakt ↗</a></h4><div class="x"><div class="c tall">${coverSVG(w.art, 48, 66)}</div><div><b>${PH(w.show)}</b><small>${w.ep}</small><div class="pg"><i style="width:${w.progress * 100}%"></i></div><small>synced ${TRAKT.synced}</small></div></div></div>
<div><h4><span>Playing</span><a href="#" data-jump="lanternfall">notes ↘</a></h4><div class="x"><div class="c">${coverSVG(g.art, 48, 48)}</div><div><b>${PH(g.title)}</b><small>${PH('Switch · ' + g.hours + ' h so far')}</small><small style="color:var(--dim)">updated by hand</small></div></div></div>`;
}
/* ---------- wall ---------- */
const wall = document.getElementById('wall');
let filter = 'all', olderShown = false;
function render() {
const entries = sorted.map(it => ({ d: it.date, html: cardHTML(it) }))
.concat([{ d: '2026-10-25', html: hardcoverCard() }, { d: '2026-08-29', html: traktCard() }])
.sort((a, b) => new Date(b.d) - new Date(a.d));
let h = entries.map(e => e.html).join('');
document.getElementById('olderHost').innerHTML = `<div class="older" id="older"><span>2026 so far. Older things settle into smaller cards:</span><button data-older="2025">2025 · 24 things ↓</button><button data-older="2024" disabled style="opacity:.5">2024 · 23</button></div>`;
wall.innerHTML = h;
applyFilter();
}
function olderCards() {
const picks = [['comics', 'Lore of the Lighthouse', 'nr'], ['music', 'Phoebe Bridgers, Motion Sickness', null, 'moon'], ['games', 'Hollow Knight', null, 'planet'], ['books', 'The Left Hand of Darkness', null, 'mountain'], ['comics', 'The Duchess Will Not Be Moved', 'vl'], ['film', 'Past Lives', null, 'trees'], ['writing', 'On being bad at guitar, on purpose'], ['made', 'Sketchbook: trains', null, 'city']];
let h = `<div class="yr">2025 <small>24 things · showing 8</small></div>`;
picks.forEach((p, k) => {
const c = ARC_COLORS[k % ARC_COLORS.length];
const th = p[2] ? comicPageSVG(p[2], k).replace('<svg ', '<svg preserveAspectRatio="xMidYMid slice" ') : p[3] ? coverSVG({ bg: c, fg: '#f1e6d6', acc: '#e8b04a', motif: p[3] }, 200, 120) : '';
h += `<article class="card arc" data-tags="${p[0]}" style="--span:3" title="placeholder archive item">${th ? `<div class="th">${th}</div>` : ''}<div class="pad"><div class="meta"><span>${INTERESTS.find(i => i[0] === p[0])[1]}</span><span>· 2025</span></div><h3${th ? '' : ' style="font-size:24px;font-style:italic;font-weight:300"'}>${PH(p[1])}</h3></div></article>`;
});
return h;
}
function applyFilter() {
let n = 0;
document.querySelectorAll('.wall .card').forEach(c => { const on = filter === 'all' || c.dataset.tags.split(' ').includes(filter); c.classList.toggle('hide', !on); if (on) n++; });
wall.querySelectorAll('.empty').forEach(e => e.remove());
// filtered views are short: give them an even rhythm instead of the curated mix
document.querySelectorAll('.wall .card').forEach(c => { if (!c.dataset.span0) c.dataset.span0 = c.style.getPropertyValue('--span'); c.style.setProperty('--span', filter === 'all' ? c.dataset.span0 : (n <= 4 ? 6 : 4)); });
if (!n) wall.insertAdjacentHTML('afterbegin', '<div class="empty">nothing here yet</div>');
const lab = INTERESTS.find(i => i[0] === filter)[1];
document.getElementById('wtitle').textContent = filter === 'all' ? 'everything, newest first' : lab + ', newest first';
document.getElementById('wsum').innerHTML = filter === 'all' ? '<b>' + ITEMS.length + '</b> things this year · 1–3 new a month · 65 since 2024' : '<b>' + n + '</b> on the wall · more in older years';
document.querySelectorAll('.chip').forEach(c => c.setAttribute('aria-pressed', c.dataset.f === filter));
layout();
}
function layout() {
const rowH = 6, gap = 0;
document.querySelectorAll('.wall > :not(.hide)').forEach(c => {
c.style.gridRowEnd = 'auto';
const h = c.getBoundingClientRect().height + 22;
c.style.gridRowEnd = 'span ' + Math.ceil((h + gap) / (rowH + gap));
});
}
function renderChips() {
const count = f => f === 'all' ? ITEMS.length + 2 : ITEMS.filter(it => tagsOf(it).includes(f)).length + (f === 'books' || f === 'film' ? 1 : 0);
document.getElementById('chips').innerHTML = INTERESTS.map(([f, l]) => `<button class="chip" data-f="${f}" aria-pressed="${f === filter}">${l}<sup>${f === 'books' ? 'live' : count(f)}</sup></button>`).join('');
}
/* ---------- open in place ---------- */
let openCard = null, reader = null;
function openC(card) {
if (openCard === card) return;
closeC(true);
openCard = card; card.classList.add('open');
const it = byId(card.dataset.id);
if (it && it.pal) { reader = { it, p: 0, card }; drawReader(); }
const sl = card.querySelector('[data-setlist]');
if (sl) sl.querySelectorAll('li').forEach(li => li.onclick = e => { e.stopPropagation(); PLAYER.id = it.id; PLAYER.t = +li.dataset.at; if (!PLAYER.timer) playerToggle(it.id, it.len); else playerEmit(); });
layout();
requestAnimationFrame(() => card.scrollIntoView({ block: 'start', behavior: 'smooth' }));
}
function closeC(silent) {
if (!openCard) return;
const c = openCard; openCard = null; reader = null; c.classList.remove('open'); layout();
if (!silent) c.focus({ preventScroll: true });
}
function drawReader() {
const { it, p, card } = reader;
card.querySelector('[data-spread]').innerHTML = [p, p + 1].filter(i => i < it.pages).map(i => `<div class="pgbig">${comicPageSVG(it.pal, i)}</div>`).join('');
card.querySelector('[data-pos]').textContent = 'pages ' + (p + 1) + (p + 1 < it.pages ? '–' + (p + 2) : '') + ' of ' + it.pages;
card.querySelector('[data-prev]').disabled = p === 0;
card.querySelector('[data-next]').disabled = p + 2 >= it.pages;
}
function turn(d) { if (!reader) return; const np = reader.p + d * 2; if (np < 0 || np >= reader.it.pages) return; reader.p = np; drawReader(); }
/* ---------- player ---------- */
PLAYER.subs.push(st => {
document.querySelectorAll('[data-mini]').forEach(m => {
const on = m.dataset.mini === st.id, len = +m.dataset.len, t = on ? st.t : 0, playing = on && !!st.timer;
m.querySelector('.pp').textContent = playing ? '❚❚' : '▶';
m.querySelector('.bar2 i').style.width = (t / len * 100) + '%';
m.querySelector('.tm').textContent = mmss(t) + ' / ' + mmss(len);
});
document.querySelectorAll('[data-mini-btn]').forEach(b => { const p = b.dataset.miniBtn === st.id && !!st.timer; b.textContent = p ? '❚❚' : '▶'; b.setAttribute('aria-label', p ? 'pause' : 'play'); });
document.querySelectorAll('[data-sleeve]').forEach(s => s.classList.toggle('playing', s.dataset.sleeve === st.id && !!st.timer));
const it = byId(st.id);
document.querySelectorAll('[data-setlist]').forEach(sl => { if (!it || !it.set) return; let acc = 0; sl.querySelectorAll('li').forEach((li, i) => { const a = acc; acc += it.set[i][1]; li.classList.toggle('on', st.t >= a && st.t < acc && (st.t > 0 || !!st.timer)); }); });
});
/* ---------- wire up ---------- */
renderNow(); renderChips(); render();
document.addEventListener('click', e => {
const t = e.target;
const chip = t.closest('.chip'); if (chip) { filter = chip.dataset.f; closeC(true); applyFilter(); return; }
const mb = t.closest('[data-mini-btn]'); if (mb) { e.stopPropagation(); playerToggle(mb.dataset.miniBtn, +mb.dataset.len); return; }
const mini = t.closest('[data-mini]');
if (mini) { const id = mini.dataset.mini, len = +mini.dataset.len;
if (t.closest('.pp')) playerToggle(id, len);
else if (t.closest('.bar2')) { const r = mini.querySelector('.bar2').getBoundingClientRect(); playerSeek(id, len, (e.clientX - r.left) / r.width); }
return; }
if (t.closest('[data-close]')) { closeC(); return; }
if (t.closest('[data-prev]')) { turn(-1); return; }
if (t.closest('[data-next]')) { turn(1); return; }
const j = t.closest('[data-jump]'); if (j) { e.preventDefault(); filter = 'all'; applyFilter(); openC(wall.querySelector(`[data-id="${j.dataset.jump}"]`)); return; }
const ob = t.closest('[data-older]'); if (ob && !olderShown) { olderShown = true; const o = document.getElementById('older'); document.getElementById('wall2').innerHTML = olderCards(); ob.disabled = true; ob.style.opacity = .5; o.querySelector('span').textContent = '2025 loaded. Older years keep stacking below, each behind its own button:'; o.querySelectorAll('button')[1].disabled = false; o.querySelectorAll('button')[1].style.opacity = 1; applyFilter(); return; }
const card = t.closest('.card[data-id]'); if (card && !card.classList.contains('open') && !card.dataset.id.startsWith('_') && !t.closest('a')) openC(card);
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeC(); return; }
if (reader && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) { turn(e.key === 'ArrowRight' ? 1 : -1); return; }
if (e.key === 'Enter' && e.target.matches('.card[data-id]')) openC(e.target);
});
window.addEventListener('resize', layout);
if (document.fonts) document.fonts.ready.then(layout);
window.addEventListener('load', layout);
(function () { const m = location.hash.replace('&noscroll', '').match(/^#(open|filter)=(.+)$/); const ns = location.hash.includes('noscroll'); if (!m) return; if (m[1] === 'filter') { filter = m[2]; applyFilter(); } else { const c = wall.querySelector(`[data-id="${m[2]}"]`); if (c) { c.classList.add('open'); openCard = c; const it = byId(m[2]); if (it.pal) { reader = { it, p: 0, card: c }; drawReader(); } layout(); if (!ns) setTimeout(() => c.scrollIntoView({ block: 'start' }), 50); } } })();
</script>
</body>
</html>
+761
View File
@@ -0,0 +1,761 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Off the clock · Nº 04 · Autumn 2026 · 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=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..600&family=Caveat:wght@500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--paper: #f1e6d6; --paper2: #e9dcc7; --card: #f7efe3; --ink: #2a1a1f; --ink2: #4e3a3f; --mute: #84706a; --dim: #ab9a8f;
--line: rgba(42,26,31,.16); --line2: rgba(42,26,31,.32);
--amber: #e8b04a; --rose: #c9706b; --plum: #2a1a1f; --plum2: #3a252b; --cream: #f1e6d6;
--serif: "Fraunces", Georgia, serif; --hand: "Caveat", cursive; --mono: "Geist Mono", ui-monospace, monospace;
--ease: cubic-bezier(.2,.8,.2,1); --edge: 42px;
}
* { box-sizing: border-box; }
html { scroll-padding-top: 76px; }
html, body { margin: 0; }
body { background: var(--paper); color: var(--ink); font-family: var(--serif); font-size: 16px; line-height: 1.55; font-optical-sizing: auto; -webkit-font-smoothing: antialiased; }
body::before { content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0; opacity: .5; mix-blend-mode: multiply;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .55 0 0 0 0 .42 0 0 0 0 .36 0 0 0 .22 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>"); }
a { color: inherit; }
button { font: inherit; color: inherit; }
::selection { background: var(--amber); color: var(--ink); }
:focus-visible { outline: 2px solid var(--rose); outline-offset: 2px; }
.ph { text-decoration: underline dotted rgba(42,26,31,.38); text-underline-offset: 3px; text-decoration-thickness: 1px; cursor: help; }
.mono { font-family: var(--mono); }
.hand { font-family: var(--hand); }
/* mockup note */
.mk { position: fixed; bottom: 14px; right: 14px; z-index: 80; width: 310px; background: #fffaf2; color: #2a1a1f; font: 12px/1.45 var(--mono); padding: 10px 28px 10px 12px; border-radius: 8px; box-shadow: 0 6px 24px rgba(42,26,31,.18); border: 1px solid var(--line); }
.mk b { display: block; font-family: var(--serif); font-size: 14px; margin-bottom: 3px; }
.mk button { position: absolute; top: 4px; right: 6px; background: none; border: 0; font-size: 16px; cursor: pointer; }
/* work edge, mirrors tech-r3-c's right-side "off the clock" */
.edge { position: fixed; top: 0; left: 0; bottom: 0; width: var(--edge); z-index: 60; background: #131210; color: #ede7dd; border-right: 1px solid #2a2724; display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; transform: none; font: 500 12px var(--mono); letter-spacing: .12em; text-transform: uppercase; text-decoration: none; transition: width .2s var(--ease); }
.edge span { transform: rotate(180deg); }
.edge:hover { width: 54px; }
.page { position: relative; z-index: 1; margin-left: var(--edge); }
.wrap { max-width: 1260px; margin: 0 auto; padding: 0 40px; }
/* top bar */
.bar { position: sticky; top: 0; z-index: 50; background: rgba(241,230,214,.88); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); }
.bar .in { max-width: 1260px; margin: 0 auto; padding: 0 40px; height: 56px; display: flex; align-items: center; gap: 26px; }
.crumb { display: flex; align-items: center; gap: 10px; 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; transition: color .15s, border-color .15s; }
.crumb a.back:hover { color: var(--ink); border-color: var(--ink); }
.crumb b { font-weight: 600; font-size: 15px; }
.crumb span { font-style: italic; color: var(--mute); }
.nav { display: flex; gap: 2px; }
.nav a { text-decoration: none; color: var(--ink2); font-size: 15px; padding: 5px 10px; border-radius: 6px; transition: background .15s; }
.nav a:hover { background: rgba(42,26,31,.06); }
.fill { margin-left: auto; display: flex; align-items: center; gap: 10px; font: 12px var(--mono); color: var(--mute); }
.fill .dots { display: flex; gap: 3px; }
.fill .dots i { width: 8px; height: 11px; border-radius: 1.5px; border: 1px solid var(--ink2); }
.fill .dots i.on { background: var(--ink); }
/* masthead */
.mast { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 30px; padding: 30px 0 14px; border-bottom: 2px solid var(--ink); position: relative; }
.mast h1 { margin: 0; font-weight: 300; font-style: italic; font-size: 92px; line-height: .9; letter-spacing: -.035em; font-variation-settings: "opsz" 144; }
.mast h1 em { font-style: normal; font-weight: 600; color: var(--rose); }
.mast .sub { font: 12px var(--mono); color: var(--mute); margin-top: 12px; letter-spacing: .02em; }
.issue-no { text-align: right; display: grid; grid-template-columns: auto auto; align-items: end; gap: 0 16px; }
.issue-no .n { font-weight: 600; font-size: 84px; line-height: .8; letter-spacing: -.05em; font-variation-settings: "opsz" 144; grid-row: span 3; }
.issue-no .n small { font-size: 26px; font-weight: 400; vertical-align: top; letter-spacing: 0; margin-right: 2px; }
.issue-no .s { font-style: italic; font-size: 22px; line-height: 1.1; text-align: left; }
.issue-no .d { font: 11.5px var(--mono); color: var(--mute); text-align: left; }
.scribble { position: absolute; right: 290px; top: 34px; font-family: var(--hand); font-size: 22px; color: var(--rose); transform: rotate(-4deg); line-height: 1; }
.scribble svg { display: block; margin: 2px 0 0 60px; }
.coverline { display: flex; justify-content: space-between; gap: 30px; align-items: baseline; padding: 10px 0; border-bottom: 1px solid var(--line2); font-size: 18px; font-style: italic; color: var(--ink2); }
.coverline .r { font: 11.5px var(--mono); font-style: normal; color: var(--mute); white-space: nowrap; }
/* issue grid */
.issue { display: grid; grid-template-columns: repeat(12, 1fr); gap: 0 30px; }
.pg { position: relative; padding: 24px 0 26px; border-bottom: 1px solid var(--line2); min-width: 0; }
.pgno { position: absolute; top: 24px; right: 0; font: italic 400 14px var(--serif); color: var(--mute); }
.kick { font: 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--mute); display: flex; gap: 10px; align-items: center; }
.kick .t { color: var(--rose); }
.kick .new { background: var(--amber); color: var(--ink); border-radius: 3px; padding: 0 5px; letter-spacing: .04em; }
.pg h2 { font-weight: 500; font-size: 30px; line-height: 1.05; letter-spacing: -.02em; margin: 8px 0 10px; font-variation-settings: "opsz" 72; }
.pg h2 i { font-weight: 300; }
.pg p { margin: 0 0 10px; }
.vr { border-left: 1px solid var(--line2); padding-left: 30px; margin-left: -1px; }
.s3 { grid-column: span 3; } .s4 { grid-column: span 4; } .s5 { grid-column: span 5; } .s6 { grid-column: span 6; } .s7 { grid-column: span 7; } .s12 { grid-column: 1 / -1; }
/* contents */
.toc ol { list-style: none; margin: 12px 0 0; padding: 0; counter-reset: none; }
.toc li a { display: grid; grid-template-columns: 30px 1fr; gap: 0 6px; padding: 7px 0; border-top: 1px solid var(--line); text-decoration: none; transition: background .15s, padding .15s; }
.toc li:last-child a { border-bottom: 1px solid var(--line); }
.toc li a:hover { background: rgba(232,176,74,.16); padding-left: 6px; }
.toc .p { font: italic 500 17px/1.2 var(--serif); color: var(--rose); }
.toc .tt { font-size: 15px; line-height: 1.25; }
.toc .m { grid-column: 2; font: 10.5px var(--mono); color: var(--mute); letter-spacing: .03em; margin-top: 2px; }
.toc .m b { font-weight: 500; color: var(--ink); background: var(--amber); padding: 0 4px; border-radius: 2px; }
.toc li.empty a { color: var(--dim); cursor: default; }
.toc li.empty a:hover { background: none; padding-left: 0; }
.toc li.empty .p { color: var(--dim); }
.letter { margin-top: 18px; font-size: 14.5px; font-style: italic; color: var(--ink2); line-height: 1.5; }
.letter .sig { font: 600 24px var(--hand); font-style: normal; color: var(--ink); display: block; margin-top: 2px; transform: rotate(-2deg); }
/* comic spread */
.comic-wrap { display: grid; grid-template-columns: 1fr; gap: 14px; }
.quote { font: italic 300 25px/1.22 var(--serif); letter-spacing: -.01em; margin: 0; color: var(--ink); }
.quote::before { content: "“"; color: var(--rose); font-weight: 600; margin-right: 2px; }
.quote::after { content: "”"; color: var(--rose); font-weight: 600; }
.quote-src { font: 11px var(--mono); color: var(--mute); margin-top: 4px; }
.comicpage { background: #fbf6ee; padding: 10px; border: 1px solid var(--line); box-shadow: 0 1px 0 rgba(42,26,31,.08), 0 14px 30px -18px rgba(42,26,31,.35); display: grid; gap: 8px; grid-template-columns: 1.15fr 1fr 1fr; grid-template-rows: 150px 118px; grid-template-areas: "a a b" "c d d"; position: relative; transition: box-shadow .25s; }
.panel { all: unset; box-sizing: border-box; position: relative; border: 2px solid var(--ink); background: #fffaf3; overflow: hidden; cursor: zoom-in; transition: transform .25s var(--ease), opacity .25s, filter .25s; }
.panel svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.panel:hover { transform: translateY(-2px); }
.panel:focus-visible { outline: 3px solid var(--rose); outline-offset: 2px; }
.panel.a { grid-area: a; } .panel.b { grid-area: b; } .panel.c { grid-area: c; } .panel.d { grid-area: d; }
.comicpage.zoom .panel:not(.on) { opacity: .28; filter: grayscale(.6); }
.comicpage.zoom .panel.on { cursor: zoom-out; transform: scale(1.08); z-index: 2; box-shadow: 0 18px 36px -12px rgba(42,26,31,.55); }
.panel-cap { min-height: 46px; font-size: 14.5px; color: var(--ink2); display: flex; gap: 12px; align-items: flex-start; }
.panel-cap .hn { font: 600 22px/1 var(--hand); color: var(--rose); flex: none; transform: rotate(-3deg); margin-top: 2px; }
.comic-meta { display: flex; justify-content: space-between; gap: 16px; font: 11.5px var(--mono); color: var(--mute); flex-wrap: wrap; }
.ext { color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--rose); }
.ext:hover { color: var(--rose); }
/* currently sidebar */
.cur { background: var(--plum); color: var(--cream); border-radius: 4px; padding: 18px 18px 14px; position: relative; overflow: hidden; margin-top: 22px; }
.cur::before { content: ""; position: absolute; inset: 0; opacity: .16; pointer-events: none; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 .9 0 0 0 0 .8 0 0 0 .55 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>"); }
.cur > * { position: relative; }
.cur h3 { margin: 0; font: 600 26px/1 var(--hand); color: var(--amber); transform: rotate(-2deg); transform-origin: left; }
.cur .synced { font: 10.5px var(--mono); color: #b8a592; margin: 4px 0 6px; }
.cur-item { padding: 11px 0; border-top: 1px solid rgba(241,230,214,.14); display: grid; grid-template-columns: 38px 1fr; gap: 0 12px; }
.cur-item .art { width: 38px; height: 54px; border-radius: 2px; box-shadow: 0 2px 6px rgba(0,0,0,.35); }
.cur-item .lbl { font: 10.5px var(--mono); color: #b8a592; text-transform: uppercase; letter-spacing: .08em; display: flex; justify-content: space-between; }
.cur-item .lbl a { color: #b8a592; text-decoration: none; text-transform: none; letter-spacing: 0; }
.cur-item .lbl a:hover { color: var(--amber); }
.cur-item .ti { font-size: 15.5px; line-height: 1.2; margin-top: 2px; }
.cur-item .su { font-size: 13px; color: #d9c8b3; font-style: italic; }
.prog { height: 3px; background: rgba(241,230,214,.16); border-radius: 2px; margin-top: 6px; overflow: hidden; }
.prog i { display: block; height: 100%; background: var(--amber); }
.rated { list-style: none; margin: 4px 0 0; padding: 0; font-size: 13.5px; }
.rated li { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; color: #e8dccb; }
.rated .st { color: var(--amber); font-size: 11px; letter-spacing: 1px; white-space: nowrap; }
.cur .out { display: flex; gap: 6px; margin-top: 10px; }
.cur .out a { font: 11px var(--mono); color: var(--cream); text-decoration: none; border: 1px solid rgba(241,230,214,.25); border-radius: 5px; padding: 2px 8px; transition: border-color .15s, background .15s; }
.cur .out a:hover { border-color: var(--amber); background: rgba(232,176,74,.12); }
/* song of the season */
.song { display: grid; grid-template-columns: 200px 1fr; gap: 0 22px; }
.sleeve { width: 168px; height: 168px; position: relative; box-shadow: 0 14px 28px -14px rgba(42,26,31,.55); }
.sleeve svg { width: 100%; height: 100%; display: block; }
.vinyl { position: absolute; top: 8px; left: 12px; width: 152px; height: 152px; border-radius: 50%; background: radial-gradient(circle, var(--amber) 0 13%, #1b1013 13.5% 15%, #2a1a1f 15.5% 100%); z-index: -1; transition: left .3s var(--ease); box-shadow: inset 0 0 0 1px #000; }
.vinyl::after { content: ""; position: absolute; inset: 10px; border-radius: 50%; background: repeating-radial-gradient(circle, transparent 0 3px, rgba(255,255,255,.05) 3px 4px); }
.song.playing .vinyl, .sleeve-box:hover .vinyl { left: 40px; }
.sleeve-box { position: relative; z-index: 0; width: 168px; }
.song .title { font-weight: 500; font-size: 28px; line-height: 1.05; letter-spacing: -.02em; margin: 8px 0 0; }
.song .artist { font-style: italic; color: var(--ink2); font-size: 17px; }
.song .why { font-size: 15px; margin: 10px 0 0; color: var(--ink2); }
.deck { grid-column: 1 / -1; margin-top: 18px; display: grid; grid-template-columns: auto 1fr auto auto; gap: 14px; align-items: center; }
.pp { width: 46px; height: 46px; border-radius: 50%; border: 0; background: var(--ink); color: var(--cream); cursor: pointer; display: grid; place-items: center; transition: background .15s, transform .15s; }
.pp:hover { background: var(--rose); transform: scale(1.05); }
.pp svg { width: 16px; height: 16px; }
.bar2 { position: relative; height: 22px; cursor: pointer; }
.bar2::before { content: ""; position: absolute; left: 0; right: 0; top: 10px; height: 2px; background: var(--line2); border-radius: 2px; }
.bar2 i { position: absolute; left: 0; top: 10px; height: 2px; background: var(--rose); width: 0; border-radius: 2px; }
.bar2 b { position: absolute; top: 6px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: var(--rose); left: 0; transform: scale(0); transition: transform .15s; }
.bar2:hover b, .song.playing .bar2 b { transform: scale(1); }
.time { font: 12px var(--mono); color: var(--mute); font-variant-numeric: tabular-nums; white-space: nowrap; }
.loop { font: 11px var(--mono); border: 1px solid var(--line2); background: none; border-radius: 99px; padding: 3px 9px; cursor: pointer; color: var(--mute); transition: all .15s; }
.loop[aria-pressed="true"] { background: var(--amber); border-color: var(--amber); color: var(--ink); }
.deck-note { grid-column: 1 / -1; font: 11px var(--mono); color: var(--mute); margin-top: -4px; }
.bside { grid-column: 1 / -1; margin-top: 16px; border-top: 1px solid var(--line); }
.bside .h { font: 11px var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--mute); padding: 8px 0 2px; }
.bside button { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: 22px 1fr auto; gap: 8px; width: 100%; padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; align-items: baseline; transition: color .15s; }
.bside button:hover { color: var(--rose); }
.bside button .k { font: 11px var(--mono); color: var(--dim); }
.bside button .d { font: 11px var(--mono); color: var(--mute); }
.bside button[aria-current="true"] { color: var(--rose); }
.bside button[aria-current="true"] .k { color: var(--rose); }
/* game */
.shot { height: 150px; border-radius: 3px; overflow: hidden; position: relative; margin: 4px 0 12px; box-shadow: 0 12px 24px -16px rgba(42,26,31,.6); }
.shot svg { width: 100%; height: 100%; display: block; transition: transform .35s var(--ease); }
.shot:hover svg { transform: scale(1.03); }
.shot .tag { position: absolute; left: 8px; bottom: 8px; font: 10px var(--mono); color: var(--cream); background: rgba(42,26,31,.7); padding: 2px 6px; border-radius: 3px; }
.facts { display: flex; flex-wrap: wrap; gap: 4px 14px; font: 11.5px var(--mono); color: var(--mute); margin: 2px 0 10px; }
.facts b { color: var(--ink); font-weight: 500; }
.more { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .3s var(--ease); }
.more > div { overflow: hidden; }
.more.open { grid-template-rows: 1fr; }
.rd { background: none; border: 0; padding: 0; font: 12px var(--mono); color: var(--rose); cursor: pointer; border-bottom: 1px dotted var(--rose); }
.rd:hover { color: var(--ink); border-color: var(--ink); }
/* drawing */
.sheet { background: #fffaf2; padding: 14px 14px 8px; margin: 14px 6px 0; transform: rotate(-2.2deg); box-shadow: 0 2px 0 rgba(42,26,31,.06), 0 16px 26px -18px rgba(42,26,31,.6); position: relative; transition: transform .3s var(--ease); }
.sheet:hover { transform: rotate(-.4deg) translateY(-3px); }
.sheet::before, .sheet::after { content: ""; position: absolute; width: 70px; height: 20px; background: rgba(232,176,74,.55); top: -9px; }
.sheet::before { left: 18px; transform: rotate(-6deg); }
.sheet::after { right: 18px; transform: rotate(5deg); }
.sheet svg { width: 100%; display: block; }
.sheet .cap { font: 600 22px/1.1 var(--hand); text-align: center; margin: 4px 0 2px; }
.drawn-note { font-size: 14px; color: var(--ink2); margin-top: 16px; }
/* essay */
.essay .stand { font-size: 19px; line-height: 1.4; font-style: italic; color: var(--ink2); margin: 0 0 14px; }
.cols { columns: 2; column-gap: 30px; column-rule: 1px solid var(--line); font-size: 15.5px; }
.cols p { margin: 0 0 10px; }
.cols > p:first-child::first-letter { float: left; font: 600 58px/.82 var(--serif); margin: 6px 8px 0 0; color: var(--rose); }
.essay .foot { display: flex; justify-content: space-between; font: 11.5px var(--mono); color: var(--mute); margin-top: 8px; }
/* trope */
.bingo { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--ink); border-left: 1px solid var(--ink); margin: 12px 0 12px; }
.bingo button { all: unset; box-sizing: border-box; border-right: 1px solid var(--ink); border-bottom: 1px solid var(--ink); padding: 9px 9px; font-size: 13px; line-height: 1.2; min-height: 64px; cursor: pointer; position: relative; transition: background .15s; }
.bingo button:hover { background: rgba(201,112,107,.1); }
.bingo button[aria-pressed="true"]::after { content: ""; position: absolute; inset: 6px; border: 2.5px solid var(--rose); border-radius: 50%; transform: rotate(-8deg); opacity: .85; }
.bingo button.free { font-family: var(--hand); font-size: 20px; text-align: center; display: grid; place-items: center; background: rgba(232,176,74,.25); }
.recs { list-style: none; padding: 0; margin: 0; font-size: 14.5px; }
.recs li { padding: 5px 0; border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 10px; }
.recs li span { font: 11px var(--mono); color: var(--mute); white-space: nowrap; }
/* blank pages */
.blank { margin: 18px 0 4px; border: 1.5px dashed var(--line2); border-radius: 4px; padding: 16px 20px; display: flex; gap: 24px; align-items: center; color: var(--mute); }
.blank .pp2 { font: italic 500 30px/1 var(--serif); color: var(--dim); white-space: nowrap; }
.blank p { margin: 0; font-size: 15px; }
.blank .hand { font-size: 22px; color: var(--rose); margin-left: auto; white-space: nowrap; transform: rotate(-2deg); }
/* section heads */
.sec { padding: 46px 0 0; }
.sh { display: flex; align-items: baseline; gap: 16px; border-bottom: 2px solid var(--ink); padding-bottom: 8px; }
.sh h2 { margin: 0; font-weight: 300; font-style: italic; font-size: 44px; letter-spacing: -.03em; line-height: 1; }
.sh .n { font: 12px var(--mono); color: var(--mute); }
.sh .r { margin-left: auto; font: 12px var(--mono); color: var(--mute); }
/* back issues */
.shelf { display: grid; grid-template-columns: 70px 1fr; gap: 20px; padding: 22px 0 0; }
.yr { font: italic 500 28px var(--serif); color: var(--mute); padding-top: 4px; }
.covers { display: grid; grid-template-columns: repeat(5, 1fr); gap: 22px; }
.cover { all: unset; box-sizing: border-box; cursor: pointer; display: block; }
.cover .fr { aspect-ratio: 3 / 4; position: relative; overflow: hidden; border-radius: 2px; box-shadow: 0 1px 0 rgba(42,26,31,.1), 0 10px 22px -14px rgba(42,26,31,.55); transition: transform .25s var(--ease), box-shadow .25s; }
.cover .fr svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.cover:hover .fr, .cover:focus-visible .fr { transform: translateY(-6px) rotate(-1deg); box-shadow: 0 1px 0 rgba(42,26,31,.1), 0 22px 30px -16px rgba(42,26,31,.6); }
.cover[aria-expanded="true"] .fr { transform: translateY(-6px); box-shadow: 0 0 0 3px var(--paper), 0 0 0 5px var(--ink), 0 22px 30px -16px rgba(42,26,31,.6); }
.cover .cap { display: flex; justify-content: space-between; margin-top: 10px; font-size: 15px; }
.cover .cap span { font: 11px var(--mono); color: var(--mute); }
.cover.ghost .fr { border: 1.5px dashed var(--line2); box-shadow: none; background: transparent; display: grid; place-items: center; text-align: center; font-family: var(--hand); font-size: 22px; color: var(--mute); padding: 20px; }
.cover.ghost { cursor: default; }
.cover.ghost:hover .fr { transform: none; box-shadow: none; }
.drawer { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .3s var(--ease); margin-left: 90px; }
.drawer > div { overflow: hidden; }
.drawer.open { grid-template-rows: 1fr; }
.drawer-in { margin-top: 22px; border-top: 1px solid var(--ink); border-bottom: 1px solid var(--line2); padding: 14px 0 16px; display: grid; grid-template-columns: 240px 1fr; gap: 30px; }
.drawer-in h3 { margin: 0; font-weight: 500; font-size: 26px; line-height: 1.05; letter-spacing: -.02em; }
.drawer-in h3 i { font-weight: 300; }
.drawer-in .lead { font-size: 14.5px; color: var(--ink2); font-style: italic; margin: 8px 0 10px; }
.dlist { list-style: none; margin: 0; padding: 0; columns: 2; column-gap: 30px; }
.dlist li { break-inside: avoid; display: grid; grid-template-columns: 30px 1fr auto; gap: 6px; padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 15px; align-items: baseline; }
.dlist .p { font: italic 500 15px var(--serif); color: var(--rose); }
.tagx { font: 10.5px var(--mono); color: var(--mute); white-space: nowrap; }
/* index */
.idx-top { display: flex; gap: 18px; align-items: center; padding: 18px 0 14px; flex-wrap: wrap; }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip { border: 1px solid var(--line2); background: none; border-radius: 99px; padding: 4px 12px 4px 10px; font-size: 15px; cursor: pointer; display: inline-flex; gap: 7px; align-items: baseline; transition: background .15s, border-color .15s, color .15s; }
.chip sup { font: 10.5px var(--mono); color: var(--mute); top: 0; }
.chip:hover { border-color: var(--ink); }
.chip[aria-pressed="true"] { background: var(--ink); color: var(--cream); border-color: var(--ink); }
.chip[aria-pressed="true"] sup { color: var(--amber); }
.find { margin-left: auto; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line2); padding: 2px 0; }
.find input { border: 0; background: none; font: 15px var(--serif); font-style: italic; color: var(--ink); width: 200px; outline: none; }
.find input::placeholder { color: var(--dim); }
.find kbd { font: 10px var(--mono); border: 1px solid var(--line2); border-radius: 3px; padding: 0 4px; color: var(--mute); }
.idx { display: grid; grid-template-columns: 1fr 1fr; gap: 0 40px; }
.group { break-inside: avoid; margin-bottom: 18px; }
.group h4 { margin: 0; display: flex; align-items: baseline; gap: 10px; font-weight: 500; font-size: 20px; border-bottom: 1px solid var(--ink); padding: 6px 0 5px; }
.group h4 span { font: 11px var(--mono); color: var(--mute); }
.group h4 .glyph { width: 16px; height: 16px; align-self: center; }
.row { display: grid; grid-template-columns: 1fr auto auto; gap: 14px; padding: 7px 0; border-bottom: 1px solid var(--line); text-decoration: none; align-items: baseline; transition: background .15s, padding .15s; position: relative; }
.row:hover { background: rgba(232,176,74,.14); padding-left: 6px; padding-right: 6px; }
.row .t { font-size: 15.5px; line-height: 1.25; }
.row .t small { display: block; font-size: 13px; color: var(--mute); font-style: italic; margin-top: 1px; max-height: 0; overflow: hidden; transition: max-height .2s; }
.row:hover .t small, .row:focus-visible .t small { max-height: 40px; }
.row .iss { font: 11px var(--mono); color: var(--mute); white-space: nowrap; }
.row .iss b { color: var(--ink); font-weight: 500; }
.row .dt { font: 11px var(--mono); color: var(--dim); white-space: nowrap; width: 54px; text-align: right; }
.row.cur-iss .iss b { background: var(--amber); padding: 0 4px; border-radius: 2px; }
.idx-empty { grid-column: 1 / -1; font-style: italic; color: var(--mute); padding: 20px 0; }
.idx-note { font: 11.5px var(--mono); color: var(--mute); padding: 6px 0 0; }
/* colophon */
.colo { margin-top: 56px; background: var(--plum); color: var(--cream); position: relative; }
.colo::before { content: ""; position: absolute; inset: 0; opacity: .14; pointer-events: none; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 .9 0 0 0 0 .8 0 0 0 .55 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>"); }
.colo .wrap { display: grid; grid-template-columns: 1.4fr 1fr 1.3fr; gap: 40px; padding-top: 30px; padding-bottom: 34px; position: relative; font-size: 14px; color: #e3d4c1; }
.colo h5 { margin: 0 0 6px; font: 600 24px var(--hand); color: var(--amber); }
.colo a { color: var(--cream); text-decoration-color: var(--rose); text-underline-offset: 3px; }
.colo .ph { text-decoration-color: rgba(241,230,214,.4); }
.colo .small { font: 11.5px/1.6 var(--mono); color: #b8a592; }
@media (max-width: 1100px) {
.s3, .s4, .s5, .s6, .s7 { grid-column: span 6; }
.vr { border-left: 0; padding-left: 0; }
.covers { grid-template-columns: repeat(3, 1fr); }
.scribble { display: none; }
}
@media (max-width: 760px) {
:root { --edge: 26px; }
.edge { font-size: 10px; }
.wrap, .bar .in { padding-left: 16px; padding-right: 16px; }
.bar .in { gap: 10px; } .nav, .fill { display: none; }
.mast { grid-template-columns: 1fr; } .mast h1 { font-size: 58px; } .issue-no { text-align: left; justify-content: start; } .issue-no .n { font-size: 60px; }
.coverline { flex-direction: column; gap: 4px; }
.s3, .s4, .s5, .s6, .s7 { grid-column: 1 / -1; }
.comicpage { grid-template-rows: 110px 90px; }
.song { grid-template-columns: 1fr; } .song .sleeve-box { margin-bottom: 12px; }
.cols { columns: 1; }
.shelf { grid-template-columns: 1fr; } .covers { grid-template-columns: repeat(2, 1fr); } .drawer { margin-left: 0; }
.drawer-in { grid-template-columns: 1fr; } .dlist { columns: 1; }
.idx { grid-template-columns: 1fr; } .find { margin-left: 0; }
.colo .wrap { grid-template-columns: 1fr; }
.blank { flex-wrap: wrap; } .blank .hand { margin-left: 0; }
}
</style>
</head>
<body>
<div class="mk" id="mk" role="note"><button onclick="document.getElementById('mk').remove()" aria-label="Dismiss note">×</button><b>personal-r2-c · The zine, grown up</b>What "come in" opens: the current seasonal issue is the front page and fills up as the season goes (1–3 things a month fills an issue naturally). Past issues sit below as covers, and an index by interest cuts across every issue. Evolves P5, with marcschuster.com's editorial calm and beige.zone's archival index.</div>
<a class="edge" href="tech-r3-c.html" title="cross over to the work side"><span>← work</span></a>
<div class="page">
<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>/ off the clock</span></div>
<nav class="nav" aria-label="sections"><a href="#issue">This issue</a><a href="#back">Back issues</a><a href="#index">By interest</a></nav>
<div class="fill" title="pages filled in this issue"><span>Nº 04 · 7 of ~9 pages</span><span class="dots" aria-hidden="true"><i class="on"></i><i class="on"></i><i class="on"></i><i class="on"></i><i class="on"></i><i class="on"></i><i class="on"></i><i></i><i></i></span></div>
</div>
</header>
<main class="wrap">
<section class="mast" id="issue">
<div>
<h1>Off the clock<em>.</em></h1>
<div class="sub">a seasonal zine of things I love and things I think · by Lucas Winther</div>
</div>
<div class="issue-no">
<div class="n"><small>Nº</small>04</div>
<div class="s">Autumn 2026</div>
<div class="d">Sep · Oct · Nov</div>
<div class="d">closes 30 Nov</div>
</div>
<div class="scribble" aria-hidden="true">still filling up!
<svg width="60" height="28" viewBox="0 0 60 28" fill="none"><path d="M2 4 C 20 4, 40 8, 54 22" stroke="#c9706b" stroke-width="2" stroke-linecap="round"/><path d="M46 22 L55 23 L53 14" stroke="#c9706b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</section>
<div class="coverline"><span>This season: a webcomic that wrecked me, a song for cold bike rides, a pumpkin, and why I keep falling for villainesses.</span><span class="r">last page added 2 Nov</span></div>
<div class="issue">
<!-- contents -->
<aside class="pg s3 toc" aria-label="Contents">
<div class="kick">In this issue</div>
<ol>
<li><a href="#p2"><span class="p">2</span><span class="tt">Now Recharging made me cry, a lot</span><span class="m">comics · 14 Sep</span></a></li>
<li><a href="#p4"><span class="p">4</span><span class="tt">Song of the season</span><span class="m">music · 21 Sep</span></a></li>
<li><a href="#p5"><span class="p">5</span><span class="tt ph" title="placeholder">Hades II and the comfort of trying again</span><span class="m">games · 6 Oct</span></a></li>
<li><a href="#p6"><span class="p">6</span><span class="tt">The pumpkin</span><span class="m">made by me · <b>new</b> 2 Nov</span></a></li>
<li><a href="#p7"><span class="p">7</span><span class="tt ph" title="placeholder">Why I cry at fiction and not at the news</span><span class="m">writing · 19 Oct</span></a></li>
<li><a href="#p8"><span class="p">8</span><span class="tt">Reborn as the villainess (again)</span><span class="m">comics · <b>new</b> 28 Oct</span></a></li>
<li><a href="#cur"><span class="p">✦</span><span class="tt">Currently: watching, reading</span><span class="m">pulled from Trakt + Hardcover</span></a></li>
<li class="empty"><a href="#p9" tabindex="-1"><span class="p">9</span><span class="tt">still blank</span><span class="m">before 30 Nov</span></a></li>
</ol>
<div class="letter">Autumn turned into a comics season. I read more than I played, cried at one chapter more than I'd like to admit, and carved a pumpkin for the first time in years.<span class="sig">— Lucas</span></div>
</aside>
<!-- comic spread -->
<article class="pg s6 vr" id="p2">
<span class="pgno">pp. 2–3</span>
<div class="kick"><span class="t">comics</span><span>webcomic · ongoing</span></div>
<h2>Now Recharging made me <i>cry, a lot</i></h2>
<div class="comic-wrap">
<div>
<p class="quote">i cried so much in this chapter – death has always been something that hits me emotionally hard</p>
<div class="quote-src">me, on Bluesky · 14 Sep</div>
</div>
<div class="comicpage" id="comic">
<button class="panel a" data-cap="The quiet before. Two of them on a roof, the city humming underneath, and you just know." aria-label="Panel 1, enlarge">
<svg viewBox="0 0 400 150" preserveAspectRatio="xMidYMid slice"><defs><pattern id="dt1" width="7" height="7" patternUnits="userSpaceOnUse"><circle cx="3.5" cy="3.5" r="1.5" fill="#c9706b" opacity=".5"/></pattern><linearGradient id="sk1" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f3c9a0"/><stop offset="1" stop-color="#fffaf3"/></linearGradient></defs><rect width="400" height="150" fill="url(#sk1)"/><rect width="400" height="90" fill="url(#dt1)"/><circle cx="300" cy="70" r="26" fill="#e8b04a" opacity=".85"/><g fill="#2a1a1f"><rect x="0" y="92" width="38" height="58"/><rect x="42" y="78" width="26" height="72"/><rect x="72" y="100" width="40" height="50"/><rect x="250" y="84" width="30" height="66"/><rect x="284" y="98" width="46" height="52"/><rect x="334" y="74" width="24" height="76"/><rect x="362" y="96" width="38" height="54"/></g><rect x="110" y="112" width="150" height="38" fill="#2a1a1f"/><g fill="#fffaf3"><rect x="50" y="88" width="4" height="5"/><rect x="58" y="100" width="4" height="5"/><rect x="342" y="86" width="4" height="5"/><rect x="342" y="100" width="4" height="5"/></g><g fill="#2a1a1f"><circle cx="160" cy="90" r="8"/><path d="M150 112 q10 -20 20 0z"/><circle cx="196" cy="92" r="7"/><path d="M187 112 q9 -18 18 0z"/></g><path d="M176 104 q4 -6 10 -2" stroke="#2a1a1f" stroke-width="1.5" fill="none"/></svg>
</button>
<button class="panel b" data-cap="This face. No words in the panel, and none needed." aria-label="Panel 2, enlarge">
<svg viewBox="0 0 200 150" preserveAspectRatio="xMidYMid slice"><rect width="200" height="150" fill="#fffaf3"/><g stroke="#2a1a1f" stroke-width="1" opacity=".35"> <path d="M0 10 L200 140"/><path d="M0 30 L200 160"/><path d="M0 -10 L200 120"/><path d="M0 50 L200 180"/></g><path d="M30 78 Q100 26 170 78 Q100 120 30 78Z" fill="#fffaf3" stroke="#2a1a1f" stroke-width="3"/><circle cx="100" cy="76" r="22" fill="#2a1a1f"/><circle cx="108" cy="68" r="6" fill="#fffaf3"/><path d="M40 60 Q100 20 162 58" stroke="#2a1a1f" stroke-width="3" fill="none"/><path d="M128 104 q6 14 0 22 q-6 -8 0 -22z" fill="#c9706b" stroke="#2a1a1f" stroke-width="1.5"/></svg>
</button>
<button class="panel c" data-cap="The line that did it. (Paraphrased; read it yourself.)" aria-label="Panel 3, enlarge">
<svg viewBox="0 0 200 118" preserveAspectRatio="xMidYMid slice"><defs><pattern id="dt2" width="6" height="6" patternUnits="userSpaceOnUse"><circle cx="3" cy="3" r="1.2" fill="#2a1a1f" opacity=".25"/></pattern></defs><rect width="200" height="118" fill="url(#dt2)"/><path d="M18 14 H182 Q192 14 192 24 V70 Q192 80 182 80 H70 L52 100 L56 80 H18 Q8 80 8 70 V24 Q8 14 18 14Z" fill="#fffaf3" stroke="#2a1a1f" stroke-width="2.5"/><text x="100" y="44" text-anchor="middle" font-family="Caveat, cursive" font-size="21" fill="#2a1a1f">you can rest now.</text><text x="100" y="66" text-anchor="middle" font-family="Caveat, cursive" font-size="17" fill="#2a1a1f">i've got it from here.</text></svg>
</button>
<button class="panel d" data-cap="The empty bench. The scarf. I had to put my phone down for a minute here." aria-label="Panel 4, enlarge">
<svg viewBox="0 0 400 118" preserveAspectRatio="xMidYMid slice"><rect width="400" height="118" fill="#f4e6d2"/><rect y="88" width="400" height="30" fill="#e9d6bd"/><g stroke="#2a1a1f" stroke-width="3" stroke-linecap="round"><path d="M150 72 H260"/><path d="M150 82 H260"/><path d="M158 82 V100"/><path d="M252 82 V100"/><path d="M150 62 H260"/></g><path d="M196 62 q12 -2 20 6 q-4 18 4 30 l-8 2 q-8 -12 -6 -28 q-6 -6 -10 -10z" fill="#c9706b" stroke="#2a1a1f" stroke-width="1.5"/><g fill="#e8b04a" stroke="#2a1a1f" stroke-width="1"><path d="M80 30 q8 -6 12 2 q-6 6 -12 -2z"/><path d="M320 20 q8 -6 12 2 q-6 6 -12 -2z"/><path d="M290 60 q8 -6 12 2 q-6 6 -12 -2z"/><path d="M110 70 q8 -6 12 2 q-6 6 -12 -2z"/></g></svg>
</button>
</div>
<div class="panel-cap" id="pcap"><span class="hn">tap a panel</span><span>Drawn stand-ins for a few pages from the chapter. Click one to look closer, click again to step back.</span></div>
<div class="comic-meta"><span>panels: placeholders, not the real art</span><a class="ext" href="https://nowrecharging.com" target="_blank" rel="noopener">read it at nowrecharging.com ↗</a></div>
</div>
</article>
<!-- currently -->
<aside class="pg s3 vr" id="cur" aria-label="Currently">
<div class="kick">Sidebar</div>
<div class="cur">
<h3>currently</h3>
<div class="synced">pulled from Trakt + Hardcover · 3 h ago</div>
<div class="cur-item">
<div class="art" style="background:linear-gradient(160deg,#7f9a8c 0 45%,#e9e2cf 45% 60%,#6d6a8f 60%)"></div>
<div><div class="lbl">Watching <a href="#" title="placeholder link">trakt ↗</a></div><div class="ti ph" title="placeholder">Frieren: Beyond Journey's End</div><div class="su">S2 · E6, two days ago</div><div class="prog"><i style="width:43%"></i></div></div>
</div>
<div class="cur-item">
<div class="art" style="background:radial-gradient(circle at 50% 38%,#f1e6d6 0 16%,transparent 17%),linear-gradient(#3e5a6b,#21323c)"></div>
<div><div class="lbl">Reading <a href="#" title="placeholder link">hardcover ↗</a></div><div class="ti ph" title="placeholder">Piranesi</div><div class="su">Susanna Clarke · p. 152 of 272</div><div class="prog"><i style="width:56%"></i></div></div>
</div>
<div class="cur-item" style="grid-template-columns:1fr">
<div><div class="lbl">Recently rated <span>trakt</span></div>
<ul class="rated">
<li><span class="ph" title="placeholder">Perfect Days</span><span class="st">★★★★★</span></li>
<li><span class="ph" title="placeholder">The Wild Robot</span><span class="st">★★★★☆</span></li>
<li><span class="ph" title="placeholder">Arcane, S2</span><span class="st">★★★★☆</span></li>
</ul>
</div>
</div>
<div class="cur-item" style="grid-template-columns:1fr">
<div><div class="lbl">Finished this season <span>hardcover</span></div>
<ul class="rated">
<li><span class="ph" title="placeholder">Tomorrow, and Tomorrow…</span><span class="st">★★★★★</span></li>
<li><span class="ph" title="placeholder">The Hobbit (reread)</span><span class="st">★★★★☆</span></li>
</ul>
</div>
</div>
<div class="out"><a href="#" title="placeholder link">Trakt ↗</a><a href="#" title="placeholder link">Hardcover ↗</a></div>
</div>
<p style="font-size:13.5px;color:var(--mute);font-style:italic;margin:12px 0 0">Not logged twice: this box refreshes from the APIs, and freezes when the issue closes.</p>
</aside>
<!-- song -->
<article class="pg s5" id="p4">
<span class="pgno">p. 4</span>
<div class="kick"><span class="t">music</span><span>song of the season</span></div>
<div class="song" id="song">
<div class="sleeve-box"><div class="vinyl"></div><div class="sleeve">
<svg viewBox="0 0 168 168"><rect width="168" height="168" fill="#c9706b"/><rect y="104" width="168" height="64" fill="#2a1a1f"/><circle cx="84" cy="104" r="40" fill="#e8b04a"/><rect y="104" width="168" height="64" fill="#2a1a1f"/><g stroke="#c9706b" stroke-width="3"><path d="M20 120 H148"/><path d="M36 134 H132"/><path d="M54 148 H114"/></g><text x="12" y="24" font-family="Fraunces, serif" font-style="italic" font-size="14" fill="#2a1a1f">sadness as a gift</text></svg>
</div></div>
<div>
<div class="title ph" title="placeholder: song">Sadness as a Gift</div>
<div class="artist ph" title="placeholder: artist">Adrianne Lenker</div>
<p class="why">This was on repeat on every cold bike ride in October. It sounds like the end of something, but in a kind way 🍂</p>
</div>
<div class="deck">
<button class="pp" id="pp" aria-label="Play"><svg viewBox="0 0 16 16" id="ppi"><path d="M4 2 L14 8 L4 14Z" fill="currentColor"/></svg></button>
<div class="bar2" id="seek" role="slider" aria-label="position" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0"><i id="sfill"></i><b id="sknob"></b></div>
<span class="time" id="time">0:00 / 4:12</span>
<button class="loop" id="loop" aria-pressed="false" title="loop this song">loop</button>
<div class="deck-note" id="dnote">press play · nothing starts on its own · mock player, no audio</div>
</div>
<div class="bside" id="bside">
<div class="h">also this season</div>
</div>
</div>
</article>
<!-- game -->
<article class="pg s4 vr" id="p5">
<span class="pgno">p. 5</span>
<div class="kick"><span class="t">games</span><span>write-up</span></div>
<h2 class="ph" title="placeholder">Hades II and the comfort of <i>trying again</i></h2>
<div class="shot">
<svg viewBox="0 0 360 150" preserveAspectRatio="xMidYMid slice"><defs><radialGradient id="gl" cx=".5" cy=".55" r=".5"><stop offset="0" stop-color="#e8b04a" stop-opacity=".9"/><stop offset="1" stop-color="#e8b04a" stop-opacity="0"/></radialGradient></defs><rect width="360" height="150" fill="#2b1d2e"/><rect y="110" width="360" height="40" fill="#1c1320"/><circle cx="180" cy="86" r="70" fill="url(#gl)"/><g fill="#1c1320"><rect x="40" y="20" width="26" height="100"/><rect x="34" y="16" width="38" height="8"/><rect x="294" y="20" width="26" height="100"/><rect x="288" y="16" width="38" height="8"/></g><path d="M66 30 Q180 -20 294 30" stroke="#1c1320" stroke-width="10" fill="none"/><path d="M172 112 l8 -34 l8 34z" fill="#1c1320"/><circle cx="180" cy="72" r="7" fill="#1c1320"/><path d="M186 82 l26 -24" stroke="#f1e6d6" stroke-width="2"/><g fill="#e8b04a"><circle cx="120" cy="60" r="1.6"/><circle cx="240" cy="50" r="1.6"/><circle cx="210" cy="96" r="1.2"/><circle cx="150" cy="40" r="1.2"/></g></svg>
<span class="tag">screenshot placeholder</span>
</div>
<div class="facts"><span><b>41 h</b> played</span><span>Switch 2</span><span class="ph" title="placeholder">★★★★½</span></div>
<p>Every run ends, and that's the point. I've been having a heavy autumn, and there's something gentle about a game that says <i>go again, you'll be a bit stronger</i>.</p>
<div class="more" id="gmore"><div><p>The witch-y moveset took me a while. Now I can't go back. The music is doing half the work, honestly. Best moment so far: finally beating the second boss on the bus home and nearly shouting.</p></div></div>
<button class="rd" data-more="gmore">read the rest</button>
</article>
<!-- drawing -->
<article class="pg s3 vr" id="p6">
<span class="pgno">p. 6</span>
<div class="kick"><span class="t">made by me</span><span class="new">new</span></div>
<div class="sheet">
<svg viewBox="0 0 200 170" aria-label="drawing placeholder: a carved pumpkin">
<path d="M100 30 c-4 -14 4 -22 14 -22" stroke="#2a1a1f" stroke-width="3" fill="none" stroke-linecap="round"/>
<path d="M100 36 C 60 26, 20 50, 24 96 C 28 140, 70 158, 100 152 C 130 158, 174 140, 176 96 C 180 50, 140 26, 100 36Z" fill="#e8b04a" stroke="#2a1a1f" stroke-width="2.6" stroke-linejoin="round"/>
<path d="M100 36 C 82 50, 80 140, 100 152 M100 36 C 118 50, 122 140, 100 152" stroke="#2a1a1f" stroke-width="1.6" fill="none" opacity=".6"/>
<path d="M58 44 C 44 70, 46 130, 70 150 M142 44 C 156 70, 154 130, 130 150" stroke="#2a1a1f" stroke-width="1.4" fill="none" opacity=".45"/>
<path d="M58 80 l16 -12 l8 16z M118 84 l8 -16 l16 12z" fill="#2a1a1f"/>
<path d="M94 96 l6 -10 l6 10z" fill="#2a1a1f"/>
<path d="M56 108 q44 34 88 0 l-10 2 l-6 8 l-8 -6 l-8 8 l-8 -8 l-8 6 l-6 -8 l-10 -2z" fill="#2a1a1f"/>
<path d="M140 18 q8 4 4 12" stroke="#c9706b" stroke-width="2" fill="none" stroke-linecap="round"/>
</svg>
<div class="cap">carved it, then drew it 🎃</div>
</div>
<p class="drawn-note">My first pumpkin in years. The drawing is honestly better than the pumpkin. Pen and one marker, ten minutes.</p>
<div class="facts"><span>drawing placeholder</span><span>31 Oct</span></div>
</article>
<!-- essay -->
<article class="pg s7 essay" id="p7">
<span class="pgno">p. 7</span>
<div class="kick"><span class="t">writing</span><span>essay · 6 min</span></div>
<h2 class="ph" title="placeholder">Why I cry at fiction <i>and not at the news</i></h2>
<p class="stand">A comic chapter about a goodbye got me more than a month of headlines. I think that's not coldness. I think it's scale.</p>
<div class="cols">
<p>When a story lets you sit with one person for long enough, their loss becomes something you can hold. The news hands you thousands at once, and my brain just files it under weather. I don't love that about myself, but I understand it better now.</p>
<p>Death has always hit me hard in stories. Maybe because stories are the only place where you get to rehearse it gently, with a page you can close.</p>
<div class="more" id="emore"><div><p>So I've stopped feeling guilty about crying at a webcomic. If fiction is where I practice caring, then the practice counts. The trick is to carry a little of it back out with me, into the real week.</p><p>Anyway. Read Now Recharging. Bring tissues.</p></div></div>
</div>
<div class="foot"><button class="rd" data-more="emore">keep reading</button><span>19 Oct · writing, life</span></div>
</article>
<!-- trope -->
<article class="pg s5 vr" id="p8">
<span class="pgno">p. 8</span>
<div class="kick"><span class="t">comics</span><span>a trope I can't quit</span><span class="new">new</span></div>
<h2>Reborn as the villainess, <i>surrounded by lovely psychopaths</i></h2>
<p style="font-size:15px;color:var(--ink2)">She wakes up as the doomed villain, decides to just be nice, and everyone around her gets weirdly, possessively obsessed. I know. I love it anyway.</p>
<div class="bingo" id="bingo" aria-label="trope bingo, click to mark">
<button aria-pressed="true">wakes up in a novel she read</button>
<button aria-pressed="false">"I'll just avoid the prince"</button>
<button aria-pressed="true">the duke is a menace</button>
<button aria-pressed="false">tea party ambush</button>
<button class="free" aria-pressed="true">free square: she's too kind</button>
<button aria-pressed="false">the maid knows everything</button>
<button aria-pressed="true">brother has a knife collection</button>
<button aria-pressed="false">deathflag dodged in ch. 3</button>
<button aria-pressed="false">everyone's possessive</button>
</div>
<ul class="recs">
<li><span class="ph" title="placeholder" style="font:inherit;color:inherit">The Villainess Lives Twice</span><span>manhwa · ongoing</span></li>
<li><span class="ph" title="placeholder" style="font:inherit;color:inherit">Death Is the Only Ending for the Villainess</span><span>manhwa</span></li>
</ul>
</article>
<div class="s12" id="p9">
<div class="blank"><span class="pp2">p. 9</span><p>This page is still blank. Something will land here before the issue closes on 30 November, then Nº 05 opens for winter.</p><span class="hand">maybe a Kickstarter game?</span></div>
</div>
</div>
<!-- back issues -->
<section class="sec" id="back">
<div class="sh"><h2>Back issues</h2><span class="n">3 printed · one per season since winter</span><span class="r">click a cover for its contents</span></div>
<div class="shelf">
<div class="yr">2026</div>
<div class="covers" id="covers"></div>
</div>
<div class="drawer" id="drawer"><div><div class="drawer-in" id="drawer-in"></div></div></div>
<p class="idx-note" style="margin-left:90px">Each year gets its own row of four. After a few years this is still a short shelf, and the index below does the heavy lifting.</p>
</section>
<!-- index -->
<section class="sec" id="index">
<div class="sh"><h2>Index, by interest</h2><span class="n" id="idxn"></span><span class="r">every page from every issue</span></div>
<div class="idx-top">
<div class="chips" id="chips" role="group" aria-label="filter by interest"></div>
<label class="find"><input id="q" type="search" placeholder="find a title…" aria-label="find a title"><kbd>/</kbd></label>
</div>
<div class="idx" id="idx"></div>
</section>
</main>
<footer class="colo">
<div class="wrap">
<div><h5>colophon</h5>Written, drawn and put together by Lucas Winther. One issue per season, closed when the season ends and never rewritten. Shares land on a page as they happen, so the current issue is always the newest thing here.</div>
<div><h5>elsewhere</h5><a href="https://bsky.app/profile/stereotypicalcat.bsky.social">Bluesky</a> · <a href="#" class="ph" title="placeholder link">Trakt</a> · <a href="#" class="ph" title="placeholder link">Hardcover</a><br><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br><a href="tech-r3-c.html">the work side →</a></div>
<div class="small">Placeholders (dotted underline): the game, song, essay titles, Trakt/Hardcover data, manhwa titles, back-issue contents, all panel and cover art (drawn stand-ins). Real: Now Recharging, Junkwraith, Cupid 13 and the quotes from Bluesky, the villainess trope, the Halloween pumpkin.</div>
</div>
</footer>
</div>
<script>
// ---------- comic panels: click to look closer ----------
(function () {
var page = document.getElementById('comic'), cap = document.getElementById('pcap');
var def = cap.innerHTML;
page.querySelectorAll('.panel').forEach(function (p, i) {
p.addEventListener('click', function () {
var on = p.classList.contains('on');
page.querySelectorAll('.panel').forEach(function (x) { x.classList.remove('on'); });
if (on) { page.classList.remove('zoom'); cap.innerHTML = def; return; }
p.classList.add('on'); page.classList.add('zoom');
cap.innerHTML = '<span class="hn">panel ' + (i + 1) + '</span><span>' + p.dataset.cap + '</span>';
});
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && page.classList.contains('zoom')) { page.querySelector('.panel.on').click(); } });
})();
// ---------- read more toggles ----------
document.querySelectorAll('.rd[data-more]').forEach(function (b) {
var label = b.textContent;
b.addEventListener('click', function () {
var m = document.getElementById(b.dataset.more), open = m.classList.toggle('open');
b.textContent = open ? 'fold it back' : label;
});
});
// ---------- bingo ----------
document.querySelectorAll('#bingo button').forEach(function (b) {
b.addEventListener('click', function () { b.setAttribute('aria-pressed', b.getAttribute('aria-pressed') === 'true' ? 'false' : 'true'); });
});
// ---------- mock player ----------
(function () {
var TRACKS = [
{ t: 'Sadness as a Gift', a: 'Adrianne Lenker', len: 252, main: true },
{ t: 'Ichiban', a: 'Hiatus Kaiyote', len: 214 },
{ t: 'Sunday Morning (handcam, rehearsal)', a: 'our band', len: 301, band: true }
];
var cur = 0, pos = 0, on = false, loop = false, timer = null;
var song = document.getElementById('song'), pp = document.getElementById('pp'), ppi = document.getElementById('ppi');
var fillEl = document.getElementById('sfill'), knob = document.getElementById('sknob'), time = document.getElementById('time'), note = document.getElementById('dnote');
var seek = document.getElementById('seek'), loopB = document.getElementById('loop'), bside = document.getElementById('bside');
var fmt = function (s) { return Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0'); };
TRACKS.forEach(function (tr, i) {
var b = document.createElement('button');
b.innerHTML = '<span class="k">' + (i === 0 ? 'A' : 'B' + i) + '</span><span><span class="ph" title="placeholder">' + tr.t + '</span> <i style="color:var(--mute)">· ' + tr.a + '</i></span><span class="d">' + fmt(tr.len) + '</span>';
b.addEventListener('click', function () { if (cur === i) { set(!on); return; } cur = i; pos = 0; set(true); });
bside.appendChild(b);
});
function ui() {
var L = TRACKS[cur].len, pct = pos / L * 100;
fillEl.style.width = pct + '%'; knob.style.left = pct + '%'; seek.setAttribute('aria-valuenow', Math.round(pct));
time.textContent = fmt(pos) + ' / ' + fmt(L);
bside.querySelectorAll('button').forEach(function (b, i) { b.setAttribute('aria-current', i === cur ? 'true' : 'false'); });
note.textContent = on ? 'playing ' + TRACKS[cur].t + (loop ? ' · on loop' : '') : (pos > 0 ? 'paused' : 'press play · nothing starts on its own · mock player, no audio');
}
function set(v) {
on = v; clearInterval(timer); song.classList.toggle('playing', on);
pp.setAttribute('aria-label', on ? 'Pause' : 'Play');
ppi.innerHTML = on ? '<rect x="3" y="2" width="3.5" height="12" fill="currentColor"/><rect x="9.5" y="2" width="3.5" height="12" fill="currentColor"/>' : '<path d="M4 2 L14 8 L4 14Z" fill="currentColor"/>';
if (on) timer = setInterval(function () {
pos++;
if (pos >= TRACKS[cur].len) { pos = 0; if (!loop) { if (cur < TRACKS.length - 1) cur++; else { set(false); } } }
ui();
}, 1000);
ui();
}
pp.addEventListener('click', function () { set(!on); });
loopB.addEventListener('click', function () { loop = !loop; loopB.setAttribute('aria-pressed', loop); ui(); });
seek.addEventListener('click', function (e) { var r = seek.getBoundingClientRect(); pos = Math.floor((e.clientX - r.left) / r.width * TRACKS[cur].len); ui(); });
seek.addEventListener('keydown', function (e) { if (e.key === 'ArrowRight') { pos = Math.min(TRACKS[cur].len - 1, pos + 5); ui(); } if (e.key === 'ArrowLeft') { pos = Math.max(0, pos - 5); ui(); } });
ui();
})();
// ---------- data: every page of every issue ----------
var INTERESTS = [
['comics', 'Comics'], ['games', 'Games'], ['music', 'Music'], ['film', 'Film & TV'], ['books', 'Books'], ['made', 'Made by me'], ['writing', 'Writing'], ['life', 'Life']
];
var ISSUES = [
{ no: '01', season: 'Winter', yr: '2026', months: 'Dec · Jan · Feb', title: 'Snowed in, <i>reading</i>', lead: 'The first one. Mostly about staying inside and finding a comic with a world I wanted to live in.', bg: '#2a1a1f', fg: '#f1e6d6', motif: 'snow' },
{ no: '02', season: 'Spring', yr: '2026', months: 'Mar · Apr · May', title: 'Faith, a sniper, <i>and cats</i>', lead: 'A one-shot that stuck with me, a commission I still look at daily, and the band filmed through a door.', bg: '#c9706b', fg: '#2a1a1f', motif: 'sprout' },
{ no: '03', season: 'Summer', yr: '2026', months: 'Jun · Jul · Aug', title: 'Long days, <i>slow films</i>', lead: 'Outer Wilds at last, a film about cleaning toilets that made me happier than most, and sketches from the ferry.', bg: '#e8b04a', fg: '#2a1a1f', motif: 'sun' },
{ no: '04', season: 'Autumn', yr: '2026', months: 'Sep · Oct · Nov', title: 'A comics season', lead: 'This one, still filling up.', bg: '#f7efe3', fg: '#2a1a1f', motif: 'leaf', current: true }
];
// [issue, page, interest(s), title, note, date, placeholder?]
var PAGES = [
['01', 2, ['comics'], 'Junkwraith', 'a new comic with an interesting art style… feels like a coming-of-age story with a unique world', '12 Jan', false],
['01', 4, ['music'], 'Song of the season: Nakamarra', 'Hiatus Kaiyote, on repeat all January', '20 Jan', true],
['01', 5, ['games'], 'Balatro, one more run', 'a poker game that ate my holidays', '2 Jan', true],
['01', 6, ['film'], 'Frieren, all of it, again', 'the best show about time passing', '8 Feb', true],
['01', 7, ['writing'], 'Why I\'m making a zine instead of a feed', 'four times a year, no infinite scroll', '1 Dec', true],
['01', 8, ['life'], 'My first winter swim (never again)', 'a dare, a fjord, a scream', '15 Feb', true],
['02', 2, ['comics'], 'Cupid 13', 'a MangaPlus one-shot about a forsaken sniper who regains faith', '9 Mar', false],
['02', 4, ['made'], 'The band, drawn as cats', 'a commission I look at every day', '30 Mar', true],
['02', 5, ['music'], 'Through the door: Sunday Morning', 'our band\'s handcam from rehearsal', '18 Apr', true],
['02', 6, ['games'], 'Backed on Kickstarter, finally playing: Sea of Stars', 'three years after pledging', '2 May', true],
['02', 7, ['books'], 'Piranesi, the first time', 'a house with endless halls and tides', '20 May', true],
['03', 2, ['games'], 'Outer Wilds, at last', 'no spoilers, just go', '14 Jun', true],
['03', 4, ['film'], 'Perfect Days made me tidy my flat', 'a slow film about small routines', '2 Jul', true],
['03', 5, ['music'], 'Song of the season: Are You Looking Up', 'Mk.gee, windows down', '10 Jul', true],
['03', 6, ['made'], 'Sketchbook: ferry seats', 'twelve drawings of people asleep', '28 Jul', true],
['03', 7, ['writing', 'life'], 'Liking things without irony', 'an essay about being openly into stuff', '19 Aug', true],
['04', 2, ['comics'], 'Now Recharging made me cry, a lot', 'i cried so much in this chapter', '14 Sep', false],
['04', 4, ['music'], 'Song of the season: Sadness as a Gift', 'cold bike rides in October', '21 Sep', true],
['04', 5, ['games'], 'Hades II and the comfort of trying again', 'every run ends, and that\'s the point', '6 Oct', true],
['04', 6, ['made'], 'The pumpkin', 'carved it, then drew it', '2 Nov', false],
['04', 7, ['writing', 'life'], 'Why I cry at fiction and not at the news', 'an essay about scale', '19 Oct', true],
['04', 8, ['comics'], 'Reborn as the villainess (again)', 'a trope I can\'t quit, with bingo', '28 Oct', false]
];
var GLYPH = {
comics: '<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="1.5" width="13" height="13"/><path d="M1.5 8H9M9 1.5v13"/></svg>',
games: '<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="4.5" width="13" height="7" rx="3.5"/><path d="M5 6.5v3M3.5 8h3"/><circle cx="11" cy="7.5" r=".6" fill="currentColor"/></svg>',
music: '<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.6"/></svg>',
film: '<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><rect x="1.5" y="3.5" width="13" height="9"/><path d="M1.5 6.5h13M5 3.5v3M9 3.5v3"/></svg>',
books: '<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M2 3c2-1 4-1 6 1 2-2 4-2 6-1v10c-2-1-4-1-6 1-2-2-4-2-6-1z"/><path d="M8 4v10"/></svg>',
made: '<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M11 2l3 3-8 8H3v-3z"/></svg>',
writing: '<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M2 4h12M2 7h12M2 10h8M2 13h5"/></svg>',
life: '<svg class="glyph" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M8 14s-6-3.5-6-8a3 3 0 0 1 6-1 3 3 0 0 1 6 1c0 4.5-6 8-6 8z"/></svg>'
};
var MOTIF = {
snow: function (c) { var s = ''; [[30,40],[90,30],[150,60],[60,90],[120,110],[40,150],[160,140],[100,170],[20,200],[140,200]].forEach(function (p) { s += '<circle cx="' + p[0] + '" cy="' + p[1] + '" r="3" fill="' + c + '" opacity=".7"/>'; }); return s + '<path d="M0 200 Q50 186 100 198 T200 194 V214 Q100 206 0 214Z" fill="' + c + '" opacity=".9"/>'; },
sprout: function (c) { return '<path d="M100 240 V150" stroke="' + c + '" stroke-width="5"/><path d="M100 175 C100 140 70 125 45 128 C45 160 70 175 100 175Z" fill="' + c + '"/><path d="M100 160 C100 125 128 110 158 112 C158 145 130 160 100 160Z" fill="' + c + '" opacity=".75"/>'; },
sun: function (c) { var r = ''; for (var i = 0; i < 12; i++) { var a = i * Math.PI / 6; r += '<path d="M' + (100 + Math.cos(a) * 52) + ' ' + (150 + Math.sin(a) * 52) + ' L' + (100 + Math.cos(a) * 70) + ' ' + (150 + Math.sin(a) * 70) + '" stroke="' + c + '" stroke-width="4" stroke-linecap="round"/>'; } return '<circle cx="100" cy="150" r="40" fill="' + c + '"/>' + r; },
leaf: function (c) { return '<path d="M60 220 C 50 150, 110 100, 160 110 C 165 170, 120 225, 60 220Z" fill="#c9706b"/><path d="M60 220 C 90 180, 120 150, 150 120" stroke="' + c + '" stroke-width="2.5" fill="none"/>'; }
};
// ---------- back issues ----------
(function () {
var wrap = document.getElementById('covers'), drawer = document.getElementById('drawer'), din = document.getElementById('drawer-in');
var open = null;
ISSUES.forEach(function (x) {
var b = document.createElement('button');
b.className = 'cover'; b.setAttribute('aria-expanded', 'false');
b.innerHTML = '<div class="fr" style="background:' + x.bg + '"><svg viewBox="0 0 200 267" preserveAspectRatio="xMidYMid slice">' + MOTIF[x.motif](x.fg) +
'<text x="14" y="34" font-family="Fraunces, serif" font-style="italic" font-weight="300" font-size="21" fill="' + x.fg + '">Off the clock.</text>' +
'<text x="186" y="252" text-anchor="end" font-family="Fraunces, serif" font-weight="600" font-size="58" letter-spacing="-3" fill="' + x.fg + '">' + x.no + '</text>' +
'<text x="14" y="252" font-family="Geist Mono, monospace" font-size="10" fill="' + x.fg + '" opacity=".8">' + x.season.toUpperCase() + ' ' + x.yr + '</text></svg></div>' +
'<div class="cap"><span style="font:inherit;color:inherit">Nº ' + x.no + ' · ' + x.season + '</span><span>' + (x.current ? 'reading now' : PAGES.filter(function (p) { return p[0] === x.no; }).length + ' pages') + '</span></div>';
b.addEventListener('click', function () {
if (x.current) { document.getElementById('issue').scrollIntoView({ behavior: 'smooth' }); return; }
wrap.querySelectorAll('.cover').forEach(function (c) { c.setAttribute('aria-expanded', 'false'); });
if (open === x.no) { open = null; drawer.classList.remove('open'); return; }
open = x.no; b.setAttribute('aria-expanded', 'true');
var ps = PAGES.filter(function (p) { return p[0] === x.no; });
din.innerHTML = '<div><div class="kick">Nº ' + x.no + ' · ' + x.season + ' ' + x.yr + ' · ' + x.months + '</div><h3 style="margin-top:6px" class="ph" title="placeholder">' + x.title + '</h3><p class="lead">' + x.lead + '</p><a href="#" class="ext" onclick="return false">open the whole issue →</a></div>' +
'<ol class="dlist">' + ps.map(function (p) { return '<li><span class="p">' + p[1] + '</span><span' + (p[6] ? ' class="ph" title="placeholder"' : '') + '>' + p[3] + '</span><span class="tagx">' + p[2].join(', ') + '</span></li>'; }).join('') + '</ol>';
drawer.classList.add('open');
});
wrap.appendChild(b);
});
var g = document.createElement('div');
g.className = 'cover ghost';
g.innerHTML = '<div class="fr">Nº 05 · winter<br>opens 1 December</div><div class="cap"><span style="font:inherit;color:var(--mute)">next</span><span>0 pages</span></div>';
wrap.appendChild(g);
})();
// ---------- index by interest ----------
(function () {
var chips = document.getElementById('chips'), idx = document.getElementById('idx'), q = document.getElementById('q'), n = document.getElementById('idxn');
var sel = 'all';
n.textContent = PAGES.length + ' pages · ' + ISSUES.length + ' issues';
var count = function (k) { return PAGES.filter(function (p) { return p[2].indexOf(k) > -1; }).length; };
var html = '<button class="chip" data-k="all" aria-pressed="true">Everything <sup>' + PAGES.length + '</sup></button>';
INTERESTS.forEach(function (i) { html += '<button class="chip" data-k="' + i[0] + '" aria-pressed="false">' + i[1] + ' <sup>' + count(i[0]) + '</sup></button>'; });
chips.innerHTML = html;
chips.querySelectorAll('.chip').forEach(function (c) {
c.addEventListener('click', function () { sel = c.dataset.k; chips.querySelectorAll('.chip').forEach(function (x) { x.setAttribute('aria-pressed', x === c); }); render(); });
});
function row(p) {
return '<a class="row' + (p[0] === '04' ? ' cur-iss' : '') + '" href="#"><span class="t"><span' + (p[6] ? ' class="ph" title="placeholder"' : '') + '>' + p[3] + '</span><small>' + p[4] + '</small></span><span class="iss"><b>Nº ' + p[0] + '</b> · p. ' + p[1] + '</span><span class="dt">' + p[5] + '</span></a>';
}
function render() {
var term = q.value.trim().toLowerCase();
var list = PAGES.filter(function (p) { return !term || (p[3] + ' ' + p[4]).toLowerCase().indexOf(term) > -1; })
.slice().sort(function (a, b) { return b[0].localeCompare(a[0]) || b[1] - a[1]; });
var groups = sel === 'all' ? INTERESTS : INTERESTS.filter(function (i) { return i[0] === sel; });
var out = '';
groups.forEach(function (gi) {
var ps = list.filter(function (p) { return p[2][0] === gi[0] || (sel !== 'all' && p[2].indexOf(gi[0]) > -1); });
if (!ps.length) return;
out += '<div class="group"><h4>' + GLYPH[gi[0]] + gi[1] + ' <span>' + ps.length + '</span></h4>' + ps.map(row).join('') + '</div>';
});
idx.style.gridTemplateColumns = sel === 'all' ? '' : '1fr';
idx.innerHTML = out || '<p class="idx-empty">Nothing with that name yet. Maybe next season.</p>';
}
q.addEventListener('input', render);
document.addEventListener('keydown', function (e) { if (e.key === '/' && document.activeElement !== q) { e.preventDefault(); q.focus(); } });
render();
})();
</script>
</body>
</html>
+533
View File
@@ -0,0 +1,533 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther · off the clock</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=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..600&family=Caveat:wght@500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #2a1a1f; --bg2: #321f26; --panel: #38242b; --panel2: #432c34; --ink: #f1e6d6; --ink2: #e3d4c1; --mute: #b8a592; --dim: #8a7568;
--line: rgba(241,230,214,.12); --line2: rgba(241,230,214,.24);
--amber: #e8b04a; --rose: #c9706b; --cream: #f1e6d6; --plum: #2a1a1f;
--serif: "Fraunces", Georgia, serif; --hand: "Caveat", cursive; --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(--serif); font-size: 16px; line-height: 1.55; font-weight: 350; -webkit-font-smoothing: antialiased; }
body::before { content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0; opacity: .16;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 .9 0 0 0 0 .8 0 0 0 .55 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>"); }
body::after { content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0; background: radial-gradient(900px 500px at 75% -10%, rgba(232,176,74,.08), transparent 70%), radial-gradient(700px 500px at 0% 60%, rgba(201,112,107,.06), transparent 70%); }
a { color: inherit; }
button { font: inherit; color: inherit; }
::selection { background: var(--amber); color: var(--plum); }
:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
.ph { text-decoration: underline dotted rgba(241,230,214,.35); text-underline-offset: 3px; text-decoration-thickness: 1px; cursor: help; }
.mono { font-family: var(--mono); }
.mk { position: fixed; bottom: 14px; right: 14px; z-index: 80; width: 310px; background: #f4ece0; color: #2a1a1f; font: 12px/1.45 var(--mono); padding: 10px 28px 10px 12px; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,.35); }
.mk b { display: block; font-family: var(--serif); font-size: 14px; margin-bottom: 3px; }
.mk button { position: absolute; top: 4px; right: 6px; background: none; border: 0; font-size: 16px; cursor: pointer; color: #2a1a1f; }
.edge { position: fixed; top: 0; left: 0; bottom: 0; width: var(--edge); z-index: 60; background: #131210; color: #ede7dd; border-right: 1px solid #2a2724; display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; font: 500 12px var(--mono); letter-spacing: .12em; text-transform: uppercase; text-decoration: none; transition: width .2s var(--ease); }
.edge span { transform: rotate(180deg); }
.edge:hover { width: 54px; }
.page { position: relative; z-index: 1; margin-left: var(--edge); }
.wrap { max-width: 1240px; margin: 0 auto; padding: 0 40px; }
.bar { position: sticky; top: 0; z-index: 50; background: rgba(42,26,31,.82); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--line); }
.bar .in { max-width: 1240px; margin: 0 auto; padding: 0 40px; height: 56px; display: flex; align-items: center; gap: 26px; }
.crumb { display: flex; align-items: center; gap: 10px; 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; transition: color .15s, border-color .15s; }
.crumb a.back:hover { color: var(--ink); border-color: var(--mute); }
.crumb b { font-weight: 500; font-size: 15px; }
.crumb span { font-style: italic; color: var(--mute); }
.nav { display: flex; gap: 2px; }
.nav a { text-decoration: none; color: var(--ink2); font-size: 15px; padding: 5px 10px; border-radius: 6px; transition: background .15s; }
.nav a:hover { background: rgba(241,230,214,.07); }
.upd { margin-left: auto; font: 12px var(--mono); color: var(--mute); display: flex; align-items: center; gap: 8px; }
.upd i { width: 7px; height: 7px; border-radius: 50%; background: var(--amber); }
/* intro */
.intro { display: grid; grid-template-columns: 1fr 280px; gap: 60px; padding: 46px 0 30px; align-items: center; }
.hi { font: 600 30px/1 var(--hand); color: var(--amber); transform: rotate(-2deg); transform-origin: left; display: inline-block; }
.intro h1 { margin: 10px 0 0; font-weight: 300; font-size: 50px; line-height: 1.08; letter-spacing: -.02em; font-variation-settings: "opsz" 90; max-width: 17em; }
.intro h1 em { font-style: italic; color: var(--amber); }
.intro h1 .r { font-style: italic; color: var(--rose); }
.intro p { margin: 16px 0 0; font-size: 18px; color: var(--ink2); max-width: 36em; }
.intro .meta { margin-top: 18px; font: 12px var(--mono); color: var(--mute); display: flex; gap: 18px; flex-wrap: wrap; }
.me { position: relative; justify-self: end; }
.me .card { width: 240px; background: #f4ece0; padding: 10px 10px 8px; transform: rotate(2.5deg); box-shadow: 0 2px 0 rgba(0,0,0,.25), 0 24px 40px -20px rgba(0,0,0,.7); transition: transform .3s var(--ease); }
.me .card:hover { transform: rotate(.5deg) translateY(-4px); }
.me svg { display: block; width: 100%; }
.me .cap { font: 600 21px/1 var(--hand); color: #2a1a1f; text-align: center; margin: 6px 0 2px; }
.me .tape { position: absolute; width: 80px; height: 22px; background: rgba(232,176,74,.7); top: -10px; left: 80px; transform: rotate(-4deg); }
/* section heads */
.sh { display: flex; align-items: baseline; gap: 14px; margin: 0 0 14px; }
.sh h2 { margin: 0; font: 600 30px/1 var(--hand); color: var(--amber); transform: rotate(-1.5deg); transform-origin: left; }
.sh .n { font: 12px var(--mono); color: var(--mute); }
.sh .r { margin-left: auto; font: 12px var(--mono); color: var(--mute); }
.sh .r a { color: var(--ink2); text-decoration: none; border-bottom: 1px solid var(--rose); }
.sh .r a:hover { color: var(--amber); }
section { padding: 26px 0; }
/* currently */
.now { display: grid; grid-template-columns: repeat(4, 1fr) 1.35fr; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; overflow: hidden; box-shadow: 0 30px 60px -40px rgba(0,0,0,.8); }
.slot { padding: 18px 18px 16px; border-right: 1px solid var(--line); position: relative; transition: background .2s; min-width: 0; }
.slot:hover { background: rgba(241,230,214,.03); }
.slot:last-child { border-right: 0; background: linear-gradient(160deg, rgba(232,176,74,.12), rgba(201,112,107,.06) 70%); }
.slot .lbl { font: 11px var(--mono); text-transform: uppercase; letter-spacing: .09em; color: var(--mute); display: flex; justify-content: space-between; gap: 6px; }
.slot .lbl a { color: var(--dim); text-decoration: none; text-transform: none; letter-spacing: 0; transition: color .15s; }
.slot .lbl a:hover { color: var(--amber); }
.cov { height: 112px; margin: 12px 0 12px; display: flex; align-items: flex-end; gap: 12px; }
.cov .art { height: 112px; border-radius: 3px; box-shadow: 0 10px 20px -10px rgba(0,0,0,.8); flex: none; transition: transform .25s var(--ease); }
.slot:hover .cov .art { transform: translateY(-3px) rotate(-1.5deg); }
.cov .art.book { width: 75px; } .cov .art.sq { width: 112px; } .cov .art.wide { width: 100%; height: 100px; }
.slot .ti { font-size: 18px; font-weight: 450; line-height: 1.15; }
.slot .su { font-size: 14px; color: var(--mute); font-style: italic; margin-top: 2px; }
.slot .prog { height: 3px; border-radius: 2px; background: rgba(241,230,214,.12); margin-top: 10px; overflow: hidden; }
.slot .prog i { display: block; height: 100%; background: var(--amber); }
.slot .pm { font: 11px var(--mono); color: var(--dim); margin-top: 5px; display: flex; justify-content: space-between; }
.slot .note { font: 500 20px/1.1 var(--hand); color: var(--rose); margin-top: 10px; max-height: 0; opacity: 0; overflow: hidden; transition: max-height .25s var(--ease), opacity .2s; }
.slot:hover .note, .slot:focus-within .note { max-height: 60px; opacity: 1; }
.now-foot { display: flex; justify-content: space-between; font: 11.5px var(--mono); color: var(--dim); padding: 10px 4px 0; }
/* looping player */
.loopbox { display: flex; gap: 14px; align-items: center; margin: 12px 0 10px; }
.disc { width: 112px; height: 112px; border-radius: 50%; flex: none; position: relative; background: radial-gradient(circle, #f1e6d6 0 5%, var(--amber) 5.5% 26%, #1b1013 26.5% 28%, #221418 28.5% 100%); box-shadow: 0 10px 22px -10px rgba(0,0,0,.9); }
.disc::after { content: ""; position: absolute; inset: 8px; border-radius: 50%; background: repeating-radial-gradient(circle, transparent 0 3px, rgba(255,255,255,.045) 3px 4px); }
.disc::before { content: ""; position: absolute; left: 50%; top: 50%; width: 46%; height: 2px; background: linear-gradient(90deg, transparent, rgba(255,255,255,.18)); transform-origin: left; transform: rotate(-30deg); z-index: 1; }
.loopbox.on .disc { animation: spin 3.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.eq { display: flex; gap: 3px; align-items: flex-end; height: 26px; margin-bottom: 6px; }
.eq i { width: 4px; background: var(--amber); border-radius: 1px; height: 6px; opacity: .55; transition: height .2s; }
.loopbox.on .eq i { animation: eq 0.9s ease-in-out infinite alternate; opacity: 1; }
.loopbox.on .eq i:nth-child(2) { animation-delay: -.3s; } .loopbox.on .eq i:nth-child(3) { animation-delay: -.6s; } .loopbox.on .eq i:nth-child(4) { animation-delay: -.15s; } .loopbox.on .eq i:nth-child(5) { animation-delay: -.45s; }
@keyframes eq { from { height: 4px; } to { height: 24px; } }
.ctl { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
.pp { width: 40px; height: 40px; border-radius: 50%; border: 0; background: var(--amber); color: var(--plum); display: grid; place-items: center; cursor: pointer; flex: none; transition: transform .15s, background .15s; }
.pp:hover { transform: scale(1.06); background: #f2c063; }
.pp svg { width: 14px; height: 14px; }
.seek { flex: 1; position: relative; height: 18px; cursor: pointer; }
.seek::before { content: ""; position: absolute; left: 0; right: 0; top: 8px; height: 2px; border-radius: 2px; background: rgba(241,230,214,.18); }
.seek i { position: absolute; left: 0; top: 8px; height: 2px; border-radius: 2px; background: var(--amber); width: 0; }
.tm { font: 11px var(--mono); color: var(--mute); font-variant-numeric: tabular-nums; white-space: nowrap; }
.loopct { font: 11px var(--mono); color: var(--mute); margin-top: 8px; }
.loopct b { color: var(--amber); font-weight: 500; }
/* featured + shares */
.pair { display: grid; grid-template-columns: 1.25fr 1fr; gap: 48px; }
.feat { position: relative; }
.feat .kick, .share .kick { font: 11px var(--mono); text-transform: uppercase; letter-spacing: .09em; color: var(--mute); display: flex; gap: 10px; }
.kick .t { color: var(--rose); }
.feat h3 { margin: 10px 0 12px; font-weight: 300; font-size: 44px; line-height: 1.04; letter-spacing: -.025em; font-variation-settings: "opsz" 144; }
.feat h3 i { color: var(--amber); }
.feat .stand { font-size: 19px; font-style: italic; color: var(--ink2); margin: 0 0 16px; line-height: 1.45; }
.feat .body { font-size: 16.5px; color: var(--ink2); max-width: 36em; }
.feat .body p { margin: 0 0 12px; }
.feat .body > p:first-child::first-letter { float: left; font: 500 62px/.8 var(--serif); color: var(--amber); margin: 6px 10px 0 0; }
.more { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .3s var(--ease); }
.more > div { overflow: hidden; }
.more.open { grid-template-rows: 1fr; }
.rd { background: none; border: 0; padding: 0; cursor: pointer; font: italic 17px var(--serif); color: var(--ink); display: inline-flex; gap: 8px; align-items: center; border-bottom: 1px solid var(--rose); }
.rd .arrow { transition: transform .2s; display: inline-block; }
.rd:hover { color: var(--amber); } .rd:hover .arrow { transform: translateX(4px); }
.margin-note { position: absolute; right: -6px; top: 4px; font: 500 21px/1.05 var(--hand); color: var(--rose); transform: rotate(4deg); text-align: right; }
.feat .meta { font: 11.5px var(--mono); color: var(--dim); margin-top: 14px; display: flex; gap: 16px; }
.shares { display: flex; flex-direction: column; }
.share { display: grid; grid-template-columns: 124px 1fr; gap: 18px; padding: 16px 0; border-top: 1px solid var(--line); text-decoration: none; position: relative; }
.share:last-of-type { border-bottom: 1px solid var(--line); }
.share .thumb { height: 100px; position: relative; transition: transform .25s var(--ease); }
.share:hover .thumb { transform: translateY(-3px) rotate(-1.5deg); }
.share .thumb svg { width: 100%; height: 100%; display: block; }
.share h4 { margin: 6px 0 4px; font-weight: 450; font-size: 20px; line-height: 1.15; letter-spacing: -.01em; transition: color .15s; }
.share:hover h4 { color: var(--amber); }
.share p { margin: 0; font-size: 14.5px; color: var(--ink2); }
.share .when { position: absolute; right: 0; top: 16px; font: 11px var(--mono); color: var(--dim); }
/* doors */
.doors { display: grid; grid-template-columns: repeat(8, 1fr); gap: 14px; perspective: 900px; }
.door { all: unset; box-sizing: border-box; cursor: pointer; display: block; text-align: center; }
.frame { position: relative; height: 190px; border-radius: 70px 70px 4px 4px; background: #1d1115; overflow: hidden; box-shadow: inset 0 0 0 1px var(--line2); }
.inside { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; padding: 0 8px 16px 46%; background: radial-gradient(circle at 70% 70%, rgba(232,176,74,.35), transparent 70%); }
.inside .cnt { font: 500 42px/1 var(--serif); color: var(--amber); }
.inside .lt { font: 10px/1.3 var(--mono); text-align: center; color: var(--ink2); margin-top: 6px; }
.leaf { position: absolute; inset: 0; border-radius: 70px 70px 4px 4px; transform-origin: left center; transition: transform .3s var(--ease); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.25); backface-visibility: hidden; }
.leaf::before { content: ""; position: absolute; inset: 14px 14px 14px 14px; border-radius: 56px 56px 2px 2px; border: 1px solid rgba(0,0,0,.18); }
.leaf .knob { position: absolute; right: 14px; top: 56%; width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,.35); box-shadow: inset 0 1px 0 rgba(255,255,255,.3); }
.leaf svg { width: 30px; height: 30px; position: relative; }
.door:hover .leaf, .door:focus-visible .leaf, .door[aria-pressed="true"] .leaf { transform: rotateY(-62deg); }
.door:focus-visible { outline: none; } .door:focus-visible .frame { outline: 2px solid var(--amber); outline-offset: 3px; }
.door .nm { margin-top: 10px; font-size: 16px; }
.door .nm small { display: block; font: 10.5px var(--mono); color: var(--dim); margin-top: 1px; }
.door[aria-pressed="true"] .nm { color: var(--amber); }
.room { margin-top: 22px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; display: grid; grid-template-columns: 260px 1fr; min-height: 180px; }
.room .about { padding: 20px 22px; border-right: 1px solid var(--line); }
.room .about h4 { margin: 0; font-weight: 300; font-style: italic; font-size: 34px; line-height: 1; letter-spacing: -.02em; }
.room .about p { margin: 10px 0 14px; font-size: 14.5px; color: var(--ink2); }
.room .about a { font: 12px var(--mono); color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--rose); }
.room .about a:hover { color: var(--amber); }
.room ul { list-style: none; margin: 0; padding: 10px 22px; columns: 2; column-gap: 34px; }
.room li { break-inside: avoid; display: grid; grid-template-columns: 1fr auto; gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--line); font-size: 15.5px; align-items: baseline; }
.room li small { display: block; font-style: italic; color: var(--mute); font-size: 13px; }
.room li .d { font: 11px var(--mono); color: var(--dim); white-space: nowrap; }
/* life */
.life { display: grid; grid-template-columns: 1fr 1.25fr; gap: 60px; }
.life h3 { margin: 6px 0 12px; font-weight: 300; font-size: 46px; line-height: 1.02; letter-spacing: -.025em; }
.life h3 i { color: var(--rose); }
.life .lede { font-size: 17px; color: var(--ink2); max-width: 30em; margin: 0; }
.mol { display: block; text-decoration: none; margin: 30px 10px 0 4px; background: #f4ece0; color: #2a1a1f; padding: 22px 24px 20px; position: relative; transform: rotate(-1.2deg); box-shadow: 0 2px 0 rgba(0,0,0,.25), 0 30px 50px -30px rgba(0,0,0,.8); transition: transform .3s var(--ease); max-width: 440px; }
.mol:hover { transform: rotate(0deg) translateY(-4px); }
.mol::before { content: ""; position: absolute; width: 90px; height: 22px; background: rgba(201,112,107,.55); top: -10px; left: 30px; transform: rotate(-5deg); }
.mol .k { font: 11px var(--mono); text-transform: uppercase; letter-spacing: .09em; color: #84706a; }
.mol h4 { margin: 8px 0 8px; font-weight: 400; font-size: 30px; line-height: 1.05; letter-spacing: -.02em; }
.mol h4 i { color: #b45b56; }
.mol p { margin: 0; font-size: 15.5px; color: #4e3a3f; font-style: italic; }
.mol .go { margin-top: 14px; display: flex; justify-content: space-between; align-items: baseline; font: 12px var(--mono); color: #84706a; }
.mol .go b { font: italic 400 17px var(--serif); color: #2a1a1f; border-bottom: 1px solid #c9706b; }
.mol .scrib { position: absolute; right: -18px; top: -26px; font: 600 22px/1 var(--hand); color: var(--amber); transform: rotate(6deg); }
.tl { list-style: none; margin: 0; padding: 0; position: relative; }
.tl::before { content: ""; position: absolute; left: 87px; top: 12px; bottom: 12px; width: 0; border-left: 1.5px dashed rgba(201,112,107,.5); }
.tl li { position: relative; }
.tl button { all: unset; box-sizing: border-box; cursor: pointer; display: grid; grid-template-columns: 70px 1fr; gap: 0 36px; width: 100%; padding: 9px 0; }
.tl .y { font: italic 400 20px/1.2 var(--serif); color: var(--mute); text-align: right; transition: color .15s; }
.tl .tx { font-size: 17px; line-height: 1.35; position: relative; transition: color .15s; }
.tl .tx::before { content: ""; position: absolute; left: -24px; top: 7px; width: 10px; height: 10px; border-radius: 50%; background: var(--bg); border: 1.5px solid var(--rose); transition: background .15s, transform .15s; }
.tl button:hover .tx::before, .tl li.open .tx::before { background: var(--rose); transform: scale(1.15); }
.tl button:hover .y, .tl li.open .y { color: var(--amber); }
.tl .ex { grid-column: 2; display: grid; grid-template-rows: 0fr; transition: grid-template-rows .3s var(--ease); }
.tl .ex > div { overflow: hidden; font-size: 15px; color: var(--mute); font-style: italic; }
.tl li.open .ex { grid-template-rows: 1fr; }
.tl .ex p { margin: 6px 0 4px; }
.tl li.nowrow .y { color: var(--amber); }
.tl li.nowrow .tx::before { background: var(--amber); border-color: var(--amber); box-shadow: 0 0 0 4px rgba(232,176,74,.18); }
.tl-hint { font: 11.5px var(--mono); color: var(--dim); margin: 10px 0 0 106px; }
/* footer */
footer { margin-top: 30px; border-top: 1px solid var(--line); }
footer .wrap { display: grid; grid-template-columns: 1.2fr 1fr 1.4fr; gap: 40px; padding-top: 26px; padding-bottom: 34px; font-size: 14px; color: var(--ink2); }
footer h5 { margin: 0 0 6px; font: 600 23px var(--hand); color: var(--amber); }
footer a { color: var(--ink); text-decoration-color: var(--rose); text-underline-offset: 3px; }
footer .small { font: 11.5px/1.6 var(--mono); color: var(--mute); }
@media (max-width: 1100px) {
.now { grid-template-columns: repeat(2, 1fr); } .slot { border-bottom: 1px solid var(--line); } .slot:last-child { grid-column: 1 / -1; }
.doors { grid-template-columns: repeat(4, 1fr); }
.pair, .life { grid-template-columns: 1fr; gap: 30px; }
}
@media (max-width: 760px) {
:root { --edge: 26px; } .edge { font-size: 10px; }
.wrap, .bar .in { padding-left: 16px; padding-right: 16px; }
.nav, .upd span { display: none; }
.intro { grid-template-columns: 1fr; gap: 26px; } .intro h1 { font-size: 34px; } .me { justify-self: start; }
.now { grid-template-columns: 1fr; }
.doors { grid-template-columns: repeat(2, 1fr); }
.room { grid-template-columns: 1fr; } .room .about { border-right: 0; border-bottom: 1px solid var(--line); } .room ul { columns: 1; }
.feat h3 { font-size: 32px; } .margin-note { display: none; }
.share { grid-template-columns: 90px 1fr; } .share .when { position: static; }
.tl button { grid-template-columns: 50px 1fr; gap: 0 30px; } .tl::before { left: 64px; } .tl-hint { margin-left: 0; }
footer .wrap { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="mk" id="mk" role="note"><button onclick="document.getElementById('mk').remove()" aria-label="Dismiss note">×</button><b>personal-r2-d · A personal homepage</b>What "come in" opens: a warm "about me, right now" page. A short hello, a "currently" strip fed by Trakt and Hardcover (and a song on loop), one featured essay, the latest three shares, then eight doors into collections by interest, and a gentle life thread. A modern now-page plus a personal index; maggieappleton.com's sorting by type, the gate's plum warmth.</div>
<a class="edge" href="tech-r3-c.html" title="cross over to the work side"><span>← work</span></a>
<div class="page">
<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>/ off the clock</span></div>
<nav class="nav" aria-label="sections"><a href="#now">Now</a><a href="#shares">Shares</a><a href="#interests">Interests</a><a href="#life">Life</a></nav>
<div class="upd"><i></i><span>updated 2 Nov 2026</span></div>
</div>
</header>
<main class="wrap">
<!-- intro -->
<section class="intro">
<div>
<span class="hi">hi, I'm Lucas</span>
<h1>I write software for a living. The rest of the time I'm <em>reading comics</em>, crying at them, and playing <span class="r">the same song</span> forty times in a row.</h1>
<p>This is my corner for the things I love and the things I think about. Something new lands here once or twice a month, whenever it's worth it.</p>
<div class="meta"><span>Denmark</span><span>bsky: stereotypicalcat</span><span>says hi back: lucas@lucaswinther.info</span></div>
</div>
<div class="me">
<span class="tape"></span>
<div class="card">
<svg viewBox="0 0 220 200" aria-label="drawing placeholder: a doodle self-portrait">
<rect width="220" height="200" fill="#efe3d1"/>
<circle cx="170" cy="48" r="20" fill="#e8b04a" opacity=".6"/>
<path d="M60 200 C 64 150, 156 150, 160 200" fill="#c9706b" stroke="#2a1a1f" stroke-width="2.4"/>
<path d="M98 140 v14 h24 v-14" fill="#f4dcc4" stroke="#2a1a1f" stroke-width="2.2"/>
<ellipse cx="110" cy="100" rx="40" ry="46" fill="#f4dcc4" stroke="#2a1a1f" stroke-width="2.4"/>
<path d="M70 92 C 66 50, 150 44, 152 92 C 140 70, 120 64, 104 66 C 92 72, 80 78, 70 92Z" fill="#4a2f2a" stroke="#2a1a1f" stroke-width="2.2"/>
<path d="M62 100 C 58 52, 162 52, 158 100" fill="none" stroke="#2a1a1f" stroke-width="4"/>
<rect x="54" y="92" width="16" height="26" rx="6" fill="#2a1a1f"/><rect x="150" y="92" width="16" height="26" rx="6" fill="#2a1a1f"/>
<circle cx="96" cy="102" r="9" fill="none" stroke="#2a1a1f" stroke-width="2"/><circle cx="124" cy="102" r="9" fill="none" stroke="#2a1a1f" stroke-width="2"/><path d="M105 102 h10" stroke="#2a1a1f" stroke-width="2"/>
<circle cx="97" cy="103" r="2" fill="#2a1a1f"/><circle cx="125" cy="103" r="2" fill="#2a1a1f"/>
<path d="M100 124 q10 8 20 0" fill="none" stroke="#2a1a1f" stroke-width="2.2" stroke-linecap="round"/>
<path d="M28 40 q6 -10 12 0 q6 10 12 0" fill="none" stroke="#c9706b" stroke-width="2" stroke-linecap="round"/>
<path d="M176 140 l6 -14 l3 12 l8 -6" fill="none" stroke="#2a1a1f" stroke-width="1.6" stroke-linecap="round"/>
</svg>
<div class="cap">me, by me (generously)</div>
</div>
</div>
</section>
<!-- currently -->
<section id="now">
<div class="sh"><h2>currently</h2><span class="n">synced from Trakt and Hardcover · 3 h ago</span><span class="r">hover for a note</span></div>
<div class="now">
<div class="slot" tabindex="0">
<div class="lbl">Playing <span>by hand</span></div>
<div class="cov"><div class="art sq" style="background:radial-gradient(circle at 50% 62%,#e8b04a 0 16%,rgba(232,176,74,.25) 30%,transparent 50%),linear-gradient(#3b2638,#1c1320)"></div></div>
<div class="ti ph" title="placeholder">Hades II</div>
<div class="su">Switch 2 · 41 hours in</div>
<div class="note">one more run. always one more run.</div>
</div>
<div class="slot" tabindex="0">
<div class="lbl">Reading <a href="#" title="placeholder link">hardcover ↗</a></div>
<div class="cov"><div class="art book" style="background:radial-gradient(circle at 50% 34%,#f1e6d6 0 12%,transparent 13%),repeating-linear-gradient(0deg,rgba(255,255,255,.06) 0 2px,transparent 2px 8px),linear-gradient(#456273,#20313b)"></div></div>
<div class="ti ph" title="placeholder">Piranesi</div>
<div class="su">Susanna Clarke</div>
<div class="prog"><i style="width:56%"></i></div>
<div class="pm"><span>p. 152 / 272</span><span>56%</span></div>
<div class="note">reading it slowly on purpose</div>
</div>
<div class="slot" tabindex="0">
<div class="lbl">Watching <a href="#" title="placeholder link">trakt ↗</a></div>
<div class="cov"><div class="art book" style="background:linear-gradient(165deg,#88a596 0 42%,#ece4d0 42% 56%,#6c6990 56%)"></div></div>
<div class="ti ph" title="placeholder">Frieren: Beyond Journey's End</div>
<div class="su">S2 · E6 · two days ago</div>
<div class="prog"><i style="width:43%"></i></div>
<div class="pm"><span>6 / 14 eps</span><span>★★★★★</span></div>
<div class="note">no spoilers but ep 5 😭</div>
</div>
<div class="slot" tabindex="0">
<div class="lbl">Listening <span>album</span></div>
<div class="cov"><div class="art sq" style="background:radial-gradient(circle at 50% 58%,#e8b04a 0 22%,transparent 22.5%) top/100% 100% no-repeat,linear-gradient(#c9706b 0 58%,#2a1a1f 58%)"></div></div>
<div class="ti ph" title="placeholder">Bright Future</div>
<div class="su ph" title="placeholder">Adrianne Lenker</div>
<div class="note">best with headphones, on a bike</div>
</div>
<div class="slot" id="looping">
<div class="lbl">Looping <span>song of the moment</span></div>
<div class="loopbox" id="loopbox">
<div class="disc" aria-hidden="true"></div>
<div style="min-width:0">
<div class="eq" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></div>
<div class="ti ph" title="placeholder: song">Sadness as a Gift</div>
<div class="su ph" title="placeholder: artist">Adrianne Lenker</div>
</div>
</div>
<div class="ctl">
<button class="pp" id="pp" aria-label="Play"><svg viewBox="0 0 16 16" id="ppi"><path d="M4 2 L14 8 L4 14Z" fill="currentColor"/></svg></button>
<div class="seek" id="seek" aria-label="position"><i id="sfill"></i></div>
<span class="tm" id="tm">0:00 / 4:12</span>
</div>
<div class="loopct" id="loopct">played <b id="plays">38</b>× this week · press play, it won't start by itself</div>
</div>
</div>
<div class="now-foot"><span>Trakt and Hardcover data shown as if pulled from their APIs (placeholders)</span><span>the song and game are typed in by me</span></div>
</section>
<!-- featured + shares -->
<section id="shares">
<div class="pair">
<article class="feat">
<div class="sh"><h2>lately I've been thinking</h2></div>
<div class="kick"><span class="t">writing</span><span>essay · 7 min · 19 Oct</span></div>
<h3 class="ph" title="placeholder">What a webcomic taught me about <i>saying goodbye</i></h3>
<p class="stand">I cried so much at a chapter of Now Recharging that I had to put my phone down. This is me trying to work out why death in stories hits me so hard.</p>
<div class="body">
<p>Death has always been something that hits me hard, in stories more than anywhere. I used to think that was a bit embarrassing. Now I think stories are the one place where you get to rehearse a goodbye gently, with a page you can close and come back to.</p>
<div class="more" id="fmore"><div>
<p>The chapter doesn't do anything dramatic. Two characters sit on a roof. One of them knows. The other one figures it out a panel too late, like you do. That's it, and it wrecked me.</p>
<p>I think the reason is scale. The news gives you thousands of people at once, and my brain files it under weather. A story gives you one person, for long enough to know how they take their coffee. Then it asks you to let them go.</p>
<p>So I've stopped apologising for crying at comics. If fiction is where I practise caring, the practice counts. 🌧️</p>
</div></div>
</div>
<button class="rd" data-more="fmore">read the whole thing <span class="arrow">→</span></button>
<div class="meta"><span>also in: comics, life</span><span>2 older essays</span></div>
<div class="margin-note">this one<br>was hard to write</div>
</article>
<div>
<div class="sh"><h2>latest shares</h2><span class="r"><a href="#interests">all 21, by interest ↓</a></span></div>
<div class="shares">
<a class="share" href="#">
<div class="thumb">
<svg viewBox="0 0 124 100"><rect x="10" y="4" width="100" height="92" fill="#f4ece0" transform="rotate(-3 60 50)"/><g transform="rotate(-3 60 50)"><path d="M60 26 c-2 -8 2 -12 8 -12" stroke="#2a1a1f" stroke-width="2" fill="none"/><path d="M60 30 C 38 24, 20 38, 22 60 C 24 82, 46 90, 60 86 C 74 90, 98 82, 98 60 C 100 38, 82 24, 60 30Z" fill="#e8b04a" stroke="#2a1a1f" stroke-width="2"/><path d="M40 50 l9 -7 l5 9z M66 52 l5 -9 l9 7z" fill="#2a1a1f"/><path d="M38 64 q22 18 44 0 l-6 1 l-4 5 l-5 -4 l-5 5 l-5 -5 l-5 4 l-4 -5z" fill="#2a1a1f"/></g><rect x="40" y="0" width="40" height="11" fill="rgba(232,176,74,.7)" transform="rotate(-6 60 6)"/></svg>
</div>
<div><div class="kick"><span class="t">made by me</span><span>drawing</span></div><h4>The pumpkin 🎃</h4><p>Carved my first pumpkin in years, then drew it. The drawing is better than the pumpkin.</p></div>
<span class="when">2 Nov</span>
</a>
<a class="share" href="#">
<div class="thumb">
<svg viewBox="0 0 124 100"><rect x="2" y="6" width="120" height="88" fill="#fbf6ee"/><rect x="7" y="11" width="66" height="40" fill="#f3c9a0" stroke="#2a1a1f" stroke-width="1.6"/><circle cx="52" cy="28" r="7" fill="#e8b04a"/><path d="M7 51 V38 h10 v-6 h8 v19z M50 51 v-10 h12 v-4 h11 v14z" fill="#2a1a1f"/><circle cx="32" cy="38" r="3" fill="#2a1a1f"/><circle cx="40" cy="39" r="2.6" fill="#2a1a1f"/><rect x="77" y="11" width="40" height="40" fill="#fffaf3" stroke="#2a1a1f" stroke-width="1.6"/><path d="M82 31 Q97 18 112 31 Q97 42 82 31Z" fill="none" stroke="#2a1a1f" stroke-width="1.5"/><circle cx="97" cy="30" r="5" fill="#2a1a1f"/><path d="M104 38 q2 5 0 8 q-2 -3 0 -8z" fill="#c9706b"/><rect x="7" y="55" width="110" height="34" fill="#f4e6d2" stroke="#2a1a1f" stroke-width="1.6"/><path d="M44 72 h36 M44 77 h36 M47 77 v8 M77 77 v8" stroke="#2a1a1f" stroke-width="1.6"/><path d="M60 70 q4 0 6 3 q-1 6 1 10 l-3 1 q-2 -4 -2 -9z" fill="#c9706b"/></svg>
</div>
<div><div class="kick"><span class="t">comics</span><span>a few pages</span></div><h4>Now Recharging, chapter <span class="ph" title="placeholder">12</span></h4><p>“i cried so much in this chapter – death has always been something that hits me emotionally hard”</p></div>
<span class="when">14 Oct</span>
</a>
<a class="share" href="#">
<div class="thumb">
<svg viewBox="0 0 124 100"><rect x="2" y="8" width="120" height="84" rx="3" fill="#1f2a3d"/><circle cx="34" cy="38" r="14" fill="#e8b04a"/><circle cx="92" cy="30" r="9" fill="#cfd6e6"/><circle cx="96" cy="27" r="8" fill="#1f2a3d"/><path d="M2 70 Q32 54 62 68 T122 64 V92 H2Z" fill="#2c3d55"/><path d="M2 80 Q40 70 70 80 T122 78 V92 H2Z" fill="#14202f"/><path d="M58 70 l6 -12 l6 12z" fill="#14202f"/><rect x="6" y="12" width="48" height="11" rx="2" fill="#c9706b"/><text x="30" y="20.5" text-anchor="middle" font-family="Geist Mono, monospace" font-size="6.5" fill="#2a1a1f">BACKED 2020</text></svg>
</div>
<div><div class="kick"><span class="t">games</span><span>kickstarter</span></div><h4 class="ph" title="placeholder">Sea of Stars, five years after I backed it</h4><p>Pledged in 2020, forgot, and finally started it. Turns out past me had good taste.</p></div>
<span class="when">20 Oct</span>
</a>
</div>
</div>
</div>
</section>
<!-- doors -->
<section id="interests">
<div class="sh"><h2>wander by interest</h2><span class="n" id="doorsn"></span><span class="r">hover a door to peek in, click to keep it open</span></div>
<div class="doors" id="doors"></div>
<div class="room" id="room"></div>
</section>
<!-- life -->
<section id="life">
<div class="life">
<div>
<div class="sh"><h2>life, so far</h2></div>
<h3>A short life story, <i>told gently</i></h3>
<p class="lede">Not a CV. Just the bits that made me who I am, the way I'd tell them to a friend over a long dinner. Click a year if you want the longer version.</p>
<a class="mol" href="#">
<span class="scrib">took me a year</span>
<div class="k">essay · 12 min · the big one</div>
<h4 class="ph" title="placeholder">What I think the point of it all <i>might be</i></h4>
<p>Mostly: be kind to the few people near you, make small things, and like what you like out loud. The rest I'm still working out, and I've made peace with that.</p>
<div class="go"><span>written slowly, spring 2026</span><b>read it, slowly →</b></div>
</a>
</div>
<div>
<ol class="tl" id="tl">
<li><button><span class="y ph" title="placeholder">1998</span><span class="tx">Born in Denmark. Apparently I didn't sleep through a night for a year.</span><span class="ex"><div><p>My parents say I was a curious baby. I think they mean loud.</p></div></span></button></li>
<li><button><span class="y ph" title="placeholder">2006</span><span class="tx">Anders And &amp; Co. every Wednesday. The start of a lifelong comics habit.</span><span class="ex"><div><p>I read them until the spines fell off. Some of them are still in a box at my parents'.</p></div></span></button></li>
<li><button><span class="y ph" title="placeholder">2012</span><span class="tx">A second-hand guitar and three chords for a whole year.</span><span class="ex"><div><p>Still mostly three chords, to be honest. Just better ones.</p></div></span></button></li>
<li><button><span class="y">2016</span><span class="tx">Made a VR game for my high-school exam, and found out I love building things people can step into.</span><span class="ex"><div><p>It was called Censor Simulator. It was janky. Watching my classmates put on the headset and laugh is still one of my favourite memories.</p></div></span></button></li>
<li><button><span class="y">2019</span><span class="tx">Started computer science at Aarhus University.</span><span class="ex"><div><p>Moved out, learned to cook three dishes, and met most of the people I'd call my close friends today.</p></div></span></button></li>
<li><button><span class="y ph" title="placeholder">2022</span><span class="tx">Started a small band with friends. We're not good. It's the best night of the week.</span><span class="ex"><div><p>We film our gigs on a phone from the third row. On purpose.</p></div></span></button></li>
<li><button><span class="y ph" title="placeholder">2024</span><span class="tx ph" title="placeholder">A year worth a line here, in your own words.</span><span class="ex"><div><p class="ph" title="placeholder">A few sentences on what that year meant. Placeholder: written by Lucas, not guessed.</p></div></span></button></li>
<li><button><span class="y">2025</span><span class="tx">Started at Aarhus University's IT research team. Grown-up job, same curiosity.</span><span class="ex"><div><p>The work side of this site has the details. This side is for everything else.</p></div></span></button></li>
<li class="nowrow"><button><span class="y">now</span><span class="tx">Comics, a song on loop, a pumpkin, and this little corner of the internet.</span><span class="ex"><div><p>Thanks for reading this far. Say hi on Bluesky if you like.</p></div></span></button></li>
</ol>
<div class="tl-hint">click a year for a little more</div>
</div>
</div>
</section>
</main>
<footer>
<div class="wrap">
<div><h5>elsewhere</h5><a href="https://bsky.app/profile/stereotypicalcat.bsky.social">Bluesky</a> · <a href="#" class="ph" title="placeholder link">Trakt</a> · <a href="#" class="ph" title="placeholder link">Hardcover</a><br><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br><a href="tech-r3-c.html">the work side →</a> · <a href="nav-d.html">← the gate</a></div>
<div><h5>about this page</h5>A "now" page that's always current, plus doors to everything older. Shares are filed by interest the moment they're posted, so nothing scrolls away.</div>
<div class="small">Placeholders (dotted underline): game, album, song, book and show data (as if from Trakt/Hardcover), essay titles, Sea of Stars, most life years and events, door contents, all drawings and cover art. Real: Now Recharging, Junkwraith, Cupid 13 and the Bluesky quotes, the villainess trope, the pumpkin, Censor Simulator (2016), Aarhus University (2019, 2025).</div>
</div>
</footer>
</div>
<script>
// ---------- read more ----------
document.querySelectorAll('.rd[data-more]').forEach(function (b) {
var html = b.innerHTML;
b.addEventListener('click', function () {
var open = document.getElementById(b.dataset.more).classList.toggle('open');
b.innerHTML = open ? 'fold it back <span class="arrow">↑</span>' : html;
});
});
// ---------- timeline ----------
document.querySelectorAll('#tl li').forEach(function (li) {
li.querySelector('button').addEventListener('click', function () { li.classList.toggle('open'); });
});
// ---------- looping player (mock) ----------
(function () {
var L = 252, pos = 0, on = false, t = null, plays = 38;
var box = document.getElementById('loopbox'), pp = document.getElementById('pp'), ppi = document.getElementById('ppi');
var fill = document.getElementById('sfill'), tm = document.getElementById('tm'), seek = document.getElementById('seek'), pl = document.getElementById('plays');
var fmt = function (s) { return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function ui() { fill.style.width = (pos / L * 100) + '%'; tm.textContent = fmt(pos) + ' / ' + fmt(L); pl.textContent = plays; }
function set(v) {
on = v; clearInterval(t); box.classList.toggle('on', on);
pp.setAttribute('aria-label', on ? 'Pause' : 'Play');
ppi.innerHTML = on ? '<rect x="3" y="2" width="3.5" height="12" fill="currentColor"/><rect x="9.5" y="2" width="3.5" height="12" fill="currentColor"/>' : '<path d="M4 2 L14 8 L4 14Z" fill="currentColor"/>';
if (on) t = setInterval(function () { pos++; if (pos >= L) { pos = 0; plays++; } ui(); }, 1000);
ui();
}
pp.addEventListener('click', function () { set(!on); });
seek.addEventListener('click', function (e) { var r = seek.getBoundingClientRect(); pos = Math.floor((e.clientX - r.left) / r.width * L); ui(); });
ui();
})();
// ---------- doors by interest ----------
(function () {
var ICON = {
comics: '<path d="M3 3h24v24H3z M3 15h14 M17 3v24" />',
games: '<rect x="2" y="9" width="26" height="13" rx="6.5"/><path d="M9 12.5v6M6 15.5h6"/><circle cx="20" cy="14" r="1"/><circle cx="23" cy="17" r="1"/>',
music: '<circle cx="15" cy="15" r="12"/><circle cx="15" cy="15" r="3"/>',
film: '<rect x="3" y="7" width="24" height="17"/><path d="M3 12h24M9 7v5M15 7v5M21 7v5"/>',
books: '<path d="M3 6c4-2 8-2 12 2 4-4 8-4 12-2v18c-4-2-8-2-12 2-4-4-8-4-12-2z M15 8v18"/>',
made: '<path d="M20 4l6 6-15 15H5v-6z M17 7l6 6"/>',
writing: '<path d="M4 7h22M4 12h22M4 17h16M4 22h10"/>',
life: '<path d="M15 26S4 19.5 4 11a5.5 5.5 0 0 1 11-2 5.5 5.5 0 0 1 11 2c0 8.5-11 15-11 15z"/>'
};
var D = [
{ k: 'comics', n: 'Comics', c: '#c9706b', about: 'Webcomics, manga one-shots and the odd trope I can\'t quit. Usually a few pages and a line about what got me.', items: [
['Now Recharging, chapter 12', 'i cried so much in this chapter', '14 Oct'], ['Reborn as the villainess (again)', 'everyone around her is possessive and I love it', '28 Oct'],
['Cupid 13', 'a MangaPlus one-shot about a forsaken sniper who regains faith', 'Mar'], ['Junkwraith', 'a coming-of-age story with a unique world', 'Jan'], ['Three panels I keep thinking about', 'a small collection', 'Dec 25', 1]] },
{ k: 'games', n: 'Games', c: '#8b6aa0', about: 'Games I love and why, plus Kickstarters I backed and actually played.', items: [
['Sea of Stars, five years after I backed it', 'past me had good taste', '20 Oct', 1], ['Hades II', 'one more run', 'Sep', 1], ['Outer Wilds, at last', 'no spoilers, just go', 'Jun', 1], ['Balatro ate my holidays', 'a poker game, somehow', 'Jan', 1]] },
{ k: 'music', n: 'Music', c: '#e8b04a', about: 'Songs I want to share right now, and our band\'s handcam recordings, one topic among many.', items: [
['Sadness as a Gift, on loop', 'cold bike rides in October', 'Oct', 1], ['Our band through a rehearsal door', 'handcam, two songs', 'Apr', 1], ['Nakamarra', 'on repeat all January', 'Jan', 1], ['Are You Looking Up', 'windows down', 'Jul', 1]] },
{ k: 'film', n: 'Film & TV', c: '#7f9a8c', about: 'What I\'m watching, pulled from Trakt, plus a few longer thoughts when something stays with me.', items: [
['Perfect Days made me tidy my flat', 'a slow film about small routines', 'Jul', 1], ['Frieren, all of it, again', 'the best show about time passing', 'Feb', 1], ['My Trakt top 10', 'pulled in, updates itself', 'live', 1]] },
{ k: 'books', n: 'Books', c: '#5a7a8c', about: 'Reading from Hardcover, and the occasional note on a book that rearranged something.', items: [
['Piranesi, read slowly', 'a house with endless halls', 'now', 1], ['Tomorrow, and Tomorrow, and Tomorrow', 'a novel about making games with friends', 'Aug', 1]] },
{ k: 'made', n: 'Made by me', c: '#d99a6c', about: 'My own small drawings, and art I commissioned from people far better at it.', items: [
['The pumpkin', 'carved it, then drew it', '2 Nov'], ['Sketchbook: ferry seats', 'twelve drawings of people asleep', 'Jul', 1], ['The band, drawn as cats', 'a commission I look at daily', 'Mar', 1]] },
{ k: 'writing', n: 'Writing', c: '#b8a592', about: 'Short essays on things I care about. Occasional, never rushed.', items: [
['What a webcomic taught me about saying goodbye', '7 min', '19 Oct', 1], ['Liking things without irony', '5 min', 'Aug', 1], ['Why this page isn\'t a feed', '3 min', 'Dec 25', 1]] },
{ k: 'life', n: 'Life', c: '#a85d63', about: 'The longer, quieter pieces: my life so far, and what I think it\'s all for.', items: [
['What I think the point of it all might be', '12 min · the big one', 'spring', 1], ['A short life story', 'the timeline, below', 'always', 1], ['My first winter swim (never again)', 'a dare, a fjord, a scream', 'Feb', 1]] }
];
var doors = document.getElementById('doors'), room = document.getElementById('room'), pinned = 'comics';
var total = D.reduce(function (s, d) { return s + d.items.length; }, 0);
document.getElementById('doorsn').textContent = '8 interests · ' + total + ' things so far';
D.forEach(function (d) {
var b = document.createElement('button');
b.className = 'door'; b.dataset.k = d.k; b.setAttribute('aria-pressed', d.k === pinned);
b.innerHTML = '<div class="frame"><div class="inside"><span class="cnt">' + d.items.length + '</span><span class="lt">latest:<br>' + d.items[0][2] + '</span></div>' +
'<div class="leaf" style="background:linear-gradient(160deg,' + d.c + ',color-mix(in srgb,' + d.c + ' 70%,#2a1a1f))"><svg viewBox="0 0 30 30" fill="none" stroke="#2a1a1f" stroke-width="1.6" stroke-linejoin="round">' + ICON[d.k] + '</svg><span class="knob"></span></div></div>' +
'<div class="nm">' + d.n + '<small>' + d.items.length + ' things</small></div>';
b.addEventListener('mouseenter', function () { show(d.k); });
b.addEventListener('focus', function () { show(d.k); });
b.addEventListener('mouseleave', function () { show(pinned); });
b.addEventListener('click', function () { pinned = d.k; doors.querySelectorAll('.door').forEach(function (x) { x.setAttribute('aria-pressed', x.dataset.k === pinned); }); show(pinned); });
doors.appendChild(b);
});
function show(k) {
var d = D.filter(function (x) { return x.k === k; })[0];
room.innerHTML = '<div class="about"><h4 style="color:' + d.c + '">' + d.n + '</h4><p>' + d.about + '</p><a href="#">open the whole ' + d.n.toLowerCase() + ' shelf →</a></div>' +
'<ul>' + d.items.map(function (i) { return '<li><span><span' + (i[3] ? ' class="ph" title="placeholder"' : '') + '>' + i[0] + '</span><small>' + i[1] + '</small></span><span class="d">' + i[2] + '</span></li>'; }).join('') + '</ul>';
}
show(pinned);
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

+372
View File
@@ -0,0 +1,372 @@
<!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 &amp; 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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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(" &nbsp; ")}</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>
+277
View File
@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther: two chapters</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=Instrument+Serif:ital@0;1&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#E2E5D9; --bg-2:#D7DBCC; --ink:#1E2A21; --muted:#5D695E; --line:#B9C0AE;
--verm:#CF4A2C; --pencil:#4A4F48;
--serif:"Instrument Serif",Georgia,serif; --sans:"Instrument Sans",system-ui,sans-serif;
}
*{box-sizing:border-box}
html{background:var(--bg);scroll-behavior:smooth}
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
body{margin:0;color:var(--ink);font-family:var(--sans);font-size:15px;line-height:1.5}
a{color:inherit;text-underline-offset:3px;text-decoration-thickness:1px}
a:hover{color:var(--verm)}
.ph{text-decoration:underline dotted var(--muted);text-underline-offset:3px;cursor:help}
.wrap{max-width:1200px;margin:0 auto;padding:0 32px}
.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}
header{display:grid;grid-template-columns:1.3fr 1fr;gap:48px;padding:48px 0 26px;align-items:end}
h1{font-family:var(--serif);font-weight:400;font-size:68px;line-height:.95;margin:0 0 14px;letter-spacing:-.01em}
.lede{font-size:18px;max-width:34em;margin:0;color:var(--ink)}
.lede em{font-family:var(--serif);font-size:21px}
.facts{margin:0;display:grid;grid-template-columns:auto 1fr;gap:6px 16px;border-top:1px solid var(--ink);padding-top:12px}
.facts dt{font-size:11px;text-transform:uppercase;letter-spacing:.09em;color:var(--muted);padding-top:3px}
.facts dd{margin:0}
.facts .links a{margin-right:12px}
/* minimap */
.mini{display:flex;align-items:flex-end;gap:3px;height:58px;border-bottom:1px solid var(--ink);margin-top:6px}
.mini a{flex:1;display:flex;flex-direction:column-reverse;align-items:stretch;gap:2px;text-decoration:none;height:100%;padding-bottom:4px;position:relative}
.mini a i{display:block;height:5px;background:var(--pencil);opacity:.55}
.mini a i.e{background:transparent;border:1px solid var(--pencil)}
.mini a i.ai{background:var(--verm);opacity:1}
.mini a span{position:absolute;bottom:-20px;left:0;font-size:11px;color:var(--muted)}
.mini a:hover i{opacity:1}
.mini a:hover span{color:var(--ink)}
.mini .gap{width:1px;background:var(--ink);margin:0 6px;align-self:stretch}
.mini-cap{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin:26px 0 0}
/* lens / filter */
.lanes-head{position:sticky;top:0;z-index:6;background:var(--bg);display:grid;grid-template-columns:var(--cols);gap:0 18px;padding:12px 0 8px;border-bottom:1px solid var(--line);margin-top:28px}
.lanes-head h4{grid-row:2;margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.09em;color:var(--muted);font-weight:500;align-self:end}
.lens{grid-column:1 / -1;grid-row:1;display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;justify-content:flex-end;font-size:12.5px;margin-bottom:6px}
.lens > span{color:var(--muted)}
.lens label{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none}
.lens input{position:absolute;opacity:0;pointer-events:none}
.lens .sw{width:26px;height:14px;border:1.5px solid var(--ink);display:inline-block}
.lens .sw.none{background:transparent}
.lens .sw.some{border-style:dashed}
.lens .sw.full{background:var(--verm);border-color:var(--verm)}
.lens input:not(:checked) + .sw{opacity:.3}
.lens input:not(:checked) ~ b{text-decoration:line-through;color:var(--muted);font-weight:400}
.lens input:focus-visible + .sw{outline:2px solid var(--verm);outline-offset:2px}
.lens b{font-weight:500}
:root{--cols:76px 150px 170px 1.8fr 1.25fr}
.tl{display:grid;grid-template-columns:var(--cols);gap:0 18px;position:relative}
.yr{grid-column:1;font-family:var(--serif);font-size:34px;line-height:1;padding:14px 0 18px;border-top:1px solid var(--line);color:var(--ink)}
.cell{padding:14px 0 18px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px;align-items:flex-start;min-width:0}
.cell.p{grid-column:4}.cell.w{grid-column:5}
.bar{padding:14px 0 18px;border-top:1px solid var(--line);position:relative;z-index:1}
.bar.ed{grid-column:2}.bar.wk{grid-column:3}
.bar .stripe{position:absolute;left:0;top:14px;bottom:10px;width:3px;background:var(--ink)}
.bar.now .stripe{background:linear-gradient(var(--verm),var(--verm)) top/3px 100% no-repeat}
.bar .in{padding-left:12px;cursor:default}
.bar b{display:block;font-weight:500;line-height:1.3}
.bar small{display:block;color:var(--muted);font-size:12.5px;margin-top:2px}
.bar p{margin:6px 0 0;font-size:13px;color:var(--muted);max-width:15em}
.chip{font:inherit;text-align:left;background:transparent;color:var(--ink);border:1.5px solid var(--ink);padding:4px 9px;cursor:pointer;max-width:100%;transition:background .15s,opacity .2s,color .15s;line-height:1.35}
.chip[data-ai="some"]{border-style:dashed}
.chip[data-ai="full"]{background:var(--verm);border-color:var(--verm);color:#FBF3EC}
.chip.post{border:0;border-left:1.5px solid var(--muted);padding:2px 0 2px 10px;font-family:var(--serif);font-size:17px}
.chip .k{font-family:var(--sans);font-size:11px;color:var(--muted);margin-left:6px;letter-spacing:.03em}
.chip:hover{background:var(--bg-2)}
.chip[data-ai="full"]:hover{background:#B83F24}
.chip[data-ai="full"] .k{color:rgba(251,243,236,.75)}
.chip.dim{opacity:.18}
.chip.rel,.bar.rel .in{outline:2px solid var(--verm);outline-offset:2px}
.detail{display:none;width:100%;border-left:1.5px solid var(--ink);margin:2px 0 6px;padding:8px 12px 10px;background:var(--bg-2);font-size:14px}
.detail.open{display:block}
.detail p{margin:0 0 6px}
.detail .meta{font-size:12px;color:var(--muted)}
.detail .ph-img{height:84px;margin:4px 0 8px;background:repeating-linear-gradient(45deg,transparent 0 6px,rgba(30,42,33,.08) 6px 7px);border:1px solid var(--line);font-size:11px;color:var(--muted);display:flex;align-items:flex-end;padding:4px 6px}
/* chapter divider */
.chapter{grid-column:1 / -1;display:grid;grid-template-columns:76px 1fr auto;gap:0 18px;align-items:end;padding:44px 0 18px;border-top:2px solid var(--ink)}
.chapter .num{font-family:var(--serif);font-size:15px;color:var(--muted);align-self:start;padding-top:6px}
.chapter h2{font-family:var(--serif);font-weight:400;font-size:44px;line-height:1;margin:0}
.chapter h2 i{color:var(--muted)}
.chapter p{margin:8px 0 0;max-width:40em;color:var(--muted)}
.chapter .aside{font-size:13px;text-align:right;max-width:240px}
.chapter .aside a{font-weight:500}
.chapter.one{margin-top:40px}
/* chapter I: pencil era */
.era-one{grid-column:1 / -1;display:grid;grid-template-columns:var(--cols);gap:0 18px;
background-image:repeating-linear-gradient(0deg,transparent 0 27px,rgba(74,79,72,.09) 27px 28px);}
.era-one .yr{color:var(--pencil);font-style:italic}
.era-one .chip{border-color:var(--pencil);color:var(--pencil);background:rgba(226,229,217,.85)}
.era-one .bar .stripe{background:var(--pencil)}
footer{display:grid;grid-template-columns:1fr 1fr 1fr;gap:28px;padding:36px 0 64px;margin-top:40px;border-top:2px solid var(--ink);font-size:13px;color:var(--muted)}
footer h5{margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.09em;color:var(--ink);font-weight:500}
footer p{margin:0 0 6px}
@media (max-width:860px){
header{grid-template-columns:1fr;gap:24px}
h1{font-size:52px}
.lanes-head h4{display:none}
.lanes-head{display:block}
.lens{grid-column:1 / -1;justify-content:flex-start}
.tl,.era-one{display:block}
.yr{padding-bottom:6px}
.cell,.bar{border-top:0;padding:4px 0 8px}
.bar .stripe{top:4px}
.cell:empty{display:none}
.chapter{display:block}
.chapter .aside{text-align:left;margin-top:10px}
footer{grid-template-columns:1fr}
.mini a span{display:none}
.mini a:nth-child(odd) span{display:block}
}
@media (max-width:480px){ .wrap{padding:0 16px} h1{font-size:44px} }
</style>
</head>
<body>
<div class="mock-note"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>tech-2 · Two chapters</b>Education, jobs, projects and writing on one year-by-year timeline, split into a by-hand chapter and an AI chapter. Hover anything to see what it connects to; click to open. Borrows lanes and varied shapes from sankar.work, yearly grouping from beige.zone.</div>
<div class="wrap">
<header>
<div>
<h1>Lucas Winther</h1>
<p class="lede">Computer scientist. I wrote software by hand for six years, then started building with AI as well. <em>This page holds both chapters</em>, on one line, so you can see where one ends and the next begins.</p>
</div>
<dl class="facts">
<dt>Now</dt><dd><span class="ph" title="placeholder">Software engineer, Nordlys Systems</span> <span class="ph" title="placeholder">(2023–)</span></dd>
<dt>Studied</dt><dd>BSc Computer Science, <span class="ph" title="placeholder">Aarhus University</span>, <span class="ph" title="placeholder">2022</span></dd>
<dt>Writes</dt><dd>AI tooling, devops, homelab at <a href="https://blog.lucaswinther.info">blog.lucaswinther.info</a></dd>
<dt>Contact</dt><dd class="links"><a href="mailto:lucas@lucaswinther.info">Email</a><a href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a><a href="https://github.com/StereotypicalCat">GitHub</a><a href="#" title="placeholder: CV PDF">CV (PDF)</a></dd>
</dl>
</header>
<nav class="mini" id="mini" aria-label="Jump to year"></nav>
<div class="mini-cap"><span></span><span>Each mark is one thing made, studied or written. Vermillion: made with some or full AI help.</span></div>
<div class="lanes-head">
<h4>Year</h4><h4>Education</h4><h4>Work</h4><h4>Projects</h4><h4>Writing</h4>
<div class="lens" id="lens">
<span>Projects by AI involvement</span>
<label><input type="checkbox" value="none" checked><i class="sw none"></i><b>By hand</b></label>
<label><input type="checkbox" value="some" checked><i class="sw some"></i><b>Some help</b></label>
<label><input type="checkbox" value="full" checked><i class="sw full"></i><b>Fully AI</b></label>
</div>
</div>
<div class="tl" id="tl"></div>
<footer>
<div><h5>Chapter I, preserved</h5><p>The 2021 React portfolio is frozen at <a href="https://old.lucaswinther.info">old.lucaswinther.info</a>. Nothing there was made with AI.</p></div>
<div><h5>Elsewhere</h5><p><a href="https://blog.lucaswinther.info">Blog</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></p><p>Set in Instrument Serif and Instrument Sans.</p></div>
<div><h5>Placeholders</h5><p>Dotted underline = invented: employer and job titles, university, school, all years, blog post titles, CV link, “Homelab metrics” and “this site” projects.</p></div>
</footer>
</div>
<script>
const ED=[
{id:"htx",lane:"ed",from:2016,to:2018,t:"HTX, programming line",s:"2016–2018",p:"Upper secondary. Exam projects in VR and Python.",ph:1,rel:["censor","breakout"]},
{id:"bsc",lane:"ed",from:2019,to:2022,t:"BSc Computer Science",s:"Aarhus University · 2019–2022",p:"Distributed systems, HCI, a thesis on P2P archival.",ph:1,rel:["hyper","dash","vrc","r-thesis","r-dash","r-vrc","rowing"]},
{id:"stud",lane:"wk",from:2021,to:2022,t:"Student developer",s:"University IT · 2021–2022",p:"Small React and Node tools for teaching staff.",ph:1,rel:["dash"]},
{id:"job",lane:"wk",from:2023,to:2026,now:1,t:"Software engineer",s:"Nordlys Systems · 2023–now",p:"Platform team: CI, internal tooling, deployment automation.",ph:1,rel:["config","metrics","w-agent","w-repo"]}
];
const ITEMS=[
{id:"site",y:2026,lane:"p",t:"lucaswinther.info",k:"this site",ai:"some",ph:1,d:"Design and copy by hand; the timeline plumbing pair-written with AI.",m:"HTML, CSS, JS",rel:["w-agent"]},
{id:"metrics",y:2026,lane:"p",t:"Homelab metrics",k:"infra",ai:"some",ph:1,d:"Prometheus and Grafana for the homelab. Alert rules by hand, dashboards drafted by AI and rewritten.",m:"Prometheus, Grafana",rel:["config","w-k3s","job"]},
{id:"w-agent",y:2026,lane:"w",t:"What I let the agent write, and what I didn't",k:"AI",ph:1,d:"A line-by-line audit of a project built with an agent.",rel:["gacha","site"]},
{id:"w-k3s",y:2026,lane:"w",t:"Three mini PCs, one k3s cluster",k:"Homelab",ph:1,d:"Notes from moving the homelab to Kubernetes, and back out for some services.",rel:["metrics","config"]},
{id:"w-repo",y:2026,lane:"w",t:"Rebuilding the homelab from one git repo",k:"Devops",ph:1,d:"Disaster recovery, tested by actually wiping a machine.",rel:["config"]},
{id:"gacha",y:2025,lane:"p",t:"Event Tracker for Gacha Games",k:"web app",ai:"full",d:"Scrapes event announcements into one calendar. I wrote the spec and tests; an agent wrote the code.",m:"JavaScript, scraping · Live demo · GitHub",rel:["w-agent","w-spec"]},
{id:"config",y:2025,lane:"p",t:"Config",k:"infra",ai:"full",d:"Ansible and docker-compose that rebuild the homelab from bare metal. Generated with AI, decisions mine.",m:"Ansible, docker-compose · GitHub",rel:["w-ans","w-repo","metrics"]},
{id:"w-spec",y:2025,lane:"w",t:"Specs are prompts with manners",k:"AI",ph:1,d:"Why writing a good ticket and writing a good prompt are the same skill.",rel:["gacha"]},
{id:"w-ans",y:2025,lane:"w",t:"Ansible idempotency, the hard way",k:"Devops",ph:1,d:"The bug that restarted every container on every run.",rel:["config"]},
{id:"w-cop",y:2024,lane:"w",t:"A month of autocomplete: a diary",k:"AI",ph:1,d:"Daily notes from the first month with an AI pair programmer.",rel:[]},
{id:"w-prox",y:2023,lane:"w",t:"Moving the homelab to Proxmox",k:"Homelab",ph:1,d:"The first post on the new blog.",rel:["config"]},
{id:"hyper",y:2022,lane:"p",t:"HyperCache",k:"bachelor project",ai:"none",d:"Reputation systems for peer-to-peer archival, simulated in Node.js under churn and free-riding.",m:"Node.js, P2P",rel:["r-thesis","bsc"]},
{id:"r-thesis",y:2022,lane:"w",t:"Bachelor report: trust in P2P archival",k:"Thesis PDF",d:"The written half of HyperCache.",rel:["hyper","bsc"]},
{id:"dash",y:2021,lane:"p",t:"Student Dashboard",k:"HCI",ai:"none",d:"A React dashboard designed from two rounds of user studies.",m:"React · Report · Demo",rel:["r-dash","bsc","stud"]},
{id:"exp",y:2021,lane:"p",t:"Monthly Expenses Tracker",k:"web app",ai:"none",d:"Spending in DKK against a budget. Plain JS, all amounts stored in øre.",m:"JavaScript",rel:[]},
{id:"port",y:2021,lane:"p",t:"Portfolio (the old site)",k:"archive",ai:"none",d:"Create React App and Material UI. Frozen at old.lucaswinther.info.",m:"React, Material UI",rel:[]},
{id:"r-dash",y:2021,lane:"w",t:"Student Dashboard report",k:"Report PDF",d:"Method, studies and what we got wrong first.",rel:["dash","bsc"]},
{id:"rowing",y:2020,lane:"p",t:"Mixed-Reality Rowing Game",k:"hardware",ai:"none",d:"Two real oars with sensors drive a Unity boat race. Arduino firmware plus the game.",m:"Unity, Arduino, C#",rel:["bsc"]},
{id:"chess",y:2020,lane:"p",t:"Chessswap",k:"game",ai:"none",d:"Chess where the board swaps every few turns.",m:"React",rel:[]},
{id:"vrc",y:2019,lane:"p",t:"VR-Classroom",k:"prototype",ai:"none",d:"A shared VR classroom prototype with a Node.js sync server.",m:"Node.js · Report · Video",rel:["r-vrc","bsc"]},
{id:"lol",y:2019,lane:"p",t:"LoL Winrate Discord Bot",k:"bot",ai:"none",d:"Answers with champion win rates for the current patch.",m:"Node.js",rel:[]},
{id:"r-vrc",y:2019,lane:"w",t:"VR-Classroom report",k:"Report",d:"What remote teaching needs from presence.",rel:["vrc","bsc"]},
{id:"censor",y:2018,lane:"p",t:"Censor Simulator",k:"VR, exam project",ai:"none",d:"You play the examiner grading a nervous virtual student.",m:"Unity, C#",rel:["htx"]},
{id:"breakout",y:2017,lane:"p",t:"BreakoutPython",k:"exam project",ai:"none",d:"Breakout with levels and power-ups.",m:"Python",rel:["htx"]},
{id:"borlaug",y:2016,lane:"p",t:"Tribute to Norman Borlaug",k:"first website",ai:"none",d:"A freeCodeCamp tribute page. Where it started.",m:"HTML, CSS",rel:[]}
];
const YEARS=[];for(let y=2026;y>=2016;y--)YEARS.push(y);
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const AIL={none:"By hand",some:"Some AI help",full:"Fully AI-made"};
function yearBlock(y,rowBase,rowsMap){
let h=`<div class="yr" id="y${y}" style="grid-row:${rowsMap[y]}">${y}</div>`;
ED.filter(e=>e.to===y || (e.now && y===2026)).forEach(e=>{
if(e.to!==y)return;
const start=rowsMap[e.to], end=rowsMap[Math.max(e.from,rowBase)]+1;
h+=`<div class="bar ${e.lane}${e.now?" now":""}" data-id="${e.id}" style="grid-row:${start} / ${end}"><i class="stripe"></i><div class="in" tabindex="0"><b>${e.ph?`<span class="ph" title="placeholder">${esc(e.t)}</span>`:esc(e.t)}</b><small>${esc(e.s)}</small><p>${esc(e.p)}</p></div></div>`;
});
["p","w"].forEach(l=>{
const its=ITEMS.filter(i=>i.y===y&&i.lane===l);
h+=`<div class="cell ${l}" style="grid-row:${rowsMap[y]}">`+its.map(i=>{
const title=i.ph?`<span class="ph" title="placeholder">${esc(i.t)}</span>`:esc(i.t);
return `<button class="chip${l==="w"?" post":""}" data-id="${i.id}" ${i.ai?`data-ai="${i.ai}"`:""} aria-expanded="false">${title}<span class="k">${esc(i.k)}</span></button>
<div class="detail" id="d-${i.id}">${l==="p"?`<div class="ph-img">placeholder: screenshot of ${esc(i.t)}</div>`:""}<p>${esc(i.d)}</p><div class="meta">${i.m?esc(i.m)+" · ":""}${i.ai?AIL[i.ai]:(i.y>=2023?`<a href="https://blog.lucaswinther.info">Read on the blog →</a>`:"PDF")}</div></div>`;
}).join("")+`</div>`;
});
return h;
}
function build(){
const two=[2026,2025,2024,2023], one=[2022,2021,2020,2019,2018,2017,2016];
const m2={},m1={};two.forEach((y,i)=>m2[y]=i+2);one.forEach((y,i)=>m1[y]=i+1);
let h=`<div class="chapter" style="border-top:0;padding-top:8px;grid-row:1"><span class="num">II</span><div><h2>With AI <i>2023 → now</i></h2><p>Work and side projects with AI in the loop. Every project says how much: by hand, some help, or fully AI-made with me as spec-writer and reviewer.</p></div><div class="aside">Rows run newest first. Hover a job or degree to see what came out of it.</div></div>`;
two.forEach(y=>h+=yearBlock(y,2023,m2));
h+=`<div class="chapter one" style="grid-row:6"><span class="num">I</span><div><h2>By hand <i>2016 – 2022</i></h2><p>School, university and side projects, all typed by me. No AI was involved in anything in this chapter.</p></div><div class="aside">This chapter is also preserved as it looked in 2021:<br><a href="https://old.lucaswinther.info">old.lucaswinther.info →</a></div></div>`;
h+=`<div class="era-one" style="grid-row:7">`;one.forEach(y=>h+=yearBlock(y,2016,m1));h+=`</div>`;
document.getElementById("tl").innerHTML=h;
// minimap
let mm="";
for(let y=2016;y<=2026;y++){
if(y===2023)mm+=`<i class="gap" title="Chapter I | Chapter II"></i>`;
const its=ITEMS.filter(i=>i.y===y);
const eds=ED.filter(e=>y>=e.from&&y<=e.to);
mm+=`<a href="#y${y}" title="${y}: ${its.length} items">`+eds.map(()=>`<i class="e"></i>`).join("")+its.map(i=>`<i class="${i.ai==="full"||i.ai==="some"?"ai":""}"></i>`).join("")+`<span>${y}</span></a>`;
}
document.getElementById("mini").innerHTML=mm;
}
build();
const tl=document.getElementById("tl");
tl.addEventListener("click",e=>{
const c=e.target.closest(".chip");if(!c)return;
const d=document.getElementById("d-"+c.dataset.id);
const open=d.classList.toggle("open");c.setAttribute("aria-expanded",open);
});
function relIds(id){
const all=[...ITEMS,...ED];const me=all.find(x=>x.id===id);if(!me)return[];
const s=new Set(me.rel||[]);all.forEach(x=>{if((x.rel||[]).includes(id))s.add(x.id);});return[...s];
}
function hi(id,on){
relIds(id).forEach(r=>{
document.querySelectorAll(`.chip[data-id="${r}"], .bar[data-id="${r}"]`).forEach(el=>el.classList.toggle("rel",on));
});
}
["mouseover","mouseout","focusin","focusout"].forEach(ev=>tl.addEventListener(ev,e=>{
const t=e.target.closest(".chip,.bar");if(!t)return;
if((ev==="mouseout"||ev==="mouseover")&&e.relatedTarget&&t.contains(e.relatedTarget))return;
hi(t.dataset.id,ev==="mouseover"||ev==="focusin");
}));
const lens=document.getElementById("lens");
lens.addEventListener("change",()=>{
const on=[...lens.querySelectorAll("input:checked")].map(i=>i.value);
document.querySelectorAll(".chip[data-ai]").forEach(c=>c.classList.toggle("dim",!on.includes(c.dataset.ai)));
});
</script>
</body>
</html>
+351
View File
@@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>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=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..500&family=Figtree:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--ox:#3A1D1A; --ox-2:#4A2722; --cream:#F2E7D7; --cream-dim:#C9B8A4; --saffron:#E3A43C;
--page:#F7F4EE; --ink:#2B201D; --muted:#7A6D66; --rule:#E2DAD0;
--serif:"Fraunces",Georgia,serif; --sans:"Figtree",system-ui,sans-serif;
}
*{box-sizing:border-box}
html{background:var(--page)}
body{margin:0;font-family:var(--sans);font-size:16px;line-height:1.6;color:var(--ink)}
a{color:inherit}
.ph{text-decoration:underline dotted currentColor;text-decoration-thickness:1px;text-underline-offset:3px;cursor:help}
.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}
.shell{display:grid;grid-template-columns:minmax(340px,42%) 1fr;min-height:100vh}
/* left */
.side{position:sticky;top:0;height:100vh;background:var(--ox);color:var(--cream);padding:56px 48px 36px 56px;display:flex;flex-direction:column;overflow:hidden}
.side::before{content:"";position:absolute;inset:0;pointer-events:none;
background-image:radial-gradient(circle,rgba(242,231,215,.5) 1px,transparent 1.4px);background-size:18px 18px;
-webkit-mask-image:radial-gradient(220px circle at var(--mx,-500px) var(--my,-500px),#000 0,transparent 70%);
mask-image:radial-gradient(220px circle at var(--mx,-500px) var(--my,-500px),#000 0,transparent 70%);opacity:.55}
.side > *{position:relative}
.side h1{font-family:var(--serif);font-weight:400;font-size:54px;line-height:1;margin:0;letter-spacing:-.015em;font-variation-settings:"SOFT" 100}
.side .role{font-family:var(--serif);font-style:italic;font-size:20px;color:var(--cream-dim);margin:10px 0 0}
.side .intro{max-width:24em;margin:18px 0 0;color:var(--cream)}
.mode{margin-top:22px;display:inline-flex;border:1px solid rgba(242,231,215,.35);align-self:flex-start}
.mode button{font:inherit;font-size:13px;background:none;border:0;color:var(--cream-dim);padding:5px 12px;cursor:pointer}
.mode button[aria-pressed="true"]{background:var(--cream);color:var(--ox)}
.mode-hint{font-size:12.5px;color:var(--cream-dim);margin-top:6px}
.toc{list-style:none;margin:34px 0 0;padding:0}
.toc a{display:flex;align-items:center;gap:12px;text-decoration:none;color:var(--cream-dim);font-size:13px;letter-spacing:.08em;text-transform:uppercase;padding:5px 0}
.toc a::before{content:"";height:1px;width:28px;background:currentColor;transition:width .2s}
.toc a:hover,.toc a.on{color:var(--cream)}
.toc a.on::before{width:56px;background:var(--saffron)}
.toc .n{font-family:var(--serif);font-style:italic;text-transform:none;letter-spacing:0;color:var(--cream-dim);font-size:13px;margin-left:auto}
.readout{margin-top:auto;border-top:1px solid rgba(242,231,215,.22);padding-top:14px;min-height:118px;font-size:14px}
.readout .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--saffron)}
.readout h3{font-family:var(--serif);font-weight:400;font-size:21px;margin:2px 0 4px;line-height:1.2}
.readout p{margin:0;color:var(--cream-dim)}
.readout .mix{display:flex;height:6px;margin:8px 0 4px;max-width:240px;background:rgba(242,231,215,.15)}
.readout .mix i{background:var(--cream)}
.readout .mix b{background:var(--saffron)}
.side-foot{display:flex;gap:16px;margin-top:16px;font-size:14px;flex-wrap:wrap}
.side-foot a{color:var(--cream);text-decoration:none;border-bottom:1px solid rgba(242,231,215,.3)}
.side-foot a:hover{border-color:var(--saffron)}
/* right */
main{padding:56px 64px 40px 64px;max-width:820px}
section{padding:0 0 64px;scroll-margin-top:40px}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:600;margin:0 0 18px;display:flex;align-items:center;gap:12px}
h2::after{content:"";flex:1;height:1px;background:var(--rule)}
.about p{margin:0 0 14px;max-width:36em}
.about p:first-child{font-family:var(--serif);font-size:24px;line-height:1.35;font-weight:350}
.peek{border-bottom:1px dashed var(--muted);cursor:help}
.peek:hover{border-bottom-color:var(--ox);color:var(--ox)}
.row{display:grid;grid-template-columns:120px 1fr;gap:20px;padding:16px 14px;margin:0 -14px;transition:background .15s}
.row:hover{background:#EFE9E0}
.row .when{font-size:13px;color:var(--muted);padding-top:3px}
.row h3{margin:0;font-size:17px;font-weight:600;line-height:1.35}
.row h3 small{font-weight:400;color:var(--muted)}
.row p{margin:4px 0 8px;color:#4E423D;font-size:15px}
.tags{display:flex;flex-wrap:wrap;gap:6px;padding:0;margin:0;list-style:none}
.tags li{font-size:12px;padding:1px 8px;background:#EAE2D6;color:var(--ox)}
.cv-link{display:inline-block;margin-top:8px;font-weight:600;text-decoration:none;border-bottom:2px solid var(--saffron)}
.ai-sentence{font-family:var(--serif);font-size:20px;margin:0 0 14px}
.ai-sentence select{font:inherit;font-style:italic;color:var(--ox);background:transparent;border:0;border-bottom:2px solid var(--saffron);padding:0 22px 0 2px;cursor:pointer;appearance:none;-webkit-appearance:none;
background-image:linear-gradient(45deg,transparent 50%,var(--ox) 50%),linear-gradient(135deg,var(--ox) 50%,transparent 50%);background-position:calc(100% - 9px) 55%,calc(100% - 4px) 55%;background-size:5px 5px;background-repeat:no-repeat}
.ai-sentence .cnt{font-family:var(--sans);font-size:13px;color:var(--muted);margin-left:8px}
.proj{grid-template-columns:120px 1fr;position:relative}
.proj .thumb{height:72px;border:1px solid var(--rule);background:repeating-linear-gradient(-45deg,transparent 0 5px,rgba(58,29,26,.07) 5px 6px),#EFE8DE;font-size:10px;color:var(--muted);padding:3px 5px;display:flex;align-items:flex-end;line-height:1.2}
.proj .gauge{position:absolute;left:0;top:16px;bottom:16px;width:3px;background:var(--rule)}
.proj .gauge i{position:absolute;left:0;right:0;bottom:0;background:var(--saffron)}
.proj .ailab{font-size:12px;color:var(--muted);margin-left:8px;font-weight:400;white-space:nowrap}
.proj .ailab b{font-weight:600;color:var(--ox)}
.proj.hide{display:none}
.proj h3 a{text-decoration:none}
.proj h3 a:hover{color:var(--ox);text-decoration:underline;text-decoration-color:var(--saffron)}
.more-proj{font:inherit;font-size:14px;background:none;border:0;padding:8px 0;color:var(--ox);font-weight:600;cursor:pointer;border-bottom:2px solid var(--saffron)}
.topics{display:flex;gap:4px;margin:0 0 6px}
.topics button{font:inherit;font-size:13px;background:none;border:1px solid var(--rule);padding:3px 10px;cursor:pointer;color:var(--muted)}
.topics button[aria-pressed="true"]{border-color:var(--ox);color:var(--ox);background:#EFE9E0}
.post{display:grid;grid-template-columns:90px 1fr auto;gap:16px;align-items:baseline;padding:12px 0;border-bottom:1px solid var(--rule);text-decoration:none}
.post .d{font-size:13px;color:var(--muted)}
.post .t{font-family:var(--serif);font-size:19px;line-height:1.3}
.post .t small{display:block;font-family:var(--sans);font-size:14px;color:var(--muted);margin-top:2px}
.post .tp{font-size:12px;color:var(--ox)}
.post:hover .t{color:var(--ox)}
.post.hide{display:none}
.archive{display:grid;grid-template-columns:220px 1fr;gap:28px;align-items:center;padding:20px;border:1px solid var(--rule);text-decoration:none;transition:border-color .15s}
.archive:hover{border-color:var(--ox)}
.oldsite{height:130px;background:#212121;display:grid;grid-template-columns:34% 1fr;gap:6px;padding:8px;font-size:8px;color:#bbb}
.oldsite .l{background:#2c2c2c;display:flex;flex-direction:column;align-items:center;padding-top:10px;gap:4px}
.oldsite .av{width:26px;height:26px;border-radius:50%;background:#555}
.oldsite .ln{height:3px;width:70%;background:#555}
.oldsite .g{display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;align-content:start}
.oldsite .g i{height:30px;background:#333;border-top:3px solid #90caf9}
.archive h3{margin:0 0 4px;font-family:var(--serif);font-weight:400;font-size:22px}
.archive p{margin:0;color:var(--muted);font-size:15px}
footer{font-size:13px;color:var(--muted);border-top:1px solid var(--rule);padding-top:18px}
/* recruiter mode */
body.rec .not-rec{display:none}
body.rec .proj p, body.rec .proj .thumb{display:none}
body.rec .proj{padding-top:8px;padding-bottom:8px}
body.rec .proj .gauge{top:8px;bottom:8px}
.rec-only{display:none}
body.rec .rec-only{display:block}
.rec-box{background:#EFE9E0;padding:16px 18px;margin:0 0 24px;border-left:3px solid var(--saffron)}
.rec-box dl{margin:0;display:grid;grid-template-columns:110px 1fr;gap:4px 14px;font-size:15px}
.rec-box dt{color:var(--muted)}
.rec-box dd{margin:0}
@media (max-width:900px){
.shell{display:block}
.side{position:relative;height:auto;padding:40px 24px 28px}
.side h1{font-size:42px}
.toc,.readout{display:none}
main{padding:36px 24px}
.row,.proj{grid-template-columns:1fr;gap:6px}
.proj .thumb{display:none}
.archive{grid-template-columns:1fr}
.post{grid-template-columns:1fr;gap:2px}
}
</style>
</head>
<body>
<div class="mock-note"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>tech-3 · The evolved split page</b>Brittany Chiang's sticky identity / scrolling content split, warmed up: a reader toggle (recruiter or curious), a left panel that reacts to what you point at, an AI filter written as a sentence, and a real blog section.</div>
<div class="shell">
<aside class="side" id="side">
<h1>Lucas Winther</h1>
<p class="role"><span class="ph" title="placeholder">Software engineer at Nordlys Systems</span></p>
<p class="intro">I build web tools and infrastructure, by hand for years, and with AI in the loop now. I write about both.</p>
<div class="mode" role="group" aria-label="Reading as">
<button data-mode="curious" aria-pressed="true">Just looking</button>
<button data-mode="rec" aria-pressed="false">Hiring</button>
</div>
<div class="mode-hint" id="modeHint">Everything, including writing and side notes.</div>
<ul class="toc" id="toc">
<li><a href="#about">About<span class="n">01</span></a></li>
<li><a href="#experience">Experience<span class="n">02</span></a></li>
<li><a href="#projects">Projects<span class="n">03</span></a></li>
<li class="not-rec"><a href="#writing">Writing<span class="n">04</span></a></li>
<li><a href="#before">By hand, before<span class="n">05</span></a></li>
</ul>
<div class="readout" id="readout" aria-live="polite">
<div class="lbl">Pointing at</div>
<h3>Nothing yet</h3>
<p>Hover a project, job or underlined word on the right and the details show up here.</p>
</div>
<div class="side-foot">
<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">Email</a><a href="#" title="placeholder: CV PDF">CV</a>
</div>
</aside>
<main>
<section id="about" class="about">
<div class="rec-only rec-box">
<dl>
<dt>Role</dt><dd><span class="ph" title="placeholder">Software engineer, platform team, Nordlys Systems (2023–)</span></dd>
<dt>Education</dt><dd>BSc Computer Science, <span class="ph" title="placeholder">Aarhus University, 2022</span></dd>
<dt>Core stack</dt><dd>TypeScript, Node.js, React, Ansible, Docker, Linux</dd>
<dt>Languages</dt><dd>Danish, English</dd>
<dt>Contact</dt><dd><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a> · <a href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a></dd>
</dl>
<a class="cv-link" href="#" title="placeholder: CV PDF">Download CV (PDF)</a>
</div>
<h2>About</h2>
<p>Computer scientist with a passion for development, which is what the old site said, and it still holds.</p>
<p>I studied computer science and wrote my bachelor thesis on <span class="peek" data-peek="thesis">trust in peer-to-peer archival</span>. Before that I built VR exam projects and a rowing game with <span class="peek" data-peek="oars">two real oars</span>. These days I work on <span class="ph" title="placeholder">platform tooling</span> and run a small <span class="peek" data-peek="homelab">homelab</span> that I rebuild from code.</p>
<p class="not-rec">Since 2023 I've used AI for a growing share of the typing. Every project below says how much, and the <a href="#before">by-hand archive</a> is still up for anyone who'd rather see work without it.</p>
</section>
<section id="experience">
<h2>Experience &amp; education</h2>
<div class="row" data-peek="job">
<div class="when"><span class="ph" title="placeholder">2023 — now</span></div>
<div><h3><span class="ph" title="placeholder">Software engineer · Nordlys Systems</span></h3>
<p>Platform team. CI pipelines, internal developer tooling, deployment automation with Ansible and containers.</p>
<ul class="tags"><li>TypeScript</li><li>Ansible</li><li>Docker</li><li>GitHub Actions</li></ul></div>
</div>
<div class="row" data-peek="stud">
<div class="when"><span class="ph" title="placeholder">2021 — 2022</span></div>
<div><h3><span class="ph" title="placeholder">Student developer · University IT</span></h3>
<p>Small React and Node.js tools for teaching staff; first taste of maintaining something other people rely on.</p>
<ul class="tags"><li>React</li><li>Node.js</li></ul></div>
</div>
<div class="row" data-peek="bsc">
<div class="when"><span class="ph" title="placeholder">2019 — 2022</span></div>
<div><h3>BSc Computer Science <small>· <span class="ph" title="placeholder">Aarhus University</span></small></h3>
<p>Distributed systems, HCI, VR. Thesis: reputation systems in peer-to-peer archival.</p>
<ul class="tags"><li>Distributed systems</li><li>HCI</li><li>User studies</li></ul></div>
</div>
<a class="cv-link" href="#" title="placeholder: CV PDF">Full CV (PDF) →</a>
</section>
<section id="projects">
<h2>Projects</h2>
<p class="ai-sentence">Show me projects made
<select id="aiSel" aria-label="AI involvement">
<option value="all">any way</option>
<option value="none">entirely by hand</option>
<option value="some">with some AI help</option>
<option value="full">fully by AI</option>
</select><span class="cnt" id="cnt"></span></p>
<div id="projList"></div>
<button class="more-proj" id="moreBtn">Show all 15 projects</button>
</section>
<section id="writing" class="not-rec">
<h2>Writing</h2>
<div class="topics" role="group" aria-label="Topic">
<button aria-pressed="true" data-t="all">All</button><button aria-pressed="false" data-t="AI">AI</button><button aria-pressed="false" data-t="Devops">Devops</button><button aria-pressed="false" data-t="Homelab">Homelab</button>
</div>
<div id="posts"></div>
<p><a class="cv-link" href="https://blog.lucaswinther.info">Everything on blog.lucaswinther.info →</a></p>
</section>
<section id="before">
<h2>By hand, before</h2>
<a class="archive" href="https://old.lucaswinther.info" data-peek="old">
<div class="oldsite" aria-hidden="true"><div class="l"><i class="av"></i><i class="ln"></i><i class="ln" style="width:50%"></i></div><div class="g"><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
<div><h3>The 2021 site, frozen</h3><p>React and Material UI, written entirely by hand before I used any AI tools. Kept exactly as it was, at old.lucaswinther.info.</p></div>
</a>
</section>
<footer>
Set in Fraunces and Figtree. Placeholders (dotted underline): employer, job titles and dates, university, blog post titles, CV link, the “Homelab metrics” and “this site” projects, AI-share percentages.
</footer>
</main>
</div>
<script>
const PROJ=[
{id:"site",y:2026,t:"lucaswinther.info",n:"this site",ai:"some",pct:30,ph:1,d:"Design, copy and layout by hand; scroll tracking and the filter pair-written with AI.",tags:["HTML","CSS","JS"]},
{id:"metrics",y:2026,t:"Homelab metrics",ai:"some",pct:40,ph:1,d:"Prometheus and Grafana for the homelab. Alert rules by hand; dashboards drafted by AI and rewritten.",tags:["Prometheus","Grafana"]},
{id:"gacha",y:2025,t:"Event Tracker for Gacha Games",ai:"full",pct:90,d:"Scrapes event announcements from several games into one calendar. I wrote the spec and tests, an agent wrote the code.",tags:["JavaScript","Scraping"],links:"Live · GitHub"},
{id:"config",y:2025,t:"Config",ai:"full",pct:85,d:"Ansible and docker-compose that rebuild my homelab from bare metal.",tags:["Ansible","docker-compose","IaC"],links:"GitHub"},
{id:"hyper",y:2022,t:"HyperCache",n:"bachelor report",ai:"none",pct:0,d:"Reputation systems in peer-to-peer archival, simulated in Node.js.",tags:["Node.js","P2P"],links:"Thesis PDF"},
{id:"dash",y:2021,t:"Student Dashboard",ai:"none",pct:0,d:"React dashboard built on two rounds of user studies.",tags:["React","User studies"],links:"Report · Demo"},
{id:"rowing",y:2020,t:"Mixed-Reality Rowing Game",ai:"none",pct:0,d:"Two real oars with sensors drive a Unity boat race.",tags:["Unity","Arduino","C#"]},
{id:"chess",y:2020,t:"Chessswap",ai:"none",pct:0,d:"Chess where the board swaps every few turns.",tags:["React"]},
{id:"exp",y:2021,t:"Monthly Expenses Tracker",ai:"none",pct:0,d:"Monthly spending in DKK against a budget.",tags:["JavaScript"]},
{id:"vrc",y:2019,t:"VR-Classroom",ai:"none",pct:0,d:"Node.js prototype of a shared VR classroom, with report and video.",tags:["Node.js","VR"]},
{id:"lol",y:2019,t:"LoL Winrate Discord Bot",ai:"none",pct:0,d:"Champion win rates on request in Discord.",tags:["Node.js"]},
{id:"censor",y:2018,t:"Censor Simulator",ai:"none",pct:0,d:"VR high-school exam project: you are the examiner.",tags:["Unity","VR"]},
{id:"breakout",y:2017,t:"BreakoutPython",ai:"none",pct:0,d:"High-school exam project.",tags:["Python"]},
{id:"borlaug",y:2016,t:"Tribute to Norman Borlaug",ai:"none",pct:0,d:"One of my first websites, from freeCodeCamp.",tags:["HTML","CSS"]},
{id:"port",y:2021,t:"Portfolio (2021)",ai:"none",pct:0,d:"The old React site, now the archive.",tags:["React","Material UI"]}
];
const AIL={none:"by hand",some:"some AI help",full:"fully AI"};
const PEEK={
thesis:["HyperCache","Bachelor thesis, 2022. Which peers do you trust to keep a copy of your data when anyone can leave at any time?",0],
oars:["Mixed-Reality Rowing Game","Arduino reads two oar angles; Unity turns them into strokes. Recalibrates from a resting pose.",0],
homelab:["The homelab","Three mini PCs, a NAS and a UPS. Rebuilt from one git repo (Config). Placeholder specs.",null],
job:["Nordlys Systems (placeholder)","Platform team, 2023–now. Uses AI for boilerplate and reviews; owns the deployment pipeline.",null],
stud:["University IT (placeholder)","Student job, 2021–2022. Tools for teaching staff.",null],
bsc:["BSc Computer Science","Placeholder university, 2019–2022. Thesis on P2P archival reputation.",null],
old:["old.lucaswinther.info","The 2021 site, untouched. 13 projects, none made with AI.",0]
};
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
let showAll=false;
function renderProj(){
const v=document.getElementById("aiSel").value;
const list=PROJ.slice().sort((a,b)=>b.y-a.y);
const match=list.filter(p=>v==="all"||p.ai===v);
const shown=(showAll||v!=="all")?match:match.slice(0,6);
document.getElementById("projList").innerHTML=shown.map(p=>`
<div class="row proj" data-proj="${p.id}">
<i class="gauge" title="${p.pct}% AI-written (estimate)"><i style="height:${p.pct}%"></i></i>
<div><div class="thumb">placeholder: ${esc(p.t)}</div></div>
<div><h3><a href="#">${p.ph?`<span class="ph" title="placeholder">${esc(p.t)}</span>`:esc(p.t)}</a> <small>· ${p.y}${p.n?" · "+esc(p.n):""}</small><span class="ailab">— <b>${AIL[p.ai]}</b></span></h3>
<p>${esc(p.d)}</p><ul class="tags">${p.tags.map(t=>`<li>${esc(t)}</li>`).join("")}</ul></div>
</div>`).join("");
document.getElementById("cnt").textContent=match.length+" of "+PROJ.length;
const btn=document.getElementById("moreBtn");
btn.style.display=(v==="all"&&!showAll)?"":"none";
}
document.getElementById("aiSel").addEventListener("change",renderProj);
document.getElementById("moreBtn").addEventListener("click",()=>{showAll=true;renderProj();});
renderProj();
const POSTS=[
["Oct 2026","What I let the agent write, and what I didn't","A line-by-line audit of the gacha tracker.","AI"],
["Aug 2026","Rebuilding the homelab from one git repo","Disaster recovery, tested by wiping a machine.","Devops"],
["Jun 2026","Three mini PCs, one k3s cluster","And why some services went back to plain compose.","Homelab"],
["Mar 2026","Specs are prompts with manners","Writing a good ticket and a good prompt are the same skill.","AI"],
["Nov 2025","Backups that have actually been restored","Restic, a schedule, and a restore drill.","Homelab"],
["Sep 2025","Ansible idempotency, the hard way","The bug that restarted every container on every run.","Devops"]
];
document.getElementById("posts").innerHTML=POSTS.map(p=>`<a class="post" href="https://blog.lucaswinther.info" data-t="${p[3]}"><span class="d"><span class="ph" title="placeholder">${p[0]}</span></span><span class="t"><span class="ph" title="placeholder">${p[1]}</span><small>${p[2]}</small></span><span class="tp">${p[3]}</span></a>`).join("");
document.querySelectorAll(".topics button").forEach(b=>b.addEventListener("click",()=>{
document.querySelectorAll(".topics button").forEach(x=>x.setAttribute("aria-pressed",x===b));
document.querySelectorAll(".post").forEach(p=>p.classList.toggle("hide",b.dataset.t!=="all"&&p.dataset.t!==b.dataset.t));
}));
// reader mode
const HINT={curious:"Everything, including writing and side notes.",rec:"CV summary first, projects condensed, blog hidden."};
document.querySelectorAll(".mode button").forEach(b=>b.addEventListener("click",()=>{
document.querySelectorAll(".mode button").forEach(x=>x.setAttribute("aria-pressed",x===b));
document.body.classList.toggle("rec",b.dataset.mode==="rec");
document.getElementById("modeHint").textContent=HINT[b.dataset.mode];
}));
// readout: left panel shows what you point at
const ro=document.getElementById("readout");
const DEFAULT=ro.innerHTML;
function show(title,text,pct,label){
ro.innerHTML=`<div class="lbl">${label||"Pointing at"}</div><h3>${title}</h3><p>${text}</p>`+
(pct!=null?`<div class="mix" title="hand vs AI"><i style="width:${100-pct}%"></i><b style="width:${pct}%"></b></div><p style="font-size:12px">${100-pct}% by hand · ${pct}% AI <span class="ph" title="placeholder estimate">(est.)</span></p>`:"");
}
document.querySelector("main").addEventListener("mouseover",e=>{
const pr=e.target.closest("[data-proj]");
if(pr){const p=PROJ.find(x=>x.id===pr.dataset.proj);show(esc(p.t)+" · "+p.y,esc(p.tags.join(", "))+". "+(p.links?esc(p.links):""),p.pct,"Project");return;}
const pk=e.target.closest("[data-peek]");
if(pk){const d=PEEK[pk.dataset.peek];show(esc(d[0]),esc(d[1]),d[2]);}
});
document.querySelector("main").addEventListener("mouseleave",()=>{ro.innerHTML=DEFAULT;});
// cursor-following dot grid on the left panel (only moves when the mouse does)
const side=document.getElementById("side");
side.addEventListener("mousemove",e=>{const r=side.getBoundingClientRect();side.style.setProperty("--mx",(e.clientX-r.left)+"px");side.style.setProperty("--my",(e.clientY-r.top)+"px");});
side.addEventListener("mouseleave",()=>{side.style.setProperty("--mx","-500px");side.style.setProperty("--my","-500px");});
// active section in the index
const links=[...document.querySelectorAll("#toc a")];
const io=new IntersectionObserver(es=>{es.forEach(en=>{if(en.isIntersecting){links.forEach(a=>a.classList.toggle("on",a.getAttribute("href")==="#"+en.target.id));}});},{rootMargin:"-30% 0px -60% 0px"});
document.querySelectorAll("main section").forEach(s=>io.observe(s));
</script>
</body>
</html>
+329
View File
@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther: what I know, and the proof</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=Schibsted+Grotesk:ital,wght@0,400;0,500;0,700;1,400&family=Martian+Mono:wght@300;400&display=swap" rel="stylesheet">
<style>
:root{
--bg:#1C1A18; --bg-2:#25221F; --bg-3:#302C28; --bone:#E8E1D2; --muted:#8C847A; --faint:#4A4540;
--ochre:#D8A23F; --moss:#9BAA74;
--sans:"Schibsted Grotesk",system-ui,sans-serif; --mono:"Martian Mono",ui-monospace,monospace;
}
*{box-sizing:border-box}
html{background:var(--bg)}
body{margin:0;color:var(--bone);font-family:var(--sans);font-size:15px;line-height:1.5}
a{color:inherit;text-decoration-color:var(--faint);text-underline-offset:3px}
a:hover{color:var(--ochre);text-decoration-color:var(--ochre)}
.ph{text-decoration:underline dotted var(--muted);text-underline-offset:3px;cursor:help}
.mono{font-family:var(--mono);font-size:11px;letter-spacing:.02em}
.wrap{max-width:1240px;margin:0 auto;padding:0 32px}
.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}
header{display:grid;grid-template-columns:1fr;gap:20px 40px;padding:40px 0 22px;border-bottom:1px solid var(--faint);align-items:end}
h1{margin:0;font-weight:700;font-size:40px;letter-spacing:-.02em;line-height:1}
h1 span{font-weight:400;color:var(--muted)}
.sub{margin:10px 0 0;color:var(--muted);max-width:44em}
.card{display:grid;grid-template-columns:repeat(5,auto);justify-content:start;gap:4px 44px;font-size:14px}
.card .mono{color:var(--muted);display:block;text-transform:uppercase}
.card a{white-space:nowrap}
/* AI filter: proportional bar */
.filterbar{display:grid;grid-template-columns:auto 1fr auto;gap:18px;align-items:center;padding:18px 0}
.filterbar .mono{color:var(--muted);text-transform:uppercase}
.seg{display:flex;height:34px;gap:3px}
.seg button{font:inherit;font-size:13px;border:0;cursor:pointer;color:var(--bone);background:var(--bg-3);text-align:left;padding:0 10px;display:flex;align-items:center;gap:8px;min-width:0;white-space:nowrap;overflow:hidden;transition:background .15s,opacity .15s;position:relative}
.seg button .ln{flex:0 0 22px;height:0;border-top:2px solid var(--bone)}
.seg button[data-ai="some"] .ln{border-top-style:dashed;border-color:var(--ochre)}
.seg button[data-ai="full"] .ln{border-top-style:dotted;border-top-width:3px;border-color:var(--ochre)}
.seg button:hover{background:#3B3631}
.seg button[aria-pressed="true"]{background:var(--bone);color:var(--bg)}
.seg button[aria-pressed="true"] .ln{border-color:var(--bg)}
.seg.filtered button[aria-pressed="false"]{opacity:.45}
.seg b{font-weight:500;font-family:var(--mono);font-size:11px;opacity:.7}
.reset{font:inherit;font-size:13px;background:none;border:0;color:var(--muted);cursor:pointer;text-decoration:underline;text-underline-offset:3px;visibility:hidden}
.seg.filtered ~ .reset{visibility:visible}
/* map */
.map{position:relative;display:grid;grid-template-columns:1.15fr 1fr .95fr;gap:0 72px;padding:8px 0 30px}
.map svg{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}
.map svg path{fill:none;stroke:var(--bone);stroke-width:1.2;opacity:.65}
.map svg path.some{stroke:var(--ochre);stroke-dasharray:5 4}
.map svg path.full{stroke:var(--ochre);stroke-dasharray:1.5 3.5;stroke-width:2;stroke-linecap:round}
.col h2{font-size:12px;font-family:var(--mono);font-weight:400;color:var(--muted);text-transform:uppercase;margin:0 0 12px;display:flex;justify-content:space-between}
.grp{margin:0 0 14px}
.grp h3{font-family:var(--mono);font-weight:300;font-size:10.5px;color:var(--muted);margin:0 0 4px;text-transform:uppercase;letter-spacing:.05em}
.skills{display:flex;flex-wrap:wrap;gap:4px 5px}
.node{position:relative;z-index:1;font:inherit;color:var(--bone);background:var(--bg-2);border:1px solid var(--faint);cursor:pointer;transition:background .12s,border-color .12s,opacity .15s,color .12s;text-align:left}
.skill{padding:2px 8px;font-size:calc(12.5px + var(--w,0) * 1.1px)}
.skill sup{font-family:var(--mono);font-size:9px;color:var(--muted);margin-left:3px}
.proj,.post{display:flex;justify-content:space-between;gap:10px;width:100%;padding:6px 10px;margin:0 0 4px;align-items:baseline}
.proj .y,.post .y{font-family:var(--mono);font-size:10.5px;color:var(--muted);flex:none}
.proj .t{font-weight:500}
.proj[data-ai="some"]{border-left:3px dashed var(--ochre)}
.proj[data-ai="full"]{border-left:3px dotted var(--ochre)}
.proj[data-ai="none"]{border-left:3px solid var(--bone)}
.post{border-color:transparent;background:transparent;border-left:1px solid var(--faint)}
.post .t{font-style:italic}
.map.active .node{opacity:.28}
.map.active .node.lit{opacity:1;border-color:var(--ochre);background:var(--bg-3)}
.map.active .node.src{opacity:1;background:var(--bone);color:var(--bg);border-color:var(--bone)}
.map.active .node.src sup,.map.active .node.src .y{color:var(--bg)}
.node.off{display:none}
.node:focus-visible{outline:2px solid var(--ochre);outline-offset:1px}
.inspector{min-height:66px;border-top:1px solid var(--faint);border-bottom:1px solid var(--faint);padding:12px 0;display:grid;grid-template-columns:200px 1fr auto;gap:24px;align-items:start}
.inspector .mono{color:var(--ochre);text-transform:uppercase}
.inspector h4{margin:0;font-size:18px;font-weight:500}
.inspector p{margin:2px 0 0;color:var(--muted);max-width:60em}
.inspector .pin{color:var(--muted)}
/* matrix */
.lower{display:grid;grid-template-columns:1.4fr 1fr;gap:56px;padding:44px 0 20px}
.lower h2{font-size:22px;font-weight:500;margin:0 0 4px;letter-spacing:-.01em}
.lower .note{color:var(--muted);margin:0 0 14px;font-size:14px}
.matrix{border-collapse:collapse;width:100%}
.matrix th{font-family:var(--mono);font-weight:300;font-size:9.5px;color:var(--muted);padding:2px 0}
.matrix th.r{text-align:right;padding-right:10px;font-family:var(--sans);font-size:12.5px;font-weight:400;color:var(--bone);white-space:nowrap}
.matrix td{padding:1.5px}
.matrix td i{display:block;height:15px;background:var(--bg-2);transition:outline-color .1s}
.matrix td i.l1{background:#5B5348}.matrix td i.l2{background:#A08F72}.matrix td i.l3{background:var(--bone)}
.matrix td i.ai{box-shadow:inset 0 -4px 0 var(--ochre)}
.matrix td i:hover{outline:1px solid var(--ochre)}
.matrix tr.split th.r,.matrix tr.split td{border-top:1px solid var(--faint)}
.matrix .era th{font-size:9.5px;color:var(--muted);text-align:left;padding-top:6px}
.legend{display:flex;gap:16px;margin-top:10px;color:var(--muted);align-items:center;flex-wrap:wrap}
.legend i{display:inline-block;width:14px;height:10px;vertical-align:-1px;margin-right:4px}
.writing ul{list-style:none;margin:0;padding:0;border-top:1px solid var(--faint)}
.writing li a{display:grid;grid-template-columns:72px 1fr;gap:12px;padding:10px 0;border-bottom:1px solid var(--faint);text-decoration:none}
.writing li .k{display:block;font-family:var(--mono);font-size:10px;color:var(--muted);margin-top:2px}
.writing li a:hover .tt{color:var(--ochre)}
.old{display:block;margin-top:22px;padding:14px 16px;border:1px solid var(--faint);text-decoration:none}
.old b{font-weight:500}
.old span{display:block;color:var(--muted);font-size:14px}
.old:hover{border-color:var(--bone);color:var(--bone)}
footer{padding:24px 0 56px;border-top:1px solid var(--faint);margin-top:24px;color:var(--muted);font-size:13px;display:flex;justify-content:space-between;gap:24px;flex-wrap:wrap}
@media (max-width:900px){
header{grid-template-columns:1fr}
.card{grid-template-columns:1fr 1fr}
.map{grid-template-columns:1fr;gap:20px}
.map svg{display:none}
.lower{grid-template-columns:1fr}
.inspector{grid-template-columns:1fr;gap:4px}
.filterbar{grid-template-columns:1fr}
}
@media (max-width:480px){ .wrap{padding:0 16px} h1{font-size:32px} .seg button{font-size:11px;padding:0 6px} .seg button .ln{display:none} .matrix th.r{font-size:11px} }
</style>
</head>
<body>
<div class="mock-note"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>tech-4 · The skills map</b>Skills linked to the projects and posts that prove them: hover one side and the other lights up, with connecting lines whose dash shows AI involvement. The same data again as a skills × years matrix. Borrows sankar.work's "information in different ways".</div>
<div class="wrap">
<header>
<div>
<h1>Lucas Winther <span>/ what I know, and the proof</span></h1>
<p class="sub">Computer scientist. Every skill below links to something I built or wrote. Point at a skill to see where it was used, or at a project to see what it took.</p>
</div>
<div class="card">
<div><span class="mono">Now</span><span class="ph" title="placeholder">Software engineer, Nordlys Systems</span></div>
<div><span class="mono">Education</span>BSc CS, <span class="ph" title="placeholder">Aarhus Univ., 2022</span></div>
<div><span class="mono">Contact</span><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a></div>
<div><span class="mono">Elsewhere</span><a href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a> · <a href="https://github.com/StereotypicalCat">GitHub</a> · <a href="#" title="placeholder: CV PDF">CV</a></div>
<div><span class="mono">By hand, before</span><a href="https://old.lucaswinther.info">old.lucaswinther.info</a></div>
</div>
</header>
<div class="filterbar">
<span class="mono">How it was made</span>
<div class="seg" id="seg" role="group" aria-label="Filter projects by AI involvement"></div>
<button class="reset" id="reset">Show all</button>
</div>
<div class="map" id="map">
<svg id="wires" aria-hidden="true"></svg>
<div class="col"><h2><span>Skills &amp; tools</span><span>size = projects</span></h2><div id="skillCol"></div></div>
<div class="col"><h2><span>Projects</span><span id="pc"></span></h2><div id="projCol"></div></div>
<div class="col"><h2><span>Writing</span><a href="https://blog.lucaswinther.info">blog →</a></h2><div id="postCol"></div></div>
</div>
<div class="inspector" id="insp" aria-live="polite">
<span class="mono">Inspector</span>
<div><h4>Point at anything above</h4><p>Click to pin a selection, click again or press Esc to release it.</p></div>
<span class="pin mono"></span>
</div>
<div class="lower">
<section>
<h2>Skills over time</h2>
<p class="note">The same links, laid out by year. Brighter cells mean more projects that year; an ochre underline means AI was involved. The rule at 2023 is where the by-hand years end.</p>
<table class="matrix" id="matrix"></table>
<div class="legend mono"><span><i style="background:#5B5348"></i>1 project</span><span><i style="background:#A08F72"></i>2</span><span><i style="background:var(--bone)"></i>3+</span><span><i style="background:var(--bg-2);box-shadow:inset 0 -4px 0 var(--ochre)"></i>with AI</span></div>
</section>
<section class="writing">
<h2>Latest writing</h2>
<p class="note">AI tooling, devops and the homelab, at <a href="https://blog.lucaswinther.info">blog.lucaswinther.info</a>.</p>
<ul id="writeList"></ul>
<a class="old" href="https://old.lucaswinther.info"><b>By hand, before →</b><span>The 2021 portfolio at old.lucaswinther.info, frozen. Everything there predates AI tools.</span></a>
</section>
</div>
<footer>
<span>Set in Schibsted Grotesk and Martian Mono. <a href="https://old.lucaswinther.info">Old site</a> · <a href="https://blog.lucaswinther.info">Blog</a> · <a href="https://github.com/StereotypicalCat">GitHub</a></span>
<span>Placeholders (dotted underline): employer, university, all years, blog post titles, CV link, “Homelab metrics” and “this site” projects, skill groupings.</span>
</footer>
</div>
<script>
const GROUPS=[
["Languages",[["js","JavaScript / TS"],["python","Python"],["csharp","C#"],["htmlcss","HTML & CSS"]]],
["Web & apps",[["node","Node.js"],["react","React"],["scraping","Scraping"]]],
["Infrastructure",[["linux","Linux"],["docker","Docker / compose"],["ansible","Ansible"],["k8s","k3s"],["prom","Prometheus & Grafana"]]],
["Hardware & XR",[["unity","Unity"],["vr","VR / MR"],["arduino","Arduino"]]],
["Research",[["p2p","Distributed systems"],["hci","User studies"]]],
["Working with AI",[["agents","Agent workflows"],["spec","Spec writing & review"]]]
];
const SK={};GROUPS.forEach(g=>g[1].forEach(s=>SK[s[0]]=s[1]));
const PROJ=[
{id:"site",y:2026,t:"lucaswinther.info",ai:"some",ph:1,s:["htmlcss","js","agents"],d:"This site. Layout and copy by hand; the wiring of this map pair-written with AI."},
{id:"metrics",y:2026,t:"Homelab metrics",ai:"some",ph:1,s:["prom","docker","linux","agents"],d:"Alerting and dashboards for the homelab. Rules by hand, dashboards drafted by AI."},
{id:"gacha",y:2025,t:"Event Tracker for Gacha Games",ai:"full",s:["js","node","scraping","agents","spec"],d:"Scrapes gacha-game events into one calendar. Spec and tests mine; code by an agent. Live demo and GitHub."},
{id:"config",y:2025,t:"Config",ai:"full",s:["ansible","docker","linux","agents","spec"],d:"Ansible and docker-compose that rebuild the homelab from bare metal."},
{id:"hyper",y:2022,t:"HyperCache (thesis)",ai:"none",s:["node","p2p","js"],d:"Bachelor project: reputation systems in peer-to-peer archival. Thesis PDF."},
{id:"dash",y:2021,t:"Student Dashboard",ai:"none",s:["react","js","hci"],d:"React dashboard built on two rounds of user studies. Report and demo."},
{id:"exp",y:2021,t:"Monthly Expenses Tracker",ai:"none",s:["js","htmlcss"],d:"Spending in DKK against a monthly budget."},
{id:"port",y:2021,t:"Portfolio (2021)",ai:"none",s:["react","js","htmlcss"],d:"The old site, kept as the by-hand archive."},
{id:"rowing",y:2020,t:"Mixed-Reality Rowing Game",ai:"none",s:["unity","csharp","arduino","vr"],d:"Two real oars with sensors steer a boat in Unity."},
{id:"chess",y:2020,t:"Chessswap",ai:"none",s:["react","js"],d:"Chess where the board swaps every few turns."},
{id:"vrc",y:2019,t:"VR-Classroom",ai:"none",s:["node","vr","js","hci"],d:"Shared VR classroom prototype. Report and video."},
{id:"lol",y:2019,t:"LoL Winrate Discord Bot",ai:"none",s:["node","js","scraping"],d:"Champion win rates on request."},
{id:"censor",y:2018,t:"Censor Simulator",ai:"none",s:["unity","csharp","vr"],d:"VR high-school exam project."},
{id:"breakout",y:2017,t:"BreakoutPython",ai:"none",s:["python"],d:"High-school exam project."},
{id:"borlaug",y:2016,t:"Tribute to Norman Borlaug",ai:"none",s:["htmlcss"],d:"One of the first websites I made."}
];
const POSTS=[
{id:"p1",y:"2026",t:"What I let the agent write, and what I didn't",k:"AI",s:["agents","spec","js"],p:["gacha","site"]},
{id:"p2",y:"2026",t:"Rebuilding the homelab from one git repo",k:"Devops",s:["ansible","linux","docker"],p:["config"]},
{id:"p3",y:"2026",t:"Three mini PCs, one k3s cluster",k:"Homelab",s:["k8s","linux","docker"],p:["metrics"]},
{id:"p4",y:"2026",t:"Specs are prompts with manners",k:"AI",s:["spec","agents"],p:["gacha"]},
{id:"p5",y:"2025",t:"Backups that have actually been restored",k:"Homelab",s:["linux","docker"],p:["config"]},
{id:"p6",y:"2025",t:"Ansible idempotency, the hard way",k:"Devops",s:["ansible"],p:["config"]},
{id:"p7",y:"2022",t:"Bachelor report: trust in P2P archival",k:"Thesis",s:["p2p","node"],p:["hyper"],real:1}
];
const AIL={none:"No AI",some:"Some AI help",full:"Fully AI-made"};
const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
let filter=null, pinned=null;
const map=document.getElementById("map"), svg=document.getElementById("wires"), insp=document.getElementById("insp");
const visP=()=>PROJ.filter(p=>!filter||p.ai===filter);
function renderSeg(){
const c={none:0,some:0,full:0};PROJ.forEach(p=>c[p.ai]++);
const seg=document.getElementById("seg");
seg.classList.toggle("filtered",!!filter);
seg.innerHTML=["none","some","full"].map(a=>`<button data-ai="${a}" aria-pressed="${filter===a}" style="flex:${c[a]+2} 1 0"><span class="ln"></span>${AIL[a]} <b>${c[a]}</b></button>`).join("");
}
function render(){
const vp=visP();
document.getElementById("skillCol").innerHTML=GROUPS.map(g=>`<div class="grp"><h3>${g[0]}</h3><div class="skills">`+g[1].map(([id,n])=>{
const cnt=vp.filter(p=>p.s.includes(id)).length;
const pc=POSTS.filter(p=>p.s.includes(id)).length;
return `<button class="node skill${cnt+pc===0?" off":""}" data-kind="s" data-id="${id}" style="--w:${Math.min(cnt,6)}">${esc(n)}<sup>${cnt}</sup></button>`;
}).join("")+`</div></div>`).join("");
document.getElementById("projCol").innerHTML=PROJ.map(p=>`<button class="node proj${vp.includes(p)?"":" off"}" data-kind="p" data-id="${p.id}" data-ai="${p.ai}"><span class="t">${p.ph?`<span class="ph" title="placeholder">${esc(p.t)}</span>`:esc(p.t)}</span><span class="y">${p.y}</span></button>`).join("");
document.getElementById("postCol").innerHTML=POSTS.map(p=>`<button class="node post" data-kind="w" data-id="${p.id}"><span class="t">${p.real?esc(p.t):`<span class="ph" title="placeholder">${esc(p.t)}</span>`}</span><span class="y">${p.k}</span></button>`).join("");
document.getElementById("pc").textContent=vp.length+" / "+PROJ.length;
renderMatrix();
if(pinned)activate(pinned.kind,pinned.id);else clear();
}
function related(kind,id){
const out={s:new Set(),p:new Set(),w:new Set()};
const vp=visP();
if(kind==="s"){vp.forEach(p=>p.s.includes(id)&&out.p.add(p.id));POSTS.forEach(p=>p.s.includes(id)&&out.w.add(p.id));}
if(kind==="p"){const p=PROJ.find(x=>x.id===id);p.s.forEach(s=>out.s.add(s));POSTS.forEach(w=>w.p.includes(id)&&out.w.add(w.id));}
if(kind==="w"){const w=POSTS.find(x=>x.id===id);w.s.forEach(s=>out.s.add(s));w.p.forEach(p=>vp.some(v=>v.id===p)&&out.p.add(p));}
return out;
}
const el=(k,id)=>map.querySelector(`.node[data-kind="${k}"][data-id="${id}"]`);
function wire(a,b,cls){
const m=map.getBoundingClientRect(),ra=a.getBoundingClientRect(),rb=b.getBoundingClientRect();
let x1,x2;
if(ra.left<rb.left){x1=ra.right;x2=rb.left;}else{x1=ra.left;x2=rb.right;}
const y1=ra.top+ra.height/2-m.top,y2=rb.top+rb.height/2-m.top;x1-=m.left;x2-=m.left;
const dx=(x2-x1)*.5;
return `<path class="${cls||""}" d="M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}"/>`;
}
function activate(kind,id){
const src=el(kind,id);if(!src){clear();return;}
const r=related(kind,id);
map.classList.add("active");
map.querySelectorAll(".node").forEach(n=>n.classList.remove("lit","src"));
src.classList.add("src");
let paths="";
["s","p","w"].forEach(k=>r[k].forEach(i=>{
const n=el(k,i);if(!n||n.classList.contains("off"))return;n.classList.add("lit");
let cls="";
if(kind==="p")cls=PROJ.find(x=>x.id===id).ai;
else if(k==="p")cls=PROJ.find(x=>x.id===i).ai;
if(cls==="none")cls="";
// wires: skill<->project, project<->post, skill<->post only if no project in between
if(kind==="s"&&k==="w")cls="post-wire";
paths+=wire(src,n,cls);
}));
svg.innerHTML=paths;
svg.querySelectorAll(".post-wire").forEach(p=>{p.style.opacity=".25";});
// inspector
let title,body,meta="";
if(kind==="s"){title=SK[id];body=`Used in ${r.p.size} project${r.p.size===1?"":"s"}${filter?` (${AIL[filter].toLowerCase()})`:""} and ${r.w.size} post${r.w.size===1?"":"s"}: `+[...r.p].map(i=>PROJ.find(x=>x.id===i).t).join(", ")+".";}
if(kind==="p"){const p=PROJ.find(x=>x.id===id);title=`${p.t} · ${p.y}`;body=p.d;meta=AIL[p.ai];}
if(kind==="w"){const w=POSTS.find(x=>x.id===id);title=w.t;body=`${w.k} · ${w.y}. Touches ${[...r.s].map(s=>SK[s]).join(", ")}. Read on blog.lucaswinther.info.`;}
insp.innerHTML=`<span class="mono">${{s:"Skill",p:"Project",w:"Post"}[kind]}${pinned?" · pinned":""}</span><div><h4>${esc(title)}</h4><p>${esc(body)}</p></div><span class="pin mono">${meta}</span>`;
}
function clear(){
map.classList.remove("active");svg.innerHTML="";
map.querySelectorAll(".node").forEach(n=>n.classList.remove("lit","src"));
insp.innerHTML=`<span class="mono">Inspector</span><div><h4>Point at anything above</h4><p>Click to pin a selection, click again or press Esc to release it.</p></div><span class="pin mono"></span>`;
}
map.addEventListener("mouseover",e=>{const n=e.target.closest(".node");if(n&&!pinned)activate(n.dataset.kind,n.dataset.id);});
map.addEventListener("mouseleave",()=>{if(!pinned)clear();});
map.addEventListener("focusin",e=>{const n=e.target.closest(".node");if(n&&!pinned)activate(n.dataset.kind,n.dataset.id);});
map.addEventListener("click",e=>{
const n=e.target.closest(".node");if(!n)return;
if(pinned&&pinned.id===n.dataset.id&&pinned.kind===n.dataset.kind){pinned=null;activate(n.dataset.kind,n.dataset.id);return;}
pinned={kind:n.dataset.kind,id:n.dataset.id};activate(pinned.kind,pinned.id);
});
document.addEventListener("keydown",e=>{if(e.key==="Escape"){pinned=null;clear();}});
window.addEventListener("resize",()=>{if(pinned)activate(pinned.kind,pinned.id);else clear();});
document.getElementById("seg").addEventListener("click",e=>{
const b=e.target.closest("button");if(!b)return;
filter=filter===b.dataset.ai?null:b.dataset.ai;pinned=null;renderSeg();render();
});
document.getElementById("reset").addEventListener("click",()=>{filter=null;renderSeg();render();});
function renderMatrix(){
const years=[];for(let y=2016;y<=2026;y++)years.push(y);
const vp=visP();
let h=`<tr><th></th>${years.map(y=>`<th>${String(y).slice(2)}</th>`).join("")}</tr>`;
GROUPS.forEach((g,gi)=>g[1].forEach(([id,n],si)=>{
h+=`<tr class="${si===0&&gi>0?"split":""}"><th class="r">${esc(n)}</th>`+years.map(y=>{
const ps=vp.filter(p=>p.y===y&&p.s.includes(id));
const lv=Math.min(ps.length,3);const ai=ps.some(p=>p.ai!=="none");
const tip=ps.length?`${n}, ${y}: ${ps.map(p=>p.t).join(", ")}`:`${n}, ${y}`;
return `<td${y===2023?' style="border-left:1px solid var(--muted)"':""}><i class="${lv?"l"+lv:""}${ai?" ai":""}" title="${esc(tip)}"></i></td>`;
}).join("")+`</tr>`;
}));
h+=`<tr class="era"><th></th><th colspan="7">by hand</th><th colspan="4" style="padding-left:4px">with AI</th></tr>`;
document.getElementById("matrix").innerHTML=h;
}
document.getElementById("writeList").innerHTML=POSTS.filter(p=>!p.real).slice(0,5).map(p=>`<li><a href="https://blog.lucaswinther.info"><span class="mono" style="color:var(--muted)"><span class="ph" title="placeholder">${p.y}</span></span><span><span class="tt ph" title="placeholder">${esc(p.t)}</span><span class="k">${p.k} · ${p.s.map(s=>SK[s]).join(", ")}</span></span></a></li>`).join("");
renderSeg();render();
</script>
</body>
</html>
+341
View File
@@ -0,0 +1,341 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther: lab notebook, vol. II</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=Spectral:ital,wght@0,300;0,400;0,600;1,300;1,400&family=Azeret+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--paper:#F4F2EA; --grid:rgba(96,128,140,.13); --graphite:#2E3032; --muted:#6F716F; --red:#C2443A;
--hl:#F1DE6E; --hl-soft:rgba(241,222,110,.45); --rule:#CFCFC6;
--serif:"Spectral",Georgia,serif; --mono:"Azeret Mono",ui-monospace,monospace;
}
*{box-sizing:border-box}
html{background:var(--paper)}
body{margin:0;color:var(--graphite);font-family:var(--serif);font-size:17px;line-height:1.55;
background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px);background-size:24px 24px}
a{color:inherit;text-decoration-color:var(--red);text-underline-offset:3px}
a:hover{color:var(--red)}
.ph{text-decoration:underline dotted var(--muted);text-underline-offset:3px;cursor:help}
.mono{font-family:var(--mono);font-size:11.5px;letter-spacing:.02em}
.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}
.page{max-width:1040px;margin:0 auto;padding:48px 32px 40px;display:grid;grid-template-columns:150px 1fr;gap:0 34px;position:relative}
.page::before{content:"";position:absolute;top:0;bottom:0;left:calc(32px + 150px + 16px);width:1px;background:var(--red);opacity:.55}
.m{grid-column:1;text-align:right;color:var(--muted);font-size:14px;font-style:italic;line-height:1.4;padding-top:4px}
.m .mono{font-style:normal;display:block;color:var(--graphite)}
.b{grid-column:2;min-width:0}
/* cover */
.cover h1{font-weight:300;font-size:52px;line-height:1.02;margin:0;letter-spacing:-.01em}
.cover h1 i{font-size:26px;color:var(--muted);display:block;margin-top:6px;font-weight:300}
.label{margin:26px 0 0;border:1.5px solid var(--graphite);background:var(--paper);display:grid;grid-template-columns:1fr 1fr;max-width:760px}
.label > div{padding:12px 16px}
.label > div + div{border-left:1px solid var(--graphite)}
.label .mono{display:block;color:var(--muted);text-transform:uppercase;margin-bottom:2px;font-size:10.5px}
.label p{margin:0 0 8px;font-size:16px;line-height:1.4}
.label ol{margin:0;padding:0;list-style:none;font-size:15px}
.label li{display:grid;grid-template-columns:82px 1fr;gap:8px;padding:2px 0}
.label li span:first-child{font-family:var(--mono);font-size:11px;color:var(--muted);padding-top:3px}
.label .bar{grid-column:1 / -1;border-top:1px solid var(--graphite);padding:8px 16px;display:flex;gap:18px;flex-wrap:wrap;font-family:var(--mono);font-size:12px}
.volumes{margin:18px 0 0;max-width:760px;font-size:16px}
.volumes b{font-weight:600}
h2{font-weight:400;font-size:28px;margin:0 0 6px}
.sec{margin-top:56px}
/* stamps filter */
.stamps{display:flex;flex-wrap:wrap;gap:12px 16px;margin:12px 0 18px;align-items:center}
.stamp{font-family:var(--mono);font-size:11.5px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;border:2px solid currentColor;padding:5px 10px 4px;background:transparent;cursor:pointer;transform:rotate(-1.5deg);transition:opacity .15s,transform .15s}
.stamp:nth-child(2){transform:rotate(1deg)}
.stamp:nth-child(3){transform:rotate(-.5deg)}
.stamp.hand{color:var(--graphite)}
.stamp.mix{color:#8A6D12;background:var(--hl-soft)}
.stamp.ai{color:var(--red)}
.stamp[aria-pressed="false"]{opacity:.28;text-decoration:line-through}
.stamp:hover{transform:rotate(0)}
.stamps .hint{color:var(--muted);font-style:italic;font-size:14px}
.tag-stamp{display:inline-block;font-family:var(--mono);font-size:9.5px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;border:1.5px solid currentColor;padding:1px 6px;transform:rotate(-2deg);vertical-align:2px}
/* contents */
.toc{border-top:1.5px solid var(--graphite);margin:0;padding:0;list-style:none}
.toc li{display:grid;grid-template-columns:62px 1fr 110px 92px;gap:12px;align-items:baseline;border-bottom:1px solid var(--rule);padding:6px 0}
.toc li.hide{display:none}
.toc .no{font-family:var(--mono);font-size:11px;color:var(--muted)}
.toc a{text-decoration:none}
.toc a:hover{text-decoration:underline;text-decoration-color:var(--red)}
.toc .kind{font-style:italic;color:var(--muted);font-size:14px}
.prov{display:flex;height:8px;border:1px solid var(--graphite);background:var(--paper)}
.prov i{display:block}
.prov .h{background:repeating-linear-gradient(90deg,var(--graphite) 0 1px,transparent 1px 3px)}
.prov .a{background:var(--hl)}
.toc .note-row .t{font-style:italic}
/* entries */
.entry{margin:0 0 30px;scroll-margin-top:20px}
.entry.hide{display:none}
details.case{border-top:1.5px solid var(--graphite)}
details.case > summary{list-style:none;cursor:pointer;padding:10px 0 8px;display:flex;align-items:baseline;gap:12px;flex-wrap:wrap}
details.case > summary::-webkit-details-marker{display:none}
details.case > summary::before{content:"+";font-family:var(--mono);font-size:14px;width:14px;color:var(--red)}
details.case[open] > summary::before{content:"−"}
summary h3{margin:0;font-size:23px;font-weight:400}
summary .meta{color:var(--muted);font-size:14px;font-style:italic}
summary:hover h3{color:var(--red)}
.case-body{padding:2px 0 8px 26px}
.case-body h4{font-family:var(--mono);font-size:10.5px;font-weight:500;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin:16px 0 6px}
.case-body p{margin:0 0 8px;max-width:38em}
table.who{border-collapse:collapse;width:100%;max-width:720px;font-size:15px;background:var(--paper)}
table.who th{font-family:var(--mono);font-weight:500;font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);text-align:left;padding:4px 10px 4px 0;border-bottom:1px solid var(--graphite)}
table.who td{padding:5px 10px 5px 0;border-bottom:1px solid var(--rule);vertical-align:top}
table.who td:first-child{width:170px}
table.who td.w{width:120px;font-family:var(--mono);font-size:11.5px;white-space:nowrap}
td.w mark{background:var(--hl);color:var(--graphite);padding:0 4px}
td.w .hb{border-bottom:1.5px solid var(--graphite)}
td.w .mx{background:linear-gradient(transparent 55%,var(--hl) 55%);padding:0 4px}
.m > .ph{display:block;width:max-content;margin-left:auto}
ol.dec{margin:0;padding-left:20px;max-width:40em}
ol.dec li{margin:0 0 4px}
.log{margin:0;padding:0;list-style:none;font-size:15.5px;max-width:42em}
.log li{display:grid;grid-template-columns:96px 1fr;gap:10px;padding:3px 0}
.log li span:first-child{font-family:var(--mono);font-size:11px;color:var(--muted);padding-top:4px}
.log .x{color:var(--red)}
.sketch{height:120px;max-width:420px;border:1px dashed var(--graphite);margin:6px 0 10px;position:relative;background:rgba(244,242,234,.7)}
.sketch svg{position:absolute;inset:8px;width:calc(100% - 16px);height:calc(100% - 26px)}
.sketch span{position:absolute;left:8px;bottom:4px;font-family:var(--mono);font-size:10px;color:var(--muted)}
.links{font-family:var(--mono);font-size:12px;display:flex;gap:16px;flex-wrap:wrap;margin-top:8px}
/* blog notes */
.note{border-left:3px solid var(--hl);padding:2px 0 2px 16px;margin:0 0 30px;max-width:40em}
.note h3{margin:0;font-size:20px;font-weight:400;font-style:italic}
.note h3 a{text-decoration:none}
.note h3 a:hover{text-decoration:underline;text-decoration-color:var(--red)}
.note p{margin:4px 0 0;font-size:16px;color:#4A4C4D}
.note .mono{color:var(--muted)}
/* compact older entries */
.older{margin:0;padding:0;list-style:none;border-top:1.5px solid var(--graphite)}
.older li{border-bottom:1px solid var(--rule);padding:8px 0;display:grid;grid-template-columns:1fr auto;gap:4px 16px}
.older li.hide{display:none}
.older b{font-weight:600}
.older .d{grid-column:1 / -1;font-size:15px;color:#4A4C4D}
.foot{border-top:1.5px solid var(--graphite);padding-top:14px;color:var(--muted);font-size:14px}
@media (max-width:760px){
.page{grid-template-columns:1fr;padding:32px 18px}
.page::before{display:none}
.m{grid-column:1;text-align:left;padding:0 0 4px}
.b{grid-column:1}
.b.sec{margin-top:6px}
.m > .ph{margin-left:0}
.label{grid-template-columns:1fr}
.label > div + div{border-left:0;border-top:1px solid var(--graphite)}
.toc li{grid-template-columns:50px 1fr 70px}
.toc .kind{display:none}
.cover h1{font-size:40px}
.case-body{padding-left:0}
table.who td:first-child{width:auto}
}
</style>
</head>
<body>
<div class="mock-note"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>tech-5 · The lab notebook</b>Projects as case files that are honest about process: who wrote which part (highlighter = AI), decisions, and a log of what broke. Blog posts are notebook entries in between. The old site is "Volume I". Light identity in the spirit of ntch.design / beige.zone's archival labels.</div>
<div class="page">
<!-- cover -->
<div class="m"><span class="mono">Vol. II</span>2023 →</div>
<div class="b cover">
<h1>Lab notebook of Lucas Winther<i>Builds, decisions and breakages, with AI in the loop</i></h1>
<div class="label">
<div>
<span class="mono">Owner</span>
<p>Lucas Winther, computer scientist. <span class="ph" title="placeholder">Software engineer at Nordlys Systems</span>.</p>
<span class="mono">Reach</span>
<p><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br><a href="https://www.linkedin.com/in/lucas-winther">linkedin/lucas-winther</a> · <a href="https://github.com/StereotypicalCat">github/StereotypicalCat</a></p>
</div>
<div>
<span class="mono">Experience &amp; education</span>
<ol>
<li><span class="ph" title="placeholder">2023–now</span><span><span class="ph" title="placeholder">Software engineer, Nordlys Systems</span>: platform, CI, deployment</span></li>
<li><span class="ph" title="placeholder">2021–22</span><span><span class="ph" title="placeholder">Student developer, university IT</span></span></li>
<li><span class="ph" title="placeholder">2019–22</span><span>BSc Computer Science, <span class="ph" title="placeholder">Aarhus University</span></span></li>
</ol>
</div>
<div class="bar"><a href="#" title="placeholder: CV PDF">CV (PDF)</a><span>Skills: TypeScript · Node.js · React · Ansible · Docker · Linux · Unity/C#</span></div>
</div>
<p class="volumes"><b>Vol. I</b>, everything from 2016 to 2022, was written entirely by hand. It's kept unaltered as the old site at <a href="https://old.lucaswinther.info">old.lucaswinther.info</a>. This volume marks how much of each build was mine and how much came from an AI.</p>
</div>
<!-- contents -->
<div class="m sec"><span class="mono">p. 1</span>contents</div>
<div class="b sec">
<h2>Contents</h2>
<div class="stamps" role="group" aria-label="Filter by who wrote it">
<button class="stamp hand" data-f="hand" aria-pressed="true">Hand-written</button>
<button class="stamp mix" data-f="mix" aria-pressed="true">AI-assisted</button>
<button class="stamp ai" data-f="ai" aria-pressed="true">AI-made</button>
<span class="hint">Click a stamp to hide or show those entries.</span>
</div>
<ol class="toc" id="toc"></ol>
<p class="mono" style="color:var(--muted);margin-top:8px">Bar = share of the code: <span class="prov" style="display:inline-flex;width:40px;vertical-align:-1px"><i class="h" style="width:100%"></i></span> by hand · <span class="prov" style="display:inline-flex;width:40px;vertical-align:-1px"><i class="a" style="width:100%"></i></span> by AI (highlighter)</p>
</div>
<!-- entries -->
<div class="m sec"><span class="mono">No. 018</span><span class="ph" title="placeholder">14 Oct 2026</span></div>
<div class="b sec"><div class="note entry" data-f="mix" id="n18">
<span class="mono">Note · AI</span>
<h3><a href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">What I let the agent write, and what I didn't</span></a></h3>
<p>Going back through the gacha tracker file by file and marking every line by origin. The surprise was the tests: I thought I wrote most of them. I wrote the names. →</p>
</div></div>
<div class="m"><span class="mono">No. 017</span><span class="ph" title="placeholder">Sep 2026</span>case file</div>
<div class="b"><details class="case entry" data-f="mix" id="c17" open>
<summary><h3><span class="ph" title="placeholder project">Homelab metrics</span></h3><span class="tag-stamp" style="color:#8A6D12;background:var(--hl-soft)">AI-assisted</span><span class="meta">Prometheus · Grafana · Alertmanager</span></summary>
<div class="case-body">
<h4>Goal</h4>
<p>Know that something is wrong before a family member tells me the photo backup stopped.</p>
<h4>Who wrote what</h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>Alert rules</td><td class="w"><span class="hb">by hand</span></td><td>Every alert has to be something I would get up for.</td></tr>
<tr><td>Exporters (UPS, NAS)</td><td class="w"><span class="mx">mixed</span></td><td>AI found the SNMP OIDs; I rewrote the parsing.</td></tr>
<tr><td>Grafana dashboards</td><td class="w"><mark>AI</mark></td><td>Generated JSON, then deleted half the panels.</td></tr>
<tr><td>Compose + Ansible role</td><td class="w"><mark>AI</mark></td><td>Extends the existing Config repo.</td></tr>
</table>
<h4>Decisions</h4>
<ol class="dec"><li>Prometheus over a hosted service: the lab should work offline.</li><li>Four alerts, not forty. Dashboards are for curiosity; alerts are for action.</li></ol>
<h4>What broke</h4>
<ul class="log"><li><span>2026-09-03</span><span class="x">Alert storm on every reboot: 22 notifications for one planned restart.</span></li><li><span>2026-09-04</span><span>Added inhibition rules and a 5-minute <code>for:</code>. Quiet since.</span></li></ul>
<div class="links"><a href="#">Write-up</a><a href="https://github.com/StereotypicalCat">Source</a></div>
</div>
</details></div>
<div class="m"><span class="mono">No. 016</span><span class="ph" title="placeholder">Jun 2026</span></div>
<div class="b"><div class="note entry" data-f="hand" id="n16">
<span class="mono">Note · Homelab</span>
<h3><a href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">Three mini PCs, one k3s cluster</span></a></h3>
<p>Why the cluster happened, and why the media server went back to plain docker-compose a week later. →</p>
</div></div>
<div class="m"><span class="mono">No. 015</span><span class="ph" title="placeholder">Mar 2025</span>case file</div>
<div class="b"><details class="case entry" data-f="ai" id="c15">
<summary><h3>Event Tracker for Gacha Games</h3><span class="tag-stamp" style="color:var(--red)">AI-made</span><span class="meta">JavaScript · scraping · web app</span></summary>
<div class="case-body">
<h4>Goal</h4>
<p>One calendar for limited-time events across several gacha games, so banners stop slipping by unnoticed.</p>
<div class="sketch"><svg viewBox="0 0 400 90" preserveAspectRatio="none" aria-hidden="true"><g fill="none" stroke="#2E3032" stroke-width="1.2"><rect x="4" y="30" width="80" height="30"/><rect x="160" y="30" width="80" height="30"/><rect x="316" y="30" width="80" height="30"/><path d="M84 45H160M240 45H316"/><path d="M152 41l8 4-8 4M308 41l8 4-8 4"/></g><g font-family="Azeret Mono" font-size="9" fill="#2E3032"><text x="14" y="49">scrapers ×4</text><text x="172" y="49">normalise</text><text x="328" y="49">calendar UI</text></g></svg><span>sketch: pipeline</span></div>
<h4>Who wrote what</h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>Spec &amp; acceptance tests</td><td class="w"><span class="hb">by hand</span></td><td>One page of requirements, one failing test per game.</td></tr>
<tr><td>Scrapers</td><td class="w"><mark>AI</mark></td><td>Agent-written against my fixtures.</td></tr>
<tr><td>Event normaliser</td><td class="w"><mark>AI</mark></td><td>I rejected the first version: it guessed time zones.</td></tr>
<tr><td>Calendar UI</td><td class="w"><mark>AI</mark></td><td>Plain JS, no framework, by request.</td></tr>
<tr><td>Review</td><td class="w"><span class="hb">by hand</span></td><td>Every change read before merge.</td></tr>
</table>
<h4>Decisions</h4>
<ol class="dec"><li>Scrape announcement pages, not the games: less fragile, and no ToS grey zone.</li><li>Store everything in UTC; render in the visitor's zone.</li><li>Let the agent write code, never let it write the tests' expectations.</li></ol>
<h4>What broke</h4>
<ul class="log"><li><span class="ph" title="placeholder">2025-05-11</span><span class="x">Two scrapers broke when a site changed its markup.</span></li><li><span class="ph" title="placeholder">2025-05-11</span><span>Fixed by the agent in one pass from the failing fixtures. Took 10 minutes.</span></li><li><span class="ph" title="placeholder">2025-07-02</span><span class="x">Duplicate events after a daylight-saving change.</span></li></ul>
<div class="links"><a href="#">Live demo</a><a href="https://github.com/StereotypicalCat">GitHub</a></div>
</div>
</details></div>
<div class="m"><span class="mono">No. 014</span><span class="ph" title="placeholder">Jan 2025</span>case file</div>
<div class="b"><details class="case entry" data-f="ai" id="c14">
<summary><h3>Config</h3><span class="tag-stamp" style="color:var(--red)">AI-made</span><span class="meta">Ansible · docker-compose · IaC</span></summary>
<div class="case-body">
<h4>Goal</h4>
<p>Rebuild every homelab machine from bare metal with one command, and have git be the documentation.</p>
<h4>Who wrote what</h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>Inventory &amp; layout</td><td class="w"><span class="hb">by hand</span></td><td>Which service lives where, and why.</td></tr>
<tr><td>Playbooks &amp; roles</td><td class="w"><mark>AI</mark></td><td>Generated, then iterated over a month.</td></tr>
<tr><td>Compose files</td><td class="w"><mark>AI</mark></td><td>Templated from one pattern I wrote down.</td></tr>
</table>
<h4>What broke</h4>
<ul class="log"><li><span class="ph" title="placeholder">2025-02-20</span><span class="x">Every run restarted every container: a template changed a timestamp on each render.</span></li><li><span class="ph" title="placeholder">2025-02-21</span><span>Found it by hand with <code>--diff</code>. The agent kept suggesting handlers.</span></li></ul>
<div class="links"><a href="https://github.com/StereotypicalCat">GitHub</a></div>
</div>
</details></div>
<div class="m"><span class="mono">No. 013</span><span class="ph" title="placeholder">Sep 2025</span></div>
<div class="b"><div class="note entry" data-f="hand" id="n13">
<span class="mono">Note · Devops</span>
<h3><a href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">Ansible idempotency, the hard way</span></a></h3>
<p>The write-up of No. 014's worst week. →</p>
</div></div>
<!-- Vol I summary -->
<div class="m sec"><span class="mono">Vol. I</span>2016–2022<br>by hand</div>
<div class="b sec">
<h2>From volume I</h2>
<p style="margin:0 0 12px;max-width:40em">The older work, summarised. Nothing here used AI. The full entries, with screenshots and demos, live on the <a href="https://old.lucaswinther.info">old site</a>.</p>
<ul class="older" id="older"></ul>
</div>
<div class="m sec"></div>
<footer class="b sec"><div class="foot">
Set in Spectral and Azeret Mono. Also: <a href="https://blog.lucaswinther.info">blog.lucaswinther.info</a> (all notes) · <a href="https://old.lucaswinther.info">Vol. I, the old site</a>.<br>
Placeholders (dotted underline): employer and job titles, university, all dates, blog post titles and excerpts, CV link, the Homelab metrics project, log entries and process breakdowns.
</div></footer>
</div>
<script>
const OLD=[
{t:"HyperCache, bachelor report",y:2022,d:"Reputation systems for peer-to-peer archival, simulated in Node.js. Thesis PDF.",k:"research"},
{t:"Student Dashboard",y:2021,d:"React, built on two rounds of user studies. Report and demo.",k:"HCI"},
{t:"Monthly Expenses Tracker",y:2021,d:"Spending in DKK, all amounts in øre to dodge floating point.",k:"web app"},
{t:"Portfolio",y:2021,d:"The old React + Material UI site itself.",k:"website"},
{t:"Mixed-Reality Rowing Game",y:2020,d:"Unity, Arduino and C#; two real oars with sensors.",k:"hardware"},
{t:"Chessswap",y:2020,d:"Chess where the board swaps every few turns. React.",k:"game"},
{t:"VR-Classroom",y:2019,d:"Node.js prototype, report and video.",k:"prototype"},
{t:"LoL Winrate Discord Bot",y:2019,d:"Node.js.",k:"bot"},
{t:"Censor Simulator",y:2018,d:"VR high-school exam project in Unity.",k:"VR"},
{t:"BreakoutPython",y:2017,d:"High-school exam project.",k:"game"},
{t:"Tribute to Norman Borlaug",y:2016,d:"One of the first websites, from freeCodeCamp.",k:"website"}
];
document.getElementById("older").innerHTML=OLD.map(o=>`<li data-f="hand" class="entry-lite"><span><b>${o.t}</b> <span style="color:var(--muted);font-style:italic">· ${o.k}</span></span><span class="mono" style="color:var(--muted)">${o.y} <span class="tag-stamp" style="margin-left:6px">hand</span></span><span class="d">${o.d}</span></li>`).join("");
const TOC=[
["018","What I let the agent write, and what I didn't","note · AI","n18","mix",null,1],
["017","Homelab metrics","case file","c17","mix",55,1],
["016","Three mini PCs, one k3s cluster","note · homelab","n16","hand",null,1],
["015","Event Tracker for Gacha Games","case file","c15","ai",10,0],
["014","Config","case file","c14","ai",15,0],
["013","Ansible idempotency, the hard way","note · devops","n13","hand",null,1],
["I","Eleven projects from volume I","summary","older","hand",100,0]
];
document.getElementById("toc").innerHTML=TOC.map(r=>{
const note=r[2].startsWith("note");
const t=r[6]?`<span class="ph" title="placeholder">${r[1]}</span>`:r[1];
return `<li data-f="${r[4]}" class="${note?"note-row":""}"><span class="no">No. ${r[0]}</span><a class="t" href="#${r[3]}" data-open="${r[3]}">${t}</a><span class="kind">${r[2]}</span>${r[5]!=null?`<span class="prov" title="${r[5]}% by hand (estimate)"><i class="h" style="width:${r[5]}%"></i><i class="a" style="width:${100-r[5]}%"></i></span>`:`<span class="mono" style="color:var(--muted)">${{hand:"written",mix:"written w/ AI",ai:"AI"}[r[4]]}</span>`}</li>`;
}).join("");
document.getElementById("toc").addEventListener("click",e=>{
const a=e.target.closest("[data-open]");if(!a)return;
const d=document.getElementById(a.dataset.open);if(d&&d.tagName==="DETAILS")d.open=true;
});
const on={hand:true,mix:true,ai:true};
document.querySelectorAll(".stamp").forEach(b=>b.addEventListener("click",()=>{
on[b.dataset.f]=!on[b.dataset.f];b.setAttribute("aria-pressed",on[b.dataset.f]);
document.querySelectorAll("[data-f]").forEach(el=>{
if(el.classList.contains("stamp"))return;
const hide=!on[el.dataset.f];
el.classList.toggle("hide",hide);
// hide margin note alongside a hidden entry
const wrap=el.closest(".b");
if(wrap&&wrap.previousElementSibling&&wrap.previousElementSibling.classList.contains("m")&&el.classList.contains("entry")){
wrap.style.display=hide?"none":"";wrap.previousElementSibling.style.display=hide?"none":"";
}
});
}));
</script>
</body>
</html>
+851
View File
@@ -0,0 +1,851 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther / work: the notebook</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Serif:ital,wght@0,300;0,400;0,500;1,300;1,400&display=swap" rel="stylesheet">
<style>
:root{
--bg:#e8eae6; --grid:rgba(160,168,162,.32); --paper:#f4f4f0; --ink:#1c2024; --soft:#3a4044; --mute:#6b726f; --line:#cfd3cd;
--acc:#d9532b; --hl:#efd96a; --hl-soft:rgba(239,217,106,.5); --art:#2a1a1f; --art-ink:#f1e6d6;
--serif:"IBM Plex Serif",Georgia,serif; --sans:"IBM Plex Sans",system-ui,sans-serif; --mono:"IBM Plex Mono",ui-monospace,monospace;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--serif);font-size:15.5px;line-height:1.55;
background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px);background-size:24px 24px;background-position:-1px -1px}
a{color:inherit;text-decoration-color:var(--acc);text-underline-offset:3px}
a:hover{color:var(--acc)}
.ph{text-decoration:underline dotted var(--mute);text-underline-offset:3px;cursor:help}
.mono{font-family:var(--mono)}
.k{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
.k b{color:var(--acc);font-weight:500}
.mock-note{position:fixed;top:10px;right:56px;z-index:99;max-width:300px;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}
/* edge to the other gate, as in nav-d's opened state */
.edge{position:fixed;top:0;right:0;bottom:0;width:40px;background:var(--art);color:var(--art-ink);z-index:20;display:flex;align-items:center;justify-content:center;text-decoration:none;writing-mode:vertical-rl;font:italic 15px/1 Georgia,serif;letter-spacing:.03em;transition:width .2s}
.edge:hover{width:52px;color:#e8b04a}
.wrap{max-width:1340px;margin:0 auto;padding:0 64px 0 24px;display:grid;grid-template-columns:232px minmax(0,1fr);gap:0 40px}
/* ---------- rail ---------- */
.rail{position:sticky;top:0;height:100vh;overflow:auto;padding:22px 0 24px;font-family:var(--sans);font-size:13.5px;scrollbar-width:thin}
.rail .back{font-family:var(--mono);font-size:12px;text-decoration:none;display:inline-flex;gap:8px;align-items:center;color:var(--soft)}
.rail .back:hover{color:var(--acc)}
.rail .back kbd,.kbd{font-family:var(--mono);font-size:10px;border:1px solid currentColor;border-radius:3px;padding:0 4px;opacity:.7}
.rail h2{font-family:var(--mono);font-weight:500;font-size:13px;letter-spacing:.06em;text-transform:uppercase;margin:22px 0 8px;display:flex;justify-content:space-between;align-items:baseline}
.rail h2 span{font-size:10px;color:var(--mute);letter-spacing:.04em;text-transform:none;font-weight:400}
.filter{border:1px solid var(--ink);background:var(--paper);padding:8px 10px 9px}
.filter .k{display:block;margin-bottom:5px}
.filter button{display:flex;width:100%;align-items:center;gap:8px;background:none;border:0;font:13px var(--sans);color:var(--soft);padding:3px 0;cursor:pointer;text-align:left}
.filter button::before{content:"";width:9px;height:9px;border:1.5px solid var(--ink);border-radius:50%;flex:none}
.filter button[aria-pressed="true"]{color:var(--ink);font-weight:500}
.filter button[aria-pressed="true"]::before{background:var(--acc);border-color:var(--acc)}
.filter button:hover{color:var(--acc)}
.filter .note{font-family:var(--mono);font-size:10.5px;color:var(--mute);margin-top:4px;min-height:14px}
.grp{margin:16px 0 0}
.grp .k{display:block;padding:0 0 4px;border-bottom:1px solid var(--ink);margin-bottom:2px}
.toc{list-style:none;margin:0;padding:0}
.toc a{position:relative;display:grid;grid-template-columns:24px 1fr 12px;gap:6px;align-items:baseline;padding:3px 6px 3px 0;text-decoration:none;color:var(--soft);border-bottom:1px dotted var(--line);transition:color .15s,background .15s}
.toc a .pg{font-family:var(--mono);font-size:10px;color:var(--mute)}
.toc a:hover{color:var(--ink);background:rgba(255,255,255,.55)}
.toc a.on{color:var(--ink);font-weight:500;background:var(--paper)}
.toc a.on::before{content:"";position:absolute;left:-14px;top:2px;bottom:2px;width:8px;background:var(--acc)}
.toc a.feat .t::after{content:" ✦";display:none}
.toc li.off a{opacity:.32;text-decoration:line-through;text-decoration-color:var(--mute)}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;border:1.5px solid var(--ink);align-self:center}
.dot.ai{background:var(--acc);border-color:var(--acc)}
.dot.some{background:linear-gradient(90deg,var(--ink) 50%,transparent 50%)}
.dot.unk{border-style:dashed;border-color:var(--mute)}
.legend{margin-top:14px;font-family:var(--mono);font-size:10px;color:var(--mute);display:grid;grid-template-columns:1fr 1fr;gap:3px 8px}
.legend span{display:flex;gap:6px;align-items:center}
.rail .also{margin-top:16px;padding-top:10px;border-top:1px solid var(--ink);font-family:var(--mono);font-size:11.5px;display:flex;flex-direction:column;gap:4px}
.rail .also a{text-decoration:none;color:var(--soft)}
.rail .also a:hover{color:var(--acc)}
.railtoggle{display:none;width:100%;margin:0 0 10px;background:var(--paper);border:1px solid var(--ink);font:12px var(--mono);padding:6px 8px;text-align:left;cursor:pointer}
.rail .keys{font-family:var(--mono);font-size:10px;color:var(--mute);margin-top:10px}
/* ---------- main ---------- */
main{padding:22px 0 60px;min-width:0}
.crumb{display:flex;justify-content:space-between;font-family:var(--mono);font-size:11.5px;color:var(--mute);border-bottom:1px solid var(--ink);padding-bottom:8px}
.crumb a{text-decoration:none}
.row{display:grid;grid-template-columns:minmax(0,1fr) 210px;gap:0 28px}
.row > .notes{padding-top:8px}
/* CV block */
.cv{margin-top:20px}
.cv h1{font-family:var(--mono);font-weight:500;font-size:34px;letter-spacing:-.03em;line-height:1.05;margin:0}
.cv h1 i{font-family:var(--serif);font-weight:300;font-size:19px;letter-spacing:0;color:var(--soft);display:block;margin-top:6px}
.card{margin-top:14px;border:1.5px solid var(--ink);background:var(--paper);display:grid;grid-template-columns:1.35fr 1fr 1fr .9fr;font-family:var(--sans);font-size:13.5px;line-height:1.45}
.card > div{padding:10px 14px 12px}
.card > div + div{border-left:1px solid var(--line)}
.card .k{display:block;margin-bottom:4px}
.card p{margin:0 0 4px}
.card .sub{color:var(--mute);font-size:12.5px}
.card ul{margin:2px 0 0;padding:0;list-style:none}
.card li{padding-left:12px;position:relative}
.card li::before{content:"–";position:absolute;left:0;color:var(--mute)}
.card .cvpdf{display:inline-block;margin-top:6px;font-family:var(--mono);font-size:12px;border:1px solid var(--ink);padding:2px 8px;text-decoration:none}
.card .cvpdf:hover{background:var(--ink);color:var(--paper)}
.card .wide{grid-column:1 / -1;border-left:0;border-top:1px solid var(--line);padding:6px 14px;font-family:var(--mono);font-size:11.5px;color:var(--soft);display:flex;gap:6px 16px;flex-wrap:wrap}
/* group heading */
.ghead{margin:46px 0 10px;display:flex;flex-wrap:wrap;align-items:baseline;gap:14px;border-bottom:1px solid var(--ink);padding-bottom:6px}
.ghead h2{font-family:var(--mono);font-weight:500;font-size:22px;letter-spacing:-.02em;margin:0}
.ghead p{margin:0;color:var(--mute);font-style:italic;font-size:15px}
/* spread */
.proj{scroll-margin-top:16px;margin:0 0 26px}
.proj.hide,.notes.hide{display:none}
.spread{background:var(--paper);border:1px solid #b9beb8;box-shadow:0 1px 0 #b9beb8,0 3px 0 -1px var(--paper),0 3px 0 0 #b9beb8;position:relative}
.shead{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;padding:12px 20px 10px;border-bottom:1px solid var(--line)}
.shead .k{flex:none}
.shead h3{margin:0;font-family:var(--serif);font-weight:400;font-size:27px;line-height:1.1;letter-spacing:-.005em}
.shead .meta{font-family:var(--mono);font-size:11.5px;color:var(--mute)}
.shead .stamp{margin-left:auto}
.stamp{font-family:var(--mono);font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;border:1.5px solid currentColor;padding:2px 7px 1px;transform:rotate(-2deg);display:inline-block;white-space:nowrap}
.stamp.hand{color:var(--ink)}
.stamp.ai{color:var(--acc)}
.stamp.some{color:#7a6410;background:var(--hl-soft)}
.stamp.unk{color:var(--mute);border-style:dashed}
.pages{display:grid;grid-template-columns:1fr 1fr;position:relative}
.pages::before{content:"";position:absolute;top:0;bottom:0;left:50%;width:22px;margin-left:-11px;background:linear-gradient(90deg,transparent,rgba(28,32,36,.06) 45%,rgba(28,32,36,.1) 50%,rgba(28,32,36,.06) 55%,transparent);pointer-events:none}
.page{padding:14px 26px 18px 20px;min-width:0}
.page + .page{padding:14px 20px 18px 30px}
.page.single{grid-column:1 / -1;max-width:46em}
.single-pages{display:grid;grid-template-columns:1.25fr 1fr;gap:0 28px;padding:14px 20px 16px}
h4{font-family:var(--mono);font-size:10.5px;font-weight:500;text-transform:uppercase;letter-spacing:.1em;color:var(--mute);margin:14px 0 5px;display:flex;align-items:center;gap:8px}
h4:first-child{margin-top:2px}
h4::after{content:"";flex:1;border-bottom:1px solid var(--line)}
.page p,.single-pages p{margin:0 0 8px}
.draft{font-family:var(--mono);font-size:9.5px;letter-spacing:.06em;color:var(--mute);border:1px dashed var(--mute);padding:0 4px;text-transform:uppercase;font-weight:400}
figure{margin:6px 0 4px;position:relative}
figure svg{display:block;width:100%;height:auto}
figcaption{font-family:var(--mono);font-size:10.5px;color:var(--mute);margin-top:2px}
.anno{font-family:var(--mono);font-style:italic;fill:var(--acc);font-size:10px}
.lbl{font-family:var(--mono);font-size:10.5px;fill:var(--ink)}
.lbl-s{font-family:var(--mono);font-size:9.5px;fill:var(--mute)}
table.who{border-collapse:collapse;width:100%;font-family:var(--sans);font-size:13px;line-height:1.35}
table.who th{font-family:var(--mono);font-weight:500;font-size:9.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--mute);text-align:left;padding:3px 8px 3px 0;border-bottom:1px solid var(--ink)}
table.who td{padding:5px 8px 5px 0;border-bottom:1px solid var(--line);vertical-align:top}
table.who td.w{font-family:var(--mono);font-size:11px;white-space:nowrap;width:78px}
.w .h{border-bottom:1.5px solid var(--ink)}
.w .a{background:var(--hl);padding:0 3px}
.w .m{background:linear-gradient(transparent 55%,var(--hl) 55%);padding:0 3px}
.w .u{color:var(--mute);border-bottom:1px dashed var(--mute)}
.share{display:flex;height:8px;border:1px solid var(--ink);margin:8px 0 2px;background:var(--paper)}
.share i{display:block}
.share .h{background:repeating-linear-gradient(90deg,var(--ink) 0 1px,transparent 1px 3px)}
.share .a{background:var(--hl)}
.share .u{background:repeating-linear-gradient(135deg,var(--line) 0 3px,transparent 3px 6px)}
.share-l{font-family:var(--mono);font-size:10px;color:var(--mute)}
ol.dec{margin:0;padding-left:18px;font-size:15px}
ol.dec li{margin:0 0 4px}
ul.log{margin:0;padding:0;list-style:none;font-size:14.5px}
ul.log li{position:relative;padding:2px 0 2px 20px}
ul.log li::before{content:"✕";position:absolute;left:0;top:5px;font-family:var(--mono);font-size:11px;color:var(--acc)}
ul.log li.fix::before{content:"✓";color:var(--ink)}
.links{display:flex;gap:6px 14px;flex-wrap:wrap;font-family:var(--mono);font-size:12px;margin-top:6px}
.links a::after{content:" ↗";color:var(--mute)}
.sfoot{display:flex;justify-content:space-between;gap:12px;border-top:1px solid var(--line);padding:7px 20px;font-family:var(--mono);font-size:11px;color:var(--mute)}
.sfoot a{text-decoration:none;color:var(--soft)}
.sfoot a:hover{color:var(--acc)}
/* compact page entries */
.mini{background:var(--paper);border:1px solid #b9beb8;border-left:3px solid var(--ink);display:grid;grid-template-columns:150px minmax(0,1fr) auto;gap:4px 18px;padding:10px 16px 11px;align-items:baseline}
.mini h3{margin:0;font-weight:400;font-size:19px;line-height:1.2}
.mini .meta{font-family:var(--mono);font-size:11px;color:var(--mute)}
.mini p{margin:0;font-size:15px;grid-column:2 / 3}
.mini .left{grid-row:span 2}
.mini .links{grid-column:2 / -1;margin-top:2px}
.mini .stamp{justify-self:end}
.proj.small{margin-bottom:10px}
/* margin notes = writing */
.mnote{position:relative;font-family:var(--serif);font-size:14px;line-height:1.4;padding:6px 0 10px 12px;border-left:2px solid var(--hl);margin-bottom:16px}
.mnote .k{display:block;margin-bottom:2px}
.mnote a.t{font-style:italic;font-size:15.5px;text-decoration:none;display:block;color:var(--ink)}
.mnote a.t:hover{color:var(--acc);text-decoration:underline;text-decoration-color:var(--acc)}
.mnote p{margin:3px 0 0;color:var(--soft)}
.mnote .rel{font-family:var(--mono);font-size:10px;color:var(--mute);margin-top:4px;display:block}
.notes h5{font-family:var(--mono);font-weight:500;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;margin:0 0 10px;color:var(--ink);display:flex;justify-content:space-between}
.notes h5 a{color:var(--mute);text-decoration:none;font-weight:400}
.notes h5 a:hover{color:var(--acc)}
main > footer{margin-top:50px;border-top:1px solid var(--ink);padding-top:12px;font-family:var(--sans);font-size:13px;color:var(--mute);max-width:900px}
main > footer p{margin:0 0 6px}
@media (max-width:1180px){
.row{grid-template-columns:1fr}
.row > .notes{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0 20px;padding-top:0}
.notes h5{grid-column:1 / -1}
}
@media (max-width:860px){
.wrap{grid-template-columns:1fr;padding:0 52px 0 16px}
.rail{position:relative;height:auto;border-bottom:1px solid var(--ink)}
.rail .grps,.rail .legend,.rail .keys{display:none}
.rail.open .grps{display:grid}
.rail.open .legend{display:grid}
.railtoggle{display:block!important}
.rail .grps{grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0 18px}
.toc a.on::before{display:none}
.card{grid-template-columns:1fr 1fr}
.card > div:nth-child(3){border-left:0}
.card > div:nth-child(n+3){border-top:1px solid var(--line)}
.pages,.single-pages{grid-template-columns:1fr}
.pages::before{display:none}
.page + .page{padding:4px 20px 18px 20px}
.mini{grid-template-columns:1fr auto}
.mini p,.mini .links{grid-column:1 / -1}
.mini .left{grid-row:auto}
}
@media (max-width:480px){
.card{grid-template-columns:1fr}
.card > div + div{border-left:0;border-top:1px solid var(--line)}
.cv h1{font-size:27px}
.shead h3{font-size:22px}
.edge{width:30px;font-size:13px}
.wrap{padding-right:40px}
}
</style>
</head>
<body>
<div class="mock-note"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>tech-r2-a · The notebook, project-first</b>T5's lab notebook, moved into the left gate's grid paper and Plex type. Every project is a notebook spread (what and why, a sketch of how it works, who wrote what, decisions, what broke); a page-tab rail lists them all. Writing lives in the margins. Borrows ntch.design's tagged list and beige.zone's archival labels.</div>
<a class="edge" href="nav-d.html" title="Cross to the personal side">off the clock →</a>
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<filter id="rough" x="-5%" y="-5%" width="110%" height="110%"><feTurbulence type="fractalNoise" baseFrequency=".018" numOctaves="1" seed="4"/><feDisplacementMap in="SourceGraphic" scale="2.2"/></filter>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 1 L9 5 L0 9" fill="none" stroke="#1c2024" stroke-width="1.4"/></marker>
<marker id="arr-r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 1 L9 5 L0 9" fill="none" stroke="#d9532b" stroke-width="1.4"/></marker>
</defs>
</svg>
<div class="wrap">
<!-- =============== RAIL =============== -->
<nav class="rail" aria-label="Contents">
<a class="back" href="nav-d.html"><span>←</span> the gate <kbd>Esc</kbd></a>
<h2>Contents <span id="count">18 projects</span></h2>
<button class="railtoggle" id="railtoggle" aria-expanded="false">show all projects ▾</button>
<div class="filter" role="group" aria-label="Filter projects by how they were made">
<span class="k">projects made</span>
<button aria-pressed="true" data-pref="all">any way</button>
<button aria-pressed="false" data-pref="nofull">not fully by AI</button>
<button aria-pressed="false" data-pref="hand">by hand only</button>
<div class="note" id="fnote"></div>
</div>
<div class="grps" id="grps"><!-- built from the pages below --></div>
<div class="legend">
<span><i class="dot"></i>by hand</span><span><i class="dot some"></i>some AI</span>
<span><i class="dot ai"></i>AI-made</span><span><i class="dot unk"></i>not marked yet</span>
</div>
<div class="also">
<a href="#cv">↑ CV and contact</a>
<a href="https://blog.lucaswinther.info">all writing ↗</a>
<a href="https://old.lucaswinther.info">the 2021 site (by hand) ↗</a>
<a href="nav-d.html">off the clock →</a>
</div>
<div class="keys"><span class="kbd">[</span> <span class="kbd">]</span> previous / next project</div>
</nav>
<!-- =============== MAIN =============== -->
<main>
<div class="crumb"><span><a href="nav-d.html">lucaswinther.info</a> / work / notebook</span><span>vol. II · <span class="ph" title="placeholder">updated Nov 2026</span></span></div>
<!-- CV, compact, at the very top -->
<section class="row cv" id="cv">
<div>
<h1>Lucas Winther<i>A notebook of things I've built: what they are, how they work, and who (or what) wrote each part.</i></h1>
<div class="card">
<div>
<span class="k"><b>●</b> now</span>
<p><b>IT-medarbejder</b>, IT Research Tech, Aarhus University <span class="sub">· Jan 2025–now</span></p>
<p class="sub">Builds and runs research software: a Slurm compute platform, document services, and the secure, monitored infrastructure under them.</p>
</div>
<div>
<span class="k">before</span>
<ul>
<li>ScienceAtHome, intern <span class="sub">· Jul 2019</span></li>
<li>TERMA Group, student helper, local configuration management <span class="sub">· 2016–18</span></li>
</ul>
</div>
<div>
<span class="k">education</span>
<p>BSc Computer Science, <span class="ph" title="placeholder: university">Aarhus University</span></p>
<p class="sub">Thesis: <a href="#p-hypercache">HyperCache</a>, reputation in P2P archival</p>
</div>
<div>
<span class="k">reach</span>
<p><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br><a href="https://github.com/StereotypicalCat">GitHub</a> · <a href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a></p>
<a class="cvpdf" href="#" title="placeholder: CV PDF">CV (PDF) ↓</a>
</div>
<div class="wide"><span>DevOps</span><span>Docker</span><span>Linux admin</span><span>Observability</span><span>Cloud (OCI)</span><span>Slurm</span><span>React / Node</span><span>Ansible</span><span>Unity / C#</span><span>Leadership &amp; organisation</span></div>
</div>
</div>
<aside class="notes" aria-label="Latest writing">
<h5>Latest writing <a href="https://blog.lucaswinther.info">blog ↗</a></h5>
<div class="mnote">
<span class="k"><span class="ph" title="placeholder">28 Oct 2026</span> · AI</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">What I let the agent write, and what I didn't</span></a>
<p>Going back through two projects line by line and marking every part by origin.</p>
<span class="rel">→ see <a href="#p-gacha">Gacha tracker</a>, <a href="#p-config">Config</a></span>
</div>
<div class="mnote">
<span class="k"><span class="ph" title="placeholder">Sep 2026</span> · devops</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">A Slurm cluster that scales to zero</span></a>
<span class="rel">→ see <a href="#p-compute">AU compute platform</a></span>
</div>
</aside>
</section>
<!-- ================= AT WORK ================= -->
<div class="ghead" id="g-work"><h2>At work</h2><p>Aarhus University, IT Research Tech, 2025–now; earlier internships.</p></div>
<section class="row">
<article class="proj" id="p-compute" data-group="at work" data-title="AU compute platform" data-ai="unk" data-feat>
<div class="spread">
<header class="shead">
<span class="k">pp. 2–3</span>
<h3>AU compute platform</h3>
<span class="meta">Slurm · React · Node · Oracle Cloud · Entra ID · ERDA · 2025–now</span>
<span class="stamp unk" title="AI level not marked yet (placeholder)">AI: not marked</span>
</header>
<div class="pages">
<div class="page">
<h4>What and why</h4>
<p>A compute job system for researchers at Aarhus University. Slurm is powerful but unfriendly; this puts a web app in front of it, so submitting, watching and collecting a job doesn't need a terminal.</p>
<p>Both halves scale: the web stack and the compute nodes grow and shrink on Oracle Cloud with the queue, so the university isn't paying for idle machines.</p>
<h4>How it works</h4>
<figure>
<svg viewBox="0 0 400 268" role="img" aria-label="Sketch: browser to React app and Node API, login through Entra ID, jobs go to the Slurm controller, which scales compute nodes on Oracle Cloud; data in ERDA">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<rect x="8" y="22" width="86" height="40"/>
<rect x="138" y="22" width="110" height="40"/>
<rect x="292" y="10" width="100" height="34" stroke-dasharray="4 3"/>
<rect x="138" y="112" width="110" height="40"/>
<rect x="138" y="204" width="110" height="40"/>
<path d="M270 180 q-8 -22 18 -24 q6 -20 34 -14 q20 -14 40 4 q30 0 26 24 q14 18 -6 30 q-4 22 -30 18 q-18 16 -42 4 q-28 6 -32 -14 q-20 -8 -8 -28z" stroke="#6b726f" stroke-dasharray="5 4"/>
<rect x="296" y="178" width="28" height="22"/><rect x="330" y="178" width="28" height="22"/><rect x="296" y="206" width="28" height="22" stroke-dasharray="3 3"/><rect x="330" y="206" width="28" height="22" stroke-dasharray="3 3"/>
<rect x="8" y="204" width="86" height="40"/>
<path d="M94 42 H136" marker-end="url(#arr)"/>
<path d="M248 36 C268 32 274 28 290 27" marker-end="url(#arr)" stroke-dasharray="4 3"/>
<path d="M193 62 V110" marker-end="url(#arr)"/>
<path d="M193 152 V202" marker-end="url(#arr)"/>
<path d="M248 224 C262 222 270 214 284 206" marker-end="url(#arr)"/>
<path d="M290 222 C230 262 130 262 96 232" marker-end="url(#arr)"/>
<path d="M94 214 C114 180 128 150 136 136" marker-end="url(#arr)" stroke-dasharray="4 3"/>
</g>
<g>
<text class="lbl" x="18" y="46">browser</text>
<text class="lbl" x="148" y="40">React web app</text><text class="lbl-s" x="148" y="54">+ Node API</text>
<text class="lbl" x="302" y="25">Entra ID</text><text class="lbl-s" x="302" y="38">OAuth</text>
<text class="lbl" x="148" y="130">job service</text><text class="lbl-s" x="148" y="144">queue · status</text>
<text class="lbl" x="148" y="222">Slurm</text><text class="lbl-s" x="148" y="236">controller</text>
<text class="lbl-s" x="300" y="170">compute nodes</text>
<text class="lbl" x="18" y="222">ERDA</text><text class="lbl-s" x="18" y="236">AU storage</text>
<text class="anno" x="300" y="60">log in with</text><text class="anno" x="300" y="72">the AU account</text>
<text class="anno" x="262" y="100">nodes appear when</text><text class="anno" x="262" y="112">the queue grows, and</text><text class="anno" x="262" y="124">go away when it's empty</text>
<text class="anno" x="12" y="186">inputs in,</text><text class="anno" x="12" y="198">results out</text>
</g>
</svg>
<figcaption>sketch 1 · request path, top to bottom. Dashed = external or elastic.</figcaption>
</figure>
</div>
<div class="page">
<h4>Who wrote what <span class="draft" title="placeholder">not marked yet</span></h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>React front end</td><td class="w"><span class="u">?</span></td><td>Job form, queue view, results.</td></tr>
<tr><td>Node API &amp; job service</td><td class="w"><span class="u">?</span></td><td>Talks to Slurm on the user's behalf.</td></tr>
<tr><td>OCI autoscaling</td><td class="w"><span class="u">?</span></td><td>Web stack and compute, separately.</td></tr>
<tr><td>OAuth / Entra ID login</td><td class="w"><span class="u">?</span></td><td>University accounts only.</td></tr>
<tr><td>ERDA integration</td><td class="w"><span class="u">?</span></td><td>Pick inputs, deliver results.</td></tr>
</table>
<div class="share" title="not marked yet"><i class="u" style="width:100%"></i></div>
<span class="share-l">share by hand vs. AI: to be filled in</span>
<h4>Decisions <span class="draft" title="placeholder">placeholder</span></h4>
<ol class="dec">
<li>Keep Slurm, wrap it. Researchers' existing scripts keep working.</li>
<li>Scale the web stack and the compute pool independently: a busy week of uploads isn't a busy week of jobs.</li>
<li>Store nothing long-term in the platform; ERDA is the source of truth.</li>
</ol>
<h4>What broke <span class="draft" title="placeholder">placeholder</span></h4>
<ul class="log">
<li>New nodes joined the cluster before their mounts were ready, and the first jobs on them failed.</li>
<li class="fix">Nodes now report healthy only after a mount check; Slurm waits for that.</li>
</ul>
<div class="links"><span class="mono" style="color:var(--mute)">internal tool, no public link</span><a href="https://blog.lucaswinther.info">write-up</a></div>
</div>
</div>
<footer class="sfoot"><span>at work · 1 of 4</span><span><a href="#p-docs">next: document services →</a></span></footer>
</div>
</article>
<aside class="notes">
<div class="mnote">
<span class="k">margin · related</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">Entra ID, OAuth and the redirect URI that ate a week</span></a>
<p>Notes from wiring university login into a small web app.</p>
</div>
<div class="mnote">
<span class="k">margin · skills here</span>
<p style="margin:0;font-family:var(--mono);font-size:11.5px;line-height:1.6">Slurm · OCI · React · Node · OAuth · Linux · cloud cost</p>
</div>
</aside>
</section>
<section class="row">
<article class="proj" id="p-docs" data-group="at work" data-title="Document services" data-ai="unk">
<div class="spread">
<header class="shead">
<span class="k">p. 4</span>
<h3>Document &amp; information services</h3>
<span class="meta">Overleaf · OpenProject · integrations · 2025–now</span>
<span class="stamp unk" title="placeholder">AI: not marked</span>
</header>
<div class="single-pages">
<div>
<h4>What and why</h4>
<p>Self-hosted writing and planning tools for research groups: Overleaf for papers, OpenProject for the projects behind them, wired into university login and each other so a group gets one place to work instead of five accounts.</p>
<h4>How it works</h4>
<figure>
<svg viewBox="0 0 360 96" role="img" aria-label="Sketch: AU login in front of Overleaf and OpenProject, both on shared containers">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<rect x="6" y="30" width="80" height="34"/><rect x="140" y="8" width="96" height="32"/><rect x="140" y="56" width="96" height="32"/>
<rect x="290" y="30" width="64" height="34" stroke-dasharray="4 3"/>
<path d="M86 42 L138 26" marker-end="url(#arr)"/><path d="M86 52 L138 70" marker-end="url(#arr)"/>
<path d="M236 24 L288 40" marker-end="url(#arr)" stroke-dasharray="4 3"/><path d="M236 72 L288 56" marker-end="url(#arr)" stroke-dasharray="4 3"/>
</g>
<text class="lbl" x="14" y="51">AU login</text><text class="lbl" x="150" y="28">Overleaf</text><text class="lbl" x="150" y="76">OpenProject</text><text class="lbl-s" x="298" y="51">backups</text>
</svg>
</figure>
</div>
<div>
<h4>Who wrote what <span class="draft">not marked yet</span></h4>
<table class="who"><tr><td>Deployment &amp; config</td><td class="w"><span class="u">?</span></td></tr><tr><td>Login integration</td><td class="w"><span class="u">?</span></td></tr><tr><td>Glue between the two</td><td class="w"><span class="u">?</span></td></tr></table>
<h4>What broke <span class="draft">placeholder</span></h4>
<ul class="log"><li>An upgrade changed the Overleaf compile image, and old projects stopped building.</li><li class="fix">Pinned images per project; upgrades are now opt-in.</li></ul>
</div>
</div>
<footer class="sfoot"><span><a href="#p-compute">← compute platform</a></span><span><a href="#p-ops">next: kept secure, kept running →</a></span></footer>
</div>
</article>
<aside class="notes"></aside>
</section>
<section class="row">
<article class="proj" id="p-ops" data-group="at work" data-title="Kept secure, kept running" data-ai="unk">
<div class="spread">
<header class="shead">
<span class="k">p. 5</span>
<h3>Kept secure, kept running</h3>
<span class="meta">OpenBao / Vault · Grafana · Docker · Ansible</span>
<span class="stamp unk" title="placeholder">AI: not marked</span>
</header>
<div class="single-pages">
<div>
<h4>What and why</h4>
<p>Trying out newer versions and new kinds of software for researchers, without it becoming a risk. Everything runs isolated in containers, secrets live in OpenBao (Vault), machines are configured with Ansible, and Grafana watches all of it.</p>
<p>Less a single project than a way of working, but the part I'd most like to show someone.</p>
</div>
<div>
<h4>The loop</h4>
<figure>
<svg viewBox="0 0 300 120" role="img" aria-label="Sketch: a loop of try, isolate, update, monitor">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<ellipse cx="150" cy="60" rx="110" ry="44"/>
<path d="M258 52 l2 10 l-9 -4" />
</g>
<text class="lbl" x="124" y="12">try it</text><text class="lbl" x="250" y="88">isolate</text><text class="lbl" x="118" y="118">update</text><text class="lbl" x="2" y="88">monitor</text>
<text class="lbl-s" x="96" y="58">Docker · Ansible</text><text class="lbl-s" x="96" y="70">OpenBao · Grafana</text>
</svg>
</figure>
<h4>Who wrote what <span class="draft">not marked yet</span></h4>
<p class="mono" style="font-size:11.5px;color:var(--mute)">Playbooks, dashboards, policies: to be filled in.</p>
</div>
</div>
<footer class="sfoot"><span><a href="#p-docs">← document services</a></span><span><a href="#p-sah">next: ScienceAtHome prototype →</a></span></footer>
</div>
</article>
<aside class="notes">
<div class="mnote">
<span class="k">margin · related</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">Four alerts, not forty</span></a>
<p>What I'd get up at night for, and what can wait for a dashboard.</p>
</div>
</aside>
</section>
<section class="row">
<article class="proj small" id="p-sah" data-group="at work" data-title="ScienceAtHome prototype" data-ai="hand">
<div class="mini">
<div class="left"><span class="k">p. 6 · 2019</span><br><span class="meta">internship</span></div>
<h3>Early prototype, ScienceAtHome</h3>
<span class="stamp hand">by hand</span>
<p>A month on a new project: ideation and planning with the team, then an early prototype of the base mechanics so the people it was for had something to react to. Pair-programmed with experienced developers to start the real thing. <span class="ph" title="placeholder">Project name and stack unknown.</span></p>
</div>
</article>
<aside class="notes"></aside>
</section>
<!-- ================= ON MY OWN ================= -->
<div class="ghead" id="g-own"><h2>On my own</h2><p>Side projects: the homelab, small tools, games.</p></div>
<section class="row">
<article class="proj" id="p-gacha" data-group="on my own" data-title="Gacha event tracker" data-ai="ai" data-feat>
<div class="spread">
<header class="shead">
<span class="k">pp. 7–8</span>
<h3>Event Tracker for Gacha Games</h3>
<span class="meta">JavaScript · scraping · web app · <span class="ph" title="placeholder">2025</span></span>
<span class="stamp ai">AI-made</span>
</header>
<div class="pages">
<div class="page">
<h4>What and why</h4>
<p>One calendar for the limited-time events in several gacha games, so banners stop slipping past unnoticed. Also a deliberate test: how far can an agent take a small, real project if I only write the spec and the checks?</p>
<h4>How it works</h4>
<figure>
<svg viewBox="0 0 400 110" role="img" aria-label="Sketch: scrapers feed a normaliser which feeds a calendar UI">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<rect x="6" y="14" width="78" height="22"/><rect x="10" y="40" width="78" height="22"/><rect x="14" y="66" width="78" height="22"/>
<rect x="150" y="34" width="96" height="40"/><rect x="306" y="26" width="88" height="56"/>
<path d="M306 42 H394 M336 26 V82 M366 26 V82" stroke-width=".8"/>
<path d="M94 52 H148" marker-end="url(#arr)"/><path d="M246 54 H304" marker-end="url(#arr)"/>
</g>
<text class="lbl-s" x="14" y="29">game A</text><text class="lbl-s" x="18" y="55">game B</text><text class="lbl-s" x="22" y="81">game C…</text>
<text class="lbl" x="160" y="52">normalise</text><text class="lbl-s" x="160" y="66">dedupe · UTC</text>
<text class="lbl-s" x="310" y="38">calendar</text>
<text class="anno" x="140" y="100">the part I rejected twice</text>
</svg>
</figure>
<h4>Decisions</h4>
<ol class="dec"><li>Scrape the announcement pages, not the games.</li><li>Everything stored in UTC, shown in the visitor's zone.</li><li>The agent writes code; it never writes the tests' expectations.</li></ol>
</div>
<div class="page">
<h4>Who wrote what <span class="draft" title="placeholder">detail is placeholder</span></h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>Spec &amp; tests</td><td class="w"><span class="h">by hand</span></td><td>One page of requirements, one failing test per game.</td></tr>
<tr><td>Scrapers</td><td class="w"><span class="a">AI</span></td><td>Written against my fixtures.</td></tr>
<tr><td>Normaliser</td><td class="w"><span class="a">AI</span></td><td>First version guessed time zones. Rejected.</td></tr>
<tr><td>Calendar UI</td><td class="w"><span class="a">AI</span></td><td>Plain JS, no framework, on request.</td></tr>
<tr><td>Review</td><td class="w"><span class="h">by hand</span></td><td>Every change read before merge.</td></tr>
</table>
<div class="share" title="about 10% by hand"><i class="h" style="width:10%"></i><i class="a" style="width:90%"></i></div>
<span class="share-l">≈ 10% of lines by hand, 90% AI · highlighter = AI</span>
<h4>What broke <span class="draft" title="placeholder">placeholder</span></h4>
<ul class="log"><li>Two scrapers died when a site changed its markup.</li><li class="fix">Fixed by the agent in one pass from the failing fixtures.</li><li>Duplicate events after a daylight-saving change.</li></ul>
<div class="links"><a href="#">live demo</a><a href="https://github.com/StereotypicalCat">GitHub</a></div>
</div>
</div>
<footer class="sfoot"><span>on my own · 1 of 7</span><span><a href="#p-config">next: Config →</a></span></footer>
</div>
</article>
<aside class="notes">
<div class="mnote">
<span class="k">margin · related</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">What I let the agent write, and what I didn't</span></a>
<p>I thought I wrote most of the tests. I wrote their names.</p>
</div>
</aside>
</section>
<section class="row">
<article class="proj" id="p-config" data-group="on my own" data-title="Config" data-ai="ai" data-feat>
<div class="spread">
<header class="shead">
<span class="k">pp. 9–10</span>
<h3>Config</h3>
<span class="meta">Ansible · docker-compose · IaC · homelab</span>
<span class="stamp ai">AI-made</span>
</header>
<div class="pages">
<div class="page">
<h4>What and why</h4>
<p>Every machine in the homelab, rebuildable from bare metal with one command, with git as the documentation. Deployment management for a growing pile of docker-compose services.</p>
<h4>How it works</h4>
<figure>
<svg viewBox="0 0 400 130" role="img" aria-label="Sketch: git repo, ansible-playbook, three hosts each running compose stacks">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<path d="M8 30 h70 v70 h-70z M8 46 h70"/>
<rect x="132" y="48" width="90" height="34"/>
<rect x="290" y="6" width="104" height="32"/><rect x="290" y="49" width="104" height="32"/><rect x="290" y="92" width="104" height="32"/>
<path d="M78 65 H130" marker-end="url(#arr)"/>
<path d="M222 58 L288 24" marker-end="url(#arr)"/><path d="M222 65 H288" marker-end="url(#arr)"/><path d="M222 72 L288 106" marker-end="url(#arr)"/>
</g>
<text class="lbl" x="14" y="42">repo</text><text class="lbl-s" x="14" y="62">inventory</text><text class="lbl-s" x="14" y="74">roles/</text><text class="lbl-s" x="14" y="86">compose/</text>
<text class="lbl" x="140" y="69">ansible</text>
<text class="lbl-s" x="298" y="26">nas · media, backup</text><text class="lbl-s" x="298" y="69">mini-1 · services</text><text class="lbl-s" x="298" y="112">mini-2 · monitoring</text>
<text class="anno" x="96" y="112">one command, any host</text>
</svg>
<figcaption>sketch · <span class="ph" title="placeholder">host names placeholder</span></figcaption>
</figure>
</div>
<div class="page">
<h4>Who wrote what <span class="draft" title="placeholder">detail is placeholder</span></h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>Inventory &amp; layout</td><td class="w"><span class="h">by hand</span></td><td>Which service lives where, and why.</td></tr>
<tr><td>Playbooks &amp; roles</td><td class="w"><span class="a">AI</span></td><td>Generated, then iterated for a month.</td></tr>
<tr><td>Compose files</td><td class="w"><span class="a">AI</span></td><td>Templated from one pattern I wrote.</td></tr>
</table>
<div class="share"><i class="h" style="width:15%"></i><i class="a" style="width:85%"></i></div>
<span class="share-l">≈ 15% by hand</span>
<h4>Decisions</h4>
<ol class="dec"><li>Compose, not Kubernetes: the lab is three machines.</li><li>Secrets in an encrypted vault file, never in the repo history.</li></ol>
<h4>What broke <span class="draft" title="placeholder">placeholder</span></h4>
<ul class="log"><li>Every run restarted every container: a template wrote a fresh timestamp on each render.</li><li class="fix">Found by hand with <code>--diff</code>. The agent kept suggesting handlers.</li></ul>
<div class="links"><a href="https://github.com/StereotypicalCat">GitHub</a></div>
</div>
</div>
<footer class="sfoot"><span><a href="#p-gacha">← gacha tracker</a></span><span><a href="#p-chess">next: Chessswap →</a></span></footer>
</div>
</article>
<aside class="notes">
<div class="mnote">
<span class="k">margin · related</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">Ansible idempotency, the hard way</span></a>
<p>The write-up of Config's worst week.</p>
</div>
<div class="mnote">
<span class="k">margin · related</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">Three mini PCs, one k3s cluster</span></a>
<p>…and why it went back to compose a week later.</p>
</div>
</aside>
</section>
<div id="own-minis"></div>
<!-- ================= SCHOOL ================= -->
<div class="ghead" id="g-school"><h2>School</h2><p>University and high school. All of it by hand.</p></div>
<section class="row">
<article class="proj" id="p-hypercache" data-group="school" data-title="HyperCache (thesis)" data-ai="hand" data-feat>
<div class="spread">
<header class="shead">
<span class="k">pp. 16–17</span>
<h3>HyperCache, bachelor thesis</h3>
<span class="meta">Node.js · simulation · P2P · <span class="ph" title="placeholder">2022</span></span>
<span class="stamp hand">by hand</span>
</header>
<div class="pages">
<div class="page">
<h4>What and why</h4>
<p>Peer-to-peer archives only work if peers actually keep what they promise to keep. The thesis looks at reputation systems for P2P archival: how peers can rate each other so that free-riders and bad actors are found and routed around.</p>
<h4>How it works</h4>
<figure>
<svg viewBox="0 0 400 192" role="img" aria-label="Sketch: a network of peers with reputation scores; one free-riding peer is crossed out and routed around">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<path d="M60 40 L170 30 L280 52 L340 120 L220 140 L90 120 Z M60 40 L90 120 M170 30 L220 140 M170 30 L90 120 M280 52 L220 140"/>
<circle cx="60" cy="40" r="16" fill="#f4f4f0"/><circle cx="170" cy="30" r="16" fill="#f4f4f0"/><circle cx="280" cy="52" r="16" fill="#f4f4f0"/>
<circle cx="340" cy="120" r="16" fill="#f4f4f0"/><circle cx="220" cy="140" r="16" fill="#f4f4f0"/><circle cx="90" cy="120" r="16" fill="#f4f4f0"/>
<circle cx="150" cy="92" r="14" fill="#f4f4f0" stroke-dasharray="3 3" stroke="#d9532b"/>
<path d="M141 83 l18 18 M159 83 l-18 18" stroke="#d9532b"/>
</g>
<g class="lbl-s"><text x="52" y="44">.92</text><text x="162" y="34">.88</text><text x="272" y="56">.71</text><text x="332" y="124">.95</text><text x="212" y="144">.64</text><text x="82" y="124">.90</text></g>
<path d="M150 108 V160" fill="none" stroke="#d9532b" stroke-width="1" stroke-dasharray="2 2"/><text class="anno" x="110" y="174">free-rider: claims to store,</text><text class="anno" x="110" y="186">fails the spot checks</text>
<text class="anno" x="214" y="12">score = past audits</text>
</svg>
<figcaption>sketch · peers audit each other's stored chunks; scores decide who gets data</figcaption>
</figure>
</div>
<div class="page">
<h4>Who wrote what</h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>Simulator</td><td class="w"><span class="h">by hand</span></td><td>Node.js, peers as processes in one event loop.</td></tr>
<tr><td>Reputation models</td><td class="w"><span class="h">by hand</span></td><td>Compared against a no-reputation baseline.</td></tr>
<tr><td>Report</td><td class="w"><span class="h">by hand</span></td><td>Written in 2022, before any of this was AI.</td></tr>
</table>
<div class="share"><i class="h" style="width:100%"></i></div>
<span class="share-l">100% by hand</span>
<h4>Decisions <span class="draft" title="placeholder">placeholder</span></h4>
<ol class="dec"><li>Simulate instead of deploy: thousands of peers on one laptop, reproducible runs.</li><li>Model churn explicitly; honest peers going offline shouldn't look like cheating.</li></ol>
<h4>What broke <span class="draft" title="placeholder">placeholder</span></h4>
<ul class="log"><li>Early runs punished honest peers on slow links harder than free-riders.</li><li class="fix">Audits got a grace window, and the result became a chapter.</li></ul>
<div class="links"><a href="#">thesis (PDF)</a><a href="https://github.com/StereotypicalCat">code</a></div>
</div>
</div>
<footer class="sfoot"><span>school · 1 of 6</span><span><a href="#p-rowing">next: rowing game →</a></span></footer>
</div>
</article>
<aside class="notes">
<div class="mnote">
<span class="k">margin · related</span>
<a class="t" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">Reading a thesis I wrote by hand, three years later</span></a>
<p>What holds up, and what an agent would have got wrong.</p>
</div>
</aside>
</section>
<section class="row">
<article class="proj" id="p-rowing" data-group="school" data-title="Mixed-reality rowing game" data-ai="hand" data-feat>
<div class="spread">
<header class="shead">
<span class="k">pp. 18–19</span>
<h3>Mixed-Reality Rowing Game</h3>
<span class="meta">Unity · C# · Arduino · two real oars · <span class="ph" title="placeholder">2020</span></span>
<span class="stamp hand">by hand</span>
</header>
<div class="pages">
<div class="page">
<h4>What and why</h4>
<p>A rowing game you play with two real oars. Sensors on the oars feed an Arduino, the Arduino feeds Unity, and the boat in the headset moves the way your arms do. The question: does real resistance make a virtual space feel more real?</p>
<h4>How it works</h4>
<figure>
<svg viewBox="0 0 400 150" role="img" aria-label="Sketch: two oars with angle sensors wired to an Arduino, over USB serial to Unity, rendered to a headset">
<g fill="none" stroke="#1c2024" stroke-width="1.4" filter="url(#rough)">
<path d="M10 30 L90 58 M10 120 L90 92"/><path d="M4 22 q-4 10 10 14 l-6 -18z M4 128 q-4 -10 10 -14 l-6 18z"/>
<circle cx="96" cy="60" r="7"/><circle cx="96" cy="90" r="7"/>
<rect x="150" y="56" width="70" height="38"/>
<rect x="270" y="50" width="70" height="50"/>
<path d="M350 66 h34 q8 0 8 8 v6 q0 8 -8 8 h-10 l-6 -6 h-6 l-6 6 h-6 q-8 0 -8 -8 v-6 q0 -8 8 -8z"/>
<path d="M103 60 C124 60 130 68 148 70" marker-end="url(#arr)"/><path d="M103 90 C124 90 130 82 148 80" marker-end="url(#arr)"/>
<path d="M220 75 H268" marker-end="url(#arr)"/>
</g>
<text class="lbl" x="158" y="79">Arduino</text><text class="lbl" x="282" y="79">Unity</text><text class="lbl-s" x="282" y="92">C#</text>
<text class="lbl-s" x="226" y="68">serial</text>
<text class="anno" x="40" y="146">angle sensor at each oarlock</text>
<text class="anno" x="262" y="26">mixed reality: you</text><text class="anno" x="262" y="38">see your own hands</text>
</svg>
</figure>
</div>
<div class="page">
<h4>Who wrote what</h4>
<table class="who">
<tr><th>Part</th><th>Written by</th><th>Note</th></tr>
<tr><td>Arduino firmware</td><td class="w"><span class="h">by hand</span></td><td>Reads two sensors, streams angles.</td></tr>
<tr><td>Unity game &amp; physics</td><td class="w"><span class="h">by hand</span></td><td>C#, group project.</td></tr>
<tr><td>Oar rig</td><td class="w"><span class="h">by hand</span></td><td>Literally, with tape.</td></tr>
</table>
<div class="share"><i class="h" style="width:100%"></i></div>
<span class="share-l">100% by hand</span>
<h4>Decisions <span class="draft" title="placeholder">placeholder</span></h4>
<ol class="dec"><li>Measure oar angle, not force: cheaper sensors, and it's what the boat needs.</li><li>Smooth on the Unity side so the firmware stays dumb.</li></ol>
<h4>What broke <span class="draft" title="placeholder">placeholder</span></h4>
<ul class="log"><li>Serial lag made the boat feel drunk.</li><li class="fix">Smaller packets, higher baud rate, and the latency went away.</li></ul>
<div class="links"><a href="#">video</a><a href="https://github.com/StereotypicalCat">GitHub</a></div>
</div>
</div>
<footer class="sfoot"><span><a href="#p-hypercache">← HyperCache</a></span><span><a href="#p-dashboard">next: Student Dashboard →</a></span></footer>
</div>
</article>
<aside class="notes"></aside>
</section>
<div id="school-minis"></div>
<footer>
<p>Set in IBM Plex Serif, Sans and Mono, on the same grid paper as the gate. Highlighter means AI; an underline means by hand; a dashed ring means not marked yet.</p>
<p><b>Placeholders</b> (dotted underline or "placeholder" tag): university name, project years, all blog titles and dates, the CV PDF link, every "decisions" and "what broke" entry, the per-part AI breakdowns and percentages, the AU projects' AI level, ScienceAtHome project name, Config host names, live demo links.</p>
<p><a href="nav-d.html">← back to the gate</a> · <a href="nav-d.html">off the clock →</a> · <a href="https://old.lucaswinther.info">the 2021 site</a></p>
</footer>
</main>
</div>
<script>
(function(){
// compact pages for the smaller projects
var MINI = {
"own-minis": [
["p-chess","Chessswap","on my own","hand","p. 11","React · game","Chess where the board swaps every few turns. A small rules engine and a lot of edge cases: what happens to a pinned piece when its side of the board leaves?",[["GitHub","https://github.com/StereotypicalCat"]],"2020"],
["p-expenses","Monthly Expenses Tracker","on my own","hand","p. 12","JS · web app · DKK","Where the money went each month, in kroner. Amounts stored in øre so floating point never touches them.",[["GitHub","https://github.com/StereotypicalCat"]],"2021"],
["p-lol","LoL Winrate Discord Bot","on my own","hand","p. 13","Node.js · Discord","Ask the bot in chat, get a champion's current win rate back. Built for one friend group.",[["GitHub","https://github.com/StereotypicalCat"]],"2019"],
["p-portfolio","Portfolio, 2021","on my own","hand","p. 14","React · Material UI","The previous version of this site, frozen as it was. Searchable project grid, written entirely by hand.",[["old.lucaswinther.info","https://old.lucaswinther.info"]],"2021"],
["p-borlaug","Tribute to Norman Borlaug","on my own","hand","p. 15","HTML · CSS · freeCodeCamp","One of my first websites. Kept for honesty.",[],"2016"]
],
"school-minis": [
["p-dashboard","Student Dashboard","school","hand","p. 20","React · user studies","A dashboard for students, designed from two rounds of user studies; the report is as much the project as the code.",[["report (PDF)","#"],["demo","#"]],"2021"],
["p-vr","VR-Classroom","school","hand","p. 21","Node.js · prototype","A prototype of a shared virtual classroom: a Node.js server keeping everyone's view in sync.",[["report","#"],["video","#"]],"2019"],
["p-censor","Censor Simulator","school","hand","p. 22","Unity · VR · high school","High-school exam project: a VR game about being the censor.",[],"2018"],
["p-breakout","BreakoutPython","school","hand","p. 23","Python · high school","High-school exam project. Breakout, in Python, from scratch.",[["GitHub","https://github.com/StereotypicalCat"]],"2017"]
]
};
var AI_LABEL = {hand:"by hand", ai:"AI-made", some:"some AI", unk:"AI: not marked"};
Object.keys(MINI).forEach(function(id){
var host = document.getElementById(id);
host.outerHTML = MINI[id].map(function(p){
var links = p[7].map(function(l){return '<a href="'+l[1]+'">'+l[0]+'</a>';}).join("");
return '<section class="row"><article class="proj small" id="'+p[0]+'" data-group="'+p[2]+'" data-title="'+p[1]+'" data-ai="'+p[3]+'">'+
'<div class="mini"><div class="left"><span class="k">'+p[4]+' · <span class="ph" title="placeholder year">'+p[8]+'</span></span><br><span class="meta">'+p[5]+'</span></div>'+
'<h3>'+p[1]+'</h3><span class="stamp '+p[3]+'">'+AI_LABEL[p[3]]+'</span><p>'+p[6]+'</p>'+(links?'<div class="links">'+links+'</div>':'')+'</div></article><aside class="notes"></aside></section>';
}).join("");
});
// build the rail from the pages
var projs = Array.prototype.slice.call(document.querySelectorAll(".proj"));
var groups = {};
projs.forEach(function(p){ (groups[p.dataset.group] = groups[p.dataset.group] || []).push(p); });
var grps = document.getElementById("grps");
grps.innerHTML = Object.keys(groups).map(function(g){
return '<div class="grp"><span class="k">'+g+' · '+groups[g].length+'</span><ul class="toc">'+groups[g].map(function(p){
var pg = (p.querySelector(".shead .k, .mini .k")||{}).textContent || "";
pg = pg.replace(/^pp?\.\s*/,"").split(/[–·\s]/)[0];
return '<li data-for="'+p.id+'"><a href="#'+p.id+'"'+(p.hasAttribute("data-feat")?' class="feat"':'')+'><span class="pg">'+pg+'</span><span class="t">'+p.dataset.title+'</span><i class="dot '+(p.dataset.ai==="hand"?"":p.dataset.ai)+'"></i></a></li>';
}).join("")+'</ul></div>';
}).join("");
document.getElementById("count").textContent = projs.length + " projects";
// highlight the page you're on (no motion, just a tab)
var links = {};
grps.querySelectorAll("a").forEach(function(a){ links[a.getAttribute("href").slice(1)] = a; });
var current = null;
function setOn(id){ if(current===id) return; current=id; Object.keys(links).forEach(function(k){ links[k].classList.toggle("on", k===id); }); }
if ("IntersectionObserver" in window){
var io = new IntersectionObserver(function(entries){
entries.forEach(function(e){ if(e.isIntersecting) setOn(e.target.id); });
},{rootMargin:"-30% 0px -60% 0px"});
projs.forEach(function(p){ io.observe(p); });
}
// AI filter (same wording as the gate's footer)
var fnote = document.getElementById("fnote");
function applyPref(pref){
var hidden = 0;
projs.forEach(function(p){
var a = p.dataset.ai;
var hide = (pref==="nofull" && a==="ai") || (pref==="hand" && a!=="hand");
p.classList.toggle("hide", hide);
var sec = p.parentNode; var aside = sec.querySelector(".notes");
if (aside && !sec.classList.contains("cv")) aside.classList.toggle("hide", hide);
var li = grps.querySelector('[data-for="'+p.id+'"]'); if (li) li.classList.toggle("off", hide);
if (hide) hidden++;
});
fnote.textContent = hidden ? hidden + " hidden" + (pref==="hand" ? " (incl. unmarked work)" : "") : "";
document.querySelectorAll("[data-pref]").forEach(function(b){ b.setAttribute("aria-pressed", b.dataset.pref===pref ? "true" : "false"); });
}
document.querySelectorAll("[data-pref]").forEach(function(b){ b.addEventListener("click", function(){ applyPref(b.dataset.pref); }); });
var rail=document.querySelector(".rail"), rt=document.getElementById("railtoggle");
rt.addEventListener("click",function(){var o=rail.classList.toggle("open");rt.setAttribute("aria-expanded",o);rt.textContent=o?"hide project list ▴":"show all projects ▾";});
grps.addEventListener("click",function(){ if(window.innerWidth<=860){rail.classList.remove("open");rt.setAttribute("aria-expanded","false");rt.textContent="show all projects ▾";} });
// [ and ] jump between projects; Esc goes back to the gate
document.addEventListener("keydown", function(e){
if (e.target.closest && e.target.closest("input,textarea")) return;
if (e.key === "Escape") { location.href = "nav-d.html"; return; }
if (e.key !== "[" && e.key !== "]") return;
var vis = projs.filter(function(p){ return !p.classList.contains("hide"); });
var i = vis.findIndex(function(p){ return p.id === current; });
var n = e.key === "]" ? Math.min(vis.length-1, i+1) : Math.max(0, i-1);
if (vis[n]) { vis[n].scrollIntoView(); setOn(vis[n].id); history.replaceState(null,"","#"+vis[n].id); }
});
})();
</script>
</body>
</html>
+735
View File
@@ -0,0 +1,735 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther / work: drawing set</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Sans+Condensed:wght@500;600&display=swap" rel="stylesheet">
<style>
:root{
--bg:#e8eae6; --grid:#d5d9d3; --grid2:#c7ccc5; --sheet:#f3f4f1; --ink:#1c2024; --soft:#3a4044; --mute:#6b726f; --line:#cfd3cd;
--acc:#d9532b; --art:#2a1a1f; --art-ink:#f1e6d6;
--sans:"IBM Plex Sans",system-ui,sans-serif; --cond:"IBM Plex Sans Condensed","IBM Plex Sans",sans-serif; --mono:"IBM Plex Mono",ui-monospace,monospace;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth;scroll-padding-top:64px}
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{transition:none!important}}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14.5px;line-height:1.5;
background-image:linear-gradient(var(--grid2) 1px,transparent 1px),linear-gradient(90deg,var(--grid2) 1px,transparent 1px),linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px);
background-size:120px 120px,120px 120px,24px 24px,24px 24px;background-position:-1px -1px}
a{color:inherit;text-decoration-color:var(--acc);text-underline-offset:3px}
a:hover{color:var(--acc)}
button{font:inherit;color:inherit}
.ph{text-decoration:underline dotted var(--mute);text-underline-offset:3px;cursor:help}
.mono{font-family:var(--mono)}
.k{font-family:var(--mono);font-size:9.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--mute)}
.mock-note{position:fixed;bottom:12px;right:56px;z-index:99;max-width:310px;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}
.edge{position:fixed;top:0;right:0;bottom:0;width:40px;background:var(--art);color:var(--art-ink);z-index:30;display:flex;align-items:center;justify-content:center;text-decoration:none;writing-mode:vertical-rl;font:italic 15px/1 Georgia,serif;letter-spacing:.03em;transition:width .2s}
.edge:hover{width:52px;color:#e8b04a}
/* ---------- top bar ---------- */
.bar{position:sticky;top:0;z-index:20;background:var(--ink);color:#c9cec8;font-family:var(--mono);font-size:12px;margin-right:40px}
.bar-in{max-width:1380px;margin:0 auto;padding:0 24px;display:flex;align-items:stretch;gap:0;height:46px}
.bar a{text-decoration:none;color:#e8eae6}
.bar .back{display:flex;align-items:center;gap:8px;padding-right:18px;border-right:1px solid #3b4044}
.bar .back:hover{color:var(--acc)}
.bar kbd{font-family:var(--mono);font-size:10px;border:1px solid #5a6064;border-radius:3px;padding:0 4px;color:#9aa09b}
@media (max-width:1500px){.bar .set{display:none!important}}
.bar .set{display:flex;align-items:center;padding:0 18px;color:#8d938e;border-right:1px solid #3b4044;white-space:nowrap}
.tabs{display:flex;align-items:stretch;flex:1;min-width:0;overflow-x:auto;scrollbar-width:none}
.tabs a{display:flex;align-items:center;gap:7px;padding:0 13px;border-right:1px solid #2e3337;white-space:nowrap;color:#b9bdb7;position:relative}
.tabs a b{color:#7d837f;font-weight:400}
.tabs a:hover{background:#262b2f;color:#fff}
.tabs a.on{background:var(--sheet);color:var(--ink)}
.tabs a.on b{color:var(--acc)}
.tabs a.off{opacity:.35;text-decoration:line-through}
.pref{display:flex;align-items:center;gap:6px;padding-left:16px;white-space:nowrap}
.pref span{color:#7d837f}
.pref button{background:none;border:1px solid #3b4044;color:#b9bdb7;font-size:11px;padding:2px 8px;border-radius:99px;cursor:pointer}
.pref button[aria-pressed="true"]{background:#e8eae6;color:var(--ink);border-color:#e8eae6}
.pref button:hover{border-color:#e8eae6}
.wrap{max-width:1380px;margin:0 auto;padding:22px 64px 60px 24px;display:grid;grid-template-columns:minmax(0,1fr) 316px;gap:0 28px}
/* ---------- CV title block ---------- */
.cv{grid-column:1 / -1;display:grid;grid-template-columns:1.25fr 1.5fr 1.2fr 1.1fr 150px;border:2px solid var(--ink);background:var(--sheet);font-size:13px;line-height:1.4}
.cv > div{padding:9px 12px 10px;border-left:1px solid var(--ink);min-width:0}
.cv > div:first-child{border-left:0}
.cv .k{display:block;margin-bottom:3px}
.cv h1{font-family:var(--cond);font-weight:600;font-size:30px;letter-spacing:-.01em;line-height:1;margin:0;text-transform:uppercase}
.cv .sub{color:var(--mute)}
.cv p{margin:0}
.cv .cvpdf{padding:9px 12px 10px;display:flex;flex-direction:column;justify-content:space-between;text-decoration:none;background:var(--ink);color:var(--sheet);font-family:var(--mono);font-size:12px}
.cv .cvpdf:hover{background:var(--acc);color:#fff}
.cv .cvpdf .k{color:#9aa09b}
.cv .cvpdf big{font-size:20px;font-weight:500}
.cv .row2{grid-column:1 / -1;border-left:0;border-top:1px solid var(--ink);display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;font-family:var(--mono);font-size:11px;color:var(--soft);padding:5px 12px}
.sechead{grid-column:1;display:flex;flex-wrap:wrap;align-items:baseline;gap:14px;margin:30px 0 10px}
.sechead h2{margin:0;font-family:var(--cond);font-weight:600;font-size:20px;letter-spacing:.02em;text-transform:uppercase}
.sechead p{margin:0;color:var(--mute);font-size:13px}
.sechead .k{margin-left:auto}
/* ---------- sheets ---------- */
.sheets{grid-column:1;min-width:0}
.sheet{position:relative;background:var(--sheet);border:1px solid var(--ink);padding:16px;margin:0 0 30px;scroll-margin-top:62px;cursor:default;transition:box-shadow .2s}
.sheet.hide{display:none}
.sheet::before{content:"";position:absolute;inset:10px;border:2px solid var(--ink);pointer-events:none}
.sheet .zones-x,.sheet .zones-y{position:absolute;font-family:var(--mono);font-size:8.5px;color:var(--mute);display:flex;pointer-events:none}
.sheet .zones-x{left:10px;right:10px;top:0;height:10px;align-items:center}
.sheet .zones-x span{flex:1;text-align:center;border-left:1px solid var(--ink);line-height:10px}
.sheet .zones-x span:first-child{border-left:0}
.sheet .zones-y{top:10px;bottom:10px;left:0;width:10px;flex-direction:column}
.sheet .zones-y span{flex:1;display:flex;align-items:center;justify-content:center;border-top:1px solid var(--ink)}
.sheet .zones-y span:first-child{border-top:0}
.sheet.sel{box-shadow:0 0 0 3px var(--acc)}
.selmark{display:none;position:absolute;top:-22px;left:0;font-family:var(--mono);font-size:10.5px;color:var(--acc)}
.sheet-in{position:relative;display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);grid-template-rows:auto 1fr auto;gap:0;min-height:520px}
.fig{grid-row:1 / 3;border-right:1px solid var(--ink);padding:14px 16px 8px 14px;position:relative;min-width:0}
.fig svg{display:block;width:100%;height:auto;overflow:visible}
.fig .figcap{font-family:var(--mono);font-size:10px;color:var(--mute);display:flex;justify-content:space-between;gap:10px;border-top:1px solid var(--line);padding-top:5px;margin-top:6px}
.notes{padding:14px 14px 10px 16px;font-size:13.5px;min-width:0}
.notes h3{font-family:var(--cond);font-weight:600;font-size:12px;letter-spacing:.08em;text-transform:uppercase;margin:0 0 8px;border-bottom:1px solid var(--ink);padding-bottom:4px}
.notes ol{margin:0;padding:0;list-style:none;counter-reset:n}
.notes ol li{counter-increment:n;position:relative;padding-left:24px;margin-bottom:8px}
.notes ol li::before{content:counter(n);position:absolute;left:0;top:1px;width:16px;height:16px;border:1px solid var(--acc);color:var(--acc);border-radius:50%;font:500 9.5px/14px var(--mono);text-align:center}
.notes ol li b{font-weight:600}
.notes .gen{margin:12px 0 0;font-size:13px;color:var(--soft)}
.notes .gen p{margin:0 0 6px}
.notes .gen strong{font-family:var(--mono);font-size:9.5px;letter-spacing:.08em;font-weight:500;color:var(--mute);display:block;text-transform:uppercase}
.tb{grid-column:1 / -1;border-top:2px solid var(--ink);display:grid;grid-template-columns:2.1fr .9fr .8fr 1.7fr 1.1fr 128px;font-size:12.5px}
.tb > div{padding:5px 9px 6px;border-left:1px solid var(--ink);min-width:0}
.tb > div:first-child{border-left:0}
.tb .k{display:block;font-size:8.5px}
.tb .title{font-family:var(--cond);font-weight:600;font-size:19px;line-height:1.1;text-transform:uppercase;letter-spacing:.01em}
.tb .v{font-family:var(--mono);font-size:11.5px}
.tb .stampcell{display:flex;align-items:center;justify-content:center;position:relative;overflow:visible}
.stamp{font-family:var(--mono);font-size:11px;font-weight:600;letter-spacing:.12em;text-transform:uppercase;border:2px solid currentColor;padding:4px 8px 3px;transform:rotate(-7deg);display:inline-block;white-space:nowrap;line-height:1.15;text-align:center;background:rgba(243,244,241,.6)}
.stamp small{display:block;font-size:8px;letter-spacing:.1em;font-weight:500}
.stamp.hand{color:var(--ink);border-style:double;border-width:4px}
.stamp.ai{color:var(--acc)}
.stamp.unk{color:var(--mute);border-style:dashed}
.rev{grid-column:2;align-self:end;margin:0 14px 10px 16px;border:1px solid var(--ink);font-family:var(--mono);font-size:10.5px;border-collapse:collapse}
.rev th,.rev td{border:1px solid var(--line);padding:2px 6px;text-align:left;vertical-align:top}
.rev th{font-weight:500;font-size:8.5px;letter-spacing:.08em;color:var(--mute);border-bottom:1px solid var(--ink)}
.links{display:flex;gap:4px 14px;flex-wrap:wrap;font-family:var(--mono);font-size:11.5px;margin-top:8px}
.links a::after{content:" ↗";color:var(--mute)}
.placeholder-tag{font-family:var(--mono);font-size:9px;color:var(--mute);border:1px dashed var(--mute);padding:0 4px;letter-spacing:.06em;text-transform:uppercase;vertical-align:1px}
/* svg drawing styles */
.ln{fill:none;stroke:var(--ink);stroke-width:1.3}
.ln-th{fill:none;stroke:var(--ink);stroke-width:.8}
.hid{fill:none;stroke:var(--ink);stroke-width:1;stroke-dasharray:5 3}
.ctr{fill:none;stroke:var(--mute);stroke-width:.7;stroke-dasharray:12 3 2 3}
.dim{fill:none;stroke:var(--mute);stroke-width:.8}
.dimt{font-family:var(--mono);font-size:9.5px;fill:var(--soft)}
.lbl{font-family:var(--mono);font-size:10px;fill:var(--ink)}
.lbl-s{font-family:var(--mono);font-size:8.5px;fill:var(--mute)}
.lead{fill:none;stroke:var(--acc);stroke-width:1}
.cb circle{fill:var(--sheet);stroke:var(--acc);stroke-width:1.2}
.cb text{font-family:var(--mono);font-size:10px;font-weight:500;fill:var(--acc);text-anchor:middle;dominant-baseline:central}
.hatch{fill:url(#hatch)}
.phbox{fill:none;stroke:var(--line);stroke-width:1}
/* ---------- register ---------- */
.register{grid-column:1;border:2px solid var(--ink);background:var(--sheet);margin-bottom:10px}
.register table{width:100%;border-collapse:collapse;font-size:13.5px}
.register th{font-family:var(--mono);font-weight:500;font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--mute);text-align:left;padding:7px 10px;border-bottom:2px solid var(--ink);background:var(--sheet);position:sticky;top:46px;z-index:2}
.register td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:baseline}
.register tr.r{cursor:pointer;transition:background .12s}
.register tr.r:hover{background:#fff}
.register tr.r.open{background:#fff}
.register tr.r.open td{border-bottom-color:transparent}
.register tr.grp td{font-family:var(--mono);font-size:10px;letter-spacing:.09em;text-transform:uppercase;color:var(--soft);background:#e3e6e1;padding:4px 10px;border-bottom:1px solid var(--ink)}
.register tr.hide{display:none}
.register .no{font-family:var(--mono);font-size:11.5px;color:var(--mute);white-space:nowrap;width:74px}
.register .t{font-weight:500}
.register .t small{font-weight:400;color:var(--mute);margin-left:6px}
.register .st{font-family:var(--mono);font-size:11px;color:var(--soft)}
.register .yr{font-family:var(--mono);font-size:11.5px;width:60px}
.register .made{font-family:var(--mono);font-size:10.5px;white-space:nowrap;width:118px}
.register .op{width:72px;text-align:right;font-family:var(--mono);font-size:11px;color:var(--acc);white-space:nowrap}
.register tr.lit td{background:rgba(217,83,43,.07)}
.aid{display:inline-block;width:9px;height:9px;border-radius:50%;border:1.5px solid var(--ink);margin-right:6px;vertical-align:-1px}
.aid.ai{background:var(--acc);border-color:var(--acc)}
.aid.unk{border-style:dashed;border-color:var(--mute)}
tr.drawer > td{padding:0 10px 14px;background:#fff;border-bottom:2px solid var(--ink)}
tr.drawer .sheet{margin:6px 0 0;cursor:default}
tr.drawer .sheet-in{min-height:0}
.mini-fig{grid-row:1 / 3}
.mini-fig svg{max-width:400px;margin:0 auto}
/* ---------- inspector ---------- */
.insp{grid-column:2;grid-row:2 / span 20;align-self:start;position:sticky;top:64px;border:2px solid var(--ink);background:var(--sheet);font-size:13.5px;max-height:calc(100vh - 80px);overflow:auto;scrollbar-width:thin}
.insp header{background:var(--ink);color:#e8eae6;padding:8px 12px 9px;font-family:var(--mono);font-size:11px;display:flex;justify-content:space-between}
.insp header span:last-child{color:var(--acc)}
.insp .sec{padding:10px 12px 12px;border-bottom:1px solid var(--ink)}
.insp .sec:last-child{border-bottom:0}
.insp h3{margin:2px 0 2px;font-family:var(--cond);font-weight:600;font-size:21px;text-transform:uppercase;line-height:1.05}
.insp p{margin:4px 0 0}
.insp .k{display:block;margin-bottom:5px}
.skills{display:flex;flex-wrap:wrap;gap:5px}
.skills button{font-family:var(--mono);font-size:11px;border:1px solid var(--ink);background:none;padding:1px 7px;cursor:pointer}
.skills button:hover,.skills button[aria-pressed="true"]{background:var(--ink);color:var(--sheet)}
.skillnote{font-family:var(--mono);font-size:10.5px;color:var(--mute);margin-top:6px;min-height:15px}
.skillnote a{color:var(--ink)}
.wlist{list-style:none;margin:0;padding:0}
.wlist li{padding:5px 0;border-bottom:1px dotted var(--line)}
.wlist li:last-child{border-bottom:0}
.wlist a{text-decoration:none;font-weight:500;display:block;line-height:1.3}
.wlist a:hover{text-decoration:underline;text-decoration-color:var(--acc)}
.wlist span{font-family:var(--mono);font-size:10px;color:var(--mute)}
.whobar{display:flex;height:10px;border:1px solid var(--ink);margin:6px 0 3px}
.whobar i{display:block}
.whobar .h{background:repeating-linear-gradient(90deg,var(--ink) 0 1px,transparent 1px 3px)}
.whobar .a{background:var(--acc)}
.whobar .u{background:repeating-linear-gradient(135deg,var(--line) 0 3px,transparent 3px 6px)}
.insp .hint{font-family:var(--mono);font-size:10px;color:var(--mute)}
footer.foot{grid-column:1;margin-top:30px;border-top:2px solid var(--ink);padding-top:10px;font-size:12.5px;color:var(--mute)}
footer.foot p{margin:0 0 5px}
@media (max-width:1180px){
.wrap{grid-template-columns:1fr}
.insp{grid-column:1;grid-row:auto;position:relative;top:0;max-height:none;margin:0 0 20px}
.cv{grid-template-columns:1fr 1fr 1fr}
.cv > div:nth-child(4){border-left:0}
.cv > div:nth-child(n+4){border-top:1px solid var(--ink)}
}
@media (max-width:860px){
.bar-in{flex-wrap:wrap;height:auto;padding:0 10px}
.bar .back{padding:8px 12px 8px 0}
.pref{padding:6px 0;width:100%;overflow-x:auto}
.tabs{order:3;width:100%;flex:none;border-top:1px solid #3b4044}
.tabs a{padding:8px 10px}
.register th{top:0;position:static}
.sheet-in{grid-template-columns:1fr;min-height:0}
.fig{grid-row:auto;border-right:0;border-bottom:1px solid var(--ink)}
.rev{grid-column:1}
.tb{grid-template-columns:1fr 1fr}
.tb > div{border-top:1px solid var(--ink)}
.tb > div:nth-child(odd){border-left:0}
.tb > div:first-child{grid-column:1 / -1;border-top:0}
.tb > div:nth-child(2){border-left:0}
.tb > div:nth-child(3){border-left:1px solid var(--ink)}
.tb > div:nth-child(4){border-left:0}
.tb > div:nth-child(5){border-left:1px solid var(--ink)}
.tb > div:nth-child(6){border-left:0;grid-column:1 / -1;padding:12px}
.cv{grid-template-columns:1fr 1fr}
.cv > div{border-left:0!important;border-top:1px solid var(--ink)}
.cv > div:first-child{grid-column:1 / -1;border-top:0}
.register .st,.register th.st,.register .yr,.register th.yr{display:none}
}
@media (max-width:480px){
.wrap{padding:16px 48px 40px 12px}
.cv{grid-template-columns:1fr}
.register .made{width:auto}
.register .op{display:none}
.bar{margin-right:30px}
.edge{width:30px;font-size:13px}
}
</style>
</head>
<body>
<div class="mock-note"><button aria-label="Dismiss note" onclick="this.parentNode.remove()">×</button><b>tech-r2-b · Spec sheets</b>Projects as engineering drawings on the gate's grid paper. Four featured projects get full sheets with a title block, an AI stamp and numbered callouts; the rest sit in a drawing register (T1's overview, with character) that opens each sheet in place. A T4-style inspector shows the selected sheet's skills and writing.</div>
<a class="edge" href="nav-d.html" title="Cross to the personal side">off the clock →</a>
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><path d="M0 0V6" stroke="#9aa09b" stroke-width="1"/></pattern>
<marker id="ar" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 2 L10 5 L0 8z" fill="#6b726f"/></marker>
<marker id="ar-i" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 1 L10 5 L0 9" fill="none" stroke="#1c2024" stroke-width="1.5"/></marker>
<marker id="dotr" viewBox="0 0 6 6" refX="3" refY="3" markerWidth="5" markerHeight="5"><circle cx="3" cy="3" r="2.4" fill="#d9532b"/></marker>
</defs></svg>
<!-- ============ TOP BAR ============ -->
<nav class="bar" aria-label="Drawing set">
<div class="bar-in">
<a class="back" href="nav-d.html">← gate <kbd>Esc</kbd></a>
<span class="set">LW–WORK · drawing set</span>
<div class="tabs" id="tabs">
<a href="#cv"><b>00</b> CV</a>
<a href="#s-compute" data-sheet="compute"><b>01</b> compute</a>
<a href="#s-hypercache" data-sheet="hypercache"><b>02</b> HyperCache</a>
<a href="#s-rowing" data-sheet="rowing"><b>03</b> rowing</a>
<a href="#s-config" data-sheet="config"><b>04</b> Config</a>
<a href="#register"><b>R</b> register (17)</a>
</div>
<div class="pref" role="group" aria-label="Filter projects by how they were made">
<span>projects made</span>
<button aria-pressed="true" data-pref="all">any way</button>
<button aria-pressed="false" data-pref="nofull">not fully by AI</button>
<button aria-pressed="false" data-pref="hand">by hand only</button>
</div>
</div>
</nav>
<div class="wrap">
<!-- ============ CV as the set's title block ============ -->
<section class="cv" id="cv" aria-label="CV">
<div><span class="k">drawn by</span><h1>Lucas Winther</h1><p class="sub" style="margin-top:4px">Computer scientist. Builds and runs research software.</p></div>
<div><span class="k">current · Jan 2025–now</span><p><b>IT-medarbejder</b>, IT Research Tech, Aarhus University</p><p class="sub">Slurm compute platform, document services, secure and monitored infrastructure.</p></div>
<div><span class="k">previous</span><p>ScienceAtHome, intern <span class="sub">· 2019</span></p><p>TERMA Group, student helper, local configuration management <span class="sub">· 2016–18</span></p></div>
<div><span class="k">education · contact</span><p>BSc Computer Science, <span class="ph" title="placeholder: university">Aarhus University</span></p><p><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br><a href="https://github.com/StereotypicalCat">GitHub</a> · <a href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a></p></div>
<a class="cvpdf" href="#" title="placeholder: CV PDF"><span class="k">full CV</span><big>PDF ↓</big><span>1 page</span></a>
<div class="row2"><span>DevOps · Docker · Linux admin · observability · cloud (OCI) · Slurm · React/Node · Ansible · Unity/C# · leadership</span><span>sheets 01–04 featured · 17 in register · <span class="ph" title="placeholder">rev. Nov 2026</span></span></div>
</section>
<div class="sechead"><h2>Featured sheets</h2><p>Click a sheet to inspect it. Numbered circles point at the notes on the right of each sheet.</p></div>
<!-- ============ INSPECTOR ============ -->
<aside class="insp" id="insp" aria-live="polite">
<header><span>INSPECTOR</span><span id="i-no">LW-01</span></header>
<div class="sec">
<span class="k" id="i-group">at work</span>
<h3 id="i-title">AU compute platform</h3>
<p id="i-one">…</p>
<div class="links" id="i-links"></div>
</div>
<div class="sec">
<span class="k">who wrote it</span>
<div class="whobar" id="i-bar"></div>
<span class="hint" id="i-who"></span>
</div>
<div class="sec">
<span class="k">skills on this sheet · click to find other sheets</span>
<div class="skills" id="i-skills"></div>
<div class="skillnote" id="i-skillnote"></div>
</div>
<div class="sec">
<span class="k">writing about it</span>
<ul class="wlist" id="i-writing"></ul>
</div>
<div class="sec">
<span class="k">latest writing · <a href="https://blog.lucaswinther.info">blog ↗</a></span>
<ul class="wlist" id="i-latest"></ul>
</div>
</aside>
<div class="sheets" id="sheets">
<!-- ===== SHEET 01: COMPUTE ===== -->
<article class="sheet" id="s-compute" data-id="compute" data-ai="unk" tabindex="0">
<span class="selmark">▸ selected</span>
<div class="zones-x"><span>A</span><span>B</span><span>C</span><span>D</span><span>E</span><span>F</span></div>
<div class="zones-y"><span>1</span><span>2</span><span>3</span><span>4</span></div>
<div class="sheet-in">
<div class="fig">
<svg viewBox="0 0 520 380" role="img" aria-label="Placeholder figure: the job dashboard in a browser frame, with callouts to the job form, queue, ERDA picker and node count; dimension lines show the elastic web and compute tiers">
<!-- dimension: web tier -->
<path class="dim" d="M40 22 V34 M330 22 V34"/><path class="dim" d="M40 28 H330" marker-start="url(#ar)" marker-end="url(#ar)"/>
<rect x="140" y="20" width="90" height="14" fill="#f3f4f1"/><text class="dimt" x="185" y="31" text-anchor="middle">web: 1…n</text>
<!-- browser frame -->
<rect class="ln" x="40" y="44" width="290" height="216"/>
<path class="ln-th" d="M40 62 H330"/><circle class="ln-th" cx="50" cy="53" r="3"/><circle class="ln-th" cx="60" cy="53" r="3"/><rect class="ln-th" x="76" y="48" width="160" height="10"/>
<text class="lbl-s" x="82" y="56">compute.au.dk (placeholder)</text>
<!-- job form -->
<rect class="ln-th" x="52" y="74" width="118" height="98"/>
<text class="lbl" x="58" y="88">new job</text>
<rect class="phbox" x="58" y="96" width="104" height="10"/><rect class="phbox" x="58" y="112" width="104" height="10"/><rect class="phbox" x="58" y="128" width="64" height="10"/>
<rect x="58" y="150" width="50" height="14" fill="#1c2024"/><text x="64" y="160" class="lbl-s" fill="#f3f4f1" style="fill:#f3f4f1">submit</text>
<!-- queue -->
<rect class="ln-th" x="180" y="74" width="140" height="122"/>
<text class="lbl" x="186" y="88">queue</text>
<g class="ln-th"><path d="M186 98 H314 M186 112 H314 M186 126 H314 M186 140 H314 M186 154 H314 M186 168 H314"/></g>
<rect x="290" y="102" width="20" height="6" class="hatch"/><rect x="290" y="116" width="20" height="6" fill="#d9532b" opacity=".6"/>
<!-- ERDA picker -->
<rect class="ln-th" x="52" y="182" width="118" height="66"/>
<text class="lbl" x="58" y="196">ERDA files</text>
<path class="ln-th" d="M60 206 h8 v6 h-8z M60 220 h8 v6 h-8z M60 234 h8 v6 h-8z"/><path class="phbox" d="M74 209 H150 M74 223 H140 M74 237 H156"/>
<!-- node gauge -->
<rect class="ln-th" x="180" y="206" width="140" height="42"/>
<text class="lbl" x="186" y="220">nodes</text>
<g><rect x="186" y="228" width="12" height="12" fill="#1c2024"/><rect x="202" y="228" width="12" height="12" fill="#1c2024"/><rect x="218" y="228" width="12" height="12" fill="#1c2024"/><rect class="hid" x="234" y="228" width="12" height="12"/><rect class="hid" x="250" y="228" width="12" height="12"/></g>
<!-- compute tier, side elevation -->
<path class="ctr" d="M20 300 H500"/>
<g><rect class="ln" x="60" y="284" width="44" height="32"/><rect class="ln" x="112" y="284" width="44" height="32"/><rect class="ln" x="164" y="284" width="44" height="32"/><rect class="hid" x="216" y="284" width="44" height="32"/><rect class="hid" x="268" y="284" width="44" height="32"/></g>
<text class="lbl-s" x="66" y="304">node</text><text class="lbl-s" x="118" y="304">node</text><text class="lbl-s" x="170" y="304">node</text>
<path class="dim" d="M60 326 V340 M312 326 V340"/><path class="dim" d="M60 334 H312" marker-start="url(#ar)" marker-end="url(#ar)"/>
<rect x="130" y="327" width="110" height="14" fill="#f3f4f1"/><text class="dimt" x="186" y="338" text-anchor="middle">compute: 0…n (OCI)</text>
<rect class="ln" x="370" y="276" width="110" height="48"/><text class="lbl" x="380" y="296">Slurm ctl.</text><text class="lbl-s" x="380" y="310">+ job service</text>
<path class="ln-th" d="M370 300 H314" marker-end="url(#ar-i)"/>
<rect class="ln" x="370" y="72" width="110" height="40"/><text class="lbl" x="380" y="90">Entra ID</text><text class="lbl-s" x="380" y="103">OAuth login</text>
<path class="hid" d="M330 92 H368"/>
<rect class="ln" x="370" y="180" width="110" height="40"/><text class="lbl" x="380" y="198">ERDA</text><text class="lbl-s" x="380" y="211">AU storage</text>
<path class="hid" d="M425 222 V274"/><path class="hid" d="M330 215 H368"/>
<path class="ln-th" d="M300 260 C310 270 350 260 370 290" marker-end="url(#ar-i)"/>
<!-- callouts -->
<g><path class="lead" d="M110 134 L20 134 L20 112" marker-start="url(#dotr)"/><g class="cb"><circle cx="20" cy="104" r="8"/><text x="20" y="104">1</text></g></g>
<g><path class="lead" d="M250 160 L348 160 L348 140" marker-start="url(#dotr)"/><g class="cb"><circle cx="348" cy="132" r="8"/><text x="348" y="132">2</text></g></g>
<g><path class="lead" d="M110 236 L20 236 L20 262" marker-start="url(#dotr)"/><g class="cb"><circle cx="20" cy="270" r="8"/><text x="20" y="270">3</text></g></g>
<g><path class="lead" d="M262 234 L348 246 L348 256" marker-start="url(#dotr)"/><g class="cb"><circle cx="348" cy="264" r="8"/><text x="348" y="264">4</text></g></g>
<g><path class="lead" d="M440 112 L460 140" marker-start="url(#dotr)"/><g class="cb"><circle cx="464" cy="148" r="8"/><text x="464" y="148">5</text></g></g>
</svg>
<div class="figcap"><span>FIG. 1 · placeholder: job dashboard screenshot, over the platform's two elastic tiers</span><span>hidden lines = scaled-down</span></div>
</div>
<div class="notes">
<h3>Notes</h3>
<ol>
<li><b>Job form.</b> Researchers submit Slurm jobs without a terminal: script, resources, inputs.</li>
<li><b>Queue.</b> Status and logs per job, fed by a Node service that talks to Slurm for the user.</li>
<li><b>ERDA picker.</b> Inputs come from, and results go back to, AU's own ERDA storage.</li>
<li><b>Nodes.</b> Compute nodes on Oracle Cloud scale up with the queue and down to zero when it's empty.</li>
<li><b>Login.</b> OAuth with Entra ID: university accounts only.</li>
</ol>
<div class="gen">
<p><strong>what &amp; why</strong>A scalable, easy-to-use compute job system for researchers: Slurm's power behind a React/Node web app, with both web stack and compute scaling on Oracle Cloud.</p>
<p><strong>interesting</strong><span class="ph" title="placeholder">Scaling the web tier and the compute pool separately, since a busy week of uploads isn't a busy week of jobs.</span></p>
</div>
</div>
<table class="rev"><tr><th>REV</th><th>DATE</th><th>CHANGE</th></tr><tr><td>A</td><td>2025</td><td>first release, <span class="ph" title="placeholder">one group</span></td></tr><tr><td>B</td><td><span class="ph" title="placeholder">2026</span></td><td><span class="ph" title="placeholder">autoscaling compute</span></td></tr></table>
<div class="tb">
<div><span class="k">project</span><span class="title">AU compute platform</span></div>
<div><span class="k">dwg no.</span><span class="v">LW-01</span></div>
<div><span class="k">rev / year</span><span class="v">B · 2025–</span></div>
<div><span class="k">stack</span><span class="v">Slurm · React · Node · OCI · Entra ID</span></div>
<div><span class="k">role</span><span class="v">developer &amp; ops, AU IT Research Tech</span></div>
<div class="stampcell"><span class="stamp unk" title="placeholder: AI level not known yet">AI level<small>not marked</small></span></div>
</div>
</div>
</article>
<!-- ===== SHEET 02: HYPERCACHE ===== -->
<article class="sheet" id="s-hypercache" data-id="hypercache" data-ai="hand" tabindex="0">
<span class="selmark">▸ selected</span>
<div class="zones-x"><span>A</span><span>B</span><span>C</span><span>D</span><span>E</span><span>F</span></div>
<div class="zones-y"><span>1</span><span>2</span><span>3</span><span>4</span></div>
<div class="sheet-in">
<div class="fig">
<svg viewBox="0 0 520 360" role="img" aria-label="Figure: peer network with reputation scores; a free-riding peer is cut off; an inset shows an audit round with a time interval dimension">
<g class="ln-th">
<path d="M70 70 L200 50 L330 80 L400 180 L260 220 L110 190 Z M70 70 L110 190 M200 50 L260 220 M200 50 L110 190 M330 80 L260 220"/>
</g>
<path class="hid" d="M200 50 L180 130 M110 190 L180 130 M260 220 L180 130"/>
<g fill="#f3f4f1" stroke="#1c2024" stroke-width="1.3">
<circle cx="70" cy="70" r="20"/><circle cx="200" cy="50" r="20"/><circle cx="330" cy="80" r="20"/><circle cx="400" cy="180" r="20"/><circle cx="260" cy="220" r="20"/><circle cx="110" cy="190" r="20"/>
</g>
<circle cx="180" cy="130" r="18" class="hatch" stroke="#1c2024" stroke-width="1.3"/>
<g class="lbl" text-anchor="middle"><text x="70" y="74">.92</text><text x="200" y="54">.88</text><text x="330" y="84">.71</text><text x="400" y="184">.95</text><text x="260" y="224">.64</text><text x="110" y="194">.90</text></g>
<!-- inset: audit round -->
<rect class="ln" x="40" y="258" width="440" height="84"/>
<text class="lbl-s" x="48" y="272">DETAIL A · one audit round</text>
<path class="ctr" d="M60 312 H460"/>
<g class="ln"><path d="M90 300 V324 M210 300 V324 M330 300 V324 M450 300 V324"/></g>
<text class="lbl-s" x="78" y="296">challenge</text><text class="lbl-s" x="196" y="296">proof</text><text class="lbl-s" x="316" y="296">score</text><text class="lbl-s" x="436" y="296">next</text>
<path class="dim" d="M90 332 H450" marker-start="url(#ar)" marker-end="url(#ar)"/><rect x="240" y="326" width="48" height="12" fill="#f3f4f1"/><text class="dimt" x="264" y="336" text-anchor="middle">Δt</text>
<!-- callouts -->
<g><path class="lead" d="M180 130 L120 130 L120 116" marker-start="url(#dotr)"/><g class="cb"><circle cx="120" cy="108" r="8"/><text x="120" y="108">1</text></g></g>
<g><path class="lead" d="M330 80 L430 60" marker-start="url(#dotr)"/><g class="cb"><circle cx="438" cy="58" r="8"/><text x="438" y="58">2</text></g></g>
<g><path class="lead" d="M200 300 L200 246 L170 246" marker-start="url(#dotr)"/><g class="cb"><circle cx="162" cy="246" r="8"/><text x="162" y="246">3</text></g></g>
<g><path class="lead" d="M260 220 L330 240 L420 240" marker-start="url(#dotr)"/><g class="cb"><circle cx="428" cy="240" r="8"/><text x="428" y="240">4</text></g></g>
</svg>
<div class="figcap"><span>FIG. 2 · reputation network (scores = weighted audit history) and detail A</span><span>hatched = free-rider</span></div>
</div>
<div class="notes">
<h3>Notes</h3>
<ol>
<li><b>Free-rider.</b> Claims to store chunks, fails the spot checks. Hidden lines: the links that get dropped.</li>
<li><b>Score.</b> Each peer's reputation comes from its past audits, weighted toward recent ones.</li>
<li><b>Audit round.</b> Challenge, proof, score, repeat every Δt.</li>
<li><b>Churn.</b> <span class="ph" title="placeholder">Honest peers going offline mustn't look like cheating; this needed a grace window.</span></li>
</ol>
<div class="gen">
<p><strong>what &amp; why</strong>Bachelor thesis: reputation systems for peer-to-peer archival, so peers that don't keep what they promise get found and routed around. Simulated in Node.js.</p>
<div class="links"><a href="#">thesis PDF</a><a href="https://github.com/StereotypicalCat">code</a></div>
</div>
</div>
<table class="rev"><tr><th>REV</th><th>DATE</th><th>CHANGE</th></tr><tr><td>A</td><td><span class="ph" title="placeholder">2022</span></td><td>thesis submitted</td></tr></table>
<div class="tb">
<div><span class="k">project</span><span class="title">HyperCache, bachelor thesis</span></div>
<div><span class="k">dwg no.</span><span class="v">LW-02</span></div>
<div><span class="k">rev / year</span><span class="v">A · <span class="ph" title="placeholder">2022</span></span></div>
<div><span class="k">stack</span><span class="v">Node.js · simulation · P2P</span></div>
<div><span class="k">role</span><span class="v">author, BSc CS</span></div>
<div class="stampcell"><span class="stamp hand">by hand<small>no AI</small></span></div>
</div>
</div>
</article>
<!-- ===== SHEET 03: ROWING ===== -->
<article class="sheet" id="s-rowing" data-id="rowing" data-ai="hand" tabindex="0">
<span class="selmark">▸ selected</span>
<div class="zones-x"><span>A</span><span>B</span><span>C</span><span>D</span><span>E</span><span>F</span></div>
<div class="zones-y"><span>1</span><span>2</span><span>3</span><span>4</span></div>
<div class="sheet-in">
<div class="fig">
<svg viewBox="0 0 520 340" role="img" aria-label="Figure: plan view of the rowing rig with two oars, angle sensors at the oarlocks, an Arduino and a headset, with a dimension line for oar length and a swept angle">
<!-- seat & rail -->
<path class="ctr" d="M260 20 V300"/>
<rect class="ln" x="230" y="120" width="60" height="140"/>
<rect class="hatch" x="240" y="200" width="40" height="30" stroke="#1c2024" stroke-width="1"/>
<text class="lbl-s" x="242" y="250">seat</text>
<!-- rigger -->
<path class="ln" d="M130 140 H390"/>
<circle class="ln" cx="140" cy="140" r="7"/><circle class="ln" cx="380" cy="140" r="7"/>
<!-- oars -->
<path class="ln" d="M140 140 L30 80 M380 140 L490 80"/>
<path class="ln" d="M30 80 l-12 -10 l-8 14 l14 6z M490 80 l12 -10 l8 14 l-14 6z"/>
<path class="ln" d="M140 140 L236 168 M380 140 L284 168"/>
<!-- swept angle -->
<path class="dim" d="M80 113 A70 70 0 0 0 76 170"/><path class="hid" d="M140 140 L40 196"/>
<text class="dimt" x="44" y="146">≈70°</text>
<!-- oar length -->
<path class="dim" d="M397 170 L520 106" /><path class="dim" d="M386 154 L404 186 M496 96 L514 128"/>
<path class="dim" d="M395 170 L505 112" marker-start="url(#ar)" marker-end="url(#ar)"/>
<text class="dimt" x="452" y="158" transform="rotate(-28 452 158)"><tspan class="ph">≈ 2.9 m</tspan></text>
<!-- arduino -->
<rect class="ln" x="226" y="44" width="68" height="40"/><text class="lbl" x="234" y="62">Arduino</text><text class="lbl-s" x="234" y="76">angles @ 60Hz</text>
<path class="hid" d="M140 133 C150 80 200 64 226 64 M380 133 C370 80 320 64 294 64"/>
<!-- headset -->
<path class="ln" d="M232 290 h56 q8 0 8 8 v8 q0 8 -8 8 h-16 l-6 -6 h-12 l-6 6 h-16 q-8 0 -8 -8 v-8 q0 -8 8 -8z"/>
<text class="lbl-s" x="300" y="306">headset · Unity</text>
<path class="hid" d="M226 70 C150 90 150 270 230 298"/>
<!-- callouts -->
<g><path class="lead" d="M140 140 L100 220 L100 240" marker-start="url(#dotr)"/><g class="cb"><circle cx="100" cy="248" r="8"/><text x="100" y="248">1</text></g></g>
<g><path class="lead" d="M294 52 L360 30" marker-start="url(#dotr)"/><g class="cb"><circle cx="368" cy="28" r="8"/><text x="368" y="28">2</text></g></g>
<g><path class="lead" d="M234 304 L190 304" marker-start="url(#dotr)"/><g class="cb"><circle cx="182" cy="304" r="8"/><text x="182" y="304">3</text></g></g>
<g><path class="lead" d="M30 80 L40 34" marker-start="url(#dotr)"/><g class="cb"><circle cx="42" cy="26" r="8"/><text x="42" y="26">4</text></g></g>
</svg>
<div class="figcap"><span>FIG. 3 · rig, plan view · <span class="ph" title="placeholder">dimensions approximate</span></span><span>hidden lines = cables</span></div>
</div>
<div class="notes">
<h3>Notes</h3>
<ol>
<li><b>Oarlock sensor.</b> An angle sensor where each oar pivots; angle, not force, is what the boat needs.</li>
<li><b>Arduino.</b> Reads both sensors and streams angles to Unity over USB serial.</li>
<li><b>Unity.</b> C# turns two angle streams into a boat; mixed reality, so you still see your hands.</li>
<li><b>Real oars.</b> Two of them, with real weight. <span class="ph" title="placeholder">Taped, not welded.</span></li>
</ol>
<div class="gen">
<p><strong>what &amp; why</strong>A mixed-reality rowing game played with two real oars. Does real resistance make a virtual space feel more real?</p>
<div class="links"><a href="#">video</a><a href="https://github.com/StereotypicalCat">GitHub</a></div>
</div>
</div>
<table class="rev"><tr><th>REV</th><th>DATE</th><th>CHANGE</th></tr><tr><td>A</td><td><span class="ph" title="placeholder">2020</span></td><td>course project, group</td></tr></table>
<div class="tb">
<div><span class="k">project</span><span class="title">Mixed-reality rowing game</span></div>
<div><span class="k">dwg no.</span><span class="v">LW-03</span></div>
<div><span class="k">rev / year</span><span class="v">A · <span class="ph" title="placeholder">2020</span></span></div>
<div><span class="k">stack</span><span class="v">Unity · C# · Arduino</span></div>
<div><span class="k">role</span><span class="v">hardware + game, <span class="ph" title="placeholder">group of 3</span></span></div>
<div class="stampcell"><span class="stamp hand">by hand<small>no AI</small></span></div>
</div>
</div>
</article>
<!-- ===== SHEET 04: CONFIG ===== -->
<article class="sheet" id="s-config" data-id="config" data-ai="ai" tabindex="0">
<span class="selmark">▸ selected</span>
<div class="zones-x"><span>A</span><span>B</span><span>C</span><span>D</span><span>E</span><span>F</span></div>
<div class="zones-y"><span>1</span><span>2</span><span>3</span><span>4</span></div>
<div class="sheet-in">
<div class="fig">
<svg viewBox="0 0 520 320" role="img" aria-label="Figure: repository tree on the left, ansible in the middle, three hosts with compose stacks on the right">
<rect class="ln" x="20" y="30" width="150" height="250"/>
<text class="lbl" x="30" y="50">config/</text>
<g class="lbl-s"><text x="40" y="74">inventory.yml</text><text x="40" y="96">group_vars/</text><text x="40" y="118">roles/</text><text x="56" y="138">docker/</text><text x="56" y="156">backup/</text><text x="56" y="174">monitoring/</text><text x="40" y="198">compose/</text><text x="56" y="218">media.yml</text><text x="56" y="236">services.yml</text><text x="40" y="262">vault.yml (enc.)</text></g>
<path class="ln-th" d="M32 58 V258 M32 70 H38 M32 92 H38 M32 114 H38 M48 120 V170 M48 134 H54 M48 152 H54 M48 170 H54 M32 194 H38 M48 200 V232 M48 214 H54 M48 232 H54 M32 258 H38"/>
<rect class="ln" x="210" y="130" width="96" height="44"/><text class="lbl" x="222" y="150">ansible</text><text class="lbl-s" x="222" y="164">playbook site.yml</text>
<path class="ln-th" d="M170 152 H208" marker-end="url(#ar-i)"/>
<g><rect class="ln" x="360" y="40" width="140" height="60"/><rect class="ln" x="360" y="130" width="140" height="60"/><rect class="ln" x="360" y="220" width="140" height="60"/></g>
<g class="lbl"><text x="370" y="58">nas</text><text x="370" y="148">mini-1</text><text x="370" y="238">mini-2</text></g>
<g><rect class="hatch" x="370" y="66" width="36" height="24"/><rect class="hatch" x="412" y="66" width="36" height="24"/><rect class="hatch" x="370" y="156" width="36" height="24"/><rect class="hatch" x="412" y="156" width="36" height="24"/><rect class="hatch" x="454" y="156" width="36" height="24"/><rect class="hatch" x="370" y="246" width="36" height="24"/></g>
<path class="ln-th" d="M306 144 L358 70 M306 152 H358 M306 160 L358 250" marker-end="url(#ar-i)"/>
<path class="dim" d="M360 300 H500" marker-start="url(#ar)" marker-end="url(#ar)"/><text class="dimt" x="392" y="314">3 hosts, 1 command</text>
<g><path class="lead" d="M120 262 L190 262 L190 290" marker-start="url(#dotr)"/><g class="cb"><circle cx="190" cy="298" r="8"/><text x="190" y="298">1</text></g></g>
<g><path class="lead" d="M258 130 L258 96" marker-start="url(#dotr)"/><g class="cb"><circle cx="258" cy="88" r="8"/><text x="258" y="88">2</text></g></g>
<g><path class="lead" d="M100 120 L200 100 L200 70" marker-start="url(#dotr)"/><g class="cb"><circle cx="200" cy="62" r="8"/><text x="200" y="62">3</text></g></g>
<g><path class="lead" d="M430 168 L430 200 L340 200" marker-start="url(#dotr)"/><g class="cb"><circle cx="332" cy="200" r="8"/><text x="332" y="200">4</text></g></g>
</svg>
<div class="figcap"><span>FIG. 4 · repo and hosts · <span class="ph" title="placeholder">host and file names placeholder</span></span><span>hatched = compose stack</span></div>
</div>
<div class="notes">
<h3>Notes</h3>
<ol>
<li><b>Secrets.</b> In an encrypted vault file, never in plain history.</li>
<li><b>One command.</b> Any host rebuilt from bare metal with a single playbook run.</li>
<li><b>Roles.</b> Generated by AI, then iterated for a month; the layout was written by hand.</li>
<li><b>Compose, not Kubernetes.</b> The lab is three machines.</li>
</ol>
<div class="gen">
<p><strong>what &amp; why</strong>Ansible + docker-compose deployment management for the homelab, with git as the documentation.</p>
<p><strong>broke</strong><span class="ph" title="placeholder">Every run restarted every container: a template rendered a new timestamp. Found by hand with --diff.</span></p>
<div class="links"><a href="https://github.com/StereotypicalCat">GitHub</a></div>
</div>
</div>
<table class="rev"><tr><th>REV</th><th>DATE</th><th>CHANGE</th></tr><tr><td>A</td><td><span class="ph" title="placeholder">2025</span></td><td>first hosts</td></tr><tr><td>B</td><td><span class="ph" title="placeholder">2025</span></td><td>idempotent templates</td></tr></table>
<div class="tb">
<div><span class="k">project</span><span class="title">Config</span></div>
<div><span class="k">dwg no.</span><span class="v">LW-04</span></div>
<div><span class="k">rev / year</span><span class="v">B · <span class="ph" title="placeholder">2025</span></span></div>
<div><span class="k">stack</span><span class="v">Ansible · docker-compose · IaC</span></div>
<div><span class="k">role</span><span class="v">solo, homelab</span></div>
<div class="stampcell"><span class="stamp ai">AI-made<small>spec by hand</small></span></div>
</div>
</div>
</article>
</div>
<!-- ============ REGISTER ============ -->
<div class="sechead" id="register"><h2>Drawing register</h2><p>Every project. Click a row to open its sheet in place.</p><span class="k" id="regcount"></span></div>
<div class="register">
<table>
<thead><tr><th>dwg</th><th>title</th><th class="st">stack</th><th class="yr">year</th><th>made</th><th></th></tr></thead>
<tbody id="reg"></tbody>
</table>
</div>
<footer class="foot">
<p>Set in IBM Plex Sans, Sans Condensed and Mono, on the gate's grid paper. Stamps: double ring = by hand, red = AI-made, dashed = not marked yet.</p>
<p><b>Placeholders</b> (dotted underline): university, project years, revision tables, roles and group sizes, all blog titles and dates, the CV PDF, the AU projects' AI level, figure dimensions and host names, "interesting / broke" notes, the ScienceAtHome project name. Figures are drawn placeholders for real screenshots.</p>
<p><a href="nav-d.html">← back to the gate</a> · <a href="nav-d.html">off the clock →</a> · <a href="https://old.lucaswinther.info">the 2021 site (by hand)</a></p>
</footer>
</div>
<script>
(function(){
var W = { // placeholder blog titles
slurm:["A Slurm cluster that scales to zero","Sep 2026"],
entra:["Entra ID, OAuth and the redirect URI that ate a week","Jul 2026"],
agent:["What I let the agent write, and what I didn't","Oct 2026"],
ansible:["Ansible idempotency, the hard way","May 2026"],
k3s:["Three mini PCs, one k3s cluster","Jun 2026"],
alerts:["Four alerts, not forty","Aug 2026"],
thesis:["Reading a thesis I wrote by hand, three years later","Mar 2026"]
};
var LATEST = ["agent","slurm","alerts","entra"];
// id, no, title, group, year, stack, ai, one-liner, skills, writing, links, share(hand%), featured, callouts
var P = [
["compute","LW-01","AU compute platform","at work · AU","2025–","Slurm · React · Node · OCI","unk","Slurm job system with a React/Node web app, elastic on Oracle Cloud, Entra ID login, ERDA storage.",["Slurm","React","Node","Cloud (OCI)","OAuth","Linux"],["slurm","entra"],[],null,1],
["docs","LW-05","Document & information services","at work · AU","2025–","Overleaf · OpenProject","unk","Self-hosted Overleaf and OpenProject for research groups, wired into university login and each other.",["Docker","Linux","OAuth"],[],[],null,0,["login","Overleaf","OpenProject"]],
["ops","LW-06","Kept secure, kept running","at work · AU","2025–","OpenBao · Grafana · Docker · Ansible","unk","Testing new software for researchers while keeping it isolated, updated and monitored.",["Docker","Ansible","Observability","Linux","Vault"],["alerts"],[],null,0,["containers","OpenBao","Grafana"]],
["sah","LW-07","Early prototype, ScienceAtHome","at work · internship","2019","prototype","hand","Ideation and planning with the team, an early prototype of the base mechanics, pair programming to start the real project.",["Prototyping"],[],[],100,0,["base mechanics","demo for stakeholders"]],
["gacha","LW-08","Event Tracker for Gacha Games","on my own","2025","JS · scraping · web app","ai","One calendar for limited-time events across several gacha games. Spec and tests by hand, code by an agent.",["JavaScript","Scraping","Testing"],["agent"],[["live demo","#"],["GitHub","https://github.com/StereotypicalCat"]],10,0,["scrapers","normaliser","calendar"]],
["config","LW-04","Config","on my own","2025","Ansible · compose · IaC","ai","Homelab deployment management: every host rebuildable with one command.",["Ansible","Docker","Linux","IaC"],["ansible","k3s","agent"],[],15,1],
["chess","LW-09","Chessswap","on my own","2020","React","hand","Chess where the board swaps every few turns.",["React","JavaScript"],[],[["GitHub","https://github.com/StereotypicalCat"]],100,0,["board","swap timer"]],
["expenses","LW-10","Monthly Expenses Tracker","on my own","2021","JS · web app","hand","Monthly spending in DKK, amounts kept in øre.",["JavaScript"],[],[["GitHub","https://github.com/StereotypicalCat"]],100,0,["month view","DKK totals"]],
["lol","LW-11","LoL Winrate Discord Bot","on my own","2019","Node.js · Discord","hand","Ask in chat, get a champion's win rate back.",["Node","JavaScript"],[],[["GitHub","https://github.com/StereotypicalCat"]],100,0,["command","reply"]],
["portfolio","LW-12","Portfolio, 2021","on my own","2021","React · Material UI","hand","The previous site, frozen as it was.",["React","JavaScript"],[],[["old.lucaswinther.info","https://old.lucaswinther.info"]],100,0,["side panel","project grid"]],
["borlaug","LW-13","Tribute to Norman Borlaug","on my own","2016","HTML · CSS","hand","One of my first websites, from freeCodeCamp.",["HTML/CSS"],[],[],100,0,["portrait","timeline"]],
["hypercache","LW-02","HyperCache, bachelor thesis","school · university","2022","Node.js · simulation","hand","Reputation systems for peer-to-peer archival, simulated in Node.js.",["Node","Distributed systems","Research"],["thesis"],[],100,1],
["rowing","LW-03","Mixed-reality rowing game","school · university","2020","Unity · C# · Arduino","hand","A rowing game played with two real oars.",["Unity","C#","Arduino","Hardware"],[],[],100,1],
["dashboard","LW-14","Student Dashboard","school · university","2021","React · user studies","hand","A student dashboard designed from two rounds of user studies.",["React","User studies"],[],[["report PDF","#"],["demo","#"]],100,0,["overview","deadlines"]],
["vr","LW-15","VR-Classroom","school · university","2019","Node.js · prototype","hand","A shared virtual classroom prototype, synced by a Node.js server.",["Node","VR"],[],[["report","#"],["video","#"]],100,0,["sync server","clients"]],
["censor","LW-16","Censor Simulator","school · high school","2018","Unity · VR","hand","High-school exam project: a VR game about being the censor.",["Unity","C#","VR"],[],[],100,0,["desk","documents"]],
["breakout","LW-17","BreakoutPython","school · high school","2017","Python","hand","High-school exam project: Breakout, from scratch.",["Python"],[],[["GitHub","https://github.com/StereotypicalCat"]],100,0,["paddle","bricks"]]
];
var byId = {}; P.forEach(function(p){ byId[p[0]] = p; });
var AIL = {hand:"by hand", ai:"AI-made", unk:"not marked"};
var YEAR_PH = function(y){ return /^20(2[5-9])/.test(y) && y.indexOf("–")>0 ? y : '<span class="ph" title="placeholder year">'+y+'</span>'; };
// ---------- register ----------
var reg = document.getElementById("reg"), html = "", last = "";
P.slice().sort(function(a,b){ var o=["at work","on my own","school"]; return o.indexOf(a[3].split(" · ")[0]) - o.indexOf(b[3].split(" · ")[0]); }).forEach(function(p){
var g = p[3].split(" · ")[0];
if (g !== last){ html += '<tr class="grp" data-g="'+g+'"><td colspan="6">'+g+'</td></tr>'; last = g; }
html += '<tr class="r" data-id="'+p[0]+'" data-ai="'+p[6]+'" tabindex="0" aria-expanded="false"><td class="no">'+p[1]+'</td><td class="t">'+p[2]+''+(p[3].split(" · ")[1]?'<small>'+p[3].split(" · ")[1]+'</small>':'')+'</td><td class="st">'+p[5]+'</td><td class="yr">'+YEAR_PH(p[4])+'</td><td class="made"><i class="aid '+(p[6]==="hand"?"":p[6])+'"></i>'+AIL[p[6]]+'</td><td class="op">'+(p[12]?'sheet ↑':'open ▾')+'</td></tr>';
});
reg.innerHTML = html;
function miniSheet(p){
var c = p[13] || ["part A","part B"];
var pts = [[70,60],[200,52],[140,118]];
var callouts = c.map(function(t,i){ var q=pts[i]; return '<path class="lead" d="M'+q[0]+' '+q[1]+' L'+(q[0]+(i%2?60:-40))+' '+(q[1]-26)+'" marker-start="url(#dotr)"/><g class="cb"><circle cx="'+(q[0]+(i%2?66:-46))+'" cy="'+(q[1]-30)+'" r="8"/><text x="'+(q[0]+(i%2?66:-46))+'" y="'+(q[1]-30)+'">'+(i+1)+'</text></g>'; }).join("");
var links = (p[10]||[]).map(function(l){ return '<a href="'+l[1]+'">'+l[0]+'</a>'; }).join("");
var stamp = p[6]==="ai" ? '<span class="stamp ai">AI-made</span>' : p[6]==="unk" ? '<span class="stamp unk" title="placeholder">AI level<small>not marked</small></span>' : '<span class="stamp hand">by hand<small>no AI</small></span>';
return '<article class="sheet" data-id="'+p[0]+'"><div class="zones-x"><span>A</span><span>B</span><span>C</span><span>D</span></div><div class="zones-y"><span>1</span><span>2</span></div><div class="sheet-in">'+
'<div class="fig mini-fig"><svg viewBox="0 0 300 160" role="img" aria-label="placeholder figure"><rect class="ln" x="40" y="40" width="220" height="110"/><path class="phbox" d="M40 40 L260 150 M260 40 L40 150"/><text class="lbl-s" x="150" y="100" text-anchor="middle" style="paint-order:stroke;stroke:#f3f4f1;stroke-width:6px">placeholder: screenshot</text><path class="dim" d="M40 156 H260" marker-start="url(#ar)" marker-end="url(#ar)"/>'+callouts+'</svg><div class="figcap"><span>FIG. '+p[1].slice(3)+' · placeholder figure</span></div></div>'+
'<div class="notes"><h3>Notes</h3><ol>'+c.map(function(t){ return '<li><b>'+t+'.</b> <span class="ph" title="placeholder">detail to come</span></li>'; }).join("")+'</ol><div class="gen"><p><strong>what &amp; why</strong>'+p[7]+'</p>'+(links?'<div class="links">'+links+'</div>':'')+'</div></div>'+
'<div class="tb"><div><span class="k">project</span><span class="title" style="font-size:16px">'+p[2]+'</span></div><div><span class="k">dwg no.</span><span class="v">'+p[1]+'</span></div><div><span class="k">year</span><span class="v">'+YEAR_PH(p[4])+'</span></div><div><span class="k">stack</span><span class="v">'+p[5]+'</span></div><div><span class="k">group</span><span class="v">'+p[3]+'</span></div><div class="stampcell">'+stamp+'</div></div>'+
'</div></article>';
}
function toggleRow(tr, forceOpen){
var p = byId[tr.dataset.id];
if (p[12]) { var s = document.getElementById("s-"+p[0]); s.scrollIntoView(); select(p[0]); return; }
var next = tr.nextElementSibling, isOpen = next && next.classList.contains("drawer");
if (isOpen && !forceOpen){ next.remove(); tr.classList.remove("open"); tr.setAttribute("aria-expanded","false"); tr.querySelector(".op").textContent="open ▾"; return; }
if (!isOpen){
var d = document.createElement("tr"); d.className = "drawer"; d.innerHTML = '<td colspan="6">'+miniSheet(p)+'</td>';
tr.after(d); tr.classList.add("open"); tr.setAttribute("aria-expanded","true"); tr.querySelector(".op").textContent="close ▴";
}
select(p[0]);
}
reg.addEventListener("click", function(e){ var tr = e.target.closest("tr.r"); if (tr && !e.target.closest("a")) toggleRow(tr); });
reg.addEventListener("keydown", function(e){ var tr = e.target.closest("tr.r"); if (tr && (e.key==="Enter"||e.key===" ")){ e.preventDefault(); toggleRow(tr); } });
// ---------- inspector ----------
var $ = function(id){ return document.getElementById(id); };
var current = null;
function skillUsers(skill){ return P.filter(function(p){ return p[8].indexOf(skill) > -1; }); }
function select(id){
if (current === id) return; current = id;
var p = byId[id];
document.querySelectorAll(".sheet[id]").forEach(function(s){ s.classList.toggle("sel", s.dataset.id === id); });
document.querySelectorAll("#tabs a[data-sheet]").forEach(function(a){ a.classList.toggle("on", a.dataset.sheet === id); });
$("i-no").textContent = p[1]; $("i-group").textContent = p[3]; $("i-title").textContent = p[2]; $("i-one").textContent = p[7];
$("i-links").innerHTML = (p[10]||[]).map(function(l){ return '<a href="'+l[1]+'">'+l[0]+'</a>'; }).join("");
var h = p[11];
$("i-bar").innerHTML = h==null ? '<i class="u" style="width:100%"></i>' : '<i class="h" style="width:'+h+'%"></i><i class="a" style="width:'+(100-h)+'%"></i>';
$("i-who").innerHTML = h==null ? '<span class="ph" title="placeholder">not marked yet: work project, breakdown to come</span>' : h===100 ? 'all by hand' : '≈ '+h+'% by hand, '+(100-h)+'% AI <span class="ph" title="placeholder">(estimate)</span>';
$("i-skills").innerHTML = p[8].map(function(s){ return '<button data-skill="'+s+'" aria-pressed="false">'+s+' <span style="opacity:.55">'+skillUsers(s).length+'</span></button>'; }).join("");
$("i-skillnote").textContent = "";
clearLit();
$("i-writing").innerHTML = p[9].length ? p[9].map(function(k){ return '<li><a href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title" style="font:inherit;color:inherit">'+W[k][0]+'</span></a><span>'+W[k][1]+' · placeholder</span></li>'; }).join("") : '<li><span>nothing written about this one yet</span></li>';
}
function clearLit(){ document.querySelectorAll("#reg tr.lit").forEach(function(t){ t.classList.remove("lit"); }); }
$("i-skills").addEventListener("click", function(e){
var b = e.target.closest("button"); if (!b) return;
var on = b.getAttribute("aria-pressed") !== "true";
document.querySelectorAll("#i-skills button").forEach(function(x){ x.setAttribute("aria-pressed","false"); });
clearLit();
if (!on){ $("i-skillnote").textContent = ""; return; }
b.setAttribute("aria-pressed","true");
var users = skillUsers(b.dataset.skill).filter(function(p){ return p[0] !== current; });
users.forEach(function(p){ var tr = reg.querySelector('tr.r[data-id="'+p[0]+'"]'); if (tr) tr.classList.add("lit"); });
$("i-skillnote").innerHTML = users.length ? "also in: " + users.map(function(p){ return '<a href="#register" data-go="'+p[0]+'">'+p[2]+'</a>'; }).join(", ") + " (marked in the register)" : "only on this sheet";
});
$("i-skillnote").addEventListener("click", function(e){
var a = e.target.closest("[data-go]"); if (!a) return; e.preventDefault();
var tr = reg.querySelector('tr.r[data-id="'+a.dataset.go+'"]'); if (!tr) return;
toggleRow(tr, true); (tr.classList.contains("open") ? tr : document.getElementById("s-"+a.dataset.go) || tr).scrollIntoView({block:"start"});
});
$("i-latest").innerHTML = LATEST.map(function(k){ return '<li><a href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title" style="font:inherit;color:inherit">'+W[k][0]+'</span></a><span>'+W[k][1]+'</span></li>'; }).join("");
document.getElementById("sheets").addEventListener("click", function(e){ var s = e.target.closest(".sheet"); if (s) select(s.dataset.id); });
document.getElementById("sheets").addEventListener("focusin", function(e){ var s = e.target.closest(".sheet"); if (s) select(s.dataset.id); });
if ("IntersectionObserver" in window){
var io = new IntersectionObserver(function(es){ es.forEach(function(en){ if (en.isIntersecting) select(en.target.dataset.id); }); }, {rootMargin:"-40% 0px -50% 0px"});
document.querySelectorAll(".sheet[id]").forEach(function(s){ io.observe(s); });
}
select("compute");
// ---------- AI filter ----------
function applyPref(pref){
var hidden = 0;
function hideFor(ai){ return (pref==="nofull" && ai==="ai") || (pref==="hand" && ai!=="hand"); }
document.querySelectorAll(".sheet[id]").forEach(function(s){ var h = hideFor(s.dataset.ai); s.classList.toggle("hide", h); var t = document.querySelector('#tabs a[data-sheet="'+s.dataset.id+'"]'); if (t) t.classList.toggle("off", h); });
reg.querySelectorAll("tr.r").forEach(function(tr){
var h = hideFor(tr.dataset.ai); tr.classList.toggle("hide", h); if (h) hidden++;
var n = tr.nextElementSibling; if (h && n && n.classList.contains("drawer")) { n.remove(); tr.classList.remove("open"); tr.querySelector(".op").textContent="open ▾"; }
});
reg.querySelectorAll("tr.grp").forEach(function(g){
var n = g.nextElementSibling, any = false;
while (n && !n.classList.contains("grp")) { if (n.classList.contains("r") && !n.classList.contains("hide")) any = true; n = n.nextElementSibling; }
g.classList.toggle("hide", !any);
});
$("regcount").textContent = (P.length - hidden) + " of " + P.length + " shown" + (pref==="hand" ? " · unmarked work hidden" : "");
document.querySelectorAll("[data-pref]").forEach(function(b){ b.setAttribute("aria-pressed", b.dataset.pref===pref ? "true" : "false"); });
}
document.querySelectorAll("[data-pref]").forEach(function(b){ b.addEventListener("click", function(){ applyPref(b.dataset.pref); }); });
applyPref("all");
var m = location.search.match(/open=(\w+)/); if (m){ var tr0 = reg.querySelector('tr.r[data-id="'+m[1]+'"]'); if (tr0){ toggleRow(tr0); tr0.scrollIntoView(); } }
document.addEventListener("keydown", function(e){ if (e.key === "Escape" && !(e.target.closest && e.target.closest("input,textarea"))) location.href = "nav-d.html"; });
})();
</script>
</body>
</html>
+680
View File
@@ -0,0 +1,680 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Work · Lucas Winther</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&family=Fraunces:ital,opsz,wght@1,9..144,300&display=swap" rel="stylesheet">
<style>
:root {
--bg: #e8eae6; --ink: #1c2024; --ink2: #343a3e; --mute: #6b726f; --line: #cfd3cd; --acc: #d9532b; --paper: #f5f6f3;
--a-bg: #2a1a1f; --a-ink: #f1e6d6;
--mono: "IBM Plex Mono", ui-monospace, monospace; --sans: "IBM Plex Sans", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body { font-family: var(--sans); color: var(--ink); background: var(--bg);
background-image: linear-gradient(var(--line) 1px, transparent 1px), linear-gradient(90deg, var(--line) 1px, transparent 1px);
background-size: 24px 24px; background-position: -1px -1px; }
a { color: inherit; text-decoration-color: var(--acc); text-underline-offset: 3px; }
a:hover { color: var(--acc); }
button { font: inherit; color: inherit; }
.mono { font-family: var(--mono); }
.lbl { font-family: var(--mono); font-size: 10.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--mute); }
.lbl b { color: var(--acc); font-weight: 500; }
.ph { text-decoration: underline dotted var(--mute); text-underline-offset: 3px; cursor: help; }
:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; }
/* AI marks, same as nav-d */
.ai { font-family: var(--mono); font-size: 11px; color: var(--mute); white-space: nowrap; }
.ai i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; margin-right: 5px; vertical-align: 0; }
.ai.full { color: var(--acc); } .ai.full i { background: currentColor; }
.ai.some i { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
.ai.unk i { border-style: dotted; }
/* ---------- frame ---------- */
.shell { display: grid; grid-template-columns: 296px minmax(0, 1fr) 232px; margin-right: 44px; min-height: 100vh; }
.edge { position: fixed; top: 0; right: 0; bottom: 0; width: 44px; background: var(--a-bg); color: var(--a-ink); display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; font-family: "Fraunces", serif; font-style: italic; font-weight: 300; font-size: 16px; text-decoration: none; z-index: 10; transition: width .2s; }
.edge:hover { width: 52px; color: #e8b04a; }
/* ---------- left: the index ---------- */
.index { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; border-right: 1px solid var(--ink); background: rgba(232,234,230,.9); }
.ix-head { padding: 18px 18px 12px 22px; border-bottom: 1px solid var(--ink); }
.ix-top { display: flex; justify-content: space-between; align-items: baseline; font-family: var(--mono); font-size: 12px; }
.ix-top a { text-decoration: none; color: var(--mute); }
.ix-top a:hover { color: var(--acc); }
.ix-name { font-family: var(--mono); font-weight: 500; font-size: 15px; }
.ix-name span { color: var(--mute); font-weight: 400; }
.pref { margin-top: 14px; display: flex; flex-wrap: wrap; gap: 5px; align-items: center; }
.pref .lbl { width: 100%; margin-bottom: 2px; }
.pref button { background: none; border: 1px solid var(--line); background: var(--paper); font-family: var(--mono); font-size: 11px; padding: 2px 7px; border-radius: 99px; cursor: pointer; color: var(--ink2); }
.pref button:hover { border-color: var(--ink); }
.pref button[aria-pressed="true"] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.pref-note { font-family: var(--mono); font-size: 10.5px; color: var(--mute); min-height: 14px; margin-top: 5px; }
.ix-list { flex: 1; overflow-y: auto; padding: 8px 0 18px; position: relative; scrollbar-width: thin; }
.ix-group { margin-top: 12px; }
.ix-gh { display: flex; align-items: baseline; gap: 8px; padding: 4px 18px 4px 22px; font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .09em; color: var(--mute); }
.ix-gh b { color: var(--ink); font-weight: 500; }
.ix-gh::after { content: ""; flex: 1; border-bottom: 1px solid var(--line); transform: translateY(-3px); }
.it { position: relative; display: grid; grid-template-columns: 30px 1fr auto; gap: 0 8px; align-items: baseline; width: 100%; text-align: left; border: 0; background: none; padding: 6px 16px 6px 22px; cursor: pointer; }
.it::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 12px; background: repeating-linear-gradient(var(--mute) 0 1px, transparent 1px 6px); background-size: 6px 6px; background-repeat: repeat-y; opacity: .5; }
.it .no { font-family: var(--mono); font-size: 10.5px; color: var(--mute); }
.it .nm { font-size: 14.5px; line-height: 1.3; }
.it .sub { grid-column: 2 / 4; font-family: var(--mono); font-size: 10.5px; color: var(--mute); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.it .ai { font-size: 0; } .it .ai i { margin: 0; }
.it:hover { background: rgba(255,255,255,.55); }
.it[aria-current="true"] { background: var(--paper); box-shadow: inset 0 1px 0 var(--ink), inset 0 -1px 0 var(--ink); }
.it[aria-current="true"]::after { content: ""; position: absolute; left: 0; top: 50%; border: 6px solid transparent; border-left: 9px solid var(--acc); transform: translateY(-50%); }
.it[aria-current="true"] .nm { font-weight: 500; }
.it.off { display: none; }
.it.hl .nm { background: linear-gradient(transparent 62%, rgba(217,83,43,.25) 62%); }
.it.overview .nm { font-family: var(--mono); font-size: 13px; }
.ix-foot { padding: 10px 18px 12px 22px; border-top: 1px solid var(--line); font-family: var(--mono); font-size: 10.5px; color: var(--mute); display: flex; gap: 12px; flex-wrap: wrap; }
kbd { font-family: var(--mono); font-size: 10px; border: 1px solid currentColor; border-radius: 3px; padding: 0 4px; }
/* ---------- middle: the sheet ---------- */
.stage { padding: 22px 26px 60px; min-width: 0; }
.crumbs { display: flex; justify-content: space-between; gap: 16px; font-family: var(--mono); font-size: 11.5px; color: var(--mute); margin-bottom: 12px; }
.crumbs a { color: var(--mute); text-decoration: none; } .crumbs a:hover { color: var(--acc); }
.crumbs .here { color: var(--ink); }
.sheet { background: var(--paper); border: 1px solid var(--ink); padding: 34px 40px 40px; position: relative; }
.sheet::before, .sheet::after { content: ""; position: absolute; width: 14px; height: 14px; border-color: var(--acc); border-style: solid; }
.sheet::before { top: -6px; left: -6px; border-width: 1.5px 0 0 1.5px; }
.sheet::after { bottom: -6px; right: -6px; border-width: 0 1.5px 1.5px 0; }
.kick { display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: baseline; }
h1 { font-family: var(--mono); font-weight: 500; font-size: clamp(30px, 3.3vw, 44px); letter-spacing: -.03em; line-height: 1.05; margin: 12px 0 0; }
.one { font-size: 19px; line-height: 1.45; color: var(--ink2); max-width: 34em; margin: 12px 0 0; }
.fig { margin: 26px 0 0; }
.fig .box { border: 1px solid var(--ink); background: repeating-linear-gradient(135deg, rgba(28,32,36,.045) 0 1px, transparent 1px 9px), var(--bg); aspect-ratio: 720 / 280; position: relative; }
.fig svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.fig figcaption { margin-top: 6px; font-family: var(--mono); font-size: 11px; color: var(--mute); }
.fig figcaption b { color: var(--ink); font-weight: 500; }
.fig .ticks { position: absolute; left: 0; right: 0; top: -7px; height: 6px; background: repeating-linear-gradient(90deg, var(--ink) 0 1px, transparent 1px 24px); opacity: .5; }
.sec { margin-top: 36px; }
.sec > .lbl { display: flex; gap: 10px; align-items: baseline; border-bottom: 1px solid var(--ink); padding-bottom: 6px; margin-bottom: 4px; }
.sec > .lbl .r { margin-left: auto; text-transform: none; letter-spacing: 0; }
.story { display: grid; grid-template-columns: 150px 1fr; }
.story > div { padding: 12px 0; border-bottom: 1px solid var(--line); }
.story .k { font-family: var(--mono); font-size: 12px; color: var(--mute); padding-right: 16px; }
.story .k b { display: block; color: var(--acc); font-weight: 500; font-size: 10.5px; }
.story .v { font-size: 16px; line-height: 1.6; max-width: 40em; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 0 36px; }
.stack { display: flex; flex-wrap: wrap; gap: 6px; padding-top: 10px; }
.tag { font-family: var(--mono); font-size: 12px; border: 1px solid var(--ink); padding: 2px 8px; background: var(--paper); cursor: pointer; }
.tag:hover, .tag.on { background: var(--ink); color: var(--paper); }
.links { list-style: none; margin: 0; padding: 8px 0 0; font-family: var(--mono); font-size: 13px; }
.links li { padding: 4px 0; }
.links .dead { color: var(--mute); font-style: italic; }
table.who { border-collapse: collapse; width: 100%; font-size: 14.5px; }
table.who td { padding: 9px 12px 9px 0; border-bottom: 1px solid var(--line); vertical-align: middle; }
table.who td:first-child { width: 38%; }
.meter { display: inline-grid; grid-template-columns: repeat(10, 7px); gap: 2px; vertical-align: middle; margin-right: 10px; }
.meter i { height: 12px; border: 1px solid var(--ink); }
.meter i.f { background: var(--acc); border-color: var(--acc); }
.meter i.h { background: var(--ink); }
.meter.unk i { border-style: dotted; border-color: var(--mute); }
.who-note { font-family: var(--mono); font-size: 11px; color: var(--mute); margin-top: 8px; }
.rel { display: grid; grid-template-columns: 1fr 1fr; gap: 0 36px; }
.rel ul { list-style: none; margin: 0; padding: 0; }
.rel li { border-bottom: 1px solid var(--line); }
.rel button, .rel a.post { display: grid; grid-template-columns: 30px 1fr auto; gap: 8px; width: 100%; text-align: left; background: none; border: 0; padding: 9px 0; cursor: pointer; font-size: 14.5px; text-decoration: none; align-items: baseline; }
.rel a.post { grid-template-columns: 82px 1fr; }
.rel button:hover .t, .rel a.post:hover .t { color: var(--acc); }
.rel .no, .rel .d { font-family: var(--mono); font-size: 10.5px; color: var(--mute); }
.rel .ai { font-size: 0; } .rel .ai i { margin: 0; }
.pn { display: flex; justify-content: space-between; margin-top: 40px; font-family: var(--mono); font-size: 12.5px; }
.pn button { background: none; border: 0; cursor: pointer; padding: 0; text-align: left; }
.pn button:last-child { text-align: right; }
.pn button:hover { color: var(--acc); }
.pn small { display: block; color: var(--mute); font-size: 10.5px; }
/* overview */
.intro { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 36px; }
.intro p { font-size: 17px; line-height: 1.6; color: var(--ink2); margin: 14px 0 0; max-width: 32em; }
.cv { border-top: 1px solid var(--ink); }
.cv .row { display: grid; grid-template-columns: 78px 1fr; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 14px; line-height: 1.45; }
.cv .row .k { font-family: var(--mono); font-size: 10.5px; color: var(--mute); text-transform: uppercase; letter-spacing: .06em; padding-top: 2px; }
.cv .row small { color: var(--mute); font-family: var(--mono); font-size: 11px; }
.cv .bar { display: flex; gap: 14px; flex-wrap: wrap; padding: 10px 0; font-family: var(--mono); font-size: 12px; }
.pdf { display: inline-flex; gap: 6px; align-items: center; border: 1px solid var(--ink); padding: 3px 9px; text-decoration: none; background: var(--bg); }
.pdf:hover { background: var(--ink); color: var(--paper); }
.starts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 14px; }
.start { text-align: left; background: none; border: 0; border-top: 1px solid var(--ink); padding: 10px 0 0; cursor: pointer; }
.start .mini { aspect-ratio: 720/280; border: 1px solid var(--line); background: var(--bg); position: relative; margin-bottom: 8px; transition: border-color .15s; }
.start .mini svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.start:hover .mini { border-color: var(--acc); }
.start .t { font-family: var(--mono); font-weight: 500; font-size: 14px; display: block; }
.start:hover .t { color: var(--acc); }
.start .s { font-size: 13.5px; color: var(--mute); line-height: 1.4; display: block; margin-top: 3px; }
.posts { list-style: none; margin: 0; padding: 0; }
.posts li { border-bottom: 1px solid var(--line); }
.posts a { display: grid; grid-template-columns: 92px 1fr 76px; gap: 12px; padding: 10px 0; text-decoration: none; align-items: baseline; }
.posts .d, .posts .tg { font-family: var(--mono); font-size: 11px; color: var(--mute); }
.posts .tg { text-align: right; }
.posts .t { font-size: 15.5px; }
.posts .b { display: block; font-size: 13.5px; color: var(--mute); margin-top: 2px; }
.posts a:hover .t { color: var(--acc); }
/* ---------- right: inspector strip ---------- */
.insp { position: sticky; top: 0; height: 100vh; overflow-y: auto; border-left: 1px solid var(--line); padding: 22px 18px 22px 18px; background: rgba(232,234,230,.82); font-size: 13.5px; }
.insp h3 { font-family: var(--mono); font-size: 10.5px; font-weight: 500; text-transform: uppercase; letter-spacing: .09em; color: var(--mute); margin: 22px 0 6px; padding-bottom: 4px; border-bottom: 1px solid var(--line); }
.insp h3:first-child { margin-top: 0; }
.insp > div + div { margin-top: 24px; }
.insp .head { font-family: var(--mono); font-size: 11px; display: flex; justify-content: space-between; color: var(--mute); }
.insp .head b { color: var(--acc); font-weight: 500; }
.sk { list-style: none; margin: 0; padding: 0; }
.sk button { display: flex; justify-content: space-between; width: 100%; background: none; border: 0; padding: 3px 0; cursor: pointer; font-family: var(--mono); font-size: 12px; text-align: left; }
.sk button span:last-child { color: var(--mute); }
.sk button .bar { flex: 1; margin: 0 8px; border-bottom: 1px dotted var(--line); transform: translateY(-4px); }
.sk button:hover, .sk button.on { color: var(--acc); }
.sk button.on span:last-child { color: var(--acc); }
.insp .hint { font-family: var(--mono); font-size: 10.5px; color: var(--mute); margin-top: 6px; line-height: 1.5; }
.insp ul.w { list-style: none; margin: 0; padding: 0; }
.insp ul.w li { padding: 5px 0; line-height: 1.35; }
.insp ul.w small { display: block; font-family: var(--mono); font-size: 10.5px; color: var(--mute); }
.share { display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px; background: var(--paper); border: 1px solid var(--line); padding: 5px 6px; word-break: break-all; }
.share button { margin-left: auto; border: 1px solid var(--ink); background: none; font-family: var(--mono); font-size: 10px; padding: 1px 6px; cursor: pointer; flex: none; }
.share button:hover { background: var(--ink); color: var(--paper); }
/* mockup note */
.mk { position: fixed; top: 10px; right: 54px; z-index: 50; width: 310px; background: #fff; color: #222; border: 1px solid #bbb; padding: 10px 26px 10px 12px; font: 12.5px/1.4 system-ui, sans-serif; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
.mk b { display: block; margin-bottom: 4px; }
.mk button { position: absolute; top: 4px; right: 6px; border: 0; background: none; font-size: 16px; cursor: pointer; color: #666; }
.sheet > * { animation: none; }
.sheet.swap > * { animation: in .18s ease-out; }
@keyframes in { from { opacity: .35; transform: translateY(3px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
@media (max-width: 1180px) {
.shell { grid-template-columns: 270px minmax(0, 1fr); }
.insp { position: static; height: auto; grid-column: 2; border-left: 0; border-top: 1px solid var(--line); margin: 0 26px 40px; padding: 18px 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0 28px; background: none; }
.insp h3:first-child, .insp h3 { margin-top: 12px; }
.insp > div { min-width: 0; }
}
@media (max-width: 760px) {
.shell { display: block; margin-right: 0; }
.edge { position: static; writing-mode: horizontal-tb; width: auto; height: 40px; }
.edge:hover { width: auto; }
.index { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--ink); }
.ix-list { max-height: 320px; }
.stage { padding: 16px 12px 30px; }
.sheet { padding: 24px 18px 28px; }
.story, .two, .rel, .intro, .starts { grid-template-columns: 1fr; }
.story .k { padding-top: 12px; border-bottom: 0; padding-bottom: 0; }
.posts a { grid-template-columns: 1fr; gap: 2px; }
.posts .tg { text-align: left; }
.insp { margin: 0 12px 30px; }
.mk { right: 10px; width: auto; left: 10px; }
}
</style>
</head>
<body>
<div class="mk" id="mk"><button onclick="document.getElementById('mk').remove()" aria-label="close">×</button>
<b>tech-r2-c · Project explorer</b>What "enter work" opens: every project in one list on the left, a big readable project sheet on the right that swaps instantly (↑/↓ or click; the URL hash changes so any project can be shared). A slim inspector shows skills and related writing. Grid paper, Plex Mono and accent from nav-d's left gate; list-plus-detail from ntch.design, the inspector from T4, "who wrote what" from T5.</div>
<a class="edge" href="nav-d.html" title="cross over to the personal side">off the clock →</a>
<div class="shell">
<!-- ============ the index ============ -->
<nav class="index" aria-label="Projects">
<div class="ix-head">
<div class="ix-top"><span class="ix-name">Lucas Winther <span>/ work</span></span><a href="nav-d.html" title="back to the two gates">↖ gate</a></div>
<div class="pref" role="group" aria-label="Filter projects by how they were made">
<span class="lbl">projects made</span>
<button aria-pressed="true" data-pref="all">any way</button>
<button aria-pressed="false" data-pref="nofull">not fully by AI</button>
<button aria-pressed="false" data-pref="hand">by hand only</button>
</div>
<div class="pref-note" id="prefNote"></div>
</div>
<div class="ix-list" id="list"></div>
<div class="ix-foot"><span><kbd>↑</kbd> <kbd>↓</kbd> move</span><span><kbd>esc</kbd> overview</span><span><span class="ai full"><i></i></span>AI <span class="ai some"><i></i></span>some <span class="ai none"><i></i></span>hand <span class="ai unk"><i></i></span>?</span></div>
</nav>
<!-- ============ the sheet ============ -->
<main class="stage">
<div class="crumbs"><span><a href="nav-d.html">lucaswinther.info</a> / <a href="#" data-go="">work</a> / <span class="here" id="here">overview</span></span><span><a href="#" data-cv>cv</a> · <a href="https://blog.lucaswinther.info">writing</a> · <a href="#" onclick="return false" title="placeholder: /simple business card">/simple</a></span></div>
<article class="sheet" id="sheet" aria-live="polite"></article>
</main>
<!-- ============ the inspector ============ -->
<aside class="insp" id="insp" aria-label="Context"></aside>
</div>
<script>
/* ---------- shared content (same in tech-r2-c and tech-r2-d) ---------- */
var PH = function (s) { return '<span class="ph" title="placeholder">' + s + '</span>'; };
var GROUPS = {
work: { key: 'A', name: 'Work', sub: 'paid, for someone else' },
own: { key: 'B', name: 'Own', sub: 'built for myself' },
school: { key: 'C', name: 'School', sub: 'university and high school' }
};
var AI = {
full: { label: 'fully AI', long: 'made with AI' },
some: { label: 'some AI', long: 'some AI help' },
none: { label: 'by hand', long: 'no AI, by hand' },
unk: { label: 'AI: ?', long: 'AI level not recorded yet (placeholder)' }
};
var POSTS = [
{ id: 'agent', t: "What I let the agent write, and what I didn't", d: '2026-10-14', tag: 'AI', blurb: 'Going back through the gacha tracker file by file and marking every part by who wrote it.' },
{ id: 'slurm', t: 'Slurm on a cloud that scales to zero', d: '2026-09-02', tag: 'devops', blurb: 'What it takes for a job queue to grow nodes on demand, and shrink again without killing anything.' },
{ id: 'vault', t: 'Moving secrets out of .env and into OpenBao', d: '2026-07-21', tag: 'homelab', blurb: 'The boring migration that made every other change less scary.' },
{ id: 'ansible', t: 'Ansible for three servers is not overkill', d: '2026-05-08', tag: 'devops', blurb: 'Why Config exists, and the week every run restarted every container.' },
{ id: 'thesis', t: 'Rereading my bachelor thesis four years later', d: '2026-03-30', tag: 'notes', blurb: 'What HyperCache got right about trust, and what I would simulate differently now.' }
];
var PROJECTS = [
{ id: 'compute', g: 'work', name: 'Compute job platform', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'arch',
one: 'Slurm jobs from a browser: a React/Node app in front of a cluster that scales itself on Oracle Cloud.',
stack: ['Slurm', 'React', 'Node.js', 'Oracle Cloud', 'OAuth / Entra ID', 'ERDA', 'Docker'],
fig: 'diagram: web app → Slurm → cloud nodes',
problem: 'Researchers need to run heavy jobs without first learning Slurm, SSH and batch scripts. Use is bursty, so a fixed cluster either sits idle or runs out.',
approach: 'A full-stack React/Node web app in front of Slurm. People log in with their AU account (OAuth with Entra ID), pick their data from ERDA, AU\'s own storage, and submit. The web stack and the compute nodes both scale up and down on Oracle Cloud.',
interesting: 'Making "easy to use" and "scales to zero" live together: ' + PH('a queued job has to explain why it is waiting while a node boots, instead of just looking stuck.'),
parts: [['web app (React/Node)', 'unk'], ['Slurm + cloud scaling', 'unk'], ['Entra ID login, ERDA link', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['docs', 'ops', 'config'], posts: ['slurm', 'vault'] },
{ id: 'docs', g: 'work', name: 'Document & information services', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'doc',
one: 'Overleaf and OpenProject, run by us for researchers and wired into the university\'s own systems.',
stack: ['Overleaf', 'OpenProject', 'Docker', 'Linux'],
fig: 'screenshot: Overleaf project list',
problem: 'Research groups write papers together and plan projects, and want to do it on tools the university can run, secure and back up itself.',
approach: 'Self-hosted Overleaf and OpenProject, containerised, with integrations into the rest of AU\'s services. ' + PH('Which integrations exactly is still to be written up.'),
interesting: PH('Updating two large open-source products without breaking a group that is three days from a deadline.'),
parts: [['deployment', 'unk'], ['integrations', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['compute', 'ops'], posts: ['vault'] },
{ id: 'ops', g: 'work', name: 'Secure test & run setup', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'infra',
one: 'Trying newer versions of software while keeping everything isolated, patched and watched.',
stack: ['OpenBao / Vault', 'Grafana', 'Docker', 'Ansible', 'Linux'],
fig: 'screenshot: Grafana overview board',
problem: 'A research IT team has to try new software and new versions early, without that becoming the weak spot in everything else.',
approach: 'Everything runs containerised and isolated, configured with Ansible, secrets in OpenBao / HashiCorp Vault, and watched in Grafana.',
interesting: PH('Deciding what deserves an alert. Most dashboards are for curiosity; only a few things should wake someone up.'),
parts: [['Ansible roles', 'unk'], ['dashboards & alerts', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['config', 'compute'], posts: ['vault', 'ansible'] },
{ id: 'sah', g: 'work', name: 'ScienceAtHome prototype', yr: '2019', where: 'ScienceAtHome · internship', ai: 'none', motif: 'game',
one: 'A month-long internship: an early prototype of a new project\'s base mechanics, to show the people it was for.',
stack: [PH('Unity'), 'prototyping', 'pair programming'],
fig: 'screenshot: prototype, ' + PH('project name'),
problem: 'A new project needed its core idea tested before anyone built it properly, and something concrete to show the people it was made for.',
approach: 'Joined the ideation and planning, built a quick prototype of the base mechanics, then pair-programmed with experienced developers to start the real project.',
interesting: 'Seeing how a prototype changes the conversation: people react to something they can try very differently from a plan.',
parts: [['prototype', 'none']],
links: [], rel: ['rowing', 'vrclass'], posts: [] },
{ id: 'gacha', g: 'own', name: 'Event Tracker for Gacha Games', yr: PH('2025'), ai: 'full', motif: 'web',
one: 'Scrapes event schedules from several games into one timeline, so limited events stop slipping by.',
stack: ['JavaScript', 'scraping', 'web app'],
fig: 'screenshot: the event timeline',
problem: 'Each game announces its limited events somewhere different, in a different time zone. Keeping track meant checking five places.',
approach: 'Scrapers per game feed one normalised list of events, shown as a single timeline in a small web app.',
interesting: 'A test of building with AI: ' + PH('I wrote the spec and the fixtures, the agent wrote the scrapers, and every change was read before it went in.'),
parts: [['spec & fixtures', 'none'], ['scrapers', 'full'], ['timeline UI', 'full']],
links: [['live demo', '#'], ['GitHub', 'https://github.com/StereotypicalCat']], rel: ['config', 'expenses'], posts: ['agent'] },
{ id: 'config', g: 'own', name: 'Config', yr: PH('2025'), ai: 'full', motif: 'infra',
one: 'Ansible and docker-compose: every service on my servers, deployed and updated from one repo.',
stack: ['Ansible', 'IaC', 'docker-compose', 'Docker', 'Linux'],
fig: 'screenshot: repo tree and a playbook run',
problem: 'Services were set up by hand over SSH, one machine at a time, and the only documentation was my memory.',
approach: 'An inventory of hosts, one role per kind of thing, and compose stacks templated from one pattern. One command brings a machine to the state git describes.',
interesting: PH('Idempotency: for a week every run restarted every container, because a template rendered a new timestamp each time.'),
parts: [['inventory & layout', 'none'], ['playbooks & roles', 'full'], ['compose files', 'full']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['ops', 'gacha'], posts: ['ansible'] },
{ id: 'chess', g: 'own', name: 'Chessswap', yr: PH('2020'), ai: 'none', motif: 'board',
one: 'Chess, except the board swaps every few turns.',
stack: ['React', 'JavaScript'],
fig: 'screenshot: board mid-swap',
problem: 'Chess openings are memorised; a twist that breaks memorisation makes it about thinking again.',
approach: 'A React board with normal chess rules, plus a swap every few turns that changes whose pieces you are playing.',
interesting: 'Rules that sound simple get strange at the edges: ' + PH('what happens to castling rights and check right at a swap?'),
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['breakout', 'dashboard'], posts: [] },
{ id: 'expenses', g: 'own', name: 'Monthly Expenses Tracker', yr: PH('2021'), ai: 'none', motif: 'web',
one: 'A small web app for where the money goes each month, in DKK.',
stack: ['JavaScript', 'web app'],
fig: 'screenshot: month view',
problem: 'Bank apps show transactions, not the shape of a month.',
approach: 'A plain JavaScript web app: enter expenses, see each month by category, in kroner.',
interesting: PH('Storing amounts in øre to dodge floating-point surprises.'),
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['gacha', 'dashboard'], posts: [] },
{ id: 'lolbot', g: 'own', name: 'LoL Winrate Discord Bot', yr: PH('2020'), ai: 'none', motif: 'bot',
one: 'Ask in Discord, get a League of Legends champion\'s current winrate.',
stack: ['Node.js', 'Discord API'],
fig: 'screenshot: bot reply in a channel',
problem: 'Mid-draft, nobody wants to alt-tab to a stats site.',
approach: 'A Node.js bot that answers a command with the champion\'s current winrate.',
interesting: PH('Rate limits and caching: a bot that is popular for ten minutes a night.'),
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['gacha'], posts: [] },
{ id: 'portfolio', g: 'own', name: 'Portfolio (2021)', yr: '2021', ai: 'none', motif: 'web',
one: 'The old React + Material UI site, kept frozen as the by-hand archive.',
stack: ['React', 'Material UI'],
fig: 'screenshot: the 2021 site',
problem: 'A place to put projects, with a search.',
approach: 'Create React App and Material UI, projects read from one info.json.',
interesting: 'It is now the archive: everything on it was written by hand, before AI tools.',
parts: [['everything', 'none']],
links: [['old site', '#']], rel: ['borlaug'], posts: [] },
{ id: 'borlaug', g: 'own', name: 'Tribute to Norman Borlaug', yr: PH('2016'), ai: 'none', motif: 'doc',
one: 'One of my first websites, a freeCodeCamp tribute page.',
stack: ['HTML', 'CSS'],
fig: 'screenshot: tribute page',
problem: 'Learning to make a web page at all.',
approach: 'One HTML page and a stylesheet, following the freeCodeCamp brief.',
interesting: 'Kept for honesty: this is where it started.',
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['portfolio'], posts: [] },
{ id: 'hypercache', g: 'school', name: 'HyperCache', yr: PH('2022'), where: 'Bachelor thesis · ' + PH('Aarhus University'), ai: 'none', motif: 'net',
one: 'Reputation systems for peer-to-peer archival: who can you trust to keep your data?',
stack: ['Node.js', 'P2P', 'simulation', 'reputation systems'],
fig: 'diagram: peers and trust',
problem: 'In a peer-to-peer archive, data only survives if peers keep storing it. Some peers will freeload or quietly drop data.',
approach: 'A reputation system where peers rate each other on whether data is really kept, built and simulated in Node.js.',
interesting: PH('How fast trust recovers after a bad actor leaves, and how cheaply a newcomer can fake a good history.'),
parts: [['thesis & code', 'none']],
links: [['thesis (PDF)', '#']], rel: ['vrclass', 'compute'], posts: ['thesis'] },
{ id: 'dashboard', g: 'school', name: 'Student Dashboard', yr: PH('2021'), ai: 'none', motif: 'web',
one: 'A dashboard for students, shaped by user studies rather than guesses.',
stack: ['React', 'user studies'],
fig: 'screenshot: dashboard, second iteration',
problem: 'Students juggle several university systems to answer simple questions like "what is due this week?".',
approach: 'User studies first, then a React dashboard, then studies again to see what actually helped.',
interesting: PH('The first interviews threw out half of the planned features.'),
parts: [['everything', 'none']],
links: [['report (PDF)', '#'], ['demo', '#']], rel: ['vrclass', 'chess'], posts: [] },
{ id: 'rowing', g: 'school', name: 'Mixed-Reality Rowing Game', yr: PH('2020'), ai: 'none', motif: 'game',
one: 'Row a virtual boat with two real oars, wired up through an Arduino.',
stack: ['Unity', 'C#', 'Arduino'],
fig: 'photo: the two oars on their rig',
problem: 'Rowing machines are boring and VR controllers don\'t feel like oars.',
approach: 'Two real oars with sensors read by an Arduino, sending each stroke into a Unity scene over serial.',
interesting: PH('Turning noisy sensor readings into a stroke that feels right, with almost no lag.'),
parts: [['everything', 'none']],
links: [['video', '#']], rel: ['censor', 'vrclass', 'sah'], posts: [] },
{ id: 'vrclass', g: 'school', name: 'VR-Classroom', yr: PH('2020'), ai: 'none', motif: 'vr',
one: 'A Node.js prototype of a shared virtual classroom.',
stack: ['Node.js', 'VR', 'prototyping'],
fig: 'still from the video',
problem: 'Remote teaching loses the feeling of being in the same room.',
approach: 'A Node.js prototype that keeps participants in a shared virtual room in sync.',
interesting: PH('Small things like where the teacher stands matter more than resolution.'),
parts: [['everything', 'none']],
links: [['report (PDF)', '#'], ['video', '#']], rel: ['rowing', 'dashboard', 'censor'], posts: [] },
{ id: 'censor', g: 'school', name: 'Censor Simulator', yr: PH('2018'), where: 'High-school exam project', ai: 'none', motif: 'vr',
one: 'A VR exam project in Unity: you are the one deciding what gets through.',
stack: ['Unity', 'C#', 'VR'],
fig: 'screenshot: the censor\'s desk',
problem: 'An exam project that had to be both a game and an argument.',
approach: 'A Unity VR scene where you sort and censor incoming material.',
interesting: PH('First time building for VR: comfort and scale before anything else.'),
parts: [['everything', 'none']],
links: [], rel: ['rowing', 'breakout'], posts: [] },
{ id: 'breakout', g: 'school', name: 'BreakoutPython', yr: PH('2017'), where: 'High-school exam project', ai: 'none', motif: 'board',
one: 'Breakout, written in Python for a high-school exam.',
stack: ['Python'],
fig: 'screenshot: level one',
problem: 'Learn to program a game loop from scratch.',
approach: 'Paddle, ball, bricks and collision, in Python.',
interesting: 'Collision is the whole game, and the first place bugs feel physical.',
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['chess', 'censor'], posts: [] }
];
function P(id) { for (var i = 0; i < PROJECTS.length; i++) if (PROJECTS[i].id === id) return PROJECTS[i]; }
function POST(id) { for (var i = 0; i < POSTS.length; i++) if (POSTS[i].id === id) return POSTS[i]; }
function strip(s) { return String(s).replace(/<[^>]+>/g, ''); }
function aiMark(a, withLabel) { return '<span class="ai ' + a + '" title="' + AI[a].long + '"><i></i>' + (withLabel === false ? '' : AI[a].label) + '</span>'; }
var PREF = 'all';
function allowed(p) { if (PREF === 'nofull') return p.ai !== 'full'; if (PREF === 'hand') return p.ai === 'none'; return true; }
/* ---------- line-drawn figure placeholders, one motif per kind of project ---------- */
function figSVG(m) {
var s = '<svg viewBox="0 0 720 280" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><g fill="none" stroke="#1c2024" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">';
var A = '#d9532b';
function box(x, y, w, h, t, acc) { return '<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" fill="#f5f6f3"' + (acc ? ' stroke="' + A + '"' : '') + '/><text x="' + (x + w / 2) + '" y="' + (y + h / 2 + 4) + '" text-anchor="middle" fill="#1c2024" stroke="none" font-family="IBM Plex Mono" font-size="12">' + t + '</text>'; }
if (m === 'arch') {
s += '<rect x="372" y="34" width="320" height="214" stroke-dasharray="4 4" stroke="#6b726f"/><text x="384" y="52" fill="#6b726f" stroke="none" font-family="IBM Plex Mono" font-size="11">oracle cloud · scales up/down</text>';
s += box(28, 120, 92, 40, 'browser') + box(160, 120, 100, 40, 'web app', 1) + box(400, 120, 104, 40, 'slurm ctl') + box(560, 70, 100, 34, 'node') + box(560, 123, 100, 34, 'node') + box(560, 176, 100, 34, 'node ···');
s += box(160, 210, 100, 34, 'entra id') + box(160, 36, 100, 34, 'erda');
s += '<path d="M120 140H160M260 140H400" stroke="' + A + '"/><path d="M504 140H530M530 87V193M530 87H560M530 140H560M530 193H560"/><path d="M210 160V210M210 70V120" stroke-dasharray="3 3"/>';
} else if (m === 'infra') {
for (var i = 0; i < 3; i++) { var x = 90 + i * 200; s += '<rect x="' + x + '" y="70" width="140" height="150" fill="#f5f6f3"/>'; for (var j = 0; j < 4; j++) s += '<path d="M' + (x + 12) + ' ' + (92 + j * 32) + 'H' + (x + 100) + '"/><circle cx="' + (x + 120) + '" cy="' + (92 + j * 32) + '" r="3.5"' + (j === 1 && i === 1 ? ' fill="' + A + '" stroke="' + A + '"' : '') + '/>'; }
s += '<path d="M230 145H290M430 145H490" stroke-dasharray="3 3"/><text x="360" y="250" text-anchor="middle" fill="#6b726f" stroke="none" font-family="IBM Plex Mono" font-size="11">host-01 · host-02 · host-03</text>';
} else if (m === 'web') {
s += '<rect x="120" y="30" width="480" height="222" fill="#f5f6f3"/><path d="M120 54H600"/><circle cx="136" cy="42" r="3"/><circle cx="148" cy="42" r="3"/><circle cx="160" cy="42" r="3"/>';
s += '<rect x="144" y="74" width="120" height="156"/><path d="M290 84H570M290 102H520" /><rect x="290" y="124" width="280" height="40" stroke="' + A + '"/><rect x="290" y="176" width="130" height="54"/><rect x="440" y="176" width="130" height="54"/>';
} else if (m === 'doc') {
s += '<rect x="250" y="20" width="220" height="244" fill="#f5f6f3"/><path d="M276 50H420M276 70H444M276 84H444M276 98H400"/><rect x="276" y="116" width="168" height="70" stroke="' + A + '"/><path d="M276 204H444M276 218H444M276 232H380"/><rect x="216" y="36" width="20" height="200" stroke-dasharray="3 3" stroke="#6b726f"/>';
} else if (m === 'net') {
var n = [[120, 80], [200, 190], [290, 110], [370, 220], [400, 60], [480, 150], [560, 90], [600, 210], [250, 240], [160, 140]];
var e = [[0, 2], [0, 9], [9, 1], [1, 2], [2, 4], [2, 5], [4, 6], [5, 6], [5, 7], [3, 5], [3, 8], [1, 8], [6, 7]];
e.forEach(function (k) { s += '<path d="M' + n[k[0]][0] + ' ' + n[k[0]][1] + 'L' + n[k[1]][0] + ' ' + n[k[1]][1] + '"' + (k[0] === 2 && k[1] === 5 ? ' stroke="' + A + '"' : '') + '/>'; });
n.forEach(function (p, i) { s += '<circle cx="' + p[0] + '" cy="' + p[1] + '" r="' + (i === 7 ? 10 : 8) + '" fill="#f5f6f3"' + (i === 7 ? ' stroke="' + A + '" stroke-dasharray="3 2"' : '') + '/>'; });
} else if (m === 'game') {
s += '<path d="M40 150H680" stroke="#6b726f"/><path d="M60 180q20 -8 40 0t40 0t40 0M420 200q20 -8 40 0t40 0t40 0M200 230q20 -8 40 0t40 0" stroke="#6b726f"/>';
s += '<path d="M300 170L420 170L400 186L320 186Z" fill="#f5f6f3"/><path d="M330 172L250 222M390 172L470 222" stroke="' + A + '" stroke-width="2"/><rect x="242" y="216" width="18" height="10" transform="rotate(-30 251 221)"/><rect x="462" y="216" width="18" height="10" transform="rotate(30 471 221)"/>';
} else if (m === 'board') {
for (var r = 0; r < 6; r++) for (var c = 0; c < 6; c++) if ((r + c) % 2) s += '<rect x="' + (270 + c * 30) + '" y="' + (50 + r * 30) + '" width="30" height="30" fill="#cfd3cd" stroke="none"/>';
s += '<rect x="270" y="50" width="180" height="180"/><path d="M470 110q40 30 0 60" stroke="' + A + '"/><path d="M466 166l6 6l2 -8" stroke="' + A + '"/>';
} else if (m === 'bot') {
s += '<rect x="170" y="40" width="380" height="200" fill="#f5f6f3"/><rect x="194" y="64" width="210" height="30"/><rect x="290" y="110" width="236" height="56" stroke="' + A + '"/><path d="M306 128H480M306 146H430"/><rect x="194" y="182" width="160" height="30"/>';
} else if (m === 'vr') {
s += '<path d="M120 250L300 150H420L600 250M200 250L320 150M520 250L400 150M360 150V250M160 220H560M220 185H500"/>';
s += '<rect x="290" y="40" width="140" height="70" rx="14" fill="#f5f6f3"/><circle cx="330" cy="75" r="16" stroke="' + A + '"/><circle cx="390" cy="75" r="16" stroke="' + A + '"/>';
}
return s + '</g></svg>';
}
(function () {
var list = document.getElementById('list'), sheet = document.getElementById('sheet'), insp = document.getElementById('insp'), here = document.getElementById('here');
var current = ''; // '' = overview
var skillOn = null;
// numbering: A1, A2, B1 ...
var counts = {}; PROJECTS.forEach(function (p) { counts[p.g] = (counts[p.g] || 0) + 1; p.no = GROUPS[p.g].key + counts[p.g]; });
// ---------- list ----------
function buildList() {
var h = '<button class="it overview" data-id=""><span class="no">00</span><span class="nm">overview &amp; cv</span><span></span><span class="sub">start here · latest writing</span></button>';
Object.keys(GROUPS).forEach(function (g) {
h += '<div class="ix-group"><div class="ix-gh"><b>' + GROUPS[g].key + '</b>' + GROUPS[g].name + ' · ' + GROUPS[g].sub + '</div>';
PROJECTS.filter(function (p) { return p.g === g; }).forEach(function (p) {
h += '<button class="it" data-id="' + p.id + '"><span class="no">' + p.no + '</span><span class="nm">' + p.name + '</span>' + aiMark(p.ai, false) +
'<span class="sub">' + strip(p.yr) + ' · ' + p.stack.slice(0, 3).map(strip).join(' · ') + '</span></button>';
});
h += '</div>';
});
list.innerHTML = h;
}
function items() { return Array.prototype.slice.call(list.querySelectorAll('.it:not(.off)')); }
function markList() {
list.querySelectorAll('.it').forEach(function (b) {
b.setAttribute('aria-current', b.dataset.id === current ? 'true' : 'false');
var p = P(b.dataset.id);
b.classList.toggle('off', !!p && !allowed(p) && p.id !== current);
b.classList.toggle('hl', !!p && !!skillOn && p.stack.map(strip).indexOf(skillOn) > -1);
});
}
// ---------- skills ----------
function skillCounts(ps) {
var c = {};
ps.forEach(function (p) { p.stack.forEach(function (s) { s = strip(s); c[s] = (c[s] || 0) + 1; }); });
return c;
}
var ALL = skillCounts(PROJECTS);
function skillList(names) {
return '<ul class="sk">' + names.map(function (s) {
return '<li><button data-skill="' + s + '" class="' + (s === skillOn ? 'on' : '') + '"><span>' + s + '</span><span class="bar"></span><span>' + ALL[s] + '</span></button></li>';
}).join('') + '</ul>';
}
// ---------- views ----------
function overview() {
var feat = ['compute', 'rowing', 'hypercache'].map(P);
return '' +
'<div class="kick"><span class="lbl"><b>●</b> 01 / work</span><span class="lbl">17 projects · 3 groups</span></div>' +
'<div class="intro"><div><h1>Work</h1>' +
'<p>I build and run research software at Aarhus University, and make things on my own time, lately often with AI in the loop. Every project here says what it is, why it exists, how it was built, and how much of it an AI wrote.</p>' +
'<p style="font-size:15px;color:var(--mute)">Pick anything on the left. <kbd>↑</kbd> <kbd>↓</kbd> walk through the list, and the address bar always points at what you are reading.</p></div>' +
'<div id="cv"><div class="lbl" style="margin-bottom:6px">cv in ten seconds</div><div class="cv">' +
'<div class="row"><span class="k">now</span><span>IT-medarbejder, IT Research Tech, Aarhus University <small>2025–now</small><br><small>devops · containers · linux · observability · cloud · full-stack</small></span></div>' +
'<div class="row"><span class="k">before</span><span>Intern, ScienceAtHome <small>2019</small><br>Student helper, Local Configuration Management, TERMA Group <small>2016–2018</small></span></div>' +
'<div class="row"><span class="k">education</span><span>BSc Computer Science, ' + PH('Aarhus University') + ' <small>' + PH('2022') + '</small><br><small>thesis: HyperCache, reputation in P2P archival</small></span></div>' +
'<div class="row"><span class="k">contact</span><span><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br><small><a href="https://github.com/StereotypicalCat">github/StereotypicalCat</a> · <a href="https://www.linkedin.com/in/lucas-winther/">linkedin/lucas-winther</a></small></span></div>' +
'<div class="bar"><a class="pdf" href="#" onclick="return false" title="placeholder: CV PDF">↓ CV (PDF)</a><a href="#" onclick="return false" title="placeholder">old site, 2021, by hand</a></div>' +
'</div></div></div>' +
'<div class="sec"><div class="lbl"><b>02</b> start with one of these <span class="r">or anything in the list</span></div><div class="starts">' +
feat.map(function (p) { return '<button class="start" data-id="' + p.id + '"><div class="mini">' + figSVG(p.motif) + '</div><span class="lbl">' + p.no + ' · ' + GROUPS[p.g].name + '</span><span class="t">' + p.name + '</span><span class="s">' + strip(p.one) + '</span></button>'; }).join('') +
'</div></div>' +
'<div class="sec"><div class="lbl"><b>03</b> latest writing <span class="r"><a href="https://blog.lucaswinther.info">blog.lucaswinther.info →</a></span></div><ul class="posts">' +
POSTS.slice(0, 4).map(function (w) { return '<li><a href="https://blog.lucaswinther.info"><span class="d ph" title="placeholder">' + w.d + '</span><span><span class="t ph" title="placeholder title">' + w.t + '</span><span class="b">' + w.blurb + '</span></span><span class="tg">' + w.tag + '</span></a></li>'; }).join('') +
'</ul></div>';
}
function meter(level) {
var n = { full: 10, some: 5, none: 0, unk: 0 }[level], h = '';
for (var i = 0; i < 10; i++) h += '<i class="' + (level === 'unk' ? '' : i < n ? 'f' : 'h') + '"></i>';
return '<span class="meter' + (level === 'unk' ? ' unk' : '') + '">' + h + '</span>';
}
function project(p) {
var vis = items().filter(function (b) { return b.dataset.id; });
var idx = vis.findIndex(function (b) { return b.dataset.id === p.id; });
var prev = idx > 0 ? P(vis[idx - 1].dataset.id) : null, next = idx > -1 && idx < vis.length - 1 ? P(vis[idx + 1].dataset.id) : null;
var unk = p.parts.every(function (x) { return x[1] === 'unk'; });
return '' +
'<div class="kick"><span class="lbl"><b>' + p.no + '</b> · ' + GROUPS[p.g].name + '</span><span class="lbl">' + (p.where || 'own project') + '</span><span class="lbl">' + p.yr + '</span>' + aiMark(p.ai) + '</div>' +
'<h1>' + p.name + '</h1><p class="one">' + p.one + '</p>' +
'<figure class="fig"><div class="box"><span class="ticks"></span>' + figSVG(p.motif) + '</div><figcaption><b>fig. ' + p.no + '</b> · ' + p.fig + ' <span class="ph" title="placeholder image">(placeholder)</span></figcaption></figure>' +
'<div class="sec"><div class="lbl"><b>01</b> the story <span class="r ph" title="drafted from the one-line description; Lucas rewrites">draft text</span></div><div class="story">' +
'<div class="k"><b>i.</b>problem</div><div class="v">' + p.problem + '</div>' +
'<div class="k"><b>ii.</b>approach</div><div class="v">' + p.approach + '</div>' +
'<div class="k"><b>iii.</b>what was interesting</div><div class="v">' + p.interesting + '</div>' +
'</div></div>' +
'<div class="sec two"><div><div class="lbl" style="border-bottom:1px solid var(--ink);padding-bottom:6px"><b>02</b> stack</div><div class="stack">' +
p.stack.map(function (s) { return '<button class="tag' + (strip(s) === skillOn ? ' on' : '') + '" data-skill="' + strip(s) + '" title="mark every project that uses this">' + s + '</button>'; }).join('') +
'</div></div><div><div class="lbl" style="border-bottom:1px solid var(--ink);padding-bottom:6px"><b>03</b> links</div><ul class="links">' +
(p.links.length ? p.links.map(function (l) { return l[1] ? '<li><a href="' + l[1] + '"' + (l[1] === '#' ? ' onclick="return false" title="placeholder link"' : '') + '>→ ' + l[0] + '</a></li>' : '<li class="dead">' + l[0] + '</li>'; }).join('') : '<li class="dead">no public links</li>') +
'</ul></div></div>' +
'<div class="sec"><div class="lbl"><b>04</b> who wrote what <span class="r">' + AI[p.ai].long + '</span></div><table class="who">' +
p.parts.map(function (x) { return '<tr><td>' + x[0] + '</td><td>' + meter(x[1]) + '<span class="ai ' + x[1] + '"><i></i>' + AI[x[1]].label + '</span></td></tr>'; }).join('') +
'</table><div class="who-note">' + (unk ? 'AI share for AU work is not recorded yet; the meter stays dotted until it is (placeholder).' : 'filled = written by AI · dark = written by hand') + '</div></div>' +
'<div class="sec"><div class="lbl"><b>05</b> related</div><div class="rel"><ul>' +
p.rel.map(P).map(function (r) { return '<li><button data-id="' + r.id + '"><span class="no">' + r.no + '</span><span class="t">' + r.name + '</span>' + aiMark(r.ai, false) + '</button></li>'; }).join('') +
'</ul><ul>' +
(p.posts.length ? p.posts.map(POST).map(function (w) { return '<li><a class="post" href="https://blog.lucaswinther.info"><span class="d">' + w.d + '</span><span class="t ph" title="placeholder title">' + w.t + '</span></a></li>'; }).join('') : '<li style="padding:9px 0;color:var(--mute);font-size:14px">no posts about this one yet</li>') +
'</ul></div></div>' +
'<div class="pn">' + (prev ? '<button data-id="' + prev.id + '"><small>↑ previous</small>' + prev.no + ' ' + prev.name + '</button>' : '<button data-id=""><small>↑ back to</small>overview</button>') +
(next ? '<button data-id="' + next.id + '"><small>next ↓</small>' + next.no + ' ' + next.name + '</button>' : '<span></span>') + '</div>';
}
function inspector(p) {
var url = 'lucaswinther.info/work' + (p ? '#' + p.id : '');
if (!p) {
var top = Object.keys(ALL).filter(function (s) { return ALL[s] > 1; }).sort(function (a, b) { return ALL[b] - ALL[a]; });
return '<div><h3>inspector</h3><div class="head"><span>everything</span><b>17</b></div><div class="hint">Context for whatever is open. Click a skill to mark the projects that use it.</div></div>' +
'<div><h3>skills across projects</h3>' + skillList(top) + '<div class="hint" id="skHint">' + skHint() + '</div></div>' +
'<div><h3>by how it was made</h3><ul class="w">' + ['full', 'none', 'unk'].map(function (a) { var n = PROJECTS.filter(function (x) { return x.ai === a; }).length; return '<li>' + aiMark(a) + ' <span style="float:right" class="mono">' + n + '</span></li>'; }).join('') + '</ul><div class="hint">No project is "some AI" yet; that mark is ready for the next one.</div></div>';
}
var others = p.stack.map(strip);
return '<div><h3>inspector · ' + p.no + '</h3><div class="head"><span>' + GROUPS[p.g].name.toLowerCase() + ' · ' + strip(p.yr) + '</span>' + aiMark(p.ai) + '</div>' +
'<div class="hint">' + (p.where || 'Own project, own time.') + '</div></div>' +
'<div><h3>skills used here</h3>' + skillList(others) + '<div class="hint" id="skHint">' + skHint() + '</div></div>' +
'<div><h3>related writing</h3>' + (p.posts.length ? '<ul class="w">' + p.posts.map(POST).map(function (w) { return '<li><a href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">' + w.t + '</span></a><small>' + w.d + ' · ' + w.tag + '</small></li>'; }).join('') + '</ul>' : '<div class="hint">Nothing written about this one yet.</div>') + '</div>' +
'<div><h3>share this project</h3><div class="share"><span id="url">' + url + '</span><button id="copy">copy</button></div></div>';
}
function skHint() {
if (!skillOn) return 'number = projects that use it';
var n = PROJECTS.filter(function (p) { return p.stack.map(strip).indexOf(skillOn) > -1; }).length;
return '<span style="color:var(--acc)">' + skillOn + '</span>: ' + n + ' project' + (n > 1 ? 's' : '') + ' marked in the list · click again to clear';
}
// ---------- routing ----------
function render(id, push) {
var p = id ? P(id) : null;
if (id && !p) { id = ''; }
current = id;
if (push === 'push') history.pushState(null, '', id ? '#' + id : location.pathname);
else if (push === 'replace') history.replaceState(null, '', id ? '#' + id : location.pathname);
markList();
sheet.innerHTML = p ? project(p) : overview();
sheet.classList.remove('swap'); void sheet.offsetWidth; sheet.classList.add('swap');
insp.innerHTML = inspector(p);
here.textContent = p ? p.no + ' ' + p.name.toLowerCase() : 'overview';
document.title = (p ? p.name + ' · ' : '') + 'Work · Lucas Winther';
var cur = list.querySelector('[aria-current="true"]');
if (cur) cur.scrollIntoView({ block: 'nearest' });
if (window.scrollY > 120) window.scrollTo(0, 0);
}
window.addEventListener('popstate', function () { render(location.hash.slice(1)); });
document.addEventListener('click', function (e) {
var t = e.target.closest('[data-id]');
if (t) { e.preventDefault(); render(t.dataset.id, 'push'); return; }
var go = e.target.closest('[data-go]');
if (go) { e.preventDefault(); render('', 'push'); return; }
if (e.target.closest('[data-cv]')) {
e.preventDefault(); if (current) render('', 'push');
var cv = document.getElementById('cv'); cv.scrollIntoView({ block: 'center' }); cv.style.outline = '2px solid #d9532b'; cv.style.outlineOffset = '6px'; setTimeout(function () { cv.style.outline = ''; }, 900); return;
}
var sk = e.target.closest('[data-skill]');
if (sk) {
skillOn = skillOn === sk.dataset.skill ? null : sk.dataset.skill;
markList();
document.querySelectorAll('[data-skill]').forEach(function (b) { b.classList.toggle('on', b.dataset.skill === skillOn); });
var hn = document.getElementById('skHint'); if (hn) hn.innerHTML = skHint();
return;
}
if (e.target.id === 'copy') {
var u = 'https://' + document.getElementById('url').textContent;
if (navigator.clipboard) navigator.clipboard.writeText(u).catch(function () {});
e.target.textContent = 'copied'; setTimeout(function () { e.target.textContent = 'copy'; }, 1200);
}
});
document.addEventListener('keydown', function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.target.closest && e.target.closest('input, textarea')) return;
var k = e.key;
if (k === 'ArrowDown' || k === 'ArrowUp' || k === 'j' || k === 'k') {
var its = items(), i = its.findIndex(function (b) { return b.dataset.id === current; });
var n = (k === 'ArrowDown' || k === 'j') ? Math.min(its.length - 1, i + 1) : Math.max(0, i - 1);
e.preventDefault();
if (its[n] && its[n].dataset.id !== current) { render(its[n].dataset.id, 'replace'); its[n].focus({ preventScroll: true }); }
} else if (k === 'Escape') {
if (skillOn) { skillOn = null; render(current); }
else if (current) render('', 'push');
}
});
// AI preference
var note = document.getElementById('prefNote');
document.querySelectorAll('[data-pref]').forEach(function (b) {
b.addEventListener('click', function () {
PREF = b.dataset.pref;
document.querySelectorAll('[data-pref]').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); });
var hidden = PROJECTS.filter(function (p) { return !allowed(p); }).length;
note.textContent = PREF === 'all' ? '' : PREF === 'nofull' ? 'hiding ' + hidden + ' made fully with AI' : 'hiding ' + hidden + ': AI-made, or AI level unknown';
markList();
});
});
buildList();
render(location.hash.slice(1));
})();
</script>
<!-- Placeholders: university and graduation year, project years (except AU, ScienceAtHome, 2021 portfolio), ScienceAtHome project name and engine, story details marked with dotted underline, all figure images, blog post titles/dates/excerpts, CV PDF, AI level for AU work. -->
</body>
</html>
+785
View File
@@ -0,0 +1,785 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Work · Lucas Winther</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&family=Fraunces:ital,opsz,wght@1,9..144,300&display=swap" rel="stylesheet">
<style>
:root {
--bg: #e8eae6; --ink: #1c2024; --ink2: #343a3e; --mute: #6b726f; --line: #cfd3cd; --acc: #d9532b; --paper: #f5f6f3;
--a-bg: #2a1a1f; --a-ink: #f1e6d6;
--mono: "IBM Plex Mono", ui-monospace, monospace; --sans: "IBM Plex Sans", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 64px; }
html, body { margin: 0; }
body { font-family: var(--sans); color: var(--ink); background: var(--bg);
background-image: linear-gradient(var(--line) 1px, transparent 1px), linear-gradient(90deg, var(--line) 1px, transparent 1px);
background-size: 24px 24px; background-position: -1px -1px; }
a { color: inherit; text-decoration-color: var(--acc); text-underline-offset: 3px; }
a:hover { color: var(--acc); }
button { font: inherit; color: inherit; }
.lbl { font-family: var(--mono); font-size: 10.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--mute); }
.lbl b { color: var(--acc); font-weight: 500; }
.ph { text-decoration: underline dotted var(--mute); text-underline-offset: 3px; cursor: help; }
:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; }
kbd { font-family: var(--mono); font-size: 10px; border: 1px solid currentColor; border-radius: 3px; padding: 0 4px; }
.ai { font-family: var(--mono); font-size: 11px; color: var(--mute); white-space: nowrap; }
.ai i { display: inline-block; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; margin-right: 5px; vertical-align: 0; }
.ai.full { color: var(--acc); } .ai.full i { background: currentColor; }
.ai.some i { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
.ai.unk i { border-style: dotted; }
/* ---------- frame ---------- */
.edge { position: fixed; top: 0; right: 0; bottom: 0; width: 44px; background: var(--a-bg); color: var(--a-ink); display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; font-family: "Fraunces", serif; font-style: italic; font-weight: 300; font-size: 16px; text-decoration: none; z-index: 30; transition: width .2s; }
.edge:hover { width: 52px; color: #e8b04a; }
.bar { position: sticky; top: 0; z-index: 20; margin-right: 44px; background: rgba(232,234,230,.94); border-bottom: 1px solid var(--ink); backdrop-filter: none; }
.bar-in { max-width: 1180px; margin: 0 auto; padding: 0 40px; height: 48px; display: flex; align-items: center; gap: 28px; font-family: var(--mono); font-size: 12px; }
.bar .me { font-weight: 500; font-size: 13.5px; text-decoration: none; }
.bar .me span { color: var(--mute); font-weight: 400; }
.bar nav { display: flex; gap: 18px; }
.bar nav a { text-decoration: none; color: var(--ink2); padding: 14px 0; border-bottom: 2px solid transparent; }
.bar nav a:hover, .bar nav a.on { color: var(--ink); border-bottom-color: var(--acc); }
.bar nav a span { color: var(--mute); }
.bar .sp { flex: 1; }
.pref { display: flex; gap: 5px; align-items: center; }
.pref .why { color: var(--mute); font-size: 11px; margin-right: 3px; }
.pref button { border: 1px solid var(--line); background: var(--paper); font-family: var(--mono); font-size: 11px; padding: 2px 8px; border-radius: 99px; cursor: pointer; color: var(--ink2); }
.pref button:hover { border-color: var(--ink); }
.pref button[aria-pressed="true"] { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.bar .gate { color: var(--mute); text-decoration: none; }
.bar .gate:hover { color: var(--acc); }
main { margin-right: 44px; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 40px; }
/* ---------- head ---------- */
.head { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 56px; padding: 44px 0 36px; }
h1 { font-family: var(--mono); font-weight: 500; font-size: clamp(40px, 5vw, 64px); letter-spacing: -.03em; line-height: 1; margin: 14px 0 0; }
.lede { font-size: 18px; line-height: 1.55; color: var(--ink2); margin: 16px 0 0; max-width: 31em; }
.toc { margin-top: 26px; border-top: 1px solid var(--ink); }
.toc a { display: grid; grid-template-columns: 34px 1fr auto; gap: 10px; align-items: baseline; padding: 7px 0; border-bottom: 1px solid var(--line); text-decoration: none; font-size: 15px; }
.toc a:hover { background: rgba(255,255,255,.5); }
.toc a:hover .t { color: var(--acc); }
.toc .no { font-family: var(--mono); font-size: 11px; color: var(--acc); }
.toc .sh { font-family: var(--mono); font-size: 11px; color: var(--mute); }
.toc a.all .no { color: var(--mute); }
.cv { background: var(--paper); border: 1px solid var(--ink); padding: 14px 18px 4px; align-self: start; position: relative; margin-top: 8px; }
.cv::before { content: "cv in ten seconds"; position: absolute; top: -9px; left: 12px; background: var(--bg); padding: 0 6px; font-family: var(--mono); font-size: 10.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--mute); }
.cv .row { display: grid; grid-template-columns: 78px 1fr; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14px; line-height: 1.45; }
.cv .k { font-family: var(--mono); font-size: 10.5px; color: var(--mute); text-transform: uppercase; letter-spacing: .06em; padding-top: 2px; }
.cv small { color: var(--mute); font-family: var(--mono); font-size: 11px; }
.cv .links { display: flex; gap: 14px; flex-wrap: wrap; padding: 10px 0; font-family: var(--mono); font-size: 12px; align-items: center; }
.pdf { display: inline-flex; gap: 6px; align-items: center; border: 1px solid var(--ink); padding: 3px 9px; text-decoration: none; background: var(--bg); }
.pdf:hover { background: var(--ink); color: var(--paper); }
.part { display: flex; align-items: baseline; gap: 14px; border-top: 2px solid var(--ink); padding: 10px 0 0; margin-top: 30px; }
.part h2 { font-family: var(--mono); font-weight: 500; font-size: 22px; letter-spacing: -.02em; margin: 0; }
.part .lbl { margin-left: auto; text-transform: none; letter-spacing: 0; font-size: 11.5px; }
/* ---------- features ---------- */
.feat { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 0 40px; padding: 40px 0 46px; border-bottom: 1px solid var(--ink); }
.feat.gone { display: none; }
.meta { font-size: 13px; }
.meta .big { font-family: var(--mono); font-size: 46px; font-weight: 500; line-height: 1; letter-spacing: -.04em; color: var(--acc); }
.meta .shape { font-family: var(--mono); font-size: 11px; color: var(--mute); margin-top: 6px; }
.meta dl { margin: 18px 0 0; border-top: 1px solid var(--ink); }
.meta dt { font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: .09em; color: var(--mute); margin-top: 10px; }
.meta dd { margin: 2px 0 0; line-height: 1.45; }
.meta .st { font-family: var(--mono); font-size: 11.5px; }
.meta .lk { font-family: var(--mono); font-size: 12px; display: block; }
.meta .lk.dead { color: var(--mute); font-style: italic; }
.feat h3 { font-family: var(--mono); font-weight: 500; font-size: 32px; letter-spacing: -.03em; margin: 0; line-height: 1.1; }
.feat .one { font-size: 18px; line-height: 1.5; color: var(--ink2); margin: 10px 0 0; max-width: 36em; }
.plate { margin-top: 22px; background: var(--paper); border: 1px solid var(--ink); position: relative; }
.plate .cap { display: flex; justify-content: space-between; gap: 12px; border-top: 1px solid var(--line); padding: 8px 14px; font-family: var(--mono); font-size: 11.5px; color: var(--mute); min-height: 34px; }
.plate .cap b { color: var(--ink); font-weight: 500; }
.plate .cap .live { color: var(--ink); }
.story3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 28px; margin-top: 22px; }
.story3 h4 { margin: 0 0 4px; font-family: var(--mono); font-weight: 500; font-size: 10.5px; text-transform: uppercase; letter-spacing: .09em; color: var(--mute); border-top: 1px solid var(--ink); padding-top: 8px; }
.story3 h4 b { color: var(--acc); font-weight: 500; }
.story3 p { margin: 0; font-size: 15px; line-height: 1.6; }
.hiddenby { display: none; padding: 14px 0; border-bottom: 1px solid var(--ink); font-family: var(--mono); font-size: 12px; color: var(--mute); }
.hiddenby.show { display: block; }
.hiddenby button { background: none; border: 0; padding: 0; text-decoration: underline; text-decoration-color: var(--acc); text-underline-offset: 3px; cursor: pointer; font-family: var(--mono); font-size: 12px; color: var(--ink); }
/* F1 architecture */
.arch svg { display: block; width: 100%; height: auto; }
.arch .n rect { fill: var(--paper); stroke: var(--ink); stroke-width: 1.3; transition: fill .15s, stroke .15s; }
.arch .n text { font-family: var(--mono); font-size: 12px; fill: var(--ink); }
.arch .n text.s { font-size: 10px; fill: var(--mute); }
.arch .n { cursor: default; }
.arch .n:hover rect, .arch .n.on rect { fill: #fff; stroke: var(--acc); }
.arch .n.ghost rect { stroke-dasharray: 4 3; stroke: var(--mute); fill: transparent; }
.arch .w { fill: none; stroke: var(--ink); stroke-width: 1.2; }
.arch .w.req { stroke: var(--acc); }
.arch .w.dash { stroke-dasharray: 3 3; }
.arch .zone { fill: none; stroke: var(--mute); stroke-dasharray: 5 4; }
.arch .zl { font-family: var(--mono); font-size: 11px; fill: var(--mute); }
/* F2 contact sheet */
.strip { background: var(--ink); padding: 18px 14px; display: grid; grid-template-columns: 1fr 1fr 1fr 2fr; gap: 10px; position: relative; }
.strip::before, .strip::after { content: ""; position: absolute; left: 10px; right: 10px; height: 6px; background: repeating-linear-gradient(90deg, var(--bg) 0 8px, transparent 8px 20px); opacity: .55; }
.strip::before { top: 6px; } .strip::after { bottom: 6px; }
.frame { background: var(--bg); aspect-ratio: 4 / 3; position: relative; overflow: hidden; border: 0; padding: 0; cursor: default; text-align: left; }
.frame.wide { aspect-ratio: auto; }
.frame .hatch { position: absolute; inset: 0; background: repeating-linear-gradient(135deg, rgba(28,32,36,.08) 0 1px, transparent 1px 8px); }
.frame .fl { position: absolute; left: 8px; bottom: 6px; right: 8px; font-family: var(--mono); font-size: 10px; color: var(--ink2); }
.frame .fn { position: absolute; left: 8px; top: 6px; font-family: var(--mono); font-size: 10px; color: var(--acc); }
.frame svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.frame:hover, .frame.on { outline: 2px solid var(--acc); outline-offset: -2px; }
.wire text { font-family: var(--mono); font-size: 11px; fill: var(--ink); }
.wire text.s { font-size: 9.5px; fill: var(--mute); }
/* F3 network */
.netwrap { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); }
.net svg { display: block; width: 100%; height: auto; }
.net .e { stroke: var(--line); stroke-width: 1.4; transition: stroke .15s; }
.net .e.on { stroke: var(--ink); }
.net .e.bad.on { stroke: var(--acc); stroke-dasharray: 4 3; }
.net .pe circle { fill: var(--paper); stroke: var(--ink); stroke-width: 1.4; transition: fill .15s; }
.net .pe.fr circle { stroke: var(--acc); stroke-dasharray: 3 2; }
.net .pe text { font-family: var(--mono); font-size: 10px; fill: var(--ink); text-anchor: middle; pointer-events: none; }
.net .pe { cursor: default; }
.net .pe:hover circle, .net .pe.on circle { fill: var(--ink); }
.net .pe:hover text, .net .pe.on text { fill: var(--paper); }
.readout { border-left: 1px solid var(--line); padding: 18px 20px; font-family: var(--mono); font-size: 12px; display: flex; flex-direction: column; }
.readout .scr { background: var(--bg); border: 1px solid var(--ink); padding: 12px 14px; line-height: 1.8; min-height: 150px; }
.readout .scr .k { color: var(--mute); display: inline-block; width: 92px; }
.readout .gauge { height: 8px; border: 1px solid var(--ink); margin: 4px 0 2px; position: relative; background: var(--paper); }
.readout .gauge i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--ink); transition: width .2s; }
.readout .gauge.low i { background: var(--acc); }
.readout .lg { margin-top: 14px; color: var(--mute); font-size: 11px; line-height: 1.7; }
/* F4 before / after */
.ba { display: grid; grid-template-columns: 1fr 40px 1fr; }
.ba .side { padding: 16px 18px 18px; }
.ba .side h5 { margin: 0 0 10px; font-family: var(--mono); font-size: 10.5px; font-weight: 500; text-transform: uppercase; letter-spacing: .09em; color: var(--mute); display: flex; justify-content: space-between; }
.ba .side h5 b { color: var(--ink); font-weight: 500; }
.ba .mid { border-left: 1px solid var(--line); border-right: 1px solid var(--line); display: flex; align-items: center; justify-content: center; font-family: var(--mono); color: var(--acc); font-size: 18px; background: repeating-linear-gradient(90deg, transparent 0 4px, rgba(28,32,36,.04) 4px 5px); }
.notes { list-style: none; margin: 0; padding: 0; font-family: var(--mono); font-size: 12px; line-height: 1.5; }
.notes li { display: grid; grid-template-columns: 70px 1fr; gap: 8px; padding: 3px 0; border-bottom: 1px dotted var(--line); }
.notes li span:first-child { color: var(--mute); }
.notes .x { color: var(--acc); }
.tree { font-family: var(--mono); font-size: 12px; line-height: 1.6; white-space: pre; margin: 0; color: var(--ink2); }
.tree b { color: var(--ink); font-weight: 500; }
.cmd { margin-top: 12px; border: 1px solid var(--ink); background: var(--bg); padding: 8px 10px; font-family: var(--mono); font-size: 12px; }
.cmd .r { display: block; color: var(--mute); margin-top: 4px; font-size: 11px; }
.cmd .r b { color: var(--ink); font-weight: 500; }
.who { display: flex; gap: 22px; flex-wrap: wrap; padding: 10px 14px; border-top: 1px solid var(--line); font-family: var(--mono); font-size: 11.5px; }
.meter { display: inline-grid; grid-template-columns: repeat(10, 5px); gap: 2px; vertical-align: -1px; margin-right: 6px; }
.meter i { height: 10px; border: 1px solid var(--ink); }
.meter i.f { background: var(--acc); border-color: var(--acc); }
.meter i.h { background: var(--ink); }
/* ---------- index ---------- */
.ix-tools { display: flex; gap: 16px; align-items: baseline; margin: 14px 0 6px; font-family: var(--mono); font-size: 11.5px; color: var(--mute); flex-wrap: wrap; }
.seg { display: inline-flex; border: 1px solid var(--ink); }
.seg button { border: 0; background: var(--paper); font-family: var(--mono); font-size: 11.5px; padding: 3px 10px; cursor: pointer; }
.seg button + button { border-left: 1px solid var(--ink); }
.seg button[aria-pressed="true"] { background: var(--ink); color: var(--paper); }
.ix-g { margin-top: 18px; }
.ix-gh { display: grid; grid-template-columns: 52px 1fr; align-items: end; border-bottom: 1px solid var(--ink); padding-bottom: 6px; }
.ix-gh .L { font-family: var(--mono); font-size: 34px; font-weight: 500; line-height: .9; color: var(--acc); }
.ix-gh .n { font-family: var(--mono); font-size: 14px; font-weight: 500; }
.ix-gh .n span { color: var(--mute); font-weight: 400; font-size: 11.5px; margin-left: 8px; }
.row { border-bottom: 1px solid var(--line); }
.row.off { display: none; }
.row > button { display: grid; grid-template-columns: 52px 92px minmax(0, 1fr) 210px 66px 82px 18px; gap: 14px; align-items: center; width: 100%; background: none; border: 0; padding: 9px 0; text-align: left; cursor: pointer; position: relative; }
.row > button:hover { background: rgba(255,255,255,.55); }
.row > button:hover .nm { color: var(--acc); }
.row .no { font-family: var(--mono); font-size: 11px; color: var(--mute); padding-left: 4px; }
.thumb { height: 36px; border: 1px solid var(--line); background: var(--bg); position: relative; transition: border-color .15s; }
.thumb svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.row > button:hover .thumb { border-color: var(--ink); }
.row .nm { font-size: 15.5px; font-weight: 500; }
.row .nm .fe { font-family: var(--mono); font-size: 10px; font-weight: 400; color: var(--acc); border: 1px solid var(--acc); padding: 0 4px; margin-left: 8px; vertical-align: 2px; }
.row .ds { display: block; font-size: 13.5px; color: var(--mute); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .stk { font-family: var(--mono); font-size: 11px; color: var(--ink2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .yr { font-family: var(--mono); font-size: 11.5px; }
.row .pm { font-family: var(--mono); color: var(--mute); transition: transform .2s; }
.row.open .pm { transform: rotate(45deg); color: var(--acc); }
.row.open > button { background: var(--paper); }
.more { display: none; grid-template-columns: 52px 92px 1fr; gap: 14px; padding: 4px 0 18px; background: var(--paper); }
.row.open .more { display: grid; }
.more .body { grid-column: 3; }
.more .s3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; }
.more h4 { margin: 0 0 3px; font-family: var(--mono); font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: .09em; color: var(--mute); }
.more p { margin: 0; font-size: 14px; line-height: 1.55; }
.more .ft { margin-top: 12px; font-family: var(--mono); font-size: 12px; display: flex; gap: 16px; flex-wrap: wrap; align-items: baseline; }
.more .ft .dead { color: var(--mute); font-style: italic; }
.ix-legend { margin-top: 12px; font-family: var(--mono); font-size: 11px; color: var(--mute); display: flex; gap: 16px; flex-wrap: wrap; }
/* ---------- writing ---------- */
.wr { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 40px; margin-top: 16px; }
.wr .lead { background: var(--paper); border: 1px solid var(--ink); padding: 18px 20px; text-decoration: none; display: block; }
.wr .lead .t { font-family: var(--mono); font-weight: 500; font-size: 21px; letter-spacing: -.02em; line-height: 1.25; display: block; margin-top: 8px; }
.wr .lead p { margin: 10px 0 0; color: var(--ink2); line-height: 1.55; }
.wr .lead:hover .t { color: var(--acc); }
.wr ul { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--ink); }
.wr li a { display: grid; grid-template-columns: 92px 1fr 70px; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); text-decoration: none; align-items: baseline; }
.wr .d, .wr .tg { font-family: var(--mono); font-size: 11px; color: var(--mute); }
.wr .tg { text-align: right; }
.wr li a:hover .t { color: var(--acc); }
.wr li .b { display: block; font-size: 13.5px; color: var(--mute); margin-top: 2px; }
footer { margin-top: 60px; border-top: 1px solid var(--ink); padding: 16px 0 40px; display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; font-family: var(--mono); font-size: 12px; color: var(--mute); }
footer a { color: var(--ink); }
.mk { position: fixed; top: 58px; right: 54px; z-index: 50; width: 310px; background: #fff; color: #222; border: 1px solid #bbb; padding: 10px 26px 10px 12px; font: 12.5px/1.4 system-ui, sans-serif; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
.mk b { display: block; margin-bottom: 4px; }
.mk button { position: absolute; top: 4px; right: 6px; border: 0; background: none; font-size: 16px; cursor: pointer; color: #666; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } * { transition: none !important; } }
@media (max-width: 1080px) {
.bar nav { display: none; }
.row > button { grid-template-columns: 44px 72px minmax(0, 1fr) 60px 70px 18px; }
.row .stk { display: none; }
.more { grid-template-columns: 44px 72px 1fr; }
}
@media (max-width: 760px) {
.edge { position: static; writing-mode: horizontal-tb; width: auto; height: 40px; }
.edge:hover { width: auto; }
.bar, main { margin-right: 0; }
.bar-in { padding: 0 14px; gap: 10px; height: auto; flex-wrap: wrap; padding-top: 8px; padding-bottom: 8px; }
.pref .why { display: none; }
.wrap { padding: 0 14px; }
.head, .feat, .story3, .netwrap, .ba, .wr, .more .s3 { grid-template-columns: 1fr; }
.head { gap: 30px; }
.feat { gap: 18px; }
.meta dl { display: none; }
.strip { grid-template-columns: 1fr 1fr; }
.frame.wide { grid-column: 1 / -1; aspect-ratio: 2 / 1; }
.readout { border-left: 0; border-top: 1px solid var(--line); }
.ba .mid { height: 30px; border: 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.row > button { grid-template-columns: 34px minmax(0, 1fr) 60px 18px; }
.row .thumb, .row .yr { display: none; }
.more { grid-template-columns: 1fr; padding: 4px 10px 14px; }
.more .body { grid-column: 1; }
.wr li a { grid-template-columns: 1fr; gap: 2px; }
.wr .tg { text-align: left; }
.mk { right: 10px; left: 10px; width: auto; }
}
</style>
</head>
<body>
<div class="mk" id="mk"><button onclick="document.getElementById('mk').remove()" aria-label="close">×</button>
<b>tech-r2-d · Featured, then everything</b>What "enter work" opens: a normal scrolling page. Four featured projects, each told in the shape that suits it (architecture, contact sheet, network, before/after), then a full index of all 17 with personality, then writing. Grid paper and Plex from nav-d's left gate; varied shapes from sankar.work, kept restrained; the index is T1 with character.</div>
<a class="edge" href="nav-d.html" title="cross over to the personal side">off the clock →</a>
<header class="bar">
<div class="bar-in">
<a class="me" href="#top">Lucas Winther <span>/ work</span></a>
<nav aria-label="On this page"><a href="#featured">featured <span>4</span></a><a href="#index">index <span>17</span></a><a href="#writing">writing</a><a href="#top">cv</a></nav>
<span class="sp"></span>
<span class="pref" role="group" aria-label="Filter projects by how they were made">
<span class="why">projects made</span>
<button aria-pressed="true" data-pref="all">any way</button>
<button aria-pressed="false" data-pref="nofull">not fully by AI</button>
<button aria-pressed="false" data-pref="hand">by hand only</button>
</span>
<a class="gate" href="nav-d.html" title="back to the two gates">↖ gate</a>
</div>
</header>
<main id="top">
<div class="wrap">
<!-- ============ head ============ -->
<section class="head">
<div>
<div class="lbl"><b>●</b> 01 / work</div>
<h1>Work</h1>
<p class="lede">I build and run research software at Aarhus University, and make things on my own time, lately often with AI in the loop. Four projects are told properly below; everything else is in the index, each marked with how much of it an AI wrote.</p>
<div class="toc" id="toc"></div>
</div>
<div class="cv" id="cv">
<div class="row"><span class="k">now</span><span>IT-medarbejder, IT Research Tech, Aarhus University <small>2025–now</small><br><small>devops · containers · linux · observability · cloud · full-stack</small></span></div>
<div class="row"><span class="k">before</span><span>Intern, ScienceAtHome <small>2019</small><br>Student helper, Local Configuration Management, TERMA Group <small>2016–2018</small></span></div>
<div class="row"><span class="k">education</span><span>BSc Computer Science, <span class="ph" title="placeholder">Aarhus University</span> <small class="ph" title="placeholder">2022</small><br><small>thesis: HyperCache, reputation in P2P archival</small></span></div>
<div class="row"><span class="k">contact</span><span><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br><small><a href="https://github.com/StereotypicalCat">github/StereotypicalCat</a> · <a href="https://www.linkedin.com/in/lucas-winther/">linkedin/lucas-winther</a></small></span></div>
<div class="links"><a class="pdf" href="#" onclick="return false" title="placeholder: CV PDF">↓ CV (PDF)</a><a href="#" onclick="return false" title="placeholder">old site, 2021, by hand</a><a href="#" onclick="return false" title="placeholder">/simple</a></div>
</div>
</section>
<!-- ============ featured ============ -->
<div class="part" id="featured"><h2>Featured</h2><span class="lbl">four projects, each shown in the shape that explains it best</span></div>
<div id="features"></div>
<!-- ============ index ============ -->
<div class="part" id="index" style="margin-top:56px"><h2>Everything</h2><span class="lbl" id="ixCount">17 projects · click a row to open it</span></div>
<div class="ix-tools">
<span>arrange by</span>
<span class="seg" role="group" aria-label="Arrange index"><button aria-pressed="true" data-by="group">kind</button><button aria-pressed="false" data-by="year">year</button><button aria-pressed="false" data-by="ai">how it was made</button></span>
<span id="ixNote"></span>
</div>
<div id="ix"></div>
<div class="ix-legend"><span><span class="ai full"><i></i>fully AI</span></span><span><span class="ai some"><i></i>some AI</span></span><span><span class="ai none"><i></i>by hand</span></span><span><span class="ai unk"><i></i>not recorded yet</span></span><span>dotted underline = placeholder</span></div>
<!-- ============ writing ============ -->
<div class="part" id="writing" style="margin-top:56px"><h2>Latest writing</h2><span class="lbl"><a href="https://blog.lucaswinther.info">all of it at blog.lucaswinther.info →</a></span></div>
<div class="wr" id="wr"></div>
<footer>
<span><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a> · <a href="https://github.com/StereotypicalCat">GitHub</a> · <a href="https://www.linkedin.com/in/lucas-winther/">LinkedIn</a> · <a href="nav-d.html">↖ back to the gate</a></span>
<span>Placeholders: university &amp; year, most project years, story details, images, post titles, CV PDF, AI level for AU work.</span>
</footer>
</div>
</main>
<script>
/* ---------- shared content (same in tech-r2-c and tech-r2-d) ---------- */
var PH = function (s) { return '<span class="ph" title="placeholder">' + s + '</span>'; };
var GROUPS = {
work: { key: 'A', name: 'Work', sub: 'paid, for someone else' },
own: { key: 'B', name: 'Own', sub: 'built for myself' },
school: { key: 'C', name: 'School', sub: 'university and high school' }
};
var AI = {
full: { label: 'fully AI', long: 'made with AI' },
some: { label: 'some AI', long: 'some AI help' },
none: { label: 'by hand', long: 'no AI, by hand' },
unk: { label: 'AI: ?', long: 'AI level not recorded yet (placeholder)' }
};
var POSTS = [
{ id: 'agent', t: "What I let the agent write, and what I didn't", d: '2026-10-14', tag: 'AI', blurb: 'Going back through the gacha tracker file by file and marking every part by who wrote it.' },
{ id: 'slurm', t: 'Slurm on a cloud that scales to zero', d: '2026-09-02', tag: 'devops', blurb: 'What it takes for a job queue to grow nodes on demand, and shrink again without killing anything.' },
{ id: 'vault', t: 'Moving secrets out of .env and into OpenBao', d: '2026-07-21', tag: 'homelab', blurb: 'The boring migration that made every other change less scary.' },
{ id: 'ansible', t: 'Ansible for three servers is not overkill', d: '2026-05-08', tag: 'devops', blurb: 'Why Config exists, and the week every run restarted every container.' },
{ id: 'thesis', t: 'Rereading my bachelor thesis four years later', d: '2026-03-30', tag: 'notes', blurb: 'What HyperCache got right about trust, and what I would simulate differently now.' }
];
var PROJECTS = [
{ id: 'compute', g: 'work', name: 'Compute job platform', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'arch',
one: 'Slurm jobs from a browser: a React/Node app in front of a cluster that scales itself on Oracle Cloud.',
stack: ['Slurm', 'React', 'Node.js', 'Oracle Cloud', 'OAuth / Entra ID', 'ERDA', 'Docker'],
fig: 'diagram: web app → Slurm → cloud nodes',
problem: 'Researchers need to run heavy jobs without first learning Slurm, SSH and batch scripts. Use is bursty, so a fixed cluster either sits idle or runs out.',
approach: 'A full-stack React/Node web app in front of Slurm. People log in with their AU account (OAuth with Entra ID), pick their data from ERDA, AU\'s own storage, and submit. The web stack and the compute nodes both scale up and down on Oracle Cloud.',
interesting: 'Making "easy to use" and "scales to zero" live together: ' + PH('a queued job has to explain why it is waiting while a node boots, instead of just looking stuck.'),
parts: [['web app (React/Node)', 'unk'], ['Slurm + cloud scaling', 'unk'], ['Entra ID login, ERDA link', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['docs', 'ops', 'config'], posts: ['slurm', 'vault'] },
{ id: 'docs', g: 'work', name: 'Document & information services', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'doc',
one: 'Overleaf and OpenProject, run by us for researchers and wired into the university\'s own systems.',
stack: ['Overleaf', 'OpenProject', 'Docker', 'Linux'],
fig: 'screenshot: Overleaf project list',
problem: 'Research groups write papers together and plan projects, and want to do it on tools the university can run, secure and back up itself.',
approach: 'Self-hosted Overleaf and OpenProject, containerised, with integrations into the rest of AU\'s services. ' + PH('Which integrations exactly is still to be written up.'),
interesting: PH('Updating two large open-source products without breaking a group that is three days from a deadline.'),
parts: [['deployment', 'unk'], ['integrations', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['compute', 'ops'], posts: ['vault'] },
{ id: 'ops', g: 'work', name: 'Secure test & run setup', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'infra',
one: 'Trying newer versions of software while keeping everything isolated, patched and watched.',
stack: ['OpenBao / Vault', 'Grafana', 'Docker', 'Ansible', 'Linux'],
fig: 'screenshot: Grafana overview board',
problem: 'A research IT team has to try new software and new versions early, without that becoming the weak spot in everything else.',
approach: 'Everything runs containerised and isolated, configured with Ansible, secrets in OpenBao / HashiCorp Vault, and watched in Grafana.',
interesting: PH('Deciding what deserves an alert. Most dashboards are for curiosity; only a few things should wake someone up.'),
parts: [['Ansible roles', 'unk'], ['dashboards & alerts', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['config', 'compute'], posts: ['vault', 'ansible'] },
{ id: 'sah', g: 'work', name: 'ScienceAtHome prototype', yr: '2019', where: 'ScienceAtHome · internship', ai: 'none', motif: 'game',
one: 'A month-long internship: an early prototype of a new project\'s base mechanics, to show the people it was for.',
stack: [PH('Unity'), 'prototyping', 'pair programming'],
fig: 'screenshot: prototype, ' + PH('project name'),
problem: 'A new project needed its core idea tested before anyone built it properly, and something concrete to show the people it was made for.',
approach: 'Joined the ideation and planning, built a quick prototype of the base mechanics, then pair-programmed with experienced developers to start the real project.',
interesting: 'Seeing how a prototype changes the conversation: people react to something they can try very differently from a plan.',
parts: [['prototype', 'none']],
links: [], rel: ['rowing', 'vrclass'], posts: [] },
{ id: 'gacha', g: 'own', name: 'Event Tracker for Gacha Games', yr: PH('2025'), ai: 'full', motif: 'web',
one: 'Scrapes event schedules from several games into one timeline, so limited events stop slipping by.',
stack: ['JavaScript', 'scraping', 'web app'],
fig: 'screenshot: the event timeline',
problem: 'Each game announces its limited events somewhere different, in a different time zone. Keeping track meant checking five places.',
approach: 'Scrapers per game feed one normalised list of events, shown as a single timeline in a small web app.',
interesting: 'A test of building with AI: ' + PH('I wrote the spec and the fixtures, the agent wrote the scrapers, and every change was read before it went in.'),
parts: [['spec & fixtures', 'none'], ['scrapers', 'full'], ['timeline UI', 'full']],
links: [['live demo', '#'], ['GitHub', 'https://github.com/StereotypicalCat']], rel: ['config', 'expenses'], posts: ['agent'] },
{ id: 'config', g: 'own', name: 'Config', yr: PH('2025'), ai: 'full', motif: 'infra',
one: 'Ansible and docker-compose: every service on my servers, deployed and updated from one repo.',
stack: ['Ansible', 'IaC', 'docker-compose', 'Docker', 'Linux'],
fig: 'screenshot: repo tree and a playbook run',
problem: 'Services were set up by hand over SSH, one machine at a time, and the only documentation was my memory.',
approach: 'An inventory of hosts, one role per kind of thing, and compose stacks templated from one pattern. One command brings a machine to the state git describes.',
interesting: PH('Idempotency: for a week every run restarted every container, because a template rendered a new timestamp each time.'),
parts: [['inventory & layout', 'none'], ['playbooks & roles', 'full'], ['compose files', 'full']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['ops', 'gacha'], posts: ['ansible'] },
{ id: 'chess', g: 'own', name: 'Chessswap', yr: PH('2020'), ai: 'none', motif: 'board',
one: 'Chess, except the board swaps every few turns.',
stack: ['React', 'JavaScript'],
fig: 'screenshot: board mid-swap',
problem: 'Chess openings are memorised; a twist that breaks memorisation makes it about thinking again.',
approach: 'A React board with normal chess rules, plus a swap every few turns that changes whose pieces you are playing.',
interesting: 'Rules that sound simple get strange at the edges: ' + PH('what happens to castling rights and check right at a swap?'),
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['breakout', 'dashboard'], posts: [] },
{ id: 'expenses', g: 'own', name: 'Monthly Expenses Tracker', yr: PH('2021'), ai: 'none', motif: 'web',
one: 'A small web app for where the money goes each month, in DKK.',
stack: ['JavaScript', 'web app'],
fig: 'screenshot: month view',
problem: 'Bank apps show transactions, not the shape of a month.',
approach: 'A plain JavaScript web app: enter expenses, see each month by category, in kroner.',
interesting: PH('Storing amounts in øre to dodge floating-point surprises.'),
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['gacha', 'dashboard'], posts: [] },
{ id: 'lolbot', g: 'own', name: 'LoL Winrate Discord Bot', yr: PH('2020'), ai: 'none', motif: 'bot',
one: 'Ask in Discord, get a League of Legends champion\'s current winrate.',
stack: ['Node.js', 'Discord API'],
fig: 'screenshot: bot reply in a channel',
problem: 'Mid-draft, nobody wants to alt-tab to a stats site.',
approach: 'A Node.js bot that answers a command with the champion\'s current winrate.',
interesting: PH('Rate limits and caching: a bot that is popular for ten minutes a night.'),
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['gacha'], posts: [] },
{ id: 'portfolio', g: 'own', name: 'Portfolio (2021)', yr: '2021', ai: 'none', motif: 'web',
one: 'The old React + Material UI site, kept frozen as the by-hand archive.',
stack: ['React', 'Material UI'],
fig: 'screenshot: the 2021 site',
problem: 'A place to put projects, with a search.',
approach: 'Create React App and Material UI, projects read from one info.json.',
interesting: 'It is now the archive: everything on it was written by hand, before AI tools.',
parts: [['everything', 'none']],
links: [['old site', '#']], rel: ['borlaug'], posts: [] },
{ id: 'borlaug', g: 'own', name: 'Tribute to Norman Borlaug', yr: PH('2016'), ai: 'none', motif: 'doc',
one: 'One of my first websites, a freeCodeCamp tribute page.',
stack: ['HTML', 'CSS'],
fig: 'screenshot: tribute page',
problem: 'Learning to make a web page at all.',
approach: 'One HTML page and a stylesheet, following the freeCodeCamp brief.',
interesting: 'Kept for honesty: this is where it started.',
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['portfolio'], posts: [] },
{ id: 'hypercache', g: 'school', name: 'HyperCache', yr: PH('2022'), where: 'Bachelor thesis · ' + PH('Aarhus University'), ai: 'none', motif: 'net',
one: 'Reputation systems for peer-to-peer archival: who can you trust to keep your data?',
stack: ['Node.js', 'P2P', 'simulation', 'reputation systems'],
fig: 'diagram: peers and trust',
problem: 'In a peer-to-peer archive, data only survives if peers keep storing it. Some peers will freeload or quietly drop data.',
approach: 'A reputation system where peers rate each other on whether data is really kept, built and simulated in Node.js.',
interesting: PH('How fast trust recovers after a bad actor leaves, and how cheaply a newcomer can fake a good history.'),
parts: [['thesis & code', 'none']],
links: [['thesis (PDF)', '#']], rel: ['vrclass', 'compute'], posts: ['thesis'] },
{ id: 'dashboard', g: 'school', name: 'Student Dashboard', yr: PH('2021'), ai: 'none', motif: 'web',
one: 'A dashboard for students, shaped by user studies rather than guesses.',
stack: ['React', 'user studies'],
fig: 'screenshot: dashboard, second iteration',
problem: 'Students juggle several university systems to answer simple questions like "what is due this week?".',
approach: 'User studies first, then a React dashboard, then studies again to see what actually helped.',
interesting: PH('The first interviews threw out half of the planned features.'),
parts: [['everything', 'none']],
links: [['report (PDF)', '#'], ['demo', '#']], rel: ['vrclass', 'chess'], posts: [] },
{ id: 'rowing', g: 'school', name: 'Mixed-Reality Rowing Game', yr: PH('2020'), ai: 'none', motif: 'game',
one: 'Row a virtual boat with two real oars, wired up through an Arduino.',
stack: ['Unity', 'C#', 'Arduino'],
fig: 'photo: the two oars on their rig',
problem: 'Rowing machines are boring and VR controllers don\'t feel like oars.',
approach: 'Two real oars with sensors read by an Arduino, sending each stroke into a Unity scene over serial.',
interesting: PH('Turning noisy sensor readings into a stroke that feels right, with almost no lag.'),
parts: [['everything', 'none']],
links: [['video', '#']], rel: ['censor', 'vrclass', 'sah'], posts: [] },
{ id: 'vrclass', g: 'school', name: 'VR-Classroom', yr: PH('2020'), ai: 'none', motif: 'vr',
one: 'A Node.js prototype of a shared virtual classroom.',
stack: ['Node.js', 'VR', 'prototyping'],
fig: 'still from the video',
problem: 'Remote teaching loses the feeling of being in the same room.',
approach: 'A Node.js prototype that keeps participants in a shared virtual room in sync.',
interesting: PH('Small things like where the teacher stands matter more than resolution.'),
parts: [['everything', 'none']],
links: [['report (PDF)', '#'], ['video', '#']], rel: ['rowing', 'dashboard', 'censor'], posts: [] },
{ id: 'censor', g: 'school', name: 'Censor Simulator', yr: PH('2018'), where: 'High-school exam project', ai: 'none', motif: 'vr',
one: 'A VR exam project in Unity: you are the one deciding what gets through.',
stack: ['Unity', 'C#', 'VR'],
fig: 'screenshot: the censor\'s desk',
problem: 'An exam project that had to be both a game and an argument.',
approach: 'A Unity VR scene where you sort and censor incoming material.',
interesting: PH('First time building for VR: comfort and scale before anything else.'),
parts: [['everything', 'none']],
links: [], rel: ['rowing', 'breakout'], posts: [] },
{ id: 'breakout', g: 'school', name: 'BreakoutPython', yr: PH('2017'), where: 'High-school exam project', ai: 'none', motif: 'board',
one: 'Breakout, written in Python for a high-school exam.',
stack: ['Python'],
fig: 'screenshot: level one',
problem: 'Learn to program a game loop from scratch.',
approach: 'Paddle, ball, bricks and collision, in Python.',
interesting: 'Collision is the whole game, and the first place bugs feel physical.',
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['chess', 'censor'], posts: [] }
];
function P(id) { for (var i = 0; i < PROJECTS.length; i++) if (PROJECTS[i].id === id) return PROJECTS[i]; }
function POST(id) { for (var i = 0; i < POSTS.length; i++) if (POSTS[i].id === id) return POSTS[i]; }
function strip(s) { return String(s).replace(/<[^>]+>/g, ''); }
function aiMark(a, withLabel) { return '<span class="ai ' + a + '" title="' + AI[a].long + '"><i></i>' + (withLabel === false ? '' : AI[a].label) + '</span>'; }
var PREF = 'all';
function allowed(p) { if (PREF === 'nofull') return p.ai !== 'full'; if (PREF === 'hand') return p.ai === 'none'; return true; }
/* ---------- line-drawn figure placeholders, one motif per kind of project ---------- */
function figSVG(m) {
var s = '<svg viewBox="0 0 720 280" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><g fill="none" stroke="#1c2024" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">';
var A = '#d9532b';
function box(x, y, w, h, t, acc) { return '<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" fill="#f5f6f3"' + (acc ? ' stroke="' + A + '"' : '') + '/><text x="' + (x + w / 2) + '" y="' + (y + h / 2 + 4) + '" text-anchor="middle" fill="#1c2024" stroke="none" font-family="IBM Plex Mono" font-size="12">' + t + '</text>'; }
if (m === 'arch') {
s += '<rect x="372" y="34" width="320" height="214" stroke-dasharray="4 4" stroke="#6b726f"/><text x="384" y="52" fill="#6b726f" stroke="none" font-family="IBM Plex Mono" font-size="11">oracle cloud · scales up/down</text>';
s += box(28, 120, 92, 40, 'browser') + box(160, 120, 100, 40, 'web app', 1) + box(400, 120, 104, 40, 'slurm ctl') + box(560, 70, 100, 34, 'node') + box(560, 123, 100, 34, 'node') + box(560, 176, 100, 34, 'node ···');
s += box(160, 210, 100, 34, 'entra id') + box(160, 36, 100, 34, 'erda');
s += '<path d="M120 140H160M260 140H400" stroke="' + A + '"/><path d="M504 140H530M530 87V193M530 87H560M530 140H560M530 193H560"/><path d="M210 160V210M210 70V120" stroke-dasharray="3 3"/>';
} else if (m === 'infra') {
for (var i = 0; i < 3; i++) { var x = 90 + i * 200; s += '<rect x="' + x + '" y="70" width="140" height="150" fill="#f5f6f3"/>'; for (var j = 0; j < 4; j++) s += '<path d="M' + (x + 12) + ' ' + (92 + j * 32) + 'H' + (x + 100) + '"/><circle cx="' + (x + 120) + '" cy="' + (92 + j * 32) + '" r="3.5"' + (j === 1 && i === 1 ? ' fill="' + A + '" stroke="' + A + '"' : '') + '/>'; }
s += '<path d="M230 145H290M430 145H490" stroke-dasharray="3 3"/><text x="360" y="250" text-anchor="middle" fill="#6b726f" stroke="none" font-family="IBM Plex Mono" font-size="11">host-01 · host-02 · host-03</text>';
} else if (m === 'web') {
s += '<rect x="120" y="30" width="480" height="222" fill="#f5f6f3"/><path d="M120 54H600"/><circle cx="136" cy="42" r="3"/><circle cx="148" cy="42" r="3"/><circle cx="160" cy="42" r="3"/>';
s += '<rect x="144" y="74" width="120" height="156"/><path d="M290 84H570M290 102H520" /><rect x="290" y="124" width="280" height="40" stroke="' + A + '"/><rect x="290" y="176" width="130" height="54"/><rect x="440" y="176" width="130" height="54"/>';
} else if (m === 'doc') {
s += '<rect x="250" y="20" width="220" height="244" fill="#f5f6f3"/><path d="M276 50H420M276 70H444M276 84H444M276 98H400"/><rect x="276" y="116" width="168" height="70" stroke="' + A + '"/><path d="M276 204H444M276 218H444M276 232H380"/><rect x="216" y="36" width="20" height="200" stroke-dasharray="3 3" stroke="#6b726f"/>';
} else if (m === 'net') {
var n = [[120, 80], [200, 190], [290, 110], [370, 220], [400, 60], [480, 150], [560, 90], [600, 210], [250, 240], [160, 140]];
var e = [[0, 2], [0, 9], [9, 1], [1, 2], [2, 4], [2, 5], [4, 6], [5, 6], [5, 7], [3, 5], [3, 8], [1, 8], [6, 7]];
e.forEach(function (k) { s += '<path d="M' + n[k[0]][0] + ' ' + n[k[0]][1] + 'L' + n[k[1]][0] + ' ' + n[k[1]][1] + '"' + (k[0] === 2 && k[1] === 5 ? ' stroke="' + A + '"' : '') + '/>'; });
n.forEach(function (p, i) { s += '<circle cx="' + p[0] + '" cy="' + p[1] + '" r="' + (i === 7 ? 10 : 8) + '" fill="#f5f6f3"' + (i === 7 ? ' stroke="' + A + '" stroke-dasharray="3 2"' : '') + '/>'; });
} else if (m === 'game') {
s += '<path d="M40 150H680" stroke="#6b726f"/><path d="M60 180q20 -8 40 0t40 0t40 0M420 200q20 -8 40 0t40 0t40 0M200 230q20 -8 40 0t40 0" stroke="#6b726f"/>';
s += '<path d="M300 170L420 170L400 186L320 186Z" fill="#f5f6f3"/><path d="M330 172L250 222M390 172L470 222" stroke="' + A + '" stroke-width="2"/><rect x="242" y="216" width="18" height="10" transform="rotate(-30 251 221)"/><rect x="462" y="216" width="18" height="10" transform="rotate(30 471 221)"/>';
} else if (m === 'board') {
for (var r = 0; r < 6; r++) for (var c = 0; c < 6; c++) if ((r + c) % 2) s += '<rect x="' + (270 + c * 30) + '" y="' + (50 + r * 30) + '" width="30" height="30" fill="#cfd3cd" stroke="none"/>';
s += '<rect x="270" y="50" width="180" height="180"/><path d="M470 110q40 30 0 60" stroke="' + A + '"/><path d="M466 166l6 6l2 -8" stroke="' + A + '"/>';
} else if (m === 'bot') {
s += '<rect x="170" y="40" width="380" height="200" fill="#f5f6f3"/><rect x="194" y="64" width="210" height="30"/><rect x="290" y="110" width="236" height="56" stroke="' + A + '"/><path d="M306 128H480M306 146H430"/><rect x="194" y="182" width="160" height="30"/>';
} else if (m === 'vr') {
s += '<path d="M120 250L300 150H420L600 250M200 250L320 150M520 250L400 150M360 150V250M160 220H560M220 185H500"/>';
s += '<rect x="290" y="40" width="140" height="70" rx="14" fill="#f5f6f3"/><circle cx="330" cy="75" r="16" stroke="' + A + '"/><circle cx="390" cy="75" r="16" stroke="' + A + '"/>';
}
return s + '</g></svg>';
}
(function () {
var counts = {}; PROJECTS.forEach(function (p) { counts[p.g] = (counts[p.g] || 0) + 1; p.no = GROUPS[p.g].key + counts[p.g]; });
var FEAT = [
{ id: 'compute', shape: 'architecture', visual: archPlate },
{ id: 'rowing', shape: 'contact sheet + wiring', visual: rowPlate },
{ id: 'hypercache', shape: 'network', visual: netPlate },
{ id: 'config', shape: 'before / after', visual: baPlate }
];
FEAT.forEach(function (f, i) { P(f.id).feat = 'F' + (i + 1); });
function meter(level) { var n = { full: 10, some: 5, none: 0 }[level] || 0, h = ''; for (var i = 0; i < 10; i++) h += '<i class="' + (i < n ? 'f' : 'h') + '"></i>'; return '<span class="meter">' + h + '</span>'; }
// ---------- toc in the head ----------
document.getElementById('toc').innerHTML = FEAT.map(function (f, i) {
var p = P(f.id); return '<a href="#f-' + p.id + '"><span class="no">F' + (i + 1) + '</span><span class="t">' + p.name + '</span><span class="sh">' + f.shape + '</span></a>';
}).join('') + '<a class="all" href="#index"><span class="no">↓</span><span class="t">Everything else, 13 more</span><span class="sh">index</span></a>';
// ---------- F1 architecture ----------
function archPlate() {
var N = [
['br', 20, 150, 110, 46, 'researcher', 'browser', 'Researchers log in with their AU account and submit jobs from a form. No SSH, no batch scripts.'],
['web', 200, 110, 120, 46, 'web app', 'React', 'React front end: choose a job, choose data, follow its progress and log.'],
['api', 200, 196, 120, 46, 'api', 'Node.js', 'Node back end: keeps job state and talks to Slurm. Scales with load like the front end.'],
['ent', 20, 300, 110, 40, 'entra id', 'OAuth', 'Login through OAuth with Entra ID, so every AU account just works.'],
['erda', 200, 300, 120, 40, 'erda', 'AU storage', 'ERDA, AU\'s own storage: input data in, results back out, no copying by hand.'],
['slurm', 420, 196, 120, 46, 'slurm ctl', 'queue', 'Slurm queues the jobs and asks for more nodes when the queue grows.'],
['n1', 620, 96, 110, 38, 'node 01', 'running', 'A compute node on Oracle Cloud, started because jobs were waiting.'],
['n2', 620, 150, 110, 38, 'node 02', 'running', 'A compute node on Oracle Cloud, started because jobs were waiting.'],
['n3', 620, 204, 110, 38, 'node 03', 'idle → stop', 'Idle nodes are stopped again, so nothing is paid for while no one is computing.'],
['n4', 760, 96, 110, 38, 'node ··', 'on demand', 'Dashed nodes only exist while they are needed.', 1],
['n5', 760, 150, 110, 38, 'node ··', 'on demand', 'Dashed nodes only exist while they are needed.', 1]
];
var s = '<svg viewBox="0 0 900 370" role="img" aria-label="Architecture of the compute job platform">';
s += '<rect class="zone" x="180" y="70" width="705" height="190"/><text class="zl" x="192" y="88">oracle cloud · web stack and compute scale up and down</text>';
s += '<rect class="zone" x="8" y="282" width="330" height="76" style="stroke-dasharray:2 3"/><text class="zl" x="16" y="352" style="font-size:10px">aarhus university services</text>';
s += '<path class="w req" d="M130 173H165V133H200"/><path class="w req" d="M260 156V196"/><path class="w req" d="M320 219H420"/>';
s += '<path class="w" d="M540 219H580V115H620M580 169H620M580 223H620"/><path class="w dash" d="M730 115H760M730 169H760"/>';
s += '<path class="w dash" d="M260 242V300"/><path class="w dash" d="M75 196V300"/><path class="w dash" d="M320 320H480V242"/>';
N.forEach(function (n) {
s += '<g class="n' + (n[8] ? ' ghost' : '') + '" data-note="' + n[7].replace(/"/g, '&quot;') + '" data-name="' + n[5] + '" tabindex="0"><rect x="' + n[1] + '" y="' + n[2] + '" width="' + n[3] + '" height="' + n[4] + '"/>' +
'<text x="' + (n[1] + 10) + '" y="' + (n[2] + 18) + '">' + n[5] + '</text><text class="s" x="' + (n[1] + 10) + '" y="' + (n[2] + n[4] - 9) + '">' + n[6] + '</text></g>';
});
s += '<text class="zl" x="350" y="212">submit</text><text class="zl" x="350" y="314" style="font-size:10px">data in / out</text></svg>';
return '<div class="plate arch">' + s + '<div class="cap"><span class="live" id="archCap">Point at a box to see what it does. <span style="color:var(--mute)">Orange is the path a job takes.</span></span><span><b>fig. F1</b> · ' + PH('simplified') + '</span></div></div>';
}
// ---------- F2 contact sheet ----------
function rowPlate() {
var wire = '<svg class="wire" viewBox="0 0 400 150" preserveAspectRatio="xMidYMid meet"><g fill="none" stroke="#1c2024" stroke-width="1.2" stroke-linecap="round">' +
'<path d="M18 110L70 40" stroke="#d9532b" stroke-width="2.2"/><circle cx="58" cy="56" r="7" fill="#f5f6f3"/>' +
'<rect x="120" y="44" width="70" height="40" fill="#f5f6f3"/><path d="M65 56H120" stroke-dasharray="3 3"/>' +
'<rect x="230" y="44" width="70" height="40" fill="#f5f6f3"/><path d="M190 64H230"/><path d="M300 64H330"/>' +
'<rect x="330" y="44" width="58" height="40" fill="#f5f6f3"/></g>' +
'<text x="128" y="62">arduino</text><text class="s" x="128" y="76">reads both oars</text>' +
'<text x="238" y="62">serial</text><text class="s" x="238" y="76">usb, per stroke</text>' +
'<text x="337" y="62">unity</text><text class="s" x="337" y="76">C# · boat</text>' +
'<text class="s" x="40" y="30">oar + sensor<title>placeholder: sensor type</title></text><text class="s" x="80" y="112">× 2 (left, right)</text></svg>';
var F = [
['01', 'photo: the two oars on their rig', 'The rig: two real oars on pivots, one sensor each. ' + PH('Built from a workbench and scrap timber.')],
['02', 'photo: playtest', 'Playtesting. ' + PH('People rowed far harder than we had tuned for.')],
['03', 'screenshot: the lake in Unity', 'In the headset: the boat moves only as fast as you row.'],
['04', 'sketch: how the oars reach the game', 'Each oar\'s angle is read by the Arduino and sent over USB serial; Unity turns two angles into a stroke and moves the boat.', 1]
];
return '<div class="plate"><div class="strip">' + F.map(function (f) {
return '<button class="frame' + (f[3] ? ' wide' : '') + '" data-note="' + f[2].replace(/"/g, '&quot;') + '"><span class="hatch"' + (f[3] ? ' style="opacity:.35"' : '') + '></span>' + (f[3] ? wire : '') + '<span class="fn">' + f[0] + 'A</span><span class="fl">' + f[1] + '</span></button>';
}).join('') + '</div><div class="cap"><span class="live" id="rowCap">Four frames from the project. Point at one for its caption.</span><span><b>fig. F2</b> · ' + PH('photos to come') + '</span></div></div>';
}
// ---------- F3 network ----------
var PEERS = [
[90, 70, .91, 12, 12], [190, 170, .84, 11, 11], [70, 250, .77, 9, 9], [300, 80, .95, 14, 14], [320, 230, .62, 8, 9],
[430, 140, .88, 12, 12], [450, 280, .18, 3, 12, 1], [540, 70, .80, 10, 10], [580, 210, .72, 9, 9], [220, 300, .35, 4, 10, 1], [660, 130, .90, 13, 13]
];
var EDGES = [[0, 1], [0, 3], [1, 2], [1, 3], [1, 4], [2, 9], [3, 5], [3, 7], [4, 5], [4, 6], [4, 9], [5, 7], [5, 8], [6, 8], [7, 10], [8, 10], [5, 10], [6, 9]];
function netPlate() {
var s = '<svg viewBox="0 0 720 340" role="img" aria-label="Peer network with reputation scores">';
EDGES.forEach(function (e, i) { var a = PEERS[e[0]], b = PEERS[e[1]]; s += '<line class="e' + (a[5] || b[5] ? ' bad' : '') + '" data-a="' + e[0] + '" data-b="' + e[1] + '" x1="' + a[0] + '" y1="' + a[1] + '" x2="' + b[0] + '" y2="' + b[1] + '"/>'; });
PEERS.forEach(function (p, i) { var r = 12 + p[2] * 10; s += '<g class="pe' + (p[5] ? ' fr' : '') + '" data-i="' + i + '" tabindex="0"><circle cx="' + p[0] + '" cy="' + p[1] + '" r="' + r + '"/><text x="' + p[0] + '" y="' + (p[1] + 3.5) + '">' + String(i + 1).padStart(2, '0') + '</text></g>'; });
s += '</svg>';
return '<div class="plate"><div class="netwrap"><div class="net">' + s + '</div><div class="readout"><div class="lbl" style="margin-bottom:8px">peer readout</div><div class="scr" id="netScr">Point at a peer.<br><span style="color:var(--mute)">Circle size is its reputation. Dashed peers claim to store more than they do.</span></div>' +
'<div class="lg">Reputation rises when a peer can prove it still holds the chunks it promised, and falls when it can\'t. Numbers here are illustrative, ' + PH('not results from the thesis') + '.</div></div></div>' +
'<div class="cap"><span>Each peer stores pieces of an archive for the others. The question: who do you trust with yours?</span><span><b>fig. F3</b></span></div></div>';
}
function peerReadout(i) {
var p = PEERS[i], nb = EDGES.filter(function (e) { return e[0] === i || e[1] === i; }).length, low = p[2] < .5;
return '<span class="k">peer</span>' + String(i + 1).padStart(2, '0') + (p[5] ? ' <span style="color:var(--acc)">· freeloading</span>' : '') +
'<br><span class="k">reputation</span>' + p[2].toFixed(2) + '<div class="gauge' + (low ? ' low' : '') + '"><i style="width:' + Math.round(p[2] * 100) + '%"></i></div>' +
'<span class="k">holds</span>' + p[3] + ' of ' + p[4] + ' chunks' + (p[3] < p[4] ? ' <span style="color:var(--acc)">(claims ' + p[4] + ')</span>' : '') +
'<br><span class="k">neighbours</span>' + nb + '<br><span class="k">verdict</span>' + (low ? '<span style="color:var(--acc)">don\'t store here</span>' : 'safe to store here');
}
// ---------- F4 before / after ----------
function baPlate() {
return '<div class="plate"><div class="ba"><div class="side"><h5><b>before</b><span>' + PH('notes.txt') + '</span></h5><ul class="notes">' +
'<li><span>host-01</span><span>ssh in, git pull, docker compose up -d</span></li>' +
'<li><span>host-02</span><span>same, but the compose file there is older</span></li>' +
'<li><span>host-02</span><span class="x">forgot the .env, service down for a day</span></li>' +
'<li><span>host-03</span><span>"temporary" container started by hand in 2023</span></li>' +
'<li><span>new box</span><span class="x">which of these did I do last time?</span></li>' +
'</ul></div><div class="mid">→</div><div class="side"><h5><b>after</b><span>config/</span></h5>' +
'<pre class="tree"><b>inventory/</b>hosts.yml\n<b>roles/</b> base · docker · backups\n<b>stacks/</b> one compose file per service\n<b>site.yml</b></pre>' +
'<div class="cmd">$ ansible-playbook site.yml<span class="r">every host matches git · <b>' + PH('ok=48 changed=2 failed=0') + '</b></span></div></div></div>' +
'<div class="who"><span>who wrote what:</span><span>' + meter('none') + 'inventory, by hand</span><span>' + meter('full') + 'playbooks, AI</span><span>' + meter('full') + 'compose files, AI</span></div>' +
'<div class="cap"><span>The same three servers, before and after Config. ' + PH('Details illustrative.') + '</span><span><b>fig. F4</b></span></div></div>';
}
// ---------- render features ----------
document.getElementById('features').innerHTML = FEAT.map(function (f, i) {
var p = P(f.id);
return '<div class="hiddenby" data-for="' + p.id + '">F' + (i + 1) + ' ' + p.name + ' is hidden by your filter (' + AI[p.ai].long + '). <button data-show="' + p.id + '">show it anyway</button></div>' +
'<article class="feat" id="f-' + p.id + '" data-id="' + p.id + '"><div class="meta"><div class="big">F' + (i + 1) + '</div><div class="shape">shown as: ' + f.shape + '</div><dl>' +
'<dt>' + GROUPS[p.g].name + '</dt><dd>' + (p.where || (p.g === 'school' ? 'University project' : 'Own project')) + '</dd><dt>when</dt><dd>' + p.yr + '</dd><dt>made</dt><dd>' + aiMark(p.ai) + '</dd>' +
'<dt>stack</dt><dd class="st">' + p.stack.join(' · ') + '</dd><dt>links</dt><dd>' +
(p.links.length ? p.links.map(function (l) { return l[1] ? '<a class="lk" href="' + l[1] + '"' + (l[1] === '#' ? ' onclick="return false" title="placeholder link"' : '') + '>→ ' + l[0] + '</a>' : '<span class="lk dead">' + l[0] + '</span>'; }).join('') : '<span class="lk dead">none</span>') +
(p.posts.length ? '<a class="lk" href="https://blog.lucaswinther.info" title="placeholder title">✎ ' + POST(p.posts[0]).t.toLowerCase() + '</a>' : '') + '</dd></dl></div>' +
'<div><h3>' + p.name + '</h3><p class="one">' + p.one + '</p>' + f.visual() +
'<div class="story3"><div><h4><b>i.</b> problem</h4><p>' + p.problem + '</p></div><div><h4><b>ii.</b> approach</h4><p>' + p.approach + '</p></div><div><h4><b>iii.</b> what was interesting</h4><p>' + p.interesting + '</p></div></div>' +
'</div></article>';
}).join('');
// hover captions (only respond to the visitor)
function hoverCap(sel, capId, getText, idle) {
var cap = document.getElementById(capId);
document.querySelectorAll(sel).forEach(function (el) {
function on() { document.querySelectorAll(sel).forEach(function (x) { x.classList.toggle('on', x === el); }); cap.innerHTML = getText(el); }
el.addEventListener('mouseenter', on); el.addEventListener('focus', on);
el.addEventListener('mouseleave', function () { el.classList.remove('on'); cap.innerHTML = idle; });
});
}
hoverCap('.arch .n', 'archCap', function (el) { return '<b>' + el.dataset.name + '</b> · ' + el.dataset.note; }, document.getElementById('archCap').innerHTML);
hoverCap('.frame', 'rowCap', function (el) { return el.dataset.note; }, document.getElementById('rowCap').innerHTML);
var scr = document.getElementById('netScr'), scrIdle = scr.innerHTML;
document.querySelectorAll('.pe').forEach(function (g) {
var i = +g.dataset.i;
function on() {
document.querySelectorAll('.pe').forEach(function (x) { x.classList.toggle('on', x === g); });
document.querySelectorAll('.net .e').forEach(function (e) { e.classList.toggle('on', +e.dataset.a === i || +e.dataset.b === i); });
scr.innerHTML = peerReadout(i);
}
function off() { g.classList.remove('on'); document.querySelectorAll('.net .e').forEach(function (e) { e.classList.remove('on'); }); scr.innerHTML = scrIdle; }
g.addEventListener('mouseenter', on); g.addEventListener('focus', on); g.addEventListener('mouseleave', off); g.addEventListener('blur', off);
});
// ---------- index ----------
var BY = 'group', open = {};
function yearOf(p) { var m = strip(p.yr).match(/\d{4}/); return m ? +m[0] : 0; }
function rowHTML(p) {
return '<div class="row' + (open[p.id] ? ' open' : '') + '" data-row="' + p.id + '" id="p-' + p.id + '"><button aria-expanded="' + !!open[p.id] + '"><span class="no">' + p.no + '</span><span class="thumb">' + figSVG(p.motif) + '</span>' +
'<span><span class="nm">' + p.name + (p.feat ? '<span class="fe">' + p.feat + ' above</span>' : '') + '</span><span class="ds">' + strip(p.one) + '</span></span>' +
'<span class="stk">' + p.stack.map(strip).join(' · ') + '</span><span class="yr">' + p.yr + '</span>' + aiMark(p.ai) + '<span class="pm">+</span></button>' +
'<div class="more"><div class="body"><div class="s3"><div><h4>problem</h4><p>' + p.problem + '</p></div><div><h4>approach</h4><p>' + p.approach + '</p></div><div><h4>what was interesting</h4><p>' + p.interesting + '</p></div></div>' +
'<div class="ft">' + (p.links.length ? p.links.map(function (l) { return l[1] ? '<a 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>') +
(p.feat ? '<a href="#f-' + p.id + '">↑ read the full story (' + p.feat + ')</a>' : '') +
'<span style="color:var(--mute)">related: ' + p.rel.map(function (r) { return '<a href="#p-' + r + '" data-jump="' + r + '">' + P(r).name + '</a>'; }).join(', ') + '</span></div></div></div></div>';
}
function renderIndex() {
var groups;
if (BY === 'group') groups = Object.keys(GROUPS).map(function (g) { return [GROUPS[g].key, GROUPS[g].name, GROUPS[g].sub, PROJECTS.filter(function (p) { return p.g === g; })]; });
else if (BY === 'year') {
var ps = PROJECTS.slice().sort(function (a, b) { return yearOf(b) - yearOf(a); });
groups = [['↓', 'Newest first', 'years with a dotted line are guesses', ps]];
} else groups = [['●', 'Made with AI', 'fully AI', PROJECTS.filter(function (p) { return p.ai === 'full'; })], ['?', 'Not recorded yet', 'AU work, AI share to be filled in', PROJECTS.filter(function (p) { return p.ai === 'unk'; })], ['○', 'By hand', 'no AI at all', PROJECTS.filter(function (p) { return p.ai === 'none'; })]];
document.getElementById('ix').innerHTML = groups.map(function (g) {
return '<div class="ix-g"><div class="ix-gh"><span class="L">' + g[0] + '</span><span class="n">' + g[1] + '<span>' + g[2] + ' · ' + g[3].length + '</span></span></div>' + g[3].map(rowHTML).join('') + '</div>';
}).join('');
applyPref();
}
document.getElementById('ix').addEventListener('click', function (e) {
var j = e.target.closest('[data-jump]');
if (j) { open[j.dataset.jump] = true; renderIndex(); return; }
var b = e.target.closest('.row > button'); if (!b) return;
var r = b.parentNode, id = r.dataset.row; open[id] = !open[id];
r.classList.toggle('open', open[id]); b.setAttribute('aria-expanded', open[id]);
});
document.querySelectorAll('[data-by]').forEach(function (b) {
b.addEventListener('click', function () { BY = b.dataset.by; document.querySelectorAll('[data-by]').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); }); renderIndex(); });
});
// ---------- AI preference ----------
var shown = {};
function applyPref() {
var hidden = 0;
document.querySelectorAll('.row').forEach(function (r) { var ok = allowed(P(r.dataset.row)); r.classList.toggle('off', !ok); if (!ok) hidden++; });
FEAT.forEach(function (f) {
var ok = allowed(P(f.id)) || shown[f.id];
document.getElementById('f-' + f.id).classList.toggle('gone', !ok);
document.querySelector('.hiddenby[data-for="' + f.id + '"]').classList.toggle('show', !ok);
});
document.getElementById('ixNote').textContent = hidden ? hidden + ' hidden by "' + { nofull: 'not fully by AI', hand: 'by hand only' }[PREF] + '"' : '';
document.getElementById('ixCount').textContent = (PROJECTS.length - hidden) + ' projects · click a row to open it';
}
document.querySelectorAll('[data-pref]').forEach(function (b) {
b.addEventListener('click', function () {
PREF = b.dataset.pref; shown = {};
document.querySelectorAll('[data-pref]').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); });
applyPref();
});
});
document.getElementById('features').addEventListener('click', function (e) {
var s = e.target.closest('[data-show]'); if (s) { shown[s.dataset.show] = true; applyPref(); }
});
// ---------- writing ----------
var w0 = POSTS[0];
document.getElementById('wr').innerHTML = '<a class="lead" href="https://blog.lucaswinther.info"><span class="lbl"><span class="ph" title="placeholder">' + w0.d + '</span> · ' + w0.tag + ' · newest</span><span class="t ph" title="placeholder title">' + w0.t + '</span><p>' + w0.blurb + ' Related: <u>Event Tracker for Gacha Games</u>.</p></a>' +
'<ul>' + POSTS.slice(1).map(function (w) { return '<li><a href="https://blog.lucaswinther.info"><span class="d ph" title="placeholder">' + w.d + '</span><span><span class="t ph" title="placeholder title">' + w.t + '</span><span class="b">' + w.blurb + '</span></span><span class="tg">' + w.tag + '</span></a></li>'; }).join('') + '</ul>';
// ---------- section marker in the bar (passive, follows the visitor's scroll) ----------
var navs = document.querySelectorAll('.bar nav a');
var secs = ['featured', 'index', 'writing'].map(function (id) { return document.getElementById(id); });
window.addEventListener('scroll', function () {
var y = window.scrollY + 120, cur = null;
secs.forEach(function (s) { if (s.offsetTop <= y) cur = s.id; });
navs.forEach(function (a) { a.classList.toggle('on', cur && a.getAttribute('href') === '#' + cur); });
}, { passive: true });
// deep links: #p-config opens that row
if (/^#p-/.test(location.hash)) { open[location.hash.slice(3)] = true; }
renderIndex();
if (/^#p-/.test(location.hash)) { var t = document.getElementById(location.hash.slice(1)); if (t) t.scrollIntoView(); }
})();
</script>
<!-- Placeholders: university and graduation year, project years (except AU, ScienceAtHome, 2021 portfolio), ScienceAtHome project name and engine, rowing sensor type and photos, HyperCache numbers, Config before/after details, story details marked with dotted underline, all images, blog post titles/dates/excerpts, CV PDF, AI level for AU work. -->
</body>
</html>
+927
View File
@@ -0,0 +1,927 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther / work</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&family=Fraunces:ital,opsz,wght@1,9..144,300&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f7f7f5; --panel: #efefeb; --surface: #ffffff;
--ink: #141518; --ink2: #3b3e43; --mute: #74777d; --faint: #a6a8ac;
--line: #e3e3de; --line2: #d4d5cf;
--acc: #d9532b; --acc-tint: #f8e3da;
--a-bg: #2a1a1f; --a-ink: #f1e6d6;
--sans: "Geist", system-ui, sans-serif; --mono: "Geist Mono", ui-monospace, monospace;
}
* { box-sizing: border-box; }
html { scroll-padding-top: 76px; }
body { margin: 0; font-family: var(--sans); color: var(--ink); background: var(--bg); font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.mono { font-family: var(--mono); }
.ph { text-decoration: underline dotted var(--faint); text-underline-offset: 3px; text-decoration-thickness: 1px; cursor: help; }
:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; border-radius: 4px; }
.lbl { font-family: var(--mono); font-size: 11px; letter-spacing: .02em; color: var(--mute); }
kbd { font-family: var(--mono); font-size: 10.5px; border: 1px solid var(--line2); border-bottom-width: 2px; border-radius: 4px; padding: 0 4px; color: var(--ink2); background: var(--surface); }
/* AI mark: a small square, filled by how much AI wrote */
.ai { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px; color: var(--mute); white-space: nowrap; }
.ai i { width: 9px; height: 9px; border: 1.5px solid currentColor; border-radius: 2px; flex: none; }
.ai.full { color: var(--acc); } .ai.full i { background: currentColor; }
.ai.some i { background: linear-gradient(to top, currentColor 50%, transparent 50%); }
.ai.none { color: var(--ink2); }
.ai.unk i { border-style: dashed; border-color: var(--faint); }
/* frame */
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 92px 0 52px; }
.edge { position: fixed; top: 0; right: 0; bottom: 0; width: 40px; background: var(--a-bg); color: var(--a-ink); display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; font-family: "Fraunces", serif; font-style: italic; font-weight: 300; font-size: 16px; z-index: 30; transition: width .18s ease; }
.edge:hover { width: 48px; color: #e8b04a; }
/* top bar */
.bar { position: sticky; top: 0; z-index: 20; background: var(--bg); border-bottom: 1px solid transparent; transition: border-color .2s; }
.bar.scrolled { border-bottom-color: var(--line); }
.bar .wrap { display: flex; align-items: center; height: 56px; gap: 28px; }
.crumb { font-size: 14px; display: flex; gap: 8px; align-items: center; }
.crumb b { font-weight: 600; }
.crumb .sep { color: var(--faint); }
.crumb a:hover { color: var(--acc); }
.crumb .gate { font-family: var(--mono); font-size: 11.5px; color: var(--mute); border: 1px solid var(--line2); border-radius: 999px; padding: 2px 9px; margin-right: 4px; }
.crumb .gate:hover { border-color: var(--acc); color: var(--acc); }
.bar nav { margin-left: auto; display: flex; gap: 22px; font-size: 14px; color: var(--ink2); }
.bar nav a:hover { color: var(--acc); }
.kbtn { display: inline-flex; align-items: center; gap: 10px; border: 1px solid var(--line2); background: var(--surface); border-radius: 8px; padding: 5px 7px 5px 11px; font-size: 13px; color: var(--mute); min-width: 200px; transition: border-color .15s; }
.kbtn:hover { border-color: var(--ink2); color: var(--ink2); }
.kbtn span { margin-right: auto; }
/* intro */
.intro { padding: 30px 0 26px; display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 64px; align-items: end; }
.intro h1 { font-size: 33px; line-height: 1.14; letter-spacing: -0.025em; font-weight: 500; margin: 10px 0 0; max-width: 21em; }
.intro h1 em { font-style: normal; color: var(--mute); }
.intro .side { font-size: 14px; color: var(--ink2); line-height: 1.55; }
.intro .side p { margin: 0 0 10px; }
.cv { display: grid; grid-template-columns: 1.35fr 1.35fr 1fr 1.1fr; border-top: 1px solid var(--line2); border-bottom: 1px solid var(--line); margin-bottom: 40px; }
.cv > div { padding: 12px 18px 13px 0; font-size: 13.5px; line-height: 1.45; }
.cv > div + div { padding-left: 18px; border-left: 1px solid var(--line); }
.cv .lbl { display: block; margin-bottom: 3px; }
.cv small { color: var(--mute); font-size: 13px; }
.cv a { border-bottom: 1px solid var(--line2); }
.cv a:hover { color: var(--acc); border-color: var(--acc); }
.cv .pdf { font-weight: 500; }
/* stage */
.sec-head { display: flex; align-items: baseline; gap: 16px; margin-bottom: 16px; }
.sec-head h2 { font-size: 20px; font-weight: 600; letter-spacing: -0.01em; margin: 0; }
.sec-head .count { font-family: var(--mono); font-size: 12px; color: var(--mute); }
.filter { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.seg { display: inline-flex; background: var(--panel); border-radius: 8px; padding: 3px; }
.seg button { font-size: 12.5px; padding: 4px 11px; border-radius: 6px; color: var(--mute); transition: background .15s, color .15s; }
.seg button:hover { color: var(--ink); }
.seg button[aria-pressed="true"] { background: var(--surface); color: var(--ink); box-shadow: 0 0 0 1px var(--line2); }
.filter-note { font-family: var(--mono); font-size: 11px; color: var(--mute); min-width: 0; }
.stagewrap { display: grid; grid-template-columns: 390px minmax(0, 1fr); gap: 44px; align-items: start; }
.plist { list-style: none; margin: 0; padding: 0; }
.plist .grp { display: flex; justify-content: space-between; align-items: baseline; padding: 18px 0 7px; border-bottom: 1px solid var(--line2); }
.plist .grp:first-child { padding-top: 4px; }
.plist .grp b { font-size: 12.5px; font-weight: 600; }
.plist .grp span { font-family: var(--mono); font-size: 11px; color: var(--faint); }
.row { position: relative; display: grid; grid-template-columns: 26px minmax(0, 1fr) auto 16px; align-items: center; gap: 10px; padding: 9px 10px 9px 0; border-bottom: 1px solid var(--line); font-size: 14.5px; transition: color .12s; }
.row::before { content: ""; position: absolute; left: -14px; top: 50%; width: 6px; height: 6px; margin-top: -3px; border-radius: 50%; background: var(--acc); opacity: 0; transform: scale(.4); transition: opacity .12s, transform .12s; }
.row .no { font-family: var(--mono); font-size: 11px; color: var(--faint); }
.row .nm { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row .yr { font-family: var(--mono); font-size: 11px; color: var(--mute); }
.row .ai { justify-self: end; }
.row:hover, .row.on { color: var(--ink); }
.row.on .nm { font-weight: 500; }
.row.on::before { opacity: 1; transform: none; }
.row.on .no { color: var(--acc); }
.plist:hover .row:not(.on) .nm, .plist:focus-within .row:not(.on) .nm { color: var(--ink2); }
.plist li.hide { display: none; }
.plist .empty { padding: 16px 0; font-size: 13.5px; color: var(--mute); }
.stage { position: sticky; top: 76px; }
.stage-top { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; font-size: 13px; color: var(--mute); }
.stage-top .mono { font-size: 11px; color: var(--acc); }
.stage-top .ai { margin-left: auto; }
.frame { background: var(--surface); border: 1px solid var(--line2); border-radius: 10px; overflow: hidden; box-shadow: 0 1px 0 rgba(20,21,24,.03), 0 18px 40px -28px rgba(20,21,24,.25); }
.stage-frame { view-transition-name: hero; display: block; cursor: zoom-in; }
.fbar { height: 30px; display: flex; align-items: center; gap: 10px; padding: 0 12px; border-bottom: 1px solid var(--line); background: #fbfbfa; font-family: var(--mono); font-size: 11px; color: var(--mute); }
.fbar .dotz { display: flex; gap: 5px; } .fbar .dotz i { width: 7px; height: 7px; border-radius: 50%; background: var(--line2); }
.fbar .url { flex: 1; text-align: center; margin-right: 27px; }
.fbar.fig .url { text-align: left; margin: 0; }
.fbody svg { display: block; width: 100%; height: auto; }
.fbody.swap svg { animation: pin .16s ease-out; }
@keyframes pin { from { opacity: .25; } to { opacity: 1; } }
.stage-cap { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 24px; padding: 16px 2px 0; align-items: start; }
.stage-title { view-transition-name: ptitle; font-size: 22px; font-weight: 600; letter-spacing: -0.015em; margin: 0; line-height: 1.25; width: fit-content; }
.stage-cap p { grid-column: 1; margin: 2px 0 0; color: var(--ink2); font-size: 15px; max-width: 38em; }
.stage-cap .open { grid-column: 2; grid-row: 1 / span 2; align-self: center; display: inline-flex; align-items: center; gap: 8px; font-size: 13.5px; font-weight: 500; padding: 8px 12px 8px 14px; border-radius: 8px; background: var(--ink); color: #fff; transition: background .15s; }
.stage-cap .open:hover { background: var(--acc); }
.stage-cap .open kbd { background: transparent; color: #cfd0d2; border-color: #4a4d52; }
.stage-cap .stack { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.chip { font-family: var(--mono); font-size: 11px; color: var(--ink2); background: var(--panel); border-radius: 5px; padding: 2px 7px; }
.drawnote { font-family: var(--mono); font-size: 10.5px; color: var(--faint); margin-top: 12px; }
/* writing */
.writing { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: 44px; padding: 80px 0 30px; }
.writing .sec-head { display: block; }
.writing .sec-head p { color: var(--mute); font-size: 14px; margin: 8px 0 14px; }
.writing .sec-head a { font-size: 13.5px; border-bottom: 1px solid var(--line2); }
.writing .sec-head a:hover { color: var(--acc); border-color: var(--acc); }
.posts { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line2); }
.posts a { display: grid; grid-template-columns: 96px minmax(0, 1fr) 90px 20px; gap: 18px; align-items: baseline; padding: 14px 0 15px; border-bottom: 1px solid var(--line); }
.posts .d { font-family: var(--mono); font-size: 11.5px; color: var(--mute); }
.posts .t { font-size: 16px; font-weight: 500; letter-spacing: -0.005em; }
.posts .b { display: block; color: var(--mute); font-size: 13.5px; margin-top: 2px; }
.posts .tg { font-family: var(--mono); font-size: 11px; color: var(--mute); text-align: right; }
.posts .ar { color: var(--faint); transition: transform .15s, color .15s; }
.posts a:hover .t { color: var(--acc); }
.posts a:hover .ar { color: var(--acc); transform: translateX(3px); }
footer { border-top: 1px solid var(--line); margin-top: 60px; padding: 22px 0 40px; font-size: 13px; color: var(--mute); }
footer .wrap { display: flex; flex-wrap: wrap; gap: 8px 26px; }
footer a:hover { color: var(--acc); }
footer .phs { flex-basis: 100%; font-size: 12px; color: var(--faint); margin-top: 6px; }
/* detail */
#detail { display: none; padding-top: 8px; padding-bottom: 40px; }
body.in-detail #home { display: none; }
body.in-detail #detail { display: block; }
.dnav { display: flex; align-items: center; gap: 16px; padding: 14px 0 26px; font-size: 13.5px; }
.back { display: inline-flex; align-items: center; gap: 8px; color: var(--ink2); padding: 5px 10px 5px 8px; margin-left: -8px; border-radius: 7px; transition: background .15s, color .15s; }
.back:hover { background: var(--panel); color: var(--ink); }
.dnav .where { color: var(--mute); }
.dnav .pn { margin-left: auto; display: flex; align-items: center; gap: 4px; }
.dnav .pn a { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 7px; color: var(--ink2); }
.dnav .pn a:hover { background: var(--panel); color: var(--ink); }
.dnav .pn .of { font-family: var(--mono); font-size: 11px; color: var(--mute); padding: 0 6px; }
.dhead { margin-bottom: 26px; }
.dhero { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 56px; align-items: start; }
.d-title { view-transition-name: ptitle; font-size: 44px; line-height: 1.08; letter-spacing: -0.03em; font-weight: 600; margin: 8px 0 12px; width: fit-content; }
.dhead .pitch { font-size: 19px; line-height: 1.45; color: var(--ink2); margin: 0; max-width: 34em; }
.facts { margin: 0; font-size: 13.5px; }
.facts div { display: grid; grid-template-columns: 80px 1fr; gap: 12px; padding: 7px 0; border-top: 1px solid var(--line); }
.facts dt { font-family: var(--mono); font-size: 11px; color: var(--mute); padding-top: 2px; }
.facts dd { margin: 0; }
.d-frame { view-transition-name: hero; }
.dgrid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 56px; margin-top: 36px; }
.story section { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: 24px; padding: 18px 0; border-top: 1px solid var(--line); }
.story h3 { font-size: 13px; font-weight: 600; margin: 3px 0 0; }
.story h3 span { display: block; font-family: var(--mono); font-weight: 400; font-size: 11px; color: var(--mute); margin-top: 2px; }
.story p { margin: 0; font-size: 16px; line-height: 1.6; color: var(--ink2); max-width: 36em; }
.who { list-style: none; padding: 0; margin: 0; }
.who li { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
.who li:last-child { border-bottom: 0; }
.aside h4 { font-family: var(--mono); font-weight: 400; font-size: 11px; color: var(--mute); margin: 18px 0 6px; }
.aside h4:first-child { margin-top: 2px; }
.aside ul { list-style: none; margin: 0; padding: 0; }
.aside li a { display: flex; justify-content: space-between; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 14px; }
.aside li a:hover { color: var(--acc); }
.aside li a small { font-family: var(--mono); font-size: 11px; color: var(--mute); white-space: nowrap; }
.aside .dead { padding: 7px 0; font-size: 13.5px; color: var(--mute); }
.aside .stack { display: flex; flex-wrap: wrap; gap: 6px; }
.pnbig { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 48px; }
.pnbig a { border: 1px solid var(--line2); border-radius: 10px; padding: 14px 18px 16px; background: var(--surface); transition: border-color .15s; }
.pnbig a:hover { border-color: var(--ink2); }
.pnbig a:last-child { text-align: right; }
.pnbig .lbl { display: block; margin-bottom: 4px; }
.pnbig b { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
/* command palette */
dialog.pal { border: 1px solid var(--line2); border-radius: 12px; padding: 0; width: min(600px, calc(100vw - 32px)); margin-top: 14vh; background: var(--surface); color: var(--ink); box-shadow: 0 30px 70px -30px rgba(20,21,24,.45); overflow: hidden; }
dialog.pal::backdrop { background: rgba(247,247,245,.72); }
dialog.pal[open] { animation: palin .14s ease-out; }
@keyframes palin { from { opacity: 0; transform: translateY(-4px) scale(.99); } to { opacity: 1; transform: none; } }
.pal-in { display: flex; align-items: center; gap: 10px; padding: 0 16px; border-bottom: 1px solid var(--line); }
.pal-in svg { flex: none; }
.pal input { flex: 1; border: 0; outline: 0; font: inherit; font-size: 16px; padding: 16px 0; background: none; color: var(--ink); }
.pal ul { list-style: none; margin: 0; padding: 6px; max-height: 380px; overflow: auto; }
.pal .g { font-family: var(--mono); font-size: 10.5px; color: var(--faint); padding: 10px 10px 4px; }
.pal .it { display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 7px; cursor: pointer; font-size: 14px; }
.pal .it .k { font-family: var(--mono); font-size: 10.5px; color: var(--faint); width: 22px; }
.pal .it .s { margin-left: auto; font-size: 12px; color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 45%; }
.pal .it.on { background: var(--panel); }
.pal .it.on .k { color: var(--acc); }
.pal .none { padding: 18px 10px; color: var(--mute); font-size: 14px; }
.pal-foot { display: flex; gap: 16px; padding: 8px 16px; border-top: 1px solid var(--line); font-size: 11.5px; color: var(--mute); background: #fbfbfa; }
/* svg drawing palette */
svg text { font-family: var(--sans); }
.k-ink { fill: var(--ink); } .k-2 { fill: #5d6066; } .k-3 { fill: #a6a8ac; } .k-4 { fill: #dcddd8; } .k-5 { fill: #f0f0ec; } .k-w { fill: #fff; } .k-acc { fill: var(--acc); } .k-at { fill: var(--acc-tint); } .nf { fill: none; } .pc { font-family: 'DejaVu Sans', 'Segoe UI Symbol', serif; }
.ln { stroke: #e3e3de; stroke-width: 1; fill: none; } .ln2 { stroke: #c9cac4; stroke-width: 1; fill: none; }
.sk { stroke: var(--ink); fill: none; stroke-width: 1.5; } .ska { stroke: var(--acc); fill: none; stroke-width: 1.5; }
.bd { stroke: #d4d5cf; stroke-width: 1; }
.tx { font-size: 11px; fill: #6b6e74; } .tb { font-size: 11px; fill: var(--ink); font-weight: 600; } .tm { font-family: var(--mono); }
.tw { fill: #fff; font-size: 11px; font-weight: 500; } .ta { fill: var(--acc); font-size: 11px; } .t3 { fill: #a6a8ac; font-size: 9px; letter-spacing: .06em; }
/* mockup note */
.mk { position: fixed; top: 10px; right: 52px; z-index: 60; width: 320px; background: #fff; color: #222; border: 1px solid #bbb; padding: 10px 26px 10px 12px; font: 12.5px/1.4 system-ui, sans-serif; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
.mk b { display: block; margin-bottom: 4px; }
.mk button { position: absolute; top: 4px; right: 6px; border: 0; background: none; font-size: 16px; cursor: pointer; color: #666; }
::view-transition-group(*) { animation-duration: .28s; animation-timing-function: cubic-bezier(.2, .7, .2, 1); }
::view-transition-old(root), ::view-transition-new(root) { animation-duration: .2s; }
@media (prefers-reduced-motion: reduce) { *, ::view-transition-group(*) { transition: none !important; animation: none !important; } }
@media (max-width: 1100px) {
.intro { grid-template-columns: 1fr; gap: 18px; }
.cv { grid-template-columns: 1fr 1fr; } .cv > div:nth-child(3) { border-left: 0; padding-left: 0; } .cv > div:nth-child(n+3) { border-top: 1px solid var(--line); }
.stagewrap { grid-template-columns: 320px minmax(0, 1fr); gap: 28px; }
.dhero, .dgrid { grid-template-columns: 1fr; gap: 24px; }
}
@media (max-width: 760px) {
.wrap { padding: 0 18px; }
.edge { position: static; writing-mode: horizontal-tb; width: auto; height: 40px; }
.edge:hover { width: auto; }
.bar nav, .kbtn span, .kbtn kbd { display: none; } .kbtn { min-width: 0; margin-left: auto; }
.intro h1 { font-size: 28px; }
.cv { grid-template-columns: 1fr; } .cv > div + div { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); }
.sec-head { flex-wrap: wrap; } .filter { margin-left: 0; flex-wrap: wrap; }
.stagewrap { grid-template-columns: 1fr; } .stage { display: none; }
.row { grid-template-columns: 26px minmax(0, 1fr) auto; } .row .yr { display: none; }
.writing { grid-template-columns: 1fr; gap: 10px; padding-top: 50px; }
.posts a { grid-template-columns: 1fr; gap: 2px; } .posts .tg, .posts .ar { display: none; }
.d-title { font-size: 32px; } .story section { grid-template-columns: 1fr; gap: 6px; }
.dnav .where { display: none; } .pnbig { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="mk" id="mk"><button aria-label="dismiss" onclick="this.parentNode.remove()">×</button><b>tech-r3-a · Project stage</b>Modern and light: projects are the stage. Hover or focus a project and the big preview swaps at once; click for the full project, with a View Transition, back and prev/next. Press <kbd>/</kbd> or <kbd>⌘K</kbd> to jump anywhere. Borrows the list-and-preview from ntch.design, the craft and command palette from rauno.me, and the reactive feel (not the colours) from sankar.work.</div>
<a class="edge" href="nav-d.html" title="cross over to the personal side">off the clock →</a>
<header class="bar" id="bar">
<div class="wrap">
<div class="crumb"><a class="gate" href="nav-d.html" title="back to the two gates">↖ gate</a><a href="#" data-home><b>Lucas Winther</b></a><span class="sep">/</span><span>work</span></div>
<nav><a href="#" data-jump="projects">Projects</a><a href="#" data-jump="writing">Writing</a><a href="#" data-jump="cv">CV</a></nav>
<button class="kbtn" id="kbtn" aria-label="Open command palette"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="7" cy="7" r="5"/><path d="M11 11l3.5 3.5"/></svg><span>Jump to…</span><kbd>⌘K</kbd></button>
</div>
</header>
<main id="home" class="wrap">
<section class="intro" id="intro">
<div>
<div class="lbl">Computer scientist · Aarhus, Denmark <span class="ph" title="placeholder: city">(city?)</span></div>
<h1>I build and run research software at Aarhus University. <em>On the side I make small tools, games and experiments, some by hand and some with AI.</em></h1>
</div>
<div class="side">
<p>Every project below says how it was made. Hover one to see it, open it for the why and the how.</p>
<p class="lbl" style="margin:0">Press <kbd>/</kbd> to jump to any project, post or section.</p>
</div>
</section>
<section class="cv" id="cv" aria-label="CV in short">
<div><span class="lbl">now</span>IT-medarbejder, IT Research Tech<br><small>Aarhus University · Jan 2025–now</small></div>
<div><span class="lbl">before</span>ScienceAtHome, intern <small>2019</small><br>TERMA Group, student helper <small>2016–18</small></div>
<div><span class="lbl">education</span>BSc Computer Science<br><small><span class="ph" title="placeholder: university and year">Aarhus University, 2022</span> · thesis: HyperCache</small></div>
<div><span class="lbl">contact</span><a href="mailto:lucas@lucaswinther.info">email</a> · <a href="https://github.com/StereotypicalCat">GitHub</a> · <a href="https://www.linkedin.com/in/lucas-winther">LinkedIn</a><br><a class="pdf" href="#" onclick="return false" title="placeholder: CV PDF">↓ CV, PDF</a> <small class="ph" title="placeholder">(1 page)</small></div>
</section>
<section id="projects">
<div class="sec-head">
<h2>Projects</h2><span class="count" id="count">17</span>
<div class="filter">
<span class="filter-note" id="fnote"></span>
<div class="seg" role="group" aria-label="Filter by how it was made">
<button aria-pressed="true" data-pref="all">any way</button>
<button aria-pressed="false" data-pref="nofull">not fully by AI</button>
<button aria-pressed="false" data-pref="hand">by hand only</button>
</div>
</div>
</div>
<div class="stagewrap">
<ul class="plist" id="plist"></ul>
<div class="stage" id="stage" aria-live="polite">
<div class="stage-top"><span class="mono" id="sNo">01</span><span id="sWhere"></span><span id="sAi"></span></div>
<a class="frame stage-frame" id="sFrame" href="#" aria-label="Open project"></a>
<div class="stage-cap">
<h3 class="stage-title" id="sTitle"></h3>
<p id="sPitch"></p>
<a class="open" id="sOpen" href="#">Open project <kbd>↵</kbd></a>
<div class="stack" id="sStack"></div>
</div>
<div class="drawnote">drawn stand-in for a screenshot · placeholder</div>
</div>
</div>
</section>
<section class="writing" id="writing">
<div class="sec-head"><h2>Latest writing</h2><p>On AI, devops and the homelab. Posted at blog.lucaswinther.info.</p><a href="https://blog.lucaswinther.info">All posts →</a></div>
<ul class="posts" id="posts"></ul>
</section>
</main>
<main id="detail" class="wrap" aria-live="polite"></main>
<footer>
<div class="wrap">
<a href="nav-d.html">↖ back to the gate</a>
<a href="nav-d.html">off the clock →</a>
<a href="#" onclick="return false" title="placeholder: the frozen 2021 site">old site, 2021, by hand</a>
<a href="#" onclick="return false" title="placeholder: /simple business card">/simple</a>
<span style="margin-left:auto">lucas@lucaswinther.info</span>
<div class="phs">Placeholders (dotted underline): city, university and year, most project years, story details, every drawn preview, blog post titles, dates and blurbs, CV PDF, AI level of AU work, numbers inside the drawings.</div>
</div>
</footer>
<dialog class="pal" id="pal" aria-label="Jump to">
<div class="pal-in"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="#74777d" stroke-width="1.6"><circle cx="7" cy="7" r="5"/><path d="M11 11l3.5 3.5"/></svg><input id="palq" placeholder="Jump to a project, post or section…" autocomplete="off" spellcheck="false" aria-controls="pallist"></div>
<ul id="pallist" role="listbox"></ul>
<div class="pal-foot"><span><kbd>↑</kbd> <kbd>↓</kbd> move</span><span><kbd>↵</kbd> open</span><span><kbd>esc</kbd> close</span></div>
</dialog>
<script>
var PH = function (s) { return '<span class="ph" title="placeholder">' + s + '</span>'; };
var GROUPS = {
work: { key: 'A', name: 'Work', sub: 'paid, for someone else' },
own: { key: 'B', name: 'Own', sub: 'built for myself' },
school: { key: 'C', name: 'School', sub: 'university and high school' }
};
var AI = {
full: { label: 'fully AI', long: 'made with AI' },
some: { label: 'some AI', long: 'some AI help' },
none: { label: 'by hand', long: 'no AI, by hand' },
unk: { label: 'AI: ?', long: 'AI level not recorded yet (placeholder)' }
};
var POSTS = [
{ id: 'agent', t: "What I let the agent write, and what I didn't", d: '2026-10-14', tag: 'AI', blurb: 'Going back through the gacha tracker file by file and marking every part by who wrote it.' },
{ id: 'slurm', t: 'Slurm on a cloud that scales to zero', d: '2026-09-02', tag: 'devops', blurb: 'What it takes for a job queue to grow nodes on demand, and shrink again without killing anything.' },
{ id: 'vault', t: 'Moving secrets out of .env and into OpenBao', d: '2026-07-21', tag: 'homelab', blurb: 'The boring migration that made every other change less scary.' },
{ id: 'ansible', t: 'Ansible for three servers is not overkill', d: '2026-05-08', tag: 'devops', blurb: 'Why Config exists, and the week every run restarted every container.' },
{ id: 'thesis', t: 'Rereading my bachelor thesis four years later', d: '2026-03-30', tag: 'notes', blurb: 'What HyperCache got right about trust, and what I would simulate differently now.' }
];
var PROJECTS = [
{ id: 'compute', g: 'work', name: 'Compute job platform', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'arch',
one: 'Slurm jobs from a browser: a React/Node app in front of a cluster that scales itself on Oracle Cloud.',
stack: ['Slurm', 'React', 'Node.js', 'Oracle Cloud', 'OAuth / Entra ID', 'ERDA', 'Docker'],
fig: 'diagram: web app → Slurm → cloud nodes',
problem: 'Researchers need to run heavy jobs without first learning Slurm, SSH and batch scripts. Use is bursty, so a fixed cluster either sits idle or runs out.',
approach: 'A full-stack React/Node web app in front of Slurm. People log in with their AU account (OAuth with Entra ID), pick their data from ERDA, AU\'s own storage, and submit. The web stack and the compute nodes both scale up and down on Oracle Cloud.',
interesting: 'Making "easy to use" and "scales to zero" live together: ' + PH('a queued job has to explain why it is waiting while a node boots, instead of just looking stuck.'),
parts: [['web app (React/Node)', 'unk'], ['Slurm + cloud scaling', 'unk'], ['Entra ID login, ERDA link', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['docs', 'ops', 'config'], posts: ['slurm', 'vault'] },
{ id: 'docs', g: 'work', name: 'Document & information services', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'doc',
one: 'Overleaf and OpenProject, run by us for researchers and wired into the university\'s own systems.',
stack: ['Overleaf', 'OpenProject', 'Docker', 'Linux'],
fig: 'screenshot: Overleaf project list',
problem: 'Research groups write papers together and plan projects, and want to do it on tools the university can run, secure and back up itself.',
approach: 'Self-hosted Overleaf and OpenProject, containerised, with integrations into the rest of AU\'s services. ' + PH('Which integrations exactly is still to be written up.'),
interesting: PH('Updating two large open-source products without breaking a group that is three days from a deadline.'),
parts: [['deployment', 'unk'], ['integrations', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['compute', 'ops'], posts: ['vault'] },
{ id: 'ops', g: 'work', name: 'Secure test & run setup', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'infra',
one: 'Trying newer versions of software while keeping everything isolated, patched and watched.',
stack: ['OpenBao / Vault', 'Grafana', 'Docker', 'Ansible', 'Linux'],
fig: 'screenshot: Grafana overview board',
problem: 'A research IT team has to try new software and new versions early, without that becoming the weak spot in everything else.',
approach: 'Everything runs containerised and isolated, configured with Ansible, secrets in OpenBao / HashiCorp Vault, and watched in Grafana.',
interesting: PH('Deciding what deserves an alert. Most dashboards are for curiosity; only a few things should wake someone up.'),
parts: [['Ansible roles', 'unk'], ['dashboards & alerts', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['config', 'compute'], posts: ['vault', 'ansible'] },
{ id: 'sah', g: 'work', name: 'ScienceAtHome prototype', yr: '2019', where: 'ScienceAtHome · internship', ai: 'none', motif: 'game',
one: 'A month-long internship: an early prototype of a new project\'s base mechanics, to show the people it was for.',
stack: [PH('Unity'), 'prototyping', 'pair programming'],
fig: 'screenshot: prototype, ' + PH('project name'),
problem: 'A new project needed its core idea tested before anyone built it properly, and something concrete to show the people it was made for.',
approach: 'Joined the ideation and planning, built a quick prototype of the base mechanics, then pair-programmed with experienced developers to start the real project.',
interesting: 'Seeing how a prototype changes the conversation: people react to something they can try very differently from a plan.',
parts: [['prototype', 'none']],
links: [], rel: ['rowing', 'vrclass'], posts: [] },
{ id: 'gacha', g: 'own', name: 'Event Tracker for Gacha Games', yr: PH('2025'), ai: 'full', motif: 'web',
one: 'Scrapes event schedules from several games into one timeline, so limited events stop slipping by.',
stack: ['JavaScript', 'scraping', 'web app'],
fig: 'screenshot: the event timeline',
problem: 'Each game announces its limited events somewhere different, in a different time zone. Keeping track meant checking five places.',
approach: 'Scrapers per game feed one normalised list of events, shown as a single timeline in a small web app.',
interesting: 'A test of building with AI: ' + PH('I wrote the spec and the fixtures, the agent wrote the scrapers, and every change was read before it went in.'),
parts: [['spec & fixtures', 'none'], ['scrapers', 'full'], ['timeline UI', 'full']],
links: [['live demo', '#'], ['GitHub', 'https://github.com/StereotypicalCat']], rel: ['config', 'expenses'], posts: ['agent'] },
{ id: 'config', g: 'own', name: 'Config', yr: PH('2025'), ai: 'full', motif: 'infra',
one: 'Ansible and docker-compose: every service on my servers, deployed and updated from one repo.',
stack: ['Ansible', 'IaC', 'docker-compose', 'Docker', 'Linux'],
fig: 'screenshot: repo tree and a playbook run',
problem: 'Services were set up by hand over SSH, one machine at a time, and the only documentation was my memory.',
approach: 'An inventory of hosts, one role per kind of thing, and compose stacks templated from one pattern. One command brings a machine to the state git describes.',
interesting: PH('Idempotency: for a week every run restarted every container, because a template rendered a new timestamp each time.'),
parts: [['inventory & layout', 'none'], ['playbooks & roles', 'full'], ['compose files', 'full']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['ops', 'gacha'], posts: ['ansible'] },
{ id: 'chess', g: 'own', name: 'Chessswap', yr: PH('2020'), ai: 'none', motif: 'board',
one: 'Chess, except the board swaps every few turns.',
stack: ['React', 'JavaScript'],
fig: 'screenshot: board mid-swap',
problem: 'Chess openings are memorised; a twist that breaks memorisation makes it about thinking again.',
approach: 'A React board with normal chess rules, plus a swap every few turns that changes whose pieces you are playing.',
interesting: 'Rules that sound simple get strange at the edges: ' + PH('what happens to castling rights and check right at a swap?'),
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['breakout', 'dashboard'], posts: [] },
{ id: 'expenses', g: 'own', name: 'Monthly Expenses Tracker', yr: PH('2021'), ai: 'none', motif: 'web',
one: 'A small web app for where the money goes each month, in DKK.',
stack: ['JavaScript', 'web app'],
fig: 'screenshot: month view',
problem: 'Bank apps show transactions, not the shape of a month.',
approach: 'A plain JavaScript web app: enter expenses, see each month by category, in kroner.',
interesting: PH('Storing amounts in øre to dodge floating-point surprises.'),
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['gacha', 'dashboard'], posts: [] },
{ id: 'lolbot', g: 'own', name: 'LoL Winrate Discord Bot', yr: PH('2020'), ai: 'none', motif: 'bot',
one: 'Ask in Discord, get a League of Legends champion\'s current winrate.',
stack: ['Node.js', 'Discord API'],
fig: 'screenshot: bot reply in a channel',
problem: 'Mid-draft, nobody wants to alt-tab to a stats site.',
approach: 'A Node.js bot that answers a command with the champion\'s current winrate.',
interesting: PH('Rate limits and caching: a bot that is popular for ten minutes a night.'),
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['gacha'], posts: [] },
{ id: 'portfolio', g: 'own', name: 'Portfolio (2021)', yr: '2021', ai: 'none', motif: 'web',
one: 'The old React + Material UI site, kept frozen as the by-hand archive.',
stack: ['React', 'Material UI'],
fig: 'screenshot: the 2021 site',
problem: 'A place to put projects, with a search.',
approach: 'Create React App and Material UI, projects read from one info.json.',
interesting: 'It is now the archive: everything on it was written by hand, before AI tools.',
parts: [['everything', 'none']],
links: [['old site', '#']], rel: ['borlaug'], posts: [] },
{ id: 'borlaug', g: 'own', name: 'Tribute to Norman Borlaug', yr: PH('2016'), ai: 'none', motif: 'doc',
one: 'One of my first websites, a freeCodeCamp tribute page.',
stack: ['HTML', 'CSS'],
fig: 'screenshot: tribute page',
problem: 'Learning to make a web page at all.',
approach: 'One HTML page and a stylesheet, following the freeCodeCamp brief.',
interesting: 'Kept for honesty: this is where it started.',
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['portfolio'], posts: [] },
{ id: 'hypercache', g: 'school', name: 'HyperCache', yr: PH('2022'), where: 'Bachelor thesis · ' + PH('Aarhus University'), ai: 'none', motif: 'net',
one: 'Reputation systems for peer-to-peer archival: who can you trust to keep your data?',
stack: ['Node.js', 'P2P', 'simulation', 'reputation systems'],
fig: 'diagram: peers and trust',
problem: 'In a peer-to-peer archive, data only survives if peers keep storing it. Some peers will freeload or quietly drop data.',
approach: 'A reputation system where peers rate each other on whether data is really kept, built and simulated in Node.js.',
interesting: PH('How fast trust recovers after a bad actor leaves, and how cheaply a newcomer can fake a good history.'),
parts: [['thesis & code', 'none']],
links: [['thesis (PDF)', '#']], rel: ['vrclass', 'compute'], posts: ['thesis'] },
{ id: 'dashboard', g: 'school', name: 'Student Dashboard', yr: PH('2021'), ai: 'none', motif: 'web',
one: 'A dashboard for students, shaped by user studies rather than guesses.',
stack: ['React', 'user studies'],
fig: 'screenshot: dashboard, second iteration',
problem: 'Students juggle several university systems to answer simple questions like "what is due this week?".',
approach: 'User studies first, then a React dashboard, then studies again to see what actually helped.',
interesting: PH('The first interviews threw out half of the planned features.'),
parts: [['everything', 'none']],
links: [['report (PDF)', '#'], ['demo', '#']], rel: ['vrclass', 'chess'], posts: [] },
{ id: 'rowing', g: 'school', name: 'Mixed-Reality Rowing Game', yr: PH('2020'), ai: 'none', motif: 'game',
one: 'Row a virtual boat with two real oars, wired up through an Arduino.',
stack: ['Unity', 'C#', 'Arduino'],
fig: 'photo: the two oars on their rig',
problem: 'Rowing machines are boring and VR controllers don\'t feel like oars.',
approach: 'Two real oars with sensors read by an Arduino, sending each stroke into a Unity scene over serial.',
interesting: PH('Turning noisy sensor readings into a stroke that feels right, with almost no lag.'),
parts: [['everything', 'none']],
links: [['video', '#']], rel: ['censor', 'vrclass', 'sah'], posts: [] },
{ id: 'vrclass', g: 'school', name: 'VR-Classroom', yr: PH('2020'), ai: 'none', motif: 'vr',
one: 'A Node.js prototype of a shared virtual classroom.',
stack: ['Node.js', 'VR', 'prototyping'],
fig: 'still from the video',
problem: 'Remote teaching loses the feeling of being in the same room.',
approach: 'A Node.js prototype that keeps participants in a shared virtual room in sync.',
interesting: PH('Small things like where the teacher stands matter more than resolution.'),
parts: [['everything', 'none']],
links: [['report (PDF)', '#'], ['video', '#']], rel: ['rowing', 'dashboard', 'censor'], posts: [] },
{ id: 'censor', g: 'school', name: 'Censor Simulator', yr: PH('2018'), where: 'High-school exam project', ai: 'none', motif: 'vr',
one: 'A VR exam project in Unity: you are the one deciding what gets through.',
stack: ['Unity', 'C#', 'VR'],
fig: 'screenshot: the censor\'s desk',
problem: 'An exam project that had to be both a game and an argument.',
approach: 'A Unity VR scene where you sort and censor incoming material.',
interesting: PH('First time building for VR: comfort and scale before anything else.'),
parts: [['everything', 'none']],
links: [], rel: ['rowing', 'breakout'], posts: [] },
{ id: 'breakout', g: 'school', name: 'BreakoutPython', yr: PH('2017'), where: 'High-school exam project', ai: 'none', motif: 'board',
one: 'Breakout, written in Python for a high-school exam.',
stack: ['Python'],
fig: 'screenshot: level one',
problem: 'Learn to program a game loop from scratch.',
approach: 'Paddle, ball, bricks and collision, in Python.',
interesting: 'Collision is the whole game, and the first place bugs feel physical.',
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['chess', 'censor'], posts: [] }
];
function P(id) { for (var i = 0; i < PROJECTS.length; i++) if (PROJECTS[i].id === id) return PROJECTS[i]; }
function POST(id) { for (var i = 0; i < POSTS.length; i++) if (POSTS[i].id === id) return POSTS[i]; }
function strip(s) { return String(s).replace(/<[^>]+>/g, ''); }
function aiMark(a, withLabel) { return '<span class="ai ' + a + '" title="' + AI[a].long + '"><i></i>' + (withLabel === false ? '' : AI[a].label) + '</span>'; }
AI.full.label = 'made with AI'; AI.some.label = 'some AI'; AI.none.label = 'by hand'; AI.unk.label = 'AI: not noted';
function aiMark(a, withLabel) { return '<span class="ai ' + a + '" title="' + AI[a].long + '"><i></i>' + (withLabel === false ? '' : AI[a].label) + '</span>'; }
var PREF = 'all';
function allowed(p) { if (PREF === 'nofull') return p.ai !== 'full'; if (PREF === 'hand') return p.ai === 'none'; return true; }
function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;'); }
/* ---------- drawings: small UIs and diagrams standing in for screenshots ---------- */
function R(x, y, w, h, c, rx, extra) { return '<rect x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '" class="' + (c || 'k-4') + '"' + (rx ? ' rx="' + rx + '"' : '') + (extra || '') + '/>'; }
function T(x, y, s, c, fs, anchor, extra) { return '<text x="' + x + '" y="' + y + '" class="' + (c || 'tx') + '"' + (fs ? ' style="font-size:' + fs + 'px"' : '') + (anchor ? ' text-anchor="' + anchor + '"' : '') + (extra || '') + '>' + s + '</text>'; }
function L(x1, y1, x2, y2, c, extra) { return '<line x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2 + '" class="' + (c || 'ln') + '"' + (extra || '') + '/>'; }
function C(x, y, r, c, extra) { return '<circle cx="' + x + '" cy="' + y + '" r="' + r + '" class="' + (c || 'k-4') + '"' + (extra || '') + '/>'; }
function PL(pts, c, extra) { return '<polyline points="' + pts.join(' ') + '" class="' + c + '"' + (extra || '') + '/>'; }
function series(x, y, w, h, vals) { var n = vals.length, out = []; for (var i = 0; i < n; i++) out.push((x + i * w / (n - 1)).toFixed(1) + ',' + (y + h - vals[i] * h).toFixed(1)); return out; }
function lines(x, y, ws, gap, c) { var s = ''; for (var i = 0; i < ws.length; i++) s += R(x, y + i * gap, ws[i], 5, c || 'k-4', 2.5); return s; }
function svg(inner) { return '<svg viewBox="0 0 600 360" aria-hidden="true">' + inner + '</svg>'; }
var DRAW = {
compute: function () {
var s = R(0, 0, 132, 360, 'k-5') + T(16, 28, 'Research compute', 'tb', 12) + R(8, 46, 116, 26, 'k-w', 6, ' stroke="#e3e3de"');
['Jobs', 'New job', 'Data · ERDA', 'Nodes', 'Usage'].forEach(function (n, i) { s += T(20, 63 + i * 30, n, i ? 'tx' : 'tb', 11.5); });
s += C(24, 332, 9, 'k-4') + T(40, 329, 'signed in', 'tx', 9.5) + T(40, 342, 'AU · Entra ID', 'tb', 9.5);
s += T(152, 32, 'Jobs', 'tb', 17) + T(152, 50, '12 running · 4 queued', 'tx', 11) + R(498, 16, 86, 28, 'k-ink', 7) + T(541, 34, '+ New job', 'tw', 11.5, 'middle');
s += T(152, 78, 'NAME', 't3') + T(330, 78, 'STATUS', 't3') + T(424, 78, 'NODES', 't3') + T(500, 78, 'TIME', 't3') + L(152, 86, 584, 86, 'ln2');
[['protein-fold-03', 'running', '4', '01:12:40'], ['nlp-tokenise', 'running', '2', '00:41:05'], ['climate-ens-17', 'queued', '–', 'node booting'], ['mri-preproc', 'done', '1', '00:08:22'], ['thesis-sim-b', 'done', '8', '03:55:10']].forEach(function (r, i) {
var y = 108 + i * 28;
s += T(152, y, r[0], 'tm', 11, null, ' fill="#141518"');
s += r[1] === 'running' ? R(330, y - 12, 58, 17, 'k-at', 8.5) + T(359, y, 'running', 'ta', 10, 'middle') : r[1] === 'queued' ? R(330, y - 12, 58, 17, 'k-w', 8.5, ' stroke="#c9cac4"') + T(359, y, 'queued', 'tx', 10, 'middle') : R(330, y - 12, 58, 17, 'k-5', 8.5) + T(359, y, 'done', 'tx', 10, 'middle');
s += T(424, y, r[2], 'tx tm', 11) + T(500, y, r[3], 'tx tm', 10.5) + L(152, y + 9, 584, y + 9);
});
s += R(152, 254, 432, 92, 'k-w', 8, ' stroke="#dcddd8"') + T(166, 274, 'Cloud nodes, last 24 h', 'tb', 11) + T(570, 274, 'scaling 3 → 6', 'ta tm', 10.5, 'end');
s += PL(['166,332', '220,332', '220,320', '300,320', '300,304', '360,304', '360,326', '440,326', '440,312', '510,312'], 'sk') + PL(['510,312', '510,292', '570,292'], 'ska', ' stroke-dasharray="4 3"');
return svg(s);
},
docs: function () {
var s = R(0, 0, 600, 36, 'k-5') + T(14, 23, 'paper-draft.tex', 'tb tm', 11) + C(404, 18, 8, 'k-3') + C(416, 18, 8, 'k-at', ' stroke="#fff" stroke-width="2"') + T(430, 22, '2 editing', 'tx', 10) + R(498, 8, 88, 21, 'k-acc', 6) + T(542, 22, 'Recompile', 'tw', 10.5, 'middle');
var code = ['\\documentclass{article}', '\\usepackage{graphicx}', '', '\\title{Trust in P2P archives}', '\\begin{document}', '\\maketitle', '', '\\section{Method}', 'We simulate 400 peers that', 'store and audit each other.', '', '\\begin{figure}', ' \\includegraphics{trust}', '\\end{figure}', '\\end{document}'];
code.forEach(function (c, i) { var y = 60 + i * 19; s += T(16, y, i + 1, 'tx tm', 9.5, null, ' fill="#b8babd"') + (c ? T(40, y, esc(c), c.charAt(0) === '\\' || c.charAt(2) === '\\' ? 'ta tm' : 'tx tm', 10.5) : ''); });
s += R(286, 145, 2, 14, 'k-ink');
s += R(300, 36, 300, 324, 'k-5') + L(300, 36, 300, 360, 'ln2') + R(338, 54, 224, 306, 'k-w', 0, ' stroke="#dcddd8"');
s += T(450, 86, 'Trust in P2P archives', 'tb', 12, 'middle') + R(410, 94, 80, 4, 'k-4', 2) + T(352, 124, '1 Method', 'tb', 10) + lines(352, 134, [196, 190, 120], 11) + R(366, 176, 168, 82, 'k-5', 3) + PL(series(378, 188, 144, 58, [.1, .35, .3, .55, .62, .8, .78, .9]), 'ska') + T(450, 272, 'Figure 1: trust over time', 'tx', 9, 'middle') + lines(352, 288, [196, 184, 196, 90], 11);
return svg(s);
},
ops: function () {
var s = R(0, 0, 600, 360, 'k-5');
[['Uptime', '99.97%'], ['Containers', '42'], ['Vault', 'unsealed'], ['Alerts', '1 firing']].forEach(function (c, i) {
var x = 14 + i * 145; s += R(x, 14, 133, 58, 'k-w', 7) + T(x + 12, 34, c[0], 'tx', 10.5) + T(x + 12, 59, c[1], i === 3 ? 'ta' : 'tb', 18, null, i === 3 ? ' font-weight="600"' : '');
});
s += R(14, 84, 280, 136, 'k-w', 7) + T(26, 104, 'CPU · compute nodes', 'tb', 11) + L(26, 200, 282, 200, 'ln2');
s += PL(series(26, 116, 256, 80, [.3, .32, .45, .4, .62, .58, .7, .52, .48, .66, .8, .74, .6, .55]), 'sk') + PL(series(26, 116, 256, 80, [.1, .15, .12, .2, .18, .3, .25, .22, .28, .35, .3, .26, .2, .22]), 'ska');
s += R(306, 84, 280, 136, 'k-w', 7) + T(318, 104, 'Requests / min', 'tb', 11);
[.4, .5, .45, .62, .7, .55, .5, .66, .8, .72, .6, .58, .74, .9, .68, .6].forEach(function (v, i) { s += R(318 + i * 16, 200 - v * 80, 11, v * 80, i === 13 ? 'k-acc' : 'k-4', 2); });
s += R(14, 232, 280, 114, 'k-w', 7) + T(26, 252, 'Image updates', 'tb', 11);
[['overleaf', '5.2 → 5.3', 'staged'], ['openproject', '15.1', 'ok'], ['openbao', '2.1', 'ok'], ['grafana', '11.3', 'ok']].forEach(function (r, i) { var y = 276 + i * 20; s += T(26, y, r[0], 'tm', 10.5, null, ' fill="#141518"') + T(150, y, r[1], 'tx tm', 10.5) + T(282, y, r[2], r[2] === 'ok' ? 'tx' : 'ta', 10.5, 'end'); });
s += R(306, 232, 280, 114, 'k-w', 7) + T(318, 252, 'Disk', 'tb', 11);
[['/var/lib/docker', .62], ['/srv/erda-cache', .81], ['/backup', .38]].forEach(function (r, i) { var y = 270 + i * 25; s += T(318, y, r[0], 'tx tm', 10) + R(318, y + 6, 254, 6, 'k-5', 3) + R(318, y + 6, 254 * r[1], 6, r[1] > .8 ? 'k-acc' : 'k-ink', 3); });
return svg(s);
},
sah: function () {
var s = T(22, 32, 'Level 1', 'tb', 14) + T(578, 32, 'moves 12', 'tx tm', 11, 'end') + L(22, 46, 578, 46);
var pts = [], path = [[0, 0], [1, 0], [1, 1], [2, 1], [3, 1], [3, 2], [4, 2], [5, 2], [5, 3]];
for (var r = 0; r < 4; r++) for (var c = 0; c < 7; c++) pts.push([120 + c * 60, 92 + r * 66]);
s += PL(path.map(function (p) { return (120 + p[0] * 60) + ',' + (92 + p[1] * 66); }), 'sk', ' stroke-width="4" stroke-linecap="round" stroke-linejoin="round"');
pts.forEach(function (p, i) { s += C(p[0], p[1], 9, 'k-w', ' stroke="#c9cac4" stroke-width="1.5"'); });
path.forEach(function (p) { s += C(120 + p[0] * 60, 92 + p[1] * 66, 9, 'k-ink'); });
s += C(420, 290, 13, 'k-acc') + C(420, 290, 20, 'nf', ' stroke="#d9532b" stroke-dasharray="3 3"');
s += T(300, 344, 'base mechanics only, no art yet', 'tx', 10.5, 'middle');
return svg(s);
},
gacha: function () {
var s = T(18, 30, 'Upcoming events', 'tb', 15) + R(470, 14, 112, 24, 'k-5', 6) + R(473, 17, 54, 18, 'k-w', 4) + T(500, 30, 'Week', 'tb', 10.5, 'middle') + T(554, 30, 'Month', 'tx', 10.5, 'middle');
var days = ['Mon 9', 'Tue 10', 'Wed 11', 'Thu 12', 'Fri 13', 'Sat 14', 'Sun 15'];
days.forEach(function (d, i) { var x = 110 + i * 68; s += T(x + 6, 64, d, 'tx', 10) + L(x, 52, x, 346); });
var games = ['Game A', 'Game B', 'Game C', 'Game D', 'Game E'];
var bars = [[[0, 2.6, 'Limited banner', 'acc'], [3.2, 7, 'Login event']], [[0, 4.5, 'Story event'], [5, 7, 'Rerun']], [[1, 3, 'Double drops'], [3.4, 6.2, 'Limited banner']], [[0, 1.4, 'Shop reset'], [2.2, 7, 'Anniversary']], [[0.5, 5.5, 'Limited banner']]];
games.forEach(function (g, i) {
var y = 82 + i * 52; s += T(18, y + 22, g, 'tb', 11) + L(18, y + 44, 586, y + 44);
bars[i].forEach(function (b) { var x = 110 + b[0] * 68 + 2, w = (b[1] - b[0]) * 68 - 4; s += R(x, y + 8, w, 24, b[3] ? 'k-at' : 'k-5', 5, b[3] ? ' stroke="#d9532b"' : ' stroke="#dcddd8"') + T(x + 8, y + 24, b[2], b[3] ? 'ta' : 'tx', 10.5); });
});
var tx = 110 + 2.35 * 68; s += L(tx, 48, tx, 346, 'ska') + R(tx - 34, 330, 68, 18, 'k-acc', 9) + T(tx, 343, 'ends in 14h', 'tw', 9.5, 'middle');
return svg(s);
},
config: function () {
var s = R(0, 0, 176, 360, 'k-5') + T(16, 26, 'config', 'tb tm', 11);
var tree = [['inventory/', 1], ['hosts.yml', 2], ['roles/', 1], ['docker/', 2], ['caddy/', 2], ['backup/', 2], ['stacks/', 1], ['grafana.yml.j2', 2], ['overleaf.yml.j2', 2], ['site.yml', 1]];
tree.forEach(function (t, i) { var y = 52 + i * 22; if (t[0] === 'site.yml') s += R(8, y - 14, 160, 20, 'k-w', 4); s += T(14 + t[1] * 12, y, t[0], t[0].slice(-1) === '/' ? 'tb tm' : 'tx tm', 10.5); });
var out = [['$ ansible-playbook site.yml -l homelab', 'tb'], ['', ''], ['PLAY [homelab]', 'tb'], ['TASK [docker : install engine]', 'tx'], ['ok: [nas] ok: [pi]', 'k'], ['TASK [stacks : render compose]', 'tx'], ['ok: [nas]', 'k'], ['TASK [stacks : compose up]', 'tx'], ['ok: [nas]', 'k'], ['', ''], ['PLAY RECAP', 'tb']];
out.forEach(function (o, i) { if (o[0]) s += T(196, 34 + i * 21, o[0], (o[1] === 'k' ? 'tx' : o[1]) + ' tm', 10.5); });
s += T(196, 286, 'nas ok=24', 'tm', 10.5, null, ' fill="#141518"') + T(300, 286, 'changed=0', 'ta tm', 10.5) + T(380, 286, 'failed=0', 'tx tm', 10.5);
s += T(196, 307, 'pi ok=17', 'tm', 10.5, null, ' fill="#141518"') + T(300, 307, 'changed=0', 'ta tm', 10.5) + T(380, 307, 'failed=0', 'tx tm', 10.5);
s += R(196, 322, 8, 14, 'k-ink');
return svg(s);
},
chess: function () {
var pos = ['rnbqk..r', 'ppp..ppp', '..n..n..', '...pp...', '..B.P...', '.....N..', 'PPPP.PPP', 'RNBQK..R'], G = { k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟' }, s = '', sq = 37;
for (var r = 0; r < 8; r++) for (var c = 0; c < 8; c++) {
var x = 30 + c * sq, y = 30 + r * sq, last = (r === 5 && c === 5) || (r === 7 && c === 6);
s += R(x, y, sq, sq, last ? 'k-at' : ((r + c) % 2 ? 'k-4' : 'k-5'));
var ch = pos[r].charAt(c);
if (ch !== '.') { var white = ch === ch.toUpperCase(); s += T(x + sq / 2, y + 28, G[ch.toLowerCase()], 'pc', 27, 'middle', white ? ' fill="#fff" stroke="#141518" stroke-width=".9"' : ' fill="#141518"'); }
}
s += R(30, 30, 296, 296, 'nf', 0, ' stroke="#141518"');
s += T(360, 52, 'Chessswap', 'tb', 17) + T(360, 72, 'turn 7 · white to move', 'tx', 11);
s += T(360, 112, 'BOARD SWAPS IN', 't3');
for (var i = 0; i < 4; i++) s += R(360 + i * 26, 122, 20, 8, i < 2 ? 'k-acc' : 'k-4', 2);
s += T(470, 130, '2 turns', 'ta', 11);
s += '<path d="M372 168 h40 l-8 -7 M412 168 l-8 7" class="sk"/><path d="M412 186 h-40 l8 -7 M372 186 l8 7" class="sk"/>' + T(424, 182, 'you play the other side', 'tx', 11);
['1. e4 e5', '2. Nf3 Nc6', '3. Bc4 Nf6', '4. ⇄ swap', '5. d5 Bxd5', '6. Nf6 …'].forEach(function (m, i) { s += T(360, 230 + i * 19, m, m.indexOf('swap') > 0 ? 'ta tm' : 'tx tm', 11); });
return svg(s);
},
expenses: function () {
var s = T(20, 34, 'October 2026', 'tb', 17) + T(20, 52, 'by category', 'tx', 11) + T(580, 30, 'total', 'tx', 10.5, 'end') + T(580, 52, '12.480 kr.', 'tb tm', 18, 'end');
var cats = [['Rent', 6200], ['Food', 2940, 2600], ['Going out', 1320], ['Transport', 890], ['Other', 720], ['Subscriptions', 410]];
cats.forEach(function (c, i) {
var y = 86 + i * 34, w = c[1] / 6200 * 300;
s += T(20, y + 12, c[0], 'tb', 11.5, null, ' font-weight="500"') + R(140, y, 300, 16, 'k-5', 3) + R(140, y, w, 16, c[2] ? 'k-acc' : 'k-ink', 3) + T(580, y + 12, c[1].toLocaleString('da-DK') + ' kr.', 'tx tm', 11, 'end');
if (c[2]) { var bx = 140 + c[2] / 6200 * 300; s += L(bx, y - 4, bx, y + 20, 'sk') + T(bx + 6, y - 6, 'budget', 'ta', 9); }
});
s += L(20, 296, 580, 296);
'JFMAMJJASOND'.split('').forEach(function (m, i) { var v = [.6, .55, .62, .58, .7, .66, .8, .64, .6, .74, 0, 0][i], x = 20 + i * 47; s += R(x, 340 - v * 30, 30, v * 30, i === 9 ? 'k-acc' : (v ? 'k-4' : 'k-5'), 2) + T(x + 15, 354, m, 'tx', 9, 'middle'); });
return svg(s);
},
lolbot: function () {
var s = R(0, 0, 136, 360, 'k-5') + T(16, 28, 'friends', 'tb', 12);
['# general', '# draft', '# clips', '# memes'].forEach(function (c, i) { var y = 60 + i * 26; if (i === 1) s += R(8, y - 15, 120, 22, 'k-w', 5); s += T(18, y, c, i === 1 ? 'tb' : 'tx', 11.5); });
s += T(154, 28, '# draft', 'tb', 12) + L(136, 42, 600, 42);
s += C(170, 72, 13, 'k-4') + T(192, 68, 'mads', 'tb', 11.5) + T(226, 68, 'today 21:14', 'tx', 9.5) + T(192, 84, '!wr jinx', 'tm', 11.5, null, ' fill="#141518"');
s += C(170, 124, 13, 'k-ink') + T(170, 128, 'WR', 'tw', 8.5, 'middle') + T(192, 120, 'WinrateBot', 'tb', 11.5) + R(256, 110, 26, 13, 'k-acc', 3) + T(269, 120, 'BOT', 'tw', 8, 'middle');
s += R(192, 132, 330, 128, 'k-5', 6) + R(192, 132, 4, 128, 'k-acc', 2) + T(210, 156, 'Jinx · bottom lane', 'tb', 12.5);
[['Win rate', '51.8%'], ['Pick rate', '14.2%'], ['Games', '24.1k'], ['Patch', '14.21']].forEach(function (r, i) { var x = 210 + (i % 2) * 150, y = 184 + Math.floor(i / 2) * 40; s += T(x, y, r[0], 'tx', 10) + T(x, y + 18, r[1], 'tb tm', 14); });
s += C(170, 290, 13, 'k-4') + T(192, 286, 'sofie', 'tb', 11.5) + T(192, 302, 'ok jinx it is', 'tx', 11.5, null, ' fill="#3b3e43"');
s += R(150, 322, 434, 28, 'k-5', 7) + T(164, 340, 'Message #draft', 'tx', 11);
return svg(s);
},
portfolio: function () {
var s = '<rect width="600" height="360" fill="#1f2125"/><rect width="180" height="360" fill="#2a2d31"/>' + '<circle cx="90" cy="80" r="34" fill="#4a4e54"/>' + T(90, 140, 'Lucas Winther', 'tw', 13, 'middle') + T(90, 160, 'Computer Scientist with', '', 9.5, 'middle', ' fill="#9a9ea4"') + T(90, 173, 'passion for development.', '', 9.5, 'middle', ' fill="#9a9ea4"');
for (var i = 0; i < 4; i++) s += '<circle cx="' + (54 + i * 24) + '" cy="204" r="8" fill="#3c4046"/>';
s += '<rect x="200" y="20" width="380" height="30" rx="4" fill="#2f3237"/>' + T(214, 39, 'Search projects…', '', 10.5, null, ' fill="#7d8187"');
for (var j = 0; j < 6; j++) { var x = 200 + (j % 3) * 128, y = 66 + Math.floor(j / 3) * 144; s += '<rect x="' + x + '" y="' + y + '" width="120" height="134" rx="4" fill="#2f3237"/><rect x="' + x + '" y="' + y + '" width="120" height="62" rx="4" fill="#3a3e44"/><rect x="' + (x + 10) + '" y="' + (y + 76) + '" width="' + (70 + (j * 13) % 30) + '" height="7" rx="3" fill="#c8cbd0"/><rect x="' + (x + 10) + '" y="' + (y + 92) + '" width="96" height="5" rx="2.5" fill="#55595f"/><rect x="' + (x + 10) + '" y="' + (y + 102) + '" width="70" height="5" rx="2.5" fill="#55595f"/><rect x="' + (x + 10) + '" y="' + (y + 116) + '" width="34" height="10" rx="5" fill="#90caf9" opacity=".55"/>'; }
return svg(s);
},
borlaug: function () {
var s = T(300, 50, 'Dr. Norman Borlaug', 'tb', 20, 'middle') + T(300, 70, 'The man who saved a billion lives', 'tx', 11.5, 'middle') + R(200, 86, 200, 112, 'k-5', 2);
for (var i = 0; i < 9; i++) { var x = 222 + i * 20; s += L(x, 190, x + 4, 120 + (i % 3) * 8, 'sk', ' stroke="#a6a8ac"') + '<ellipse cx="' + (x + 4) + '" cy="' + (114 + (i % 3) * 8) + '" rx="3" ry="9" class="k-3"/>'; }
s += T(300, 214, 'photo: wheat field (alt text)', 'tx', 9.5, 'middle');
[['1914', 'Born in Cresco, Iowa'], ['1944', 'Starts wheat research in Mexico'], ['1970', 'Nobel Peace Prize'], ['2009', 'Dies, aged 95']].forEach(function (r, i) { var y = 248 + i * 24; s += C(200, y - 4, 3, 'k-ink') + T(214, y, r[0], 'tb tm', 11) + T(254, y, r[1], 'tx', 11.5); });
s += L(200, 244, 200, 316, 'ln2');
return svg(s);
},
hypercache: function () {
var N = [[90, 90], [200, 60], [320, 96], [440, 64], [530, 130], [120, 210], [250, 180], [370, 200], [500, 250], [80, 310], [210, 290], [340, 310], [450, 330]], E = [[0, 1, 3], [1, 2, 2], [2, 3, 3], [3, 4, 2], [0, 5, 1], [1, 6, 3], [2, 6, 2], [2, 7, 3], [3, 7, 1], [4, 8, 2], [5, 6, 2], [6, 7, 3], [7, 8, 2], [5, 9, 2], [5, 10, 3], [6, 10, 2], [10, 11, 3], [11, 12, 2], [8, 12, 3], [9, 10, 1]], bad = 7, s = '';
E.forEach(function (e) { var a = N[e[0]], b = N[e[1]], isBad = e[0] === bad || e[1] === bad; s += L(a[0], a[1], b[0], b[1], isBad ? 'ska' : 'sk', ' stroke-width="' + (isBad ? 1.2 : e[2] * .9) + '"' + (isBad ? ' stroke-dasharray="4 4"' : ' stroke-opacity=".55"')); });
N.forEach(function (n, i) { s += i === bad ? C(n[0], n[1], 14, 'k-w', ' stroke="#d9532b" stroke-width="2"') + C(n[0], n[1], 5, 'k-acc') : C(n[0], n[1], 11, 'k-w', ' stroke="#141518" stroke-width="1.5"') + R(n[0] - 4, n[1] - 4, 8, 8, i % 3 ? 'k-ink' : 'k-3', 1); });
s += T(386, 190, 'drops data', 'ta', 10.5) + T(386, 178, 'peer 7', 'tb', 10.5);
s += T(20, 28, 'fig. peers rating each other', 'tb', 11) + T(580, 28, 'line weight = trust · dashed = failed audit', 'tx', 10, 'end');
return svg(s);
},
dashboard: function () {
var s = T(20, 34, 'Good morning, Lucas', 'tb', 16) + T(580, 34, 'week 44', 'tx tm', 11, 'end');
s += R(20, 52, 260, 290, 'k-5', 8) + T(36, 76, 'Due this week', 'tb', 11.5) + T(264, 76, '3 left', 'ta', 10.5, 'end');
[['Report draft', 'HCI · Wed', 1], ['Quiz 4', 'Algorithms · Thu', 0], ['Lab hand-in', 'Databases · Fri', 0], ['Group meeting', 'Project · Fri', 0], ['Read ch. 7', 'HCI · Sun', 0]].forEach(function (r, i) {
var y = 98 + i * 46; s += R(32, y, 236, 38, 'k-w', 6) + R(44, y + 12, 13, 13, r[2] ? 'k-ink' : 'k-w', 3, r[2] ? '' : ' stroke="#a6a8ac"') + (r[2] ? '<path d="M47 ' + (y + 18) + ' l3 3 l5 -6" stroke="#fff" stroke-width="1.6" fill="none"/>' : '') + T(68, y + 17, r[0], 'tb', 11, null, r[2] ? ' text-decoration="line-through" fill="#a6a8ac"' : '') + T(68, y + 30, r[1], 'tx', 9.5);
});
s += T(300, 76, 'Schedule', 'tb', 11.5);
['Mon', 'Tue', 'Wed', 'Thu', 'Fri'].forEach(function (d, i) { var x = 300 + i * 56; s += T(x + 24, 98, d, 'tx', 10, 'middle') + R(x, 106, 48, 236, 'k-5', 5); });
[[0, 120, 50], [0, 200, 40], [1, 140, 70], [2, 116, 36], [2, 230, 60, 1], [3, 160, 46], [4, 124, 30], [4, 180, 80]].forEach(function (b) { s += R(304 + b[0] * 56, b[1], 40, b[2], b[3] ? 'k-acc' : 'k-4', 4); });
return svg(s);
},
rowing: function () {
var s = T(20, 28, 'fig. two real oars into a Unity scene', 'tb', 11);
s += '<path d="M30 70 L150 120 M30 170 L150 130" class="sk" stroke-width="3" stroke-linecap="round"/><path d="M20 62 l18 4 -6 14 -16 -6z M20 178 l18 -4 -6 -14 -16 6z" class="k-ink"/>';
s += R(140, 104, 60, 42, 'k-w', 6, ' stroke="#141518" stroke-width="1.5"') + T(170, 129, 'sensors', 'tx', 10, 'middle') + T(170, 164, 'rig + 2 encoders', 'tx', 9.5, 'middle');
s += L(200, 125, 250, 125, 'sk') + '<path d="M250 125 l-6 -4 v8z" class="k-ink"/>';
s += R(252, 100, 92, 50, 'k-ink', 6) + T(298, 122, 'Arduino', 'tw', 11.5, 'middle') + T(298, 138, 'reads strokes', '', 9.5, 'middle', ' fill="#a6a8ac"');
s += L(344, 125, 394, 125, 'sk', ' stroke-dasharray="4 3"') + '<path d="M394 125 l-6 -4 v8z" class="k-ink"/>' + T(369, 114, 'serial', 'ta', 9.5, 'middle');
s += R(396, 70, 184, 110, 'k-5', 6, ' stroke="#c9cac4"') + T(408, 88, 'Unity scene', 'tb', 10.5);
for (var i = 0; i < 4; i++) s += '<path d="M' + (410 + i * 8) + ' ' + (120 + i * 14) + ' q20 -6 40 0 t40 0 t40 0 t40 0" class="ln2"/>';
s += '<path d="M450 128 h70 l-10 12 h-50z" class="k-ink"/><path d="M470 128 l-20 -16 M500 128 l20 -16" class="sk"/>';
s += T(20, 222, 'stroke force over time', 'tb', 10.5) + L(20, 330, 580, 330, 'ln2');
var pts = []; for (var x = 0; x <= 560; x += 4) { var ph = (x % 140) / 140, v = ph < .45 ? Math.sin(ph / .45 * Math.PI) : 0; pts.push((20 + x) + ',' + (330 - v * 82).toFixed(1)); }
s += PL(pts, 'sk');
for (var k = 0; k < 4; k++) s += C(20 + k * 140, 330, 4, 'k-acc');
s += T(20, 348, 'catch', 'ta', 9.5) + T(160, 348, 'catch', 'ta', 9.5);
return svg(s);
},
vrclass: function () {
var s = R(30, 40, 350, 290, 'k-w', 4, ' stroke="#141518" stroke-width="1.5"') + R(120, 48, 170, 6, 'k-ink', 2) + T(205, 72, 'whiteboard', 'tx', 9.5, 'middle');
s += C(205, 98, 12, 'k-acc') + T(225, 102, 'teacher', 'ta', 10);
var sync = [[0, 0], [1, 1], [2, 2], [0, 2]];
for (var r = 0; r < 3; r++) for (var c = 0; c < 3; c++) {
var x = 70 + c * 110, y = 150 + r * 62; s += R(x, y, 70, 24, 'k-5', 3, ' stroke="#dcddd8"') + C(x + 20, y + 36, 8, 'k-ink') + C(x + 50, y + 36, 8, r === 1 && c === 2 ? 'k-w' : 'k-ink', r === 1 && c === 2 ? ' stroke="#a6a8ac" stroke-dasharray="2 2"' : '');
}
s += R(440, 150, 130, 58, 'k-ink', 7) + T(505, 175, 'Node.js server', 'tw', 11, 'middle') + T(505, 192, 'room state', '', 9.5, 'middle', ' fill="#a6a8ac"');
sync.forEach(function (p) { var x = 70 + p[1] * 110 + 50, y = 150 + p[0] * 62 + 36; s += L(x, y, 440, 179, 'ska', ' stroke-dasharray="3 4" stroke-width="1"'); });
s += L(217, 98, 440, 170, 'ska', ' stroke-dasharray="3 4" stroke-width="1"');
s += T(440, 232, 'positions, gaze and', 'tx', 10.5) + T(440, 247, 'who is talking, synced', 'tx', 10.5) + T(440, 262, 'to every headset', 'tx', 10.5);
s += T(30, 26, 'fig. one shared room, seen from above', 'tb', 11);
return svg(s);
},
censor: function () {
var s = R(0, 0, 600, 360, 'k-5') + '<path d="M0 230 L600 230" class="ln2"/><path d="M60 360 L210 230 M540 360 L390 230 M300 360 L300 230" class="ln"/>';
s += '<path d="M90 250 L510 250 L570 340 L30 340 Z" class="k-w" stroke="#141518" stroke-width="1.5"/>';
s += R(140, 90, 320, 120, 'k-w', 6, ' stroke="#c9cac4"') + T(160, 114, 'INCOMING · item 7 of 12', 't3') + lines(160, 128, [260, 240, 270, 180], 14) + R(160, 128 + 14, 120, 5, 'k-ink', 2.5) + T(160, 196, 'decide: approve or censor', 'tx', 10.5);
s += '<g transform="rotate(-8 190 290)">' + R(130, 266, 140, 52, 'k-w', 2, ' stroke="#141518"') + lines(142, 278, [110, 90, 100], 10) + '</g>';
s += '<g transform="rotate(6 400 292)">' + R(330, 266, 140, 52, 'k-w', 2, ' stroke="#141518"') + lines(342, 278, [110, 90, 100], 10) + R(352, 274, 96, 28, 'nf', 3, ' stroke="#d9532b" stroke-width="2"') + T(400, 293, 'CENSORED', 'ta', 12, 'middle', ' font-weight="600" letter-spacing="1"') + '</g>';
s += T(20, 30, 'Day 3', 'tb', 13) + T(580, 30, '5 left in tray', 'tx', 11, 'end');
return svg(s);
},
breakout: function () {
var s = R(0, 0, 600, 360, 'k-5') + T(20, 28, 'SCORE 0420', 'tb tm', 12) + T(580, 28, 'LIVES ● ● ○', 'tx tm', 11, 'end');
var cls = ['k-ink', 'k-2', 'k-acc', 'k-3', 'k-4'], gone = { '1-3': 1, '2-4': 1, '2-5': 1, '3-5': 1, '4-2': 1, '4-5': 1, '4-6': 1, '3-4': 1 };
for (var r = 0; r < 5; r++) for (var c = 0; c < 9; c++) if (!gone[r + '-' + c]) s += R(30 + c * 61, 50 + r * 22, 56, 16, cls[r], 2);
s += PL(['300,330', '360,250', '410,180', '360,120'], 'sk', ' stroke-dasharray="2 5" stroke-opacity=".6"') + C(392, 206, 6, 'k-ink') + R(250, 330, 100, 10, 'k-ink', 5);
return svg(s);
}
};
var FRAME = {
compute: ['url', 'compute.cloud.au.dk/jobs'], docs: ['url', 'overleaf.au.dk/project/…'], ops: ['url', 'grafana · research-ops'], sah: ['fig', 'prototype · name unknown'],
gacha: ['url', 'events.local/timeline'], config: ['url', '~/config — terminal'], chess: ['url', 'localhost:3000'], expenses: ['url', 'expenses · oktober'],
lolbot: ['url', 'discord · friends'], portfolio: ['url', 'lucaswinther.info, 2021'], borlaug: ['url', 'codepen · tribute page'],
hypercache: ['fig', 'diagram'], dashboard: ['url', 'dashboard.local/week'], rowing: ['fig', 'diagram'], vrclass: ['fig', 'diagram'], censor: ['fig', 'Unity · VR'], breakout: ['fig', 'python breakout.py']
};
function frameHTML(id) {
var f = FRAME[id], bar = f[0] === 'url' ? '<div class="fbar"><span class="dotz"><i></i><i></i><i></i></span><span class="url">' + f[1] + '</span></div>' : '<div class="fbar fig"><span class="url">' + f[1] + '</span></div>';
return bar + '<div class="fbody">' + DRAW[id]() + '</div>';
}
/* ---------- order, numbering ---------- */
var ORDER = PROJECTS.map(function (p) { return p.id; });
function num(id) { var n = ORDER.indexOf(id) + 1; return (n < 10 ? '0' : '') + n; }
function whereOf(p) { return p.where ? p.where : GROUPS[p.g].name + ' · ' + GROUPS[p.g].sub; }
function visible() { return PROJECTS.filter(allowed); }
/* ---------- list ---------- */
var plist = document.getElementById('plist');
(function buildList() {
var h = '';
['work', 'own', 'school'].forEach(function (g) {
var ps = PROJECTS.filter(function (p) { return p.g === g; });
h += '<li class="grp" data-g="' + g + '"><b>' + GROUPS[g].name + '</b><span>' + GROUPS[g].sub + '</span></li>';
ps.forEach(function (p) { h += '<li data-li="' + p.id + '"><a class="row" href="#p/' + p.id + '" data-id="' + p.id + '"><span class="no">' + num(p.id) + '</span><span class="nm">' + strip(p.name) + '</span><span class="yr">' + strip(p.yr).replace('2025–now', '2025–') + '</span>' + aiMark(p.ai, false) + '</a></li>'; });
});
h += '<li class="empty" id="empty" hidden>Nothing matches this filter.</li>';
plist.innerHTML = h;
})();
/* ---------- stage ---------- */
var stageId = null, sFrame = document.getElementById('sFrame');
function setStage(id, anim) {
if (id === stageId) return; stageId = id; var p = P(id);
sFrame.innerHTML = frameHTML(id); sFrame.href = '#p/' + id;
if (anim) sFrame.querySelector('.fbody').classList.add('swap');
document.getElementById('sNo').textContent = num(id);
document.getElementById('sWhere').innerHTML = whereOf(p) + ' · ' + p.yr;
document.getElementById('sAi').innerHTML = aiMark(p.ai);
document.getElementById('sTitle').innerHTML = p.name;
document.getElementById('sPitch').innerHTML = p.one;
document.getElementById('sOpen').href = '#p/' + id;
document.getElementById('sStack').innerHTML = p.stack.slice(0, 6).map(function (t) { return '<span class="chip">' + t + '</span>'; }).join('');
plist.querySelectorAll('.row').forEach(function (r) { r.classList.toggle('on', r.dataset.id === id); });
}
plist.addEventListener('mouseover', function (e) { var r = e.target.closest('.row'); if (r) setStage(r.dataset.id, true); });
plist.addEventListener('focusin', function (e) { var r = e.target.closest('.row'); if (r) setStage(r.dataset.id, true); });
plist.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
var rows = [].slice.call(plist.querySelectorAll('li:not(.hide) .row')), i = rows.indexOf(document.activeElement);
if (i < 0) return; e.preventDefault();
var n = rows[Math.max(0, Math.min(rows.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))]; n.focus();
});
/* ---------- filter ---------- */
function setPref(v) {
PREF = v;
document.querySelectorAll('[data-pref]').forEach(function (b) { b.setAttribute('aria-pressed', b.dataset.pref === v); });
var vis = 0;
PROJECTS.forEach(function (p) { var ok = allowed(p); if (ok) vis++; plist.querySelector('[data-li="' + p.id + '"]').classList.toggle('hide', !ok); });
plist.querySelectorAll('.grp').forEach(function (g) { g.classList.toggle('hide', !PROJECTS.some(function (p) { return p.g === g.dataset.g && allowed(p); })); });
document.getElementById('empty').hidden = vis > 0;
document.getElementById('count').textContent = vis === PROJECTS.length ? PROJECTS.length : vis + ' of ' + PROJECTS.length;
var hidden = PROJECTS.length - vis;
document.getElementById('fnote').textContent = v === 'all' ? '' : v === 'nofull' ? 'hiding ' + hidden + ' made fully with AI' : 'hiding ' + hidden + ': made with AI, or AI not noted';
if (!allowed(P(stageId)) && vis) setStage(visible()[0].id, true);
}
document.querySelectorAll('[data-pref]').forEach(function (b) { b.addEventListener('click', function () { setPref(b.dataset.pref); }); });
/* ---------- writing ---------- */
document.getElementById('posts').innerHTML = POSTS.map(function (w) {
return '<li><a href="https://blog.lucaswinther.info"><span class="d ph" title="placeholder date">' + 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><span class="ar">→</span></a></li>';
}).join('');
/* ---------- detail ---------- */
var detail = document.getElementById('detail'), current = null;
function neighbours(id) { var v = visible().map(function (p) { return p.id; }); if (v.indexOf(id) < 0) v = ORDER; var i = v.indexOf(id), n = v.length; return { prev: v[(i - 1 + n) % n], next: v[(i + 1) % n], i: i + 1, n: n }; }
function renderDetail(id) {
var p = P(id), nb = neighbours(id), pv = P(nb.prev), nx = P(nb.next), unk = p.ai === 'unk';
detail.innerHTML =
'<div class="dnav"><a class="back" href="#" data-back>← All projects <kbd>esc</kbd></a><span class="where">' + GROUPS[p.g].name + ' / ' + strip(p.name) + '</span>' +
'<span class="pn"><a href="#p/' + nb.prev + '" title="' + strip(pv.name) + '">← prev</a><span class="of">' + nb.i + ' / ' + nb.n + '</span><a href="#p/' + nb.next + '" title="' + strip(nx.name) + '">next →</a></span></div>' +
'<div class="dhead"><div><div class="lbl">' + num(id) + ' · ' + whereOf(p) + '</div><h1 class="d-title">' + p.name + '</h1><p class="pitch">' + p.one + '</p></div></div>' +
'<div class="dhero"><div class="frame d-frame">' + frameHTML(id) + '</div>' +
'<dl class="facts"><div><dt>when</dt><dd>' + p.yr + '</dd></div><div><dt>made</dt><dd>' + aiMark(p.ai) + (unk ? ' <span class="ph" title="placeholder">(to fill in)</span>' : '') + '</dd></div><div><dt>stack</dt><dd>' + p.stack.slice(0, 4).join(', ') + (p.stack.length > 4 ? ' +' + (p.stack.length - 4) : '') + '</dd></div></dl></div>' +
'<div class="dgrid"><div class="story">' +
'<section><h3>Why<span>the problem</span></h3><p>' + p.problem + '</p></section>' +
'<section><h3>How<span>the approach</span></h3><p>' + p.approach + '</p></section>' +
'<section><h3>The interesting part</h3><p>' + p.interesting + '</p></section>' +
'<section><h3>Who made what<span>' + (unk ? 'not noted yet' : 'me, or the AI') + '</span></h3><ul class="who">' + p.parts.map(function (pt) { return '<li><span>' + pt[0] + '</span>' + aiMark(pt[1]) + '</li>'; }).join('') + '</ul></section>' +
'</div><aside class="aside">' +
'<h4>links</h4><ul>' + (p.links.length ? p.links.map(function (l) { return l[1] ? '<li><a href="' + l[1] + '"' + (l[1] === '#' ? ' onclick="return false" title="placeholder link"' : '') + '>' + l[0] + '<small>↗</small></a></li>' : '<li class="dead">' + l[0] + '</li>'; }).join('') : '<li class="dead">no public links</li>') + '</ul>' +
'<h4>stack</h4><div class="stack">' + p.stack.map(function (t) { return '<span class="chip">' + t + '</span>'; }).join('') + '</div>' +
'<h4>writing about it</h4><ul>' + (p.posts.length ? p.posts.map(POST).map(function (w) { return '<li><a href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">' + w.t + '</span><small>' + w.d.slice(0, 7) + '</small></a></li>'; }).join('') : '<li class="dead">nothing written yet</li>') + '</ul>' +
'<h4>related</h4><ul>' + p.rel.map(function (r) { var q = P(r); return '<li><a href="#p/' + r + '">' + strip(q.name) + aiMark(q.ai, false) + '</a></li>'; }).join('') + '</ul>' +
'</aside></div>' +
'<div class="pnbig"><a href="#p/' + nb.prev + '"><span class="lbl">← previous</span><b>' + strip(pv.name) + '</b></a><a href="#p/' + nb.next + '"><span class="lbl">next →</span><b>' + strip(nx.name) + '</b></a></div>';
}
/* ---------- routing with view transitions ---------- */
var listY = 0, booted = false, enteredFromList = false;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
document.addEventListener('click', function (e) {
var a = e.target.closest('a[href^="#p/"]');
if (a && !document.body.classList.contains('in-detail')) { listY = window.scrollY; enteredFromList = true; }
if (e.target.closest('[data-back]')) { e.preventDefault(); goBack(); }
var j = e.target.closest('[data-jump]'); if (j) { e.preventDefault(); jump(j.dataset.jump); }
if (e.target.closest('[data-home]')) { e.preventDefault(); jump('intro'); }
});
function goBack() { if (enteredFromList && history.length > 1) history.back(); else location.hash = ''; }
function route() {
var m = location.hash.match(/^#p\/([\w-]+)/), id = m && P(m[1]) ? m[1] : null;
var apply = function () {
if (id) { renderDetail(id); document.body.classList.add('in-detail'); window.scrollTo(0, 0); document.title = strip(P(id).name) + ' · Lucas Winther'; current = id; }
else {
if (current) setStage(current); document.body.classList.remove('in-detail'); document.title = 'Lucas Winther / work';
var sec = location.hash.slice(1); if (sec && document.getElementById(sec)) document.getElementById(sec).scrollIntoView(); else window.scrollTo(0, listY);
if (current) { var r = plist.querySelector('[data-id="' + current + '"]'); if (r) r.focus({ preventScroll: true }); }
current = null;
}
};
if (booted && document.startViewTransition && !reduce) document.startViewTransition(apply); else apply();
}
window.addEventListener('hashchange', route);
function jump(sec) {
var go = function () { var el = document.getElementById(sec); if (el) el.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth' }); };
if (document.body.classList.contains('in-detail')) { enteredFromList = false; listY = 0; history.pushState(null, '', location.pathname); route(); setTimeout(go, 30); } else go();
}
/* ---------- keyboard ---------- */
document.addEventListener('keydown', function (e) {
var typing = /INPUT|TEXTAREA/.test(document.activeElement.tagName);
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || (e.key === '/' && !typing)) { e.preventDefault(); openPal(); return; }
if (typing || pal.open) return;
if (document.body.classList.contains('in-detail')) {
if (e.key === 'Escape') { e.preventDefault(); goBack(); }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { var nb = neighbours(current); location.hash = 'p/' + (e.key === 'ArrowLeft' ? nb.prev : nb.next); }
}
});
/* ---------- command palette ---------- */
var pal = document.getElementById('pal'), palq = document.getElementById('palq'), pallist = document.getElementById('pallist'), palItems = [], palOn = 0;
var CMDS = [];
PROJECTS.forEach(function (p) { CMDS.push({ g: 'Projects', k: num(p.id), label: strip(p.name), sub: strip(p.one), run: function () { if (!document.body.classList.contains('in-detail')) { listY = window.scrollY; enteredFromList = true; } location.hash = 'p/' + p.id; } }); });
POSTS.forEach(function (w) { CMDS.push({ g: 'Writing', k: '¶', label: w.t, sub: w.tag + ' · ' + w.d, run: function () { location.href = 'https://blog.lucaswinther.info'; } }); });
[['intro', 'Intro', 'top of the page'], ['cv', 'CV in short', 'job, education, contact'], ['projects', 'All projects', 'the list'], ['writing', 'Latest writing', 'blog']].forEach(function (s) { CMDS.push({ g: 'Sections', k: '§', label: s[1], sub: s[2], run: function () { jump(s[0]); } }); });
[['all', 'Show projects made any way'], ['nofull', 'Hide projects made fully by AI'], ['hand', 'Show only projects made by hand']].forEach(function (f) { CMDS.push({ g: 'Filter', k: '◧', label: f[1], sub: '', run: function () { setPref(f[0]); jump('projects'); } }); });
CMDS.push({ g: 'Go', k: '↖', label: 'Back to the gate', sub: 'start page', run: function () { location.href = 'nav-d.html'; } });
CMDS.push({ g: 'Go', k: '→', label: 'Off the clock', sub: 'the personal side', run: function () { location.href = 'nav-d.html'; } });
CMDS.push({ g: 'Go', k: '↓', label: 'Download CV (PDF)', sub: 'placeholder', run: function () {} });
CMDS.push({ g: 'Go', k: '@', label: 'Email Lucas', sub: 'lucas@lucaswinther.info', run: function () { location.href = 'mailto:lucas@lucaswinther.info'; } });
function score(c, q) {
if (!q) return 1; var hay = (c.label + ' ' + c.sub + ' ' + c.g).toLowerCase(), words = q.toLowerCase().split(/\s+/).filter(Boolean);
for (var i = 0; i < words.length; i++) if (hay.indexOf(words[i]) < 0) return 0;
var lab = c.label.toLowerCase(); return lab.indexOf(words[0]) === 0 ? 4 : (' ' + lab).indexOf(' ' + words[0]) >= 0 ? 3 : lab.indexOf(words[0]) >= 0 ? 2 : 1;
}
function renderPal() {
var q = palq.value.trim();
palItems = CMDS.map(function (c) { return { c: c, s: score(c, q) }; }).filter(function (x) { return x.s > 0; });
if (q) palItems.sort(function (a, b) { return b.s - a.s; });
var groups = q ? ['Projects', 'Sections', 'Writing', 'Filter', 'Go'] : ['Projects', 'Sections', 'Writing', 'Filter', 'Go'], h = '', idx = 0, ordered = [];
groups.forEach(function (g) {
var items = palItems.filter(function (x) { return x.c.g === g; }); if (!q && g === 'Projects') items = items.slice(0, 17);
if (!items.length) return; h += '<li class="g">' + g + '</li>';
items.forEach(function (x) { ordered.push(x); h += '<li class="it" role="option" data-i="' + idx + '"><span class="k">' + x.c.k + '</span><span>' + esc(x.c.label) + '</span><span class="s">' + esc(x.c.sub) + '</span></li>'; idx++; });
});
palItems = ordered; palOn = 0;
pallist.innerHTML = h || '<li class="none">Nothing found for “' + esc(q) + '”.</li>';
markPal();
}
function markPal() { pallist.querySelectorAll('.it').forEach(function (li) { var on = +li.dataset.i === palOn; li.classList.toggle('on', on); li.setAttribute('aria-selected', on); if (on) li.scrollIntoView({ block: 'nearest' }); }); }
function openPal() { if (pal.open) { palq.select(); return; } palq.value = ''; renderPal(); pal.showModal(); palq.focus(); }
function runPal(i) { var x = palItems[i]; if (!x) return; pal.close(); x.c.run(); }
palq.addEventListener('input', renderPal);
palq.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { e.preventDefault(); palOn = Math.min(palItems.length - 1, palOn + 1); markPal(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); palOn = Math.max(0, palOn - 1); markPal(); }
else if (e.key === 'Enter') { e.preventDefault(); runPal(palOn); }
});
pallist.addEventListener('mousemove', function (e) { var li = e.target.closest('.it'); if (li && +li.dataset.i !== palOn) { palOn = +li.dataset.i; markPal(); } });
pallist.addEventListener('click', function (e) { var li = e.target.closest('.it'); if (li) runPal(+li.dataset.i); });
pal.addEventListener('click', function (e) { if (e.target === pal) pal.close(); });
document.getElementById('kbtn').addEventListener('click', openPal);
/* ---------- boot ---------- */
var bar = document.getElementById('bar');
window.addEventListener('scroll', function () { bar.classList.toggle('scrolled', window.scrollY > 4); }, { passive: true });
setStage('compute');
route();
booted = true;
</script>
</body>
</html>
+947
View File
@@ -0,0 +1,947 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lucas Winther · Work</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&family=Fraunces:ital,opsz,wght@1,9..144,300&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f4f4f1; --paper: #ffffff; --ink: #111111; --ink2: #3b3b39; --mute: #85857f; --line: #e0e0db; --line2: #c9c9c3; --hov: #ebebe7;
--acc: #f0461a; --acc-ink: #c4350f;
--a-bg: #2a1a1f; --a-ink: #f1e6d6;
--sans: "Geist", system-ui, sans-serif; --mono: "Geist Mono", ui-monospace, monospace;
--edge: 36px; --pad: 48px; --ease: cubic-bezier(.2, .7, .2, 1);
}
* { box-sizing: border-box; }
html { scroll-padding-top: 72px; }
body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased; font-feature-settings: "ss01"; }
a { color: inherit; text-decoration: underline; text-decoration-color: var(--line2); text-underline-offset: 3px; text-decoration-thickness: 1px; }
a:hover { text-decoration-color: var(--acc); color: var(--ink); }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; }
.mono { font-family: var(--mono); }
.lbl { font-family: var(--mono); font-size: 11px; letter-spacing: .02em; color: var(--mute); }
.ph { text-decoration: underline dotted var(--mute); text-decoration-thickness: 1px; text-underline-offset: 3px; cursor: help; }
.wrap { max-width: 1360px; margin: 0 auto; padding: 0 var(--pad); }
.page { margin-right: var(--edge); }
/* mockup note */
.mk { position: fixed; top: 64px; right: calc(var(--edge) + 12px); z-index: 90; width: 300px; background: var(--ink); color: #ddd; font: 12px/1.45 var(--mono); padding: 10px 30px 11px 12px; border-radius: 6px; }
.mk b { color: #fff; display: block; font-weight: 500; margin-bottom: 3px; }
.mk button { position: absolute; top: 5px; right: 9px; color: #aaa; font-size: 16px; }
/* edge to the personal side */
.edge { position: fixed; top: 0; right: 0; bottom: 0; width: var(--edge); background: var(--a-bg); color: var(--a-ink); display: flex; align-items: center; justify-content: center; writing-mode: vertical-rl; font-family: "Fraunces", serif; font-style: italic; font-weight: 300; font-size: 15px; text-decoration: none; z-index: 60; transition: width .2s var(--ease); }
.edge:hover { width: 48px; color: var(--a-ink); }
/* ---------- header ---------- */
.bar { position: sticky; top: 0; z-index: 50; background: var(--bg); border-bottom: 1px solid var(--line); }
.bar .wrap { height: 52px; display: flex; align-items: center; gap: 28px; font-size: 13px; }
.me { font-weight: 600; letter-spacing: -.01em; text-decoration: none; white-space: nowrap; }
.me span { color: var(--mute); font-weight: 400; }
.bar nav { display: flex; gap: 20px; }
.bar nav a { text-decoration: none; color: var(--ink2); }
.bar nav a sup { font-family: var(--mono); font-size: 10px; color: var(--mute); margin-left: 2px; }
.bar nav a:hover { color: var(--ink); }
.bar .sp { flex: 1; }
.seg { display: inline-flex; align-items: center; gap: 2px; background: var(--hov); border-radius: 8px; padding: 2px; font-size: 12.5px; }
.seg .why { padding: 0 8px 0 8px; color: var(--mute); font-family: var(--mono); font-size: 11px; }
.seg button { padding: 4px 10px; border-radius: 6px; color: var(--ink2); white-space: nowrap; transition: background .15s, color .15s; }
.seg button:hover { color: var(--ink); }
.seg button[aria-pressed="true"] { background: var(--paper); color: var(--ink); box-shadow: 0 0 0 1px var(--line2); }
.hbtn { font-size: 13px; padding: 5px 10px; border-radius: 6px; text-decoration: none; white-space: nowrap; }
.hbtn:hover { background: var(--hov); }
.hbtn[aria-expanded="true"] { background: var(--ink); color: var(--bg); }
.hbtn.gate { color: var(--mute); }
.hbtn.gate:hover { color: var(--ink); }
/* CV sheet */
.cv { position: fixed; top: 60px; right: calc(var(--edge) + var(--pad) - 10px); z-index: 70; width: 400px; background: var(--paper); border: 1px solid var(--ink); border-radius: 10px; padding: 18px 20px 16px; font-size: 13.5px; opacity: 0; transform: translateY(-6px); pointer-events: none; transition: opacity .16s, transform .16s var(--ease); }
.cv.on { opacity: 1; transform: none; pointer-events: auto; }
.cv h3 { margin: 0 0 12px; font-size: 13px; font-weight: 500; display: flex; justify-content: space-between; }
.cv dl { margin: 0; display: grid; grid-template-columns: 82px 1fr; gap: 9px 12px; }
.cv dt { font-family: var(--mono); font-size: 11px; color: var(--mute); padding-top: 2px; }
.cv dd { margin: 0; }
.cv dd small { color: var(--mute); font-size: 12px; display: block; }
.cv .pdf { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
.cv .pdf a.b { background: var(--ink); color: var(--bg); text-decoration: none; padding: 6px 12px; border-radius: 6px; font-size: 13px; }
.cv .pdf a.b:hover { background: var(--acc); }
/* ---------- intro ---------- */
.intro { display: grid; grid-template-columns: repeat(12, 1fr); gap: 0 24px; padding: 56px 0 52px; }
.intro .stmt { grid-column: 1 / span 8; }
.intro .lbl { margin-bottom: 18px; display: block; }
.intro h1 { margin: 0; font-size: 52px; line-height: 1.04; font-weight: 500; letter-spacing: -.036em; max-width: 15em; }
.intro h1 em { font-style: normal; color: var(--mute); }
.intro .side { grid-column: 9 / span 4; align-self: end; font-size: 13.5px; }
.hist { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--ink); }
.hist li { display: grid; grid-template-columns: 78px 1fr; gap: 12px; padding: 9px 0 10px; border-bottom: 1px solid var(--line); }
.hist .y { font-family: var(--mono); font-size: 11.5px; color: var(--mute); padding-top: 1px; }
.hist b { font-weight: 500; display: block; }
.hist span.r { color: var(--ink2); }
.hist a { font-family: var(--mono); font-size: 11px; color: var(--mute); text-decoration: none; margin-right: 8px; }
.hist a:hover { color: var(--acc-ink); }
.side .lks { margin-top: 12px; font-family: var(--mono); font-size: 11.5px; color: var(--mute); display: flex; flex-wrap: wrap; gap: 4px 14px; }
.side .lks a { color: var(--ink2); }
/* ---------- index ---------- */
.ihead { display: flex; align-items: baseline; justify-content: space-between; border-bottom: 1px solid var(--ink); padding-bottom: 10px; gap: 24px; flex-wrap: wrap; }
.ihead h2, .whead h2 { margin: 0; font-size: 15px; font-weight: 500; }
.ihead h2 span, .whead h2 span { color: var(--mute); font-weight: 400; margin-left: 6px; font-family: var(--mono); font-size: 12px; }
.legend { display: flex; gap: 18px; font-family: var(--mono); font-size: 11.5px; color: var(--ink2); align-items: center; flex-wrap: wrap; }
.legend .hid { color: var(--acc-ink); }
.cols, .rh { display: grid; grid-template-columns: 76px minmax(0, 1fr) 230px 92px 118px 28px; gap: 0 20px; align-items: baseline; }
.cols { padding: 10px 0 8px; border-bottom: 1px solid var(--line); }
/* AI mark: a dot filled by how much AI did */
.ai { font-family: var(--mono); font-size: 11.5px; color: var(--ink2); white-space: nowrap; display: inline-flex; align-items: center; gap: 7px; }
.ai i { width: 9px; height: 9px; border-radius: 50%; border: 1.25px solid currentColor; flex: none; }
.ai.full i { background: currentColor; }
.ai.some i { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
.ai.unk i { border-style: dashed; }
.ai.unk { color: var(--mute); }
.row { border-bottom: 1px solid var(--line); transition: border-color .2s; }
.row[hidden] { display: none; }
.rh { width: 100%; text-align: left; padding: 18px 0 17px; position: relative; }
.rh .no { font-size: 40px; line-height: 1; font-weight: 400; color: var(--line2); letter-spacing: -.03em; font-variant-numeric: tabular-nums; transition: color .15s; }
.rh .nm { font-size: 40px; line-height: 1.05; font-weight: 500; letter-spacing: -.032em; transition: transform .22s var(--ease); }
.rh .wh, .rh .yr { font-family: var(--mono); font-size: 11.5px; color: var(--ink2); }
.rh .wh { color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rh .pm { justify-self: end; width: 18px; height: 18px; position: relative; align-self: center; }
.rh .pm::before, .rh .pm::after { content: ""; position: absolute; left: 2px; right: 2px; top: 8px; height: 1.5px; background: var(--ink); transition: transform .22s var(--ease), background .15s; }
.rh .pm::after { transform: rotate(90deg); }
.rh:hover .no, .rh:focus-visible .no { color: var(--acc); }
.rh:hover .nm { transform: translateX(6px); }
.row.open { border-bottom-color: var(--ink); }
.row.open .rh .no { color: var(--acc); }
.row.open .rh .nm { transform: none; }
.row.open .rh .pm::after { transform: rotate(0deg); }
.row.open .rh .pm::before { background: var(--acc); }
.row.open .rh .pm::after { background: var(--acc); }
/* expanding detail: 0fr -> 1fr */
.rd { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .28s var(--ease); }
.row.open .rd { grid-template-rows: 1fr; }
.rd > .in { overflow: hidden; min-height: 0; }
.instant .rd, .instant .rd .det { transition: none !important; }
.det { display: grid; grid-template-columns: 76px repeat(12, minmax(0, 1fr)); gap: 0 20px; padding: 4px 0 40px; opacity: 0; transform: translateY(-4px); transition: opacity .2s .06s, transform .28s var(--ease); }
.row.open .det { opacity: 1; transform: none; }
.det .story { grid-column: 2 / span 5; }
.det .lead { font-size: 23px; line-height: 1.28; letter-spacing: -.018em; margin: 0 0 22px; font-weight: 450; max-width: 22em; }
.det .where { font-family: var(--mono); font-size: 11.5px; color: var(--mute); margin-bottom: 12px; display: block; }
.det .st { display: grid; grid-template-columns: 96px 1fr; gap: 12px; padding: 11px 0; border-top: 1px solid var(--line); font-size: 14.5px; color: var(--ink2); }
.det .st .k { font-family: var(--mono); font-size: 11px; color: var(--mute); padding-top: 3px; }
.det .st p { margin: 0; max-width: 34em; }
.det .figw { grid-column: 7 / span 7; }
.fig { background: var(--paper); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; position: relative; }
.fig .cv-in { display: flex; justify-content: center; align-items: center; padding: 22px; min-height: 300px; }
.fig .cv-in > svg { width: 100%; height: auto; display: block; }
.fig.small .cv-in > svg { max-width: 470px; }
.cap { display: flex; justify-content: space-between; gap: 16px; padding: 9px 14px; border-top: 1px solid var(--line); font-family: var(--mono); font-size: 11.5px; color: var(--mute); }
.cap .live { color: var(--ink2); min-height: 1.5em; }
.cap .live b { color: var(--ink); font-weight: 500; }
.facts { grid-column: 2 / span 12; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0 20px; margin-top: 28px; border-top: 1px solid var(--ink); }
.facts > div { padding-top: 12px; font-size: 13.5px; }
.facts h4 { margin: 0 0 9px; font-family: var(--mono); font-size: 11px; font-weight: 400; color: var(--mute); }
.facts ul { list-style: none; margin: 0; padding: 0; }
.facts li { padding: 2px 0; }
.stack { display: flex; flex-wrap: wrap; gap: 5px; }
.stack span { border: 1px solid var(--line2); border-radius: 5px; padding: 1px 7px; font-size: 12.5px; }
.parts li { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; padding: 4px 0; border-bottom: 1px dashed var(--line); }
.parts li:last-child { border-bottom: 0; }
.facts a.go { text-decoration: none; display: inline-flex; gap: 6px; }
.facts a.go:hover { color: var(--acc-ink); }
.facts .none { color: var(--mute); }
.nextp { grid-column: 2 / span 12; display: flex; justify-content: space-between; margin-top: 22px; font-family: var(--mono); font-size: 11.5px; color: var(--mute); }
.nextp button:hover, .nextp a:hover { color: var(--ink); }
.nextp a { text-decoration: none; }
.fnote { padding: 14px 0 0; font-family: var(--mono); font-size: 11.5px; color: var(--mute); min-height: 30px; }
/* floating hover preview */
.pv { position: fixed; left: 0; top: 0; z-index: 40; width: 300px; background: var(--paper); border: 1px solid var(--ink); border-radius: 10px; overflow: hidden; pointer-events: none; opacity: 0; transform: translate3d(var(--x, 0), var(--y, 0), 0) scale(.96); transform-origin: 0 50%; transition: opacity .14s, transform .14s var(--ease); box-shadow: 0 10px 30px -12px rgba(0,0,0,.25); }
.pv.on { opacity: 1; transform: translate3d(var(--x, 0), var(--y, 0), 0) scale(1); }
.pv .pvf { padding: 10px; }
.pv svg { width: 100%; height: auto; display: block; }
.pv .pvc span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pv .pvc span:last-child { white-space: nowrap; }
.pv .pvc { display: flex; gap: 10px; justify-content: space-between; padding: 7px 12px 8px; border-top: 1px solid var(--line); font-family: var(--mono); font-size: 10.5px; color: var(--mute); }
/* figure drawing language */
.fg text { font-family: var(--mono); font-size: 8.5px; fill: var(--ink); }
.fg text.m { fill: var(--mute); }
.fg text.w { fill: #f4f4f1; }
.fg text.a { fill: var(--acc); }
.fg text.big { font-family: var(--sans); font-weight: 500; letter-spacing: -.02em; }
.fg .s { fill: none; stroke: var(--ink); stroke-width: 1; vector-effect: non-scaling-stroke; }
.fg .sl { fill: none; stroke: var(--line2); stroke-width: 1; vector-effect: non-scaling-stroke; }
.fg .b { fill: var(--paper); stroke: var(--ink); stroke-width: 1; vector-effect: non-scaling-stroke; }
.fg .g { fill: #ececea; }
.fg .gg { fill: #dcdcd6; }
.fg .k { fill: var(--ink); }
.fg .a { fill: var(--acc); }
.fg .as { fill: none; stroke: var(--acc); stroke-width: 1.6; vector-effect: non-scaling-stroke; }
.fg .d { stroke-dasharray: 3 3; }
.fg .hit { cursor: pointer; }
.fg .hit:hover rect, .fg .hit:focus rect, .fg .hit.on rect { stroke: var(--acc); stroke-width: 2; }
.fg .hit:focus { outline: none; }
.fg .peer circle { fill: var(--paper); stroke: var(--ink); vector-effect: non-scaling-stroke; transition: fill .12s; }
.fg .peer.bad circle { stroke: var(--acc); stroke-dasharray: 3 2; }
.fg .peer:hover circle, .fg .peer.on circle { fill: var(--ink); }
.fg .peer:hover text, .fg .peer.on text { fill: var(--bg); }
.fg .peer text { text-anchor: middle; }
.fg line.e { stroke: var(--line2); stroke-width: 1; vector-effect: non-scaling-stroke; }
.fg line.e.on { stroke: var(--ink); stroke-width: 1.6; }
/* config before / after (HTML figure) */
.ba { display: grid; grid-template-columns: 1fr 1fr; gap: 0; width: 100%; font-family: var(--mono); font-size: 12px; align-self: stretch; }
.ba > div { padding: 4px 18px; }
.ba > div + div { border-left: 1px solid var(--line); }
.ba h5 { margin: 0 0 10px; font-weight: 500; font-size: 11px; color: var(--mute); }
.ba h5 b { color: var(--ink); font-weight: 500; }
.ba ul { list-style: none; margin: 0; padding: 0; }
.ba li { display: grid; grid-template-columns: 62px 1fr; gap: 8px; padding: 5px 0; border-bottom: 1px dashed var(--line); color: var(--ink2); }
.ba li span:first-child { color: var(--mute); }
.ba li .x { color: var(--acc-ink); }
.ba pre { margin: 0; font: inherit; line-height: 1.75; color: var(--ink2); }
.ba pre b { color: var(--ink); font-weight: 500; }
.ba .term { margin-top: 12px; background: var(--ink); color: #d6d6d0; border-radius: 6px; padding: 9px 11px; line-height: 1.6; font-size: 11.5px; }
.ba .term .ok { color: #9fd49b; } .ba .term .ch { color: var(--acc); }
/* ---------- writing ---------- */
.writing { margin-top: 88px; }
.whead { display: flex; justify-content: space-between; align-items: baseline; border-bottom: 1px solid var(--ink); padding-bottom: 10px; }
.whead a { font-family: var(--mono); font-size: 11.5px; color: var(--ink2); }
.wl { list-style: none; margin: 0; padding: 0; }
.wl li a { display: grid; grid-template-columns: 76px minmax(0, 1fr) 230px 92px 118px 28px; gap: 0 20px; align-items: baseline; padding: 16px 0 15px; border-bottom: 1px solid var(--line); text-decoration: none; }
.wl .d { font-family: var(--mono); font-size: 11.5px; color: var(--mute); }
.wl .t { font-size: 22px; font-weight: 500; letter-spacing: -.02em; line-height: 1.2; }
.wl .bl { display: block; font-size: 13.5px; color: var(--mute); font-weight: 400; letter-spacing: 0; margin-top: 4px; }
.wl .rel { font-family: var(--mono); font-size: 11.5px; color: var(--mute); }
.wl .tg { font-family: var(--mono); font-size: 11.5px; color: var(--ink2); }
.wl .ar { justify-self: end; transition: transform .2s var(--ease); }
.wl li a:hover .t { color: var(--ink); }
.wl li a:hover .ar { transform: translate(3px, -3px); color: var(--acc); }
.wl li a:hover .d { color: var(--acc-ink); }
footer { margin-top: 96px; border-top: 1px solid var(--ink); padding: 18px 0 40px; display: grid; grid-template-columns: 76px 1fr 1fr; gap: 0 20px; font-size: 13px; }
footer .big { font-size: 28px; letter-spacing: -.03em; font-weight: 500; line-height: 1.15; grid-column: 2; }
footer .big a { text-decoration-color: var(--acc); }
footer .r { font-family: var(--mono); font-size: 11.5px; color: var(--mute); display: flex; flex-direction: column; gap: 4px; align-items: flex-end; text-align: right; }
footer .r a { color: var(--ink2); }
footer .phs { grid-column: 2 / span 2; margin-top: 28px; font-family: var(--mono); font-size: 11px; color: var(--mute); max-width: 90ch; }
/* ---------- small screens ---------- */
@media (max-width: 1100px) {
.bar nav { display: none; }
.cols, .rh, .wl li a { grid-template-columns: 52px minmax(0, 1fr) 92px 28px; }
.cols .c-wh, .cols .c-yr, .rh .wh, .rh .yr, .wl .rel, .wl .tg { display: none; }
.rh .no, .rh .nm { font-size: 30px; }
.det { grid-template-columns: 52px repeat(12, minmax(0, 1fr)); }
.det .story, .det .figw { grid-column: 2 / span 12; }
.det .figw { margin-top: 20px; }
.facts { grid-template-columns: 1fr 1fr; gap: 16px 20px; }
.intro .stmt, .intro .side { grid-column: 1 / -1; }
.intro .side { margin-top: 36px; }
}
@media (max-width: 720px) {
:root { --pad: 18px; --edge: 0px; }
.edge { position: static; writing-mode: horizontal-tb; width: auto; height: 40px; }
.edge:hover { width: auto; }
.bar .wrap { height: auto; flex-wrap: wrap; gap: 8px 14px; padding-top: 8px; padding-bottom: 8px; }
.bar .sp { display: none; }
.seg .why { display: none; }
.intro { padding: 36px 0; }
.intro h1 { font-size: 36px; }
.cols, .rh, .wl li a { grid-template-columns: 34px minmax(0, 1fr) 24px; }
.cols .c-ai, .rh .aiw { display: none; }
.rh .no { font-size: 15px; padding-top: 6px; }
.rh .nm { font-size: 24px; }
.det { grid-template-columns: 0 repeat(12, minmax(0, 1fr)); gap: 0 8px; }
.det .st { grid-template-columns: 1fr; gap: 2px; }
.facts { grid-template-columns: 1fr; }
.ba { grid-template-columns: 1fr; }
.ba > div + div { border-left: 0; border-top: 1px solid var(--line); padding-top: 12px; margin-top: 8px; }
.wl .t { font-size: 18px; }
.wl .e { display: none; }
footer { grid-template-columns: 1fr; }
footer .big, footer .phs { grid-column: 1; }
footer .r { align-items: flex-start; text-align: left; margin-top: 16px; }
.mk { top: auto; bottom: 12px; right: 12px; }
.cv { right: 12px; left: 12px; width: auto; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0s !important; } }
</style>
</head>
<body>
<div class="mk" id="mk"><button onclick="document.getElementById('mk').remove()" aria-label="close">×</button>
<b>tech-r3-b · Swiss index, modern</b>Every project is one big numbered row. Point at a row for a small drawn preview by the cursor; click to open it in place (one at a time, the URL follows). Strict grid, Geist, near-white with one orange. Borrows rauno.me's restraint and sankar.work's hover previews, minus the noise.</div>
<a class="edge" href="nav-d.html" title="cross over to the personal side">off the clock →</a>
<div class="page">
<header class="bar">
<div class="wrap">
<a class="me" href="#top">Lucas Winther <span>/ work</span></a>
<nav aria-label="On this page"><a href="#index">Index<sup id="navN">17</sup></a><a href="#cvsec">Work history</a><a href="#writing">Writing<sup>5</sup></a></nav>
<span class="sp"></span>
<div class="seg" role="group" aria-label="Filter projects by how they were made">
<span class="why">made</span>
<button aria-pressed="true" data-pref="all">any way</button>
<button aria-pressed="false" data-pref="nofull">not fully by AI</button>
<button aria-pressed="false" data-pref="hand">by hand only</button>
</div>
<button class="hbtn" id="cvBtn" aria-expanded="false" aria-controls="cv">CV</button>
<a class="hbtn gate" href="nav-d.html" title="back to the two gates">↖ gate</a>
</div>
</header>
<aside class="cv" id="cv" aria-label="CV in ten seconds">
<h3><span>CV, in ten seconds</span><span class="lbl">esc</span></h3>
<dl>
<dt>now</dt><dd>IT-medarbejder, IT Research Tech<small>Aarhus University · Jan 2025 – now</small></dd>
<dt>before</dt><dd>Intern, ScienceAtHome <small>July 2019</small>Student helper, Local Configuration Management, TERMA Group<small>May 2016 – Sep 2018</small></dd>
<dt>education</dt><dd>BSc Computer Science<small><span class="ph" title="placeholder">Aarhus University, 2022</span> · thesis: HyperCache</small></dd>
<dt>skills</dt><dd>DevOps, Docker, Linux administration, observability, cloud, full-stack, leadership</dd>
<dt>contact</dt><dd><a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><small><a href="https://github.com/StereotypicalCat">GitHub</a> · <a href="https://www.linkedin.com/in/lucas-winther/">LinkedIn</a></small></dd>
</dl>
<div class="pdf"><span class="lbl"><span class="ph" title="placeholder">2 pages · updated Nov 2026</span></span><a class="b" href="#" title="placeholder: CV PDF">Download CV (PDF)</a></div>
</aside>
<main class="wrap" id="top">
<section class="intro" id="cvsec">
<div class="stmt">
<span class="lbl">lucaswinther.info / work · computer scientist</span>
<h1>I build and run research software at Aarhus University, <em>and make things on the side. Each project says how much AI was involved.</em></h1>
</div>
<div class="side">
<ul class="hist">
<li><span class="y">2025 – now</span><span><b>Aarhus University</b><span class="r">IT-medarbejder, IT Research Tech</span><br><a href="#compute">01 compute</a><a href="#docs">02 docs</a><a href="#ops">03 ops</a></span></li>
<li><span class="y">2019</span><span><b>ScienceAtHome</b><span class="r">Internship, one month</span><br><a href="#sah">04 prototype</a></span></li>
<li><span class="y">2016 – 18</span><span><b>TERMA Group</b><span class="r">Student helper, Local Configuration Mgmt.</span></span></li>
<li><span class="y ph" title="placeholder">2022</span><span><b>BSc Computer Science</b><span class="r"><span class="ph" title="placeholder">Aarhus University</span></span><br><a href="#hypercache">12 thesis</a></span></li>
</ul>
<div class="lks"><a href="mailto:lucas@lucaswinther.info">email</a><a href="https://github.com/StereotypicalCat">github</a><a href="https://www.linkedin.com/in/lucas-winther/">linkedin</a><a href="#" title="placeholder: CV PDF">cv.pdf</a></div>
</div>
</section>
<section id="index" aria-label="Projects">
<div class="ihead">
<h2>Index<span id="idxN">17 projects</span></h2>
<div class="legend" id="legend"></div>
</div>
<div class="cols lbl" aria-hidden="true"><span>no.</span><span>project</span><span class="c-wh">where</span><span class="c-yr">year</span><span class="c-ai">made</span><span></span></div>
<div id="rows"></div>
<div class="fnote" id="fnote"></div>
</section>
<section class="writing" id="writing">
<div class="whead"><h2>Writing<span>blog.lucaswinther.info</span></h2><a href="https://blog.lucaswinther.info">all posts ↗</a></div>
<ul class="wl" id="wl"></ul>
</section>
<footer>
<span></span>
<div class="big">Write to <a href="mailto:lucas@lucaswinther.info">lucas@lucaswinther.info</a><br>or find the code on <a href="https://github.com/StereotypicalCat">GitHub</a>.</div>
<div class="r"><a href="nav-d.html">↖ back to the gate</a><a href="nav-d.html">off the clock →</a><a href="#" title="placeholder: old site URL">the 2021 site, by hand ↗</a></div>
<p class="phs">Placeholders (dotted underline): university and year, most project years, AI level of the AU work, ScienceAtHome engine, story details, all figures (drawn stand-ins for screenshots), HyperCache numbers, blog titles and dates, CV PDF.</p>
</footer>
</main>
</div>
<div class="pv" id="pv" aria-hidden="true"><div class="pvf" id="pvf"></div><div class="pvc"><span id="pvc1"></span><span id="pvc2"></span></div></div>
<script>
/* ---------- shared content (same in tech-r2-c and tech-r2-d) ---------- */
var PH = function (s) { return '<span class="ph" title="placeholder">' + s + '</span>'; };
var GROUPS = {
work: { key: 'A', name: 'Work', sub: 'paid, for someone else' },
own: { key: 'B', name: 'Own', sub: 'built for myself' },
school: { key: 'C', name: 'School', sub: 'university and high school' }
};
var AI = {
full: { label: 'fully AI', long: 'made with AI' },
some: { label: 'some AI', long: 'some AI help' },
none: { label: 'by hand', long: 'no AI, by hand' },
unk: { label: 'AI: ?', long: 'AI level not recorded yet (placeholder)' }
};
var POSTS = [
{ id: 'agent', t: "What I let the agent write, and what I didn't", d: '2026-10-14', tag: 'AI', blurb: 'Going back through the gacha tracker file by file and marking every part by who wrote it.' },
{ id: 'slurm', t: 'Slurm on a cloud that scales to zero', d: '2026-09-02', tag: 'devops', blurb: 'What it takes for a job queue to grow nodes on demand, and shrink again without killing anything.' },
{ id: 'vault', t: 'Moving secrets out of .env and into OpenBao', d: '2026-07-21', tag: 'homelab', blurb: 'The boring migration that made every other change less scary.' },
{ id: 'ansible', t: 'Ansible for three servers is not overkill', d: '2026-05-08', tag: 'devops', blurb: 'Why Config exists, and the week every run restarted every container.' },
{ id: 'thesis', t: 'Rereading my bachelor thesis four years later', d: '2026-03-30', tag: 'notes', blurb: 'What HyperCache got right about trust, and what I would simulate differently now.' }
];
var PROJECTS = [
{ id: 'compute', g: 'work', name: 'Compute job platform', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'arch',
one: 'Slurm jobs from a browser: a React/Node app in front of a cluster that scales itself on Oracle Cloud.',
stack: ['Slurm', 'React', 'Node.js', 'Oracle Cloud', 'OAuth / Entra ID', 'ERDA', 'Docker'],
fig: 'diagram: web app → Slurm → cloud nodes',
problem: 'Researchers need to run heavy jobs without first learning Slurm, SSH and batch scripts. Use is bursty, so a fixed cluster either sits idle or runs out.',
approach: 'A full-stack React/Node web app in front of Slurm. People log in with their AU account (OAuth with Entra ID), pick their data from ERDA, AU\'s own storage, and submit. The web stack and the compute nodes both scale up and down on Oracle Cloud.',
interesting: 'Making "easy to use" and "scales to zero" live together: ' + PH('a queued job has to explain why it is waiting while a node boots, instead of just looking stuck.'),
parts: [['web app (React/Node)', 'unk'], ['Slurm + cloud scaling', 'unk'], ['Entra ID login, ERDA link', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['docs', 'ops', 'config'], posts: ['slurm', 'vault'] },
{ id: 'docs', g: 'work', name: 'Document & information services', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'doc',
one: 'Overleaf and OpenProject, run by us for researchers and wired into the university\'s own systems.',
stack: ['Overleaf', 'OpenProject', 'Docker', 'Linux'],
fig: 'screenshot: Overleaf project list',
problem: 'Research groups write papers together and plan projects, and want to do it on tools the university can run, secure and back up itself.',
approach: 'Self-hosted Overleaf and OpenProject, containerised, with integrations into the rest of AU\'s services. ' + PH('Which integrations exactly is still to be written up.'),
interesting: PH('Updating two large open-source products without breaking a group that is three days from a deadline.'),
parts: [['deployment', 'unk'], ['integrations', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['compute', 'ops'], posts: ['vault'] },
{ id: 'ops', g: 'work', name: 'Secure test & run setup', yr: '2025–now', where: 'Aarhus University · IT Research Tech', ai: 'unk', motif: 'infra',
one: 'Trying newer versions of software while keeping everything isolated, patched and watched.',
stack: ['OpenBao / Vault', 'Grafana', 'Docker', 'Ansible', 'Linux'],
fig: 'screenshot: Grafana overview board',
problem: 'A research IT team has to try new software and new versions early, without that becoming the weak spot in everything else.',
approach: 'Everything runs containerised and isolated, configured with Ansible, secrets in OpenBao / HashiCorp Vault, and watched in Grafana.',
interesting: PH('Deciding what deserves an alert. Most dashboards are for curiosity; only a few things should wake someone up.'),
parts: [['Ansible roles', 'unk'], ['dashboards & alerts', 'unk']],
links: [['internal AU service, no public link', '']], rel: ['config', 'compute'], posts: ['vault', 'ansible'] },
{ id: 'sah', g: 'work', name: 'ScienceAtHome prototype', yr: '2019', where: 'ScienceAtHome · internship', ai: 'none', motif: 'game',
one: 'A month-long internship: an early prototype of a new project\'s base mechanics, to show the people it was for.',
stack: [PH('Unity'), 'prototyping', 'pair programming'],
fig: 'screenshot: prototype, ' + PH('project name'),
problem: 'A new project needed its core idea tested before anyone built it properly, and something concrete to show the people it was made for.',
approach: 'Joined the ideation and planning, built a quick prototype of the base mechanics, then pair-programmed with experienced developers to start the real project.',
interesting: 'Seeing how a prototype changes the conversation: people react to something they can try very differently from a plan.',
parts: [['prototype', 'none']],
links: [], rel: ['rowing', 'vrclass'], posts: [] },
{ id: 'gacha', g: 'own', name: 'Event Tracker for Gacha Games', yr: PH('2025'), ai: 'full', motif: 'web',
one: 'Scrapes event schedules from several games into one timeline, so limited events stop slipping by.',
stack: ['JavaScript', 'scraping', 'web app'],
fig: 'screenshot: the event timeline',
problem: 'Each game announces its limited events somewhere different, in a different time zone. Keeping track meant checking five places.',
approach: 'Scrapers per game feed one normalised list of events, shown as a single timeline in a small web app.',
interesting: 'A test of building with AI: ' + PH('I wrote the spec and the fixtures, the agent wrote the scrapers, and every change was read before it went in.'),
parts: [['spec & fixtures', 'none'], ['scrapers', 'full'], ['timeline UI', 'full']],
links: [['live demo', '#'], ['GitHub', 'https://github.com/StereotypicalCat']], rel: ['config', 'expenses'], posts: ['agent'] },
{ id: 'config', g: 'own', name: 'Config', yr: PH('2025'), ai: 'full', motif: 'infra',
one: 'Ansible and docker-compose: every service on my servers, deployed and updated from one repo.',
stack: ['Ansible', 'IaC', 'docker-compose', 'Docker', 'Linux'],
fig: 'screenshot: repo tree and a playbook run',
problem: 'Services were set up by hand over SSH, one machine at a time, and the only documentation was my memory.',
approach: 'An inventory of hosts, one role per kind of thing, and compose stacks templated from one pattern. One command brings a machine to the state git describes.',
interesting: PH('Idempotency: for a week every run restarted every container, because a template rendered a new timestamp each time.'),
parts: [['inventory & layout', 'none'], ['playbooks & roles', 'full'], ['compose files', 'full']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['ops', 'gacha'], posts: ['ansible'] },
{ id: 'chess', g: 'own', name: 'Chessswap', yr: PH('2020'), ai: 'none', motif: 'board',
one: 'Chess, except the board swaps every few turns.',
stack: ['React', 'JavaScript'],
fig: 'screenshot: board mid-swap',
problem: 'Chess openings are memorised; a twist that breaks memorisation makes it about thinking again.',
approach: 'A React board with normal chess rules, plus a swap every few turns that changes whose pieces you are playing.',
interesting: 'Rules that sound simple get strange at the edges: ' + PH('what happens to castling rights and check right at a swap?'),
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['breakout', 'dashboard'], posts: [] },
{ id: 'expenses', g: 'own', name: 'Monthly Expenses Tracker', yr: PH('2021'), ai: 'none', motif: 'web',
one: 'A small web app for where the money goes each month, in DKK.',
stack: ['JavaScript', 'web app'],
fig: 'screenshot: month view',
problem: 'Bank apps show transactions, not the shape of a month.',
approach: 'A plain JavaScript web app: enter expenses, see each month by category, in kroner.',
interesting: PH('Storing amounts in øre to dodge floating-point surprises.'),
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['gacha', 'dashboard'], posts: [] },
{ id: 'lolbot', g: 'own', name: 'LoL Winrate Discord Bot', yr: PH('2020'), ai: 'none', motif: 'bot',
one: 'Ask in Discord, get a League of Legends champion\'s current winrate.',
stack: ['Node.js', 'Discord API'],
fig: 'screenshot: bot reply in a channel',
problem: 'Mid-draft, nobody wants to alt-tab to a stats site.',
approach: 'A Node.js bot that answers a command with the champion\'s current winrate.',
interesting: PH('Rate limits and caching: a bot that is popular for ten minutes a night.'),
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['gacha'], posts: [] },
{ id: 'portfolio', g: 'own', name: 'Portfolio (2021)', yr: '2021', ai: 'none', motif: 'web',
one: 'The old React + Material UI site, kept frozen as the by-hand archive.',
stack: ['React', 'Material UI'],
fig: 'screenshot: the 2021 site',
problem: 'A place to put projects, with a search.',
approach: 'Create React App and Material UI, projects read from one info.json.',
interesting: 'It is now the archive: everything on it was written by hand, before AI tools.',
parts: [['everything', 'none']],
links: [['old site', '#']], rel: ['borlaug'], posts: [] },
{ id: 'borlaug', g: 'own', name: 'Tribute to Norman Borlaug', yr: PH('2016'), ai: 'none', motif: 'doc',
one: 'One of my first websites, a freeCodeCamp tribute page.',
stack: ['HTML', 'CSS'],
fig: 'screenshot: tribute page',
problem: 'Learning to make a web page at all.',
approach: 'One HTML page and a stylesheet, following the freeCodeCamp brief.',
interesting: 'Kept for honesty: this is where it started.',
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['portfolio'], posts: [] },
{ id: 'hypercache', g: 'school', name: 'HyperCache', yr: PH('2022'), where: 'Bachelor thesis · ' + PH('Aarhus University'), ai: 'none', motif: 'net',
one: 'Reputation systems for peer-to-peer archival: who can you trust to keep your data?',
stack: ['Node.js', 'P2P', 'simulation', 'reputation systems'],
fig: 'diagram: peers and trust',
problem: 'In a peer-to-peer archive, data only survives if peers keep storing it. Some peers will freeload or quietly drop data.',
approach: 'A reputation system where peers rate each other on whether data is really kept, built and simulated in Node.js.',
interesting: PH('How fast trust recovers after a bad actor leaves, and how cheaply a newcomer can fake a good history.'),
parts: [['thesis & code', 'none']],
links: [['thesis (PDF)', '#']], rel: ['vrclass', 'compute'], posts: ['thesis'] },
{ id: 'dashboard', g: 'school', name: 'Student Dashboard', yr: PH('2021'), ai: 'none', motif: 'web',
one: 'A dashboard for students, shaped by user studies rather than guesses.',
stack: ['React', 'user studies'],
fig: 'screenshot: dashboard, second iteration',
problem: 'Students juggle several university systems to answer simple questions like "what is due this week?".',
approach: 'User studies first, then a React dashboard, then studies again to see what actually helped.',
interesting: PH('The first interviews threw out half of the planned features.'),
parts: [['everything', 'none']],
links: [['report (PDF)', '#'], ['demo', '#']], rel: ['vrclass', 'chess'], posts: [] },
{ id: 'rowing', g: 'school', name: 'Mixed-Reality Rowing Game', yr: PH('2020'), ai: 'none', motif: 'game',
one: 'Row a virtual boat with two real oars, wired up through an Arduino.',
stack: ['Unity', 'C#', 'Arduino'],
fig: 'photo: the two oars on their rig',
problem: 'Rowing machines are boring and VR controllers don\'t feel like oars.',
approach: 'Two real oars with sensors read by an Arduino, sending each stroke into a Unity scene over serial.',
interesting: PH('Turning noisy sensor readings into a stroke that feels right, with almost no lag.'),
parts: [['everything', 'none']],
links: [['video', '#']], rel: ['censor', 'vrclass', 'sah'], posts: [] },
{ id: 'vrclass', g: 'school', name: 'VR-Classroom', yr: PH('2020'), ai: 'none', motif: 'vr',
one: 'A Node.js prototype of a shared virtual classroom.',
stack: ['Node.js', 'VR', 'prototyping'],
fig: 'still from the video',
problem: 'Remote teaching loses the feeling of being in the same room.',
approach: 'A Node.js prototype that keeps participants in a shared virtual room in sync.',
interesting: PH('Small things like where the teacher stands matter more than resolution.'),
parts: [['everything', 'none']],
links: [['report (PDF)', '#'], ['video', '#']], rel: ['rowing', 'dashboard', 'censor'], posts: [] },
{ id: 'censor', g: 'school', name: 'Censor Simulator', yr: PH('2018'), where: 'High-school exam project', ai: 'none', motif: 'vr',
one: 'A VR exam project in Unity: you are the one deciding what gets through.',
stack: ['Unity', 'C#', 'VR'],
fig: 'screenshot: the censor\'s desk',
problem: 'An exam project that had to be both a game and an argument.',
approach: 'A Unity VR scene where you sort and censor incoming material.',
interesting: PH('First time building for VR: comfort and scale before anything else.'),
parts: [['everything', 'none']],
links: [], rel: ['rowing', 'breakout'], posts: [] },
{ id: 'breakout', g: 'school', name: 'BreakoutPython', yr: PH('2017'), where: 'High-school exam project', ai: 'none', motif: 'board',
one: 'Breakout, written in Python for a high-school exam.',
stack: ['Python'],
fig: 'screenshot: level one',
problem: 'Learn to program a game loop from scratch.',
approach: 'Paddle, ball, bricks and collision, in Python.',
interesting: 'Collision is the whole game, and the first place bugs feel physical.',
parts: [['everything', 'none']],
links: [['GitHub', 'https://github.com/StereotypicalCat']], rel: ['chess', 'censor'], posts: [] }
];
function P(id) { for (var i = 0; i < PROJECTS.length; i++) if (PROJECTS[i].id === id) return PROJECTS[i]; }
function POST(id) { for (var i = 0; i < POSTS.length; i++) if (POSTS[i].id === id) return POSTS[i]; }
function strip(s) { return String(s).replace(/<[^>]+>/g, ''); }
function aiMark(a, withLabel) { return '<span class="ai ' + a + '" title="' + AI[a].long + '"><i></i>' + (withLabel === false ? '' : AI[a].label) + '</span>'; }
var PREF = 'all';
function allowed(p) { if (PREF === 'nofull') return p.ai !== 'full'; if (PREF === 'hand') return p.ai === 'none'; return true; }
/* ================= tech-r3-b ================= */
(function () {
PROJECTS.forEach(function (p, i) {
p.no = String(i + 1).padStart(2, '0');
if (!p.where) p.where = p.g === 'own' ? 'Own project' : 'University project';
});
var AILONG = { full: 'made with AI', some: 'some AI help', none: 'by hand', unk: 'not recorded' };
function aiMk(a) { return '<span class="ai ' + a + '" title="' + AI[a].long + '"><i></i>' + AILONG[a] + '</span>'; }
function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
/* ---------- drawing helpers (mini figures are 300 x 190) ---------- */
function R(x, y, w, h, c, rx) { return '<rect class="' + (c || 'b') + '" x="' + x + '" y="' + y + '" width="' + w + '" height="' + h + '"' + (rx ? ' rx="' + rx + '"' : '') + '/>'; }
function T(x, y, s, c, extra) { return '<text' + (c ? ' class="' + c + '"' : '') + ' x="' + x + '" y="' + y + '"' + (extra || '') + '>' + s + '</text>'; }
function Lp(d, c) { return '<path class="' + (c || 's') + '" d="' + d + '"/>'; }
function C(x, y, r, c) { return '<circle class="' + (c || 'b') + '" cx="' + x + '" cy="' + y + '" r="' + r + '"/>'; }
function lines(x, y, ws, gap, c) { var s = ''; ws.forEach(function (w, i) { s += R(x, y + i * gap, w, 3, c || 'gg', 1.5); }); return s; }
function win(x, y, w, h, title) { return R(x, y, w, h, 'b', 6) + Lp('M' + x + ' ' + (y + 16) + 'H' + (x + w), 'sl') + C(x + 9, y + 8, 2.2, 'gg') + C(x + 16, y + 8, 2.2, 'gg') + C(x + 23, y + 8, 2.2, 'gg') + (title ? T(x + 34, y + 11, title, 'm', ' style="font-size:7px"') : ''); }
function svg(inner, vb, label) { return '<svg class="fg" viewBox="' + (vb || '0 0 300 190') + '" role="img" aria-label="' + esc(label || '') + '">' + inner + '</svg>'; }
var MINI = {
compute: function () {
var s = R(88, 22, 204, 132, 'sl d', 6) + T(96, 34, 'oracle cloud', 'm');
s += R(8, 74, 56, 32, 'b', 4) + T(15, 88, 'browser') + T(15, 99, 'AU login', 'm');
s += R(98, 48, 62, 28, 'b', 4) + T(105, 60, 'react') + T(105, 70, 'web app', 'm');
s += R(98, 100, 62, 28, 'b', 4) + T(105, 112, 'node') + T(105, 122, 'api', 'm');
s += R(186, 100, 50, 28, 'b', 4) + T(193, 112, 'slurm') + T(193, 122, 'queue', 'm');
s += R(250, 46, 34, 18, 'k', 3) + R(250, 70, 34, 18, 'k', 3) + R(250, 94, 34, 18, 'b', 3) + R(250, 118, 34, 18, 'sl d', 3);
s += T(256, 58, 'n01', 'w') + T(256, 82, 'n02', 'w') + T(256, 106, 'n03') + T(256, 130, '··', 'm');
s += Lp('M64 90H80V62H98', 'as') + Lp('M129 76V100', 'as') + Lp('M160 114H186', 'as') + Lp('M236 114H243V55H250M243 79H250M243 103H250', 's');
s += R(98, 160, 62, 20, 'b', 4) + T(105, 173, 'erda') + R(8, 160, 56, 20, 'b', 4) + T(15, 173, 'entra id') + Lp('M129 128V160M36 106V160', 's d');
return svg(s, 0, 'web app in front of Slurm, nodes on Oracle Cloud');
},
docs: function () {
var s = win(10, 10, 280, 170, 'overleaf · thesis.tex');
s += R(10, 26, 54, 154, 'g') + lines(18, 36, [32, 26, 36, 22, 30], 12);
s += T(74, 42, '\\section{Method}', 'm') + lines(74, 52, [96, 104, 80, 100, 60], 10) + T(74, 112, '\\begin{figure}', 'm') + lines(74, 122, [90, 70], 10);
s += R(190, 34, 90, 138, 'b', 2) + lines(200, 46, [50], 0, 'k') + lines(200, 58, [70, 66, 70, 52], 7) + R(200, 92, 70, 34, 'g') + lines(200, 134, [70, 62, 68, 40], 7);
s += R(122, 160, 56, 14, 'a', 7) + T(130, 170, '2 editing', 'w', ' style="font-size:7px"');
return svg(s, 0, 'shared editor with a page preview');
},
ops: function () {
var s = win(10, 10, 280, 170, 'grafana · research-services');
s += R(20, 34, 124, 62, 'b', 4) + T(28, 46, 'cpu, 24h', 'm') + Lp('M28 84L44 78L58 82L72 66L86 72L100 58L114 64L136 52', 's') + Lp('M28 60H136', 'sl d');
s += R(156, 34, 124, 62, 'b', 4) + T(164, 46, 'uptime', 'm') + T(164, 80, '99.97%', 'big', ' style="font-size:22px"');
s += R(20, 104, 124, 66, 'b', 4) + T(28, 116, 'containers', 'm');
[22, 30, 18, 34, 26, 30, 38, 20].forEach(function (h, i) { s += R(30 + i * 13, 162 - h, 8, h, 'gg'); });
s += R(156, 104, 124, 66, 'b', 4) + T(164, 116, 'checks', 'm');
['vault', 'overleaf', 'openproject', 'backup'].forEach(function (n, i) { s += C(168, 128 + i * 11, 2.6, i === 3 ? 'a' : 'k') + T(176, 131 + i * 11, n + (i === 3 ? ' late' : '')); });
return svg(s, 0, 'monitoring dashboard');
},
sah: function () {
var s = win(10, 10, 280, 170, 'prototype v0');
var pts = [[60, 70], [110, 50], [160, 80], [210, 56], [250, 100], [190, 130], [120, 120], [70, 140]];
[[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7], [7, 0], [2, 6], [2, 5]].forEach(function (e) { s += Lp('M' + pts[e[0]] + 'L' + pts[e[1]], e[0] === 2 && e[1] === 5 ? 'as' : 'sl'); });
pts.forEach(function (p, i) { s += C(p[0], p[1], i === 5 ? 9 : 7, i === 2 ? 'k' : i === 5 ? 'a' : 'b'); });
s += T(22, 172, 'base mechanic only · no art yet', 'm');
return svg(s, 0, 'early game prototype');
},
gacha: function () {
var s = win(10, 10, 280, 170, 'events · this week');
['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'].forEach(function (d, i) { s += T(74 + i * 30, 36, d, 'm'); s += Lp('M' + (70 + i * 30) + ' 42V172', 'sl'); });
var rows = [['game a', 70, 120, 0], ['game b', 100, 180, 1], ['game c', 70, 90, 0], ['game d', 160, 120, 0], ['game e', 130, 60, 0]];
rows.forEach(function (r, i) { var y = 50 + i * 24; s += T(20, y + 10, r[0], 'm') + R(r[1], y, r[2], 14, r[3] ? 'a' : (i % 2 ? 'k' : 'gg'), 3); });
s += Lp('M148 40V174', 'as') + T(151, 48, 'now', 'a', ' style="font-size:7px"');
s += T(184, 84, 'ends in 2d', 'w', ' style="font-size:7px"');
return svg(s, 0, 'event timeline for several games');
},
config: function () {
var s = R(10, 10, 280, 170, 'k', 6);
var L = [['$ ansible-playbook site.yml', 'w'], ['PLAY [all] ******************', 'm'], ['ok: [host-01]', 'w'], ['ok: [host-02]', 'w'], ['changed: [host-03]', 'a'], ['', ''], ['PLAY RECAP *****************', 'm'], ['host-01 ok=24 changed=0', 'w'], ['host-02 ok=24 changed=0', 'w'], ['host-03 ok=24 changed=1', 'a']];
L.forEach(function (l, i) { if (l[0]) s += T(22, 30 + i * 14.5, l[0], l[1]); });
s += R(22, 172, 6, 2, 'a');
return svg(s, 0, 'a playbook run in a terminal');
},
chess: function () {
var s = '', x0 = 22, y0 = 15, q = 20;
for (var r = 0; r < 8; r++) for (var c = 0; c < 8; c++) s += R(x0 + c * q, y0 + r * q, q, q, (r + c) % 2 ? 'gg' : 'b');
s += R(x0, y0, 160, 160, 's');
[[1, 6], [2, 6], [5, 6], [4, 7], [3, 4]].forEach(function (p) { s += C(x0 + p[0] * q + 10, y0 + p[1] * q + 10, 6, 'k'); });
[[2, 1], [5, 1], [6, 1], [4, 0], [4, 3]].forEach(function (p) { s += C(x0 + p[0] * q + 10, y0 + p[1] * q + 10, 6, 'b'); });
s += Lp('M210 70H270M262 64L270 70L262 76', 'as') + Lp('M270 110H210M218 104L210 110L218 116', 'as');
s += T(206, 54, 'board swaps', 'm') + T(206, 136, 'in 2 turns', 'a');
return svg(s, 0, 'chess board that swaps sides');
},
expenses: function () {
var s = win(10, 10, 280, 170, 'october');
s += T(22, 50, 'kr 8.420', 'big', ' style="font-size:22px"') + T(22, 62, 'spent this month', 'm');
var cats = [['rent', 120], ['food', 64], ['transport', 30], ['fun', 22], ['other', 14]];
cats.forEach(function (c, i) { var y = 78 + i * 18; s += T(22, y + 9, c[0], 'm') + R(80, y, c[1], 10, i === 1 ? 'a' : 'gg', 2); });
[40, 52, 46, 60, 50, 56].forEach(function (h, i) { s += R(222 + i * 10, 166 - h, 6, h, i === 5 ? 'k' : 'gg'); });
s += T(222, 54, 'by month', 'm');
return svg(s, 0, 'monthly expenses by category');
},
lolbot: function () {
var s = R(10, 10, 280, 170, 'b', 6) + R(10, 10, 56, 170, 'g', 0) + T(18, 28, '# draft', 'm') + T(18, 42, '# general', 'm');
s += C(84, 44, 8, 'gg') + T(98, 42, 'mads') + T(98, 54, '!wr ahri mid', 'm');
s += C(84, 82, 8, 'k') + T(98, 80, 'winrate-bot') + R(98, 88, 172, 58, 'b', 4) + R(98, 88, 3, 58, 'a');
s += T(108, 102, 'Ahri · mid · plat+') + T(108, 124, '51.3%', 'big', ' style="font-size:18px"') + T(160, 124, 'win rate', 'm') + T(108, 138, 'patch 14.x · 22k games', 'm');
s += R(76, 158, 200, 14, 'g', 4) + T(84, 168, 'message #draft', 'm');
return svg(s, 0, 'discord bot reply');
},
portfolio: function () {
var s = R(10, 10, 280, 170, 'k', 6) + C(46, 50, 14, 'gg') + lines(26, 74, [40, 32], 8) + lines(26, 98, [30, 30, 30, 30], 9);
for (var i = 0; i < 6; i++) { var x = 92 + (i % 3) * 64, y = 26 + Math.floor(i / 3) * 76; s += R(x, y, 56, 68, 'b', 3) + R(x + 4, y + 4, 48, 30, 'gg') + lines(x + 4, y + 42, [40, 30], 8); }
return svg(s, 0, 'the 2021 site: dark sidebar and project grid');
},
borlaug: function () {
var s = win(10, 10, 280, 170, 'tribute page');
s += T(150, 44, 'Dr. Norman Borlaug', 'big', ' text-anchor="middle" style="font-size:13px"') + T(150, 56, 'the man who saved a billion lives', 'm', ' text-anchor="middle"');
s += R(100, 64, 100, 56, 'g') + Lp('M100 64L200 120M200 64L100 120', 'sl');
s += lines(90, 130, [120, 110, 120, 90], 9);
return svg(s, 0, 'a simple tribute web page');
},
hypercache: function () {
var P2 = [[40, 50, .9], [100, 30, .8], [160, 60, .95], [230, 40, .7], [270, 100, .85], [200, 120, .2, 1], [130, 140, .75], [60, 130, .6], [110, 90, .9]];
var E2 = [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7], [7, 0], [8, 0], [8, 2], [8, 6], [2, 5], [8, 7]];
var s = '';
E2.forEach(function (e) { var a = P2[e[0]], b = P2[e[1]]; s += Lp('M' + a[0] + ' ' + a[1] + 'L' + b[0] + ' ' + b[1], a[3] || b[3] ? 'as d' : 'sl'); });
P2.forEach(function (p, i) { s += C(p[0], p[1], 6 + p[2] * 8, p[3] ? 'b' : i === 8 ? 'k' : 'b'); });
s += Lp('M200 120m-8 0a8 8 0 1 0 16 0a8 8 0 1 0 -16 0', 'as d');
s += T(212, 146, 'claims 12 chunks,', 'a') + T(212, 157, 'holds 3', 'a') + T(20, 178, 'size = reputation', 'm');
return svg(s, 0, 'peer network with reputation');
},
dashboard: function () {
var s = win(10, 10, 280, 170, 'student dashboard');
s += R(20, 34, 130, 136, 'b', 4) + T(28, 48, 'due this week', 'm');
['report draft', 'quiz 3', 'lab hand-in', 'reading'].forEach(function (t, i) { var y = 62 + i * 24; s += R(28, y - 7, 8, 8, i === 0 ? 'a' : 'b', 2) + T(42, y, t) + T(110, y, ['mon', 'tue', 'thu', 'fri'][i], 'm'); });
s += R(160, 34, 120, 70, 'b', 4) + T(168, 48, 'week 44', 'm');
for (var d = 0; d < 7; d++) s += R(168 + d * 15, 58, 11, 36, d === 1 ? 'k' : 'g', 2);
s += R(160, 112, 120, 58, 'b', 4) + T(168, 126, 'credits', 'm') + R(168, 140, 104, 8, 'g', 4) + R(168, 140, 70, 8, 'k', 4) + T(168, 162, '120 / 180 ECTS', 'm');
return svg(s, 0, 'dashboard with deadlines and calendar');
},
rowing: function () {
var s = '<ellipse class="b" cx="150" cy="95" rx="22" ry="70"/>' + Lp('M150 30V160', 'sl');
s += Lp('M130 92L40 60', 'as') + Lp('M170 92L260 60', 'as') + C(130, 92, 4, 'k') + C(170, 92, 4, 'k');
s += Lp('M40 60Q30 96 44 128', 's d') + Lp('M260 60Q270 96 256 128', 's d');
s += T(12, 50, 'left oar', 'm') + T(236, 50, 'right oar', 'm') + T(12, 150, 'angle → arduino', 'm') + T(200, 150, 'serial → unity', 'm');
s += R(118, 170, 64, 14, 'k', 3) + T(124, 180, 'stroke 0.92', 'w', ' style="font-size:7.5px"');
return svg(s, 0, 'two real oars on a boat');
},
vrclass: function () {
var s = R(10, 10, 280, 170, 'b', 6);
s += Lp('M10 120L120 70H180L290 120', 'sl') + Lp('M120 70V10M180 70V10', 'sl');
for (var i = 0; i < 6; i++) s += Lp('M' + (120 + i * 12) + ' 70L' + (10 + i * 56) + ' 180', 'sl');
s += R(128, 34, 44, 26, 'g') + T(134, 50, 'slides', 'm');
[[60, 140], [110, 140], [190, 140], [240, 140], [95, 110], [150, 110], [205, 110]].forEach(function (p, i) { s += R(p[0] - 14, p[1], 28, 8, 'gg', 1) + C(p[0], p[1] - 7, 5, i === 5 ? 'a' : 'b'); });
s += C(150, 76, 6, 'k') + T(160, 80, 'teacher', 'm');
return svg(s, 0, 'a shared virtual classroom');
},
censor: function () {
var s = R(10, 120, 280, 60, 'g') + Lp('M10 120H290', 's');
s += '<g transform="rotate(-4 90 80)">' + R(40, 40, 100, 120, 'b', 2) + lines(52, 56, [70, 60, 76, 50, 70, 66, 40], 10) + R(52, 76, 60, 7, 'k') + R(52, 106, 44, 7, 'k') + '</g>';
s += '<g transform="rotate(5 210 80)">' + R(160, 36, 100, 120, 'b', 2) + lines(172, 52, [70, 62, 74, 56, 70], 10) + R(176, 112, 70, 22, 'as') + T(186, 127, 'APPROVED?', 'a') + '</g>';
return svg(s, 0, 'documents on a censor\'s desk');
},
breakout: function () {
var s = R(10, 10, 280, 170, 'k', 6);
for (var r = 0; r < 4; r++) for (var c = 0; c < 9; c++) if (!(r === 3 && (c === 4 || c === 5)) && !(r === 2 && c === 5)) s += R(22 + c * 29, 24 + r * 12, 25, 8, r === 0 ? 'a' : 'gg', 1);
s += Lp('M150 160L196 110L170 76', 's d') + '<path d="M150 160L196 110L170 76" style="stroke:#f4f4f1;stroke-dasharray:3 3;fill:none"/>' + C(170, 76, 4, 'b') + R(124, 164, 52, 6, 'b', 3);
s += T(22, 150, 'score 1420', 'w') + T(232, 150, 'lives 2', 'w');
return svg(s, 0, 'breakout game');
}
};
/* ---------- big figures for a few projects (660 x 380) ---------- */
var ARCH = [
['br', 16, 160, 112, 46, 'researcher', 'browser', 'Researchers log in with their AU account and submit jobs from a form. No SSH, no batch scripts.'],
['web', 190, 110, 120, 46, 'web app', 'React', 'React front end: choose a job, choose data, follow its progress and log.'],
['api', 190, 200, 120, 46, 'api', 'Node.js', 'Node back end: keeps job state and talks to Slurm. Scales with load, like the front end.'],
['ent', 16, 310, 112, 42, 'entra id', 'OAuth', 'Login through OAuth with Entra ID, so every AU account just works.'],
['erda', 190, 310, 120, 42, 'erda', 'AU storage', 'ERDA, AU\'s own storage: input data in, results back out, no copying by hand.'],
['slurm', 360, 200, 110, 46, 'slurm ctl', 'queue', 'Slurm queues the jobs and asks for more nodes when the queue grows.'],
['n1', 520, 92, 112, 40, 'node 01', 'running', 'A compute node on Oracle Cloud, started because jobs were waiting.'],
['n2', 520, 143, 112, 40, 'node 02', 'running', 'A compute node on Oracle Cloud, started because jobs were waiting.'],
['n3', 520, 194, 112, 40, 'node 03', 'idle → stop', 'Idle nodes are stopped again, so nothing is paid for while no one is computing.'],
['n4', 520, 245, 112, 40, 'node ··', 'on demand', 'Dashed nodes only exist while they are needed.', 1]
];
var PEERS = [[90, 70, .91, 12, 12], [190, 170, .84, 11, 11], [70, 260, .77, 9, 9], [300, 80, .95, 14, 14], [320, 240, .62, 8, 9], [430, 150, .88, 12, 12], [450, 290, .18, 3, 12, 1], [540, 70, .80, 10, 10], [580, 220, .72, 9, 9], [220, 310, .35, 4, 10, 1]];
var EDGES = [[0, 1], [0, 3], [1, 2], [1, 3], [1, 4], [2, 9], [3, 5], [3, 7], [4, 5], [4, 6], [4, 9], [5, 7], [5, 8], [6, 8], [7, 8], [6, 9]];
var BIG = {
compute: function () {
var s = R(170, 64, 476, 236, 'sl d', 8) + T(184, 84, 'oracle cloud · web stack and compute scale up and down', 'm', ' style="font-size:10.5px"');
s += Lp('M128 183H158V133H190', 'as') + Lp('M250 156V200', 'as') + Lp('M310 223H360', 'as') + Lp('M470 223H495V112H520M495 163H520M495 214H520', 's') + Lp('M495 265H520', 's d');
s += Lp('M250 246V310', 's d') + Lp('M72 206V310', 's d');
ARCH.forEach(function (n) {
s += '<g class="hit" tabindex="0" data-note="' + esc(n[7]) + '" data-name="' + n[5] + '">' + R(n[1], n[2], n[3], n[4], n[8] ? 'b d' : 'b', 5) +
'<text x="' + (n[1] + 11) + '" y="' + (n[2] + 17) + '" style="font-size:11.5px">' + n[5] + '</text><text class="m" x="' + (n[1] + 11) + '" y="' + (n[2] + n[4] - 8) + '" style="font-size:10px">' + n[6] + '</text></g>';
});
s += T(318, 214, 'submit', 'm', ' style="font-size:10px"') + T(16, 370, 'aarhus university services', 'm', ' style="font-size:10px"');
return { svg: svg(s, '0 0 660 380', 'Architecture of the compute job platform'), cap: 'Point at a box. Orange is the path a job takes.', tag: 'fig. 01 · ' + PH('simplified') };
},
hypercache: function () {
var s = '';
EDGES.forEach(function (e) { var a = PEERS[e[0]], b = PEERS[e[1]]; s += '<line class="e" data-a="' + e[0] + '" data-b="' + e[1] + '" x1="' + a[0] + '" y1="' + a[1] + '" x2="' + b[0] + '" y2="' + b[1] + '"/>'; });
PEERS.forEach(function (p, i) { s += '<g class="peer' + (p[5] ? ' bad' : '') + '" tabindex="0" data-i="' + i + '"><circle cx="' + p[0] + '" cy="' + p[1] + '" r="' + (12 + p[2] * 12) + '"/><text x="' + p[0] + '" y="' + (p[1] + 4) + '" style="font-size:11px">' + String(i + 1).padStart(2, '0') + '</text></g>'; });
s += T(16, 368, 'circle size = reputation · dashed = claims more than it holds', 'm', ' style="font-size:10.5px"');
return { svg: svg(s, '0 0 660 380', 'Peer network with reputation scores'), cap: 'Point at a peer to see whether you should trust it.', tag: 'fig. 12 · ' + PH('illustrative numbers') };
},
gacha: function () {
var s = R(4, 4, 652, 372, 'b', 10) + Lp('M4 40H656', 'sl') + T(20, 27, 'Event Tracker', 'big', ' style="font-size:14px"') + T(130, 27, 'all games · week 45 · times in your zone', 'm', ' style="font-size:10px"');
var days = ['mon 3', 'tue 4', 'wed 5', 'thu 6', 'fri 7', 'sat 8', 'sun 9'];
days.forEach(function (d, i) { s += T(146 + i * 72, 62, d, 'm', ' style="font-size:10px"') + Lp('M' + (140 + i * 72) + ' 70V350', 'sl'); });
var ev = [['Game A', 'Lantern rite', 140, 200, 'gg'], ['Game A', 'Weapon banner', 284, 280, 'k'], ['Game B', 'Summer event', 140, 330, 'a'], ['Game C', 'Login bonus', 212, 144, 'gg'], ['Game D', 'Limited raid', 356, 130, 'k'], ['Game E', 'Anniversary', 428, 214, 'gg'], ['Game E', 'Shop reset', 140, 60, 'gg']];
ev.forEach(function (e, i) { var y = 82 + i * 38; s += T(20, y + 17, e[0], '', ' style="font-size:11px"') + R(e[2], y, e[3], 26, e[4], 4) + T(e[2] + 10, y + 17, e[1], e[4] === 'gg' ? '' : 'w', ' style="font-size:10.5px"'); });
s += Lp('M318 66V356', 'as') + R(296, 352, 44, 16, 'a', 3) + T(305, 363, 'now', 'w', ' style="font-size:9.5px"');
s += T(384, 175, 'ends in 2d 4h', 'w', ' style="font-size:10px;opacity:.8"');
return { svg: svg(s, '0 0 660 380', 'Event timeline screen'), cap: 'Five games, one week, one time zone.', tag: 'fig. 05 · ' + PH('drawn, not a screenshot') };
},
rowing: function () {
var s = '<ellipse class="b" cx="120" cy="190" rx="34" ry="130"/>' + Lp('M120 64V316', 'sl') + Lp('M94 186L14 120', 'as') + Lp('M146 186L226 120', 'as') + C(94, 186, 5, 'k') + C(146, 186, 5, 'k');
s += T(20, 104, 'oar + sensor', 'm', ' style="font-size:10px"') + T(180, 104, '× 2', 'm', ' style="font-size:10px"');
var box = function (x, y, a, b) { return R(x, y, 110, 52, 'b', 5) + T(x + 12, y + 22, a, '', ' style="font-size:12px"') + T(x + 12, y + 40, b, 'm', ' style="font-size:10px"'); };
s += box(280, 164, 'arduino', 'reads both oars') + box(420, 164, 'serial', 'usb, per stroke') + box(540, 164, 'unity', 'C# · boat');
s += Lp('M226 160Q250 190 280 190', 's d') + Lp('M390 190H420M530 190H540', 's');
s += R(280, 260, 370, 80, 'g', 5) + T(294, 282, 'stroke', 'm', ' style="font-size:10px"');
s += Lp('M294 326C320 326 330 292 360 292S400 326 430 326S470 292 500 292S540 326 570 326S610 292 636 292', 'as');
s += T(280, 140, 'sensor type: to confirm<title>placeholder</title>', 'm', ' style="font-size:10px;text-decoration:underline dotted"');
return { svg: svg(s, '0 0 660 380', 'Wiring from oars to Unity'), cap: 'Two angles in, one stroke out, the boat moves as hard as you row.', tag: 'fig. 14 · ' + PH('photos to come') };
},
config: function () {
var h = '<div class="ba"><div><h5><b>before</b> · ' + PH('notes.txt') + '</h5><ul>' +
'<li><span>host-01</span><span>ssh in, git pull, docker compose up -d</span></li>' +
'<li><span>host-02</span><span>same, but its compose file is older</span></li>' +
'<li><span>host-02</span><span class="x">forgot the .env, down for a day</span></li>' +
'<li><span>host-03</span><span>"temporary" container from 2023</span></li>' +
'<li><span>new box</span><span class="x">which of these did I do last time?</span></li></ul></div>' +
'<div><h5><b>after</b> · config/</h5><pre><b>inventory/</b> hosts.yml\n<b>roles/</b> base · docker · backups\n<b>stacks/</b> one compose file per service\n<b>site.yml</b></pre>' +
'<div class="term">$ ansible-playbook site.yml<br>host-01 <span class="ok">ok=24</span> changed=0<br>host-02 <span class="ok">ok=24</span> changed=0<br>host-03 <span class="ok">ok=23</span> <span class="ch">changed=1</span></div></div></div>';
return { html: h, cap: 'One command brings every box to the state git describes.', tag: 'fig. 06 · ' + PH('details') };
}
};
function figureFor(p) {
var b = BIG[p.id] && BIG[p.id]();
if (b) return '<div class="fig" data-fig="' + p.id + '"><div class="cv-in">' + (b.svg || b.html) + '</div><div class="cap"><span class="live">' + b.cap + '</span><span>' + b.tag + '</span></div></div>';
return '<div class="fig small"><div class="cv-in">' + MINI[p.id]() + '</div><div class="cap"><span class="live">' + p.fig + '</span><span>fig. ' + p.no + ' · ' + PH('drawn stand-in') + '</span></div></div>';
}
/* ---------- rows ---------- */
var rowsEl = document.getElementById('rows');
rowsEl.innerHTML = PROJECTS.map(function (p) {
return '<article class="row" id="p-' + p.id + '" data-id="' + p.id + '">' +
'<button class="rh" aria-expanded="false" aria-controls="d-' + p.id + '"><span class="no">' + p.no + '</span><span class="nm">' + p.name + '</span><span class="wh">' + strip(p.where).split(' · ')[0] + '</span><span class="yr">' + p.yr + '</span><span class="aiw">' + aiMk(p.ai) + '</span><span class="pm" aria-hidden="true"></span></button>' +
'<div class="rd" id="d-' + p.id + '"><div class="in"></div></div></article>';
}).join('');
function detailHTML(p, idx) {
var rel = p.rel.map(P).filter(Boolean);
var nxt = PROJECTS[(idx + 1) % PROJECTS.length];
var s = '<div class="det">';
s += '<div class="story"><span class="where">' + p.where + ' · ' + p.yr + '</span><p class="lead">' + p.one + '</p>' +
'<div class="st"><span class="k">why</span><p>' + p.problem + '</p></div>' +
'<div class="st"><span class="k">how</span><p>' + p.approach + '</p></div>' +
'<div class="st"><span class="k">interesting</span><p>' + p.interesting + '</p></div></div>';
s += '<div class="figw">' + figureFor(p) + '</div>';
s += '<div class="facts">' +
'<div><h4>stack</h4><div class="stack">' + p.stack.map(function (t) { return '<span>' + t + '</span>'; }).join('') + '</div></div>' +
'<div><h4>how it was made</h4><ul class="parts">' + p.parts.map(function (x) { return '<li><span>' + x[0] + '</span>' + aiMk(x[1]) + '</li>'; }).join('') + '</ul></div>' +
'<div><h4>links</h4><ul>' + (p.links.length ? p.links.map(function (l) { return l[1] ? '<li><a class="go" href="' + l[1] + '"' + (l[1] === '#' ? ' title="placeholder link"' : '') + '>' + l[0] + ' <span>↗</span></a></li>' : '<li class="none">' + l[0] + '</li>'; }).join('') : '<li class="none">nothing public</li>') + '</ul></div>' +
'<div><h4>related</h4><ul>' + p.posts.map(function (id) { var w = POST(id); return '<li><a class="go" href="https://blog.lucaswinther.info"><span class="ph" title="placeholder title">' + w.t + '</span></a></li>'; }).join('') +
rel.map(function (r) { return '<li><a class="go" href="#' + r.id + '"><span class="lbl">' + r.no + '</span> ' + r.name + '</a></li>'; }).join('') + '</ul></div>' +
'</div>';
s += '<div class="nextp"><button data-close>close ×</button><a href="#' + nxt.id + '">next: ' + nxt.no + ' ' + nxt.name + ' →</a></div>';
return s + '</div>';
}
/* ---------- open / close, one at a time, hash follows ---------- */
var openId = null;
function rowOf(id) { return document.getElementById('p-' + id); }
function headerH() { return document.querySelector('.bar').offsetHeight; }
function closeRow(id, instant) {
var r = rowOf(id); if (!r) return;
if (instant) r.classList.add('instant');
r.classList.remove('open'); r.querySelector('.rh').setAttribute('aria-expanded', 'false');
if (instant) { r.offsetHeight; r.classList.remove('instant'); }
}
function open(id, opts) {
opts = opts || {};
var r = rowOf(id); if (!r || r.hidden) return;
if (openId === id) { if (opts.toggle) { closeRow(id); openId = null; setHash(''); } return; }
hidePv();
if (openId) {
var old = rowOf(openId), oldAbove = old.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_FOLLOWING;
var before = old.offsetHeight;
closeRow(openId, true);
if (oldAbove) window.scrollBy(0, old.offsetHeight - before);
}
var inEl = r.querySelector('.in');
if (!inEl.firstChild) { inEl.innerHTML = detailHTML(P(id), PROJECTS.indexOf(P(id))); wireFig(r); }
r.offsetHeight;
r.classList.add('open'); r.querySelector('.rh').setAttribute('aria-expanded', 'true');
openId = id; setHash(id);
var top = r.getBoundingClientRect().top - headerH();
if (opts.scroll || top < 0 || top > window.innerHeight * .45) window.scrollTo({ top: window.scrollY + top - 8, behavior: opts.jump ? 'auto' : 'smooth' });
}
function setHash(id) { history.replaceState(null, '', id ? '#' + id : location.pathname + location.search); }
rowsEl.addEventListener('click', function (e) {
var h = e.target.closest('.rh'); if (h) { open(h.parentNode.dataset.id, { toggle: true }); return; }
if (e.target.closest('[data-close]')) { var id = openId; closeRow(id); openId = null; setHash(''); var r = rowOf(id); if (r.getBoundingClientRect().top < headerH()) window.scrollTo({ top: window.scrollY + r.getBoundingClientRect().top - headerH() - 8, behavior: 'smooth' }); r.querySelector('.rh').focus({ preventScroll: true }); }
});
// any in-page link to a project id opens that row
document.addEventListener('click', function (e) {
var a = e.target.closest('a[href^="#"]'); if (!a) return;
var id = a.getAttribute('href').slice(1);
if (P(id)) { e.preventDefault(); if (rowOf(id).hidden) setPref('all'); open(id, { scroll: true }); }
});
window.addEventListener('hashchange', function () { var id = location.hash.slice(1); if (P(id)) open(id, { scroll: true }); });
/* ---------- figure interactions ---------- */
function wireFig(r) {
var f = r.querySelector('.fig'); if (!f) return;
var cap = f.querySelector('.live'), base = cap.innerHTML;
if (f.dataset.fig === 'compute') {
f.querySelectorAll('.hit').forEach(function (g) {
var on = function () { cap.innerHTML = '<b>' + g.dataset.name + '</b> · ' + g.dataset.note; };
g.addEventListener('mouseenter', on); g.addEventListener('focus', on);
g.addEventListener('mouseleave', function () { cap.innerHTML = base; });
});
}
if (f.dataset.fig === 'hypercache') {
f.querySelectorAll('.peer').forEach(function (g) {
var i = +g.dataset.i, p = PEERS[i];
var on = function () {
f.querySelectorAll('line.e').forEach(function (l) { l.classList.toggle('on', +l.dataset.a === i || +l.dataset.b === i); });
cap.innerHTML = '<b>peer ' + String(i + 1).padStart(2, '0') + '</b> · reputation ' + p[2].toFixed(2) + ' · holds ' + p[3] + ' of ' + p[4] + ' chunks · ' + (p[2] < .5 ? '<span style="color:var(--acc-ink)">don\'t store here</span>' : 'safe to store here');
};
g.addEventListener('mouseenter', on); g.addEventListener('focus', on);
g.addEventListener('mouseleave', function () { cap.innerHTML = base; f.querySelectorAll('line.e.on').forEach(function (l) { l.classList.remove('on'); }); });
});
}
}
/* ---------- floating hover preview ---------- */
var pv = document.getElementById('pv'), pvf = document.getElementById('pvf'), pvId = null;
function hidePv() { pv.classList.remove('on'); pvId = null; }
function placePv(e) {
var w = 302, h = pv.offsetHeight || 240, x = e.clientX + 28, y = e.clientY - h / 2;
if (x + w > window.innerWidth - 60) x = e.clientX - w - 28;
y = Math.max(headerH() + 8, Math.min(y, window.innerHeight - h - 12));
pv.style.setProperty('--x', x + 'px'); pv.style.setProperty('--y', y + 'px');
}
{
rowsEl.addEventListener('pointermove', function (e) {
if (e.pointerType !== 'mouse') return;
var h = e.target.closest('.rh');
if (!h || h.parentNode.classList.contains('open')) { if (pvId) hidePv(); return; }
var id = h.parentNode.dataset.id;
if (id !== pvId) {
var p = P(id); pvf.innerHTML = MINI[id]();
document.getElementById('pvc1').textContent = p.no + ' · ' + strip(p.fig).replace(/^(screenshot|photo|diagram|still from the video|sketch): ?/, '').replace(/^still from the video$/, 'video still');
document.getElementById('pvc2').textContent = AILONG[p.ai];
var first = !pv.classList.contains('on');
if (first) { pv.style.transition = 'none'; placePv(e); pv.offsetHeight; pv.style.transition = ''; }
pvId = id; pv.classList.add('on');
}
placePv(e);
});
rowsEl.addEventListener('pointerleave', hidePv);
window.addEventListener('scroll', function () { if (pvId) hidePv(); }, { passive: true });
}
/* ---------- AI filter ---------- */
var PREFNAME = { nofull: 'not fully by AI', hand: 'by hand only' };
function setPref(v) {
PREF = v;
document.querySelectorAll('.seg button').forEach(function (b) { b.setAttribute('aria-pressed', b.dataset.pref === v ? 'true' : 'false'); });
var shown = 0;
PROJECTS.forEach(function (p) { var r = rowOf(p.id), ok = allowed(p); r.hidden = !ok; if (ok) shown++; if (!ok && openId === p.id) { closeRow(p.id, true); openId = null; setHash(''); } });
var hidden = PROJECTS.length - shown;
document.getElementById('idxN').textContent = shown + (hidden ? ' of ' + PROJECTS.length : '') + ' projects';
document.getElementById('navN').textContent = shown;
document.getElementById('fnote').innerHTML = hidden ? hidden + ' hidden by "' + PREFNAME[v] + '". The numbers keep their gaps so nothing quietly moves. <button style="text-decoration:underline" onclick="void 0" data-showall>show all</button>' : '';
legend();
}
document.querySelectorAll('.seg button').forEach(function (b) { b.addEventListener('click', function () { setPref(b.dataset.pref); }); });
document.getElementById('fnote').addEventListener('click', function (e) { if (e.target.closest('[data-showall]')) setPref('all'); });
function legend() {
var n = { none: 0, some: 0, full: 0, unk: 0 }; PROJECTS.forEach(function (p) { n[p.ai]++; });
document.getElementById('legend').innerHTML = ['none', 'some', 'full', 'unk'].filter(function (k) { return n[k]; }).map(function (k) { return aiMk(k) + '<span style="color:var(--mute);margin-left:-12px">' + n[k] + '</span>'; }).join('');
}
/* ---------- writing ---------- */
var REL = { agent: 'gacha', slurm: 'compute', vault: 'ops', ansible: 'config', thesis: 'hypercache' };
document.getElementById('wl').innerHTML = POSTS.map(function (w) {
var p = P(REL[w.id]);
return '<li><a href="https://blog.lucaswinther.info"><span class="d ph" title="placeholder">' + w.d.slice(0, 7).replace('-', '·') + '</span><span class="t"><span class="ph" title="placeholder title">' + w.t + '</span><span class="bl">' + w.blurb + '</span></span><span class="rel">on ' + p.no + ' ' + p.name.toLowerCase() + '</span><span class="tg">' + w.tag + '</span><span class="e"></span><span class="ar">↗</span></a></li>';
}).join('');
/* ---------- CV sheet ---------- */
var cvBtn = document.getElementById('cvBtn'), cv = document.getElementById('cv');
function cvSet(on) { cv.classList.toggle('on', on); cvBtn.setAttribute('aria-expanded', on ? 'true' : 'false'); }
cvBtn.addEventListener('click', function (e) { e.stopPropagation(); cvSet(!cv.classList.contains('on')); });
document.addEventListener('click', function (e) { if (cv.classList.contains('on') && !e.target.closest('#cv')) cvSet(false); });
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
if (cv.classList.contains('on')) { cvSet(false); cvBtn.focus(); return; }
if (openId) { var r = rowOf(openId); closeRow(openId); openId = null; setHash(''); r.querySelector('.rh').focus({ preventScroll: true }); }
});
setPref('all');
var h0 = location.hash.slice(1); if (P(h0)) open(h0, { scroll: true, jump: true });
})();
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff