docs(plan): add implementation plan 001 for the site
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ecadaf687
commit
51b271a186
1 file changed
+328
@@ -0,0 +1,328 @@
|
||||
---
|
||||
status: approved
|
||||
---
|
||||
|
||||
# 001 — The site: Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build lucaswinther.info as specified: a two-gate start page, a work space, a personal space, issues, `/everything/`, `/simple/`, a self-refreshing container, and starter content.
|
||||
|
||||
**Architecture:** Astro renders static HTML from a validated content directory plus cached data from
|
||||
Ghost, Trakt and Hardcover; React islands add interaction. A Bun server (ported from the sister repo
|
||||
`/home/user/commission-compass`) polls the content repo, rebuilds on new commits and on a timer, swaps
|
||||
builds atomically, and serves them.
|
||||
|
||||
**Tech Stack:** Bun 1.3.14, TypeScript 7, Astro 7.4, @astrojs/react 7, React 19, marked 18, Playwright 1.65, Docker (oven/bun:1.3.14-slim).
|
||||
|
||||
**Spec:** `docs/specs/001-site.md` (read it first). Visual truth: `mockups/nav-d.html`, `mockups/tech-r3-c.html`, `mockups/personal-r2-b.html`. Tokens: `DESIGN.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Bun only: `bun`, `bun test`, `bun run`, `bunx`. Never node/npm/npx/jest/vitest. Prefer `Bun.YAML`, `Bun.file`, `` Bun.$ ``.
|
||||
- Branch `feat/site-v1`; Conventional Commits; every commit leaves `bun run typecheck` and `bun test` green.
|
||||
- No loading screens, no autoplay, nothing moves until the visitor acts; `prefers-reduced-motion` disables motion; pointer effects only under `@media (hover: hover)`.
|
||||
- Every page is complete HTML with JavaScript off. React only via Astro islands (`client:visible` or `client:idle`), never a client-rendered page.
|
||||
- No third-party requests on page load: fonts are self-hosted via `@fontsource*`; song embeds load only after a click. The one exception is the existing analytics script (`https://analytics.lucaswinther.info/ingress/a95f48ac-7e9f-4650-a9ae-43a67de924e7/script.js`, `defer`).
|
||||
- AI levels: `ai-made | some-ai | by-hand | unknown`; marks: filled, half, outline, dashed. Preference values: `any | not-fully-ai | by-hand-only`, localStorage key `lw:projects-made`, default `any`.
|
||||
- Never invent facts about Lucas. Unknown content stays out (or `draft: true` in the content repo).
|
||||
- This repo holds only synthetic fixture content (`fixtures/content/`). Tests never call Ghost, Trakt, Hardcover or Gitea.
|
||||
- Match the mockups' palettes, type and structure (see `DESIGN.md`); use the impeccable skill (`craft`) for each page and `polish`/`harden` before Task 14.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. **A Ghost/Trakt/Hardcover outage or expired token** → the build succeeds with the last cached data and `/status` names the failing source (Task 4, Task 6 tests).
|
||||
2. **Content with zero items of a kind, or an empty personal side** → sections hide cleanly, no empty boxes or "undefined" (Task 5 test `sparse content renders no empty sections`, Task 13 e2e on `fixtures/sparse`).
|
||||
3. **"By hand only" preference on a page whose featured projects are all hidden** → collapsed lines with "show anyway", and a link to the 2021 archive; personal shares never hidden (Task 2 tests, Task 13 e2e).
|
||||
4. **Deep link to a hidden or unknown id** (`/work/nope/`, `/off-the-clock/x/` with filter on) → real 404 page in the site's style; a filtered-out item opened by direct URL still shows, with a note (Task 9/10 e2e).
|
||||
5. **Long titles, missing images, items without `line` or `media`** → cards keep their shape (fixture `fixtures/content/items/edge-long-title.md`, Task 10 build test).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Scaffold the app, tooling and CI
|
||||
|
||||
**Files:**
|
||||
- Create: `package.json`, `bunfig.toml`, `tsconfig.json`, `astro.config.mjs`, `playwright.config.ts`, `.gitea/workflows/ci.yml`, `.gitea/workflows/agent-docs.yml`, `src/site/pages/index.astro` (placeholder "ok"), `test/smoke.test.ts`
|
||||
- Modify: `AGENTS.md` (Commands), `docs/PERMISSIONS.md` + `.claude/settings.json` + `.gemini/settings.json` + `.codex/rules/project.rules` (verified commands), `.gitignore` (`.tmp/`, `test-results/`, `playwright-report/`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: scripts `test` (`bun test`), `typecheck` (`tsc --noEmit`), `e2e` (`bunx playwright test`), `site` (`bun src/cli.ts`); Astro `srcDir: './src/site'`, `output: 'static'`, `build.format: 'directory'`, `integrations: [react()]`, `devToolbar: { enabled: false }`.
|
||||
|
||||
- [ ] **Step 1:** Copy `tsconfig.json`, `bunfig.toml` (`[test] root = "test"`) and `playwright.config.ts` from `/home/user/commission-compass`, changing the port to 4378 and the webServer command to `bun src/cli.ts build fixtures/content .tmp/e2e-site && bun src/cli.ts preview .tmp/e2e-site 4378`. Add `"jsx": "react-jsx"` to tsconfig and include `src/**/*.tsx`.
|
||||
- [ ] **Step 2:** `bun add astro@^7.4 @astrojs/react@^7 react@^19 react-dom@^19 marked@^18 @fontsource/geist-sans @fontsource/geist-mono @fontsource/ibm-plex-sans @fontsource/ibm-plex-mono @fontsource-variable/fraunces @fontsource/caveat` and `bun add -d typescript@^7 @types/bun @types/react @types/react-dom @playwright/test@^1.65`. Set `"engines": { "bun": ">=1.3" }`, `"type": "module"`, `"private": true`.
|
||||
- [ ] **Step 3:** Write `test/smoke.test.ts`: `expect(1).toBe(1)` style sanity test importing `bun:test`. Run `bun test` → PASS; `bun run typecheck` → no errors.
|
||||
- [ ] **Step 4:** CI: copy both workflows from commission-compass; image name `gitea.lucaswinther.info/lucasw89/portfolio-next`; agent-docs list: `AGENTS.md CLAUDE.md GEMINI.md PRODUCT.md DESIGN.md docs/ARCHITECTURE.md docs/DECISIONS.md docs/TODO.md docs/PERMISSIONS.md docs/CONTENT-CONTRACT.md docs/DEPLOY.md docs/specs/README.md docs/plans/README.md`. The `image` job stays but its smoke step is added in Task 14 (until then the job builds only; mark the smoke line commented with a reference to Task 14).
|
||||
- [ ] **Step 5:** Update `AGENTS.md` Commands with the exact commands verified in this task (`bun install --frozen-lockfile`, `bun run typecheck`, `bun test`, `bun test test/x.test.ts`, `bun test -t 'name'`); update `docs/PERMISSIONS.md` verified table and add them to the Allow tier of every tool file. Keep `AGENTS.md` ≤ 150 lines.
|
||||
- [ ] **Step 6:** Commit `chore: scaffold astro, react, tests and ci`.
|
||||
|
||||
### Task 2: AI levels and the preference rules (`src/core/ai.ts`)
|
||||
|
||||
**Files:** Create `src/core/ai.ts`; Test `test/core/ai.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `type AiLevel = 'ai-made' | 'some-ai' | 'by-hand' | 'unknown'`
|
||||
- `type Preference = 'any' | 'not-fully-ai' | 'by-hand-only'`
|
||||
- `const PREFERENCE_KEY = 'lw:projects-made'`
|
||||
- `function isAiLevel(x: unknown): x is AiLevel`
|
||||
- `function parsePreference(x: unknown): Preference` (anything invalid → `'any'`)
|
||||
- `function isVisible(level: AiLevel | null, pref: Preference): boolean` (`null` = not subject to the filter, always visible)
|
||||
- `const AI_LABEL: Record<AiLevel, string>` = `{ 'ai-made': 'made with AI', 'some-ai': 'some AI', 'by-hand': 'by hand', unknown: 'not recorded' }`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
```ts
|
||||
test('any shows everything', () => { for (const l of LEVELS) expect(isVisible(l, 'any')).toBe(true); });
|
||||
test('not-fully-ai hides only ai-made', () => {
|
||||
expect(isVisible('ai-made', 'not-fully-ai')).toBe(false);
|
||||
expect(isVisible('some-ai', 'not-fully-ai')).toBe(true);
|
||||
expect(isVisible('unknown', 'not-fully-ai')).toBe(true);
|
||||
});
|
||||
test('by-hand-only shows only by-hand', () => {
|
||||
expect(isVisible('by-hand', 'by-hand-only')).toBe(true);
|
||||
for (const l of ['ai-made', 'some-ai', 'unknown'] as const) expect(isVisible(l, 'by-hand-only')).toBe(false);
|
||||
});
|
||||
test('null level is never filtered', () => { expect(isVisible(null, 'by-hand-only')).toBe(true); });
|
||||
test('invalid preference falls back to any', () => { expect(parsePreference('x')).toBe('any'); expect(parsePreference(null)).toBe('any'); });
|
||||
```
|
||||
- [ ] **Step 2:** `bun test test/core/ai.test.ts` → FAIL (module missing).
|
||||
- [ ] **Step 3:** Implement the interface above in `src/core/ai.ts` (no DOM, no Node APIs; shared by build and browser).
|
||||
- [ ] **Step 4:** Run → PASS. **Step 5:** Commit `feat(core): add ai levels and preference rules`.
|
||||
|
||||
### Task 3: Content contract loader (`src/library/`) and fixtures
|
||||
|
||||
**Files:**
|
||||
- Create: `src/library/types.ts`, `src/library/frontmatter.ts` (copy from commission-compass), `src/library/errors.ts` (copy), `src/library/load.ts`, `src/cli.ts` (only `validate` for now), `fixtures/content/**`, `fixtures/sparse/**`, `docs/CONTENT-CONTRACT.md`
|
||||
- Test: `test/library/load.test.ts`, `test/library/helpers.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AiLevel`, `isAiLevel` (Task 2).
|
||||
- Produces (`src/library/types.ts`):
|
||||
```ts
|
||||
type Link = { label: string; url: string };
|
||||
type Site = { contract: 1; name: string; intro: string[]; now: string[]; tagline: string;
|
||||
links: { email: string; github: string; linkedin: string; bluesky?: string; cv?: string /* media path */ };
|
||||
cv: { now: string; before: string[]; degree: string };
|
||||
issues: { notes: Record<string, string> } };
|
||||
type Interest = { id: string; name: string };
|
||||
type AiPart = { part: string; level: AiLevel; note?: string };
|
||||
type Project = { id: string; title: string; date: string; ai: AiLevel; group: 'work' | 'own' | 'school';
|
||||
where: string; years: string; stack: string[]; summary: string; featured?: number; showcase?: string;
|
||||
links: Link[]; aiParts: AiPart[]; related: string[]; bodyHtml: string };
|
||||
type ItemKind = 'comic' | 'song' | 'game' | 'film' | 'book' | 'art' | 'essay' | 'life' | 'music';
|
||||
type Media = { file?: string; url?: string; alt: string; caption?: string };
|
||||
type Item = { id: string; title: string; date: string; kind: ItemKind; interest: string;
|
||||
size: 's' | 'm' | 'l'; line?: string; media: Media[]; rating?: number; link?: string; credit?: string;
|
||||
kickstarter: boolean; ownWork: boolean; ai: AiLevel | null; bodyHtml: string };
|
||||
type Content = { site: Site; interests: Interest[]; projects: Project[]; items: Item[] };
|
||||
function loadContent(dir: string): Promise<{ content: Content | null; errors: ValidationError[] }>;
|
||||
```
|
||||
- Rules (from spec §11): `contract` must be `1`; ids are file names matching `^[a-z0-9-]+$`; `draft: true` files are skipped entirely; `ai` defaults to `unknown` on projects; on items `ai` is allowed only when `ownWork` is true (kinds `essay`, `life`, `music` are always own work; `art` is own work unless `credit` is set) and then defaults to `unknown`, else `ai` must be absent (error if present) and is `null`; `size` defaults: `l` for `comic`/`essay`, `m` for `song`/`game`/`film`/`art`/`music`, `s` for `book`/`life`; `interest` must exist in `interests.yml`; `related` ids must exist; media `file` must exist under `media/`; dates `YYYY-MM-DD`. Sort projects by `featured` then date desc; items by date desc. Markdown via `marked` (gfm).
|
||||
|
||||
- [ ] **Step 1:** Write fixtures: `fixtures/content/` with `site.yml`, `interests.yml` (comics, games, music, film-tv, books, made-by-me, writing, life), 6 projects (2 featured with `showcase`, one per group, one of each AI level), 12 items (every kind at least once, one commissioned `art` with `credit`, one `kickstarter: true` game, one draft, `edge-long-title.md` with an 140-character title and no media), and small placeholder images (generated SVG/PNG, synthetic). `fixtures/sparse/` with `site.yml`, `interests.yml`, 1 project, 0 items. All names obviously fake ("Example Compute Portal", "Fixture Comic").
|
||||
- [ ] **Step 2: Write failing tests** in `test/library/load.test.ts` (helpers write temp content dirs like commission-compass's `test/library/helpers.ts`):
|
||||
- `loads the fixture content` → 5 visible projects (draft excluded if any), 11 items, interests in file order.
|
||||
- `rejects unsupported contract` → error `{ file: 'site.yml', field: 'contract' }`.
|
||||
- `rejects ai on a share of someone else's work` → item kind `film` with `ai: by-hand` → error field `ai`.
|
||||
- `own work defaults ai to unknown, shares get null` → essay without `ai` → `'unknown'`; film → `null`; art with `credit` → `null`.
|
||||
- `applies size defaults per kind`.
|
||||
- `reports unknown interest, related id and missing media file` (three errors in one run, all listed).
|
||||
- `skips drafts`.
|
||||
- [ ] **Step 3:** Run → FAIL. **Step 4:** Implement `loadContent` collecting all errors (never stop at the first) and the `validate` CLI command (`bun src/cli.ts validate <dir>` prints `Content OK (N projects, M items)` or each error and exits 1).
|
||||
- [ ] **Step 5:** Run → PASS; `bun src/cli.ts validate fixtures/content` → `Content OK (5 projects, 11 items)`.
|
||||
- [ ] **Step 6:** Write `docs/CONTENT-CONTRACT.md` (layout, every field, defaults, examples), modelled on commission-compass's. **Step 7:** Commit `feat(library): load and validate the content contract`.
|
||||
|
||||
### Task 4: External sources: Ghost, Trakt, Hardcover (`src/sources/`)
|
||||
|
||||
**Files:** Create `src/sources/types.ts`, `src/sources/ghost.ts`, `src/sources/trakt.ts`, `src/sources/hardcover.ts`, `src/sources/cache.ts`, `src/sources/index.ts`, `fixtures/sources/{ghost-posts.json,trakt-watching.json,trakt-history.json,trakt-ratings.json,hardcover.json}`; Test `test/sources/*.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```ts
|
||||
type Post = { slug: string; title: string; date: string; excerpt: string; html: string; tags: string[];
|
||||
side: 'work' | 'off-the-clock'; kind: 'post' | 'essay' | 'life'; ai: AiLevel; url: string /* ghost url */ };
|
||||
type Watching = { title: string; show?: string; episode?: string; progress?: number; url: string; poster?: string } | null;
|
||||
type Rated = { title: string; rating: number; url: string; date: string };
|
||||
type Reading = { title: string; author: string; progress?: number; url: string; cover?: string };
|
||||
type SourceStatus = { ok: boolean; fetchedAt?: string; error?: string };
|
||||
type Sources = { posts: Post[]; trakt: { watching: Watching; recent: Rated[] }; hardcover: { reading: Reading[]; recent: Rated[] };
|
||||
status: Record<'ghost' | 'trakt' | 'hardcover', SourceStatus> };
|
||||
function postFromGhost(raw: GhostPost): Post; // tag rules below
|
||||
function loadSources(opts: { cacheDir: string; env: Record<string, string | undefined>; fetch?: typeof fetch }): Promise<Sources>;
|
||||
```
|
||||
- Ghost: `GET ${GHOST_URL}/ghost/api/content/posts/?key=${GHOST_CONTENT_KEY}&include=tags&formats=html&limit=all&filter=status:published`. Tag rules (tag `slug`s): `off-the-clock` → side `off-the-clock`, kind `essay`; plus `life` → kind `life`; otherwise side `work`, kind `post`. AI tag `ai-made|some-ai|by-hand` → level, none → `unknown`. Internal tags (`#…`) ignored.
|
||||
- Trakt (`TRAKT_CLIENT_ID`, user `TRAKT_USER`=`lucasw89`): `GET https://api.trakt.tv/users/${user}/watching` (204 → `null`), `/users/${user}/ratings?limit=5` with headers `trakt-api-version: 2`, `trakt-api-key`. A 401/403/404 means the profile isn't readable: error message `Trakt profile not readable with an API key (private?)`.
|
||||
- Hardcover (`HARDCOVER_TOKEN`): GraphQL `POST https://api.hardcover.app/v1/graphql`, `Authorization: Bearer …`; currently reading (status 2) and the 5 most recent rated books of the token's user. Verify the query against the live schema docs (https://docs.hardcover.app) when implementing; the fixture file is the contract for tests.
|
||||
- Cache: each source writes `cacheDir/<source>.json` on success; on any failure (network, non-2xx, bad JSON, missing env) it returns the cached value with `ok: false, error`, or empty data if no cache. `loadSources` never throws.
|
||||
|
||||
- [ ] **Step 1: Failing tests** with an injected `fetch` that serves the fixture files:
|
||||
- `ghost tags decide side, kind and ai level` (three posts → work/post/unknown, off-the-clock/essay/by-hand, off-the-clock/life/some-ai).
|
||||
- `trakt 204 means not watching`.
|
||||
- `a failing source returns cached data and reports the error` (first call succeeds and writes cache; second call fetch rejects → same data, `status.trakt.ok === false`).
|
||||
- `missing credentials report a clear error, not a throw` (`status.hardcover.error` mentions `HARDCOVER_TOKEN`).
|
||||
- `private trakt profile is reported` (403 → message above).
|
||||
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement. **Step 4:** Run → PASS. **Step 5:** Commit `feat(sources): fetch ghost, trakt and hardcover with cached fallback`.
|
||||
|
||||
### Task 5: Site data assembly (`src/site/data.ts`, `src/core/issues.ts`)
|
||||
|
||||
**Files:** Create `src/site/data.ts`, `src/core/issues.ts`; Test `test/core/issues.test.ts`, `test/site/data.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `loadContent` (Task 3), `loadSources` (Task 4).
|
||||
- Produces:
|
||||
```ts
|
||||
type Issue = { id: string /* '2026-h2' */; year: number; half: 1 | 2; title: string /* e.g. 'Nº 3 · Autumn–Winter 2026' */;
|
||||
from: string; to: string; current: boolean; note?: string; entries: Array<Item | Post> };
|
||||
function issueId(date: string): string; // '2026-07-01' → '2026-h2'
|
||||
function buildIssues(entries: Array<{ date: string }>, notes: Record<string,string>, today: string): Issue[];
|
||||
type SiteData = Content & { sources: Sources; workPosts: Post[]; personal: Array<Item | Post>; issues: Issue[];
|
||||
everything: Array<{ type: 'project' | 'post' | 'item'; id: string; title: string; date: string; side: 'work' | 'off-the-clock';
|
||||
kind: string; ai: AiLevel | null; url: string }> };
|
||||
function getSiteData(): Promise<SiteData>; // reads SITE_CONTENT_DIR and SITE_CACHE_DIR; memoised per dir
|
||||
function md(src: string): string; function mdInline(src: string): string;
|
||||
```
|
||||
- Issue titles: `Nº <n> · <Spring–Summer|Autumn–Winter> <year>`, numbered from the oldest half-year that has entries (`n` = 1). Half 1 = Jan–Jun, half 2 = Jul–Dec. `current` = the half containing `today`; empty past halves produce no issue; the current half always exists (may be empty).
|
||||
- Personal = items + posts with side `off-the-clock`, date desc.
|
||||
|
||||
- [ ] **Step 1: Failing tests:** `issueId boundaries` (06-30 → h1, 07-01 → h2); `numbers issues from the oldest half with entries and skips empty past halves`; `current half exists even when empty`; `data merges posts into personal by tag`; `sparse content renders no empty sections` (getSiteData on `fixtures/sparse` returns `personal: []`, `issues` with only the current one).
|
||||
- [ ] **Step 2–4:** FAIL → implement → PASS. **Step 5:** Commit `feat(site): assemble site data and half-yearly issues`.
|
||||
|
||||
### Task 6: Runtime: build, poll, swap, serve, status (`src/runtime/`)
|
||||
|
||||
**Files:** Create by porting from `/home/user/commission-compass/src/runtime/`: `build.ts`, `git.ts`, `refresh.ts`, `serve.ts`, `state.ts`, `static-server.ts`, plus `src/core/duration.ts`; extend `src/cli.ts` with `build`, `preview`, `refresh`, `serve`. Test: port `test/runtime/{refresh,server,serve}.test.ts` and `test/cli.test.ts`, adapted.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `loadContent`, `loadSources`.
|
||||
- Produces: `buildSite(contentDir: string, outDir: string, opts?: { cacheDir?: string }): Promise<void>` (validates, runs `loadSources` writing into `cacheDir` (default `<outDir>/../../sources` in serve mode, `.tmp/sources` locally), sets `SITE_CONTENT_DIR`/`SITE_CACHE_DIR`, runs Astro, copies `media/` to `<out>/media/`). CLI `site` with the same commands as `compass`.
|
||||
- Changes vs. the sister repo:
|
||||
- Env names: `CONTENT_REPO`, `CONTENT_BRANCH`, `CONTENT_REPO_TOKEN`, `POLL_INTERVAL` (default `1h`), **`SOURCES_INTERVAL`** (default `6h`), `GHOST_URL`, `GHOST_CONTENT_KEY`, `TRAKT_CLIENT_ID`, `TRAKT_USER`, `HARDCOVER_TOKEN`, `DATA_DIR`, `PORT`, `APP_VERSION`.
|
||||
- `refresh` rebuilds when the commit or app version changed **or** `now - builtAt ≥ SOURCES_INTERVAL` (a "sources" rebuild of the same commit). State gains `sources: Record<'ghost'|'trakt'|'hardcover', SourceStatus>` read from the build's `sources-status.json`.
|
||||
- The build child must not receive `CONTENT_REPO_TOKEN`.
|
||||
- `/status` returns state JSON including `sources`; a 404 page from the build (`404.html`) is served for unknown paths; before the first build pages answer 503 "No build yet".
|
||||
- [ ] **Step 1:** Port the tests first, adapting names; add `rebuilds the same commit when SOURCES_INTERVAL has passed` and `does not pass CONTENT_REPO_TOKEN to the build`. Run → FAIL.
|
||||
- [ ] **Step 2:** Port and adapt the code. Run `bun test test/runtime` → PASS.
|
||||
- [ ] **Step 3:** `bun src/cli.ts build fixtures/content .tmp/site` (with no source env vars) → succeeds, prints `Built …`, `.tmp/site/index.html` exists; `.tmp/site/sources-status.json` lists three `ok: false` sources with "missing … " errors. For local work with fixture sources, `SOURCES_FIXTURES=fixtures/sources` makes `loadSources` read fixture files instead of fetching (used by e2e).
|
||||
- [ ] **Step 4:** Commit `feat(runtime): build, poll, swap and serve with timed source refresh`.
|
||||
|
||||
### Task 7: Base layout, tokens, fonts, preference script
|
||||
|
||||
**Files:** Create `src/site/layouts/Base.astro`, `src/site/styles/{tokens.css,base.css}`, `src/site/scripts/preference.ts`, `src/site/components/{AiMark.astro,PreferenceSwitch.tsx,Placeholder404.astro}`, `src/site/pages/404.astro`; Test `test/site/preference.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PREFERENCE_KEY`, `parsePreference`, `isVisible`, `AI_LABEL` (Task 2).
|
||||
- Produces:
|
||||
- `Base.astro` props `{ title: string; description: string; theme: 'gate' | 'work' | 'personal' | 'neutral' }`; sets `<html data-theme>`, loads only the fonts that theme needs, includes the analytics script, and an inline (tiny, blocking) script that sets `document.documentElement.dataset.pref` from localStorage before paint so filtered items never flash.
|
||||
- Filterable markup contract: any element with `data-ai="<level>"` is hidden by CSS when `[data-pref=by-hand-only]` and level ≠ `by-hand`, or `[data-pref=not-fully-ai]` and level = `ai-made`. Elements without `data-ai` are never hidden. Featured panels use `data-ai` + a sibling `.filtered-note` with a "show anyway" button that adds `data-show` to the panel. With JS off, `data-pref` is absent and everything shows.
|
||||
- `<AiMark level={AiLevel}>` renders the mark + `AI_LABEL` text.
|
||||
- `<PreferenceSwitch client:idle />` (three-way control, "projects made: any way / not fully by AI / by hand only"), writes localStorage + `data-pref`, dispatches `lw:pref` event; when `by-hand-only` shows "Prefer work made without AI? The 2021 site was all by hand →" linking `https://old.lucaswinther.info/`.
|
||||
- `tokens.css`: CSS custom properties per theme copied from `DESIGN.md` (gate, work = tech-r3-c `:root`, personal = personal-r2-b `:root`).
|
||||
- [ ] **Step 1: Failing test** (`test/site/preference.test.ts`, happy-dom not required — test the pure `applyPreference(root: { dataset: Record<string,string> }, storage: Pick<Storage,'getItem'|'setItem'>, value?: Preference)` helper exported from `scripts/preference.ts`): stores and applies; invalid stored value → `any`.
|
||||
- [ ] **Step 2–4:** FAIL → implement → PASS. Build fixtures and check `/404.html` renders in the neutral theme.
|
||||
- [ ] **Step 5:** Commit `feat(site): base layout, theme tokens, fonts and ai preference`.
|
||||
|
||||
### Task 8: Start page `/` (from `mockups/nav-d.html`)
|
||||
|
||||
**Files:** Create `src/site/pages/index.astro`, `src/site/components/gate/{GateSide.astro,Gate.tsx,GateFooter.astro}`, `src/site/styles/gate.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getSiteData`, `AiMark`, `PreferenceSwitch`.
|
||||
- Produces: links `/work/`, `/off-the-clock/`, `/everything/`, `/simple/`.
|
||||
- Left side rows: now (`site.cv.now`), latest project (newest by date), latest work post (if any), plus on lean: up to 3 more featured projects. Right side: up to three objects from the newest personal entries (a song as a playable sleeve if the newest song exists, newest image-bearing item as a polaroid, newest essay/post as a note), plus the newest entry's title. Missing kinds are skipped; with zero personal entries the right side shows only the intro line.
|
||||
- `Gate.tsx` (`client:idle`) only adds the lean/keyboard behaviour (←/→ lean, Enter enters, G → `/everything/`, crosshair readout on the work side under `(hover: hover)`); the HTML works as two plain links without it.
|
||||
- [ ] **Step 1:** Port the structure and CSS from `mockups/nav-d.html` into the components, replacing inline content with data. Use the impeccable `craft` skill against the mockup.
|
||||
- [ ] **Step 2:** Build fixtures, screenshot `.tmp/site/index.html` with headless Chrome at 1440×900 and 400×860 (`google-chrome --headless=new --no-sandbox --screenshot=… --window-size=… file://…` won't resolve absolute links, so preview the site with `bun src/cli.ts preview .tmp/site 4379` and screenshot `http://localhost:4379/`); compare to `mockups/shots/navd-none.png`. Fix differences.
|
||||
- [ ] **Step 3:** Commit `feat(site): start page with two gates`.
|
||||
|
||||
### Task 9: Work space `/work/`, project pages, writing, showcases
|
||||
|
||||
**Files:** Create `src/site/pages/work/index.astro`, `src/site/pages/work/[id].astro`, `src/site/pages/work/writing/[slug].astro`, `src/site/components/work/{TopBar.astro,NowStrip.astro,Featured.astro,ProjectList.tsx,ProjectSheet.tsx,ProjectBody.astro,WorkHistory.astro,EdgeToPersonal.astro}`, `src/site/components/showcases/{index.ts,ComputeQueue.tsx,ConfigEditor.tsx,PeerNetwork.tsx,RowingStroke.tsx,GenericFrame.astro}`, `src/site/styles/work.css`; Test `test/site/build.test.ts` (start the file here)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getSiteData`, `AiMark`, `PreferenceSwitch`, filter markup contract (Task 7).
|
||||
- Produces: `SHOWCASES: Record<string, React.ComponentType<{ interactive: boolean }>>` keyed `compute-queue`, `config-editor`, `peer-network`, `rowing-stroke`; unknown/absent `showcase` → `GenericFrame`. Project pages at `/work/<id>/`; posts at `/work/writing/<slug>/`.
|
||||
- Structure per spec §6 and `mockups/tech-r3-c.html`: top bar, intro, Now/Latest writing/CV strip (hide "Latest writing" when there are no work posts; hide "Now" when `site.now` is empty), featured panels (`featured` order, max 4), all projects with tabs/search, side sheet (`client:visible`) that fetches nothing: sheet content for every project is rendered into the page as `<template id="p-<id>">` and the sheet clones it; opening pushes `/work/<id>/`, closing restores `/work/`; Esc closes; ←/→ step; focus trapped and restored. Writing list + work history. Plum "off the clock →" edge.
|
||||
- Showcases are ported from the drawings in `mockups/tech-r3-c.html` (compute queue, config editor, peer network, rowing stroke) as React components; pointer effects only under `(hover: hover)`; render a static SVG on the server (`interactive={false}` markup is the SSR output; hydrate `client:visible`).
|
||||
- [ ] **Step 1: Failing build test** (`test/site/build.test.ts`, builds `fixtures/content` once in `beforeAll` into a temp dir with `SOURCES_FIXTURES`): `work page lists every visible project`, `every project has its own page`, `featured panels carry data-ai`, `a project without a showcase gets the generic frame`, `latest writing is hidden when there are no work posts` (build `fixtures/sparse`).
|
||||
- [ ] **Step 2:** Run → FAIL. **Step 3:** Implement with impeccable `craft` against the mockup. **Step 4:** Run → PASS; screenshot `/work/` and a project sheet open via `/work/<id>/`, compare with `mockups/shots/tech-r3-c.png`.
|
||||
- [ ] **Step 5:** Commit `feat(site): work space, project pages, writing and showcases`.
|
||||
|
||||
### Task 10: Personal space `/off-the-clock/`, item pages, card kinds
|
||||
|
||||
**Files:** Create `src/site/pages/off-the-clock/index.astro`, `src/site/pages/off-the-clock/[id].astro`, `src/site/components/personal/{Chips.tsx,NowStrip.astro,Wall.astro,Card.astro,cards/{Comic,Song,Game,Film,Book,Art,Essay,Life,Music}.astro,ComicReader.tsx,SongPlayer.tsx,EmbedOnClick.tsx,OlderYears.astro,EdgeToWork.astro}`, `src/site/styles/personal.css`; Test: extend `test/site/build.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getSiteData` (`personal`, `interests`, `sources`), filter markup contract.
|
||||
- Produces: item pages `/off-the-clock/<id>/` (items) and `/off-the-clock/<slug>/` for Ghost posts tagged `off-the-clock`; card `data-ai` only when `ai !== null`.
|
||||
- Structure per spec §7 and `mockups/personal-r2-b.html`. Chips filter client-side by `data-interest`; with JS off the chips are hidden and the full wall shows. Wall: current year full size, earlier years in `OlderYears` collapsed `<details>` with smaller cards. Opening a card expands it in place (`client:visible` behaviour) and pushes its URL; the item page is the same content standalone. Now strip: listening = newest `song` item; reading/watching from `sources` (hide a cell when empty; show "synced N h ago" from `status.fetchedAt`); playing = newest `game` item.
|
||||
- `EmbedOnClick` renders a poster + play button and only inserts the iframe (YouTube/Bandcamp/Spotify URL from `link`) after a click. Own audio (`media[].file` ending `.mp3|.ogg|.m4a`) uses `SongPlayer` with a native `<audio preload="none">`.
|
||||
- [ ] **Step 1: Failing build tests:** `wall renders one card per personal entry with its kind class`, `shares carry no data-ai`, `older years are collapsed`, `item without media keeps its card` (edge fixture), `embeds are not in the html until clicked` (no `<iframe` in `/off-the-clock/index.html`), `empty personal side shows the designed empty state` (sparse).
|
||||
- [ ] **Step 2–4:** FAIL → implement (impeccable `craft`) → PASS; screenshot vs `mockups/shots/personal-r2-b.png`.
|
||||
- [ ] **Step 5:** Commit `feat(site): personal space, item pages and card kinds`.
|
||||
|
||||
### Task 11: Issues, `/everything/`, `/simple/`
|
||||
|
||||
**Files:** Create `src/site/pages/off-the-clock/issues/index.astro`, `src/site/pages/off-the-clock/issues/[id].astro`, `src/site/pages/everything.astro`, `src/site/pages/simple.astro`, `src/site/components/everything/Filters.tsx`, `src/site/styles/{issue.css,everything.css,simple.css}`; Test: extend `test/site/build.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getSiteData` (`issues`, `everything`), card components (Task 10), `AiMark`.
|
||||
- Issue page: zine layout from `mockups/personal-r2-c.html` (masthead with issue title, contents column, entries as spreads, editor's note from `site.issues.notes[id]`); current issue shows "still filling up · closes <last day of half>". Issues index lists covers newest first.
|
||||
- `/everything/`: one list of `everything`, filters by side, kind and AI level (client-side, `data-*` attributes), restyled between the two palettes (neutral theme) in the spirit of `mockups/nav-c.html`, without maturity marks.
|
||||
- `/simple/`: `mockups/tech-3.html`'s split layout, static, no islands: name, tagline, `site.cv`, contact links, CV PDF (only if `links.cv` set), links to `/work/` and `/off-the-clock/`.
|
||||
- [ ] **Step 1: Failing build tests:** `issues index lists each issue`, `current issue says still filling up`, `everything lists projects, posts and items`, `simple page has no script tags except analytics and preference`, `simple hides the CV link when no cv is set`.
|
||||
- [ ] **Step 2–4:** FAIL → implement (impeccable `craft` for issue and everything pages) → PASS; screenshot each.
|
||||
- [ ] **Step 5:** Commit `feat(site): issues, everything and simple pages`.
|
||||
|
||||
### Task 12: Accessibility, motion and performance pass
|
||||
|
||||
**Files:** Modify components/styles from Tasks 7–11 as needed; Create `e2e/a11y.spec.ts`
|
||||
|
||||
- [ ] **Step 1:** Run the impeccable `audit` then `polish` and `harden` skills over `/`, `/work/`, `/off-the-clock/`, an issue, `/everything/`, `/simple/`. Fix findings that violate the spec's constraints.
|
||||
- [ ] **Step 2:** Add `e2e/a11y.spec.ts`: with `reducedMotion: 'reduce'` every element's computed `transition-duration` and `animation-duration` on the gate and both spaces is `0s`; Tab order reaches both gates, the preference switch, the project list and the wall cards; every `<img>` has `alt`.
|
||||
- [ ] **Step 3:** Lighthouse mobile performance on the built fixture site via `bunx lighthouse http://localhost:4378/ --only-categories=performance --form-factor=mobile --quiet --chrome-flags="--headless=new --no-sandbox"` (ask before running `bunx` per permissions): ≥ 90 for `/`, `/work/`, `/off-the-clock/`. Record scores in the commit message.
|
||||
- [ ] **Step 4:** Commit `fix(site): accessibility, motion and performance pass`.
|
||||
|
||||
### Task 13: End-to-end tests
|
||||
|
||||
**Files:** Create `e2e/{gate,work,personal,preference,deeplinks,nojs}.spec.ts`; Modify `playwright.config.ts` (`SOURCES_FIXTURES=fixtures/sources` in the webServer env)
|
||||
|
||||
- [ ] **Step 1:** Write specs (desktop + phone projects):
|
||||
- gate: clicking each side navigates; G opens `/everything/`; nothing animates before input (no element with running animations on load).
|
||||
- work: open a project from the list → URL becomes `/work/<id>/`, Esc → `/work/`, ←/→ steps; `/` focuses search.
|
||||
- personal: chips filter the wall; opening a card updates the URL; song embed iframe appears only after clicking play.
|
||||
- preference: choose "by hand only" → `ai-made`/`some-ai`/`unknown` projects hidden, featured hidden ones show "show anyway", personal shares still visible, archive link present; reload → preference persists.
|
||||
- deeplinks: `/work/<id>/` and `/off-the-clock/<id>/` load directly; `/work/nope/` → 404 page with status 404; a filtered-out project opened by URL still shows with a note.
|
||||
- nojs (`javaScriptEnabled: false`): every page shows all content; no hidden projects.
|
||||
- [ ] **Step 2:** `bunx playwright install --with-deps chromium` (ask first), then `bunx playwright test` → all pass. Fix failures in the owning components.
|
||||
- [ ] **Step 3:** Commit `test(e2e): cover gate, spaces, preference, deep links and no-js`.
|
||||
|
||||
### Task 14: Docker, deploy files, smoke test, old-site archive
|
||||
|
||||
**Files:** Create `Dockerfile`, `.dockerignore`, `scripts/smoke-test.sh`, `deploy/{compose.yml,compose.local.yml,.env.example,portfolio-next.yml,compose.old-site.yml}`, `scripts/archive-old-site.sh`, `archive/2021/` (fetched files), `docs/DEPLOY.md`; Modify `.gitea/workflows/ci.yml` (enable smoke step)
|
||||
|
||||
**Interfaces:**
|
||||
- Image `gitea.lucaswinther.info/lucasw89/portfolio-next`, entrypoint `site serve`, port 8080, uid 1000, `/data` volume — as commission-compass's Dockerfile.
|
||||
- `deploy/compose.yml`: as commission-compass's, `VIRTUAL_HOST`/`LETSENCRYPT_HOST: lucaswinther.info`, envs from Task 6 with `${…:?…}` for `CONTENT_REPO_TOKEN`, `GHOST_CONTENT_KEY`, `TRAKT_CLIENT_ID`, `HARDCOVER_TOKEN`; `GHOST_URL: https://blog.lucaswinther.info`, `TRAKT_USER: lucasw89`, `SOURCES_INTERVAL: 6h`.
|
||||
- `scripts/archive-old-site.sh`: mirrors the live 2021 site (`index.html`, `/static/**`, `/info.json`, `/images/**` for each project title, `/documents/*.pdf`, `/favicon.ico`, `/logo192.png`, `/manifest.json`) from `https://lucaswinther.info/` into `archive/2021/` using `curl`; idempotent. `deploy/compose.old-site.yml` serves `archive/2021/` with `nginx:alpine` at `old.lucaswinther.info`.
|
||||
- [ ] **Step 1:** Port the Dockerfile, `.dockerignore`, smoke test and compose files; adapt names and envs. Smoke test additionally checks `/`, `/work/`, `/off-the-clock/` return 200 and `/status` has a `sources` object.
|
||||
- [ ] **Step 2:** `docker build -t portfolio-next:local . && scripts/smoke-test.sh portfolio-next:local` → `smoke: ok`. (Ask first if Docker isn't usable here; if unavailable, record "not run" in `docs/PERMISSIONS.md`.)
|
||||
- [ ] **Step 3:** Run `scripts/archive-old-site.sh`; verify `archive/2021/index.html` and `archive/2021/info.json` exist and the page renders in headless Chrome when served locally (`bun src/cli.ts preview archive/2021 4380`).
|
||||
- [ ] **Step 4:** Write `docs/DEPLOY.md` (what runs, envs, secrets to store in OpenBao: `content_repo_token`, `ghost_content_key`, `trakt_client_id`, `hardcover_token`; files → config repo table; old-site container; DNS cutover checklist incl. old blog URLs; verifying with `/status`). Do not touch the config repo.
|
||||
- [ ] **Step 5:** Commit `feat(deploy): container, compose examples, smoke test and 2021 archive`.
|
||||
|
||||
### Task 15: Starter content in `portfolio-next-content`
|
||||
|
||||
**Files (in a clone of `https://gitea.lucaswinther.info/lucasw89/portfolio-next-content.git` at `/home/user/portfolio-next-content`):** `site.yml`, `interests.yml`, `projects/*.md`, `items/*.md`, `media/`, `README.md`
|
||||
|
||||
- [ ] **Step 1:** Clone the content repo next to this one. Write `site.yml` (contract 1, name "Lucas Winther", tagline "Computer Scientist with passion for development." (from the 2021 site), links email `lucas@lucaswinther.info`, GitHub `https://github.com/StereotypicalCat`, LinkedIn `https://www.linkedin.com/in/lucas-winther/`, Bluesky `https://bsky.app/profile/stereotypicalcat.bsky.social`; `cv` from `extra_info.txt`; `intro` and `now` empty — Lucas writes them).
|
||||
- [ ] **Step 2:** `projects/`: the 13 projects from `https://lucaswinther.info/info.json` (title, description → `summary`, links, tags → `stack`; Gacha and Config `ai: ai-made`, others `ai: by-hand`; group: school for Bachelor Report, Student Dashboard, VR-Classroom, Censor Simulator, BreakoutPython; own for the rest), with the project images from `https://lucaswinther.info/images/<title>.jpg` in `media/`. Years only where known from the old site, else omit the field. The three AU projects from `extra_info.txt` with `ai: unknown`, `group: work`, `draft: true` (Lucas confirms before they go live). This site as a project, `ai: some-ai`, `draft: true`.
|
||||
- [ ] **Step 3:** `items/`: the Bluesky shares as `comic` items (Now Recharging 2024-11-23 with link `https://nowrecharging.com` and Lucas's quote as `line`; Junkwraith 2025-07-17; Cupid 13 2025-04-17), each `draft: true` until Lucas adds page images he's allowed to post. `interests.yml` as in the fixtures.
|
||||
- [ ] **Step 4:** `bun /home/user/portfolio-next/src/cli.ts validate /home/user/portfolio-next-content` → `Content OK`; build it with no source env and check the go-live gate visually: 13 projects, bio line, contact links, no placeholder text.
|
||||
- [ ] **Step 5:** Commit in the content repo (`feat: starter content from the 2021 site, work history and bluesky`) and push to its `main` (the owner provided this repo for it).
|
||||
|
||||
### Task 16: Docs and wrap-up
|
||||
|
||||
**Files:** Modify `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TODO.md`, `AGENTS.md`, `README.md`, `docs/specs/001-site.md` (status → implemented); Delete `docs/plans/001-site.md` (`git rm`)
|
||||
|
||||
- [ ] **Step 1:** Rewrite `docs/ARCHITECTURE.md` to the real map (`src/core`, `src/library`, `src/sources`, `src/site`, `src/runtime`, `fixtures/`, `deploy/`, `archive/`), data flow (content repo + sources → build → swap → serve), invariants. Append decisions made during the build to `docs/DECISIONS.md`. Move deferred items to `docs/TODO.md` (gate palette match, old blog URL redirects, Trakt OAuth if private).
|
||||
- [ ] **Step 2:** `AGENTS.md`: final commands (build, preview, validate, e2e, docker), map, and the `src/core` "pure, shared by build and browser" rule. ≤ 150 lines. README: run-locally section like commission-compass's.
|
||||
- [ ] **Step 3:** Full verification: `bun install --frozen-lockfile && bun run typecheck && bun test && bunx playwright test` all green in this session.
|
||||
- [ ] **Step 4:** Spec status `implemented`; `git rm docs/plans/001-site.md`; commit `docs: record architecture and decisions for spec 001`. Do not merge to `main` — the owner merges on approval.
|
||||
Reference in new issue
Block a user