Merge branch 'feat/work-page-cv-first': spec 009, CV-first work page, axe checks
This commit is contained in:
commit
cccd5e7548
60 files changed
+4118
-173
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
+12
-3
@@ -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) ─┐
|
||||
|
||||
@@ -13,6 +13,8 @@ interests.yml
|
||||
projects/<project-id>.md
|
||||
items/<item-id>.md
|
||||
issues/<yyyy>-<h1|h2>.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/<files> # images, audio, the CV PDF
|
||||
```
|
||||
|
||||
@@ -32,7 +34,7 @@ To show a media file inside a Markdown body, link it by its URL, `/media/<file>`
|
||||
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.
|
||||
@@ -393,3 +393,48 @@ that only differ in one line); stripping the ` · site name` suffix for `og:titl
|
||||
**Consequences:** A page's `<body>` 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 "<n> 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`).
|
||||
@@ -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
|
||||
|
||||
+9
-1
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
"<n> courses & hands-on labs · ~<h> h since <Mon YYYY of the earliest completion>"; then up to **5** topic chips,
|
||||
"<topic> <certificates>", 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=<text>`. 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, "<n> certificates", "from <n> courses and hands-on labs", "~<h> hours of course
|
||||
time since <Mon YYYY>"), then a table by topic, ordered by total time: topic · "<a> courses · <b> 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 <n>", 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.
|
||||
+19
-2
@@ -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<string[]> {
|
||||
|
||||
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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
+8
-1
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
+69
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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] }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,269 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Work page: three directions</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#131210;--panel:#1b1916;--panel2:#221f1b;--line:#2b2824;--ink:#ede7dd;--mute:#8e877c;--acc:#ea7444;--ok:#a8b48f;
|
||||
--sans:'Geist',system-ui,sans-serif;--mono:'Geist Mono',ui-monospace,monospace;--e:cubic-bezier(.2,.8,.2,1)}
|
||||
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 var(--sans)}
|
||||
a{color:inherit;text-decoration:none}
|
||||
.wrap{max-width:1180px;margin:0 auto;padding:0 32px}
|
||||
.opt-head{border-top:1px dashed #3a352f;margin-top:80px;padding:28px 0 8px;display:flex;gap:18px;align-items:baseline}
|
||||
.opt-head:first-of-type{margin-top:24px;border-top:0}
|
||||
.opt-head .L{font:600 13px var(--mono);color:var(--bg);background:var(--acc);border-radius:4px;padding:2px 8px}
|
||||
.opt-head h1{font-size:22px;margin:0;font-weight:600}
|
||||
.opt-head p{margin:0;color:var(--mute);max-width:640px}
|
||||
.note{font:12px/1.5 var(--mono);color:var(--mute);border-left:2px solid var(--acc);padding:2px 0 2px 12px;margin:10px 0 24px;max-width:820px}
|
||||
.sh{display:flex;align-items:baseline;gap:12px;margin:40px 0 16px}
|
||||
.sh h2{font-size:22px;margin:0;font-weight:600}.sh .n{font:12px var(--mono);color:var(--mute)}
|
||||
.sh .r{margin-left:auto;font:12px var(--mono);color:var(--mute)}
|
||||
.mk{display:inline-flex;align-items:center;gap:6px;font:11px var(--mono);border:1px solid var(--line);border-radius:99px;padding:2px 8px;white-space:nowrap;color:var(--ink)}
|
||||
.mk i{width:8px;height:8px;border-radius:50%;border:1.5px solid currentColor;display:inline-block}
|
||||
.mk.ai{color:var(--acc);border-color:#6b3a26}.mk.ai i{background:var(--acc);border-color:var(--acc)}
|
||||
.meta{font:11px var(--mono);color:var(--mute);letter-spacing:.02em}
|
||||
.chips{display:flex;gap:6px;flex-wrap:wrap}.chip{font:11px var(--mono);color:var(--mute);border:1px solid var(--line);border-radius:4px;padding:1px 6px}
|
||||
.shot{background:#0e0d0c center/cover no-repeat;border-bottom:1px solid var(--line)}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;transition:border-color .2s var(--e),transform .2s var(--e)}
|
||||
.card:hover{border-color:#4a433b}
|
||||
.card .txt{padding:16px 18px}
|
||||
.card h3{margin:6px 0 4px;font-size:18px;font-weight:600}
|
||||
.card p{margin:0 0 12px;color:#c9c2b6;font-size:14px}
|
||||
.ft{display:flex;align-items:center;justify-content:space-between;margin-top:12px}
|
||||
.ft .open{font:12px var(--mono);color:var(--acc)}
|
||||
/* drawn showcase stand-ins */
|
||||
.draw{position:relative;background:var(--panel2);border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:center}
|
||||
.draw .tag{position:absolute;top:10px;left:12px;font:10px var(--mono);color:var(--mute)}
|
||||
.code{font:11px/1.7 var(--mono);color:#b9b2a6;padding:26px 22px;width:100%}
|
||||
.code b{color:var(--acc);font-weight:400}.code u{color:var(--ok);text-decoration:none}
|
||||
/* small tiles */
|
||||
.mini{display:flex;gap:12px;align-items:center;padding:10px 12px;background:var(--panel);border:1px solid var(--line);border-radius:8px}
|
||||
.mini .th{width:56px;height:38px;border-radius:4px;background:#0e0d0c center/cover;flex:none;border:1px solid var(--line)}
|
||||
.mini h4{margin:0;font-size:14px;font-weight:500}.mini .meta{font-size:10.5px}
|
||||
.mini .mk{margin-left:auto;font-size:10px;padding:1px 6px}
|
||||
/* ---------- A ---------- */
|
||||
.gA{display:grid;grid-template-columns:repeat(12,1fr);gap:14px}
|
||||
.gA .big{grid-column:span 6}.gA .mid{grid-column:span 4}.gA .sm{grid-column:span 3}
|
||||
.gA .big .shot,.gA .big .draw{height:230px}.gA .mid .shot,.gA .mid .draw{height:170px}
|
||||
/* recognition strip */
|
||||
.rec{display:grid;grid-template-columns:1.1fr 1fr;gap:14px;margin-top:14px}
|
||||
.box{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px 20px}
|
||||
.box h3{margin:0 0 12px;font:500 11px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
|
||||
.led{display:grid;grid-template-columns:52px 1fr auto;gap:4px 14px;font-size:14px}
|
||||
.led>div{padding:8px 0;border-top:1px solid var(--line)}
|
||||
.led .y{font:12px var(--mono);color:var(--mute)}
|
||||
.led .res{font:12px var(--mono);color:var(--mute);text-align:right}
|
||||
.led .res.win{color:var(--acc)}
|
||||
.cl{display:grid;grid-template-columns:1fr auto;gap:0 14px;font-size:14px}
|
||||
.cl>div{padding:7px 0;border-top:1px solid var(--line)}.cl .c{font:12px var(--mono);color:var(--mute);text-align:right}
|
||||
.more{display:inline-block;margin-top:12px;font:12px var(--mono);color:var(--acc)}
|
||||
/* ---------- B ---------- */
|
||||
.gB{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
|
||||
.gB .shot,.gB .draw{height:180px}
|
||||
.gBs{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:10px}
|
||||
.cvs{display:grid;grid-template-columns:1fr 1fr;background:var(--panel);border:1px solid var(--line);border-radius:10px;margin-top:24px}
|
||||
.cvs>div{padding:18px 20px}.cvs>div+div{border-left:1px solid var(--line)}
|
||||
.cvs h3{margin:0 0 10px;font:500 11px var(--mono);letter-spacing:.08em;color:var(--mute)}
|
||||
.kv{display:grid;grid-template-columns:72px 1fr;gap:6px 14px;font-size:14px}.kv .k{font:11px var(--mono);color:var(--mute);padding-top:2px}
|
||||
.kv .acc{color:var(--acc)}
|
||||
.lower{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:40px}
|
||||
.log>div{display:grid;grid-template-columns:70px 1fr auto;gap:12px;padding:8px 0;border-top:1px solid var(--line);font-size:14px}
|
||||
.log .d{font:11px var(--mono);color:var(--mute);padding-top:3px}.log .k{font:10.5px var(--mono);color:var(--ok);border:1px solid #3d4433;border-radius:4px;padding:0 5px;height:18px;align-self:center}
|
||||
.topics{display:flex;gap:6px;flex-wrap:wrap;margin:14px 0 2px}
|
||||
/* ---------- C ---------- */
|
||||
.gC{display:grid;grid-template-columns:repeat(6,1fr);grid-auto-rows:118px;gap:14px}
|
||||
.gC .card{display:flex;flex-direction:column}
|
||||
.gC .card .shot,.gC .card .draw{flex:1;min-height:0}
|
||||
.gC .card .txt{padding:12px 16px}
|
||||
.gC .card h3{font-size:16px;margin:2px 0 0}
|
||||
.gC .card p{display:none}
|
||||
.gC .hero{grid-column:span 4;grid-row:span 4}.gC .hero p{display:block;margin:6px 0 0}
|
||||
.gC .hero h3{font-size:22px}
|
||||
.gC .tall{grid-column:span 2;grid-row:span 2}
|
||||
.gC .wide{grid-column:span 2;grid-row:span 2}
|
||||
.gC .mini{grid-column:span 2;align-self:stretch}
|
||||
.track{margin-top:44px;display:grid;grid-template-columns:260px 1fr;gap:0;border-top:1px solid var(--line)}
|
||||
.track .lab{padding:20px 0;font:500 11px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;border-bottom:1px solid var(--line)}
|
||||
.track .lab span{display:block;font:13px var(--sans);text-transform:none;letter-spacing:0;color:var(--mute);margin-top:6px;max-width:220px}
|
||||
.track .row{padding:20px 0;border-bottom:1px solid var(--line)}
|
||||
.medals{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
|
||||
.medal .y{font:12px var(--mono);color:var(--mute)}.medal .t{font-weight:500;margin:4px 0}.medal .r{font:12px var(--mono);color:var(--mute)}
|
||||
.medal .r.win{color:var(--acc)}
|
||||
.bars{display:grid;grid-template-columns:150px 1fr 70px;gap:8px 14px;align-items:center;font-size:14px}
|
||||
.bar{height:8px;background:var(--panel2);border-radius:99px;overflow:hidden}.bar i{display:block;height:100%;background:#8e877c;border-radius:99px}
|
||||
.bars .c{font:11px var(--mono);color:var(--mute)}
|
||||
/* learning page */
|
||||
.lp{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:20px 22px;margin-top:14px}
|
||||
.lp .top{display:flex;gap:10px;align-items:center;margin-bottom:12px}
|
||||
.search{flex:1;max-width:380px;background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:7px 10px;font:13px var(--sans);color:var(--mute)}
|
||||
.seg{display:flex;gap:4px}.seg span{font:12px var(--sans);color:var(--mute);padding:4px 9px;border-radius:5px}.seg span.on{background:var(--panel2);color:var(--ink)}
|
||||
table{width:100%;border-collapse:collapse;font-size:13.5px}td,th{text-align:left;padding:8px 10px 8px 0;border-top:1px solid var(--line);vertical-align:top}
|
||||
th{font:500 10.5px var(--mono);letter-spacing:.06em;color:var(--mute);text-transform:uppercase;border-top:0}
|
||||
td.m{font:12px var(--mono);color:var(--mute);white-space:nowrap}
|
||||
.pick{display:flex;gap:10px;margin:22px 0 60px}
|
||||
.pick button{font:13px var(--mono);background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:8px 14px;cursor:pointer}
|
||||
.pick button.selected,.pick button:hover{border-color:var(--acc);color:var(--acc)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<p class="meta" style="margin-top:28px">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.</p>
|
||||
|
||||
<!-- ================= A ================= -->
|
||||
<div class="opt-head"><span class="L">A</span><div><h1>One grid, three sizes</h1><p>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.</p></div></div>
|
||||
<div class="note">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.</div>
|
||||
|
||||
<div class="sh"><h2>Projects</h2><span class="n">13 · 6 picked</span><span class="r">All · Own · School · search</span></div>
|
||||
<div class="gA">
|
||||
<article class="card big"><div class="shot" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="txt"><div class="meta">01 · Own · personal project</div><h3>Event Tracker for Gacha Games</h3><p>Used to easily figure out which live service games events to prioritize.</p><div class="chips"><span class="chip">scraping</span><span class="chip">webapp</span><span class="chip">javascript</span></div><div class="ft"><span class="open">Read the full story →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="card big"><div class="draw"><span class="tag">drawn showcase · config-editor · hover</span><div class="code"><b>services:</b><br> jellyfin:<br> image: <u>lscr.io/linuxserver/jellyfin</u><br> volumes: [ /srv/media:/data ]<br><b>hosts:</b> [ lucwin-01, lucwin-02 ]<br><b>sync:</b> <u>✓ 2 hosts up to date</u></div></div><div class="txt"><div class="meta">02 · Own · personal project</div><h3>Config</h3><p>A repository to store, sync and manage docker-compose based deployment on servers.</p><div class="chips"><span class="chip">ansible</span><span class="chip">configuration management</span></div><div class="ft"><span class="open">Read the full story →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="card mid"><div class="shot" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="txt"><div class="meta">03 · School</div><h3>Bachelor Report</h3><p>Reputation systems in peer-to-peer archival.</p><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="card mid"><div class="shot" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="txt"><div class="meta">04 · School</div><h3>Student Dashboard</h3><p>A concept dashboard, built upon conducted user studies.</p><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="card mid"><div class="draw"><span class="tag">drawn showcase · rowing-stroke · hover</span><svg width="86%" height="110" viewBox="0 0 300 110"><path d="M0 90 C40 90 50 20 90 20 S140 90 180 90 S230 25 260 25 S290 80 300 85" fill="none" stroke="#ea7444" stroke-width="2"/><path d="M0 95 C45 95 55 35 95 35 S145 95 185 95 S235 40 265 40" fill="none" stroke="#8e877c" stroke-width="1.5" stroke-dasharray="4 4"/></svg></div><div class="txt"><div class="meta">05 · Own</div><h3>Mixed-Reality Rowing Game</h3><p>Two people, an Arduino and two real oars.</p><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="card big" style="grid-column:span 12;display:grid;grid-template-columns:1fr 1fr"><div class="shot" style="background-image:url(/files/vr-classroom.jpg);height:auto;min-height:180px;border-bottom:0;border-right:1px solid var(--line)"></div><div class="txt"><div class="meta">06 · School</div><h3>VR-Classroom</h3><p>A prototype website where you can register yourself, and then create + sign up for classes.</p><div class="chips"><span class="chip">javascript</span><span class="chip">node.js</span></div><div class="ft"><span class="open">Read the full story →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<div class="mini sm"><div class="th" style="background-image:url(/files/chess-swap.jpg)"></div><div><h4>Chessswap</h4><div class="meta">Own · react</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini sm"><div class="th" style="background-image:url(/files/budget.jpg)"></div><div><h4>Monthly Expenses</h4><div class="meta">Own · javascript</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini sm"><div class="th" style="background-image:url(/files/lol-winrate-discord-bot.jpg)"></div><div><h4>LoL Winrate Bot</h4><div class="meta">Own · node.js</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini sm"><div class="th" style="background-image:url(/files/censor-simulator.jpg)"></div><div><h4>Censor Simulator</h4><div class="meta">School · unity, VR</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini sm"><div class="th" style="background-image:url(/files/breakout-python.jpg)"></div><div><h4>BreakoutPython</h4><div class="meta">School · python</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini sm"><div class="th" style="background-image:url(/files/tribute-to-bourlaug.jpg)"></div><div><h4>Tribute to Borlaugh</h4><div class="meta">Own · html, css</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini sm"><div class="th" style="background-image:url(/files/portfolio.jpg)"></div><div><h4>Portfolio (2021)</h4><div class="meta">Own · react</div></div><span class="mk"><i></i></span></div>
|
||||
</div>
|
||||
|
||||
<div class="sh"><h2>Record</h2><span class="n">competitions and learning</span></div>
|
||||
<div class="rec">
|
||||
<div class="box"><h3>Competitions & recognition</h3>
|
||||
<div class="led">
|
||||
<div class="y">2024</div><div>De Danske Cybermesterskaber</div><div class="res">regional participant</div>
|
||||
<div class="y">2021</div><div>Google Hash Code</div><div class="res">top 12%</div>
|
||||
<div class="y">2019</div><div>AUHackathon</div><div class="res win">winner · best AUHack</div>
|
||||
<div class="y">—</div><div>First Lego League <span class="meta">· four years</span></div><div class="res win">won round 1, twice</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box"><h3>Keeping up · 24 courses and labs since Feb 2025</h3>
|
||||
<div class="cl">
|
||||
<div>Serverless on AWS</div><div class="c">8 · 3 labs</div>
|
||||
<div>Ansible & configuration</div><div class="c">5 · 3 labs</div>
|
||||
<div>RAG & LLM systems</div><div class="c">5 · 1 lab</div>
|
||||
<div>Security <span class="meta">· JWT, ISO 27001, honeypots</span></div><div class="c">3 · 1 lab</div>
|
||||
<div>Linux admin, Grafana, WordPress</div><div class="c">3</div>
|
||||
</div>
|
||||
<a class="more" href="#lp">All courses, searchable →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= B ================= -->
|
||||
<div class="opt-head"><span class="L">B</span><div><h1>Picked six, then a tight grid</h1><p>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.</p></div></div>
|
||||
<div class="note">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.</div>
|
||||
|
||||
<div class="cvs">
|
||||
<div><h3>LATEST WRITING</h3><div style="font-size:14px">A synthetic work post <span class="meta">03/14</span></div></div>
|
||||
<div><h3>CV IN TEN SECONDS</h3>
|
||||
<div class="kv"><span class="k">now</span><span>IT-medarbejder, IT Research Tech, Aarhus University (2025–now)</span>
|
||||
<span class="k">degree</span><span>Bachelor in Computer Science</span>
|
||||
<span class="k">won</span><span><span class="acc">AUHackathon 2019</span>, best AUHack · First Lego League, round 1 twice</span>
|
||||
<span class="k">placed</span><span>Google Hash Code 2021, top 12% · DDC 2024, regional</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sh"><h2>Picked</h2><span class="n">6 projects</span><span class="r">hover the drawings</span></div>
|
||||
<div class="gB">
|
||||
<article class="card"><div class="shot" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="txt"><div class="meta">Own</div><h3>Event Tracker for Gacha Games</h3><p>Which live service game events to prioritize.</p><div class="ft"><span class="open">Read more →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="card"><div class="draw"><span class="tag">config-editor · hover</span><div class="code"><b>services:</b><br> jellyfin: <u>✓</u><br><b>hosts:</b> [ lucwin-01, lucwin-02 ]<br><b>sync:</b> <u>✓ up to date</u></div></div><div class="txt"><div class="meta">Own</div><h3>Config</h3><p>Store, sync and manage docker-compose deployments.</p><div class="ft"><span class="open">Read more →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="card"><div class="shot" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="txt"><div class="meta">School</div><h3>Bachelor Report</h3><p>Reputation systems in peer-to-peer archival.</p><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="card"><div class="shot" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="txt"><div class="meta">School</div><h3>Student Dashboard</h3><p>A concept dashboard built on user studies.</p><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="card"><div class="draw"><span class="tag">rowing-stroke · hover</span><svg width="86%" height="100" viewBox="0 0 300 110"><path d="M0 90 C40 90 50 20 90 20 S140 90 180 90 S230 25 260 25 S290 80 300 85" fill="none" stroke="#ea7444" stroke-width="2"/></svg></div><div class="txt"><div class="meta">Own</div><h3>Mixed-Reality Rowing Game</h3><p>Two people, an Arduino and two real oars.</p><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="card"><div class="shot" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="txt"><div class="meta">School</div><h3>VR-Classroom</h3><p>Register, then create and sign up for classes.</p><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
</div>
|
||||
<div class="sh" style="margin-top:28px"><h2 style="font-size:17px">Everything else</h2><span class="n">7</span><span class="r">All · Own · School · search</span></div>
|
||||
<div class="gBs">
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/chess-swap.jpg)"></div><div><h4>Chessswap</h4><div class="meta">Own · react</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/budget.jpg)"></div><div><h4>Monthly Expenses</h4><div class="meta">Own · javascript</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/lol-winrate-discord-bot.jpg)"></div><div><h4>LoL Winrate Bot</h4><div class="meta">Own · node.js</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/censor-simulator.jpg)"></div><div><h4>Censor Simulator</h4><div class="meta">School · unity, VR</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/breakout-python.jpg)"></div><div><h4>BreakoutPython</h4><div class="meta">School · python</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/tribute-to-bourlaug.jpg)"></div><div><h4>Tribute to Borlaugh</h4><div class="meta">Own · html, css</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/portfolio.jpg)"></div><div><h4>Portfolio (2021)</h4><div class="meta">Own · react</div></div><span class="mk"><i></i></span></div>
|
||||
</div>
|
||||
|
||||
<div class="lower">
|
||||
<div class="box"><h3>Writing</h3><div style="font-size:14px;color:var(--mute)">(unchanged)</div></div>
|
||||
<div class="box"><h3>Learning · 24 since Feb 2025</h3>
|
||||
<div class="log">
|
||||
<div><span class="d">Oct 2025</span><span>Active Defense: Honeypots</span><span class="k">lab</span></div>
|
||||
<div><span class="d">Sep 2025</span><span>Grafana Concepts and Basic Configuration</span><span></span></div>
|
||||
<div><span class="d">Aug 2025</span><span>Vector Space Models and Embeddings in RAGs</span><span></span></div>
|
||||
<div><span class="d">Aug 2025</span><span>Learning the Essentials of CentOS 7 Administration</span><span class="meta">6h 43m</span></div>
|
||||
</div>
|
||||
<div class="topics"><span class="chip">serverless 8</span><span class="chip">ansible 5</span><span class="chip">rag 5</span><span class="chip">security 3</span><span class="chip">linux 1</span></div>
|
||||
<a class="more" href="#lp">All 24, searchable →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= C ================= -->
|
||||
<div class="opt-head"><span class="L">C</span><div><h1>One hero, uneven grid</h1><p>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.</p></div></div>
|
||||
<div class="note">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).</div>
|
||||
|
||||
<div class="sh"><h2>Projects</h2><span class="n">13</span><span class="r">All · Own · School · search</span></div>
|
||||
<div class="gC">
|
||||
<article class="card hero"><div class="draw"><span class="tag">drawn showcase · config-editor · hover</span><div class="code" style="font-size:13px"><b>services:</b><br> jellyfin:<br> image: <u>lscr.io/linuxserver/jellyfin</u><br> volumes: [ /srv/media:/data ]<br> immich:<br> image: <u>ghcr.io/immich-app/server</u><br><b>hosts:</b> [ lucwin-01, lucwin-02 ]<br><b>sync:</b> <u>✓ 2 hosts up to date</u></div></div><div class="txt"><div class="meta">01 · Own · personal project</div><h3>Config</h3><p>A repository to store, sync and manage docker-compose based deployment on servers.</p><div class="ft"><span class="open">Read the full story →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="card tall"><div class="shot" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="txt"><h3>Gacha Event Tracker</h3><div class="ft" style="margin-top:6px"><span class="meta">Own</span><span class="mk ai"><i></i>AI</span></div></div></article>
|
||||
<article class="card tall"><div class="draw"><span class="tag">rowing-stroke</span><svg width="80%" height="70" viewBox="0 0 300 110"><path d="M0 90 C40 90 50 20 90 20 S140 90 180 90 S230 25 260 25 S290 80 300 85" fill="none" stroke="#ea7444" stroke-width="2"/></svg></div><div class="txt"><h3>Mixed-Reality Rowing</h3><div class="ft" style="margin-top:6px"><span class="meta">Own</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="card wide"><div class="shot" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="txt"><h3>Bachelor Report</h3></div></article>
|
||||
<article class="card wide"><div class="shot" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="txt"><h3>Student Dashboard</h3></div></article>
|
||||
<article class="card wide"><div class="shot" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="txt"><h3>VR-Classroom</h3></div></article>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/chess-swap.jpg)"></div><div><h4>Chessswap</h4><div class="meta">Own · react</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/budget.jpg)"></div><div><h4>Monthly Expenses</h4><div class="meta">Own · javascript</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/lol-winrate-discord-bot.jpg)"></div><div><h4>LoL Winrate Bot</h4><div class="meta">Own · node.js</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/censor-simulator.jpg)"></div><div><h4>Censor Simulator</h4><div class="meta">School · unity, VR</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/breakout-python.jpg)"></div><div><h4>BreakoutPython</h4><div class="meta">School · python</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/tribute-to-bourlaug.jpg)"></div><div><h4>Tribute to Borlaugh</h4><div class="meta">Own · html, css</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="mini"><div class="th" style="background-image:url(/files/portfolio.jpg)"></div><div><h4>Portfolio (2021)</h4><div class="meta">Own · react</div></div><span class="mk"><i></i></span></div>
|
||||
</div>
|
||||
|
||||
<div class="track">
|
||||
<div class="lab">Competitions<span>Things I entered, and how it went.</span></div>
|
||||
<div class="row"><div class="medals">
|
||||
<div class="medal"><div class="y">2019</div><div class="t">AUHackathon</div><div class="r win">winner · best AUHack</div></div>
|
||||
<div class="medal"><div class="y">four years</div><div class="t">First Lego League</div><div class="r win">won round 1, twice</div></div>
|
||||
<div class="medal"><div class="y">2021</div><div class="t">Google Hash Code</div><div class="r">top 12%</div></div>
|
||||
<div class="medal"><div class="y">2024</div><div class="t">De Danske Cybermesterskaber</div><div class="r">regional participant</div></div>
|
||||
</div></div>
|
||||
<div class="lab">Keeping up<span>24 courses and hands-on labs since February 2025. Course time only, not the digging after.</span></div>
|
||||
<div class="row"><div class="bars">
|
||||
<span>Ansible</span><div class="bar"><i style="width:100%"></i></div><span class="c">5 · ~10h</span>
|
||||
<span>Linux admin</span><div class="bar"><i style="width:67%"></i></div><span class="c">1 · 6.7h</span>
|
||||
<span>Security</span><div class="bar"><i style="width:50%"></i></div><span class="c">3 · ~5h</span>
|
||||
<span>Serverless on AWS</span><div class="bar"><i style="width:40%"></i></div><span class="c">8 · ~4h</span>
|
||||
<span>RAG & LLMs</span><div class="bar"><i style="width:18%"></i></div><span class="c">5 · ~2h</span>
|
||||
</div><a class="more" href="#lp">All courses, searchable →</a></div>
|
||||
</div>
|
||||
|
||||
<!-- ================= Learning page (shared) ================= -->
|
||||
<div class="opt-head" id="lp"><span class="L" style="background:var(--mute)">+</span><div><h1>/work/learning/: the searchable list (any option)</h1><p>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.</p></div></div>
|
||||
<div class="lp">
|
||||
<div class="top"><div class="search">Search course, provider, keyword…</div><div class="seg"><span class="on">All 24</span><span>Serverless</span><span>Ansible</span><span>RAG</span><span>Security</span><span>Linux</span><span>Other</span></div></div>
|
||||
<table>
|
||||
<tr><th>Completed</th><th>Course</th><th>Topic</th><th>Provider</th><th>Type</th><th>Time</th></tr>
|
||||
<tr><td class="m">2025-10-08</td><td>Active Defense: Honeypots</td><td class="m">security</td><td class="m">Pluralsight</td><td class="m">lab</td><td class="m">1h 30m</td></tr>
|
||||
<tr><td class="m">2025-09-07</td><td>Grafana Concepts and Basic Configuration</td><td class="m">observability</td><td class="m">Pluralsight</td><td class="m">video</td><td class="m">25m</td></tr>
|
||||
<tr><td class="m">2025-08-16</td><td>Security Compliance: ISO 27001</td><td class="m">security</td><td class="m">Pluralsight</td><td class="m">video</td><td class="m">56m</td></tr>
|
||||
<tr><td class="m">2025-08-08</td><td>Learning the Essentials of CentOS Enterprise Linux 7 Administration</td><td class="m">linux</td><td class="m">Pluralsight</td><td class="m">video · quiz ✓</td><td class="m">6h 43m</td></tr>
|
||||
<tr><td class="m">2025-05-29</td><td>JWT Fundamentals</td><td class="m">security</td><td class="m">Pluralsight</td><td class="m">video</td><td class="m">2h 29m</td></tr>
|
||||
<tr><td class="m" colspan="6" style="color:var(--mute)">… 19 more</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pick">
|
||||
<button data-choice="a" onclick="pick(this)">A · one grid, three sizes</button>
|
||||
<button data-choice="b" onclick="pick(this)">B · picked six + tight grid</button>
|
||||
<button data-choice="c" onclick="pick(this)">C · hero + uneven grid</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>function pick(b){document.querySelectorAll('.pick button').forEach(x=>x.classList.remove('selected'));b.classList.add('selected');if(window.brainstorm&&brainstorm.send)brainstorm.send({type:'click',choice:b.dataset.choice,text:b.textContent});else if(window.toggleSelect)toggleSelect(b);}</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,210 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Work page: round 2</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#131210;--panel:#1b1916;--panel2:#221f1b;--line:#2b2824;--ink:#ede7dd;--mute:#8e877c;--acc:#ea7444;--ok:#a8b48f;
|
||||
--sans:'Geist',system-ui,sans-serif;--mono:'Geist Mono',ui-monospace,monospace;--e:cubic-bezier(.2,.8,.2,1)}
|
||||
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 var(--sans)}
|
||||
.wrap{max-width:1180px;margin:0 auto;padding:0 32px}
|
||||
.opt-head{border-top:1px dashed #3a352f;margin-top:80px;padding:28px 0 8px;display:flex;gap:18px;align-items:baseline}
|
||||
.opt-head.first{margin-top:24px;border-top:0}
|
||||
.opt-head .L{font:600 13px var(--mono);color:var(--bg);background:var(--acc);border-radius:4px;padding:2px 8px}
|
||||
.opt-head .L.g{background:var(--mute)}
|
||||
.opt-head h1{font-size:22px;margin:0;font-weight:600}.opt-head p{margin:4px 0 0;color:var(--mute);max-width:720px}
|
||||
.note{font:12px/1.55 var(--mono);color:var(--mute);border-left:2px solid var(--acc);padding:2px 0 2px 12px;margin:12px 0 22px;max-width:860px}
|
||||
.sh{display:flex;align-items:baseline;gap:12px;margin:34px 0 16px}
|
||||
.sh h2{font-size:22px;margin:0;font-weight:600}.sh .n{font:12px var(--mono);color:var(--mute)}
|
||||
.sh .r{margin-left:auto;display:flex;gap:10px;align-items:center;font:12px var(--mono);color:var(--mute)}
|
||||
.seg{display:flex;gap:2px}.seg span{font:12.5px var(--sans);color:var(--mute);padding:4px 9px;border-radius:5px}.seg span.on{background:var(--panel2);color:var(--ink);border:1px solid var(--line)}
|
||||
.search{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:6px 10px;font:13px var(--sans);color:var(--mute);min-width:240px}
|
||||
.search b{color:var(--ink);font-weight:400}
|
||||
.meta{font:11px var(--mono);color:var(--mute);letter-spacing:.02em}
|
||||
.ph{border-bottom:1px dotted var(--mute);cursor:help}
|
||||
.mk{display:inline-flex;align-items:center;gap:6px;font:11px var(--mono);border:1px solid var(--line);border-radius:99px;padding:2px 8px;white-space:nowrap}
|
||||
.mk i{width:8px;height:8px;border-radius:50%;border:1.5px solid currentColor;display:inline-block}
|
||||
.mk.ai{color:var(--acc);border-color:#6b3a26}.mk.ai i{background:var(--acc);border-color:var(--acc)}
|
||||
.mk.un i{border-style:dashed}
|
||||
.chip{font:11px var(--mono);color:var(--mute);border:1px solid var(--line);border-radius:4px;padding:1px 6px}
|
||||
/* tiles */
|
||||
.t{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;min-height:0;transition:border-color .2s var(--e)}
|
||||
.t:hover{border-color:#4a433b}
|
||||
.t .vis{flex:1;min-height:0;background:#0e0d0c center top/cover no-repeat;border-bottom:1px solid var(--line);position:relative}
|
||||
.t .tx{padding:12px 16px 14px}
|
||||
.t h3{margin:3px 0 2px;font-size:17px;font-weight:600;line-height:1.25}
|
||||
.t p{margin:4px 0 0;color:#c9c2b6;font-size:13.5px;line-height:1.45}
|
||||
.t .ft{display:flex;justify-content:space-between;align-items:center;margin-top:10px}
|
||||
.t .open{font:12px var(--mono);color:var(--acc)}
|
||||
.t.L h3{font-size:22px}
|
||||
.tag{position:absolute;top:10px;left:12px;font:10px var(--mono);color:var(--mute)}
|
||||
.draw{background:var(--panel2)!important;display:flex;align-items:center;justify-content:center}
|
||||
.code{font:12px/1.7 var(--mono);color:#b9b2a6;padding:30px 24px;width:100%}.code b{color:var(--acc);font-weight:400}.code u{color:var(--ok);text-decoration:none}
|
||||
/* small tile: same footprint as today's list row */
|
||||
.s{flex-direction:row;align-items:center;gap:12px;padding:0 12px}
|
||||
.s .th{width:60px;height:40px;border-radius:4px;background:#0e0d0c center/cover;flex:none;border:1px solid var(--line)}
|
||||
.s h4{margin:0;font-size:14px;font-weight:500;line-height:1.3}
|
||||
.s .mk{margin-left:auto;padding:1px 6px}
|
||||
/* other-work tiles */
|
||||
.ow .vis{background:repeating-linear-gradient(135deg,#1d1b18 0 10px,#1a1815 10px 20px)!important;display:flex;align-items:center;justify-content:center}
|
||||
.site{font:500 15px var(--mono);color:var(--ink);border:1px solid var(--line);background:var(--bg);border-radius:6px;padding:8px 14px}
|
||||
.site small{display:block;font:10px var(--mono);color:var(--mute);margin-top:2px}
|
||||
.badge{font:10px var(--mono);color:var(--ok);border:1px solid #3d4433;border-radius:4px;padding:0 5px}
|
||||
.badge.gone{color:var(--mute);border-color:var(--line)}
|
||||
/* ---------- D : woven pattern ---------- */
|
||||
.gD{display:grid;grid-template-columns:repeat(12,1fr);grid-auto-rows:62px;gap:12px}
|
||||
/* ---------- E : dense auto-pack ---------- */
|
||||
.gE{display:grid;grid-template-columns:repeat(12,1fr);grid-auto-rows:62px;gap:12px;grid-auto-flow:row dense}
|
||||
.gE .L{grid-column:span 7;grid-row:span 6}
|
||||
.gE .M{grid-column:span 5;grid-row:span 5}
|
||||
.gE .T{grid-column:span 4;grid-row:span 6}
|
||||
.gE .W{grid-column:span 5;grid-row:span 4}
|
||||
.gE .ow{grid-column:span 3;grid-row:span 3}
|
||||
.gE .s{grid-column:span 3;grid-row:span 1}
|
||||
/* ---------- record ---------- */
|
||||
.rec{display:grid;grid-template-columns:1fr 1.25fr;gap:14px}
|
||||
.box{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px 20px}
|
||||
.box h3{margin:0 0 4px;font:500 11px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
|
||||
.box .sum{font-size:14px;color:#c9c2b6;margin:0 0 12px}
|
||||
.box .sum b{color:var(--ink);font-weight:500}
|
||||
.led{display:grid;grid-template-columns:44px 1fr auto;gap:0 14px;font-size:14px}
|
||||
.led>div{padding:8px 0;border-top:1px solid var(--line)}
|
||||
.led .y{font:12px var(--mono);color:var(--mute)}.led .res{font:12px var(--mono);color:var(--mute);text-align:right}
|
||||
.led .hi{color:var(--acc)}
|
||||
.ku{display:grid;grid-template-columns:1fr auto auto auto;gap:0 18px;font-size:14px;align-items:center}
|
||||
.ku>div{padding:9px 0;border-top:1px solid var(--line)}
|
||||
.ku .h{font:500 10px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;border-top:0;padding-top:0}
|
||||
.ku .c{font:12px var(--mono);color:var(--mute);text-align:right;white-space:nowrap}
|
||||
.cert{display:inline-flex;align-items:center;gap:5px;font:12px var(--mono);color:var(--ink);white-space:nowrap}
|
||||
.cert svg{flex:none}
|
||||
.ku .c.cer{color:var(--ink)}
|
||||
.more{display:inline-block;margin-top:14px;font:12px var(--mono);color:var(--acc)}
|
||||
/* search results */
|
||||
.res-wrap{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px 20px}
|
||||
.rgrid{display:grid;grid-template-columns:1.1fr 1fr;gap:18px}
|
||||
.rh{font:500 10.5px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;margin:0 0 8px}
|
||||
.rl>div{display:grid;grid-template-columns:1fr auto;gap:10px;padding:8px 0;border-top:1px solid var(--line);font-size:14px}
|
||||
.rl .m{font:11.5px var(--mono);color:var(--mute);white-space:nowrap}
|
||||
mark{background:none;color:var(--acc)}
|
||||
/* other work in history */
|
||||
.hist{display:grid;grid-template-columns:90px 1fr auto;gap:0 16px;font-size:14px}
|
||||
.hist>div{padding:10px 0;border-top:1px solid var(--line)}.hist .k{font:11px var(--mono);color:var(--mute);padding-top:12px}
|
||||
.pick{display:flex;gap:10px;margin:26px 0 70px}
|
||||
.pick button{font:13px var(--mono);background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:8px 14px;cursor:pointer}
|
||||
.pick button.selected,.pick button:hover{border-color:var(--acc);color:var(--acc)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<svg width="0" height="0" style="position:absolute"><defs><symbol id="seal" viewBox="0 0 16 16"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></symbol></defs></svg>
|
||||
|
||||
<p class="meta" style="margin-top:28px">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.</p>
|
||||
|
||||
<!-- ================= D ================= -->
|
||||
<div class="opt-head first"><span class="L">D</span><div><h1>Woven grid</h1><p>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.</p></div></div>
|
||||
<div class="note">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.</div>
|
||||
|
||||
<div class="sh"><h2>Projects</h2><span class="n">15 · 6 picked</span><span class="r"><span class="seg"><span class="on">All</span><span>Own</span><span>School</span><span>For others</span></span><span class="search">Search projects & certificates</span></span></div>
|
||||
<div class="gD">
|
||||
<article class="t L" style="grid-column:1/8;grid-row:1/7"><div class="vis draw"><span class="tag">drawn showcase · config-editor · hover</span><div class="code"><b>services:</b><br> jellyfin:<br> image: <u>lscr.io/linuxserver/jellyfin</u><br> volumes: [ /srv/media:/data ]<br><b>hosts:</b> [ lucwin-01, lucwin-02 ]<br><b>sync:</b> <u>✓ 2 hosts up to date</u></div></div><div class="tx"><div class="meta">Own · personal project</div><h3>Config</h3><p>A repository to store, sync and manage docker-compose based deployment on servers.</p><div class="ft"><span class="open">Read the full story →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="t M" style="grid-column:8/13;grid-row:1/5"><div class="vis" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="tx"><div class="meta">Own</div><h3>Event Tracker for Gacha Games</h3><div class="ft"><span class="open">Read more →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<div class="t s" style="grid-column:8/13;grid-row:5/6"><div class="th" style="background-image:url(/files/chess-swap.jpg)"></div><div><h4>Chessswap</h4><div class="meta">Own · react</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s" style="grid-column:8/13;grid-row:6/7"><div class="th" style="background-image:url(/files/budget.jpg)"></div><div><h4>Monthly Expenses Tracker</h4><div class="meta">Own · javascript</div></div><span class="mk"><i></i></span></div>
|
||||
|
||||
<article class="t T" style="grid-column:1/5;grid-row:7/13"><div class="vis draw"><span class="tag">rowing-stroke · hover</span><svg width="84%" height="120" viewBox="0 0 300 110"><path d="M0 90 C40 90 50 20 90 20 S140 90 180 90 S230 25 260 25 S290 80 300 85" fill="none" stroke="#ea7444" stroke-width="2"/><path d="M0 95 C45 95 55 35 95 35 S145 95 185 95 S235 40 265 40" fill="none" stroke="#8e877c" stroke-width="1.5" stroke-dasharray="4 4"/></svg></div><div class="tx"><div class="meta">Own</div><h3>Mixed-Reality Rowing Game</h3><p>Two people, an Arduino and two real oars.</p><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="t M" style="grid-column:5/9;grid-row:7/11"><div class="vis" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="tx"><div class="meta">School · thesis</div><h3>Bachelor Report</h3><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="t ow" style="grid-column:9/13;grid-row:7/10"><div class="vis"><div class="site">art-bubble.dk<small>IT & website</small></div></div><div class="tx" style="padding-top:10px"><div class="meta">For others · <span class="ph" title="placeholder">years?</span></div><h3>Art Bubble</h3><div class="ft" style="margin-top:4px"><span class="badge">ongoing</span><span class="mk un"><i></i>not recorded</span></div></div></article>
|
||||
<div class="t s ow" style="grid-column:9/13;grid-row:10/11"><div class="th" style="background:repeating-linear-gradient(135deg,#1d1b18 0 6px,#1a1815 6px 12px)"></div><div><h4>Lystrup Genbrugser</h4><div class="meta">For others · site retired</div></div><span class="mk un"><i></i></span></div>
|
||||
<div class="t s" style="grid-column:5/9;grid-row:11/12"><div class="th" style="background-image:url(/files/lol-winrate-discord-bot.jpg)"></div><div><h4>LoL Winrate Discord Bot</h4><div class="meta">Own · node.js</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s" style="grid-column:9/13;grid-row:11/12"><div class="th" style="background-image:url(/files/censor-simulator.jpg)"></div><div><h4>Censor Simulator</h4><div class="meta">School · unity, VR</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s" style="grid-column:5/9;grid-row:12/13"><div class="th" style="background-image:url(/files/breakout-python.jpg)"></div><div><h4>BreakoutPython</h4><div class="meta">School · python</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s" style="grid-column:9/13;grid-row:12/13"><div class="th" style="background-image:url(/files/tribute-to-bourlaug.jpg)"></div><div><h4>Tribute to Norman Borlaugh</h4><div class="meta">Own · html, css</div></div><span class="mk"><i></i></span></div>
|
||||
|
||||
<article class="t W" style="grid-column:1/7;grid-row:13/18"><div class="vis" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="tx"><div class="meta">School</div><h3>Student Dashboard</h3><p>A concept student dashboard web-app, built upon conducted user studies.</p></div></article>
|
||||
<article class="t W" style="grid-column:7/13;grid-row:13/17"><div class="vis" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="tx"><div class="meta">School</div><h3>VR-Classroom</h3></div></article>
|
||||
<div class="t s" style="grid-column:7/13;grid-row:17/18"><div class="th" style="background-image:url(/files/portfolio.jpg)"></div><div><h4>Portfolio (2021)</h4><div class="meta">Own · react</div></div><span class="mk"><i></i></span></div>
|
||||
</div>
|
||||
|
||||
<!-- ================= E ================= -->
|
||||
<div class="opt-head"><span class="L">E</span><div><h1>Packed by size</h1><p>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.</p></div></div>
|
||||
<div class="note">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.</div>
|
||||
<div class="sh"><h2>Projects</h2><span class="n">15 · 6 picked</span><span class="r"><span class="seg"><span class="on">All</span><span>Own</span><span>School</span><span>For others</span></span><span class="search">Search projects & certificates</span></span></div>
|
||||
<div class="gE">
|
||||
<article class="t L"><div class="vis draw"><span class="tag">config-editor · hover</span><div class="code"><b>services:</b><br> jellyfin: <u>✓</u><br><b>hosts:</b> [ lucwin-01, lucwin-02 ]<br><b>sync:</b> <u>✓ 2 hosts up to date</u></div></div><div class="tx"><div class="meta">Own</div><h3>Config</h3><p>Store, sync and manage docker-compose based deployment on servers.</p><div class="ft"><span class="open">Read the full story →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="t M"><div class="vis" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="tx"><div class="meta">Own</div><h3>Event Tracker for Gacha Games</h3><div class="ft"><span class="open">Read more →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="t W"><div class="vis" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="tx"><div class="meta">School · thesis</div><h3>Bachelor Report</h3></div></article>
|
||||
<article class="t T"><div class="vis draw"><span class="tag">rowing-stroke</span><svg width="80%" height="100" viewBox="0 0 300 110"><path d="M0 90 C40 90 50 20 90 20 S140 90 180 90 S230 25 260 25 S290 80 300 85" fill="none" stroke="#ea7444" stroke-width="2"/></svg></div><div class="tx"><div class="meta">Own</div><h3>Mixed-Reality Rowing Game</h3><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="t M"><div class="vis" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="tx"><div class="meta">School</div><h3>Student Dashboard</h3></div></article>
|
||||
<article class="t ow"><div class="vis"><div class="site">art-bubble.dk<small>IT & website</small></div></div><div class="tx" style="padding-top:10px"><div class="meta">For others</div><h3>Art Bubble</h3></div></article>
|
||||
<article class="t W"><div class="vis" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="tx"><div class="meta">School</div><h3>VR-Classroom</h3></div></article>
|
||||
<div class="t s ow"><div class="th" style="background:repeating-linear-gradient(135deg,#1d1b18 0 6px,#1a1815 6px 12px)"></div><div><h4>Lystrup Genbrugser</h4><div class="meta">For others · site retired</div></div><span class="mk un"><i></i></span></div>
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/chess-swap.jpg)"></div><div><h4>Chessswap</h4><div class="meta">Own · react</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/budget.jpg)"></div><div><h4>Monthly Expenses Tracker</h4><div class="meta">Own · javascript</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/lol-winrate-discord-bot.jpg)"></div><div><h4>LoL Winrate Discord Bot</h4><div class="meta">Own · node.js</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/censor-simulator.jpg)"></div><div><h4>Censor Simulator</h4><div class="meta">School · unity, VR</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/breakout-python.jpg)"></div><div><h4>BreakoutPython</h4><div class="meta">School · python</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/tribute-to-bourlaug.jpg)"></div><div><h4>Tribute to Norman Borlaugh</h4><div class="meta">Own · html, css</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/portfolio.jpg)"></div><div><h4>Portfolio (2021)</h4><div class="meta">Own · react</div></div><span class="mk"><i></i></span></div>
|
||||
</div>
|
||||
|
||||
<!-- ================= Record ================= -->
|
||||
<div class="opt-head"><span class="L g">+</span><div><h1>Record: competitions and keeping up (either grid)</h1><p>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.</p></div></div>
|
||||
<div class="rec">
|
||||
<div class="box"><h3>Competitions & recognition</h3><p class="sum">4 entries · <span style="color:var(--acc)">orange</span> = flagged as a highlight</p>
|
||||
<div class="led">
|
||||
<div class="y">2024</div><div>De Danske Cybermesterskaber</div><div class="res">regional participant</div>
|
||||
<div class="y">2021</div><div>Google Hash Code</div><div class="res hi">top 12%</div>
|
||||
<div class="y">2019</div><div class="hi">AUHackathon</div><div class="res hi">winner · best AUHack</div>
|
||||
<div class="y"><span class="ph" title="placeholder">yrs?</span></div><div>First Lego League <span class="meta">· four years</span></div><div class="res hi">won round 1, two years running</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box"><h3>Keeping up</h3><p class="sum"><span class="cert" style="font:500 14px var(--sans)"><svg width="15" height="15" style="color:var(--acc)"><use href="#seal"/></svg><b>18 certificates</b></span> from 23 courses and hands-on labs · <b>~27 hours</b> of course time since Feb 2025</p>
|
||||
<div class="ku">
|
||||
<div class="h">topic</div><div class="h" style="text-align:right">done</div><div class="h" style="text-align:right">certificates</div><div class="h" style="text-align:right">time</div>
|
||||
<div>Ansible & configuration</div><div class="c">2 courses · 3 labs</div><div class="c cer"><span class="cert"><svg width="13" height="13"><use href="#seal"/></svg>5</span></div><div class="c">9.4h</div>
|
||||
<div>Linux administration</div><div class="c">1 course</div><div class="c cer"><span class="cert"><svg width="13" height="13"><use href="#seal"/></svg>1</span></div><div class="c">6.7h</div>
|
||||
<div>Security <span class="meta">· JWT, ISO 27001, honeypots</span></div><div class="c">2 courses · 1 lab</div><div class="c cer"><span class="cert"><svg width="13" height="13"><use href="#seal"/></svg>2</span></div><div class="c">4.9h</div>
|
||||
<div>Serverless on AWS</div><div class="c">5 courses · 3 labs</div><div class="c cer"><span class="cert"><svg width="13" height="13"><use href="#seal"/></svg>5</span></div><div class="c">3.9h</div>
|
||||
<div>RAG & LLM systems</div><div class="c">4 courses · 1 lab</div><div class="c cer"><span class="cert"><svg width="13" height="13"><use href="#seal"/></svg>4</span></div><div class="c">1.8h</div>
|
||||
<div>Observability <span class="meta">· Grafana</span></div><div class="c">1 course</div><div class="c cer"><span class="cert"><svg width="13" height="13"><use href="#seal"/></svg>1</span></div><div class="c">0.4h</div>
|
||||
</div>
|
||||
<a class="more">All courses and certificates, searchable →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= Search ================= -->
|
||||
<div class="opt-head"><span class="L g">+</span><div><h1>Searching the work page finds certificates too</h1><p>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/.</p></div></div>
|
||||
<div class="sh" style="margin-top:10px"><h2>Projects</h2><span class="n">1 project · 5 certificates match</span><span class="r"><span class="search"><b>ansible</b></span></span></div>
|
||||
<div class="res-wrap">
|
||||
<div class="rgrid">
|
||||
<div><p class="rh">Projects</p><div class="rl"><div><span><b>Config</b> · stores, syncs and manages docker-compose deployments <span class="chip"><mark>ansible</mark></span></span><span class="mk ai"><i></i>AI</span></div></div></div>
|
||||
<div><p class="rh">Certificates & labs</p><div class="rl">
|
||||
<div><span><span class="cert"><svg width="13" height="13" style="color:var(--acc)"><use href="#seal"/></svg></span> <mark>Ansible</mark> Fundamentals</span><span class="m">Pluralsight · Apr 2025 · 3.7h</span></div>
|
||||
<div><span><span class="cert"><svg width="13" height="13" style="color:var(--acc)"><use href="#seal"/></svg></span> Troubleshoot <mark>Ansible</mark> Connectivity <span class="meta">lab</span></span><span class="m">Mar 2025 · 1.2h</span></div>
|
||||
<div><span><span class="cert"><svg width="13" height="13" style="color:var(--acc)"><use href="#seal"/></svg></span> Build a Production Ready <mark>Ansible</mark> Playbook <span class="meta">lab</span></span><span class="m">Mar 2025 · 1h</span></div>
|
||||
<div><span class="meta">+ 2 more →</span><span></span></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= Other work in history ================= -->
|
||||
<div class="opt-head"><span class="L g">+</span><div><h1>Other work also in "Where I've worked"</h1><p>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).</p></div></div>
|
||||
<div class="box" style="max-width:640px"><h3>Where I've worked</h3>
|
||||
<div class="hist">
|
||||
<div class="k">now</div><div>IT-medarbejder, IT Research Tech, Aarhus University (2025–now)</div><div></div>
|
||||
<div class="k">on the side</div><div>IT and website, <u style="text-decoration-color:var(--acc)">Art Bubble</u> (<span class="ph" title="placeholder">years?</span>)</div><div class="badge" style="align-self:center">ongoing</div>
|
||||
<div class="k"></div><div>IT and website, Lystrup Genbrugser (<span class="ph" title="placeholder">years?</span>)</div><div class="badge gone" style="align-self:center">site retired</div>
|
||||
<div class="k">before</div><div>Intern, ScienceAtHome (2019)</div><div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pick">
|
||||
<button data-choice="d" onclick="pick(this)">D · woven grid</button>
|
||||
<button data-choice="e" onclick="pick(this)">E · packed by size</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>function pick(b){document.querySelectorAll('.pick button').forEach(x=>x.classList.remove('selected'));b.classList.add('selected');if(window.brainstorm&&brainstorm.send)brainstorm.send({type:'click',choice:b.dataset.choice,text:b.textContent});}</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,256 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Work page: round 3</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
|
||||
:root{--bg:#131210;--panel:#1b1916;--panel2:#221f1b;--line:#2b2824;--ink:#ede7dd;--mute:#8e877c;--acc:#ea7444;--ok:#a8b48f;
|
||||
--sans:'Geist',system-ui,sans-serif;--mono:'Geist Mono',ui-monospace,monospace;--e:cubic-bezier(.2,.8,.2,1)}
|
||||
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 var(--sans)}
|
||||
.wrap{max-width:1180px;margin:0 auto;padding:0 32px}
|
||||
.opt-head{border-top:1px dashed #3a352f;margin-top:80px;padding:28px 0 8px;display:flex;gap:18px;align-items:baseline}
|
||||
.opt-head.first{margin-top:24px;border-top:0}
|
||||
.opt-head .L{font:600 13px var(--mono);color:var(--bg);background:var(--acc);border-radius:4px;padding:2px 8px}
|
||||
.opt-head .L.g{background:var(--mute)}
|
||||
.opt-head h1{font-size:22px;margin:0;font-weight:600}.opt-head p{margin:4px 0 0;color:var(--mute);max-width:720px}
|
||||
.note{font:12px/1.55 var(--mono);color:var(--mute);border-left:2px solid var(--acc);padding:2px 0 2px 12px;margin:12px 0 22px;max-width:860px}
|
||||
.sh{display:flex;align-items:baseline;gap:12px;margin:34px 0 16px}
|
||||
.sh h2{font-size:22px;margin:0;font-weight:600}.sh .n{font:12px var(--mono);color:var(--mute)}
|
||||
.sh .r{margin-left:auto;display:flex;gap:10px;align-items:center;font:12px var(--mono);color:var(--mute)}
|
||||
.seg{display:flex;gap:2px}.seg span{font:12.5px var(--sans);color:var(--mute);padding:4px 9px;border-radius:5px}.seg span.on{background:var(--panel2);color:var(--ink);border:1px solid var(--line)}
|
||||
.search{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:6px 10px;font:13px var(--sans);color:var(--mute);min-width:240px}
|
||||
.search b{color:var(--ink);font-weight:400}
|
||||
.meta{font:11px var(--mono);color:var(--mute);letter-spacing:.02em}
|
||||
.ph{border-bottom:1px dotted var(--mute);cursor:help}
|
||||
.mk{display:inline-flex;align-items:center;gap:6px;font:11px var(--mono);border:1px solid var(--line);border-radius:99px;padding:2px 8px;white-space:nowrap}
|
||||
.mk i{width:8px;height:8px;border-radius:50%;border:1.5px solid currentColor;display:inline-block}
|
||||
.mk.ai{color:var(--acc);border-color:#6b3a26}.mk.ai i{background:var(--acc);border-color:var(--acc)}
|
||||
.mk.un i{border-style:dashed}
|
||||
.chip{font:11px var(--mono);color:var(--mute);border:1px solid var(--line);border-radius:4px;padding:1px 6px}
|
||||
/* tiles */
|
||||
.t{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;min-height:0;transition:border-color .2s var(--e)}
|
||||
.t:hover{border-color:#4a433b}
|
||||
.t .vis{flex:1;min-height:0;background:#0e0d0c center top/cover no-repeat;border-bottom:1px solid var(--line);position:relative}
|
||||
.t .tx{padding:12px 16px 14px}
|
||||
.t h3{margin:3px 0 2px;font-size:17px;font-weight:600;line-height:1.25}
|
||||
.t p{margin:4px 0 0;color:#c9c2b6;font-size:13.5px;line-height:1.45}
|
||||
.t .ft{display:flex;justify-content:space-between;align-items:center;margin-top:10px}
|
||||
.t .open{font:12px var(--mono);color:var(--acc)}
|
||||
.t.L h3{font-size:22px}
|
||||
.tag{position:absolute;top:10px;left:12px;font:10px var(--mono);color:var(--mute)}
|
||||
.draw{background:var(--panel2)!important;display:flex;align-items:center;justify-content:center}
|
||||
.code{font:12px/1.7 var(--mono);color:#b9b2a6;padding:30px 24px;width:100%}.code b{color:var(--acc);font-weight:400}.code u{color:var(--ok);text-decoration:none}
|
||||
/* small tile: same footprint as today's list row */
|
||||
.s{flex-direction:row;align-items:center;gap:12px;padding:0 12px}
|
||||
.s .th{width:60px;height:40px;border-radius:4px;background:#0e0d0c center/cover;flex:none;border:1px solid var(--line)}
|
||||
.s h4{margin:0;font-size:14px;font-weight:500;line-height:1.3}
|
||||
.s .mk{margin-left:auto;padding:1px 6px}
|
||||
/* other-work tiles */
|
||||
.ow .vis{background:repeating-linear-gradient(135deg,#1d1b18 0 10px,#1a1815 10px 20px)!important;display:flex;align-items:center;justify-content:center}
|
||||
.site{font:500 15px var(--mono);color:var(--ink);border:1px solid var(--line);background:var(--bg);border-radius:6px;padding:8px 14px}
|
||||
.site small{display:block;font:10px var(--mono);color:var(--mute);margin-top:2px}
|
||||
.badge{font:10px var(--mono);color:var(--ok);border:1px solid #3d4433;border-radius:4px;padding:0 5px}
|
||||
.badge.gone{color:var(--mute);border-color:var(--line)}
|
||||
/* ---------- D : woven pattern ---------- */
|
||||
.gD{display:grid;grid-template-columns:repeat(12,1fr);grid-auto-rows:62px;gap:12px}
|
||||
/* ---------- E : dense auto-pack ---------- */
|
||||
.gE{display:grid;grid-template-columns:repeat(12,1fr);grid-auto-rows:62px;gap:12px;grid-auto-flow:row dense}
|
||||
.gE .L{grid-column:span 7;grid-row:span 6}
|
||||
.gE .M{grid-column:span 5;grid-row:span 5}
|
||||
.gE .T{grid-column:span 4;grid-row:span 6}
|
||||
.gE .W{grid-column:span 5;grid-row:span 4}
|
||||
.gE .ow{grid-column:span 3;grid-row:span 3}
|
||||
.gE .s{grid-column:span 3;grid-row:span 1}
|
||||
/* ---------- record ---------- */
|
||||
.rec{display:grid;grid-template-columns:1fr 1.25fr;gap:14px}
|
||||
.box{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px 20px}
|
||||
.box h3{margin:0 0 4px;font:500 11px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
|
||||
.box .sum{font-size:14px;color:#c9c2b6;margin:0 0 12px}
|
||||
.box .sum b{color:var(--ink);font-weight:500}
|
||||
.led{display:grid;grid-template-columns:44px 1fr auto;gap:0 14px;font-size:14px}
|
||||
.led>div{padding:8px 0;border-top:1px solid var(--line)}
|
||||
.led .y{font:12px var(--mono);color:var(--mute)}.led .res{font:12px var(--mono);color:var(--mute);text-align:right}
|
||||
.led .hi{color:var(--acc)}
|
||||
.ku{display:grid;grid-template-columns:1fr auto auto auto;gap:0 18px;font-size:14px;align-items:center}
|
||||
.ku>div{padding:9px 0;border-top:1px solid var(--line)}
|
||||
.ku .h{font:500 10px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;border-top:0;padding-top:0}
|
||||
.ku .c{font:12px var(--mono);color:var(--mute);text-align:right;white-space:nowrap}
|
||||
.cert{display:inline-flex;align-items:center;gap:5px;font:12px var(--mono);color:var(--ink);white-space:nowrap}
|
||||
.cert svg{flex:none}
|
||||
.ku .c.cer{color:var(--ink)}
|
||||
.more{display:inline-block;margin-top:14px;font:12px var(--mono);color:var(--acc)}
|
||||
/* search results */
|
||||
.res-wrap{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px 20px}
|
||||
.rgrid{display:grid;grid-template-columns:1.1fr 1fr;gap:18px}
|
||||
.rh{font:500 10.5px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;margin:0 0 8px}
|
||||
.rl>div{display:grid;grid-template-columns:1fr auto;gap:10px;padding:8px 0;border-top:1px solid var(--line);font-size:14px}
|
||||
.rl .m{font:11.5px var(--mono);color:var(--mute);white-space:nowrap}
|
||||
mark{background:none;color:var(--acc)}
|
||||
/* other work in history */
|
||||
.hist{display:grid;grid-template-columns:90px 1fr auto;gap:0 16px;font-size:14px}
|
||||
.hist>div{padding:10px 0;border-top:1px solid var(--line)}.hist .k{font:11px var(--mono);color:var(--mute);padding-top:12px}
|
||||
.pick{display:flex;gap:10px;margin:26px 0 70px}
|
||||
.pick button{font:13px var(--mono);background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:8px 14px;cursor:pointer}
|
||||
.pick button.selected,.pick button:hover{border-color:var(--acc);color:var(--acc)}
|
||||
|
||||
/* F: alternating bands */
|
||||
.band{display:grid;grid-template-columns:repeat(12,1fr);gap:12px;margin-bottom:12px}
|
||||
.band .big{grid-column:span 8;height:340px}
|
||||
.band .side{grid-column:span 4;display:grid;grid-template-rows:repeat(4,1fr);gap:12px;height:340px}
|
||||
.band .half{grid-column:span 6;height:300px}
|
||||
.band .side .s{height:auto}
|
||||
.slot{border:1px dashed var(--line);border-radius:10px;display:flex;align-items:center;justify-content:center;font:11px var(--mono);color:#4d4740}
|
||||
/* G: features + index column */
|
||||
.gG{display:grid;grid-template-columns:1fr 340px;gap:28px;align-items:start}
|
||||
.fl{display:flex;flex-direction:column;gap:12px}
|
||||
.fr{display:grid;grid-template-columns:260px 1fr;min-height:190px}
|
||||
.fr .vis{border-bottom:0;border-right:1px solid var(--line)}
|
||||
.fr .tx{padding:16px 18px;display:flex;flex-direction:column}
|
||||
.fr .ft{margin-top:auto}
|
||||
.idx{position:sticky;top:16px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
|
||||
.idx h3{margin:0 0 6px;font:500 11px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;display:flex;justify-content:space-between}
|
||||
.idx .it{display:grid;grid-template-columns:44px 1fr auto;gap:10px;align-items:center;padding:8px 0;border-top:1px solid var(--line)}
|
||||
.idx .th{width:44px;height:30px;border-radius:3px;background:#0e0d0c center/cover;border:1px solid var(--line)}
|
||||
.idx h4{margin:0;font-size:13.5px;font-weight:500;line-height:1.25}
|
||||
.idx .yr{font:11px var(--mono);color:var(--mute)}
|
||||
.idx .grp{font:500 10px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;padding:12px 0 2px}
|
||||
/* H: two sizes, even grid */
|
||||
.gH{display:grid;grid-template-columns:repeat(4,1fr);grid-auto-rows:150px;gap:12px;grid-auto-flow:row dense}
|
||||
.gH .P{grid-column:span 2;grid-row:span 2}
|
||||
.gH .c{grid-column:span 1;grid-row:span 1}
|
||||
.gH .c .vis{flex:1}.gH .c .tx{padding:8px 12px 10px}.gH .c h3{font-size:14px;font-weight:500;margin:0}
|
||||
.gH .c .ft{margin-top:2px}
|
||||
/* recognition v3 */
|
||||
.rec3{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.vl{display:grid;grid-template-columns:1fr auto;gap:0 14px;font-size:14px}
|
||||
.vl>div{padding:10px 0;border-top:1px solid var(--line)}
|
||||
.vl .r{font:12px var(--mono);color:var(--mute);text-align:right;white-space:nowrap}
|
||||
.vl .sub{display:block;font:12px var(--mono);color:var(--mute);margin-top:2px}
|
||||
.vl a{color:var(--ink);text-decoration:underline;text-decoration-color:var(--acc);text-underline-offset:3px}
|
||||
.k3{display:grid;grid-template-columns:1fr;gap:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<svg width="0" height="0" style="position:absolute"><defs><symbol id="seal" viewBox="0 0 16 16"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></symbol></defs></svg>
|
||||
|
||||
<p class="meta" style="margin-top:28px">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.</p>
|
||||
|
||||
<!-- ================= F ================= -->
|
||||
<div class="opt-head first"><span class="L">F</span><div><h1>Alternating bands</h1><p>A strict, repeating rhythm instead of a mosaic: <b>one big + a stack of four small</b>, then <b>two big side by side</b>, 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.</p></div></div>
|
||||
<div class="note">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.</div>
|
||||
<div class="sh"><h2>Projects</h2><span class="n">13 · 6 picked</span><span class="r"><span class="seg"><span class="on">All</span><span>Own</span><span>School</span></span><span class="search">Search projects & certificates</span></span></div>
|
||||
<div class="band">
|
||||
<article class="t big"><div class="vis draw"><span class="tag">drawn showcase · config-editor · hover</span><div class="code"><b>services:</b><br> jellyfin:<br> image: <u>lscr.io/linuxserver/jellyfin</u><br><b>hosts:</b> [ lucwin-01, lucwin-02 ]<br><b>sync:</b> <u>✓ 2 hosts up to date</u></div></div><div class="tx"><div class="meta">Own · personal project</div><h3>Config</h3><p>A repository to store, sync and manage docker-compose based deployment on servers.</p><div class="ft"><span class="open">Read the full story →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<div class="side">
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/chess-swap.jpg)"></div><div><h4>Chessswap</h4><div class="meta">Own · react</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/budget.jpg)"></div><div><h4>Monthly Expenses Tracker</h4><div class="meta">Own · javascript</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/lol-winrate-discord-bot.jpg)"></div><div><h4>LoL Winrate Discord Bot</h4><div class="meta">Own · node.js</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/censor-simulator.jpg)"></div><div><h4>Censor Simulator</h4><div class="meta">School · unity, VR</div></div><span class="mk"><i></i></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="band">
|
||||
<article class="t half"><div class="vis" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="tx"><div class="meta">Own</div><h3>Event Tracker for Gacha Games</h3><div class="ft"><span class="open">Read more →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="t half"><div class="vis draw"><span class="tag">rowing-stroke · hover</span><svg width="80%" height="110" viewBox="0 0 300 110"><path d="M0 90 C40 90 50 20 90 20 S140 90 180 90 S230 25 260 25 S290 80 300 85" fill="none" stroke="#ea7444" stroke-width="2"/><path d="M0 95 C45 95 55 35 95 35 S145 95 185 95 S235 40 265 40" fill="none" stroke="#8e877c" stroke-width="1.5" stroke-dasharray="4 4"/></svg></div><div class="tx"><div class="meta">Own</div><h3>Mixed-Reality Rowing Game</h3><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
</div>
|
||||
<div class="band">
|
||||
<div class="side">
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/breakout-python.jpg)"></div><div><h4>BreakoutPython</h4><div class="meta">School · python</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/tribute-to-bourlaug.jpg)"></div><div><h4>Tribute to Norman Borlaugh</h4><div class="meta">Own · html, css</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="t s"><div class="th" style="background-image:url(/files/portfolio.jpg)"></div><div><h4>Portfolio (2021)</h4><div class="meta">Own · react</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="slot">next small project</div>
|
||||
</div>
|
||||
<article class="t big"><div class="vis" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="tx"><div class="meta">School · thesis</div><h3>Bachelor Report</h3><p>My bachelor thesis on using reputation systems in peer-to-peer archival.</p><div class="ft"><span class="open">Read the full story →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
</div>
|
||||
<div class="band">
|
||||
<article class="t half"><div class="vis" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="tx"><div class="meta">School</div><h3>Student Dashboard</h3><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="t half"><div class="vis" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="tx"><div class="meta">School</div><h3>VR-Classroom</h3><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
</div>
|
||||
|
||||
<!-- ================= G ================= -->
|
||||
<div class="opt-head"><span class="L">G</span><div><h1>Picked down the left, index on the right</h1><p>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".</p></div></div>
|
||||
<div class="note">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.</div>
|
||||
<div class="sh"><h2>Projects</h2><span class="n">13 · 6 picked</span><span class="r"><span class="seg"><span class="on">All</span><span>Own</span><span>School</span></span><span class="search">Search projects & certificates</span></span></div>
|
||||
<div class="gG">
|
||||
<div class="fl">
|
||||
<article class="t fr"><div class="vis draw"><span class="tag">config-editor · hover</span><div class="code" style="padding:30px 16px;font-size:11px"><b>services:</b><br> jellyfin: <u>✓</u><br><b>hosts:</b> [ lucwin-01 ]<br><b>sync:</b> <u>✓ up to date</u></div></div><div class="tx"><div class="meta">01 · Own · personal project</div><h3>Config</h3><p>A repository to store, sync and manage docker-compose based deployment on servers.</p><div class="ft"><span class="open">Read the full story →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="t fr"><div class="vis" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="tx"><div class="meta">02 · Own</div><h3>Event Tracker for Gacha Games</h3><p>Used to easily figure out which live service games events to prioritize.</p><div class="ft"><span class="open">Read more →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="t fr"><div class="vis" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="tx"><div class="meta">03 · School · thesis</div><h3>Bachelor Report</h3><p>Reputation systems in peer-to-peer archival.</p><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="t fr"><div class="vis draw"><span class="tag">rowing-stroke</span><svg width="80%" height="80" viewBox="0 0 300 110"><path d="M0 90 C40 90 50 20 90 20 S140 90 180 90 S230 25 260 25 S290 80 300 85" fill="none" stroke="#ea7444" stroke-width="2"/></svg></div><div class="tx"><div class="meta">04 · Own</div><h3>Mixed-Reality Rowing Game</h3><p>A fun rowing game for two people, that uses an arduino and two real oars to be played.</p><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="t fr"><div class="vis" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="tx"><div class="meta">05 · School</div><h3>Student Dashboard</h3><p>A concept student dashboard web-app, built upon conducted user studies.</p><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="t fr"><div class="vis" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="tx"><div class="meta">06 · School</div><h3>VR-Classroom</h3><p>Register yourself, then create and sign up for classes.</p><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
</div>
|
||||
<aside class="idx"><h3><span>More projects</span><span>7</span></h3>
|
||||
<div class="grp">Own</div>
|
||||
<div class="it"><div class="th" style="background-image:url(/files/chess-swap.jpg)"></div><div><h4>Chessswap</h4><div class="yr">react</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="it"><div class="th" style="background-image:url(/files/budget.jpg)"></div><div><h4>Monthly Expenses Tracker</h4><div class="yr">javascript</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="it"><div class="th" style="background-image:url(/files/lol-winrate-discord-bot.jpg)"></div><div><h4>LoL Winrate Discord Bot</h4><div class="yr">node.js</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="it"><div class="th" style="background-image:url(/files/tribute-to-bourlaug.jpg)"></div><div><h4>Tribute to Norman Borlaugh</h4><div class="yr">html, css</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="it"><div class="th" style="background-image:url(/files/portfolio.jpg)"></div><div><h4>Portfolio (2021)</h4><div class="yr">react</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="grp">School</div>
|
||||
<div class="it"><div class="th" style="background-image:url(/files/censor-simulator.jpg)"></div><div><h4>Censor Simulator</h4><div class="yr">unity, VR</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="it"><div class="th" style="background-image:url(/files/breakout-python.jpg)"></div><div><h4>BreakoutPython</h4><div class="yr">python</div></div><span class="mk"><i></i></span></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- ================= H ================= -->
|
||||
<div class="opt-head"><span class="L">H</span><div><h1>Two sizes, one even grid</h1><p>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.</p></div></div>
|
||||
<div class="note">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.</div>
|
||||
<div class="sh"><h2>Projects</h2><span class="n">13 · 6 picked</span><span class="r"><span class="seg"><span class="on">All</span><span>Own</span><span>School</span></span><span class="search">Search projects & certificates</span></span></div>
|
||||
<div class="gH">
|
||||
<article class="t P"><div class="vis draw"><span class="tag">config-editor · hover</span><div class="code"><b>services:</b><br> jellyfin: <u>✓</u><br><b>hosts:</b> [ lucwin-01, lucwin-02 ]<br><b>sync:</b> <u>✓ 2 hosts up to date</u></div></div><div class="tx"><div class="meta">Own</div><h3>Config</h3><div class="ft"><span class="open">Read the full story →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="t P"><div class="vis" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="tx"><div class="meta">Own</div><h3>Event Tracker for Gacha Games</h3><div class="ft"><span class="open">Read more →</span><span class="mk ai"><i></i>made with AI</span></div></div></article>
|
||||
<article class="t c"><div class="vis" style="background-image:url(/files/chess-swap.jpg)"></div><div class="tx"><h3>Chessswap</h3><div class="ft"><span class="meta">Own</span><span class="mk"><i></i></span></div></div></article>
|
||||
<article class="t c"><div class="vis" style="background-image:url(/files/budget.jpg)"></div><div class="tx"><h3>Monthly Expenses</h3><div class="ft"><span class="meta">Own</span><span class="mk"><i></i></span></div></div></article>
|
||||
<article class="t P"><div class="vis" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="tx"><div class="meta">School · thesis</div><h3>Bachelor Report</h3><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="t c"><div class="vis" style="background-image:url(/files/lol-winrate-discord-bot.jpg)"></div><div class="tx"><h3>LoL Winrate Bot</h3><div class="ft"><span class="meta">Own</span><span class="mk"><i></i></span></div></div></article>
|
||||
<article class="t c"><div class="vis" style="background-image:url(/files/censor-simulator.jpg)"></div><div class="tx"><h3>Censor Simulator</h3><div class="ft"><span class="meta">School</span><span class="mk"><i></i></span></div></div></article>
|
||||
<article class="t P"><div class="vis draw"><span class="tag">rowing-stroke</span><svg width="80%" height="100" viewBox="0 0 300 110"><path d="M0 90 C40 90 50 20 90 20 S140 90 180 90 S230 25 260 25 S290 80 300 85" fill="none" stroke="#ea7444" stroke-width="2"/></svg></div><div class="tx"><div class="meta">Own</div><h3>Mixed-Reality Rowing Game</h3><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="t P"><div class="vis" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="tx"><div class="meta">School</div><h3>Student Dashboard</h3><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="t c"><div class="vis" style="background-image:url(/files/breakout-python.jpg)"></div><div class="tx"><h3>BreakoutPython</h3><div class="ft"><span class="meta">School</span><span class="mk"><i></i></span></div></div></article>
|
||||
<article class="t c"><div class="vis" style="background-image:url(/files/tribute-to-bourlaug.jpg)"></div><div class="tx"><h3>Tribute to Borlaugh</h3><div class="ft"><span class="meta">Own</span><span class="mk"><i></i></span></div></div></article>
|
||||
<article class="t P"><div class="vis" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="tx"><div class="meta">School</div><h3>VR-Classroom</h3><div class="ft"><span class="open">Read more →</span><span class="mk"><i></i>by hand</span></div></div></article>
|
||||
<article class="t c"><div class="vis" style="background-image:url(/files/portfolio.jpg)"></div><div class="tx"><h3>Portfolio (2021)</h3><div class="ft"><span class="meta">Own</span><span class="mk"><i></i></span></div></div></article>
|
||||
</div>
|
||||
|
||||
<!-- ================= Recognition & volunteering ================= -->
|
||||
<div class="opt-head"><span class="L g">+</span><div><h1>Recognition & volunteering (any grid)</h1><p>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.</p></div></div>
|
||||
<div class="rec3">
|
||||
<div class="box"><h3>Recognition & volunteering</h3>
|
||||
<div class="vl" style="margin-top:12px">
|
||||
<div style="border-top:0;padding-top:0;font:500 10px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase">Competitions</div><div style="border-top:0"></div>
|
||||
<div><span style="color:var(--acc)">AUHackathon 2019</span></div><div class="r" style="color:var(--acc)">winner · best AUHack</div>
|
||||
<div>First Lego League<span class="sub">four years running</span></div><div class="r" style="color:var(--acc)">won round 1, twice</div>
|
||||
<div>Google Hash Code 2021</div><div class="r" style="color:var(--acc)">top 12%</div>
|
||||
<div>De Danske Cybermesterskaber 2024</div><div class="r">regional participant</div>
|
||||
<div style="font:500 10px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;padding-top:18px">Volunteering</div><div></div>
|
||||
<div><a>Art Bubble</a> <span class="meta">art-bubble.dk ↗</span><span class="sub">IT and website, as a volunteer</span></div><div class="r"><span class="ph" title="placeholder">since ?</span> · ongoing</div>
|
||||
<div>Lystrup Genbrugser<span class="sub">IT and website, as a volunteer · site since retired</span></div><div class="r"><span class="ph" title="placeholder">years?</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box"><h3>Keeping up</h3><p class="sum"><span class="cert" style="font:500 14px var(--sans)"><svg width="15" height="15" style="color:var(--acc)"><use href="#seal"/></svg><b>18 certificates</b></span> from 23 courses and hands-on labs · <b>~27 hours</b> since Feb 2025</p>
|
||||
<div class="ku">
|
||||
<div class="h">topic</div><div class="h" style="text-align:right">done</div><div class="h" style="text-align:right">cert.</div><div class="h" style="text-align:right">time</div>
|
||||
<div>Ansible & configuration</div><div class="c">2 courses · 3 labs</div><div class="c cer"><span class="cert"><svg width="13" height="13"><use href="#seal"/></svg>5</span></div><div class="c">9.4h</div>
|
||||
<div>Linux administration</div><div class="c">1 course</div><div class="c cer"><span class="cert"><svg width="13" height="13"><use href="#seal"/></svg>1</span></div><div class="c">6.7h</div>
|
||||
<div>Security</div><div class="c">2 courses · 1 lab</div><div class="c cer"><span class="cert"><svg width="13" height="13"><use href="#seal"/></svg>2</span></div><div class="c">4.9h</div>
|
||||
<div>Serverless on AWS</div><div class="c">5 courses · 3 labs</div><div class="c cer"><span class="cert"><svg width="13" height="13"><use href="#seal"/></svg>5</span></div><div class="c">3.9h</div>
|
||||
<div>RAG & LLM systems</div><div class="c">4 courses · 1 lab</div><div class="c cer"><span class="cert"><svg width="13" height="13"><use href="#seal"/></svg>4</span></div><div class="c">1.8h</div>
|
||||
<div>Observability</div><div class="c">1 course</div><div class="c cer"><span class="cert"><svg width="13" height="13"><use href="#seal"/></svg>1</span></div><div class="c">0.4h</div>
|
||||
</div>
|
||||
<a class="more">All courses and certificates, searchable →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pick">
|
||||
<button data-choice="f" onclick="pick(this)">F · alternating bands</button>
|
||||
<button data-choice="g" onclick="pick(this)">G · picked + index column</button>
|
||||
<button data-choice="h" onclick="pick(this)">H · two sizes, even grid</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>function pick(b){document.querySelectorAll('.pick button').forEach(x=>x.classList.remove('selected'));b.classList.add('selected');if(window.brainstorm&&brainstorm.send)brainstorm.send({type:'click',choice:b.dataset.choice,text:b.textContent});}</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,199 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Work page: round 4</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
|
||||
:root{--bg:#131210;--panel:#1b1916;--panel2:#221f1b;--line:#2b2824;--ink:#ede7dd;--mute:#8e877c;--acc:#ea7444;--ok:#a8b48f;
|
||||
--sans:'Geist',system-ui,sans-serif;--mono:'Geist Mono',ui-monospace,monospace;--e:cubic-bezier(.2,.8,.2,1)}
|
||||
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 var(--sans)}
|
||||
.wrap{max-width:1180px;margin:0 auto;padding:0 32px}
|
||||
.opt-head{border-top:1px dashed #3a352f;margin-top:80px;padding:28px 0 8px;display:flex;gap:18px;align-items:baseline}
|
||||
.opt-head.first{margin-top:24px;border-top:0}
|
||||
.opt-head .L{font:600 13px var(--mono);color:var(--bg);background:var(--acc);border-radius:4px;padding:2px 8px}
|
||||
.opt-head .L.g{background:var(--mute)}
|
||||
.opt-head h1{font-size:22px;margin:0;font-weight:600}.opt-head p{margin:4px 0 0;color:var(--mute);max-width:720px}
|
||||
.note{font:12px/1.55 var(--mono);color:var(--mute);border-left:2px solid var(--acc);padding:2px 0 2px 12px;margin:12px 0 22px;max-width:860px}
|
||||
.sh{display:flex;align-items:baseline;gap:12px;margin:34px 0 16px}
|
||||
.sh h2{font-size:22px;margin:0;font-weight:600}.sh .n{font:12px var(--mono);color:var(--mute)}
|
||||
.sh .r{margin-left:auto;display:flex;gap:10px;align-items:center;font:12px var(--mono);color:var(--mute)}
|
||||
.seg{display:flex;gap:2px}.seg span{font:12.5px var(--sans);color:var(--mute);padding:4px 9px;border-radius:5px}.seg span.on{background:var(--panel2);color:var(--ink);border:1px solid var(--line)}
|
||||
.search{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:6px 10px;font:13px var(--sans);color:var(--mute);min-width:240px}
|
||||
.search b{color:var(--ink);font-weight:400}
|
||||
.meta{font:11px var(--mono);color:var(--mute);letter-spacing:.02em}
|
||||
.ph{border-bottom:1px dotted var(--mute);cursor:help}
|
||||
.mk{display:inline-flex;align-items:center;gap:6px;font:11px var(--mono);border:1px solid var(--line);border-radius:99px;padding:2px 8px;white-space:nowrap}
|
||||
.mk i{width:8px;height:8px;border-radius:50%;border:1.5px solid currentColor;display:inline-block}
|
||||
.mk.ai{color:var(--acc);border-color:#6b3a26}.mk.ai i{background:var(--acc);border-color:var(--acc)}
|
||||
.mk.un i{border-style:dashed}
|
||||
.chip{font:11px var(--mono);color:var(--mute);border:1px solid var(--line);border-radius:4px;padding:1px 6px}
|
||||
/* tiles */
|
||||
.t{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;min-height:0;transition:border-color .2s var(--e)}
|
||||
.t:hover{border-color:#4a433b}
|
||||
.t .vis{flex:1;min-height:0;background:#0e0d0c center top/cover no-repeat;border-bottom:1px solid var(--line);position:relative}
|
||||
.t .tx{padding:12px 16px 14px}
|
||||
.t h3{margin:3px 0 2px;font-size:17px;font-weight:600;line-height:1.25}
|
||||
.t p{margin:4px 0 0;color:#c9c2b6;font-size:13.5px;line-height:1.45}
|
||||
.t .ft{display:flex;justify-content:space-between;align-items:center;margin-top:10px}
|
||||
.t .open{font:12px var(--mono);color:var(--acc)}
|
||||
.t.L h3{font-size:22px}
|
||||
.tag{position:absolute;top:10px;left:12px;font:10px var(--mono);color:var(--mute)}
|
||||
.draw{background:var(--panel2)!important;display:flex;align-items:center;justify-content:center}
|
||||
.code{font:12px/1.7 var(--mono);color:#b9b2a6;padding:30px 24px;width:100%}.code b{color:var(--acc);font-weight:400}.code u{color:var(--ok);text-decoration:none}
|
||||
/* small tile: same footprint as today's list row */
|
||||
.s{flex-direction:row;align-items:center;gap:12px;padding:0 12px}
|
||||
.s .th{width:60px;height:40px;border-radius:4px;background:#0e0d0c center/cover;flex:none;border:1px solid var(--line)}
|
||||
.s h4{margin:0;font-size:14px;font-weight:500;line-height:1.3}
|
||||
.s .mk{margin-left:auto;padding:1px 6px}
|
||||
/* other-work tiles */
|
||||
.ow .vis{background:repeating-linear-gradient(135deg,#1d1b18 0 10px,#1a1815 10px 20px)!important;display:flex;align-items:center;justify-content:center}
|
||||
.site{font:500 15px var(--mono);color:var(--ink);border:1px solid var(--line);background:var(--bg);border-radius:6px;padding:8px 14px}
|
||||
.site small{display:block;font:10px var(--mono);color:var(--mute);margin-top:2px}
|
||||
.badge{font:10px var(--mono);color:var(--ok);border:1px solid #3d4433;border-radius:4px;padding:0 5px}
|
||||
.badge.gone{color:var(--mute);border-color:var(--line)}
|
||||
/* ---------- D : woven pattern ---------- */
|
||||
.gD{display:grid;grid-template-columns:repeat(12,1fr);grid-auto-rows:62px;gap:12px}
|
||||
/* ---------- E : dense auto-pack ---------- */
|
||||
.gE{display:grid;grid-template-columns:repeat(12,1fr);grid-auto-rows:62px;gap:12px;grid-auto-flow:row dense}
|
||||
.gE .L{grid-column:span 7;grid-row:span 6}
|
||||
.gE .M{grid-column:span 5;grid-row:span 5}
|
||||
.gE .T{grid-column:span 4;grid-row:span 6}
|
||||
.gE .W{grid-column:span 5;grid-row:span 4}
|
||||
.gE .ow{grid-column:span 3;grid-row:span 3}
|
||||
.gE .s{grid-column:span 3;grid-row:span 1}
|
||||
/* ---------- record ---------- */
|
||||
.rec{display:grid;grid-template-columns:1fr 1.25fr;gap:14px}
|
||||
.box{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px 20px}
|
||||
.box h3{margin:0 0 4px;font:500 11px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
|
||||
.box .sum{font-size:14px;color:#c9c2b6;margin:0 0 12px}
|
||||
.box .sum b{color:var(--ink);font-weight:500}
|
||||
.led{display:grid;grid-template-columns:44px 1fr auto;gap:0 14px;font-size:14px}
|
||||
.led>div{padding:8px 0;border-top:1px solid var(--line)}
|
||||
.led .y{font:12px var(--mono);color:var(--mute)}.led .res{font:12px var(--mono);color:var(--mute);text-align:right}
|
||||
.led .hi{color:var(--acc)}
|
||||
.ku{display:grid;grid-template-columns:1fr auto auto auto;gap:0 18px;font-size:14px;align-items:center}
|
||||
.ku>div{padding:9px 0;border-top:1px solid var(--line)}
|
||||
.ku .h{font:500 10px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;border-top:0;padding-top:0}
|
||||
.ku .c{font:12px var(--mono);color:var(--mute);text-align:right;white-space:nowrap}
|
||||
.cert{display:inline-flex;align-items:center;gap:5px;font:12px var(--mono);color:var(--ink);white-space:nowrap}
|
||||
.cert svg{flex:none}
|
||||
.ku .c.cer{color:var(--ink)}
|
||||
.more{display:inline-block;margin-top:14px;font:12px var(--mono);color:var(--acc)}
|
||||
/* search results */
|
||||
.res-wrap{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px 20px}
|
||||
.rgrid{display:grid;grid-template-columns:1.1fr 1fr;gap:18px}
|
||||
.rh{font:500 10.5px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;margin:0 0 8px}
|
||||
.rl>div{display:grid;grid-template-columns:1fr auto;gap:10px;padding:8px 0;border-top:1px solid var(--line);font-size:14px}
|
||||
.rl .m{font:11.5px var(--mono);color:var(--mute);white-space:nowrap}
|
||||
mark{background:none;color:var(--acc)}
|
||||
/* other work in history */
|
||||
.hist{display:grid;grid-template-columns:90px 1fr auto;gap:0 16px;font-size:14px}
|
||||
.hist>div{padding:10px 0;border-top:1px solid var(--line)}.hist .k{font:11px var(--mono);color:var(--mute);padding-top:12px}
|
||||
.pick{display:flex;gap:10px;margin:26px 0 70px}
|
||||
.pick button{font:13px var(--mono);background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:8px 14px;cursor:pointer}
|
||||
.pick button.selected,.pick button:hover{border-color:var(--acc);color:var(--acc)}
|
||||
|
||||
.blocks{display:grid;gap:14px}
|
||||
.b2{grid-template-columns:repeat(2,1fr)}
|
||||
.b3{grid-template-columns:repeat(3,1fr)}
|
||||
/* every block is the same size */
|
||||
.blk{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;transition:border-color .2s var(--e)}
|
||||
.b2 .blk{height:372px}.b3 .blk{height:318px}
|
||||
.blk:hover{border-color:#4a433b}
|
||||
.blk .vis{flex:1;min-height:0;background:#0e0d0c center top/cover no-repeat;border-bottom:1px solid var(--line);position:relative}
|
||||
/* one image treatment: screenshots toned to the page until you point at them */
|
||||
.tone .vis.img{filter:grayscale(.85) sepia(.25) brightness(.62) contrast(1.05);transition:filter .3s var(--e)}
|
||||
.tone .blk:hover .vis.img{filter:none}
|
||||
.blk .tx{padding:14px 18px 16px}
|
||||
.blk .row1{display:flex;justify-content:space-between;align-items:center}
|
||||
.blk h3{margin:6px 0 2px;font-size:18px;font-weight:600}
|
||||
.b3 .blk h3{font-size:16px}
|
||||
.blk p{margin:0;color:#b9b2a6;font-size:13.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.num{font:11px var(--mono);color:var(--acc)}
|
||||
/* a block holding small projects at today's row size */
|
||||
.smalls{padding:6px 16px 8px;justify-content:flex-start}
|
||||
.smalls .lab{font:500 10px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;padding:10px 0 6px}
|
||||
.smalls .r{display:grid;grid-template-columns:56px 1fr auto;gap:12px;align-items:center;padding:10px 0;border-top:1px solid var(--line)}
|
||||
.smalls .th{width:56px;height:37px;border-radius:4px;background:#0e0d0c center/cover;border:1px solid var(--line)}
|
||||
.tone .smalls .th{filter:grayscale(.85) sepia(.25) brightness(.62)}
|
||||
.tone .smalls .r:hover .th{filter:none}
|
||||
.smalls h4{margin:0;font-size:14px;font-weight:500}
|
||||
.smalls .d{font:11px var(--mono);color:var(--mute)}
|
||||
.smalls .mk{padding:1px 6px}
|
||||
.b3 .smalls .r{padding:8px 0}
|
||||
.empty{border:1px dashed var(--line);background:none}
|
||||
.legend2{display:flex;gap:18px;font:11.5px var(--mono);color:var(--mute);margin:-6px 0 16px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<p class="meta" style="margin-top:28px">Round 4 · taking F and H and removing what made them busy: <b style="color:var(--ink);font-weight:500">every block is the same size</b>, small projects come in blocks of their own (still at today's row size), <b style="color:var(--ink);font-weight:500">screenshots are toned</b> to the page until you point at one, and tiles carry only number, title, one line and the AI mark.</p>
|
||||
|
||||
<!-- ================= I ================= -->
|
||||
<div class="opt-head first"><span class="L">I</span><div><h1>Equal blocks, two across</h1><p>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.</p></div></div>
|
||||
<div class="note">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.</div>
|
||||
<div class="sh"><h2>Projects</h2><span class="n">13 · 6 picked</span><span class="r"><span class="seg"><span class="on">All</span><span>Own</span><span>School</span></span><span class="search">Search projects & certificates</span></span></div>
|
||||
<div class="blocks b2 tone">
|
||||
<a class="blk"><div class="vis draw"><span class="tag">config-editor · hover</span><div class="code"><b>services:</b><br> jellyfin:<br> image: <u>lscr.io/linuxserver/jellyfin</u><br><b>hosts:</b> [ lucwin-01, lucwin-02 ]<br><b>sync:</b> <u>✓ 2 hosts up to date</u></div></div><div class="tx"><div class="row1"><span class="num">01 <span class="meta">· own</span></span><span class="mk ai"><i></i>made with AI</span></div><h3>Config</h3><p>Store, sync and manage docker-compose based deployment on servers.</p></div></a>
|
||||
<a class="blk"><div class="vis img" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="tx"><div class="row1"><span class="num">02 <span class="meta">· own</span></span><span class="mk ai"><i></i>made with AI</span></div><h3>Event Tracker for Gacha Games</h3><p>Which live service game events to prioritize.</p></div></a>
|
||||
<a class="blk"><div class="vis img" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="tx"><div class="row1"><span class="num">03 <span class="meta">· school · thesis</span></span><span class="mk"><i></i>by hand</span></div><h3>Bachelor Report</h3><p>Reputation systems in peer-to-peer archival.</p></div></a>
|
||||
<div class="blk smalls"><div class="lab">More · own</div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/chess-swap.jpg)"></div><div><h4>Chessswap</h4><div class="d">react · webapp</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/budget.jpg)"></div><div><h4>Monthly Expenses Tracker</h4><div class="d">javascript · webapp</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/lol-winrate-discord-bot.jpg)"></div><div><h4>LoL Winrate Discord Bot</h4><div class="d">node.js · bot</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/tribute-to-bourlaug.jpg)"></div><div><h4>Tribute to Norman Borlaugh</h4><div class="d">html · css</div></div><span class="mk"><i></i></span></div>
|
||||
</div>
|
||||
<a class="blk"><div class="vis draw"><span class="tag">rowing-stroke · hover</span><svg width="80%" height="120" viewBox="0 0 300 110"><path d="M0 90 C40 90 50 20 90 20 S140 90 180 90 S230 25 260 25 S290 80 300 85" fill="none" stroke="#ea7444" stroke-width="2"/><path d="M0 95 C45 95 55 35 95 35 S145 95 185 95 S235 40 265 40" fill="none" stroke="#8e877c" stroke-width="1.5" stroke-dasharray="4 4"/></svg></div><div class="tx"><div class="row1"><span class="num">04 <span class="meta">· own</span></span><span class="mk"><i></i>by hand</span></div><h3>Mixed-Reality Rowing Game</h3><p>Two people, an Arduino and two real oars.</p></div></a>
|
||||
<a class="blk"><div class="vis img" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="tx"><div class="row1"><span class="num">05 <span class="meta">· school</span></span><span class="mk"><i></i>by hand</span></div><h3>Student Dashboard</h3><p>A concept dashboard, built upon conducted user studies.</p></div></a>
|
||||
<div class="blk smalls"><div class="lab">More · school & own</div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/censor-simulator.jpg)"></div><div><h4>Censor Simulator</h4><div class="d">unity · VR · C#</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/breakout-python.jpg)"></div><div><h4>BreakoutPython</h4><div class="d">python · game</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/portfolio.jpg)"></div><div><h4>Portfolio (2021)</h4><div class="d">react · website</div></div><span class="mk"><i></i></span></div>
|
||||
</div>
|
||||
<a class="blk"><div class="vis img" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="tx"><div class="row1"><span class="num">06 <span class="meta">· school</span></span><span class="mk"><i></i>by hand</span></div><h3>VR-Classroom</h3><p>Register, then create and sign up for classes.</p></div></a>
|
||||
</div>
|
||||
|
||||
<!-- ================= J ================= -->
|
||||
<div class="opt-head"><span class="L">J</span><div><h1>Equal blocks, three across</h1><p>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.</p></div></div>
|
||||
<div class="note">The row of three makes the six read as "the main ones" at a glance.</div>
|
||||
<div class="sh"><h2>Projects</h2><span class="n">13 · 6 picked</span><span class="r"><span class="seg"><span class="on">All</span><span>Own</span><span>School</span></span><span class="search">Search projects & certificates</span></span></div>
|
||||
<div class="blocks b3 tone">
|
||||
<a class="blk"><div class="vis draw"><span class="tag">config-editor · hover</span><div class="code" style="padding:20px 18px;font-size:11px"><b>services:</b><br> jellyfin: <u>✓</u><br><b>hosts:</b> [ lucwin-01, lucwin-02 ]<br><b>sync:</b> <u>✓ up to date</u></div></div><div class="tx"><div class="row1"><span class="num">01 <span class="meta">· own</span></span><span class="mk ai"><i></i>AI</span></div><h3>Config</h3><p>Store, sync and manage docker-compose deployments.</p></div></a>
|
||||
<a class="blk"><div class="vis img" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="tx"><div class="row1"><span class="num">02 <span class="meta">· own</span></span><span class="mk ai"><i></i>AI</span></div><h3>Event Tracker for Gacha Games</h3><p>Which live service game events to prioritize.</p></div></a>
|
||||
<div class="blk smalls"><div class="lab">More · own</div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/chess-swap.jpg)"></div><div><h4>Chessswap</h4><div class="d">react</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/budget.jpg)"></div><div><h4>Monthly Expenses Tracker</h4><div class="d">javascript</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/lol-winrate-discord-bot.jpg)"></div><div><h4>LoL Winrate Discord Bot</h4><div class="d">node.js</div></div><span class="mk"><i></i></span></div>
|
||||
</div>
|
||||
<a class="blk"><div class="vis img" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="tx"><div class="row1"><span class="num">03 <span class="meta">· school</span></span><span class="mk"><i></i>by hand</span></div><h3>Bachelor Report</h3><p>Reputation systems in peer-to-peer archival.</p></div></a>
|
||||
<a class="blk"><div class="vis draw"><span class="tag">rowing-stroke · hover</span><svg width="80%" height="90" viewBox="0 0 300 110"><path d="M0 90 C40 90 50 20 90 20 S140 90 180 90 S230 25 260 25 S290 80 300 85" fill="none" stroke="#ea7444" stroke-width="2"/></svg></div><div class="tx"><div class="row1"><span class="num">04 <span class="meta">· own</span></span><span class="mk"><i></i>by hand</span></div><h3>Mixed-Reality Rowing Game</h3><p>Two people, an Arduino and two real oars.</p></div></a>
|
||||
<a class="blk"><div class="vis img" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="tx"><div class="row1"><span class="num">05 <span class="meta">· school</span></span><span class="mk"><i></i>by hand</span></div><h3>Student Dashboard</h3><p>A concept dashboard built on user studies.</p></div></a>
|
||||
<div class="blk smalls"><div class="lab">More · school</div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/censor-simulator.jpg)"></div><div><h4>Censor Simulator</h4><div class="d">unity · VR</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/breakout-python.jpg)"></div><div><h4>BreakoutPython</h4><div class="d">python</div></div><span class="mk"><i></i></span></div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/tribute-to-bourlaug.jpg)"></div><div><h4>Tribute to Norman Borlaugh</h4><div class="d">html · css</div></div><span class="mk"><i></i></span></div>
|
||||
</div>
|
||||
<a class="blk"><div class="vis img" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="tx"><div class="row1"><span class="num">06 <span class="meta">· school</span></span><span class="mk"><i></i>by hand</span></div><h3>VR-Classroom</h3><p>Register, then create and sign up for classes.</p></div></a>
|
||||
<div class="blk smalls"><div class="lab">More · own</div>
|
||||
<div class="r"><div class="th" style="background-image:url(/files/portfolio.jpg)"></div><div><h4>Portfolio (2021)</h4><div class="d">react</div></div><span class="mk"><i></i></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- untoned comparison -->
|
||||
<div class="opt-head"><span class="L g">?</span><div><h1>Same blocks without the toning</h1><p>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.</p></div></div>
|
||||
<div class="blocks b2" style="margin-top:16px">
|
||||
<a class="blk"><div class="vis img" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="tx"><div class="row1"><span class="num">03 <span class="meta">· school · thesis</span></span><span class="mk"><i></i>by hand</span></div><h3>Bachelor Report</h3><p>Reputation systems in peer-to-peer archival.</p></div></a>
|
||||
<a class="blk"><div class="vis img" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="tx"><div class="row1"><span class="num">06 <span class="meta">· school</span></span><span class="mk"><i></i>by hand</span></div><h3>VR-Classroom</h3><p>Register, then create and sign up for classes.</p></div></a>
|
||||
</div>
|
||||
|
||||
<div class="pick">
|
||||
<button data-choice="i" onclick="pick(this)">I · equal blocks, two across</button>
|
||||
<button data-choice="j" onclick="pick(this)">J · equal blocks, three across</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>function pick(b){document.querySelectorAll('.pick button').forEach(x=>x.classList.remove('selected'));b.classList.add('selected');if(window.brainstorm&&brainstorm.send)brainstorm.send({type:'click',choice:b.dataset.choice,text:b.textContent});}</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,404 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Work page: full-page directions</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
|
||||
:root{--bg:#131210;--panel:#1b1916;--panel2:#221f1b;--line:#2b2824;--ink:#ede7dd;--mute:#8e877c;--acc:#ea7444;--ok:#a8b48f;
|
||||
--sans:'Geist',system-ui,sans-serif;--mono:'Geist Mono',ui-monospace,monospace;--e:cubic-bezier(.2,.8,.2,1)}
|
||||
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 var(--sans)}
|
||||
.wrap{max-width:1180px;margin:0 auto;padding:0 32px}
|
||||
.opt-head{border-top:1px dashed #3a352f;margin-top:80px;padding:28px 0 8px;display:flex;gap:18px;align-items:baseline}
|
||||
.opt-head.first{margin-top:24px;border-top:0}
|
||||
.opt-head .L{font:600 13px var(--mono);color:var(--bg);background:var(--acc);border-radius:4px;padding:2px 8px}
|
||||
.opt-head .L.g{background:var(--mute)}
|
||||
.opt-head h1{font-size:22px;margin:0;font-weight:600}.opt-head p{margin:4px 0 0;color:var(--mute);max-width:720px}
|
||||
.note{font:12px/1.55 var(--mono);color:var(--mute);border-left:2px solid var(--acc);padding:2px 0 2px 12px;margin:12px 0 22px;max-width:860px}
|
||||
.sh{display:flex;align-items:baseline;gap:12px;margin:34px 0 16px}
|
||||
.sh h2{font-size:22px;margin:0;font-weight:600}.sh .n{font:12px var(--mono);color:var(--mute)}
|
||||
.sh .r{margin-left:auto;display:flex;gap:10px;align-items:center;font:12px var(--mono);color:var(--mute)}
|
||||
.seg{display:flex;gap:2px}.seg span{font:12.5px var(--sans);color:var(--mute);padding:4px 9px;border-radius:5px}.seg span.on{background:var(--panel2);color:var(--ink);border:1px solid var(--line)}
|
||||
.search{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:6px 10px;font:13px var(--sans);color:var(--mute);min-width:240px}
|
||||
.search b{color:var(--ink);font-weight:400}
|
||||
.meta{font:11px var(--mono);color:var(--mute);letter-spacing:.02em}
|
||||
.ph{border-bottom:1px dotted var(--mute);cursor:help}
|
||||
.mk{display:inline-flex;align-items:center;gap:6px;font:11px var(--mono);border:1px solid var(--line);border-radius:99px;padding:2px 8px;white-space:nowrap}
|
||||
.mk i{width:8px;height:8px;border-radius:50%;border:1.5px solid currentColor;display:inline-block}
|
||||
.mk.ai{color:var(--acc);border-color:#6b3a26}.mk.ai i{background:var(--acc);border-color:var(--acc)}
|
||||
.mk.un i{border-style:dashed}
|
||||
.chip{font:11px var(--mono);color:var(--mute);border:1px solid var(--line);border-radius:4px;padding:1px 6px}
|
||||
/* tiles */
|
||||
.t{background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column;min-height:0;transition:border-color .2s var(--e)}
|
||||
.t:hover{border-color:#4a433b}
|
||||
.t .vis{flex:1;min-height:0;background:#0e0d0c center top/cover no-repeat;border-bottom:1px solid var(--line);position:relative}
|
||||
.t .tx{padding:12px 16px 14px}
|
||||
.t h3{margin:3px 0 2px;font-size:17px;font-weight:600;line-height:1.25}
|
||||
.t p{margin:4px 0 0;color:#c9c2b6;font-size:13.5px;line-height:1.45}
|
||||
.t .ft{display:flex;justify-content:space-between;align-items:center;margin-top:10px}
|
||||
.t .open{font:12px var(--mono);color:var(--acc)}
|
||||
.t.L h3{font-size:22px}
|
||||
.tag{position:absolute;top:10px;left:12px;font:10px var(--mono);color:var(--mute)}
|
||||
.draw{background:var(--panel2)!important;display:flex;align-items:center;justify-content:center}
|
||||
.code{font:12px/1.7 var(--mono);color:#b9b2a6;padding:30px 24px;width:100%}.code b{color:var(--acc);font-weight:400}.code u{color:var(--ok);text-decoration:none}
|
||||
/* small tile: same footprint as today's list row */
|
||||
.s{flex-direction:row;align-items:center;gap:12px;padding:0 12px}
|
||||
.s .th{width:60px;height:40px;border-radius:4px;background:#0e0d0c center/cover;flex:none;border:1px solid var(--line)}
|
||||
.s h4{margin:0;font-size:14px;font-weight:500;line-height:1.3}
|
||||
.s .mk{margin-left:auto;padding:1px 6px}
|
||||
/* other-work tiles */
|
||||
.ow .vis{background:repeating-linear-gradient(135deg,#1d1b18 0 10px,#1a1815 10px 20px)!important;display:flex;align-items:center;justify-content:center}
|
||||
.site{font:500 15px var(--mono);color:var(--ink);border:1px solid var(--line);background:var(--bg);border-radius:6px;padding:8px 14px}
|
||||
.site small{display:block;font:10px var(--mono);color:var(--mute);margin-top:2px}
|
||||
.badge{font:10px var(--mono);color:var(--ok);border:1px solid #3d4433;border-radius:4px;padding:0 5px}
|
||||
.badge.gone{color:var(--mute);border-color:var(--line)}
|
||||
/* ---------- D : woven pattern ---------- */
|
||||
.gD{display:grid;grid-template-columns:repeat(12,1fr);grid-auto-rows:62px;gap:12px}
|
||||
/* ---------- E : dense auto-pack ---------- */
|
||||
.gE{display:grid;grid-template-columns:repeat(12,1fr);grid-auto-rows:62px;gap:12px;grid-auto-flow:row dense}
|
||||
.gE .L{grid-column:span 7;grid-row:span 6}
|
||||
.gE .M{grid-column:span 5;grid-row:span 5}
|
||||
.gE .T{grid-column:span 4;grid-row:span 6}
|
||||
.gE .W{grid-column:span 5;grid-row:span 4}
|
||||
.gE .ow{grid-column:span 3;grid-row:span 3}
|
||||
.gE .s{grid-column:span 3;grid-row:span 1}
|
||||
/* ---------- record ---------- */
|
||||
.rec{display:grid;grid-template-columns:1fr 1.25fr;gap:14px}
|
||||
.box{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px 20px}
|
||||
.box h3{margin:0 0 4px;font:500 11px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--mute)}
|
||||
.box .sum{font-size:14px;color:#c9c2b6;margin:0 0 12px}
|
||||
.box .sum b{color:var(--ink);font-weight:500}
|
||||
.led{display:grid;grid-template-columns:44px 1fr auto;gap:0 14px;font-size:14px}
|
||||
.led>div{padding:8px 0;border-top:1px solid var(--line)}
|
||||
.led .y{font:12px var(--mono);color:var(--mute)}.led .res{font:12px var(--mono);color:var(--mute);text-align:right}
|
||||
.led .hi{color:var(--acc)}
|
||||
.ku{display:grid;grid-template-columns:1fr auto auto auto;gap:0 18px;font-size:14px;align-items:center}
|
||||
.ku>div{padding:9px 0;border-top:1px solid var(--line)}
|
||||
.ku .h{font:500 10px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;border-top:0;padding-top:0}
|
||||
.ku .c{font:12px var(--mono);color:var(--mute);text-align:right;white-space:nowrap}
|
||||
.cert{display:inline-flex;align-items:center;gap:5px;font:12px var(--mono);color:var(--ink);white-space:nowrap}
|
||||
.cert svg{flex:none}
|
||||
.ku .c.cer{color:var(--ink)}
|
||||
.more{display:inline-block;margin-top:14px;font:12px var(--mono);color:var(--acc)}
|
||||
/* search results */
|
||||
.res-wrap{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px 20px}
|
||||
.rgrid{display:grid;grid-template-columns:1.1fr 1fr;gap:18px}
|
||||
.rh{font:500 10.5px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;margin:0 0 8px}
|
||||
.rl>div{display:grid;grid-template-columns:1fr auto;gap:10px;padding:8px 0;border-top:1px solid var(--line);font-size:14px}
|
||||
.rl .m{font:11.5px var(--mono);color:var(--mute);white-space:nowrap}
|
||||
mark{background:none;color:var(--acc)}
|
||||
/* other work in history */
|
||||
.hist{display:grid;grid-template-columns:90px 1fr auto;gap:0 16px;font-size:14px}
|
||||
.hist>div{padding:10px 0;border-top:1px solid var(--line)}.hist .k{font:11px var(--mono);color:var(--mute);padding-top:12px}
|
||||
.pick{display:flex;gap:10px;margin:26px 0 70px}
|
||||
.pick button{font:13px var(--mono);background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:8px 14px;cursor:pointer}
|
||||
.pick button.selected,.pick button:hover{border-color:var(--acc);color:var(--acc)}
|
||||
|
||||
body{padding-bottom:80px}
|
||||
.tabs{position:sticky;top:0;z-index:50;background:#0c0b0a;border-bottom:1px solid var(--line);display:flex;gap:6px;align-items:center;padding:10px 32px}
|
||||
.tabs .lbl{font:11px var(--mono);color:var(--mute);margin-right:10px}
|
||||
.tabs button{font:12.5px var(--mono);background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer}
|
||||
.tabs button.on{border-color:var(--acc);color:var(--acc)}
|
||||
.tabs .desc{margin-left:14px;font:12px var(--sans);color:var(--mute)}
|
||||
.page{display:none}.page.on{display:block}
|
||||
/* site chrome */
|
||||
.top{display:flex;align-items:center;gap:22px;height:56px;border-bottom:1px solid var(--line);padding:0 32px;font-size:13.5px}
|
||||
.top .gate{font:12px var(--mono);border:1px solid var(--line);border-radius:5px;padding:3px 8px;color:var(--mute)}
|
||||
.top .nm{font-weight:600}.top .nm span{color:var(--mute);font-weight:400}
|
||||
.top nav{display:flex;gap:18px;color:var(--mute)}
|
||||
.top .pref{margin-left:auto;display:flex;align-items:center;gap:8px;font:12px var(--mono);color:var(--mute)}
|
||||
.top .pref .sw{display:flex;border:1px solid var(--line);border-radius:6px;padding:2px}
|
||||
.top .pref .sw span{padding:3px 8px;border-radius:4px}.top .pref .sw span.on{background:var(--ink);color:var(--bg)}
|
||||
.foot{border-top:1px solid var(--line);margin-top:70px;padding:22px 32px;font:12px var(--mono);color:var(--mute);display:flex;gap:22px}
|
||||
/* muted screenshots */
|
||||
.mut{filter:saturate(.5) brightness(.82) contrast(1.02);transition:filter .3s var(--e)}
|
||||
a:hover .mut,.hov:hover .mut{filter:none}
|
||||
a{color:inherit;text-decoration:none}
|
||||
.lab{font:500 10.5px var(--mono);letter-spacing:.09em;color:var(--mute);text-transform:uppercase}
|
||||
.acc{color:var(--acc)}
|
||||
.seal{display:inline-block;vertical-align:-2px}
|
||||
/* common bits */
|
||||
.kv{display:grid;grid-template-columns:78px 1fr;gap:9px 16px;font-size:15px}
|
||||
.kv .k{font:11.5px var(--mono);color:var(--mute);padding-top:3px}
|
||||
.btns{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.btn{font:12px var(--mono);border:1px solid var(--line);border-radius:5px;padding:4px 10px;color:var(--ink)}
|
||||
.btn.p{border-color:#6b3a26;color:var(--acc)}
|
||||
.hl{display:grid;grid-template-columns:1fr auto;gap:0 14px;font-size:14px}
|
||||
.hl>div{padding:9px 0;border-top:1px solid var(--line)}
|
||||
.hl .r{font:12px var(--mono);color:var(--mute);text-align:right;white-space:nowrap}
|
||||
.hl .r.acc{color:var(--acc)}
|
||||
.big{font-size:30px;font-weight:600;letter-spacing:-.01em;line-height:1}
|
||||
/* project cards */
|
||||
.pc{display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;transition:border-color .2s var(--e)}
|
||||
.pc:hover{border-color:#4a433b}
|
||||
.pc .vis{background:#0e0d0c center top/cover no-repeat;border-bottom:1px solid var(--line);position:relative}
|
||||
.pc .tx{padding:14px 18px 16px}
|
||||
.pc h3{margin:6px 0 3px;font-size:18px;font-weight:600}
|
||||
.pc p{margin:0;color:#c3bcb0;font-size:13.5px}
|
||||
.pc .row1{display:flex;justify-content:space-between;align-items:center}
|
||||
.num{font:11px var(--mono);color:var(--acc)}
|
||||
.dr{background:var(--panel2)!important;display:flex;align-items:center;justify-content:center}
|
||||
/* quiet "also made" */
|
||||
.also{display:grid;gap:0 28px}
|
||||
.also a{display:grid;grid-template-columns:1fr auto;gap:10px;padding:7px 0;border-top:1px solid var(--line);font-size:13.5px;color:#b9b2a6}
|
||||
.also a:hover{color:var(--ink)}
|
||||
.also .m{font:11px var(--mono);color:var(--mute)}
|
||||
.also .mk{padding:0 5px;font-size:10px;border-color:transparent}
|
||||
.ai-note{font-size:13px;color:var(--mute);display:flex;align-items:center;gap:14px}
|
||||
/* ===== K ===== */
|
||||
.k-hero{display:grid;grid-template-columns:1.35fr 1fr;gap:0;border:1px solid var(--line);border-radius:12px;background:var(--panel);margin-top:28px;overflow:hidden}
|
||||
.k-hero .cv{padding:30px 34px}
|
||||
.k-hero .side{border-left:1px solid var(--line);padding:26px 28px;background:#181614;display:flex;flex-direction:column;gap:22px}
|
||||
.k-hero h1{font-size:34px;line-height:1.1;margin:10px 0 6px;font-weight:600;letter-spacing:-.015em}
|
||||
.k-hero .tg{color:var(--mute);margin:0 0 24px;font-size:16px}
|
||||
.k-stats{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;align-items:baseline}
|
||||
.k-stats .s{font:12px var(--mono);color:var(--mute)}
|
||||
.k6{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
|
||||
.k6 .vis{height:190px}
|
||||
.lowr{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.ku2{display:grid;grid-template-columns:1fr auto auto auto;gap:0 18px;font-size:14px}
|
||||
.ku2>div{padding:8px 0;border-top:1px solid var(--line)}
|
||||
.ku2 .c{font:12px var(--mono);color:var(--mute);text-align:right;white-space:nowrap}
|
||||
.ku2 .h{font:500 10px var(--mono);letter-spacing:.08em;color:var(--mute);text-transform:uppercase;border-top:0}
|
||||
/* ===== L ===== */
|
||||
.l-wrap{display:grid;grid-template-columns:330px 1fr;gap:44px;max-width:1240px;margin:0 auto;padding:0 32px}
|
||||
.l-side{position:sticky;top:64px;align-self:start;padding-top:30px}
|
||||
.l-side h1{font-size:28px;margin:8px 0 4px;font-weight:600;line-height:1.1}
|
||||
.l-side .tg{color:var(--mute);margin:0 0 20px;font-size:14.5px}
|
||||
.l-side .kv{grid-template-columns:62px 1fr;font-size:14px;gap:8px 12px}
|
||||
.l-side .blk{border-top:1px solid var(--line);padding:16px 0}
|
||||
.l-main{padding-top:30px}
|
||||
.l-lead{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.l-lead .vis{height:240px}
|
||||
.l-four{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:14px}
|
||||
.l-four .vis{height:120px}.l-four h3{font-size:15px}.l-four p{display:none}
|
||||
/* ===== M ===== */
|
||||
.m-intro{margin-top:30px;display:flex;align-items:baseline;gap:18px}
|
||||
.m-intro h1{margin:0;font-size:26px;font-weight:600}.m-intro span{color:var(--mute)}
|
||||
.m-band{display:grid;grid-template-columns:1.4fr 1fr 1fr;border:1px solid var(--line);border-radius:12px;background:var(--panel);margin-top:18px}
|
||||
.m-band>div{padding:22px 24px}.m-band>div+div{border-left:1px solid var(--line)}
|
||||
.m-band .kv{font-size:14px;grid-template-columns:62px 1fr}
|
||||
.m-feat{display:grid;grid-template-columns:1.25fr 1fr;gap:14px}
|
||||
.m-feat .lead .vis{height:330px}
|
||||
.m-feat .stack{display:grid;grid-template-rows:1fr 1fr;gap:14px}
|
||||
.m-feat .stack .pc{flex-direction:row}.m-feat .stack .vis{width:46%;border-bottom:0;border-right:1px solid var(--line)}
|
||||
.m-row{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:14px}
|
||||
.m-row .pc{flex-direction:row}.m-row .vis{width:120px;flex:none;border-bottom:0;border-right:1px solid var(--line)}
|
||||
.m-row h3{font-size:15px}.m-row .tx{padding:12px 14px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg width="0" height="0" style="position:absolute"><defs><symbol id="seal" viewBox="0 0 16 16"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="cfg" viewBox="0 0 300 150"><text x="18" y="34" font-family="Geist Mono,monospace" font-size="12" fill="#ea7444">services:</text><text x="18" y="54" font-family="Geist Mono,monospace" font-size="12" fill="#b9b2a6"> jellyfin: image: linuxserver/jellyfin</text><text x="18" y="74" font-family="Geist Mono,monospace" font-size="12" fill="#ea7444">hosts:<tspan fill="#b9b2a6"> [ lucwin-01, lucwin-02 ]</tspan></text><text x="18" y="94" font-family="Geist Mono,monospace" font-size="12" fill="#ea7444">sync:<tspan fill="#a8b48f"> ✓ 2 hosts up to date</tspan></text></symbol>
|
||||
<symbol id="row" viewBox="0 0 300 110"><path d="M0 90 C40 90 50 20 90 20 S140 90 180 90 S230 25 260 25 S290 80 300 85" fill="none" stroke="#ea7444" stroke-width="2"/><path d="M0 95 C45 95 55 35 95 35 S145 95 185 95 S235 40 265 40" fill="none" stroke="#8e877c" stroke-width="1.5" stroke-dasharray="4 4"/></symbol></defs></svg>
|
||||
|
||||
<div class="tabs"><span class="lbl">Round 5 · full pages</span>
|
||||
<button data-p="k" class="on">K · CV hero</button><button data-p="l">L · CV sidebar</button><button data-p="m">M · at-a-glance band</button>
|
||||
<span class="desc" id="desc"></span>
|
||||
</div>
|
||||
|
||||
<!-- =========================================================== K -->
|
||||
<section class="page on" id="k" data-desc="The CV is a big card at the top; highlights and certificates sit inside it on the right. Then the six, then a quiet list.">
|
||||
<div class="top"><span class="gate">← gate</span><span class="nm">Lucas Winther <span>/ work</span></span><nav><span>Projects</span><span>Record</span><span>Writing</span><span>History</span></nav><span class="pref">projects made: <span class="sw"><span class="on">any way</span><span>not fully by AI</span><span>by hand only</span></span></span></div>
|
||||
<div class="wrap">
|
||||
<div class="k-hero">
|
||||
<div class="cv">
|
||||
<div class="lab"><span class="acc">●</span> CV in ten seconds</div>
|
||||
<h1>Lucas Winther</h1>
|
||||
<p class="tg">Computer Scientist with passion for development.</p>
|
||||
<div class="kv">
|
||||
<span class="k">now</span><span>IT-medarbejder, IT Research Tech, Aarhus University <span class="meta">2025–now</span></span>
|
||||
<span class="k">before</span><span>Intern, ScienceAtHome <span class="meta">2019</span><br>Student helper, Local Configuration Management, TERMA Group <span class="meta">2016–2018</span></span>
|
||||
<span class="k">degree</span><span>Bachelor in Computer Science</span>
|
||||
<span class="k">contact</span><span style="text-decoration:underline;text-decoration-color:var(--acc);text-underline-offset:3px">lucas@lucaswinther.info</span>
|
||||
</div>
|
||||
<div class="btns" style="margin-top:22px"><span class="btn">full history ↓</span><span class="btn">GitHub</span><span class="btn">LinkedIn</span></div>
|
||||
</div>
|
||||
<div class="side">
|
||||
<div><div class="lab" style="margin-bottom:6px">Highlights</div>
|
||||
<div class="hl">
|
||||
<div class="acc">AUHackathon 2019</div><div class="r acc">winner</div>
|
||||
<div>First Lego League</div><div class="r acc">won round 1, twice</div>
|
||||
<div>Google Hash Code 2021</div><div class="r acc">top 12%</div>
|
||||
</div><a class="meta" style="display:block;margin-top:8px">+ 3 more in the record ↓</a></div>
|
||||
<div><div class="lab" style="margin-bottom:10px">Keeping up</div>
|
||||
<div class="k-stats"><span class="big acc"><svg class="seal" width="22" height="22"><use href="#seal"/></svg> 18</span><span>certificates <span class="s">· 23 courses & labs · ~27 h since Feb 2025</span></span></div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:12px"><span class="chip">Ansible 5</span><span class="chip">Serverless/AWS 5</span><span class="chip">RAG & LLMs 4</span><span class="chip">Security 2</span><span class="chip">Linux 1</span></div>
|
||||
<a class="meta" style="display:block;margin-top:10px">all courses ↓</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sh" style="margin-top:46px"><h2>Projects I'm proudest of</h2><span class="n">6</span><span class="r ai-note">marked by how much AI went into them · <span class="mk ai"><i></i>AI</span><span class="mk"><i></i>by hand</span></span></div>
|
||||
<div class="k6">
|
||||
<a class="pc"><div class="vis dr"><svg width="92%" height="150"><use href="#cfg"/></svg></div><div class="tx"><div class="row1"><span class="num">01 <span class="meta">· own</span></span><span class="mk ai"><i></i>made with AI</span></div><h3>Config</h3><p>Store, sync and manage docker-compose deployments on servers.</p></div></a>
|
||||
<a class="pc"><div class="vis mut" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="tx"><div class="row1"><span class="num">02 <span class="meta">· own</span></span><span class="mk ai"><i></i>made with AI</span></div><h3>Event Tracker for Gacha Games</h3><p>Which live service game events to prioritize.</p></div></a>
|
||||
<a class="pc"><div class="vis mut" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="tx"><div class="row1"><span class="num">03 <span class="meta">· school · thesis</span></span><span class="mk"><i></i>by hand</span></div><h3>Bachelor Report</h3><p>Reputation systems in peer-to-peer archival.</p></div></a>
|
||||
<a class="pc"><div class="vis dr"><svg width="80%" height="110"><use href="#row"/></svg></div><div class="tx"><div class="row1"><span class="num">04 <span class="meta">· own</span></span><span class="mk"><i></i>by hand</span></div><h3>Mixed-Reality Rowing Game</h3><p>Two people, an Arduino and two real oars.</p></div></a>
|
||||
<a class="pc"><div class="vis mut" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="tx"><div class="row1"><span class="num">05 <span class="meta">· school</span></span><span class="mk"><i></i>by hand</span></div><h3>Student Dashboard</h3><p>A concept dashboard built upon user studies.</p></div></a>
|
||||
<a class="pc"><div class="vis mut" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="tx"><div class="row1"><span class="num">06 <span class="meta">· school</span></span><span class="mk"><i></i>by hand</span></div><h3>VR-Classroom</h3><p>Register, then create and sign up for classes.</p></div></a>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:180px 1fr;gap:20px;margin-top:26px">
|
||||
<div><div class="lab">Also made</div><p class="meta" style="margin:6px 0 0;line-height:1.6">7 smaller and older projects · search</p></div>
|
||||
<div class="also" style="grid-template-columns:repeat(3,1fr)">
|
||||
<a>Chessswap <span class="m">react</span></a><a>Monthly Expenses Tracker <span class="m">js</span></a><a>LoL Winrate Discord Bot <span class="m">node</span></a>
|
||||
<a>Censor Simulator <span class="m">unity · VR</span></a><a>BreakoutPython <span class="m">python</span></a><a>Tribute to Norman Borlaugh <span class="m">html</span></a>
|
||||
<a>Portfolio (2021) <span class="m">react</span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sh" style="margin-top:60px"><h2>Record</h2><span class="n">the full version of the highlights</span></div>
|
||||
<div class="lowr">
|
||||
<div class="box"><h3>Recognition & volunteering</h3>
|
||||
<div class="hl" style="margin-top:10px">
|
||||
<div class="acc">AUHackathon 2019</div><div class="r acc">winner · best AUHack</div>
|
||||
<div>First Lego League <span class="meta">· four years</span></div><div class="r acc">won round 1, two years running</div>
|
||||
<div>Google Hash Code 2021</div><div class="r acc">top 12%</div>
|
||||
<div>De Danske Cybermesterskaber 2024</div><div class="r">regional participant</div>
|
||||
<div>Art Bubble <span class="meta">· art-bubble.dk ↗</span></div><div class="r">volunteer IT & website · ongoing</div>
|
||||
<div>Lystrup Genbrugser</div><div class="r">volunteer IT & website · site retired</div>
|
||||
</div></div>
|
||||
<div class="box"><h3>Keeping up · 18 certificates</h3>
|
||||
<div class="ku2" style="margin-top:10px">
|
||||
<div class="h">topic</div><div class="h" style="text-align:right">done</div><div class="h" style="text-align:right">cert.</div><div class="h" style="text-align:right">time</div>
|
||||
<div>Ansible & configuration</div><div class="c">2 courses · 3 labs</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 5</div><div class="c">9.4h</div>
|
||||
<div>Linux administration</div><div class="c">1 course</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 1</div><div class="c">6.7h</div>
|
||||
<div>Security</div><div class="c">2 courses · 1 lab</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 2</div><div class="c">4.9h</div>
|
||||
<div>Serverless on AWS</div><div class="c">5 courses · 3 labs</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 5</div><div class="c">3.9h</div>
|
||||
<div>RAG & LLM systems</div><div class="c">4 courses · 1 lab</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 4</div><div class="c">1.8h</div>
|
||||
<div>Observability</div><div class="c">1 course</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 1</div><div class="c">0.4h</div>
|
||||
</div><a class="more">All courses and certificates, searchable →</a></div>
|
||||
</div>
|
||||
<div class="lowr" style="margin-top:14px">
|
||||
<div class="box"><h3>Writing · 1 post</h3><div style="font-size:14px">A synthetic work post <span class="meta">· 14 Mar 2025</span></div></div>
|
||||
<div class="box"><h3>Where I've worked</h3><div class="kv" style="font-size:14px;margin-top:10px"><span class="k">now</span><span>IT Research Tech, Aarhus University (2025–now)</span><span class="k">before</span><span>Intern, ScienceAtHome (2019)<br>Student helper, TERMA Group (2016–2018)</span><span class="k">degree</span><span>Bachelor in Computer Science</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot"><span>← back to the gate</span><span>/simple</span><span>GitHub</span><span>LinkedIn</span><span>lucas@lucaswinther.info</span></div>
|
||||
</section>
|
||||
|
||||
<!-- =========================================================== L -->
|
||||
<section class="page" id="l" data-desc="The CV, highlights and certificates live in a left column that stays put while you scroll the projects. Like a CV with a portfolio attached.">
|
||||
<div class="top"><span class="gate">← gate</span><span class="nm">Lucas Winther <span>/ work</span></span><nav><span>Projects</span><span>Record</span><span>Writing</span></nav><span class="pref">projects made: <span class="sw"><span class="on">any way</span><span>not fully by AI</span><span>by hand only</span></span></span></div>
|
||||
<div class="l-wrap">
|
||||
<aside class="l-side">
|
||||
<div class="lab"><span class="acc">●</span> CV in ten seconds</div>
|
||||
<h1>Lucas Winther</h1><p class="tg">Computer Scientist with passion for development.</p>
|
||||
<div class="kv"><span class="k">now</span><span>IT-medarbejder, IT Research Tech, Aarhus University <span class="meta">2025–</span></span><span class="k">before</span><span>Intern, ScienceAtHome <span class="meta">2019</span><br>Student helper, TERMA Group <span class="meta">2016–18</span></span><span class="k">degree</span><span>BSc Computer Science</span></div>
|
||||
<div class="btns" style="margin:16px 0 4px"><span class="btn">GitHub</span><span class="btn">LinkedIn</span><span class="btn">Email</span></div>
|
||||
<div class="blk" style="margin-top:14px"><div class="lab" style="margin-bottom:4px">Highlights</div>
|
||||
<div class="hl"><div class="acc">AUHackathon 2019</div><div class="r acc">winner</div><div>First Lego League</div><div class="r acc">round 1 ×2</div><div>Hash Code 2021</div><div class="r acc">top 12%</div></div></div>
|
||||
<div class="blk"><div class="lab" style="margin-bottom:8px">Keeping up</div>
|
||||
<div><span class="big acc" style="font-size:26px"><svg class="seal" width="20" height="20"><use href="#seal"/></svg> 18</span> certificates</div>
|
||||
<div class="meta" style="margin-top:6px">23 courses & labs · ~27 h · since Feb 2025<br>Ansible · AWS serverless · RAG · security · Linux</div></div>
|
||||
</aside>
|
||||
<main class="l-main">
|
||||
<div class="sh" style="margin-top:0"><h2>Projects I'm proudest of</h2><span class="n">6</span><span class="r ai-note"><span class="mk ai"><i></i>AI</span><span class="mk"><i></i>by hand</span></span></div>
|
||||
<div class="l-lead">
|
||||
<a class="pc"><div class="vis dr"><svg width="92%" height="160"><use href="#cfg"/></svg></div><div class="tx"><div class="row1"><span class="num">01 <span class="meta">· own</span></span><span class="mk ai"><i></i>made with AI</span></div><h3>Config</h3><p>Store, sync and manage docker-compose deployments on servers.</p></div></a>
|
||||
<a class="pc"><div class="vis mut" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="tx"><div class="row1"><span class="num">02 <span class="meta">· own</span></span><span class="mk ai"><i></i>made with AI</span></div><h3>Event Tracker for Gacha Games</h3><p>Which live service game events to prioritize.</p></div></a>
|
||||
</div>
|
||||
<div class="l-four">
|
||||
<a class="pc"><div class="vis mut" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="tx"><div class="row1"><span class="num">03</span><span class="mk"><i></i></span></div><h3>Bachelor Report</h3></div></a>
|
||||
<a class="pc"><div class="vis dr"><svg width="80%" height="70"><use href="#row"/></svg></div><div class="tx"><div class="row1"><span class="num">04</span><span class="mk"><i></i></span></div><h3>Mixed-Reality Rowing</h3></div></a>
|
||||
<a class="pc"><div class="vis mut" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="tx"><div class="row1"><span class="num">05</span><span class="mk"><i></i></span></div><h3>Student Dashboard</h3></div></a>
|
||||
<a class="pc"><div class="vis mut" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="tx"><div class="row1"><span class="num">06</span><span class="mk"><i></i></span></div><h3>VR-Classroom</h3></div></a>
|
||||
</div>
|
||||
<div style="margin-top:26px"><div class="lab" style="margin-bottom:6px">Also made · 7</div>
|
||||
<div class="also" style="grid-template-columns:repeat(2,1fr)">
|
||||
<a>Chessswap <span class="m">react</span></a><a>Censor Simulator <span class="m">unity · VR</span></a>
|
||||
<a>Monthly Expenses Tracker <span class="m">js</span></a><a>BreakoutPython <span class="m">python</span></a>
|
||||
<a>LoL Winrate Discord Bot <span class="m">node</span></a><a>Tribute to Norman Borlaugh <span class="m">html</span></a>
|
||||
<a>Portfolio (2021) <span class="m">react</span></a>
|
||||
</div></div>
|
||||
<div class="sh" style="margin-top:56px"><h2>Record</h2></div>
|
||||
<div class="box"><h3>Recognition & volunteering</h3><div class="hl" style="margin-top:10px">
|
||||
<div class="acc">AUHackathon 2019</div><div class="r acc">winner · best AUHack</div>
|
||||
<div>First Lego League <span class="meta">· four years</span></div><div class="r acc">won round 1, two years running</div>
|
||||
<div>Google Hash Code 2021</div><div class="r acc">top 12%</div>
|
||||
<div>De Danske Cybermesterskaber 2024</div><div class="r">regional participant</div>
|
||||
<div>Art Bubble <span class="meta">· art-bubble.dk ↗</span></div><div class="r">volunteer IT & website · ongoing</div>
|
||||
<div>Lystrup Genbrugser</div><div class="r">volunteer IT & website · site retired</div></div></div>
|
||||
<div class="box" style="margin-top:14px"><h3>Keeping up · 18 certificates · 23 courses & labs</h3>
|
||||
<div class="ku2" style="margin-top:10px">
|
||||
<div>Ansible & configuration</div><div class="c">2 courses · 3 labs</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 5</div><div class="c">9.4h</div>
|
||||
<div>Linux administration</div><div class="c">1 course</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 1</div><div class="c">6.7h</div>
|
||||
<div>Security</div><div class="c">2 courses · 1 lab</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 2</div><div class="c">4.9h</div>
|
||||
<div>Serverless on AWS</div><div class="c">5 courses · 3 labs</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 5</div><div class="c">3.9h</div>
|
||||
<div>RAG & LLM systems</div><div class="c">4 courses · 1 lab</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 4</div><div class="c">1.8h</div>
|
||||
</div><a class="more">All courses and certificates, searchable →</a></div>
|
||||
<div class="box" style="margin-top:14px"><h3>Writing · 1 post</h3><div style="font-size:14px">A synthetic work post <span class="meta">· 14 Mar 2025</span></div></div>
|
||||
</main>
|
||||
</div>
|
||||
<div class="foot"><span>← back to the gate</span><span>/simple</span><span>GitHub</span><span>LinkedIn</span><span>lucas@lucaswinther.info</span></div>
|
||||
</section>
|
||||
|
||||
<!-- =========================================================== M -->
|
||||
<section class="page" id="m" data-desc="A three-part band across the top (CV · highlights · keeping up), then one lead project, then the other five, then a quiet list.">
|
||||
<div class="top"><span class="gate">← gate</span><span class="nm">Lucas Winther <span>/ work</span></span><nav><span>Projects</span><span>Record</span><span>Writing</span><span>History</span></nav><span class="pref">projects made: <span class="sw"><span class="on">any way</span><span>not fully by AI</span><span>by hand only</span></span></span></div>
|
||||
<div class="wrap">
|
||||
<div class="m-intro"><h1>Lucas Winther</h1><span>Computer Scientist with passion for development.</span></div>
|
||||
<div class="m-band">
|
||||
<div><div class="lab" style="margin-bottom:12px"><span class="acc">●</span> CV in ten seconds</div>
|
||||
<div class="kv"><span class="k">now</span><span>IT-medarbejder, IT Research Tech, Aarhus University <span class="meta">2025–now</span></span><span class="k">before</span><span>Intern, ScienceAtHome <span class="meta">2019</span> · Student helper, TERMA Group <span class="meta">2016–2018</span></span><span class="k">degree</span><span>Bachelor in Computer Science</span></div>
|
||||
<div class="btns" style="margin-top:16px"><span class="btn">GitHub</span><span class="btn">LinkedIn</span><span class="btn">Email</span></div></div>
|
||||
<div><div class="lab" style="margin-bottom:6px">Highlights</div>
|
||||
<div class="hl"><div class="acc">AUHackathon 2019</div><div class="r acc">winner</div><div>First Lego League</div><div class="r acc">round 1 ×2</div><div>Google Hash Code 2021</div><div class="r acc">top 12%</div></div>
|
||||
<a class="meta" style="display:block;margin-top:8px">+ 3 more ↓</a></div>
|
||||
<div><div class="lab" style="margin-bottom:10px">Keeping up</div>
|
||||
<div><span class="big acc"><svg class="seal" width="22" height="22"><use href="#seal"/></svg> 18</span> <span>certificates</span></div>
|
||||
<div class="meta" style="margin:8px 0 10px">23 courses & labs · ~27 h since Feb 2025</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap"><span class="chip">Ansible</span><span class="chip">AWS serverless</span><span class="chip">RAG</span><span class="chip">Security</span><span class="chip">Linux</span></div></div>
|
||||
</div>
|
||||
|
||||
<div class="sh" style="margin-top:46px"><h2>Projects I'm proudest of</h2><span class="n">6</span><span class="r ai-note">marked by how much AI went into them · <span class="mk ai"><i></i>AI</span><span class="mk"><i></i>by hand</span></span></div>
|
||||
<div class="m-feat">
|
||||
<a class="pc lead"><div class="vis dr"><svg width="92%" height="200"><use href="#cfg"/></svg></div><div class="tx"><div class="row1"><span class="num">01 <span class="meta">· own · personal project</span></span><span class="mk ai"><i></i>made with AI</span></div><h3 style="font-size:24px">Config</h3><p>A repository to store, sync and manage docker-compose based deployment on servers.</p></div></a>
|
||||
<div class="stack">
|
||||
<a class="pc"><div class="vis mut" style="background-image:url(/files/gacha-event-tracker.jpg)"></div><div class="tx"><div class="row1"><span class="num">02</span><span class="mk ai"><i></i>AI</span></div><h3>Event Tracker for Gacha Games</h3><p>Which live service game events to prioritize.</p></div></a>
|
||||
<a class="pc"><div class="vis dr"><svg width="86%" height="90"><use href="#row"/></svg></div><div class="tx"><div class="row1"><span class="num">03</span><span class="mk"><i></i>by hand</span></div><h3>Mixed-Reality Rowing Game</h3><p>Two people, an Arduino and two real oars.</p></div></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-row">
|
||||
<a class="pc"><div class="vis mut" style="background-image:url(/files/bachelor-report.jpg)"></div><div class="tx"><div class="row1"><span class="num">04</span><span class="mk"><i></i></span></div><h3>Bachelor Report</h3><p class="meta">school · thesis</p></div></a>
|
||||
<a class="pc"><div class="vis mut" style="background-image:url(/files/student-dashboard.jpg)"></div><div class="tx"><div class="row1"><span class="num">05</span><span class="mk"><i></i></span></div><h3>Student Dashboard</h3><p class="meta">school</p></div></a>
|
||||
<a class="pc"><div class="vis mut" style="background-image:url(/files/vr-classroom.jpg)"></div><div class="tx"><div class="row1"><span class="num">06</span><span class="mk"><i></i></span></div><h3>VR-Classroom</h3><p class="meta">school</p></div></a>
|
||||
</div>
|
||||
<div style="margin-top:22px;display:flex;gap:10px 22px;flex-wrap:wrap;align-items:baseline;font-size:13.5px;color:#a39c90">
|
||||
<span class="lab" style="margin-right:4px">Also made</span>
|
||||
<a>Chessswap</a><a>Monthly Expenses Tracker</a><a>LoL Winrate Discord Bot</a><a>Censor Simulator</a><a>BreakoutPython</a><a>Tribute to Norman Borlaugh</a><a>Portfolio (2021)</a>
|
||||
<span class="meta">· all projects, searchable →</span>
|
||||
</div>
|
||||
|
||||
<div class="sh" style="margin-top:60px"><h2>Record</h2><span class="n">the full version</span></div>
|
||||
<div class="lowr">
|
||||
<div class="box"><h3>Recognition & volunteering</h3><div class="hl" style="margin-top:10px">
|
||||
<div class="acc">AUHackathon 2019</div><div class="r acc">winner · best AUHack</div>
|
||||
<div>First Lego League <span class="meta">· four years</span></div><div class="r acc">won round 1, two years running</div>
|
||||
<div>Google Hash Code 2021</div><div class="r acc">top 12%</div>
|
||||
<div>De Danske Cybermesterskaber 2024</div><div class="r">regional participant</div>
|
||||
<div>Art Bubble <span class="meta">· art-bubble.dk ↗</span></div><div class="r">volunteer IT & website · ongoing</div>
|
||||
<div>Lystrup Genbrugser</div><div class="r">volunteer IT & website · site retired</div></div></div>
|
||||
<div class="box"><h3>Keeping up · 18 certificates</h3><div class="ku2" style="margin-top:10px">
|
||||
<div>Ansible & configuration</div><div class="c">2 courses · 3 labs</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 5</div><div class="c">9.4h</div>
|
||||
<div>Linux administration</div><div class="c">1 course</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 1</div><div class="c">6.7h</div>
|
||||
<div>Security</div><div class="c">2 courses · 1 lab</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 2</div><div class="c">4.9h</div>
|
||||
<div>Serverless on AWS</div><div class="c">5 courses · 3 labs</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 5</div><div class="c">3.9h</div>
|
||||
<div>RAG & LLM systems</div><div class="c">4 courses · 1 lab</div><div class="c" style="color:var(--ink)"><svg class="seal" width="12" height="12"><use href="#seal"/></svg> 4</div><div class="c">1.8h</div>
|
||||
</div><a class="more">All courses and certificates, searchable →</a></div>
|
||||
</div>
|
||||
<div class="lowr" style="margin-top:14px">
|
||||
<div class="box"><h3>Writing · 1 post</h3><div style="font-size:14px">A synthetic work post <span class="meta">· 14 Mar 2025</span></div></div>
|
||||
<div class="box"><h3>Where I've worked</h3><div class="kv" style="font-size:14px;margin-top:10px"><span class="k">now</span><span>IT Research Tech, Aarhus University (2025–now)</span><span class="k">before</span><span>Intern, ScienceAtHome (2019)<br>Student helper, TERMA Group (2016–2018)</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot"><span>← back to the gate</span><span>/simple</span><span>GitHub</span><span>LinkedIn</span><span>lucas@lucaswinther.info</span></div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function(){var d=document.getElementById('desc');function show(id){document.querySelectorAll('.page').forEach(function(p){p.classList.toggle('on',p.id===id)});document.querySelectorAll('.tabs button').forEach(function(b){b.classList.toggle('on',b.dataset.p===id)});d.textContent=document.getElementById(id).dataset.desc;window.scrollTo(0,0);if(window.brainstorm&&brainstorm.send)brainstorm.send({type:'click',choice:id,text:'viewed '+id});}
|
||||
document.querySelectorAll('.tabs button').forEach(function(b){b.onclick=function(){show(b.dataset.p)}});show((location.hash||'#k').slice(1));})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, TopicSummary>();
|
||||
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<CourseLike, 'title' | 'provider' | 'topic' | 'keywords'>): 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<CourseLike, 'title' | 'provider' | 'topic' | 'keywords'>, 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]}` : '';
|
||||
}
|
||||
+116
-2
@@ -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<unknown[]> {
|
||||
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<LoadResult> {
|
||||
);
|
||||
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 };
|
||||
}
|
||||
+31
-1
@@ -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[] };
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
// "<seal> 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;
|
||||
---
|
||||
<p class="sumline">{sum.certificates > 0 && <><span class="acc"><Fragment set:html={sealSvg(14)} /> <b class="acc">{sum.certificates} {sum.certificates === 1 ? 'certificate' : 'certificates'}</b></span> from </>}{sum.count} {sum.count === 1 ? 'course' : 'courses'} and hands-on labs · <b>~{approxHours(sum.minutes)} hours</b> of course time since {monthYear(sum.since)}</p>
|
||||
@@ -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<number, string[]> = {
|
||||
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<Project['ai'], string> = {
|
||||
'ai-made': 'made with AI', 'some-ai': 'some AI', 'by-hand': 'by hand', unknown: 'AI level not recorded',
|
||||
};
|
||||
---
|
||||
<section id="featured" data-sheet-ctx="featured">
|
||||
<div class="sh">
|
||||
<h2>Featured</h2><span class="n">{projects.length} {projects.length === 1 ? 'project' : 'projects'}</span>
|
||||
<span class="r">hover the drawings · open a panel for the full story</span>
|
||||
<h2>Projects I'm proudest of</h2><span class="n">{projects.length} {projects.length === 1 ? 'project' : 'projects'}</span>
|
||||
<div class="fside">
|
||||
<div class="ailegend" aria-label="How much AI went into each project">
|
||||
<span class="t">marked by how much AI went in</span>
|
||||
<span class="marks"><AiMark level="ai-made" /><AiMark level="some-ai" /><AiMark level="by-hand" /><AiMark level="unknown" /></span>
|
||||
</div>
|
||||
<span class="hint">hover the drawings · open a panel for the full story</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feats">
|
||||
{projects.map((p, i) => {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
// Now / Latest writing / CV in ten seconds. Cells without content are left out; the writing cell
|
||||
// is a filter box (base.css), and the flex row closes up around whichever cells remain.
|
||||
import type { Site } from '../../../library/types.ts';
|
||||
import type { Post } from '../../../sources/types.ts';
|
||||
import { cvRows, formatDate } from './sections.ts';
|
||||
interface Props { site: Site; posts: Post[] }
|
||||
const { site, posts } = Astro.props;
|
||||
const latest = posts.slice(0, 3);
|
||||
const cv = site.cv;
|
||||
---
|
||||
<section class="now" aria-label="Now, latest writing and CV">
|
||||
{site.now.length > 0 && (
|
||||
<div style="--grow: 1">
|
||||
<h2><span>Now</span></h2>
|
||||
<ul>{site.now.map((n) => <li>{n}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
{latest.length > 0 && (
|
||||
<div class="filter-box" style="--grow: 1.15">
|
||||
<h2><span>Latest writing</span><a href="#writing">all ↓</a></h2>
|
||||
<div class="wl">
|
||||
{latest.map((p, i) => (
|
||||
<a href={`/work/writing/${p.slug}/`} class={i === 0 ? 'lead' : undefined} data-ai={p.ai}>
|
||||
<span class="t">{p.title}</span><span class="d">{formatDate(p.date, 'short')}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style="--grow: 1.35">
|
||||
<h2><span>CV in ten seconds</span></h2>
|
||||
<div class="cv10">
|
||||
{cv.now && <><span class="k">now</span><span class="v"><b>{cv.now}</b></span></>}
|
||||
{cv.before.length > 0 && <><span class="k">before</span><span class="v">{cv.before.join(' · ')}</span></>}
|
||||
{cv.degree && <><span class="k">degree</span><span class="v">{cv.degree}</span></>}
|
||||
{site.links.email && <><span class="k">contact</span><span class="v"><a href={`mailto:${site.links.email}`}>{site.links.email}</a></span></>}
|
||||
</div>
|
||||
<div class="cvlinks">
|
||||
{site.links.cv && <a class="btn solid" href={`/media/${site.links.cv}`}>↓ CV (PDF)</a>}
|
||||
{cvRows(site).length > 0 && <a class="btn" href="#cv">full history ↓</a>}
|
||||
{site.links.github && <a class="btn" href={site.links.github}>GitHub</a>}
|
||||
{site.links.linkedin && <a class="btn" href={site.links.linkedin}>LinkedIn</a>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,9 +1,13 @@
|
||||
// "Everything else": the compact project list with group tabs and search (`/` focuses it).
|
||||
// "Also made": the compact project list with group tabs and search (`/` focuses it).
|
||||
// Server output lists every row as a plain link; rows carry data-ai, so the CSS filter applies
|
||||
// before hydration. Clicking a row is picked up by ProjectSheet.
|
||||
// before hydration. Clicking a row is picked up by ProjectSheet. While the search has text, matching
|
||||
// courses show as a strip above the rows (spec 009 §3); courses carry no AI level, so the filter never hides them.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AI_LABEL, isVisible, parsePreference, PREFERENCE_LABEL, type AiLevel, type Preference } from '../../../core/ai.ts';
|
||||
import type { Project } from '../../../library/types.ts';
|
||||
import { formatHours, monthYear } from '../../../core/courses.ts';
|
||||
import { courseStrip, matchCountText, type ListCourse } from './course-search.ts';
|
||||
import { sealSvg } from './record.ts';
|
||||
import { GROUP_LABEL } from './sections.ts';
|
||||
|
||||
export type ListItem = Pick<Project, 'id' | 'title' | 'group' | 'where' | 'years' | 'stack' | 'summary' | 'ai'> & {
|
||||
@@ -16,7 +20,7 @@ function AiMark({ level }: { level: AiLevel }) {
|
||||
return <span className="ai-mark" data-level={level}><i aria-hidden="true" /><span>{AI_LABEL[level]}</span></span>;
|
||||
}
|
||||
|
||||
export default function ProjectList({ items, title }: { items: ListItem[]; title: string }) {
|
||||
export default function ProjectList({ items, title, courses = [] }: { items: ListItem[]; title: string; courses?: ListCourse[] }) {
|
||||
const [group, setGroup] = useState<Group>('all');
|
||||
const [q, setQ] = useState('');
|
||||
const [pref, setPref] = useState<Preference>('any');
|
||||
@@ -58,14 +62,15 @@ export default function ProjectList({ items, title }: { items: ListItem[]; title
|
||||
);
|
||||
const shown = revealed ? base : base.filter((p) => isVisible(p.ai, pref));
|
||||
const hidden = base.length - shown.length;
|
||||
const strip = courseStrip(courses, q);
|
||||
const countText = matchCountText(shown.length, strip.count)
|
||||
|| (shown.length === items.length ? `${items.length} ${items.length === 1 ? 'project' : 'projects'}` : `${shown.length} of ${items.length} projects`);
|
||||
|
||||
return (
|
||||
<section className="idx" id="all" data-sheet-ctx="list">
|
||||
<div className="sh">
|
||||
<h2>{title}</h2>
|
||||
<span className="n">
|
||||
{shown.length === items.length ? `${items.length} ${items.length === 1 ? 'project' : 'projects'}` : `${shown.length} of ${items.length} projects`}
|
||||
</span>
|
||||
<span className="n">{countText}</span>
|
||||
</div>
|
||||
<div className="tools">
|
||||
{groups.length > 1 && (
|
||||
@@ -78,10 +83,22 @@ export default function ProjectList({ items, title }: { items: ListItem[]; title
|
||||
</div>
|
||||
)}
|
||||
<label className="search">
|
||||
<input ref={input} type="search" placeholder="Search name, stack, idea" aria-label="Search projects" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<input ref={input} type="search" placeholder={courses.length > 0 ? 'Search name, stack, idea, course' : 'Search name, stack, idea'} aria-label={courses.length > 0 ? 'Search projects and courses' : 'Search projects'} value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</label>
|
||||
</div>
|
||||
{strip.count > 0 && (
|
||||
<div className="cstrip" aria-label="Matching courses">
|
||||
{strip.shown.map((c) => (
|
||||
<div key={`${c.title}|${c.completed}`} className="crow">
|
||||
<span className="cseal">{c.certificate && <span title="certificate" dangerouslySetInnerHTML={{ __html: sealSvg(13) }} />}</span>
|
||||
<span className="ct">{c.title}{c.kind === 'lab' && <span className="tag">lab</span>}{c.certificate && <span className="sr-only"> (certificate)</span>}</span>
|
||||
<span className="cm">{c.provider} · {monthYear(c.completed)} · {formatHours(c.minutes)}</span>
|
||||
</div>
|
||||
))}
|
||||
{strip.more > 0 && <a className="cmore" href={strip.href}>+ {strip.more} more on the learning page →</a>}
|
||||
</div>
|
||||
)}
|
||||
<div className="list">
|
||||
{base.map((p) => (
|
||||
<a key={p.id} className="row" href={`/work/${p.id}/`} data-open-sheet={p.id} data-ai={p.ai} data-show={revealed ? '' : undefined}>
|
||||
@@ -98,7 +115,7 @@ export default function ProjectList({ items, title }: { items: ListItem[]; title
|
||||
<span className="go" aria-hidden="true">→</span>
|
||||
</a>
|
||||
))}
|
||||
{base.length === 0 && <div className="empty">Nothing matches{needle ? ` "${q.trim()}"` : ''}.</div>}
|
||||
{base.length === 0 && <div className="empty">{strip.count > 0 ? 'No project matches' : 'Nothing matches'}{needle ? ` "${q.trim()}"` : ''}.</div>}
|
||||
</div>
|
||||
{hidden > 0 && (
|
||||
<div className="hiddenby">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
// The Record (spec 009 §4, `#record`): recognition & volunteering next to keeping up, the full versions of
|
||||
// the header's highlights. A card without content is left out; the page leaves the section out with neither.
|
||||
import type { Course, Recognition } from '../../../library/types.ts';
|
||||
import { courseSummary, formatHours } from '../../../core/courses.ts';
|
||||
import CourseSumLine from './CourseSumLine.astro';
|
||||
import { doneText, hostOf, sealSvg } from './record.ts';
|
||||
interface Props { recognition: Recognition[]; courses: Course[] }
|
||||
const { recognition, courses } = Astro.props;
|
||||
const groups = [
|
||||
{ label: 'Competitions', entries: recognition.filter((r) => r.kind === 'competition') },
|
||||
{ label: 'Volunteering', entries: recognition.filter((r) => r.kind === 'volunteering') },
|
||||
].filter((g) => g.entries.length > 0);
|
||||
const sum = courseSummary(courses);
|
||||
const STATUS = { ongoing: 'ongoing', retired: 'site retired' } as const;
|
||||
const right = (r: Recognition) => [r.result, r.status && STATUS[r.status]].filter(Boolean).join(' · ');
|
||||
---
|
||||
<section id="record">
|
||||
<div class="sh"><h2>Record</h2><span class="n">the full version of the highlights</span></div>
|
||||
<div class:list={['rec', !(groups.length > 0 && sum.count > 0) && 'single']}>
|
||||
{groups.length > 0 && (
|
||||
<div class="card">
|
||||
<h3 class="lab">Recognition & volunteering</h3>
|
||||
<div class="hl">
|
||||
{groups.map((g) => (
|
||||
<>
|
||||
<h4 class="subh">{g.label}</h4>
|
||||
{g.entries.map((r) => (
|
||||
<>
|
||||
<div>{r.url ? <a href={r.url}>{r.title}</a> : r.title}{r.year && <> <span class="y">{r.year}</span></>}{r.url && <> <span class="dt">· {hostOf(r.url)} ↗</span></>}{r.detail && <> <span class="dt">· {r.detail}</span></>}</div>
|
||||
<div class:list={['r', r.highlight && 'acc']}>{right(r)}</div>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sum.count > 0 && (
|
||||
<div class="card">
|
||||
<h3 class="lab">Keeping up</h3>
|
||||
<CourseSumLine sum={sum} />
|
||||
<table class="ku">
|
||||
<thead><tr><th scope="col">topic</th><th scope="col" class="c">done</th><th scope="col" class="c">certificates</th><th scope="col" class="c">time</th></tr></thead>
|
||||
<tbody>
|
||||
{sum.topics.map((t) => (
|
||||
<tr><td class="t">{t.topic}</td><td class="c">{doneText(t.courses, t.labs)}</td><td class="c ce">{t.certificates > 0 ? <><Fragment set:html={sealSvg(12)} /> {t.certificates}</> : '—'}</td><td class="c">{formatHours(t.minutes)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<a class="lnk acc" href="/work/learning/">All courses and certificates, searchable →</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
import PreferenceSwitch from '../PreferenceSwitch.astro';
|
||||
interface Props { name: string; onIndex: boolean; hasWriting: boolean; hasFeatured: boolean; hasList: boolean; hasCv: boolean }
|
||||
const { name, onIndex, hasWriting, hasFeatured, hasList, hasCv } = Astro.props;
|
||||
// Section links: Projects, Record, Writing, CV, each only when /work/ has that section (spec 009 §6).
|
||||
// Off /work/ itself (project, post and learning pages) they point back into /work/.
|
||||
interface Props { name: string; onIndex: boolean; hasWriting: boolean; hasFeatured: boolean; hasRecord: boolean; hasCv: boolean }
|
||||
const { name, onIndex, hasWriting, hasFeatured, hasRecord, hasCv } = Astro.props;
|
||||
const at = (hash: string) => (onIndex ? hash : `/work/${hash}`);
|
||||
const links = [
|
||||
hasFeatured && { href: at('#featured'), label: 'Featured' },
|
||||
hasList && { href: at('#all'), label: 'All projects' },
|
||||
hasFeatured && { href: at('#featured'), label: 'Projects' },
|
||||
hasRecord && { href: at('#record'), label: 'Record' },
|
||||
hasWriting && { href: at('#writing'), label: 'Writing' },
|
||||
hasCv && { href: at('#cv'), label: 'CV' },
|
||||
].filter((l): l is { href: string; label: string } => Boolean(l));
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
// The CV header at the top of /work/ (spec 009 §1): "CV in ten seconds" on the left; Highlights (from
|
||||
// recognition.yml) and Keeping up (from courses.yml) on the right. A block without content is left out, and
|
||||
// with neither the card is one column.
|
||||
import type { Course, Recognition, Site } from '../../../library/types.ts';
|
||||
import { courseSummary, monthYear } from '../../../core/courses.ts';
|
||||
import { cvRows } from './sections.ts';
|
||||
import { approxHours, headerHighlights, sealSvg } from './record.ts';
|
||||
interface Props { site: Site; recognition: Recognition[]; courses: Course[] }
|
||||
const { site, recognition, courses } = Astro.props;
|
||||
const { cv, links } = site;
|
||||
const { shown, more } = headerHighlights(recognition);
|
||||
const sum = courseSummary(courses);
|
||||
const chips = sum.topics.slice(0, 5);
|
||||
const hasSide = shown.length > 0 || sum.count > 0;
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||
---
|
||||
<section class:list={['khero', !hasSide && 'solo']} aria-label="CV in ten seconds">
|
||||
<div class="cv">
|
||||
<div class="lab"><b>●</b> CV in ten seconds</div>
|
||||
<h1>{site.name}</h1>
|
||||
{site.tagline && <p class="tg">{site.tagline}</p>}
|
||||
<div class="kv">
|
||||
{cv.now && <><span class="k">now</span><span>{cv.now}</span></>}
|
||||
{cv.before.length > 0 && <><span class="k">before</span><span>{cv.before.map((b, i) => <>{i > 0 && <br />}{b}</>)}</span></>}
|
||||
{cv.degree && <><span class="k">degree</span><span>{cv.degree}</span></>}
|
||||
{links.email && <><span class="k">contact</span><span><a href={`mailto:${links.email}`}>{links.email}</a></span></>}
|
||||
</div>
|
||||
{site.now.length > 0 && (
|
||||
<ul class="know" aria-label="Now">{site.now.map((n) => <li>{n}</li>)}</ul>
|
||||
)}
|
||||
<div class="kbtns">
|
||||
{links.cv && <a class="btn solid" href={`/media/${links.cv}`}>↓ CV (PDF)</a>}
|
||||
{cvRows(site).length > 0 && <a class="btn" href="#cv">full history ↓</a>}
|
||||
{links.github && <a class="btn" href={links.github}>GitHub</a>}
|
||||
{links.linkedin && <a class="btn" href={links.linkedin}>LinkedIn</a>}
|
||||
</div>
|
||||
</div>
|
||||
{hasSide && (
|
||||
<div class="side">
|
||||
{shown.length > 0 && (
|
||||
<div>
|
||||
<h2 class="lab">Highlights</h2>
|
||||
<div class="hl">
|
||||
{shown.map((r) => <><div>{r.title}</div><div class="r acc">{r.result}</div></>)}
|
||||
</div>
|
||||
{more > 0 && <a class="lnk" href="#record">+ {more} more in the record ↓</a>}
|
||||
</div>
|
||||
)}
|
||||
{sum.count > 0 && (
|
||||
<div>
|
||||
<h2 class="lab">Keeping up</h2>
|
||||
{sum.certificates > 0 && (
|
||||
<div class="kcount">
|
||||
<span class="bignum"><Fragment set:html={sealSvg(24)} /> {sum.certificates}</span>
|
||||
<span>{sum.certificates === 1 ? 'certificate' : 'certificates'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div class:list={['kline', sum.certificates === 0 && 'lead']}>{plural(sum.count, 'course', 'courses')} & hands-on labs · ~{approxHours(sum.minutes)} h since {monthYear(sum.since)}</div>
|
||||
<div class="chips">{chips.map((t) => <span class="chip">{t.certificates > 0 ? `${t.topic} ${t.certificates}` : t.topic}</span>)}</div>
|
||||
<a class="lnk" href="#record">all courses ↓</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -0,0 +1,21 @@
|
||||
// The courses strip under the "Also made" search (spec 009 §3): which courses match, and the count text.
|
||||
import { courseMatches, type CourseLike } from '../../../core/courses.ts';
|
||||
|
||||
/** What the island receives per course: the plain course fields, nothing more. */
|
||||
export type ListCourse = CourseLike;
|
||||
|
||||
export const STRIP_MAX = 5;
|
||||
|
||||
/** Matching courses, newest first, at most five; `more` links on to the learning page with the query. */
|
||||
export function courseStrip(courses: readonly ListCourse[], query: string) {
|
||||
const q = query.trim();
|
||||
if (!q) return { shown: [] as ListCourse[], more: 0, count: 0, href: '' };
|
||||
const hits = courses.filter((c) => courseMatches(c, q)).sort((a, b) => b.completed.localeCompare(a.completed));
|
||||
return { shown: hits.slice(0, STRIP_MAX), more: Math.max(0, hits.length - STRIP_MAX), count: hits.length, href: `/work/learning/?q=${encodeURIComponent(q)}` };
|
||||
}
|
||||
|
||||
/** "1 project · 5 courses match" (labs count as courses); '' when no course matches. */
|
||||
export function matchCountText(projects: number, courses: number): string {
|
||||
if (courses === 0) return '';
|
||||
return `${projects} ${projects === 1 ? 'project' : 'projects'} · ${courses} ${courses === 1 ? 'course matches' : 'courses match'}`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// View helpers for the CV header and the Record section (spec 009): the seal mark, highlights, hours.
|
||||
import type { Recognition } from '../../../library/types.ts';
|
||||
|
||||
/** The certificate seal, drawn in currentColor. Decorative: the text next to it always says "certificate". */
|
||||
export const sealSvg = (size: number): string =>
|
||||
`<svg class="seal" width="${size}" height="${size}" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>`;
|
||||
|
||||
export const HIGHLIGHTS_MAX = 3;
|
||||
|
||||
/** The header's highlights: highlighted entries in file order, at most three; `more` counts the rest of the record. */
|
||||
export function headerHighlights(recognition: Recognition[]): { shown: Recognition[]; more: number } {
|
||||
const shown = recognition.filter((r) => r.highlight).slice(0, HIGHLIGHTS_MAX);
|
||||
return { shown, more: recognition.length - shown.length };
|
||||
}
|
||||
|
||||
/** Whole hours for the totals ("~11 h"); never "~0". */
|
||||
export const approxHours = (minutes: number): number => Math.max(1, Math.round(minutes / 60));
|
||||
|
||||
/** "1 course", "2 courses · 3 labs"; a part that is zero is left out. */
|
||||
export function doneText(courses: number, labs: number): string {
|
||||
return [
|
||||
courses > 0 && `${courses} ${courses === 1 ? 'course' : 'courses'}`,
|
||||
labs > 0 && `${labs} ${labs === 1 ? 'lab' : 'labs'}`,
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/** The host of a link, for the small "example.org ↗" after a linked title. */
|
||||
export const hostOf = (url: string): string => new URL(url).host.replace(/^www\./, '');
|
||||
@@ -1,6 +1,6 @@
|
||||
// Splits a project's rendered Markdown body at its `<h2>` headings ("Why", "How", "What's
|
||||
// interesting", ...) so the sheet and panels can lay them out as labelled rows.
|
||||
import type { Project, Site } from '../../../library/types.ts';
|
||||
import type { Course, Project, Recognition, Site } from '../../../library/types.ts';
|
||||
import type { Post } from '../../../sources/types.ts';
|
||||
|
||||
export type Section = { title: string | null; html: string };
|
||||
@@ -43,12 +43,23 @@ export function formatDate(iso: string, style: 'short' | 'long' = 'long'): strin
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
|
||||
}
|
||||
|
||||
// The featured panels on /work/: up to four projects, by their `featured` rank.
|
||||
// The featured panels on /work/ ("Projects I'm proudest of"): up to six projects, by their `featured` rank.
|
||||
export const FEATURED_MAX = 6;
|
||||
export const featuredOf = (projects: Project[]) =>
|
||||
projects
|
||||
.filter((p) => p.featured !== undefined)
|
||||
.sort((a, b) => a.featured! - b.featured!)
|
||||
.slice(0, 4);
|
||||
.slice(0, FEATURED_MAX);
|
||||
|
||||
// Panel classes by position: wide · half · half · wide reversed · half · half. The pattern stops where the
|
||||
// projects end; a half left alone in the last row takes the full width as a wide panel.
|
||||
const FEAT_PATTERN = ['wide', '', '', 'wide rev', '', ''];
|
||||
export function featLayout(n: number): string[] {
|
||||
const out = FEAT_PATTERN.slice(0, n);
|
||||
const halvesAtEnd = out.length - 1 - out.findLastIndex((c) => c.includes('wide'));
|
||||
if (halvesAtEnd % 2 === 1) out[out.length - 1] = 'wide';
|
||||
return out;
|
||||
}
|
||||
|
||||
// "Where I've worked" rows, from site.yml's cv block only (no dates are made up here).
|
||||
export function cvRows(site: Site): { when: string; what: string }[] {
|
||||
@@ -61,8 +72,18 @@ export function cvRows(site: Site): { when: string; what: string }[] {
|
||||
}
|
||||
|
||||
// Which /work/ sections exist, so the top bar never links to an anchor that is not on the page.
|
||||
export type WorkSections = { featured: boolean; list: boolean; writing: boolean; cv: boolean };
|
||||
export function workSections(site: Site, projects: Project[], posts: Post[]): WorkSections {
|
||||
// `learning` says whether /work/learning/ is built (it needs courses).
|
||||
export type WorkSections = { featured: boolean; list: boolean; record: boolean; learning: boolean; writing: boolean; cv: boolean };
|
||||
export function workSections(
|
||||
site: Site, projects: Project[], posts: Post[], recognition: Recognition[], courses: Course[],
|
||||
): WorkSections {
|
||||
const featured = featuredOf(projects).length;
|
||||
return { featured: featured > 0, list: projects.length > featured, writing: posts.length > 0, cv: cvRows(site).length > 0 };
|
||||
return {
|
||||
featured: featured > 0,
|
||||
list: projects.length > featured,
|
||||
record: recognition.length > 0 || courses.length > 0,
|
||||
learning: courses.length > 0,
|
||||
writing: posts.length > 0,
|
||||
cv: cvRows(site).length > 0,
|
||||
};
|
||||
}
|
||||
+2
-1
@@ -20,12 +20,13 @@ export const FEEDS: Record<FeedName, { path: string; page: string; title: (siteN
|
||||
/** The `rel="alternate"` feed a page links: its path and title. */
|
||||
export const feedLink = (name: FeedName, siteName: string) => ({ path: FEEDS[name].path, title: FEEDS[name].title(siteName) });
|
||||
|
||||
type Data = Pick<SiteData, 'projects' | 'workPosts' | 'personal' | 'issues'>;
|
||||
type Data = Pick<SiteData, 'projects' | 'workPosts' | 'personal' | 'issues' | 'courses'>;
|
||||
|
||||
/** Every page the site builds except the 404, root-relative with the trailing slash it is served with. */
|
||||
export function sitePaths(data: Data): string[] {
|
||||
return [
|
||||
'/', '/work/', '/off-the-clock/', '/off-the-clock/issues/', '/everything/', '/simple/',
|
||||
...(data.courses.length > 0 ? ['/work/learning/'] : []),
|
||||
...data.projects.map((p) => `/work/${p.id}/`),
|
||||
...data.workPosts.map((p) => `/work/writing/${p.slug}/`),
|
||||
...data.personal.map((e) => `/off-the-clock/${'slug' in e ? e.slug : e.id}/`),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
// Frame shared by every page of the work space: plum edge, top bar, footer.
|
||||
// Frame shared by every page of the work space: plum edge, top bar, footer (no Bluesky here since spec 009;
|
||||
// the off-the-clock footer and /simple keep it).
|
||||
import Base from './Base.astro';
|
||||
import TopBar from '../components/work/TopBar.astro';
|
||||
import EdgeToPersonal from '../components/work/EdgeToPersonal.astro';
|
||||
@@ -21,7 +22,7 @@ const { links } = site;
|
||||
---
|
||||
<Base title={title} description={description} theme="work" type={type} image={image} feed="work">
|
||||
<EdgeToPersonal />
|
||||
<TopBar name={site.name} onIndex={onIndex} hasFeatured={sections.featured} hasList={sections.list} hasWriting={sections.writing} hasCv={sections.cv} />
|
||||
<TopBar name={site.name} onIndex={onIndex} hasFeatured={sections.featured} hasRecord={sections.record} hasWriting={sections.writing} hasCv={sections.cv} />
|
||||
<main class="work">
|
||||
<slot />
|
||||
<footer>
|
||||
@@ -30,7 +31,6 @@ const { links } = site;
|
||||
<a href="/simple/">/simple</a>
|
||||
{links.github && <a href={links.github}>GitHub</a>}
|
||||
{links.linkedin && <a href={links.linkedin}>LinkedIn</a>}
|
||||
{links.bluesky && <a href={links.bluesky}>Bluesky</a>}
|
||||
{links.email && <a href={`mailto:${links.email}`}>{links.email}</a>}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function getStaticPaths() {
|
||||
}
|
||||
|
||||
const { project } = Astro.props as { project: Project };
|
||||
const { site, projects, workPosts } = await getSiteData();
|
||||
const { site, projects, workPosts, recognition, courses } = await getSiteData();
|
||||
---
|
||||
<WorkLayout
|
||||
title={`${project.title} · ${site.name}`}
|
||||
@@ -19,7 +19,7 @@ const { site, projects, workPosts } = await getSiteData();
|
||||
type="article"
|
||||
image={project.image}
|
||||
site={site}
|
||||
sections={workSections(site, projects, workPosts)}
|
||||
sections={workSections(site, projects, workPosts, recognition, courses)}
|
||||
>
|
||||
<article class="wrap page">
|
||||
<a class="up" href="/work/">← all work</a>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
---
|
||||
import WorkLayout from '../../layouts/Work.astro';
|
||||
import AiMark from '../../components/AiMark.astro';
|
||||
import NowStrip from '../../components/work/NowStrip.astro';
|
||||
import WorkHeader from '../../components/work/WorkHeader.astro';
|
||||
import Featured from '../../components/work/Featured.astro';
|
||||
import ProjectList, { type ListItem } from '../../components/work/ProjectList.tsx';
|
||||
import type { ListCourse } from '../../components/work/course-search.ts';
|
||||
import ProjectSheet from '../../components/work/ProjectSheet.tsx';
|
||||
import ProjectBody from '../../components/work/ProjectBody.astro';
|
||||
import Record from '../../components/work/Record.astro';
|
||||
import WorkHistory from '../../components/work/WorkHistory.astro';
|
||||
import { hasShowcase, thumbSvg } from '../../components/showcases/index.ts';
|
||||
import { featuredOf, formatDate, topicTag, workSections } from '../../components/work/sections.ts';
|
||||
import { getSiteData } from '../../data';
|
||||
import { isVisible } from '../../../core/ai.ts';
|
||||
|
||||
const { site, projects, workPosts } = await getSiteData();
|
||||
const { site, projects, workPosts, recognition, courses } = await getSiteData();
|
||||
const featured = featuredOf(projects);
|
||||
const rest = projects.filter((p) => !featured.includes(p));
|
||||
const items: ListItem[] = rest.map((p) => ({
|
||||
@@ -21,10 +22,13 @@ const items: ListItem[] = rest.map((p) => ({
|
||||
...(p.years && { years: p.years }),
|
||||
...(p.image && !hasShowcase(p.showcase) && { image: p.image }),
|
||||
}));
|
||||
// The island gets only what the course search needs (no urls).
|
||||
const listCourses: ListCourse[] = courses.map(({ title, provider, kind, topic, completed, minutes, certificate, keywords }) => ({
|
||||
title, provider, kind, topic, completed, minutes, certificate, keywords,
|
||||
}));
|
||||
const titles = Object.fromEntries(projects.map((p) => [p.id, p.title]));
|
||||
const blog = workPosts[0] ? new URL(workPosts[0].url).origin : undefined;
|
||||
const [lead, more] = site.intro;
|
||||
const sections = workSections(site, projects, workPosts);
|
||||
const sections = workSections(site, projects, workPosts, recognition, courses);
|
||||
// Post counts per preference, so the Writing card never claims posts the filter hides.
|
||||
const plural = (n: number) => `${n} ${n === 1 ? 'post' : 'posts'}`;
|
||||
const postCounts: [string, number][] = [
|
||||
@@ -41,23 +45,13 @@ const postCounts: [string, number][] = [
|
||||
sections={sections}
|
||||
>
|
||||
<div class="wrap">
|
||||
<section class="intro">
|
||||
<div>
|
||||
<div class="eyebrow"><b>●</b> work · projects, writing, cv</div>
|
||||
{lead ? <h1>{lead}{more && <> <em>{more}</em></>}</h1> : <h1 class="sr-only">{site.name}</h1>}
|
||||
<p>Every project is marked with how much of it was made with AI, and the filter at the top hides what you would rather not see.</p>
|
||||
</div>
|
||||
<div class="legend" aria-label="Legend">
|
||||
<div><AiMark level="ai-made" /><AiMark level="some-ai" /></div>
|
||||
<div><AiMark level="by-hand" /><AiMark level="unknown" /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NowStrip site={site} posts={workPosts} />
|
||||
<WorkHeader site={site} recognition={recognition} courses={courses} />
|
||||
|
||||
{featured.length > 0 && <Featured projects={featured} />}
|
||||
|
||||
{rest.length > 0 && <ProjectList client:idle items={items} title={featured.length > 0 ? 'Everything else' : 'All projects'} />}
|
||||
{rest.length > 0 && <ProjectList client:idle items={items} title="Also made" courses={listCourses} />}
|
||||
|
||||
{sections.record && <Record recognition={recognition} courses={courses} />}
|
||||
|
||||
{(sections.writing || sections.cv) && (
|
||||
<div class:list={['lower', !(sections.writing && sections.cv) && 'single']}>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
// /work/learning/ (spec 009 §5): every course and lab, newest first, with search and topic filters.
|
||||
// A rest route so the page can be left out: without courses getStaticPaths returns nothing and no page is built.
|
||||
// Complete without JavaScript; scripts/learning-filter.ts only hides rows.
|
||||
import WorkLayout from '../../../layouts/Work.astro';
|
||||
import CourseSumLine from '../../../components/work/CourseSumLine.astro';
|
||||
import { workSections } from '../../../components/work/sections.ts';
|
||||
import { sealSvg } from '../../../components/work/record.ts';
|
||||
import { courseSearchText, courseSummary, formatHours } from '../../../../core/courses.ts';
|
||||
import { formatDate } from '../../../components/work/sections.ts';
|
||||
import { getSiteData } from '../../../data';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const { courses } = await getSiteData();
|
||||
return courses.length > 0 ? [{ params: { path: undefined } }] : [];
|
||||
}
|
||||
|
||||
const { site, projects, workPosts, recognition, courses } = await getSiteData();
|
||||
const sum = courseSummary(courses);
|
||||
const rows = [...courses].sort((a, b) => b.completed.localeCompare(a.completed) || a.title.localeCompare(b.title));
|
||||
---
|
||||
<WorkLayout
|
||||
title={`Courses & certificates · ${site.name}`}
|
||||
description={`Courses, hands-on labs and certificates ${site.name} has completed.`}
|
||||
site={site}
|
||||
sections={workSections(site, projects, workPosts, recognition, courses)}
|
||||
>
|
||||
<div class="wrap page learning">
|
||||
<a class="up" href="/work/#record">← back to work</a>
|
||||
<h1>Courses & certificates</h1>
|
||||
<CourseSumLine sum={sum} />
|
||||
<div class="tools ltools">
|
||||
<div class="tabs" role="group" aria-label="Filter by topic">
|
||||
<button type="button" data-topic="" aria-pressed="true">All<small>{courses.length}</small></button>
|
||||
{sum.topics.map((t) => <button type="button" data-topic={t.topic} aria-pressed="false">{t.topic}<small>{t.courses + t.labs}</small></button>)}
|
||||
</div>
|
||||
<span class="lshown" data-shown aria-live="polite"></span>
|
||||
<label class="search">
|
||||
<input type="search" data-course-search placeholder="Search course, provider, topic" aria-label="Search courses" />
|
||||
</label>
|
||||
</div>
|
||||
<table class="ltable">
|
||||
<thead><tr><th scope="col">completed</th><th scope="col">course</th><th scope="col">topic</th><th scope="col">provider</th><th scope="col" class="c">certificate</th><th scope="col" class="c">time</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((c) => (
|
||||
<tr data-course data-topic={c.topic} data-search={courseSearchText(c)}>
|
||||
<td class="d"><time datetime={c.completed}>{formatDate(c.completed)}</time></td>
|
||||
<td class="ct">{c.url ? <a href={c.url}>{c.title}</a> : c.title}{c.kind === 'lab' && <span class="tag">lab</span>}</td>
|
||||
<td class="tp">{c.topic}</td>
|
||||
<td class="pv">{c.provider}</td>
|
||||
<td class="c ce">{c.certificate ? <><Fragment set:html={sealSvg(13)} /><span class="sr-only">certificate</span></> : <span aria-hidden="true">—</span><span class="sr-only">no certificate</span>}</td>
|
||||
<td class="c">{formatHours(c.minutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="none" data-none hidden>No course matches.</p>
|
||||
</div>
|
||||
<script>
|
||||
import { initLearningFilter } from '../../../scripts/learning-filter.ts';
|
||||
initLearningFilter();
|
||||
</script>
|
||||
</WorkLayout>
|
||||
@@ -11,7 +11,7 @@ export async function getStaticPaths() {
|
||||
}
|
||||
|
||||
const { post } = Astro.props as { post: Post };
|
||||
const { site, projects, workPosts } = await getSiteData();
|
||||
const { site, projects, workPosts, recognition, courses } = await getSiteData();
|
||||
const topic = topicTag(post.tags);
|
||||
---
|
||||
<WorkLayout
|
||||
@@ -19,7 +19,7 @@ const topic = topicTag(post.tags);
|
||||
description={post.excerpt || post.title}
|
||||
type="article"
|
||||
site={site}
|
||||
sections={workSections(site, projects, workPosts)}
|
||||
sections={workSections(site, projects, workPosts, recognition, courses)}
|
||||
>
|
||||
<article class="wrap page post">
|
||||
<a class="up" href="/work/#writing">← all writing</a>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// /work/learning/: filters the course table by search text and topic button, and reads `?q=` on load.
|
||||
// The page is complete without it: every row is in the HTML and visible. Rows that do not match get
|
||||
// `hidden`; "No course matches" shows when none do. Plain script, no React (spec 009 §5).
|
||||
|
||||
export type Row = { search: string; topic: string };
|
||||
|
||||
/** Search is a case-insensitive substring of the row's search text; an empty topic means all topics. */
|
||||
export function rowMatches(row: Row, query: string, topic: string): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (!topic || row.topic === topic) && (!q || row.search.includes(q));
|
||||
}
|
||||
|
||||
/* ---------- browser ---------- */
|
||||
|
||||
export function initLearningFilter(root: ParentNode = document): void {
|
||||
const input = root.querySelector<HTMLInputElement>('[data-course-search]');
|
||||
const buttons = [...root.querySelectorAll<HTMLButtonElement>('button[data-topic]')];
|
||||
const rows = [...root.querySelectorAll<HTMLElement>('[data-course]')];
|
||||
const none = root.querySelector<HTMLElement>('[data-none]');
|
||||
const count = root.querySelector<HTMLElement>('[data-shown]');
|
||||
if (!input) return;
|
||||
let topic = '';
|
||||
|
||||
const apply = () => {
|
||||
let shown = 0;
|
||||
for (const r of rows) {
|
||||
const ok = rowMatches({ search: r.dataset.search ?? '', topic: r.dataset.topic ?? '' }, input.value, topic);
|
||||
r.hidden = !ok;
|
||||
if (ok) shown++;
|
||||
}
|
||||
if (none) none.hidden = shown > 0;
|
||||
if (count) count.textContent = shown === rows.length ? '' : `${shown} of ${rows.length} shown`;
|
||||
};
|
||||
|
||||
const q = new URLSearchParams(location.search).get('q');
|
||||
if (q) input.value = q;
|
||||
input.addEventListener('input', apply);
|
||||
for (const b of buttons) {
|
||||
b.addEventListener('click', () => {
|
||||
topic = b.dataset.topic ?? '';
|
||||
for (const o of buttons) o.setAttribute('aria-pressed', String(o === b));
|
||||
apply();
|
||||
});
|
||||
}
|
||||
apply();
|
||||
}
|
||||
+124
-33
@@ -46,34 +46,35 @@
|
||||
main.work { margin-right: var(--edge); }
|
||||
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 40px; }
|
||||
|
||||
/* intro + now strip */
|
||||
.intro { padding: 32px 0 22px; display: grid; grid-template-columns: 1fr auto; gap: 40px; align-items: end; }
|
||||
.eyebrow { font: 12px var(--mono); color: var(--mute); letter-spacing: .02em; }
|
||||
.eyebrow b { color: var(--acc); font-weight: 500; }
|
||||
.intro h1 { font-size: 34px; line-height: 1.08; letter-spacing: -.028em; font-weight: 600; margin: 12px 0 0; max-width: 20em; }
|
||||
.intro h1 em { font-style: normal; color: var(--mute); }
|
||||
.intro p { margin: 10px 0 0; color: var(--ink2); max-width: 44em; font-size: 16px; }
|
||||
.legend { display: flex; flex-direction: column; gap: 7px; padding-bottom: 4px; }
|
||||
.legend > div { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
/* CV header (spec 009): CV in ten seconds | highlights + keeping up */
|
||||
.khero { display: grid; grid-template-columns: 1.35fr 1fr; border: 1px solid var(--line); border-radius: 18px; background: var(--panel); margin: 34px 0 48px; overflow: hidden; }
|
||||
.khero.solo { grid-template-columns: 1fr; }
|
||||
.khero .cv { padding: 32px 36px; min-width: 0; }
|
||||
.khero .side { border-left: 1px solid var(--line); padding: 28px 30px; background: var(--bg2); display: flex; flex-direction: column; gap: 24px; min-width: 0; }
|
||||
.khero h1 { font-size: 36px; line-height: 1.08; margin: 10px 0 6px; font-weight: 600; letter-spacing: -.02em; }
|
||||
.khero .tg { color: var(--mute); margin: 0 0 24px; font-size: 16px; }
|
||||
.lab { margin: 0 0 6px; font: 500 11px var(--mono); letter-spacing: .09em; color: var(--mute); text-transform: uppercase; }
|
||||
.lab b { color: var(--acc); font-weight: 500; }
|
||||
.kv { display: grid; grid-template-columns: 78px 1fr; gap: 9px 16px; font-size: 15px; }
|
||||
.kv .k { font: 11.5px var(--mono); color: var(--mute); padding-top: 3px; }
|
||||
.know { margin: 16px 0 0; padding: 0; list-style: none; font-size: 14px; color: var(--ink2); }
|
||||
.know li { padding: 2px 0; display: flex; gap: 8px; }
|
||||
.know li::before { content: ""; flex: 0 0 5px; height: 5px; margin-top: 9px; border-radius: 50%; background: var(--dim); }
|
||||
.know li:first-child::before { background: var(--acc); }
|
||||
.kbtns { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 22px; }
|
||||
.hl { display: grid; grid-template-columns: 1fr auto; gap: 0; font-size: 14.5px; }
|
||||
.hl > div { padding: 9px 0; border-top: 1px solid var(--line); min-width: 0; }
|
||||
.hl .r { font: 12px var(--mono); color: var(--mute); text-align: right; padding-left: 14px; }
|
||||
.hl .acc, .acc { color: var(--acc); }
|
||||
.seal { display: inline-block; vertical-align: -2px; flex: none; }
|
||||
.kcount { display: flex; align-items: baseline; gap: 12px; margin-top: 6px; }
|
||||
.bignum { font-size: 34px; font-weight: 600; line-height: 1; letter-spacing: -.02em; color: var(--acc); }
|
||||
.kline { font: 12px var(--mono); color: var(--mute); margin-top: 8px; }
|
||||
.kline.lead { font: 15px var(--sans); color: var(--ink2); margin-top: 6px; }
|
||||
.khero .chips { margin-top: 12px; }
|
||||
.lnk { font: 12px var(--mono); color: var(--mute); text-decoration: none; display: inline-block; margin-top: 10px; }
|
||||
.lnk:hover { color: var(--ink); }
|
||||
|
||||
.now { display: flex; gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; margin-bottom: 28px; }
|
||||
.now > div { flex: var(--grow, 1) 1 0; background: var(--bg2); padding: 14px 20px; min-width: 0; }
|
||||
.now h2 { margin: 0 0 8px; font: 500 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--mute); display: flex; justify-content: space-between; gap: 8px; }
|
||||
.now h2 a { text-decoration: none; color: var(--mute); }
|
||||
.now h2 a:hover { color: var(--ink); }
|
||||
.now ul { margin: 0; padding: 0; list-style: none; font-size: 14px; }
|
||||
.now li { padding: 2px 0; color: var(--ink2); display: flex; gap: 8px; }
|
||||
.now li::before { content: ""; flex: 0 0 5px; height: 5px; margin-top: 9px; border-radius: 50%; background: var(--dim); }
|
||||
.now li:first-child::before { background: var(--acc); }
|
||||
.wl a { display: block; text-decoration: none; padding: 2px 0; }
|
||||
.wl a .t { color: var(--ink); font-size: 14px; }
|
||||
.wl a:hover .t { color: var(--acc); }
|
||||
.wl a .d { font: 11px var(--mono); color: var(--dim); margin-left: 6px; }
|
||||
.wl a.lead .t { font-weight: 500; }
|
||||
.cv10 { display: grid; grid-template-columns: 70px 1fr; gap: 3px 12px; font-size: 13.5px; color: var(--ink2); }
|
||||
.cv10 .k { font: 11px var(--mono); color: var(--dim); padding-top: 2px; }
|
||||
.cv10 .v b { color: var(--ink); font-weight: 500; }
|
||||
.cvlinks { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; font: 12px var(--mono); text-decoration: none; color: var(--ink2); border: 1px solid var(--line2); border-radius: 6px; padding: 3px 9px; background: none; cursor: pointer; transition: border-color .15s, color .15s, background .15s; }
|
||||
.btn:hover { color: var(--ink); border-color: var(--mute); }
|
||||
.btn.solid { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
||||
@@ -88,6 +89,12 @@ main.work { margin-right: var(--edge); }
|
||||
.sh .n { font: 12px var(--mono); color: var(--mute); }
|
||||
.sh .r { margin-left: auto; font: 12px var(--mono); color: var(--mute); text-decoration: none; }
|
||||
a.r:hover { color: var(--acc); }
|
||||
.sh h2 { white-space: nowrap; }
|
||||
/* The page's AI explanation, at the right of the "Projects I'm proudest of" heading, with the hint under it. */
|
||||
.fside { margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; align-self: flex-end; }
|
||||
.sh .hint { font: 12px var(--mono); color: var(--dim); }
|
||||
.ailegend { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; font: 12px var(--mono); color: var(--mute); }
|
||||
.ailegend .marks { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
|
||||
/* featured panels */
|
||||
.feats { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
@@ -147,9 +154,65 @@ html:not(.js) .idx .tools { display: none; }
|
||||
.row .go { color: var(--dim); transition: transform .2s var(--ease), color .15s; }
|
||||
.row:hover .go { color: var(--acc); }
|
||||
@media (hover: hover) { .row:hover .go { transform: translateX(3px); } }
|
||||
/* courses matching the list search (spec 009 §3) */
|
||||
.cstrip { border: 1px solid var(--line); border-radius: 10px; background: var(--bg2); padding: 4px 14px 8px; margin: 0 0 14px; }
|
||||
.crow { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 10px; align-items: baseline; padding: 7px 0; border-top: 1px solid var(--line); font-size: 14px; }
|
||||
.crow:first-child { border-top: 0; }
|
||||
.cseal { color: var(--acc); }
|
||||
.ct .tag { margin-left: 8px; font: 10.5px var(--mono); color: var(--mute); border: 1px solid var(--line2); border-radius: 4px; padding: 0 5px; vertical-align: 1px; }
|
||||
.cm { font: 12px var(--mono); color: var(--mute); white-space: nowrap; }
|
||||
.cmore { display: inline-block; margin-top: 4px; font: 12px var(--mono); color: var(--acc); text-decoration: none; }
|
||||
.cmore:hover { text-decoration: underline; }
|
||||
|
||||
.empty { padding: 18px 8px; font: 13px var(--mono); color: var(--mute); border-bottom: 1px solid var(--line); }
|
||||
.hiddenby { font: 12px var(--mono); color: var(--mute); padding: 10px 8px; }
|
||||
|
||||
/* record (spec 009 §4) */
|
||||
#record { margin-top: 70px; }
|
||||
.rec { display: grid; grid-template-columns: 1fr 1.15fr; gap: 18px; }
|
||||
.rec.single { grid-template-columns: 1fr; }
|
||||
.rec .card { min-width: 0; }
|
||||
.rec .hl { margin-top: 8px; }
|
||||
.subh { margin: 0; font: 500 10px var(--mono); letter-spacing: .08em; color: var(--mute); text-transform: uppercase; padding: 16px 0 4px; grid-column: 1 / -1; }
|
||||
.rec .hl > .subh { border-top: 0; padding-top: 16px; }
|
||||
.rec .hl > .subh:first-child { padding-top: 4px; }
|
||||
.rec .y, .rec .dt { font: 11px var(--mono); color: var(--mute); }
|
||||
.rec .hl a { color: inherit; text-decoration-color: var(--line2); text-underline-offset: 3px; }
|
||||
.rec .hl a:hover { color: var(--acc); }
|
||||
.sumline { font-size: 14px; color: var(--ink2); margin: 6px 0 14px; }
|
||||
.sumline b { color: var(--ink); font-weight: 500; }
|
||||
.sumline b.acc { color: var(--acc); }
|
||||
.ku { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.ku th { font: 500 10px var(--mono); letter-spacing: .08em; color: var(--mute); text-transform: uppercase; text-align: left; padding: 0 0 8px; }
|
||||
.ku td { padding: 9px 0; border-top: 1px solid var(--line); }
|
||||
.ku .c { font-family: var(--mono); font-size: 12px; color: var(--mute); text-align: right; white-space: nowrap; padding-left: 18px; }
|
||||
.ku th.c { font-size: 10px; }
|
||||
.ku .ce { color: var(--ink); }
|
||||
.rec .lnk.acc { color: var(--acc); }
|
||||
.rec .lnk.acc:hover { text-decoration: underline; }
|
||||
|
||||
/* /work/learning/ (spec 009 §5) */
|
||||
.page.learning { max-width: 1100px; }
|
||||
.learning h1 { font-size: 34px; letter-spacing: -.025em; line-height: 1.1; margin: 22px 0 8px; font-weight: 600; }
|
||||
.learning .sumline { margin-bottom: 22px; }
|
||||
html:not(.js) .ltools { display: none; }
|
||||
.ltools .tabs { flex-wrap: wrap; }
|
||||
.ltools .tabs button { white-space: nowrap; }
|
||||
.lshown { font: 12px var(--mono); color: var(--mute); }
|
||||
.ltable { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.ltable th { font: 500 10px var(--mono); letter-spacing: .08em; color: var(--mute); text-transform: uppercase; text-align: left; padding: 0 12px 8px 0; }
|
||||
.ltable td { padding: 9px 12px 9px 0; border-top: 1px solid var(--line); vertical-align: baseline; }
|
||||
.ltable .d, .ltable .pv, .ltable .tp { font: 12px var(--mono); color: var(--mute); white-space: nowrap; }
|
||||
.ltable .tp { color: var(--ink2); }
|
||||
.ltable .ct a { color: var(--ink); text-decoration-color: var(--line2); text-underline-offset: 3px; }
|
||||
.ltable .ct a:hover { color: var(--acc); }
|
||||
.ltable .ct .tag { margin-left: 8px; font: 10.5px var(--mono); color: var(--mute); border: 1px solid var(--line2); border-radius: 4px; padding: 0 5px; vertical-align: 1px; }
|
||||
.ltable .c { font: 12px var(--mono); color: var(--mute); text-align: right; white-space: nowrap; padding-right: 0; padding-left: 12px; }
|
||||
.ltable th.c { font-size: 10px; }
|
||||
.ltable .ce { color: var(--acc); }
|
||||
.ltable .ce [aria-hidden] { color: var(--dim); }
|
||||
.learning .none { font: 13px var(--mono); color: var(--mute); padding: 14px 0; }
|
||||
|
||||
/* writing + history */
|
||||
.lower { margin-top: 64px; display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.lower.single { grid-template-columns: 1fr; }
|
||||
@@ -234,6 +297,10 @@ html[data-sheet-open] { overflow: hidden; }
|
||||
[data-pref='by-hand-only'] .pref-note:not([data-level='by-hand']),
|
||||
[data-pref='not-fully-ai'] .pref-note[data-level='ai-made'] { display: block; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.khero, .rec { grid-template-columns: 1fr; }
|
||||
.khero .side { border-left: 0; border-top: 1px solid var(--line); }
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.nav { display: none; }
|
||||
.feat.wide, .feat.wide.rev { grid-template-columns: 1fr; }
|
||||
@@ -249,23 +316,47 @@ html[data-sheet-open] { overflow: hidden; }
|
||||
.bar .pref-label { display: none; }
|
||||
.bar .pref-archive { position: static; flex-basis: 100%; white-space: normal; margin-top: 6px; }
|
||||
.edge { font-size: 13px; }
|
||||
.intro { grid-template-columns: 1fr; }
|
||||
.legend > div { justify-content: flex-start; }
|
||||
.intro h1 { font-size: 30px; }
|
||||
.now { flex-direction: column; }
|
||||
.now > div { flex: none; }
|
||||
.khero { margin-top: 20px; }
|
||||
.khero .cv, .khero .side { padding: 24px 20px; }
|
||||
.khero h1 { font-size: 30px; }
|
||||
.kv { grid-template-columns: 64px 1fr; }
|
||||
.feats { grid-template-columns: 1fr; }
|
||||
.row { grid-template-columns: 56px 1fr 20px; gap: 12px; }
|
||||
.row .one, .row .ai-mark { display: none; }
|
||||
.crow { grid-template-columns: 18px minmax(0, 1fr); }
|
||||
.cm { grid-column: 2; white-space: normal; }
|
||||
.search { margin-left: 0; width: 100%; }
|
||||
.search input { width: 100%; }
|
||||
.lower { grid-template-columns: 1fr; }
|
||||
.posts a { grid-template-columns: 1fr; gap: 2px; }
|
||||
.sh .r { display: none; }
|
||||
.sh .r, .sh .hint { display: none; }
|
||||
.fside { margin-left: 0; flex-basis: 100%; align-items: flex-start; }
|
||||
.ailegend { justify-content: flex-start; }
|
||||
.sh:has(.fside) { flex-wrap: wrap; }
|
||||
.pbody section { grid-template-columns: 1fr; gap: 6px; }
|
||||
.hl { grid-template-columns: 1fr; }
|
||||
.hl .r { text-align: left; padding: 0 0 9px; border-top: 0; }
|
||||
.hl > div:not(.r) { padding-bottom: 2px; }
|
||||
.ku th:nth-child(2), .ku td:nth-child(2) { display: none; }
|
||||
.ku .c { padding-left: 12px; }
|
||||
.ltable thead { display: none; }
|
||||
.ltable tr { display: grid; grid-template-columns: 1fr auto auto; gap: 2px 12px; padding: 10px 0; border-top: 1px solid var(--line); }
|
||||
.ltable tr[hidden] { display: none; }
|
||||
.ltable td { border-top: 0; padding: 0; }
|
||||
.ltable .ct { grid-column: 1 / -1; order: -1; }
|
||||
.ltable .tp, .ltable .pv { display: none; }
|
||||
.ltable .d { grid-column: 1; }
|
||||
.page .pbody h1, .post h1 { font-size: 30px; }
|
||||
}
|
||||
|
||||
/* Screenshots are muted (half saturation, 80% brightness) until the pointer is over their panel or row, or
|
||||
focus is within; drawn showcases keep their colour. base.css drops the transition under reduced motion. */
|
||||
.feat .s-image img { object-position: top center; filter: saturate(.5) brightness(.8); transition: filter .3s var(--ease); }
|
||||
.feat:hover .s-image img, .feat:focus-within .s-image img { filter: none; }
|
||||
.feat.wide .s-image { min-height: 380px; }
|
||||
.row .th-image img { filter: saturate(.5) brightness(.8); transition: filter .3s var(--ease); }
|
||||
.row:hover .th-image img, .row:focus-visible .th-image img { filter: none; }
|
||||
|
||||
/* project images (stand in for a showcase; contain so screenshots are never cropped oddly) */
|
||||
.row .th-image { overflow: hidden; }
|
||||
.row .th-image img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ const cli = (...args: string[]) => Bun.spawnSync(['bun', 'src/cli.ts', ...args],
|
||||
it('validate passes on the fixture content', () => {
|
||||
const r = cli('validate', 'fixtures/content');
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout.toString()).toContain('Content OK (5 projects, 14 items)');
|
||||
expect(r.stdout.toString()).toContain('Content OK (9 projects, 14 items)');
|
||||
});
|
||||
|
||||
it('validate lists every error and exits 1', () => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { courseMatches, courseSummary, formatHours, monthYear, type CourseLike } from '../../src/core/courses.ts';
|
||||
|
||||
const c = (over: Partial<CourseLike>): CourseLike => ({
|
||||
title: 'T', provider: 'P', kind: 'course', topic: 'A', completed: '2025-03-01', minutes: 60, certificate: false,
|
||||
keywords: [], ...over,
|
||||
});
|
||||
|
||||
describe('courseSummary', () => {
|
||||
test('counts, certificates, minutes, earliest month and topics by time', () => {
|
||||
const s = courseSummary([
|
||||
c({ topic: 'Beta', minutes: 100, certificate: true, completed: '2025-04-02' }),
|
||||
c({ topic: 'Alpha', minutes: 30, kind: 'lab', completed: '2025-02-20' }),
|
||||
c({ topic: 'Alpha', minutes: 70, certificate: true, completed: '2025-05-01' }),
|
||||
c({ topic: 'Gamma', minutes: 300, kind: 'lab', certificate: true, completed: '2025-06-01' }),
|
||||
]);
|
||||
expect(s).toEqual({
|
||||
count: 4, certificates: 3, minutes: 500, since: '2025-02',
|
||||
topics: [
|
||||
{ topic: 'Gamma', courses: 0, labs: 1, certificates: 1, minutes: 300 },
|
||||
{ topic: 'Alpha', courses: 1, labs: 1, certificates: 1, minutes: 100 },
|
||||
{ topic: 'Beta', courses: 1, labs: 0, certificates: 1, minutes: 100 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('no courses', () => {
|
||||
expect(courseSummary([])).toEqual({ count: 0, certificates: 0, minutes: 0, since: '', topics: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('courseMatches', () => {
|
||||
const course = c({ title: 'Containers from Scratch', provider: 'Sample School', topic: 'Docker', keywords: ['Images'] });
|
||||
test('case-insensitive over title, provider, topic and keywords', () => {
|
||||
expect(courseMatches(course, 'scratch')).toBe(true);
|
||||
expect(courseMatches(course, 'SAMPLE')).toBe(true);
|
||||
expect(courseMatches(course, 'docker')).toBe(true);
|
||||
expect(courseMatches(course, 'images')).toBe(true);
|
||||
expect(courseMatches(course, ' images ')).toBe(true);
|
||||
expect(courseMatches(course, 'kotlin')).toBe(false);
|
||||
});
|
||||
test('an empty query matches nothing', () => {
|
||||
expect(courseMatches(course, '')).toBe(false);
|
||||
expect(courseMatches(course, ' ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('formatHours gives one decimal', () => {
|
||||
expect(formatHours(564)).toBe('9.4h');
|
||||
expect(formatHours(24)).toBe('0.4h');
|
||||
expect(formatHours(60)).toBe('1.0h');
|
||||
});
|
||||
|
||||
test('monthYear', () => {
|
||||
expect(monthYear('2025-02')).toBe('Feb 2025');
|
||||
expect(monthYear('2024-12-31')).toBe('Dec 2024');
|
||||
expect(monthYear('')).toBe('');
|
||||
});
|
||||
@@ -13,7 +13,7 @@ describe('loadContent', () => {
|
||||
test('loads the fixture content', async () => {
|
||||
const { content, errors } = await loadContent(`${FIXTURES}/content`);
|
||||
expect(errors).toEqual([]);
|
||||
expect(content!.projects).toHaveLength(5);
|
||||
expect(content!.projects).toHaveLength(9);
|
||||
expect(content!.items).toHaveLength(14);
|
||||
expect(content!.interests.map((i) => i.id)).toEqual([
|
||||
'comics', 'games', 'music', 'film-tv', 'books', 'made-by-me', 'writing', 'life',
|
||||
@@ -23,6 +23,9 @@ describe('loadContent', () => {
|
||||
expect(dates).toEqual([...dates].sort().reverse());
|
||||
expect(content!.items.find((i) => i.id === 'edge-long-title')!.title).toHaveLength(140);
|
||||
expect(content!.site.issues.notes['2025-h2']).toContain('editor');
|
||||
expect(content!.recognition).toHaveLength(6);
|
||||
expect(content!.recognition.filter((r) => r.highlight)).toHaveLength(4);
|
||||
expect(content!.courses).toHaveLength(11);
|
||||
});
|
||||
|
||||
test('loads the sparse fixtures', async () => {
|
||||
@@ -30,6 +33,8 @@ describe('loadContent', () => {
|
||||
expect(errors).toEqual([]);
|
||||
expect(content!.projects).toHaveLength(1);
|
||||
expect(content!.items).toHaveLength(0);
|
||||
expect(content!.recognition).toEqual([]);
|
||||
expect(content!.courses).toEqual([]);
|
||||
});
|
||||
|
||||
test('accepts contract 2', async () => {
|
||||
@@ -271,3 +276,92 @@ describe('loadContent', () => {
|
||||
expect(r.content!.items[0]!.bodyHtml).toContain('<table>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recognition.yml and courses.yml', () => {
|
||||
const rec = [
|
||||
'- { title: Hack One, kind: competition, result: winner, highlight: true, year: 2019 }',
|
||||
'- { title: Helping Out, kind: volunteering, result: IT & website, url: "https://example.invalid/h", status: retired, detail: two years }',
|
||||
'- { title: Hack Two, kind: competition, result: top 10%, year: 2021 }',
|
||||
].join('\n');
|
||||
const courses = [
|
||||
'- { title: Course A, provider: Prov, kind: course, topic: Topic, completed: 2025-04-18, minutes: 90, certificate: true, keywords: [alpha, beta] }',
|
||||
'- { title: Lab B, provider: Prov, kind: lab, topic: Topic, completed: 2025-02-01, minutes: 30, url: "https://example.invalid/b" }',
|
||||
].join('\n');
|
||||
|
||||
test('both files load; recognition keeps file order', async () => {
|
||||
const { content, errors } = await load({ 'recognition.yml': rec, 'courses.yml': courses });
|
||||
expect(errors).toEqual([]);
|
||||
expect(content!.recognition.map((r) => r.title)).toEqual(['Hack One', 'Helping Out', 'Hack Two']);
|
||||
expect(content!.recognition[0]).toEqual({ title: 'Hack One', kind: 'competition', result: 'winner', highlight: true, year: '2019' });
|
||||
expect(content!.recognition[1]).toEqual({
|
||||
title: 'Helping Out', kind: 'volunteering', result: 'IT & website', highlight: false,
|
||||
detail: 'two years', url: 'https://example.invalid/h', status: 'retired',
|
||||
});
|
||||
expect(content!.courses).toHaveLength(2);
|
||||
expect(content!.courses[0]).toEqual({
|
||||
title: 'Course A', provider: 'Prov', kind: 'course', topic: 'Topic', completed: '2025-04-18', minutes: 90,
|
||||
certificate: true, keywords: ['alpha', 'beta'],
|
||||
});
|
||||
expect(content!.courses[1]).toEqual({
|
||||
title: 'Lab B', provider: 'Prov', kind: 'lab', topic: 'Topic', completed: '2025-02-01', minutes: 30,
|
||||
certificate: false, url: 'https://example.invalid/b', keywords: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('missing files mean empty lists', async () => {
|
||||
const { content, errors } = await load({});
|
||||
expect(errors).toEqual([]);
|
||||
expect(content!.recognition).toEqual([]);
|
||||
expect(content!.courses).toEqual([]);
|
||||
});
|
||||
|
||||
test('reports each problem in recognition.yml by field', async () => {
|
||||
const { content, errors } = await load({
|
||||
'recognition.yml': [
|
||||
'- { kind: competition, result: x }',
|
||||
'- { title: T, kind: medal, result: x }',
|
||||
'- { title: T, kind: competition }',
|
||||
'- { title: T, kind: competition, result: x, highlight: yes }',
|
||||
'- { title: T, kind: competition, result: x, url: "ftp://nope" }',
|
||||
'- { title: T, kind: competition, result: x, status: ongoing }',
|
||||
'- { title: T, kind: volunteering, result: x, status: paused }',
|
||||
'- { title: T, kind: competition, result: x, highlite: true }',
|
||||
'- just text',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(content).toBeNull();
|
||||
expect(errors.filter((e) => e.file === 'recognition.yml').map((e) => e.field)).toEqual([
|
||||
'[0].title', '[1].kind', '[2].result', '[3].highlight', '[4].url', '[5].status', '[6].status', '[7].highlite', '[8]',
|
||||
]);
|
||||
});
|
||||
|
||||
test('reports each problem in courses.yml by field', async () => {
|
||||
const ok = 'provider: P, kind: course, topic: T, completed: 2025-01-01, minutes: 10';
|
||||
const { content, errors } = await load({
|
||||
'courses.yml': [
|
||||
`- { provider: P, kind: course, topic: T, completed: 2025-01-01, minutes: 10 }`,
|
||||
`- { title: C, provider: P, kind: video, topic: T, completed: 2025-01-01, minutes: 10 }`,
|
||||
`- { title: C, provider: P, kind: course, topic: T, completed: 2025-02-30, minutes: 10 }`,
|
||||
`- { title: C, provider: P, kind: course, topic: T, completed: 2025-01-01, minutes: 0 }`,
|
||||
`- { title: C, provider: P, kind: course, topic: T, completed: 2025-01-01, minutes: 1.5 }`,
|
||||
`- { title: C, ${ok}, certificate: "yes" }`,
|
||||
`- { title: C, ${ok}, url: "not a url" }`,
|
||||
`- { title: C, ${ok}, keywords: [1, 2] }`,
|
||||
`- { title: C, ${ok}, certifcate: true }`,
|
||||
`- { title: C, provider: P, kind: course, completed: 2025-01-01, minutes: 10 }`,
|
||||
`- [a, list]`,
|
||||
].join('\n'),
|
||||
});
|
||||
expect(content).toBeNull();
|
||||
expect(errors.filter((e) => e.file === 'courses.yml').map((e) => e.field)).toEqual([
|
||||
'[0].title', '[1].kind', '[2].completed', '[3].minutes', '[4].minutes', '[5].certificate', '[6].url',
|
||||
'[7].keywords', '[8].certifcate', '[9].topic', '[10]',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a file that is not a list is an error', async () => {
|
||||
const { errors } = await load({ 'recognition.yml': 'title: x', 'courses.yml': 'just text' });
|
||||
expect(errors).toContainEqual(expect.objectContaining({ file: 'recognition.yml', field: 'file' }));
|
||||
expect(errors).toContainEqual(expect.objectContaining({ file: 'courses.yml', field: 'file' }));
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,7 @@ describe('link previews on every page', () => {
|
||||
|
||||
test('og:image exactly where the page has its own (raster) image, with the large card', () => {
|
||||
const expected = new Map<string, string>();
|
||||
for (const p of data.projects) if (p.image) expected.set(`/work/${p.id}/`, p.image.file);
|
||||
for (const p of data.projects) if (p.image && !p.image.file.endsWith('.svg')) expected.set(`/work/${p.id}/`, p.image.file);
|
||||
for (const i of data.items) {
|
||||
const img = i.media.find((m) => m.file && /\.(png|jpe?g|gif|webp|avif|svg)$/i.test(m.file));
|
||||
if (img && !img.file!.endsWith('.svg')) expected.set(`/off-the-clock/${i.id}/`, img.file!);
|
||||
|
||||
@@ -226,3 +226,181 @@ describe('without SITE_URL (spec 007)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('/work/ CV header (spec 009)', () => {
|
||||
const header = (root: string) => {
|
||||
const html = page(root, 'work/index.html');
|
||||
// The header holds no nested sections, so the first </section> after it closes it.
|
||||
const start = html.indexOf('<section class="khero');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
return html.slice(start, html.indexOf('</section>', start));
|
||||
};
|
||||
|
||||
test('the left column: name, tagline, cv rows, contact, now list and buttons', () => {
|
||||
const h = header(out);
|
||||
expect(h).toMatch(/<h1[^>]*>Example Person<\/h1>/);
|
||||
expect(h).toContain('Fixture tagline, obviously fake');
|
||||
expect(h).toContain('CV in ten seconds');
|
||||
expect(h).toContain('Example Role at Example Corp (fixture)');
|
||||
expect(h).toMatch(/Sample Role at Sample Ltd \(fixture\)<br>Demo Role at Demo Inc \(fixture\)/);
|
||||
expect(h).toContain('Example Degree, Fixture University');
|
||||
expect(h).toContain('href="mailto:person@example.invalid"');
|
||||
expect(h).toMatch(/<li>Reading a Fixture Book<\/li>\s*<li>Building the Example Compute Portal<\/li>/);
|
||||
expect(h).toContain('href="/media/fixture-cv.pdf"');
|
||||
expect(h).toContain('href="#cv"');
|
||||
expect(h).toContain('href="https://example.invalid/github/example-person"');
|
||||
expect(h).toContain('href="https://example.invalid/linkedin/example-person"');
|
||||
// The old intro and the Now / Latest writing / CV strip are gone.
|
||||
const html = page(out, 'work/index.html');
|
||||
expect(html).not.toContain('This is synthetic fixture content for tests.');
|
||||
expect(html).not.toContain('class="now"');
|
||||
expect(html).not.toContain('Latest writing');
|
||||
expect(html.match(/<h1\b/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('highlights: the first three highlighted entries, then "+ N more in the record"', () => {
|
||||
const h = header(out);
|
||||
const hl = h.slice(h.indexOf('Highlights'), h.indexOf('Keeping up'));
|
||||
expect(hl).toContain('Example Hackathon 2019');
|
||||
expect(hl).toContain('winner · best fixture');
|
||||
expect(hl).toContain('Fixture Robot League');
|
||||
expect(hl).toContain('Sample Code Contest 2021');
|
||||
expect(hl).not.toContain('Demo Capture the Flag 2024');
|
||||
expect(hl).toMatch(/href="#record"[^>]*>\+ 3 more in the record ↓/);
|
||||
// Owner 2026-11-12: the placement carries the accent, not the competition's name.
|
||||
expect(hl).toMatch(/<div>Example Hackathon 2019<\/div>\s*<div class="r acc">winner · best fixture<\/div>/);
|
||||
});
|
||||
|
||||
test('keeping up: certificate count, course line and topic chips by time', () => {
|
||||
const h = header(out);
|
||||
const ku = h.slice(h.indexOf('Keeping up'));
|
||||
expect(ku).toMatch(/class="bignum"[^>]*>[\s\S]*?7<\/span>\s*<span[^>]*>certificates<\/span>/);
|
||||
expect(ku).toContain('11 courses & hands-on labs · ~11 h since Feb 2025');
|
||||
const chips = [...ku.matchAll(/<span class="chip"[^>]*>([^<]*)<\/span>/g)].map((m) => m[1]);
|
||||
expect(chips).toEqual(['Fixture Cloud 1', 'Java 4', 'Containers 2', 'Observability']);
|
||||
expect(ku).toMatch(/href="#record"[^>]*>all courses ↓/);
|
||||
});
|
||||
|
||||
test('sparse: no highlights, no keeping up, one column, no CV button', () => {
|
||||
const h = header(sparseOut);
|
||||
expect(h).toMatch(/<h1[^>]*>Example Person<\/h1>/);
|
||||
expect(h).toContain('class="khero solo"');
|
||||
expect(h).not.toContain('Highlights');
|
||||
expect(h).not.toContain('Keeping up');
|
||||
expect(h).not.toContain('/media/');
|
||||
expect(page(sparseOut, 'work/index.html')).not.toContain('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/work/ record (spec 009)', () => {
|
||||
const record = (root: string) => {
|
||||
const html = page(root, 'work/index.html');
|
||||
const start = html.indexOf('id="record"');
|
||||
// The record is followed by the Writing / history row, or the footer.
|
||||
const ends = ['class="lower', '<footer'].map((m) => html.indexOf(m, start)).filter((i) => i > 0);
|
||||
return start < 0 ? '' : html.slice(start, Math.min(...ends));
|
||||
};
|
||||
|
||||
test('the page order: header, proudest, also made, record, writing, history', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
const at = ['class="khero', 'id="featured"', 'id="all"', 'id="record"', 'id="writing"', 'id="cv"'].map((m) => html.indexOf(m));
|
||||
expect(at.every((i) => i > 0)).toBe(true);
|
||||
expect(at).toEqual([...at].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
test('recognition & volunteering: groups, accent on highlights, links, status words, years only when given', () => {
|
||||
const r = record(out);
|
||||
expect(r).toContain('Recognition & volunteering');
|
||||
const comp = r.slice(r.indexOf('Competitions'), r.indexOf('Volunteering'));
|
||||
const vol = r.slice(r.indexOf('Volunteering'), r.indexOf('Keeping up'));
|
||||
expect(comp).toMatch(/<div>Example Hackathon 2019 <span class="y">2019<\/span><\/div>\s*<div class="r acc">winner · best fixture<\/div>/);
|
||||
expect(comp).toMatch(/Fixture Robot League <span class="dt">· four years<\/span>/);
|
||||
expect(comp).toContain('Demo Capture the Flag 2024');
|
||||
expect(vol).toMatch(/<a href="https:\/\/example.invalid\/art-club">Example Art Club<\/a> <span class="dt">· example.invalid ↗<\/span>/);
|
||||
expect(vol).toContain('IT & website · ongoing');
|
||||
expect(vol).toContain('IT & website · site retired');
|
||||
expect(vol).not.toMatch(/class="acc"/);
|
||||
expect(r).not.toContain('undefined');
|
||||
});
|
||||
|
||||
test('keeping up: summary line, topic table by time, link to the learning page', () => {
|
||||
const r = record(out);
|
||||
const ku = r.slice(r.indexOf('Keeping up'));
|
||||
expect(ku).toMatch(/7 certificates<\/b><\/span> from 11 courses and hands-on labs · <b>~11 hours<\/b> of course time since Feb 2025/);
|
||||
const rows = [...ku.matchAll(/<td class="t">([^<]*)<\/td><td class="c">([^<]*)<\/td><td class="c ce">(?:<svg[\s\S]*?<\/svg>)?\s*([\d—]+)<\/td><td class="c">([^<]*)<\/td>/g)]
|
||||
.map((m) => [m[1], m[2], m[3], m[4]]);
|
||||
expect(rows).toEqual([
|
||||
['Fixture Cloud', '1 course · 1 lab', '1', '3.8h'],
|
||||
['Java', '4 courses · 2 labs', '4', '3.7h'],
|
||||
['Containers', '1 course · 1 lab', '2', '3.2h'],
|
||||
['Observability', '1 course', '—', '0.4h'],
|
||||
]);
|
||||
expect(ku).toMatch(/href="\/work\/learning\/"[^>]*>All courses and certificates, searchable →/);
|
||||
});
|
||||
|
||||
test('sparse: no record section', () => {
|
||||
expect(page(sparseOut, 'work/index.html')).not.toContain('id="record"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/work/learning/ (spec 009 §5)', () => {
|
||||
test('lists every course newest first, complete without JavaScript', () => {
|
||||
const html = page(out, 'work/learning/index.html');
|
||||
expect(html).toMatch(/<h1[^>]*>Courses & certificates<\/h1>/);
|
||||
expect(html).toMatch(/7 certificates<\/b><\/span> from 11 courses and hands-on labs/);
|
||||
const rows = [...html.matchAll(/<tr\b[^>]*data-course[^>]*>([\s\S]*?)<\/tr>/g)].map((m) => m[0]);
|
||||
expect(rows).toHaveLength(11);
|
||||
const dates = rows.map((r) => r.match(/<time datetime="([^"]+)"/)![1]!);
|
||||
expect(dates).toEqual([...dates].sort().reverse());
|
||||
expect(rows.some((r) => / hidden[ >]/.test(r.slice(0, r.indexOf('>'))))).toBe(false);
|
||||
// Newest first: a course without certificate, then a lab with one; a course with a url is a link.
|
||||
expect(rows[0]).toContain('Logs and Metrics 101');
|
||||
expect(rows[0]).not.toContain('class="seal"');
|
||||
expect(rows[1]).toContain('Profiling the JVM');
|
||||
expect(rows[1]).toContain('class="tag">lab<');
|
||||
expect(rows[1]).toContain('class="seal"');
|
||||
expect(html).toContain('<a href="https://example.invalid/courses/cloud">Fixture Cloud Foundations</a>');
|
||||
// Search text for the script: title, provider, topic and keywords.
|
||||
expect(rows.find((r) => r.includes('Containers from Scratch'))).toMatch(/data-search="containers from scratch sample school containers docker images"/);
|
||||
// Topic buttons: All, then each topic with its count, by time.
|
||||
const buttons = [...html.matchAll(/<button[^>]*data-topic="([^"]*)"[^>]*>([\s\S]*?)<\/button>/g)].map((m) => [m[1], m[2]!.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()]);
|
||||
expect(buttons).toEqual([['', 'All 11'], ['Fixture Cloud', 'Fixture Cloud 2'], ['Java', 'Java 6'], ['Containers', 'Containers 2'], ['Observability', 'Observability 1']]);
|
||||
expect(html).toMatch(/<p[^>]*class="none"[^>]*hidden[^>]*>No course matches/);
|
||||
expect(html).toContain('<script type="module"');
|
||||
});
|
||||
|
||||
test('the top bar of the learning page links back into /work/', () => {
|
||||
const nav = page(out, 'work/learning/index.html').match(/<nav class="nav"[\s\S]*?<\/nav>/)![0];
|
||||
expect(nav).toContain('href="/work/#featured"');
|
||||
expect(nav).toContain('href="/work/#record"');
|
||||
});
|
||||
|
||||
test('without courses there is no learning page and nothing links to it', () => {
|
||||
expect(existsSync(join(sparseOut, 'work/learning/index.html'))).toBe(false);
|
||||
expect(page(sparseOut, 'work/index.html')).not.toContain('/work/learning/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('work top bar and footer (spec 009 §6)', () => {
|
||||
const nav = (html: string) => [...html.match(/<nav class="nav"[\s\S]*?<\/nav>/)![0].matchAll(/<a href="([^"]+)">([^<]+)<\/a>/g)].map((m) => `${m[2]} ${m[1]}`);
|
||||
|
||||
test('the top bar links Projects, Record, Writing and CV, only to sections that exist', () => {
|
||||
expect(nav(page(out, 'work/index.html'))).toEqual(['Projects #featured', 'Record #record', 'Writing #writing', 'CV #cv']);
|
||||
expect(nav(page(out, 'work/demo-course-planner/index.html'))).toEqual([
|
||||
'Projects /work/#featured', 'Record /work/#record', 'Writing /work/#writing', 'CV /work/#cv',
|
||||
]);
|
||||
expect(nav(page(sparseOut, 'work/index.html'))).not.toContain('Record #record');
|
||||
});
|
||||
|
||||
test('the work footer has no Bluesky link; the off-the-clock footer and /simple keep it', () => {
|
||||
const footer = (html: string) => html.slice(html.lastIndexOf('<footer'));
|
||||
for (const path of ['work/index.html', 'work/learning/index.html', 'work/demo-course-planner/index.html']) {
|
||||
const f = footer(page(out, path));
|
||||
expect(f).not.toContain('Bluesky');
|
||||
expect(f).toContain('href="/simple/"');
|
||||
expect(f).toContain('href="mailto:person@example.invalid"');
|
||||
}
|
||||
expect(page(out, 'off-the-clock/index.html')).toContain('https://example.invalid/bluesky/example-person');
|
||||
expect(page(out, 'simple/index.html')).toContain('https://example.invalid/bluesky/example-person');
|
||||
});
|
||||
});
|
||||
+32
-12
@@ -19,7 +19,7 @@ beforeAll(async () => {
|
||||
describe('work space', () => {
|
||||
test('work page lists every visible project', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
expect(projects.length).toBe(5);
|
||||
expect(projects.length).toBe(9);
|
||||
for (const p of projects) {
|
||||
expect(html).toContain(`href="/work/${p.id}/"`);
|
||||
expect(html).toContain(`<template id="p-${p.id}">`);
|
||||
@@ -50,6 +50,30 @@ describe('work space', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('six featured panels lay out wide, half, half, wide reversed, half, half under "Projects I\'m proudest of"', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
const classes = [...html.matchAll(/<article\b[^>]*\bclass="(feat\b[^"]*)"[^>]*>/g)].map((m) => m[1]!.replace(/\s*astro-\w+/g, '').trim());
|
||||
expect(classes).toEqual(['feat wide', 'feat', 'feat', 'feat wide rev', 'feat', 'feat']);
|
||||
const feat = html.slice(html.indexOf('id="featured"'));
|
||||
expect(feat).toMatch(/<h2[^>]*>Projects I('|'|’)m proudest of<\/h2>/);
|
||||
expect(feat.slice(0, 1500)).toContain('6 projects');
|
||||
expect(feat.slice(0, 1500)).toContain('hover the drawings · open a panel for the full story');
|
||||
// The AI explanation sits with the heading, with the four legend marks.
|
||||
const head = feat.slice(0, feat.indexOf('class="feats"'));
|
||||
for (const level of ['ai-made', 'some-ai', 'by-hand', 'unknown']) expect(head).toContain(`data-level="${level}"`);
|
||||
});
|
||||
|
||||
test('screenshots in panels and list thumbnails are muted until hovered; drawings are not', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
const css = html + readdirSync(join(out, '_astro')).filter((f) => f.endsWith('.css')).map((f) => page(out, `_astro/${f}`)).join('');
|
||||
expect(css).toMatch(/\.feat \.s-image img[^{]*\{[^}]*filter:\s*saturate\(\.5\)\s*brightness\(\.8\)/);
|
||||
expect(css).toMatch(/\.row \.th-image img[^{]*\{[^}]*filter:\s*saturate\(\.5\)\s*brightness\(\.8\)/);
|
||||
expect(css).toMatch(/\.feat:hover \.s-image img/);
|
||||
expect(css).toMatch(/\.feat:focus-within \.s-image img/);
|
||||
expect(css).toMatch(/\.row:focus-visible \.th-image img|\.row:focus \.th-image img/);
|
||||
expect(css).not.toMatch(/\.stage\s*\{[^}]*saturate/);
|
||||
});
|
||||
|
||||
test('a hidden featured panel is followed by a note with a working "show anyway" button', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
const featured = projects.filter((p) => p.featured !== undefined);
|
||||
@@ -100,19 +124,17 @@ describe('work space', () => {
|
||||
expect(page(out, 'work/placeholder-notes/index.html')).toContain('data-showcase="generic"');
|
||||
});
|
||||
|
||||
test('latest writing is hidden when there are no work posts', () => {
|
||||
expect(page(out, 'work/index.html')).toContain('Latest writing');
|
||||
test('the Writing card is left out when there are no work posts', () => {
|
||||
expect(page(out, 'work/index.html')).toContain('id="writing"');
|
||||
const sparse = page(sparseOut, 'work/index.html');
|
||||
expect(sparse).not.toContain('Latest writing');
|
||||
expect(sparse).not.toContain('id="writing"');
|
||||
expect(sparse).not.toContain('undefined');
|
||||
});
|
||||
|
||||
test('writing boxes collapse when the filter hides every post in them', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
// The Latest writing cell, the Writing card and the row holding it are filter boxes: base.css
|
||||
// hides a box with no visible [data-ai] link under the active preference.
|
||||
expect(html).toMatch(/<div\b[^>]*\bclass="[^"]*\bfilter-box\b[^"]*"[^>]*>\s*<h2><span>Latest writing<\/span>/);
|
||||
// The Writing card is a filter box: base.css hides a box with no visible [data-ai] link under the
|
||||
// active preference.
|
||||
expect(html).toMatch(/<section\b[^>]*\bclass="card filter-box"[^>]*\bid="writing"/);
|
||||
// Counts never claim hidden posts: one count per preference, CSS shows the active one.
|
||||
// The fixture's only work post has no AI level, so "by hand only" counts 0.
|
||||
@@ -141,17 +163,15 @@ describe('intros', () => {
|
||||
|
||||
test('the gate and the wall use personal_intro, never the work intro', () => {
|
||||
const gate = page(out, 'index.html');
|
||||
const workH1 = page(out, 'work/index.html').match(/<h1[^>]*>([^<]*)/)?.[1]?.trim();
|
||||
expect(workH1).toBe('This is synthetic fixture content for tests.');
|
||||
expect(ledeOfPersonalGate(gate)).toBe('Fixture personal intro, also invented.');
|
||||
expect(ledeOfPersonalGate(gate)).not.toBe(workH1);
|
||||
expect(gate).not.toContain('This is synthetic fixture content for tests.');
|
||||
expect(page(out, 'off-the-clock/index.html')).toContain('Fixture personal intro, also invented.');
|
||||
});
|
||||
|
||||
test('without personal_intro there is no lede; without intro /work/ still has an h1', () => {
|
||||
test('without personal_intro there is no lede; /work/ is headed by the name (spec 009)', () => {
|
||||
expect(ledeOfPersonalGate(page(sparseOut, 'index.html'))).toBeUndefined();
|
||||
const work = page(sparseOut, 'work/index.html');
|
||||
expect(work).toMatch(/<h1 class="sr-only">Example Person<\/h1>/);
|
||||
expect(work).toMatch(/<h1>Example Person<\/h1>/);
|
||||
expect(work.match(/<h1\b/g)).toHaveLength(1);
|
||||
expect(page(sparseOut, 'off-the-clock/index.html')).not.toContain('Fixture personal intro');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { courseStrip, matchCountText, type ListCourse } from '../../src/site/components/work/course-search.ts';
|
||||
|
||||
const c = (title: string, completed: string, over: Partial<ListCourse> = {}): ListCourse => ({
|
||||
title, provider: 'Prov', kind: 'course', topic: 'Java', completed, minutes: 30, certificate: false, keywords: [], ...over,
|
||||
});
|
||||
const all = [
|
||||
c('Java A', '2025-01-01'), c('Java B', '2025-03-01', { certificate: true }), c('Java C', '2025-02-01', { kind: 'lab' }),
|
||||
c('Java D', '2025-05-01'), c('Java E', '2025-04-01'), c('Java F', '2025-06-01'), c('Other', '2025-07-01', { topic: 'Cloud' }),
|
||||
];
|
||||
|
||||
test('courseStrip: nothing for an empty query', () => {
|
||||
expect(courseStrip(all, ' ')).toEqual({ shown: [], more: 0, count: 0, href: '' });
|
||||
});
|
||||
|
||||
test('courseStrip: newest first, at most five, the rest counted, link carries the query', () => {
|
||||
const s = courseStrip(all, 'java');
|
||||
expect(s.count).toBe(6);
|
||||
expect(s.shown.map((x) => x.title)).toEqual(['Java F', 'Java D', 'Java E', 'Java B', 'Java C']);
|
||||
expect(s.more).toBe(1);
|
||||
expect(s.href).toBe('/work/learning/?q=java');
|
||||
expect(courseStrip(all, 'a b&c').href).toBe('/work/learning/?q=a%20b%26c');
|
||||
});
|
||||
|
||||
test('matchCountText', () => {
|
||||
expect(matchCountText(1, 5)).toBe('1 project · 5 courses match');
|
||||
expect(matchCountText(0, 1)).toBe('0 projects · 1 course matches');
|
||||
expect(matchCountText(2, 0)).toBe('');
|
||||
});
|
||||
+10
-1
@@ -36,13 +36,22 @@ describe('sitePaths', () => {
|
||||
for (const i of data.items) expect(paths).toContain(`/off-the-clock/${i.id}/`);
|
||||
for (const p of data.sources.posts.filter((x) => x.side === 'off-the-clock')) expect(paths).toContain(`/off-the-clock/${p.slug}/`);
|
||||
for (const i of data.issues) expect(paths).toContain(`/off-the-clock/issues/${i.id}/`);
|
||||
expect(paths.length).toBe(6 + data.projects.length + data.workPosts.length + data.personal.length + data.issues.length);
|
||||
expect(paths).toContain('/work/learning/');
|
||||
expect(paths.length).toBe(7 + data.projects.length + data.workPosts.length + data.personal.length + data.issues.length);
|
||||
expect(paths.some((p) => p.includes('404'))).toBe(false);
|
||||
expect(paths.some((p) => p.includes('draft'))).toBe(false);
|
||||
});
|
||||
|
||||
test('/work/learning/ only when there are courses', () => {
|
||||
expect(sitePaths({ ...data, courses: [] })).not.toContain('/work/learning/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('feedEntries', () => {
|
||||
test('courses are never feed entries', () => {
|
||||
for (const name of ['all', 'work'] as const) expect(feedEntries(data, name).some((e) => e.path.includes('learning'))).toBe(false);
|
||||
});
|
||||
|
||||
test('work: projects and work writing, newest first, with their summaries and levels', () => {
|
||||
const entries = feedEntries(data, 'work');
|
||||
expect(entries.length).toBe(data.projects.length + data.workPosts.length);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { rowMatches } from '../../src/site/scripts/learning-filter.ts';
|
||||
|
||||
const row = { search: 'containers from scratch sample school containers docker images', topic: 'Containers' };
|
||||
|
||||
test('a row matches the search text and the topic', () => {
|
||||
expect(rowMatches(row, '', '')).toBe(true);
|
||||
expect(rowMatches(row, 'DOCKER', '')).toBe(true);
|
||||
expect(rowMatches(row, ' sample ', 'Containers')).toBe(true);
|
||||
expect(rowMatches(row, 'docker', 'Java')).toBe(false);
|
||||
expect(rowMatches(row, 'kotlin', '')).toBe(false);
|
||||
expect(rowMatches(row, '', 'Containers')).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { approxHours, doneText, headerHighlights, hostOf } from '../../src/site/components/work/record.ts';
|
||||
import type { Recognition } from '../../src/library/types.ts';
|
||||
|
||||
const r = (title: string, highlight: boolean): Recognition => ({ title, kind: 'competition', result: 'x', highlight });
|
||||
|
||||
test('headerHighlights takes the first three highlighted, counts the rest of the record', () => {
|
||||
const h = headerHighlights([r('a', true), r('b', false), r('c', true), r('d', true), r('e', true)]);
|
||||
expect(h.shown.map((x) => x.title)).toEqual(['a', 'c', 'd']);
|
||||
expect(h.more).toBe(2);
|
||||
expect(headerHighlights([r('a', false)])).toEqual({ shown: [], more: 1 });
|
||||
});
|
||||
|
||||
test('doneText leaves out a zero part', () => {
|
||||
expect(doneText(2, 3)).toBe('2 courses · 3 labs');
|
||||
expect(doneText(1, 0)).toBe('1 course');
|
||||
expect(doneText(0, 1)).toBe('1 lab');
|
||||
});
|
||||
|
||||
test('approxHours and hostOf', () => {
|
||||
expect(approxHours(659)).toBe(11);
|
||||
expect(approxHours(10)).toBe(1);
|
||||
expect(hostOf('https://www.example.invalid/x')).toBe('example.invalid');
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { featLayout, featuredOf } from '../../src/site/components/work/sections.ts';
|
||||
import type { Project } from '../../src/library/types.ts';
|
||||
|
||||
const proj = (id: string, featured?: number): Project => ({
|
||||
id, title: id, date: '2025-01-01', ai: 'unknown', group: 'work', where: 'x', stack: [], summary: 's', links: [],
|
||||
aiParts: [], related: [], bodyHtml: '', ...(featured !== undefined && { featured }),
|
||||
});
|
||||
|
||||
describe('featured panels', () => {
|
||||
test('featuredOf takes up to six by rank', () => {
|
||||
const ps = [7, 3, 1, 5, 2, 6, 4].map((n) => proj(`p${n}`, n)).concat(proj('none'));
|
||||
expect(featuredOf(ps).map((p) => p.id)).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6']);
|
||||
});
|
||||
|
||||
test('featLayout repeats wide, half, half, wide reversed, half, half; a lone last half goes wide', () => {
|
||||
expect(featLayout(0)).toEqual([]);
|
||||
expect(featLayout(1)).toEqual(['wide']);
|
||||
expect(featLayout(2)).toEqual(['wide', 'wide']);
|
||||
expect(featLayout(3)).toEqual(['wide', '', '']);
|
||||
expect(featLayout(4)).toEqual(['wide', '', '', 'wide rev']);
|
||||
expect(featLayout(5)).toEqual(['wide', '', '', 'wide rev', 'wide']);
|
||||
expect(featLayout(6)).toEqual(['wide', '', '', 'wide rev', '', '']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user