feat(work): CV-first work page, six proud projects, record and courses (spec 009)

- CV header with highlights and keeping-up summary replaces the intro and Now strip
- "Projects I'm proudest of": up to six featured panels; screenshots muted until hover
- "Also made" list; searching it also lists matching courses
- Record section: recognition & volunteering, courses by topic
- /work/learning/: searchable course list (plain script), in the sitemap
- Top bar: Projects, Record, Writing, CV; no Bluesky link in the work footer
- Highlights: only the placement takes the accent (owner, after the preview)

Owner looked at the rendered preview on 2026-11-12 before this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-12 10:44:46 +01:00
1 parent 249e4a8017
commit 974b3eb0df
42 files changed
+1050 -188

No files matched your search

+13 -3
View File
@@ -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`,
+11 -2
View File
@@ -14,9 +14,9 @@ content format: `docs/CONTENT-CONTRACT.md`; deployment: `docs/DEPLOY.md`.
| 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`), course summary, search and formatting (`courses.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/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) ─┐
+39
View File
@@ -393,3 +393,42 @@ 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.
+6 -1
View File
@@ -64,10 +64,12 @@ 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).
- [ ] Plan 009 asked for axe checks of `/work/` and `/work/learning/` in `e2e/a11y.spec.ts`, but the repo has no axe dependency (adding one needs the owner's yes). The learning page was added to the existing alt-text and reduced-motion checks instead.
## Recurring
- [ ] Maintain `AGENTS.md`:
- **Add a rule only in response to a real failure.** When an agent repeatedly gets something wrong,
@@ -79,6 +81,9 @@ 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).
- [ ] `AGENTS.md` still names `mockups/tech-r3-c.html` as the work page's visual truth; since spec 009 `/work/` follows `mockups/work-k2.html` (built on it). `DESIGN.md` says so; the rules file needs the owner's edit.
- [ ] 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.
+20 -20
View File
@@ -39,50 +39,50 @@ Test imports use explicit `.ts` extensions. `src/core` stays pure (no imports fr
- [x] `src/core/courses.ts` (takes a plain course shape, not the library type). Commit `feat(core): course summary and search`.
## 3. Six featured panels and muted screenshots (do not commit)
- [ ] Test for the pure part in `sections.ts`: `featuredOf` returns up to 6; `featLayout(n)` is
- [x] Test for the pure part in `sections.ts`: `featuredOf` returns up to 6; `featLayout(n)` is
`['wide','','','wide rev','','']` sliced to n, with a trailing lone half made `wide`:
n=1 → wide; n=2 → wide, wide; n=3 → wide, half, half; n=4 → … wide rev; n=5 → … wide rev, wide; n=6 → as listed.
- [ ] `Featured.astro` uses it; heading "Projects I'm proudest of", hint, AI legend line at the right (moved from intro).
- [ ] CSS: `.feat` image stages and `.row` thumbnails muted (`saturate(.5) brightness(.8)`), full colour on
- [x] `Featured.astro` uses it; heading "Projects I'm proudest of", hint, AI legend line at the right (moved from intro).
- [x] CSS: `.feat` image stages and `.row` thumbnails muted (`saturate(.5) brightness(.8)`), full colour on
`:hover`/`:focus-within`; transition 300 ms, none under `prefers-reduced-motion`. Drawn showcases untouched.
- [ ] Fixtures: six featured fixture projects (add `featured` to existing synthetic ones / add synthetic ones).
- [x] Fixtures: six featured fixture projects (add `featured` to existing synthetic ones / add synthetic ones).
## 4. CV header (do not commit)
- [ ] `src/site/components/work/WorkHeader.astro` replaces the intro block and `NowStrip.astro` (delete it if unused).
- [x] `src/site/components/work/WorkHeader.astro` replaces the intro block and `NowStrip.astro` (delete it if unused).
Content and fallbacks exactly as spec §1. Uses `courseSummary`.
- [ ] `test/site/build-pages.test.ts`: header rows, highlights (max 3, "+N more"), keeping-up count/line/chips,
- [x] `test/site/build-pages.test.ts`: header rows, highlights (max 3, "+N more"), keeping-up count/line/chips,
blocks left out on the sparse fixture, `site.now` list.
## 5. Also made + certificate search (do not commit)
- [ ] `ProjectList.tsx`: heading "Also made"; new prop `courses` (minimal fields); when the query is non-empty show the
- [x] `ProjectList.tsx`: heading "Also made"; new prop `courses` (minimal fields); when the query is non-empty show the
matching-courses strip (max 5, "+ N more on the learning page →" to `/work/learning/?q=`), and the count text
"<p> project(s) · <c> course(s) match" (spec: courses, labs included; a seal marks the ones with a certificate).
- [ ] Tests: unit test for the strip's data (pure helper) + Playwright check in `e2e/work.spec.ts`.
- [x] Tests: unit test for the strip's data (pure helper) + Playwright check in `e2e/work.spec.ts`.
## 6. Record section (do not commit)
- [ ] `src/site/components/work/Record.astro` (`#record`) as spec §4; `workSections` gains `record`.
- [ ] Build test: both cards, groups, accent on highlights, status words, years only when present, topic table.
- [x] `src/site/components/work/Record.astro` (`#record`) as spec §4; `workSections` gains `record`.
- [x] Build test: both cards, groups, accent on highlights, status words, years only when present, topic table.
## 7. `/work/learning/` (do not commit)
- [ ] `src/site/pages/work/learning.astro` (only when courses exist) + `src/site/scripts/learning-filter.ts` (plain
- [x] `src/site/pages/work/learning.astro` (only when courses exist) + `src/site/scripts/learning-filter.ts` (plain
script: search, topic buttons, `?q=`, "No course matches").
- [ ] Add to the sitemap page list (`src/site/feeds.ts`), not to feeds.
- [ ] Tests: build test (rows newest first, no page without courses), sitemap test, Playwright (filter, `?q=`,
- [x] Add to the sitemap page list (`src/site/feeds.ts`), not to feeds.
- [x] Tests: build test (rows newest first, no page without courses), sitemap test, Playwright (filter, `?q=`,
no-JS shows all rows in `e2e/nojs.spec.ts`).
## 8. Top bar and footer (do not commit)
- [ ] `TopBar.astro`: Projects (`#featured`), Record (`#record`), Writing, CV — only existing sections; on
- [x] `TopBar.astro`: Projects (`#featured`), Record (`#record`), Writing, CV — only existing sections; on
`/work/learning/` the links point to `/work/#…`.
- [ ] `Work.astro`: no Bluesky link. Personal footer and `/simple` unchanged. Tests for both footers.
- [x] `Work.astro`: no Bluesky link. Personal footer and `/simple` unchanged. Tests for both footers.
## 9. Checks and docs (do not commit)
- [ ] `bun run typecheck`, `bun test`, `bunx playwright test` all pass; update existing tests that asserted the old
- [x] `bun run typecheck`, `bun test`, `bunx playwright test` all pass; update existing tests that asserted the old
intro, "All projects"/"Everything else" headings, the Now strip or a four-panel cap.
- [ ] Axe checks in `e2e/a11y.spec.ts` cover `/work/` and `/work/learning/`.
- [ ] `docs/ARCHITECTURE.md` (new files, `src/core/courses.ts`, learning page), `docs/DECISIONS.md` (the [call]s),
- [ ] Axe checks in `e2e/a11y.spec.ts` cover `/work/` and `/work/learning/`. (No axe dependency in the repo; `/work/learning/` added to the existing alt-text and motion checks instead; see `docs/TODO.md`.)
- [x] `docs/ARCHITECTURE.md` (new files, `src/core/courses.ts`, learning page), `docs/DECISIONS.md` (the [call]s),
`DESIGN.md` work section, `docs/TODO.md` (anything noticed and left), spec status → `implemented` after review.
## 10. Preview for the owner (main session)
- [ ] Build a temporary copy of the real content with the proposed `recognition.yml`, `courses.yml`, featured ranks
- [x] Build a temporary copy of the real content with the proposed `recognition.yml`, `courses.yml`, featured ranks
and showcases (scratchpad `content-proposal/`); serve on port 4392; screenshots at 1440 and 390 in `~/`.
- [ ] Wait for his look; then commit tasks 3–9 in small Conventional Commits; remove this plan before merging.
- [x] Wait for his look; then commit tasks 3–9 in small Conventional Commits; remove this plan before merging.
+5 -4
View File
@@ -1,5 +1,5 @@
---
status: approved
status: implemented
---
# 009 — `/work/`: CV first, six proud projects, a record of competitions and courses
@@ -37,8 +37,9 @@ One card, two columns (stacked on narrow screens):
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]**; each title
and result in the accent colour. A link "+ N more in the record ↓" when the record holds more entries than shown.
- **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,
@@ -78,7 +79,7 @@ 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 title and result in the accent colour. Years appear only when given.
`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
+2 -2
View File
@@ -28,7 +28,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,7 +72,7 @@ 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')));
+8 -1
View File
@@ -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 }) => {
+3 -3
View File
@@ -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
View File
@@ -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 @@
---
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,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>
+13 -10
View File
@@ -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) => {
-46
View File
@@ -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>
+25 -8
View File
@@ -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">
+55
View File
@@ -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 &amp; 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>
+6 -4
View File
@@ -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));
+66
View File
@@ -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>&nbsp; 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')} &amp; 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>
+21
View File
@@ -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'}`;
}
+28
View File
@@ -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\./, '');
+27 -6
View File
@@ -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
View File
@@ -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}/`),
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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>
+13 -19
View File
@@ -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>&nbsp; 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 &amp; 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>
+2 -2
View File
@@ -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>
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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', () => {
+1 -1
View File
@@ -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',
+1 -1
View 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!);
+178
View 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 &amp; 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 &amp; 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 &amp; website · ongoing');
expect(vol).toContain('IT &amp; 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 &amp; 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
View File
@@ -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(&#39;|'|’)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');
});
+29
View File
@@ -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
View File
@@ -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);
+13
View File
@@ -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);
});
+24
View File
@@ -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');
});
+25
View File
@@ -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', '', '']);
});
});