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:
1 parent
249e4a8017
commit
974b3eb0df
42 files changed
+1050
-188
No files matched your search
@@ -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
@@ -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) ─┐
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -17,7 +17,14 @@ test('work page lists every project and hides none', async ({ page }) => {
|
||||
await expect(page.locator('.filtered-note:visible')).toHaveCount(0);
|
||||
await expect(page.locator('.idx a.row')).toHaveCount(3);
|
||||
await expect(page.locator('html[data-pref]')).toHaveCount(0);
|
||||
await expect(page.locator('article.feat:visible')).toHaveCount(2);
|
||||
await expect(page.locator('article.feat:visible')).toHaveCount(6);
|
||||
});
|
||||
|
||||
test('the learning page shows every course without JavaScript', async ({ page }) => {
|
||||
await page.goto('/work/learning/');
|
||||
await expect(page.locator('tr[data-course]:visible')).toHaveCount(11);
|
||||
await expect(page.getByText('No course matches')).toBeHidden();
|
||||
await expect(page.locator('.ltools')).toBeHidden();
|
||||
});
|
||||
|
||||
test('personal wall shows every card, including AI-marked and unknown', async ({ page }) => {
|
||||
|
||||
@@ -23,15 +23,15 @@ test('by hand only hides ai-made, some-ai and unknown; keeps by-hand', async ({
|
||||
|
||||
test('featured hidden panels show "show anyway"; archive link appears', async ({ page }) => {
|
||||
await openWork(page);
|
||||
await expect(page.locator('.filtered-note')).toHaveCount(2);
|
||||
await expect(page.locator('.filtered-note')).toHaveCount(6);
|
||||
await expect(page.locator('.filtered-note').first()).toBeHidden();
|
||||
// The archive line is in the HTML but hidden (spec 006): none of it may show, to the eye or to assistive tech.
|
||||
await expect(page.locator('a[href="https://old.lucaswinther.info/"]:visible')).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: 'The 2021 site was all by hand →' })).toHaveCount(0);
|
||||
await chooseByHand(page);
|
||||
const visibleNotes = page.locator('.filtered-note:visible');
|
||||
await expect(visibleNotes).toHaveCount(1); // only the some-ai featured project
|
||||
await expect(visibleNotes.getByRole('button', { name: 'show anyway' })).toBeVisible();
|
||||
await expect(visibleNotes).toHaveCount(3); // the two some-ai featured projects and the unknown one
|
||||
await expect(visibleNotes.first().getByRole('button', { name: 'show anyway' })).toBeVisible();
|
||||
await expect(page.locator('a[href="https://old.lucaswinther.info/"]:visible').first()).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
+69
-2
@@ -128,17 +128,84 @@ test('"/" focuses search', async ({ page, isMobile }) => {
|
||||
test.skip(!!isMobile, 'keyboard');
|
||||
await openWork(page);
|
||||
await page.keyboard.press('/');
|
||||
await expect(page.getByRole('searchbox', { name: 'Search projects' })).toBeFocused();
|
||||
await expect(page.getByRole('searchbox', { name: /Search projects/ })).toBeFocused();
|
||||
});
|
||||
|
||||
test('"/" focuses the search right after load, without scrolling to the list', async ({ page }) => {
|
||||
await page.goto('/work/');
|
||||
await page.waitForLoadState('load');
|
||||
await page.waitForFunction(() => 'requestIdleCallback' in window);
|
||||
const search = page.getByRole('searchbox', { name: 'Search projects' });
|
||||
const search = page.getByRole('searchbox', { name: /Search projects/ });
|
||||
// The list sits below the fold; it must hydrate on idle, not when scrolled into view.
|
||||
await expect.poll(async () => {
|
||||
await page.keyboard.press('/');
|
||||
return search.evaluate((el) => el === document.activeElement);
|
||||
}, { timeout: 5_000 }).toBe(true);
|
||||
});
|
||||
|
||||
test('searching the list shows matching courses with the count and a link to the learning page', async ({ page }) => {
|
||||
await openWork(page);
|
||||
const search = page.getByRole('searchbox', { name: /Search projects/ });
|
||||
const strip = page.locator('.idx .cstrip');
|
||||
await expect(strip).toHaveCount(0);
|
||||
await search.fill('java');
|
||||
await expect(page.locator('.idx .sh .n')).toHaveText('1 project · 6 courses match');
|
||||
await expect(strip.locator('.crow')).toHaveCount(5);
|
||||
// Newest first; the seal marks a certificate, the tag marks a lab.
|
||||
await expect(strip.locator('.crow').first()).toContainText('Profiling the JVM');
|
||||
await expect(strip.locator('.crow').first()).toContainText('lab');
|
||||
await expect(strip.locator('.crow').first().locator('.seal')).toHaveCount(1);
|
||||
await expect(strip.getByRole('link', { name: '+ 1 more on the learning page →' })).toHaveAttribute('href', '/work/learning/?q=java');
|
||||
// The AI preference never hides courses.
|
||||
await search.fill('');
|
||||
await expect(strip).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('screenshots are muted until hovered; drawings are not', async ({ page, isMobile }) => {
|
||||
test.skip(!!isMobile, 'hover');
|
||||
await openWork(page);
|
||||
const img = page.locator('article.feat[data-id="fixture-photo-board"] .s-image img');
|
||||
await img.scrollIntoViewIfNeeded();
|
||||
await expect.poll(() => img.evaluate((el) => getComputedStyle(el).filter)).toContain('saturate(0.5)');
|
||||
await page.locator('article.feat[data-id="fixture-photo-board"] h3').hover();
|
||||
await expect.poll(() => img.evaluate((el) => getComputedStyle(el).filter)).toBe('none');
|
||||
const drawing = page.locator('article.feat[data-id="example-compute-portal"] .stage');
|
||||
expect(await drawing.evaluate((el) => getComputedStyle(el).filter)).toBe('none');
|
||||
});
|
||||
|
||||
test.describe('/work/learning/', () => {
|
||||
const rows = (page: Page) => page.locator('tr[data-course]:visible');
|
||||
|
||||
test('lists every course; search and the topic buttons hide rows', async ({ page }) => {
|
||||
await page.goto('/work/learning/');
|
||||
await expect(rows(page)).toHaveCount(11);
|
||||
const search = page.getByRole('searchbox', { name: 'Search courses' });
|
||||
await search.fill('docker');
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
await expect(rows(page).first()).toContainText('Containers from Scratch');
|
||||
await search.fill('');
|
||||
await page.getByRole('button', { name: /^Java/ }).click();
|
||||
await expect(page.getByRole('button', { name: /^Java/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(rows(page)).toHaveCount(6);
|
||||
await search.fill('docker');
|
||||
await expect(rows(page)).toHaveCount(0);
|
||||
await expect(page.getByText('No course matches')).toBeVisible();
|
||||
await page.getByRole('button', { name: /^All/ }).click();
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
await expect(page.getByText('No course matches')).toBeHidden();
|
||||
});
|
||||
|
||||
test('?q= pre-filters the table', async ({ page }) => {
|
||||
await page.goto('/work/learning/?q=java');
|
||||
await expect(page.getByRole('searchbox', { name: 'Search courses' })).toHaveValue('java');
|
||||
await expect(rows(page)).toHaveCount(6);
|
||||
});
|
||||
|
||||
test('the search strip on /work/ leads here with the query', async ({ page }) => {
|
||||
await openWork(page);
|
||||
await page.getByRole('searchbox', { name: /Search projects/ }).fill('java');
|
||||
await page.getByRole('link', { name: /more on the learning page/ }).click();
|
||||
await expect(page).toHaveURL(/\/work\/learning\/\?q=java$/);
|
||||
await expect(rows(page)).toHaveCount(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,26 +1,29 @@
|
||||
---
|
||||
// Featured projects as large panels with a drawn showcase each. A panel hidden by the visitor's
|
||||
// AI preference collapses to its sibling .filtered-note (base.css), whose button shows it anyway.
|
||||
// "Projects I'm proudest of": up to six featured projects as large panels, each with a drawn showcase or a
|
||||
// (muted until hovered) screenshot. A panel hidden by the visitor's AI preference collapses to its sibling
|
||||
// .filtered-note (base.css), whose button shows it anyway. The page's AI legend sits with the heading.
|
||||
import type { Project } from '../../../library/types.ts';
|
||||
import AiMark from '../AiMark.astro';
|
||||
import Showcase from '../showcases/Showcase.astro';
|
||||
import { GROUP_LABEL, panelFacts } from './sections.ts';
|
||||
import { featLayout, GROUP_LABEL, panelFacts } from './sections.ts';
|
||||
interface Props { projects: Project[] }
|
||||
const { projects } = Astro.props;
|
||||
|
||||
// 1: one wide · 2: wide, wide reversed · 3: wide, two halves · 4: wide, two halves, wide reversed
|
||||
const LAYOUT: Record<number, string[]> = {
|
||||
1: ['wide'], 2: ['wide', 'wide rev'], 3: ['wide', '', ''], 4: ['wide', '', '', 'wide rev'],
|
||||
};
|
||||
const layout = LAYOUT[projects.length] ?? [];
|
||||
const layout = featLayout(projects.length);
|
||||
const why: Record<Project['ai'], string> = {
|
||||
'ai-made': 'made with AI', 'some-ai': 'some AI', 'by-hand': 'by hand', unknown: 'AI level not recorded',
|
||||
};
|
||||
---
|
||||
<section id="featured" data-sheet-ctx="featured">
|
||||
<div class="sh">
|
||||
<h2>Featured</h2><span class="n">{projects.length} {projects.length === 1 ? 'project' : 'projects'}</span>
|
||||
<span class="r">hover the drawings · open a panel for the full story</span>
|
||||
<h2>Projects I'm proudest of</h2><span class="n">{projects.length} {projects.length === 1 ? 'project' : 'projects'}</span>
|
||||
<div class="fside">
|
||||
<div class="ailegend" aria-label="How much AI went into each project">
|
||||
<span class="t">marked by how much AI went in</span>
|
||||
<span class="marks"><AiMark level="ai-made" /><AiMark level="some-ai" /><AiMark level="by-hand" /><AiMark level="unknown" /></span>
|
||||
</div>
|
||||
<span class="hint">hover the drawings · open a panel for the full story</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feats">
|
||||
{projects.map((p, i) => {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
// Now / Latest writing / CV in ten seconds. Cells without content are left out; the writing cell
|
||||
// is a filter box (base.css), and the flex row closes up around whichever cells remain.
|
||||
import type { Site } from '../../../library/types.ts';
|
||||
import type { Post } from '../../../sources/types.ts';
|
||||
import { cvRows, formatDate } from './sections.ts';
|
||||
interface Props { site: Site; posts: Post[] }
|
||||
const { site, posts } = Astro.props;
|
||||
const latest = posts.slice(0, 3);
|
||||
const cv = site.cv;
|
||||
---
|
||||
<section class="now" aria-label="Now, latest writing and CV">
|
||||
{site.now.length > 0 && (
|
||||
<div style="--grow: 1">
|
||||
<h2><span>Now</span></h2>
|
||||
<ul>{site.now.map((n) => <li>{n}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
{latest.length > 0 && (
|
||||
<div class="filter-box" style="--grow: 1.15">
|
||||
<h2><span>Latest writing</span><a href="#writing">all ↓</a></h2>
|
||||
<div class="wl">
|
||||
{latest.map((p, i) => (
|
||||
<a href={`/work/writing/${p.slug}/`} class={i === 0 ? 'lead' : undefined} data-ai={p.ai}>
|
||||
<span class="t">{p.title}</span><span class="d">{formatDate(p.date, 'short')}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style="--grow: 1.35">
|
||||
<h2><span>CV in ten seconds</span></h2>
|
||||
<div class="cv10">
|
||||
{cv.now && <><span class="k">now</span><span class="v"><b>{cv.now}</b></span></>}
|
||||
{cv.before.length > 0 && <><span class="k">before</span><span class="v">{cv.before.join(' · ')}</span></>}
|
||||
{cv.degree && <><span class="k">degree</span><span class="v">{cv.degree}</span></>}
|
||||
{site.links.email && <><span class="k">contact</span><span class="v"><a href={`mailto:${site.links.email}`}>{site.links.email}</a></span></>}
|
||||
</div>
|
||||
<div class="cvlinks">
|
||||
{site.links.cv && <a class="btn solid" href={`/media/${site.links.cv}`}>↓ CV (PDF)</a>}
|
||||
{cvRows(site).length > 0 && <a class="btn" href="#cv">full history ↓</a>}
|
||||
{site.links.github && <a class="btn" href={site.links.github}>GitHub</a>}
|
||||
{site.links.linkedin && <a class="btn" href={site.links.linkedin}>LinkedIn</a>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,9 +1,13 @@
|
||||
// "Everything else": the compact project list with group tabs and search (`/` focuses it).
|
||||
// "Also made": the compact project list with group tabs and search (`/` focuses it).
|
||||
// Server output lists every row as a plain link; rows carry data-ai, so the CSS filter applies
|
||||
// before hydration. Clicking a row is picked up by ProjectSheet.
|
||||
// before hydration. Clicking a row is picked up by ProjectSheet. While the search has text, matching
|
||||
// courses show as a strip above the rows (spec 009 §3); courses carry no AI level, so the filter never hides them.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AI_LABEL, isVisible, parsePreference, PREFERENCE_LABEL, type AiLevel, type Preference } from '../../../core/ai.ts';
|
||||
import type { Project } from '../../../library/types.ts';
|
||||
import { formatHours, monthYear } from '../../../core/courses.ts';
|
||||
import { courseStrip, matchCountText, type ListCourse } from './course-search.ts';
|
||||
import { sealSvg } from './record.ts';
|
||||
import { GROUP_LABEL } from './sections.ts';
|
||||
|
||||
export type ListItem = Pick<Project, 'id' | 'title' | 'group' | 'where' | 'years' | 'stack' | 'summary' | 'ai'> & {
|
||||
@@ -16,7 +20,7 @@ function AiMark({ level }: { level: AiLevel }) {
|
||||
return <span className="ai-mark" data-level={level}><i aria-hidden="true" /><span>{AI_LABEL[level]}</span></span>;
|
||||
}
|
||||
|
||||
export default function ProjectList({ items, title }: { items: ListItem[]; title: string }) {
|
||||
export default function ProjectList({ items, title, courses = [] }: { items: ListItem[]; title: string; courses?: ListCourse[] }) {
|
||||
const [group, setGroup] = useState<Group>('all');
|
||||
const [q, setQ] = useState('');
|
||||
const [pref, setPref] = useState<Preference>('any');
|
||||
@@ -58,14 +62,15 @@ export default function ProjectList({ items, title }: { items: ListItem[]; title
|
||||
);
|
||||
const shown = revealed ? base : base.filter((p) => isVisible(p.ai, pref));
|
||||
const hidden = base.length - shown.length;
|
||||
const strip = courseStrip(courses, q);
|
||||
const countText = matchCountText(shown.length, strip.count)
|
||||
|| (shown.length === items.length ? `${items.length} ${items.length === 1 ? 'project' : 'projects'}` : `${shown.length} of ${items.length} projects`);
|
||||
|
||||
return (
|
||||
<section className="idx" id="all" data-sheet-ctx="list">
|
||||
<div className="sh">
|
||||
<h2>{title}</h2>
|
||||
<span className="n">
|
||||
{shown.length === items.length ? `${items.length} ${items.length === 1 ? 'project' : 'projects'}` : `${shown.length} of ${items.length} projects`}
|
||||
</span>
|
||||
<span className="n">{countText}</span>
|
||||
</div>
|
||||
<div className="tools">
|
||||
{groups.length > 1 && (
|
||||
@@ -78,10 +83,22 @@ export default function ProjectList({ items, title }: { items: ListItem[]; title
|
||||
</div>
|
||||
)}
|
||||
<label className="search">
|
||||
<input ref={input} type="search" placeholder="Search name, stack, idea" aria-label="Search projects" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<input ref={input} type="search" placeholder={courses.length > 0 ? 'Search name, stack, idea, course' : 'Search name, stack, idea'} aria-label={courses.length > 0 ? 'Search projects and courses' : 'Search projects'} value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<kbd aria-hidden="true">/</kbd>
|
||||
</label>
|
||||
</div>
|
||||
{strip.count > 0 && (
|
||||
<div className="cstrip" aria-label="Matching courses">
|
||||
{strip.shown.map((c) => (
|
||||
<div key={`${c.title}|${c.completed}`} className="crow">
|
||||
<span className="cseal">{c.certificate && <span title="certificate" dangerouslySetInnerHTML={{ __html: sealSvg(13) }} />}</span>
|
||||
<span className="ct">{c.title}{c.kind === 'lab' && <span className="tag">lab</span>}{c.certificate && <span className="sr-only"> (certificate)</span>}</span>
|
||||
<span className="cm">{c.provider} · {monthYear(c.completed)} · {formatHours(c.minutes)}</span>
|
||||
</div>
|
||||
))}
|
||||
{strip.more > 0 && <a className="cmore" href={strip.href}>+ {strip.more} more on the learning page →</a>}
|
||||
</div>
|
||||
)}
|
||||
<div className="list">
|
||||
{base.map((p) => (
|
||||
<a key={p.id} className="row" href={`/work/${p.id}/`} data-open-sheet={p.id} data-ai={p.ai} data-show={revealed ? '' : undefined}>
|
||||
@@ -98,7 +115,7 @@ export default function ProjectList({ items, title }: { items: ListItem[]; title
|
||||
<span className="go" aria-hidden="true">→</span>
|
||||
</a>
|
||||
))}
|
||||
{base.length === 0 && <div className="empty">Nothing matches{needle ? ` "${q.trim()}"` : ''}.</div>}
|
||||
{base.length === 0 && <div className="empty">{strip.count > 0 ? 'No project matches' : 'Nothing matches'}{needle ? ` "${q.trim()}"` : ''}.</div>}
|
||||
</div>
|
||||
{hidden > 0 && (
|
||||
<div className="hiddenby">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
// The Record (spec 009 §4, `#record`): recognition & volunteering next to keeping up, the full versions of
|
||||
// the header's highlights. A card without content is left out; the page leaves the section out with neither.
|
||||
import type { Course, Recognition } from '../../../library/types.ts';
|
||||
import { courseSummary, formatHours } from '../../../core/courses.ts';
|
||||
import CourseSumLine from './CourseSumLine.astro';
|
||||
import { doneText, hostOf, sealSvg } from './record.ts';
|
||||
interface Props { recognition: Recognition[]; courses: Course[] }
|
||||
const { recognition, courses } = Astro.props;
|
||||
const groups = [
|
||||
{ label: 'Competitions', entries: recognition.filter((r) => r.kind === 'competition') },
|
||||
{ label: 'Volunteering', entries: recognition.filter((r) => r.kind === 'volunteering') },
|
||||
].filter((g) => g.entries.length > 0);
|
||||
const sum = courseSummary(courses);
|
||||
const STATUS = { ongoing: 'ongoing', retired: 'site retired' } as const;
|
||||
const right = (r: Recognition) => [r.result, r.status && STATUS[r.status]].filter(Boolean).join(' · ');
|
||||
---
|
||||
<section id="record">
|
||||
<div class="sh"><h2>Record</h2><span class="n">the full version of the highlights</span></div>
|
||||
<div class:list={['rec', !(groups.length > 0 && sum.count > 0) && 'single']}>
|
||||
{groups.length > 0 && (
|
||||
<div class="card">
|
||||
<h3 class="lab">Recognition & volunteering</h3>
|
||||
<div class="hl">
|
||||
{groups.map((g) => (
|
||||
<>
|
||||
<h4 class="subh">{g.label}</h4>
|
||||
{g.entries.map((r) => (
|
||||
<>
|
||||
<div>{r.url ? <a href={r.url}>{r.title}</a> : r.title}{r.year && <> <span class="y">{r.year}</span></>}{r.url && <> <span class="dt">· {hostOf(r.url)} ↗</span></>}{r.detail && <> <span class="dt">· {r.detail}</span></>}</div>
|
||||
<div class:list={['r', r.highlight && 'acc']}>{right(r)}</div>
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sum.count > 0 && (
|
||||
<div class="card">
|
||||
<h3 class="lab">Keeping up</h3>
|
||||
<CourseSumLine sum={sum} />
|
||||
<table class="ku">
|
||||
<thead><tr><th scope="col">topic</th><th scope="col" class="c">done</th><th scope="col" class="c">certificates</th><th scope="col" class="c">time</th></tr></thead>
|
||||
<tbody>
|
||||
{sum.topics.map((t) => (
|
||||
<tr><td class="t">{t.topic}</td><td class="c">{doneText(t.courses, t.labs)}</td><td class="c ce">{t.certificates > 0 ? <><Fragment set:html={sealSvg(12)} /> {t.certificates}</> : '—'}</td><td class="c">{formatHours(t.minutes)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<a class="lnk acc" href="/work/learning/">All courses and certificates, searchable →</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
import PreferenceSwitch from '../PreferenceSwitch.astro';
|
||||
interface Props { name: string; onIndex: boolean; hasWriting: boolean; hasFeatured: boolean; hasList: boolean; hasCv: boolean }
|
||||
const { name, onIndex, hasWriting, hasFeatured, hasList, hasCv } = Astro.props;
|
||||
// Section links: Projects, Record, Writing, CV, each only when /work/ has that section (spec 009 §6).
|
||||
// Off /work/ itself (project, post and learning pages) they point back into /work/.
|
||||
interface Props { name: string; onIndex: boolean; hasWriting: boolean; hasFeatured: boolean; hasRecord: boolean; hasCv: boolean }
|
||||
const { name, onIndex, hasWriting, hasFeatured, hasRecord, hasCv } = Astro.props;
|
||||
const at = (hash: string) => (onIndex ? hash : `/work/${hash}`);
|
||||
const links = [
|
||||
hasFeatured && { href: at('#featured'), label: 'Featured' },
|
||||
hasList && { href: at('#all'), label: 'All projects' },
|
||||
hasFeatured && { href: at('#featured'), label: 'Projects' },
|
||||
hasRecord && { href: at('#record'), label: 'Record' },
|
||||
hasWriting && { href: at('#writing'), label: 'Writing' },
|
||||
hasCv && { href: at('#cv'), label: 'CV' },
|
||||
].filter((l): l is { href: string; label: string } => Boolean(l));
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
// The CV header at the top of /work/ (spec 009 §1): "CV in ten seconds" on the left; Highlights (from
|
||||
// recognition.yml) and Keeping up (from courses.yml) on the right. A block without content is left out, and
|
||||
// with neither the card is one column.
|
||||
import type { Course, Recognition, Site } from '../../../library/types.ts';
|
||||
import { courseSummary, monthYear } from '../../../core/courses.ts';
|
||||
import { cvRows } from './sections.ts';
|
||||
import { approxHours, headerHighlights, sealSvg } from './record.ts';
|
||||
interface Props { site: Site; recognition: Recognition[]; courses: Course[] }
|
||||
const { site, recognition, courses } = Astro.props;
|
||||
const { cv, links } = site;
|
||||
const { shown, more } = headerHighlights(recognition);
|
||||
const sum = courseSummary(courses);
|
||||
const chips = sum.topics.slice(0, 5);
|
||||
const hasSide = shown.length > 0 || sum.count > 0;
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||
---
|
||||
<section class:list={['khero', !hasSide && 'solo']} aria-label="CV in ten seconds">
|
||||
<div class="cv">
|
||||
<div class="lab"><b>●</b> CV in ten seconds</div>
|
||||
<h1>{site.name}</h1>
|
||||
{site.tagline && <p class="tg">{site.tagline}</p>}
|
||||
<div class="kv">
|
||||
{cv.now && <><span class="k">now</span><span>{cv.now}</span></>}
|
||||
{cv.before.length > 0 && <><span class="k">before</span><span>{cv.before.map((b, i) => <>{i > 0 && <br />}{b}</>)}</span></>}
|
||||
{cv.degree && <><span class="k">degree</span><span>{cv.degree}</span></>}
|
||||
{links.email && <><span class="k">contact</span><span><a href={`mailto:${links.email}`}>{links.email}</a></span></>}
|
||||
</div>
|
||||
{site.now.length > 0 && (
|
||||
<ul class="know" aria-label="Now">{site.now.map((n) => <li>{n}</li>)}</ul>
|
||||
)}
|
||||
<div class="kbtns">
|
||||
{links.cv && <a class="btn solid" href={`/media/${links.cv}`}>↓ CV (PDF)</a>}
|
||||
{cvRows(site).length > 0 && <a class="btn" href="#cv">full history ↓</a>}
|
||||
{links.github && <a class="btn" href={links.github}>GitHub</a>}
|
||||
{links.linkedin && <a class="btn" href={links.linkedin}>LinkedIn</a>}
|
||||
</div>
|
||||
</div>
|
||||
{hasSide && (
|
||||
<div class="side">
|
||||
{shown.length > 0 && (
|
||||
<div>
|
||||
<h2 class="lab">Highlights</h2>
|
||||
<div class="hl">
|
||||
{shown.map((r) => <><div>{r.title}</div><div class="r acc">{r.result}</div></>)}
|
||||
</div>
|
||||
{more > 0 && <a class="lnk" href="#record">+ {more} more in the record ↓</a>}
|
||||
</div>
|
||||
)}
|
||||
{sum.count > 0 && (
|
||||
<div>
|
||||
<h2 class="lab">Keeping up</h2>
|
||||
{sum.certificates > 0 && (
|
||||
<div class="kcount">
|
||||
<span class="bignum"><Fragment set:html={sealSvg(24)} /> {sum.certificates}</span>
|
||||
<span>{sum.certificates === 1 ? 'certificate' : 'certificates'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div class:list={['kline', sum.certificates === 0 && 'lead']}>{plural(sum.count, 'course', 'courses')} & hands-on labs · ~{approxHours(sum.minutes)} h since {monthYear(sum.since)}</div>
|
||||
<div class="chips">{chips.map((t) => <span class="chip">{t.certificates > 0 ? `${t.topic} ${t.certificates}` : t.topic}</span>)}</div>
|
||||
<a class="lnk" href="#record">all courses ↓</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -0,0 +1,21 @@
|
||||
// The courses strip under the "Also made" search (spec 009 §3): which courses match, and the count text.
|
||||
import { courseMatches, type CourseLike } from '../../../core/courses.ts';
|
||||
|
||||
/** What the island receives per course: the plain course fields, nothing more. */
|
||||
export type ListCourse = CourseLike;
|
||||
|
||||
export const STRIP_MAX = 5;
|
||||
|
||||
/** Matching courses, newest first, at most five; `more` links on to the learning page with the query. */
|
||||
export function courseStrip(courses: readonly ListCourse[], query: string) {
|
||||
const q = query.trim();
|
||||
if (!q) return { shown: [] as ListCourse[], more: 0, count: 0, href: '' };
|
||||
const hits = courses.filter((c) => courseMatches(c, q)).sort((a, b) => b.completed.localeCompare(a.completed));
|
||||
return { shown: hits.slice(0, STRIP_MAX), more: Math.max(0, hits.length - STRIP_MAX), count: hits.length, href: `/work/learning/?q=${encodeURIComponent(q)}` };
|
||||
}
|
||||
|
||||
/** "1 project · 5 courses match" (labs count as courses); '' when no course matches. */
|
||||
export function matchCountText(projects: number, courses: number): string {
|
||||
if (courses === 0) return '';
|
||||
return `${projects} ${projects === 1 ? 'project' : 'projects'} · ${courses} ${courses === 1 ? 'course matches' : 'courses match'}`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// View helpers for the CV header and the Record section (spec 009): the seal mark, highlights, hours.
|
||||
import type { Recognition } from '../../../library/types.ts';
|
||||
|
||||
/** The certificate seal, drawn in currentColor. Decorative: the text next to it always says "certificate". */
|
||||
export const sealSvg = (size: number): string =>
|
||||
`<svg class="seal" width="${size}" height="${size}" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle cx="8" cy="6.5" r="4.6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5.2 10.2 4.4 15l3.6-1.8 3.6 1.8-.8-4.8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>`;
|
||||
|
||||
export const HIGHLIGHTS_MAX = 3;
|
||||
|
||||
/** The header's highlights: highlighted entries in file order, at most three; `more` counts the rest of the record. */
|
||||
export function headerHighlights(recognition: Recognition[]): { shown: Recognition[]; more: number } {
|
||||
const shown = recognition.filter((r) => r.highlight).slice(0, HIGHLIGHTS_MAX);
|
||||
return { shown, more: recognition.length - shown.length };
|
||||
}
|
||||
|
||||
/** Whole hours for the totals ("~11 h"); never "~0". */
|
||||
export const approxHours = (minutes: number): number => Math.max(1, Math.round(minutes / 60));
|
||||
|
||||
/** "1 course", "2 courses · 3 labs"; a part that is zero is left out. */
|
||||
export function doneText(courses: number, labs: number): string {
|
||||
return [
|
||||
courses > 0 && `${courses} ${courses === 1 ? 'course' : 'courses'}`,
|
||||
labs > 0 && `${labs} ${labs === 1 ? 'lab' : 'labs'}`,
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/** The host of a link, for the small "example.org ↗" after a linked title. */
|
||||
export const hostOf = (url: string): string => new URL(url).host.replace(/^www\./, '');
|
||||
@@ -1,6 +1,6 @@
|
||||
// Splits a project's rendered Markdown body at its `<h2>` headings ("Why", "How", "What's
|
||||
// interesting", ...) so the sheet and panels can lay them out as labelled rows.
|
||||
import type { Project, Site } from '../../../library/types.ts';
|
||||
import type { Course, Project, Recognition, Site } from '../../../library/types.ts';
|
||||
import type { Post } from '../../../sources/types.ts';
|
||||
|
||||
export type Section = { title: string | null; html: string };
|
||||
@@ -43,12 +43,23 @@ export function formatDate(iso: string, style: 'short' | 'long' = 'long'): strin
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
|
||||
}
|
||||
|
||||
// The featured panels on /work/: up to four projects, by their `featured` rank.
|
||||
// The featured panels on /work/ ("Projects I'm proudest of"): up to six projects, by their `featured` rank.
|
||||
export const FEATURED_MAX = 6;
|
||||
export const featuredOf = (projects: Project[]) =>
|
||||
projects
|
||||
.filter((p) => p.featured !== undefined)
|
||||
.sort((a, b) => a.featured! - b.featured!)
|
||||
.slice(0, 4);
|
||||
.slice(0, FEATURED_MAX);
|
||||
|
||||
// Panel classes by position: wide · half · half · wide reversed · half · half. The pattern stops where the
|
||||
// projects end; a half left alone in the last row takes the full width as a wide panel.
|
||||
const FEAT_PATTERN = ['wide', '', '', 'wide rev', '', ''];
|
||||
export function featLayout(n: number): string[] {
|
||||
const out = FEAT_PATTERN.slice(0, n);
|
||||
const halvesAtEnd = out.length - 1 - out.findLastIndex((c) => c.includes('wide'));
|
||||
if (halvesAtEnd % 2 === 1) out[out.length - 1] = 'wide';
|
||||
return out;
|
||||
}
|
||||
|
||||
// "Where I've worked" rows, from site.yml's cv block only (no dates are made up here).
|
||||
export function cvRows(site: Site): { when: string; what: string }[] {
|
||||
@@ -61,8 +72,18 @@ export function cvRows(site: Site): { when: string; what: string }[] {
|
||||
}
|
||||
|
||||
// Which /work/ sections exist, so the top bar never links to an anchor that is not on the page.
|
||||
export type WorkSections = { featured: boolean; list: boolean; writing: boolean; cv: boolean };
|
||||
export function workSections(site: Site, projects: Project[], posts: Post[]): WorkSections {
|
||||
// `learning` says whether /work/learning/ is built (it needs courses).
|
||||
export type WorkSections = { featured: boolean; list: boolean; record: boolean; learning: boolean; writing: boolean; cv: boolean };
|
||||
export function workSections(
|
||||
site: Site, projects: Project[], posts: Post[], recognition: Recognition[], courses: Course[],
|
||||
): WorkSections {
|
||||
const featured = featuredOf(projects).length;
|
||||
return { featured: featured > 0, list: projects.length > featured, writing: posts.length > 0, cv: cvRows(site).length > 0 };
|
||||
return {
|
||||
featured: featured > 0,
|
||||
list: projects.length > featured,
|
||||
record: recognition.length > 0 || courses.length > 0,
|
||||
learning: courses.length > 0,
|
||||
writing: posts.length > 0,
|
||||
cv: cvRows(site).length > 0,
|
||||
};
|
||||
}
|
||||
+2
-1
@@ -20,12 +20,13 @@ export const FEEDS: Record<FeedName, { path: string; page: string; title: (siteN
|
||||
/** The `rel="alternate"` feed a page links: its path and title. */
|
||||
export const feedLink = (name: FeedName, siteName: string) => ({ path: FEEDS[name].path, title: FEEDS[name].title(siteName) });
|
||||
|
||||
type Data = Pick<SiteData, 'projects' | 'workPosts' | 'personal' | 'issues'>;
|
||||
type Data = Pick<SiteData, 'projects' | 'workPosts' | 'personal' | 'issues' | 'courses'>;
|
||||
|
||||
/** Every page the site builds except the 404, root-relative with the trailing slash it is served with. */
|
||||
export function sitePaths(data: Data): string[] {
|
||||
return [
|
||||
'/', '/work/', '/off-the-clock/', '/off-the-clock/issues/', '/everything/', '/simple/',
|
||||
...(data.courses.length > 0 ? ['/work/learning/'] : []),
|
||||
...data.projects.map((p) => `/work/${p.id}/`),
|
||||
...data.workPosts.map((p) => `/work/writing/${p.slug}/`),
|
||||
...data.personal.map((e) => `/off-the-clock/${'slug' in e ? e.slug : e.id}/`),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
// Frame shared by every page of the work space: plum edge, top bar, footer.
|
||||
// Frame shared by every page of the work space: plum edge, top bar, footer (no Bluesky here since spec 009;
|
||||
// the off-the-clock footer and /simple keep it).
|
||||
import Base from './Base.astro';
|
||||
import TopBar from '../components/work/TopBar.astro';
|
||||
import EdgeToPersonal from '../components/work/EdgeToPersonal.astro';
|
||||
@@ -21,7 +22,7 @@ const { links } = site;
|
||||
---
|
||||
<Base title={title} description={description} theme="work" type={type} image={image} feed="work">
|
||||
<EdgeToPersonal />
|
||||
<TopBar name={site.name} onIndex={onIndex} hasFeatured={sections.featured} hasList={sections.list} hasWriting={sections.writing} hasCv={sections.cv} />
|
||||
<TopBar name={site.name} onIndex={onIndex} hasFeatured={sections.featured} hasRecord={sections.record} hasWriting={sections.writing} hasCv={sections.cv} />
|
||||
<main class="work">
|
||||
<slot />
|
||||
<footer>
|
||||
@@ -30,7 +31,6 @@ const { links } = site;
|
||||
<a href="/simple/">/simple</a>
|
||||
{links.github && <a href={links.github}>GitHub</a>}
|
||||
{links.linkedin && <a href={links.linkedin}>LinkedIn</a>}
|
||||
{links.bluesky && <a href={links.bluesky}>Bluesky</a>}
|
||||
{links.email && <a href={`mailto:${links.email}`}>{links.email}</a>}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function getStaticPaths() {
|
||||
}
|
||||
|
||||
const { project } = Astro.props as { project: Project };
|
||||
const { site, projects, workPosts } = await getSiteData();
|
||||
const { site, projects, workPosts, recognition, courses } = await getSiteData();
|
||||
---
|
||||
<WorkLayout
|
||||
title={`${project.title} · ${site.name}`}
|
||||
@@ -19,7 +19,7 @@ const { site, projects, workPosts } = await getSiteData();
|
||||
type="article"
|
||||
image={project.image}
|
||||
site={site}
|
||||
sections={workSections(site, projects, workPosts)}
|
||||
sections={workSections(site, projects, workPosts, recognition, courses)}
|
||||
>
|
||||
<article class="wrap page">
|
||||
<a class="up" href="/work/">← all work</a>
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
---
|
||||
import WorkLayout from '../../layouts/Work.astro';
|
||||
import AiMark from '../../components/AiMark.astro';
|
||||
import NowStrip from '../../components/work/NowStrip.astro';
|
||||
import WorkHeader from '../../components/work/WorkHeader.astro';
|
||||
import Featured from '../../components/work/Featured.astro';
|
||||
import ProjectList, { type ListItem } from '../../components/work/ProjectList.tsx';
|
||||
import type { ListCourse } from '../../components/work/course-search.ts';
|
||||
import ProjectSheet from '../../components/work/ProjectSheet.tsx';
|
||||
import ProjectBody from '../../components/work/ProjectBody.astro';
|
||||
import Record from '../../components/work/Record.astro';
|
||||
import WorkHistory from '../../components/work/WorkHistory.astro';
|
||||
import { hasShowcase, thumbSvg } from '../../components/showcases/index.ts';
|
||||
import { featuredOf, formatDate, topicTag, workSections } from '../../components/work/sections.ts';
|
||||
import { getSiteData } from '../../data';
|
||||
import { isVisible } from '../../../core/ai.ts';
|
||||
|
||||
const { site, projects, workPosts } = await getSiteData();
|
||||
const { site, projects, workPosts, recognition, courses } = await getSiteData();
|
||||
const featured = featuredOf(projects);
|
||||
const rest = projects.filter((p) => !featured.includes(p));
|
||||
const items: ListItem[] = rest.map((p) => ({
|
||||
@@ -21,10 +22,13 @@ const items: ListItem[] = rest.map((p) => ({
|
||||
...(p.years && { years: p.years }),
|
||||
...(p.image && !hasShowcase(p.showcase) && { image: p.image }),
|
||||
}));
|
||||
// The island gets only what the course search needs (no urls).
|
||||
const listCourses: ListCourse[] = courses.map(({ title, provider, kind, topic, completed, minutes, certificate, keywords }) => ({
|
||||
title, provider, kind, topic, completed, minutes, certificate, keywords,
|
||||
}));
|
||||
const titles = Object.fromEntries(projects.map((p) => [p.id, p.title]));
|
||||
const blog = workPosts[0] ? new URL(workPosts[0].url).origin : undefined;
|
||||
const [lead, more] = site.intro;
|
||||
const sections = workSections(site, projects, workPosts);
|
||||
const sections = workSections(site, projects, workPosts, recognition, courses);
|
||||
// Post counts per preference, so the Writing card never claims posts the filter hides.
|
||||
const plural = (n: number) => `${n} ${n === 1 ? 'post' : 'posts'}`;
|
||||
const postCounts: [string, number][] = [
|
||||
@@ -41,23 +45,13 @@ const postCounts: [string, number][] = [
|
||||
sections={sections}
|
||||
>
|
||||
<div class="wrap">
|
||||
<section class="intro">
|
||||
<div>
|
||||
<div class="eyebrow"><b>●</b> work · projects, writing, cv</div>
|
||||
{lead ? <h1>{lead}{more && <> <em>{more}</em></>}</h1> : <h1 class="sr-only">{site.name}</h1>}
|
||||
<p>Every project is marked with how much of it was made with AI, and the filter at the top hides what you would rather not see.</p>
|
||||
</div>
|
||||
<div class="legend" aria-label="Legend">
|
||||
<div><AiMark level="ai-made" /><AiMark level="some-ai" /></div>
|
||||
<div><AiMark level="by-hand" /><AiMark level="unknown" /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NowStrip site={site} posts={workPosts} />
|
||||
<WorkHeader site={site} recognition={recognition} courses={courses} />
|
||||
|
||||
{featured.length > 0 && <Featured projects={featured} />}
|
||||
|
||||
{rest.length > 0 && <ProjectList client:idle items={items} title={featured.length > 0 ? 'Everything else' : 'All projects'} />}
|
||||
{rest.length > 0 && <ProjectList client:idle items={items} title="Also made" courses={listCourses} />}
|
||||
|
||||
{sections.record && <Record recognition={recognition} courses={courses} />}
|
||||
|
||||
{(sections.writing || sections.cv) && (
|
||||
<div class:list={['lower', !(sections.writing && sections.cv) && 'single']}>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
// /work/learning/ (spec 009 §5): every course and lab, newest first, with search and topic filters.
|
||||
// A rest route so the page can be left out: without courses getStaticPaths returns nothing and no page is built.
|
||||
// Complete without JavaScript; scripts/learning-filter.ts only hides rows.
|
||||
import WorkLayout from '../../../layouts/Work.astro';
|
||||
import CourseSumLine from '../../../components/work/CourseSumLine.astro';
|
||||
import { workSections } from '../../../components/work/sections.ts';
|
||||
import { sealSvg } from '../../../components/work/record.ts';
|
||||
import { courseSearchText, courseSummary, formatHours } from '../../../../core/courses.ts';
|
||||
import { formatDate } from '../../../components/work/sections.ts';
|
||||
import { getSiteData } from '../../../data';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const { courses } = await getSiteData();
|
||||
return courses.length > 0 ? [{ params: { path: undefined } }] : [];
|
||||
}
|
||||
|
||||
const { site, projects, workPosts, recognition, courses } = await getSiteData();
|
||||
const sum = courseSummary(courses);
|
||||
const rows = [...courses].sort((a, b) => b.completed.localeCompare(a.completed) || a.title.localeCompare(b.title));
|
||||
---
|
||||
<WorkLayout
|
||||
title={`Courses & certificates · ${site.name}`}
|
||||
description={`Courses, hands-on labs and certificates ${site.name} has completed.`}
|
||||
site={site}
|
||||
sections={workSections(site, projects, workPosts, recognition, courses)}
|
||||
>
|
||||
<div class="wrap page learning">
|
||||
<a class="up" href="/work/#record">← back to work</a>
|
||||
<h1>Courses & certificates</h1>
|
||||
<CourseSumLine sum={sum} />
|
||||
<div class="tools ltools">
|
||||
<div class="tabs" role="group" aria-label="Filter by topic">
|
||||
<button type="button" data-topic="" aria-pressed="true">All<small>{courses.length}</small></button>
|
||||
{sum.topics.map((t) => <button type="button" data-topic={t.topic} aria-pressed="false">{t.topic}<small>{t.courses + t.labs}</small></button>)}
|
||||
</div>
|
||||
<span class="lshown" data-shown aria-live="polite"></span>
|
||||
<label class="search">
|
||||
<input type="search" data-course-search placeholder="Search course, provider, topic" aria-label="Search courses" />
|
||||
</label>
|
||||
</div>
|
||||
<table class="ltable">
|
||||
<thead><tr><th scope="col">completed</th><th scope="col">course</th><th scope="col">topic</th><th scope="col">provider</th><th scope="col" class="c">certificate</th><th scope="col" class="c">time</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((c) => (
|
||||
<tr data-course data-topic={c.topic} data-search={courseSearchText(c)}>
|
||||
<td class="d"><time datetime={c.completed}>{formatDate(c.completed)}</time></td>
|
||||
<td class="ct">{c.url ? <a href={c.url}>{c.title}</a> : c.title}{c.kind === 'lab' && <span class="tag">lab</span>}</td>
|
||||
<td class="tp">{c.topic}</td>
|
||||
<td class="pv">{c.provider}</td>
|
||||
<td class="c ce">{c.certificate ? <><Fragment set:html={sealSvg(13)} /><span class="sr-only">certificate</span></> : <span aria-hidden="true">—</span><span class="sr-only">no certificate</span>}</td>
|
||||
<td class="c">{formatHours(c.minutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="none" data-none hidden>No course matches.</p>
|
||||
</div>
|
||||
<script>
|
||||
import { initLearningFilter } from '../../../scripts/learning-filter.ts';
|
||||
initLearningFilter();
|
||||
</script>
|
||||
</WorkLayout>
|
||||
@@ -11,7 +11,7 @@ export async function getStaticPaths() {
|
||||
}
|
||||
|
||||
const { post } = Astro.props as { post: Post };
|
||||
const { site, projects, workPosts } = await getSiteData();
|
||||
const { site, projects, workPosts, recognition, courses } = await getSiteData();
|
||||
const topic = topicTag(post.tags);
|
||||
---
|
||||
<WorkLayout
|
||||
@@ -19,7 +19,7 @@ const topic = topicTag(post.tags);
|
||||
description={post.excerpt || post.title}
|
||||
type="article"
|
||||
site={site}
|
||||
sections={workSections(site, projects, workPosts)}
|
||||
sections={workSections(site, projects, workPosts, recognition, courses)}
|
||||
>
|
||||
<article class="wrap page post">
|
||||
<a class="up" href="/work/#writing">← all writing</a>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// /work/learning/: filters the course table by search text and topic button, and reads `?q=` on load.
|
||||
// The page is complete without it: every row is in the HTML and visible. Rows that do not match get
|
||||
// `hidden`; "No course matches" shows when none do. Plain script, no React (spec 009 §5).
|
||||
|
||||
export type Row = { search: string; topic: string };
|
||||
|
||||
/** Search is a case-insensitive substring of the row's search text; an empty topic means all topics. */
|
||||
export function rowMatches(row: Row, query: string, topic: string): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (!topic || row.topic === topic) && (!q || row.search.includes(q));
|
||||
}
|
||||
|
||||
/* ---------- browser ---------- */
|
||||
|
||||
export function initLearningFilter(root: ParentNode = document): void {
|
||||
const input = root.querySelector<HTMLInputElement>('[data-course-search]');
|
||||
const buttons = [...root.querySelectorAll<HTMLButtonElement>('button[data-topic]')];
|
||||
const rows = [...root.querySelectorAll<HTMLElement>('[data-course]')];
|
||||
const none = root.querySelector<HTMLElement>('[data-none]');
|
||||
const count = root.querySelector<HTMLElement>('[data-shown]');
|
||||
if (!input) return;
|
||||
let topic = '';
|
||||
|
||||
const apply = () => {
|
||||
let shown = 0;
|
||||
for (const r of rows) {
|
||||
const ok = rowMatches({ search: r.dataset.search ?? '', topic: r.dataset.topic ?? '' }, input.value, topic);
|
||||
r.hidden = !ok;
|
||||
if (ok) shown++;
|
||||
}
|
||||
if (none) none.hidden = shown > 0;
|
||||
if (count) count.textContent = shown === rows.length ? '' : `${shown} of ${rows.length} shown`;
|
||||
};
|
||||
|
||||
const q = new URLSearchParams(location.search).get('q');
|
||||
if (q) input.value = q;
|
||||
input.addEventListener('input', apply);
|
||||
for (const b of buttons) {
|
||||
b.addEventListener('click', () => {
|
||||
topic = b.dataset.topic ?? '';
|
||||
for (const o of buttons) o.setAttribute('aria-pressed', String(o === b));
|
||||
apply();
|
||||
});
|
||||
}
|
||||
apply();
|
||||
}
|
||||
+124
-33
@@ -46,34 +46,35 @@
|
||||
main.work { margin-right: var(--edge); }
|
||||
.wrap { max-width: 1320px; margin: 0 auto; padding: 0 40px; }
|
||||
|
||||
/* intro + now strip */
|
||||
.intro { padding: 32px 0 22px; display: grid; grid-template-columns: 1fr auto; gap: 40px; align-items: end; }
|
||||
.eyebrow { font: 12px var(--mono); color: var(--mute); letter-spacing: .02em; }
|
||||
.eyebrow b { color: var(--acc); font-weight: 500; }
|
||||
.intro h1 { font-size: 34px; line-height: 1.08; letter-spacing: -.028em; font-weight: 600; margin: 12px 0 0; max-width: 20em; }
|
||||
.intro h1 em { font-style: normal; color: var(--mute); }
|
||||
.intro p { margin: 10px 0 0; color: var(--ink2); max-width: 44em; font-size: 16px; }
|
||||
.legend { display: flex; flex-direction: column; gap: 7px; padding-bottom: 4px; }
|
||||
.legend > div { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
/* CV header (spec 009): CV in ten seconds | highlights + keeping up */
|
||||
.khero { display: grid; grid-template-columns: 1.35fr 1fr; border: 1px solid var(--line); border-radius: 18px; background: var(--panel); margin: 34px 0 48px; overflow: hidden; }
|
||||
.khero.solo { grid-template-columns: 1fr; }
|
||||
.khero .cv { padding: 32px 36px; min-width: 0; }
|
||||
.khero .side { border-left: 1px solid var(--line); padding: 28px 30px; background: var(--bg2); display: flex; flex-direction: column; gap: 24px; min-width: 0; }
|
||||
.khero h1 { font-size: 36px; line-height: 1.08; margin: 10px 0 6px; font-weight: 600; letter-spacing: -.02em; }
|
||||
.khero .tg { color: var(--mute); margin: 0 0 24px; font-size: 16px; }
|
||||
.lab { margin: 0 0 6px; font: 500 11px var(--mono); letter-spacing: .09em; color: var(--mute); text-transform: uppercase; }
|
||||
.lab b { color: var(--acc); font-weight: 500; }
|
||||
.kv { display: grid; grid-template-columns: 78px 1fr; gap: 9px 16px; font-size: 15px; }
|
||||
.kv .k { font: 11.5px var(--mono); color: var(--mute); padding-top: 3px; }
|
||||
.know { margin: 16px 0 0; padding: 0; list-style: none; font-size: 14px; color: var(--ink2); }
|
||||
.know li { padding: 2px 0; display: flex; gap: 8px; }
|
||||
.know li::before { content: ""; flex: 0 0 5px; height: 5px; margin-top: 9px; border-radius: 50%; background: var(--dim); }
|
||||
.know li:first-child::before { background: var(--acc); }
|
||||
.kbtns { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 22px; }
|
||||
.hl { display: grid; grid-template-columns: 1fr auto; gap: 0; font-size: 14.5px; }
|
||||
.hl > div { padding: 9px 0; border-top: 1px solid var(--line); min-width: 0; }
|
||||
.hl .r { font: 12px var(--mono); color: var(--mute); text-align: right; padding-left: 14px; }
|
||||
.hl .acc, .acc { color: var(--acc); }
|
||||
.seal { display: inline-block; vertical-align: -2px; flex: none; }
|
||||
.kcount { display: flex; align-items: baseline; gap: 12px; margin-top: 6px; }
|
||||
.bignum { font-size: 34px; font-weight: 600; line-height: 1; letter-spacing: -.02em; color: var(--acc); }
|
||||
.kline { font: 12px var(--mono); color: var(--mute); margin-top: 8px; }
|
||||
.kline.lead { font: 15px var(--sans); color: var(--ink2); margin-top: 6px; }
|
||||
.khero .chips { margin-top: 12px; }
|
||||
.lnk { font: 12px var(--mono); color: var(--mute); text-decoration: none; display: inline-block; margin-top: 10px; }
|
||||
.lnk:hover { color: var(--ink); }
|
||||
|
||||
.now { display: flex; gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; margin-bottom: 28px; }
|
||||
.now > div { flex: var(--grow, 1) 1 0; background: var(--bg2); padding: 14px 20px; min-width: 0; }
|
||||
.now h2 { margin: 0 0 8px; font: 500 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--mute); display: flex; justify-content: space-between; gap: 8px; }
|
||||
.now h2 a { text-decoration: none; color: var(--mute); }
|
||||
.now h2 a:hover { color: var(--ink); }
|
||||
.now ul { margin: 0; padding: 0; list-style: none; font-size: 14px; }
|
||||
.now li { padding: 2px 0; color: var(--ink2); display: flex; gap: 8px; }
|
||||
.now li::before { content: ""; flex: 0 0 5px; height: 5px; margin-top: 9px; border-radius: 50%; background: var(--dim); }
|
||||
.now li:first-child::before { background: var(--acc); }
|
||||
.wl a { display: block; text-decoration: none; padding: 2px 0; }
|
||||
.wl a .t { color: var(--ink); font-size: 14px; }
|
||||
.wl a:hover .t { color: var(--acc); }
|
||||
.wl a .d { font: 11px var(--mono); color: var(--dim); margin-left: 6px; }
|
||||
.wl a.lead .t { font-weight: 500; }
|
||||
.cv10 { display: grid; grid-template-columns: 70px 1fr; gap: 3px 12px; font-size: 13.5px; color: var(--ink2); }
|
||||
.cv10 .k { font: 11px var(--mono); color: var(--dim); padding-top: 2px; }
|
||||
.cv10 .v b { color: var(--ink); font-weight: 500; }
|
||||
.cvlinks { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; font: 12px var(--mono); text-decoration: none; color: var(--ink2); border: 1px solid var(--line2); border-radius: 6px; padding: 3px 9px; background: none; cursor: pointer; transition: border-color .15s, color .15s, background .15s; }
|
||||
.btn:hover { color: var(--ink); border-color: var(--mute); }
|
||||
.btn.solid { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
||||
@@ -88,6 +89,12 @@ main.work { margin-right: var(--edge); }
|
||||
.sh .n { font: 12px var(--mono); color: var(--mute); }
|
||||
.sh .r { margin-left: auto; font: 12px var(--mono); color: var(--mute); text-decoration: none; }
|
||||
a.r:hover { color: var(--acc); }
|
||||
.sh h2 { white-space: nowrap; }
|
||||
/* The page's AI explanation, at the right of the "Projects I'm proudest of" heading, with the hint under it. */
|
||||
.fside { margin-left: auto; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; align-self: flex-end; }
|
||||
.sh .hint { font: 12px var(--mono); color: var(--dim); }
|
||||
.ailegend { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; font: 12px var(--mono); color: var(--mute); }
|
||||
.ailegend .marks { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
|
||||
/* featured panels */
|
||||
.feats { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
@@ -147,9 +154,65 @@ html:not(.js) .idx .tools { display: none; }
|
||||
.row .go { color: var(--dim); transition: transform .2s var(--ease), color .15s; }
|
||||
.row:hover .go { color: var(--acc); }
|
||||
@media (hover: hover) { .row:hover .go { transform: translateX(3px); } }
|
||||
/* courses matching the list search (spec 009 §3) */
|
||||
.cstrip { border: 1px solid var(--line); border-radius: 10px; background: var(--bg2); padding: 4px 14px 8px; margin: 0 0 14px; }
|
||||
.crow { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 10px; align-items: baseline; padding: 7px 0; border-top: 1px solid var(--line); font-size: 14px; }
|
||||
.crow:first-child { border-top: 0; }
|
||||
.cseal { color: var(--acc); }
|
||||
.ct .tag { margin-left: 8px; font: 10.5px var(--mono); color: var(--mute); border: 1px solid var(--line2); border-radius: 4px; padding: 0 5px; vertical-align: 1px; }
|
||||
.cm { font: 12px var(--mono); color: var(--mute); white-space: nowrap; }
|
||||
.cmore { display: inline-block; margin-top: 4px; font: 12px var(--mono); color: var(--acc); text-decoration: none; }
|
||||
.cmore:hover { text-decoration: underline; }
|
||||
|
||||
.empty { padding: 18px 8px; font: 13px var(--mono); color: var(--mute); border-bottom: 1px solid var(--line); }
|
||||
.hiddenby { font: 12px var(--mono); color: var(--mute); padding: 10px 8px; }
|
||||
|
||||
/* record (spec 009 §4) */
|
||||
#record { margin-top: 70px; }
|
||||
.rec { display: grid; grid-template-columns: 1fr 1.15fr; gap: 18px; }
|
||||
.rec.single { grid-template-columns: 1fr; }
|
||||
.rec .card { min-width: 0; }
|
||||
.rec .hl { margin-top: 8px; }
|
||||
.subh { margin: 0; font: 500 10px var(--mono); letter-spacing: .08em; color: var(--mute); text-transform: uppercase; padding: 16px 0 4px; grid-column: 1 / -1; }
|
||||
.rec .hl > .subh { border-top: 0; padding-top: 16px; }
|
||||
.rec .hl > .subh:first-child { padding-top: 4px; }
|
||||
.rec .y, .rec .dt { font: 11px var(--mono); color: var(--mute); }
|
||||
.rec .hl a { color: inherit; text-decoration-color: var(--line2); text-underline-offset: 3px; }
|
||||
.rec .hl a:hover { color: var(--acc); }
|
||||
.sumline { font-size: 14px; color: var(--ink2); margin: 6px 0 14px; }
|
||||
.sumline b { color: var(--ink); font-weight: 500; }
|
||||
.sumline b.acc { color: var(--acc); }
|
||||
.ku { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.ku th { font: 500 10px var(--mono); letter-spacing: .08em; color: var(--mute); text-transform: uppercase; text-align: left; padding: 0 0 8px; }
|
||||
.ku td { padding: 9px 0; border-top: 1px solid var(--line); }
|
||||
.ku .c { font-family: var(--mono); font-size: 12px; color: var(--mute); text-align: right; white-space: nowrap; padding-left: 18px; }
|
||||
.ku th.c { font-size: 10px; }
|
||||
.ku .ce { color: var(--ink); }
|
||||
.rec .lnk.acc { color: var(--acc); }
|
||||
.rec .lnk.acc:hover { text-decoration: underline; }
|
||||
|
||||
/* /work/learning/ (spec 009 §5) */
|
||||
.page.learning { max-width: 1100px; }
|
||||
.learning h1 { font-size: 34px; letter-spacing: -.025em; line-height: 1.1; margin: 22px 0 8px; font-weight: 600; }
|
||||
.learning .sumline { margin-bottom: 22px; }
|
||||
html:not(.js) .ltools { display: none; }
|
||||
.ltools .tabs { flex-wrap: wrap; }
|
||||
.ltools .tabs button { white-space: nowrap; }
|
||||
.lshown { font: 12px var(--mono); color: var(--mute); }
|
||||
.ltable { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.ltable th { font: 500 10px var(--mono); letter-spacing: .08em; color: var(--mute); text-transform: uppercase; text-align: left; padding: 0 12px 8px 0; }
|
||||
.ltable td { padding: 9px 12px 9px 0; border-top: 1px solid var(--line); vertical-align: baseline; }
|
||||
.ltable .d, .ltable .pv, .ltable .tp { font: 12px var(--mono); color: var(--mute); white-space: nowrap; }
|
||||
.ltable .tp { color: var(--ink2); }
|
||||
.ltable .ct a { color: var(--ink); text-decoration-color: var(--line2); text-underline-offset: 3px; }
|
||||
.ltable .ct a:hover { color: var(--acc); }
|
||||
.ltable .ct .tag { margin-left: 8px; font: 10.5px var(--mono); color: var(--mute); border: 1px solid var(--line2); border-radius: 4px; padding: 0 5px; vertical-align: 1px; }
|
||||
.ltable .c { font: 12px var(--mono); color: var(--mute); text-align: right; white-space: nowrap; padding-right: 0; padding-left: 12px; }
|
||||
.ltable th.c { font-size: 10px; }
|
||||
.ltable .ce { color: var(--acc); }
|
||||
.ltable .ce [aria-hidden] { color: var(--dim); }
|
||||
.learning .none { font: 13px var(--mono); color: var(--mute); padding: 14px 0; }
|
||||
|
||||
/* writing + history */
|
||||
.lower { margin-top: 64px; display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.lower.single { grid-template-columns: 1fr; }
|
||||
@@ -234,6 +297,10 @@ html[data-sheet-open] { overflow: hidden; }
|
||||
[data-pref='by-hand-only'] .pref-note:not([data-level='by-hand']),
|
||||
[data-pref='not-fully-ai'] .pref-note[data-level='ai-made'] { display: block; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.khero, .rec { grid-template-columns: 1fr; }
|
||||
.khero .side { border-left: 0; border-top: 1px solid var(--line); }
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.nav { display: none; }
|
||||
.feat.wide, .feat.wide.rev { grid-template-columns: 1fr; }
|
||||
@@ -249,23 +316,47 @@ html[data-sheet-open] { overflow: hidden; }
|
||||
.bar .pref-label { display: none; }
|
||||
.bar .pref-archive { position: static; flex-basis: 100%; white-space: normal; margin-top: 6px; }
|
||||
.edge { font-size: 13px; }
|
||||
.intro { grid-template-columns: 1fr; }
|
||||
.legend > div { justify-content: flex-start; }
|
||||
.intro h1 { font-size: 30px; }
|
||||
.now { flex-direction: column; }
|
||||
.now > div { flex: none; }
|
||||
.khero { margin-top: 20px; }
|
||||
.khero .cv, .khero .side { padding: 24px 20px; }
|
||||
.khero h1 { font-size: 30px; }
|
||||
.kv { grid-template-columns: 64px 1fr; }
|
||||
.feats { grid-template-columns: 1fr; }
|
||||
.row { grid-template-columns: 56px 1fr 20px; gap: 12px; }
|
||||
.row .one, .row .ai-mark { display: none; }
|
||||
.crow { grid-template-columns: 18px minmax(0, 1fr); }
|
||||
.cm { grid-column: 2; white-space: normal; }
|
||||
.search { margin-left: 0; width: 100%; }
|
||||
.search input { width: 100%; }
|
||||
.lower { grid-template-columns: 1fr; }
|
||||
.posts a { grid-template-columns: 1fr; gap: 2px; }
|
||||
.sh .r { display: none; }
|
||||
.sh .r, .sh .hint { display: none; }
|
||||
.fside { margin-left: 0; flex-basis: 100%; align-items: flex-start; }
|
||||
.ailegend { justify-content: flex-start; }
|
||||
.sh:has(.fside) { flex-wrap: wrap; }
|
||||
.pbody section { grid-template-columns: 1fr; gap: 6px; }
|
||||
.hl { grid-template-columns: 1fr; }
|
||||
.hl .r { text-align: left; padding: 0 0 9px; border-top: 0; }
|
||||
.hl > div:not(.r) { padding-bottom: 2px; }
|
||||
.ku th:nth-child(2), .ku td:nth-child(2) { display: none; }
|
||||
.ku .c { padding-left: 12px; }
|
||||
.ltable thead { display: none; }
|
||||
.ltable tr { display: grid; grid-template-columns: 1fr auto auto; gap: 2px 12px; padding: 10px 0; border-top: 1px solid var(--line); }
|
||||
.ltable tr[hidden] { display: none; }
|
||||
.ltable td { border-top: 0; padding: 0; }
|
||||
.ltable .ct { grid-column: 1 / -1; order: -1; }
|
||||
.ltable .tp, .ltable .pv { display: none; }
|
||||
.ltable .d { grid-column: 1; }
|
||||
.page .pbody h1, .post h1 { font-size: 30px; }
|
||||
}
|
||||
|
||||
/* Screenshots are muted (half saturation, 80% brightness) until the pointer is over their panel or row, or
|
||||
focus is within; drawn showcases keep their colour. base.css drops the transition under reduced motion. */
|
||||
.feat .s-image img { object-position: top center; filter: saturate(.5) brightness(.8); transition: filter .3s var(--ease); }
|
||||
.feat:hover .s-image img, .feat:focus-within .s-image img { filter: none; }
|
||||
.feat.wide .s-image { min-height: 380px; }
|
||||
.row .th-image img { filter: saturate(.5) brightness(.8); transition: filter .3s var(--ease); }
|
||||
.row:hover .th-image img, .row:focus-visible .th-image img { filter: none; }
|
||||
|
||||
/* project images (stand in for a showcase; contain so screenshots are never cropped oddly) */
|
||||
.row .th-image { overflow: hidden; }
|
||||
.row .th-image img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ const cli = (...args: string[]) => Bun.spawnSync(['bun', 'src/cli.ts', ...args],
|
||||
it('validate passes on the fixture content', () => {
|
||||
const r = cli('validate', 'fixtures/content');
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout.toString()).toContain('Content OK (5 projects, 14 items)');
|
||||
expect(r.stdout.toString()).toContain('Content OK (9 projects, 14 items)');
|
||||
});
|
||||
|
||||
it('validate lists every error and exits 1', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('link previews on every page', () => {
|
||||
|
||||
test('og:image exactly where the page has its own (raster) image, with the large card', () => {
|
||||
const expected = new Map<string, string>();
|
||||
for (const p of data.projects) if (p.image) expected.set(`/work/${p.id}/`, p.image.file);
|
||||
for (const p of data.projects) if (p.image && !p.image.file.endsWith('.svg')) expected.set(`/work/${p.id}/`, p.image.file);
|
||||
for (const i of data.items) {
|
||||
const img = i.media.find((m) => m.file && /\.(png|jpe?g|gif|webp|avif|svg)$/i.test(m.file));
|
||||
if (img && !img.file!.endsWith('.svg')) expected.set(`/off-the-clock/${i.id}/`, img.file!);
|
||||
|
||||
@@ -226,3 +226,181 @@ describe('without SITE_URL (spec 007)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('/work/ CV header (spec 009)', () => {
|
||||
const header = (root: string) => {
|
||||
const html = page(root, 'work/index.html');
|
||||
// The header holds no nested sections, so the first </section> after it closes it.
|
||||
const start = html.indexOf('<section class="khero');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
return html.slice(start, html.indexOf('</section>', start));
|
||||
};
|
||||
|
||||
test('the left column: name, tagline, cv rows, contact, now list and buttons', () => {
|
||||
const h = header(out);
|
||||
expect(h).toMatch(/<h1[^>]*>Example Person<\/h1>/);
|
||||
expect(h).toContain('Fixture tagline, obviously fake');
|
||||
expect(h).toContain('CV in ten seconds');
|
||||
expect(h).toContain('Example Role at Example Corp (fixture)');
|
||||
expect(h).toMatch(/Sample Role at Sample Ltd \(fixture\)<br>Demo Role at Demo Inc \(fixture\)/);
|
||||
expect(h).toContain('Example Degree, Fixture University');
|
||||
expect(h).toContain('href="mailto:person@example.invalid"');
|
||||
expect(h).toMatch(/<li>Reading a Fixture Book<\/li>\s*<li>Building the Example Compute Portal<\/li>/);
|
||||
expect(h).toContain('href="/media/fixture-cv.pdf"');
|
||||
expect(h).toContain('href="#cv"');
|
||||
expect(h).toContain('href="https://example.invalid/github/example-person"');
|
||||
expect(h).toContain('href="https://example.invalid/linkedin/example-person"');
|
||||
// The old intro and the Now / Latest writing / CV strip are gone.
|
||||
const html = page(out, 'work/index.html');
|
||||
expect(html).not.toContain('This is synthetic fixture content for tests.');
|
||||
expect(html).not.toContain('class="now"');
|
||||
expect(html).not.toContain('Latest writing');
|
||||
expect(html.match(/<h1\b/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('highlights: the first three highlighted entries, then "+ N more in the record"', () => {
|
||||
const h = header(out);
|
||||
const hl = h.slice(h.indexOf('Highlights'), h.indexOf('Keeping up'));
|
||||
expect(hl).toContain('Example Hackathon 2019');
|
||||
expect(hl).toContain('winner · best fixture');
|
||||
expect(hl).toContain('Fixture Robot League');
|
||||
expect(hl).toContain('Sample Code Contest 2021');
|
||||
expect(hl).not.toContain('Demo Capture the Flag 2024');
|
||||
expect(hl).toMatch(/href="#record"[^>]*>\+ 3 more in the record ↓/);
|
||||
// Owner 2026-11-12: the placement carries the accent, not the competition's name.
|
||||
expect(hl).toMatch(/<div>Example Hackathon 2019<\/div>\s*<div class="r acc">winner · best fixture<\/div>/);
|
||||
});
|
||||
|
||||
test('keeping up: certificate count, course line and topic chips by time', () => {
|
||||
const h = header(out);
|
||||
const ku = h.slice(h.indexOf('Keeping up'));
|
||||
expect(ku).toMatch(/class="bignum"[^>]*>[\s\S]*?7<\/span>\s*<span[^>]*>certificates<\/span>/);
|
||||
expect(ku).toContain('11 courses & hands-on labs · ~11 h since Feb 2025');
|
||||
const chips = [...ku.matchAll(/<span class="chip"[^>]*>([^<]*)<\/span>/g)].map((m) => m[1]);
|
||||
expect(chips).toEqual(['Fixture Cloud 1', 'Java 4', 'Containers 2', 'Observability']);
|
||||
expect(ku).toMatch(/href="#record"[^>]*>all courses ↓/);
|
||||
});
|
||||
|
||||
test('sparse: no highlights, no keeping up, one column, no CV button', () => {
|
||||
const h = header(sparseOut);
|
||||
expect(h).toMatch(/<h1[^>]*>Example Person<\/h1>/);
|
||||
expect(h).toContain('class="khero solo"');
|
||||
expect(h).not.toContain('Highlights');
|
||||
expect(h).not.toContain('Keeping up');
|
||||
expect(h).not.toContain('/media/');
|
||||
expect(page(sparseOut, 'work/index.html')).not.toContain('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/work/ record (spec 009)', () => {
|
||||
const record = (root: string) => {
|
||||
const html = page(root, 'work/index.html');
|
||||
const start = html.indexOf('id="record"');
|
||||
// The record is followed by the Writing / history row, or the footer.
|
||||
const ends = ['class="lower', '<footer'].map((m) => html.indexOf(m, start)).filter((i) => i > 0);
|
||||
return start < 0 ? '' : html.slice(start, Math.min(...ends));
|
||||
};
|
||||
|
||||
test('the page order: header, proudest, also made, record, writing, history', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
const at = ['class="khero', 'id="featured"', 'id="all"', 'id="record"', 'id="writing"', 'id="cv"'].map((m) => html.indexOf(m));
|
||||
expect(at.every((i) => i > 0)).toBe(true);
|
||||
expect(at).toEqual([...at].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
test('recognition & volunteering: groups, accent on highlights, links, status words, years only when given', () => {
|
||||
const r = record(out);
|
||||
expect(r).toContain('Recognition & volunteering');
|
||||
const comp = r.slice(r.indexOf('Competitions'), r.indexOf('Volunteering'));
|
||||
const vol = r.slice(r.indexOf('Volunteering'), r.indexOf('Keeping up'));
|
||||
expect(comp).toMatch(/<div>Example Hackathon 2019 <span class="y">2019<\/span><\/div>\s*<div class="r acc">winner · best fixture<\/div>/);
|
||||
expect(comp).toMatch(/Fixture Robot League <span class="dt">· four years<\/span>/);
|
||||
expect(comp).toContain('Demo Capture the Flag 2024');
|
||||
expect(vol).toMatch(/<a href="https:\/\/example.invalid\/art-club">Example Art Club<\/a> <span class="dt">· example.invalid ↗<\/span>/);
|
||||
expect(vol).toContain('IT & website · ongoing');
|
||||
expect(vol).toContain('IT & website · site retired');
|
||||
expect(vol).not.toMatch(/class="acc"/);
|
||||
expect(r).not.toContain('undefined');
|
||||
});
|
||||
|
||||
test('keeping up: summary line, topic table by time, link to the learning page', () => {
|
||||
const r = record(out);
|
||||
const ku = r.slice(r.indexOf('Keeping up'));
|
||||
expect(ku).toMatch(/7 certificates<\/b><\/span> from 11 courses and hands-on labs · <b>~11 hours<\/b> of course time since Feb 2025/);
|
||||
const rows = [...ku.matchAll(/<td class="t">([^<]*)<\/td><td class="c">([^<]*)<\/td><td class="c ce">(?:<svg[\s\S]*?<\/svg>)?\s*([\d—]+)<\/td><td class="c">([^<]*)<\/td>/g)]
|
||||
.map((m) => [m[1], m[2], m[3], m[4]]);
|
||||
expect(rows).toEqual([
|
||||
['Fixture Cloud', '1 course · 1 lab', '1', '3.8h'],
|
||||
['Java', '4 courses · 2 labs', '4', '3.7h'],
|
||||
['Containers', '1 course · 1 lab', '2', '3.2h'],
|
||||
['Observability', '1 course', '—', '0.4h'],
|
||||
]);
|
||||
expect(ku).toMatch(/href="\/work\/learning\/"[^>]*>All courses and certificates, searchable →/);
|
||||
});
|
||||
|
||||
test('sparse: no record section', () => {
|
||||
expect(page(sparseOut, 'work/index.html')).not.toContain('id="record"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/work/learning/ (spec 009 §5)', () => {
|
||||
test('lists every course newest first, complete without JavaScript', () => {
|
||||
const html = page(out, 'work/learning/index.html');
|
||||
expect(html).toMatch(/<h1[^>]*>Courses & certificates<\/h1>/);
|
||||
expect(html).toMatch(/7 certificates<\/b><\/span> from 11 courses and hands-on labs/);
|
||||
const rows = [...html.matchAll(/<tr\b[^>]*data-course[^>]*>([\s\S]*?)<\/tr>/g)].map((m) => m[0]);
|
||||
expect(rows).toHaveLength(11);
|
||||
const dates = rows.map((r) => r.match(/<time datetime="([^"]+)"/)![1]!);
|
||||
expect(dates).toEqual([...dates].sort().reverse());
|
||||
expect(rows.some((r) => / hidden[ >]/.test(r.slice(0, r.indexOf('>'))))).toBe(false);
|
||||
// Newest first: a course without certificate, then a lab with one; a course with a url is a link.
|
||||
expect(rows[0]).toContain('Logs and Metrics 101');
|
||||
expect(rows[0]).not.toContain('class="seal"');
|
||||
expect(rows[1]).toContain('Profiling the JVM');
|
||||
expect(rows[1]).toContain('class="tag">lab<');
|
||||
expect(rows[1]).toContain('class="seal"');
|
||||
expect(html).toContain('<a href="https://example.invalid/courses/cloud">Fixture Cloud Foundations</a>');
|
||||
// Search text for the script: title, provider, topic and keywords.
|
||||
expect(rows.find((r) => r.includes('Containers from Scratch'))).toMatch(/data-search="containers from scratch sample school containers docker images"/);
|
||||
// Topic buttons: All, then each topic with its count, by time.
|
||||
const buttons = [...html.matchAll(/<button[^>]*data-topic="([^"]*)"[^>]*>([\s\S]*?)<\/button>/g)].map((m) => [m[1], m[2]!.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()]);
|
||||
expect(buttons).toEqual([['', 'All 11'], ['Fixture Cloud', 'Fixture Cloud 2'], ['Java', 'Java 6'], ['Containers', 'Containers 2'], ['Observability', 'Observability 1']]);
|
||||
expect(html).toMatch(/<p[^>]*class="none"[^>]*hidden[^>]*>No course matches/);
|
||||
expect(html).toContain('<script type="module"');
|
||||
});
|
||||
|
||||
test('the top bar of the learning page links back into /work/', () => {
|
||||
const nav = page(out, 'work/learning/index.html').match(/<nav class="nav"[\s\S]*?<\/nav>/)![0];
|
||||
expect(nav).toContain('href="/work/#featured"');
|
||||
expect(nav).toContain('href="/work/#record"');
|
||||
});
|
||||
|
||||
test('without courses there is no learning page and nothing links to it', () => {
|
||||
expect(existsSync(join(sparseOut, 'work/learning/index.html'))).toBe(false);
|
||||
expect(page(sparseOut, 'work/index.html')).not.toContain('/work/learning/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('work top bar and footer (spec 009 §6)', () => {
|
||||
const nav = (html: string) => [...html.match(/<nav class="nav"[\s\S]*?<\/nav>/)![0].matchAll(/<a href="([^"]+)">([^<]+)<\/a>/g)].map((m) => `${m[2]} ${m[1]}`);
|
||||
|
||||
test('the top bar links Projects, Record, Writing and CV, only to sections that exist', () => {
|
||||
expect(nav(page(out, 'work/index.html'))).toEqual(['Projects #featured', 'Record #record', 'Writing #writing', 'CV #cv']);
|
||||
expect(nav(page(out, 'work/demo-course-planner/index.html'))).toEqual([
|
||||
'Projects /work/#featured', 'Record /work/#record', 'Writing /work/#writing', 'CV /work/#cv',
|
||||
]);
|
||||
expect(nav(page(sparseOut, 'work/index.html'))).not.toContain('Record #record');
|
||||
});
|
||||
|
||||
test('the work footer has no Bluesky link; the off-the-clock footer and /simple keep it', () => {
|
||||
const footer = (html: string) => html.slice(html.lastIndexOf('<footer'));
|
||||
for (const path of ['work/index.html', 'work/learning/index.html', 'work/demo-course-planner/index.html']) {
|
||||
const f = footer(page(out, path));
|
||||
expect(f).not.toContain('Bluesky');
|
||||
expect(f).toContain('href="/simple/"');
|
||||
expect(f).toContain('href="mailto:person@example.invalid"');
|
||||
}
|
||||
expect(page(out, 'off-the-clock/index.html')).toContain('https://example.invalid/bluesky/example-person');
|
||||
expect(page(out, 'simple/index.html')).toContain('https://example.invalid/bluesky/example-person');
|
||||
});
|
||||
});
|
||||
+32
-12
@@ -19,7 +19,7 @@ beforeAll(async () => {
|
||||
describe('work space', () => {
|
||||
test('work page lists every visible project', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
expect(projects.length).toBe(5);
|
||||
expect(projects.length).toBe(9);
|
||||
for (const p of projects) {
|
||||
expect(html).toContain(`href="/work/${p.id}/"`);
|
||||
expect(html).toContain(`<template id="p-${p.id}">`);
|
||||
@@ -50,6 +50,30 @@ describe('work space', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('six featured panels lay out wide, half, half, wide reversed, half, half under "Projects I\'m proudest of"', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
const classes = [...html.matchAll(/<article\b[^>]*\bclass="(feat\b[^"]*)"[^>]*>/g)].map((m) => m[1]!.replace(/\s*astro-\w+/g, '').trim());
|
||||
expect(classes).toEqual(['feat wide', 'feat', 'feat', 'feat wide rev', 'feat', 'feat']);
|
||||
const feat = html.slice(html.indexOf('id="featured"'));
|
||||
expect(feat).toMatch(/<h2[^>]*>Projects I('|'|’)m proudest of<\/h2>/);
|
||||
expect(feat.slice(0, 1500)).toContain('6 projects');
|
||||
expect(feat.slice(0, 1500)).toContain('hover the drawings · open a panel for the full story');
|
||||
// The AI explanation sits with the heading, with the four legend marks.
|
||||
const head = feat.slice(0, feat.indexOf('class="feats"'));
|
||||
for (const level of ['ai-made', 'some-ai', 'by-hand', 'unknown']) expect(head).toContain(`data-level="${level}"`);
|
||||
});
|
||||
|
||||
test('screenshots in panels and list thumbnails are muted until hovered; drawings are not', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
const css = html + readdirSync(join(out, '_astro')).filter((f) => f.endsWith('.css')).map((f) => page(out, `_astro/${f}`)).join('');
|
||||
expect(css).toMatch(/\.feat \.s-image img[^{]*\{[^}]*filter:\s*saturate\(\.5\)\s*brightness\(\.8\)/);
|
||||
expect(css).toMatch(/\.row \.th-image img[^{]*\{[^}]*filter:\s*saturate\(\.5\)\s*brightness\(\.8\)/);
|
||||
expect(css).toMatch(/\.feat:hover \.s-image img/);
|
||||
expect(css).toMatch(/\.feat:focus-within \.s-image img/);
|
||||
expect(css).toMatch(/\.row:focus-visible \.th-image img|\.row:focus \.th-image img/);
|
||||
expect(css).not.toMatch(/\.stage\s*\{[^}]*saturate/);
|
||||
});
|
||||
|
||||
test('a hidden featured panel is followed by a note with a working "show anyway" button', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
const featured = projects.filter((p) => p.featured !== undefined);
|
||||
@@ -100,19 +124,17 @@ describe('work space', () => {
|
||||
expect(page(out, 'work/placeholder-notes/index.html')).toContain('data-showcase="generic"');
|
||||
});
|
||||
|
||||
test('latest writing is hidden when there are no work posts', () => {
|
||||
expect(page(out, 'work/index.html')).toContain('Latest writing');
|
||||
test('the Writing card is left out when there are no work posts', () => {
|
||||
expect(page(out, 'work/index.html')).toContain('id="writing"');
|
||||
const sparse = page(sparseOut, 'work/index.html');
|
||||
expect(sparse).not.toContain('Latest writing');
|
||||
expect(sparse).not.toContain('id="writing"');
|
||||
expect(sparse).not.toContain('undefined');
|
||||
});
|
||||
|
||||
test('writing boxes collapse when the filter hides every post in them', () => {
|
||||
const html = page(out, 'work/index.html');
|
||||
// The Latest writing cell, the Writing card and the row holding it are filter boxes: base.css
|
||||
// hides a box with no visible [data-ai] link under the active preference.
|
||||
expect(html).toMatch(/<div\b[^>]*\bclass="[^"]*\bfilter-box\b[^"]*"[^>]*>\s*<h2><span>Latest writing<\/span>/);
|
||||
// The Writing card is a filter box: base.css hides a box with no visible [data-ai] link under the
|
||||
// active preference.
|
||||
expect(html).toMatch(/<section\b[^>]*\bclass="card filter-box"[^>]*\bid="writing"/);
|
||||
// Counts never claim hidden posts: one count per preference, CSS shows the active one.
|
||||
// The fixture's only work post has no AI level, so "by hand only" counts 0.
|
||||
@@ -141,17 +163,15 @@ describe('intros', () => {
|
||||
|
||||
test('the gate and the wall use personal_intro, never the work intro', () => {
|
||||
const gate = page(out, 'index.html');
|
||||
const workH1 = page(out, 'work/index.html').match(/<h1[^>]*>([^<]*)/)?.[1]?.trim();
|
||||
expect(workH1).toBe('This is synthetic fixture content for tests.');
|
||||
expect(ledeOfPersonalGate(gate)).toBe('Fixture personal intro, also invented.');
|
||||
expect(ledeOfPersonalGate(gate)).not.toBe(workH1);
|
||||
expect(gate).not.toContain('This is synthetic fixture content for tests.');
|
||||
expect(page(out, 'off-the-clock/index.html')).toContain('Fixture personal intro, also invented.');
|
||||
});
|
||||
|
||||
test('without personal_intro there is no lede; without intro /work/ still has an h1', () => {
|
||||
test('without personal_intro there is no lede; /work/ is headed by the name (spec 009)', () => {
|
||||
expect(ledeOfPersonalGate(page(sparseOut, 'index.html'))).toBeUndefined();
|
||||
const work = page(sparseOut, 'work/index.html');
|
||||
expect(work).toMatch(/<h1 class="sr-only">Example Person<\/h1>/);
|
||||
expect(work).toMatch(/<h1>Example Person<\/h1>/);
|
||||
expect(work.match(/<h1\b/g)).toHaveLength(1);
|
||||
expect(page(sparseOut, 'off-the-clock/index.html')).not.toContain('Fixture personal intro');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { courseStrip, matchCountText, type ListCourse } from '../../src/site/components/work/course-search.ts';
|
||||
|
||||
const c = (title: string, completed: string, over: Partial<ListCourse> = {}): ListCourse => ({
|
||||
title, provider: 'Prov', kind: 'course', topic: 'Java', completed, minutes: 30, certificate: false, keywords: [], ...over,
|
||||
});
|
||||
const all = [
|
||||
c('Java A', '2025-01-01'), c('Java B', '2025-03-01', { certificate: true }), c('Java C', '2025-02-01', { kind: 'lab' }),
|
||||
c('Java D', '2025-05-01'), c('Java E', '2025-04-01'), c('Java F', '2025-06-01'), c('Other', '2025-07-01', { topic: 'Cloud' }),
|
||||
];
|
||||
|
||||
test('courseStrip: nothing for an empty query', () => {
|
||||
expect(courseStrip(all, ' ')).toEqual({ shown: [], more: 0, count: 0, href: '' });
|
||||
});
|
||||
|
||||
test('courseStrip: newest first, at most five, the rest counted, link carries the query', () => {
|
||||
const s = courseStrip(all, 'java');
|
||||
expect(s.count).toBe(6);
|
||||
expect(s.shown.map((x) => x.title)).toEqual(['Java F', 'Java D', 'Java E', 'Java B', 'Java C']);
|
||||
expect(s.more).toBe(1);
|
||||
expect(s.href).toBe('/work/learning/?q=java');
|
||||
expect(courseStrip(all, 'a b&c').href).toBe('/work/learning/?q=a%20b%26c');
|
||||
});
|
||||
|
||||
test('matchCountText', () => {
|
||||
expect(matchCountText(1, 5)).toBe('1 project · 5 courses match');
|
||||
expect(matchCountText(0, 1)).toBe('0 projects · 1 course matches');
|
||||
expect(matchCountText(2, 0)).toBe('');
|
||||
});
|
||||
+10
-1
@@ -36,13 +36,22 @@ describe('sitePaths', () => {
|
||||
for (const i of data.items) expect(paths).toContain(`/off-the-clock/${i.id}/`);
|
||||
for (const p of data.sources.posts.filter((x) => x.side === 'off-the-clock')) expect(paths).toContain(`/off-the-clock/${p.slug}/`);
|
||||
for (const i of data.issues) expect(paths).toContain(`/off-the-clock/issues/${i.id}/`);
|
||||
expect(paths.length).toBe(6 + data.projects.length + data.workPosts.length + data.personal.length + data.issues.length);
|
||||
expect(paths).toContain('/work/learning/');
|
||||
expect(paths.length).toBe(7 + data.projects.length + data.workPosts.length + data.personal.length + data.issues.length);
|
||||
expect(paths.some((p) => p.includes('404'))).toBe(false);
|
||||
expect(paths.some((p) => p.includes('draft'))).toBe(false);
|
||||
});
|
||||
|
||||
test('/work/learning/ only when there are courses', () => {
|
||||
expect(sitePaths({ ...data, courses: [] })).not.toContain('/work/learning/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('feedEntries', () => {
|
||||
test('courses are never feed entries', () => {
|
||||
for (const name of ['all', 'work'] as const) expect(feedEntries(data, name).some((e) => e.path.includes('learning'))).toBe(false);
|
||||
});
|
||||
|
||||
test('work: projects and work writing, newest first, with their summaries and levels', () => {
|
||||
const entries = feedEntries(data, 'work');
|
||||
expect(entries.length).toBe(data.projects.length + data.workPosts.length);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { rowMatches } from '../../src/site/scripts/learning-filter.ts';
|
||||
|
||||
const row = { search: 'containers from scratch sample school containers docker images', topic: 'Containers' };
|
||||
|
||||
test('a row matches the search text and the topic', () => {
|
||||
expect(rowMatches(row, '', '')).toBe(true);
|
||||
expect(rowMatches(row, 'DOCKER', '')).toBe(true);
|
||||
expect(rowMatches(row, ' sample ', 'Containers')).toBe(true);
|
||||
expect(rowMatches(row, 'docker', 'Java')).toBe(false);
|
||||
expect(rowMatches(row, 'kotlin', '')).toBe(false);
|
||||
expect(rowMatches(row, '', 'Containers')).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { approxHours, doneText, headerHighlights, hostOf } from '../../src/site/components/work/record.ts';
|
||||
import type { Recognition } from '../../src/library/types.ts';
|
||||
|
||||
const r = (title: string, highlight: boolean): Recognition => ({ title, kind: 'competition', result: 'x', highlight });
|
||||
|
||||
test('headerHighlights takes the first three highlighted, counts the rest of the record', () => {
|
||||
const h = headerHighlights([r('a', true), r('b', false), r('c', true), r('d', true), r('e', true)]);
|
||||
expect(h.shown.map((x) => x.title)).toEqual(['a', 'c', 'd']);
|
||||
expect(h.more).toBe(2);
|
||||
expect(headerHighlights([r('a', false)])).toEqual({ shown: [], more: 1 });
|
||||
});
|
||||
|
||||
test('doneText leaves out a zero part', () => {
|
||||
expect(doneText(2, 3)).toBe('2 courses · 3 labs');
|
||||
expect(doneText(1, 0)).toBe('1 course');
|
||||
expect(doneText(0, 1)).toBe('1 lab');
|
||||
});
|
||||
|
||||
test('approxHours and hostOf', () => {
|
||||
expect(approxHours(659)).toBe(11);
|
||||
expect(approxHours(10)).toBe(1);
|
||||
expect(hostOf('https://www.example.invalid/x')).toBe('example.invalid');
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { featLayout, featuredOf } from '../../src/site/components/work/sections.ts';
|
||||
import type { Project } from '../../src/library/types.ts';
|
||||
|
||||
const proj = (id: string, featured?: number): Project => ({
|
||||
id, title: id, date: '2025-01-01', ai: 'unknown', group: 'work', where: 'x', stack: [], summary: 's', links: [],
|
||||
aiParts: [], related: [], bodyHtml: '', ...(featured !== undefined && { featured }),
|
||||
});
|
||||
|
||||
describe('featured panels', () => {
|
||||
test('featuredOf takes up to six by rank', () => {
|
||||
const ps = [7, 3, 1, 5, 2, 6, 4].map((n) => proj(`p${n}`, n)).concat(proj('none'));
|
||||
expect(featuredOf(ps).map((p) => p.id)).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6']);
|
||||
});
|
||||
|
||||
test('featLayout repeats wide, half, half, wide reversed, half, half; a lone last half goes wide', () => {
|
||||
expect(featLayout(0)).toEqual([]);
|
||||
expect(featLayout(1)).toEqual(['wide']);
|
||||
expect(featLayout(2)).toEqual(['wide', 'wide']);
|
||||
expect(featLayout(3)).toEqual(['wide', '', '']);
|
||||
expect(featLayout(4)).toEqual(['wide', '', '', 'wide rev']);
|
||||
expect(featLayout(5)).toEqual(['wide', '', '', 'wide rev', 'wide']);
|
||||
expect(featLayout(6)).toEqual(['wide', '', '', 'wide rev', '', '']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user