diff --git a/AGENTS.md b/AGENTS.md index a352a03..b27acea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,8 @@ Use impeccable for user-facing UI — `craft` for new, `audit`/`polish` on exist ## 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; + `mockups/work-k2.html` (`/work/`, spec 009; built on `mockups/tech-r3-c.html`, which still sets the rest of the + work space: panels, list, side sheet, project pages), `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, diff --git a/DESIGN.md b/DESIGN.md index 00640bb..4ab591a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -21,9 +21,19 @@ Hovering a side widens it; clicking enters. A small dark pill on the seam opens ## 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. +Geist + Geist Mono. Plum "off the clock →" edge on the right. +`/work/` since spec 009 follows `mockups/work-k2.html` (built on tech-r3-c; screenshot `mockups/shots/work-k2-1440.png`): +top bar (← gate; Projects, Record, Writing, CV; AI filter) → **CV header**, one card: "CV in ten seconds" (name, tagline, +now/before/degree/contact rows, `site.now` as a short list, buttons) left; Highlights (result in the accent, title plain) and +Keeping up (big accent seal + certificate count, mono course line, topic chips) on `--bg2` right; one column when both +right blocks are empty → **Projects I'm proudest of**, up to six panels laid out wide · half · half · wide reversed · +half · half (a lone last half goes wide), the AI legend line and the hover hint stacked at the heading's right; +screenshots muted (`saturate(.5) brightness(.8)`) until the panel is hovered or focused within, drawings never → +**Also made**, the filterable list with a side sheet (Esc closes, ←/→ steps, `#/id` deep links); while searching, a +compact strip of matching courses (seal, title, lab tag, provider · month · hours) sits above the rows → **Record**, +two cards: recognition & volunteering (mono group labels, accent on highlighted results) and keeping up (summary line, topic +table) → writing and work history. The seal (a ring with two ribbon tails, `currentColor`) is the only certificate +mark: no trophies, badge walls or medals. `/work/learning/` is a quiet table page in the same frame. ## Personal space — `mockups/personal-r2-b.html` Light cream. `--bg #f1e6d6`, `--card #f8f1e6`, `--line #dccbb2`, `--ink #2a1a1f`, `--mute #7d6a5c`, diff --git a/bun.lock b/bun.lock index 378f3bd..4c096f6 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "react-dom": "^19", }, "devDependencies": { + "@axe-core/playwright": "^4.14.0", "@playwright/test": "^1.65", "@types/bun": "^1.4.4", "@types/react": "^19.3.0", @@ -61,6 +62,8 @@ "@astrojs/telemetry": ["@astrojs/telemetry@3.4.2", "", { "dependencies": { "ci-info": "^4.4.0", "dset": "^3.1.4", "is-docker": "^4.0.0", "package-manager-detector": "^1.9.0" } }, "sha512-2JOMRUxJrBSfiVDfeKLv1wBmvSIx1Y6u2YEO8awZ9qSq3u/rNAiUyFdOYi8dHv91kHrmYEQX7pRDw0J4xyj3UA=="], + "@axe-core/playwright": ["@axe-core/playwright@4.14.0", "", { "dependencies": { "axe-core": "~4.14.0" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.13", "", {}, "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], @@ -303,6 +306,8 @@ "astro": ["astro@7.4.7", "", { "dependencies": { "@astrojs/compiler-rs": "^0.5.2", "@astrojs/internal-helpers": "0.13.1", "@astrojs/markdown-satteri": "0.4.7", "@astrojs/telemetry": "3.4.2", "@capsizecss/unpack": "^4.0.1", "@clack/prompts": "^1.8.1", "@oslojs/encoding": "^1.1.0", "am-i-vibing": "^0.5.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", "devalue": "^6.0.3", "diff": "^9.0.0", "dset": "^3.1.4", "es-module-lexer": "^3.0.4", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.6", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.3.0", "jsonc-parser": "^3.3.1", "magic-string": "^1.4.5", "magicast": "^0.5.5", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", "obug": "^3.0.0", "p-limit": "^7.3.3", "p-queue": "^10.0.0", "package-manager-detector": "^1.9.0", "piccolore": "^0.1.3", "picomatch": "^4.0.7", "rolldown": "^1.2.16", "shiki": "^4.5.0", "smol-toml": "^1.9.1", "svgo": "^4.1.0", "tinyclip": "^1.0.3", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "tinyps": "^0.5.1", "ultrahtml": "^1.7.0", "unifont": "^1.0.3", "unstorage": "^1.17.6", "verkit": "^0.5.0", "vite": "^8.3.5", "vitefu": "^1.1.3", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.6.7" }, "optionalDependencies": { "sharp": "^0.35.4" }, "peerDependencies": { "@astrojs/markdown-remark": "^7.3.0" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-fH54IOaCkZY1paEjygKn8CPfuJ3w8ns7PourcPML5AouJkQuTBqBQ2RbbIbBhutZbg2qn5se6Yl9DYybzqjC0Q=="], + "axe-core": ["axe-core@4.14.0", "", {}, "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c22ec8c..a9af659 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,10 +13,10 @@ content format: `docs/CONTENT-CONTRACT.md`; deployment: `docs/DEPLOY.md`. ## Map | Path | Responsibility | |---|---| -| `src/core/` | Pure, dependency-free logic shared by the build and the browser: AI levels and visibility (`ai.ts`), durations, issue numbering (`issues.ts`), `lazyImages` for Markdown bodies (`post-html.ts`), media extensions (`media.ts`), showcase ids (`showcases.ts`) | -| `src/library/` | Reads and validates the content repo (`load.ts`, `frontmatter.ts`, `types.ts`, `errors.ts`); `RESERVED_PERSONAL_IDS` guards ids that would shadow a page | +| `src/core/` | Pure, dependency-free logic shared by the build and the browser: AI levels and visibility (`ai.ts`), durations, issue numbering (`issues.ts`), `lazyImages` for Markdown bodies (`post-html.ts`), media extensions (`media.ts`), showcase ids (`showcases.ts`), course summary, search and formatting (`courses.ts`) | +| `src/library/` | Reads and validates the content repo (`load.ts`, `frontmatter.ts`, `types.ts`, `errors.ts`), including the optional `recognition.yml` and `courses.yml`; `RESERVED_PERSONAL_IDS` guards ids that would shadow a page | | `src/sources/` | Ghost, Trakt and Hardcover clients plus a JSON cache (`cache.ts`); `loadSources` honours `SOURCES_FIXTURES`. Trakt: last film (`history/movies?limit=1` + `ratings/movies`) and recent ratings; no "watching now". ListenBrainz (`LISTENBRAINZ_USER`, no token): the week's top track, counted from the listens | -| `src/site/` | The Astro site: `pages/` (gate, `work/`, `off-the-clock/` with `issues/`, `everything`, `simple`, 404, and `[...file].xml.ts` for `/sitemap.xml` and the three Atom feeds), `components/` (Astro parts, React islands, `showcases/`), `layouts/` (`Base`, `Work`, `Personal`), `styles/` (per-space CSS and tokens), `scripts/` (plain page scripts: `preference.ts`, `pref-switch.ts` for the plain-HTML `PreferenceSwitch.astro`, `filter-note.ts`, `gate.ts` for the start page), `data.ts` (`getSiteData`: content plus cached sources and `siteUrl` from `SITE_URL`, never fetches), `link-preview.ts` (`SITE_URL` parsing and each page's canonical/Open Graph/feed head tags, rendered by `Base.astro`), `feeds.ts` (the page list for the sitemap and each feed's entries), `xml.ts` (XML escaping, the Atom and sitemap writers; all three pure and build-only), `post-html.ts` (`safePostHtml`, Ghost HTML allowlist on Bun's `HTMLRewriter`; build-time only) | +| `src/site/` | The Astro site: `pages/` (gate, `work/` with `learning/[...path].astro` for `/work/learning/`, built only when there are courses, `off-the-clock/` with `issues/`, `everything`, `simple`, 404, and `[...file].xml.ts` for `/sitemap.xml` and the three Atom feeds), `components/` (Astro parts, React islands, `showcases/`), `layouts/` (`Base`, `Work`, `Personal`), `styles/` (per-space CSS and tokens), `scripts/` (plain page scripts: `learning-filter.ts` for `/work/learning/`, `preference.ts`, `pref-switch.ts` for the plain-HTML `PreferenceSwitch.astro`, `filter-note.ts`, `gate.ts` for the start page), `data.ts` (`getSiteData`: content plus cached sources and `siteUrl` from `SITE_URL`, never fetches), `link-preview.ts` (`SITE_URL` parsing and each page's canonical/Open Graph/feed head tags, rendered by `Base.astro`), `feeds.ts` (the page list for the sitemap and each feed's entries), `xml.ts` (XML escaping, the Atom and sitemap writers; all three pure and build-only), `post-html.ts` (`safePostHtml`, Ghost HTML allowlist on Bun's `HTMLRewriter`; build-time only) | | `src/runtime/` | `build.ts` (validate, fetch sources once, run Astro, copy media), `refresh.ts` (poll git, timed rebuild, swap `live`), `serve.ts`, `static-server.ts` (`/status`, 404 page), `state.ts`, `git.ts` | | `src/cli.ts` | Entry point: `validate`, `build`, `preview`, `refresh`, `serve` | | `fixtures/` | Synthetic content (`content/`, `sparse/`) and source JSON (`sources/`); the only content in this repo | @@ -28,6 +28,15 @@ content format: `docs/CONTENT-CONTRACT.md`; deployment: `docs/DEPLOY.md`. | `mockups/`, `docs/inspiration.md` | The approved visual source of truth and the owner's taste log; `extra_info.txt` is his real work history | | `public/` | Copied to the site root as is (`favicon.svg`) | +## The `/work/` page (spec 009) +`pages/work/index.astro` composes, top to bottom: `WorkHeader.astro` (CV header: site.yml `cv`/`now`/links, highlights +from `recognition.yml`, keeping up from `courses.yml` via `courseSummary`), `Featured.astro` (up to six panels, +`featLayout` in `sections.ts`), the `ProjectList.tsx` island ("Also made"; it receives the courses' plain fields and +shows matching ones while searching, `course-search.ts`), `Record.astro` (`#record`), then Writing and +`WorkHistory.astro`. `workSections` in `sections.ts` says which sections exist (and whether `/work/learning/` is built) +so the top bar and links never point at something missing. `record.ts` holds the seal SVG and the small view helpers; +`CourseSumLine.astro` is the summary line shared by the record and the learning page. + ## Data flow ``` content repo (git) ─┐ diff --git a/docs/CONTENT-CONTRACT.md b/docs/CONTENT-CONTRACT.md index 1740fd1..4143f5e 100644 --- a/docs/CONTENT-CONTRACT.md +++ b/docs/CONTENT-CONTRACT.md @@ -13,6 +13,8 @@ interests.yml projects/.md items/.md issues/-.md # optional editor's note for a half-yearly issue +recognition.yml # optional: competitions and volunteering +courses.yml # optional: completed courses and hands-on labs media/ # images, audio, the CV PDF ``` @@ -32,7 +34,7 @@ To show a media file inside a Markdown body, link it by its URL, `/media/` contract: 2 # 1 or 2; declare 2 when any item uses an interest list name: Example Person tagline: One line under the name -intro: [First line, Second line] # the /work/ heading (first line, second line greyed) +intro: [First line, Second line] # not shown on /work/ since spec 009 (the CV header leads); kept for compatibility personal_intro: One line, off the clock # optional; the lede on the start page's right half and /off-the-clock/ now: [Reading something, Building something] links: @@ -49,8 +51,8 @@ cv: `contract`, `name`, `tagline`, `links.email|github|linkedin`, `cv.now` and `cv.degree` are required. `intro`, `now` and `cv.before` are lists of text (empty if omitted). `personal_intro` is optional text; when it is -missing there is simply no lede off the clock (the work `intro` is never reused there). Without `intro`, `/work/` -keeps a hidden heading with `name` for screen readers. +missing there is simply no lede off the clock (the work `intro` is never reused there). `intro` is no longer shown on +`/work/`: the page opens with the CV header (`name`, `tagline`, the `cv` rows, and `now` as a short list). ## `interests.yml` @@ -150,6 +152,43 @@ Markdown body. Plain Markdown (no front matter needed): the editor's note shown on that half-year's issue, e.g. `issues/2025-h2.md`. Issues themselves are generated from items by date. +## `recognition.yml` (optional) + +Competitions and volunteering. A list; file order is display order. A missing file means no entries, and the +"Highlights" block on `/work/` and the "Recognition & volunteering" card are left out. + +```yaml +- title: Example Hackathon 2019 # required + kind: competition # required: competition | volunteering + result: winner # required; for volunteering the role, e.g. "IT & website" + highlight: true # optional, default false; the first 3 show in the /work/ header + detail: four years # optional short text after the title + year: "2019" # optional text ("2019", "2016–2019"); shown only when present + url: https://example.invalid # optional http(s) + status: ongoing # optional, volunteering only: ongoing | retired +``` + +## `courses.yml` (optional) + +Completed courses and hands-on labs. A list in any order (the site sorts). A missing file means no courses: the +"Keeping up" blocks, the course search under "Also made" and `/work/learning/` are left out. + +```yaml +- title: Example Fundamentals # required + provider: Example Academy # required + kind: course # required: course | lab + topic: Ansible # required, a short name; the same text groups courses (chips, table, filters) + completed: 2025-04-18 # required, a real calendar date + minutes: 224 # required, a positive whole number (course time only) + certificate: true # optional, default false + url: https://example.invalid # optional http(s) + keywords: [ansible, automation] # optional list of text, used by search only +``` + +Keep `topic` short: it is a chip label, a table row and a filter button. Hours shown are course time only. +In both files an unknown field is an error (it catches typos such as `certifcate`). Neither file needs a +`contract` bump: older sites ignore them. + ## Ghost Writing lives on Ghost (`GHOST_URL`); the site reads published posts at build time and decides by tag: @@ -179,6 +218,6 @@ there is one. Drafts never appear. All of this needs the image's `SITE_URL` (`do ## What the validator checks `contract` is 1 or 2; IDs match `a-z0-9-`; dates are real calendar days as `YYYY-MM-DD`; required fields exist; -enums are valid (including `showcase`); every `interest` id is in `interests.yml` (a list is non-empty, without repeats); `related` ids exist; media paths stay +enums are valid (including `showcase`); every `interest` id is in `interests.yml` (a list is non-empty, without repeats); `related` ids exist; `recognition.yml` and `courses.yml` entries as above (no unknown fields); media paths stay inside `media/` and name files; `ai` rules above. The fixtures in `fixtures/content/` (synthetic) show every kind; `fixtures/sparse/` is a minimal valid repo. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d4bc521..6a7d893 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -393,3 +393,48 @@ that only differ in one line); stripping the ` · site name` suffix for `og:titl **Consequences:** A page's `` does not change. Feeds are served as `application/xml` by the static server (Bun's type for `.xml`), which feed readers accept. A new page type must be added to `sitePaths` in `feeds.ts`; a build test compares the sitemap with the files Astro wrote, so a missing one fails. + +## 2026-11-12 — `/work/` CV first (spec 009): the calls made while building it +**Status:** accepted +**Context:** Spec 009 marks several choices **[call]** and leaves a few details open. The owner approved the design +(`mockups/work-k2.html`); these are the implementation's answers, recorded so nobody has to reverse-engineer them. +**Decision:** +- *Unknown fields.* `recognition.yml` and `courses.yml` reject unknown keys (the spec says "as for projects"; projects + do not actually check unknown keys today, so the check exists only for the two new files). A file that is empty + (YAML null) counts as no entries; anything else that is not a list is an error. Field names in errors are + `[index].field`, like `interests.yml`. +- *`year` and `detail`* accept numbers as well as text (`year: 2019` unquoted is common YAML) and keep them as text. +- *`status` on a competition* is an error ("only volunteering has a status") rather than being ignored. +- *Featured layout.* `featLayout(n)` slices `wide · half · half · wide rev · half · half`; when an odd number of + halves ends the list the last one becomes `wide` (not `wide rev`), so five panels end wide, and two panels are two + wide panels (previously wide + wide reversed). +- *AI legend placement.* "marked by how much AI went in" plus the four marks sit at the right of the "Projects I'm + proudest of" heading, with the hover hint stacked under them (the heading row could not hold heading, count, hint + and legend on one line at 1440 px). On phones both wrap under the heading. +- *Header chips.* Topics with no certificate show the topic alone ("Observability"), not "Observability 0". The + record table shows "—" for zero certificates. +- *Totals.* "~N h" / "~N hours" round course minutes to whole hours, never below 1. Per-topic and per-course times use + one decimal ("3.8h"). +- *Header course line* reads " courses & hands-on labs" (singular "course" for one). +- *Search strip.* Matching courses are listed newest first; the count reads "1 project · 6 courses match" ("1 course + matches" for one) and falls back to the usual project count when no course matches. Course titles in the strip are + plain text (no outbound links), keeping the list's tab order short; the learning page links them. The search box's + label becomes "Search projects and courses" when there are courses. +- *Learning page route.* `/work/learning/` is a rest route (`pages/work/learning/[...path].astro`) whose + `getStaticPaths` returns nothing without courses, so no page is built; the sitemap lists it only then, and it is + never a feed entry. Rows are ordered by completion date, newest first, then title. Topic buttons count courses and + labs together and follow the time order of the record. Rows carry their search text in `data-search` (from + `courseSearchText` in `src/core/courses.ts`, the same text the list strip searches). +- *Fixtures.* Four synthetic projects were added (six featured in total: two drawings, two screenshots, two more + drawings), plus synthetic `recognition.yml` (6 entries, 4 highlighted) and `courses.yml` (11 courses and labs over + four topics). `fixtures/sparse/` has neither file. +**Alternatives:** keep `Featured` at four (the owner asked for six); a separate learning page always built with an +empty state (the spec says no page without courses); linking strip titles to course URLs. +**Consequences:** `site.intro` is still validated but not shown on `/work/`. The old Now / Latest writing / CV strip +(`components/work/NowStrip.astro`) is gone. Tests that counted fixture projects or featured panels were updated. + +## 2026-11-12 — axe-core in the e2e suite +The owner allowed `@axe-core/playwright` (dev dependency only). `e2e/a11y.spec.ts` runs the WCAG 2.0/2.1 A and AA rules +on the start page, `/work/`, `/work/learning/`, a project page, `/off-the-clock/`, `/simple/` and `/everything/`. +`color-contrast` is disabled for now: passing it means retuning dim text tokens in every space, a visual change the +owner reviews first (`docs/TODO.md`). diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 68be0f3..672af28 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -3,6 +3,16 @@ Where development stopped, and how to pick it up. Delete or rewrite this file when the next session starts. Everything a new agent needs from the 2026-11-11 session is in this file or in a file it points to. +## 2026-11-12: spec 009 merged (CV-first `/work/`) + +Spec 009 (`docs/specs/009-work-page-cv-first.md`, mockup `mockups/work-k2.html`) was designed with the owner, built +(Opus 5.5 subagent, checked by the main session), previewed on a copy of the real content, adjusted once (only the +placement of a highlight is orange) and merged into `main` locally with his approval. The content for it +(`recognition.yml`, `courses.yml`, featured ranks 1–6 + showcases, the spreadsheet in `sources/`) is committed and +merged into `main` of `~/portfolio-next-content` locally. **Neither repo is pushed** until the owner says so; push the +app first (an older app ignores the new content files, but the new page wants them). Axe checks run in +`e2e/a11y.spec.ts` with `color-contrast` disabled until the dim tokens are retuned (`docs/TODO.md`). + ## State in one paragraph The site is **live** at `lucaswinther.info` (behind Cloudflare) since the DNS cutover on 2026-11-10; the 2021 site is at diff --git a/docs/TODO.md b/docs/TODO.md index dcd4a7e..90f78c5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -64,10 +64,16 @@ Site (personal, issues, everything) - [ ] Preference: the storage key is duplicated in the inline pre-paint script in `Base.astro`, and `PAINT_SWITCH` in `scripts/pref-switch.ts` mirrors `paintSwitch` (both on purpose, no imports there; a unit test keeps the painters in step). The pressed-state flicker until hydration is gone (spec 006: the switch is plain HTML painted before first paint). Tests - [ ] `test/site/fixture-sites.ts`: the fixture build child has no timeout. On 2026-11-11, with the machine at load ~15, one `bun test` run hung in `bun src/cli.ts build fixtures/sparse` (idle, 5 minutes) until the 300 s `beforeAll` limit failed the run; three reruns of the build and two of the suite passed in 12 s. Not reproduced, cause unknown. -- [ ] e2e: helpers hard-code `localhost:4378`; the third-party check is not re-run after the play click; "nothing animates" uses a `getAnimations` snapshot; the no-JS featured count is hard-coded to 2; sparse specs lack positive controls; the YouTube exemption matches by substring; `bun test` can contend with an overlapping Playwright server. +- [ ] e2e: helpers hard-code `localhost:4378`; the third-party check is not re-run after the play click; "nothing animates" uses a `getAnimations` snapshot; the no-JS featured count is hard-coded (now 6); sparse specs lack positive controls; the YouTube exemption matches by substring; `bun test` can contend with an overlapping Playwright server. - [ ] Missing automated tests: sheet history, keys and focus; filter toggling on `/everything/`; dead-anchor and live-region regressions; a date-independent issue test; the CLI `build` path; the CSS filter contract and pre-paint script. - [ ] Gemini allow `run_shell_command(bun test)` may not match argument forms (cosmetic). +- [ ] Colour contrast (axe `color-contrast`, disabled in `e2e/a11y.spec.ts`): small dim text is below 4.5:1 in all three + spaces. Work `--dim #625c54` on the panels (2.6–2.8:1; mono labels, facts `dt`, list sub-lines, history dates, + drawn-showcase captions); off the clock `--mute #7d6a5c` (4.1–4.3:1) and sage `#6f7f45` (3.5:1), cream text on the + amber typeset card (1.6:1); `/everything/` `#786a62` on `#1b1517` (3.5:1). Retune the tokens, show the owner, then + remove `disableRules(['color-contrast'])`. + ## Recurring - [ ] Maintain `AGENTS.md`: - **Add a rule only in response to a real failure.** When an agent repeatedly gets something wrong, @@ -79,6 +85,8 @@ Tests file, and compare steps and token use. ## Known issues +- [ ] A project file named `learning.md` would claim `/work/learning/`, the course page (spec 009); the loader does not reserve that id the way `RESERVED_PERSONAL_IDS` guards `issues` off the clock. +- [ ] `RowingStroke` showcase at 390 px: the chart's "one stroke · force at the handle" caption and the "scrub the curve" hint overlap (seen while checking spec 009; the showcase itself is unchanged). - [ ] 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. diff --git a/docs/inspiration.md b/docs/inspiration.md index b69275e..c38d725 100644 --- a/docs/inspiration.md +++ b/docs/inspiration.md @@ -220,6 +220,29 @@ dated "Lately" list across both sides (`mockups/simple-side-a.html`, `simple-sid gacha-event-tracker for work and Saltlakridserne for off-the-clock, and then have the app fill in the rest by newest." That is `docs/specs/008-simple-side-index.md` (approved, not built yet on his request). +## Work page, CV first (2026-11-12) + +His feedback on `/work/`: everything had the same weight; he wanted a grid with the impressive projects accentuated, +and room for courses, certificates, competitions and other accolades. He named the six that matter: Event Tracker for +Gacha Games, Config, Bachelor Report, Student Dashboard, Mixed-Reality Rowing Game, VR-Classroom. Courses come from +his own spreadsheet; competitions from his CV; Art Bubble and Lystrup Genbrugser are volunteer IT/website work +("not just my project"), so they belong with recognition, not with the projects. Rounds (all in `mockups/work-*.html`): + +- **r1** A one grid three sizes, B picked six + tight grid, C hero + uneven grid: "none are completely what I imagine". + Liked: course counts ("8 courses · 3 labs") and time spent; wants certificates shown clearly, and certificates found + when searching the work page. Orange for the most impressive accolades. +- **r2** D woven grid, E packed by size: "too chaotic". **r3** F alternating bands, G picked + index column, H two + sizes even grid: F and H closest, "still a bit too chaotic and unfocused". **r4** equal blocks with greyscale + screenshots: no. **Colour on hover is nice, but full desaturation makes the page look boring and not interactable.** +- Then the picture in his words: the first thing seen is the **CV in ten seconds**; writing matters less (few posts); + then the most impressive projects first, the weaker ones viewable but less focused "so the ones of more questionable + quality isn't clicked too much on"; certificates and the main accolades near the top, full detail at the bottom. +- **r5** K CV hero, L CV sidebar, M at-a-glance band: **K closest.** "Projects I'm proudest of" should get more detail; + "also made" should keep today's list view. Remove Bluesky from the work side (keep /simple, GitHub, LinkedIn, email). +- **K v2** (`mockups/work-k2.html`): K's header + the featured panels of the approved `tech-r3-c` ("Look at the + featured section here. This is really good.") + today's list + the record. **Picked**: "This looks like a good + improvement. Lets make it work!" → `docs/specs/009-work-page-cv-first.md`. + ## 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). diff --git a/docs/specs/009-work-page-cv-first.md b/docs/specs/009-work-page-cv-first.md new file mode 100644 index 0000000..d136936 --- /dev/null +++ b/docs/specs/009-work-page-cv-first.md @@ -0,0 +1,179 @@ +--- +status: implemented +--- + +# 009 — `/work/`: CV first, six proud projects, a record of competitions and courses + +Designed with the owner on 2026-11-12 over six rounds of rendered mockups (log in `docs/inspiration.md`, +"Work page, CV first"). He picked **K** and then its refinement, built on the approved `mockups/tech-r3-c.html`: +"This looks like a good improvement. Lets make it work!" Design truth: `mockups/work-k2.html` (screenshot +`mockups/shots/work-k2-1440.png`). Extends `docs/specs/001-site.md` §8 (the work space). Choices marked **[call]** +were made while writing the spec, not by the owner. + +## Why +The owner has made many projects of uneven quality, and the page gave them all the same weight; there was no place for +his courses, certificates, competitions or volunteer work. In his words, a visitor should first see the CV in ten +seconds, then "the most impressive projects I've made first, with the other less important ones still viewable … but +less focused", and near the top "the certificates/courses and most important accolades, with fuller information near +the bottom". Courses should show that he keeps up to date and "actively works to keep himself knowledgeable", with a +searchable list "somewhere, but it doesn't need to be front and center". + +## What + +### Page order on `/work/` (top to bottom) +1. **CV header** (replaces today's intro block and the Now / Latest writing / CV strip). +2. **Projects I'm proudest of**: the featured projects as large panels (today's `Featured`, extended to six). +3. **Also made**: today's project list (tabs, search, thumbnails, side sheet) with the remaining projects. +4. **Record**: recognition & volunteering next to keeping up (courses), the full versions. +5. **Writing** and **Where I've worked**, as today. +6. Footer. + +Every section that has no content is left out, and the top bar links only to sections that exist (as today). + +### 1. CV header +One card, two columns (stacked on narrow screens): +- **Left, "CV in ten seconds"**: `site.name` as the heading, `site.tagline` under it; rows *now* (`cv.now`), *before* + (`cv.before`, one per line), *degree*, *contact* (email); if `site.now` has lines they are shown as a short list + under the rows **[call]** (today's "Now" cell has no other home). Buttons: CV PDF (only when `links.cv`), "full + history ↓" (to `#cv`), GitHub, LinkedIn. +- **Right, two blocks**: + - **Highlights**: recognition entries marked `highlight: true`, in file order, at most **3** **[call]**; the result + (the placement) in the accent colour, the title in the normal text colour (owner, 2026-11-12: "focus on the + placement, not the competition"). A link "+ N more in the record ↓" when the record holds more entries than shown. + No highlighted entries: the block is left out. + - **Keeping up**: a large certificate count with the seal mark ("18 certificates"), then one mono line: + " courses & hands-on labs · ~ h since "; then up to **5** topic chips, + " ", topics ordered by total time, largest first **[call]**; topics are short names + (one field serves chips, table and filters, so content keeps them short, e.g. "Ansible", "RAG & LLMs"); link "all courses ↓" (to the + record). No courses: the block is left out. No certificates but courses: the count line leads instead. + - Both blocks missing: the card is one column. +- The page's AI explanation moves to the right of the "Projects I'm proudest of" heading: one short line plus the + legend marks. **[call]** `site.intro` is no longer shown on `/work/`; `docs/CONTENT-CONTRACT.md` says so. + +### 2. Projects I'm proudest of +- Heading "Projects I'm proudest of", count, hint "hover the drawings · open a panel for the full story". +- Up to **6** featured projects (today: 4), by `featured` rank. Layout by position, repeating: + `1 wide · 2–3 halves · 4 wide reversed · 5–6 halves`. With fewer than six the pattern stops where they end; an odd + last half takes the full row width as a wide panel **[call]**. +- Panels are today's: showcase (or image) + number, group and where, title, summary, the *why* / *interesting* + facts from the body (`panelFacts`, unchanged: rows with no matching section are left out, never placeholders), + stack chips, "Read the full story" / "Read more", AI mark, filter note when hidden. +- **Screenshots are muted**: an `image` in a panel (and a thumbnail in the list) is shown desaturated and slightly + darker (about half saturation, 80% brightness) and comes to full colour when the pointer is over its panel or row, or + the panel has keyboard focus within. Drawn showcases are not muted. Owner: full greyscale "makes the page look boring + and not interactable"; the colour on hover "is nice". Under `prefers-reduced-motion` the change is instant. + +### 3. Also made +Today's `ProjectList` with the non-featured projects, heading "Also made" (was "All projects" / "Everything else"). +Behaviour unchanged (tabs, search, thumbnails, side sheet, AI filter), plus: +- **Search finds certificates too.** When the search box has text, the courses whose title, provider, topic or + keywords match are listed under the search, above the project rows, as a compact strip: seal mark when there is a + certificate, title, "lab" tag for labs, provider · month year · duration. At most **5** shown, then + "+ N more on the learning page →" linking to `/work/learning/?q=`. The count next to the heading reads e.g. + "1 project · 5 courses match" (labs count as courses here; the seal marks which carry a certificate) **[call]**. **[call]** Courses never appear when the search is empty, and they are not + affected by the AI preference (courses carry no AI level). + +### 4. Record (`#record`) +Two cards side by side (stacked on narrow screens). Either card is left out without content; with neither, the +section is left out. +- **Recognition & volunteering**: two groups, "Competitions" and "Volunteering" (a group with no entries is left + out), entries in file order. Each row: title (a link when `url` is given, with its host and ↗), an optional small + detail after it, and at the right the result; for volunteering, the role and then "ongoing" or "site retired" when + `status` says so. `highlight: true` entries show the result in the accent colour; titles stay in the text colour. Years appear only when given. +- **Keeping up**: summary line (seal, " certificates", "from courses and hands-on labs", "~ hours of course + time since "), then a table by topic, ordered by total time: topic · " courses · labs" (a part + that is zero is left out) · seal + certificate count · hours (one decimal, "h"). Then a link + "All courses and certificates, searchable →" to `/work/learning/`. +- Hours are course time only (from `minutes`); the page says "course time", never more. + +### 5. `/work/learning/` (new page) +A quiet page in the work frame: heading "Courses & certificates", the same summary line, a search field and topic +filter buttons ("All ", then each topic with its count), and a table, newest first: completed (date), course (title, +a link when `url` is given; "lab" tag for labs), topic, provider, certificate (seal or —), time. +- Complete without JavaScript: the full table is in the HTML. A small plain page script (no React) filters rows by the + search text (title, provider, topic, keywords) and the topic button, and reads `?q=` on load. Rows not matching are + `hidden`; a "No course matches" line appears when none do. +- In the sitemap; not in the feeds. Linked from the record, from the search strip, and in the top bar of this page. +- Without any courses the page is not built and nothing links to it. + +### 6. Top bar and footer (work space) +- Top bar links on `/work/`: Projects (`#featured`), Record (`#record`), Writing, CV, each only when its section exists. + "All projects" goes; the list is reached by scrolling or from Projects. **[call]** +- Work footer: back to the gate, `/simple`, GitHub, LinkedIn, email. **The Bluesky link is removed from the work + footer** (owner, 2026-11-12). The off-the-clock footer and `/simple` keep it. + +### Content: two new optional files (content repo root) +`docs/CONTENT-CONTRACT.md` documents both. Both are optional; a missing file means no entries. **[call]** No +`contract` bump: an older app ignores unknown files and a newer app reads content without them (the same reasoning as +spec 008's `pins`). + +`recognition.yml`, a list, file order = display order: +```yaml +- title: AUHackathon 2019 # required + kind: competition # required: competition | volunteering + result: winner · best AUHack # required for competitions; for volunteering the role, e.g. "IT & website" + highlight: true # optional, default false; candidates for the header (first 3) + detail: four years # optional short text after the title + year: 2019 # optional text ("2019", "2016–2019"); shown only when present + url: https://example.invalid # optional http(s) + status: ongoing # optional, volunteering only: ongoing | retired +``` + +`courses.yml`, a list (any order; the site sorts): +```yaml +- title: Ansible Fundamentals # required + provider: Pluralsight # required + kind: course # required: course | lab + topic: Ansible # required, a short name; courses with the same text group together + completed: 2025-04-18 # required, a real calendar date + minutes: 224 # required, positive integer (course time only) + certificate: true # optional, default false + url: https://example.invalid # optional http(s) + keywords: [ansible, automation] # optional list of text, used by search only +``` +Validation follows the existing loader: every problem is listed with its file and field; unknown keys are errors +**[call]** (catches typos like `certifcate`), as for projects. + +### Content changes the owner makes (not in this repo) +For the real site, in `portfolio-next-content`: `featured` ranks for the six (Gacha 1, Config 2, Bachelor Report 3, +Mixed-Reality Rowing Game 4, Student Dashboard 5, VR-Classroom 6), `showcase: config-editor` on Config, +`peer-network` on Bachelor Report, `rowing-stroke` on the Rowing game; `## Why` and `## What's interesting` sections +in project bodies where he wants the facts rows; `recognition.yml` from his CV lines; `courses.yml` from his +spreadsheet "Courses and Learning.xlsx" (the WordPress course has no completion date and stays out until it has one). +The implementation prepares these as a folder outside both repos (the session scratchpad, `content-proposal/`), +uses it only through a temporary copy of the real content for the preview, and hands it to the owner; it never writes +to the `portfolio-next-content` clone. Rules for that set: +- Only facts he gave: the four CV lines and the two volunteer roles as he wrote them; unknown years are left out + (no placeholders on the real site). No `## Why` / `## What's interesting` text is written for him; panels without + those sections simply show no facts rows. +- Spreadsheet mapping **[call]**: one row per completed course (rows without a completion date are left out); + `kind: lab` when "Course Type" contains "Lab"; `minutes` from "Course Hours"; `certificate: true` when + "Certificate Available?" is "Yes" (he asked for the page to "show that I got certificates from them"; Pluralsight + issues a certificate for every completed course that offers one); `keywords` from "Keywords"; `url` from "Link". + `topic` is a hand grouping of "Bottom Category" into short names, the same text in chips, table and filter: + Serverless → "Serverless / AWS", Ansible → "Ansible", Machine Learning and LLMs/RAG → "RAG & LLMs", + Linux & System Administration → "Linux", Security → "Security", Metrics/logging → "Observability". + +## Non-goals +- No new drawn showcases (Gacha, Student Dashboard, VR-Classroom use their screenshots). +- No Skill IQ percentiles (left out on purpose during design). +- No change to the side sheet, project pages, writing pages, the off-the-clock side or `/simple` (beyond what is + named above). +- No certificate PDFs or images. + +## Anti-goals (owner's hard lines) +Nothing moves unless the visitor acts; no trophy icons, badges walls or anything that sells; no uneven "bento" +mosaic (tried in rounds 2–4 and rejected as "too chaotic and unfocused"); no greyscale screenshots. + +## Acceptance +- `/work/` on the fixtures shows, in order: CV header (with highlights and keeping up), Projects I'm proudest of, + Also made, Record, Writing, Where I've worked; each section disappears when its content is missing. +- Six featured fixture projects lay out as wide, half, half, wide reversed, half, half at ≥ 1100 px; one column on a + phone; five featured ends with a wide panel. +- Screenshot panels and list thumbnails are muted until hovered; drawings are not. +- Searching the list for a fixture keyword shows matching courses with the right count and the learning-page link. +- `/work/learning/` lists every fixture course newest first; search and topic filter hide rows; `?q=` pre-filters; + without JavaScript every row is visible. +- The work footer has no Bluesky link; the off-the-clock footer still does. +- `validate` reports each malformed field of `recognition.yml` and `courses.yml`, by file and field. +- Typecheck, `bun test` and Playwright pass; the page is checked at 1440 px and 390 px. diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 9e92c26..5f03963 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -1,4 +1,6 @@ import { expect, test, type Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { P } from './helpers.ts'; // Accessibility and motion checks on the built fixture site (see playwright.config.ts). @@ -28,7 +30,7 @@ async function movingElements(page: Page): Promise { test.describe('reduced motion', () => { test.use({ reducedMotion: 'reduce' }); - for (const path of ['/', '/work/', '/off-the-clock/']) { + for (const path of ['/', '/work/', '/work/learning/', '/off-the-clock/']) { test(`nothing moves on ${path}`, async ({ page }) => { await page.goto(path); expect(await movingElements(page)).toEqual([]); @@ -72,10 +74,25 @@ test('Tab reaches the wall cards on /off-the-clock/', async ({ page }) => { test('every image has alt text', async ({ page }) => { await page.goto('/off-the-clock/issues/'); const issue = await page.locator('a[href^="/off-the-clock/issues/2"]').first().getAttribute('href'); - const paths = ['/', '/work/', '/off-the-clock/', '/everything/', '/simple/', '/off-the-clock/fixture-comic/', issue!]; + const paths = ['/', '/work/', '/work/learning/', '/off-the-clock/', '/everything/', '/simple/', '/off-the-clock/fixture-comic/', issue!]; for (const path of paths) { await page.goto(path); const missing = await page.$$eval('img:not([alt])', (imgs) => imgs.map((i) => i.getAttribute('src'))); expect(missing, path).toEqual([]); } }); + +// axe-core (WCAG 2.x A and AA rules) on every kind of page. Spec 009 asked for /work/ and /work/learning/; +// the others ride along so the whole site keeps the same bar. `color-contrast` is left out until the dim text +// tokens of all three spaces are retuned (a visual change the owner looks at first; see docs/TODO.md). +test.describe('axe', () => { + const pages = ['/', '/work/', '/work/learning/', `/work/${P.byHand2}/`, '/off-the-clock/', '/simple/', '/everything/']; + for (const path of pages) { + test(`no WCAG A/AA violations on ${path}`, async ({ page }) => { + await page.goto(path); + const { violations } = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).disableRules(['color-contrast']).analyze(); + const summary = violations.map((v) => `${v.id} (${v.impact}): ${v.nodes.length}× ${v.nodes.slice(0, 3).map((n) => n.target.join(' ')).join(' | ')}`); + expect(summary).toEqual([]); + }); + } +}); diff --git a/e2e/nojs.spec.ts b/e2e/nojs.spec.ts index f8c8b74..1e81699 100644 --- a/e2e/nojs.spec.ts +++ b/e2e/nojs.spec.ts @@ -17,7 +17,14 @@ test('work page lists every project and hides none', async ({ page }) => { await expect(page.locator('.filtered-note:visible')).toHaveCount(0); await expect(page.locator('.idx a.row')).toHaveCount(3); await expect(page.locator('html[data-pref]')).toHaveCount(0); - await expect(page.locator('article.feat:visible')).toHaveCount(2); + await expect(page.locator('article.feat:visible')).toHaveCount(6); +}); + +test('the learning page shows every course without JavaScript', async ({ page }) => { + await page.goto('/work/learning/'); + await expect(page.locator('tr[data-course]:visible')).toHaveCount(11); + await expect(page.getByText('No course matches')).toBeHidden(); + await expect(page.locator('.ltools')).toBeHidden(); }); test('personal wall shows every card, including AI-marked and unknown', async ({ page }) => { diff --git a/e2e/preference.spec.ts b/e2e/preference.spec.ts index 396d572..f9771ab 100644 --- a/e2e/preference.spec.ts +++ b/e2e/preference.spec.ts @@ -23,15 +23,15 @@ test('by hand only hides ai-made, some-ai and unknown; keeps by-hand', async ({ test('featured hidden panels show "show anyway"; archive link appears', async ({ page }) => { await openWork(page); - await expect(page.locator('.filtered-note')).toHaveCount(2); + await expect(page.locator('.filtered-note')).toHaveCount(6); await expect(page.locator('.filtered-note').first()).toBeHidden(); // The archive line is in the HTML but hidden (spec 006): none of it may show, to the eye or to assistive tech. await expect(page.locator('a[href="https://old.lucaswinther.info/"]:visible')).toHaveCount(0); await expect(page.getByRole('link', { name: 'The 2021 site was all by hand →' })).toHaveCount(0); await chooseByHand(page); const visibleNotes = page.locator('.filtered-note:visible'); - await expect(visibleNotes).toHaveCount(1); // only the some-ai featured project - await expect(visibleNotes.getByRole('button', { name: 'show anyway' })).toBeVisible(); + await expect(visibleNotes).toHaveCount(3); // the two some-ai featured projects and the unknown one + await expect(visibleNotes.first().getByRole('button', { name: 'show anyway' })).toBeVisible(); await expect(page.locator('a[href="https://old.lucaswinther.info/"]:visible').first()).toBeVisible(); }); diff --git a/e2e/work.spec.ts b/e2e/work.spec.ts index 46e8fbb..ccff846 100644 --- a/e2e/work.spec.ts +++ b/e2e/work.spec.ts @@ -128,17 +128,84 @@ test('"/" focuses search', async ({ page, isMobile }) => { test.skip(!!isMobile, 'keyboard'); await openWork(page); await page.keyboard.press('/'); - await expect(page.getByRole('searchbox', { name: 'Search projects' })).toBeFocused(); + await expect(page.getByRole('searchbox', { name: /Search projects/ })).toBeFocused(); }); test('"/" focuses the search right after load, without scrolling to the list', async ({ page }) => { await page.goto('/work/'); await page.waitForLoadState('load'); await page.waitForFunction(() => 'requestIdleCallback' in window); - const search = page.getByRole('searchbox', { name: 'Search projects' }); + const search = page.getByRole('searchbox', { name: /Search projects/ }); // The list sits below the fold; it must hydrate on idle, not when scrolled into view. await expect.poll(async () => { await page.keyboard.press('/'); return search.evaluate((el) => el === document.activeElement); }, { timeout: 5_000 }).toBe(true); }); + +test('searching the list shows matching courses with the count and a link to the learning page', async ({ page }) => { + await openWork(page); + const search = page.getByRole('searchbox', { name: /Search projects/ }); + const strip = page.locator('.idx .cstrip'); + await expect(strip).toHaveCount(0); + await search.fill('java'); + await expect(page.locator('.idx .sh .n')).toHaveText('1 project · 6 courses match'); + await expect(strip.locator('.crow')).toHaveCount(5); + // Newest first; the seal marks a certificate, the tag marks a lab. + await expect(strip.locator('.crow').first()).toContainText('Profiling the JVM'); + await expect(strip.locator('.crow').first()).toContainText('lab'); + await expect(strip.locator('.crow').first().locator('.seal')).toHaveCount(1); + await expect(strip.getByRole('link', { name: '+ 1 more on the learning page →' })).toHaveAttribute('href', '/work/learning/?q=java'); + // The AI preference never hides courses. + await search.fill(''); + await expect(strip).toHaveCount(0); +}); + +test('screenshots are muted until hovered; drawings are not', async ({ page, isMobile }) => { + test.skip(!!isMobile, 'hover'); + await openWork(page); + const img = page.locator('article.feat[data-id="fixture-photo-board"] .s-image img'); + await img.scrollIntoViewIfNeeded(); + await expect.poll(() => img.evaluate((el) => getComputedStyle(el).filter)).toContain('saturate(0.5)'); + await page.locator('article.feat[data-id="fixture-photo-board"] h3').hover(); + await expect.poll(() => img.evaluate((el) => getComputedStyle(el).filter)).toBe('none'); + const drawing = page.locator('article.feat[data-id="example-compute-portal"] .stage'); + expect(await drawing.evaluate((el) => getComputedStyle(el).filter)).toBe('none'); +}); + +test.describe('/work/learning/', () => { + const rows = (page: Page) => page.locator('tr[data-course]:visible'); + + test('lists every course; search and the topic buttons hide rows', async ({ page }) => { + await page.goto('/work/learning/'); + await expect(rows(page)).toHaveCount(11); + const search = page.getByRole('searchbox', { name: 'Search courses' }); + await search.fill('docker'); + await expect(rows(page)).toHaveCount(1); + await expect(rows(page).first()).toContainText('Containers from Scratch'); + await search.fill(''); + await page.getByRole('button', { name: /^Java/ }).click(); + await expect(page.getByRole('button', { name: /^Java/ })).toHaveAttribute('aria-pressed', 'true'); + await expect(rows(page)).toHaveCount(6); + await search.fill('docker'); + await expect(rows(page)).toHaveCount(0); + await expect(page.getByText('No course matches')).toBeVisible(); + await page.getByRole('button', { name: /^All/ }).click(); + await expect(rows(page)).toHaveCount(1); + await expect(page.getByText('No course matches')).toBeHidden(); + }); + + test('?q= pre-filters the table', async ({ page }) => { + await page.goto('/work/learning/?q=java'); + await expect(page.getByRole('searchbox', { name: 'Search courses' })).toHaveValue('java'); + await expect(rows(page)).toHaveCount(6); + }); + + test('the search strip on /work/ leads here with the query', async ({ page }) => { + await openWork(page); + await page.getByRole('searchbox', { name: /Search projects/ }).fill('java'); + await page.getByRole('link', { name: /more on the learning page/ }).click(); + await expect(page).toHaveURL(/\/work\/learning\/\?q=java$/); + await expect(rows(page)).toHaveCount(6); + }); +}); diff --git a/fixtures/content/courses.yml b/fixtures/content/courses.yml new file mode 100644 index 0000000..2b246d4 --- /dev/null +++ b/fixtures/content/courses.yml @@ -0,0 +1,12 @@ +# Synthetic fixture courses; titles and providers are invented. +- { title: Fixture Cloud Foundations, provider: Example Academy, kind: course, topic: Fixture Cloud, completed: 2025-03-10, minutes: 180, certificate: true, url: "https://example.invalid/courses/cloud", keywords: [serverless, functions] } +- { title: Fixture Cloud Queues, provider: Example Academy, kind: lab, topic: Fixture Cloud, completed: 2025-03-22, minutes: 45, keywords: [queues] } +- { title: Containers from Scratch, provider: Sample School, kind: course, topic: Containers, completed: 2025-05-02, minutes: 150, certificate: true, keywords: [docker, images] } +- { title: Compose in Practice, provider: Sample School, kind: lab, topic: Containers, completed: 2025-05-09, minutes: 40, certificate: true } +- { title: Java Basics Refresher, provider: Demo Learning, kind: course, topic: Java, completed: 2025-02-14, minutes: 60, certificate: true, keywords: [jvm] } +- { title: Java Streams, provider: Demo Learning, kind: course, topic: Java, completed: 2025-06-01, minutes: 35, certificate: true } +- { title: Java Records Lab, provider: Demo Learning, kind: lab, topic: Java, completed: 2025-06-03, minutes: 20 } +- { title: Testing Java Services, provider: Demo Learning, kind: course, topic: Java, completed: 2025-07-11, minutes: 50, certificate: true, keywords: [junit, testing] } +- { title: Java Build Tools, provider: Demo Learning, kind: course, topic: Java, completed: 2025-08-19, minutes: 25 } +- { title: Profiling the JVM, provider: Demo Learning, kind: lab, topic: Java, completed: 2025-09-05, minutes: 30, certificate: true, keywords: [java, performance] } +- { title: Logs and Metrics 101, provider: Example Academy, kind: course, topic: Observability, completed: 2025-10-20, minutes: 24, keywords: [grafana] } diff --git a/fixtures/content/projects/demo-rowing-rig.md b/fixtures/content/projects/demo-rowing-rig.md new file mode 100644 index 0000000..854d787 --- /dev/null +++ b/fixtures/content/projects/demo-rowing-rig.md @@ -0,0 +1,12 @@ +--- +title: Demo Rowing Rig +date: 2021-04-01 +group: own +where: At home (fixture) +stack: [Unity, Arduino] +summary: A synthetic game for two players with real oars. +featured: 4 +showcase: rowing-stroke +ai: some-ai +--- +Body of the demo rowing rig. diff --git a/fixtures/content/projects/example-vr-room.md b/fixtures/content/projects/example-vr-room.md new file mode 100644 index 0000000..629277d --- /dev/null +++ b/fixtures/content/projects/example-vr-room.md @@ -0,0 +1,12 @@ +--- +title: Example VR Room +date: 2019-05-01 +group: school +where: Fixture University +stack: [JavaScript] +summary: A synthetic sign-up site for a classroom in VR. +featured: 6 +image: { file: fixture-4.svg, alt: Screenshot of Example VR Room } +ai: by-hand +--- +Body of the example VR room. diff --git a/fixtures/content/projects/fixture-photo-board.md b/fixtures/content/projects/fixture-photo-board.md new file mode 100644 index 0000000..91c9c01 --- /dev/null +++ b/fixtures/content/projects/fixture-photo-board.md @@ -0,0 +1,12 @@ +--- +title: Fixture Photo Board +date: 2020-11-01 +group: school +where: Fixture University +stack: [React, Webapp] +summary: A synthetic dashboard shown with a screenshot. +featured: 5 +image: { file: fixture-pixel.png, alt: Screenshot of Fixture Photo Board } +ai: unknown +--- +Body of the fixture photo board. diff --git a/fixtures/content/projects/sample-peer-archive.md b/fixtures/content/projects/sample-peer-archive.md new file mode 100644 index 0000000..6b824c0 --- /dev/null +++ b/fixtures/content/projects/sample-peer-archive.md @@ -0,0 +1,19 @@ +--- +title: Sample Peer Archive +date: 2022-06-01 +group: school +where: Fixture University +years: 2022 +stack: [Node.js, JavaScript] +summary: A synthetic thesis project about peers that trust each other. +featured: 3 +showcase: peer-network +ai: by-hand +--- +## Why + +Fixture reason: archives need someone to keep them. + +## What's interesting + +Fixture insight: trust can be counted. diff --git a/fixtures/content/recognition.yml b/fixtures/content/recognition.yml new file mode 100644 index 0000000..be30f49 --- /dev/null +++ b/fixtures/content/recognition.yml @@ -0,0 +1,28 @@ +# Synthetic fixture entries; nothing here describes a real person or event. +- title: Example Hackathon 2019 + kind: competition + result: winner · best fixture + highlight: true + year: "2019" +- title: Fixture Robot League + kind: competition + result: won round 1, twice + highlight: true + detail: four years +- title: Sample Code Contest 2021 + kind: competition + result: top 12% + highlight: true +- title: Demo Capture the Flag 2024 + kind: competition + result: regional participant + highlight: true +- title: Example Art Club + kind: volunteering + result: IT & website + url: https://example.invalid/art-club + status: ongoing +- title: Sample Charity Shop + kind: volunteering + result: IT & website + status: retired diff --git a/mockups/shots/work-k2-1440.png b/mockups/shots/work-k2-1440.png new file mode 100644 index 0000000..084a5d4 Binary files /dev/null and b/mockups/shots/work-k2-1440.png differ diff --git a/mockups/work-k2.html b/mockups/work-k2.html new file mode 100644 index 0000000..f090687 --- /dev/null +++ b/mockups/work-k2.html @@ -0,0 +1,1060 @@ + + + + + + +Work · Lucas Winther + + + + + + + + +off the clock → + +
+
+
← gateLucas Winther/ work
+ +
made +
+ +
+
+
+
+ +
+
+
+
+
●  CV in ten seconds
+

Lucas Winther

+

Computer Scientist with passion for development.

+
+ nowIT-medarbejder, IT Research Tech, Aarhus University 2025–now + beforeIntern, ScienceAtHome 2019
Student helper, Local Configuration Management, TERMA Group 2016–2018
+ degreeBachelor in Computer Science + contactlucas@lucaswinther.info +
+ +
+
+
Highlights
+
+
AUHackathon 2019
winner · best AUHack
+
First Lego League
won round 1, twice
+
Google Hash Code 2021
top 12%
+
+ 3 more in the record ↓
+
Keeping up
+
18certificates
+
23 courses & hands-on labs · ~27 h since Feb 2025
+
Ansible 5Serverless / AWS 5RAG & LLMs 4Security 2Linux 1
+ all courses ↓
+
+
+ + + + + +
+

Also made

13 projects
+
+
+ +
+
+
+
+ +
+

Record

the full version of the highlights
+
+
Recognition & volunteering
+
+
Competitions
+
AUHackathon 2019
winner · best AUHack
+
First Lego League · four years, years?
won round 1, two years running
+
Google Hash Code 2021
top 12%
+
De Danske Cybermesterskaber 2024
regional participant
+
Volunteering
+
Art Bubble · art-bubble.dk ↗
IT & website · since ? · ongoing
+
Lystrup Genbrugser
IT & website · years? · site retired
+
+
Keeping up
+

18 certificates from 23 courses and hands-on labs · ~27 hours of course time since Feb 2025

+
topic
done
certificates
time
Ansible & configuration
2 courses · 3 labs
5
9.4h
Linux administration
1 course
1
6.7h
Security · JWT, ISO 27001, honeypots
2 courses · 1 lab
2
4.9h
Serverless on AWS
5 courses · 3 labs
5
3.9h
RAG & LLM systems
4 courses · 1 lab
4
1.8h
Observability · Grafana
1 course
1
0.4h
+ All courses and certificates, searchable →
+
+
+ + +
+
+

Writing

AI, devops, homelabblog.lucaswinther.info →
+
+
+
+

Where I've worked

↓ CV (PDF)
+
    +
  • 2025 – now
    Aarhus University · IT-medarbejder, IT Research Tech +

    Building and running software for researchers:

    +
    • a scalable, easy-to-use compute job system on Slurm, with a custom React/Node web app, scaling on Oracle Cloud, OAuth/Entra ID login and AU's ERDA storage
    • +
    • document and information services, integrating Overleaf and OpenProject
    • +
    • trying newer software while keeping it secure, containerised, updated and monitored: OpenBao/Vault, Grafana, Docker, Ansible
    +
    DevOpsDockerLinux adminobservabilitycloudfull-stackleadership
  • +
  • Jul 2019
    ScienceAtHome · internship +

    Joined ideation and planning on a new project, built an early prototype of its base mechanics to show the people it was for, and pair-programmed with experienced developers to start it.

  • +
  • 2016 – 2018
    TERMA Group · student helper, Local Configuration Management
  • +
  • degree
    BSc Computer Science · Aarhus University, 2022 +

    Thesis: HyperCache, reputation systems for peer-to-peer archival.

  • +
+
+
+
+ + + +
+ +
+ + + + + diff --git a/mockups/work-r1-options.html b/mockups/work-r1-options.html new file mode 100644 index 0000000..e364d82 --- /dev/null +++ b/mockups/work-r1-options.html @@ -0,0 +1,269 @@ + + + + + +Work page: three directions + + + + + +
+ +

Mockups · work page · everything uses your real titles, screenshots, CV lines and spreadsheet. The top bar, intro and CV strip are unchanged and left out.

+ + +
A

One grid, three sizes

All 13 projects in one grid. The six are big or medium tiles, and the rest are compact cards. Under it, a single "Record" band: competitions on the left, learning by topic on the right.

+
Risk: this is the closest to the "generic bento" look your DESIGN.md rules out. It's saved by the drawn showcases and the plain tiles, but it's the most template-like of the three.
+ +

Projects

13 · 6 pickedAll · Own · School · search
+
+
01 · Own · personal project

Event Tracker for Gacha Games

Used to easily figure out which live service games events to prioritize.

scrapingwebappjavascript
Read the full story →made with AI
+
drawn showcase · config-editor · hover
services:
  jellyfin:
    image: lscr.io/linuxserver/jellyfin
    volumes: [ /srv/media:/data ]
hosts: [ lucwin-01, lucwin-02 ]
sync: ✓ 2 hosts up to date
02 · Own · personal project

Config

A repository to store, sync and manage docker-compose based deployment on servers.

ansibleconfiguration management
Read the full story →made with AI
+
03 · School

Bachelor Report

Reputation systems in peer-to-peer archival.

Read more →by hand
+
04 · School

Student Dashboard

A concept dashboard, built upon conducted user studies.

Read more →by hand
+
drawn showcase · rowing-stroke · hover
05 · Own

Mixed-Reality Rowing Game

Two people, an Arduino and two real oars.

Read more →by hand
+
06 · School

VR-Classroom

A prototype website where you can register yourself, and then create + sign up for classes.

javascriptnode.js
Read the full story →by hand
+

Chessswap

Own · react
+

Monthly Expenses

Own · javascript
+

LoL Winrate Bot

Own · node.js
+

Censor Simulator

School · unity, VR
+

BreakoutPython

School · python
+

Tribute to Borlaugh

Own · html, css
+

Portfolio (2021)

Own · react
+
+ +

Record

competitions and learning
+
+

Competitions & recognition

+
+
2024
De Danske Cybermesterskaber
regional participant
+
2021
Google Hash Code
top 12%
+
2019
AUHackathon
winner · best AUHack
+
—
First Lego League · four years
won round 1, twice
+
+
+

Keeping up · 24 courses and labs since Feb 2025

+
+
Serverless on AWS
8 · 3 labs
+
Ansible & configuration
5 · 3 labs
+
RAG & LLM systems
5 · 1 lab
+
Security · JWT, ISO 27001, honeypots
3 · 1 lab
+
Linux admin, Grafana, WordPress
3
+
+ All courses, searchable → +
+
+ + +
B

Picked six, then a tight grid

The six get an even 3×2 grid of real cards. Everything else becomes a dense four-across grid of small cards, the same size as today's list rows but tiled. Accomplishments join the "CV in ten seconds" strip. Learning gets its own card next to Writing.

+
The calmest option and closest to today's structure. Accomplishments read as CV facts, which suits four entries. The learning card shows the latest few courses, so it looks alive as you keep going.
+ +
+

LATEST WRITING

A synthetic work post 03/14
+

CV IN TEN SECONDS

+
nowIT-medarbejder, IT Research Tech, Aarhus University (2025–now) + degreeBachelor in Computer Science + wonAUHackathon 2019, best AUHack · First Lego League, round 1 twice + placedGoogle Hash Code 2021, top 12% · DDC 2024, regional
+
+
+ +

Picked

6 projectshover the drawings
+
+
Own

Event Tracker for Gacha Games

Which live service game events to prioritize.

Read more →made with AI
+
config-editor · hover
services:
  jellyfin: ✓
hosts: [ lucwin-01, lucwin-02 ]
sync: ✓ up to date
Own

Config

Store, sync and manage docker-compose deployments.

Read more →made with AI
+
School

Bachelor Report

Reputation systems in peer-to-peer archival.

Read more →by hand
+
School

Student Dashboard

A concept dashboard built on user studies.

Read more →by hand
+
rowing-stroke · hover
Own

Mixed-Reality Rowing Game

Two people, an Arduino and two real oars.

Read more →by hand
+
School

VR-Classroom

Register, then create and sign up for classes.

Read more →by hand
+
+

Everything else

7All · Own · School · search
+
+

Chessswap

Own · react
+

Monthly Expenses

Own · javascript
+

LoL Winrate Bot

Own · node.js
+

Censor Simulator

School · unity, VR
+

BreakoutPython

School · python
+

Tribute to Borlaugh

Own · html, css
+

Portfolio (2021)

Own · react
+
+ +
+

Writing

(unchanged)
+

Learning · 24 since Feb 2025

+
+
Oct 2025Active Defense: Honeypotslab
+
Sep 2025Grafana Concepts and Basic Configuration
+
Aug 2025Vector Space Models and Embeddings in RAGs
+
Aug 2025Learning the Essentials of CentOS 7 Administration6h 43m
+
+
serverless 8ansible 5rag 5security 3linux 1
+ All 24, searchable → +
+
+ + +
C

One hero, uneven grid

An editorial grid that varies on purpose. The top pick is large with its drawing. The other five come in two shapes around it. The seven small projects fill the bottom as compact tiles. Below, a "Track record" section with the four accomplishments side by side and learning shown as topic bars.

+
The most character, and the "varied ways of showing information" principle at work, since the bars are a small graph. Downside: it needs a clear #1, and you'd choose the order of the six (I've put Config first because it has a live drawing).
+ +

Projects

13All · Own · School · search
+
+
drawn showcase · config-editor · hover
services:
  jellyfin:
    image: lscr.io/linuxserver/jellyfin
    volumes: [ /srv/media:/data ]
  immich:
    image: ghcr.io/immich-app/server
hosts: [ lucwin-01, lucwin-02 ]
sync: ✓ 2 hosts up to date
01 · Own · personal project

Config

A repository to store, sync and manage docker-compose based deployment on servers.

Read the full story →made with AI
+

Gacha Event Tracker

OwnAI
+
rowing-stroke

Mixed-Reality Rowing

Ownby hand
+

Bachelor Report

+

Student Dashboard

+

VR-Classroom

+

Chessswap

Own · react
+

Monthly Expenses

Own · javascript
+

LoL Winrate Bot

Own · node.js
+

Censor Simulator

School · unity, VR
+

BreakoutPython

School · python
+

Tribute to Borlaugh

Own · html, css
+

Portfolio (2021)

Own · react
+
+ +
+
CompetitionsThings I entered, and how it went.
+
+
2019
AUHackathon
winner · best AUHack
+
four years
First Lego League
won round 1, twice
+
2021
Google Hash Code
top 12%
+
2024
De Danske Cybermesterskaber
regional participant
+
+
Keeping up24 courses and hands-on labs since February 2025. Course time only, not the digging after.
+
+ Ansible
5 · ~10h + Linux admin
1 · 6.7h + Security
3 · ~5h + Serverless on AWS
8 · ~4h + RAG & LLMs
5 · ~2h +
All courses, searchable →
+
+ + +
+

/work/learning/: the searchable list (any option)

Its own quiet page, linked from whichever section you pick. Search plus topic filter, newest first. It's built from a content file you add a line to after each course.

+
+
All 24ServerlessAnsibleRAGSecurityLinuxOther
+ + + + + + + + +
CompletedCourseTopicProviderTypeTime
2025-10-08Active Defense: HoneypotssecurityPluralsightlab1h 30m
2025-09-07Grafana Concepts and Basic ConfigurationobservabilityPluralsightvideo25m
2025-08-16Security Compliance: ISO 27001securityPluralsightvideo56m
2025-08-08Learning the Essentials of CentOS Enterprise Linux 7 AdministrationlinuxPluralsightvideo · quiz ✓6h 43m
2025-05-29JWT FundamentalssecurityPluralsightvideo2h 29m
… 19 more
+
+ +
+ + + +
+
+ + + diff --git a/mockups/work-r2-grids.html b/mockups/work-r2-grids.html new file mode 100644 index 0000000..3255025 --- /dev/null +++ b/mockups/work-r2-grids.html @@ -0,0 +1,210 @@ + + + + + +Work page: round 2 + + + + +
+ + +

Round 2 · uneven grids with the small projects woven in · other work as its own kind of tile · certificates up front · search that also finds certificates. Dotted underline = a fact I don't have yet.

+ + +
D

Woven grid

Each big tile has a column of small tiles beside or under it, so the small projects fill gaps between the big ones instead of collecting at the bottom. Sizes vary on purpose: one large, some tall, some wide.

+
How it scales: the page repeats a fixed rhythm of slots (large + 2 small, tall + medium + small stack, …). New picked projects take the next big slot; everything else fills small slots in date order. You control which ones are big, not where they land.
+ +

Projects

15 · 6 pickedAllOwnSchoolFor othersSearch projects & certificates
+
+
drawn showcase · config-editor · hover
services:
  jellyfin:
    image: lscr.io/linuxserver/jellyfin
    volumes: [ /srv/media:/data ]
hosts: [ lucwin-01, lucwin-02 ]
sync: ✓ 2 hosts up to date
Own · personal project

Config

A repository to store, sync and manage docker-compose based deployment on servers.

Read the full story →made with AI
+
Own

Event Tracker for Gacha Games

Read more →made with AI
+

Chessswap

Own · react
+

Monthly Expenses Tracker

Own · javascript
+ +
rowing-stroke · hover
Own

Mixed-Reality Rowing Game

Two people, an Arduino and two real oars.

Read more →by hand
+
School · thesis

Bachelor Report

Read more →by hand
+
art-bubble.dkIT & website
For others · years?

Art Bubble

ongoingnot recorded
+

Lystrup Genbrugser

For others · site retired
+

LoL Winrate Discord Bot

Own · node.js
+

Censor Simulator

School · unity, VR
+

BreakoutPython

School · python
+

Tribute to Norman Borlaugh

Own · html, css
+ +
School

Student Dashboard

A concept student dashboard web-app, built upon conducted user studies.

+
School

VR-Classroom

+

Portfolio (2021)

Own · react
+
+ + +
E

Packed by size

Every project has a size (large, tall, wide, medium, small) and the grid packs them tightly in your order, letting small tiles slip into whatever gaps the big ones leave. Less hand-tuned than D, more organic, and it never needs re-laying out when you add things.

+
Trade-off: D's rhythm is predictable and tidy; E is more uneven and "found", but exactly where a small tile lands depends on what's around it. Same tiles, same order, as D.
+

Projects

15 · 6 pickedAllOwnSchoolFor othersSearch projects & certificates
+
+
config-editor · hover
services:
  jellyfin: ✓
hosts: [ lucwin-01, lucwin-02 ]
sync: ✓ 2 hosts up to date
Own

Config

Store, sync and manage docker-compose based deployment on servers.

Read the full story →made with AI
+
Own

Event Tracker for Gacha Games

Read more →made with AI
+
School · thesis

Bachelor Report

+
rowing-stroke
Own

Mixed-Reality Rowing Game

Read more →by hand
+
School

Student Dashboard

+
art-bubble.dkIT & website
For others

Art Bubble

+
School

VR-Classroom

+

Lystrup Genbrugser

For others · site retired
+

Chessswap

Own · react
+

Monthly Expenses Tracker

Own · javascript
+

LoL Winrate Discord Bot

Own · node.js
+

Censor Simulator

School · unity, VR
+

BreakoutPython

School · python
+

Tribute to Norman Borlaugh

Own · html, css
+

Portfolio (2021)

Own · react
+
+ + +
+

Record: competitions and keeping up (either grid)

Certificates are now the headline number and get their own column, marked with a small seal. Orange = what you flag as most impressive; the list just grows downward.

+
+

Competitions & recognition

4 entries · orange = flagged as a highlight

+
+
2024
De Danske Cybermesterskaber
regional participant
+
2021
Google Hash Code
top 12%
+
2019
AUHackathon
winner · best AUHack
+
yrs?
First Lego League · four years
won round 1, two years running
+
+
+

Keeping up

18 certificates from 23 courses and hands-on labs · ~27 hours of course time since Feb 2025

+
+
topic
done
certificates
time
+
Ansible & configuration
2 courses · 3 labs
5
9.4h
+
Linux administration
1 course
1
6.7h
+
Security · JWT, ISO 27001, honeypots
2 courses · 1 lab
2
4.9h
+
Serverless on AWS
5 courses · 3 labs
5
3.9h
+
RAG & LLM systems
4 courses · 1 lab
4
1.8h
+
Observability · Grafana
1 course
1
0.4h
+
+ All courses and certificates, searchable → +
+
+ + +
+

Searching the work page finds certificates too

The same search box over the grid. Projects that match stay in the grid (others dim); matching certificates appear in a strip right under the search, linking to /work/learning/.

+

Projects

1 project · 5 certificates matchansible
+
+
+

Projects

Config · stores, syncs and manages docker-compose deployments ansibleAI
+

Certificates & labs

+
Ansible FundamentalsPluralsight · Apr 2025 · 3.7h
+
Troubleshoot Ansible Connectivity labMar 2025 · 1.2h
+
Build a Production Ready Ansible Playbook labMar 2025 · 1h
+
+ 2 more →
+
+
+
+ + +
+

Other work also in "Where I've worked"

Besides the tile, the work history gets an "on the side" line, so the CV reads complete. Retired sites keep their line, just not the link (or link an archived copy).

+

Where I've worked

+
+
now
IT-medarbejder, IT Research Tech, Aarhus University (2025–now)
+
on the side
IT and website, Art Bubble (years?)
ongoing
+
IT and website, Lystrup Genbrugser (years?)
site retired
+
before
Intern, ScienceAtHome (2019)
+
+
+ +
+ + +
+
+ + + diff --git a/mockups/work-r3-calm.html b/mockups/work-r3-calm.html new file mode 100644 index 0000000..c4fcb59 --- /dev/null +++ b/mockups/work-r3-calm.html @@ -0,0 +1,256 @@ + + + + + +Work page: round 3 + + + + +
+ + +

Round 3 · calmer structures than D/E · Art Bubble and Lystrup Genbrugser moved out of the projects into a "Recognition & volunteering" box · Keeping up unchanged from round 2.

+ + +
F

Alternating bands

A strict, repeating rhythm instead of a mosaic: one big + a stack of four small, then two big side by side, then the first band mirrored. Only two band shapes, always aligned, so it reads as ordered while still mixing small projects in between the big ones.

+
Scales: each new picked project fills the next big slot; small projects fill the side stacks in date order. When the stacks are full, extra small ones form a last band of four across. Empty stack slots (dashed here) simply collapse.
+

Projects

13 · 6 pickedAllOwnSchoolSearch projects & certificates
+
+
drawn showcase · config-editor · hover
services:
  jellyfin:
    image: lscr.io/linuxserver/jellyfin
hosts: [ lucwin-01, lucwin-02 ]
sync: ✓ 2 hosts up to date
Own · personal project

Config

A repository to store, sync and manage docker-compose based deployment on servers.

Read the full story →made with AI
+
+

Chessswap

Own · react
+

Monthly Expenses Tracker

Own · javascript
+

LoL Winrate Discord Bot

Own · node.js
+

Censor Simulator

School · unity, VR
+
+
+
+
Own

Event Tracker for Gacha Games

Read more →made with AI
+
rowing-stroke · hover
Own

Mixed-Reality Rowing Game

Read more →by hand
+
+
+
+

BreakoutPython

School · python
+

Tribute to Norman Borlaugh

Own · html, css
+

Portfolio (2021)

Own · react
+
next small project
+
+
School · thesis

Bachelor Report

My bachelor thesis on using reputation systems in peer-to-peer archival.

Read the full story →by hand
+
+
+
School

Student Dashboard

Read more →by hand
+
School

VR-Classroom

Read more →by hand
+
+ + +
G

Picked down the left, index on the right

The six picked projects as roomy horizontal cards in one column. Beside them, from the very top, a compact index of everything else that stays in view while you scroll. The small projects are always on screen, never "the rest at the bottom".

+
The calmest and most editorial. Scales the easiest: the index just gets longer (and could fold by group). On phones the index drops under the six.
+

Projects

13 · 6 pickedAllOwnSchoolSearch projects & certificates
+
+
+
config-editor · hover
services:
  jellyfin: ✓
hosts: [ lucwin-01 ]
sync: ✓ up to date
01 · Own · personal project

Config

A repository to store, sync and manage docker-compose based deployment on servers.

Read the full story →made with AI
+
02 · Own

Event Tracker for Gacha Games

Used to easily figure out which live service games events to prioritize.

Read more →made with AI
+
03 · School · thesis

Bachelor Report

Reputation systems in peer-to-peer archival.

Read more →by hand
+
rowing-stroke
04 · Own

Mixed-Reality Rowing Game

A fun rowing game for two people, that uses an arduino and two real oars to be played.

Read more →by hand
+
05 · School

Student Dashboard

A concept student dashboard web-app, built upon conducted user studies.

Read more →by hand
+
06 · School

VR-Classroom

Register yourself, then create and sign up for classes.

Read more →by hand
+
+ +
+ + +
H

Two sizes, one even grid

Only two tile sizes on a regular four-column grid: picked projects take a 2×2 block, everything else a single cell. Same rhythm everywhere, so nothing feels random, and small projects sit right next to big ones.

+
Calm and classic. The catch: small projects become small cards with a picture (a bit larger than today's list rows), and the look edges toward a standard portfolio grid.
+

Projects

13 · 6 pickedAllOwnSchoolSearch projects & certificates
+
+
config-editor · hover
services:
  jellyfin: ✓
hosts: [ lucwin-01, lucwin-02 ]
sync: ✓ 2 hosts up to date
Own

Config

Read the full story →made with AI
+
Own

Event Tracker for Gacha Games

Read more →made with AI
+

Chessswap

Own
+

Monthly Expenses

Own
+
School · thesis

Bachelor Report

Read more →by hand
+

LoL Winrate Bot

Own
+

Censor Simulator

School
+
rowing-stroke
Own

Mixed-Reality Rowing Game

Read more →by hand
+
School

Student Dashboard

Read more →by hand
+

BreakoutPython

School
+

Tribute to Borlaugh

Own
+
School

VR-Classroom

Read more →by hand
+

Portfolio (2021)

Own
+
+ + +
+

Recognition & volunteering (any grid)

One box, two kinds of entry: things you competed in, and things you gave time to. Volunteer work isn't framed as your project, just as what you did there. Orange still marks what you flag as a highlight. Next to it, Keeping up from round 2, unchanged.

+
+

Recognition & volunteering

+
+
Competitions
+
AUHackathon 2019
winner · best AUHack
+
First Lego Leaguefour years running
won round 1, twice
+
Google Hash Code 2021
top 12%
+
De Danske Cybermesterskaber 2024
regional participant
+
Volunteering
+
Art Bubble art-bubble.dk ↗IT and website, as a volunteer
since ? · ongoing
+
Lystrup GenbrugserIT and website, as a volunteer · site since retired
years?
+
+
+

Keeping up

18 certificates from 23 courses and hands-on labs · ~27 hours since Feb 2025

+
+
topic
done
cert.
time
+
Ansible & configuration
2 courses · 3 labs
5
9.4h
+
Linux administration
1 course
1
6.7h
+
Security
2 courses · 1 lab
2
4.9h
+
Serverless on AWS
5 courses · 3 labs
5
3.9h
+
RAG & LLM systems
4 courses · 1 lab
4
1.8h
+
Observability
1 course
1
0.4h
+
+ All courses and certificates, searchable → +
+
+ +
+ + + +
+
+ + + diff --git a/mockups/work-r4-blocks.html b/mockups/work-r4-blocks.html new file mode 100644 index 0000000..0dc3ff0 --- /dev/null +++ b/mockups/work-r4-blocks.html @@ -0,0 +1,199 @@ + + + + + +Work page: round 4 + + + + +
+

Round 4 · taking F and H and removing what made them busy: every block is the same size, small projects come in blocks of their own (still at today's row size), screenshots are toned to the page until you point at one, and tiles carry only number, title, one line and the AI mark.

+ + +
I

Equal blocks, two across

A plain two-column grid where every block is identical in size. A block is either one picked project or a group of four small ones. After the first pair, a block of small ones takes every few slots, so the small projects show up among the big ones, not after them.

+
Why it should feel calmer than F/H: one shape only, aligned rows, the same image treatment on every tile, and much less text. Hover any tile to see its real colours.
+

Projects

13 · 6 pickedAllOwnSchoolSearch projects & certificates
+ + + +
J

Equal blocks, three across

The same idea, denser: three identical blocks per row, groups of three small projects per block. More fits on one screen; each picked project gets a bit less room.

+
The row of three makes the six read as "the main ones" at a glance.
+

Projects

13 · 6 pickedAllOwnSchoolSearch projects & certificates
+ + + +
?

Same blocks without the toning

For comparison: the first row of I with the screenshots at full colour. The white Bachelor Report and bright VR-Classroom screenshots are a big part of what made F and H feel noisy.

+ + +
+ + +
+
+ + + diff --git a/mockups/work-r5-full.html b/mockups/work-r5-full.html new file mode 100644 index 0000000..8b469b6 --- /dev/null +++ b/mockups/work-r5-full.html @@ -0,0 +1,404 @@ + + + + + +Work page: full-page directions + + + + + +services: jellyfin: image: linuxserver/jellyfinhosts: [ lucwin-01, lucwin-02 ]sync: ✓ 2 hosts up to date + + +
Round 5 · full pages + + +
+ + +
+
← gateLucas Winther / workprojects made: any waynot fully by AIby hand only
+
+
+
+
●  CV in ten seconds
+

Lucas Winther

+

Computer Scientist with passion for development.

+
+ nowIT-medarbejder, IT Research Tech, Aarhus University 2025–now + beforeIntern, ScienceAtHome 2019
Student helper, Local Configuration Management, TERMA Group 2016–2018
+ degreeBachelor in Computer Science + contactlucas@lucaswinther.info +
+
full history ↓GitHubLinkedIn
+
+
+
Highlights
+
+
AUHackathon 2019
winner
+
First Lego League
won round 1, twice
+
Google Hash Code 2021
top 12%
+
+ 3 more in the record ↓
+
Keeping up
+
18certificates · 23 courses & labs · ~27 h since Feb 2025
+
Ansible 5Serverless/AWS 5RAG & LLMs 4Security 2Linux 1
+ all courses ↓
+
+
+ +

Projects I'm proudest of

6marked by how much AI went into them · AIby hand
+ + + +

Record

the full version of the highlights
+
+

Recognition & volunteering

+
+
AUHackathon 2019
winner · best AUHack
+
First Lego League · four years
won round 1, two years running
+
Google Hash Code 2021
top 12%
+
De Danske Cybermesterskaber 2024
regional participant
+
Art Bubble · art-bubble.dk ↗
volunteer IT & website · ongoing
+
Lystrup Genbrugser
volunteer IT & website · site retired
+
+

Keeping up · 18 certificates

+
+
topic
done
cert.
time
+
Ansible & configuration
2 courses · 3 labs
5
9.4h
+
Linux administration
1 course
1
6.7h
+
Security
2 courses · 1 lab
2
4.9h
+
Serverless on AWS
5 courses · 3 labs
5
3.9h
+
RAG & LLM systems
4 courses · 1 lab
4
1.8h
+
Observability
1 course
1
0.4h
+
All courses and certificates, searchable →
+
+
+

Writing · 1 post

A synthetic work post · 14 Mar 2025
+

Where I've worked

nowIT Research Tech, Aarhus University (2025–now)beforeIntern, ScienceAtHome (2019)
Student helper, TERMA Group (2016–2018)
degreeBachelor in Computer Science
+
+
+
← back to the gate/simpleGitHubLinkedInlucas@lucaswinther.info
+
+ + +
+
← gateLucas Winther / workprojects made: any waynot fully by AIby hand only
+
+ +
+

Projects I'm proudest of

6AIby hand
+ + + +

Record

+

Recognition & volunteering

+
AUHackathon 2019
winner · best AUHack
+
First Lego League · four years
won round 1, two years running
+
Google Hash Code 2021
top 12%
+
De Danske Cybermesterskaber 2024
regional participant
+
Art Bubble · art-bubble.dk ↗
volunteer IT & website · ongoing
+
Lystrup Genbrugser
volunteer IT & website · site retired
+

Keeping up · 18 certificates · 23 courses & labs

+
+
Ansible & configuration
2 courses · 3 labs
5
9.4h
+
Linux administration
1 course
1
6.7h
+
Security
2 courses · 1 lab
2
4.9h
+
Serverless on AWS
5 courses · 3 labs
5
3.9h
+
RAG & LLM systems
4 courses · 1 lab
4
1.8h
+
All courses and certificates, searchable →
+

Writing · 1 post

A synthetic work post · 14 Mar 2025
+
+
+
← back to the gate/simpleGitHubLinkedInlucas@lucaswinther.info
+
+ + +
+
← gateLucas Winther / workprojects made: any waynot fully by AIby hand only
+
+

Lucas Winther

Computer Scientist with passion for development.
+
+
●  CV in ten seconds
+
nowIT-medarbejder, IT Research Tech, Aarhus University 2025–nowbeforeIntern, ScienceAtHome 2019 · Student helper, TERMA Group 2016–2018degreeBachelor in Computer Science
+
GitHubLinkedInEmail
+
Highlights
+
AUHackathon 2019
winner
First Lego League
round 1 ×2
Google Hash Code 2021
top 12%
+ + 3 more ↓
+
Keeping up
+
18 certificates
+
23 courses & labs · ~27 h since Feb 2025
+
AnsibleAWS serverlessRAGSecurityLinux
+
+ +

Projects I'm proudest of

6marked by how much AI went into them · AIby hand
+ + + + +

Record

the full version
+
+

Recognition & volunteering

+
AUHackathon 2019
winner · best AUHack
+
First Lego League · four years
won round 1, two years running
+
Google Hash Code 2021
top 12%
+
De Danske Cybermesterskaber 2024
regional participant
+
Art Bubble · art-bubble.dk ↗
volunteer IT & website · ongoing
+
Lystrup Genbrugser
volunteer IT & website · site retired
+

Keeping up · 18 certificates

+
Ansible & configuration
2 courses · 3 labs
5
9.4h
+
Linux administration
1 course
1
6.7h
+
Security
2 courses · 1 lab
2
4.9h
+
Serverless on AWS
5 courses · 3 labs
5
3.9h
+
RAG & LLM systems
4 courses · 1 lab
4
1.8h
+
All courses and certificates, searchable →
+
+
+

Writing · 1 post

A synthetic work post · 14 Mar 2025
+

Where I've worked

nowIT Research Tech, Aarhus University (2025–now)beforeIntern, ScienceAtHome (2019)
Student helper, TERMA Group (2016–2018)
+
+
+
← back to the gate/simpleGitHubLinkedInlucas@lucaswinther.info
+
+ + + + diff --git a/package.json b/package.json index 3a1bae8..210e928 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "react-dom": "^19" }, "devDependencies": { + "@axe-core/playwright": "^4.14.0", "@playwright/test": "^1.65", "@types/bun": "^1.4.4", "@types/react": "^19.3.0", diff --git a/src/core/courses.ts b/src/core/courses.ts new file mode 100644 index 0000000..0e89ff7 --- /dev/null +++ b/src/core/courses.ts @@ -0,0 +1,66 @@ +// Courses and labs: the summary behind "Keeping up", course search, and the small formatters both use. +// Pure: takes a plain course shape so the browser (ProjectList, the learning page script) can use it too. + +export type CourseLike = { + title: string; + provider: string; + kind: 'course' | 'lab'; + topic: string; + completed: string; // YYYY-MM-DD + minutes: number; + certificate: boolean; + keywords: string[]; +}; + +export type TopicSummary = { topic: string; courses: number; labs: number; certificates: number; minutes: number }; +export type CourseSummary = { + count: number; + certificates: number; + minutes: number; + since: string; // YYYY-MM of the earliest completion, '' without courses + topics: TopicSummary[]; // by total minutes, largest first, then by name +}; + +export function courseSummary(courses: readonly CourseLike[]): CourseSummary { + const topics = new Map(); + let since = ''; + for (const c of courses) { + const t = topics.get(c.topic) ?? { topic: c.topic, courses: 0, labs: 0, certificates: 0, minutes: 0 }; + if (c.kind === 'lab') t.labs++; + else t.courses++; + if (c.certificate) t.certificates++; + t.minutes += c.minutes; + topics.set(c.topic, t); + const month = c.completed.slice(0, 7); + if (!since || month < since) since = month; + } + return { + count: courses.length, + certificates: courses.filter((c) => c.certificate).length, + minutes: courses.reduce((n, c) => n + c.minutes, 0), + since, + topics: [...topics.values()].sort((a, b) => b.minutes - a.minutes || a.topic.localeCompare(b.topic)), + }; +} + +/** The text a course is searched by: title, provider, topic and keywords. */ +export const courseSearchText = (c: Pick): string => + [c.title, c.provider, c.topic, ...c.keywords].join(' ').toLowerCase(); + +/** Case-insensitive substring match; an empty query matches nothing (courses only show while searching). */ +export function courseMatches(c: Pick, query: string): boolean { + const needle = query.trim().toLowerCase(); + return needle !== '' && courseSearchText(c).includes(needle); +} + +/** Course time in hours with one decimal: 564 → "9.4h". */ +export const formatHours = (minutes: number): string => `${(minutes / 60).toFixed(1)}h`; + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +/** "2025-02" (or a full date) → "Feb 2025". */ +export function monthYear(ym: string): string { + const m = /^(\d{4})-(\d{2})/.exec(ym); + const name = m ? MONTHS[Number(m[2]) - 1] : undefined; + return m && name ? `${name} ${m[1]}` : ''; +} diff --git a/src/library/load.ts b/src/library/load.ts index dd61a02..b1135f3 100644 --- a/src/library/load.ts +++ b/src/library/load.ts @@ -9,7 +9,7 @@ import { isShowcaseId, SHOWCASE_IDS } from '../core/showcases.ts'; import type { ValidationError } from './errors.ts'; import { splitFrontmatter } from './frontmatter.ts'; import type { - AiPart, Content, Interest, Item, ItemKind, LoadResult, Link, Media, Project, Site, + AiPart, Content, Course, Interest, Item, ItemKind, LoadResult, Link, Media, Project, Recognition, Site, } from './types.ts'; export const SUPPORTED_CONTRACTS = [1, 2]; @@ -402,6 +402,115 @@ function loadItem(doc: Doc, mediaDir: string, report: Report): Item { return item; } +const RECOGNITION_KEYS = ['title', 'kind', 'result', 'highlight', 'detail', 'year', 'url', 'status']; +const COURSE_KEYS = ['title', 'provider', 'kind', 'topic', 'completed', 'minutes', 'certificate', 'url', 'keywords']; + +/** Reads an optional YAML list file: missing means no entries, anything but a list is an error. */ +async function readOptionalList(dir: string, file: string, report: Report): Promise { + const path = join(dir, file); + if (!existsSync(path)) return []; + let raw: unknown; + try { + raw = Bun.YAML.parse(await Bun.file(path).text()); + } catch (e) { + report('file', yamlError(e)); + return []; + } + if (raw === null || raw === undefined) return []; + if (!Array.isArray(raw)) { + report('file', 'must be a list'); + return []; + } + return raw; +} + +/** Runs `each` over map entries, reporting non-maps and unknown keys (a typo like `certifcate` is caught). */ +function eachEntry(list: unknown[], keys: string[], report: Report, each: (o: Obj, at: Report) => void): void { + list.forEach((e, i) => { + if (!isObj(e)) return report(`[${i}]`, 'must be a map of fields'); + const at: Report = (field, message) => report(`[${i}].${field}`, message); + each(e, at); + for (const k of Object.keys(e)) if (!keys.includes(k)) at(k, 'unknown field'); + }); +} + +function optionalText(v: unknown, field: string, report: Report): string | undefined { + if (v === undefined || v === null) return undefined; + const s = typeof v === 'number' ? String(v) : str(v); + if (!s) report(field, 'must be text'); + return s; +} + +function optionalBool(v: unknown, field: string, report: Report): boolean { + if (v === undefined) return false; + if (typeof v !== 'boolean') report(field, 'must be true or false'); + return v === true; +} + +function optionalUrl(v: unknown, field: string, report: Report): string | undefined { + if (v === undefined) return undefined; + const s = str(v); + if (!s || !isUrl(s)) { + report(field, 'must be an http(s) URL'); + return undefined; + } + return s; +} + +function loadRecognition(list: unknown[], report: Report): Recognition[] { + const out: Recognition[] = []; + eachEntry(list, RECOGNITION_KEYS, report, (o, at) => { + const kind = (['competition', 'volunteering'] as const).find((k) => k === o.kind); + if (!kind) at('kind', 'must be competition or volunteering'); + const r: Recognition = { + title: required(o.title, 'title', at), + kind: kind ?? 'competition', + result: required(o.result, 'result', at), + highlight: optionalBool(o.highlight, 'highlight', at), + }; + const detail = optionalText(o.detail, 'detail', at); + const year = optionalText(o.year, 'year', at); + const url = optionalUrl(o.url, 'url', at); + if (detail) r.detail = detail; + if (year) r.year = year; + if (url) r.url = url; + if (o.status !== undefined) { + const status = (['ongoing', 'retired'] as const).find((s) => s === o.status); + if (kind === 'competition') at('status', 'only volunteering has a status'); + else if (!status) at('status', 'must be ongoing or retired'); + else r.status = status; + } + out.push(r); + }); + return out; +} + +function loadCourses(list: unknown[], report: Report): Course[] { + const out: Course[] = []; + eachEntry(list, COURSE_KEYS, report, (o, at) => { + const kind = (['course', 'lab'] as const).find((k) => k === o.kind); + if (!kind) at('kind', 'must be course or lab'); + const completed = o.completed instanceof Date ? o.completed.toISOString().slice(0, 10) : typeof o.completed === 'string' ? o.completed : ''; + if (!isCalendarDate(completed)) at('completed', 'required, a real date formatted YYYY-MM-DD'); + const minutes = typeof o.minutes === 'number' && Number.isInteger(o.minutes) && o.minutes > 0 ? o.minutes : 0; + if (!minutes) at('minutes', 'required, a positive whole number'); + const c: Course = { + title: required(o.title, 'title', at), + provider: required(o.provider, 'provider', at), + kind: kind ?? 'course', + topic: required(o.topic, 'topic', at), + completed, + minutes, + certificate: optionalBool(o.certificate, 'certificate', at), + keywords: textList(o.keywords, 'keywords', at), + }; + const url = optionalUrl(o.url, 'url', at); + if (url) c.url = url; + out.push(c); + }); + return out; +} + const byDateDesc = (a: { date: string; id: string }, b: { date: string; id: string }) => b.date.localeCompare(a.date) || a.id.localeCompare(b.id); @@ -452,7 +561,12 @@ export async function loadContent(dir: string): Promise { ); items.sort(byDateDesc); + const recognition = loadRecognition( + await readOptionalList(dir, 'recognition.yml', reporter('recognition.yml')), reporter('recognition.yml'), + ); + const courses = loadCourses(await readOptionalList(dir, 'courses.yml', reporter('courses.yml')), reporter('courses.yml')); + if (errors.length > 0 || !site) return { content: null, errors }; - const content: Content = { site, interests, projects, items }; + const content: Content = { site, interests, projects, items, recognition, courses }; return { content, errors }; } diff --git a/src/library/types.ts b/src/library/types.ts index 443cdd5..9ea1642 100644 --- a/src/library/types.ts +++ b/src/library/types.ts @@ -53,5 +53,35 @@ export type Item = { ai: AiLevel | null; bodyHtml: string; }; -export type Content = { site: Site; interests: Interest[]; projects: Project[]; items: Item[] }; +// recognition.yml: competitions and volunteering, file order = display order. +export type Recognition = { + title: string; + kind: 'competition' | 'volunteering'; + result: string; // the result of a competition, or the role in volunteering + highlight: boolean; + detail?: string; + year?: string; + url?: string; + status?: 'ongoing' | 'retired'; // volunteering only +}; +// courses.yml: completed courses and hands-on labs. +export type Course = { + title: string; + provider: string; + kind: 'course' | 'lab'; + topic: string; + completed: string; // YYYY-MM-DD + minutes: number; // course time only + certificate: boolean; + url?: string; + keywords: string[]; +}; +export type Content = { + site: Site; + interests: Interest[]; + projects: Project[]; + items: Item[]; + recognition: Recognition[]; + courses: Course[]; +}; export type LoadResult = { content: Content | null; errors: ValidationError[] }; diff --git a/src/site/components/work/CourseSumLine.astro b/src/site/components/work/CourseSumLine.astro new file mode 100644 index 0000000..9cad712 --- /dev/null +++ b/src/site/components/work/CourseSumLine.astro @@ -0,0 +1,9 @@ +--- +// " 7 certificates from 11 courses and hands-on labs · ~11 hours of course time since Feb 2025". +// Shared by the Record's Keeping up card and /work/learning/. Hours are course time only. +import { monthYear, type CourseSummary } from '../../../core/courses.ts'; +import { approxHours, sealSvg } from './record.ts'; +interface Props { sum: CourseSummary } +const { sum } = Astro.props; +--- +

{sum.certificates > 0 && <> {sum.certificates} {sum.certificates === 1 ? 'certificate' : 'certificates'} from }{sum.count} {sum.count === 1 ? 'course' : 'courses'} and hands-on labs · ~{approxHours(sum.minutes)} hours of course time since {monthYear(sum.since)}

diff --git a/src/site/components/work/Featured.astro b/src/site/components/work/Featured.astro index ad2277a..1d2bcac 100644 --- a/src/site/components/work/Featured.astro +++ b/src/site/components/work/Featured.astro @@ -1,26 +1,29 @@ --- -// Featured projects as large panels with a drawn showcase each. A panel hidden by the visitor's -// AI preference collapses to its sibling .filtered-note (base.css), whose button shows it anyway. +// "Projects I'm proudest of": up to six featured projects as large panels, each with a drawn showcase or a +// (muted until hovered) screenshot. A panel hidden by the visitor's AI preference collapses to its sibling +// .filtered-note (base.css), whose button shows it anyway. The page's AI legend sits with the heading. import type { Project } from '../../../library/types.ts'; import AiMark from '../AiMark.astro'; import Showcase from '../showcases/Showcase.astro'; -import { GROUP_LABEL, panelFacts } from './sections.ts'; +import { featLayout, GROUP_LABEL, panelFacts } from './sections.ts'; interface Props { projects: Project[] } const { projects } = Astro.props; -// 1: one wide · 2: wide, wide reversed · 3: wide, two halves · 4: wide, two halves, wide reversed -const LAYOUT: Record = { - 1: ['wide'], 2: ['wide', 'wide rev'], 3: ['wide', '', ''], 4: ['wide', '', '', 'wide rev'], -}; -const layout = LAYOUT[projects.length] ?? []; +const layout = featLayout(projects.length); const why: Record = { 'ai-made': 'made with AI', 'some-ai': 'some AI', 'by-hand': 'by hand', unknown: 'AI level not recorded', }; ---